Add subtitle metadata and improve downloader UI

Expose subtitle information in the API and update the frontend to use it: backend adds subtitle_languages and has_subtitles to the downloader serializer/response (computed from subtitles and automatic_captions). Generated TypeScript models (public & private) now include these fields. Downloader UI: gather available subtitle languages, show a select when languages exist, provide contextual input/placeholder when only auto-captions exist, and disable/annotate embed controls when subtitles or thumbnails are unavailable. These changes enable subtitle selection/embedding and surface thumbnail availability to users.
This commit is contained in:
2026-05-11 00:53:24 +02:00
parent f8150eeda7
commit e1df55df0e
4 changed files with 105 additions and 23 deletions

View File

@@ -96,6 +96,13 @@ class Downloader(APIView):
child=serializers.CharField(), child=serializers.CharField(),
help_text="List of available audio format options" help_text="List of available audio format options"
), ),
"subtitle_languages": serializers.ListField(
child=serializers.CharField(),
help_text="List of manually available subtitle language codes (e.g., 'en', 'cs')"
),
"has_subtitles": serializers.BooleanField(
help_text="True if any subtitles or auto-captions are available"
),
} }
), ),
help_text="Array of video information (single video for individual URLs, multiple for playlists)" help_text="Array of video information (single video for individual URLs, multiple for playlists)"
@@ -177,13 +184,20 @@ class Downloader(APIView):
# If thumbnail fetch fails, just continue without it # If thumbnail fetch fails, just continue without it
pass pass
subtitles_data = video_data.get("subtitles") or {}
auto_captions = video_data.get("automatic_captions") or {}
subtitle_languages = sorted(subtitles_data.keys())
has_subtitles = bool(subtitles_data) or bool(auto_captions)
return { return {
"id": video_data.get("id", ""), "id": video_data.get("id", ""),
"title": video_data.get("title", ""), "title": video_data.get("title", ""),
"duration": video_data.get("duration"), "duration": video_data.get("duration"),
"thumbnail": thumbnail_blob, # Now a base64 blob instead of URL "thumbnail": thumbnail_blob,
"video_resolutions": video_resolutions, "video_resolutions": video_resolutions,
"audio_resolutions": audio_resolutions, "audio_resolutions": audio_resolutions,
"subtitle_languages": subtitle_languages,
"has_subtitles": has_subtitles,
} }
# Check if this is a playlist # Check if this is a playlist

View File

@@ -23,4 +23,8 @@ export interface VideoInfo {
video_resolutions: string[]; video_resolutions: string[];
/** List of available audio format options */ /** List of available audio format options */
audio_resolutions: string[]; audio_resolutions: string[];
/** List of manually available subtitle language codes (e.g., 'en', 'cs') */
subtitle_languages: string[];
/** True if any subtitles or auto-captions are available */
has_subtitles: boolean;
} }

View File

@@ -23,4 +23,8 @@ export interface VideoInfo {
video_resolutions: string[]; video_resolutions: string[];
/** List of available audio format options */ /** List of available audio format options */
audio_resolutions: string[]; audio_resolutions: string[];
/** List of manually available subtitle language codes (e.g., 'en', 'cs') */
subtitle_languages: string[];
/** True if any subtitles or auto-captions are available */
has_subtitles: boolean;
} }

View File

@@ -97,6 +97,18 @@ export default function Downloader() {
}); });
}; };
const getSubtitleLanguages = () => {
if (!videoInfo?.videos) return [];
const allLangs = new Set<string>();
videoInfo.videos.forEach(video => {
video.subtitle_languages.forEach(lang => allLangs.add(lang));
});
return Array.from(allLangs).sort();
};
const videoHasSubtitles = videoInfo?.videos.some(v => v.has_subtitles) ?? false;
const videoHasThumbnail = videoInfo?.videos.some(v => v.thumbnail !== null) ?? false;
async function retrieveVideoInfo() { async function retrieveVideoInfo() {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
@@ -479,40 +491,88 @@ export default function Downloader() {
<div> <div>
<label className="block text-sm font-medium mb-2 flex items-center gap-2"> <label className="block text-sm font-medium mb-2 flex items-center gap-2">
<FaFont className="text-gray-500" /> <FaFont className="text-gray-500" />
Subtitles (optional) Subtitles
{!videoHasSubtitles && <span className="text-gray-400 font-normal text-xs">(not available)</span>}
</label> </label>
<input {(() => {
type="text" const subtitleLanguages = getSubtitleLanguages();
value={subtitles} if (subtitleLanguages.length > 0) {
onChange={(e) => setSubtitles(e.target.value)} return (
placeholder="e.g., 'en', 'en,cs', or 'all'" <>
className="w-full p-2 border rounded" <select
/> value={subtitles}
<p className="text-xs text-gray-500 mt-1"> onChange={(e) => setSubtitles(e.target.value)}
Language codes (e.g., 'en', 'cs') or 'all' for all available className="w-full p-2 border rounded"
</p> >
<option value="">None</option>
<option value="all">All available</option>
{subtitleLanguages.map(lang => (
<option key={lang} value={lang}>{lang}</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
{subtitleLanguages.length} language{subtitleLanguages.length !== 1 ? 's' : ''} available
</p>
</>
);
}
return (
<>
<input
type="text"
value={subtitles}
onChange={(e) => setSubtitles(e.target.value)}
placeholder={videoHasSubtitles ? "e.g., 'en', 'en,cs', or 'all'" : 'No subtitles available'}
disabled={!videoHasSubtitles}
className={`w-full p-2 border rounded ${!videoHasSubtitles ? 'opacity-50 cursor-not-allowed bg-gray-100' : ''}`}
/>
{videoHasSubtitles && (
<p className="text-xs text-gray-500 mt-1">
Auto-captions available enter language codes (e.g., 'en', 'cs') or 'all'
</p>
)}
</>
);
})()}
</div> </div>
{/* Checkboxes Row */} {/* Checkboxes Row */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="flex items-center gap-2"> {(() => {
<input const embedSubsDisabled = !subtitles || !['mkv', 'mp4'].includes(selectedExtension);
type="checkbox" return (
checked={embedSubtitles} <label className={`flex items-center gap-2 ${embedSubsDisabled ? 'opacity-50' : ''}`}>
onChange={(e) => setEmbedSubtitles(e.target.checked)} <input
className="w-4 h-4" type="checkbox"
/> checked={embedSubtitles}
<span className="text-sm">Embed Subtitles (mkv/mp4 only)</span> onChange={(e) => setEmbedSubtitles(e.target.checked)}
</label> disabled={embedSubsDisabled}
className="w-4 h-4"
/>
<span className="text-sm">
Embed Subtitles
{!['mkv', 'mp4'].includes(selectedExtension)
? <span className="text-xs text-gray-400"> (mkv/mp4 only)</span>
: !subtitles
? <span className="text-xs text-gray-400"> (select subtitles first)</span>
: null}
</span>
</label>
);
})()}
<label className="flex items-center gap-2"> <label className={`flex items-center gap-2 ${!videoHasThumbnail ? 'opacity-50' : ''}`}>
<input <input
type="checkbox" type="checkbox"
checked={embedThumbnail} checked={embedThumbnail}
onChange={(e) => setEmbedThumbnail(e.target.checked)} onChange={(e) => setEmbedThumbnail(e.target.checked)}
disabled={!videoHasThumbnail}
className="w-4 h-4" className="w-4 h-4"
/> />
<span className="text-sm">Embed Thumbnail</span> <span className="text-sm">
Embed Thumbnail
{!videoHasThumbnail && <span className="text-xs text-gray-400"> (no thumbnail available)</span>}
</span>
</label> </label>
</div> </div>