diff --git a/app/features/scanner-ingest/actions/scanner-ingest.server.ts b/app/features/scanner-ingest/actions/scanner-ingest.server.ts index 6e50dcd9c..5a109b81b 100644 --- a/app/features/scanner-ingest/actions/scanner-ingest.server.ts +++ b/app/features/scanner-ingest/actions/scanner-ingest.server.ts @@ -146,8 +146,8 @@ interface IngestContextCandidate { * activity around play time is the strong signal: their match running then (for cast footage, * the casted sets of tournaments they help run). Candidates are scored by how many matches would * link, but kept even when nothing links yet (a live minimap-only match still gets its hint). - * Without activity the content decides: mode+stage sequence plus roster sides is near-unique in - * a user's history. + * Without activity (a set that outlasted the activity window) the user's history decides: the + * context whose games were reported around the matches' play times. */ async function resolveIngestContext({ matches, @@ -300,7 +300,7 @@ function withoutDisprovenCast(match: ScannerMatch): ScannerMatch { return { ...match, cast: false }; } -/** When the request's matches were probably played: the latest playedAt, else "now". */ +/** When the request's matches were probably played: the latest playedAt, else "now" (live reads without a scoreboard). */ function anchorTime(matches: ScannerMatch[]): number { const playedAts = matches .map((match) => match.playedAt) diff --git a/app/features/scanner-ingest/core/Scoreboards.test.ts b/app/features/scanner-ingest/core/Scoreboards.test.ts index 515ebdd6e..91f0c939a 100644 --- a/app/features/scanner-ingest/core/Scoreboards.test.ts +++ b/app/features/scanner-ingest/core/Scoreboards.test.ts @@ -53,7 +53,7 @@ function tournamentMatchIdOf(matched: Scoreboards.MatchedGame): number | null { } function testMatch({ - t = 60, + playedAt = 1000, mode = "SZ", stage = 0, lobby = "PRIVATE", @@ -64,7 +64,8 @@ function testMatch({ objective = null, playerStatus = null, }: { - t?: number; + /** database timestamp (seconds) like the games' `playedAt`; null for a read without a wall clock (VoD) */ + playedAt?: number | null; mode?: ModeShort | null; stage?: StageId | null; lobby?: ScannerLobby | null; @@ -87,9 +88,9 @@ function testMatch({ }), ); return { - startsAt: t, - endsAt: t, - playedAt: null, + startsAt: 60, + endsAt: 360, + playedAt: playedAt === null ? null : playedAt * 1000, lobby, mode, stage, @@ -287,7 +288,7 @@ describe("matchedGames", () => { test("matches matches to games by mode and stage", () => { const matched = Scoreboards.matchedGames({ - matches: [testMatch({ mode: "RM", stage: 1, t: 60 })], + matches: [testMatch({ mode: "RM", stage: 1 })], games: [ testGame({ mapIndex: 0, mode: "SZ", stageId: 0 as StageId }), testGame({ mapIndex: 1, mode: "RM", stageId: 1 as StageId }), @@ -301,11 +302,11 @@ describe("matchedGames", () => { const matched = Scoreboards.matchedGames({ matches: [ testMatch({ - t: 60, + playedAt: 1000, names: ["a", "b", "c", "d", "e", "f", "g", "h"], }), testMatch({ - t: 5000, + playedAt: 2000, names: ["i", "j", "k", "l", "m", "n", "o", "p"], }), ], @@ -323,7 +324,7 @@ describe("matchedGames", () => { test("skips duplicate detections of the same game", () => { const matched = Scoreboards.matchedGames({ - matches: [testMatch({ t: 60 }), testMatch({ t: 65 })], + matches: [testMatch(), testMatch({ playedAt: 1005 })], games: [ testGame({ tournamentMatchId: 1, playedAt: 1000 }), testGame({ tournamentMatchId: 2, playedAt: 2000 }), @@ -337,9 +338,9 @@ describe("matchedGames", () => { test("skips a duplicate detection despite a couple of OCR-misread names", () => { const matched = Scoreboards.matchedGames({ matches: [ - testMatch({ t: 60 }), + testMatch(), testMatch({ - t: 65, + playedAt: 1005, names: ["w1", "vv2", "w3", "w4", "l1", "l2", "l3", "I4"], }), ], @@ -374,9 +375,9 @@ describe("matchedGames", () => { test("skips matches that have no matching game left", () => { const matched = Scoreboards.matchedGames({ matches: [ - testMatch({ t: 60 }), + testMatch(), testMatch({ - t: 5000, + playedAt: 1300, names: ["i", "j", "k", "l", "m", "n", "o", "p"], }), ], @@ -483,8 +484,8 @@ describe("matchedGames", () => { test("does not assign a game played before the previously assigned one", () => { const matched = Scoreboards.matchedGames({ matches: [ - testMatch({ t: 60, mode: "RM", stage: 1 }), - testMatch({ t: 1000, mode: "SZ", stage: 0 }), + testMatch({ playedAt: 2000, mode: "RM", stage: 1 }), + testMatch({ playedAt: 2100, mode: "SZ", stage: 0 }), ], games: [ testGame({ @@ -504,6 +505,36 @@ describe("matchedGames", () => { expect(matched.map(tournamentMatchIdOf)).toEqual([2]); }); + + test("never links a read without a play time", () => { + const matched = Scoreboards.matchedGames({ + matches: [testMatch({ playedAt: null })], + games: [testGame()], + }); + + expect(matched).toHaveLength(0); + }); + + test("links the play of a map reported nearest the read", () => { + const matched = Scoreboards.matchedGames({ + matches: [testMatch({ playedAt: 3900 })], + games: [ + testGame({ tournamentMatchId: 1, playedAt: 2600 }), + testGame({ tournamentMatchId: 2, playedAt: 4000 }), + ], + }); + + expect(matched.map(tournamentMatchIdOf)).toEqual([2]); + }); + + test("leaves a read unlinked when no game was reported within 30 minutes of it", () => { + const matched = Scoreboards.matchedGames({ + matches: [testMatch({ playedAt: 1000 + 31 * 60 })], + games: [testGame({ playedAt: 1000 })], + }); + + expect(matched).toHaveLength(0); + }); }); describe("deriveScoreboardData", () => { @@ -786,7 +817,7 @@ describe("resolveContext", () => { mapIndex: i, mode, stageId: stageId as StageId, - playedAt: 1000 + i, + playedAt: 1000 + i * 600, ...partial, }), context: { type: "tournament", tournamentId }, @@ -803,7 +834,7 @@ describe("resolveContext", () => { mapIndex: i, mode, stageId: stageId as StageId, - playedAt: 1000 + i, + playedAt: 1000 + i * 600, }), target: { type: "sendouq", @@ -815,8 +846,8 @@ describe("resolveContext", () => { } const seenSequence = [ - testMatch({ t: 60, mode: "SZ", stage: 0 }), - testMatch({ t: 600, mode: "TC", stage: 1 }), + testMatch({ playedAt: 1000, mode: "SZ", stage: 0 }), + testMatch({ playedAt: 1600, mode: "TC", stage: 1 }), ]; test("resolves the tournament whose games match the seen sequence", () => { @@ -894,7 +925,7 @@ describe("resolveContext", () => { const context = Scoreboards.resolveContext({ matches: [ seenSequence[0]!, - testMatch({ t: 300, stage: null }), + testMatch({ playedAt: 1300, stage: null }), seenSequence[1]!, ], games: [ diff --git a/app/features/scanner-ingest/core/Scoreboards.ts b/app/features/scanner-ingest/core/Scoreboards.ts index feddc0d34..72108a0ab 100644 --- a/app/features/scanner-ingest/core/Scoreboards.ts +++ b/app/features/scanner-ingest/core/Scoreboards.ts @@ -22,7 +22,7 @@ const MIN_LINKED_DUPLICATE_NAME_MATCHES = 6; /** How many players on the winning (first) resp. losing side of a scoreboard. */ const PLAYERS_PER_TEAM = 4; -/** Matches that must align with one context's games before content resolution trusts it: one (mode, stage, sides) is common across a user's history, two carry order. */ +/** Matches that must align with one context's games before content resolution trusts it. */ const MIN_RESOLVED_SCOREBOARDS = 2; /** @@ -127,11 +127,10 @@ export function resolveContext({ /** * Decides which game result each ingested match links to. * - * Only matches with a known winner and two full teams qualify (minimap-only reads never link). - * Matches and games are walked chronologically: each match takes an unassigned game of the same - * mode+stage whose sides agree with what is known. A match with a wall clock takes the game - * reported nearest it (none beyond `PLAYED_AT_TOLERANCE_MS`); a VoD read (video offsets only) - * takes the next in sequence. + * Only matches with a known winner, two full teams and a play time qualify (minimap-only and VoD + * reads never link). Matches and games are walked chronologically: each match takes the unassigned + * game of the same mode+stage reported nearest it whose sides agree with what is known, none + * beyond `PLAYED_AT_TOLERANCE_MS`. * * The sender is the POV player, so the roster they sit in pins the scan's sides — OCR'd names * are too unreliable to overrule it. Only without a POV seat (cast footage) do in-game names @@ -154,12 +153,12 @@ export function matchedGames({ const views = dedupeViews( matches .map((match, matchIndex) => { - const view = winnerFirstView(match, matchIndex); + const view = winnerFirstView(match); return view ? { ...view, matchIndex } : null; }) - .filter((view): view is IndexedView => view !== null) + .filter((view): view is TimedView => view?.playedAt != null) .filter((view) => isLinkableLobby(view.lobby)) - .sort((a, b) => a.order - b.order), + .sort((a, b) => a.playedAt - b.playedAt), ); const orderedGames = games.toSorted( (a, b) => a.playedAt - b.playedAt || a.mapIndex - b.mapIndex, @@ -231,7 +230,7 @@ export function deriveScoreboardData({ merged = Matches.mergeMatches(merged, other.data).merged; } - const view = winnerFirstView(merged, 0); + const view = winnerFirstView(merged); if (!view) return null; const players = view.players.map( @@ -260,7 +259,7 @@ export function deriveScoreboardData({ /** A match's players winner-first in row order (unread names as ""), or null without such a view — a game's `linkedPlayerNames`. */ export function winnerFirstPlayerNames(match: ScannerMatch): string[] | null { - const view = winnerFirstView(match, 0); + const view = winnerFirstView(match); return view ? view.players.map((player) => player.name.trim()) : null; } @@ -279,12 +278,11 @@ interface WinnerFirstView { povIndex: number | null; /** wall-clock ms the game was played, when the read carried a clock at all */ playedAt: number | null; - /** chronological walk key: wall-clock, else video time, else input order */ - order: number; } -interface IndexedView extends WinnerFirstView { +interface TimedView extends WinnerFirstView { matchIndex: number; + playedAt: number; } interface WinnerFirstPlayer { @@ -297,10 +295,7 @@ interface WinnerFirstPlayer { abilities?: AbilityWithUnknown[][]; } -function winnerFirstView( - match: ScannerMatch, - index: number, -): WinnerFirstView | null { +function winnerFirstView(match: ScannerMatch): WinnerFirstView | null { if (match.winner === null) return null; const winners = match.teams[match.winner]; const losers = match.teams[match.winner === 0 ? 1 : 0]; @@ -342,7 +337,6 @@ function winnerFirstView( ? match.pov.index : PLAYERS_PER_TEAM + match.pov.index, playedAt: match.playedAt, - order: match.playedAt ?? match.startsAt ?? index, }; } @@ -424,7 +418,7 @@ function attributePovUsers( ) { for (const { data, povUserId } of linked) { if (povUserId === null || data.pov === null) continue; - const view = winnerFirstView(data, 0); + const view = winnerFirstView(data); if (!view || view.povIndex === null) continue; if (players.some((player) => player.userId === povUserId)) continue; @@ -458,8 +452,8 @@ function attributionIndex( } /** Drops re-detections of the same game within one request, with the same OCR-jitter tolerance as isLinkedDuplicate. */ -function dedupeViews(sorted: IndexedView[]): IndexedView[] { - const result: IndexedView[] = []; +function dedupeViews(sorted: TimedView[]): TimedView[] { + const result: TimedView[] = []; for (const view of sorted) { const isDuplicate = result.some( @@ -479,11 +473,11 @@ function dedupeViews(sorted: IndexedView[]): IndexedView[] { /** * Index of the game `view` links to, or null. Only games from `from` on are considered so two - * scans of one request never take the same game. A scan with a play time takes the candidate - * reported nearest it (keeps two plays of one map apart); one without takes the next in sequence. + * scans of one request never take the same game. The candidate reported nearest the scan's play + * time wins, which keeps two plays of one map apart. */ function pickGame( - view: IndexedView, + view: TimedView, orderedGames: IngestableGame[], from: number, povUserId: number | null, @@ -493,7 +487,6 @@ function pickGame( for (let i = from; i < orderedGames.length; i++) { const game = orderedGames[i]!; if (!canLink(view, game, povUserId)) continue; - if (view.playedAt === null) return i; const distance = Math.abs( view.playedAt - databaseTimestampToJavascriptTimestamp(game.playedAt), diff --git a/app/features/scanner-ingest/tests/ingest-scenarios.test.ts b/app/features/scanner-ingest/tests/ingest-scenarios.test.ts index e158312fc..83c4ae79f 100644 --- a/app/features/scanner-ingest/tests/ingest-scenarios.test.ts +++ b/app/features/scanner-ingest/tests/ingest-scenarios.test.ts @@ -442,35 +442,21 @@ describe("tournament flow", () => { expect(laterPage.ingestedScoreboards).toHaveLength(0); }); - test("T2 VoD scan spanning two sets links each read into its own set", async () => { + test("T2 reads without a play time (VoD) are stored but never linked", async () => { const w = await tournamentWorld(); - const [set1, set2] = w.matchesOfTeam(w.championTeamId); - const games = [...(await w.games(set1!.id)), ...(await w.games(set2!.id))]; + const [set1] = w.matchesOfTeam(w.championTeamId); + const games = await w.games(set1!.id); const res = await ingest( w.povUser, games.map((game) => w.scanned(game, { playedAt: null })), ); - expect(res.linkedGamesCount).toBe(4); - expect(res.linkedMatches).toEqual( - [set1, set1, set2, set2].map((set, matchIndex) => ({ - matchIndex, - link: { - type: "tournament", - tournamentId: w.tournamentId, - matchId: set!.id, - }, - })), - ); - const set1Page = await tournamentMatchPage(w.tournamentId, set1!.id); - expect(set1Page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([ - 0, 1, - ]); - const set2Page = await tournamentMatchPage(w.tournamentId, set2!.id); - expect(set2Page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([ - 0, 1, - ]); + expect(res.storedMatchesCount).toBe(games.length); + expect(res.linkedGamesCount).toBe(0); + expect(res.linkedMatches).toEqual([]); + const page = await tournamentMatchPage(w.tournamentId, set1!.id); + expect(page.ingestedScoreboards).toHaveLength(0); }); test("T3 partial then fuller resend: the replay read merges into the stored partial and links", async () => { diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index d4bce62da..f728d91bd 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -6,7 +6,7 @@ detects Splatoon 3 UI screens with OpenCV.js in a Web Worker, and parses them into events speaking sendou.ink ids (`ModeShort`/`StageId`/weapon ids/`Ability`). Events aggregate client-side into `ScannerMatch` objects (`core/scanner-match.ts`) — one detected game per object, every field -nullable — which feed `/ingest` (features/scanner-ingest), the `/vods/new` +nullable — which feed `/ingest` (features/scanner-ingest, live captures only), the `/vods/new` prefill, the match cards and the clip cutter. Imported from the emberz repo; see `MIGRATION.md` there. @@ -70,10 +70,11 @@ opens it, for anyone, through the same handoff Inspect uses. - **Upload** is on by default when logged in (settings toggle, persisted). Live: a scoreboard closes its match and sends it, a 15 s tick retries unlinked matches on a backoff (`sendou-ingest.ts`) and flushes closed - matches whose send was skipped; Stop sends what is left. VoD: the whole - scan sends once saved. Both write per-event send statuses to their own - store (`updateEventsSend` takes the store), so the cards' upload status - button (`UploadStatus.tsx`) and its Retry/Upload are one code path. + matches whose send was skipped; Stop sends what is left. Per-event send + statuses back the cards' upload status button (`UploadStatus.tsx`) and its + Retry/Upload. VoD scans never upload: their reads carry no wall clock, and + without one a stranger's game can't be told apart from the uploader's + (linking needs a `playedAt` within 30 min of the game's report). - **CSV** is a normal feature: `⇩ CSV` in every session header offers `Matches` (`core/csv/matches.ts`, one row per game, the rows the cards render) and `Raw detections` (`core/csv/events.ts`, one row per event). @@ -192,8 +193,8 @@ sequenceDiagram TL-->>UI: deduped timeline, status reads kept per run end (IndexedDB: events / vod-events) UI->>MB: buildScannerMatches(events) MB-->>UI: ScannerMatch[] + source events - UI->>ING: POST { matches } (live: on match close / stop, VoD: once saved) - ING->>ING: resolve context (current tournament/SendouQ activity, casts via staff roles, else content sequence ≥2) + UI->>ING: POST { matches } (live only: on match close / stop) + ING->>ING: resolve context (current tournament/SendouQ activity, casts via staff roles, else games reported around the play times ≥2) ING->>DB: merge-store IngestedMatch (matchHash, isSameMatch + merge, context hints) ING->>DB: link matches to game results → IngestedMatchLink (POV weapon → ReportedWeapon; scoreboards derived at read time) Note over UI: VoD "Add to VoDs": ScannerMatch → slim prefill param → /vods/new @@ -225,8 +226,8 @@ sequenceDiagram scanner-ingest merges them server-side. Senders filter with `ingestSkipReasons`: private/unread lobby only, and no games a disconnect cut short (scoreless + counter left more time than the footage did, or - replayed right after on the same map — the latter is a VoD-scan filter in - practice since it only resolves after the fact). + replayed right after on the same map — the latter only resolves after the + fact, so a live scan may already have sent the game). - The route (`routes/scanner.tsx`) is SSR-guarded: the client tree loads via `React.lazy` after `useHydrated`; nothing from `core/worker/capture/store` may be imported at route-module top level. There is no feature flag: the diff --git a/app/features/scanner/components/LandingView.tsx b/app/features/scanner/components/LandingView.tsx index 926124527..2bcc393cb 100644 --- a/app/features/scanner/components/LandingView.tsx +++ b/app/features/scanner/components/LandingView.tsx @@ -1,7 +1,7 @@ /** * The landing: two entry cards (Live / File), the clip history strip and * the sessions list. Anyone can capture, scan files and get clips locally; - * only uploading needs a login. Dropping a file anywhere here starts a scan; + * only uploading (live captures) needs a login. Dropping a file anywhere here starts a scan; * an image opens the screenshot view instead, through the same handoff * Inspect uses. */ diff --git a/app/features/scanner/components/MatchCard.tsx b/app/features/scanner/components/MatchCard.tsx index 73abfd75d..af41d0749 100644 --- a/app/features/scanner/components/MatchCard.tsx +++ b/app/features/scanner/components/MatchCard.tsx @@ -106,7 +106,8 @@ export function MatchCard({ justFormed: boolean; /** false while the match is still being scanned: no expand button yet */ expandable: boolean; - upload: UploadState; + /** null when the session's matches never upload */ + upload: UploadState | null; /** the clips this game produced, best first */ clips: readonly ScannerClip[]; onPlayClip: (clip: ScannerClip) => void; @@ -118,14 +119,13 @@ export function MatchCard({ const tabIdPrefix = useId(); // fixed at mount: re-rendering must not cut the animation short const [enter] = useState(justFormed); - const [prevUploadKind, setPrevUploadKind] = useState(upload.kind); + const uploadKind = upload?.kind ?? null; + const [prevUploadKind, setPrevUploadKind] = useState(uploadKind); const [flash, setFlash] = useState<"uploaded" | "failed" | null>(null); - if (prevUploadKind !== upload.kind) { - setPrevUploadKind(upload.kind); + if (prevUploadKind !== uploadKind) { + setPrevUploadKind(uploadKind); setFlash( - upload.kind === "uploaded" || upload.kind === "failed" - ? upload.kind - : null, + uploadKind === "uploaded" || uploadKind === "failed" ? uploadKind : null, ); } @@ -215,7 +215,9 @@ export function MatchCard({ ) : null} {expandable ? ( - + {upload ? ( + + ) : null} @@ -34,7 +32,6 @@ export function ScanWorkers({
Scanning -
{headerEnd}
{children} diff --git a/app/features/scanner/components/SessionView.tsx b/app/features/scanner/components/SessionView.tsx index 1ddc6d9cb..3085708f7 100644 --- a/app/features/scanner/components/SessionView.tsx +++ b/app/features/scanner/components/SessionView.tsx @@ -90,8 +90,9 @@ export function SessionView({ header: (info: SessionInfo) => React.ReactNode; getFrame: (event: ScanEvent) => GetFrame | undefined; /** logged in: Retry/Upload buttons show */ - canUpload: boolean; - onUpload: (built: BuiltMatch) => void; + canUpload?: boolean; + /** absent for sessions whose matches never upload (VoD scans): the cards show no upload state */ + onUpload?: (built: BuiltMatch) => void; emptyText: string; /** rendered between the header and the clips (a scan's progress, an error) */ children?: React.ReactNode; @@ -143,13 +144,17 @@ export function SessionView({ !newerGameStarted ) } - upload={uploadStateOf({ - send: aggregateSendStatus(b.sources), - skipReason: skipReasons.get(b), - lobby: b.match.lobby, - canUpload, - onUpload: () => onUpload(b), - })} + upload={ + onUpload + ? uploadStateOf({ + send: aggregateSendStatus(b.sources), + skipReason: skipReasons.get(b), + lobby: b.match.lobby, + canUpload: canUpload ?? false, + onUpload: () => onUpload(b), + }) + : null + } clips={clipsByMatch[index]!} onPlayClip={setPlaying} getFrame={getFrame} diff --git a/app/features/scanner/components/VodView.tsx b/app/features/scanner/components/VodView.tsx index 326c698a4..19a25e7f9 100644 --- a/app/features/scanner/components/VodView.tsx +++ b/app/features/scanner/components/VodView.tsx @@ -33,17 +33,13 @@ import { ExportMenu } from "./ExportMenu"; import { NotFound } from "./NotFound"; import { ScanWorkers } from "./ScanWorkers"; import { SessionHeader } from "./SessionHeader"; -import { type SessionInfo, SessionView } from "./SessionView"; -import { matchContaining } from "./sendou-ingest"; +import { SessionView } from "./SessionView"; import { sendouUpload } from "./sendou-upload"; import type { ScanEvent } from "./session-data"; -import { useScannerSettings } from "./settings"; -import { sendVod } from "./upload"; import { useDebug } from "./use-debug"; import styles from "./VodView.module.css"; import { startVodScan, - uploadVodScan, useVodScan, useVodScanProgress, vodScanFrame, @@ -52,7 +48,7 @@ import { refreshVods } from "./vods-feed"; /** * Builds keyed by the events array: views re-render for reasons other than new - * events (clips, upload state), and reusing the same `BuiltMatch` objects lets + * events (clips), and reusing the same `BuiltMatch` objects lets * the unchanged cards skip rendering. */ const builtCache = new WeakMap[]>(); @@ -68,8 +64,6 @@ export function VodView() { /** The scan running (or finished) this visit. */ function ScanVodView({ name }: { name: string }) { const scan = useVodScan(); - const settings = useScannerSettings(); - const user = useUser(); const [telemetryOn] = useSearchParam(scannerSearchParams, "telemetry"); const debug = useDebug(); const scanning = scan.status === "scanning"; @@ -80,10 +74,6 @@ function ScanVodView({ name }: { name: string }) { events={scan.events} running={scanning} getFrame={vodScanFrame} - onUpload={(built) => { - const id = built.sources[0]?.id; - if (id !== undefined) void uploadVodScan(matchContaining(id)); - }} status={ scan.status === "error" ? (
@@ -107,10 +97,7 @@ function ScanVodView({ name }: { name: string }) {
) : scanning ? ( - + {scan.error ? (
{scan.error}
) : null} @@ -122,7 +109,6 @@ function ScanVodView({ name }: { name: string }) { : scan.clipsWork?.state === "done" && scan.clipsWork.error ? `Clips: ${scan.clipsWork.error}` : null} - {scan.uploading ? "Uploading…" : null} {scan.error ? ( {scan.error} ) : null} @@ -146,21 +132,16 @@ function StoredVodView({ name }: { name: string }) { if (stored.state === "missing") { return This VoD is no longer saved.; } - const { events, reload } = stored; return ( event.hasFrame && event.id !== undefined ? () => loadVodEventFrame(event.id!) : undefined } - onUpload={(built) => { - const id = built.sources[0]?.id; - if (id !== undefined) void sendVod(name, matchContaining(id), reload); - }} status={null} telemetry={null} /> @@ -174,19 +155,17 @@ type StoredVod = state: "ready"; summary: VodSummary; events: ScanEvent[]; - reload: () => void; }; -/** Loads a saved VoD's summary and events; `reload` re-reads them after a send. */ +/** Loads a saved VoD's summary and events. */ function useStoredVod(name: string): StoredVod { const [loaded, setLoaded] = useState<{ name: string; summary: VodSummary | undefined; events: ScanEvent[]; } | null>(null); - const [version, setVersion] = useState(0); - // the store is outside React: read it when the name (or version) changes + // the store is outside React: read it when the name changes useEffect(() => { let stale = false; void Promise.all([loadVod(name), loadVodEvents(name)]).then( @@ -197,7 +176,7 @@ function useStoredVod(name: string): StoredVod { return () => { stale = true; }; - }, [name, version]); + }, [name]); if (!loaded || loaded.name !== name) return { state: "loading" }; if (!loaded.summary) return { state: "missing" }; @@ -205,7 +184,6 @@ function useStoredVod(name: string): StoredVod { state: "ready", summary: loaded.summary, events: loaded.events, - reload: () => setVersion((v) => v + 1), }; } @@ -214,7 +192,6 @@ function VodSessionView({ events, running, getFrame, - onUpload, status, telemetry, }: { @@ -222,7 +199,6 @@ function VodSessionView({ events: ScanEvent[]; running: boolean; getFrame: (event: ScanEvent) => (() => Promise) | undefined; - onUpload: (built: SessionInfo["built"][number]) => void; status: React.ReactNode; telemetry: React.ReactNode; }) { @@ -250,8 +226,6 @@ function VodSessionView({ clips={vodClips} clipsTitle="Clips" running={running} - canUpload={Boolean(user)} - onUpload={onUpload} getFrame={getFrame} emptyText={ running diff --git a/app/features/scanner/components/sendou-ingest.ts b/app/features/scanner/components/sendou-ingest.ts index 0e5e9b519..7069caee1 100644 --- a/app/features/scanner/components/sendou-ingest.ts +++ b/app/features/scanner/components/sendou-ingest.ts @@ -3,8 +3,8 @@ * so requests are same-origin: the session cookie rides along and the * logged-in user comes from the root loader (useUser); the server resolves * the tournament/match. The unit of accounting is one ScannerMatch - * (core/match-builder.ts) — every source event's IndexedDB record, live or - * VoD, tracks its outcome (the `send` status the match cards display) — while + * (core/match-builder.ts) — every live source event's IndexedDB record tracks + * its outcome (the `send` status the match cards display) — while * the unit of transport is a request of up to `MAX_MATCHES_PER_REQUEST`. * Resends are safe: sendou.ink dedupes by content hash, merges partials, and * scoreboards are first-ingest-wins. @@ -43,11 +43,9 @@ export interface SendResult { * through `writeSend` (calling `onStatus` after each request's store writes). * * Matches go out in as few requests as the server cap allows: sendou.ink - * resolves a whole request at once, so several matches anchor on their - * mode+stage sequence instead of one match's timestamp — this is what makes - * catching up on a session's backlog work. One request resolves to one - * context, so a backlog spanning two links the larger and leaves the rest - * "unlinked"; the retry carries only those, which then resolve on their own. + * resolves a whole request to one context, so a backlog spanning two links + * the larger and leaves the rest "unlinked"; the retry carries only those, + * which then resolve on their own. */ export async function sendMatches({ matches, diff --git a/app/features/scanner/components/session-data.ts b/app/features/scanner/components/session-data.ts index d475171ca..15bcd07df 100644 --- a/app/features/scanner/components/session-data.ts +++ b/app/features/scanner/components/session-data.ts @@ -1,7 +1,7 @@ /** * The event shape every view renders, whichever store it came from: a live * detection (`StoredEvent`) or a VoD's (`StoredVodEvent`) — both carry the - * detection, an optional thumbnail/frame and the /ingest send status. + * detection and an optional thumbnail/frame; live ones also their /ingest send status. */ import type { DetectedEvent } from "../core/detectors/types"; import type { SendStatus } from "../store/events"; @@ -14,6 +14,7 @@ export interface ScanEvent extends DetectedEvent { thumbnail?: string; /** whether a full-res frame can be loaded for the event */ hasFrame?: boolean; + /** live events only */ send?: SendStatus; } diff --git a/app/features/scanner/components/upload.ts b/app/features/scanner/components/upload.ts index 253810bad..5e4bfe72d 100644 --- a/app/features/scanner/components/upload.ts +++ b/app/features/scanner/components/upload.ts @@ -1,6 +1,6 @@ /** - * Uploading to sendou.ink, shared by the live capture, VoD scans and the - * views' Retry/Upload buttons: one serialized sender per store, so sends + * Uploading to sendou.ink, shared by the live capture and the views' + * Retry/Upload buttons: one serialized sender per store, so sends * never overlap (a send requested mid-flight runs right after), and one * place that knows whether uploading is on at all (the setting, and a login). */ @@ -10,13 +10,8 @@ import { listCompactedMatches, updateCompactedMatchesSend, } from "../store/compacted-matches"; -import { - COMPACTED_MATCHES_STORE, - EVENTS_STORE, - VOD_EVENTS_STORE, -} from "../store/db"; +import { COMPACTED_MATCHES_STORE, EVENTS_STORE } from "../store/db"; import { listEvents, type SendStatus, updateEventsSend } from "../store/events"; -import { loadVodEvents } from "../store/vods"; import { refreshFeed } from "./events-feed"; import { type SendResult, sendMatches } from "./sendou-ingest"; import type { ScanEvent } from "./session-data"; @@ -26,7 +21,7 @@ export type MatchSelector = (built: BuiltMatch) => boolean; interface SendRequest { include: MatchSelector; - /** live: the session key built matches are loaded from; VoD: unused */ + /** the session key built matches are loaded from */ since: number; } @@ -75,7 +70,11 @@ export function sendLive( { include, since }, { load: async (from) => buildScannerMatches(await listEvents(from)), - writeSend: eventsSendWriter(EVENTS_STORE), + writeSend: (matches, sendStatus) => + updateEventsSend( + matches.flatMap((built) => built.sources.map((event) => event.id!)), + sendStatus, + ), onStatus: refreshFeed, }, ); @@ -102,23 +101,6 @@ export function sendCompacted( ); } -/** Sends the VoD's matches `include` selects; `onStatus` runs after each status write. */ -export function sendVod( - name: string, - include: MatchSelector, - onStatus: () => void, -): Promise { - return send( - `${VOD_EVENTS_STORE}:${name}`, - { include, since: 0 }, - { - load: async () => buildScannerMatches(await loadVodEvents(name)), - writeSend: eventsSendWriter(VOD_EVENTS_STORE), - onStatus, - }, - ); -} - async function send( key: string, request: SendRequest, @@ -166,12 +148,3 @@ async function send( } return result; } - -function eventsSendWriter(store: string): SendTarget["writeSend"] { - return (matches, sendStatus) => - updateEventsSend( - matches.flatMap((built) => built.sources.map((event) => event.id!)), - sendStatus, - store, - ); -} diff --git a/app/features/scanner/components/vod-scan.ts b/app/features/scanner/components/vod-scan.ts index ce5b5c775..cbb85fd6f 100644 --- a/app/features/scanner/components/vod-scan.ts +++ b/app/features/scanner/components/vod-scan.ts @@ -6,8 +6,9 @@ * (worker/analyzer.worker.ts); the seek fallback drives a