feat(downloads): redesign download manager (#1313)

* docs(downloads): specify manager MVP redesign

* docs(downloads): plan manager MVP implementation

* docs(downloads): tighten manager validation plan

* fix(downloads): keep renderer download state global

* fix(downloads): make active count accessible

* feat(downloads): derive queue and library view model

* test(downloads): close view model coverage gaps

* fix(downloads): stabilize malformed view model data

* refactor(downloads): isolate library navigation

* fix(downloads): report library navigation failures

* feat(downloads): add ready-to-watch library

* feat(downloads): add active download queue

* feat(downloads): finish manager MVP

* docs(downloads): clarify detail-first offline behavior

* docs(downloads): plan detail navigation follow-up

* fix(downloads): open completed movies in details

* test(downloads): cover pending series navigation

* fix(downloads): honor the global cover size

* fix(downloads): prefer local playback in shared details

* fix(downloads): preserve external launch priority

* fix(downloads): prefer local playback in Xtream details

* test(downloads): cover offline detail journey

* docs(downloads): document offline detail behavior

* docs(downloads): format detail navigation plan

* fix(downloads): open Stalker items in provider details

* docs(downloads): clarify Stalker navigation fallback

* fix(xtream): isolate reused detail identities

* fix(xtream): ignore stale VOD positions

* fix(downloads): keep offline Xtream playback available

* docs(downloads): clarify provider playback availability

* docs(downloads): design missing-file recovery

* docs(downloads): plan missing-file recovery

* feat(downloads): derive completed file availability

* feat(downloads): recover missing completed files

* feat(downloads): refresh missing local files

* feat(downloads): separate missing files from ready media

* feat(downloads): surface missing files for recovery

* refactor(downloads): simplify ready cards

* test(downloads): cover missing-file and series journeys

* feat(downloads): finish missing-file recovery

* docs(downloads): design offline detail views

* docs(downloads): plan offline detail views

* feat(downloads): persist offline metadata snapshots

* fix(downloads): complete metadata snapshot bridge contract

* feat(downloads): manage offline metadata snapshots

* fix(downloads): harden metadata snapshot updates

* fix(downloads): restrict snapshot artwork

* fix(downloads): guard restart artwork URL

* fix(downloads): refine artwork URL checks

* feat(downloads): expose offline metadata updates

* fix(downloads): keep metadata service change focused

* fix(downloads): preserve metadata error conventions

* feat(downloads): derive offline detail content

* fix(downloads): preserve unknown episode coordinates

* feat(downloads): add focused offline detail routes

* fix(downloads): ignore fragments in shell route state

* fix(downloads): normalize fragments before queries

* feat(downloads): open ready cards in offline details

* fix(downloads): use native disabled card styles

* feat(downloads): enrich offline detail metadata

* fix(downloads): harden offline metadata resolution

* fix(downloads): preserve stalker provider titles

* fix(downloads): distinguish stalker metadata seeds

* fix(downloads): stabilize offline metadata refresh

* fix(downloads): throttle sparse metadata refreshes

* fix(downloads): type metadata language settings

* feat(downloads): render offline movie and series details

* fix(downloads): harden offline detail interactions

* fix(downloads): close offline detail edge cases

* feat(downloads): hand off to provider-only details

* fix(downloads): preserve stalker provider handoff

* feat(downloads): capture metadata at download time

* fix(downloads): preserve snapshot source semantics

* fix(downloads): preserve episode snapshot identity

* docs(downloads): document offline details flow

* docs(downloads): clarify stalker provider fallback

* test(downloads): cover offline detail journeys

* test(downloads): stabilize offline detail selectors

* style(downloads): format changed files

* docs(downloads): clean design spec formatting

* fix(downloads): preserve offline library ownership

* test(downloads): fix Windows workspace navigation

* test(database): preserve Electron tsconfig resolution

* perf(downloads): avoid blocking file availability probes
This commit is contained in:
4gray authored and GitHub committed 2026-08-01 18:09:31 +02:00
1 parent 46c58f4f57
commit 760099358b
188 files changed
+31245 -2281

No files matched your search

@@ -54,6 +54,199 @@ describe('workspace-shell-route.utils', () => {
expect(parseWorkspaceShellRoute('/workspace/downloads')).toEqual(
expect.objectContaining({
kind: 'downloads',
section: null,
contextPanel: 'none',
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
});
it('disables collection shell state for focused download details', () => {
expect(
parseWorkspaceShellRoute('/workspace/downloads/42?q=x#details')
).toEqual(
expect.objectContaining({
kind: 'downloads',
context: null,
section: null,
contextPanel: 'none',
searchMode: 'none',
usesQuerySearch: false,
})
);
expect(
parseWorkspaceShellRoute('/workspace/xtreams/pl-1/downloads/42')
).toEqual(
expect.objectContaining({
kind: 'portal',
context: {
provider: 'xtreams',
playlistId: 'pl-1',
},
section: 'downloads',
contextPanel: 'none',
searchMode: 'none',
usesQuerySearch: false,
})
);
expect(
parseWorkspaceShellRoute('/workspace/stalker/pl-1/downloads/42')
).toEqual(
expect.objectContaining({
kind: 'portal',
context: {
provider: 'stalker',
playlistId: 'pl-1',
},
section: 'downloads',
contextPanel: 'none',
searchMode: 'none',
usesQuerySearch: false,
})
);
});
it.each([
[
'keeps Xtream query state before a fragment',
'/workspace/xtreams/pl-1/favorites?scope=all#details',
true,
],
[
'keeps Stalker query state before a fragment',
'/workspace/stalker/pl-1/favorites?scope=all#details',
true,
],
[
'ignores query-like text inside a fragment',
'/workspace/xtreams/pl-1/favorites#details?scope=all',
false,
],
])('%s', (_name, url, expectedAllScope) => {
expect(parseWorkspaceShellRoute(url).isPortalFavoritesAllScope).toBe(
expectedAllScope
);
});
it('keeps manager shell state outside exact focused download routes', () => {
expect(parseWorkspaceShellRoute('/workspace/downloads/')).toEqual(
expect.objectContaining({
kind: 'downloads',
contextPanel: 'none',
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
expect(
parseWorkspaceShellRoute('/workspace/downloads/42/extra')
).toEqual(
expect.objectContaining({
kind: 'downloads',
contextPanel: 'none',
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
expect(
parseWorkspaceShellRoute('/workspace/xtreams/pl-1/downloads')
).toEqual(
expect.objectContaining({
kind: 'portal',
section: 'downloads',
contextPanel: 'collection',
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
expect(
parseWorkspaceShellRoute(
'/workspace/xtreams/pl-1/downloads/42/extra'
)
).toEqual(
expect.objectContaining({
kind: 'portal',
section: 'downloads',
contextPanel: 'collection',
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
expect(
parseWorkspaceShellRoute('/workspace/stalker/pl-1/downloads')
).toEqual(
expect.objectContaining({
kind: 'portal',
section: 'downloads',
contextPanel: 'collection',
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
expect(
parseWorkspaceShellRoute(
'/workspace/stalker/pl-1/downloads/42/extra'
)
).toEqual(
expect.objectContaining({
kind: 'portal',
section: 'downloads',
contextPanel: 'collection',
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
});
it('ignores fragments when identifying download manager routes', () => {
expect(
parseWorkspaceShellRoute('/workspace/downloads/#details')
).toEqual(
expect.objectContaining({
kind: 'downloads',
contextPanel: 'none',
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
expect(
parseWorkspaceShellRoute(
'/workspace/xtreams/pl-1/downloads/#details'
)
).toEqual(
expect.objectContaining({
kind: 'portal',
section: 'downloads',
contextPanel: 'collection',
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
expect(
parseWorkspaceShellRoute(
'/workspace/stalker/pl-1/downloads/#details'
)
).toEqual(
expect.objectContaining({
kind: 'portal',
section: 'downloads',
contextPanel: 'collection',
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
expect(
parseWorkspaceShellRoute(
'/workspace/downloads/#details?query=ignored'
)
).toEqual(
expect.objectContaining({
kind: 'downloads',
contextPanel: 'none',
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
});
@@ -103,9 +296,7 @@ describe('workspace-shell-route.utils', () => {
});
it('parses Stalker portal routes and detects context-panel/query-search state', () => {
expect(
parseWorkspaceShellRoute('/workspace/stalker/pl-2/itv')
).toEqual(
expect(parseWorkspaceShellRoute('/workspace/stalker/pl-2/itv')).toEqual(
expect.objectContaining({
kind: 'portal',
context: {
@@ -15,11 +15,7 @@ export type WorkspaceShellPageKind =
| 'unknown';
export type WorkspaceShellContextPanel =
| 'none'
| 'sources'
| 'settings'
| 'category'
| 'collection';
'none' | 'sources' | 'settings' | 'category' | 'collection';
export interface WorkspacePortalContext {
provider: PortalProvider;
@@ -27,10 +23,7 @@ export interface WorkspacePortalContext {
}
export type WorkspaceShellSearchMode =
| 'none'
| 'local-filter'
| 'remote-search'
| 'advanced-only';
'none' | 'local-filter' | 'remote-search' | 'advanced-only';
export interface WorkspaceShellRoute {
kind: WorkspaceShellPageKind;
@@ -93,7 +86,8 @@ function normalizePath(url: string): {
segments: string[];
queryParams: URLSearchParams;
} {
const [path, query = ''] = url.split('?');
const [urlWithoutFragment] = url.split('#');
const [path, query = ''] = urlWithoutFragment.split('?');
return {
segments: path.split('/').filter(Boolean),
queryParams: new URLSearchParams(query),
@@ -266,6 +260,11 @@ export function parseWorkspaceShellRoute(url: string): WorkspaceShellRoute {
playlistId: segments[2],
};
const section = asPortalRailSection(sectionSegment);
const isFocusedDownloadDetail =
(provider === 'xtreams' || provider === 'stalker') &&
section === 'downloads' &&
segments.length === 5 &&
Boolean(segments[4]);
return {
kind: 'portal',
@@ -275,9 +274,15 @@ export function parseWorkspaceShellRoute(url: string): WorkspaceShellRoute {
section === 'favorites' &&
(provider === 'xtreams' || provider === 'stalker') &&
queryParams.get('scope') === 'all',
searchMode: resolveRouteSearchMode('portal', context, section),
usesQuerySearch: usesWorkspaceRouteQuerySearch(context, section),
contextPanel: resolveContextPanel('portal', context, section),
searchMode: isFocusedDownloadDetail
? 'none'
: resolveRouteSearchMode('portal', context, section),
usesQuerySearch: isFocusedDownloadDetail
? false
: usesWorkspaceRouteQuerySearch(context, section),
contextPanel: isFocusedDownloadDetail
? 'none'
: resolveContextPanel('portal', context, section),
};
}
@@ -298,7 +303,11 @@ export function parseWorkspaceShellRoute(url: string): WorkspaceShellRoute {
: page === 'downloads'
? 'downloads'
: 'unknown';
const searchMode = resolveRouteSearchMode(kind, null, null);
const isFocusedDownloadDetail =
kind === 'downloads' && segments.length === 3 && Boolean(segments[2]);
const searchMode = isFocusedDownloadDetail
? 'none'
: resolveRouteSearchMode(kind, null, null);
return {
kind,
@@ -308,6 +317,8 @@ export function parseWorkspaceShellRoute(url: string): WorkspaceShellRoute {
searchMode,
usesQuerySearch:
searchMode === 'local-filter' || searchMode === 'remote-search',
contextPanel: resolveContextPanel(kind, null, null),
contextPanel: isFocusedDownloadDetail
? 'none'
: resolveContextPanel(kind, null, null),
};
}