mirror of
https://github.com/alexta69/metube.git
synced 2026-09-21 13:35:01 +00:00
Compare commits
3 Commits
f11b376ce7
...
2026.08.28
| Author | SHA1 | Date | |
|---|---|---|---|
| 79388370e9 | |||
| 1251613f45 | |||
| 70d19759e8 |
@@ -90,7 +90,7 @@ Enabling `writeinfojson` or `writethumbnail` in `YTDL_OPTIONS` also writes a fee
|
||||
|
||||
### 🌐 Web Server & URLs
|
||||
|
||||
* __HOST__: The host address the web server will bind to. Defaults to `0.0.0.0` (all interfaces).
|
||||
* __HOST__: The host address the web server will bind to. Defaults to `0.0.0.0`, which is every IPv4 interface but no IPv6 one. Set it to `*` (or leave it empty) to listen on both stacks, or to `::` for IPv6 only — `::` does not also accept IPv4, whatever the host's `bindv6only` setting says.
|
||||
* __PORT__: The port number the web server will listen on. Defaults to `8081`.
|
||||
* __URL_PREFIX__: Base path for the web server (for use when hosting behind a reverse proxy). Defaults to `/`.
|
||||
* __PUBLIC_HOST_URL__: Base URL for the download links shown in the UI for completed files. By default, MeTube serves them under its own URL. If your download directory is accessible on another URL and you want the download links to be based there, use this variable to set it.
|
||||
|
||||
+15
-1
@@ -113,6 +113,18 @@ class Config:
|
||||
sys.exit(1)
|
||||
setattr(self, k, v in ('true', 'True', 'on', '1'))
|
||||
|
||||
# aiohttp hands HOST straight to getaddrinfo, which has no notion of a
|
||||
# '*' wildcard: the lookup fails and takes the server down at startup
|
||||
# with an opaque DNS error. '*' is nevertheless what people reach for
|
||||
# when they want to serve both IP stacks, while the value that actually
|
||||
# does it -- an empty string, which asyncio expands to one listening
|
||||
# socket per address family -- is undiscoverable. Accept '*' as the
|
||||
# spelling for "every interface, both stacks". Note that '::' on its own
|
||||
# is IPv6-only regardless of the host's bindv6only setting, because
|
||||
# asyncio always sets IPV6_V6ONLY on the sockets it binds.
|
||||
if self.HOST.strip() == '*':
|
||||
self.HOST = ''
|
||||
|
||||
if not self.URL_PREFIX.endswith('/'):
|
||||
self.URL_PREFIX += '/'
|
||||
|
||||
@@ -1386,7 +1398,9 @@ def isAccessLogEnabled():
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.getLogger().setLevel(parseLogLevel(config.LOGLEVEL) or logging.INFO)
|
||||
log.info(f"Listening on {config.HOST}:{config.PORT}")
|
||||
# An empty HOST binds every interface on both stacks; print the '*' spelling
|
||||
# that selects it rather than a bare ':8081'.
|
||||
log.info(f"Listening on {config.HOST or '*'}:{config.PORT}")
|
||||
|
||||
|
||||
# Auto-detect cookie file on startup
|
||||
|
||||
@@ -51,6 +51,24 @@ class ConfigTests(unittest.TestCase):
|
||||
self.assertEqual(c.PUBLIC_HOST_URL, "")
|
||||
self.assertEqual(c.PUBLIC_HOST_AUDIO_URL, "")
|
||||
|
||||
def test_host_wildcard_becomes_empty_for_dual_stack(self):
|
||||
# Regression: aiohttp passes HOST to getaddrinfo, which does not resolve
|
||||
# '*' -- the server died at startup on a DNS error. '*' now selects the
|
||||
# empty string, the only value asyncio expands to a listening socket per
|
||||
# address family.
|
||||
for raw in ("*", " * "):
|
||||
with self.subTest(raw=raw):
|
||||
with patch.dict(os.environ, _base_env(HOST=raw), clear=False):
|
||||
c = Config()
|
||||
self.assertEqual(c.HOST, "")
|
||||
|
||||
def test_host_literal_addresses_are_untouched(self):
|
||||
for raw in ("0.0.0.0", "::", "127.0.0.1", ""):
|
||||
with self.subTest(raw=raw):
|
||||
with patch.dict(os.environ, _base_env(HOST=raw), clear=False):
|
||||
c = Config()
|
||||
self.assertEqual(c.HOST, raw)
|
||||
|
||||
def test_blank_audio_host_falls_back_to_audio_download_route(self):
|
||||
# Regression: a present-but-blank PUBLIC_HOST_AUDIO_URL must not stay empty
|
||||
# (which produced root-relative, 404ing audio links). It falls back to the
|
||||
|
||||
@@ -777,6 +777,80 @@ class ProgressThrottleTests(unittest.TestCase):
|
||||
self.assertIn("error", statuses)
|
||||
|
||||
|
||||
class PostprocessorHookTests(unittest.TestCase):
|
||||
"""The postprocessing phase must be visible without ever displacing the
|
||||
terminal 'finished' status that _post_download_cleanup keys off."""
|
||||
|
||||
def _hook(self):
|
||||
dl = _make_test_download()
|
||||
forwarded = []
|
||||
dl.status_queue = types.SimpleNamespace(put=forwarded.append)
|
||||
return dl._make_postprocessor_hook(), forwarded
|
||||
|
||||
def test_postprocessor_start_reports_the_phase(self):
|
||||
hook, forwarded = self._hook()
|
||||
hook({"postprocessor": "VideoConvertor", "status": "started", "info_dict": {}})
|
||||
self.assertEqual(forwarded, [{"status": "postprocessing"}])
|
||||
|
||||
def test_postprocessor_finish_is_not_reported(self):
|
||||
# Only 'started' moves the UI; a PP finishing says nothing about what
|
||||
# comes next, and an extra broadcast per PP buys nothing.
|
||||
hook, forwarded = self._hook()
|
||||
hook({"postprocessor": "VideoConvertor", "status": "finished", "info_dict": {}})
|
||||
self.assertEqual(forwarded, [])
|
||||
|
||||
def test_move_files_still_reports_finished_with_the_final_path(self):
|
||||
hook, forwarded = self._hook()
|
||||
hook({
|
||||
"postprocessor": "MoveFiles",
|
||||
"status": "finished",
|
||||
"info_dict": {"filepath": "/tmp/video.mp4"},
|
||||
})
|
||||
self.assertEqual(forwarded, [{"status": "finished", "filename": "/tmp/video.mp4"}])
|
||||
|
||||
def test_nothing_is_reported_after_move_files_finished(self):
|
||||
# Regression guard: _post_download_cleanup turns any final status other
|
||||
# than 'finished' into an error, so an 'after_move' postprocessor
|
||||
# starting up must not overwrite it and fail the download.
|
||||
hook, forwarded = self._hook()
|
||||
hook({
|
||||
"postprocessor": "MoveFiles",
|
||||
"status": "finished",
|
||||
"info_dict": {"filepath": "/tmp/video.mp4"},
|
||||
})
|
||||
hook({"postprocessor": "SomeAfterMovePP", "status": "started", "info_dict": {}})
|
||||
self.assertEqual([item["status"] for item in forwarded], ["finished"])
|
||||
|
||||
def test_realistic_sequence_ends_finished(self):
|
||||
hook, forwarded = self._hook()
|
||||
for pp in ("Merger", "VideoConvertor", "Metadata"):
|
||||
hook({"postprocessor": pp, "status": "started", "info_dict": {}})
|
||||
hook({"postprocessor": pp, "status": "finished", "info_dict": {}})
|
||||
hook({"postprocessor": "MoveFiles", "status": "started", "info_dict": {}})
|
||||
hook({
|
||||
"postprocessor": "MoveFiles",
|
||||
"status": "finished",
|
||||
"info_dict": {"filepath": "/tmp/video.mp4"},
|
||||
})
|
||||
|
||||
statuses = [item["status"] for item in forwarded]
|
||||
self.assertEqual(statuses[-1], "finished")
|
||||
self.assertEqual(statuses.count("finished"), 1)
|
||||
self.assertTrue(all(st == "postprocessing" for st in statuses[:-1]))
|
||||
|
||||
def test_split_chapters_still_captures_files_without_a_status(self):
|
||||
hook, forwarded = self._hook()
|
||||
hook({
|
||||
"postprocessor": "SplitChapters",
|
||||
"status": "finished",
|
||||
"info_dict": {"chapters": [{"filepath": "/tmp/ch1.mp4"}, {"filepath": "/tmp/ch2.mp4"}]},
|
||||
})
|
||||
self.assertEqual(
|
||||
forwarded,
|
||||
[{"chapter_file": "/tmp/ch1.mp4"}, {"chapter_file": "/tmp/ch2.mp4"}],
|
||||
)
|
||||
|
||||
|
||||
class CancelProcessGroupTests(unittest.TestCase):
|
||||
# cancel() now sends SIGINT first (so yt-dlp/ffmpeg can finalize the
|
||||
# partial file) and schedules a SIGKILL escalation via the event loop
|
||||
@@ -1202,7 +1276,13 @@ class UpdateStatusFileStatTests(unittest.IsolatedAsyncioTestCase):
|
||||
download.loop = asyncio.get_running_loop()
|
||||
download._executor = ThreadPoolExecutor(max_workers=1)
|
||||
notifier = MagicMock()
|
||||
notifier.updated = AsyncMock()
|
||||
# updated() is handed the same DownloadInfo every time, so the status has
|
||||
# to be copied out when the call happens -- reading it off the recorded
|
||||
# call args afterwards only ever shows the final value.
|
||||
notifier.broadcast_statuses = []
|
||||
notifier.updated = AsyncMock(
|
||||
side_effect=lambda info: notifier.broadcast_statuses.append(info.status)
|
||||
)
|
||||
download.notifier = notifier
|
||||
|
||||
stat_calls = []
|
||||
@@ -1237,3 +1317,36 @@ class UpdateStatusFileStatTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
self.assertEqual(stat_calls, ["/tmp/v.mp4"])
|
||||
self.assertIsNone(download.info.size)
|
||||
|
||||
async def test_repeated_postprocessing_status_is_broadcast_once(self):
|
||||
# yt-dlp's metaclass wraps run() once per class in a postprocessor's MRO,
|
||||
# so one whose subclass overrides run (FFmpegCopyStream, among others)
|
||||
# reports 'started' twice. Observed live on a 13s libx264 re-encode.
|
||||
download, _ = await self._run_update_status([
|
||||
{"status": "downloading", "downloaded_bytes": 1},
|
||||
{"status": "postprocessing"},
|
||||
{"status": "postprocessing"},
|
||||
{"status": "postprocessing"},
|
||||
{"status": "finished", "filename": "/tmp/v.mp4"},
|
||||
])
|
||||
|
||||
self.assertEqual(
|
||||
download.notifier.broadcast_statuses,
|
||||
["downloading", "postprocessing", "finished"],
|
||||
)
|
||||
|
||||
async def test_postprocessing_is_announced_again_after_the_download_resumes(self):
|
||||
# An audio download runs pre_process postprocessors before the bytes
|
||||
# arrive, so 'postprocessing' legitimately appears on both sides of the
|
||||
# download. Deduping against the live status keeps the second one.
|
||||
download, _ = await self._run_update_status([
|
||||
{"status": "postprocessing"},
|
||||
{"status": "downloading", "downloaded_bytes": 1},
|
||||
{"status": "postprocessing"},
|
||||
{"status": "finished", "filename": "/tmp/v.mp4"},
|
||||
])
|
||||
|
||||
self.assertEqual(
|
||||
download.notifier.broadcast_statuses,
|
||||
["postprocessing", "downloading", "postprocessing", "finished"],
|
||||
)
|
||||
|
||||
+65
-29
@@ -771,6 +771,59 @@ class Download:
|
||||
|
||||
return put_status
|
||||
|
||||
def _make_postprocessor_hook(self):
|
||||
# yt-dlp reports the download 'finished' as soon as the media bytes
|
||||
# have landed, but the ffmpeg work that follows -- merging,
|
||||
# re-encoding, chapter splitting, sponsor removal -- routinely takes
|
||||
# longer than the download itself and said nothing until now, leaving
|
||||
# the row on a full, frozen progress bar for minutes. Report the phase
|
||||
# so the UI can run its indeterminate bar instead.
|
||||
#
|
||||
# Latched off once MoveFiles reports finished: that branch emits the
|
||||
# terminal 'finished', and _post_download_cleanup turns any other
|
||||
# final status into an error, so nothing may overwrite it. yt-dlp does
|
||||
# run an 'after_move' stage after MoveFiles, but every postprocessor
|
||||
# MeTube configures is 'after_filter' or 'post_process', both of which
|
||||
# precede it. Each postprocessor emits exactly one started/finished
|
||||
# pair, so this needs no throttling.
|
||||
postprocessing_done = False
|
||||
|
||||
def put_status_postprocessor(d):
|
||||
nonlocal postprocessing_done
|
||||
if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished':
|
||||
postprocessing_done = True
|
||||
filepath = d['info_dict']['filepath']
|
||||
if '__finaldir' in d['info_dict']:
|
||||
finaldir = d['info_dict']['__finaldir']
|
||||
filename = os.path.join(finaldir, os.path.basename(filepath))
|
||||
else:
|
||||
filename = filepath
|
||||
self.status_queue.put({'status': 'finished', 'filename': filename})
|
||||
# For captions-only downloads, yt-dlp may still report a media-like
|
||||
# filepath in MoveFiles. Capture subtitle outputs explicitly so the
|
||||
# UI can link to real caption files.
|
||||
if getattr(self.info, 'download_type', '') == 'captions':
|
||||
requested_subtitles = d.get('info_dict', {}).get('requested_subtitles', {}) or {}
|
||||
for subtitle in requested_subtitles.values():
|
||||
if isinstance(subtitle, dict) and subtitle.get('filepath'):
|
||||
self.status_queue.put({'subtitle_file': subtitle['filepath']})
|
||||
|
||||
# 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")
|
||||
|
||||
elif d.get('status') == 'started' and not postprocessing_done:
|
||||
self.status_queue.put({'status': 'postprocessing'})
|
||||
|
||||
return put_status_postprocessor
|
||||
|
||||
def _make_youtube_dl(self, params):
|
||||
ydl = _ConfinedYoutubeDL(
|
||||
params=params,
|
||||
@@ -815,35 +868,7 @@ class Download:
|
||||
try:
|
||||
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
|
||||
put_status = self._make_progress_hook()
|
||||
|
||||
def put_status_postprocessor(d):
|
||||
if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished':
|
||||
filepath = d['info_dict']['filepath']
|
||||
if '__finaldir' in d['info_dict']:
|
||||
finaldir = d['info_dict']['__finaldir']
|
||||
filename = os.path.join(finaldir, os.path.basename(filepath))
|
||||
else:
|
||||
filename = filepath
|
||||
self.status_queue.put({'status': 'finished', 'filename': filename})
|
||||
# For captions-only downloads, yt-dlp may still report a media-like
|
||||
# filepath in MoveFiles. Capture subtitle outputs explicitly so the
|
||||
# UI can link to real caption files.
|
||||
if getattr(self.info, 'download_type', '') == 'captions':
|
||||
requested_subtitles = d.get('info_dict', {}).get('requested_subtitles', {}) or {}
|
||||
for subtitle in requested_subtitles.values():
|
||||
if isinstance(subtitle, dict) and subtitle.get('filepath'):
|
||||
self.status_queue.put({'subtitle_file': subtitle['filepath']})
|
||||
|
||||
# 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")
|
||||
put_status_postprocessor = self._make_postprocessor_hook()
|
||||
|
||||
ytdl_params = {
|
||||
'quiet': not debug_logging,
|
||||
@@ -1090,6 +1115,17 @@ class Download:
|
||||
self.info.size = file_size
|
||||
continue
|
||||
|
||||
# yt-dlp's postprocessor metaclass wraps run() once per class in the
|
||||
# MRO, so a postprocessor whose subclass overrides run reports
|
||||
# started twice (FFmpegCopyStream, among others). Nothing has changed
|
||||
# between the two, so drop the repeat rather than re-encode and
|
||||
# rebroadcast the same state to every connected client. Compared
|
||||
# against the live status, not a flag in the hook, so the phase is
|
||||
# still announced when a pre_process postprocessor ran before the
|
||||
# download and 'downloading' came in between.
|
||||
if status['status'] == 'postprocessing' and self.info.status == 'postprocessing':
|
||||
continue
|
||||
|
||||
self.info.status = status['status']
|
||||
self.info.msg = status.get('msg')
|
||||
if 'downloaded_bytes' in status:
|
||||
|
||||
+408
-383
@@ -690,399 +690,424 @@
|
||||
Connecting to server...
|
||||
</div>
|
||||
}
|
||||
<div class="metube-section-header">Downloading</div>
|
||||
<div class="px-2 py-3 border-bottom">
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt" /> Cancel selected</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDownloadSelected (click)="startSelectedDownloads('queue')"><fa-icon [icon]="faDownload" /> Download selected</button>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 1rem;">
|
||||
<app-select-all-checkbox #queueMasterCheckboxRef [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)" />
|
||||
</th>
|
||||
<th scope="col">Video</th>
|
||||
<th scope="col" style="width: 7rem;">Format</th>
|
||||
<th scope="col" style="width: 8rem;">Speed</th>
|
||||
<th scope="col" style="width: 7rem;">ETA</th>
|
||||
<th scope="col" style="width: 6rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (download of downloads.queue | keyvalue: asIsOrder; track download.key) {
|
||||
<tr [class.disabled]='download.value.deleting'>
|
||||
<td>
|
||||
<app-item-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 class="d-flex align-items-center flex-wrap gap-2">
|
||||
<span>{{ download.value.title }}</span>
|
||||
@if (download.value.live_status === 'is_live' && download.value.status !== 'scheduled') {
|
||||
<span class="badge bg-danger">LIVE</span>
|
||||
}
|
||||
</div>
|
||||
@if (download.value.status === 'scheduled') {
|
||||
<span class="badge bg-warning text-dark">
|
||||
<fa-icon [icon]="faClock" />
|
||||
Waiting for stream
|
||||
@if (liveCountdownSeconds(download.value); as secs) {
|
||||
- starts in {{ secs | eta }}
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
<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 class="text-nowrap">{{ formatLabel(download.value) }}</td>
|
||||
<td>{{ download.value.speed | speed }}</td>
|
||||
<td>{{ download.value.eta | eta }}</td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
@if (download.value.status === 'pending' || download.value.status === 'scheduled') {
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Start download for ' + download.value.title" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
|
||||
}
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Remove ' + download.value.title + ' from queue'" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
|
||||
<a href="{{download.value.url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + download.value.title"><fa-icon [icon]="faExternalLinkAlt" /></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="metube-section-header">Completed</div>
|
||||
<div class="px-2 py-3 border-bottom">
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" (click)="toggleSortOrder()" ngbTooltip="{{ sortAscending ? 'Oldest first' : 'Newest first' }}"><fa-icon [icon]="sortAscending ? faSortAmountUp : faSortAmountDown" /> {{ sortAscending ? 'Oldest first' : 'Newest first' }}</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" /> Clear selected</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasCompletedDone" (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle" /> Clear completed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone" (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle" /> Clear failed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone" (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt" /> Retry failed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDownloadSelected (click)="downloadSelectedFiles()"><fa-icon [icon]="faDownload" /> Download Selected</button>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 1rem;">
|
||||
<app-select-all-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" [orderedIds]="cachedSortedDoneIds" (changed)="doneSelectionChanged($event)" />
|
||||
</th>
|
||||
<th scope="col">Video</th>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Quality</th>
|
||||
<th scope="col">Codec / Format</th>
|
||||
<th scope="col">File Size</th>
|
||||
<th scope="col">Downloaded</th>
|
||||
<th scope="col" style="width: 8rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of cachedSortedDone; track entry[0]) {
|
||||
<tr [class.disabled]='entry[1].deleting'>
|
||||
<td>
|
||||
<app-item-checkbox [id]="entry[0]" [master]="doneMasterCheckboxRef" [checkable]="entry[1]" />
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: inline-block; width: 1.5rem;">
|
||||
@if (entry[1].status === 'finished') {
|
||||
<fa-icon [icon]="faCheckCircle" class="text-success" />
|
||||
}
|
||||
@if (entry[1].status === 'error') {
|
||||
<button type="button" class="btn btn-link p-0"
|
||||
(click)="toggleErrorDetail(entry[0])"
|
||||
[attr.aria-label]="'Toggle error details for ' + entry[1].title"
|
||||
[attr.aria-expanded]="isErrorExpanded(entry[0])">
|
||||
<fa-icon [icon]="faTimesCircle" class="text-danger" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span ngbTooltip="{{buildResultItemTooltip(entry[1])}}">@if (!!entry[1].filename) {
|
||||
<a href="{{buildDownloadLink(entry[1])}}" target="_blank">{{ entry[1].title }}</a>
|
||||
} @else {
|
||||
@if (entry[1].status === 'error') {
|
||||
<button type="button" class="btn btn-link p-0 text-start align-baseline" (click)="toggleErrorDetail(entry[0])">
|
||||
{{entry[1].title}}
|
||||
@if (!isErrorExpanded(entry[0])) {
|
||||
<small class="text-danger ms-2">
|
||||
<fa-icon [icon]="faChevronRight" size="xs" class="me-1" />Click for details
|
||||
</small>
|
||||
}
|
||||
</button>
|
||||
} @else {
|
||||
<span>{{entry[1].title}}</span>
|
||||
}
|
||||
}</span>
|
||||
@if (entry[1].status === 'error' && isErrorExpanded(entry[0])) {
|
||||
<div class="alert alert-danger py-2 px-3 mt-2 mb-0 small" style="border-left: 4px solid var(--bs-danger);">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
@if (entry[1].msg) {
|
||||
<div class="mb-1"><strong>Message:</strong> {{entry[1].msg}}</div>
|
||||
}
|
||||
@if (entry[1].error) {
|
||||
<div class="mb-1"><strong>Error:</strong> {{entry[1].error}}</div>
|
||||
}
|
||||
<div class="text-muted" style="word-break: break-all;"><strong>URL:</strong> {{entry[1].url}}</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger ms-2 flex-shrink-0"
|
||||
(click)="copyErrorMessage(entry[0], entry[1]); $event.stopPropagation()"
|
||||
ngbTooltip="Copy error details to clipboard">
|
||||
@if (lastCopiedErrorId === entry[0]) {
|
||||
<span class="text-success">Copied!</span>
|
||||
} @else {
|
||||
<fa-icon [icon]="faCopy" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{ downloadTypeLabel(entry[1]) }}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{ formatQualityLabel(entry[1]) }}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{ formatCodecLabel(entry[1]) }}
|
||||
</td>
|
||||
<td>
|
||||
@if (entry[1].size) {
|
||||
<span>{{ entry[1].size | fileSize }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
@if (entry[1].timestamp) {
|
||||
<span>{{ entry[1].timestamp / 1000000 | date:'yyyy-MM-dd HH:mm' }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
@if (entry[1].status === 'error') {
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Retry download for ' + entry[1].title" (click)="retryDownload(entry[0], entry[1])"><fa-icon [icon]="faRedoAlt" /></button>
|
||||
}
|
||||
@if (entry[1].filename) {
|
||||
<a href="{{buildDownloadLink(entry[1])}}" download class="btn btn-link" [attr.aria-label]="'Download result file for ' + entry[1].title"><fa-icon [icon]="faDownload" /></a>
|
||||
}
|
||||
@if (entry[1].filename && canShareDownloads()) {
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Share result file for ' + entry[1].title" (click)="shareDownload(entry[1])"><fa-icon [icon]="faShareNodes" /></button>
|
||||
}
|
||||
<a href="{{entry[1].url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + entry[1].title"><fa-icon [icon]="faExternalLinkAlt" /></a>
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Delete completed item ' + entry[1].title" (click)="delDownload('done', entry[0])"><fa-icon [icon]="faTrashAlt" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@if (entry[1].chapter_files && entry[1].chapter_files.length > 0) {
|
||||
@for (chapterFile of entry[1].chapter_files; track chapterFile.filename) {
|
||||
<tr [class.disabled]='entry[1].deleting'>
|
||||
<td></td>
|
||||
<td>
|
||||
<div style="padding-left: 2rem;">
|
||||
<fa-icon [icon]="faCheckCircle" class="text-success me-2" />
|
||||
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" target="_blank" [attr.aria-label]="'Open chapter file ' + getChapterFileName(chapterFile.filename)">{{
|
||||
getChapterFileName(chapterFile.filename) }}</a>
|
||||
</div>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>
|
||||
@if (chapterFile.size) {
|
||||
<span>{{ chapterFile.size | fileSize }}</span>
|
||||
}
|
||||
</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" download [attr.aria-label]="'Download chapter file ' + getChapterFileName(chapterFile.filename)"
|
||||
class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="metube-section-header">Subscriptions</div>
|
||||
<div class="px-2 py-3 border-bottom">
|
||||
@if (checkingAllSubscriptions) {
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check all now
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
|
||||
(click)="checkAllSubscriptions()"
|
||||
[disabled]="downloads.loading || cachedSubs.length === 0 || checkingSelectedSubscriptions">
|
||||
<fa-icon [icon]="faRedoAlt" /> Check all now
|
||||
</button>
|
||||
}
|
||||
@if (checkingSelectedSubscriptions) {
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check selected
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
|
||||
(click)="checkSelectedSubscriptions()"
|
||||
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0 || checkingAllSubscriptions">
|
||||
<fa-icon [icon]="faRedoAlt" /> Check selected
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
|
||||
(click)="deleteSelectedSubscriptions()"
|
||||
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0">
|
||||
<fa-icon [icon]="faTrashAlt" /> Delete selected
|
||||
<div class="metube-section-header">
|
||||
<button type="button" class="metube-section-toggle" (click)="toggleDownloadingCollapsed()" [attr.aria-expanded]="!downloadingCollapsed">
|
||||
<span>Downloading</span>
|
||||
<fa-icon [icon]="downloadingCollapsed ? faChevronRight : faChevronDown" class="metube-section-chevron" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 1rem;">
|
||||
<input type="checkbox" class="form-check-input"
|
||||
[checked]="allSubsSelected()"
|
||||
(change)="toggleSubMaster($event)"
|
||||
[disabled]="downloads.loading || cachedSubs.length === 0"
|
||||
aria-label="Select all subscriptions" />
|
||||
</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">URL</th>
|
||||
<th scope="col" class="text-nowrap"><span class="help-title" ngbPopover="Subscriptions only — which new video titles to queue when this feed is checked. Does not affect manual downloads." triggers="click" autoClose="outside" container="body">Filter</span></th>
|
||||
<th scope="col" class="text-nowrap">Interval (min)</th>
|
||||
<th scope="col" class="text-nowrap">Last checked</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col" style="width: 8rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of cachedSubs; track entry[0]) {
|
||||
@if (!downloadingCollapsed) {
|
||||
<div class="px-2 py-3 border-bottom">
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt" /> Cancel selected</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDownloadSelected (click)="startSelectedDownloads('queue')"><fa-icon [icon]="faDownload" /> Download selected</button>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input"
|
||||
[checked]="isSubSelected(entry[0])"
|
||||
(change)="toggleSubSelected(entry[0])"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Select subscription ' + entry[1].name" />
|
||||
</td>
|
||||
<td>
|
||||
@if (editingNameId === entry[0]) {
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm flex-grow-1"
|
||||
[name]="'subName' + entry[0]"
|
||||
[(ngModel)]="nameEditDraft"
|
||||
[maxlength]="subscriptionNameMaxLength"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Subscription name for ' + entry[1].name" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
(click)="saveName(entry[0])"
|
||||
[disabled]="downloads.loading">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
(click)="cancelEditName()"
|
||||
[disabled]="downloads.loading">Cancel</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<span class="text-break">{{ entry[1].name }}</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0"
|
||||
(click)="beginEditName(entry[0], entry[1].name)"
|
||||
[disabled]="downloads.loading"
|
||||
ngbTooltip="Rename this subscription (display name only; does not affect the download folder)">Edit</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td class="text-break"><a [href]="entry[1].url" target="_blank" rel="noopener">{{ entry[1].url }}</a></td>
|
||||
<td>
|
||||
@if (editingTitleRegexId === entry[0]) {
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm flex-grow-1"
|
||||
[name]="'subTitleRegex' + entry[0]"
|
||||
[(ngModel)]="titleRegexEditDraft"
|
||||
[disabled]="downloads.loading" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
(click)="saveTitleRegex(entry[0])"
|
||||
[disabled]="downloads.loading">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
(click)="cancelEditTitleRegex()"
|
||||
[disabled]="downloads.loading">Cancel</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<span class="text-muted small text-break"
|
||||
[class.text-secondary]="!entry[1].title_regex">{{ entry[1].title_regex || '—' }}</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0"
|
||||
(click)="beginEditTitleRegex(entry[0], entry[1].title_regex)"
|
||||
[disabled]="downloads.loading"
|
||||
ngbTooltip="Edit subscription title filter (subscriptions only; not for one-off downloads)">Edit</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>{{ entry[1].check_interval_minutes }}</td>
|
||||
<td class="text-nowrap">
|
||||
@if (entry[1].last_checked !== null) {
|
||||
<span>{{ entry[1].last_checked! * 1000 | date:'yyyy-MM-dd HH:mm:ss' }}</span>
|
||||
} @else {
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (entry[1].error) {
|
||||
<span class="text-danger small">{{ entry[1].error }}</span>
|
||||
} @else if (entry[1].enabled) {
|
||||
<span class="text-success">Active</span>
|
||||
} @else {
|
||||
<span class="text-secondary">Paused</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
@if (isSubscriptionChecking(entry[0])) {
|
||||
<button type="button" class="btn btn-link btn-sm p-0 me-2"
|
||||
disabled
|
||||
[attr.aria-label]="'Checking ' + entry[1].name"
|
||||
ngbTooltip="Checking now">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-link btn-sm p-0 me-2"
|
||||
(click)="checkSubscriptionNow(entry[0])"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Check now ' + entry[1].name"
|
||||
ngbTooltip="Check now">
|
||||
<fa-icon [icon]="faRedoAlt" />
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn btn-link btn-sm p-0 me-2"
|
||||
(click)="toggleSubscriptionEnabled(entry[1])"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="(entry[1].enabled ? 'Pause ' : 'Resume ') + entry[1].name"
|
||||
[ngbTooltip]="entry[1].enabled ? 'Pause' : 'Resume'">
|
||||
@if (entry[1].enabled) {
|
||||
<fa-icon [icon]="faPause" />
|
||||
<th scope="col" style="width: 1rem;">
|
||||
<app-select-all-checkbox #queueMasterCheckboxRef [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)" />
|
||||
</th>
|
||||
<th scope="col">Video</th>
|
||||
<th scope="col" style="width: 7rem;">Format</th>
|
||||
<th scope="col" style="width: 8rem;">Speed</th>
|
||||
<th scope="col" style="width: 7rem;">ETA</th>
|
||||
<th scope="col" style="width: 6rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (download of downloads.queue | keyvalue: asIsOrder; track download.key) {
|
||||
<tr [class.disabled]='download.value.deleting'>
|
||||
<td>
|
||||
<app-item-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 class="d-flex align-items-center flex-wrap gap-2">
|
||||
<span>{{ download.value.title }}</span>
|
||||
@if (download.value.live_status === 'is_live' && download.value.status !== 'scheduled') {
|
||||
<span class="badge bg-danger">LIVE</span>
|
||||
}
|
||||
</div>
|
||||
@if (download.value.status === 'scheduled') {
|
||||
<span class="badge bg-warning text-dark">
|
||||
<fa-icon [icon]="faClock" />
|
||||
Waiting for stream
|
||||
@if (liveCountdownSeconds(download.value); as secs) {
|
||||
- starts in {{ secs | eta }}
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
<fa-icon [icon]="faPlay" />
|
||||
<ngb-progressbar height="1.5rem" [showValue]="!isIndeterminate(download.value)" [striped]="isIndeterminate(download.value)" [animated]="isIndeterminate(download.value)" type="success"
|
||||
[value]="isIndeterminate(download.value) ? 100 : download.value.percent" class="download-progressbar">
|
||||
@if (download.value.status === 'postprocessing') {
|
||||
<span>Post-processing</span>
|
||||
}
|
||||
</ngb-progressbar>
|
||||
}
|
||||
</button>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-danger"
|
||||
(click)="deleteSubscription(entry[0])"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Delete subscription ' + entry[1].name">
|
||||
<fa-icon [icon]="faTrashAlt" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-nowrap">{{ formatLabel(download.value) }}</td>
|
||||
<td>{{ download.value.speed | speed }}</td>
|
||||
<td>{{ download.value.eta | eta }}</td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
@if (download.value.status === 'pending' || download.value.status === 'scheduled') {
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Start download for ' + download.value.title" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
|
||||
}
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Remove ' + download.value.title + ' from queue'" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
|
||||
<a href="{{download.value.url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + download.value.title"><fa-icon [icon]="faExternalLinkAlt" /></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="metube-section-header">
|
||||
<button type="button" class="metube-section-toggle" (click)="toggleCompletedCollapsed()" [attr.aria-expanded]="!completedCollapsed">
|
||||
<span>Completed</span>
|
||||
<fa-icon [icon]="completedCollapsed ? faChevronRight : faChevronDown" class="metube-section-chevron" />
|
||||
</button>
|
||||
</div>
|
||||
@if (!completedCollapsed) {
|
||||
<div class="px-2 py-3 border-bottom">
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" (click)="toggleSortOrder()" ngbTooltip="{{ sortAscending ? 'Oldest first' : 'Newest first' }}"><fa-icon [icon]="sortAscending ? faSortAmountUp : faSortAmountDown" /> {{ sortAscending ? 'Oldest first' : 'Newest first' }}</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" /> Clear selected</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasCompletedDone" (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle" /> Clear completed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone" (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle" /> Clear failed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone" (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt" /> Retry failed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDownloadSelected (click)="downloadSelectedFiles()"><fa-icon [icon]="faDownload" /> Download Selected</button>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 1rem;">
|
||||
<app-select-all-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" [orderedIds]="cachedSortedDoneIds" (changed)="doneSelectionChanged($event)" />
|
||||
</th>
|
||||
<th scope="col">Video</th>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Quality</th>
|
||||
<th scope="col">Codec / Format</th>
|
||||
<th scope="col">File Size</th>
|
||||
<th scope="col">Downloaded</th>
|
||||
<th scope="col" style="width: 8rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of cachedSortedDone; track entry[0]) {
|
||||
<tr [class.disabled]='entry[1].deleting'>
|
||||
<td>
|
||||
<app-item-checkbox [id]="entry[0]" [master]="doneMasterCheckboxRef" [checkable]="entry[1]" />
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: inline-block; width: 1.5rem;">
|
||||
@if (entry[1].status === 'finished') {
|
||||
<fa-icon [icon]="faCheckCircle" class="text-success" />
|
||||
}
|
||||
@if (entry[1].status === 'error') {
|
||||
<button type="button" class="btn btn-link p-0"
|
||||
(click)="toggleErrorDetail(entry[0])"
|
||||
[attr.aria-label]="'Toggle error details for ' + entry[1].title"
|
||||
[attr.aria-expanded]="isErrorExpanded(entry[0])">
|
||||
<fa-icon [icon]="faTimesCircle" class="text-danger" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span ngbTooltip="{{buildResultItemTooltip(entry[1])}}">@if (!!entry[1].filename) {
|
||||
<a href="{{buildDownloadLink(entry[1])}}" target="_blank">{{ entry[1].title }}</a>
|
||||
} @else {
|
||||
@if (entry[1].status === 'error') {
|
||||
<button type="button" class="btn btn-link p-0 text-start align-baseline" (click)="toggleErrorDetail(entry[0])">
|
||||
{{entry[1].title}}
|
||||
@if (!isErrorExpanded(entry[0])) {
|
||||
<small class="text-danger ms-2">
|
||||
<fa-icon [icon]="faChevronRight" size="xs" class="me-1" />Click for details
|
||||
</small>
|
||||
}
|
||||
</button>
|
||||
} @else {
|
||||
<span>{{entry[1].title}}</span>
|
||||
}
|
||||
}</span>
|
||||
@if (entry[1].status === 'error' && isErrorExpanded(entry[0])) {
|
||||
<div class="alert alert-danger py-2 px-3 mt-2 mb-0 small" style="border-left: 4px solid var(--bs-danger);">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
@if (entry[1].msg) {
|
||||
<div class="mb-1"><strong>Message:</strong> {{entry[1].msg}}</div>
|
||||
}
|
||||
@if (entry[1].error) {
|
||||
<div class="mb-1"><strong>Error:</strong> {{entry[1].error}}</div>
|
||||
}
|
||||
<div class="text-muted" style="word-break: break-all;"><strong>URL:</strong> {{entry[1].url}}</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger ms-2 flex-shrink-0"
|
||||
(click)="copyErrorMessage(entry[0], entry[1]); $event.stopPropagation()"
|
||||
ngbTooltip="Copy error details to clipboard">
|
||||
@if (lastCopiedErrorId === entry[0]) {
|
||||
<span class="text-success">Copied!</span>
|
||||
} @else {
|
||||
<fa-icon [icon]="faCopy" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{ downloadTypeLabel(entry[1]) }}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{ formatQualityLabel(entry[1]) }}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{ formatCodecLabel(entry[1]) }}
|
||||
</td>
|
||||
<td>
|
||||
@if (entry[1].size) {
|
||||
<span>{{ entry[1].size | fileSize }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
@if (entry[1].timestamp) {
|
||||
<span>{{ entry[1].timestamp / 1000000 | date:'yyyy-MM-dd HH:mm' }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
@if (entry[1].status === 'error') {
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Retry download for ' + entry[1].title" (click)="retryDownload(entry[0], entry[1])"><fa-icon [icon]="faRedoAlt" /></button>
|
||||
}
|
||||
@if (entry[1].filename) {
|
||||
<a href="{{buildDownloadLink(entry[1])}}" download class="btn btn-link" [attr.aria-label]="'Download result file for ' + entry[1].title"><fa-icon [icon]="faDownload" /></a>
|
||||
}
|
||||
@if (entry[1].filename && canShareDownloads()) {
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Share result file for ' + entry[1].title" (click)="shareDownload(entry[1])"><fa-icon [icon]="faShareNodes" /></button>
|
||||
}
|
||||
<a href="{{entry[1].url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + entry[1].title"><fa-icon [icon]="faExternalLinkAlt" /></a>
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Delete completed item ' + entry[1].title" (click)="delDownload('done', entry[0])"><fa-icon [icon]="faTrashAlt" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@if (entry[1].chapter_files && entry[1].chapter_files.length > 0) {
|
||||
@for (chapterFile of entry[1].chapter_files; track chapterFile.filename) {
|
||||
<tr [class.disabled]='entry[1].deleting'>
|
||||
<td></td>
|
||||
<td>
|
||||
<div style="padding-left: 2rem;">
|
||||
<fa-icon [icon]="faCheckCircle" class="text-success me-2" />
|
||||
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" target="_blank" [attr.aria-label]="'Open chapter file ' + getChapterFileName(chapterFile.filename)">{{
|
||||
getChapterFileName(chapterFile.filename) }}</a>
|
||||
</div>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>
|
||||
@if (chapterFile.size) {
|
||||
<span>{{ chapterFile.size | fileSize }}</span>
|
||||
}
|
||||
</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" download [attr.aria-label]="'Download chapter file ' + getChapterFileName(chapterFile.filename)"
|
||||
class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="metube-section-header">
|
||||
<button type="button" class="metube-section-toggle" (click)="toggleSubscriptionsCollapsed()" [attr.aria-expanded]="!subscriptionsCollapsed">
|
||||
<span>Subscriptions</span>
|
||||
<fa-icon [icon]="subscriptionsCollapsed ? faChevronRight : faChevronDown" class="metube-section-chevron" />
|
||||
</button>
|
||||
</div>
|
||||
@if (!subscriptionsCollapsed) {
|
||||
<div class="px-2 py-3 border-bottom">
|
||||
@if (checkingAllSubscriptions) {
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check all now
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
|
||||
(click)="checkAllSubscriptions()"
|
||||
[disabled]="downloads.loading || cachedSubs.length === 0 || checkingSelectedSubscriptions">
|
||||
<fa-icon [icon]="faRedoAlt" /> Check all now
|
||||
</button>
|
||||
}
|
||||
@if (checkingSelectedSubscriptions) {
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check selected
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
|
||||
(click)="checkSelectedSubscriptions()"
|
||||
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0 || checkingAllSubscriptions">
|
||||
<fa-icon [icon]="faRedoAlt" /> Check selected
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
|
||||
(click)="deleteSelectedSubscriptions()"
|
||||
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0">
|
||||
<fa-icon [icon]="faTrashAlt" /> Delete selected
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 1rem;">
|
||||
<input type="checkbox" class="form-check-input"
|
||||
[checked]="allSubsSelected()"
|
||||
(change)="toggleSubMaster($event)"
|
||||
[disabled]="downloads.loading || cachedSubs.length === 0"
|
||||
aria-label="Select all subscriptions" />
|
||||
</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">URL</th>
|
||||
<th scope="col" class="text-nowrap"><span class="help-title" ngbPopover="Subscriptions only — which new video titles to queue when this feed is checked. Does not affect manual downloads." triggers="click" autoClose="outside" container="body">Filter</span></th>
|
||||
<th scope="col" class="text-nowrap">Interval (min)</th>
|
||||
<th scope="col" class="text-nowrap">Last checked</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col" style="width: 8rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of cachedSubs; track entry[0]) {
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input"
|
||||
[checked]="isSubSelected(entry[0])"
|
||||
(change)="toggleSubSelected(entry[0])"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Select subscription ' + entry[1].name" />
|
||||
</td>
|
||||
<td>
|
||||
@if (editingNameId === entry[0]) {
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm flex-grow-1"
|
||||
[name]="'subName' + entry[0]"
|
||||
[(ngModel)]="nameEditDraft"
|
||||
[maxlength]="subscriptionNameMaxLength"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Subscription name for ' + entry[1].name" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
(click)="saveName(entry[0])"
|
||||
[disabled]="downloads.loading">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
(click)="cancelEditName()"
|
||||
[disabled]="downloads.loading">Cancel</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<span class="text-break">{{ entry[1].name }}</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0"
|
||||
(click)="beginEditName(entry[0], entry[1].name)"
|
||||
[disabled]="downloads.loading"
|
||||
ngbTooltip="Rename this subscription (display name only; does not affect the download folder)">Edit</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td class="text-break"><a [href]="entry[1].url" target="_blank" rel="noopener">{{ entry[1].url }}</a></td>
|
||||
<td>
|
||||
@if (editingTitleRegexId === entry[0]) {
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm flex-grow-1"
|
||||
[name]="'subTitleRegex' + entry[0]"
|
||||
[(ngModel)]="titleRegexEditDraft"
|
||||
[disabled]="downloads.loading" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
(click)="saveTitleRegex(entry[0])"
|
||||
[disabled]="downloads.loading">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
(click)="cancelEditTitleRegex()"
|
||||
[disabled]="downloads.loading">Cancel</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<span class="text-muted small text-break"
|
||||
[class.text-secondary]="!entry[1].title_regex">{{ entry[1].title_regex || '—' }}</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0"
|
||||
(click)="beginEditTitleRegex(entry[0], entry[1].title_regex)"
|
||||
[disabled]="downloads.loading"
|
||||
ngbTooltip="Edit subscription title filter (subscriptions only; not for one-off downloads)">Edit</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>{{ entry[1].check_interval_minutes }}</td>
|
||||
<td class="text-nowrap">
|
||||
@if (entry[1].last_checked !== null) {
|
||||
<span>{{ entry[1].last_checked! * 1000 | date:'yyyy-MM-dd HH:mm:ss' }}</span>
|
||||
} @else {
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (entry[1].error) {
|
||||
<span class="text-danger small">{{ entry[1].error }}</span>
|
||||
} @else if (entry[1].enabled) {
|
||||
<span class="text-success">Active</span>
|
||||
} @else {
|
||||
<span class="text-secondary">Paused</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
@if (isSubscriptionChecking(entry[0])) {
|
||||
<button type="button" class="btn btn-link btn-sm p-0 me-2"
|
||||
disabled
|
||||
[attr.aria-label]="'Checking ' + entry[1].name"
|
||||
ngbTooltip="Checking now">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-link btn-sm p-0 me-2"
|
||||
(click)="checkSubscriptionNow(entry[0])"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Check now ' + entry[1].name"
|
||||
ngbTooltip="Check now">
|
||||
<fa-icon [icon]="faRedoAlt" />
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn btn-link btn-sm p-0 me-2"
|
||||
(click)="toggleSubscriptionEnabled(entry[1])"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="(entry[1].enabled ? 'Pause ' : 'Resume ') + entry[1].name"
|
||||
[ngbTooltip]="entry[1].enabled ? 'Pause' : 'Resume'">
|
||||
@if (entry[1].enabled) {
|
||||
<fa-icon [icon]="faPause" />
|
||||
} @else {
|
||||
<fa-icon [icon]="faPlay" />
|
||||
}
|
||||
</button>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-danger"
|
||||
(click)="deleteSubscription(entry[0])"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Delete subscription ' + entry[1].name">
|
||||
<fa-icon [icon]="faTrashAlt" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</main><!-- /.container -->
|
||||
|
||||
<footer class="footer navbar-dark bg-dark py-3 mt-5">
|
||||
|
||||
@@ -10,6 +10,28 @@
|
||||
padding: 0.5rem 0
|
||||
margin-top: 3.5rem
|
||||
|
||||
.metube-section-toggle
|
||||
// Positioned so it paints above the header's full-bleed :before overlay.
|
||||
position: relative
|
||||
display: flex
|
||||
align-items: center
|
||||
// Title left, chevron against the right edge, so all three section titles
|
||||
// stay on the same left margin whether or not a chevron is present.
|
||||
justify-content: space-between
|
||||
gap: 0.75rem
|
||||
width: 100%
|
||||
padding: 0
|
||||
border: 0
|
||||
background: none
|
||||
color: inherit
|
||||
font: inherit
|
||||
text-align: left
|
||||
|
||||
.metube-section-chevron
|
||||
font-size: 1.1rem
|
||||
width: 1.1rem
|
||||
color: var(--bs-secondary-color)
|
||||
|
||||
.metube-section-header:before
|
||||
content: ""
|
||||
position: absolute
|
||||
|
||||
@@ -168,6 +168,31 @@ describe('App', () => {
|
||||
expect(fixture.componentInstance.folder).toBe('music');
|
||||
});
|
||||
|
||||
it('collapses each section independently and remembers it (#1070)', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
const app = fixture.componentInstance;
|
||||
const cookies = TestBed.inject(CookieService);
|
||||
|
||||
expect(app.downloadingCollapsed).toBe(false);
|
||||
expect(app.completedCollapsed).toBe(false);
|
||||
expect(app.subscriptionsCollapsed).toBe(false);
|
||||
|
||||
app.toggleCompletedCollapsed();
|
||||
|
||||
expect(app.completedCollapsed).toBe(true);
|
||||
expect(app.downloadingCollapsed).toBe(false);
|
||||
expect(app.subscriptionsCollapsed).toBe(false);
|
||||
expect(cookies.get('metube_completed_collapsed')).toBe('true');
|
||||
|
||||
// A fresh component picks the state back up from the cookie.
|
||||
const restored = TestBed.createComponent(App);
|
||||
restored.detectChanges();
|
||||
expect(restored.componentInstance.completedCollapsed).toBe(true);
|
||||
expect(restored.componentInstance.downloadingCollapsed).toBe(false);
|
||||
expect(restored.componentInstance.subscriptionsCollapsed).toBe(false);
|
||||
});
|
||||
|
||||
it('asIsOrder returns a stable comparator value (insertion order preserved)', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
@@ -473,4 +498,50 @@ describe('App', () => {
|
||||
expect(app.buildChapterDownloadLink(audio, 'ch1.mp3')).toBe('audio_download/ch1.mp3');
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #424: ffmpeg work after the bytes land (merge, re-encode, split) used
|
||||
// to leave the row on a full, frozen bar with the item counted as neither
|
||||
// active nor queued.
|
||||
describe('post-processing is visible (#424)', () => {
|
||||
const queueEntry = (status: string): Download => ({
|
||||
id: 'vid1',
|
||||
title: 'Test',
|
||||
url: 'https://example.com/v',
|
||||
download_type: 'video',
|
||||
quality: 'best',
|
||||
format: 'any',
|
||||
folder: '',
|
||||
custom_name_prefix: '',
|
||||
playlist_item_limit: 0,
|
||||
status,
|
||||
msg: '',
|
||||
percent: 100,
|
||||
speed: 0,
|
||||
eta: 0,
|
||||
filename: '',
|
||||
checked: false,
|
||||
} as Download);
|
||||
|
||||
it('runs the bar indeterminate while preparing or post-processing', () => {
|
||||
const app = TestBed.createComponent(App).componentInstance;
|
||||
expect(app.isIndeterminate(queueEntry('preparing'))).toBe(true);
|
||||
expect(app.isIndeterminate(queueEntry('postprocessing'))).toBe(true);
|
||||
expect(app.isIndeterminate(queueEntry('downloading'))).toBe(false);
|
||||
expect(app.isIndeterminate(queueEntry('pending'))).toBe(false);
|
||||
});
|
||||
|
||||
it('labels the bar and counts the item as active', () => {
|
||||
// The component subscribes to queueChanged on construction, so the entry
|
||||
// has to be announced after it exists or updateMetrics never runs.
|
||||
const fixture = TestBed.createComponent(App);
|
||||
downloads.queue.set('https://example.com/v', queueEntry('postprocessing'));
|
||||
downloads.queueChanged.next();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Post-processing');
|
||||
expect(fixture.componentInstance.activeDownloads).toBe(1);
|
||||
expect(fixture.componentInstance.queuedDownloads).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
+30
-1
@@ -136,6 +136,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
metubeVersion: string | null = null;
|
||||
isAdvancedOpen = false;
|
||||
sortAscending = false;
|
||||
downloadingCollapsed = false;
|
||||
completedCollapsed = false;
|
||||
subscriptionsCollapsed = false;
|
||||
expandedErrors: Set<string> = new Set<string>();
|
||||
cachedSortedDone: [string, Download][] = [];
|
||||
// The done ids in rendered order, so a shift-click range follows the sort
|
||||
@@ -289,6 +292,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.previousDownloadType = this.downloadType;
|
||||
this.saveSelection(this.downloadType);
|
||||
this.sortAscending = this.cookieService.get('metube_sort_ascending') === 'true';
|
||||
this.downloadingCollapsed = this.cookieService.get('metube_downloading_collapsed') === 'true';
|
||||
this.completedCollapsed = this.cookieService.get('metube_completed_collapsed') === 'true';
|
||||
this.subscriptionsCollapsed = this.cookieService.get('metube_subscriptions_collapsed') === 'true';
|
||||
|
||||
const ci = parseInt(this.cookieService.get('metube_check_interval') || '', 10);
|
||||
if (!Number.isNaN(ci) && ci >= 1) {
|
||||
@@ -1184,6 +1190,14 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.downloads.startById([id]).subscribe((res) => this.handleActionResult(res, 'Start download failed'));
|
||||
}
|
||||
|
||||
// 'preparing' (yt-dlp starting up) and 'postprocessing' (ffmpeg merging,
|
||||
// re-encoding or splitting once the bytes have landed) both have real work in
|
||||
// flight with no percentage to report, so the bar runs animated at full width
|
||||
// instead of showing a number that cannot move.
|
||||
isIndeterminate(download: Download): boolean {
|
||||
return download.status === 'preparing' || download.status === 'postprocessing';
|
||||
}
|
||||
|
||||
liveCountdownSeconds(download: Download): number | null {
|
||||
const ts = download.live_release_timestamp;
|
||||
if (ts == null || download.status !== 'scheduled') {
|
||||
@@ -1561,6 +1575,21 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.rebuildSortedDone();
|
||||
}
|
||||
|
||||
toggleDownloadingCollapsed() {
|
||||
this.downloadingCollapsed = !this.downloadingCollapsed;
|
||||
this.cookieService.set('metube_downloading_collapsed', this.downloadingCollapsed ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
|
||||
}
|
||||
|
||||
toggleCompletedCollapsed() {
|
||||
this.completedCollapsed = !this.completedCollapsed;
|
||||
this.cookieService.set('metube_completed_collapsed', this.completedCollapsed ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
|
||||
}
|
||||
|
||||
toggleSubscriptionsCollapsed() {
|
||||
this.subscriptionsCollapsed = !this.subscriptionsCollapsed;
|
||||
this.cookieService.set('metube_subscriptions_collapsed', this.subscriptionsCollapsed ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
|
||||
}
|
||||
|
||||
private rebuildSortedDone() {
|
||||
const result: [string, Download][] = [];
|
||||
this.downloads.done.forEach((dl, key) => {
|
||||
@@ -1697,7 +1726,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
if (download.status === 'downloading') {
|
||||
active++;
|
||||
speed += download.speed || 0;
|
||||
} else if (download.status === 'preparing') {
|
||||
} else if (download.status === 'preparing' || download.status === 'postprocessing') {
|
||||
active++;
|
||||
} else if (download.status === 'pending' || download.status === 'scheduled') {
|
||||
queued++;
|
||||
|
||||
Reference in New Issue
Block a user