feat: 支持更改下载文件夹并移动已完成文件
This commit is contained in:
parent
e74559f704
commit
d14336ff51
|
|
@ -27,8 +27,8 @@ default, e.g. via QEMU emulation).
|
|||
|
||||
```sh
|
||||
docker buildx build --platform linux/amd64 \
|
||||
--build-arg VERSION=1.12 \
|
||||
-t 192.168.2.212:3000/tigeren/metube:1.12 \
|
||||
--build-arg VERSION=1.13 \
|
||||
-t 192.168.2.212:3000/tigeren/metube:1.13 \
|
||||
--push .
|
||||
```
|
||||
|
||||
|
|
|
|||
12
app/main.py
12
app/main.py
|
|
@ -352,6 +352,18 @@ async def start(request):
|
|||
status = await dqueue.start_pending(ids)
|
||||
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')
|
||||
async def history(request):
|
||||
history = { 'done': [], 'queue': [], 'pending': []}
|
||||
|
|
|
|||
80
app/ytdl.py
80
app/ytdl.py
|
|
@ -1,4 +1,5 @@
|
|||
import os
|
||||
import shutil
|
||||
import yt_dlp
|
||||
from collections import OrderedDict
|
||||
from mark_watched import mark_watched
|
||||
|
|
@ -846,6 +847,85 @@ class DownloadQueue:
|
|||
asyncio.create_task(self.__start_download(dl))
|
||||
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):
|
||||
for id in ids:
|
||||
if self.pending.exists(id):
|
||||
|
|
|
|||
|
|
@ -316,7 +316,12 @@
|
|||
<ul class="nav nav-tabs metube-tabs">
|
||||
<li class="nav-item">
|
||||
<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>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
|
|
@ -340,7 +345,7 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<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 scope="col">Video</th>
|
||||
<th scope="col" class="sortable-header" (click)="toggleSortOrder()" ngbTooltip="Sort by creation time">
|
||||
|
|
@ -355,8 +360,9 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
<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">
|
||||
<fa-icon [icon]="row.collapsed ? faChevronRight : faChevronDown" class="folder-group-chevron"></fa-icon>
|
||||
<fa-icon [icon]="faFolderOpen"></fa-icon> {{ row.folder }}
|
||||
<span class="folder-group-count">{{ row.count }}</span>
|
||||
</td>
|
||||
|
|
@ -392,6 +398,81 @@
|
|||
</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> 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> 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> 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>
|
||||
<fa-icon [icon]="faFolderOpen"></fa-icon> {{ 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="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> Clear selected</button>
|
||||
|
|
@ -423,8 +504,9 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
<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">
|
||||
<fa-icon [icon]="row.collapsed ? faChevronRight : faChevronDown" class="folder-group-chevron"></fa-icon>
|
||||
<fa-icon [icon]="faFolderOpen"></fa-icon> {{ row.folder }}
|
||||
<span class="folder-group-count">{{ row.count }}</span>
|
||||
</td>
|
||||
|
|
@ -453,7 +535,20 @@
|
|||
</ng-template>
|
||||
</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>
|
||||
<span *ngIf="row.value.size">{{ row.value.size | fileSize }}</span>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,13 @@ td
|
|||
opacity: 0.5
|
||||
pointer-events: none
|
||||
|
||||
.folder-edit-cell
|
||||
min-width: 12rem
|
||||
|
||||
.folder-group-header
|
||||
cursor: pointer
|
||||
user-select: none
|
||||
|
||||
td
|
||||
background: var(--bs-secondary-bg)
|
||||
font-weight: 600
|
||||
|
|
@ -59,6 +65,10 @@ td
|
|||
padding-top: 0.4rem
|
||||
padding-bottom: 0.4rem
|
||||
|
||||
.folder-group-chevron
|
||||
width: 0.75rem
|
||||
opacity: 0.7
|
||||
|
||||
.folder-group-count
|
||||
display: inline-block
|
||||
margin-left: 0.5rem
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Component, ViewChild, ElementRef, AfterViewInit, OnInit } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
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 { CookieService } from 'ngx-cookie-service';
|
||||
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
|
||||
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 };
|
||||
|
||||
// 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({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
|
|
@ -55,8 +59,11 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||
// Group downloads by folder instead of showing a flat list
|
||||
groupByFolder = false;
|
||||
|
||||
// Which downloads tab is shown: the queue (downloading) or the completed list
|
||||
activeTab: 'queue' | 'done' = 'queue';
|
||||
// Folders collapsed in group-by-folder view (folder name; '' = Default)
|
||||
collapsedFolders = new Set<string>();
|
||||
|
||||
// Which downloads tab is shown: downloading, pending, or the completed list
|
||||
activeTab: DownloadTab = 'queue';
|
||||
|
||||
// Download metrics
|
||||
activeDownloads = 0;
|
||||
|
|
@ -68,6 +75,9 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||
@ViewChild('queueMasterCheckbox') queueMasterCheckbox: MasterCheckboxComponent;
|
||||
@ViewChild('queueDelSelected') queueDelSelected: ElementRef;
|
||||
@ViewChild('queueDownloadSelected') queueDownloadSelected: ElementRef;
|
||||
@ViewChild('pendingMasterCheckbox') pendingMasterCheckbox: MasterCheckboxComponent;
|
||||
@ViewChild('pendingDelSelected') pendingDelSelected: ElementRef;
|
||||
@ViewChild('pendingDownloadSelected') pendingDownloadSelected: ElementRef;
|
||||
@ViewChild('doneMasterCheckbox') doneMasterCheckbox: MasterCheckboxComponent;
|
||||
@ViewChild('doneDelSelected') doneDelSelected: ElementRef;
|
||||
@ViewChild('doneClearCompleted') doneClearCompleted: ElementRef;
|
||||
|
|
@ -95,6 +105,8 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||
faSortUp = faSortUp;
|
||||
faSortDown = faSortDown;
|
||||
faFolderOpen = faFolderOpen;
|
||||
faChevronDown = faChevronDown;
|
||||
faChevronRight = faChevronRight;
|
||||
|
||||
// Events from backend (last 5 events)
|
||||
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.sortAscending = cookieService.get('metube_sort_order') === 'asc';
|
||||
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);
|
||||
|
||||
|
|
@ -152,6 +165,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||
ngAfterViewInit() {
|
||||
this.downloads.queueChanged.subscribe(() => {
|
||||
this.queueMasterCheckbox.selectionChanged();
|
||||
this.pendingMasterCheckbox.selectionChanged();
|
||||
});
|
||||
this.downloads.doneChanged.subscribe(() => {
|
||||
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 });
|
||||
}
|
||||
|
||||
setActiveTab(tab: 'queue' | 'done') {
|
||||
setActiveTab(tab: DownloadTab) {
|
||||
this.activeTab = tab;
|
||||
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
|
||||
// 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>[] = [];
|
||||
this.downloads[where].forEach((value, key) => items.push({ key, value }));
|
||||
this.tabDownloads(where).forEach((value, key) => items.push({ key, value }));
|
||||
items.sort(this.compareByTimestamp);
|
||||
|
||||
if (!this.groupByFolder) {
|
||||
|
|
@ -225,7 +264,11 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||
const rows: DisplayRow[] = [];
|
||||
for (const folder of folders) {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
pendingSelectionChanged(checked: number) {
|
||||
this.pendingDelSelected.nativeElement.disabled = checked == 0;
|
||||
this.pendingDownloadSelected.nativeElement.disabled = checked == 0;
|
||||
}
|
||||
|
||||
doneSelectionChanged(checked: number) {
|
||||
this.doneDelSelected.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();
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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();
|
||||
|
|
@ -417,12 +481,19 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||
this.downloads.delById(where, [id]).subscribe();
|
||||
}
|
||||
|
||||
startSelectedDownloads(where: string){
|
||||
this.downloads.startByFilter(where, dl => dl.checked).subscribe();
|
||||
// Whether a queue item belongs to the given tab ('done' items all qualify)
|
||||
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) {
|
||||
this.downloads.delByFilter(where, dl => dl.checked).subscribe();
|
||||
startSelectedDownloads(tab: DownloadTab){
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -75,11 +75,19 @@ export class DownloadsService {
|
|||
});
|
||||
socket.fromEvent('updated').subscribe((strdata: string) => {
|
||||
let data: Download = JSON.parse(strdata);
|
||||
let dl: Download = this.queue.get(data.url);
|
||||
data.checked = dl.checked;
|
||||
data.deleting = dl.deleting;
|
||||
this.queue.set(data.url, data);
|
||||
this.updated.next(null);
|
||||
if (this.queue.has(data.url)) {
|
||||
let dl: Download = this.queue.get(data.url);
|
||||
data.checked = dl.checked;
|
||||
data.deleting = dl.deleting;
|
||||
this.queue.set(data.url, data);
|
||||
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) => {
|
||||
let data: Download = JSON.parse(strdata);
|
||||
|
|
@ -136,6 +144,12 @@ export class DownloadsService {
|
|||
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[]) {
|
||||
ids.forEach(id => this[where].get(id).deleting = true);
|
||||
return this.http.post('delete', {where: where, ids: ids});
|
||||
|
|
|
|||
Loading…
Reference in New Issue