From 3533525df09ad76eea550450668f8832f3c8eb4b Mon Sep 17 00:00:00 2001 From: Maxim Vershinin Date: Fri, 28 Aug 2026 15:40:38 +0500 Subject: [PATCH] FEATURE: propagate improvements from CLI and web-API versions to web-frontend --- web/frontend/src/App.tsx | 15 +- web/frontend/src/api/client.ts | 52 +++-- web/frontend/src/components/OptionsPanel.tsx | 190 +++++++++++++------ web/frontend/src/components/UploadZone.tsx | 5 +- web/frontend/src/stores/optionsStore.ts | 37 ++-- web/frontend/src/types/index.ts | 57 +++--- 6 files changed, 236 insertions(+), 120 deletions(-) diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 7c48eac..52a73b9 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -68,16 +68,15 @@ const App: React.FC = () => { reset() } - const isSplitDisabled = !taskId || !isValid || entries.length === 0 || isProcessing || !!formatError + const isSplitDisabled = + !taskId || + !isValid || + entries.length === 0 || + isProcessing || + !!formatError return ( - + diff --git a/web/frontend/src/api/client.ts b/web/frontend/src/api/client.ts index 546044f..aa6b0e6 100644 --- a/web/frontend/src/api/client.ts +++ b/web/frontend/src/api/client.ts @@ -1,7 +1,12 @@ -// web/frontend/src/api/client.ts - import axios from 'axios' -import { TracklistEntry, SplitOptions, TaskStatus, FormatsResponse } from '../types' +import { + TracklistEntry, + SplitOptions, + TaskStatus, + FormatsResponse, + UploadResponse, + SplitResponse, +} from '../types' export const api = axios.create({ baseURL: '/api', @@ -10,16 +15,14 @@ export const api = axios.create({ }, }) -export const uploadFile = async (file: File): Promise<{ task_id: string; filename: string; size: number }> => { +export const uploadFile = async (file: File): Promise => { const formData = new FormData() formData.append('file', file) - const response = await api.post('/upload', formData, { headers: { 'Content-Type': 'multipart/form-data', }, }) - return response.data } @@ -27,12 +30,32 @@ export const startSplit = async ( task_id: string, tracklist: TracklistEntry[], options: SplitOptions -): Promise<{ task_id: string; status: string }> => { - const response = await api.post('/split', { +): Promise => { + // Build request payload (only send fields that are not undefined) + const payload: any = { task_id, tracklist, - options, - }) + container: options.container, // null means auto-detect + audio_codec: options.audio_codec, + video_codec: options.video_codec, + subtitle_codec: options.subtitle_codec, + video_quality: options.video_quality, + drop_video: options.drop_video, + drop_subs: options.drop_subs, + number_tracks: options.number_tracks, + replace_bad_chars: options.replace_bad_chars, + replacement_char: options.replacement_char, + bad_chars: options.bad_chars, + skip_existing: options.skip_existing, + output_template: options.output_template, + album: options.album || null, + comment: options.comment || null, + no_comment: options.no_comment, + comment_stream: options.comment_stream, + merge_comments: options.merge_comments, + comment_separator: options.comment_separator, + } + const response = await api.post('/split', payload) return response.data } @@ -49,8 +72,7 @@ export const getDownloadZipUrl = (task_id: string): string => { return `/api/download/${task_id}/splits.zip` } -// New functions for format validation feature -export const getFormatInfo = async (): Promise<{ formats: Array<{ name: string; audio_only: boolean }> }> => { +export const getFormats = async (): Promise => { const response = await api.get('/formats') return response.data } @@ -70,9 +92,3 @@ export const getRecommendedFormat = async (task_id: string): Promise<{ format: s const response = await api.get(`/info/recommended-format/${task_id}`) return response.data } - -// New: fetch containers and codecs -export const getFormats = async (): Promise => { - const response = await api.get('/formats') - return response.data -} diff --git a/web/frontend/src/components/OptionsPanel.tsx b/web/frontend/src/components/OptionsPanel.tsx index dbd5aaf..eaec8a2 100644 --- a/web/frontend/src/components/OptionsPanel.tsx +++ b/web/frontend/src/components/OptionsPanel.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react' +import React, { useEffect, useMemo } from 'react' import { Box, Paper, @@ -17,6 +17,7 @@ import { useOptionsStore } from '../stores/optionsStore' import { useUploadStore } from '../stores/uploadStore' import { useValidationStore } from '../stores/validationStore' import { getFormats } from '../api/client' +import { SplitOptions } from '../types' // ------------------------------------------------------------------------------ // Section component (collapsible) @@ -29,7 +30,6 @@ interface SectionProps { const Section: React.FC = ({ title, children, defaultExpanded = false }) => { const [expanded, setExpanded] = React.useState(defaultExpanded) - return ( = ({ title, children, defaultExpanded = fa } // ------------------------------------------------------------------------------ -// Main OptionsPanel component +// Main OptionsPanel // ------------------------------------------------------------------------------ export const OptionsPanel: React.FC = () => { const { @@ -64,57 +64,103 @@ export const OptionsPanel: React.FC = () => { setOptions, containers, codecs, + compatibility, setContainers, setCodecs, + setCompatibility, } = useOptionsStore() const { hasVideo } = useUploadStore() const { formatError, setFormatError } = useValidationStore() - // Fetch containers and codecs from backend on mount + // Fetch formats on mount useEffect(() => { const fetchFormats = async () => { try { const data = await getFormats() - setContainers(data.containers) - setCodecs(data.codecs) + setContainers(data.containers || []) + setCodecs(data.codecs || []) + setCompatibility(data.compatibility || {}) } catch (err) { console.error('Failed to fetch formats:', err) } } fetchFormats() - }, [setContainers, setCodecs]) + }, [setContainers, setCodecs, setCompatibility]) + + // -------------------------------------------------------------- + // Filter codec options based on selected container + // -------------------------------------------------------------- + const filteredAudioCodecs = useMemo(() => { + const entry = compatibility?.[options.container || ''] + if (!entry) return codecs.filter(c => c.supports_transcoding) + const audioList = entry.audio + if (audioList === null) return codecs.filter(c => c.supports_transcoding) + return codecs.filter(c => c.supports_transcoding && audioList.includes(c.name)) + }, [compatibility, options.container, codecs]) + + const filteredVideoCodecs = useMemo(() => { + const entry = compatibility?.[options.container || ''] + if (!entry) return codecs.filter(c => c.supports_video) + const videoList = entry.video + if (videoList === null) return codecs.filter(c => c.supports_video) + return codecs.filter(c => c.supports_video && videoList.includes(c.name)) + }, [compatibility, options.container, codecs]) + + // -------------------------------------------------------------- + // Validate compatibility + // -------------------------------------------------------------- + useEffect(() => { + const selectedContainer = containers.find(c => c.name === options.container) + if (selectedContainer?.audio_only && hasVideo && !options.drop_video) { + setFormatError( + `Container '${options.container}' does not support video streams. Please enable "Drop video" or choose a container that supports video.` + ) + return + } + + // Check audio codec compatibility + if (options.audio_codec && options.audio_codec !== 'copy' && options.container) { + const entry = compatibility?.[options.container] + if (entry) { + const audioList = entry.audio + if (audioList !== null && !audioList.includes(options.audio_codec)) { + setFormatError( + `Audio codec '${options.audio_codec}' is not supported by container '${options.container}'.` + ) + return + } + } + } + + // Check video codec compatibility + if (options.video_codec && options.video_codec !== 'copy' && options.container && hasVideo && !options.drop_video) { + const entry = compatibility?.[options.container] + if (entry) { + const videoList = entry.video + if (videoList !== null && !videoList.includes(options.video_codec)) { + setFormatError( + `Video codec '${options.video_codec}' is not supported by container '${options.container}'.` + ) + return + } + } + } + + setFormatError(null) + }, [options.container, options.audio_codec, options.video_codec, options.drop_video, hasVideo, compatibility, containers, setFormatError]) // -------------------------------------------------------------- // Handlers // -------------------------------------------------------------- - const handleFormatChange = (e: React.ChangeEvent) => { - const newFormat = e.target.value - setOptions({ format: newFormat }) - } - - const handleDropVideoChange = (e: React.ChangeEvent) => { - const checked = e.target.checked - setOptions({ drop_video: checked }) - } - - const handleChange = (field: string, value: any) => { + const handleChange = (field: keyof SplitOptions, value: any) => { setOptions({ [field]: value }) } - // -------------------------------------------------------------- - // Format validation – re-run when relevant state changes - // -------------------------------------------------------------- - useEffect(() => { - const selectedContainer = containers.find(c => c.name === options.format) - if (selectedContainer?.audio_only && hasVideo && !options.drop_video) { - setFormatError( - `Container '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).` - ) - } else { - setFormatError(null) - } - }, [containers, options.format, options.drop_video, hasVideo, setFormatError]) + const handleContainerChange = (e: React.ChangeEvent) => { + const value = e.target.value === '' ? null : e.target.value + handleChange('container', value) + } // -------------------------------------------------------------- // Render @@ -140,56 +186,92 @@ export const OptionsPanel: React.FC = () => { - {containers.map((container) => ( - - {container.name.toUpperCase()} ({container.extension}) + Auto-detect + {containers.map((c) => ( + + {c.name.toUpperCase()} ({c.extension}) ))} - {/* Audio Codec (Transcode) dropdown */} + {/* Audio Codec dropdown */} handleChange('transcode_to', e.target.value || undefined)} + value={options.audio_codec} + onChange={(e) => handleChange('audio_codec', e.target.value)} fullWidth size="small" - helperText="Select an audio codec to re-encode, or keep 'Copy' to preserve the original." + helperText="Audio codec (copy = keep original)" > - Copy (no transcoding) - {codecs - .filter(c => c.supports_transcoding) - .map((codec) => ( - - {codec.name.toUpperCase()} (→ {codec.recommended_container}) - - ))} + Copy (original) + {filteredAudioCodecs.map((c) => ( + + {c.name.toUpperCase()} + + ))} - {/* Drop video stream (only shown if video present) */} + {/* Video Codec dropdown */} + handleChange('video_codec', e.target.value)} + fullWidth + size="small" + helperText="Video codec (copy = keep original)" + disabled={!hasVideo || options.drop_video} + > + Copy (original) + {filteredVideoCodecs.map((c) => ( + + {c.name.toUpperCase()} + + ))} + + + {/* Video Quality */} + { + const val = e.target.value === '' ? null : parseInt(e.target.value, 10) + handleChange('video_quality', val) + }} + fullWidth + size="small" + disabled={!hasVideo || options.drop_video} + helperText="Optional quality value (encoder-specific; e.g., 1-31 for libx264, 0-10 for Theora)" + InputProps={{ + inputProps: { min: 0, max: 51, step: 1 }, + }} + /> + + {/* Drop video (if video present) */} {hasVideo && ( handleChange('drop_video', e.target.checked)} /> } label="Drop video streams" /> )} - {/* Drop subtitle streams */} + {/* Drop subtitles */} { type="number" value={options.comment_stream ?? ''} onChange={(e) => - handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value)) + handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value, 10)) } size="small" disabled={options.no_comment} diff --git a/web/frontend/src/components/UploadZone.tsx b/web/frontend/src/components/UploadZone.tsx index c49c47f..62eb5c9 100644 --- a/web/frontend/src/components/UploadZone.tsx +++ b/web/frontend/src/components/UploadZone.tsx @@ -70,9 +70,10 @@ export const UploadZone: React.FC = () => { // Fetch recommended format and update options try { + // Inside UploadZone.tsx, after fetching recommended format: const rec = await getRecommendedFormat(taskId) - // Update only the format; keep other options (e.g., transcode_to) as defaults - setOptions({ format: rec.format }) + // Update options store with container (not format) + setOptions({ container: rec.format }) } catch (err) { console.warn('Failed to fetch recommended format, using default', err) } diff --git a/web/frontend/src/stores/optionsStore.ts b/web/frontend/src/stores/optionsStore.ts index 420948a..4208d63 100644 --- a/web/frontend/src/stores/optionsStore.ts +++ b/web/frontend/src/stores/optionsStore.ts @@ -1,5 +1,3 @@ -// web/frontend/src/stores/optionsStore.ts - import { create } from 'zustand' import { SplitOptions, ContainerInfo, CodecInfo } from '../types' import { @@ -18,24 +16,16 @@ import { DEFAULT_NUMBER_TRACKS, DEFAULT_REPLACE_BAD_CHARS, DEFAULT_SKIP_EXISTING, - DEFAULT_TRANSCODE_TO, DEFAULT_TRACKLIST_FORMAT, } from '../constants/generated' -// Extend the state -interface OptionsState { - options: SplitOptions - containers: ContainerInfo[] - codecs: CodecInfo[] - setOptions: (options: Partial) => void - setContainers: (containers: ContainerInfo[]) => void - setCodecs: (codecs: CodecInfo[]) => void - reset: () => void -} - +// We use DEFAULT_FORMAT only as a fallback; container default is null (auto-detect) const DEFAULT_OPTIONS: SplitOptions = { - format: DEFAULT_FORMAT, - transcode_to: DEFAULT_TRANSCODE_TO ?? '', + container: null, + audio_codec: 'copy', + video_codec: 'copy', + subtitle_codec: 'copy', + video_quality: null, drop_video: DEFAULT_DROP_VIDEO, drop_subs: DEFAULT_DROP_SUBS, number_tracks: DEFAULT_NUMBER_TRACKS, @@ -53,20 +43,35 @@ const DEFAULT_OPTIONS: SplitOptions = { tracklist_format: DEFAULT_TRACKLIST_FORMAT, } +interface OptionsState { + options: SplitOptions + containers: ContainerInfo[] + codecs: CodecInfo[] + compatibility: Record + setOptions: (newOptions: Partial) => void + setContainers: (containers: ContainerInfo[]) => void + setCodecs: (codecs: CodecInfo[]) => void + setCompatibility: (compat: Record) => void + reset: () => void +} + export const useOptionsStore = create((set) => ({ options: { ...DEFAULT_OPTIONS }, containers: [], codecs: [], + compatibility: {}, setOptions: (newOptions) => set((state) => ({ options: { ...state.options, ...newOptions }, })), setContainers: (containers) => set({ containers }), setCodecs: (codecs) => set({ codecs }), + setCompatibility: (compatibility) => set({ compatibility }), reset: () => set({ options: { ...DEFAULT_OPTIONS }, containers: [], codecs: [], + compatibility: {}, }), })) diff --git a/web/frontend/src/types/index.ts b/web/frontend/src/types/index.ts index 44a5b4b..76437d2 100644 --- a/web/frontend/src/types/index.ts +++ b/web/frontend/src/types/index.ts @@ -22,8 +22,11 @@ export interface TaskStatus { } export interface SplitOptions { - format: string - transcode_to?: string + container: string | null // null = auto-detect + audio_codec: string // 'copy' or encoder name + video_codec: string // 'copy' or encoder name + subtitle_codec: string // 'copy' or encoder name + video_quality: number | null // encoder-specific integer drop_video: boolean drop_subs: boolean number_tracks: boolean @@ -38,7 +41,36 @@ export interface SplitOptions { comment_stream: number | null merge_comments: boolean comment_separator: string - tracklist_format: string + tracklist_format: string // for frontend parsing +} + +export interface ContainerInfo { + name: string + ffmpeg: string + extension: string + audio_only: boolean + supports_video: boolean + supports_subs: boolean +} + +export interface CodecInfo { + name: string + ffmpeg: string + recommended_container: string + recommended_extension: string + supports_transcoding: boolean + supports_video: boolean +} + +export interface CompatibilityEntry { + audio: string[] | null + video: string[] | null +} + +export interface FormatsResponse { + containers: ContainerInfo[] + codecs: CodecInfo[] + compatibility: Record } export interface UploadResponse { @@ -51,22 +83,3 @@ export interface SplitResponse { task_id: string status: string } - -export interface ContainerInfo { - name: string - ffmpeg: string - extension: string - audio_only: boolean -} - -export interface CodecInfo { - name: string - ffmpeg: string - recommended_container: string - supports_transcoding: boolean -} - -export interface FormatsResponse { - containers: ContainerInfo[] - codecs: CodecInfo[] -}