diff --git a/src/main/actions/SyncPublisherStatsAction.ts b/src/main/actions/SyncPublisherStatsAction.ts index 1f149af6..9d5231e6 100644 --- a/src/main/actions/SyncPublisherStatsAction.ts +++ b/src/main/actions/SyncPublisherStatsAction.ts @@ -5,6 +5,7 @@ import { AppDataSource } from '../data-source.js'; import { Clip } from '../entity/Clip.js'; import { publisherAuthHeaders, publisherBaseUrl } from '../services/publisherConfig.js'; import { ClipDTO } from '@shared/index.js'; +import { statsAreStale } from '../services/publisherStatsFreshness.js'; export interface PublisherClipStat { filename: string; @@ -45,16 +46,38 @@ export interface SyncPublisherStatsOutput { * state never appeared. Null when the publisher answered. */ problem: PublisherStatsProblem | null; + /** + * True when nothing was asked because the last attempt is recent enough. + * + * The rest of the answer is then empty and means nothing; a caller patching + * rows on screen must leave them alone rather than read "no clips". + */ + skipped: boolean; } +export interface SyncPublisherStatsInput { + /** + * Ask only if the last attempt is older than this. Omitted means ask now, + * which is what boot and the Publisher screen want. + */ + maxAgeMs?: number; +} + +/** When the publisher was last asked, by anyone. See `statsAreStale`. */ +let lastAttemptAt: number | null = null; +/** One request at a time: a second caller waits for the one already out. */ +let inFlight: Promise | null = null; + /** * Bring the publisher's view counts back onto the rows. * - * **Pulled, not polled.** On app start and when the Publisher screen opens. A - * background timer refreshing a number that only matters while somebody is - * looking at it is cost with no reader, and every refresh is a request over - * somebody's home uplink to their own server. + * **Pulled, not polled.** On app start, when the Publisher screen opens, and + * when the library is looked at, throttled by `maxAgeMs` so the library's + * cards stay current without asking on every alt-tab. A background timer + * refreshing a number that only matters while somebody is looking at it is + * cost with no reader, and every refresh is a request over somebody's home + * uplink to their own server. * * Mirrored onto `clip` rather than fetched per tile for the same reason * `suggestedCount` is: the library is one query per page, and a remote lookup @@ -65,15 +88,27 @@ export interface SyncPublisherStatsOutput { * are "no numbers today" rather than an error, because nothing the user asked * for has failed. */ -export class SyncPublisherStatsAction extends BaseAction { - async execute(): Promise { - const empty: SyncPublisherStatsOutput = { - clips: [], - updated: 0, - totals: { clips: 0, bytes: 0, views: 0 }, - countingSince: null, - problem: null, - }; +export class SyncPublisherStatsAction extends BaseAction< + SyncPublisherStatsInput | void, + SyncPublisherStatsOutput +> { + async execute(input: SyncPublisherStatsInput | void): Promise { + if (inFlight) return inFlight; + + const maxAgeMs = input?.maxAgeMs; + if (maxAgeMs != null && !statsAreStale(lastAttemptAt, Date.now(), maxAgeMs)) { + return { ...emptyOutput(), skipped: true }; + } + + lastAttemptAt = Date.now(); + inFlight = this.sync().finally(() => { + inFlight = null; + }); + return inFlight; + } + + private async sync(): Promise { + const empty = emptyOutput(); const baseUrl = publisherBaseUrl(); if (!baseUrl) return empty; @@ -122,6 +157,7 @@ export class SyncPublisherStatsAction extends BaseAction { - const result = await new SyncPublisherStatsAction().execute(); +statsRouter.post('/publisher/sync', asyncHandler(async (req, res) => { + // The library passes `maxAgeMs` and is answered from the last sync when it + // is recent; the Publisher screen passes nothing and always asks. + const raw = Number(req.body?.maxAgeMs); + const maxAgeMs = Number.isFinite(raw) && raw > 0 ? raw : undefined; + const result = await new SyncPublisherStatsAction().execute({ maxAgeMs }); res.json(result); })); diff --git a/src/main/services/publisherStatsFreshness.ts b/src/main/services/publisherStatsFreshness.ts new file mode 100644 index 00000000..32bc18f0 --- /dev/null +++ b/src/main/services/publisherStatsFreshness.ts @@ -0,0 +1,20 @@ +/** + * Whether the mirrored view counts are old enough to ask the publisher again. + * + * The library asks every time it is opened and every time the window comes + * back into focus, which is often. One clock in main, shared by boot, the + * Publisher screen and the library, turns that into at most one request per + * window, and a burst of navigation into none at all. + * + * An attempt counts, not only a success: a publisher that is switched off is + * asked once per window rather than on every alt-tab. + * + * Pure so `tests/unit` owns it; the clock lives in the action. + */ +export function statsAreStale(lastAttemptAt: number | null, now: number, maxAgeMs: number): boolean { + if (lastAttemptAt == null) return true; + // A clock that went backwards (a sleep, a manual change) is not "fresh for + // ever": treat it as stale rather than trusting a negative age. + const age = now - lastAttemptAt; + return age < 0 || age >= maxAgeMs; +} diff --git a/src/renderer/src/components/Library/ClipCard.vue b/src/renderer/src/components/Library/ClipCard.vue index 5c8192cd..75ae18f5 100644 --- a/src/renderer/src/components/Library/ClipCard.vue +++ b/src/renderer/src/components/Library/ClipCard.vue @@ -443,6 +443,8 @@ function handleCardClick(event: MouseEvent) { :duration-sec="clip.durationSec" :game="gameDisplayName" :recorded-at="clip.recordedAt ?? clip.fileModifiedAt ?? clip.createdAt" + :views="clip.published ? clip.publisherViews : null" + :last-viewed-at="clip.publisherLastViewedAt" /> diff --git a/src/renderer/src/components/Library/ClipMetadata.vue b/src/renderer/src/components/Library/ClipMetadata.vue index 70b3a286..222ba537 100644 --- a/src/renderer/src/components/Library/ClipMetadata.vue +++ b/src/renderer/src/components/Library/ClipMetadata.vue @@ -3,6 +3,7 @@ import { ref, computed } from 'vue'; import { useConfiguration } from '@renderer/composables/app/useConfiguration'; import { formatRelativeTime, formatExactDate } from '@renderer/helpers/dateFormat'; import { useFormat } from '@renderer/composables/ui/useFormat'; +import { viewCountLabel } from '@renderer/utils/viewCount'; /** * When the clip was recorded, which is the file's own date. @@ -29,6 +30,12 @@ interface Props { */ durationSec?: number | null; recordedAt?: string; + /** + * How often the published copy has been opened. Null for a clip that is not + * published or has not been counted; `viewCountLabel` decides what is said. + */ + views?: number | null; + lastViewedAt?: string | null; } const props = defineProps(); @@ -47,6 +54,11 @@ const length = computed(() => { return hours ? `${hours}:${two(mins)}:${two(secs)}` : `${mins}:${two(secs)}`; }); +const viewsLabel = computed(() => viewCountLabel(props.views)); +const viewsHint = computed(() => + props.lastViewedAt ? `Last opened ${formatRelativeTime(props.lastViewedAt)}` : undefined, +); + const displayDate = computed(() => { // No date is no date. Falling back to now would put a confident, wrong // "just now" on a clip whose age is simply not known. @@ -60,7 +72,8 @@ const displayDate = computed(() => {