Add auto download and pause resume controls
This commit is contained in:
parent
e9243f65de
commit
6e161f4b4b
12 changed files with 351 additions and 15 deletions
File diff suppressed because one or more lines are too long
15
.codex/hooks.json
Normal file
15
.codex/hooks.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node .claude/setup.mjs"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
2
.github/workflows/sync-upstream.yml
vendored
2
.github/workflows/sync-upstream.yml
vendored
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
upstream_sync_repo: alexta69/metube
|
||||
upstream_sync_branch: master
|
||||
target_sync_branch: master
|
||||
target_repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
target_repo_token: ${{ secrets.UPSTREAM_TOKEN }}
|
||||
test_mode: false
|
||||
|
||||
- name: Sync check
|
||||
|
|
|
|||
11
app/main.py
11
app/main.py
|
|
@ -988,6 +988,16 @@ async def start(request):
|
|||
status = await dqueue.start_pending(ids)
|
||||
return web.Response(text=serializer.encode(status))
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'pause')
|
||||
async def pause(request):
|
||||
post = await _read_json_request(request)
|
||||
ids = post.get('ids')
|
||||
if not ids or not isinstance(ids, list):
|
||||
raise web.HTTPBadRequest(reason='missing ids list')
|
||||
log.info(f"Received request to pause downloads for ids: {ids}")
|
||||
status = await dqueue.pause(ids)
|
||||
return web.Response(text=serializer.encode(status))
|
||||
|
||||
|
||||
COOKIES_PATH = os.path.join(config.STATE_DIR, 'cookies.txt')
|
||||
|
||||
|
|
@ -1229,6 +1239,7 @@ app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/delete', add_
|
|||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/check', add_cors)
|
||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'upload-cookies', add_cors)
|
||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'delete-cookies', add_cors)
|
||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'pause', add_cors)
|
||||
|
||||
async def on_prepare(request, response):
|
||||
origin = request.headers.get('Origin')
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ def mock_dqueue(monkeypatch):
|
|||
d.add = AsyncMock(return_value={"status": "ok"})
|
||||
d.cancel = AsyncMock(return_value={"status": "ok"})
|
||||
d.start_pending = AsyncMock(return_value={"status": "ok"})
|
||||
d.pause = AsyncMock(return_value={"status": "ok"})
|
||||
d.cancel_add = MagicMock()
|
||||
d.queue = MagicMock()
|
||||
d.done = MagicMock()
|
||||
|
|
@ -212,6 +213,21 @@ async def test_start_pending(mock_dqueue):
|
|||
mock_dqueue.start_pending.assert_awaited_once_with(["a"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_download(mock_dqueue):
|
||||
req = _json_request({"ids": ["a"]})
|
||||
resp = await main.pause(req)
|
||||
assert resp.status == 200
|
||||
mock_dqueue.pause.assert_awaited_once_with(["a"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_missing_ids(mock_dqueue):
|
||||
req = _json_request({})
|
||||
with pytest.raises(web.HTTPBadRequest):
|
||||
await main.pause(req)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_shape(mock_dqueue):
|
||||
mock_dqueue.queue.saved_items.return_value = []
|
||||
|
|
|
|||
|
|
@ -190,6 +190,92 @@ async def test_start_pending_moves_to_queue(dq_env):
|
|||
assert not dq.pending.exists(url)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_keeps_download_in_queue(dq_env):
|
||||
notifier = AsyncMock()
|
||||
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid-pause",
|
||||
"title": "Pause Test",
|
||||
"url": url,
|
||||
"webpage_url": url,
|
||||
}
|
||||
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
url = "https://example.com/pause"
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
||||
patch.object(DownloadQueue, "_DownloadQueue__start_download", AsyncMock()):
|
||||
await dq.add(url, "video", "auto", "any", "best", "", "", 0, auto_start=True)
|
||||
|
||||
result = await dq.pause([url])
|
||||
assert result["status"] == "ok"
|
||||
assert dq.queue.exists(url)
|
||||
download = dq.queue.get(url)
|
||||
assert download.paused is True
|
||||
assert download.info.status == "paused"
|
||||
notifier.updated.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_paused_download_reschedules_it(dq_env):
|
||||
notifier = AsyncMock()
|
||||
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid-resume",
|
||||
"title": "Resume Test",
|
||||
"url": url,
|
||||
"webpage_url": url,
|
||||
}
|
||||
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
url = "https://example.com/resume"
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
||||
patch.object(DownloadQueue, "_DownloadQueue__start_download", AsyncMock()):
|
||||
await dq.add(url, "video", "auto", "any", "best", "", "", 0, auto_start=True)
|
||||
|
||||
await dq.pause([url])
|
||||
start_mock = AsyncMock()
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
||||
result = await dq.start_pending([url])
|
||||
|
||||
assert result["status"] == "ok"
|
||||
download = dq.queue.get(url)
|
||||
assert download.paused is False
|
||||
assert download.info.status == "pending"
|
||||
start_mock.assert_called_once_with(download, download.start_generation)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_paused_download_removes_from_queue(dq_env):
|
||||
notifier = AsyncMock()
|
||||
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid-cancel-paused",
|
||||
"title": "Cancel Paused Test",
|
||||
"url": url,
|
||||
"webpage_url": url,
|
||||
}
|
||||
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
url = "https://example.com/cancel-paused"
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
||||
patch.object(DownloadQueue, "_DownloadQueue__start_download", AsyncMock()):
|
||||
await dq.add(url, "video", "auto", "any", "best", "", "", 0, auto_start=True)
|
||||
|
||||
await dq.pause([url])
|
||||
result = await dq.cancel([url])
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert not dq.queue.exists(url)
|
||||
notifier.canceled.assert_awaited_with(url)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_entry_queues_single_video_without_reextracting(dq_env):
|
||||
notifier = AsyncMock()
|
||||
|
|
|
|||
114
app/ytdl.py
114
app/ytdl.py
|
|
@ -206,6 +206,7 @@ class DownloadInfo:
|
|||
self.custom_name_prefix = custom_name_prefix
|
||||
self.msg = self.percent = self.speed = self.eta = None
|
||||
self.status = "pending"
|
||||
self.download_phase = None
|
||||
self.size = None
|
||||
self.timestamp = time.time_ns()
|
||||
self.error = error
|
||||
|
|
@ -292,6 +293,8 @@ class DownloadInfo:
|
|||
self.clip_start = None
|
||||
if not hasattr(self, "clip_end"):
|
||||
self.clip_end = None
|
||||
if not hasattr(self, "download_phase"):
|
||||
self.download_phase = None
|
||||
|
||||
|
||||
_PERSISTED_DOWNLOAD_FIELDS = (
|
||||
|
|
@ -314,6 +317,7 @@ _PERSISTED_DOWNLOAD_FIELDS = (
|
|||
"clip_start",
|
||||
"clip_end",
|
||||
"status",
|
||||
"download_phase",
|
||||
"timestamp",
|
||||
"error",
|
||||
"msg",
|
||||
|
|
@ -368,6 +372,8 @@ def _download_info_from_record(record: dict[str, Any]) -> DownloadInfo:
|
|||
info.eta = None
|
||||
if not hasattr(info, "status"):
|
||||
info.status = "pending"
|
||||
if not hasattr(info, "download_phase"):
|
||||
info.download_phase = None
|
||||
if not hasattr(info, "size"):
|
||||
info.size = None
|
||||
if not hasattr(info, "error"):
|
||||
|
|
@ -470,18 +476,34 @@ class Download:
|
|||
if "impersonate" in self.ytdl_opts:
|
||||
self.ytdl_opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.ytdl_opts["impersonate"])
|
||||
self.canceled = False
|
||||
self.paused = getattr(self.info, 'status', None) == 'paused'
|
||||
self.tmpfilename = None
|
||||
self.status_queue = None
|
||||
self.proc = None
|
||||
self.loop = None
|
||||
self.notifier = None
|
||||
self.start_generation = 0
|
||||
|
||||
def _download_phase_from_status(self, st):
|
||||
info_dict = st.get('info_dict') if isinstance(st, dict) else None
|
||||
if not isinstance(info_dict, dict):
|
||||
return None
|
||||
vcodec = str(info_dict.get('vcodec') or '').lower()
|
||||
acodec = str(info_dict.get('acodec') or '').lower()
|
||||
if vcodec and vcodec != 'none' and (not acodec or acodec == 'none'):
|
||||
return 'video'
|
||||
if acodec and acodec != 'none' and (not vcodec or vcodec == 'none'):
|
||||
return 'audio'
|
||||
if vcodec and vcodec != 'none' and acodec and acodec != 'none':
|
||||
return 'media'
|
||||
return None
|
||||
|
||||
def _download(self):
|
||||
log.info(f"Starting download for: {self.info.title} ({self.info.url})")
|
||||
try:
|
||||
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
|
||||
def put_status(st):
|
||||
self.status_queue.put({k: v for k, v in st.items() if k in (
|
||||
status = {k: v for k, v in st.items() if k in (
|
||||
'tmpfilename',
|
||||
'filename',
|
||||
'status',
|
||||
|
|
@ -491,9 +513,19 @@ class Download:
|
|||
'downloaded_bytes',
|
||||
'speed',
|
||||
'eta',
|
||||
)})
|
||||
)}
|
||||
phase = self._download_phase_from_status(st)
|
||||
if phase:
|
||||
status['download_phase'] = phase
|
||||
self.status_queue.put(status)
|
||||
|
||||
def put_status_postprocessor(d):
|
||||
if d.get('status') == 'started':
|
||||
self.status_queue.put({
|
||||
'status': 'postprocessing',
|
||||
'download_phase': 'postprocessing',
|
||||
'msg': d.get('postprocessor'),
|
||||
})
|
||||
if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished':
|
||||
filepath = d['info_dict']['filepath']
|
||||
if '__finaldir' in d['info_dict']:
|
||||
|
|
@ -581,6 +613,7 @@ class Download:
|
|||
|
||||
async def start(self, notifier):
|
||||
log.info(f"Preparing download for: {self.info.title}")
|
||||
self.paused = False
|
||||
if Download.manager is None:
|
||||
Download.manager = multiprocessing.Manager()
|
||||
self.status_queue = Download.manager.Queue()
|
||||
|
|
@ -610,6 +643,21 @@ class Download:
|
|||
if self.status_queue is not None:
|
||||
self.status_queue.put(None)
|
||||
|
||||
def pause(self):
|
||||
log.info(f"Pausing download: {self.info.title}")
|
||||
self.paused = True
|
||||
self.start_generation += 1
|
||||
self.info.status = 'paused'
|
||||
self.info.speed = None
|
||||
self.info.eta = None
|
||||
if self.running():
|
||||
try:
|
||||
self.proc.kill()
|
||||
except Exception as e:
|
||||
log.error(f"Error killing process for {self.info.title}: {e}")
|
||||
if self.status_queue is not None:
|
||||
self.status_queue.put(None)
|
||||
|
||||
def close(self):
|
||||
log.info(f"Closing download process for: {self.info.title}")
|
||||
if self.started():
|
||||
|
|
@ -630,6 +678,12 @@ class Download:
|
|||
if status is None:
|
||||
log.info(f"Status update finished for: {self.info.title}")
|
||||
return
|
||||
if self.paused:
|
||||
self.info.status = 'paused'
|
||||
self.info.speed = None
|
||||
self.info.eta = None
|
||||
await self.notifier.updated(self.info)
|
||||
return
|
||||
if self.canceled:
|
||||
log.info(f"Download {self.info.title} is canceled; stopping status updates.")
|
||||
return
|
||||
|
|
@ -704,6 +758,8 @@ class Download:
|
|||
|
||||
self.info.status = status['status']
|
||||
self.info.msg = status.get('msg')
|
||||
if 'download_phase' in status:
|
||||
self.info.download_phase = status.get('download_phase')
|
||||
if 'downloaded_bytes' in status:
|
||||
total = status.get('total_bytes') or status.get('total_bytes_estimate')
|
||||
if total:
|
||||
|
|
@ -892,18 +948,28 @@ class DownloadQueue:
|
|||
asyncio.create_task(self.__import_queue())
|
||||
asyncio.create_task(self.__import_pending())
|
||||
|
||||
async def __start_download(self, download):
|
||||
if download.canceled:
|
||||
log.info(f"Download {download.info.title} was canceled, skipping start.")
|
||||
async def __start_download(self, download, generation):
|
||||
if generation != download.start_generation or download.canceled or download.paused:
|
||||
log.info(f"Download {download.info.title} was canceled or paused, skipping start.")
|
||||
return
|
||||
async with self.semaphore:
|
||||
if download.canceled:
|
||||
log.info(f"Download {download.info.title} was canceled, skipping start.")
|
||||
if generation != download.start_generation or download.canceled or download.paused:
|
||||
log.info(f"Download {download.info.title} was canceled or paused, skipping start.")
|
||||
return
|
||||
await download.start(self.notifier)
|
||||
self._post_download_cleanup(download)
|
||||
|
||||
def _post_download_cleanup(self, download):
|
||||
key = getattr(download.info, 'key', download.info.url)
|
||||
if download.paused:
|
||||
download.info.status = 'paused'
|
||||
download.info.speed = None
|
||||
download.info.eta = None
|
||||
download.close()
|
||||
if self.queue.exists(key):
|
||||
self.queue.put(download)
|
||||
asyncio.create_task(self.notifier.updated(download.info))
|
||||
return
|
||||
if download.info.status != 'finished':
|
||||
if download.tmpfilename and os.path.isfile(download.tmpfilename):
|
||||
try:
|
||||
|
|
@ -912,7 +978,6 @@ class DownloadQueue:
|
|||
pass
|
||||
download.info.status = 'error'
|
||||
download.close()
|
||||
key = getattr(download.info, 'key', download.info.url)
|
||||
if self.queue.exists(key):
|
||||
self.queue.delete(key)
|
||||
if download.canceled:
|
||||
|
|
@ -1028,7 +1093,8 @@ class DownloadQueue:
|
|||
download = Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, dl.quality, dl.format, ytdl_options, dl)
|
||||
if auto_start is True:
|
||||
self.queue.put(download)
|
||||
asyncio.create_task(self.__start_download(download))
|
||||
download.start_generation += 1
|
||||
asyncio.create_task(self.__start_download(download, download.start_generation))
|
||||
else:
|
||||
self.pending.put(download)
|
||||
await self.notifier.added(dl)
|
||||
|
|
@ -1297,13 +1363,25 @@ class DownloadQueue:
|
|||
|
||||
async def start_pending(self, ids):
|
||||
for id in ids:
|
||||
if self.queue.exists(id):
|
||||
dl = self.queue.get(id)
|
||||
if getattr(dl.info, 'status', None) == 'paused' or dl.paused:
|
||||
dl.paused = False
|
||||
dl.info.status = 'pending'
|
||||
dl.info.speed = None
|
||||
dl.info.eta = None
|
||||
self.queue.put(dl)
|
||||
dl.start_generation += 1
|
||||
asyncio.create_task(self.__start_download(dl, dl.start_generation))
|
||||
continue
|
||||
if not self.pending.exists(id):
|
||||
log.warning(f'requested start for non-existent download {id}')
|
||||
continue
|
||||
dl = self.pending.get(id)
|
||||
self.queue.put(dl)
|
||||
self.pending.delete(id)
|
||||
asyncio.create_task(self.__start_download(dl))
|
||||
dl.start_generation += 1
|
||||
asyncio.create_task(self.__start_download(dl, dl.start_generation))
|
||||
return {'status': 'ok'}
|
||||
|
||||
async def cancel(self, ids):
|
||||
|
|
@ -1318,6 +1396,11 @@ class DownloadQueue:
|
|||
log.warning(f'requested cancel for non-existent download {id}')
|
||||
continue
|
||||
dl = self.queue.get(id)
|
||||
if getattr(dl.info, 'status', None) == 'paused' or dl.paused:
|
||||
dl.cancel()
|
||||
self.queue.delete(id)
|
||||
await self.notifier.canceled(id)
|
||||
continue
|
||||
if dl.started():
|
||||
dl.cancel()
|
||||
else:
|
||||
|
|
@ -1326,6 +1409,17 @@ class DownloadQueue:
|
|||
await self.notifier.canceled(id)
|
||||
return {'status': 'ok'}
|
||||
|
||||
async def pause(self, ids):
|
||||
for id in ids:
|
||||
if not self.queue.exists(id):
|
||||
log.warning(f'requested pause for non-existent download {id}')
|
||||
continue
|
||||
dl = self.queue.get(id)
|
||||
dl.pause()
|
||||
self.queue.put(dl)
|
||||
await self.notifier.updated(dl.info)
|
||||
return {'status': 'ok'}
|
||||
|
||||
async def clear(self, ids):
|
||||
for id in ids:
|
||||
if not self.done.exists(id):
|
||||
|
|
|
|||
|
|
@ -480,6 +480,20 @@
|
|||
ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Auto Download</span>
|
||||
<select class="form-select"
|
||||
name="autoDownloadCompleted"
|
||||
[(ngModel)]="autoDownloadCompleted"
|
||||
(change)="autoDownloadCompletedChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Automatically save completed files through the browser">
|
||||
<option [ngValue]="true">Yes</option>
|
||||
<option [ngValue]="false">No</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Subscription Check (min)</span>
|
||||
|
|
@ -688,6 +702,7 @@
|
|||
<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>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queuePauseSelected (click)="pauseSelectedDownloads()"><fa-icon [icon]="faPause" /> Pause selected</button>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<table class="table">
|
||||
|
|
@ -710,7 +725,12 @@
|
|||
</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>
|
||||
<div>
|
||||
{{ download.value.title }}
|
||||
@if (downloadPhaseLabel(download.value)) {
|
||||
<span class="badge text-bg-secondary ms-2">{{ downloadPhaseLabel(download.value) }}</span>
|
||||
}
|
||||
</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>
|
||||
|
|
@ -722,6 +742,12 @@
|
|||
@if (download.value.status === 'pending') {
|
||||
<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>
|
||||
}
|
||||
@if (download.value.status === 'paused') {
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Resume download for ' + download.value.title" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faPlay" /></button>
|
||||
}
|
||||
@if (download.value.status === 'downloading' || download.value.status === 'preparing') {
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Pause download for ' + download.value.title" (click)="pauseDownloadByKey(download.key)"><fa-icon [icon]="faPause" /></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>
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
folder!: string;
|
||||
customNamePrefix!: string;
|
||||
autoStart: boolean;
|
||||
autoDownloadCompleted: boolean;
|
||||
playlistItemLimit!: number;
|
||||
splitByChapters: boolean;
|
||||
chapterTemplate: string;
|
||||
|
|
@ -128,6 +129,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
cachedSortedDone: [string, Download][] = [];
|
||||
lastCopiedErrorId: string | null = null;
|
||||
private previousDownloadType = 'video';
|
||||
private autoDownloadedResults = new Set<string>();
|
||||
private addRequestSub?: Subscription;
|
||||
private selectionsByType: Record<string, {
|
||||
codec: string;
|
||||
|
|
@ -158,6 +160,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
readonly queueMasterCheckbox = viewChild<SelectAllCheckboxComponent>('queueMasterCheckboxRef');
|
||||
readonly queueDelSelected = viewChild.required<ElementRef>('queueDelSelected');
|
||||
readonly queueDownloadSelected = viewChild.required<ElementRef>('queueDownloadSelected');
|
||||
readonly queuePauseSelected = viewChild.required<ElementRef>('queuePauseSelected');
|
||||
readonly doneMasterCheckbox = viewChild<SelectAllCheckboxComponent>('doneMasterCheckboxRef');
|
||||
readonly doneDelSelected = viewChild.required<ElementRef>('doneDelSelected');
|
||||
readonly doneDownloadSelected = viewChild.required<ElementRef>('doneDownloadSelected');
|
||||
|
|
@ -242,6 +245,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
this.format = this.cookieService.get('metube_format') || 'any';
|
||||
this.quality = this.cookieService.get('metube_quality') || 'best';
|
||||
this.autoStart = this.cookieService.get('metube_auto_start') !== 'false';
|
||||
this.autoDownloadCompleted = this.cookieService.get('metube_auto_download_completed') !== 'false';
|
||||
this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true';
|
||||
// Will be set from backend configuration, use empty string as placeholder
|
||||
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '';
|
||||
|
|
@ -293,6 +297,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
this.updateMetrics();
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
this.downloads.completedDownload.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((download) => {
|
||||
this.autoDownloadResult(download);
|
||||
});
|
||||
|
||||
this.subscriptionsSvc.subscriptionsChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||
this.rebuildCachedSubs();
|
||||
|
|
@ -885,6 +892,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
queueSelectionChanged(checked: number) {
|
||||
this.queueDelSelected().nativeElement.disabled = checked === 0;
|
||||
this.queueDownloadSelected().nativeElement.disabled = checked === 0;
|
||||
this.queuePauseSelected().nativeElement.disabled = checked === 0;
|
||||
}
|
||||
|
||||
doneSelectionChanged(checked: number) {
|
||||
|
|
@ -1128,6 +1136,16 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
this.downloads.startById([id]).subscribe();
|
||||
}
|
||||
|
||||
pauseDownloadByKey(id: string) {
|
||||
this.downloads.pauseById([id]).subscribe();
|
||||
}
|
||||
|
||||
autoDownloadCompletedChanged() {
|
||||
this.cookieService.set('metube_auto_download_completed', String(this.autoDownloadCompleted), {
|
||||
expires: this.settingsCookieExpiryDays,
|
||||
});
|
||||
}
|
||||
|
||||
retryDownload(key: string, download: Download) {
|
||||
this.addDownload({
|
||||
url: download.url,
|
||||
|
|
@ -1161,6 +1179,18 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
this.downloads.startByFilter(where, dl => !!dl.checked).subscribe();
|
||||
}
|
||||
|
||||
pauseSelectedDownloads() {
|
||||
const ids: string[] = [];
|
||||
this.downloads.queue.forEach((dl: Download, key: string) => {
|
||||
if (dl.checked && (dl.status === 'downloading' || dl.status === 'preparing')) {
|
||||
ids.push(key);
|
||||
}
|
||||
});
|
||||
if (ids.length) {
|
||||
this.downloads.pauseById(ids).subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
delSelectedDownloads(where: State) {
|
||||
this.downloads.delByFilter(where, dl => !!dl.checked).subscribe();
|
||||
}
|
||||
|
|
@ -1196,6 +1226,28 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
private autoDownloadResult(download: Download) {
|
||||
if (!this.autoDownloadCompleted || download.status !== 'finished' || !download.filename) {
|
||||
return;
|
||||
}
|
||||
const key = `${download.url}|${download.filename}|${download.timestamp ?? ''}`;
|
||||
if (this.autoDownloadedResults.has(key)) {
|
||||
return;
|
||||
}
|
||||
this.autoDownloadedResults.add(key);
|
||||
this.triggerBrowserDownload(download);
|
||||
}
|
||||
|
||||
private triggerBrowserDownload(download: Download) {
|
||||
const link = document.createElement('a');
|
||||
link.href = this.buildDownloadLink(download);
|
||||
link.setAttribute('download', download.filename);
|
||||
link.setAttribute('target', '_self');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
buildDownloadLink(download: Download) {
|
||||
let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"];
|
||||
if (download.download_type === 'audio' || download.filename.endsWith('.mp3')) {
|
||||
|
|
@ -1209,6 +1261,24 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
return baseDir + encodeURIComponent(download.filename);
|
||||
}
|
||||
|
||||
downloadPhaseLabel(download: Download): string {
|
||||
switch (download.download_phase) {
|
||||
case 'video':
|
||||
return 'Video';
|
||||
case 'audio':
|
||||
return 'Audio';
|
||||
case 'media':
|
||||
return 'Media';
|
||||
case 'postprocessing':
|
||||
return 'Post-processing';
|
||||
default:
|
||||
if (download.status === 'paused') return 'Paused';
|
||||
if (download.status === 'pending') return 'Pending';
|
||||
if (download.status === 'preparing') return 'Preparing';
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
buildResultItemTooltip(download: Download) {
|
||||
const parts = [];
|
||||
if (download.msg) {
|
||||
|
|
@ -1581,7 +1651,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
speed += download.speed || 0;
|
||||
} else if (download.status === 'preparing') {
|
||||
active++;
|
||||
} else if (download.status === 'pending') {
|
||||
} else if (download.status === 'pending' || download.status === 'paused') {
|
||||
queued++;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export interface Download {
|
|||
clip_start?: number;
|
||||
clip_end?: number;
|
||||
status: string;
|
||||
download_phase?: string;
|
||||
msg: string;
|
||||
percent: number;
|
||||
speed: number;
|
||||
|
|
|
|||
|
|
@ -153,6 +153,13 @@ describe('DownloadsService', () => {
|
|||
req.flush({});
|
||||
});
|
||||
|
||||
it('pauseById posts ids', () => {
|
||||
service.pauseById(['a', 'b']).subscribe();
|
||||
const req = httpMock.expectOne('pause');
|
||||
expect(req.request.body).toEqual({ ids: ['a', 'b'] });
|
||||
req.flush({});
|
||||
});
|
||||
|
||||
it('delById marks items deleting and posts delete', () => {
|
||||
const dl = makeDownload({ deleting: false });
|
||||
service.queue.set('u1', dl);
|
||||
|
|
@ -271,7 +278,7 @@ describe('DownloadsService', () => {
|
|||
expect(updated?.deleting).toBe(true);
|
||||
});
|
||||
|
||||
it('socket completed moves entry to done', () => {
|
||||
it('socket completed moves entry to done and emits completedDownload', () => {
|
||||
service.queue.set('u1', {
|
||||
id: '1',
|
||||
title: 't',
|
||||
|
|
@ -290,9 +297,14 @@ describe('DownloadsService', () => {
|
|||
filename: '',
|
||||
checked: false,
|
||||
});
|
||||
let completed: Download | undefined;
|
||||
service.completedDownload.subscribe((download) => {
|
||||
completed = download;
|
||||
});
|
||||
socket.emit('completed', JSON.stringify({ url: 'u1', title: 't', status: 'finished' }));
|
||||
expect(service.queue.has('u1')).toBe(false);
|
||||
expect(service.done.has('u1')).toBe(true);
|
||||
expect(completed?.url).toBe('u1');
|
||||
});
|
||||
|
||||
it('socket canceled removes from queue', () => {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export class DownloadsService {
|
|||
ytdlOptionsChanged = new Subject<Record<string, unknown>>();
|
||||
configurationChanged = new Subject<Record<string, unknown>>();
|
||||
updated = new Subject<void>();
|
||||
completedDownload = new Subject<Download>();
|
||||
|
||||
configuration: Record<string, unknown> = {};
|
||||
customDirs: Record<string, string[]> = {};
|
||||
|
|
@ -90,6 +91,7 @@ export class DownloadsService {
|
|||
this.done.set(key, data);
|
||||
this.queueChanged.next();
|
||||
this.doneChanged.next();
|
||||
this.completedDownload.next(data);
|
||||
});
|
||||
this.socket.fromEvent('canceled')
|
||||
.pipe(takeUntilDestroyed())
|
||||
|
|
@ -177,6 +179,10 @@ export class DownloadsService {
|
|||
return this.http.post('start', {ids: ids});
|
||||
}
|
||||
|
||||
public pauseById(ids: string[]) {
|
||||
return this.http.post('pause', {ids: ids});
|
||||
}
|
||||
|
||||
public delById(where: State, ids: string[]) {
|
||||
const map = this[where];
|
||||
const touched: [string, Download, boolean | undefined][] = [];
|
||||
|
|
|
|||
Loading…
Reference in a new issue