import { describe, expect, it } from 'vitest'; import { appendRequestUrlsAfterVersion, canSubmitMetadataRows, commonMediaQualitiesForRows, mediaFormatSelectorForRow, mediaFileNameForSelectedFormat, mediaQualityForRow, metadataSummaryMessage, isYouTubePlaylistUrl, playlistFilePrefix, reconcileDownloadRows, refreshFailedMetadataRows, selectExactMediaQuality, updateRowIfCurrent, type AddDownloadDraftRow } from './addDownloadMetadata'; import i18n, { changeAppLocale } from '../i18n'; const row = ( overrides: Partial = {} ): AddDownloadDraftRow => ({ id: 'row-1', sourceUrl: 'https://example.com/file.zip', downloadUrl: 'https://example.com/file.zip', file: 'file.zip', status: 'ready', generation: 1, isMedia: false, ...overrides }); describe('add download metadata workflow', () => { it('preserves rows by normalized source URL and creates only new rows', () => { const existing = row({ file: 'server-name.zip' }); let nextId = 0; const rows = reconcileDownloadRows( 'https://example.com/file.zip\nhttps://example.com/new.zip', [existing], undefined, new Set(), () => `new-${nextId++}` ); expect(rows[0]).toBe(existing); expect(rows[1]).toMatchObject({ id: 'new-0', status: 'loading', file: 'new.zip' }); }); it('deduplicates normalized URLs and marks malformed or unsupported URLs invalid', () => { let nextId = 0; const rows = reconcileDownloadRows( 'https://example.com/a\nhttps://example.com/a\nfile:///tmp/private\nnot-a-url', [], undefined, new Set(), () => `row-${nextId++}` ); expect(rows.map(item => item.status)).toEqual(['loading', 'invalid', 'invalid']); }); it('recognizes pure YouTube playlist URLs without changing video-plus-playlist behavior', () => { expect(isYouTubePlaylistUrl('https://www.youtube.com/playlist?list=PL123')).toBe(true); expect(isYouTubePlaylistUrl('https://www.youtube.com/playlist/?list=PL123')).toBe(true); expect(isYouTubePlaylistUrl('https://music.youtube.com/playlist?list=PL123')).toBe(true); expect(isYouTubePlaylistUrl('https://www.youtube.com/watch?v=video&list=PL123')).toBe(false); expect(isYouTubePlaylistUrl('https://example.com/playlist?list=PL123')).toBe(false); }); it('keeps a playlist as one loading row until discovery succeeds', () => { const rows = reconcileDownloadRows( 'https://www.youtube.com/playlist?list=PL123', [] ); expect(rows[0]).toMatchObject({ isMedia: true, isPlaylist: true, status: 'loading' }); }); it('expands playlist entries into independently identifiable media rows', () => { const playlistUrl = 'https://www.youtube.com/playlist?list=PL123'; const rows = reconcileDownloadRows( playlistUrl, [], undefined, new Set(), undefined, {}, { [playlistUrl]: 4 }, { [playlistUrl]: { title: 'Example playlist', playlist_id: 'PL123', entry_count: 2, skipped_entries: 1, truncated: false, entries: [ { id: 'one', url: 'https://www.youtube.com/watch?v=one', title: 'First', playlist_index: 1 }, { id: 'one-duplicate', url: 'https://www.youtube.com/watch?v=one', title: 'Duplicate', playlist_index: 2 }, { id: 'two', url: 'https://www.youtube.com/watch?v=two', title: 'Second', playlist_index: 3 } ] } } ); expect(rows).toHaveLength(2); expect(rows.map(item => item.sourceUrl)).toEqual([ 'https://www.youtube.com/watch?v=one', 'https://www.youtube.com/watch?v=two' ]); expect(rows[0]).toMatchObject({ file: '001 - First', isMedia: true, playlistSourceUrl: playlistUrl, playlistTitle: 'Example playlist', playlistIndex: 1, playlistCount: 2, requestContextVersion: 4, status: 'loading' }); expect(rows[1].file).toBe('003 - Second'); expect(rows.every(item => !item.isPlaylist)).toBe(true); }); it('uses a stable three-digit playlist prefix and widens it for four-digit lists', () => { expect(playlistFilePrefix(1, 12)).toBe('001 - '); expect(playlistFilePrefix(12, 12)).toBe('012 - '); expect(playlistFilePrefix(1000, 1000)).toBe('1000 - '); expect(playlistFilePrefix(undefined, 12)).toBe(''); }); it('propagates a playlist selection to entries discovered after the user deselects it', () => { const playlistUrl = 'https://www.youtube.com/playlist?list=PL123'; const rows = reconcileDownloadRows( playlistUrl, [], undefined, new Set(), undefined, {}, {}, { [playlistUrl]: { title: 'Example playlist', playlist_id: 'PL123', entry_count: 1, skipped_entries: 0, truncated: false, entries: [{ id: 'one', url: 'https://www.youtube.com/watch?v=one', title: 'First', playlist_index: 1 }] } }, { [playlistUrl]: false } ); expect(rows).toHaveLength(1); expect(rows[0].selected).toBe(false); }); it('preserves entry-level selection when expanded rows are recreated', () => { const playlistUrl = 'https://www.youtube.com/playlist?list=PL123'; const expansion = { [playlistUrl]: { title: 'Example playlist', playlist_id: 'PL123', entry_count: 2, skipped_entries: 0, truncated: false, entries: [ { id: 'one', url: 'https://www.youtube.com/watch?v=one', title: 'First', playlist_index: 1 }, { id: 'two', url: 'https://www.youtube.com/watch?v=two', title: 'Second', playlist_index: 2 } ] } }; const rows = reconcileDownloadRows( playlistUrl, [], undefined, new Set(), undefined, {}, {}, expansion, { 'https://www.youtube.com/watch?v=one': false, 'https://www.youtube.com/watch?v=two': true } ); expect(rows.map(item => item.selected)).toEqual([false, true]); }); it('does not leave a loading playlist row when every entry is already present', () => { const videoUrl = 'https://www.youtube.com/watch?v=one'; const playlistUrl = 'https://www.youtube.com/playlist?list=PL123'; const rows = reconcileDownloadRows( `${videoUrl}\n${playlistUrl}`, [], undefined, new Set(), undefined, {}, {}, { [playlistUrl]: { title: 'Example playlist', playlist_id: 'PL123', entry_count: 1, skipped_entries: 0, truncated: false, entries: [{ id: 'one', url: videoUrl, title: 'First', playlist_index: 1 }] } } ); expect(rows).toHaveLength(1); expect(rows[0].sourceUrl).toBe(videoUrl); expect(rows.some(item => item.isPlaylist)).toBe(false); }); it('forces explicit extension media fetches through media metadata for any http page', () => { const rows = reconcileDownloadRows( 'https://adult.example/watch/123', [], undefined, new Set(['https://adult.example/watch/123']) ); expect(rows[0]).toMatchObject({ sourceUrl: 'https://adult.example/watch/123', isMedia: true, status: 'loading' }); }); it('keeps extension-provided filenames scoped to their individual URLs', () => { const rows = reconcileDownloadRows( 'https://first.example/download\nhttps://second.example/download', [], undefined, new Set(), () => crypto.randomUUID(), { 'https://first.example/download': 'first.zip', 'https://second.example/download': 'second.zip' } ); expect(rows.map(item => item.file)).toEqual(['first.zip', 'second.zip']); }); it('refreshes an existing row when a newer extension handoff changes its request context', () => { const existing = row({ isMedia: true, status: 'ready', generation: 4, requestContextVersion: 1, formats: [{ name: '1080p MP4', selector: '137+140', ext: 'mp4', formatLabel: 'MP4', detail: '10 MB', type: 'Video', bytes: 10 }], selectedFormat: 0 }); const refreshed = reconcileDownloadRows( existing.sourceUrl, [existing], undefined, new Set(), () => 'unused', {}, { [existing.sourceUrl]: 2 } ); expect(refreshed[0]).toMatchObject({ status: 'loading', generation: 5, requestContextVersion: 2, formats: undefined, selectedFormat: undefined }); }); it('replaces a stale filename when a newer handoff supplies a new one', () => { const existing = row({ file: 'old-name.zip', requestContextVersion: 1, generation: 2, size: '10 MB', sizeBytes: 10, resumable: true }); const refreshed = reconcileDownloadRows( existing.sourceUrl, [existing], undefined, new Set(), () => 'unused', { [existing.sourceUrl]: 'new-name.zip' }, { [existing.sourceUrl]: 2 } ); expect(refreshed[0]).toMatchObject({ file: 'new-name.zip', status: 'loading', generation: 3, 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 }); }); it('appends every unseen handoff after the observed version', () => { const merged = appendRequestUrlsAfterVersion( 'https://existing.example/file.zip', { 'https://first.example/file.zip': { version: 2 }, 'https://second.example/file.zip': { version: 3 }, 'https://existing.example/file.zip': { version: 4 } }, 1 ); expect(merged).toBe( 'https://existing.example/file.zip\n' + 'https://first.example/file.zip\n' + 'https://second.example/file.zip' ); }); it('upgrades an existing normal row when the user explicitly fetches it as media', () => { const existing = row({ sourceUrl: 'https://adult.example/watch/123', downloadUrl: 'https://adult.example/watch/123', file: '123', status: 'ready', generation: 2, isMedia: false }); const rows = reconcileDownloadRows( 'https://adult.example/watch/123', [existing], undefined, new Set(['https://adult.example/watch/123']) ); expect(rows[0]).toMatchObject({ sourceUrl: 'https://adult.example/watch/123', isMedia: true, status: 'loading', generation: 3, formats: undefined, selectedFormat: undefined }); }); it('refreshes only failed metadata and preserves successful format selection', () => { const ready = row({ id: 'ready', isMedia: true, formats: [{ name: '1080p MP4', selector: 'best', ext: 'mp4', formatLabel: '1080p', detail: '10 MB', type: 'Video', bytes: 10 }], selectedFormat: 0 }); const failed = row({ id: 'failed', status: 'metadata-error', generation: 4 }); const refreshed = refreshFailedMetadataRows([ready, failed]); expect(refreshed[0]).toBe(ready); expect(refreshed[1]).toMatchObject({ status: 'loading', generation: 5 }); }); it('ignores stale metadata results after generation changes', () => { const current = row({ generation: 2, status: 'loading' }); const updated = updateRowIfCurrent( [current], current.id, current.sourceUrl, 1, value => ({ ...value, status: 'ready' }) ); expect(updated[0]).toBe(current); }); it('allows normal-download fallback but blocks unresolved explicit media', () => { expect(canSubmitMetadataRows([ row(), row({ id: 'fallback', status: 'metadata-error' }) ])).toBe(true); expect(canSubmitMetadataRows([ row({ id: 'unsafe', status: 'metadata-error', metadataBlockedReason: 'unsafe-url' }) ])).toBe(false); expect(canSubmitMetadataRows([ row(), row({ id: 'media-fallback', status: 'metadata-error', isMedia: true }) ])).toBe(false); expect(canSubmitMetadataRows([row({ status: 'loading' })])).toBe(false); expect(canSubmitMetadataRows([row({ status: 'invalid' })])).toBe(false); }); it('validates only selected rows and requires at least one selection', () => { expect(canSubmitMetadataRows([ row({ status: 'loading' }), row({ id: 'skipped', status: 'invalid', selected: false }) ])).toBe(false); expect(canSubmitMetadataRows([ row({ status: 'ready' }), row({ id: 'skipped', status: 'invalid', selected: false }) ])).toBe(true); expect(canSubmitMetadataRows([ row({ selected: false }), row({ id: 'skipped', selected: false }) ])).toBe(false); expect(metadataSummaryMessage([ 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', () => { const failedMedia = row({ status: 'metadata-error', isMedia: true, formats: undefined, selectedFormat: undefined }); expect(failedMedia.isMedia).toBe(true); expect(mediaFormatSelectorForRow(failedMedia)).toBeUndefined(); }); it('replaces only known media container suffixes when selecting formats', () => { const mediaRow = row({ isMedia: true, formats: [ { name: '1080p MP4', selector: 'mp4', ext: 'mp4', formatLabel: '1080p', detail: '10 MB', type: 'Video', bytes: 10 }, { name: '1080p MKV', selector: 'mkv', ext: 'mkv', formatLabel: '1080p', detail: '11 MB', type: 'Video', bytes: 11 } ], selectedFormat: 1 }); expect(mediaFileNameForSelectedFormat('Version 1.5', mediaRow)).toBe('Version 1.5.mkv'); expect(mediaFileNameForSelectedFormat('Version 1.5.mp4', mediaRow)).toBe('Version 1.5.mkv'); }); it('reports fallback and invalid states accurately', () => { expect(metadataSummaryMessage([ row(), row({ id: 'fallback', status: 'metadata-error' }) ])).toBe('1 download ready; 1 will use fallback filename and unknown size.'); expect(metadataSummaryMessage([ row({ status: 'metadata-error' }) ])).toContain('can still be added'); expect(metadataSummaryMessage([ row({ status: 'metadata-error', metadataBlockedReason: 'unsafe-url' }) ])).toContain('unsafe URL'); expect(metadataSummaryMessage([ row({ status: 'metadata-error', isMedia: true }) ])).toContain('Refresh metadata before adding'); expect(metadataSummaryMessage([ row({ status: 'invalid' }) ])).toContain('Correct or remove 1 invalid URL'); }); it('offers only qualities shared by every selected ready media row', () => { const formats = (qualities: string[]) => qualities.map((quality, index) => ({ name: `${quality} MP4`, quality, selector: `${quality}-${index}`, ext: 'mp4', formatLabel: quality, detail: '10 MB', type: 'Video', bytes: 10 })); const rows = [ row({ id: 'one', isMedia: true, formats: formats(['1080p', '720p']), selectedFormat: 0 }), row({ id: 'two', isMedia: true, formats: formats(['720p', '480p']), selectedFormat: 0 }) ]; expect(commonMediaQualitiesForRows(rows)).toEqual(['720p']); expect(mediaQualityForRow(rows[0])).toBe('1080p'); }); it('applies an exact bulk quality without falling back to a higher or lower stream', () => { const mediaRow = row({ id: 'media', file: 'clip.mp4', isMedia: true, formats: [{ name: '1080p MP4', quality: '1080p', selector: '1080', ext: 'mp4', formatLabel: '1080p', detail: '10 MB', type: 'Video', bytes: 10 }], selectedFormat: 0 }); const unchanged = selectExactMediaQuality([mediaRow], ['media'], '720p'); expect(unchanged[0]).toBe(mediaRow); expect(selectExactMediaQuality([mediaRow], ['media'], '1080p')[0]).toMatchObject({ selectedFormat: 0, file: 'clip.mp4' }); }); it('uses few forms for Russian and Ukrainian metadata summaries', async () => { const originalLanguage = i18n.language; const twoReadyRows = [row(), row({ id: 'row-2' })]; try { await changeAppLocale('ru'); expect(metadataSummaryMessage(twoReadyRows)).toBe('Готово к добавлению: 2 загрузки.'); await changeAppLocale('uk'); expect(metadataSummaryMessage(twoReadyRows)).toBe('Готово до додавання: 2 завантаження.'); } finally { await changeAppLocale(originalLanguage === 'uk' || originalLanguage === 'ru' ? originalLanguage : 'en'); } }); });