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:
16
backend/thirdparty/downloader/views.py
vendored
16
backend/thirdparty/downloader/views.py
vendored
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user