Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 2 additions & 23 deletions src/renderer/src/components/wizard/DownloadProfileEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,7 @@ import {DOWNLOAD_PROFILE_ICONS} from '@shared/schemas.js'
import type {CommonSettings, DownloadProfile, DownloadProfileAudioFormat, DownloadProfileIcon, DownloadProfileSubtitleSource, PlaylistVideoCodec, PlaylistVideoTier, SponsorBlockMode, SubtitleFormat, SubtitleMode} from '@shared/types.js'
import {effectiveOutputDir} from '@shared/subfolder.js'
import {cn, formatHomeRelativePath} from '@renderer/lib/utils.js'
import {
createDownloadProfileDraft,
defaultProfileSubfolderName,
downloadProfileFromDraft,
type DownloadProfileAudioQuality,
type DownloadProfileDraftAction,
type DownloadProfileMediaMode,
type DownloadProfilePlaylistCap,
updateDownloadProfileDraft,
validateDownloadProfileDraft
} from '../../store/wizard/downloadProfileDraft.js'
import {createDownloadProfileDraft, defaultProfileSubfolderName, downloadProfileFromDraft, type DownloadProfileAudioQuality, type DownloadProfileDraftAction, type DownloadProfileMediaMode, updateDownloadProfileDraft, validateDownloadProfileDraft} from '../../store/wizard/downloadProfileDraft.js'
import {Alert, AlertDescription} from '../ui/alert.js'
import {Badge} from '../ui/badge.js'
import {Button} from '../ui/button.js'
Expand Down Expand Up @@ -157,14 +147,6 @@ const SPONSOR_BLOCK_OPTIONS: {value: SponsorBlockMode; label: string}[] = [

const SPONSOR_BLOCK_HINTS: Record<SponsorBlockMode, string> = {off: 'No SponsorBlock — video plays as uploaded.', mark: 'Marks sponsor segments as chapters (non-destructive).', remove: 'Cuts sponsor segments from the video using FFmpeg.'}

const PLAYLIST_CAP_OPTIONS: SelectOption<DownloadProfilePlaylistCap>[] = [
{value: 'confirm', label: 'Confirm when capped'},
{value: '100', label: 'Load 100 items'},
{value: '250', label: 'Load 250 items'},
{value: '500', label: 'Load 500 items'},
{value: '1000', label: 'Load 1000 items'}
]

const SELECTABLE_TOGGLE_CLASS = 'flex-1 data-[state=on]:border-[var(--brand)] data-[state=on]:bg-[var(--brand-dim)] data-[state=on]:text-[var(--brand)] aria-pressed:border-[var(--brand)] aria-pressed:bg-[var(--brand-dim)] aria-pressed:text-[var(--brand)]'
const OUTPUT_MODE_CARD_CLASS =
'h-auto min-h-[4.35rem] flex-col gap-1.5 whitespace-normal rounded-lg border border-[var(--border-strong)] px-2 py-2.5 text-center data-[state=on]:border-[var(--brand)] data-[state=on]:bg-[var(--brand-dim)] data-[state=on]:text-[var(--brand)] aria-pressed:border-[var(--brand)] aria-pressed:bg-[var(--brand-dim)] aria-pressed:text-[var(--brand)]'
Expand Down Expand Up @@ -258,8 +240,7 @@ export function DownloadProfileEditor({commonPaths, globalDestination = '', init
embedChapters,
saveDescription,
saveThumbnail,
sponsorBlockMode,
playlistCap
sponsorBlockMode
} = draft
const showVideo = mediaMode === 'video-audio' || mediaMode === 'video-only'
const showAudio = mediaMode === 'video-audio' || mediaMode === 'audio-only'
Expand Down Expand Up @@ -757,8 +738,6 @@ export function DownloadProfileEditor({commonPaths, globalDestination = '', init
</Alert>
)}
</Card>

<ProfileSelect label="Playlist probe cap" value={playlistCap} options={PLAYLIST_CAP_OPTIONS} onValueChange={next => updateDraft({type: 'set-playlist-cap', playlistCap: next})} testId="profiles-editor-playlist-cap" />
</FieldGroup>
</ProfilePanel>
</div>
Expand Down
14 changes: 1 addition & 13 deletions src/renderer/src/store/wizard/downloadProfileDraft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {isValidSubfolder, safeFolderName} from '@shared/subfolder.js'

export type DownloadProfileMediaMode = DownloadProfile['media']['kind']
export type DownloadProfileAudioQuality = 'best' | '320' | '192' | '128'
export type DownloadProfilePlaylistCap = 'confirm' | '100' | '250' | '500' | '1000'

export interface DownloadProfileDraft {
profileId: string | null
Expand All @@ -31,7 +30,6 @@ export interface DownloadProfileDraft {
saveDescription: boolean
saveThumbnail: boolean
sponsorBlockMode: SponsorBlockMode
playlistCap: DownloadProfilePlaylistCap
}

export type DownloadProfileDraftAction =
Expand All @@ -58,7 +56,6 @@ export type DownloadProfileDraftAction =
| {type: 'set-save-description'; saveDescription: boolean}
| {type: 'set-save-thumbnail'; saveThumbnail: boolean}
| {type: 'set-sponsor-block-mode'; sponsorBlockMode: SponsorBlockMode}
| {type: 'set-playlist-cap'; playlistCap: DownloadProfilePlaylistCap}

export interface DownloadProfileDraftValidation {
subfolderInvalid: boolean
Expand Down Expand Up @@ -88,11 +85,6 @@ function bitrateToQuality(bitrateKbps: number | undefined): DownloadProfileAudio
return bitrateKbps === undefined ? 'best' : '192'
}

function playlistCapToControlValue(cap: DownloadProfile['playlistProbeCap'] | undefined): DownloadProfilePlaylistCap {
if (cap === 100 || cap === 250 || cap === 500 || cap === 1000) return String(cap) as DownloadProfilePlaylistCap
return 'confirm'
}

function initialCodec(profile: DownloadProfile | null): PlaylistVideoCodec {
const media = profile?.media
return media?.kind === 'video-audio' || media?.kind === 'video-only' ? media.codec : 'mp4'
Expand Down Expand Up @@ -141,8 +133,7 @@ export function createDownloadProfileDraft(initialProfile: DownloadProfile | nul
embedChapters: initialProfile?.embed.chapters ?? true,
saveDescription: initialProfile?.embed.description ?? true,
saveThumbnail: initialProfile?.embed.thumbnailSidecar ?? true,
sponsorBlockMode: initialProfile?.sponsorBlock.mode ?? 'off',
playlistCap: playlistCapToControlValue(initialProfile?.playlistProbeCap)
sponsorBlockMode: initialProfile?.sponsorBlock.mode ?? 'off'
}
}

Expand Down Expand Up @@ -201,8 +192,6 @@ export function updateDownloadProfileDraft(draft: DownloadProfileDraft, action:
return {...draft, saveThumbnail: action.saveThumbnail}
case 'set-sponsor-block-mode':
return {...draft, sponsorBlockMode: action.sponsorBlockMode}
case 'set-playlist-cap':
return {...draft, playlistCap: action.playlistCap}
}
}

Expand Down Expand Up @@ -242,7 +231,6 @@ export function downloadProfileFromDraft(draft: DownloadProfileDraft, now: strin
subfolder: {enabled: draft.saveInsideSubfolder, name: draft.saveInsideSubfolder ? draft.subfolderName.trim() || defaultProfileSubfolderName(draft.profileName) : ''},
sponsorBlock: {mode: showVideo ? draft.sponsorBlockMode : 'off', categories: showVideo && draft.sponsorBlockMode !== 'off' ? [...DEFAULTS.sponsorBlockCategories] : []},
embed: {chapters: showVideo && draft.embedChapters, metadata: draft.embedMetadata, thumbnail: false, description: draft.saveDescription, thumbnailSidecar: draft.saveThumbnail},
playlistProbeCap: draft.playlistCap === 'confirm' ? 'confirm' : Number(draft.playlistCap),
createdAt: draft.createdAt ?? now,
updatedAt: now
}
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/store/wizard/queueSubmission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ export function prepareActiveProfileQueueSubmission(probe: ProbeResult, state: A

if (probe.kind === 'video') {
const outputTemplate = singleOutputTemplate(state.settings?.common?.includeIdInSingleFilenames ?? DEFAULTS.includeIdInSingleFilenames)
const item = buildProfileEntryQueueItem({entry: {url: state.wizardUrl || probe.webpageUrl, title: probe.title, thumbnail: probe.thumbnail}, outputDir: singleOutputDir, extractor: probe.extractor, extractorKey: probe.extractorKey, resolved, profile, outputTemplate, writeM3u: false, lane})
const item = buildProfileEntryQueueItem({entry: {url: probe.webpageUrl || state.wizardUrl, title: probe.title, thumbnail: probe.thumbnail}, outputDir: singleOutputDir, extractor: probe.extractor, extractorKey: probe.extractorKey, resolved, profile, outputTemplate, writeM3u: false, lane})
return {items: [item]}
}

Expand Down
1 change: 0 additions & 1 deletion src/shared/downloadProfiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ function baseProfile(id: string, name: string, media: DownloadProfile['media'],
subtitles: {enabled: false, languages: [], source: 'manual-first', mode: DEFAULTS.subtitleMode, format: DEFAULTS.subtitleFormat},
sponsorBlock: {mode: DEFAULTS.sponsorBlockMode, categories: [...DEFAULTS.sponsorBlockCategories]},
embed: {...BUILTIN_PROFILE_EMBED},
playlistProbeCap: 'confirm',
createdAt: BUILTIN_TIMESTAMP,
updatedAt: BUILTIN_TIMESTAMP,
output: {kind: 'default'},
Expand Down
1 change: 0 additions & 1 deletion src/shared/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,6 @@ export const downloadProfileSchema = z.object({
subfolder: z.object({enabled: z.boolean(), name: subfolderNameSchema}),
sponsorBlock: z.object({mode: sponsorBlockModeSchema, categories: z.array(sponsorBlockCategorySchema)}),
embed: z.object({chapters: z.boolean(), metadata: z.boolean(), thumbnail: z.boolean(), description: z.boolean(), thumbnailSidecar: z.boolean()}),
playlistProbeCap: z.union([z.literal('confirm'), playlistProbeLimitSchema]),
createdAt: z.string(),
updatedAt: z.string()
})
Expand Down
133 changes: 113 additions & 20 deletions tests/e2e/fixture-workflows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,35 @@ import fs from 'node:fs'
import path from 'node:path'
import {expect, test} from '@playwright/test'
import {BUILTIN_DOWNLOAD_PROFILES} from '../../src/shared/downloadProfiles.js'
import type {AppSettings} from '../../src/shared/types.js'
import {FIXTURE_PLAYLIST_VIDEO_IDS, FIXTURE_VIDEO_IDS} from './fixtureHarness.js'
import {withFixtureProductApp} from './fixtureProductE2E.js'
import {clickContinue, preparePlaylistConfirm, prepareSingleConfirm, startBulkFromClipboard} from './fixtureWorkflow.js'

test.describe.configure({mode: 'serial'})

const FIXTURE_M3U_VIDEO_ID_PATTERN = /\[(ARX[0-9A-Z]{8})\]\.mp4/g

function configureSmallFileQuickProfile(settings: AppSettings): void {
const smallFileProfile = BUILTIN_DOWNLOAD_PROFILES.find(profile => profile.id === 'small-file')
if (!smallFileProfile) throw new Error('small-file built-in profile missing')
settings.profiles.active = {kind: 'builtin', id: 'small-file'}
settings.profiles.overrides = [{...smallFileProfile, embed: {chapters: false, metadata: false, thumbnail: false, description: false, thumbnailSidecar: false}, sponsorBlock: {mode: 'off', categories: []}}]
}

function smallFileProfileDir(outputDir: string): string {
return path.join(outputDir, 'Small file 480p')
}

async function expectOrderedM3u(dir: string, playlistTitle: string, expectedIds: readonly string[]): Promise<void> {
const m3uPath = path.join(dir, `${playlistTitle}.m3u`)
await expect.poll(() => fs.existsSync(m3uPath), {timeout: 20_000}).toBe(true)
const m3u = fs.readFileSync(m3uPath, 'utf8')
expect(m3u.startsWith('#EXTM3U\n')).toBe(true)
const orderedIds = [...m3u.matchAll(FIXTURE_M3U_VIDEO_ID_PATTERN)].map(match => match[1])
expect(orderedIds).toEqual([...expectedIds])
}

test('Electron quick download applies the active Download Profile to a fixture video', async () => {
test.setTimeout(140_000)
const videoId = FIXTURE_VIDEO_IDS[7]
Expand All @@ -17,10 +40,7 @@ test('Electron quick download applies the active Download Profile to a fixture v
userDataPrefix: 'arroxy-fixture-profile-quick-user-',
outputPrefix: 'arroxy-fixture-profile-quick-out-',
settings: settings => {
const smallFileProfile = BUILTIN_DOWNLOAD_PROFILES.find(profile => profile.id === 'small-file')
if (!smallFileProfile) throw new Error('small-file built-in profile missing')
settings.profiles.active = {kind: 'builtin', id: 'small-file'}
settings.profiles.overrides = [{...smallFileProfile, embed: {chapters: false, metadata: false, thumbnail: false, description: false, thumbnailSidecar: false}, sponsorBlock: {mode: 'off', categories: []}}]
configureSmallFileQuickProfile(settings)
}
},
async ({page, outputDir, fixtureServer, urls, queue, files}) => {
Expand All @@ -30,14 +50,96 @@ test('Electron quick download applies the active Download Profile to a fixture v
await expect(page.locator('[data-testid="profiles-mascot-help"]')).toContainText(/queued/i, {timeout: 60_000})
await queue.expectStatus('Fixture Video 8', 'done', 120_000)

const profileOutputDir = path.join(outputDir, 'Small file 480p')
const profileOutputDir = smallFileProfileDir(outputDir)
files.expectMp4Count(1, profileOutputDir)
const mediaRequest = fixtureServer.telemetry().requests.find(request => request.kind === 'media' && request.videoId === videoId && request.status === 200)
expect(mediaRequest).toMatchObject({kind: 'media', videoId, formatId: '18', status: 200})
}
)
})

test('Electron Quick Download playlist queues entries and writes an ordered M3U', async () => {
test.setTimeout(220_000)

await withFixtureProductApp({userDataPrefix: 'arroxy-fixture-quick-playlist-user-', outputPrefix: 'arroxy-fixture-quick-playlist-out-', settings: configureSmallFileQuickProfile}, async ({page, outputDir, urls, files}) => {
await page.locator('[data-testid="profiles-main-input"]').fill(urls.playlist())
await page.locator('[data-testid="profiles-quick-download"]').click()

await expect(page.locator('[data-testid^="queue-card-"]')).toHaveCount(FIXTURE_PLAYLIST_VIDEO_IDS.length, {timeout: 60_000})
await expect(page.locator('[data-testid^="queue-card-"][data-status="done"]')).toHaveCount(FIXTURE_PLAYLIST_VIDEO_IDS.length, {timeout: 160_000})

const profileDir = smallFileProfileDir(outputDir)
files.expectMp4Count(FIXTURE_PLAYLIST_VIDEO_IDS.length, profileDir)
await expectOrderedM3u(profileDir, 'Fixture Playlist', FIXTURE_PLAYLIST_VIDEO_IDS)
})
})

test('Electron Quick Download capped playlist can queue the globally loaded slice', async () => {
test.setTimeout(180_000)
const expectedIds = FIXTURE_PLAYLIST_VIDEO_IDS.slice(0, 2)
const skippedId = FIXTURE_PLAYLIST_VIDEO_IDS[2]
if (!skippedId) throw new Error('fixture playlist needs a skipped third item')

await withFixtureProductApp(
{
userDataPrefix: 'arroxy-fixture-quick-playlist-cap-queue-user-',
outputPrefix: 'arroxy-fixture-quick-playlist-cap-queue-out-',
settings: settings => {
configureSmallFileQuickProfile(settings)
settings.common.playlistProbeLimit = 2
}
},
async ({page, outputDir, urls, files}) => {
await page.locator('[data-testid="profiles-main-input"]').fill(urls.playlist())
await page.locator('[data-testid="profiles-quick-download"]').click()

await expect(page.locator('[data-testid="quick-playlist-cap-dialog"]')).toBeVisible({timeout: 60_000})
await expect(page.locator('[data-testid="quick-playlist-cap-dialog"]')).toContainText('Arroxy loaded 2 items using the current limit of 2')
await page.locator('[data-testid="quick-playlist-cap-queue-loaded"]').click()

await expect(page.locator('[data-testid="quick-playlist-cap-dialog"]')).toBeHidden({timeout: 20_000})
await expect(page.locator('[data-testid^="queue-card-"]')).toHaveCount(expectedIds.length, {timeout: 20_000})
await expect(page.locator('[data-testid^="queue-card-"][data-status="done"]')).toHaveCount(expectedIds.length, {timeout: 140_000})

const profileDir = smallFileProfileDir(outputDir)
files.expectMp4Count(expectedIds.length, profileDir)
files.expectNoMp4For(skippedId, profileDir)
await expectOrderedM3u(profileDir, 'Fixture Playlist', expectedIds)
}
)
})

test('Electron Quick Download capped playlist can increase the global cap and retry', async () => {
test.setTimeout(220_000)

await withFixtureProductApp(
{
userDataPrefix: 'arroxy-fixture-quick-playlist-cap-retry-user-',
outputPrefix: 'arroxy-fixture-quick-playlist-cap-retry-out-',
settings: settings => {
configureSmallFileQuickProfile(settings)
settings.common.playlistProbeLimit = 2
}
},
async ({page, outputDir, urls, files}) => {
await page.locator('[data-testid="profiles-main-input"]').fill(urls.playlist())
await page.locator('[data-testid="profiles-quick-download"]').click()

await expect(page.locator('[data-testid="quick-playlist-cap-dialog"]')).toBeVisible({timeout: 60_000})
await page.locator('[data-testid="quick-playlist-cap-probe-limit-trigger"]').click()
await page.locator('[data-testid="quick-playlist-cap-probe-limit-option-100"]').click()

await expect(page.locator('[data-testid="quick-playlist-cap-dialog"]')).toBeHidden({timeout: 20_000})
await expect(page.locator('[data-testid^="queue-card-"]')).toHaveCount(FIXTURE_PLAYLIST_VIDEO_IDS.length, {timeout: 60_000})
await expect(page.locator('[data-testid^="queue-card-"][data-status="done"]')).toHaveCount(FIXTURE_PLAYLIST_VIDEO_IDS.length, {timeout: 160_000})

const profileDir = smallFileProfileDir(outputDir)
files.expectMp4Count(FIXTURE_PLAYLIST_VIDEO_IDS.length, profileDir)
await expectOrderedM3u(profileDir, 'Fixture Playlist', FIXTURE_PLAYLIST_VIDEO_IDS)
}
)
})

test('Electron full single-video wizard completes media and sidecar subtitles', async () => {
test.setTimeout(180_000)
const videoId = FIXTURE_VIDEO_IDS[8]
Expand Down Expand Up @@ -81,15 +183,7 @@ test('Electron true playlist URL queues entries and writes an ordered M3U', asyn

const playlistDir = path.join(outputDir, 'Fixture Playlist')
files.expectMp4Count(FIXTURE_PLAYLIST_VIDEO_IDS.length, playlistDir)
const m3uPath = path.join(playlistDir, 'Fixture Playlist.m3u')
await expect.poll(() => fs.existsSync(m3uPath), {timeout: 20_000}).toBe(true)
const m3u = fs.readFileSync(m3uPath, 'utf8')
expect(m3u.startsWith('#EXTM3U\n')).toBe(true)
for (const videoId of FIXTURE_PLAYLIST_VIDEO_IDS) {
expect(m3u).toContain(`[${videoId}].mp4`)
}
const orderedIds = [...m3u.matchAll(/\[(ARX[0-9A-Z]{8})\]\.mp4/g)].map(match => match[1])
expect(orderedIds).toEqual([...FIXTURE_PLAYLIST_VIDEO_IDS])
await expectOrderedM3u(playlistDir, 'Fixture Playlist', FIXTURE_PLAYLIST_VIDEO_IDS)
})
})

Expand Down Expand Up @@ -143,13 +237,10 @@ test('Electron bulk Quick Download shows preparation progress and queues fixture
userDataPrefix: 'arroxy-fixture-bulk-quick-user-',
outputPrefix: 'arroxy-fixture-bulk-quick-out-',
settings: settings => {
const smallFileProfile = BUILTIN_DOWNLOAD_PROFILES.find(profile => profile.id === 'small-file')
if (!smallFileProfile) throw new Error('small-file built-in profile missing')
settings.profiles.active = {kind: 'builtin', id: 'small-file'}
settings.profiles.overrides = [{...smallFileProfile, embed: {chapters: false, metadata: false, thumbnail: false, description: false, thumbnailSidecar: false}, sponsorBlock: {mode: 'off', categories: []}}]
configureSmallFileQuickProfile(settings)
}
},
async ({app, page, urls, files}) => {
async ({app, page, outputDir, urls, files}) => {
const rawBulkText = urls.videos([FIXTURE_VIDEO_IDS[0], FIXTURE_VIDEO_IDS[1]]).join('\n')
await startBulkFromClipboard(page, app, rawBulkText)
await expect(page.locator('[data-testid="bulk-url-valid-count"]')).toContainText('2')
Expand All @@ -165,7 +256,9 @@ test('Electron bulk Quick Download shows preparation progress and queues fixture
await expect
.poll(async () => page.locator('[data-testid^="queue-card-"]').evaluateAll(cards => cards.map(card => ({status: card.getAttribute('data-status'), text: card.textContent?.replace(/\s+/g, ' ').trim()}))), {timeout: 140_000})
.toEqual([expect.objectContaining({status: 'done'}), expect.objectContaining({status: 'done'})])
files.expectMp4Count(2)
const profileDir = smallFileProfileDir(outputDir)
await expect.poll(() => files.mediaFiles('.mp4', profileDir).length, {timeout: 20_000}).toBe(2)
files.expectMp4Count(2, profileDir)
}
)
})
11 changes: 11 additions & 0 deletions tests/renderer/download-profile-editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ describe('DownloadProfileEditor', () => {
expect(screen.queryByTestId('profiles-editor-audio-quality')).not.toBeInTheDocument()
})

it('does not render profile-specific playlist cap controls', async () => {
const profile = BUILTIN_DOWNLOAD_PROFILES.find(item => item.id === 'balanced')
expect(profile).toBeDefined()

render(<DownloadProfileEditor initialProfile={profile} open onOpenChange={() => undefined} />)

expect(await screen.findByTestId('profiles-editor-video-codec')).toBeInTheDocument()
expect(screen.queryByTestId('profiles-editor-playlist-cap')).not.toBeInTheDocument()
expect(screen.queryByText('Playlist probe cap')).not.toBeInTheDocument()
})

it('shows M4A/AAC audio preference for Smart TV H.264 MP4 video profiles', async () => {
const profile = BUILTIN_DOWNLOAD_PROFILES.find(item => item.id === 'mp4-1080')
expect(profile).toBeDefined()
Expand Down
2 changes: 1 addition & 1 deletion tests/renderer/probe-orchestrator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ describe('quickDownload', () => {
expect(api.downloads.probe).toHaveBeenCalledWith({url: mixedUrl, playlistMode: 'video'})
const queued = vi.mocked(api.queue.cmd.add).mock.calls[0]?.[0]?.[0]
expect(queued).toMatchObject({
url: mixedUrl,
url: VIDEO_PROBE.webpageUrl,
title: 'Test Video',
outputDir: '/tmp/downloads/Balanced 720p',
status: 'pending',
Expand Down
Loading
Loading