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) {
+
+ }
+
+
@@ -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) {
+
+ |
+
+
+ |
+
+ @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)