Skip to content

Commit 7c2cf0b

Browse files
committed
fix: download split video
1 parent eb97f4d commit 7c2cf0b

2 files changed

Lines changed: 53 additions & 5 deletions

File tree

backend/src/youtube/youtube.service.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export interface DownloadRequest {
6060
outputFormat?: string; // video: mp4, webm, mkv | audio: mp3, m4a, opus, flac, wav
6161
startTime?: string; // Optional: clip start time (seconds or mm:ss)
6262
endTime?: string; // Optional: clip end time (seconds or mm:ss)
63+
clipTitle?: string; // Optional: chapter/clip title to use as filename prefix
6364
embedSubtitles?: boolean; // Optional: embed subtitles into video
6465
subtitleLang?: string; // Optional: subtitle language code (en, vi, etc.)
6566
sponsorBlock?: boolean; // Optional: remove sponsor segments using SponsorBlock
@@ -708,13 +709,21 @@ export class YouTubeService implements OnModuleInit {
708709
args.push('--merge-output-format', outputFormat);
709710
}
710711

712+
// Track if we're in clip mode (for disabling aria2c later)
713+
const isClipMode = !!(request.startTime || request.endTime);
714+
711715
// Add clip download using --download-sections
712716
// Note: For long videos this will be slow as it streams from start
713-
if (request.startTime || request.endTime) {
717+
if (isClipMode) {
714718
const start = request.startTime || '0';
715719
const end = request.endTime || 'inf';
716720
args.push('--download-sections', `*${start}-${end}`);
717-
this.logger.log(`✂️ [${id}] Clip mode: ${start} to ${end}`);
721+
// CRITICAL: Force keyframes at cut points to ensure accurate clipping
722+
// Without this, video duration may be wrong and have black frames at the end
723+
args.push('--force-keyframes-at-cuts');
724+
// Prevent parallel stream issues that cause black frames
725+
args.push('--compat-option', 'no-direct-merge');
726+
this.logger.log(`✂️ [${id}] Clip mode: ${start} to ${end} (with keyframe re-encoding)`);
718727
}
719728

720729
// Add subtitle embedding (video only)
@@ -737,8 +746,9 @@ export class YouTubeService implements OnModuleInit {
737746
}
738747

739748
// Auto-use aria2c for large files (>100MB) for faster downloads
749+
// BUT NOT for clip mode - aria2c conflicts with --download-sections causing black frames
740750
const ARIA2C_THRESHOLD = 100 * 1024 * 1024; // 100MB
741-
if (request.estimatedFilesize && request.estimatedFilesize > ARIA2C_THRESHOLD) {
751+
if (request.estimatedFilesize && request.estimatedFilesize > ARIA2C_THRESHOLD && !isClipMode) {
742752
args.push(
743753
'--downloader', 'aria2c',
744754
// -x 16: 16 connections per server
@@ -917,10 +927,44 @@ export class YouTubeService implements OnModuleInit {
917927
this.logger.log(`📁 [${id}] Found file at expected path`);
918928
}
919929

930+
// For clip mode: remux to fix container metadata duration
931+
// The clipped file may contain extra streams (like subtitle/data) with original video duration
932+
// QuickTime reads duration from the longest stream, causing wrong display
933+
// We remux with explicit stream mapping to strip extra streams and fix metadata
934+
if (isClipMode && request.formatType === 'video') {
935+
const fixedFile = path.join(tmpDir, `yt_${id}_fixed.${ext}`);
936+
this.logger.log(`🔧 [${id}] Fixing clip metadata with ffmpeg remux...`);
937+
try {
938+
execSync(
939+
// Map only video (0:v:0) and audio (0:a:0) streams, strip everything else
940+
// -map_chapters -1: Remove chapters that have timestamps from original video (causes wrong duration)
941+
// -movflags +faststart moves metadata to front for better streaming
942+
`ffmpeg -y -i "${downloadedFile}" -map 0:v:0 -map 0:a:0 -map_chapters -1 -c copy -movflags +faststart "${fixedFile}"`,
943+
{ encoding: 'utf-8', timeout: 60000 }
944+
);
945+
// Replace original with fixed file
946+
fs.unlinkSync(downloadedFile);
947+
fs.renameSync(fixedFile, downloadedFile);
948+
this.logger.log(`✅ [${id}] Clip metadata fixed successfully`);
949+
} catch (remuxError) {
950+
this.logger.warn(`⚠️ [${id}] Failed to fix clip metadata: ${remuxError}`);
951+
// Continue with original file if remux fails
952+
}
953+
}
954+
920955
// Upload to R2 using streaming (memory efficient)
921956
const videoTitle = (await this.getVideoTitle(id)) || 'video';
922-
// Store original Unicode filename in DB, use simple ID-based key for R2
923-
const displayFilename = `${videoTitle.replace(/[<>:"/\\|?*\x00-\x1f]/g, '').trim().substring(0, 150)}.${ext}`;
957+
// Build filename: use clipTitle as prefix if provided (for chapter downloads)
958+
const sanitize = (s: string) => s.replace(/[<>:"/\\|?*\x00-\x1f]/g, '').trim();
959+
let displayFilename: string;
960+
if (request.clipTitle && isClipMode) {
961+
// Format: ClipTitle_VideoTitle.ext
962+
const clipPart = sanitize(request.clipTitle).substring(0, 50);
963+
const videoPart = sanitize(videoTitle).substring(0, 100);
964+
displayFilename = `${clipPart}_${videoPart}.${ext}`;
965+
} else {
966+
displayFilename = `${sanitize(videoTitle).substring(0, 150)}.${ext}`;
967+
}
924968
const objectKey = `youtube/${id}/video.${ext}`; // Simple R2-safe key
925969

926970
// Get content type based on extension

src/app/youtube/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export default function YouTubePage() {
110110
const [outputFormat, setOutputFormat] = useState<string>('mp4');
111111
const [startTime, setStartTime] = useState<string>('');
112112
const [endTime, setEndTime] = useState<string>('');
113+
const [clipTitle, setClipTitle] = useState<string>(''); // Chapter/clip title for filename
113114
const [embedSubtitles, setEmbedSubtitles] = useState(false);
114115
const [subtitleLang, setSubtitleLang] = useState('en');
115116
const [sponsorBlock, setSponsorBlock] = useState(false);
@@ -337,6 +338,7 @@ export default function YouTubePage() {
337338
// Only send clip params if user actually changed from default (full video)
338339
startTime: startTime && parseTimeToSeconds(startTime) > 0 ? startTime : undefined,
339340
endTime: endTime && videoInfo && parseTimeToSeconds(endTime) < videoInfo.duration ? endTime : undefined,
341+
clipTitle: clipTitle || undefined, // Chapter title for filename prefix
340342
// Subtitle embedding (video only)
341343
embedSubtitles: formatType === 'video' && embedSubtitles ? true : undefined,
342344
subtitleLang: formatType === 'video' && embedSubtitles ? subtitleLang : undefined,
@@ -1031,6 +1033,7 @@ export default function YouTubePage() {
10311033
onChange={() => {
10321034
setStartTime(formatDuration(chapter.startTime));
10331035
setEndTime(formatDuration(chapter.endTime));
1036+
setClipTitle(chapter.title); // Save chapter title for filename
10341037
}}
10351038
disabled={isDownloading}
10361039
/>
@@ -1047,6 +1050,7 @@ export default function YouTubePage() {
10471050
onClick={() => {
10481051
setStartTime('');
10491052
setEndTime('');
1053+
setClipTitle(''); // Clear chapter title
10501054
}}
10511055
disabled={isDownloading}
10521056
>

0 commit comments

Comments
 (0)