feat: implement content backdrop management and related database operations

This commit is contained in:
4gray committed 2026-04-22 22:22:55 +02:00
1 parent 8d6a387a9f
commit 92cbd44e89
11 files changed
+224 -18

No files matched your search

@@ -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
);
});
});
@@ -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<void> {
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 };
}
@@ -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<number>`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);
}
@@ -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,
@@ -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 };
}
@@ -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',
(
@@ -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,
})
);
@@ -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,
})
);
@@ -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',
@@ -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': {
+8 -2
View File
@@ -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<XtreamContent[]>;
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<XtreamContent | null>;
dbSetContentBackdropIfMissing: (
contentId: number,
backdropUrl?: string
) => Promise<{ success: boolean }>;
dbGetAppState: (key: string) => Promise<string | null>;
dbSetAppState: (
key: string,