diff --git a/src/main/lib/subscription-scheduler.ts b/src/main/lib/subscription-scheduler.ts index a7a9e79..66e5588 100644 --- a/src/main/lib/subscription-scheduler.ts +++ b/src/main/lib/subscription-scheduler.ts @@ -4,6 +4,10 @@ import log from 'electron-log/main' import Parser from 'rss-parser' import type { SubscriptionFeedItem, SubscriptionRule } from '../../shared/types' import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } from '../../shared/types' +import { + buildAudioFormatPreference, + buildVideoFormatPreference +} from '../../shared/utils/format-preferences' import { settingsManager } from '../settings' import { downloadEngine } from './download-engine' import { historyManager } from './history-manager' @@ -481,6 +485,11 @@ export class SubscriptionScheduler extends EventEmitter { const downloadDirectory = subscription.downloadDirectory?.trim() || settings.downloadPath const namingTemplate = subscription.namingTemplate?.trim() || DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE + const downloadType = settings.oneClickDownloadType ?? 'video' + const formatPreference = + downloadType === 'video' + ? buildVideoFormatPreference(settings) + : buildAudioFormatPreference(settings) ensureDirectoryExists(downloadDirectory) const tags = Array.from(new Set([subscription.platform, ...subscription.tags])) @@ -488,7 +497,8 @@ export class SubscriptionScheduler extends EventEmitter { try { downloadEngine.startDownload(downloadId, { url, - type: 'video', + type: downloadType, + format: formatPreference, customDownloadPath: downloadDirectory, customFilenameTemplate: namingTemplate, tags, diff --git a/src/renderer/src/components/download/DownloadDialog.tsx b/src/renderer/src/components/download/DownloadDialog.tsx index 268e5ae..1171b83 100644 --- a/src/renderer/src/components/download/DownloadDialog.tsx +++ b/src/renderer/src/components/download/DownloadDialog.tsx @@ -14,8 +14,11 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs' import { cn } from '@renderer/lib/utils' - -import type { AppSettings, OneClickQualityPreset, PlaylistInfo, VideoFormat } from '@shared/types' +import type { PlaylistInfo, VideoFormat } from '@shared/types' +import { + buildAudioFormatPreference, + buildVideoFormatPreference +} from '@shared/utils/format-preferences' import { useAtom, useSetAtom } from 'jotai' import { AlertCircle, FolderOpen, List, Loader2, Plus, Video } from 'lucide-react' import { useCallback, useEffect, useId, useMemo, useState } from 'react' @@ -37,34 +40,6 @@ import { } from '../../store/video' import { VideoInfoCard, type VideoInfoCardState } from '../video/VideoInfoCard' -const qualityPresetToVideoHeight: Record = { - best: null, - good: 1080, - normal: 720, - bad: 480, - worst: 360 -} - -const qualityPresetToAudioAbr: Record = { - best: 320, - good: 256, - normal: 192, - bad: 128, - worst: 96 -} - -const dedupe = (candidates: Array): string[] => { - const seen = new Set() - const result: string[] = [] - for (const candidate of candidates) { - if (!candidate) continue - if (seen.has(candidate)) continue - seen.add(candidate) - result.push(candidate) - } - return result -} - const isLikelyUrl = (value: string): boolean => { try { const parsed = new URL(value) @@ -74,19 +49,6 @@ const isLikelyUrl = (value: string): boolean => { } } -const getQualityPreset = (settings: AppSettings): OneClickQualityPreset => - settings.oneClickQuality ?? 'best' - -const buildAudioSelectors = (preset: OneClickQualityPreset): string[] => { - if (preset === 'worst') { - return dedupe(['worstaudio', 'bestaudio']) - } - - const abrLimit = qualityPresetToAudioAbr[preset] - // Prefer audio-only selectors so video+audio merges remain valid. - return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio']) -} - const isAudioOnlyFormat = (format: VideoFormat): boolean => !!format.acodec && format.acodec !== 'none' && (!format.video_ext || format.video_ext === 'none') @@ -140,49 +102,6 @@ const pickBestAudioFormatsByLanguage = (formats: VideoFormat[]): string[] => { }) .filter((id): id is string => !!id) } - -const buildVideoFormatPreference = (settings: AppSettings): string => { - const preset = getQualityPreset(settings) - - if (preset === 'worst') { - // Prefer separate streams, then fall back to single-file selectors. - return 'worstvideo+worstaudio/worst/best' - } - - const maxHeight = qualityPresetToVideoHeight[preset] - const videoCandidates = dedupe([ - maxHeight ? `bestvideo[height<=${maxHeight}]` : undefined, - 'bestvideo' - ]) - - const audioSelectors = buildAudioSelectors(preset) - const combinations: string[] = [] - - for (const video of videoCandidates) { - for (const audio of audioSelectors) { - combinations.push(`${video}+${audio}`) - } - } - - if (audioSelectors.includes('none')) { - for (const video of videoCandidates) { - combinations.push(video) - } - } else { - // Prefer merged formats, then allow 'best' as a compatibility fallback. - combinations.push('bestvideo+bestaudio') - } - - combinations.push('best') - - return dedupe(combinations).join('/') -} - -const buildAudioFormatPreference = (settings: AppSettings): string => { - const selectors = buildAudioSelectors(getQualityPreset(settings)) - return dedupe([...selectors, 'best']).join('/') -} - interface DownloadDialogProps { onOpenSupportedSites?: () => void onOpenSettings?: () => void diff --git a/src/shared/utils/format-preferences.ts b/src/shared/utils/format-preferences.ts new file mode 100644 index 0000000..a0db506 --- /dev/null +++ b/src/shared/utils/format-preferences.ts @@ -0,0 +1,81 @@ +import type { AppSettings, OneClickQualityPreset } from '../types' + +const qualityPresetToVideoHeight: Record = { + best: null, + good: 1080, + normal: 720, + bad: 480, + worst: 360 +} + +const qualityPresetToAudioAbr: Record = { + best: 320, + good: 256, + normal: 192, + bad: 128, + worst: 96 +} + +const dedupe = (candidates: Array): string[] => { + const seen = new Set() + const result: string[] = [] + for (const candidate of candidates) { + if (!candidate) continue + if (seen.has(candidate)) continue + seen.add(candidate) + result.push(candidate) + } + return result +} + +const getQualityPreset = (settings: AppSettings): OneClickQualityPreset => + settings.oneClickQuality ?? 'best' + +const buildAudioSelectors = (preset: OneClickQualityPreset): string[] => { + if (preset === 'worst') { + return dedupe(['worstaudio', 'bestaudio']) + } + + const abrLimit = qualityPresetToAudioAbr[preset] + return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio']) +} + +export const buildVideoFormatPreference = (settings: AppSettings): string => { + const preset = getQualityPreset(settings) + + if (preset === 'worst') { + return 'worstvideo+worstaudio/worst/best' + } + + const maxHeight = qualityPresetToVideoHeight[preset] + const videoCandidates = dedupe([ + maxHeight ? `bestvideo[height<=${maxHeight}]` : undefined, + 'bestvideo' + ]) + + const audioSelectors = buildAudioSelectors(preset) + const combinations: string[] = [] + + for (const video of videoCandidates) { + for (const audio of audioSelectors) { + combinations.push(`${video}+${audio}`) + } + } + + if (audioSelectors.includes('none')) { + for (const video of videoCandidates) { + combinations.push(video) + } + } else { + combinations.push('bestvideo+bestaudio') + } + + combinations.push('best') + + return dedupe(combinations).join('/') +} + +export const buildAudioFormatPreference = (settings: AppSettings): string => { + const selectors = buildAudioSelectors(getQualityPreset(settings)) + return dedupe([...selectors, 'best']).join('/') +}