From d35c255157a02091a4d294eea11f983ca77f247f Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 31 Jul 2026 19:03:46 +0200 Subject: [PATCH] fix(downloads): preserve offline library ownership --- .changes/downloads-manager-mvp.md | 9 +- CLAUDE.md | 16 +- .../playlist-delete-performance.spec.ts | 14 +- .../operations/playlist.operations.ts | 47 +--- .../events/database/downloads-actions.spec.ts | 46 +++ .../app/events/database/downloads.events.ts | 17 +- docs/architecture/download-manager.md | 29 +- docs/architecture/portal-detail-navigation.md | 19 +- docs/architecture/sqlite-db-worker.md | 12 +- docs/architecture/stalker-portal.md | 20 +- ...ownload-library-navigation.service.spec.ts | 266 +++++++++++++++--- .../download-library-navigation.service.ts | 70 +++-- .../feature/src/lib/downloads.component.html | 28 +- .../src/lib/downloads.component.spec.ts | 49 +++- .../feature/src/lib/downloads.component.ts | 24 +- .../download-offline-detail.component.spec.ts | 87 +++++- .../download-offline-detail.component.ts | 11 +- .../download-offline-detail.viewmodel.spec.ts | 43 +++ .../download-offline-detail.viewmodel.ts | 52 +++- .../download-offline-metadata.service.spec.ts | 33 +++ .../download-offline-metadata.service.ts | 30 +- .../lib/stalker-download-recent.matcher.ts | 71 +++++ .../database/src/lib/connection.spec.ts | 52 +++- libs/shared/database/src/lib/connection.ts | 24 +- libs/shared/database/src/lib/schema.ts | 4 +- 25 files changed, 846 insertions(+), 227 deletions(-) create mode 100644 libs/portal/downloads/feature/src/lib/stalker-download-recent.matcher.ts diff --git a/.changes/downloads-manager-mvp.md b/.changes/downloads-manager-mvp.md index c6b89e963..cc3983754 100644 --- a/.changes/downloads-manager-mvp.md +++ b/.changes/downloads-manager-mvp.md @@ -3,7 +3,8 @@ type: feature area: downloads --- -Downloads now open movies and series in focused offline details with saved -metadata and optional TMDB enrichment. Series show only episodes available -locally, while View in portal can return to the source portal for provider -playback when the source item can be recovered. +Downloads now separate active transfers from a grouped offline library, move +missing files to Needs attention with Download again, and open movies and +series in focused offline details with saved metadata. Series show only local +episodes; View in portal returns to provider playback when the source can be +recovered. diff --git a/CLAUDE.md b/CLAUDE.md index f8b5c8d5d..5c18c3125 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -917,12 +917,16 @@ engine` (restart required) or backfilled from row/provider metadata and optional TMDB enrichment when the focused detail opens. - `View in portal` resolves a concrete Xtream category/item route. Stalker - preserves regular, embedded `series[]`, or Ministra `is_series=1` mode only - when a matching recently-viewed snapshot supplies that raw shape; otherwise - it navigates with an identity/title-derived regular VOD or series fallback - that is not existence-checked first. The normal detail uses one-shot - `provider-only` presentation: it exposes provider content/playback it can - resolve while hiding Offline/local/download actions. + accepts a recently-viewed shape only when its raw movie/series mode matches + the download, and prefers an exact numeric category from the download + snapshot. Without that shape, only a movie carrying an exact category can + form a metadata-only target; unproven episode and legacy-movie handoffs stay + unavailable. The normal detail uses one-shot `provider-only` presentation: + it exposes provider content/playback it can resolve while hiding + Offline/local/download actions. +- Download rows and local files survive source deletion. The global offline + library remains visible with no playlists; only provider handoff is disabled + until the source exists again. - If a finalized file disappears while a focused detail is open, the authoritative download list refreshes and returns to the manager. A failed redirect leaves an actionable missing-file state with Back and Retry. diff --git a/apps/electron-backend/src/app/database/operations/playlist-delete-performance.spec.ts b/apps/electron-backend/src/app/database/operations/playlist-delete-performance.spec.ts index 695a273ec..99df1ee5e 100644 --- a/apps/electron-backend/src/app/database/operations/playlist-delete-performance.spec.ts +++ b/apps/electron-backend/src/app/database/operations/playlist-delete-performance.spec.ts @@ -22,7 +22,6 @@ function createDeleteHarness() { ['select:favorites', [{ id: 1 }, { id: 2 }]], ['select:recently-viewed', [{ id: 3 }]], ['select:playback-positions', []], - ['select:downloads', [{ id: 4 }]], ['select:categories', [{ id: 10 }, { id: 11 }]], [ 'select:content', @@ -80,15 +79,14 @@ describe('playlist delete performance phases', () => { ) ).resolves.toEqual({ success: true }); - expect(harness.transaction).toHaveBeenCalledTimes(7); - // Seven committed chunks plus the final playlist row each keep both + expect(harness.transaction).toHaveBeenCalledTimes(6); + // Six committed chunks plus the final playlist row each keep both // the pre-write and post-progress cooperative checkpoints. - expect(checkpoint).toHaveBeenCalledTimes(16); - expect(harness.timeline.slice(1, 7)).toEqual([ + expect(checkpoint).toHaveBeenCalledTimes(14); + expect(harness.timeline.slice(1, 6)).toEqual([ 'select:favorites', 'select:recently-viewed', 'select:playback-positions', - 'select:downloads', 'select:categories', 'select:content', ]); @@ -117,7 +115,7 @@ describe('playlist delete performance phases', () => { }, { boundary: 'end', - metadata: { itemCount: 211 }, + metadata: { itemCount: 210 }, phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, }, { @@ -126,7 +124,7 @@ describe('playlist delete performance phases', () => { }, { boundary: 'end', - metadata: { itemCount: 212 }, + metadata: { itemCount: 211 }, phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, }, ]); diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.ts index 8401fa991..a8e93b2e9 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.ts @@ -591,7 +591,6 @@ export async function updatePlaylist( interface PlaylistDeletionCollection { readonly categoryIds: number[]; readonly contentRows: Array<{ id: number }>; - readonly downloadRows: Array<{ id: number }>; readonly favoriteRows: Array<{ id: number }>; readonly playbackPositionRows: Array<{ id: number }>; readonly recentlyViewedRows: Array<{ id: number }>; @@ -601,29 +600,21 @@ async function collectPlaylistDeletionRows( db: AppDatabase, playlistId: string ): Promise { - const [ - favoriteRows, - recentlyViewedRows, - playbackPositionRows, - downloadRows, - ] = await Promise.all([ - db - .select({ id: schema.favorites.id }) - .from(schema.favorites) - .where(eq(schema.favorites.playlistId, playlistId)), - db - .select({ id: schema.recentlyViewed.id }) - .from(schema.recentlyViewed) - .where(eq(schema.recentlyViewed.playlistId, playlistId)), - db - .select({ id: schema.playbackPositions.id }) - .from(schema.playbackPositions) - .where(eq(schema.playbackPositions.playlistId, playlistId)), - db - .select({ id: schema.downloads.id }) - .from(schema.downloads) - .where(eq(schema.downloads.playlistId, playlistId)), - ]); + const [favoriteRows, recentlyViewedRows, playbackPositionRows] = + await Promise.all([ + db + .select({ id: schema.favorites.id }) + .from(schema.favorites) + .where(eq(schema.favorites.playlistId, playlistId)), + db + .select({ id: schema.recentlyViewed.id }) + .from(schema.recentlyViewed) + .where(eq(schema.recentlyViewed.playlistId, playlistId)), + db + .select({ id: schema.playbackPositions.id }) + .from(schema.playbackPositions) + .where(eq(schema.playbackPositions.playlistId, playlistId)), + ]); const categoryRows = await db .select({ id: schema.categories.id }) @@ -641,7 +632,6 @@ async function collectPlaylistDeletionRows( return { categoryIds, contentRows, - downloadRows, favoriteRows, playbackPositionRows, recentlyViewedRows, @@ -655,7 +645,6 @@ function countPlaylistDeletionRows( collection.favoriteRows.length + collection.recentlyViewedRows.length + collection.playbackPositionRows.length + - collection.downloadRows.length + collection.contentRows.length + collection.categoryIds.length ); @@ -686,12 +675,6 @@ async function deleteCollectedPlaylistRows( schema.playbackPositions.id, schema.playbackPositions, ], - [ - 'deleting-downloads', - collection.downloadRows.map((row) => row.id), - schema.downloads.id, - schema.downloads, - ], [ 'deleting-content', collection.contentRows.map((row) => row.id), diff --git a/apps/electron-backend/src/app/events/database/downloads-actions.spec.ts b/apps/electron-backend/src/app/events/database/downloads-actions.spec.ts index a65252d41..808251107 100644 --- a/apps/electron-backend/src/app/events/database/downloads-actions.spec.ts +++ b/apps/electron-backend/src/app/events/database/downloads-actions.spec.ts @@ -2,6 +2,7 @@ import { expectManagedPathLookup, getHandler, MANAGED_PATH_STATE, + mockBroadcastDownloadUpdate, mockLstatSync, mockManagedPath, mockOpenPath, @@ -85,6 +86,7 @@ describe('downloads events: pause, resume, and reveal', () => { success: true, }); expect(mockRedownloadMissingRequest).toHaveBeenCalledWith(42); + expect(mockBroadcastDownloadUpdate).toHaveBeenCalledTimes(1); }); describe.each([ @@ -245,4 +247,48 @@ describe('downloads events: pause, resume, and reveal', () => { resolveOpenPath(''); await expect(response).resolves.toEqual({ success: true }); }); + + it('returns the native shell error when playback fails but the file remains available', async () => { + const filePath = '/downloads/play-shell-error.mp4'; + const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED); + mockLstatSync.mockReturnValue({ + isFile: () => true, + isSymbolicLink: () => false, + }); + mockOpenPath.mockResolvedValue('No application can open this file'); + + await expect( + getHandler('DOWNLOADS_PLAY_FILE')(null, filePath) + ).resolves.toEqual({ + error: 'No application can open this file', + success: false, + }); + + expectManagedPathLookup(lookup, filePath); + expect(mockLstatSync).toHaveBeenCalledTimes(2); + }); + + it('reports a file-disappeared race when the native shell cannot open the path', async () => { + const filePath = '/downloads/play-disappeared.mp4'; + const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED); + mockLstatSync + .mockReturnValueOnce({ + isFile: () => true, + isSymbolicLink: () => false, + }) + .mockImplementationOnce(() => { + throw new Error('ENOENT'); + }); + mockOpenPath.mockResolvedValue('The file does not exist'); + + await expect( + getHandler('DOWNLOADS_PLAY_FILE')(null, filePath) + ).resolves.toEqual({ + error: 'File not found', + success: false, + }); + + expectManagedPathLookup(lookup, filePath); + expect(mockLstatSync).toHaveBeenCalledTimes(2); + }); }); diff --git a/apps/electron-backend/src/app/events/database/downloads.events.ts b/apps/electron-backend/src/app/events/database/downloads.events.ts index ac1ba8fc0..d5ce12070 100644 --- a/apps/electron-backend/src/app/events/database/downloads.events.ts +++ b/apps/electron-backend/src/app/events/database/downloads.events.ts @@ -167,7 +167,13 @@ ipcMain.handle( ipcMain.handle( 'DOWNLOADS_REDOWNLOAD_MISSING', - async (_event, downloadId: number) => redownloadMissingRequest(downloadId) + async (_event, downloadId: number) => { + const result = await redownloadMissingRequest(downloadId); + if (result.recovered) { + broadcastDownloadUpdate(); + } + return result; + } ); ipcMain.handle('DOWNLOADS_REMOVE', async (_event, downloadId: number) => { @@ -290,8 +296,13 @@ ipcMain.handle('DOWNLOADS_PLAY_FILE', async (_event, filePath: string) => { ) { return { error: 'File not found', success: false }; } - await shell.openPath(filePath); - return { success: true }; + const error = await shell.openPath(filePath); + if (!error) { + return { success: true }; + } + return isAvailableDownloadFile(filePath) + ? { error, success: false } + : { error: 'File not found', success: false }; }); ipcMain.handle( diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 29bd15974..d9af552b4 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -115,14 +115,17 @@ variants, contextual buttons, and theme-aware styling. targets that row's local file. The provider's other seasons and episodes are deliberately absent from this view. - `View in portal` resolves an exact category/item route for Xtream. For - Stalker it preserves the raw mode and item shape when a matching - recently-viewed snapshot is available; otherwise it builds a regular VOD or - series target from the persisted identity and title without first proving - that the provider still serves it. The handoff opens the normal provider - detail host in explicit `provider-only` presentation: provider content and - playback remain available when that host resolves them, while local, - Offline, and download actions are hidden. Regular provider navigation does - not inherit this one-shot presentation state. + Stalker it accepts a matching recently-viewed snapshot only when its raw + movie/regular-series/VOD-series markers agree with the download type, so + overlapping movie and series ids cannot select the wrong item. An exact + numeric category stored in the download snapshot wins over the recent + collection's virtual `vod`/`series` category. Without a matching recent + shape, only a movie with that exact numeric category can form a + metadata-only target; episodes and legacy movies without one leave the + handoff unavailable. The normal provider detail opens in one-shot + `provider-only` presentation: provider content and playback remain available + when that host resolves them, while local, Offline, and download actions are + hidden. - A completed row that is no longer locally available is never rendered as a ready offline detail. Direct or stale detail URLs return to the manager; if navigation fails, the detail shell shows the missing-file error with Back and @@ -132,6 +135,10 @@ variants, contextual buttons, and theme-aware styling. including any TMDB fields already present. The snapshot keeps provider identity/category separately from presentation metadata and lets the offline view render even when the source portal is unavailable. +- A grouped series selects its newest valid parent snapshot, then fills only + missing parent metadata from older valid member snapshots. Newer values, + per-episode metadata, language, and freshness identity remain authoritative. + Each episode row still uses its own stored episode metadata. - Legacy rows and sparse, stale, or wrong-language snapshots are backfilled when focused details open: row metadata supplies a safe local fallback, provider data is merged when it can be resolved, and opt-in TMDB enrichment @@ -162,11 +169,15 @@ variants, contextual buttons, and theme-aware styling. `/workspace/stalker/:id/downloads/:downloadId`. The workspace shell treats all three as focused content: route search is disabled and the context panel is `none`, so provider categories are not shown beside a local-only item. +- The manager persists its selected All/Movies/Series/In progress filter in + the current route query with `replaceUrl`. Opening a focused item then keeps + that exact scoped URL as its validated return target, so Back restores the + manager's scope, search query, filter, and browser-history position. - Downloads navigation is data-driven: `libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts` emits a `downloads` section link (`path: [...root, 'downloads']`) for both portals, so they reuse the same download page. ## Queuing, persistence, and UX notes -- Every download row writes to the shared `downloads` table with statuses (`queued`, `downloading`, `paused`, `completed`, `failed`, `canceled`) plus metadata such as `bytesDownloaded`, `totalBytes`, `errorMessage`, `requestHeaders`, `resumeValidator`, the offline-detail metadata snapshot, and Xtream identifiers. Existing SQLite tables are rebuilt on startup when their status CHECK still lacks `paused`; additive columns are applied through the idempotent column migrations. +- Every download row writes to the shared `downloads` table with statuses (`queued`, `downloading`, `paused`, `completed`, `failed`, `canceled`) plus metadata such as `bytesDownloaded`, `totalBytes`, `errorMessage`, `requestHeaders`, `resumeValidator`, the offline-detail metadata snapshot, and Xtream identifiers. Downloads are locally owned records rather than playlist children: deleting a source retains its rows and local files, keeps them visible in the global library, and disables provider handoff until that source exists again. Startup rebuilds older tables that still carry the playlist foreign key, while additive columns use the idempotent column migrations. - On startup, `download-recovery.ts` converts stale `downloading` rows with a non-empty `.part` file to `paused`, converts stale `queued` rows to `paused` while keeping any retained `.part` (a resumed download waiting behind an active one persists as `queued` with its partial), and marks stale `downloading` rows without recoverable partial bytes as `failed`. - Queue cancellation removes a queued task or records an active cancellation request and aborts the request when available. Pausing follows the same abort path but persists `paused` and keeps the `.part`. Retries reuse the same database entry: a failed row with a retained `filePath` resumes its `.part` through HTTP Range, otherwise the retry starts from zero. Resume appends to the existing `.part` through HTTP Range with `If-Range` validation. - A `.part` that cannot be deleted (locked, permission denied) never loses its database path: cancel persists `canceled` while retaining `filePath` for later cleanup, and `DOWNLOADS_REMOVE` keeps the row and answers `success: false` (surfaced as a snackbar) so retrying the remove re-attempts the deletion once the lock is released. diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index e65feafe7..dde0ed4be 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -161,13 +161,18 @@ Behavior to preserve: Download handoff behavior: - `View in portal` preserves Stalker's inline/store-state architecture. A - matching recently-viewed snapshot is carried as `openStalkerItem` into the - normal category host, preserving regular series, embedded VOD `series[]`, or - lazy Ministra VOD `is_series=1` shape when that raw snapshot exists. -- When no matching snapshot exists, a movie download falls back to a regular - VOD item and an episode download to a regular-series item derived from the - persisted provider identity and title. This fallback is not existence-checked - before navigation and cannot reconstruct embedded or `is_series=1` mode. + type-compatible recently-viewed snapshot is carried as `openStalkerItem` + into the normal category host, preserving regular series, embedded VOD + `series[]`, or lazy Ministra VOD `is_series=1` shape. Candidate filtering + rejects live items and the opposite movie/series namespace even when ids + overlap. When the download snapshot carries an exact numeric provider + category, that category wins over the recent record's virtual `vod` or + `series` marker while the raw mode fields stay intact. +- When no compatible recent snapshot exists, only a movie download with an + exact numeric provider category can form a metadata-only VOD target. A + legacy movie without that category and every episode without a recoverable + raw series mode leave `View in portal` unavailable instead of fabricating an + unverified provider target. - The provider-only marker is scoped to the resulting selected item. Its normal provider host supplies the seasons, episodes, and playback it can resolve, while the shared VOD or series UI suppresses local Offline and download diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 13357bc3a..d0893c2ba 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -245,11 +245,13 @@ counts only content and category deletion candidates; favorite, recently-viewed, and hidden-category user data is timed but is not added to that count. The matching write count uses the same deletion-candidate definition. Playlist-delete collection counts every collected favorite, -recently-viewed, playback-position, download, content, and category ID. Its -write count adds the final playlist row. Both write spans include every -existing cooperative checkpoint, 100-row transaction, progress callback, and, -for playlist deletion, the final playlist-row autocommit; they are not exact -SQLite commit-time measurements. +recently-viewed, playback-position, content, and category ID. Download rows are +intentionally excluded: they own local offline files independently of the +source playlist and survive source deletion, with provider handoff disabled +while that source is absent. The write count adds the final playlist row. Both +write spans include every existing cooperative checkpoint, 100-row +transaction, progress callback, and, for playlist deletion, the final +playlist-row autocommit; they are not exact SQLite commit-time measurements. Successful end markers carry only row/item counts. Error or cancellation still closes the active phase without metadata and preserves the original error. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 4f96eb2fa..9c766986c 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -369,15 +369,17 @@ The VOD-series contract is cross-surface: Manager detail uses only locally available episode rows; it does not reuse the provider season resource as an offline availability list. - `View in portal` first looks for a matching recently-viewed Stalker snapshot. - When found, it hands the raw shape to the normal category/inline detail host, - so regular series, embedded `series[]`, and lazy `is_series=1` mode are - preserved with identity-scoped provider-only presentation. -- Without that matching snapshot, the episode download supplies only persisted - identity/title metadata and falls back to a regular-series item. The fallback - is not existence-checked before navigation and cannot reconstruct embedded or - `is_series=1` mode. In either path, the provider host renders whatever - seasons, episodes, and playback it can resolve while Offline/local/download - controls stay hidden. A normal Stalker item open remains unchanged. + Candidates must match both identity and the requested movie/series mode, so + overlapping provider ids cannot bind an episode to a movie or vice versa. + When found, the handoff preserves the raw regular-series, embedded + `series[]`, or lazy `is_series=1` shape; an exact numeric category from the + download snapshot replaces a virtual `vod`/`series` collection category. +- Without that compatible snapshot, only a movie with an exact persisted + numeric category can form a metadata-only VOD target. Episodes and legacy + movies without that proof leave the provider handoff unavailable rather than + inventing a regular-series or generic VOD target. When a target does resolve, + the provider host renders its content in identity-scoped provider-only + presentation while Offline/local/download controls stay hidden. Core decision logic and normalization are centralized in: diff --git a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts index 25082e564..a31da7dcf 100644 --- a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts +++ b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts @@ -198,6 +198,14 @@ describe('DownloadLibraryNavigationService', () => { async (source) => { if (source === 'stalker') { playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); + playlists.getPortalRecentlyViewed.mockReturnValue( + of([ + { + movie_id: '41', + category_id: 'vod', + }, + ]) + ); } else { db.getContentByXtreamId.mockResolvedValue({ category_id: 7, @@ -346,6 +354,33 @@ describe('DownloadLibraryNavigationService', () => { ); }); + it('preserves a numeric Stalker category in the provider route and item state', async () => { + playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); + playlists.getPortalRecentlyViewed.mockReturnValue( + of([ + { + movie_id: '41', + category_id: 7, + title: 'Categorized movie', + }, + ]) + ); + + await expect(navigation.open(download())).resolves.toBe(true); + + expect(router.navigate).toHaveBeenCalledWith( + ['/workspace', 'stalker', PLAYLIST_ID, 'vod', '7'], + { + state: { + detailPresentation: 'provider-only', + openStalkerItem: expect.objectContaining({ + category_id: '7', + }), + }, + } + ); + }); + it.each([ ['is_series flag', { is_series: '1' }], ['embedded episode list', { series: [1, 2] }], @@ -392,13 +427,13 @@ describe('DownloadLibraryNavigationService', () => { } ); - it.each(['id', 'movie_id', 'series_id', 'stream_id'] as const)( + it.each(['id', 'movie_id', 'stream_id'] as const)( 'matches a Stalker recent item by normalized %s', async (idKey) => { playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); const recent = { [idKey]: '41:archive', - category_id: 'series', + category_id: 'vod', title: 'Recent title', name: 'Recent name', preserved: 'yes', @@ -409,14 +444,14 @@ describe('DownloadLibraryNavigationService', () => { const expectedId = idKey === 'stream_id' ? '41' : recent[idKey]; expect(router.navigate).toHaveBeenCalledWith( - ['/workspace', 'stalker', PLAYLIST_ID, 'vod', 'series'], + ['/workspace', 'stalker', PLAYLIST_ID, 'vod', 'vod'], { state: { detailPresentation: 'provider-only', openStalkerItem: { ...recent, id: expectedId, - category_id: 'series', + category_id: 'vod', title: 'Recent title', name: 'Recent name', }, @@ -429,7 +464,7 @@ describe('DownloadLibraryNavigationService', () => { it('preserves a matched Stalker item and fills its normalized display fields', async () => { playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); const recent = { - series_id: '41:episode', + movie_id: '41:archive', category_id: 'movie', o_name: 'Portal original name', preserved: { token: 1 }, @@ -447,7 +482,7 @@ describe('DownloadLibraryNavigationService', () => { detailPresentation: 'provider-only', openStalkerItem: { ...recent, - id: '41:episode', + id: '41:archive', category_id: 'vod', title: 'Downloaded title', name: 'Portal original name', @@ -457,11 +492,83 @@ describe('DownloadLibraryNavigationService', () => { ); }); + it('ignores an overlapping movie ID when recovering a Stalker series', async () => { + playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); + playlists.getPortalRecentlyViewed.mockReturnValue( + of([ + { + movie_id: '72', + category_id: 'vod', + title: 'Wrong movie', + }, + { + series_id: '72', + category_id: 'series', + title: 'Correct series', + }, + ]) + ); + + await expect( + navigation.open( + download({ + contentType: 'episode', + xtreamId: 501, + seriesXtreamId: 72, + }) + ) + ).resolves.toBe(true); + + expect(router.navigate).toHaveBeenCalledWith( + ['/workspace', 'stalker', PLAYLIST_ID, 'series', 'series'], + { + state: { + detailPresentation: 'provider-only', + openStalkerItem: expect.objectContaining({ + series_id: '72', + title: 'Correct series', + }), + }, + } + ); + }); + + it('ignores an overlapping series ID when recovering a Stalker movie', async () => { + playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); + playlists.getPortalRecentlyViewed.mockReturnValue( + of([ + { + series_id: '41', + category_id: 'series', + title: 'Wrong series', + }, + { + movie_id: '41', + category_id: 'vod', + title: 'Correct movie', + }, + ]) + ); + + await expect(navigation.open(download())).resolves.toBe(true); + + expect(router.navigate).toHaveBeenCalledWith( + ['/workspace', 'stalker', PLAYLIST_ID, 'vod', 'vod'], + { + state: { + detailPresentation: 'provider-only', + openStalkerItem: expect.objectContaining({ + movie_id: '41', + title: 'Correct movie', + }), + }, + } + ); + }); + it.each([ ['VOD', 'vod', 'vod'], - ['SERIES', 'series', 'vod'], - ['ItV', 'itv', 'vod'], - ['radio', 'vod', 'vod'], + ['SERIES', 'series', 'episode'], ['unknown', 'series', 'episode'], ] as const)( 'normalizes Stalker category %s to %s', @@ -470,7 +577,9 @@ describe('DownloadLibraryNavigationService', () => { playlists.getPortalRecentlyViewed.mockReturnValue( of([ { - id: '41', + ...(contentType === 'episode' + ? { series_id: '41' } + : { id: '41' }), category_id: categoryId, }, ]) @@ -498,22 +607,113 @@ describe('DownloadLibraryNavigationService', () => { } ); + it('prefers the persisted numeric category for a recovered Stalker VOD series', async () => { + playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); + playlists.getPortalRecentlyViewed.mockReturnValue( + of([ + { + movie_id: '72', + category_id: 'series', + is_series: '1', + title: 'Recovered VOD series', + }, + ]) + ); + const item = download({ + contentType: 'episode', + xtreamId: 501, + seriesXtreamId: 72, + metadataSnapshot: { + version: 1, + language: 'en', + mediaKind: 'series', + title: 'Recovered VOD series', + providerCategoryId: '18', + }, + }); + + await expect(navigation.open(item)).resolves.toBe(true); + + expect(router.navigate).toHaveBeenCalledWith( + ['/workspace', 'stalker', PLAYLIST_ID, 'vod', '18'], + { + state: { + detailPresentation: 'provider-only', + openStalkerItem: expect.objectContaining({ + movie_id: '72', + category_id: '18', + is_series: '1', + }), + }, + } + ); + }); + + it('prefers the persisted numeric category for a recovered regular Stalker series', async () => { + playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); + playlists.getPortalRecentlyViewed.mockReturnValue( + of([ + { + series_id: '72', + category_id: 'series', + title: 'Recovered regular series', + }, + ]) + ); + const item = download({ + contentType: 'episode', + xtreamId: 501, + seriesXtreamId: 72, + metadataSnapshot: { + version: 1, + language: 'en', + mediaKind: 'series', + title: 'Recovered regular series', + providerCategoryId: '19', + }, + }); + + await expect(navigation.open(item)).resolves.toBe(true); + + expect(router.navigate).toHaveBeenCalledWith( + ['/workspace', 'stalker', PLAYLIST_ID, 'series', '19'], + { + state: { + detailPresentation: 'provider-only', + openStalkerItem: expect.objectContaining({ + series_id: '72', + category_id: '19', + }), + }, + } + ); + }); + it('uses download metadata when no Stalker recent item matches', async () => { playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); playlists.getPortalRecentlyViewed.mockReturnValue( of([{ id: '999', category_id: 'vod' }]) ); + const item = download({ + metadataSnapshot: { + version: 1, + language: 'en', + mediaKind: 'movie', + title: 'Downloaded title', + providerCategoryId: '18', + }, + }); - await expect(navigation.open(download())).resolves.toBe(true); + await expect(navigation.open(item)).resolves.toBe(true); expect(router.navigate).toHaveBeenCalledWith( - ['/workspace', 'stalker', PLAYLIST_ID, 'vod', 'vod'], + ['/workspace', 'stalker', PLAYLIST_ID, 'vod', '18'], { state: { detailPresentation: 'provider-only', openStalkerItem: { id: '41', - category_id: 'vod', + category_id: '18', title: 'Downloaded title', name: 'Downloaded title', o_name: 'Downloaded title', @@ -525,7 +725,18 @@ describe('DownloadLibraryNavigationService', () => { ); }); - it('uses episode download metadata when Stalker recent loading fails', async () => { + it('does not invent a generic Stalker movie category for a legacy row', async () => { + playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); + playlists.getPortalRecentlyViewed.mockReturnValue(of([])); + + await expect( + navigation.resolveProviderTarget(download()) + ).resolves.toBeNull(); + + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('does not fabricate a regular-series target when the Stalker episode mode cannot be recovered', async () => { playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); playlists.getPortalRecentlyViewed.mockReturnValue( throwError(() => new Error('recent failed')) @@ -536,28 +747,11 @@ describe('DownloadLibraryNavigationService', () => { seriesXtreamId: 72, }); - await expect(navigation.open(item)).resolves.toBe(true); + await expect( + navigation.resolveProviderTarget(item) + ).resolves.toBeNull(); + await expect(navigation.open(item)).resolves.toBe(false); - expect(router.navigate).toHaveBeenCalledWith( - ['/workspace', 'stalker', PLAYLIST_ID, 'series', 'series'], - { - state: { - detailPresentation: 'provider-only', - openStalkerItem: { - id: '72', - category_id: 'series', - title: 'Downloaded title', - name: 'Downloaded title', - o_name: 'Downloaded title', - cover: 'https://media.example.test/poster.jpg', - logo: 'https://media.example.test/poster.jpg', - }, - }, - } - ); - expect(router.navigate).not.toHaveBeenCalledWith( - expect.arrayContaining(['recent']), - expect.anything() - ); + expect(router.navigate).not.toHaveBeenCalled(); }); }); diff --git a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts index 95f994c67..aaed330a4 100644 --- a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts +++ b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts @@ -12,13 +12,13 @@ import { PROVIDER_ONLY_DETAIL_PRESENTATION_STATE_KEY, type WorkspaceNavigationTarget, } from '@iptvnator/portal/shared/util'; -import type { StalkerPortalItem } from '@iptvnator/shared/interfaces'; +import { findMatchingStalkerDownloadRecent } from './stalker-download-recent.matcher'; type PortalSource = 'xtream' | 'stalker'; type StalkerCategory = 'vod' | 'series' | 'itv'; type StalkerFallbackCategory = Exclude; type StalkerOpenItem = Record & { - category_id: StalkerCategory; + category_id: string; }; @Injectable() @@ -150,39 +150,22 @@ export class DownloadLibraryNavigationService { }; } - private normalizePortalItemId(value: unknown): string { + private stalkerCategory(value: unknown, fallback: string): string { const raw = String(value ?? '').trim(); - return raw.includes(':') ? raw.split(':')[0] : raw; - } - - private stalkerCategory( - value: unknown, - fallback: StalkerFallbackCategory - ): StalkerCategory { - const normalized = String(value ?? '').toLowerCase(); + const normalized = raw.toLowerCase(); if (normalized === 'movie') { return 'vod'; } - return normalized === 'vod' || + if ( + normalized === 'vod' || normalized === 'series' || normalized === 'itv' - ? normalized - : fallback; - } + ) { + return normalized; + } - private findMatchingStalkerRecentItem( - items: readonly StalkerPortalItem[], - targetId: number - ): StalkerPortalItem | undefined { - const expectedId = String(targetId); - - return items.find((item) => - [item.id, item.movie_id, item.series_id, item.stream_id].some( - (candidate) => - this.normalizePortalItemId(candidate) === expectedId - ) - ); + return /^\d+$/.test(raw) ? raw : fallback; } private isStalkerVodSeries(item: Record): boolean { @@ -201,14 +184,21 @@ export class DownloadLibraryNavigationService { item: DownloadItem, targetId: number, fallback: StalkerFallbackCategory - ): Promise { + ): Promise { + const persistedCategory = + item.metadataSnapshot?.providerCategoryId?.trim(); + const exactPersistedCategory = + persistedCategory && /^\d+$/.test(persistedCategory) + ? persistedCategory + : null; try { const recent = await firstValueFrom( this.playlists.getPortalRecentlyViewed(item.playlistId) ); - const matched = this.findMatchingStalkerRecentItem( + const matched = findMatchingStalkerDownloadRecent( recent, - targetId + targetId, + item.contentType ); if (matched) { @@ -219,21 +209,24 @@ export class DownloadLibraryNavigationService { matched.series_id ?? matched.movie_id ?? String(targetId), - category_id: this.stalkerCategory( - matched.category_id, - fallback - ), + category_id: + exactPersistedCategory ?? + this.stalkerCategory(matched.category_id, fallback), title: matched.title ?? item.title, name: matched.name ?? matched.o_name ?? item.title, }; } } catch { - // Persisted download metadata is enough to open provider details. + // Fall through to the safe metadata-only movie target. + } + + if (item.contentType === 'episode' || !exactPersistedCategory) { + return null; } return { id: String(targetId), - category_id: fallback, + category_id: exactPersistedCategory, title: item.title, name: item.title, o_name: item.title, @@ -245,13 +238,16 @@ export class DownloadLibraryNavigationService { private async resolveStalkerTarget( item: DownloadItem, targetId: number - ): Promise { + ): Promise { const fallback = item.contentType === 'episode' ? 'series' : 'vod'; const openStalkerItem = await this.stalkerOpenState( item, targetId, fallback ); + if (!openStalkerItem) { + return null; + } const isVodSeries = item.contentType === 'episode' && this.isStalkerVodSeries(openStalkerItem); diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.html b/libs/portal/downloads/feature/src/lib/downloads.component.html index f19356ade..b9eff4737 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.html +++ b/libs/portal/downloads/feature/src/lib/downloads.component.html @@ -87,7 +87,11 @@ } - @if (isAvailable() && playlistsLoaded() && !hasNoPlaylists()) { + @if ( + isAvailable() && + playlistsLoaded() && + (!hasNoPlaylists() || hasScopedDownloads()) + ) {