From a313687dbe88d62297f69cbaa638e789c6c15c62 Mon Sep 17 00:00:00 2001 From: seferturan Date: Thu, 4 Jun 2026 11:27:40 +0200 Subject: [PATCH 1/2] ci: adds workflow to prune stale share images --- .github/workflows/prune_share_images.yml | 48 ++++ .../client/.scripts/pruneShareImages.run.ts | 266 ++++++++++++++++++ projects/client/.scripts/pruneShareImages.ts | 140 +++++++++ .../lib/requests/_internal/mapToShowEntry.ts | 7 +- .../src/lib/requests/models/ShowEntry.ts | 1 + .../_internal/buildImageMetadata.ts | 8 +- .../_internal/buildImagePath.ts | 28 +- .../_internal/buildTargetPrefixes.ts | 14 + .../_internal/shareImagePrefix.ts | 26 ++ 9 files changed, 509 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/prune_share_images.yml create mode 100644 projects/client/.scripts/pruneShareImages.run.ts create mode 100644 projects/client/.scripts/pruneShareImages.ts create mode 100644 projects/client/src/routes/api/shareable-image/_internal/buildTargetPrefixes.ts create mode 100644 projects/client/src/routes/api/shareable-image/_internal/shareImagePrefix.ts diff --git a/.github/workflows/prune_share_images.yml b/.github/workflows/prune_share_images.yml new file mode 100644 index 0000000000..e25e21a3b1 --- /dev/null +++ b/.github/workflows/prune_share_images.yml @@ -0,0 +1,48 @@ +name: The Purge of Stale Visions aka Prune Share Images + +on: + schedule: + - cron: '0 3 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + prune: + name: Expunging the Stale Relics + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + + steps: + - name: Sealing the Outbound Gates aka Harden Runner + uses: step-security/harden-runner@v2 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + codeload.github.com:443 + deno.com:443 + dl.deno.land:443 + github.com:443 + jsr.io:443 + npm.jsr.io:443 + registry.npmjs.org:443 + release-assets.githubusercontent.com:443 + *.r2.cloudflarestorage.com:443 + + - name: Secure the Evidence aka Checkout Code + uses: actions/checkout@v5 + + - name: Setup Deno Environment + uses: ./.github/actions/setup-deno + + - name: Purge the Stale Visions aka Prune Share Images + run: deno run --allow-env --allow-net projects/client/.scripts/pruneShareImages.run.ts + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_BUCKET_NAME: walter diff --git a/projects/client/.scripts/pruneShareImages.run.ts b/projects/client/.scripts/pruneShareImages.run.ts new file mode 100644 index 0000000000..6fa653f81b --- /dev/null +++ b/projects/client/.scripts/pruneShareImages.run.ts @@ -0,0 +1,266 @@ +/** + * Entry point for the daily share image pruning job. + * Connects to the R2 bucket via the S3-compatible API and + * delegates pruning logic to pruneShareImages. + */ + +import { buildTargetPrefixes } from '../src/routes/api/shareable-image/_internal/buildTargetPrefixes.ts'; +import { xmlUnescape } from './_internal/xml.ts'; +import { pruneShareImages } from './pruneShareImages.ts'; + +function requireEnv(name: string): string { + const value = Deno.env.get(name); + + if (!value) { + console.error(`Missing required env var: ${name}`); + Deno.exit(1); + } + + return value; +} + +const accountId = requireEnv('CLOUDFLARE_ACCOUNT_ID'); +const accessKeyId = requireEnv('R2_ACCESS_KEY_ID'); +const secretAccessKey = requireEnv('R2_SECRET_ACCESS_KEY'); +const bucketName = requireEnv('R2_BUCKET_NAME'); + +const endpoint = `https://${accountId}.r2.cloudflarestorage.com`; + +/** + * Minimal S3-compatible R2 bucket client using AWS Signature V4. + * Covers list (GET /?list-type=2) and delete (DELETE /) operations. + */ + +const REGION = 'auto'; +const SERVICE = 's3'; + +function toHex(buf: ArrayBuffer): string { + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +async function sha256(data: string): Promise { + const buf = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(data), + ); + return toHex(buf); +} + +async function hmacSha256( + key: BufferSource, + data: string, +): Promise { + const cryptoKey = await crypto.subtle.importKey( + 'raw', + key, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + return crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(data)); +} + +async function deriveSigningKey( + secretKey: string, + dateStamp: string, +): Promise { + const kDate = await hmacSha256( + new TextEncoder().encode(`AWS4${secretKey}`), + dateStamp, + ); + const kRegion = await hmacSha256(kDate, REGION); + const kService = await hmacSha256(kRegion, SERVICE); + return hmacSha256(kService, 'aws4_request'); +} + +async function signRequest( + method: string, + path: string, + query: string, + body: string, +): Promise> { + const now = new Date(); + const amzDate = `${ + now.toISOString().replace(/[:-]|\.\d{3}/g, '').slice(0, 15) + }Z`; + const dateStamp = amzDate.slice(0, 8); + const payloadHash = await sha256(body); + + const headers: Record = { + host: new URL(endpoint).host, + 'x-amz-date': amzDate, + 'x-amz-content-sha256': payloadHash, + }; + + const sortedHeaders = Object.keys(headers).sort(); + const canonicalHeaders = sortedHeaders + .map((k) => `${k.toLowerCase()}:${headers[k]}\n`) + .join(''); + const signedHeaders = sortedHeaders.map((k) => k.toLowerCase()).join(';'); + + const canonicalRequest = [ + method, + path, + query, + canonicalHeaders, + signedHeaders, + payloadHash, + ].join('\n'); + + const credentialScope = `${dateStamp}/${REGION}/${SERVICE}/aws4_request`; + const stringToSign = [ + 'AWS4-HMAC-SHA256', + amzDate, + credentialScope, + await sha256(canonicalRequest), + ].join('\n'); + + const signingKey = await deriveSigningKey(secretAccessKey, dateStamp); + const signature = toHex(await hmacSha256(signingKey, stringToSign)); + + return { + ...headers, + authorization: + `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`, + }; +} + +type ListResult = { + objects: Array<{ key: string; customMetadata?: Record }>; + truncated: boolean; + cursor?: string; +}; + +async function r2List(cursor?: string, prefix?: string): Promise { + const queryParams: Record = { 'list-type': '2' }; + if (cursor) { + queryParams['continuation-token'] = cursor; + } + if (prefix) { + queryParams['prefix'] = prefix; + } + + const query = Object.entries(queryParams) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); + + const headers = await signRequest('GET', `/${bucketName}`, query, ''); + + const response = await fetch(`${endpoint}/${bucketName}?${query}`, { + headers, + }); + + if (!response.ok) { + throw new Error( + `LIST ${prefix ?? 'root'} → ${response.status} ${await response.text()}`, + ); + } + + const xml = await response.text(); + + const keys = [...xml.matchAll(/([^<]+)<\/Key>/g)] + .map((m) => xmlUnescape(m[1] ?? '')); + const truncated = xml.includes('true'); + const nextToken = xml.match( + /([^<]+)<\/NextContinuationToken>/, + )?.[1]; + + // R2 S3 API does not return custom metadata in list responses; + // metadata is read from head requests only when needed. + const objects = keys.map((key) => ({ key, customMetadata: undefined })); + + return { + objects, + truncated, + cursor: nextToken ? xmlUnescape(nextToken) : undefined, + }; +} + +async function r2Head( + key: string, +): Promise | undefined> { + const encodedKey = key.split('/').map(encodeURIComponent).join('/'); + const headers = await signRequest( + 'HEAD', + `/${bucketName}/${encodedKey}`, + '', + '', + ); + + const response = await fetch(`${endpoint}/${bucketName}/${encodedKey}`, { + method: 'HEAD', + headers, + }); + + if (!response.ok) return undefined; + + const metadata: Record = {}; + for (const [k, v] of response.headers.entries()) { + if (k.startsWith('x-amz-meta-')) { + metadata[k.slice('x-amz-meta-'.length)] = v; + } + } + return metadata; +} + +async function r2Delete(key: string): Promise { + const encodedKey = key.split('/').map(encodeURIComponent).join('/'); + const headers = await signRequest( + 'DELETE', + `/${bucketName}/${encodedKey}`, + '', + '', + ); + + const response = await fetch(`${endpoint}/${bucketName}/${encodedKey}`, { + method: 'DELETE', + headers, + }); + + if (!response.ok && response.status !== 204) { + throw new Error( + `DELETE ${key} → ${response.status} ${await response.text()}`, + ); + } +} + +const bucket = { + list: async (opts?: { cursor?: string; prefix?: string }) => { + const raw = await r2List(opts?.cursor, opts?.prefix); + + // Hydrate custom metadata for each object via HEAD in chunks + // to avoid socket exhaustion and rate limits (1000 per page is too bursty) + const chunkSize = 50; + const objects: Array<{ + key: string; + customMetadata: Record | undefined; + }> = []; + + for (let i = 0; i < raw.objects.length; i += chunkSize) { + const chunk = raw.objects.slice(i, i + chunkSize); + const hydrated = await Promise.all( + chunk.map(async ({ key }) => ({ + key, + customMetadata: await r2Head(key), + })), + ); + objects.push(...hydrated); + } + + return { ...raw, objects }; + }, + delete: r2Delete, +}; + +const result = await pruneShareImages(bucket, buildTargetPrefixes()); + +console.log( + `Prune complete. deleted: ${result.deleted}, skipped: ${result.skipped}, errors: ${result.errors}`, +); + +if (result.errors > 0) { + Deno.exit(1); +} diff --git a/projects/client/.scripts/pruneShareImages.ts b/projects/client/.scripts/pruneShareImages.ts new file mode 100644 index 0000000000..f5e3d9ab8d --- /dev/null +++ b/projects/client/.scripts/pruneShareImages.ts @@ -0,0 +1,140 @@ +/** + * Prune stale share images from the R2 bucket. + * + * Uses the same tiered recheck schedule as the backend: + * - First 14 days since release: recheck every 12 hours + * - 15-40 days: recheck every 3 days + * - 41-120 days: recheck every 7 days + * - After 120 days: recheck every 30 days + * + * An image is pruned when its `cachedAt` age exceeds the recheck + * interval for its `releasedAt` age, so media inside its churn window + * gets regenerated frequently while settled media is evicted rarely. + * + * `releasedAt` is written by `buildImageMetadata`: the release date for + * movies, the last aired episode for shows. So a long running show that + * is still airing keeps landing in the aggressive tiers instead of + * decaying from its premiere. + */ + +import { time } from '$lib/utils/timing/time.ts'; + +type RecheckTier = { + maxDaysSinceRelease: number; + recheckIntervalMs: number; +}; + +type RecheckSchedule = { + tiers: ReadonlyArray; + fallbackIntervalMs: number; +}; + +const SHARE_IMAGE_RECHECK_SCHEDULE: RecheckSchedule = { + tiers: [ + { maxDaysSinceRelease: 14, recheckIntervalMs: time.hours(12) }, + { maxDaysSinceRelease: 40, recheckIntervalMs: time.days(3) }, + { maxDaysSinceRelease: 120, recheckIntervalMs: time.days(7) }, + ], + fallbackIntervalMs: time.days(30), +}; + +function getRecheckIntervalMs( + daysSinceRelease: number, + schedule: RecheckSchedule, +): number { + const tier = schedule.tiers.find( + (t) => daysSinceRelease <= t.maxDaysSinceRelease, + ); + + return tier?.recheckIntervalMs ?? schedule.fallbackIntervalMs; +} + +function toTimestamp(value: string | undefined): number { + if (!value) { + return Number.NaN; + } + + const parsed = Date.parse(value); + + return Number.isNaN(parsed) ? Number(value) : parsed; +} + +function isStale( + cachedAt: number, + releasedAt: number, + now: number, +): boolean { + const daysSinceRelease = (now - releasedAt) / time.days(1); + const recheckMs = getRecheckIntervalMs( + daysSinceRelease, + SHARE_IMAGE_RECHECK_SCHEDULE, + ); + return now - cachedAt >= recheckMs; +} + +type R2Bucket = { + list: ( + opts?: { cursor?: string; prefix?: string }, + ) => Promise<{ + objects: Array<{ + key: string; + customMetadata?: Record; + }>; + truncated: boolean; + cursor?: string; + }>; + delete: (key: string) => Promise; +}; + +type PruneResult = { + deleted: number; + skipped: number; + errors: number; +}; + +export async function pruneShareImages( + bucket: R2Bucket, + prefixes: ReadonlyArray, +): Promise { + const now = Date.now(); + const result: PruneResult = { deleted: 0, skipped: 0, errors: 0 }; + + for (const prefix of prefixes) { + let cursor: string | undefined; + + do { + const listed = await bucket.list({ cursor, prefix }); + + for (const object of listed.objects) { + const meta = object.customMetadata ?? {}; + const cachedAt = toTimestamp(meta.cachedAt ?? meta.cachedat); + const releasedAt = toTimestamp(meta.releasedAt ?? meta.releasedat); + + if (Number.isNaN(cachedAt)) { + result.skipped++; + continue; + } + + // Legacy entries (pre-releasedAt metadata) are always pruned so + // they get regenerated with the full metadata set. + const shouldDelete = Number.isNaN(releasedAt) || + isStale(cachedAt, releasedAt, now); + + if (!shouldDelete) { + result.skipped++; + continue; + } + + try { + await bucket.delete(object.key); + result.deleted++; + } catch { + result.errors++; + } + } + + cursor = listed.truncated ? listed.cursor : undefined; + } while (cursor); + } + return result; +} diff --git a/projects/client/src/lib/requests/_internal/mapToShowEntry.ts b/projects/client/src/lib/requests/_internal/mapToShowEntry.ts index 156b402bdb..6e75bc1f14 100644 --- a/projects/client/src/lib/requests/_internal/mapToShowEntry.ts +++ b/projects/client/src/lib/requests/_internal/mapToShowEntry.ts @@ -11,8 +11,12 @@ import { mapToSocialMedia } from './mapToSocialMedia.ts'; import { mapToTrailerUrl } from './mapToTrailerUrl.ts'; import { mapToTraktRating } from './mapToTraktRating.ts'; +type ShowResponseWithLastAired = ShowResponse & { + last_aired?: string | null; +}; + export function mapToShowEntry( - show: ShowResponse, + show: ShowResponseWithLastAired, ): ShowEntry { const poster = mapToPoster(show.images); const cover = mapToCover(show.images); @@ -63,6 +67,7 @@ export function mapToShowEntry( airDate: effectiveReleaseDate, releaseDate: effectiveReleaseDate, effectiveReleaseDate, + ...(show.last_aired ? { lastAired: new Date(show.last_aired) } : {}), certification: show.certification, votes: show.votes ?? 0, plexSlug: show.ids.plex?.slug, diff --git a/projects/client/src/lib/requests/models/ShowEntry.ts b/projects/client/src/lib/requests/models/ShowEntry.ts index 57e178459e..212adcdbfd 100644 --- a/projects/client/src/lib/requests/models/ShowEntry.ts +++ b/projects/client/src/lib/requests/models/ShowEntry.ts @@ -14,5 +14,6 @@ export const ShowEntrySchema = MediaEntrySchema.merge(EpisodeCountSchema) network: z.string().nullish(), totalRuntime: z.number(), airs: ShowAirsSchema.nullish(), + lastAired: z.date().nullish(), }); export type ShowEntry = z.infer; diff --git a/projects/client/src/routes/api/shareable-image/_internal/buildImageMetadata.ts b/projects/client/src/routes/api/shareable-image/_internal/buildImageMetadata.ts index ddbbdbe493..1d97eff8e2 100644 --- a/projects/client/src/routes/api/shareable-image/_internal/buildImageMetadata.ts +++ b/projects/client/src/routes/api/shareable-image/_internal/buildImageMetadata.ts @@ -1,7 +1,10 @@ import type { MediaEntry } from '$lib/requests/models/MediaEntry.ts'; +import type { ShowEntry } from '$lib/requests/models/ShowEntry.ts'; type BuildImageMetadataProps = { - media: Pick; + media: + & Pick + & Partial>; cachedAt: Date; }; @@ -15,6 +18,7 @@ export function buildImageMetadata( ): ImageMetadata { return { cachedAt: cachedAt.toISOString(), - releasedAt: media.effectiveReleaseDate.toISOString(), + releasedAt: (media.lastAired ?? media.effectiveReleaseDate) + .toISOString(), }; } diff --git a/projects/client/src/routes/api/shareable-image/_internal/buildImagePath.ts b/projects/client/src/routes/api/shareable-image/_internal/buildImagePath.ts index cf2cab62f9..a06bde83ea 100644 --- a/projects/client/src/routes/api/shareable-image/_internal/buildImagePath.ts +++ b/projects/client/src/routes/api/shareable-image/_internal/buildImagePath.ts @@ -1,7 +1,6 @@ import type { ShareType } from '$lib/features/share/models/ShareType.ts'; import type { MediaType } from '$lib/requests/models/MediaType.ts'; - -const ROOT_PATH = 'images/share'; +import { shareImagePrefix } from './shareImagePrefix.ts'; type BuildImagePathProps = { shareType: ShareType; @@ -9,29 +8,6 @@ type BuildImagePathProps = { type: MediaType; }; -function toShareTypePath(shareType: ShareType): string { - switch (shareType) { - case 'open-graph': - return 'og'; - case 'feed': - return 'feed'; - case 'story': - return 'story'; - } -} - -function toMediaPath(type: MediaType, slug: string): string { - switch (type) { - case 'movie': - return `movie/${slug}`; - case 'show': - return `show/${slug}`; - } -} - export function buildImagePath({ shareType, slug, type }: BuildImagePathProps) { - const shareTypePath = toShareTypePath(shareType); - const mediaPath = toMediaPath(type, slug); - - return `${ROOT_PATH}/${shareTypePath}/${mediaPath}/image.png`; + return `${shareImagePrefix({ shareType, type })}${slug}/image.png`; } diff --git a/projects/client/src/routes/api/shareable-image/_internal/buildTargetPrefixes.ts b/projects/client/src/routes/api/shareable-image/_internal/buildTargetPrefixes.ts new file mode 100644 index 0000000000..91b98a8ad5 --- /dev/null +++ b/projects/client/src/routes/api/shareable-image/_internal/buildTargetPrefixes.ts @@ -0,0 +1,14 @@ +import { + SHARE_TYPE_DIMENSIONS, + type ShareType, +} from '$lib/features/share/models/ShareType.ts'; +import { MediaTypeSchema } from '$lib/requests/models/MediaType.ts'; +import { shareImagePrefix } from './shareImagePrefix.ts'; + +export function buildTargetPrefixes(): ReadonlyArray { + const shareTypes = Object.keys(SHARE_TYPE_DIMENSIONS) as ShareType[]; + + return shareTypes.flatMap((shareType) => + MediaTypeSchema.options.map((type) => shareImagePrefix({ shareType, type })) + ); +} diff --git a/projects/client/src/routes/api/shareable-image/_internal/shareImagePrefix.ts b/projects/client/src/routes/api/shareable-image/_internal/shareImagePrefix.ts new file mode 100644 index 0000000000..df2d5bea32 --- /dev/null +++ b/projects/client/src/routes/api/shareable-image/_internal/shareImagePrefix.ts @@ -0,0 +1,26 @@ +import type { ShareType } from '$lib/features/share/models/ShareType.ts'; +import type { MediaType } from '$lib/requests/models/MediaType.ts'; + +const ROOT_PATH = 'images/share'; + +type ShareImagePrefixProps = { + shareType: ShareType; + type: MediaType; +}; + +function toShareTypePath(shareType: ShareType): string { + switch (shareType) { + case 'open-graph': + return 'og'; + case 'feed': + return 'feed'; + case 'story': + return 'story'; + } +} + +export function shareImagePrefix( + { shareType, type }: ShareImagePrefixProps, +): string { + return `${ROOT_PATH}/${toShareTypePath(shareType)}/${type}/`; +} From 0afdbb544e1f77eff38f158daf976fa413b6d64a Mon Sep 17 00:00:00 2001 From: seferturan Date: Thu, 4 Jun 2026 11:27:55 +0200 Subject: [PATCH 2/2] test: pruning share images --- .../client/.scripts/pruneShareImages.spec.ts | 401 ++++++++++++++++++ .../_internal/buildImageMetadata.spec.ts | 38 +- .../_internal/buildTargetPrefixes.spec.ts | 28 ++ 3 files changed, 460 insertions(+), 7 deletions(-) create mode 100644 projects/client/.scripts/pruneShareImages.spec.ts create mode 100644 projects/client/src/routes/api/shareable-image/_internal/buildTargetPrefixes.spec.ts diff --git a/projects/client/.scripts/pruneShareImages.spec.ts b/projects/client/.scripts/pruneShareImages.spec.ts new file mode 100644 index 0000000000..c84d4970a4 --- /dev/null +++ b/projects/client/.scripts/pruneShareImages.spec.ts @@ -0,0 +1,401 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { pruneShareImages } from './pruneShareImages.ts'; + +const NOW = new Date('2026-06-04T12:00:00Z').getTime(); + +type BucketObject = { + key: string; + customMetadata?: Record; +}; + +function makeBucket(objects: BucketObject[]) { + const deleted: string[] = []; + + const bucket = { + list: vi.fn((opts?: { prefix?: string; cursor?: string }) => { + const prefix = opts?.prefix; + const filtered = prefix + ? objects.filter((o) => o.key.startsWith(prefix)) + : objects; + return Promise.resolve({ + objects: filtered, + truncated: false, + cursor: undefined, + }); + }), + delete: vi.fn((key: string) => { + deleted.push(key); + return Promise.resolve(); + }), + deleted, + }; + + return bucket; +} + +function cachedAt(msBefore: number): string { + return new Date(NOW - msBefore).toISOString(); +} + +function releasedAt(daysAgo: number): string { + return new Date(NOW - daysAgo * 24 * 60 * 60 * 1000).toISOString(); +} + +const prefixes = ['images/share/og/movie/', 'images/share/og/show/']; + +describe('pruneShareImages', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + describe('staleness tiers', () => { + it('prunes a fresh release cached over 12 hours ago (tier 1: ≤14 days)', async () => { + const bucket = makeBucket([ + { + key: 'images/share/og/movie/the-dark-knight/image.png', + customMetadata: { + cachedAt: cachedAt(13 * 60 * 60 * 1000), // 13h ago + releasedAt: releasedAt(7), + }, + }, + ]); + + const result = await pruneShareImages(bucket, prefixes); + + expect(result.deleted).toBe(1); + expect(result.skipped).toBe(0); + }); + + it('skips a fresh release cached under 12 hours ago (tier 1: ≤14 days)', async () => { + const bucket = makeBucket([ + { + key: 'images/share/og/movie/the-dark-knight/image.png', + customMetadata: { + cachedAt: cachedAt(11 * 60 * 60 * 1000), // 11h ago + releasedAt: releasedAt(7), + }, + }, + ]); + + const result = await pruneShareImages(bucket, prefixes); + + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(1); + }); + + it('prunes a mid-age release cached over 3 days ago (tier 2: 15-40 days)', async () => { + const bucket = makeBucket([ + { + key: 'images/share/og/movie/oppenheimer/image.png', + customMetadata: { + cachedAt: cachedAt(4 * 24 * 60 * 60 * 1000), // 4d ago + releasedAt: releasedAt(20), + }, + }, + ]); + + const result = await pruneShareImages(bucket, prefixes); + + expect(result.deleted).toBe(1); + }); + + it('skips a mid-age release cached under 3 days ago (tier 2: 15-40 days)', async () => { + const bucket = makeBucket([ + { + key: 'images/share/og/movie/oppenheimer/image.png', + customMetadata: { + cachedAt: cachedAt(2 * 24 * 60 * 60 * 1000), // 2d ago + releasedAt: releasedAt(20), + }, + }, + ]); + + const result = await pruneShareImages(bucket, prefixes); + + expect(result.skipped).toBe(1); + }); + + it('prunes older release cached over 7 days ago (tier 3: 41-120 days)', async () => { + const bucket = makeBucket([ + { + key: 'images/share/og/show/breaking-bad/image.png', + customMetadata: { + cachedAt: cachedAt(8 * 24 * 60 * 60 * 1000), // 8d ago + releasedAt: releasedAt(60), + }, + }, + ]); + + const result = await pruneShareImages(bucket, prefixes); + + expect(result.deleted).toBe(1); + }); + + it('prunes catalog content cached over 30 days ago (fallback: >120 days)', async () => { + const bucket = makeBucket([ + { + key: 'images/share/og/show/the-wire/image.png', + customMetadata: { + cachedAt: cachedAt(31 * 24 * 60 * 60 * 1000), // 31d ago + releasedAt: releasedAt(365 * 10), + }, + }, + ]); + + const result = await pruneShareImages(bucket, prefixes); + + expect(result.deleted).toBe(1); + }); + + it('skips catalog content cached under 30 days ago (fallback: >120 days)', async () => { + const bucket = makeBucket([ + { + key: 'images/share/og/show/the-wire/image.png', + customMetadata: { + cachedAt: cachedAt(20 * 24 * 60 * 60 * 1000), // 20d ago + releasedAt: releasedAt(365 * 10), + }, + }, + ]); + + const result = await pruneShareImages(bucket, prefixes); + + expect(result.skipped).toBe(1); + }); + }); + + describe('threshold boundaries', () => { + it('prunes when the cached age exactly equals the recheck interval', async () => { + const bucket = makeBucket([ + { + key: 'images/share/og/movie/the-dark-knight/image.png', + customMetadata: { + cachedAt: cachedAt(12 * 60 * 60 * 1000), // exactly 12h ago + releasedAt: releasedAt(7), + }, + }, + ]); + + const result = await pruneShareImages(bucket, prefixes); + + expect(result.deleted).toBe(1); + expect(result.skipped).toBe(0); + }); + + it('keeps a release aged exactly 14 days in tier 1', async () => { + const bucket = makeBucket([ + { + key: 'images/share/og/movie/the-dark-knight/image.png', + customMetadata: { + cachedAt: cachedAt(13 * 60 * 60 * 1000), // 13h ago + releasedAt: releasedAt(14), // exactly at the tier 1 edge + }, + }, + ]); + + const result = await pruneShareImages(bucket, prefixes); + + expect(result.deleted).toBe(1); + expect(result.skipped).toBe(0); + }); + + it('moves a release aged just over 14 days into tier 2', async () => { + const bucket = makeBucket([ + { + key: 'images/share/og/movie/the-dark-knight/image.png', + customMetadata: { + cachedAt: cachedAt(13 * 60 * 60 * 1000), // 13h ago + releasedAt: releasedAt(14 + 1 / 24), // 14d + 1h + }, + }, + ]); + + const result = await pruneShareImages(bucket, prefixes); + + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(1); + }); + }); + + describe('legacy entries (missing releasedAt)', () => { + it('always prunes entries missing releasedAt metadata', async () => { + const bucket = makeBucket([ + { + key: 'images/share/og/movie/legacy-film/image.png', + customMetadata: { + cachedAt: cachedAt(60 * 60 * 1000), // 1h ago, would be skipped if it had releasedAt + }, + }, + ]); + + const result = await pruneShareImages(bucket, prefixes); + + expect(result.deleted).toBe(1); + expect(result.skipped).toBe(0); + }); + }); + + describe('legacy epoch metadata', () => { + it('still reads objects written before the ISO switch', async () => { + const bucket = makeBucket([ + { + key: 'images/share/og/movie/epoch-film/image.png', + customMetadata: { + cachedAt: String(NOW - 11 * 60 * 60 * 1000), + releasedAt: String(NOW - 7 * 24 * 60 * 60 * 1000), + }, + }, + ]); + + const result = await pruneShareImages(bucket, prefixes); + + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(1); + }); + }); + + describe('missing cachedAt', () => { + it('skips entries with no cachedAt metadata', async () => { + const bucket = makeBucket([ + { + key: 'images/share/og/movie/unknown/image.png', + customMetadata: { releasedAt: releasedAt(10) }, + }, + ]); + + const result = await pruneShareImages(bucket, prefixes); + + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(1); + }); + + it('skips entries with no metadata at all', async () => { + const bucket = makeBucket([ + { key: 'images/share/og/movie/no-meta/image.png' }, + ]); + + const result = await pruneShareImages(bucket, prefixes); + + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(1); + }); + }); + + describe('prefix scoping', () => { + it('only lists objects under the given prefixes', async () => { + const bucket = makeBucket([]); + + await pruneShareImages(bucket, [ + 'images/share/og/movie/', + 'images/share/feed/show/', + ]); + + expect(bucket.list).toHaveBeenCalledTimes(2); + expect(bucket.list).toHaveBeenCalledWith( + expect.objectContaining({ prefix: 'images/share/og/movie/' }), + ); + expect(bucket.list).toHaveBeenCalledWith( + expect.objectContaining({ prefix: 'images/share/feed/show/' }), + ); + }); + + it('does not delete objects outside the given prefixes', async () => { + const bucket = makeBucket([ + { + key: 'immutable/some-asset.js', + customMetadata: { + cachedAt: cachedAt(365 * 24 * 60 * 60 * 1000), + releasedAt: releasedAt(365), + }, + }, + ]); + + await pruneShareImages(bucket, ['images/share/og/movie/']); + + expect(bucket.deleted).toHaveLength(0); + }); + }); + + describe('error handling', () => { + it('counts delete errors and continues processing remaining objects', async () => { + const objects: BucketObject[] = [ + { + key: 'images/share/og/movie/film-a/image.png', + customMetadata: { + cachedAt: cachedAt(31 * 24 * 60 * 60 * 1000), + releasedAt: releasedAt(200), + }, + }, + { + key: 'images/share/og/movie/film-b/image.png', + customMetadata: { + cachedAt: cachedAt(31 * 24 * 60 * 60 * 1000), + releasedAt: releasedAt(200), + }, + }, + ]; + + const bucket = makeBucket(objects); + bucket.delete.mockRejectedValueOnce(new Error('R2 error')); + + const result = await pruneShareImages(bucket, ['images/share/og/movie/']); + + expect(result.errors).toBe(1); + expect(result.deleted).toBe(1); + }); + }); + + describe('pagination', () => { + it('follows cursor until truncated is false', async () => { + const page1: BucketObject[] = [ + { + key: 'images/share/og/movie/film-a/image.png', + customMetadata: { + cachedAt: cachedAt(31 * 24 * 60 * 60 * 1000), + releasedAt: releasedAt(200), + }, + }, + ]; + const page2: BucketObject[] = [ + { + key: 'images/share/og/movie/film-b/image.png', + customMetadata: { + cachedAt: cachedAt(31 * 24 * 60 * 60 * 1000), + releasedAt: releasedAt(200), + }, + }, + ]; + + const listMock = vi.fn() + .mockResolvedValueOnce({ + objects: page1, + truncated: true, + cursor: 'token-1', + }) + .mockResolvedValueOnce({ + objects: page2, + truncated: false, + cursor: undefined, + }); + + const bucket = { + list: listMock, + delete: vi.fn(async () => {}), + deleted: [] as string[], + }; + + const result = await pruneShareImages(bucket, ['images/share/og/movie/']); + + expect(listMock).toHaveBeenCalledTimes(2); + expect(listMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ cursor: 'token-1' }), + ); + expect(result.deleted).toBe(2); + }); + }); +}); diff --git a/projects/client/src/routes/api/shareable-image/_internal/buildImageMetadata.spec.ts b/projects/client/src/routes/api/shareable-image/_internal/buildImageMetadata.spec.ts index d26b4125af..70714a6adc 100644 --- a/projects/client/src/routes/api/shareable-image/_internal/buildImageMetadata.spec.ts +++ b/projects/client/src/routes/api/shareable-image/_internal/buildImageMetadata.spec.ts @@ -4,24 +4,48 @@ import { buildImageMetadata } from './buildImageMetadata.ts'; const CACHED_AT = new Date('2026-08-17T18:30:00.000Z'); const RELEASED_AT = new Date('2022-02-04T00:00:00.000Z'); +const LAST_AIRED = new Date('2026-05-30T00:00:00.000Z'); -function buildFor(effectiveReleaseDate: Date) { +type BuildForProps = { + effectiveReleaseDate?: Date; + lastAired?: Date | null; +}; + +function buildFor({ effectiveReleaseDate, lastAired }: BuildForProps = {}) { return buildImageMetadata({ - media: { effectiveReleaseDate }, + media: { + effectiveReleaseDate: effectiveReleaseDate ?? RELEASED_AT, + lastAired, + }, cachedAt: CACHED_AT, }); } describe('util: buildImageMetadata', () => { it('should record when the image was cached', () => { - expect(buildFor(RELEASED_AT).cachedAt).toBe('2026-08-17T18:30:00.000Z'); + expect(buildFor().cachedAt).toBe('2026-08-17T18:30:00.000Z'); }); - it('should record when the media was released', () => { - expect(buildFor(RELEASED_AT).releasedAt).toBe('2022-02-04T00:00:00.000Z'); + describe('for movies', () => { + it('should anchor on the release date', () => { + expect(buildFor().releasedAt).toBe('2022-02-04T00:00:00.000Z'); + }); + + it('should anchor on the max date when the release date is unknown', () => { + expect(buildFor({ effectiveReleaseDate: MAX_DATE }).releasedAt) + .toBe(MAX_DATE.toISOString()); + }); }); - it('should record the max date when the release date is unknown', () => { - expect(buildFor(MAX_DATE).releasedAt).toBe(MAX_DATE.toISOString()); + describe('for shows', () => { + it('should anchor on the last aired episode', () => { + expect(buildFor({ lastAired: LAST_AIRED }).releasedAt) + .toBe('2026-05-30T00:00:00.000Z'); + }); + + it('should anchor on the release date when nothing has aired yet', () => { + expect(buildFor({ lastAired: null }).releasedAt) + .toBe('2022-02-04T00:00:00.000Z'); + }); }); }); diff --git a/projects/client/src/routes/api/shareable-image/_internal/buildTargetPrefixes.spec.ts b/projects/client/src/routes/api/shareable-image/_internal/buildTargetPrefixes.spec.ts new file mode 100644 index 0000000000..0e7800b51b --- /dev/null +++ b/projects/client/src/routes/api/shareable-image/_internal/buildTargetPrefixes.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { buildTargetPrefixes } from './buildTargetPrefixes.ts'; + +describe('util: buildTargetPrefixes', () => { + it('should cover every share type and media type combination', () => { + expect(buildTargetPrefixes()).toEqual([ + 'images/share/og/movie/', + 'images/share/og/show/', + 'images/share/feed/movie/', + 'images/share/feed/show/', + 'images/share/story/movie/', + 'images/share/story/show/', + ]); + }); + + it('should scope every prefix under the share root', () => { + const prefixes = buildTargetPrefixes(); + + expect(prefixes.every((prefix) => prefix.startsWith('images/share/'))) + .toBe(true); + }); + + it('should end every prefix with a separator so it cannot match sibling keys', () => { + const prefixes = buildTargetPrefixes(); + + expect(prefixes.every((prefix) => prefix.endsWith('/'))).toBe(true); + }); +});