mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
feat: implement content backdrop management and related database operations
This commit is contained in:
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': {
|
||||
|
||||
Vendored
+8
-2
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user