mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
606 lines
18 KiB
TypeScript
606 lines
18 KiB
TypeScript
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> = {}
|
|
): 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');
|
|
}
|
|
});
|
|
});
|