mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
feat(xtream): add catch-up playback to favorites and recent tabs (#1166)
Enables Xtream catch-up/timeshift from the Favorites and Recent surfaces (per-playlist and global), not just Live TV, and adds start-over replay of the currently-airing programme. Carries tv_archive/tv_archive_duration through the favorites and recently-viewed DB projections and maps them onto UnifiedCollectionItem; tv_archive_duration is interpreted as days, matching live-stream-layout.controlledArchiveDays. Closes #1138. Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
c266eaa680
commit
2f8aee72df
25 files changed
+459
-48
No files matched your search
@@ -25,9 +25,12 @@ jest.mock('drizzle-orm', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import { createDbMock } from './operations.test-helpers';
|
||||
import {
|
||||
getAllGlobalFavorites,
|
||||
getFavorites,
|
||||
getGlobalFavorites,
|
||||
reorderGlobalFavorites,
|
||||
} from './favorites.operations';
|
||||
@@ -93,6 +96,38 @@ describe('favorites.operations', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// Regression for issue #1138: archive metadata must survive every
|
||||
// favorites projection so catch-up stays available outside Live TV.
|
||||
it('selects archive metadata for playlist favorites', async () => {
|
||||
const { db, select } = createGlobalFavoritesDbMock([]);
|
||||
|
||||
await getFavorites(db, 'playlist-1');
|
||||
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('selects archive metadata for global favorites', async () => {
|
||||
const { db, select } = createGlobalFavoritesDbMock([]);
|
||||
|
||||
await getGlobalFavorites(db);
|
||||
await getAllGlobalFavorites(db);
|
||||
|
||||
expect(select).toHaveBeenCalledTimes(2);
|
||||
for (const [projection] of select.mock.calls) {
|
||||
expect(projection).toEqual(
|
||||
expect.objectContaining({
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
describe('reorderGlobalFavorites', () => {
|
||||
it('short-circuits without touching the db when there are no updates', async () => {
|
||||
const { db, update, transaction } = createDbMock();
|
||||
|
||||
@@ -73,6 +73,8 @@ export async function getFavorites(db: AppDatabase, playlistId: string) {
|
||||
poster_url: schema.content.posterUrl,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
added_at: schema.favorites.addedAt,
|
||||
position: schema.favorites.position,
|
||||
})
|
||||
@@ -117,6 +119,8 @@ function selectGlobalFavoriteRows(
|
||||
: {}),
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
playlist_id: schema.playlists.id,
|
||||
playlist_name: schema.playlists.name,
|
||||
added_at: schema.favorites.addedAt,
|
||||
|
||||
+16
-2
@@ -35,7 +35,7 @@ describe('recently-viewed.operations', () => {
|
||||
{ id: 2, title: 'Newest', viewed_at: '2026-07-02 10:00:00' },
|
||||
{ id: 1, title: 'Older', viewed_at: '2026-07-01 10:00:00' },
|
||||
];
|
||||
const { db, queries } = createDbMock([rows]);
|
||||
const { db, queries, select } = createDbMock([rows]);
|
||||
|
||||
await expect(getRecentlyViewed(db)).resolves.toEqual(rows);
|
||||
|
||||
@@ -47,11 +47,19 @@ describe('recently-viewed.operations', () => {
|
||||
);
|
||||
expect(queries[0].limit).toHaveBeenCalledWith(100);
|
||||
expect(queries[0].innerJoin).toHaveBeenCalledTimes(3);
|
||||
// Regression for issue #1138: archive metadata must survive the
|
||||
// recent projections so catch-up stays available outside Live TV.
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('scopes playlist history to the playlist, newest-first with a 100 cap', async () => {
|
||||
const rows = [{ id: 5, title: 'Recent Movie' }];
|
||||
const { db, queries } = createDbMock([rows]);
|
||||
const { db, queries, select } = createDbMock([rows]);
|
||||
|
||||
await expect(getRecentItems(db, 'playlist-1')).resolves.toEqual(
|
||||
rows
|
||||
@@ -65,6 +73,12 @@ describe('recently-viewed.operations', () => {
|
||||
schema.recentlyViewed.viewedAt
|
||||
);
|
||||
expect(queries[0].limit).toHaveBeenCalledWith(100);
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ export async function getRecentlyViewed(db: AppDatabase) {
|
||||
backdrop_url: schema.content.backdropUrl,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
playlist_id: schema.categories.playlistId,
|
||||
playlist_name: schema.playlists.name,
|
||||
viewed_at: schema.recentlyViewed.viewedAt,
|
||||
@@ -58,6 +60,8 @@ export async function getRecentItems(
|
||||
backdrop_url: schema.content.backdropUrl,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
viewed_at: schema.recentlyViewed.viewedAt,
|
||||
})
|
||||
.from(schema.recentlyViewed)
|
||||
|
||||
@@ -43,7 +43,7 @@ function createEpgDatabaseMock() {
|
||||
}
|
||||
|
||||
describe('EpgDatabase', () => {
|
||||
it('refreshes a source without cascading shared channel programs from other sources', () => {
|
||||
it('refreshes a source selectively, keeping the recent past-programme archive', () => {
|
||||
const sourceUrl = 'https://example.com/playlist-guide.xml';
|
||||
const { Database, database, statements } = createEpgDatabaseMock();
|
||||
const epgDb = new EpgDatabase(Database);
|
||||
@@ -62,8 +62,11 @@ describe('EpgDatabase', () => {
|
||||
const preparedSql = database.prepare.mock.calls.map(([sql]) =>
|
||||
normalizeSql(sql)
|
||||
);
|
||||
const deleteProgramsSql =
|
||||
'DELETE FROM epg_programs WHERE source_url = ?';
|
||||
const deleteTodayAndFutureSql = normalizeSql(`
|
||||
DELETE FROM epg_programs
|
||||
WHERE source_url = ?
|
||||
AND (start >= date('now') OR start < date('now', '-7 days'))
|
||||
`);
|
||||
const deleteOrphanChannelsSql = normalizeSql(`
|
||||
DELETE FROM epg_channels
|
||||
WHERE source_url = ?
|
||||
@@ -74,12 +77,18 @@ describe('EpgDatabase', () => {
|
||||
)
|
||||
`);
|
||||
|
||||
expect(preparedSql).toContain(deleteProgramsSql);
|
||||
expect(preparedSql).toContain(deleteTodayAndFutureSql);
|
||||
expect(preparedSql).toContain(deleteOrphanChannelsSql);
|
||||
// A refresh must not wipe the whole source: the last 7 days of
|
||||
// programmes stay behind so catch-up remains browsable (#1138),
|
||||
// and other sources' shared channels must not cascade away.
|
||||
expect(preparedSql).not.toContain(
|
||||
'DELETE FROM epg_programs WHERE source_url = ?'
|
||||
);
|
||||
expect(preparedSql).not.toContain(
|
||||
'DELETE FROM epg_channels WHERE source_url = ?'
|
||||
);
|
||||
expect(statements.get(deleteProgramsSql)).toHaveBeenCalledWith(
|
||||
expect(statements.get(deleteTodayAndFutureSql)).toHaveBeenCalledWith(
|
||||
sourceUrl
|
||||
);
|
||||
expect(statements.get(deleteOrphanChannelsSql)).toHaveBeenCalledWith(
|
||||
|
||||
@@ -11,8 +11,8 @@ export class EpgDatabase {
|
||||
private readonly knownChannelIds = new Set<string>();
|
||||
private readonly insertChannelStmt: BetterSqlite3.Statement;
|
||||
private readonly insertProgramStmt: BetterSqlite3.Statement;
|
||||
private readonly deleteProgramsForSourceStmt: BetterSqlite3.Statement;
|
||||
private readonly deleteOrphanChannelsForSourceStmt: BetterSqlite3.Statement;
|
||||
private readonly deleteTodayAndFutureStmt: BetterSqlite3.Statement;
|
||||
|
||||
constructor(Database: typeof BetterSqlite3) {
|
||||
this.db = new Database(getIptvnatorDatabasePath());
|
||||
@@ -35,10 +35,6 @@ export class EpgDatabase {
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
this.deleteProgramsForSourceStmt = this.db.prepare(`
|
||||
DELETE FROM epg_programs WHERE source_url = ?
|
||||
`);
|
||||
|
||||
this.deleteOrphanChannelsForSourceStmt = this.db.prepare(`
|
||||
DELETE FROM epg_channels
|
||||
WHERE source_url = ?
|
||||
@@ -48,21 +44,28 @@ export class EpgDatabase {
|
||||
WHERE epg_programs.channel_id = epg_channels.id
|
||||
)
|
||||
`);
|
||||
|
||||
this.deleteTodayAndFutureStmt = this.db.prepare(`
|
||||
DELETE FROM epg_programs
|
||||
WHERE source_url = ?
|
||||
AND (start >= date('now') OR start < date('now', '-7 days'))
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a batch of channels. When `clearFirst` is true, the existing rows
|
||||
* for `sourceUrl` are deleted inside the same transaction as the insert so
|
||||
* old data is preserved if the fetch/parse never produces any channels.
|
||||
* Insert a batch of channels. When `clearTodayAndFuture` is true, the
|
||||
* selective delete (today+future and older-than-7-days) runs inside the
|
||||
* same transaction so a parse failure after the delete atomically rolls
|
||||
* back both — no gap left in the schedule.
|
||||
*/
|
||||
insertChannels(
|
||||
channels: ParsedChannel[],
|
||||
sourceUrl: string,
|
||||
clearFirst = false
|
||||
clearTodayAndFuture = false
|
||||
): void {
|
||||
const insertMany = this.db.transaction((channels: ParsedChannel[]) => {
|
||||
if (clearFirst) {
|
||||
this.deleteProgramsForSourceStmt.run(sourceUrl);
|
||||
if (clearTodayAndFuture) {
|
||||
this.deleteTodayAndFutureStmt.run(sourceUrl);
|
||||
this.deleteOrphanChannelsForSourceStmt.run(sourceUrl);
|
||||
this.knownChannelIds.clear();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user