feat: 支持更改下载文件夹并移动已完成文件

This commit is contained in:
tigerenwork 2026-08-09 14:59:01 +08:00
parent e74559f704
commit d14336ff51
7 changed files with 307 additions and 25 deletions

View File

@ -27,8 +27,8 @@ default, e.g. via QEMU emulation).
```sh ```sh
docker buildx build --platform linux/amd64 \ docker buildx build --platform linux/amd64 \
--build-arg VERSION=1.12 \ --build-arg VERSION=1.13 \
-t 192.168.2.212:3000/tigeren/metube:1.12 \ -t 192.168.2.212:3000/tigeren/metube:1.13 \
--push . --push .
``` ```

View File

@ -352,6 +352,18 @@ async def start(request):
status = await dqueue.start_pending(ids) status = await dqueue.start_pending(ids)
return web.Response(text=serializer.encode(status)) return web.Response(text=serializer.encode(status))
@routes.post(config.URL_PREFIX + 'set_folder')
async def set_folder(request):
post = await request.json()
ids = post.get('ids')
folder = post.get('folder', '')
if not ids:
log.error("Bad request: missing 'ids'")
raise web.HTTPBadRequest()
status = await dqueue.set_folder(ids, folder)
log.info(f"Folder change request processed for ids: {ids}, folder: '{folder}'")
return web.Response(text=serializer.encode(status))
@routes.get(config.URL_PREFIX + 'history') @routes.get(config.URL_PREFIX + 'history')
async def history(request): async def history(request):
history = { 'done': [], 'queue': [], 'pending': []} history = { 'done': [], 'queue': [], 'pending': []}

View File

@ -1,4 +1,5 @@
import os import os
import shutil
import yt_dlp import yt_dlp
from collections import OrderedDict from collections import OrderedDict
from mark_watched import mark_watched from mark_watched import mark_watched
@ -846,6 +847,85 @@ class DownloadQueue:
asyncio.create_task(self.__start_download(dl)) asyncio.create_task(self.__start_download(dl))
return {'status': 'ok'} return {'status': 'ok'}
async def set_folder(self, ids, folder):
folder = folder or ''
for id in ids:
if self.pending.exists(id):
error = await self.__set_pending_folder(id, folder)
if error is not None:
return error
elif self.done.exists(id):
error = await self.__move_done_download(id, folder)
if error is not None:
return error
else:
log.warning(f'requested folder change for non-existent download {id}')
return {'status': 'ok'}
async def __set_pending_folder(self, id, folder):
dl = self.pending.get(id)
dldirectory, error_message = self.__calc_download_path(dl.info.quality, dl.info.format, folder)
if error_message is not None:
return error_message
dl.download_dir = dldirectory
dl.info.folder = folder
# Move the filename reservation to the new directory, resolving
# conflicts the same way the precheck does
old_reserved = getattr(dl, 'reserved_filepath', None)
if old_reserved:
self.reserved_filenames.discard(old_reserved)
new_reserved = os.path.join(dldirectory, os.path.basename(old_reserved))
if os.path.exists(new_reserved) or new_reserved in self.reserved_filenames:
unique_id = ''.join(random.choices(string.ascii_lowercase + string.digits, k=5))
if dl.output_template and '.%(ext)s' in dl.output_template:
dl.output_template = dl.output_template.replace('.%(ext)s', f'_{unique_id}.%(ext)s')
elif dl.output_template:
dl.output_template = f"{dl.output_template}_{unique_id}"
base, ext = os.path.splitext(os.path.basename(old_reserved))
new_reserved = os.path.join(dldirectory, f"{base}_{unique_id}{ext}")
self.reserved_filenames.add(new_reserved)
dl.reserved_filepath = new_reserved
self.pending.put(dl) # persist the updated info
await self.notifier.updated(dl.info)
log.info(f"Changed folder for pending download {id} to '{folder or 'Default'}'")
async def __move_done_download(self, id, folder):
dl = self.done.get(id)
dldirectory, error_message = self.__calc_download_path(dl.info.quality, dl.info.format, folder)
if error_message is not None:
return error_message
filename = getattr(dl.info, 'filename', None)
if filename:
old_dldirectory, _ = self.__calc_download_path(dl.info.quality, dl.info.format, dl.info.folder)
src = os.path.join(old_dldirectory, filename)
dst = os.path.join(dldirectory, filename)
if os.path.exists(src) and os.path.abspath(src) != os.path.abspath(dst):
if os.path.exists(dst):
# Name conflict at the target: append a short unique ID
base, ext = os.path.splitext(filename)
unique_id = ''.join(random.choices(string.ascii_lowercase + string.digits, k=5))
filename = f"{base}_{unique_id}{ext}"
dst = os.path.join(dldirectory, filename)
os.makedirs(os.path.dirname(dst), exist_ok=True)
await asyncio.get_running_loop().run_in_executor(None, shutil.move, src, dst)
dl.info.filename = filename
dl.info.file_exists = True
log.info(f"Moved file for download {id}: {src} -> {dst}")
# Clean up emptied source subdirectories (e.g. playlist dirs)
src_dir = os.path.dirname(src)
if src_dir != old_dldirectory:
try:
os.removedirs(src_dir)
except OSError:
pass
elif not os.path.exists(src):
log.warning(f"File for download {id} not found at {src}, updating folder metadata only")
dl.info.folder = folder
self.done.put(dl) # persist the updated info
await self.notifier.updated(dl.info)
log.info(f"Changed folder for completed download {id} to '{folder or 'Default'}'")
return None
async def cancel(self, ids): async def cancel(self, ids):
for id in ids: for id in ids:
if self.pending.exists(id): if self.pending.exists(id):

View File

@ -316,7 +316,12 @@
<ul class="nav nav-tabs metube-tabs"> <ul class="nav nav-tabs metube-tabs">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" role="button" [class.active]="activeTab === 'queue'" (click)="setActiveTab('queue')"> <a class="nav-link" role="button" [class.active]="activeTab === 'queue'" (click)="setActiveTab('queue')">
Downloading <span class="badge text-bg-secondary">{{ downloads.queue.size }}</span> Downloading <span class="badge text-bg-secondary">{{ tabDownloads('queue').size }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" role="button" [class.active]="activeTab === 'pending'" (click)="setActiveTab('pending')">
Pending <span class="badge text-bg-secondary">{{ tabDownloads('pending').size }}</span>
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
@ -340,7 +345,7 @@
<thead> <thead>
<tr> <tr>
<th scope="col" style="width: 1rem;"> <th scope="col" style="width: 1rem;">
<app-master-checkbox #queueMasterCheckbox [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)"></app-master-checkbox> <app-master-checkbox #queueMasterCheckbox [id]="'queue'" [list]="tabDownloads('queue')" (changed)="queueSelectionChanged($event)"></app-master-checkbox>
</th> </th>
<th scope="col">Video</th> <th scope="col">Video</th>
<th scope="col" class="sortable-header" (click)="toggleSortOrder()" ngbTooltip="Sort by creation time"> <th scope="col" class="sortable-header" (click)="toggleSortOrder()" ngbTooltip="Sort by creation time">
@ -355,8 +360,9 @@
</thead> </thead>
<tbody> <tbody>
<ng-container *ngFor="let row of displayRows('queue'); trackBy: identifyDisplayRow"> <ng-container *ngFor="let row of displayRows('queue'); trackBy: identifyDisplayRow">
<tr *ngIf="row.type === 'header'" class="folder-group-header"> <tr *ngIf="row.type === 'header'" class="folder-group-header" role="button" (click)="toggleFolderCollapse(row.rawFolder)">
<td colspan="7"> <td colspan="7">
<fa-icon [icon]="row.collapsed ? faChevronRight : faChevronDown" class="folder-group-chevron"></fa-icon>&nbsp;
<fa-icon [icon]="faFolderOpen"></fa-icon>&nbsp; {{ row.folder }} <fa-icon [icon]="faFolderOpen"></fa-icon>&nbsp; {{ row.folder }}
<span class="folder-group-count">{{ row.count }}</span> <span class="folder-group-count">{{ row.count }}</span>
</td> </td>
@ -392,6 +398,81 @@
</div> </div>
</div> </div>
<div [class.d-none]="activeTab !== 'pending'">
<div class="px-2 py-3 border-bottom d-flex align-items-center flex-wrap">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #pendingDelSelected (click)="delSelectedDownloads('pending')"><fa-icon [icon]="faTrashAlt"></fa-icon>&nbsp; Cancel selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #pendingDownloadSelected (click)="startSelectedDownloads('pending')"><fa-icon [icon]="faDownload"></fa-icon>&nbsp; Download selected</button>
<div class="form-check form-switch d-flex align-items-center mb-0 ps-0">
<input class="form-check-input ms-0 me-2" type="checkbox" role="switch" id="pendingGroupByFolder" [(ngModel)]="groupByFolder" (change)="groupByFolderChanged()" ngbTooltip="Group downloads by folder">
<label class="form-check-label mb-0" for="pendingGroupByFolder"><fa-icon [icon]="faFolderOpen"></fa-icon>&nbsp; Group by folder</label>
</div>
</div>
<div class="overflow-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<app-master-checkbox #pendingMasterCheckbox [id]="'pending'" [list]="tabDownloads('pending')" (changed)="pendingSelectionChanged($event)"></app-master-checkbox>
</th>
<th scope="col">Video</th>
<th scope="col" class="sortable-header" (click)="toggleSortOrder()" ngbTooltip="Sort by creation time">
Created
<fa-icon [icon]="sortAscending ? faSortUp : faSortDown"></fa-icon>
</th>
<th scope="col">Folder</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>
<ng-container *ngFor="let row of displayRows('pending'); trackBy: identifyDisplayRow">
<tr *ngIf="row.type === 'header'" class="folder-group-header" role="button" (click)="toggleFolderCollapse(row.rawFolder)">
<td colspan="7">
<fa-icon [icon]="row.collapsed ? faChevronRight : faChevronDown" class="folder-group-chevron"></fa-icon>&nbsp;
<fa-icon [icon]="faFolderOpen"></fa-icon>&nbsp; {{ row.folder }}
<span class="folder-group-count">{{ row.count }}</span>
</td>
</tr>
<tr *ngIf="row.type === 'item'" [class.disabled]='row.value.deleting'>
<td>
<app-slave-checkbox [id]="row.key" [master]="pendingMasterCheckbox" [checkable]="row.value"></app-slave-checkbox>
</td>
<td title="{{ row.value.filename }}">
<span class="website-tag" *ngIf="row.value.website" [title]="row.value.website">{{ getSimplifiedDomain(row.value.website) }}</span>
{{ row.value.title }}
</td>
<td>{{ formatTimestamp(row.value.timestamp) }}</td>
<td class="folder-edit-cell">
<ng-select [items]="customDirsForDownload(row.value)"
placeholder="Default"
[addTag]="allowCustomDir.bind(this)"
addTagText="Create directory"
[ngModel]="row.value.folder"
(change)="folderChanged(row.key, $event)"
[clearable]="true"
[searchable]="true"
[closeOnSelect]="true"
appendTo="body"
ngbTooltip="Change download folder">
</ng-select>
</td>
<td>{{ row.value.speed | speed }}</td>
<td>{{ row.value.eta | eta }}</td>
<td>
<div class="d-flex">
<button *ngIf="row.value.status === 'pending'" type="button" class="btn btn-link" (click)="downloadItemByKey(row.key)"><fa-icon [icon]="faDownload"></fa-icon></button>
<button type="button" class="btn btn-link" (click)="delDownload('queue', row.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
<a href="{{row.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
</div>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>
</div>
<div [class.d-none]="activeTab !== 'done'"> <div [class.d-none]="activeTab !== 'done'">
<div class="px-2 py-3 border-bottom d-flex align-items-center flex-wrap"> <div class="px-2 py-3 border-bottom d-flex align-items-center flex-wrap">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt"></fa-icon>&nbsp; Clear selected</button> <button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt"></fa-icon>&nbsp; Clear selected</button>
@ -423,8 +504,9 @@
</thead> </thead>
<tbody> <tbody>
<ng-container *ngFor="let row of displayRows('done'); trackBy: identifyDisplayRow"> <ng-container *ngFor="let row of displayRows('done'); trackBy: identifyDisplayRow">
<tr *ngIf="row.type === 'header'" class="folder-group-header"> <tr *ngIf="row.type === 'header'" class="folder-group-header" role="button" (click)="toggleFolderCollapse(row.rawFolder)">
<td colspan="6"> <td colspan="6">
<fa-icon [icon]="row.collapsed ? faChevronRight : faChevronDown" class="folder-group-chevron"></fa-icon>&nbsp;
<fa-icon [icon]="faFolderOpen"></fa-icon>&nbsp; {{ row.folder }} <fa-icon [icon]="faFolderOpen"></fa-icon>&nbsp; {{ row.folder }}
<span class="folder-group-count">{{ row.count }}</span> <span class="folder-group-count">{{ row.count }}</span>
</td> </td>
@ -453,7 +535,20 @@
</ng-template> </ng-template>
</td> </td>
<td>{{ formatTimestamp(row.value.timestamp) }}</td> <td>{{ formatTimestamp(row.value.timestamp) }}</td>
<td>{{ row.value.folder || 'Default' }}</td> <td class="folder-edit-cell">
<ng-select [items]="customDirsForDownload(row.value)"
placeholder="Default"
[addTag]="allowCustomDir.bind(this)"
addTagText="Create directory"
[ngModel]="row.value.folder"
(change)="folderChanged(row.key, $event)"
[clearable]="true"
[searchable]="true"
[closeOnSelect]="true"
appendTo="body"
ngbTooltip="Move file to another folder">
</ng-select>
</td>
<td> <td>
<span *ngIf="row.value.size">{{ row.value.size | fileSize }}</span> <span *ngIf="row.value.size">{{ row.value.size | fileSize }}</span>
</td> </td>

View File

@ -51,7 +51,13 @@ td
opacity: 0.5 opacity: 0.5
pointer-events: none pointer-events: none
.folder-edit-cell
min-width: 12rem
.folder-group-header .folder-group-header
cursor: pointer
user-select: none
td td
background: var(--bs-secondary-bg) background: var(--bs-secondary-bg)
font-weight: 600 font-weight: 600
@ -59,6 +65,10 @@ td
padding-top: 0.4rem padding-top: 0.4rem
padding-bottom: 0.4rem padding-bottom: 0.4rem
.folder-group-chevron
width: 0.75rem
opacity: 0.7
.folder-group-count .folder-group-count
display: inline-block display: inline-block
margin-left: 0.5rem margin-left: 0.5rem

View File

@ -1,7 +1,7 @@
import { Component, ViewChild, ElementRef, AfterViewInit, OnInit } from '@angular/core'; import { Component, ViewChild, ElementRef, AfterViewInit, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { faTrashAlt, faCheckCircle, faTimesCircle, IconDefinition } from '@fortawesome/free-regular-svg-icons'; import { faTrashAlt, faCheckCircle, faTimesCircle, IconDefinition } from '@fortawesome/free-regular-svg-icons';
import { faRedoAlt, faSun, faMoon, faCircleHalfStroke, faCheck, faExternalLinkAlt, faDownload, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSort, faSortUp, faSortDown, faFolderOpen } from '@fortawesome/free-solid-svg-icons'; import { faRedoAlt, faSun, faMoon, faCircleHalfStroke, faCheck, faExternalLinkAlt, faDownload, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSort, faSortUp, faSortDown, faFolderOpen, faChevronDown, faChevronRight } from '@fortawesome/free-solid-svg-icons';
import { faGithub } from '@fortawesome/free-brands-svg-icons'; import { faGithub } from '@fortawesome/free-brands-svg-icons';
import { CookieService } from 'ngx-cookie-service'; import { CookieService } from 'ngx-cookie-service';
import { map, Observable, of, distinctUntilChanged } from 'rxjs'; import { map, Observable, of, distinctUntilChanged } from 'rxjs';
@ -14,9 +14,13 @@ import {KeyValue} from "@angular/common";
// A row in the download tables: either a folder group header or a download item // A row in the download tables: either a folder group header or a download item
type DisplayRow = type DisplayRow =
| { type: 'header'; key: string; folder: string; count: number } | { type: 'header'; key: string; folder: string; rawFolder: string; count: number; collapsed: boolean }
| { type: 'item'; key: string; value: Download }; | { type: 'item'; key: string; value: Download };
// Which subset of the queue a table shows: actively downloading items,
// pending (not yet started) items, or the completed list
type DownloadTab = 'queue' | 'pending' | 'done';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
templateUrl: './app.component.html', templateUrl: './app.component.html',
@ -55,8 +59,11 @@ export class AppComponent implements OnInit, AfterViewInit {
// Group downloads by folder instead of showing a flat list // Group downloads by folder instead of showing a flat list
groupByFolder = false; groupByFolder = false;
// Which downloads tab is shown: the queue (downloading) or the completed list // Folders collapsed in group-by-folder view (folder name; '' = Default)
activeTab: 'queue' | 'done' = 'queue'; collapsedFolders = new Set<string>();
// Which downloads tab is shown: downloading, pending, or the completed list
activeTab: DownloadTab = 'queue';
// Download metrics // Download metrics
activeDownloads = 0; activeDownloads = 0;
@ -68,6 +75,9 @@ export class AppComponent implements OnInit, AfterViewInit {
@ViewChild('queueMasterCheckbox') queueMasterCheckbox: MasterCheckboxComponent; @ViewChild('queueMasterCheckbox') queueMasterCheckbox: MasterCheckboxComponent;
@ViewChild('queueDelSelected') queueDelSelected: ElementRef; @ViewChild('queueDelSelected') queueDelSelected: ElementRef;
@ViewChild('queueDownloadSelected') queueDownloadSelected: ElementRef; @ViewChild('queueDownloadSelected') queueDownloadSelected: ElementRef;
@ViewChild('pendingMasterCheckbox') pendingMasterCheckbox: MasterCheckboxComponent;
@ViewChild('pendingDelSelected') pendingDelSelected: ElementRef;
@ViewChild('pendingDownloadSelected') pendingDownloadSelected: ElementRef;
@ViewChild('doneMasterCheckbox') doneMasterCheckbox: MasterCheckboxComponent; @ViewChild('doneMasterCheckbox') doneMasterCheckbox: MasterCheckboxComponent;
@ViewChild('doneDelSelected') doneDelSelected: ElementRef; @ViewChild('doneDelSelected') doneDelSelected: ElementRef;
@ViewChild('doneClearCompleted') doneClearCompleted: ElementRef; @ViewChild('doneClearCompleted') doneClearCompleted: ElementRef;
@ -95,6 +105,8 @@ export class AppComponent implements OnInit, AfterViewInit {
faSortUp = faSortUp; faSortUp = faSortUp;
faSortDown = faSortDown; faSortDown = faSortDown;
faFolderOpen = faFolderOpen; faFolderOpen = faFolderOpen;
faChevronDown = faChevronDown;
faChevronRight = faChevronRight;
// Events from backend (last 5 events) // Events from backend (last 5 events)
events: Array<{type: string, message: string, timestamp: number, url: string}> = []; events: Array<{type: string, message: string, timestamp: number, url: string}> = [];
@ -107,7 +119,8 @@ export class AppComponent implements OnInit, AfterViewInit {
this.autoStart = cookieService.get('metube_auto_start') !== 'false'; this.autoStart = cookieService.get('metube_auto_start') !== 'false';
this.sortAscending = cookieService.get('metube_sort_order') === 'asc'; this.sortAscending = cookieService.get('metube_sort_order') === 'asc';
this.groupByFolder = cookieService.get('metube_group_by_folder') === 'true'; this.groupByFolder = cookieService.get('metube_group_by_folder') === 'true';
this.activeTab = cookieService.get('metube_active_tab') === 'done' ? 'done' : 'queue'; const savedTab = cookieService.get('metube_active_tab');
this.activeTab = savedTab === 'done' || savedTab === 'pending' ? savedTab : 'queue';
this.activeTheme = this.getPreferredTheme(cookieService); this.activeTheme = this.getPreferredTheme(cookieService);
@ -152,6 +165,7 @@ export class AppComponent implements OnInit, AfterViewInit {
ngAfterViewInit() { ngAfterViewInit() {
this.downloads.queueChanged.subscribe(() => { this.downloads.queueChanged.subscribe(() => {
this.queueMasterCheckbox.selectionChanged(); this.queueMasterCheckbox.selectionChanged();
this.pendingMasterCheckbox.selectionChanged();
}); });
this.downloads.doneChanged.subscribe(() => { this.downloads.doneChanged.subscribe(() => {
this.doneMasterCheckbox.selectionChanged(); this.doneMasterCheckbox.selectionChanged();
@ -190,16 +204,41 @@ export class AppComponent implements OnInit, AfterViewInit {
this.cookieService.set('metube_group_by_folder', this.groupByFolder ? 'true' : 'false', { expires: 3650 }); this.cookieService.set('metube_group_by_folder', this.groupByFolder ? 'true' : 'false', { expires: 3650 });
} }
setActiveTab(tab: 'queue' | 'done') { setActiveTab(tab: DownloadTab) {
this.activeTab = tab; this.activeTab = tab;
this.cookieService.set('metube_active_tab', tab, { expires: 3650 }); this.cookieService.set('metube_active_tab', tab, { expires: 3650 });
} }
toggleFolderCollapse(folder: string) {
if (this.collapsedFolders.has(folder)) {
this.collapsedFolders.delete(folder);
} else {
this.collapsedFolders.add(folder);
}
}
// The downloads shown in a tab: 'queue' holds only items actually downloading
// (or preparing), 'pending' holds queued items not yet started, 'done' the
// completed list. The returned Maps share Download objects with the service,
// so checkbox state stays in sync.
tabDownloads(tab: DownloadTab): Map<string, Download> {
if (tab === 'done') {
return this.downloads.done;
}
const filtered = new Map<string, Download>();
this.downloads.queue.forEach((dl, key) => {
if (tab === 'pending' ? dl.status === 'pending' : dl.status !== 'pending') {
filtered.set(key, dl);
}
});
return filtered;
}
// Build the rows for a download table: a flat list, or folder header rows // Build the rows for a download table: a flat list, or folder header rows
// interleaved with the items of each folder when group-by-folder is enabled. // interleaved with the items of each folder when group-by-folder is enabled.
displayRows(where: 'queue' | 'done'): DisplayRow[] { displayRows(where: DownloadTab): DisplayRow[] {
const items: KeyValue<string, Download>[] = []; const items: KeyValue<string, Download>[] = [];
this.downloads[where].forEach((value, key) => items.push({ key, value })); this.tabDownloads(where).forEach((value, key) => items.push({ key, value }));
items.sort(this.compareByTimestamp); items.sort(this.compareByTimestamp);
if (!this.groupByFolder) { if (!this.groupByFolder) {
@ -225,7 +264,11 @@ export class AppComponent implements OnInit, AfterViewInit {
const rows: DisplayRow[] = []; const rows: DisplayRow[] = [];
for (const folder of folders) { for (const folder of folders) {
const groupItems = groups.get(folder); const groupItems = groups.get(folder);
rows.push({ type: 'header', key: 'header:' + folder, folder: folder || 'Default', count: groupItems.length }); const collapsed = this.collapsedFolders.has(folder);
rows.push({ type: 'header', key: 'header:' + folder, folder: folder || 'Default', rawFolder: folder, count: groupItems.length, collapsed });
if (collapsed) {
continue;
}
for (const item of groupItems) { for (const item of groupItems) {
rows.push({ type: 'item', key: item.key, value: item.value }); rows.push({ type: 'item', key: item.key, value: item.value });
} }
@ -343,6 +386,11 @@ export class AppComponent implements OnInit, AfterViewInit {
this.queueDownloadSelected.nativeElement.disabled = checked == 0; this.queueDownloadSelected.nativeElement.disabled = checked == 0;
} }
pendingSelectionChanged(checked: number) {
this.pendingDelSelected.nativeElement.disabled = checked == 0;
this.pendingDownloadSelected.nativeElement.disabled = checked == 0;
}
doneSelectionChanged(checked: number) { doneSelectionChanged(checked: number) {
this.doneDelSelected.nativeElement.disabled = checked == 0; this.doneDelSelected.nativeElement.disabled = checked == 0;
this.doneDownloadSelected.nativeElement.disabled = checked == 0; this.doneDownloadSelected.nativeElement.disabled = checked == 0;
@ -408,6 +456,22 @@ export class AppComponent implements OnInit, AfterViewInit {
this.downloads.startById([id]).subscribe(); this.downloads.startById([id]).subscribe();
} }
// Custom dirs matching a queued download's type (audio vs video),
// mirroring __calc_download_path in app/ytdl.py
customDirsForDownload(dl: Download): string[] {
const isAudio = dl.quality === 'audio' || ['m4a', 'mp3', 'opus', 'wav', 'flac'].includes(dl.format);
const dirs = this.downloads.customDirs[isAudio ? 'audio_download_dir' : 'download_dir'];
return dirs || [];
}
folderChanged(key: string, folder: string | null) {
this.downloads.setFolder([key], folder || '').subscribe((status: Status) => {
if (status.status === 'error') {
alert(`Error changing folder: ${status.msg}`);
}
});
}
retryDownload(key: string, download: Download) { retryDownload(key: string, download: Download) {
this.addDownload(download.url, download.quality, download.format, download.folder, download.custom_name_prefix, download.playlist_strict_mode, download.playlist_item_limit, true); this.addDownload(download.url, download.quality, download.format, download.folder, download.custom_name_prefix, download.playlist_strict_mode, download.playlist_item_limit, true);
this.downloads.delById('done', [key]).subscribe(); this.downloads.delById('done', [key]).subscribe();
@ -417,12 +481,19 @@ export class AppComponent implements OnInit, AfterViewInit {
this.downloads.delById(where, [id]).subscribe(); this.downloads.delById(where, [id]).subscribe();
} }
startSelectedDownloads(where: string){ // Whether a queue item belongs to the given tab ('done' items all qualify)
this.downloads.startByFilter(where, dl => dl.checked).subscribe(); private inTab(tab: DownloadTab, dl: Download): boolean {
if (tab === 'pending') return dl.status === 'pending';
if (tab === 'queue') return dl.status !== 'pending';
return true;
} }
delSelectedDownloads(where: string) { startSelectedDownloads(tab: DownloadTab){
this.downloads.delByFilter(where, dl => dl.checked).subscribe(); this.downloads.startByFilter(tab === 'done' ? 'done' : 'queue', dl => dl.checked && this.inTab(tab, dl)).subscribe();
}
delSelectedDownloads(tab: DownloadTab) {
this.downloads.delByFilter(tab === 'done' ? 'done' : 'queue', dl => dl.checked && this.inTab(tab, dl)).subscribe();
} }
clearCompletedDownloads() { clearCompletedDownloads() {

View File

@ -75,11 +75,19 @@ export class DownloadsService {
}); });
socket.fromEvent('updated').subscribe((strdata: string) => { socket.fromEvent('updated').subscribe((strdata: string) => {
let data: Download = JSON.parse(strdata); let data: Download = JSON.parse(strdata);
if (this.queue.has(data.url)) {
let dl: Download = this.queue.get(data.url); let dl: Download = this.queue.get(data.url);
data.checked = dl.checked; data.checked = dl.checked;
data.deleting = dl.deleting; data.deleting = dl.deleting;
this.queue.set(data.url, data); this.queue.set(data.url, data);
this.updated.next(null); this.updated.next(null);
} else if (this.done.has(data.url)) {
let dl: Download = this.done.get(data.url);
data.checked = dl.checked;
data.deleting = dl.deleting;
this.done.set(data.url, data);
this.doneChanged.next(null);
}
}); });
socket.fromEvent('completed').subscribe((strdata: string) => { socket.fromEvent('completed').subscribe((strdata: string) => {
let data: Download = JSON.parse(strdata); let data: Download = JSON.parse(strdata);
@ -136,6 +144,12 @@ export class DownloadsService {
return this.http.post('start', {ids: ids}); return this.http.post('start', {ids: ids});
} }
public setFolder(ids: string[], folder: string) {
return this.http.post<Status>('set_folder', {ids: ids, folder: folder}).pipe(
catchError(this.handleHTTPError)
);
}
public delById(where: string, ids: string[]) { public delById(where: string, ids: string[]) {
ids.forEach(id => this[where].get(id).deleting = true); ids.forEach(id => this[where].get(id).deleting = true);
return this.http.post('delete', {where: where, ids: ids}); return this.http.post('delete', {where: where, ids: ids});