Repository navigation
feat(follows): team, player and match follows that download; editable web follows; per-league delete [FX-19] - #156
Merged
Merged
Conversation
…ollow can be moved there [FX-19]
A follow added through the API or the web UI is always an api row of the
follows table, with or without a config file, so every field can be
changed later (before, without a config file a tournament follow was
written to config/leagues.txt and then shown locked). config/leagues.txt
stays a read-only legacy source; PATCH {"origin": "api"} moves one of its
follows into the follows table (FollowStore.adopt keeps fields and
position; the line leaves the file). /status summary.tournaments[].followed
reads the follows table.
POST /api/v1/tournaments/search takes kinds (tournament, team, player; default tournament). Tournaments alone keep SofaScore's tournament search; any other choice asks its general search /search/all?q=&page=0 (the endpoint of docs/all-sports/endpoints.csv and its research sample), one request per search. Each hit carries kind, sport, country, a player's team and whether it is followed. A 404 answer is an empty list, not an upstream error.
…-19]
A team follow reads SofaScore's /team/{id}/events/next/0 and
/team/{id}/events/last/{n} pages, a player follow /player/{id}/events/last/{n}
(both from docs/all-sports/endpoints.csv), within the follow's window
(seasons current = 365 days, last:N = N years, all = five pages back, season
ids = those seasons); an event follow is that one match. Each match goes
through the fetch pipeline with the follows' data selection (P27's
SelectionPolicy, narrowest follow wins; a player's matches take the player
follow's selection last). A finished match is fetched in full, an unknown
match that has not ended by its /event only.
ssc sync, the scheduler and POST /jobs download them: a sync without a target
takes every enabled follow; follows=[...] accepts team, player and event
follows (was 400 unsupported); ssc sync --follow KIND:ID, and --dry-run counts
them. A list that cannot be read is a failed listing (team_events or
player_events, league_id = the team's or player's id) and the job ends
partial; the request budget, the breaker and cancellation apply.
GET /jobs?target= takes team:<id> and player:<id>.
store.purge.tournament(id, season_id=) deletes a tournament's (or one
season's) events in both layouts, its schedules and, for the whole
tournament, its season list, under the maintenance lease, then rebuilds the
catalog in place (as Store.clear does). Follows, the change log, the job
history, backups and exports stay. MaintenanceService.clear_tournament
calls it.
POST /api/v1/jobs clear takes tournament_id and season_id (scope must stay
all; season_id needs tournament_id); GET /jobs?target=tournament:<id> finds
it. DELETE /api/v1/follows/{id}?delete_data=true removes a tournament follow
once a clear job holds the lease and returns that job as data.clear_job.
/api/v1/status, /health and /status/check carry connection: state (never_tried, ok, failed), last_success_at, last_failure_at, last_failure_reason and last_failure_status. The request layer reports every request's outcome (src/breaker.py report_ok / report_exception into src/bridge_health.py ConnectionState): an answer (200, 404) is a success; 403, 429, 5xx, timeout, network and parse errors are failures; a request the breaker held back counts for neither. The bridge state is unchanged (it counts refusals only and reads ok before any request).
…isted [FX-19] An export job writes exports/<league or dataset>_<UTC date>_<last 8 of the job id>.<ext> (for example premier-league_2026-10-06_x7k2m9qa.csv; -raw for a raw export, -wide for the 2.x wide CSV); the job result's file names it and the download serves it under that name. Jobs from before keep <job id>.<ext>. GET /api/v1/exports also lists the files of exports/ that no job wrote (ssc export): source file, id file:<name>, job_id null, dataset and format read from the name; /exports/file:<name>/download serves them. The Store lists and resolves them (store.export.files, file_path); a name cannot leave the folder.
…eck [FX-19]
/status and /health bridge.last_success_at and last_failure_at are the last
answered and the last failed request of any transport (curl answers never
reached the browser bridge, so after a successful download the UI still
said "not tried yet"). The bridge's state, series and last error still count
its own refusals, and the circuit breaker and the upstream reasons keep
reading the bridge's own times. connection.last_check {at, ok, reason}
carries the last POST /status/check of this server.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Item: FX-19 (new, not yet in
docs/design/03-implementation-plan.md): the backend gaps of the first-time-user review of the web UI (problems 2, 3, 4, 9, 10 and "Missing functions"), with the owner decision of 2026-10-06: team, player and single-match follows work in 3.0.0 (name search and downloading their matches).Behaviour change.
origin: "api") and every field stays editable. Before, without a config file, a tournament follow was written toconfig/leagues.txtand then shown locked.ssc sync, the scheduler andPOST /api/v1/jobs. Before, they were skipped, or answered 400unsupported./statussays whether a request ever reached SofaScore./exportslists the files written byssc export.Branch
feat/fx-19onorigin/mainat6e25d29(#153, FX-14a #154, FX-15 #155 and the dependency PRs #143 and #149 merged). Rebased on FX-15: its removal ofSyncSpec.exportstays (the new--followspec and the new tests build specs without it), andSelectionPolicykeeps both FX-15'scountryand FX-19'svia_events.docs/api/openapi-v1.jsonandfrontend/src/api/v1/schema.tswere regenerated in every commit that changes a route, so each commit's generated files match its code.What was done
Seven commits. Each one is green on its own (full suite per commit, see "How it was verified").
feat(follows): web follows live in the follows table; aleagues.txtfollow can be moved there.FollowsService.addalways writes anapirow, with or without a config file.config/leagues.txtstays a read-only legacy source:sport;PATCH {"origin": "api"}moves a row into the follows table (FollowsService.adopt). The newFollowStore.adoptchanges the row's origin and keeps its fields and position, then the line is removed from the file and from the sport sidecar. The next mirror does not bring it back, becauseapioutrankslegacy./statussummary.tournaments[].followedreads the follows table (every origin).feat(search):POST /api/v1/tournaments/searchtakeskinds(tournament,team,player)./search/unique-tournaments/{q}. Any other choice asks/search/all?q=…&page=0, the endpoint ofdocs/all-sports/endpoints.csvwith the research sampleresearch/all_sports/samples/football/search-all__1.json. Either way it is one request per search.kind, with sport, country, a player's team andfollowed.feat(sync): team, player and match follows download their matches (src/services/follow_sync.py, wired inSyncService).Lists.
/team/{id}/events/next/0and/team/{id}/events/last/{n}./player/{id}/events/last/{n}; the catalog has nonextpage for players.Window. A team or player follow uses its
seasonsvalue as a window:seasonscurrentlast:NallMAX_LAST_PAGES= 5 pages backReading back stops at
hasNextPage: falseor 404, at the page limit, or when a page's oldest match is older than the window. Upcoming matches are not cut by the time window.Needs. The planner's
compute_need, plus three rules:full;/eventonly (one request, no slices);full.Data selection. Each match goes through the fetch pipeline with the follows' selection (P27
SelectionPolicy, narrowest follow wins). A player's matches take the player follow's selection as the last link of the chain (SelectionPolicy.with_follow_events/via_events), after the event, tournament and team follows.Wiring.
ssc sync, the scheduler, andPOST /jobs {"kind": "sync"}.follows=[…]accepts these kinds (was 400unsupported).ssc sync --follow KIND:ID(repeatable);--dry-runcounts them.GET /jobs?target=takesteam:andplayer:.Failures and limits. An unreadable list is a failed listing (
team_events/player_events) and the job endspartial. The shared request budget, the job's breaker and cancellation apply: requests use the job's request context, and lists useClient.get_sync.feat(maintenance): delete one league's stored data.store.purge.tournament(id, season_id=)(newsrc/store/purge.py,Store.purge) runs under themaintenancelease, likeStore.clear. It deletes:match_details, and empty legacy containers);matches/<league>/<season>/and that season's summary files);MaintenanceService.clear_tournamentcalls it. The clear job takestournament_idandseason_id.DELETE /follows/{id}?delete_data=truestarts that clear job first (lease), removes the follow, then runs the job.feat(status): connection state. The request layer reports every request's outcome:src/breaker.pyreport_okandreport_exceptionfeedConnectionStateinsrc/bridge_health.py./status,/healthand/status/checkcarryconnection.state:never_tried: no request has ended yet;ok: SofaScore answered (200 or 404);failed: 403, 429, 5xx, timeout, network or parse error.feat(exports): readable file names; files ofssc exportare listed.<league or dataset>_<UTC date>_<last 8 of the job id>.<ext>, for examplepremier-league_2026-10-06_x7k2m9qa.csv. A raw export adds-rawand the 2.x wide CSV adds-wide.GET /exportsalso lists the files inexports/that no job wrote, withsource: "file"and idfile:<name>; they are downloadable. The listing and lookup arestore.export.files()andfile_path().feat(status): bridge times of every transport; the last connection check. Both were asked for by the coordinator after FX-14a (feat(web): newcomer pass — add-league entry points, plain words, help [FX-14a] #154)./statusand/healthbridge.last_success_at/last_failure_atare the last answered and the last failed request of any transport. Curl answers never reached the browser bridge, so after a successful download FX-14a's UI still said "not tried yet". FX-14a'sconnectionStateworks unchanged with these fields.state, series andlast_errorstill count its own refusals. The circuit breaker andsrc/web/upstream.pykeep reading the bridge's own times (bridge_health.snapshot()); the API usespublic_snapshot().connection.last_check: {at, ok, reason}is the lastPOST /status/checkof this server, so the UI need not remember it per tab.Every route, body and field change (for FX-14b)
No new path. Component names are kept where the frontend imports them (
TournamentHit,FollowRecord,ExportRecord).POST /api/v1/follows: always anapirow, sowritablelists every field.slices,seasons,liveandenabledare accepted without a config file; this was 400unsupported.PATCH /api/v1/follows/{id}: new fieldorigin: "api" | null.legacyfollow it moves the row into the follows table. The row leavesconfig/leagues.txtand its sidecar. The other fields of the same request are applied after the move.apifollow it changes nothing.configfollow it is 409follow_managed.legacyfollow'swritableis now["sport", "origin"].DELETE /api/v1/follows/{id}: new querydelete_data(default false).FollowRemoveResponse, withdata: RemovedFollow=FollowRecord+clear_job: Job | null.delete_data=trueon a tournament follow:clearjob withtournament_idtakes themaintenancelease (409job_running/data_operation_running/instance_runningif it cannot);data.clear_jobis that job.delete_dataon another kind is 400invalid_requestwithdetails: {field: "delete_data", kind}.delete_dataon a config follow is 409follow_managed.POST /api/v1/tournaments/search:kinds: ("tournament" | "team" | "player")[](1 to 3, default["tournament"]).TournamentHitgains:kind(defaulttournament);country: {code, name} | null;team: {id, name} | null(players).categorystays required: for a team or a player onlycategory.country_codeis set.followedis per kind.[](was 502upstream_error).POST /api/v1/jobs:syncfollows:team:<id>,player:<id>andevent:<id>(was 400 withdetails.unsupported);sync_follow_skipped.syncwithout a target also downloads every enabled team, player and event follow.clear: newtournament_idandseason_id.season_idwithouttournament_id→ 400invalid_requestwithdetails.fields.tournament_idwith ascopeother thanall→ 400invalid_request.clear: {scopes: ["tournament"], tournament_id, season_id, events, event_dirs, listings, catalog_rebuilt}.failed_listings[].kindcan beteam_eventsorplayer_events;league_idis then the team's or the player's id.sync_follow_listing{follow, name};sync_follow_listing_failed{follow, name, reason};sync_follow_details{count}.sync_follow_skippedis also used for team, player and event follows.GET /api/v1/jobs:targetalso takesteam:<id>andplayer:<id>. A followevent:<id>and a clear'stournament_idare targets too.GET /api/v1/status,GET /api/v1/health,POST /api/v1/status/check:connection: ConnectionStatuswithstate(never_tried|ok|failed),last_success_at,last_failure_at,last_failure_reason,last_failure_statusandlast_check: {at, ok, reason} | null.bridge.last_success_atandbridge.last_failure_atnow cover every transport, not only the browser bridge.bridge.stateis unchanged. In/status,summary.tournaments[].followed= a follow of any origin names the tournament (was "a configured league").GET /api/v1/exports:ExportRecordgainssource: "job" | "file";job_idis now nullable (null for a file).exports/that no job wrote, with idfile:<name>,state: succeeded,datasetandformatread from the name (elseunknown),bytesandcreated_atfrom the file.cursormay be up to 256 characters.GET /api/v1/exports/{export_id}/download:export_idmay befile:<name>, up to 256 characters. The download's file name is the stored file name,<label>_<date>_<id8>.<ext>(wassofascore-export-<job id>.<ext>).CLI:
ssc sync --follow KIND:ID(repeatable). Not with--tournamentor--only events(exit 2).ssc sync --dry-runJSON gainsfollows: {team, player, event}; text output gains a line.ssc_help_sync_followandssc_plan_follows(en and tr).Store API (snapshots under
tests/fixtures/store_api/regenerated):FollowStore.adopt(kind, entity_id, *, origin="api");Store.purge(Purger.tournament(tournament_id, *, season_id=None) -> TournamentClearReport);Exporter.files() -> List[ExportFile]andExporter.file_path(name);Purger,TournamentClearReportandExportFileinsrc.store.How it was verified
Full suite with
STORE_SHADOW_CHECK=1(home-diskTMPDIRand--basetemp), in the foreground:origin/main6e25d29d1d84c4Before the rebase on FX-15, each of the seven commits passed the full suite on its own (on
de83879; 9,676 / 9,686 / 9,711 / 9,726 / 9,731 / 9,736 / 9,737 passed).tests/test_api_v1_data_jobs.py::test_a_restore_replaces_the_data_and_leaves_a_finished_jobfailed once in a combined run before the rebase. On the rebased branch it passed 30 of 30 runs alone, 30 of 30 runs with its whole file, and 5 of 5 runs of the combined subset that failed once. No flake was reproduced.ruff check .clean.python -m src.web.openapi --checkclean.node scripts/gen-api-types.mjs --checkclean.Frontend
npm run build(vue-tsc) passes, and so doesvitest run(329 passed, on main with FX-14a). The frontend sources are unchanged apart fromschema.ts.No request to any SofaScore host, no browser.
New tests:
tests/test_fx19_search.py: one request per search, typed hits from the research sample, kinds and sport filters, 404 as empty, typed errors.tests/test_fx19_follow_sync.py:/eventonly), and the second sync requests only the lists;partial;POST /jobsend to end with the real context;ssc sync --followand--dry-run.tests/test_fx19_clear.py: season and whole-tournament purge in both layouts, the lease, the clear job's checks and result, andDELETE /follows?delete_data=truewith its refusals.tests/test_fx19_connection.py: the state table, request-layer outcomes (200, 404, 403), the breaker not counting, and the connection check.tests/test_fx19_exports.py: labels and names, the job's readable file and download name,ssc exportfiles listed and served, traversal refused, pagination, and the Store listing.tests/test_api_v1_follows.py(moving aleagues.txtfollow) andtests/test_store_follows.py(FollowStore.adopt).Existing tests changed, and why
tests/test_api_v1_follows.py:legacyfollow'swritablegainsorigin;test_a_tournament_follow_goes_to_the_league_file_without_a_config_fileandtest_the_league_file_keeps_only_name_and_sportare replaced by tests of the new rule (anapirow with every field);test_removing_a_league_file_follow_edits_both_filesputs the line intoleagues.txtitself (the API no longer writes it);kind,countryandteam.tests/test_api_v1_status.py::test_status_lists_followed_tournaments_without_matches:followedcomes from the follows table, so the test adds a follow instead of patchingConfigManager.get_leagues.tests/test_slice_selection.py::test_a_league_file_follow_cannot_hold_a_selection:legacyfollow still refuses one (400);origin: "api"it holds one.tests/test_fx13_api_jobs.py:follows: ["team:42"]is accepted (was 400);target=team:1is valid, so the 422 case usesleague:1.tests/test_api_v1_jobs.py: the key lists of/healthand/statusgainconnection, and/health'sbridgeequalsbridge_health.public_snapshot().tests/test_cli_data_commands.py::test_the_new_commands_are_registered_and_described:syncoptions gain--follow.tests/test_api_v1_data_jobs.py:Files outside ownership
FX-19 has no brief yet, so nothing is owned formally. Files that other items of this batch own:
src/services/sync.py(FX-15): the follow steps (_other_follows,_follow_listings,_follow_details), a skip in_leagues, and theFailedListingdocstring.src/store/api.py(FX-15): one import and the attribute lineself.purge = Purger(self), the rule-2 exception.src/store/__init__.py: one contiguous block per new name.Other files touched:
src/breaker.pyandsrc/bridge_health.py(connection state);src/services/planning.py(via_events);src/store/follows.py(adopt);src/store/export.py(files,file_path);src/cli/commands/sync.py(--follow, dry run);src/client/endpoints.py;No new
FS_ALLOWLISTorNAMED_EXCEPTIONSentry: every new file access is insidesrc/store(purge.py,export.py).Changelog entry
Added
seasons:current= the last 365 days,last:N= N years,all= up to five pages back, season ids = those seasons.ssc sync(alsossc sync --follow team:42), the scheduler and the web UI's sync (POST /api/v1/jobswithfollows). (FX-19)POST /api/v1/tournaments/searchwithkinds: ["team", "player"]returns typed hits with sport, country and a player's team. (FX-19)POST /api/v1/jobsclearwithtournament_id(andseason_id) deletes that tournament's (or season's) stored matches, schedules and season list.DELETE /api/v1/follows/{id}?delete_data=trueremoves a follow together with its data. (FX-19)/api/v1/status,/healthand/status/checkreport the connection to SofaScore asnever_tried,okorfailed, with the time and reason of the last failure and the last connection check.bridge.last_success_atandbridge.last_failure_atcount requests of every transport. (FX-19)GET /api/v1/exportslists and serves the filesssc exportwrote into the data folder'sexports/. (FX-19)Changed
config/leagues.txtand shown locked.config/leagues.txtis read as before; a follow of it can be moved into the follows table withPATCH /api/v1/follows/{id}{"origin": "api"}. The classic views (/classic, legacy/api/leagues) list onlyconfig/leagues.txt. (FX-19)premier-league_2026-10-06_x7k2m9qa.csv), and downloads use that name. (FX-19)/api/v1/statussummary.tournaments[].followedcounts every follow, including those added in the web UI. (FX-19)Design mismatches
02-services.md2.7 (FollowsService: "With a config file present, new follows get origin api; without one they are written to leagues.txt as today") and the "Follows (P21 part 3)" as-built paragraph: new follows are now alwaysapirows. Alegacyrow'swritableis["sport", "origin"], andPATCH origin: "api"moves it. TheFollowCreateandFollowsServicedocstrings changed accordingly.config_fileofFollowsServiceno longer decides anything (kept for callers).03-implementation-plan.md("Can team, player and event follows be synced at all?"): answered and built.05-web-ui.mdG23 is done for every kind. FX-13's "team, player or event follow → 400 unsupported" is gone.02-services.md2.7 namessearch_tournaments. Built:FollowsService.search(query, sport=, kinds=)(search_tournamentsremains a wrapper). The route is still/tournaments/search, now with team and player hits under the component nameTournamentHit(kept for the frontend's import). A neutral/searchroute could replace it in P30.02-services.md3.1 / P27's chain (event → tournament → home team → away team) gains a fifth link: the player follow that brought the match (via_events). The match payload does not name the players.01-storage.md9.3 knows only scope clears. The newStore.purge(Purger.tournament) and its lease, the catalog rebuild and what stays should be added there and in02-services.md2.7 (maintenance).02-services.md6 ("Data jobs (feat(api): v1 export, backup, clear, rebuild and restore-check jobs; exports, backups, logs, diagnostics [P21] #126, feat(export): normalized datasets in JSONL, CSV, Parquet and SQLite [SC-2] #130)": "written toDATA_DIR/exports/<job id>.<ext>") and03-implementation-plan.mdsection 17 (/exportsdoes not listssc exportfiles) are out of date.FailedListinggains the kindsteam_eventsandplayer_events, withleague_idreused for the team's or player's id. A separate field would have changed the job records and the CLI output of every failed listing.MAX_LAST_PAGES= 5, 365 days per season step, upcoming matches by/eventonly, listings not stored) are defined here and belong in02-services.md3.2 / 4.1.01-storage.md6.3Scope.followed("Team, player and event follows do not widen the scope") still holds for the read scope. Team follows' matches are downloaded but are not "followed" inGET /events?followed=true.Needs live validation
/search/all?q=&page=0:uniqueTournamenthits (the trimmed sample has onlyteamandplayer; built from the tournament search's entity shape, experimental);/search/unique-tournaments/{q}for a name SofaScore does not know: 404 or empty. Both are handled as[]./team/{id}/events/last/{n}and/next/{n}:hasNextPage;next/0answering 404 for a team without fixtures (the catalog shows404:6);/player/{id}/events/last/{n}: only football samples, with the eventidtrimmed out of them, so the id field is assumed. Nonextendpoint is known for players. Other sports are unknown.MAX_LAST_PAGES= 5 and the cost of a team follow withall(up to 6 list requests plus the matches)./event-only read of upcoming matches.BROWSER_FIRST(_request_sync→report_ok).Not done
sportkeepssport: null; the search hit'ssportshould be sent with it (FX-14b).ssc followscommand to move aleagues.txtfollow into the follows table (onlyPATCH).ssc watchstill skips player follows (live_follow_skipped).src/services/status.pymissing_slice_keysstill ignores the follows table's selections (P27's note, unchanged)./statusof the web server does not see requests ofssccommands orssc watch.v3/events/and the team and player directories.Notes for next items
kinds, prefillsportfrom the hit;PATCH {origin: "api"}behind an "Move into the app" or "Edit" action forlegacyrows;writabletells which fields to enable.?delete_data=trueanddata.clear_job.clearwithtournament_id.connection.state(never_tried→ "not tried yet",failed→ amber withlast_failure_reason), andconnection.last_checkinstead of the per-tab check memory of FX-14a.source: "file"rows have no job link (job_idnull)./tournaments/searchcould become/search.