refactor: clean up orphaned cached images on cache purge and metadata updates

- Add `deleteOrphanedImage(category, path)` helper to `image-cache.ts` for fire-and-forget unlink of stale cached files
- Extend `purgeShellTitlesTransaction` to collect poster, backdrop, still, and profile image paths from deleted titles/persons before cascade deletes, returning them as `orphanedImages` in `PurgeResult`
- Wire orphaned image cleanup into `purgeMetadataCache` (cache purge), `batchUpsertPersons` (profile path changes), `updateTitleWithArtInvalidation` (poster/backdrop changes), and `refreshTvChildren` (season posters and episode stills)
- Remove `getTitlesWithMissingThumbhashes`, `getTitleIdsWithMissingSeasonThumbhashes`, `getTitleIdsWithMissingEpisodeThumbhashes`, and `getTitleIdsWithMissingProfileThumbhashes` from `@sofa/db/queries/cron` — `getThumbhashBackfillTitleIds` now delegates to `getLibraryTitleIds` only
- Fix episode still cleanup to call `deleteOrphanedImage` even when `stillThumbHash` is null (previously skipped)
This commit is contained in:
2026-03-23 13:18:06 -04:00
parent cc8909d64e
commit d9be566863
7 changed files with 128 additions and 71 deletions
+90 -5
View File
@@ -1,11 +1,89 @@
import { inArray, isNull, notInArray } from "drizzle-orm";
import { db } from "../client";
import { persons, titleCast, titles, userTitleStatus } from "../schema";
import { episodes, persons, seasons, titleCast, titles, userTitleStatus } from "../schema";
const BATCH_SIZE = 500;
export function purgeShellTitlesTransaction(): { deletedTitles: number; deletedPersons: number } {
export interface OrphanedImage {
category: "posters" | "backdrops" | "stills" | "profiles";
path: string;
}
export interface PurgeResult {
deletedTitles: number;
deletedPersons: number;
orphanedImages: OrphanedImage[];
}
/** Collect all cached image paths associated with a set of title IDs. */
function collectTitleImagePaths(titleIds: string[]): OrphanedImage[] {
const images: OrphanedImage[] = [];
for (let i = 0; i < titleIds.length; i += BATCH_SIZE) {
const batch = titleIds.slice(i, i + BATCH_SIZE);
// Title posters + backdrops
const titleRows = db
.select({ posterPath: titles.posterPath, backdropPath: titles.backdropPath })
.from(titles)
.where(inArray(titles.id, batch))
.all();
for (const r of titleRows) {
if (r.posterPath) images.push({ category: "posters", path: r.posterPath });
if (r.backdropPath) images.push({ category: "backdrops", path: r.backdropPath });
}
// Season posters
const seasonRows = db
.select({ posterPath: seasons.posterPath })
.from(seasons)
.where(inArray(seasons.titleId, batch))
.all();
for (const r of seasonRows) {
if (r.posterPath) images.push({ category: "posters", path: r.posterPath });
}
// Episode stills (via seasons)
const seasonIds = db
.select({ id: seasons.id })
.from(seasons)
.where(inArray(seasons.titleId, batch))
.all()
.map((s) => s.id);
for (let j = 0; j < seasonIds.length; j += BATCH_SIZE) {
const sBatch = seasonIds.slice(j, j + BATCH_SIZE);
const epRows = db
.select({ stillPath: episodes.stillPath })
.from(episodes)
.where(inArray(episodes.seasonId, sBatch))
.all();
for (const r of epRows) {
if (r.stillPath) images.push({ category: "stills", path: r.stillPath });
}
}
}
return images;
}
/** Collect profile paths for persons that will be orphaned (no remaining titleCast). */
function collectOrphanedPersonImages(): OrphanedImage[] {
const orphaned = db
.select({ profilePath: persons.profilePath })
.from(persons)
.where(
notInArray(persons.id, db.selectDistinct({ personId: titleCast.personId }).from(titleCast)),
)
.all();
return orphaned
.filter((p): p is { profilePath: string } => p.profilePath != null)
.map((p) => ({ category: "profiles" as const, path: p.profilePath }));
}
export function purgeShellTitlesTransaction(): PurgeResult {
return db.transaction(() => {
const shellTitles = db
.select({ id: titles.id })
@@ -14,7 +92,8 @@ export function purgeShellTitlesTransaction(): { deletedTitles: number; deletedP
.all();
if (shellTitles.length === 0) {
return { deletedTitles: 0, deletedPersons: purgeOrphanedPersons() };
const orphanedImages = collectOrphanedPersonImages();
return { deletedTitles: 0, deletedPersons: purgeOrphanedPersons(), orphanedImages };
}
const libraryTitleIds = new Set(
@@ -28,15 +107,21 @@ export function purgeShellTitlesTransaction(): { deletedTitles: number; deletedP
const toDelete = shellTitles.map((t) => t.id).filter((id) => !libraryTitleIds.has(id));
if (toDelete.length === 0) {
return { deletedTitles: 0, deletedPersons: purgeOrphanedPersons() };
const orphanedImages = collectOrphanedPersonImages();
return { deletedTitles: 0, deletedPersons: purgeOrphanedPersons(), orphanedImages };
}
// Collect image paths BEFORE cascade-deleting the title rows
const orphanedImages = collectTitleImagePaths(toDelete);
for (let i = 0; i < toDelete.length; i += BATCH_SIZE) {
const batch = toDelete.slice(i, i + BATCH_SIZE);
db.delete(titles).where(inArray(titles.id, batch)).run();
}
return { deletedTitles: toDelete.length, deletedPersons: purgeOrphanedPersons() };
// After title cascade deletes, collect orphaned person images then delete persons
orphanedImages.push(...collectOrphanedPersonImages());
return { deletedTitles: toDelete.length, deletedPersons: purgeOrphanedPersons(), orphanedImages };
});
}
+1 -49
View File
@@ -1,11 +1,9 @@
import { and, eq, inArray, isNotNull, lt, or, sql } from "drizzle-orm";
import { and, eq, inArray, isNotNull, lt, or } from "drizzle-orm";
import { db } from "../client";
import {
availabilityOffers,
cronRuns,
episodes,
persons,
seasons,
titleCast,
titles,
@@ -43,52 +41,6 @@ export function getLibraryTitleIds(): string[] {
.map((r) => r.titleId);
}
export function getTitlesWithMissingThumbhashes() {
return db
.select({ id: titles.id })
.from(titles)
.where(
or(
and(isNotNull(titles.posterPath), sql`${titles.posterThumbHash} IS NULL`),
and(isNotNull(titles.backdropPath), sql`${titles.backdropThumbHash} IS NULL`),
),
)
.all()
.map((row) => row.id);
}
export function getTitleIdsWithMissingSeasonThumbhashes() {
return db
.select({ titleId: seasons.titleId })
.from(seasons)
.where(and(isNotNull(seasons.posterPath), sql`${seasons.posterThumbHash} IS NULL`))
.groupBy(seasons.titleId)
.all()
.map((row) => row.titleId);
}
export function getTitleIdsWithMissingEpisodeThumbhashes() {
return db
.select({ titleId: seasons.titleId })
.from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(and(isNotNull(episodes.stillPath), sql`${episodes.stillThumbHash} IS NULL`))
.groupBy(seasons.titleId)
.all()
.map((row) => row.titleId);
}
export function getTitleIdsWithMissingProfileThumbhashes() {
return db
.select({ titleId: titleCast.titleId })
.from(titleCast)
.innerJoin(persons, eq(titleCast.personId, persons.id))
.where(and(isNotNull(persons.profilePath), sql`${persons.profileThumbHash} IS NULL`))
.groupBy(titleCast.titleId)
.all()
.map((row) => row.titleId);
}
export function getStaleTitles(titleIds: string[], staleDate: Date) {
if (titleIds.length === 0) return [];
return db