feat: Implement chapter splitting functionality with UI controls, yt-dlp integration, and chapter file tracking.

This commit is contained in:
Igor Katkov
2025-12-30 22:07:49 -08:00
parent 4fce74d1ed
commit 962929d42d
6 changed files with 379 additions and 246 deletions

View File

@@ -247,6 +247,8 @@ async def add(request):
playlist_strict_mode = post.get('playlist_strict_mode') playlist_strict_mode = post.get('playlist_strict_mode')
playlist_item_limit = post.get('playlist_item_limit') playlist_item_limit = post.get('playlist_item_limit')
auto_start = post.get('auto_start') 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: if custom_name_prefix is None:
custom_name_prefix = '' custom_name_prefix = ''
@@ -256,10 +258,14 @@ async def add(request):
playlist_strict_mode = config.DEFAULT_OPTION_PLAYLIST_STRICT_MODE playlist_strict_mode = config.DEFAULT_OPTION_PLAYLIST_STRICT_MODE
if playlist_item_limit is None: if playlist_item_limit is None:
playlist_item_limit = config.DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT 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 = '%(section_number)02d - %(section_title)s.%(ext)s'
playlist_item_limit = int(playlist_item_limit) 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)) return web.Response(text=serializer.encode(status))
@routes.post(config.URL_PREFIX + 'delete') @routes.post(config.URL_PREFIX + 'delete')

View File

@@ -43,7 +43,7 @@ class DownloadQueueNotifier:
raise NotImplementedError raise NotImplementedError
class DownloadInfo: 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.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.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
self.url = url self.url = url
@@ -59,6 +59,8 @@ class DownloadInfo:
# Convert generators to lists to make entry pickleable # Convert generators to lists to make entry pickleable
self.entry = _convert_generators_to_lists(entry) if entry is not None else None self.entry = _convert_generators_to_lists(entry) if entry is not None else None
self.playlist_item_limit = playlist_item_limit self.playlist_item_limit = playlist_item_limit
self.split_by_chapters = split_by_chapters
self.chapter_template = chapter_template
class Download: class Download:
manager = None manager = None
@@ -98,6 +100,8 @@ class Download:
)}) )})
def put_status_postprocessor(d): def put_status_postprocessor(d):
log.debug(f"Postprocessor hook called: postprocessor={d.get('postprocessor')}, status={d.get('status')}")
if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished': if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished':
if '__finaldir' in d['info_dict']: if '__finaldir' in d['info_dict']:
filename = os.path.join(d['info_dict']['__finaldir'], os.path.basename(d['info_dict']['filepath'])) filename = os.path.join(d['info_dict']['__finaldir'], os.path.basename(d['info_dict']['filepath']))
@@ -105,7 +109,18 @@ class Download:
filename = d['info_dict']['filepath'] filename = d['info_dict']['filepath']
self.status_queue.put({'status': 'finished', 'filename': filename}) self.status_queue.put({'status': 'finished', 'filename': filename})
ret = yt_dlp.YoutubeDL(params={ # 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")
ytdl_params = {
'quiet': not debug_logging, 'quiet': not debug_logging,
'verbose': debug_logging, 'verbose': debug_logging,
'no_color': True, 'no_color': True,
@@ -117,7 +132,19 @@ class Download:
'progress_hooks': [put_status], 'progress_hooks': [put_status],
'postprocessor_hooks': [put_status_postprocessor], 'postprocessor_hooks': [put_status_postprocessor],
**self.ytdl_opts, **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'}) self.status_queue.put({'status': 'finished' if ret == 0 else 'error'})
log.info(f"Finished download for: {self.info.title}") log.info(f"Finished download for: {self.info.title}")
except yt_dlp.utils.YoutubeDLError as exc: except yt_dlp.utils.YoutubeDLError as exc:
@@ -181,6 +208,22 @@ class Download:
self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None
if self.info.format == 'thumbnail': if self.info.format == 'thumbnail':
self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename) 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
# Upsert: update if exists, otherwise append. Postprocessor hook called multiple times with chapters.
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.status = status['status']
self.info.msg = status.get('msg') self.info.msg = status.get('msg')
if 'downloaded_bytes' in status: if 'downloaded_bytes' in status:
@@ -372,7 +415,7 @@ class DownloadQueue:
self.pending.put(download) self.pending.put(download)
await self.notifier.added(dl) 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: if not entry:
return {'status': 'error', 'msg': "Invalid/empty data was given."} return {'status': 'error', 'msg': "Invalid/empty data was given."}
@@ -388,7 +431,7 @@ class DownloadQueue:
if etype.startswith('url'): if etype.startswith('url'):
log.debug('Processing as an 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': elif etype == 'playlist':
log.debug('Processing as a playlist') log.debug('Processing as a playlist')
entries = entry['entries'] entries = entry['entries']
@@ -408,7 +451,7 @@ class DownloadQueue:
for property in ("id", "title", "uploader", "uploader_id"): for property in ("id", "title", "uploader", "uploader_id"):
if property in entry: if property in entry:
etr[f"playlist_{property}"] = entry[property] 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): 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': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)}
return {'status': 'ok'} return {'status': 'ok'}
@@ -416,13 +459,13 @@ class DownloadQueue:
log.debug('Processing as a video') log.debug('Processing as a video')
key = entry.get('webpage_url') or entry['url'] key = entry.get('webpage_url') or entry['url']
if not self.queue.exists(key): 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) await self.__add_download(dl, auto_start)
return {'status': 'ok'} return {'status': 'ok'}
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'} 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): 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='%(section_number)02d - %(section_title)s.%(ext)s', already=None):
log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} {playlist_strict_mode=} {playlist_item_limit=} {auto_start=}') 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 already = set() if already is None else already
if url in already: if url in already:
log.info('recursion detected, skipping') log.info('recursion detected, skipping')
@@ -433,7 +476,7 @@ class DownloadQueue:
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url, playlist_strict_mode) entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url, playlist_strict_mode)
except yt_dlp.utils.YoutubeDLError as exc: except yt_dlp.utils.YoutubeDLError as exc:
return {'status': 'error', 'msg': str(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): async def start_pending(self, ids):
for id in ids: for id in ids:

View File

@@ -6,34 +6,34 @@
</a> </a>
<div class="download-metrics"> <div class="download-metrics">
@if (activeDownloads > 0) { @if (activeDownloads > 0) {
<div class="metric"> <div class="metric">
<fa-icon [icon]="faDownload" class="text-primary" /> <fa-icon [icon]="faDownload" class="text-primary" />
<span>{{activeDownloads}} downloading</span> <span>{{activeDownloads}} downloading</span>
</div> </div>
} }
@if (queuedDownloads > 0) { @if (queuedDownloads > 0) {
<div class="metric"> <div class="metric">
<fa-icon [icon]="faClock" class="text-warning" /> <fa-icon [icon]="faClock" class="text-warning" />
<span>{{queuedDownloads}} queued</span> <span>{{queuedDownloads}} queued</span>
</div> </div>
} }
@if (completedDownloads > 0) { @if (completedDownloads > 0) {
<div class="metric"> <div class="metric">
<fa-icon [icon]="faCheck" class="text-success" /> <fa-icon [icon]="faCheck" class="text-success" />
<span>{{completedDownloads}} completed</span> <span>{{completedDownloads}} completed</span>
</div> </div>
} }
@if (failedDownloads > 0) { @if (failedDownloads > 0) {
<div class="metric"> <div class="metric">
<fa-icon [icon]="faTimesCircle" class="text-danger" /> <fa-icon [icon]="faTimesCircle" class="text-danger" />
<span>{{failedDownloads}} failed</span> <span>{{failedDownloads}} failed</span>
</div> </div>
} }
@if ((totalSpeed | speed) !== '') { @if ((totalSpeed | speed) !== '') {
<div class="metric"> <div class="metric">
<fa-icon [icon]="faTachometerAlt" class="text-info" /> <fa-icon [icon]="faTachometerAlt" class="text-info" />
<span>{{totalSpeed | speed }}</span> <span>{{totalSpeed | speed }}</span>
</div> </div>
} }
</div> </div>
<!-- <!--
@@ -62,20 +62,20 @@
</button> </button>
<ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select"> <ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select">
@for (theme of themes; track theme) { @for (theme of themes; track theme) {
<li> <li>
<button type="button" class="dropdown-item d-flex align-items-center" <button type="button" class="dropdown-item d-flex align-items-center"
[class.active]="activeTheme === theme" [class.active]="activeTheme === theme"
(click)="themeChanged(theme)"> (click)="themeChanged(theme)">
<span class="me-2 opacity-50"> <span class="me-2 opacity-50">
<fa-icon [icon]="theme.icon" /> <fa-icon [icon]="theme.icon" />
</span> </span>
{{ theme.displayName }} {{ theme.displayName }}
<span class="ms-auto" <span class="ms-auto"
[class.d-none]="activeTheme !== theme"> [class.d-none]="activeTheme !== theme">
<fa-icon [icon]="faCheck" /> <fa-icon [icon]="faCheck" />
</span> </span>
</button> </button>
</li> </li>
} }
</ul> </ul>
</div> </div>
@@ -103,7 +103,7 @@
(click)="addDownload()" (click)="addDownload()"
[disabled]="addInProgress || downloads.loading"> [disabled]="addInProgress || downloads.loading">
@if (addInProgress) { @if (addInProgress) {
<span class="spinner-border spinner-border-sm" role="status" id="add-spinner"></span> <span class="spinner-border spinner-border-sm" role="status" id="add-spinner"></span>
} }
{{ addInProgress ? "Adding..." : "Download" }} {{ addInProgress ? "Adding..." : "Download" }}
</button> </button>
@@ -122,7 +122,7 @@
(change)="qualityChanged()" (change)="qualityChanged()"
[disabled]="addInProgress || downloads.loading"> [disabled]="addInProgress || downloads.loading">
@for (q of qualities; track q) { @for (q of qualities; track q) {
<option [ngValue]="q.id">{{ q.text }}</option> <option [ngValue]="q.id">{{ q.text }}</option>
} }
</select> </select>
</div> </div>
@@ -136,7 +136,7 @@
(change)="formatChanged()" (change)="formatChanged()"
[disabled]="addInProgress || downloads.loading"> [disabled]="addInProgress || downloads.loading">
@for (f of formats; track f) { @for (f of formats; track f) {
<option [ngValue]="f.id">{{ f.text }}</option> <option [ngValue]="f.id">{{ f.text }}</option>
} }
</select> </select>
</div> </div>
@@ -188,7 +188,7 @@
[searchable]="true" [searchable]="true"
[closeOnSelect]="true" [closeOnSelect]="true"
ngbTooltip="Choose where to save downloads. Type to create a new folder." /> ngbTooltip="Choose where to save downloads. Type to create a new folder." />
} }
</div> </div>
</div> </div>
@@ -231,6 +231,29 @@
<label class="form-check-label" for="checkbox-strict-mode">Strict Playlist Mode</label> <label class="form-check-label" for="checkbox-strict-mode">Strict Playlist Mode</label>
</div> </div>
</div> </div>
<div class="col-12">
<div class="row g-2 align-items-center">
<div class="col-auto">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="checkbox-split-chapters"
name="splitByChapters" [(ngModel)]="splitByChapters" (change)="splitByChaptersChanged()"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Split video into separate files by chapters">
<label class="form-check-label" for="checkbox-split-chapters">Split by chapters</label>
</div>
</div>
@if (splitByChapters) {
<div class="col">
<div class="input-group">
<span class="input-group-text">Template</span>
<input type="text" class="form-control" name="chapterTemplate" [(ngModel)]="chapterTemplate"
(change)="chapterTemplateChanged()" [disabled]="addInProgress || downloads.loading"
ngbTooltip="Output template for chapter files">
</div>
</div>
}
</div>
</div>
</div> </div>
<!-- Advanced Actions --> <!-- Advanced Actions -->
@@ -275,7 +298,7 @@
<!-- Batch Import Modal --> <!-- Batch Import Modal -->
<div class="modal fade" tabindex="-1" role="dialog" <div class="modal fade" tabindex="-1" role="dialog"
[class.show]="batchImportModalOpen" [class.show]="batchImportModalOpen"
[style.display]="batchImportModalOpen ? 'block' : 'none'"> [style.display]="batchImportModalOpen ? 'block' : 'none'">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -284,18 +307,18 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<textarea [(ngModel)]="batchImportText" class="form-control" rows="6" <textarea [(ngModel)]="batchImportText" class="form-control" rows="6"
placeholder="Paste one video URL per line"></textarea> placeholder="Paste one video URL per line"></textarea>
<div class="mt-2"> <div class="mt-2">
@if (batchImportStatus) { @if (batchImportStatus) {
<small>{{ batchImportStatus }}</small> <small>{{ batchImportStatus }}</small>
} }
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@if (importInProgress) { @if (importInProgress) {
<button type="button" class="btn btn-danger me-auto" (click)="cancelBatchImport()"> <button type="button" class="btn btn-danger me-auto" (click)="cancelBatchImport()">
Cancel Import Cancel Import
</button> </button>
} }
<button type="button" class="btn btn-secondary" (click)="closeBatchImportModal()">Close</button> <button type="button" class="btn btn-secondary" (click)="closeBatchImportModal()">Close</button>
<button type="button" class="btn btn-primary" (click)="startBatchImport()" [disabled]="importInProgress"> <button type="button" class="btn btn-primary" (click)="startBatchImport()" [disabled]="importInProgress">
@@ -308,9 +331,9 @@
@if (downloads.loading) { @if (downloads.loading) {
<div class="alert alert-info" role="alert"> <div class="alert alert-info" role="alert">
Connecting to server... Connecting to server...
</div> </div>
} }
<div class="metube-section-header">Downloading</div> <div class="metube-section-header">Downloading</div>
<div class="px-2 py-3 border-bottom"> <div class="px-2 py-3 border-bottom">
@@ -332,29 +355,29 @@
</thead> </thead>
<tbody> <tbody>
@for (download of downloads.queue | keyvalue: asIsOrder; track download.value.id) { @for (download of downloads.queue | keyvalue: asIsOrder; track download.value.id) {
<tr [class.disabled]='download.value.deleting'> <tr [class.disabled]='download.value.deleting'>
<td> <td>
<app-slave-checkbox [id]="download.key" [master]="queueMasterCheckboxRef" [checkable]="download.value" /> <app-slave-checkbox [id]="download.key" [master]="queueMasterCheckboxRef" [checkable]="download.value" />
</td> </td>
<td title="{{ download.value.filename }}"> <td title="{{ download.value.filename }}">
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3"> <div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
<div>{{ download.value.title }} </div> <div>{{ download.value.title }} </div>
<ngb-progressbar height="1.5rem" [showValue]="download.value.status !== 'preparing'" [striped]="download.value.status === 'preparing'" [animated]="download.value.status === 'preparing'" type="success" <ngb-progressbar height="1.5rem" [showValue]="download.value.status !== 'preparing'" [striped]="download.value.status === 'preparing'" [animated]="download.value.status === 'preparing'" type="success"
[value]="download.value.status === 'preparing' ? 100 : download.value.percent" class="download-progressbar" /> [value]="download.value.status === 'preparing' ? 100 : download.value.percent" class="download-progressbar" />
</div> </div>
</td> </td>
<td>{{ download.value.speed | speed }}</td> <td>{{ download.value.speed | speed }}</td>
<td>{{ download.value.eta | eta }}</td> <td>{{ download.value.eta | eta }}</td>
<td> <td>
<div class="d-flex"> <div class="d-flex">
@if (download.value.status === 'pending') { @if (download.value.status === 'pending') {
<button type="button" class="btn btn-link" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button> <button type="button" class="btn btn-link" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
} }
<button type="button" class="btn btn-link" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button> <button type="button" class="btn btn-link" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a> <a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a>
</div> </div>
</td> </td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
@@ -382,49 +405,72 @@
</thead> </thead>
<tbody> <tbody>
@for (download of downloads.done | keyvalue: asIsOrder; track download.value.id) { @for (download of downloads.done | keyvalue: asIsOrder; track download.value.id) {
<tr [class.disabled]='download.value.deleting'> <tr [class.disabled]='download.value.deleting'>
<td> <td>
<app-slave-checkbox [id]="download.key" [master]="doneMasterCheckboxRef" [checkable]="download.value" /> <app-slave-checkbox [id]="download.key" [master]="doneMasterCheckboxRef" [checkable]="download.value" />
</td> </td>
<td> <td>
<div style="display: inline-block; width: 1.5rem;"> <div style="display: inline-block; width: 1.5rem;">
@if (download.value.status === 'finished') { @if (download.value.status === 'finished') {
<fa-icon [icon]="faCheckCircle" class="text-success" /> <fa-icon [icon]="faCheckCircle" class="text-success" />
}
@if (download.value.status === 'error') {
<fa-icon [icon]="faTimesCircle" class="text-danger" />
}
</div>
<span ngbTooltip="{{buildResultItemTooltip(download.value)}}">@if (!!download.value.filename) {
<a href="{{buildDownloadLink(download.value)}}" target="_blank">{{ download.value.title }}</a>
} @else {
{{download.value.title}}
@if (download.value.msg) {
<span><br>{{download.value.msg}}</span>
}
@if (download.value.error) {
<span><br>Error: {{download.value.error}}</span>
}
}</span>
</td>
<td>
@if (download.value.size) {
<span>{{ download.value.size | fileSize }}</span>
} }
</td> @if (download.value.status === 'error') {
<td> <fa-icon [icon]="faTimesCircle" class="text-danger" />
<div class="d-flex"> }
@if (download.value.status === 'error') { </div>
<span ngbTooltip="{{buildResultItemTooltip(download.value)}}">@if (!!download.value.filename) {
<a href="{{buildDownloadLink(download.value)}}" target="_blank">{{ download.value.title }}</a>
} @else {
{{download.value.title}}
@if (download.value.msg) {
<span><br>{{download.value.msg}}</span>
}
@if (download.value.error) {
<span><br>Error: {{download.value.error}}</span>
}
}</span>
</td>
<td>
@if (download.value.size) {
<span>{{ download.value.size | fileSize }}</span>
}
</td>
<td>
<div class="d-flex">
@if (download.value.status === 'error') {
<button type="button" class="btn btn-link" (click)="retryDownload(download.key, download.value)"><fa-icon [icon]="faRedoAlt" /></button> <button type="button" class="btn btn-link" (click)="retryDownload(download.key, download.value)"><fa-icon [icon]="faRedoAlt" /></button>
} }
@if (download.value.filename) { @if (download.value.filename) {
<a href="{{buildDownloadLink(download.value)}}" download class="btn btn-link"><fa-icon [icon]="faDownload" /></a> <a href="{{buildDownloadLink(download.value)}}" download class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
} }
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a> <a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a>
<button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt" /></button> <button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
</div> </div>
</td> </td>
</tr> </tr>
@if (download.value.chapter_files && download.value.chapter_files.length > 0) {
@for (chapterFile of download.value.chapter_files; track chapterFile.filename) {
<tr [class.disabled]='download.value.deleting'>
<td></td>
<td>
<div style="padding-left: 2rem;">
<fa-icon [icon]="faCheckCircle" class="text-success me-2" />
<a href="{{buildChapterDownloadLink(download.value, chapterFile.filename)}}" target="_blank">{{
getChapterFileName(chapterFile.filename) }}</a>
</div>
</td>
<td>
@if (chapterFile.size) {
<span>{{ chapterFile.size | fileSize }}</span>
}
</td>
<td>
<div class="d-flex">
<a href="{{buildChapterDownloadLink(download.value, chapterFile.filename)}}" download
class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
</div>
</td>
</tr>
} }
</tbody> </tbody>
</table> </table>
@@ -434,31 +480,31 @@
<footer class="footer navbar-dark bg-dark py-3 mt-5"> <footer class="footer navbar-dark bg-dark py-3 mt-5">
<div class="container text-center"> <div class="container text-center">
@if (ytDlpVersion && metubeVersion) { @if (ytDlpVersion && metubeVersion) {
<div class="footer-content"> <div class="footer-content">
<div class="version-item"> <div class="version-item">
<span class="version-label">yt-dlp</span> <span class="version-label">yt-dlp</span>
<span class="version-value">{{ytDlpVersion}}</span> <span class="version-value">{{ytDlpVersion}}</span>
</div>
<div class="version-separator"></div>
<div class="version-item">
<span class="version-label">MeTube</span>
<span class="version-value">{{metubeVersion}}</span>
</div>
<div class="version-separator"></div>
@if (ytDlpOptionsUpdateTime) {
<div class="version-item">
<span class="version-label">yt-dlp-options</span>
<span class="version-value">{{ytDlpOptionsUpdateTime}}</span>
</div>
}
@if (ytDlpOptionsUpdateTime) {
<div class="version-separator"></div>
}
<a href="https://github.com/alexta69/metube" target="_blank" class="github-link">
<fa-icon [icon]="faGithub" />
<span>GitHub</span>
</a>
</div> </div>
<div class="version-separator"></div>
<div class="version-item">
<span class="version-label">MeTube</span>
<span class="version-value">{{metubeVersion}}</span>
</div>
<div class="version-separator"></div>
@if (ytDlpOptionsUpdateTime) {
<div class="version-item">
<span class="version-label">yt-dlp-options</span>
<span class="version-value">{{ytDlpOptionsUpdateTime}}</span>
</div>
}
@if (ytDlpOptionsUpdateTime) {
<div class="version-separator"></div>
}
<a href="https://github.com/alexta69/metube" target="_blank" class="github-link">
<fa-icon [icon]="faGithub" />
<span>GitHub</span>
</a>
</div>
} }
</div> </div>
</footer> </footer>

View File

@@ -11,24 +11,24 @@ import { faGithub } from '@fortawesome/free-brands-svg-icons';
import { CookieService } from 'ngx-cookie-service'; import { CookieService } from 'ngx-cookie-service';
import { DownloadsService } from './services/downloads.service'; import { DownloadsService } from './services/downloads.service';
import { Themes } from './theme'; import { Themes } from './theme';
import { Download, Status, Theme , Quality, Format, Formats, State } from './interfaces'; import { Download, Status, Theme, Quality, Format, Formats, State } from './interfaces';
import { EtaPipe, SpeedPipe, FileSizePipe } from './pipes'; import { EtaPipe, SpeedPipe, FileSizePipe } from './pipes';
import { MasterCheckboxComponent , SlaveCheckboxComponent} from './components/'; import { MasterCheckboxComponent, SlaveCheckboxComponent } from './components/';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [ imports: [
FormsModule, FormsModule,
KeyValuePipe, KeyValuePipe,
AsyncPipe, AsyncPipe,
FontAwesomeModule, FontAwesomeModule,
NgbModule, NgbModule,
NgSelectModule, NgSelectModule,
EtaPipe, EtaPipe,
SpeedPipe, SpeedPipe,
FileSizePipe, FileSizePipe,
MasterCheckboxComponent, MasterCheckboxComponent,
SlaveCheckboxComponent, SlaveCheckboxComponent,
], ],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.sass', styleUrl: './app.sass',
@@ -48,6 +48,8 @@ export class App implements AfterViewInit, OnInit {
autoStart: boolean; autoStart: boolean;
playlistStrictMode!: boolean; playlistStrictMode!: boolean;
playlistItemLimit!: number; playlistItemLimit!: number;
splitByChapters: boolean;
chapterTemplate: string;
addInProgress = false; addInProgress = false;
themes: Theme[] = Themes; themes: Theme[] = Themes;
activeTheme: Theme | undefined; activeTheme: Theme | undefined;
@@ -103,6 +105,8 @@ export class App implements AfterViewInit, OnInit {
this.setQualities() this.setQualities()
this.quality = this.cookieService.get('metube_quality') || 'best'; this.quality = this.cookieService.get('metube_quality') || 'best';
this.autoStart = this.cookieService.get('metube_auto_start') !== 'false'; this.autoStart = this.cookieService.get('metube_auto_start') !== 'false';
this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true';
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '%(section_number)02d - %(section_title)s.%(ext)s';
this.activeTheme = this.getPreferredTheme(this.cookieService); this.activeTheme = this.getPreferredTheme(this.cookieService);
@@ -127,7 +131,7 @@ export class App implements AfterViewInit, OnInit {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (this.activeTheme && this.activeTheme.id === 'auto') { if (this.activeTheme && this.activeTheme.id === 'auto') {
this.setTheme(this.activeTheme); this.setTheme(this.activeTheme);
} }
}); });
} }
@@ -179,12 +183,12 @@ export class App implements AfterViewInit, OnInit {
} }
isAudioType() { isAudioType() {
return this.quality == 'audio' || this.format == 'mp3' || this.format == 'm4a' || this.format == 'opus' || this.format == 'wav' || this.format == 'flac'; return this.quality == 'audio' || this.format == 'mp3' || this.format == 'm4a' || this.format == 'opus' || this.format == 'wav' || this.format == 'flac';
} }
getMatchingCustomDir() : Observable<string[]> { getMatchingCustomDir(): Observable<string[]> {
return this.downloads.customDirsChanged.asObservable().pipe( return this.downloads.customDirsChanged.asObservable().pipe(
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
map((output: any) => { map((output: any) => {
// Keep logic consistent with app/ytdl.py // Keep logic consistent with app/ytdl.py
if (this.isAudioType()) { if (this.isAudioType()) {
@@ -201,20 +205,20 @@ export class App implements AfterViewInit, OnInit {
getYtdlOptionsUpdateTime() { getYtdlOptionsUpdateTime() {
this.downloads.ytdlOptionsChanged.subscribe({ this.downloads.ytdlOptionsChanged.subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (data:any) => { next: (data: any) => {
if (data['success']){ if (data['success']) {
const date = new Date(data['update_time'] * 1000); const date = new Date(data['update_time'] * 1000);
this.ytDlpOptionsUpdateTime=date.toLocaleString(); this.ytDlpOptionsUpdateTime = date.toLocaleString();
}else{ } else {
alert("Error reload yt-dlp options: "+data['msg']); alert("Error reload yt-dlp options: " + data['msg']);
} }
} }
}); });
} }
getConfiguration() { getConfiguration() {
this.downloads.configurationChanged.subscribe({ this.downloads.configurationChanged.subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (config: any) => { next: (config: any) => {
this.playlistStrictMode = config['DEFAULT_OPTION_PLAYLIST_STRICT_MODE']; this.playlistStrictMode = config['DEFAULT_OPTION_PLAYLIST_STRICT_MODE'];
const playlistItemLimit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT']; const playlistItemLimit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT'];
@@ -260,6 +264,14 @@ export class App implements AfterViewInit, OnInit {
this.cookieService.set('metube_auto_start', this.autoStart ? 'true' : 'false', { expires: 3650 }); 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() {
this.cookieService.set('metube_chapter_template', this.chapterTemplate, { expires: 3650 });
}
queueSelectionChanged(checked: number) { queueSelectionChanged(checked: number) {
this.queueDelSelected().nativeElement.disabled = checked == 0; this.queueDelSelected().nativeElement.disabled = checked == 0;
this.queueDownloadSelected().nativeElement.disabled = checked == 0; this.queueDownloadSelected().nativeElement.disabled = checked == 0;
@@ -277,10 +289,10 @@ export class App implements AfterViewInit, OnInit {
this.qualities = format.qualities this.qualities = format.qualities
const exists = this.qualities.find(el => el.id === this.quality) const exists = this.qualities.find(el => el.id === this.quality)
this.quality = exists ? this.quality : 'best' this.quality = exists ? this.quality : 'best'
}
} }
}
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 url = url ?? this.addUrl
quality = quality ?? this.quality quality = quality ?? this.quality
format = format ?? this.format format = format ?? this.format
@@ -289,10 +301,12 @@ export class App implements AfterViewInit, OnInit {
playlistStrictMode = playlistStrictMode ?? this.playlistStrictMode playlistStrictMode = playlistStrictMode ?? this.playlistStrictMode
playlistItemLimit = playlistItemLimit ?? this.playlistItemLimit playlistItemLimit = playlistItemLimit ?? this.playlistItemLimit
autoStart = autoStart ?? this.autoStart 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); 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.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') { if (status.status === 'error') {
alert(`Error adding URL: ${status.msg}`); alert(`Error adding URL: ${status.msg}`);
} else { } else {
@@ -307,7 +321,7 @@ export class App implements AfterViewInit, OnInit {
} }
retryDownload(key: string, download: Download) { 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(); this.downloads.delById('done', [key]).subscribe();
} }
@@ -315,7 +329,7 @@ export class App implements AfterViewInit, OnInit {
this.downloads.delById(where, [id]).subscribe(); this.downloads.delById(where, [id]).subscribe();
} }
startSelectedDownloads(where: State){ startSelectedDownloads(where: State) {
this.downloads.startByFilter(where, dl => !!dl.checked).subscribe(); this.downloads.startByFilter(where, dl => !!dl.checked).subscribe();
} }
@@ -378,9 +392,28 @@ export class App implements AfterViewInit, OnInit {
return parts.join(' | '); 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) { isNumber(event: KeyboardEvent) {
const charCode = +event.code || event.keyCode; const charCode = +event.code || event.keyCode;
if (charCode > 31 && (charCode < 48 || charCode > 57)) { if (charCode > 31 && (charCode < 48 || charCode > 57)) {
event.preventDefault(); event.preventDefault();
} }
} }
@@ -434,7 +467,7 @@ export class App implements AfterViewInit, OnInit {
this.batchImportStatus = `Importing URL ${index + 1} of ${urls.length}: ${url}`; this.batchImportStatus = `Importing URL ${index + 1} of ${urls.length}: ${url}`;
// Now pass the selected quality, format, folder, etc. to the add() method // 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.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({ .subscribe({
next: (status: Status) => { next: (status: Status) => {
if (status.status === 'error') { if (status.status === 'error') {

View File

@@ -9,6 +9,8 @@ export interface Download {
custom_name_prefix: string; custom_name_prefix: string;
playlist_strict_mode: boolean; playlist_strict_mode: boolean;
playlist_item_limit: number; playlist_item_limit: number;
split_by_chapters?: boolean;
chapter_template?: string;
status: string; status: string;
msg: string; msg: string;
percent: number; percent: number;
@@ -19,4 +21,5 @@ export interface Download {
size?: number; size?: number;
error?: string; error?: string;
deleting?: boolean; deleting?: boolean;
chapter_files?: Array<{ filename: string, size: number }>;
} }

View File

@@ -27,79 +27,79 @@ export class DownloadsService {
constructor() { constructor() {
this.socket.fromEvent('all') this.socket.fromEvent('all')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
this.loading = false; this.loading = false;
const data: [[[string, Download]], [[string, Download]]] = JSON.parse(strdata); const data: [[[string, Download]], [[string, Download]]] = JSON.parse(strdata);
this.queue.clear(); this.queue.clear();
data[0].forEach(entry => this.queue.set(...entry)); data[0].forEach(entry => this.queue.set(...entry));
this.done.clear(); this.done.clear();
data[1].forEach(entry => this.done.set(...entry)); data[1].forEach(entry => this.done.set(...entry));
this.queueChanged.next(null); this.queueChanged.next(null);
this.doneChanged.next(null); this.doneChanged.next(null);
}); });
this.socket.fromEvent('added') this.socket.fromEvent('added')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata); const data: Download = JSON.parse(strdata);
this.queue.set(data.url, data); this.queue.set(data.url, data);
this.queueChanged.next(null); this.queueChanged.next(null);
}); });
this.socket.fromEvent('updated') this.socket.fromEvent('updated')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata); const data: Download = JSON.parse(strdata);
const dl: Download | undefined = this.queue.get(data.url); const dl: Download | undefined = this.queue.get(data.url);
data.checked = !!dl?.checked; data.checked = !!dl?.checked;
data.deleting = !!dl?.deleting; data.deleting = !!dl?.deleting;
this.queue.set(data.url, data); this.queue.set(data.url, data);
this.updated.next(null); this.updated.next(null);
}); });
this.socket.fromEvent('completed') this.socket.fromEvent('completed')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata); const data: Download = JSON.parse(strdata);
this.queue.delete(data.url); this.queue.delete(data.url);
this.done.set(data.url, data); this.done.set(data.url, data);
this.queueChanged.next(null); this.queueChanged.next(null);
this.doneChanged.next(null); this.doneChanged.next(null);
}); });
this.socket.fromEvent('canceled') this.socket.fromEvent('canceled')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data: string = JSON.parse(strdata); const data: string = JSON.parse(strdata);
this.queue.delete(data); this.queue.delete(data);
this.queueChanged.next(null); this.queueChanged.next(null);
}); });
this.socket.fromEvent('cleared') this.socket.fromEvent('cleared')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data: string = JSON.parse(strdata); const data: string = JSON.parse(strdata);
this.done.delete(data); this.done.delete(data);
this.doneChanged.next(null); this.doneChanged.next(null);
}); });
this.socket.fromEvent('configuration') this.socket.fromEvent('configuration')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data = JSON.parse(strdata); const data = JSON.parse(strdata);
console.debug("got configuration:", data); console.debug("got configuration:", data);
this.configuration = data; this.configuration = data;
this.configurationChanged.next(data); this.configurationChanged.next(data);
}); });
this.socket.fromEvent('custom_dirs') this.socket.fromEvent('custom_dirs')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data = JSON.parse(strdata); const data = JSON.parse(strdata);
console.debug("got custom_dirs:", data); console.debug("got custom_dirs:", data);
this.customDirs = data; this.customDirs = data;
this.customDirsChanged.next(data); this.customDirsChanged.next(data);
}); });
this.socket.fromEvent('ytdl_options_changed') this.socket.fromEvent('ytdl_options_changed')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data = JSON.parse(strdata); const data = JSON.parse(strdata);
this.ytdlOptionsChanged.next(data); this.ytdlOptionsChanged.next(data);
}); });
} }
handleHTTPError(error: HttpErrorResponse) { handleHTTPError(error: HttpErrorResponse) {
@@ -107,8 +107,8 @@ export class DownloadsService {
return of({status: 'error', msg: msg}) return of({status: 'error', msg: msg})
} }
public add(url: string, quality: string, format: string, folder: string, customNamePrefix: string, playlistStrictMode: boolean, playlistItemLimit: number, autoStart: boolean) { 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<Status>('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( return this.http.post<Status>('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) catchError(this.handleHTTPError)
); );
} }
@@ -123,7 +123,7 @@ export class DownloadsService {
if (obj) { if (obj) {
obj.deleting = true obj.deleting = true
} }
}); });
return this.http.post('delete', {where: where, ids: ids}); return this.http.post('delete', {where: where, ids: ids});
} }
@@ -150,9 +150,11 @@ export class DownloadsService {
const defaultPlaylistStrictMode = false; const defaultPlaylistStrictMode = false;
const defaultPlaylistItemLimit = 0; const defaultPlaylistItemLimit = 0;
const defaultAutoStart = true; const defaultAutoStart = true;
const defaultSplitByChapters = false;
const defaultChapterTemplate = '%(section_number)02d - %(section_title)s.%(ext)s';
return new Promise((resolve, reject) => { 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({ .subscribe({
next: (response) => resolve(response), next: (response) => resolve(response),
error: (error) => reject(error) error: (error) => reject(error)