FEATURE: propagate improvements from CLI and web-API versions to web-frontend

This commit is contained in:
2026-08-28 15:40:38 +05:00
parent 834239674a
commit 3533525df0
6 changed files with 236 additions and 120 deletions
+7 -8
View File
@@ -68,16 +68,15 @@ const App: React.FC = () => {
reset() reset()
} }
const isSplitDisabled = !taskId || !isValid || entries.length === 0 || isProcessing || !!formatError const isSplitDisabled =
!taskId ||
!isValid ||
entries.length === 0 ||
isProcessing ||
!!formatError
return ( return (
<ThemeProvider <ThemeProvider theme={createTheme({ palette: { mode: theme } })}>
theme={createTheme({
palette: {
mode: theme,
},
})}
>
<CssBaseline /> <CssBaseline />
<Layout> <Layout>
<Grid container spacing={3}> <Grid container spacing={3}>
+34 -18
View File
@@ -1,7 +1,12 @@
// web/frontend/src/api/client.ts
import axios from 'axios' 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({ export const api = axios.create({
baseURL: '/api', 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<UploadResponse> => {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
const response = await api.post('/upload', formData, { const response = await api.post('/upload', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
}) })
return response.data return response.data
} }
@@ -27,12 +30,32 @@ export const startSplit = async (
task_id: string, task_id: string,
tracklist: TracklistEntry[], tracklist: TracklistEntry[],
options: SplitOptions options: SplitOptions
): Promise<{ task_id: string; status: string }> => { ): Promise<SplitResponse> => {
const response = await api.post('/split', { // Build request payload (only send fields that are not undefined)
const payload: any = {
task_id, task_id,
tracklist, 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 return response.data
} }
@@ -49,8 +72,7 @@ export const getDownloadZipUrl = (task_id: string): string => {
return `/api/download/${task_id}/splits.zip` return `/api/download/${task_id}/splits.zip`
} }
// New functions for format validation feature export const getFormats = async (): Promise<FormatsResponse> => {
export const getFormatInfo = async (): Promise<{ formats: Array<{ name: string; audio_only: boolean }> }> => {
const response = await api.get('/formats') const response = await api.get('/formats')
return response.data 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}`) const response = await api.get(`/info/recommended-format/${task_id}`)
return response.data return response.data
} }
// New: fetch containers and codecs
export const getFormats = async (): Promise<FormatsResponse> => {
const response = await api.get('/formats')
return response.data
}
+136 -54
View File
@@ -1,4 +1,4 @@
import React, { useEffect } from 'react' import React, { useEffect, useMemo } from 'react'
import { import {
Box, Box,
Paper, Paper,
@@ -17,6 +17,7 @@ import { useOptionsStore } from '../stores/optionsStore'
import { useUploadStore } from '../stores/uploadStore' import { useUploadStore } from '../stores/uploadStore'
import { useValidationStore } from '../stores/validationStore' import { useValidationStore } from '../stores/validationStore'
import { getFormats } from '../api/client' import { getFormats } from '../api/client'
import { SplitOptions } from '../types'
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------
// Section component (collapsible) // Section component (collapsible)
@@ -29,7 +30,6 @@ interface SectionProps {
const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = false }) => { const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = false }) => {
const [expanded, setExpanded] = React.useState(defaultExpanded) const [expanded, setExpanded] = React.useState(defaultExpanded)
return ( return (
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
<Box <Box
@@ -56,7 +56,7 @@ const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = fa
} }
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------
// Main OptionsPanel component // Main OptionsPanel
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------
export const OptionsPanel: React.FC = () => { export const OptionsPanel: React.FC = () => {
const { const {
@@ -64,57 +64,103 @@ export const OptionsPanel: React.FC = () => {
setOptions, setOptions,
containers, containers,
codecs, codecs,
compatibility,
setContainers, setContainers,
setCodecs, setCodecs,
setCompatibility,
} = useOptionsStore() } = useOptionsStore()
const { hasVideo } = useUploadStore() const { hasVideo } = useUploadStore()
const { formatError, setFormatError } = useValidationStore() const { formatError, setFormatError } = useValidationStore()
// Fetch containers and codecs from backend on mount // Fetch formats on mount
useEffect(() => { useEffect(() => {
const fetchFormats = async () => { const fetchFormats = async () => {
try { try {
const data = await getFormats() const data = await getFormats()
setContainers(data.containers) setContainers(data.containers || [])
setCodecs(data.codecs) setCodecs(data.codecs || [])
setCompatibility(data.compatibility || {})
} catch (err) { } catch (err) {
console.error('Failed to fetch formats:', err) console.error('Failed to fetch formats:', err)
} }
} }
fetchFormats() 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 // Handlers
// -------------------------------------------------------------- // --------------------------------------------------------------
const handleFormatChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (field: keyof SplitOptions, value: any) => {
const newFormat = e.target.value
setOptions({ format: newFormat })
}
const handleDropVideoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const checked = e.target.checked
setOptions({ drop_video: checked })
}
const handleChange = (field: string, value: any) => {
setOptions({ [field]: value }) setOptions({ [field]: value })
} }
// -------------------------------------------------------------- const handleContainerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Format validation re-run when relevant state changes const value = e.target.value === '' ? null : e.target.value
// -------------------------------------------------------------- handleChange('container', value)
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])
// -------------------------------------------------------------- // --------------------------------------------------------------
// Render // Render
@@ -140,56 +186,92 @@ export const OptionsPanel: React.FC = () => {
<TextField <TextField
label="Container" label="Container"
select select
value={options.format} value={options.container ?? ''}
onChange={handleFormatChange} onChange={handleContainerChange}
fullWidth fullWidth
size="small" size="small"
error={!!formatError} error={!!formatError}
helperText={ helperText={
formatError || "Determines the output file extension and container structure." formatError || "Select a container (auto-detect if empty)."
} }
> >
{containers.map((container) => ( <MenuItem value="">Auto-detect</MenuItem>
<MenuItem key={container.name} value={container.name}> {containers.map((c) => (
{container.name.toUpperCase()} ({container.extension}) <MenuItem key={c.name} value={c.name}>
{c.name.toUpperCase()} ({c.extension})
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>
{/* Audio Codec (Transcode) dropdown */} {/* Audio Codec dropdown */}
<TextField <TextField
label="Audio Codec (Transcode)" label="Audio Codec"
select select
value={options.transcode_to || ''} value={options.audio_codec}
onChange={(e) => handleChange('transcode_to', e.target.value || undefined)} onChange={(e) => handleChange('audio_codec', e.target.value)}
fullWidth fullWidth
size="small" size="small"
helperText="Select an audio codec to re-encode, or keep 'Copy' to preserve the original." helperText="Audio codec (copy = keep original)"
> >
<MenuItem value="">Copy (no transcoding)</MenuItem> <MenuItem value="copy">Copy (original)</MenuItem>
{codecs {filteredAudioCodecs.map((c) => (
.filter(c => c.supports_transcoding) <MenuItem key={c.name} value={c.name}>
.map((codec) => ( {c.name.toUpperCase()}
<MenuItem key={codec.name} value={codec.ffmpeg}> </MenuItem>
{codec.name.toUpperCase()} ( {codec.recommended_container}) ))}
</MenuItem>
))}
</TextField> </TextField>
{/* Drop video stream (only shown if video present) */} {/* Video Codec dropdown */}
<TextField
label="Video Codec"
select
value={options.video_codec}
onChange={(e) => handleChange('video_codec', e.target.value)}
fullWidth
size="small"
helperText="Video codec (copy = keep original)"
disabled={!hasVideo || options.drop_video}
>
<MenuItem value="copy">Copy (original)</MenuItem>
{filteredVideoCodecs.map((c) => (
<MenuItem key={c.name} value={c.name}>
{c.name.toUpperCase()}
</MenuItem>
))}
</TextField>
{/* Video Quality */}
<TextField
label="Video Quality"
type="number"
value={options.video_quality ?? ''}
onChange={(e) => {
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 && ( {hasVideo && (
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
checked={options.drop_video} checked={options.drop_video}
onChange={handleDropVideoChange} onChange={(e) => handleChange('drop_video', e.target.checked)}
/> />
} }
label="Drop video streams" label="Drop video streams"
/> />
)} )}
{/* Drop subtitle streams */} {/* Drop subtitles */}
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
@@ -293,7 +375,7 @@ export const OptionsPanel: React.FC = () => {
type="number" type="number"
value={options.comment_stream ?? ''} value={options.comment_stream ?? ''}
onChange={(e) => 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" size="small"
disabled={options.no_comment} disabled={options.no_comment}
+3 -2
View File
@@ -70,9 +70,10 @@ export const UploadZone: React.FC = () => {
// Fetch recommended format and update options // Fetch recommended format and update options
try { try {
// Inside UploadZone.tsx, after fetching recommended format:
const rec = await getRecommendedFormat(taskId) const rec = await getRecommendedFormat(taskId)
// Update only the format; keep other options (e.g., transcode_to) as defaults // Update options store with container (not format)
setOptions({ format: rec.format }) setOptions({ container: rec.format })
} catch (err) { } catch (err) {
console.warn('Failed to fetch recommended format, using default', err) console.warn('Failed to fetch recommended format, using default', err)
} }
+21 -16
View File
@@ -1,5 +1,3 @@
// web/frontend/src/stores/optionsStore.ts
import { create } from 'zustand' import { create } from 'zustand'
import { SplitOptions, ContainerInfo, CodecInfo } from '../types' import { SplitOptions, ContainerInfo, CodecInfo } from '../types'
import { import {
@@ -18,24 +16,16 @@ import {
DEFAULT_NUMBER_TRACKS, DEFAULT_NUMBER_TRACKS,
DEFAULT_REPLACE_BAD_CHARS, DEFAULT_REPLACE_BAD_CHARS,
DEFAULT_SKIP_EXISTING, DEFAULT_SKIP_EXISTING,
DEFAULT_TRANSCODE_TO,
DEFAULT_TRACKLIST_FORMAT, DEFAULT_TRACKLIST_FORMAT,
} from '../constants/generated' } from '../constants/generated'
// Extend the state // We use DEFAULT_FORMAT only as a fallback; container default is null (auto-detect)
interface OptionsState {
options: SplitOptions
containers: ContainerInfo[]
codecs: CodecInfo[]
setOptions: (options: Partial<SplitOptions>) => void
setContainers: (containers: ContainerInfo[]) => void
setCodecs: (codecs: CodecInfo[]) => void
reset: () => void
}
const DEFAULT_OPTIONS: SplitOptions = { const DEFAULT_OPTIONS: SplitOptions = {
format: DEFAULT_FORMAT, container: null,
transcode_to: DEFAULT_TRANSCODE_TO ?? '', audio_codec: 'copy',
video_codec: 'copy',
subtitle_codec: 'copy',
video_quality: null,
drop_video: DEFAULT_DROP_VIDEO, drop_video: DEFAULT_DROP_VIDEO,
drop_subs: DEFAULT_DROP_SUBS, drop_subs: DEFAULT_DROP_SUBS,
number_tracks: DEFAULT_NUMBER_TRACKS, number_tracks: DEFAULT_NUMBER_TRACKS,
@@ -53,20 +43,35 @@ const DEFAULT_OPTIONS: SplitOptions = {
tracklist_format: DEFAULT_TRACKLIST_FORMAT, tracklist_format: DEFAULT_TRACKLIST_FORMAT,
} }
interface OptionsState {
options: SplitOptions
containers: ContainerInfo[]
codecs: CodecInfo[]
compatibility: Record<string, { audio: string[] | null; video: string[] | null }>
setOptions: (newOptions: Partial<SplitOptions>) => void
setContainers: (containers: ContainerInfo[]) => void
setCodecs: (codecs: CodecInfo[]) => void
setCompatibility: (compat: Record<string, { audio: string[] | null; video: string[] | null }>) => void
reset: () => void
}
export const useOptionsStore = create<OptionsState>((set) => ({ export const useOptionsStore = create<OptionsState>((set) => ({
options: { ...DEFAULT_OPTIONS }, options: { ...DEFAULT_OPTIONS },
containers: [], containers: [],
codecs: [], codecs: [],
compatibility: {},
setOptions: (newOptions) => setOptions: (newOptions) =>
set((state) => ({ set((state) => ({
options: { ...state.options, ...newOptions }, options: { ...state.options, ...newOptions },
})), })),
setContainers: (containers) => set({ containers }), setContainers: (containers) => set({ containers }),
setCodecs: (codecs) => set({ codecs }), setCodecs: (codecs) => set({ codecs }),
setCompatibility: (compatibility) => set({ compatibility }),
reset: () => reset: () =>
set({ set({
options: { ...DEFAULT_OPTIONS }, options: { ...DEFAULT_OPTIONS },
containers: [], containers: [],
codecs: [], codecs: [],
compatibility: {},
}), }),
})) }))
+35 -22
View File
@@ -22,8 +22,11 @@ export interface TaskStatus {
} }
export interface SplitOptions { export interface SplitOptions {
format: string container: string | null // null = auto-detect
transcode_to?: string 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_video: boolean
drop_subs: boolean drop_subs: boolean
number_tracks: boolean number_tracks: boolean
@@ -38,7 +41,36 @@ export interface SplitOptions {
comment_stream: number | null comment_stream: number | null
merge_comments: boolean merge_comments: boolean
comment_separator: string 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<string, CompatibilityEntry>
} }
export interface UploadResponse { export interface UploadResponse {
@@ -51,22 +83,3 @@ export interface SplitResponse {
task_id: string task_id: string
status: 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[]
}