feat(backend): export playlists functionality

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-21 08:52:39 +02:00
1 parent 04d2800ab1
commit 1c1a53743a
10 files changed
+276 -185

No files matched your search

@@ -396,22 +396,18 @@ const electronApi = {
dbDeletePlaylist: (playlistId: string, operationId?: string) =>
ipcRenderer.invoke('DB_DELETE_PLAYLIST', playlistId, operationId),
dbDeleteXtreamContent: (playlistId: string, operationId?: string) =>
ipcRenderer.invoke(
'DB_DELETE_XTREAM_CONTENT',
playlistId,
operationId
),
ipcRenderer.invoke('DB_DELETE_XTREAM_CONTENT', playlistId, operationId),
dbRestoreXtreamUserData: (
playlistId: string,
favoritedXtreamIds: number[],
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[],
favorites: unknown[],
recentlyViewed: unknown[],
operationId?: string
) =>
ipcRenderer.invoke(
'DB_RESTORE_XTREAM_USER_DATA',
playlistId,
favoritedXtreamIds,
recentlyViewedXtreamIds,
favorites,
recentlyViewed,
operationId
),
dbHasCategories: (playlistId: string, type: string) =>
@@ -500,7 +496,8 @@ const electronApi = {
dbGetFavorites: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_FAVORITES', playlistId),
dbGetGlobalFavorites: () => ipcRenderer.invoke('DB_GET_GLOBAL_FAVORITES'),
dbGetAllGlobalFavorites: () => ipcRenderer.invoke('DB_GET_ALL_GLOBAL_FAVORITES'),
dbGetAllGlobalFavorites: () =>
ipcRenderer.invoke('DB_GET_ALL_GLOBAL_FAVORITES'),
dbReorderGlobalFavorites: (
updates: { content_id: number; position: number }[]
) => ipcRenderer.invoke('DB_REORDER_GLOBAL_FAVORITES', updates),
@@ -562,6 +559,8 @@ const electronApi = {
),
dbGetAllPlaybackPositions: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_ALL_PLAYBACK_POSITIONS', playlistId),
dbClearAllPlaybackPositions: (playlistId: string) =>
ipcRenderer.invoke('DB_CLEAR_ALL_PLAYBACK_POSITIONS', playlistId),
dbClearPlaybackPosition: (
playlistId: string,
contentXtreamId: number,
@@ -58,10 +58,7 @@ export async function isFavorite(
return result[0].count > 0;
}
export async function getFavorites(
db: AppDatabase,
playlistId: string
) {
export async function getFavorites(db: AppDatabase, playlistId: string) {
return db
.select({
id: schema.content.id,
@@ -73,6 +70,7 @@ export async function getFavorites(
xtream_id: schema.content.xtreamId,
type: schema.content.type,
added_at: schema.favorites.addedAt,
position: schema.favorites.position,
})
.from(schema.favorites)
.innerJoin(
@@ -80,7 +78,10 @@ export async function getFavorites(
eq(schema.favorites.contentId, schema.content.id)
)
.where(eq(schema.favorites.playlistId, playlistId))
.orderBy(desc(schema.favorites.addedAt));
.orderBy(
asc(schema.favorites.position),
desc(schema.favorites.addedAt)
);
}
export async function getGlobalFavorites(db: AppDatabase) {
@@ -2,12 +2,7 @@ import { and, desc, eq, sql } from 'drizzle-orm';
import * as schema from 'database-schema';
import type { AppDatabase } from '../database.types';
type PlaylistType =
| 'xtream'
| 'stalker'
| 'm3u-file'
| 'm3u-text'
| 'm3u-url';
type PlaylistType = 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
type PlaybackPositionPayload = {
contentXtreamId: number;
@@ -98,10 +93,7 @@ export async function getPlaybackPosition(
.where(
and(
eq(schema.playbackPositions.playlistId, playlistId),
eq(
schema.playbackPositions.contentXtreamId,
contentXtreamId
),
eq(schema.playbackPositions.contentXtreamId, contentXtreamId),
eq(schema.playbackPositions.contentType, contentType)
)
)
@@ -150,6 +142,17 @@ export async function getAllPlaybackPositions(
.where(eq(schema.playbackPositions.playlistId, playlistId));
}
export async function clearAllPlaybackPositions(
db: AppDatabase,
playlistId: string
): Promise<{ success: boolean }> {
await db
.delete(schema.playbackPositions)
.where(eq(schema.playbackPositions.playlistId, playlistId));
return { success: true };
}
export async function clearPlaybackPosition(
db: AppDatabase,
playlistId: string,
@@ -161,10 +164,7 @@ export async function clearPlaybackPosition(
.where(
and(
eq(schema.playbackPositions.playlistId, playlistId),
eq(
schema.playbackPositions.contentXtreamId,
contentXtreamId
),
eq(schema.playbackPositions.contentXtreamId, contentXtreamId),
eq(schema.playbackPositions.contentType, contentType)
)
);
@@ -1,5 +1,10 @@
import { and, eq, inArray } from 'drizzle-orm';
import * as schema from 'database-schema';
import type {
XtreamBackupFavoriteItem,
XtreamBackupHiddenCategory,
XtreamBackupRecentlyViewedItem,
} from 'shared-interfaces';
import type { AppDatabase } from '../database.types';
import {
checkpointOperation,
@@ -10,15 +15,28 @@ import {
const DEFAULT_BATCH_SIZE = 100;
type ContentIdentity = {
id: number;
xtreamId: number;
contentType: XtreamBackupFavoriteItem['contentType'];
};
function toContentIdentityKey(
contentType: XtreamBackupFavoriteItem['contentType'],
xtreamId: number
): string {
return `${contentType}:${xtreamId}`;
}
export async function deleteXtreamContent(
db: AppDatabase,
playlistId: string,
control?: OperationControl
): Promise<{
success: boolean;
favoritedXtreamIds: number[];
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[];
hiddenCategories: { xtreamId: number; type: string }[];
favorites: XtreamBackupFavoriteItem[];
recentlyViewed: XtreamBackupRecentlyViewedItem[];
hiddenCategories: XtreamBackupHiddenCategory[];
}> {
const categories = await db
.select({
@@ -35,16 +53,19 @@ export async function deleteXtreamContent(
.filter((category) => category.hidden)
.map((category) => ({
xtreamId: category.xtreamId,
type: category.type,
categoryType: category.type,
}));
let favoritedXtreamIds: number[] = [];
let recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[] = [];
let favorites: XtreamBackupFavoriteItem[] = [];
let recentlyViewed: XtreamBackupRecentlyViewedItem[] = [];
if (categoryIds.length > 0) {
const favorites = await db
const favoritedContent = await db
.select({
xtreamId: schema.content.xtreamId,
contentType: schema.content.type,
addedAt: schema.favorites.addedAt,
position: schema.favorites.position,
})
.from(schema.favorites)
.innerJoin(
@@ -58,11 +79,17 @@ export async function deleteXtreamContent(
)
);
favoritedXtreamIds = favorites.map((favorite) => favorite.xtreamId);
favorites = favoritedContent.map((favorite) => ({
xtreamId: favorite.xtreamId,
contentType: favorite.contentType,
addedAt: favorite.addedAt ?? undefined,
position: favorite.position,
}));
const recentlyViewed = await db
const recentlyViewedContent = await db
.select({
xtreamId: schema.content.xtreamId,
contentType: schema.content.type,
viewedAt: schema.recentlyViewed.viewedAt,
})
.from(schema.recentlyViewed)
@@ -77,8 +104,9 @@ export async function deleteXtreamContent(
)
);
recentlyViewedXtreamIds = recentlyViewed.map((item) => ({
recentlyViewed = recentlyViewedContent.map((item) => ({
xtreamId: item.xtreamId,
contentType: item.contentType,
viewedAt: item.viewedAt || new Date().toISOString(),
}));
@@ -127,119 +155,164 @@ export async function deleteXtreamContent(
return {
success: true,
favoritedXtreamIds,
recentlyViewedXtreamIds,
favorites,
recentlyViewed,
hiddenCategories,
};
}
async function getContentIdentityMap(
db: AppDatabase,
playlistId: string,
identities: Array<{
contentType: XtreamBackupFavoriteItem['contentType'];
xtreamId: number;
}>
): Promise<Map<string, number>> {
const xtreamIds = Array.from(
new Set(identities.map((item) => item.xtreamId))
);
if (xtreamIds.length === 0) {
return new Map();
}
const content = await db
.select({
id: schema.content.id,
xtreamId: schema.content.xtreamId,
contentType: schema.content.type,
})
.from(schema.content)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.where(
and(
eq(schema.categories.playlistId, playlistId),
inArray(schema.content.xtreamId, xtreamIds)
)
);
return new Map(
content.map((item: ContentIdentity) => [
toContentIdentityKey(item.contentType, item.xtreamId),
item.id,
])
);
}
export async function restoreXtreamUserData(
db: AppDatabase,
playlistId: string,
favoritedXtreamIds: number[],
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[],
favorites: XtreamBackupFavoriteItem[],
recentlyViewed: XtreamBackupRecentlyViewedItem[],
control?: OperationControl
): Promise<{ success: boolean }> {
if (favoritedXtreamIds.length > 0) {
const content = await db
.select({
id: schema.content.id,
xtreamId: schema.content.xtreamId,
})
.from(schema.content)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.where(
and(
eq(schema.categories.playlistId, playlistId),
inArray(schema.content.xtreamId, favoritedXtreamIds)
)
await checkpointOperation(control);
await db
.delete(schema.favorites)
.where(eq(schema.favorites.playlistId, playlistId));
await checkpointOperation(control);
await db
.delete(schema.recentlyViewed)
.where(eq(schema.recentlyViewed.playlistId, playlistId));
const contentByIdentity = await getContentIdentityMap(db, playlistId, [
...favorites.map((item) => ({
contentType: item.contentType,
xtreamId: item.xtreamId,
})),
...recentlyViewed.map((item) => ({
contentType: item.contentType,
xtreamId: item.xtreamId,
})),
]);
const favoriteValues = favorites
.map((item, index) => {
const contentId = contentByIdentity.get(
toContentIdentityKey(item.contentType, item.xtreamId)
);
let currentFavorites = 0;
const totalFavorites = content.length;
if (!contentId) {
return null;
}
for (const chunk of chunkValues(content, DEFAULT_BATCH_SIZE)) {
await checkpointOperation(control);
await db.insert(schema.favorites).values(
chunk.map((item) => ({
contentId: item.id,
playlistId,
addedAt: new Date().toISOString(),
}))
);
currentFavorites += chunk.length;
await reportOperationProgress(control, {
phase: 'restoring-favorites',
current: currentFavorites,
total: totalFavorites,
increment: chunk.length,
});
}
}
if (recentlyViewedXtreamIds.length > 0) {
const xtreamIds = recentlyViewedXtreamIds.map((item) => item.xtreamId);
const content = await db
.select({
id: schema.content.id,
xtreamId: schema.content.xtreamId,
})
.from(schema.content)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.where(
and(
eq(schema.categories.playlistId, playlistId),
inArray(schema.content.xtreamId, xtreamIds)
)
);
const xtreamIdToContentId = new Map(
content.map((item) => [item.xtreamId, item.id])
return {
contentId,
playlistId,
addedAt: item.addedAt ?? new Date().toISOString(),
position: item.position ?? index,
};
})
.filter(
(
value
): value is {
contentId: number;
playlistId: string;
addedAt: string;
position: number | null;
} => value !== null
);
const values = recentlyViewedXtreamIds
.map((item) => {
const contentId = xtreamIdToContentId.get(item.xtreamId);
if (!contentId) {
return null;
}
let restoredFavorites = 0;
const totalFavorites = favoriteValues.length;
return {
contentId,
playlistId,
viewedAt: item.viewedAt,
};
})
.filter(
(
value
): value is {
contentId: number;
playlistId: string;
viewedAt: string;
} => value !== null
for (const chunk of chunkValues(favoriteValues, DEFAULT_BATCH_SIZE)) {
await checkpointOperation(control);
await db.insert(schema.favorites).values(chunk);
restoredFavorites += chunk.length;
await reportOperationProgress(control, {
phase: 'restoring-favorites',
current: restoredFavorites,
total: totalFavorites,
increment: chunk.length,
});
}
const recentlyViewedValues = recentlyViewed
.map((item) => {
const contentId = contentByIdentity.get(
toContentIdentityKey(item.contentType, item.xtreamId)
);
let currentRecentlyViewed = 0;
const totalRecentlyViewed = values.length;
if (!contentId) {
return null;
}
for (const chunk of chunkValues(values, DEFAULT_BATCH_SIZE)) {
await checkpointOperation(control);
await db.insert(schema.recentlyViewed).values(chunk);
currentRecentlyViewed += chunk.length;
await reportOperationProgress(control, {
phase: 'restoring-recently-viewed',
current: currentRecentlyViewed,
total: totalRecentlyViewed,
increment: chunk.length,
});
}
return {
contentId,
playlistId,
viewedAt: item.viewedAt,
};
})
.filter(
(
value
): value is {
contentId: number;
playlistId: string;
viewedAt: string;
} => value !== null
);
let restoredRecentlyViewed = 0;
const totalRecentlyViewed = recentlyViewedValues.length;
for (const chunk of chunkValues(recentlyViewedValues, DEFAULT_BATCH_SIZE)) {
await checkpointOperation(control);
await db.insert(schema.recentlyViewed).values(chunk);
restoredRecentlyViewed += chunk.length;
await reportOperationProgress(control, {
phase: 'restoring-recently-viewed',
current: restoredRecentlyViewed,
total: totalRecentlyViewed,
increment: chunk.length,
});
}
return { success: true };
@@ -42,8 +42,12 @@ handleWorkerRequest(
})
);
handleWorkerRequest('DB_GET_ALL_PLAYBACK_POSITIONS', (playlistId: string) => ({
playlistId,
}));
handleWorkerRequest(
'DB_GET_ALL_PLAYBACK_POSITIONS',
'DB_CLEAR_ALL_PLAYBACK_POSITIONS',
(playlistId: string) => ({
playlistId,
})
@@ -30,8 +30,8 @@ ipcMain.handle(
async (
event,
playlistId: string,
favoritedXtreamIds: number[],
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[],
favorites: unknown[],
recentlyViewed: unknown[],
operationId?: string
) => {
try {
@@ -40,8 +40,8 @@ ipcMain.handle(
'DB_RESTORE_XTREAM_USER_DATA',
{
playlistId,
favoritedXtreamIds,
recentlyViewedXtreamIds,
favorites,
recentlyViewed,
operationId,
}
);
@@ -43,6 +43,7 @@ export const DB_WORKER_OPERATIONS = [
'DB_GET_SERIES_PLAYBACK_POSITIONS',
'DB_GET_RECENT_PLAYBACK_POSITIONS',
'DB_GET_ALL_PLAYBACK_POSITIONS',
'DB_CLEAR_ALL_PLAYBACK_POSITIONS',
'DB_CLEAR_PLAYBACK_POSITION',
] as const;
@@ -3,6 +3,10 @@ import {
getWorkerDatabase,
} from './database.worker-connection';
import { parentPort } from 'worker_threads';
import type {
XtreamBackupFavoriteItem,
XtreamBackupRecentlyViewedItem,
} from 'shared-interfaces';
import type {
DbOperationEvent,
DbWorkerIncomingMessage,
@@ -40,6 +44,7 @@ import {
searchContent,
} from '../database/operations/content.operations';
import {
clearAllPlaybackPositions,
clearPlaybackPosition,
getAllPlaybackPositions,
getPlaybackPosition,
@@ -167,9 +172,7 @@ function createOperationController(config: {
const { operationId, operation, playlistId, requestId } = config;
const cancellable = config.cancellable ?? true;
const activeState =
operationId && cancellable
? { cancelled: false }
: null;
operationId && cancellable ? { cancelled: false } : null;
if (operationId && activeState) {
activeOperations.set(operationId, activeState);
@@ -232,8 +235,7 @@ function createOperationController(config: {
emitError: (error, event) => {
send('error', {
...event,
error:
error instanceof Error ? error.message : String(error),
error: error instanceof Error ? error.message : String(error),
});
},
cleanup: () => {
@@ -614,8 +616,8 @@ async function executeRequest(message: DbWorkerRequestMessage) {
case 'DB_RESTORE_XTREAM_USER_DATA': {
const payload = message.payload as {
playlistId: string;
favoritedXtreamIds: number[];
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[];
favorites: XtreamBackupFavoriteItem[];
recentlyViewed: XtreamBackupRecentlyViewedItem[];
operationId?: string;
};
@@ -628,8 +630,8 @@ async function executeRequest(message: DbWorkerRequestMessage) {
},
async (controller) => {
const totalItems =
payload.favoritedXtreamIds.length +
payload.recentlyViewedXtreamIds.length;
payload.favorites.length +
payload.recentlyViewed.length;
controller.emitStarted({
phase: DB_OPERATION_PHASES.RESTORING_FAVORITES,
current: 0,
@@ -639,8 +641,8 @@ async function executeRequest(message: DbWorkerRequestMessage) {
const result = await restoreXtreamUserData(
db,
payload.playlistId,
payload.favoritedXtreamIds,
payload.recentlyViewedXtreamIds,
payload.favorites,
payload.recentlyViewed,
controller.control
);
@@ -740,7 +742,12 @@ async function executeRequest(message: DbWorkerRequestMessage) {
episodeNumber?: number;
positionSeconds: number;
durationSeconds?: number;
playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
playlistType?:
| 'xtream'
| 'stalker'
| 'm3u-file'
| 'm3u-text'
| 'm3u-url';
};
};
return savePlaybackPosition(db, payload.playlistId, payload.data);
@@ -789,6 +796,11 @@ async function executeRequest(message: DbWorkerRequestMessage) {
return getAllPlaybackPositions(db, payload.playlistId);
}
case 'DB_CLEAR_ALL_PLAYBACK_POSITIONS': {
const payload = message.payload as { playlistId: string };
return clearAllPlaybackPositions(db, payload.playlistId);
}
case 'DB_CLEAR_PLAYBACK_POSITION': {
const payload = message.payload as {
playlistId: string;
@@ -823,7 +835,11 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => {
result,
});
} catch (error) {
console.error(loggerLabel, `Error handling ${message.operation}:`, error);
console.error(
loggerLabel,
`Error handling ${message.operation}:`,
error
);
postMessage({
type: 'response',
requestId: message.requestId,
+12 -17
View File
@@ -14,6 +14,9 @@ import {
PlaylistRefreshEvent,
PlaylistRefreshPayload,
PortalDebugEvent,
XtreamBackupFavoriteItem,
XtreamBackupHiddenCategory,
XtreamBackupRecentlyViewedItem,
XtreamCategory,
} from 'shared-interfaces';
import {
@@ -81,9 +84,7 @@ declare global {
headers?: Record<string, string>
) => Promise<ExternalPlayerSession>;
autoUpdatePlaylists: (playlists: Playlist[]) => Promise<Playlist[]>;
fetchEpg: (
urls: string[]
) => Promise<{
fetchEpg: (urls: string[]) => Promise<{
success: boolean;
message?: string;
skipped?: string[];
@@ -171,23 +172,14 @@ declare global {
operationId?: string
) => Promise<{
success: boolean;
favoritedXtreamIds: number[];
recentlyViewedXtreamIds: {
xtreamId: number;
viewedAt: string;
}[];
hiddenCategories: {
xtreamId: number;
type: string;
}[];
favorites: XtreamBackupFavoriteItem[];
recentlyViewed: XtreamBackupRecentlyViewedItem[];
hiddenCategories: XtreamBackupHiddenCategory[];
}>;
dbRestoreXtreamUserData: (
playlistId: string,
favoritedXtreamIds: number[],
recentlyViewedXtreamIds: {
xtreamId: number;
viewedAt: string;
}[],
favorites: XtreamBackupFavoriteItem[],
recentlyViewed: XtreamBackupRecentlyViewedItem[],
operationId?: string
) => Promise<{ success: boolean }>;
dbHasCategories: (
@@ -381,6 +373,9 @@ declare global {
dbGetAllPlaybackPositions: (
playlistId: string
) => Promise<PlaybackPositionData[]>;
dbClearAllPlaybackPositions: (
playlistId: string
) => Promise<{ success: boolean }>;
dbClearPlaybackPosition: (
playlistId: string,
contentXtreamId: number,
Vendored
+18 -16
View File
@@ -1,5 +1,11 @@
import 'jest-extended';
import { ExternalPlayerSession } from './libs/shared/interfaces/src/lib/external-player-session.interface';
import { PlaybackPositionData } from './libs/shared/interfaces/src/lib/playback-position.interface';
import {
XtreamBackupFavoriteItem,
XtreamBackupHiddenCategory,
XtreamBackupRecentlyViewedItem,
} from './libs/shared/interfaces/src/lib/playlist-backup.interface';
import {
PlaylistRefreshEvent,
PlaylistRefreshPayload,
@@ -156,23 +162,14 @@ declare global {
operationId?: string
) => Promise<{
success: boolean;
favoritedXtreamIds: number[];
recentlyViewedXtreamIds: {
xtreamId: number;
viewedAt: string;
}[];
hiddenCategories: {
xtreamId: number;
type: string;
}[];
favorites: XtreamBackupFavoriteItem[];
recentlyViewed: XtreamBackupRecentlyViewedItem[];
hiddenCategories: XtreamBackupHiddenCategory[];
}>;
dbRestoreXtreamUserData: (
playlistId: string,
favoritedXtreamIds: number[],
recentlyViewedXtreamIds: {
xtreamId: number;
viewedAt: string;
}[],
favorites: XtreamBackupFavoriteItem[],
recentlyViewed: XtreamBackupRecentlyViewedItem[],
operationId?: string
) => Promise<{ success: boolean }>;
dbHasCategories: (
@@ -346,8 +343,13 @@ declare global {
dbGetRecentPlaybackPositions: (
playlistId: string,
limit?: number
) => Promise<any[]>;
dbGetAllPlaybackPositions: (playlistId: string) => Promise<any[]>;
) => Promise<PlaybackPositionData[]>;
dbGetAllPlaybackPositions: (
playlistId: string
) => Promise<PlaybackPositionData[]>;
dbClearAllPlaybackPositions: (
playlistId: string
) => Promise<{ success: boolean }>;
dbClearPlaybackPosition: (
playlistId: string,
contentXtreamId: number,