diff --git a/app/main.py b/app/main.py index a70f0b8..43dd3eb 100644 --- a/app/main.py +++ b/app/main.py @@ -56,7 +56,7 @@ class Config: 'PUBLIC_HOST_URL': 'download/', 'PUBLIC_HOST_AUDIO_URL': 'audio_download/', 'OUTPUT_TEMPLATE': '%(title)s.%(ext)s', - 'OUTPUT_TEMPLATE_CHAPTER': '%(title)s - %(section_number)s %(section_title)s.%(ext)s', + 'OUTPUT_TEMPLATE_CHAPTER': '%(title)s - %(section_number)02d - %(section_title)s.%(ext)s', 'OUTPUT_TEMPLATE_PLAYLIST': '%(playlist_title)s/%(title)s.%(ext)s', 'DEFAULT_OPTION_PLAYLIST_STRICT_MODE' : 'false', 'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0', @@ -247,6 +247,8 @@ async def add(request): playlist_strict_mode = post.get('playlist_strict_mode') playlist_item_limit = post.get('playlist_item_limit') auto_start = post.get('auto_start') + split_by_chapters = post.get('split_by_chapters') + chapter_template = post.get('chapter_template') if custom_name_prefix is None: custom_name_prefix = '' @@ -256,10 +258,14 @@ async def add(request): playlist_strict_mode = config.DEFAULT_OPTION_PLAYLIST_STRICT_MODE if playlist_item_limit is None: playlist_item_limit = config.DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT + if split_by_chapters is None: + split_by_chapters = False + if chapter_template is None: + chapter_template = config.OUTPUT_TEMPLATE_CHAPTER playlist_item_limit = int(playlist_item_limit) - status = await dqueue.add(url, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start) + status = await dqueue.add(url, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, split_by_chapters, chapter_template) return web.Response(text=serializer.encode(status)) @routes.post(config.URL_PREFIX + 'delete') diff --git a/app/ytdl.py b/app/ytdl.py index 5e3cbb1..e88bcd5 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -43,7 +43,7 @@ class DownloadQueueNotifier: raise NotImplementedError class DownloadInfo: - def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit): + def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit, split_by_chapters, chapter_template): self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}' self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}' self.url = url @@ -59,6 +59,8 @@ class DownloadInfo: # Convert generators to lists to make entry pickleable self.entry = _convert_generators_to_lists(entry) if entry is not None else None self.playlist_item_limit = playlist_item_limit + self.split_by_chapters = split_by_chapters + self.chapter_template = chapter_template class Download: manager = None @@ -104,8 +106,19 @@ class Download: else: filename = d['info_dict']['filepath'] self.status_queue.put({'status': 'finished', 'filename': filename}) + + # Capture all chapter files when SplitChapters finishes + elif d.get('postprocessor') == 'SplitChapters' and d.get('status') == 'finished': + chapters = d.get('info_dict', {}).get('chapters', []) + if chapters: + for chapter in chapters: + if isinstance(chapter, dict) and 'filepath' in chapter: + log.info(f"Captured chapter file: {chapter['filepath']}") + self.status_queue.put({'chapter_file': chapter['filepath']}) + else: + log.warning("SplitChapters finished but no chapter files found in info_dict") - ret = yt_dlp.YoutubeDL(params={ + ytdl_params = { 'quiet': not debug_logging, 'verbose': debug_logging, 'no_color': True, @@ -117,7 +130,19 @@ class Download: 'progress_hooks': [put_status], 'postprocessor_hooks': [put_status_postprocessor], **self.ytdl_opts, - }).download([self.info.url]) + } + + # Add chapter splitting options if enabled + if self.info.split_by_chapters: + ytdl_params['outtmpl']['chapter'] = self.info.chapter_template + if 'postprocessors' not in ytdl_params: + ytdl_params['postprocessors'] = [] + ytdl_params['postprocessors'].append({ + 'key': 'FFmpegSplitChapters', + 'force_keyframes': False + }) + + ret = yt_dlp.YoutubeDL(params=ytdl_params).download([self.info.url]) self.status_queue.put({'status': 'finished' if ret == 0 else 'error'}) log.info(f"Finished download for: {self.info.title}") except yt_dlp.utils.YoutubeDLError as exc: @@ -181,6 +206,22 @@ class Download: self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None if self.info.format == 'thumbnail': self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename) + + # Handle chapter files + log.debug(f"Update status for {self.info.title}: {status}") + if 'chapter_file' in status: + chapter_file = status.get('chapter_file') + if not hasattr(self.info, 'chapter_files'): + self.info.chapter_files = [] + rel_path = os.path.relpath(chapter_file, self.download_dir) + file_size = os.path.getsize(chapter_file) if os.path.exists(chapter_file) else None + #Postprocessor hook called multiple times with chapters. Only insert if not already present. + existing = next((cf for cf in self.info.chapter_files if cf['filename'] == rel_path), None) + if not existing: + self.info.chapter_files.append({'filename': rel_path, 'size': file_size}) + # Skip the rest of status processing for chapter files + continue + self.info.status = status['status'] self.info.msg = status.get('msg') if 'downloaded_bytes' in status: @@ -372,7 +413,7 @@ class DownloadQueue: self.pending.put(download) await self.notifier.added(dl) - async def __add_entry(self, entry, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, already): + async def __add_entry(self, entry, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already): if not entry: return {'status': 'error', 'msg': "Invalid/empty data was given."} @@ -388,7 +429,7 @@ class DownloadQueue: if etype.startswith('url'): log.debug('Processing as an url') - return await self.add(entry['url'], quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, already) + return await self.add(entry['url'], quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already) elif etype == 'playlist': log.debug('Processing as a playlist') entries = entry['entries'] @@ -408,7 +449,7 @@ class DownloadQueue: for property in ("id", "title", "uploader", "uploader_id"): if property in entry: etr[f"playlist_{property}"] = entry[property] - results.append(await self.__add_entry(etr, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, already)) + results.append(await self.__add_entry(etr, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already)) if any(res['status'] == 'error' for res in results): return {'status': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)} return {'status': 'ok'} @@ -416,13 +457,13 @@ class DownloadQueue: log.debug('Processing as a video') key = entry.get('webpage_url') or entry['url'] if not self.queue.exists(key): - dl = DownloadInfo(entry['id'], entry.get('title') or entry['id'], key, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit) + dl = DownloadInfo(entry['id'], entry.get('title') or entry['id'], key, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit, split_by_chapters, chapter_template) await self.__add_download(dl, auto_start) return {'status': 'ok'} return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'} - async def add(self, url, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start=True, already=None): - log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} {playlist_strict_mode=} {playlist_item_limit=} {auto_start=}') + async def add(self, url, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start=True, split_by_chapters=False, chapter_template=None, already=None): + log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} {playlist_strict_mode=} {playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=}') already = set() if already is None else already if url in already: log.info('recursion detected, skipping') @@ -433,7 +474,7 @@ class DownloadQueue: entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url, playlist_strict_mode) except yt_dlp.utils.YoutubeDLError as exc: return {'status': 'error', 'msg': str(exc)} - return await self.__add_entry(entry, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, already) + return await self.__add_entry(entry, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already) async def start_pending(self, ids): for id in ids: diff --git a/ui/src/app/app.html b/ui/src/app/app.html index ff99a79..8456d9f 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -231,6 +231,29 @@ +
+
+
+
+ + +
+
+ @if (splitByChapters) { +
+
+ Template + +
+
+ } +
+
@@ -425,7 +448,32 @@ + @if (download.value.chapter_files && download.value.chapter_files.length > 0) { + @for (chapterFile of download.value.chapter_files; track chapterFile.filename) { + + + +
+ + {{ + getChapterFileName(chapterFile.filename) }} +
+ + + @if (chapterFile.size) { + {{ chapterFile.size | fileSize }} + } + + +
+ +
+ + } + } + } diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index dcfe7ac..7f7a959 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -48,6 +48,8 @@ export class App implements AfterViewInit, OnInit { autoStart: boolean; playlistStrictMode!: boolean; playlistItemLimit!: number; + splitByChapters: boolean; + chapterTemplate: string; addInProgress = false; themes: Theme[] = Themes; activeTheme: Theme | undefined; @@ -103,6 +105,9 @@ export class App implements AfterViewInit, OnInit { this.setQualities() this.quality = this.cookieService.get('metube_quality') || 'best'; this.autoStart = this.cookieService.get('metube_auto_start') !== 'false'; + this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true'; + // Will be set from backend configuration, use empty string as placeholder + this.chapterTemplate = this.cookieService.get('metube_chapter_template') || ''; this.activeTheme = this.getPreferredTheme(this.cookieService); @@ -221,6 +226,10 @@ export class App implements AfterViewInit, OnInit { if (playlistItemLimit !== '0') { this.playlistItemLimit = playlistItemLimit; } + // Set chapter template from backend config if not already set by cookie + if (!this.chapterTemplate) { + this.chapterTemplate = config['OUTPUT_TEMPLATE_CHAPTER']; + } } }); } @@ -260,6 +269,18 @@ export class App implements AfterViewInit, OnInit { this.cookieService.set('metube_auto_start', this.autoStart ? 'true' : 'false', { expires: 3650 }); } + splitByChaptersChanged() { + this.cookieService.set('metube_split_chapters', this.splitByChapters ? 'true' : 'false', { expires: 3650 }); + } + + chapterTemplateChanged() { + // Restore default if template is cleared - get from configuration + if (!this.chapterTemplate || this.chapterTemplate.trim() === '') { + this.chapterTemplate = this.downloads.configuration['OUTPUT_TEMPLATE_CHAPTER']; + } + this.cookieService.set('metube_chapter_template', this.chapterTemplate, { expires: 3650 }); + } + queueSelectionChanged(checked: number) { this.queueDelSelected().nativeElement.disabled = checked == 0; this.queueDownloadSelected().nativeElement.disabled = checked == 0; @@ -280,7 +301,7 @@ export class App implements AfterViewInit, OnInit { } } - addDownload(url?: string, quality?: string, format?: string, folder?: string, customNamePrefix?: string, playlistStrictMode?: boolean, playlistItemLimit?: number, autoStart?: boolean) { + addDownload(url?: string, quality?: string, format?: string, folder?: string, customNamePrefix?: string, playlistStrictMode?: boolean, playlistItemLimit?: number, autoStart?: boolean, splitByChapters?: boolean, chapterTemplate?: string) { url = url ?? this.addUrl quality = quality ?? this.quality format = format ?? this.format @@ -289,10 +310,18 @@ export class App implements AfterViewInit, OnInit { playlistStrictMode = playlistStrictMode ?? this.playlistStrictMode playlistItemLimit = playlistItemLimit ?? this.playlistItemLimit autoStart = autoStart ?? this.autoStart + splitByChapters = splitByChapters ?? this.splitByChapters + chapterTemplate = chapterTemplate ?? this.chapterTemplate - console.debug('Downloading: url='+url+' quality='+quality+' format='+format+' folder='+folder+' customNamePrefix='+customNamePrefix+' playlistStrictMode='+playlistStrictMode+' playlistItemLimit='+playlistItemLimit+' autoStart='+autoStart); + // Validate chapter template if chapter splitting is enabled + if (splitByChapters && !chapterTemplate.includes('%(section_number)')) { + alert('Chapter template must include %(section_number)'); + return; + } + + console.debug('Downloading: url=' + url + ' quality=' + quality + ' format=' + format + ' folder=' + folder + ' customNamePrefix=' + customNamePrefix + ' playlistStrictMode=' + playlistStrictMode + ' playlistItemLimit=' + playlistItemLimit + ' autoStart=' + autoStart + ' splitByChapters=' + splitByChapters + ' chapterTemplate=' + chapterTemplate); this.addInProgress = true; - this.downloads.add(url, quality, format, folder, customNamePrefix, playlistStrictMode, playlistItemLimit, autoStart).subscribe((status: Status) => { + this.downloads.add(url, quality, format, folder, customNamePrefix, playlistStrictMode, playlistItemLimit, autoStart, splitByChapters, chapterTemplate).subscribe((status: Status) => { if (status.status === 'error') { alert(`Error adding URL: ${status.msg}`); } else { @@ -307,7 +336,7 @@ export class App implements AfterViewInit, OnInit { } retryDownload(key: string, download: Download) { - this.addDownload(download.url, download.quality, download.format, download.folder, download.custom_name_prefix, download.playlist_strict_mode, download.playlist_item_limit, true); + this.addDownload(download.url, download.quality, download.format, download.folder, download.custom_name_prefix, download.playlist_strict_mode, download.playlist_item_limit, true, download.split_by_chapters, download.chapter_template); this.downloads.delById('done', [key]).subscribe(); } @@ -378,6 +407,25 @@ export class App implements AfterViewInit, OnInit { return parts.join(' | '); } + buildChapterDownloadLink(download: Download, chapterFilename: string) { + let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"]; + if (download.quality == 'audio' || chapterFilename.endsWith('.mp3')) { + baseDir = this.downloads.configuration["PUBLIC_HOST_AUDIO_URL"]; + } + + if (download.folder) { + baseDir += download.folder + '/'; + } + + return baseDir + encodeURIComponent(chapterFilename); + } + + getChapterFileName(filepath: string) { + // Extract just the filename from the path + const parts = filepath.split('/'); + return parts[parts.length - 1]; + } + isNumber(event: KeyboardEvent) { const charCode = +event.code || event.keyCode; if (charCode > 31 && (charCode < 48 || charCode > 57)) { @@ -434,7 +482,7 @@ export class App implements AfterViewInit, OnInit { this.batchImportStatus = `Importing URL ${index + 1} of ${urls.length}: ${url}`; // Now pass the selected quality, format, folder, etc. to the add() method this.downloads.add(url, this.quality, this.format, this.folder, this.customNamePrefix, - this.playlistStrictMode, this.playlistItemLimit, this.autoStart) + this.playlistStrictMode, this.playlistItemLimit, this.autoStart, this.splitByChapters, this.chapterTemplate) .subscribe({ next: (status: Status) => { if (status.status === 'error') { diff --git a/ui/src/app/interfaces/download.ts b/ui/src/app/interfaces/download.ts index eab72bb..24cd925 100644 --- a/ui/src/app/interfaces/download.ts +++ b/ui/src/app/interfaces/download.ts @@ -9,6 +9,8 @@ export interface Download { custom_name_prefix: string; playlist_strict_mode: boolean; playlist_item_limit: number; + split_by_chapters?: boolean; + chapter_template?: string; status: string; msg: string; percent: number; @@ -19,4 +21,5 @@ export interface Download { size?: number; error?: string; deleting?: boolean; + chapter_files?: Array<{ filename: string, size: number }>; } diff --git a/ui/src/app/services/downloads.service.ts b/ui/src/app/services/downloads.service.ts index fd4a286..5a360f8 100644 --- a/ui/src/app/services/downloads.service.ts +++ b/ui/src/app/services/downloads.service.ts @@ -107,8 +107,8 @@ export class DownloadsService { return of({status: 'error', msg: msg}) } - public add(url: string, quality: string, format: string, folder: string, customNamePrefix: string, playlistStrictMode: boolean, playlistItemLimit: number, autoStart: boolean) { - return this.http.post('add', {url: url, quality: quality, format: format, folder: folder, custom_name_prefix: customNamePrefix, playlist_strict_mode: playlistStrictMode, playlist_item_limit: playlistItemLimit, auto_start: autoStart}).pipe( + public add(url: string, quality: string, format: string, folder: string, customNamePrefix: string, playlistStrictMode: boolean, playlistItemLimit: number, autoStart: boolean, splitByChapters: boolean, chapterTemplate: string) { + return this.http.post('add', { url: url, quality: quality, format: format, folder: folder, custom_name_prefix: customNamePrefix, playlist_strict_mode: playlistStrictMode, playlist_item_limit: playlistItemLimit, auto_start: autoStart, split_by_chapters: splitByChapters, chapter_template: chapterTemplate }).pipe( catchError(this.handleHTTPError) ); } @@ -150,9 +150,11 @@ export class DownloadsService { const defaultPlaylistStrictMode = false; const defaultPlaylistItemLimit = 0; const defaultAutoStart = true; - + const defaultSplitByChapters = false; + const defaultChapterTemplate = this.configuration['OUTPUT_TEMPLATE_CHAPTER']; + return new Promise((resolve, reject) => { - this.add(url, defaultQuality, defaultFormat, defaultFolder, defaultCustomNamePrefix, defaultPlaylistStrictMode, defaultPlaylistItemLimit, defaultAutoStart) + this.add(url, defaultQuality, defaultFormat, defaultFolder, defaultCustomNamePrefix, defaultPlaylistStrictMode, defaultPlaylistItemLimit, defaultAutoStart, defaultSplitByChapters, defaultChapterTemplate) .subscribe({ next: (response) => resolve(response), error: (error) => reject(error)