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:
StefanandClaude authored and GitHub committed 2026-07-19 06:38:19 +02:00
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,
@@ -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)