Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 61 additions & 13 deletions src/main/actions/SyncPublisherStatsAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<SyncPublisherStatsOutput> | 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
Expand All @@ -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<void, SyncPublisherStatsOutput> {
async execute(): Promise<SyncPublisherStatsOutput> {
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<SyncPublisherStatsOutput> {
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<SyncPublisherStatsOutput> {
const empty = emptyOutput();

const baseUrl = publisherBaseUrl();
if (!baseUrl) return empty;
Expand Down Expand Up @@ -122,6 +157,7 @@ export class SyncPublisherStatsAction extends BaseAction<void, SyncPublisherStat
totals: payload.totals ?? empty.totals,
countingSince: payload.countingSince ?? null,
problem: null,
skipped: false,
};
}

Expand Down Expand Up @@ -149,6 +185,18 @@ export class SyncPublisherStatsAction extends BaseAction<void, SyncPublisherStat
totals: payload.totals ?? empty.totals,
countingSince: payload.countingSince ?? null,
problem: null,
skipped: false,
};
}
}

function emptyOutput(): SyncPublisherStatsOutput {
return {
clips: [],
updated: 0,
totals: { clips: 0, bytes: 0, views: 0 },
countingSince: null,
problem: null,
skipped: false,
};
}
11 changes: 9 additions & 2 deletions src/main/routes/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,20 @@ export const statsRouter = express.Router();
/**
* Bring the publisher's view counts back, and say what it reported.
*
* Also asked by the library, throttled, so the counts on its cards follow the
* publisher while the app sits in the tray for days.
*
* A POST rather than a GET: it writes to `clip` rows. Pulled by the Publisher
* screen when it opens and once at boot, rather than polled, because a number
* only matters while somebody is looking at it and every refresh is a request
* over a home uplink to somebody's own server.
*/
statsRouter.post('/publisher/sync', asyncHandler(async (_req, res) => {
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);
}));

Expand Down
20 changes: 20 additions & 0 deletions src/main/services/publisherStatsFreshness.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions src/renderer/src/components/Library/ClipCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>

<ClipTags :clip="clip" @updated="emit('updated', $event)" />
Expand Down
22 changes: 21 additions & 1 deletion src/renderer/src/components/Library/ClipMetadata.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<Props>();
Expand All @@ -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.
Expand All @@ -60,7 +72,8 @@ const displayDate = computed(() => {

<template>
<!--
One line: how long, which game, how long ago, how big.
One line: how long, which game, how long ago, how big, and for a
published clip how often it has been opened.

It was the duration and the file size on the left with the date pushed to
the right edge, so each card carried a two-column layout of its own and a
Expand Down Expand Up @@ -88,6 +101,13 @@ const displayDate = computed(() => {
</time>
<span v-if="displayDate && sizeBytes" aria-hidden="true" class="shrink-0">·</span>
<span v-if="sizeBytes" class="shrink-0 tabular-nums">{{ formatBytes(sizeBytes) }}</span>
<span v-if="viewsLabel && (length || game || displayDate || sizeBytes)" aria-hidden="true" class="shrink-0">·</span>
<span
v-if="viewsLabel"
class="shrink-0 tabular-nums"
data-testid="clip-view-count"
:title="viewsHint"
>{{ viewsLabel }}</span>
</div>
</template>

73 changes: 73 additions & 0 deletions src/renderer/src/composables/library/useLibraryViewCounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { onBeforeUnmount, onMounted, watch } from 'vue';
import { syncPublisherStats } from '@renderer/services/clips';
import { useClipsStore } from '@renderer/stores/clips';
import { useAppSettings } from '@renderer/composables/app/useAppSettings';

/**
* How stale the counts on a card may get while somebody is looking at them.
*
* Main owns the clock (`statsAreStale`), so boot, the Publisher screen and
* this all share one window and a burst of navigation is one request.
*/
export const LIBRARY_VIEW_COUNT_MAX_AGE_MS = 10 * 60_000;

/**
* Keep the view counts on the library's cards following the publisher.
*
* The counts are already on every row (`SyncPublisherStatsAction` mirrors
* them), so nothing is fetched per page or per tile. What was missing is
* freshness: the sync ran at boot and on the Publisher screen, and the app
* sits in the tray for days. So the library asks when it opens and when the
* window comes back into focus, and main answers from the last sync when it
* is recent.
*
* **Quiet on failure.** A publisher that is off leaves yesterday's numbers on
* the cards. The Publisher screen is where "could not reach" is explained; an
* optional feature does not get an error banner over the library.
*/
export function useLibraryViewCounts(): void {
const clipsStore = useClipsStore();
const { settings } = useAppSettings();

async function refresh(): Promise<void> {
if (!settings.value.publisherBaseUrl) return;
try {
const answer = await syncPublisherStats(LIBRARY_VIEW_COUNT_MAX_AGE_MS);
if (answer.skipped || answer.problem) return;
/*
* After the list lands, not over it. The page's own first fetch runs
* beside this one, and a patch applied before its rows arrive is
* replaced by rows that may have been read before the sync wrote.
*/
await untilListIdle();
clipsStore.applyPublisherViews(answer.clips ?? []);
} catch (error) {
console.warn('[library] could not refresh view counts:', error);
}
}

function untilListIdle(): Promise<void> {
if (!clipsStore.loading) return Promise.resolve();
return new Promise((resolve) => {
const stop = watch(
() => clipsStore.loading,
(loading) => {
if (loading) return;
stop();
resolve();
},
);
});
}

const onFocus = (): void => void refresh();

onMounted(() => {
void refresh();
window.addEventListener('focus', onFocus);
});

onBeforeUnmount(() => {
window.removeEventListener('focus', onFocus);
});
}
16 changes: 11 additions & 5 deletions src/renderer/src/services/clips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,17 +275,23 @@ export interface PublisherStats {
countingSince: string | null;
/** Why there are no numbers, when the publisher did not answer. */
problem: { kind: 'unreachable' | 'unauthorized' | 'outdated'; message: string } | null;
/** Nothing was asked: the last sync is inside `maxAgeMs`. The rest is empty. */
skipped: boolean;
}

/**
* Ask the publisher what it has, and mirror the numbers onto the rows.
*
* A POST because it writes. Called when the Publisher screen opens, never on a
* timer: this is the one screen in the app that reads a remote service, and a
* number nobody is looking at is not worth a request over a home uplink.
* A POST because it writes. Called when the Publisher screen opens, and by the
* library with `maxAgeMs` so main answers from the last sync when it is
* recent. Never on a timer: a number nobody is looking at is not worth a
* request over a home uplink.
*/
export async function syncPublisherStats(): Promise<PublisherStats> {
const { data } = await axios.post<PublisherStats>('/api/stats/publisher/sync');
export async function syncPublisherStats(maxAgeMs?: number): Promise<PublisherStats> {
const { data } = await axios.post<PublisherStats>(
'/api/stats/publisher/sync',
maxAgeMs ? { maxAgeMs } : undefined,
);
return data;
}

Expand Down
18 changes: 18 additions & 0 deletions src/renderer/src/stores/clips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,24 @@ export const useClipsStore = defineStore('clips', {
if (clip) clip.suggestedCount = suggestedCount;
},

/**
* Fresh view counts onto the rows already on screen.
*
* Two fields assigned rather than whole rows replaced, and never a
* refetch: the grid would jump, and a list load here would race the
* abort-and-requestId pattern above. A clip not on screen gets the count
* when it is next fetched, because the sync wrote it to the row.
*/
applyPublisherViews(published: ReadonlyArray<Pick<Clip, 'id' | 'publisherViews' | 'publisherLastViewedAt'>>): void {
const byId = new Map(published.map((row) => [row.id, row]));
for (const clip of this.items) {
const fresh = byId.get(clip.id);
if (!fresh) continue;
clip.publisherViews = fresh.publisherViews ?? null;
clip.publisherLastViewedAt = fresh.publisherLastViewedAt ?? null;
}
},

updateClip(updatedClip: Clip): void {
const index = this.items.findIndex(clip => clip.id === updatedClip.id);
if (index !== -1) {
Expand Down
20 changes: 20 additions & 0 deletions src/renderer/src/utils/viewCount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { pluralize } from './pluralize';

/**
* What a library card says about how often a published clip has been watched.
*
* **Null says nothing.** It means nobody has counted: a publisher older than
* the counter, or no sync yet. A number there would be invented.
*
* **Zero says nothing either, on a card.** The counter started at zero for
* every clip on the day it shipped, so a card cannot tell "nobody opened it"
* from "nothing was counting then", and a tile is no place to explain the
* difference. The Publisher screen knows `countingSince` and says both.
*
* Grouped digits because a clip that went round a Discord can reach four.
*/
export function viewCountLabel(views: number | null | undefined): string {
if (views == null || !Number.isFinite(views) || views <= 0) return '';
const whole = Math.floor(views);
return `${whole.toLocaleString('en-US')} ${pluralize(whole, 'view')}`;
}
3 changes: 3 additions & 0 deletions src/renderer/src/views/ClipsPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useBatchOperations } from '@renderer/composables/library/useBatchOperat
import { useClipListKeyboardShortcuts } from '@renderer/composables/library/useClipListKeyboardShortcuts';
import { useClipListHandlers } from '@renderer/composables/library/useClipListHandlers';
import { useLibraryRescan } from '@renderer/composables/library/useLibraryRescan';
import { useLibraryViewCounts } from '@renderer/composables/library/useLibraryViewCounts';
import { useSelectAllShortcut } from '@renderer/composables/ui/useSelectAllShortcut';
import { useScrolledPage } from '@renderer/composables/ui/useScrolledPage';
import type { Clip } from '@renderer/types/clip';
Expand All @@ -28,6 +29,8 @@ const gamesStore = useGamesStore();
const config = useConfiguration();
const { getVideoUrl, getThumbUrl } = useClipHandlers();
const { rescan } = useLibraryRescan();
// The view counts on published cards, refreshed while the library is looked at.
useLibraryViewCounts();

/** The shell follows the scroll; this row only reads it. */
const { scrolled } = useScrolledPage();
Expand Down
Loading
Loading