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_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 = '%(section_number)02d - %(section_title)s.%(ext)s'
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')

View File

@@ -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
@@ -98,14 +100,27 @@ class Download:
)})
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 '__finaldir' in d['info_dict']:
filename = os.path.join(d['info_dict']['__finaldir'], os.path.basename(d['info_dict']['filepath']))
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 +132,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 +208,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
# 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.msg = status.get('msg')
if 'downloaded_bytes' in status:
@@ -372,7 +415,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 +431,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 +451,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 +459,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='%(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=} {split_by_chapters=} {chapter_template=}')
already = set() if already is None else already
if url in already:
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)
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:

View File

@@ -6,34 +6,34 @@
</a>
<div class="download-metrics">
@if (activeDownloads > 0) {
<div class="metric">
<fa-icon [icon]="faDownload" class="text-primary" />
<span>{{activeDownloads}} downloading</span>
</div>
<div class="metric">
<fa-icon [icon]="faDownload" class="text-primary" />
<span>{{activeDownloads}} downloading</span>
</div>
}
@if (queuedDownloads > 0) {
<div class="metric">
<fa-icon [icon]="faClock" class="text-warning" />
<span>{{queuedDownloads}} queued</span>
</div>
<div class="metric">
<fa-icon [icon]="faClock" class="text-warning" />
<span>{{queuedDownloads}} queued</span>
</div>
}
@if (completedDownloads > 0) {
<div class="metric">
<fa-icon [icon]="faCheck" class="text-success" />
<span>{{completedDownloads}} completed</span>
</div>
<div class="metric">
<fa-icon [icon]="faCheck" class="text-success" />
<span>{{completedDownloads}} completed</span>
</div>
}
@if (failedDownloads > 0) {
<div class="metric">
<fa-icon [icon]="faTimesCircle" class="text-danger" />
<span>{{failedDownloads}} failed</span>
</div>
<div class="metric">
<fa-icon [icon]="faTimesCircle" class="text-danger" />
<span>{{failedDownloads}} failed</span>
</div>
}
@if ((totalSpeed | speed) !== '') {
<div class="metric">
<fa-icon [icon]="faTachometerAlt" class="text-info" />
<span>{{totalSpeed | speed }}</span>
</div>
<div class="metric">
<fa-icon [icon]="faTachometerAlt" class="text-info" />
<span>{{totalSpeed | speed }}</span>
</div>
}
</div>
<!--
@@ -62,20 +62,20 @@
</button>
<ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select">
@for (theme of themes; track theme) {
<li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
[class.active]="activeTheme === theme"
(click)="themeChanged(theme)">
<span class="me-2 opacity-50">
<fa-icon [icon]="theme.icon" />
</span>
{{ theme.displayName }}
<span class="me-2 opacity-50">
<fa-icon [icon]="theme.icon" />
</span>
{{ theme.displayName }}
<span class="ms-auto"
[class.d-none]="activeTheme !== theme">
<fa-icon [icon]="faCheck" />
</span>
</button>
</li>
</span>
</button>
</li>
}
</ul>
</div>
@@ -103,7 +103,7 @@
(click)="addDownload()"
[disabled]="addInProgress || downloads.loading">
@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" }}
</button>
@@ -122,7 +122,7 @@
(change)="qualityChanged()"
[disabled]="addInProgress || downloads.loading">
@for (q of qualities; track q) {
<option [ngValue]="q.id">{{ q.text }}</option>
<option [ngValue]="q.id">{{ q.text }}</option>
}
</select>
</div>
@@ -136,7 +136,7 @@
(change)="formatChanged()"
[disabled]="addInProgress || downloads.loading">
@for (f of formats; track f) {
<option [ngValue]="f.id">{{ f.text }}</option>
<option [ngValue]="f.id">{{ f.text }}</option>
}
</select>
</div>
@@ -188,9 +188,9 @@
[searchable]="true"
[closeOnSelect]="true"
ngbTooltip="Choose where to save downloads. Type to create a new folder." />
}
}
</div>
</div>
<div class="col-md-6">
<div class="input-group">
@@ -231,6 +231,29 @@
<label class="form-check-label" for="checkbox-strict-mode">Strict Playlist Mode</label>
</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>
<!-- Advanced Actions -->
@@ -275,7 +298,7 @@
<!-- Batch Import Modal -->
<div class="modal fade" tabindex="-1" role="dialog"
[class.show]="batchImportModalOpen"
[style.display]="batchImportModalOpen ? 'block' : 'none'">
[style.display]="batchImportModalOpen ? 'block' : 'none'">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
@@ -284,18 +307,18 @@
</div>
<div class="modal-body">
<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">
@if (batchImportStatus) {
<small>{{ batchImportStatus }}</small>
<small>{{ batchImportStatus }}</small>
}
</div>
</div>
<div class="modal-footer">
@if (importInProgress) {
<button type="button" class="btn btn-danger me-auto" (click)="cancelBatchImport()">
Cancel Import
</button>
<button type="button" class="btn btn-danger me-auto" (click)="cancelBatchImport()">
Cancel Import
</button>
}
<button type="button" class="btn btn-secondary" (click)="closeBatchImportModal()">Close</button>
<button type="button" class="btn btn-primary" (click)="startBatchImport()" [disabled]="importInProgress">
@@ -308,9 +331,9 @@
@if (downloads.loading) {
<div class="alert alert-info" role="alert">
Connecting to server...
</div>
<div class="alert alert-info" role="alert">
Connecting to server...
</div>
}
<div class="metube-section-header">Downloading</div>
<div class="px-2 py-3 border-bottom">
@@ -332,29 +355,29 @@
</thead>
<tbody>
@for (download of downloads.queue | keyvalue: asIsOrder; track download.value.id) {
<tr [class.disabled]='download.value.deleting'>
<td>
<app-slave-checkbox [id]="download.key" [master]="queueMasterCheckboxRef" [checkable]="download.value" />
</td>
<td title="{{ download.value.filename }}">
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
<div>{{ download.value.title }} </div>
<tr [class.disabled]='download.value.deleting'>
<td>
<app-slave-checkbox [id]="download.key" [master]="queueMasterCheckboxRef" [checkable]="download.value" />
</td>
<td title="{{ download.value.filename }}">
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
<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"
[value]="download.value.status === 'preparing' ? 100 : download.value.percent" class="download-progressbar" />
</div>
</td>
<td>{{ download.value.speed | speed }}</td>
<td>{{ download.value.eta | eta }}</td>
<td>
<div class="d-flex">
@if (download.value.status === 'pending') {
</div>
</td>
<td>{{ download.value.speed | speed }}</td>
<td>{{ download.value.eta | eta }}</td>
<td>
<div class="d-flex">
@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)="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>
</div>
</td>
</tr>
</div>
</td>
</tr>
}
</tbody>
</table>
@@ -382,49 +405,72 @@
</thead>
<tbody>
@for (download of downloads.done | keyvalue: asIsOrder; track download.value.id) {
<tr [class.disabled]='download.value.deleting'>
<td>
<app-slave-checkbox [id]="download.key" [master]="doneMasterCheckboxRef" [checkable]="download.value" />
</td>
<td>
<div style="display: inline-block; width: 1.5rem;">
@if (download.value.status === 'finished') {
<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>
<tr [class.disabled]='download.value.deleting'>
<td>
<app-slave-checkbox [id]="download.key" [master]="doneMasterCheckboxRef" [checkable]="download.value" />
</td>
<td>
<div style="display: inline-block; width: 1.5rem;">
@if (download.value.status === 'finished') {
<fa-icon [icon]="faCheckCircle" class="text-success" />
}
</td>
<td>
<div class="d-flex">
@if (download.value.status === 'error') {
@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>
<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>
}
@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="{{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>
</div>
</td>
</tr>
}
<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>
</div>
</td>
</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>
</table>
@@ -434,31 +480,31 @@
<footer class="footer navbar-dark bg-dark py-3 mt-5">
<div class="container text-center">
@if (ytDlpVersion && metubeVersion) {
<div class="footer-content">
<div class="version-item">
<span class="version-label">yt-dlp</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 class="footer-content">
<div class="version-item">
<span class="version-label">yt-dlp</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>
</footer>

View File

@@ -5,30 +5,30 @@ import { Observable, map, distinctUntilChanged } from 'rxjs';
import { FormsModule } from '@angular/forms';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { NgSelectModule } from '@ng-select/ng-select';
import { NgSelectModule } from '@ng-select/ng-select';
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt } from '@fortawesome/free-solid-svg-icons';
import { faGithub } from '@fortawesome/free-brands-svg-icons';
import { CookieService } from 'ngx-cookie-service';
import { DownloadsService } from './services/downloads.service';
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 { MasterCheckboxComponent , SlaveCheckboxComponent} from './components/';
import { MasterCheckboxComponent, SlaveCheckboxComponent } from './components/';
@Component({
selector: 'app-root',
imports: [
FormsModule,
KeyValuePipe,
AsyncPipe,
FontAwesomeModule,
NgbModule,
NgSelectModule,
EtaPipe,
SpeedPipe,
FileSizePipe,
MasterCheckboxComponent,
SlaveCheckboxComponent,
FormsModule,
KeyValuePipe,
AsyncPipe,
FontAwesomeModule,
NgbModule,
NgSelectModule,
EtaPipe,
SpeedPipe,
FileSizePipe,
MasterCheckboxComponent,
SlaveCheckboxComponent,
],
templateUrl: './app.html',
styleUrl: './app.sass',
@@ -48,11 +48,13 @@ export class App implements AfterViewInit, OnInit {
autoStart: boolean;
playlistStrictMode!: boolean;
playlistItemLimit!: number;
splitByChapters: boolean;
chapterTemplate: string;
addInProgress = false;
themes: Theme[] = Themes;
activeTheme: Theme | undefined;
customDirs$!: Observable<string[]>;
showBatchPanel = false;
showBatchPanel = false;
batchImportModalOpen = false;
batchImportText = '';
batchImportStatus = '';
@@ -103,6 +105,8 @@ 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';
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '%(section_number)02d - %(section_title)s.%(ext)s';
this.activeTheme = this.getPreferredTheme(this.cookieService);
@@ -127,7 +131,7 @@ export class App implements AfterViewInit, OnInit {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (this.activeTheme && this.activeTheme.id === 'auto') {
this.setTheme(this.activeTheme);
this.setTheme(this.activeTheme);
}
});
}
@@ -154,9 +158,9 @@ export class App implements AfterViewInit, OnInit {
// workaround to allow fetching of Map values in the order they were inserted
// https://github.com/angular/angular/issues/31420
asIsOrder() {
return 1;
}
@@ -179,12 +183,12 @@ export class App implements AfterViewInit, OnInit {
}
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(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map((output: any) => {
// Keep logic consistent with app/ytdl.py
if (this.isAudioType()) {
@@ -201,20 +205,20 @@ export class App implements AfterViewInit, OnInit {
getYtdlOptionsUpdateTime() {
this.downloads.ytdlOptionsChanged.subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (data:any) => {
if (data['success']){
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (data: any) => {
if (data['success']) {
const date = new Date(data['update_time'] * 1000);
this.ytDlpOptionsUpdateTime=date.toLocaleString();
}else{
alert("Error reload yt-dlp options: "+data['msg']);
this.ytDlpOptionsUpdateTime = date.toLocaleString();
} else {
alert("Error reload yt-dlp options: " + data['msg']);
}
}
});
}
getConfiguration() {
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) => {
this.playlistStrictMode = config['DEFAULT_OPTION_PLAYLIST_STRICT_MODE'];
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 });
}
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) {
this.queueDelSelected().nativeElement.disabled = checked == 0;
this.queueDownloadSelected().nativeElement.disabled = checked == 0;
@@ -277,10 +289,10 @@ export class App implements AfterViewInit, OnInit {
this.qualities = format.qualities
const exists = this.qualities.find(el => el.id === this.quality)
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
quality = quality ?? this.quality
format = format ?? this.format
@@ -289,10 +301,12 @@ 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);
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 +321,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();
}
@@ -315,7 +329,7 @@ export class App implements AfterViewInit, OnInit {
this.downloads.delById(where, [id]).subscribe();
}
startSelectedDownloads(where: State){
startSelectedDownloads(where: State) {
this.downloads.startByFilter(where, dl => !!dl.checked).subscribe();
}
@@ -378,9 +392,28 @@ 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)) {
if (charCode > 31 && (charCode < 48 || charCode > 57)) {
event.preventDefault();
}
}
@@ -434,7 +467,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') {
@@ -546,11 +579,11 @@ export class App implements AfterViewInit, OnInit {
this.queuedDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'pending').length;
this.completedDownloads = Array.from(this.downloads.done.values()).filter(d => d.status === 'finished').length;
this.failedDownloads = Array.from(this.downloads.done.values()).filter(d => d.status === 'error').length;
// Calculate total speed from downloading items
const downloadingItems = Array.from(this.downloads.queue.values())
.filter(d => d.status === 'downloading');
this.totalSpeed = downloadingItems.reduce((total, item) => total + (item.speed || 0), 0);
}
}

View File

@@ -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 }>;
}

View File

@@ -27,79 +27,79 @@ export class DownloadsService {
constructor() {
this.socket.fromEvent('all')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
this.loading = false;
const data: [[[string, Download]], [[string, Download]]] = JSON.parse(strdata);
this.queue.clear();
data[0].forEach(entry => this.queue.set(...entry));
this.done.clear();
data[1].forEach(entry => this.done.set(...entry));
this.queueChanged.next(null);
this.doneChanged.next(null);
});
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
this.loading = false;
const data: [[[string, Download]], [[string, Download]]] = JSON.parse(strdata);
this.queue.clear();
data[0].forEach(entry => this.queue.set(...entry));
this.done.clear();
data[1].forEach(entry => this.done.set(...entry));
this.queueChanged.next(null);
this.doneChanged.next(null);
});
this.socket.fromEvent('added')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata);
this.queue.set(data.url, data);
this.queueChanged.next(null);
});
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata);
this.queue.set(data.url, data);
this.queueChanged.next(null);
});
this.socket.fromEvent('updated')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata);
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata);
const dl: Download | undefined = this.queue.get(data.url);
data.checked = !!dl?.checked;
data.deleting = !!dl?.deleting;
this.queue.set(data.url, data);
this.updated.next(null);
});
data.checked = !!dl?.checked;
data.deleting = !!dl?.deleting;
this.queue.set(data.url, data);
this.updated.next(null);
});
this.socket.fromEvent('completed')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata);
this.queue.delete(data.url);
this.done.set(data.url, data);
this.queueChanged.next(null);
this.doneChanged.next(null);
});
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata);
this.queue.delete(data.url);
this.done.set(data.url, data);
this.queueChanged.next(null);
this.doneChanged.next(null);
});
this.socket.fromEvent('canceled')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: string = JSON.parse(strdata);
this.queue.delete(data);
this.queueChanged.next(null);
});
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: string = JSON.parse(strdata);
this.queue.delete(data);
this.queueChanged.next(null);
});
this.socket.fromEvent('cleared')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: string = JSON.parse(strdata);
this.done.delete(data);
this.doneChanged.next(null);
});
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: string = JSON.parse(strdata);
this.done.delete(data);
this.doneChanged.next(null);
});
this.socket.fromEvent('configuration')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data = JSON.parse(strdata);
console.debug("got configuration:", data);
this.configuration = data;
this.configurationChanged.next(data);
});
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data = JSON.parse(strdata);
console.debug("got configuration:", data);
this.configuration = data;
this.configurationChanged.next(data);
});
this.socket.fromEvent('custom_dirs')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data = JSON.parse(strdata);
console.debug("got custom_dirs:", data);
this.customDirs = data;
this.customDirsChanged.next(data);
});
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data = JSON.parse(strdata);
console.debug("got custom_dirs:", data);
this.customDirs = data;
this.customDirsChanged.next(data);
});
this.socket.fromEvent('ytdl_options_changed')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data = JSON.parse(strdata);
this.ytdlOptionsChanged.next(data);
});
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data = JSON.parse(strdata);
this.ytdlOptionsChanged.next(data);
});
}
handleHTTPError(error: HttpErrorResponse) {
@@ -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<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(
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, split_by_chapters: splitByChapters, chapter_template: chapterTemplate }).pipe(
catchError(this.handleHTTPError)
);
}
@@ -123,7 +123,7 @@ export class DownloadsService {
if (obj) {
obj.deleting = true
}
});
});
return this.http.post('delete', {where: where, ids: ids});
}
@@ -145,14 +145,16 @@ export class DownloadsService {
}> {
const defaultQuality = 'best';
const defaultFormat = 'mp4';
const defaultFolder = '';
const defaultFolder = '';
const defaultCustomNamePrefix = '';
const defaultPlaylistStrictMode = false;
const defaultPlaylistItemLimit = 0;
const defaultAutoStart = true;
const defaultSplitByChapters = false;
const defaultChapterTemplate = '%(section_number)02d - %(section_title)s.%(ext)s';
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)
@@ -162,6 +164,6 @@ export class DownloadsService {
public exportQueueUrls(): string[] {
return Array.from(this.queue.values()).map(download => download.url);
}
}