From 92cbd44e8926715885a775e029f6c4eec55cb8e6 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 22 Apr 2026 22:22:55 +0200 Subject: [PATCH] feat: implement content backdrop management and related database operations --- .../content-backdrop.operations.spec.ts | 93 +++++++++++++++++++ .../operations/content-backdrop.operations.ts | 33 +++++++ .../database/operations/content.operations.ts | 28 ++++-- .../operations/favorites.operations.ts | 7 +- .../operations/recently-viewed.operations.ts | 8 +- .../src/app/events/database/content.events.ts | 20 +++- .../app/events/database/favorites.events.ts | 3 +- .../events/database/recently-viewed.events.ts | 3 +- .../src/app/workers/database-worker.types.ts | 1 + .../src/app/workers/database.worker.ts | 36 ++++++- apps/web/src/typings.d.ts | 10 +- 11 files changed, 224 insertions(+), 18 deletions(-) create mode 100644 apps/electron-backend/src/app/database/operations/content-backdrop.operations.spec.ts create mode 100644 apps/electron-backend/src/app/database/operations/content-backdrop.operations.ts diff --git a/apps/electron-backend/src/app/database/operations/content-backdrop.operations.spec.ts b/apps/electron-backend/src/app/database/operations/content-backdrop.operations.spec.ts new file mode 100644 index 000000000..b7ba14c5d --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/content-backdrop.operations.spec.ts @@ -0,0 +1,93 @@ +const andMock = jest.fn((...conditions: unknown[]) => ({ + kind: 'and', + conditions, +})); +const eqMock = jest.fn((left: unknown, right: unknown) => ({ + kind: 'eq', + left, + right, +})); +const sqlMock = jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ + kind: 'sql', + strings, + values, +})); + +jest.mock('drizzle-orm', () => ({ + and: (...conditions: unknown[]) => andMock(...conditions), + eq: (left: unknown, right: unknown) => eqMock(left, right), + sql: (strings: TemplateStringsArray, ...values: unknown[]) => + sqlMock(strings, ...values), +})); + +import * as schema from 'database-schema'; +import type { AppDatabase } from '../database.types'; +import { setContentBackdropIfMissing } from './content-backdrop.operations'; + +function createDbMock() { + const where = jest.fn().mockResolvedValue(undefined); + const set = jest.fn().mockReturnValue({ where }); + const update = jest.fn().mockReturnValue({ set }); + + return { + db: { + update, + } as unknown as AppDatabase, + set, + update, + where, + }; +} + +describe('content-backdrop.operations', () => { + beforeEach(() => { + andMock.mockClear(); + eqMock.mockClear(); + sqlMock.mockClear(); + }); + + it('populates the content backdrop when a non-empty url is provided', async () => { + const { db, set, update, where } = createDbMock(); + + await expect( + setContentBackdropIfMissing( + db, + 42, + ' https://example.com/backdrop.jpg ' + ) + ).resolves.toEqual({ success: true }); + + expect(update).toHaveBeenCalledWith(schema.content); + expect(set).toHaveBeenCalledWith({ + backdropUrl: 'https://example.com/backdrop.jpg', + }); + expect(eqMock).toHaveBeenCalledWith(schema.content.id, 42); + expect(where.mock.calls[0][0].conditions).toHaveLength(2); + }); + + it('skips blank urls without touching content or recency tables', async () => { + const { db, update } = createDbMock(); + + await expect( + setContentBackdropIfMissing(db, 42, ' ') + ).resolves.toEqual({ success: true }); + + expect(update).not.toHaveBeenCalled(); + }); + + it('never updates recently viewed timestamps for backdrop-only backfill', async () => { + const { db, update } = createDbMock(); + + await setContentBackdropIfMissing( + db, + 42, + 'https://example.com/backdrop.jpg' + ); + + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith(schema.content); + expect(update).not.toHaveBeenCalledWith( + schema.recentlyViewed as unknown as never + ); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/content-backdrop.operations.ts b/apps/electron-backend/src/app/database/operations/content-backdrop.operations.ts new file mode 100644 index 000000000..c3ccc56fa --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/content-backdrop.operations.ts @@ -0,0 +1,33 @@ +import { and, eq, sql } from 'drizzle-orm'; +import * as schema from 'database-schema'; +import type { AppDatabase } from '../database.types'; + +export async function persistContentBackdropIfMissing( + db: AppDatabase, + contentId: number, + backdropUrl?: string +): Promise { + const normalizedBackdropUrl = backdropUrl?.trim(); + if (!normalizedBackdropUrl) { + return; + } + + await db + .update(schema.content) + .set({ backdropUrl: normalizedBackdropUrl }) + .where( + and( + eq(schema.content.id, contentId), + sql`(${schema.content.backdropUrl} IS NULL OR ${schema.content.backdropUrl} = '')` + ) + ); +} + +export async function setContentBackdropIfMissing( + db: AppDatabase, + contentId: number, + backdropUrl?: string +): Promise<{ success: boolean }> { + await persistContentBackdropIfMissing(db, contentId, backdropUrl); + return { success: true }; +} diff --git a/apps/electron-backend/src/app/database/operations/content.operations.ts b/apps/electron-backend/src/app/database/operations/content.operations.ts index 111ba3804..ed38fecf9 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -107,10 +107,18 @@ export async function getContent( : baseQuery.orderBy(desc(schema.content.added)); } +export type RecentlyAddedPlaylistType = + | 'xtream' + | 'stalker' + | 'm3u-file' + | 'm3u-text' + | 'm3u-url'; + export async function getGlobalRecentlyAdded( db: AppDatabase, kind: GlobalRecentlyAddedKind = 'all', - limit = 200 + limit = 200, + playlistType?: RecentlyAddedPlaylistType ) { const contentTypes = getRecentlyAddedContentTypes(kind); const normalizedLimit = Number.isFinite(limit) @@ -118,6 +126,16 @@ export async function getGlobalRecentlyAdded( : 200; const addedOrder = sql`CAST(${schema.content.added} AS INTEGER)`; + const whereConditions = [ + inArray(schema.content.type, contentTypes), + eq(schema.categories.hidden, false), + sql`${schema.content.added} <> ''`, + ]; + + if (playlistType) { + whereConditions.push(eq(schema.playlists.type, playlistType)); + } + return db .select({ ...selectContentFields(), @@ -134,13 +152,7 @@ export async function getGlobalRecentlyAdded( schema.playlists, eq(schema.categories.playlistId, schema.playlists.id) ) - .where( - and( - inArray(schema.content.type, contentTypes), - eq(schema.categories.hidden, false), - sql`${schema.content.added} <> ''` - ) - ) + .where(and(...whereConditions)) .orderBy(desc(addedOrder)) .limit(normalizedLimit); } diff --git a/apps/electron-backend/src/app/database/operations/favorites.operations.ts b/apps/electron-backend/src/app/database/operations/favorites.operations.ts index ac0ba974d..fb0ab1f90 100644 --- a/apps/electron-backend/src/app/database/operations/favorites.operations.ts +++ b/apps/electron-backend/src/app/database/operations/favorites.operations.ts @@ -7,19 +7,23 @@ import { type OperationControl, reportOperationProgress, } from './operation-control'; +import { persistContentBackdropIfMissing } from './content-backdrop.operations'; const DEFAULT_BATCH_SIZE = 100; export async function addFavorite( db: AppDatabase, contentId: number, - playlistId: string + playlistId: string, + options?: { backdropUrl?: string } ): Promise<{ success: boolean }> { await db.insert(schema.favorites).values({ contentId, playlistId, }); + await persistContentBackdropIfMissing(db, contentId, options?.backdropUrl); + return { success: true }; } @@ -127,6 +131,7 @@ export async function getAllGlobalFavorites(db: AppDatabase) { rating: schema.content.rating, added: schema.content.added, poster_url: schema.content.posterUrl, + backdrop_url: schema.content.backdropUrl, xtream_id: schema.content.xtreamId, type: schema.content.type, playlist_id: schema.playlists.id, diff --git a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts index 919281783..854153ca6 100644 --- a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts +++ b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts @@ -1,6 +1,7 @@ import { and, desc, eq, inArray, sql } from 'drizzle-orm'; import * as schema from 'database-schema'; import type { AppDatabase } from '../database.types'; +import { persistContentBackdropIfMissing } from './content-backdrop.operations'; export async function getRecentlyViewed(db: AppDatabase) { return db @@ -11,6 +12,7 @@ export async function getRecentlyViewed(db: AppDatabase) { rating: schema.content.rating, added: schema.content.added, poster_url: schema.content.posterUrl, + backdrop_url: schema.content.backdropUrl, xtream_id: schema.content.xtreamId, type: schema.content.type, playlist_id: schema.categories.playlistId, @@ -53,6 +55,7 @@ export async function getRecentItems( rating: schema.content.rating, added: schema.content.added, poster_url: schema.content.posterUrl, + backdrop_url: schema.content.backdropUrl, xtream_id: schema.content.xtreamId, type: schema.content.type, viewed_at: schema.recentlyViewed.viewedAt, @@ -70,7 +73,8 @@ export async function getRecentItems( export async function addRecentItem( db: AppDatabase, contentId: number, - playlistId: string + playlistId: string, + options?: { backdropUrl?: string } ): Promise<{ success: boolean }> { const existing = await db .select() @@ -100,6 +104,8 @@ export async function addRecentItem( }); } + await persistContentBackdropIfMissing(db, contentId, options?.backdropUrl); + return { success: true }; } diff --git a/apps/electron-backend/src/app/events/database/content.events.ts b/apps/electron-backend/src/app/events/database/content.events.ts index 0bdea56bd..5fff69473 100644 --- a/apps/electron-backend/src/app/events/database/content.events.ts +++ b/apps/electron-backend/src/app/events/database/content.events.ts @@ -27,9 +27,19 @@ handleWorkerRequest( handleWorkerRequest( 'DB_GET_GLOBAL_RECENTLY_ADDED', - (kind: 'all' | 'vod' | 'series' = 'all', limit = 200) => ({ + ( + kind: 'all' | 'vod' | 'series' = 'all', + limit = 200, + playlistType?: + | 'xtream' + | 'stalker' + | 'm3u-file' + | 'm3u-text' + | 'm3u-url' + ) => ({ kind, limit, + playlistType, }) ); @@ -81,6 +91,14 @@ handleWorkerRequest( }) ); +handleWorkerRequest( + 'DB_SET_CONTENT_BACKDROP_IF_MISSING', + (contentId: number, backdropUrl?: string) => ({ + contentId, + backdropUrl, + }) +); + handleWorkerRequest( 'DB_SEARCH_CONTENT', ( diff --git a/apps/electron-backend/src/app/events/database/favorites.events.ts b/apps/electron-backend/src/app/events/database/favorites.events.ts index 5ae140fea..a0c77ae28 100644 --- a/apps/electron-backend/src/app/events/database/favorites.events.ts +++ b/apps/electron-backend/src/app/events/database/favorites.events.ts @@ -7,9 +7,10 @@ import { handleWorkerRequest } from './worker-events.utils'; handleWorkerRequest( 'DB_ADD_FAVORITE', - (contentId: number, playlistId: string) => ({ + (contentId: number, playlistId: string, backdropUrl?: string) => ({ contentId, playlistId, + backdropUrl, }) ); diff --git a/apps/electron-backend/src/app/events/database/recently-viewed.events.ts b/apps/electron-backend/src/app/events/database/recently-viewed.events.ts index 2d9a14390..9a871f2f4 100644 --- a/apps/electron-backend/src/app/events/database/recently-viewed.events.ts +++ b/apps/electron-backend/src/app/events/database/recently-viewed.events.ts @@ -14,9 +14,10 @@ handleWorkerRequest('DB_GET_RECENT_ITEMS', (playlistId: string) => ({ handleWorkerRequest( 'DB_ADD_RECENT_ITEM', - (contentId: number, playlistId: string) => ({ + (contentId: number, playlistId: string, backdropUrl?: string) => ({ contentId, playlistId, + backdropUrl, }) ); diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index c3d9d7aaa..f8fe3a9f3 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -10,6 +10,7 @@ export const DB_WORKER_OPERATIONS = [ 'DB_SAVE_CONTENT', 'DB_CLEAR_XTREAM_IMPORT_CACHE', 'DB_GET_CONTENT_BY_XTREAM_ID', + 'DB_SET_CONTENT_BACKDROP_IF_MISSING', 'DB_SEARCH_CONTENT', 'DB_GLOBAL_SEARCH', 'DB_CREATE_PLAYLIST', diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 2f42eb8a5..c5b53c84b 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -43,6 +43,7 @@ import { saveContent, searchContent, } from '../database/operations/content.operations'; +import { setContentBackdropIfMissing } from '../database/operations/content-backdrop.operations'; import { clearAllPlaybackPositions, clearPlaybackPosition, @@ -345,8 +346,19 @@ async function executeRequest(message: DbWorkerRequestMessage) { const payload = message.payload as { kind?: 'all' | 'vod' | 'series'; limit?: number; + playlistType?: + | 'xtream' + | 'stalker' + | 'm3u-file' + | 'm3u-text' + | 'm3u-url'; }; - return getGlobalRecentlyAdded(db, payload.kind, payload.limit); + return getGlobalRecentlyAdded( + db, + payload.kind, + payload.limit, + payload.playlistType + ); } case 'DB_SAVE_CONTENT': { @@ -413,6 +425,18 @@ async function executeRequest(message: DbWorkerRequestMessage) { ); } + case 'DB_SET_CONTENT_BACKDROP_IF_MISSING': { + const payload = message.payload as { + contentId: number; + backdropUrl?: string; + }; + return setContentBackdropIfMissing( + db, + payload.contentId, + payload.backdropUrl + ); + } + case 'DB_SEARCH_CONTENT': { const payload = message.payload as { playlistId: string; @@ -661,8 +685,11 @@ async function executeRequest(message: DbWorkerRequestMessage) { const payload = message.payload as { contentId: number; playlistId: string; + backdropUrl?: string; }; - return addFavorite(db, payload.contentId, payload.playlistId); + return addFavorite(db, payload.contentId, payload.playlistId, { + backdropUrl: payload.backdropUrl, + }); } case 'DB_REMOVE_FAVORITE': { @@ -714,8 +741,11 @@ async function executeRequest(message: DbWorkerRequestMessage) { const payload = message.payload as { contentId: number; playlistId: string; + backdropUrl?: string; }; - return addRecentItem(db, payload.contentId, payload.playlistId); + return addRecentItem(db, payload.contentId, payload.playlistId, { + backdropUrl: payload.backdropUrl, + }); } case 'DB_CLEAR_PLAYLIST_RECENT_ITEMS': { diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index 884ff303c..5c84ec15e 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -242,7 +242,8 @@ declare global { // Favorites dbAddFavorite: ( contentId: number, - playlistId: string + playlistId: string, + backdropUrl?: string ) => Promise<{ success: boolean }>; dbRemoveFavorite: ( contentId: number, @@ -262,7 +263,8 @@ declare global { dbGetRecentItems: (playlistId: string) => Promise; dbAddRecentItem: ( contentId: number, - playlistId: string + playlistId: string, + backdropUrl?: string ) => Promise<{ success: boolean }>; dbClearPlaylistRecentItems: ( playlistId: string @@ -276,6 +278,10 @@ declare global { playlistId: string, contentType?: 'live' | 'movie' | 'series' ) => Promise; + dbSetContentBackdropIfMissing: ( + contentId: number, + backdropUrl?: string + ) => Promise<{ success: boolean }>; dbGetAppState: (key: string) => Promise; dbSetAppState: ( key: string,