mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(downloads): harden playlist state and scrolling
This commit is contained in:
@@ -373,6 +373,13 @@ export const AddDownloadsModal = () => {
|
||||
break;
|
||||
}
|
||||
requestSet.add(requestKey);
|
||||
if (row.isPlaylist) {
|
||||
// Invalidate stale playlist requests before any asynchronous settings,
|
||||
// keychain, or network work can yield. Otherwise an old request can
|
||||
// become the latest request again after the URL or browser context
|
||||
// has already changed.
|
||||
latestPlaylistRequestRef.current.set(row.sourceUrl, requestKey);
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -411,7 +418,6 @@ export const AddDownloadsModal = () => {
|
||||
|
||||
if (row.isPlaylist) {
|
||||
if (playlistExpansions[row.sourceUrl]) return;
|
||||
latestPlaylistRequestRef.current.set(row.sourceUrl, requestKey);
|
||||
const playlistData = await fetchMediaPlaylistMetadataDeduped({
|
||||
...mediaMetadataArgs,
|
||||
url: contextUrl
|
||||
@@ -1047,11 +1053,11 @@ export const AddDownloadsModal = () => {
|
||||
<div className="app-modal add-download-modal flex flex-col overflow-hidden text-sm">
|
||||
|
||||
{/* Main Content Split */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
|
||||
{/* Left Column: URLs and Preview */}
|
||||
<div className="add-download-left w-[55%] border-r border-border-modal flex flex-col">
|
||||
<div className="add-download-pane p-5 flex-1 flex flex-col gap-5">
|
||||
<div className="add-download-pane p-5 flex-1 min-h-0 min-w-0 flex flex-col gap-5">
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1106,18 +1112,18 @@ export const AddDownloadsModal = () => {
|
||||
<SummaryBox title="Unknown" value={selectedItems.filter(i => !i.sizeBytes).length} icon={FileText} color="text-purple-500" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 flex-1 overflow-hidden">
|
||||
<div className="flex flex-col gap-2 flex-1 min-h-0 min-w-0 overflow-hidden">
|
||||
<div className="add-download-section-title flex items-center gap-2">
|
||||
<ArrowRight size={16} className="text-blue-500" />
|
||||
Preview
|
||||
</div>
|
||||
<div className="add-download-preview flex-1 overflow-hidden flex flex-col">
|
||||
<div className="add-download-preview flex-1 min-h-0 min-w-0 overflow-hidden flex flex-col">
|
||||
<div className="add-download-preview-header px-3 py-2 flex text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
<div className="flex-[2]">File</div>
|
||||
<div className="flex-1">Size</div>
|
||||
<div className="flex-[1.5]">Status</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1" role="listbox" aria-label="Download preview">
|
||||
<div className="flex-1 min-h-0 min-w-0 overflow-y-auto p-2 space-y-1" role="listbox" aria-label="Download preview">
|
||||
{parsedItems.length === 0 ? (
|
||||
<div className="add-download-empty h-full flex items-center justify-center text-text-muted text-xs">
|
||||
No links added yet.
|
||||
|
||||
@@ -545,6 +545,7 @@ html[data-list-density="relaxed"] {
|
||||
.add-download-left {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 18% 0%, hsl(var(--accent-color) / 0.055), transparent 34%),
|
||||
hsl(var(--main-bg) / 0.56);
|
||||
@@ -643,6 +644,8 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.add-download-preview {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid hsl(var(--border-modal));
|
||||
border-radius: 11px;
|
||||
background: hsl(var(--bg-input) / 0.35);
|
||||
@@ -650,11 +653,13 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.add-download-preview-header {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid hsl(var(--border-modal));
|
||||
background: hsl(var(--surface-raised) / 0.66);
|
||||
}
|
||||
|
||||
.add-download-preview-row {
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background-color 100ms ease,
|
||||
border-color 100ms ease,
|
||||
@@ -1780,6 +1785,7 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.download-table-header {
|
||||
flex-shrink: 0;
|
||||
height: var(--download-header-height);
|
||||
display: grid;
|
||||
align-items: center;
|
||||
@@ -1882,12 +1888,14 @@ body.is-resizing .column-resize-handle:hover::after {
|
||||
|
||||
.download-table-list {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.download-row {
|
||||
flex: 0 0 var(--download-row-height);
|
||||
height: var(--download-row-height);
|
||||
display: grid;
|
||||
align-items: center;
|
||||
|
||||
@@ -293,7 +293,10 @@ describe('add download metadata workflow', () => {
|
||||
const existing = row({
|
||||
file: 'old-name.zip',
|
||||
requestContextVersion: 1,
|
||||
generation: 2
|
||||
generation: 2,
|
||||
size: '10 MB',
|
||||
sizeBytes: 10,
|
||||
resumable: true
|
||||
});
|
||||
|
||||
const refreshed = reconcileDownloadRows(
|
||||
@@ -310,7 +313,47 @@ describe('add download metadata workflow', () => {
|
||||
file: 'new-name.zip',
|
||||
status: 'loading',
|
||||
generation: 3,
|
||||
requestContextVersion: 2
|
||||
requestContextVersion: 2,
|
||||
size: undefined,
|
||||
sizeBytes: undefined,
|
||||
resumable: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('drops stale playlist provenance when an entry remains after its playlist is removed', () => {
|
||||
const videoUrl = 'https://www.youtube.com/watch?v=one';
|
||||
const playlistUrl = 'https://www.youtube.com/playlist?list=PL123';
|
||||
const existing = row({
|
||||
sourceUrl: videoUrl,
|
||||
downloadUrl: videoUrl,
|
||||
file: '001 - First.mp4',
|
||||
status: 'ready',
|
||||
generation: 3,
|
||||
isMedia: true,
|
||||
playlistSourceUrl: playlistUrl,
|
||||
playlistTitle: 'Example playlist',
|
||||
playlistIndex: 1,
|
||||
playlistCount: 2,
|
||||
playlistEntryTitle: 'First',
|
||||
requestContextVersion: 7,
|
||||
size: '10 MB',
|
||||
sizeBytes: 10,
|
||||
resumable: true
|
||||
});
|
||||
|
||||
const rows = reconcileDownloadRows(videoUrl, [existing]);
|
||||
|
||||
expect(rows[0]).toMatchObject({
|
||||
file: 'watch',
|
||||
status: 'loading',
|
||||
generation: 4,
|
||||
isMedia: true,
|
||||
playlistSourceUrl: undefined,
|
||||
playlistIndex: undefined,
|
||||
size: undefined,
|
||||
sizeBytes: undefined,
|
||||
resumable: undefined,
|
||||
requestContextVersion: undefined
|
||||
});
|
||||
});
|
||||
|
||||
@@ -425,6 +468,10 @@ describe('add download metadata workflow', () => {
|
||||
row({ status: 'metadata-error', selected: false }),
|
||||
row({ id: 'ready', status: 'ready' })
|
||||
])).toContain('Ready to add 1 download');
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error' }),
|
||||
row({ id: 'skipped', status: 'ready', selected: false })
|
||||
])).toContain('can still be added');
|
||||
});
|
||||
|
||||
it('keeps failed media routing without a format selector', () => {
|
||||
|
||||
@@ -182,21 +182,39 @@ export const reconcileDownloadRows = (
|
||||
const requestContextVersion = input.requestContextVersion;
|
||||
const contextChanged = requestContextVersion !== undefined
|
||||
&& requestContextVersion !== preserved.requestContextVersion;
|
||||
if ((forcedMedia && !preserved.isMedia) || contextChanged) {
|
||||
const playlistContextChanged = preserved.playlistSourceUrl !== input.playlistSourceUrl
|
||||
|| preserved.playlistTitle !== input.playlistTitle
|
||||
|| preserved.playlistIndex !== input.playlistIndex
|
||||
|| preserved.playlistCount !== input.playlistCount
|
||||
|| preserved.playlistEntryTitle !== input.playlistEntryTitle;
|
||||
if ((forcedMedia && !preserved.isMedia) || contextChanged || playlistContextChanged) {
|
||||
const requestedFilename = input.playlistSourceUrl
|
||||
? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}`
|
||||
: requestFilenames[input.sourceUrl];
|
||||
return {
|
||||
...preserved,
|
||||
file: contextChanged
|
||||
file: contextChanged || playlistContextChanged
|
||||
? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl))
|
||||
: preserved.file,
|
||||
status: 'loading',
|
||||
generation: preserved.generation + 1,
|
||||
requestContextVersion,
|
||||
isMedia: preserved.isMedia || forcedMedia,
|
||||
formats: preserved.isMedia || forcedMedia ? undefined : preserved.formats,
|
||||
selectedFormat: preserved.isMedia || forcedMedia ? undefined : preserved.selectedFormat,
|
||||
isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl),
|
||||
size: undefined,
|
||||
sizeBytes: undefined,
|
||||
resumable: undefined,
|
||||
formats: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl)
|
||||
? undefined
|
||||
: preserved.formats,
|
||||
selectedFormat: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl)
|
||||
? undefined
|
||||
: preserved.selectedFormat,
|
||||
isPlaylist: input.isPlaylist,
|
||||
playlistSourceUrl: input.playlistSourceUrl,
|
||||
playlistTitle: input.playlistTitle,
|
||||
playlistIndex: input.playlistIndex,
|
||||
playlistCount: input.playlistCount,
|
||||
playlistEntryTitle: input.playlistEntryTitle,
|
||||
playlistError: undefined
|
||||
};
|
||||
}
|
||||
@@ -361,7 +379,7 @@ export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
|
||||
if (failedMedia > 0) {
|
||||
return `Media metadata is unavailable for ${failedMedia} item${failedMedia === 1 ? '' : 's'}. Refresh metadata before adding.`;
|
||||
}
|
||||
if (failed === rows.length) {
|
||||
if (failed === selectedRows.length) {
|
||||
return 'Metadata is unavailable. Downloads can still be added using fallback details.';
|
||||
}
|
||||
if (failed > 0) {
|
||||
|
||||
Reference in New Issue
Block a user