mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
fix(xtream): preserve favorites across catalog refreshes
This commit is contained in:
1 parent
ec4b9286a7
commit
8ccfcb68cc
121 files changed
+4000
-398
No files matched your search
@@ -46,13 +46,30 @@ root is resolved in this order:
|
||||
Users may skip releases. The application must apply every required migration in
|
||||
dependency order when opening an older database, preserving user data without
|
||||
requiring intermediate application installations or a database reset. This is
|
||||
the repository policy mirrored in `AGENTS.md` and `CLAUDE.md`.
|
||||
the repository policy in `AGENTS.md`, which `CLAUDE.md` imports.
|
||||
|
||||
`src/lib/connection.ts` owns initialization: `createTables()` creates missing
|
||||
schema objects, then `runMigrations()` applies column/index migrations and
|
||||
dedicated schema/data upgrades. One-off data migrations can record completion
|
||||
in `app_state`. Keep existing migration paths when adding new ones.
|
||||
|
||||
Xtream favorites are migrated by `src/lib/favorites-table.ts` before legacy
|
||||
content/category deduplication. The transactional table rebuild backfills typed
|
||||
provider identity and saved display metadata while retaining favorite IDs,
|
||||
dates and positions. Duplicate favorites of the same playlist/type/provider ID
|
||||
collapse to the earliest favorite record. `content_id` becomes nullable with
|
||||
`ON DELETE SET NULL`; owning playlist deletion still cascades. The legacy
|
||||
content deduplicator updates references without replacing favorite IDs.
|
||||
Favorites whose owning playlist was already missing remain stored. The rebuild
|
||||
temporarily disables foreign-key enforcement outside its transaction, permits
|
||||
only those pre-existing playlist violations by favorite ID, and rolls back if
|
||||
it introduces another violation. The original enforcement setting is restored
|
||||
on both success and failure.
|
||||
Historical upgrade tests include tagged 0.19–0.24 schemas, duplicate categories,
|
||||
orphaned favorites, repeated startup and forced rollbacks. Keep snapshot
|
||||
migration before content deduplication so the originally favorited row's
|
||||
metadata survives.
|
||||
|
||||
When changing initialization:
|
||||
|
||||
- Create required tables before migrating them. `CREATE TABLE IF NOT EXISTS`
|
||||
@@ -102,7 +119,7 @@ playlists) are guarded in `runMigrations()`:
|
||||
The #1580 index-ordering fix is included in 0.24 through PR #1550.
|
||||
`src/lib/connection-upgrades.spec.ts` exercises `initDatabase()` with real
|
||||
SQLite under the Electron runtime, using fresh-install schema snapshots from
|
||||
tags 0.19–0.23 and a fresh current database. The `epg_channel_id` column is absent
|
||||
tags 0.19–0.24 and a fresh current database. The `epg_channel_id` column is absent
|
||||
in 0.19, present from 0.20, and indexed from 0.23. Each case checks current Drizzle
|
||||
tables, columns/types, and named indexes/uniqueness, as well as preserved user
|
||||
rows, foreign keys, database integrity, index availability, and repeated startup;
|
||||
@@ -115,3 +132,6 @@ the index ahead of its column migration makes the 0.19 case fail again.
|
||||
Run this coverage with `pnpm nx test database --runInBand`. The Electron UI and
|
||||
worker upgrade flow is also covered by
|
||||
`pnpm nx run electron-backend-e2e:e2e-ci--src/legacy-playlist-migration.e2e.ts`.
|
||||
For an orphaned 0.24 favorites table, initial startup and restart through the
|
||||
real Electron UI and worker are covered by
|
||||
`pnpm nx run electron-backend-e2e:e2e-ci--src/favorites-legacy.e2e.ts`.
|
||||
@@ -40,7 +40,11 @@ function createSqliteMock(
|
||||
const rule = rules.find(([pattern]) => compact.includes(pattern));
|
||||
|
||||
return {
|
||||
all: jest.fn(() => []),
|
||||
all: jest.fn(() =>
|
||||
compact === "PRAGMA table_info('favorites')"
|
||||
? [{ name: 'xtream_id' }]
|
||||
: []
|
||||
),
|
||||
get: jest.fn(),
|
||||
run: jest.fn(),
|
||||
...(rule?.[1] ?? {}),
|
||||
@@ -299,7 +303,7 @@ describe('runMigrations Xtream cache deduplication', () => {
|
||||
'SELECT id FROM content WHERE category_id = ?',
|
||||
{ all: () => [{ id: 10 }, { id: 11 }] },
|
||||
],
|
||||
['INSERT INTO favorites', { run: moveFavoritesRun }],
|
||||
['UPDATE OR IGNORE favorites', { run: moveFavoritesRun }],
|
||||
[
|
||||
'DELETE FROM favorites WHERE content_id = ?',
|
||||
{ run: deleteFavoritesRun },
|
||||
|
||||
@@ -5,7 +5,19 @@ import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
describe('database initialization across skipped releases (#1580)', () => {
|
||||
it.each(['0.19.0', '0.20.0', '0.21.0', '0.22.0', '0.23.0', 'fresh'])(
|
||||
it.each([
|
||||
'0.19.0',
|
||||
'0.20.0',
|
||||
'0.21.0',
|
||||
'0.22.0',
|
||||
'0.23.0',
|
||||
'0.24.0',
|
||||
'0.19.0-duplicates',
|
||||
'0.19.0-orphans',
|
||||
'0.24.0-orphans',
|
||||
'fresh',
|
||||
'rollback',
|
||||
])(
|
||||
'preserves %s data and indexes across upgrade and repeated startup',
|
||||
(version) => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'iptvnator-upgrade-'));
|
||||
@@ -13,12 +25,12 @@ describe('database initialization across skipped releases (#1580)', () => {
|
||||
'electron'
|
||||
) as string;
|
||||
const fixture =
|
||||
version === 'fresh'
|
||||
? 'fresh'
|
||||
version === 'fresh' || version === 'rollback'
|
||||
? version
|
||||
: resolve(
|
||||
__dirname,
|
||||
'testing/fixtures',
|
||||
`v${version}-schema.sql`
|
||||
`v${version.replace(/-(duplicates|orphans)$/, '')}-schema.sql`
|
||||
);
|
||||
try {
|
||||
const output = execFileSync(
|
||||
@@ -28,6 +40,11 @@ describe('database initialization across skipped releases (#1580)', () => {
|
||||
'tsx',
|
||||
resolve(__dirname, 'testing/connection-upgrade.ts'),
|
||||
fixture,
|
||||
version.endsWith('-duplicates')
|
||||
? 'deduplicate'
|
||||
: version.endsWith('-orphans')
|
||||
? 'orphans'
|
||||
: '',
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
|
||||
@@ -486,6 +486,9 @@ describe('database schema statements', () => {
|
||||
).runMigrations;
|
||||
const sqlite = {
|
||||
exec: jest.fn(),
|
||||
pragma: jest.fn((statement: string) =>
|
||||
statement === 'foreign_keys' ? 1 : []
|
||||
),
|
||||
prepare: jest.fn((statement: string) => {
|
||||
if (statement.includes('SELECT value FROM app_state')) {
|
||||
return {
|
||||
@@ -556,6 +559,9 @@ describe('database schema statements', () => {
|
||||
}
|
||||
).runMigrations;
|
||||
const sqlite = {
|
||||
pragma: jest.fn((statement: string) =>
|
||||
statement === 'foreign_keys' ? 1 : []
|
||||
),
|
||||
exec: jest.fn((statement: string) => {
|
||||
if (statement.includes('idx_epg_programs_source')) {
|
||||
callOrder.push('epg-source-index');
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
ensurePlaylistsPayloadLast,
|
||||
playlistsTableSql,
|
||||
} from './playlists-table';
|
||||
import {
|
||||
ensureDurableFavorites,
|
||||
favoritesTableSql,
|
||||
FAVORITES_RECEIPT_CLEANUP_TRIGGER,
|
||||
} from './favorites-table';
|
||||
import { xtreamCacheUniqueIndexesExist } from './xtream-cache-unique-indexes';
|
||||
/**
|
||||
* Database connection and initialization for IPTVnator
|
||||
@@ -183,15 +188,8 @@ const CREATE_TABLE_STATEMENTS = [
|
||||
FOREIGN KEY (content_id) REFERENCES content (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content_id INTEGER NOT NULL,
|
||||
playlist_id TEXT NOT NULL,
|
||||
added_at TEXT DEFAULT (datetime('now')),
|
||||
position INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (content_id) REFERENCES content(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
|
||||
)`,
|
||||
favoritesTableSql(),
|
||||
FAVORITES_RECEIPT_CLEANUP_TRIGGER,
|
||||
`CREATE INDEX IF NOT EXISTS idx_content_type ON content(type)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_content_category ON content(category_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_categories_playlist ON categories(playlist_id)`,
|
||||
@@ -407,6 +405,7 @@ const INDEX_MIGRATION_STATEMENTS = [
|
||||
// v1.3.0 -> v1.4.0: Prevent duplicate Xtream categories/content rows
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS categories_playlist_type_xtream_unique ON categories(playlist_id, type, xtream_id)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS content_category_type_xtream_unique ON content(category_id, type, xtream_id)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS favorites_playlist_type_xtream_unique ON favorites(playlist_id, type, xtream_id)`,
|
||||
// v1.6.0 -> v1.7.0: Query global favorites in stable display order
|
||||
`CREATE INDEX IF NOT EXISTS favorites_playlist_position_idx ON favorites(playlist_id, position, added_at DESC)`,
|
||||
// v1.7.1 -> v1.7.2: Query playlist-scoped EPG by source URL and channel/time
|
||||
@@ -550,11 +549,7 @@ function deduplicateXtreamCache(sqliteDb: Database.Database): void {
|
||||
ORDER BY id ASC`
|
||||
);
|
||||
const moveFavorites = sqliteDb.prepare(
|
||||
`INSERT INTO favorites (content_id, playlist_id, added_at, position)
|
||||
SELECT ?, playlist_id, added_at, COALESCE(position, 0)
|
||||
FROM favorites
|
||||
WHERE content_id = ?
|
||||
ON CONFLICT(content_id, playlist_id) DO NOTHING`
|
||||
`UPDATE OR IGNORE favorites SET content_id = ? WHERE content_id = ?`
|
||||
);
|
||||
const deleteFavorites = sqliteDb.prepare(
|
||||
`DELETE FROM favorites WHERE content_id = ?`
|
||||
@@ -1183,6 +1178,8 @@ function runMigrations(sqliteDb: Database.Database): void {
|
||||
if (!upgradeContentTitleFtsTokenizer(sqliteDb)) {
|
||||
ensureContentTitleFts(sqliteDb);
|
||||
}
|
||||
// Capture the favorited row's metadata before duplicate cache rows disappear.
|
||||
ensureDurableFavorites(sqliteDb);
|
||||
deduplicateXtreamCache(sqliteDb);
|
||||
normalizeXtreamContentAddedEpochs(sqliteDb);
|
||||
runMigrationStatements(sqliteDb, INDEX_MIGRATION_STATEMENTS);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export function favoritesTableSql(name = 'favorites'): string {
|
||||
return `CREATE TABLE IF NOT EXISTS ${name} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content_id INTEGER,
|
||||
playlist_id TEXT NOT NULL,
|
||||
xtream_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('live', 'movie', 'series')),
|
||||
title TEXT NOT NULL,
|
||||
poster_url TEXT,
|
||||
backdrop_url TEXT,
|
||||
tmdb_id INTEGER,
|
||||
release_year INTEGER,
|
||||
original_title TEXT,
|
||||
added_at TEXT DEFAULT (datetime('now')),
|
||||
position INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (content_id) REFERENCES content(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
|
||||
)`;
|
||||
}
|
||||
|
||||
/** Runs after column upgrades, before cache deduplication and dependent indexes. */
|
||||
export function ensureDurableFavorites(sqlite: Database.Database): void {
|
||||
const columns = sqlite.prepare("PRAGMA table_info('favorites')").all() as {
|
||||
name: string;
|
||||
}[];
|
||||
if (columns.some((column) => column.name === 'xtream_id')) return;
|
||||
// Historical profiles may already contain favorites with a missing owner.
|
||||
// Retain those exact records, but never let this rebuild introduce new ones.
|
||||
const orphanIds = new Set(
|
||||
(
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT f.id FROM favorites f
|
||||
LEFT JOIN playlists p ON p.id = f.playlist_id
|
||||
WHERE p.id IS NULL`
|
||||
)
|
||||
.all() as { id: number }[]
|
||||
).map(({ id }) => id)
|
||||
);
|
||||
const foreignKeys = sqlite.pragma('foreign_keys', { simple: true });
|
||||
// This must happen outside the transaction; SQLite ignores it inside one.
|
||||
sqlite.pragma('foreign_keys = OFF');
|
||||
try {
|
||||
sqlite.transaction(() => {
|
||||
sqlite.exec(favoritesTableSql('favorites_durable'));
|
||||
// Multiple categories may contain the same provider item. Keep the first
|
||||
// saved favorite's primary key, timestamp and position deterministically.
|
||||
sqlite.exec(`INSERT INTO favorites_durable
|
||||
(id, content_id, playlist_id, xtream_id, type, title, poster_url,
|
||||
backdrop_url, tmdb_id, release_year, original_title, added_at, position)
|
||||
SELECT id, content_id, playlist_id, xtream_id, type, title, poster_url,
|
||||
backdrop_url, tmdb_id, release_year, original_title, added_at, position
|
||||
FROM (
|
||||
SELECT f.id, c.id AS content_id, f.playlist_id,
|
||||
COALESCE(c.xtream_id, -f.id) AS xtream_id,
|
||||
COALESCE(c.type, 'movie') AS type,
|
||||
COALESCE(c.title, 'Saved favorite') AS title,
|
||||
c.poster_url, c.backdrop_url, c.tmdb_id, c.release_year, c.original_title,
|
||||
f.added_at, f.position,
|
||||
ROW_NUMBER() OVER (PARTITION BY f.playlist_id, c.type,
|
||||
COALESCE(c.xtream_id, -f.id) ORDER BY f.id) AS identity_rank
|
||||
FROM favorites f LEFT JOIN content c ON c.id = f.content_id
|
||||
) WHERE identity_rank = 1`);
|
||||
sqlite.exec('DROP TABLE favorites');
|
||||
sqlite.exec('ALTER TABLE favorites_durable RENAME TO favorites');
|
||||
sqlite.exec(
|
||||
'CREATE UNIQUE INDEX favorites_content_playlist_unique ON favorites(content_id, playlist_id)'
|
||||
);
|
||||
sqlite.exec(
|
||||
'CREATE INDEX favorites_playlist_idx ON favorites(playlist_id)'
|
||||
);
|
||||
sqlite.exec(
|
||||
'CREATE INDEX favorites_content_idx ON favorites(content_id)'
|
||||
);
|
||||
const violations = sqlite.pragma(
|
||||
'foreign_key_check(favorites)'
|
||||
) as {
|
||||
rowid: number;
|
||||
parent: string;
|
||||
}[];
|
||||
if (
|
||||
violations.some(
|
||||
({ rowid, parent }) =>
|
||||
parent !== 'playlists' || !orphanIds.has(rowid)
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Favorites migration introduced a foreign key violation'
|
||||
);
|
||||
}
|
||||
})();
|
||||
} finally {
|
||||
sqlite.pragma(`foreign_keys = ${foreignKeys ? 'ON' : 'OFF'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Receipts are playlist-owned even though app_state is a generic key/value table. */
|
||||
export const FAVORITES_RECEIPT_CLEANUP_TRIGGER = `CREATE TRIGGER IF NOT EXISTS favorites_receipts_playlist_delete
|
||||
AFTER DELETE ON playlists BEGIN
|
||||
DELETE FROM app_state WHERE substr(key, 1, length('xtream-favorites-restore:' || length(OLD.id) || ':' || OLD.id || ':')) =
|
||||
'xtream-favorites-restore:' || length(OLD.id) || ':' || OLD.id || ':'
|
||||
OR key IN ('xtream-import-status:' || OLD.id || ':live',
|
||||
'xtream-import-status:' || OLD.id || ':movie', 'xtream-import-status:' || OLD.id || ':series');
|
||||
END`;
|
||||
@@ -170,17 +170,30 @@ export const favorites = sqliteTable(
|
||||
'favorites',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
contentId: integer('content_id')
|
||||
.notNull()
|
||||
.references(() => content.id, { onDelete: 'cascade' }),
|
||||
contentId: integer('content_id').references(() => content.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
playlistId: text('playlist_id')
|
||||
.notNull()
|
||||
.references(() => playlists.id, { onDelete: 'cascade' }),
|
||||
xtreamId: integer('xtream_id').notNull(),
|
||||
type: text('type', { enum: ['live', 'movie', 'series'] }).notNull(),
|
||||
title: text('title').notNull(),
|
||||
posterUrl: text('poster_url'),
|
||||
backdropUrl: text('backdrop_url'),
|
||||
tmdbId: integer('tmdb_id'),
|
||||
releaseYear: integer('release_year'),
|
||||
originalTitle: text('original_title'),
|
||||
addedAt: text('added_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
/** Display order position in the global favorites list (lower = first) */
|
||||
position: integer('position').default(0),
|
||||
},
|
||||
(table) => ({
|
||||
identityUnique: uniqueIndex('favorites_playlist_type_xtream_unique').on(
|
||||
table.playlistId,
|
||||
table.type,
|
||||
table.xtreamId
|
||||
),
|
||||
contentPlaylistUnique: uniqueIndex(
|
||||
'favorites_content_playlist_unique'
|
||||
).on(table.contentId, table.playlistId),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { verifyFavoriteMigrationRollback } from './favorites-migration-rollback';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import Database from 'better-sqlite3';
|
||||
@@ -25,7 +26,6 @@ function seed(sqlite: Database.Database) {
|
||||
VALUES (1, 'saved', 'Movies', 'movies', 42, 1);
|
||||
INSERT INTO content (id, category_id, title, xtream_id, type, added)
|
||||
VALUES (1, 1, 'Saved movie', 7, 'movie', '1700000000');
|
||||
INSERT INTO favorites (content_id, playlist_id) VALUES (1, 'saved');
|
||||
INSERT INTO recently_viewed (content_id, playlist_id) VALUES (1, 'saved');
|
||||
INSERT INTO playback_positions
|
||||
(playlist_id, content_xtream_id, content_type, position_seconds, duration_seconds)
|
||||
@@ -33,6 +33,18 @@ function seed(sqlite: Database.Database) {
|
||||
INSERT INTO downloads (playlist_id, xtream_id, content_type, title, url, status)
|
||||
VALUES ('saved', 7, 'vod', 'Saved movie', 'https://source.invalid/movie', 'completed');
|
||||
`);
|
||||
const favoriteColumns = sqlite.pragma('table_info(favorites)') as {
|
||||
name: string;
|
||||
}[];
|
||||
const durable = favoriteColumns.some(({ name }) => name === 'xtream_id');
|
||||
sqlite.exec(
|
||||
durable
|
||||
? `INSERT INTO favorites (id, content_id, playlist_id, added_at, position, xtream_id, type, title) VALUES (42, 1, 'saved', '2025-01-01', 9, 7, 'movie', 'Saved movie')`
|
||||
: `INSERT INTO favorites (id, content_id, playlist_id, added_at) VALUES (42, 1, 'saved', '2025-01-01')`
|
||||
);
|
||||
if (favoriteColumns.some(({ name }) => name === 'position')) {
|
||||
sqlite.exec('UPDATE favorites SET position = 9');
|
||||
}
|
||||
const columns = sqlite.pragma('table_info(content)') as { name: string }[];
|
||||
if (columns.some(({ name }) => name === 'epg_channel_id')) {
|
||||
sqlite.exec("UPDATE content SET epg_channel_id = 'retained-epg-id'");
|
||||
@@ -139,6 +151,18 @@ function epgIndex(sqlite: Database.Database) {
|
||||
.get();
|
||||
}
|
||||
|
||||
function foreignKeyViolations(sqlite: Database.Database) {
|
||||
// Rebuilding a table can reorder its FK definitions without changing the
|
||||
// affected row or parent, so compare identities rather than FK ordinals.
|
||||
return (
|
||||
sqlite.pragma('foreign_key_check') as {
|
||||
table: string;
|
||||
rowid: number;
|
||||
parent: string;
|
||||
}[]
|
||||
).map(({ table, rowid, parent }) => ({ table, rowid, parent }));
|
||||
}
|
||||
|
||||
function verifyCurrentSchema(sqlite: Database.Database) {
|
||||
for (const table of Object.values(currentSchema)) {
|
||||
const { name, columns, indexes } = getTableConfig(table);
|
||||
@@ -173,6 +197,11 @@ function verifyCurrentSchema(sqlite: Database.Database) {
|
||||
|
||||
async function main() {
|
||||
const fixture = process.argv[2];
|
||||
if (fixture === 'rollback') {
|
||||
verifyFavoriteMigrationRollback();
|
||||
process.stdout.write('upgrade verified');
|
||||
return;
|
||||
}
|
||||
assert(
|
||||
process.env['IPTVNATOR_E2E_DATA_DIR'],
|
||||
'Never open the user database'
|
||||
@@ -191,7 +220,21 @@ async function main() {
|
||||
legacy.pragma('foreign_keys = ON');
|
||||
if (fixture !== 'fresh') legacy.exec(readFileSync(fixture, 'utf8'));
|
||||
seed(legacy);
|
||||
if (process.argv[3] === 'orphans') {
|
||||
legacy.pragma('foreign_keys = OFF');
|
||||
legacy.exec(`INSERT INTO favorites (id, content_id, playlist_id, added_at)
|
||||
VALUES (43, 1, 'deleted-playlist', '2024-01-01')`);
|
||||
legacy.pragma('foreign_keys = ON');
|
||||
}
|
||||
const before = snapshot(legacy);
|
||||
const originalViolations = foreignKeyViolations(legacy);
|
||||
if (process.argv[3] === 'deduplicate') {
|
||||
legacy.exec(`INSERT INTO categories (id, playlist_id, name, type, xtream_id, hidden)
|
||||
VALUES (2, 'saved', 'Movies', 'movies', 42, 1);
|
||||
INSERT INTO content (id, category_id, title, xtream_id, type, added)
|
||||
VALUES (2, 2, 'Original favorite title', 7, 'movie', '1700000000');
|
||||
UPDATE favorites SET content_id = 2;`);
|
||||
}
|
||||
const originalIndex = epgIndex(legacy);
|
||||
legacy.close();
|
||||
|
||||
@@ -214,7 +257,37 @@ async function main() {
|
||||
for (const { query, rows } of before) {
|
||||
assert.deepEqual(sqlite.prepare(query).all(), rows, query);
|
||||
}
|
||||
assert.deepEqual(sqlite.pragma('foreign_key_check'), []);
|
||||
assert.deepEqual(
|
||||
sqlite
|
||||
.prepare(
|
||||
'SELECT id, content_id, playlist_id, xtream_id, type, title, added_at FROM favorites'
|
||||
)
|
||||
.get(),
|
||||
{
|
||||
id: 42,
|
||||
content_id: 1,
|
||||
playlist_id: 'saved',
|
||||
xtream_id: 7,
|
||||
type: 'movie',
|
||||
title:
|
||||
process.argv[3] === 'deduplicate'
|
||||
? 'Original favorite title'
|
||||
: 'Saved movie',
|
||||
added_at: '2025-01-01',
|
||||
}
|
||||
);
|
||||
const favoriteForeignKeys = sqlite.pragma(
|
||||
'foreign_key_list(favorites)'
|
||||
) as { from: string; on_delete: string }[];
|
||||
assert.equal(
|
||||
favoriteForeignKeys.find((key) => key.from === 'content_id')
|
||||
?.on_delete,
|
||||
'SET NULL'
|
||||
);
|
||||
assert.deepEqual(
|
||||
foreignKeyViolations(sqlite),
|
||||
originalViolations
|
||||
);
|
||||
assert.deepEqual(sqlite.pragma('integrity_check'), [
|
||||
{ integrity_check: 'ok' },
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import Database from 'better-sqlite3';
|
||||
import { ensureDurableFavorites } from '../favorites-table';
|
||||
|
||||
export function verifyFavoriteMigrationRollback() {
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
try {
|
||||
sqlite.exec(`CREATE TABLE playlists (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE content (id INTEGER PRIMARY KEY, xtream_id INTEGER, type TEXT,
|
||||
title TEXT, poster_url TEXT, backdrop_url TEXT, tmdb_id INTEGER,
|
||||
release_year INTEGER, original_title TEXT);
|
||||
CREATE TABLE favorites (id INTEGER PRIMARY KEY, content_id INTEGER NOT NULL
|
||||
REFERENCES content(id) ON DELETE CASCADE, playlist_id TEXT REFERENCES playlists(id),
|
||||
added_at TEXT, position INTEGER);
|
||||
INSERT INTO playlists VALUES ('p');
|
||||
INSERT INTO content (id, xtream_id, type, title) VALUES (1, 7, 'movie', 'ABC');
|
||||
INSERT INTO favorites VALUES (42, 1, 'p', '2025-01-01', 9);
|
||||
CREATE VIEW favorites_dependency AS SELECT id FROM favorites;`);
|
||||
const before = sqlite.prepare('SELECT * FROM favorites').all();
|
||||
assert.throws(
|
||||
() => ensureDurableFavorites(sqlite),
|
||||
/favorites_dependency/
|
||||
);
|
||||
assert.equal(sqlite.pragma('foreign_keys', { simple: true }), 1);
|
||||
assert.deepEqual(
|
||||
sqlite.prepare('SELECT * FROM favorites').all(),
|
||||
before
|
||||
);
|
||||
assert.equal(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE name = 'favorites_durable'"
|
||||
)
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
sqlite.exec('DROP VIEW favorites_dependency');
|
||||
sqlite.pragma('foreign_keys = OFF');
|
||||
sqlite.exec(`INSERT INTO favorites VALUES (43, 1, 'deleted', '2024-01-01', 10);
|
||||
INSERT INTO favorites VALUES (44, 999, 'p', '2024-01-02', 11);`);
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
const withOrphans = sqlite.prepare('SELECT * FROM favorites').all();
|
||||
const exec = sqlite.exec;
|
||||
sqlite.exec = function injectInvalidOwner(statement) {
|
||||
const result = exec.call(this, statement);
|
||||
if (
|
||||
statement ===
|
||||
'ALTER TABLE favorites_durable RENAME TO favorites'
|
||||
) {
|
||||
exec.call(
|
||||
this,
|
||||
"UPDATE favorites SET playlist_id = 'new-orphan' WHERE id = 42"
|
||||
);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
// The missing content link is repaired during the rebuild. A mere
|
||||
// before/after violation count would conceal a new orphan in its place.
|
||||
try {
|
||||
assert.throws(
|
||||
() => ensureDurableFavorites(sqlite),
|
||||
/introduced a foreign key violation/
|
||||
);
|
||||
} finally {
|
||||
sqlite.exec = exec;
|
||||
}
|
||||
assert.equal(sqlite.pragma('foreign_keys', { simple: true }), 1);
|
||||
assert.deepEqual(
|
||||
sqlite.prepare('SELECT * FROM favorites').all(),
|
||||
withOrphans
|
||||
);
|
||||
assert.equal(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE name = 'favorites_durable'"
|
||||
)
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
ensureDurableFavorites(sqlite);
|
||||
assert.equal(sqlite.pragma('foreign_keys', { simple: true }), 1);
|
||||
ensureDurableFavorites(sqlite);
|
||||
sqlite.exec('DELETE FROM content');
|
||||
assert.deepEqual(
|
||||
sqlite
|
||||
.prepare(
|
||||
'SELECT id, content_id, title, added_at, position FROM favorites'
|
||||
)
|
||||
.get(),
|
||||
{
|
||||
id: 42,
|
||||
content_id: null,
|
||||
title: 'ABC',
|
||||
added_at: '2025-01-01',
|
||||
position: 9,
|
||||
}
|
||||
);
|
||||
assert.deepEqual(
|
||||
(
|
||||
sqlite.pragma('foreign_key_check') as {
|
||||
rowid: number;
|
||||
parent: string;
|
||||
}[]
|
||||
).map(({ rowid, parent }) => ({ rowid, parent })),
|
||||
[{ rowid: 43, parent: 'playlists' }]
|
||||
);
|
||||
assert.equal(
|
||||
sqlite.prepare('SELECT id FROM favorites').all().length,
|
||||
3
|
||||
);
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
-- Fresh-install schema from tag v0.24.0, libs/shared/database/src/lib/connection.ts.
|
||||
-- Whitespace compacted; statements and referenced constants retain their original order.
|
||||
CREATE TABLE IF NOT EXISTS playlists ( id TEXT PRIMARY KEY, name TEXT NOT NULL, serverUrl TEXT, username TEXT, password TEXT, date_created TEXT DEFAULT (datetime('now')), last_updated TEXT, type TEXT NOT NULL CHECK (type IN ('xtream', 'stalker', 'm3u-file', 'm3u-text', 'm3u-url')), userAgent TEXT, origin TEXT, referrer TEXT, filePath TEXT, epg_urls TEXT, detected_epg_urls TEXT, manual_epg_urls TEXT, disabled_epg_urls TEXT, autoRefresh INTEGER DEFAULT 0, macAddress TEXT, url TEXT, portal_url TEXT, count INTEGER, import_date TEXT, update_date INTEGER, position INTEGER, favorites TEXT, recently_viewed TEXT, payload TEXT, last_usage TEXT );
|
||||
CREATE TABLE IF NOT EXISTS app_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')) );
|
||||
CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, playlist_id TEXT NOT NULL, name TEXT NOT NULL, type TEXT NOT NULL CHECK (type IN ('live', 'movies', 'series')), xtream_id INTEGER NOT NULL, hidden INTEGER DEFAULT 0, UNIQUE(playlist_id, type, xtream_id), FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE );
|
||||
CREATE TABLE IF NOT EXISTS content ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL, title TEXT NOT NULL, rating TEXT, added TEXT, poster_url TEXT, backdrop_url TEXT, tmdb_id INTEGER, release_year INTEGER, original_title TEXT, epg_channel_id TEXT, tv_archive INTEGER, tv_archive_duration INTEGER, direct_source TEXT, xtream_id INTEGER NOT NULL, type TEXT NOT NULL CHECK (type IN ('live', 'movie', 'series')), UNIQUE(category_id, type, xtream_id), FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE );
|
||||
CREATE TABLE IF NOT EXISTS recently_viewed ( id INTEGER PRIMARY KEY AUTOINCREMENT, content_id INTEGER NOT NULL, playlist_id TEXT NOT NULL, viewed_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (content_id) REFERENCES content (id) ON DELETE CASCADE, FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE );
|
||||
CREATE TABLE IF NOT EXISTS favorites ( id INTEGER PRIMARY KEY AUTOINCREMENT, content_id INTEGER NOT NULL, playlist_id TEXT NOT NULL, added_at TEXT DEFAULT (datetime('now')), position INTEGER DEFAULT 0, FOREIGN KEY (content_id) REFERENCES content(id) ON DELETE CASCADE, FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE );
|
||||
CREATE INDEX IF NOT EXISTS idx_content_type ON content(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_content_category ON content(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_playlist ON categories(playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_content_title ON content(title);
|
||||
CREATE INDEX IF NOT EXISTS idx_content_xtream ON content(xtream_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_content_type_added ON content(type, added);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_visible ON categories(id, playlist_id, type) WHERE hidden = 0;
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS content_title_fts USING fts5( title, content='content', content_rowid='id', tokenize='trigram' );
|
||||
CREATE TRIGGER IF NOT EXISTS content_title_fts_ai AFTER INSERT ON content BEGIN INSERT INTO content_title_fts(rowid, title) VALUES (new.id, new.title); END;
|
||||
CREATE TRIGGER IF NOT EXISTS content_title_fts_ad AFTER DELETE ON content BEGIN INSERT INTO content_title_fts(content_title_fts, rowid, title) VALUES ('delete', old.id, old.title); END;
|
||||
CREATE TRIGGER IF NOT EXISTS content_title_fts_au AFTER UPDATE ON content BEGIN INSERT INTO content_title_fts(content_title_fts, rowid, title) VALUES ('delete', old.id, old.title); INSERT INTO content_title_fts(rowid, title) VALUES (new.id, new.title); END;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique ON favorites(content_id, playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS favorites_playlist_idx ON favorites(playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS favorites_content_idx ON favorites(content_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS recently_viewed_content_playlist_unique ON recently_viewed(content_id, playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS recently_viewed_playlist_idx ON recently_viewed(playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS recently_viewed_viewed_at_idx ON recently_viewed(viewed_at);
|
||||
CREATE INDEX IF NOT EXISTS recently_viewed_playlist_viewed_idx ON recently_viewed(playlist_id, viewed_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS epg_channels ( id TEXT PRIMARY KEY, display_name TEXT NOT NULL, icon_url TEXT, url TEXT, source_url TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')) );
|
||||
CREATE TABLE IF NOT EXISTS epg_channel_sources ( channel_id TEXT NOT NULL, source_url TEXT NOT NULL, display_name TEXT NOT NULL, icon_url TEXT, url TEXT, updated_at TEXT DEFAULT (datetime('now')), write_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (channel_id, source_url), FOREIGN KEY (channel_id) REFERENCES epg_channels(id) ON DELETE CASCADE );
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_channel_sources_source ON epg_channel_sources(source_url);
|
||||
CREATE TABLE IF NOT EXISTS epg_programs ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id TEXT NOT NULL, start TEXT NOT NULL, stop TEXT NOT NULL, title TEXT NOT NULL, description TEXT, category TEXT, icon_url TEXT, rating TEXT, episode_num TEXT, source_url TEXT, FOREIGN KEY (channel_id) REFERENCES epg_channels(id) ON DELETE CASCADE );
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_channels_source ON epg_channels(source_url);
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_channels_name ON epg_channels(display_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_programs_channel ON epg_programs(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_programs_start ON epg_programs(start);
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_programs_stop ON epg_programs(stop);
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_programs_time_range ON epg_programs(channel_id, start, stop);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS epg_programs_fts USING fts5( title, description, category, content='epg_programs', content_rowid='id' );
|
||||
CREATE TRIGGER IF NOT EXISTS epg_programs_ai AFTER INSERT ON epg_programs BEGIN INSERT INTO epg_programs_fts(rowid, title, description, category) VALUES (new.id, new.title, new.description, new.category); END;
|
||||
CREATE TRIGGER IF NOT EXISTS epg_programs_ad AFTER DELETE ON epg_programs BEGIN INSERT INTO epg_programs_fts(epg_programs_fts, rowid, title, description, category) VALUES ('delete', old.id, old.title, old.description, old.category); END;
|
||||
CREATE TRIGGER IF NOT EXISTS epg_programs_au AFTER UPDATE ON epg_programs BEGIN INSERT INTO epg_programs_fts(epg_programs_fts, rowid, title, description, category) VALUES ('delete', old.id, old.title, old.description, old.category); INSERT INTO epg_programs_fts(rowid, title, description, category) VALUES (new.id, new.title, new.description, new.category); END;
|
||||
CREATE TABLE IF NOT EXISTS epg_channel_mappings ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_key TEXT NOT NULL UNIQUE, epg_channel_id TEXT NOT NULL, playlist_id TEXT, updated_at TEXT DEFAULT (datetime('now')) );
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_channel_mappings_playlist ON epg_channel_mappings(playlist_id);
|
||||
CREATE TABLE IF NOT EXISTS vod_source_pins ( id INTEGER PRIMARY KEY AUTOINCREMENT, match_key TEXT NOT NULL UNIQUE, playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, content_id INTEGER NOT NULL, portal_type TEXT NOT NULL CHECK (portal_type IN ('xtream', 'stalker', 'm3u')), updated_at TEXT DEFAULT (datetime('now')) );
|
||||
CREATE INDEX IF NOT EXISTS idx_vod_source_pins_playlist ON vod_source_pins(playlist_id);
|
||||
CREATE TABLE IF NOT EXISTS playback_positions ( id INTEGER PRIMARY KEY AUTOINCREMENT, playlist_id TEXT NOT NULL, content_xtream_id INTEGER NOT NULL, content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode')), series_xtream_id INTEGER, season_number INTEGER, episode_number INTEGER, position_seconds INTEGER NOT NULL DEFAULT 0, duration_seconds INTEGER, updated_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE );
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS playback_positions_content_playlist_unique ON playback_positions(content_xtream_id, playlist_id, content_type);
|
||||
CREATE INDEX IF NOT EXISTS playback_positions_playlist_idx ON playback_positions(playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS playback_positions_series_idx ON playback_positions(series_xtream_id);
|
||||
CREATE INDEX IF NOT EXISTS playback_positions_updated_idx ON playback_positions(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS playback_positions_playlist_updated_idx ON playback_positions(playlist_id, updated_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS downloads ( id INTEGER PRIMARY KEY AUTOINCREMENT, playlist_id TEXT NOT NULL, xtream_id INTEGER NOT NULL, content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode', 'catchup')), programme_start INTEGER NOT NULL DEFAULT 0, catchup TEXT, series_xtream_id INTEGER, season_number INTEGER, episode_number INTEGER, episode_identity_scope TEXT, title TEXT NOT NULL, url TEXT NOT NULL, file_name TEXT, file_path TEXT, poster_url TEXT, request_headers TEXT, resume_validator TEXT, metadata_snapshot TEXT, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled')), bytes_downloaded INTEGER DEFAULT 0, total_bytes INTEGER, error_message TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) );
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type) WHERE content_type != 'catchup';
|
||||
CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status);
|
||||
CREATE TABLE IF NOT EXISTS recordings ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, owner_pid INTEGER, status TEXT NOT NULL DEFAULT 'recording' CHECK (status IN ('recording', 'completed', 'interrupted', 'failed')), file_path TEXT NOT NULL, file_size_bytes INTEGER, channel_name TEXT NOT NULL, channel_logo_url TEXT, playlist_id TEXT, playlist_name TEXT, source_type TEXT CHECK (source_type IN ('m3u', 'xtream', 'stalker')), epg_channel_id TEXT, program_title TEXT, program_description TEXT, program_start TEXT, program_stop TEXT, programs_json TEXT, error_message TEXT, started_at TEXT NOT NULL, ended_at TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) );
|
||||
CREATE INDEX IF NOT EXISTS recordings_status_idx ON recordings(status);
|
||||
CREATE INDEX IF NOT EXISTS recordings_file_path_idx ON recordings(file_path);
|
||||
CREATE INDEX IF NOT EXISTS recordings_playlist_idx ON recordings(playlist_id);
|
||||
CREATE TABLE IF NOT EXISTS tmdb_metadata ( id INTEGER PRIMARY KEY AUTOINCREMENT, media_type TEXT NOT NULL CHECK (media_type IN ('movie', 'tv', 'person')), lookup_key TEXT NOT NULL, language TEXT NOT NULL, tmdb_id INTEGER, payload TEXT, fetched_at TEXT DEFAULT (datetime('now')) );
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS tmdb_metadata_lookup_unique ON tmdb_metadata(media_type, lookup_key, language);
|
||||
@@ -81,6 +81,7 @@ export * from './lib/xtream-live-stream.interface';
|
||||
export * from './lib/xtream-response.interface';
|
||||
export * from './lib/xtream-portal.utils';
|
||||
export * from './lib/xtream-restore-state.util';
|
||||
export * from './lib/xtream-favorite.interface';
|
||||
export * from './lib/xtream-server-timezone.util';
|
||||
export * from './lib/xtream-recently-added.utils';
|
||||
export * from './lib/xtream-performance-marker.interface';
|
||||
|
||||
@@ -564,7 +564,20 @@ export interface ElectronBridgeGlobalRecentItem extends ElectronBridgeXtreamCont
|
||||
viewed_at: string;
|
||||
}
|
||||
|
||||
export interface ElectronBridgeGlobalFavoriteItem extends ElectronBridgeXtreamContent {
|
||||
export interface ElectronBridgeXtreamFavorite extends Omit<
|
||||
ElectronBridgeXtreamContent,
|
||||
'id' | 'category_id'
|
||||
> {
|
||||
id: number | null;
|
||||
category_id: number | null;
|
||||
favorite_id?: number;
|
||||
catalog_status?: import('./xtream-favorite.interface').XtreamFavoriteCatalogStatus;
|
||||
tmdb_id?: number | null;
|
||||
release_year?: number | null;
|
||||
original_title?: string | null;
|
||||
}
|
||||
|
||||
export interface ElectronBridgeGlobalFavoriteItem extends ElectronBridgeXtreamFavorite {
|
||||
playlist_id: string;
|
||||
playlist_name: string;
|
||||
added_at: string;
|
||||
@@ -577,7 +590,8 @@ export interface ElectronBridgeGlobalRecentlyAddedItem extends ElectronBridgeXtr
|
||||
}
|
||||
|
||||
export interface ElectronBridgeFavoriteReorderUpdate {
|
||||
content_id: number;
|
||||
content_id?: number;
|
||||
favorite_id?: number;
|
||||
/** Favorites are playlist-scoped — scope the position write per playlist */
|
||||
playlist_id: string;
|
||||
position: number;
|
||||
@@ -1033,6 +1047,11 @@ export interface ElectronBridgeApi {
|
||||
recentlyViewed: XtreamBackupRecentlyViewedItem[];
|
||||
hiddenCategories: XtreamBackupHiddenCategory[];
|
||||
}>;
|
||||
dbRestoreXtreamFavorites: (
|
||||
playlistId: string,
|
||||
favorites: XtreamBackupFavoriteItem[],
|
||||
restoreId?: string
|
||||
) => Promise<ElectronBridgeResult>;
|
||||
dbRestoreXtreamUserData: (
|
||||
playlistId: string,
|
||||
favorites: XtreamBackupFavoriteItem[],
|
||||
@@ -1109,15 +1128,18 @@ export interface ElectronBridgeApi {
|
||||
contentId: number,
|
||||
playlistId: string,
|
||||
backdropUrl?: string
|
||||
) => Promise<ElectronBridgeResult>;
|
||||
) => Promise<ElectronBridgeErrorResult>;
|
||||
dbRemoveFavorite: (
|
||||
contentId: number,
|
||||
contentId: import('./xtream-favorite.interface').XtreamFavoriteRemovalRef,
|
||||
playlistId: string
|
||||
) => Promise<ElectronBridgeResult>;
|
||||
dbIsFavorite: (contentId: number, playlistId: string) => Promise<boolean>;
|
||||
dbGetFavoritesForBackup: (
|
||||
playlistId: string
|
||||
) => Promise<ElectronBridgeXtreamFavorite[]>;
|
||||
dbGetFavorites: (
|
||||
playlistId: string
|
||||
) => Promise<ElectronBridgeXtreamContent[]>;
|
||||
) => Promise<ElectronBridgeXtreamFavorite[]>;
|
||||
dbGetGlobalFavorites: () => Promise<ElectronBridgeGlobalFavoriteItem[]>;
|
||||
dbGetAllGlobalFavorites: () => Promise<ElectronBridgeGlobalFavoriteItem[]>;
|
||||
dbReorderGlobalFavorites: (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { XtreamFavoriteSnapshot } from './xtream-favorite.interface';
|
||||
import { PlaybackPositionData } from './playback-position.interface';
|
||||
import { M3uRecentlyViewedItem } from './playlist-recently-viewed.interface';
|
||||
import { StalkerPortalItem } from './stalker-portal-item.interface';
|
||||
@@ -56,6 +57,7 @@ export interface XtreamBackupFavoriteItem {
|
||||
xtreamId: number;
|
||||
addedAt?: string;
|
||||
position?: number | null;
|
||||
snapshot?: XtreamFavoriteSnapshot;
|
||||
}
|
||||
|
||||
export interface XtreamBackupRecentlyViewedItem {
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface PortalActivityItem {
|
||||
/** The year the PROVIDER stated, never one read out of the title */
|
||||
release_year?: number;
|
||||
original_title?: string;
|
||||
favorite_id?: number;
|
||||
catalogStatus?: import('./xtream-favorite.interface').XtreamFavoriteCatalogStatus;
|
||||
source?: 'xtream' | 'stalker' | 'm3u';
|
||||
/** Original stalker item for navigation state; undefined for xtream. */
|
||||
stalker_item?: StalkerPortalItem;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Catalog membership is separate from whether a stream can currently play. */
|
||||
export const XTREAM_FAVORITE_CATALOG_STATUS = {
|
||||
PRESENT: 'present',
|
||||
MISSING: 'missing',
|
||||
UNKNOWN: 'unknown',
|
||||
} as const;
|
||||
|
||||
export type XtreamFavoriteCatalogStatus =
|
||||
(typeof XTREAM_FAVORITE_CATALOG_STATUS)[keyof typeof XTREAM_FAVORITE_CATALOG_STATUS];
|
||||
|
||||
export interface XtreamFavoriteSnapshot {
|
||||
title: string;
|
||||
posterUrl?: string | null;
|
||||
backdropUrl?: string | null;
|
||||
tmdbId?: number | null;
|
||||
releaseYear?: number | null;
|
||||
originalTitle?: string | null;
|
||||
}
|
||||
|
||||
/** Numeric references are legacy catalog row IDs, never provider IDs. */
|
||||
export type XtreamFavoriteRemovalRef = number | { favoriteId: number };
|
||||
|
||||
export const XTREAM_FAVORITE_IDENTITY_CONFLICT =
|
||||
'XTREAM_FAVORITE_IDENTITY_CONFLICT';
|
||||
|
||||
export function isCatalogUnavailable(item: {
|
||||
catalogStatus?: XtreamFavoriteCatalogStatus;
|
||||
}): boolean {
|
||||
return item.catalogStatus === 'missing' || item.catalogStatus === 'unknown';
|
||||
}
|
||||
@@ -1,6 +1,37 @@
|
||||
import { normalizeXtreamPendingRestoreState } from './xtream-restore-state.util';
|
||||
|
||||
describe('normalizeXtreamPendingRestoreState', () => {
|
||||
it('retains only display metadata in a durable favorite snapshot', () => {
|
||||
const state = normalizeXtreamPendingRestoreState({
|
||||
favorites: [
|
||||
{
|
||||
contentType: 'movie',
|
||||
xtreamId: 7,
|
||||
position: 2,
|
||||
snapshot: {
|
||||
title: 'ABC',
|
||||
posterUrl: 'https://example.test/poster.jpg',
|
||||
tmdbId: 42,
|
||||
password: 'private',
|
||||
streamUrl: 'https://example.test/secret',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(state.favorites).toEqual([
|
||||
{
|
||||
contentType: 'movie',
|
||||
xtreamId: 7,
|
||||
position: 2,
|
||||
snapshot: {
|
||||
title: 'ABC',
|
||||
posterUrl: 'https://example.test/poster.jpg',
|
||||
tmdbId: 42,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// No `sourcePins`: absent has to stay absent, or an older archive would
|
||||
// read as "there are no pins" and restore would clear the user's.
|
||||
const emptyState = {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
XtreamBackupRecentlyViewedItem,
|
||||
XtreamBackupSourcePin,
|
||||
} from './playlist-backup.interface';
|
||||
import { XtreamFavoriteSnapshot } from './xtream-favorite.interface';
|
||||
import { PlaybackPositionData } from './playback-position.interface';
|
||||
|
||||
export interface XtreamPendingRestoreState {
|
||||
@@ -18,6 +19,8 @@ export interface XtreamPendingRestoreState {
|
||||
* can distinguish "no opinion" from an authoritative empty collection.
|
||||
*/
|
||||
sourcePins?: XtreamBackupSourcePin[];
|
||||
/** Internal replay receipt; never exported in an archive. */
|
||||
favoritesRestoreId?: string;
|
||||
}
|
||||
|
||||
export function getXtreamPendingRestoreStorageKey(playlistId: string): string {
|
||||
@@ -36,6 +39,7 @@ interface RestoreStateCandidate {
|
||||
recentlyViewed?: unknown;
|
||||
playbackPositions?: unknown;
|
||||
sourcePins?: unknown;
|
||||
favoritesRestoreId?: unknown;
|
||||
}
|
||||
|
||||
interface RestoreEntryCandidate {
|
||||
@@ -135,9 +139,12 @@ export function normalizeXtreamPendingRestoreState(
|
||||
|
||||
return {
|
||||
hiddenCategories,
|
||||
favorites: withNumericXtreamId<XtreamBackupFavoriteItem>(
|
||||
toArray(candidate.favorites)
|
||||
),
|
||||
favorites: normalizeFavorites(candidate.favorites),
|
||||
...(typeof candidate.favoritesRestoreId === 'string' &&
|
||||
candidate.favoritesRestoreId.length > 0 &&
|
||||
candidate.favoritesRestoreId.length <= 200
|
||||
? { favoritesRestoreId: candidate.favoritesRestoreId }
|
||||
: {}),
|
||||
recentlyViewed: withNumericXtreamId<XtreamBackupRecentlyViewedItem>(
|
||||
toArray(candidate.recentlyViewed)
|
||||
),
|
||||
@@ -184,3 +191,51 @@ function normalizeSourcePins(value: unknown): XtreamBackupSourcePin[] {
|
||||
|
||||
return pins;
|
||||
}
|
||||
|
||||
/** Snapshot fields are display metadata, never a raw provider response. */
|
||||
function normalizeFavorites(value: unknown): XtreamBackupFavoriteItem[] {
|
||||
return withNumericXtreamId<XtreamBackupFavoriteItem>(toArray(value))
|
||||
.filter((item) =>
|
||||
['live', 'movie', 'series'].includes(item.contentType)
|
||||
)
|
||||
.map((item) => {
|
||||
const snapshot = normalizeFavoriteSnapshot(item.snapshot);
|
||||
return {
|
||||
contentType: item.contentType,
|
||||
xtreamId: item.xtreamId,
|
||||
...(typeof item.addedAt === 'string'
|
||||
? { addedAt: item.addedAt }
|
||||
: {}),
|
||||
...(item.position === null ||
|
||||
(typeof item.position === 'number' &&
|
||||
Number.isFinite(item.position))
|
||||
? { position: item.position }
|
||||
: {}),
|
||||
...(snapshot ? { snapshot } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeFavoriteSnapshot(
|
||||
value: unknown
|
||||
): XtreamFavoriteSnapshot | undefined {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value['title'] !== 'string' ||
|
||||
!value['title'].trim()
|
||||
)
|
||||
return undefined;
|
||||
const snapshot: XtreamFavoriteSnapshot = { title: value['title'].trim() };
|
||||
for (const key of ['posterUrl', 'backdropUrl', 'originalTitle'] as const) {
|
||||
const field = value[key];
|
||||
if (typeof field === 'string') snapshot[key] = field;
|
||||
else if (field === null) snapshot[key] = null;
|
||||
}
|
||||
for (const key of ['tmdbId', 'releaseYear'] as const) {
|
||||
const field = value[key];
|
||||
if (typeof field === 'number' && Number.isInteger(field) && field > 0)
|
||||
snapshot[key] = field;
|
||||
else if (field === null) snapshot[key] = null;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
Reference in new issue
Block a user