after downloading working, minimal changes

This commit is contained in:
DaKheera47 2025-10-15 11:34:30 +01:00
parent 1b32d49fcf
commit 8fcdfb7ff7
6 changed files with 336 additions and 92 deletions

View file

@ -151,12 +151,16 @@ class Notifier(DownloadQueueNotifier):
log.info(f"Notifier: Download canceled - {id}")
await sio.emit('canceled', serializer.encode(id))
async def cleared(self, id):
log.info(f"Notifier: Download cleared - {id}")
await sio.emit('cleared', serializer.encode(id))
dqueue = DownloadQueue(config, Notifier())
app.on_startup.append(lambda app: dqueue.initialize())
async def cleared(self, id):
log.info(f"Notifier: Download cleared - {id}")
await sio.emit('cleared', serializer.encode(id))
async def renamed(self, dl):
log.info(f"Notifier: Download renamed - {dl.url}")
await sio.emit('renamed', serializer.encode(dl))
dqueue = DownloadQueue(config, Notifier())
app.on_startup.append(lambda app: dqueue.initialize())
class FileOpsFilter(DefaultFilter):
def __call__(self, change_type: int, path: str) -> bool:
@ -251,13 +255,31 @@ async def delete(request):
return web.Response(text=serializer.encode(status))
@routes.post(config.URL_PREFIX + 'start')
async def start(request):
post = await request.json()
ids = post.get('ids')
log.info(f"Received request to start pending downloads for ids: {ids}")
status = await dqueue.start_pending(ids)
return web.Response(text=serializer.encode(status))
async def start(request):
post = await request.json()
ids = post.get('ids')
log.info(f"Received request to start pending downloads for ids: {ids}")
status = await dqueue.start_pending(ids)
return web.Response(text=serializer.encode(status))
@routes.post(config.URL_PREFIX + 'rename')
async def rename(request):
try:
post = await request.json()
except json.JSONDecodeError:
log.error("Bad request: invalid JSON in rename")
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'invalid json'}))
id = post.get('id')
new_name = post.get('new_name')
if not id or not isinstance(new_name, str) or not new_name.strip():
log.error("Bad request: missing id or new_name for rename")
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'missing id or new_name'}))
status = await dqueue.rename(id, new_name)
http_status = 200 if status.get('status') == 'ok' else 200
return web.Response(text=serializer.encode(status), status=http_status)
@routes.get(config.URL_PREFIX + 'history')
async def history(request):
history = { 'done': [], 'queue': [], 'pending': []}

View file

@ -24,11 +24,14 @@ class DownloadQueueNotifier:
async def completed(self, dl):
raise NotImplementedError
async def canceled(self, id):
raise NotImplementedError
async def cleared(self, id):
raise NotImplementedError
async def canceled(self, id):
raise NotImplementedError
async def cleared(self, id):
raise NotImplementedError
async def renamed(self, dl):
raise NotImplementedError
class DownloadInfo:
def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit):
@ -442,11 +445,11 @@ class DownloadQueue:
await self.notifier.canceled(id)
return {'status': 'ok'}
async def clear(self, ids):
for id in ids:
if not self.done.exists(id):
log.warn(f'requested delete for non-existent download {id}')
continue
async def clear(self, ids):
for id in ids:
if not self.done.exists(id):
log.warn(f'requested delete for non-existent download {id}')
continue
if self.config.DELETE_FILE_ON_TRASHCAN:
dl = self.done.get(id)
try:
@ -455,10 +458,71 @@ class DownloadQueue:
except Exception as e:
log.warn(f'deleting file for download {id} failed with error message {e!r}')
self.done.delete(id)
await self.notifier.cleared(id)
return {'status': 'ok'}
def get(self):
return (list((k, v.info) for k, v in self.queue.items()) +
list((k, v.info) for k, v in self.pending.items()),
list((k, v.info) for k, v in self.done.items()))
await self.notifier.cleared(id)
return {'status': 'ok'}
async def rename(self, id, new_name):
log.info(f"Rename requested for download {id} -> {new_name!r}")
if not isinstance(new_name, str):
return {'status': 'error', 'msg': 'new_name must be a string'}
new_name = new_name.strip()
if not id or not new_name:
return {'status': 'error', 'msg': 'missing id or new_name'}
if any(sep in new_name for sep in ('/', '\\')):
return {'status': 'error', 'msg': 'new_name cannot contain path separators'}
if '..' in new_name:
return {'status': 'error', 'msg': 'invalid name'}
if not self.done.exists(id):
log.warn(f'requested rename for non-existent download {id}')
return {'status': 'error', 'msg': 'download not found'}
download = self.done.get(id)
info = download.info
if info.status != 'finished':
return {'status': 'error', 'msg': 'only finished downloads can be renamed'}
if not getattr(info, 'filename', None):
return {'status': 'error', 'msg': 'original filename unavailable'}
dldirectory, error_message = self.__calc_download_path(info.quality, info.format, info.folder)
if error_message is not None:
return error_message
current_relative = info.filename
current_basename = os.path.basename(current_relative)
current_dir = os.path.dirname(current_relative)
_, ext = os.path.splitext(current_basename)
if ext and new_name.lower().endswith(ext.lower()):
return {'status': 'error', 'msg': 'do not include the file extension'}
target_basename = f"{new_name}{ext}"
target_relative = os.path.join(current_dir, target_basename) if current_dir else target_basename
current_path = os.path.join(dldirectory, current_relative)
target_path = os.path.join(dldirectory, target_relative)
current_norm = os.path.normcase(os.path.normpath(current_path))
target_norm = os.path.normcase(os.path.normpath(target_path))
if target_norm == current_norm:
log.info(f"Rename skipped for download {id}; target matches current filename")
return {'status': 'ok', 'filename': info.filename}
if os.path.exists(target_path) and target_norm != current_norm:
log.info(f"Rename failed for download {id}; target {target_path} already exists")
return {'status': 'error', 'msg': 'target filename already exists'}
if not os.path.exists(current_path):
log.info(f"Rename failed for download {id}; source {current_path} missing")
return {'status': 'error', 'msg': 'original file missing'}
try:
os.replace(current_path, target_path)
except OSError as exc:
log.error(f"Rename failed for download {id}: {exc}")
return {'status': 'error', 'msg': f'rename failed: {exc}'}
info.filename = target_relative
self.done.put(download)
asyncio.create_task(self.notifier.renamed(info))
log.info(f"Rename successful for download {id}: {current_basename} -> {target_basename}")
return {'status': 'ok', 'filename': info.filename}
def get(self):
return (list((k, v.info) for k, v in self.queue.items()) +
list((k, v.info) for k, v in self.pending.items()),
list((k, v.info) for k, v in self.done.items()))

View file

@ -73,17 +73,17 @@
<div class="row mb-4">
<div class="col">
<div class="input-group input-group-lg shadow-sm">
<input type="text"
autocomplete="off"
spellcheck="false"
class="form-control form-control-lg"
placeholder="Enter video or playlist URL"
name="addUrl"
[(ngModel)]="addUrl"
<input type="text"
autocomplete="off"
spellcheck="false"
class="form-control form-control-lg"
placeholder="Enter video or playlist URL"
name="addUrl"
[(ngModel)]="addUrl"
[disabled]="addInProgress || downloads.loading">
<button class="btn btn-primary btn-lg px-4"
type="submit"
(click)="addDownload()"
<button class="btn btn-primary btn-lg px-4"
type="submit"
(click)="addDownload()"
[disabled]="addInProgress || downloads.loading">
<span class="spinner-border spinner-border-sm" role="status" id="add-spinner" *ngIf="addInProgress"></span>
{{ addInProgress ? "Adding..." : "Download" }}
@ -97,10 +97,10 @@
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Quality</span>
<select class="form-select"
name="quality"
[(ngModel)]="quality"
(change)="qualityChanged()"
<select class="form-select"
name="quality"
[(ngModel)]="quality"
(change)="qualityChanged()"
[disabled]="addInProgress || downloads.loading">
<option *ngFor="let q of qualities" [ngValue]="q.id">{{ q.text }}</option>
</select>
@ -109,18 +109,18 @@
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Format</span>
<select class="form-select"
name="format"
[(ngModel)]="format"
(change)="formatChanged()"
<select class="form-select"
name="format"
[(ngModel)]="format"
(change)="formatChanged()"
[disabled]="addInProgress || downloads.loading">
<option *ngFor="let f of formats" [ngValue]="f.id">{{ f.text }}</option>
</select>
</div>
</div>
<div class="col-md-4">
<button type="button"
class="btn btn-outline-secondary w-100 h-100"
<button type="button"
class="btn btn-outline-secondary w-100 h-100"
(click)="toggleAdvanced()">
Advanced Options
</button>
@ -137,10 +137,10 @@
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Auto Start</span>
<select class="form-select"
name="autoStart"
[(ngModel)]="autoStart"
(change)="autoStartChanged()"
<select class="form-select"
name="autoStart"
[(ngModel)]="autoStart"
(change)="autoStartChanged()"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Automatically start downloads when added">
<option [ngValue]="true">Yes</option>
@ -151,12 +151,12 @@
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Download Folder</span>
<ng-select [items]="customDirs$ | async"
placeholder="Default"
[addTag]="allowCustomDir.bind(this)"
addTagText="Create directory"
bindLabel="folder"
[(ngModel)]="folder"
<ng-select [items]="customDirs$ | async"
placeholder="Default"
[addTag]="allowCustomDir.bind(this)"
addTagText="Create directory"
bindLabel="folder"
[(ngModel)]="folder"
[disabled]="addInProgress || downloads.loading"
[virtualScroll]="true"
[clearable]="true"
@ -170,11 +170,11 @@
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Custom Name Prefix</span>
<input type="text"
class="form-control"
placeholder="Default"
name="customNamePrefix"
[(ngModel)]="customNamePrefix"
<input type="text"
class="form-control"
placeholder="Default"
name="customNamePrefix"
[(ngModel)]="customNamePrefix"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Add a prefix to downloaded filenames">
</div>
@ -182,24 +182,24 @@
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Items Limit</span>
<input type="number"
min="0"
class="form-control"
placeholder="Default"
name="playlistItemLimit"
(keydown)="isNumber($event)"
[(ngModel)]="playlistItemLimit"
<input type="number"
min="0"
class="form-control"
placeholder="Default"
name="playlistItemLimit"
(keydown)="isNumber($event)"
[(ngModel)]="playlistItemLimit"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Maximum number of items to download from a playlist (0 = no limit)">
</div>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input"
type="checkbox"
role="switch"
name="playlistStrictMode"
[(ngModel)]="playlistStrictMode"
<input class="form-check-input"
type="checkbox"
role="switch"
name="playlistStrictMode"
[(ngModel)]="playlistStrictMode"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Only download playlists when URL explicitly points to a playlist">
<label class="form-check-label">Strict Playlist Mode</label>
@ -213,24 +213,24 @@
<hr class="my-3">
<div class="row g-2">
<div class="col-md-4">
<button type="button"
class="btn btn-secondary w-100"
<button type="button"
class="btn btn-secondary w-100"
(click)="openBatchImportModal()">
<fa-icon [icon]="faFileImport" class="me-2"></fa-icon>
Import URLs
</button>
</div>
<div class="col-md-4">
<button type="button"
class="btn btn-secondary w-100"
<button type="button"
class="btn btn-secondary w-100"
(click)="exportBatchUrls('all')">
<fa-icon [icon]="faFileExport" class="me-2"></fa-icon>
Export URLs
</button>
</div>
<div class="col-md-4">
<button type="button"
class="btn btn-secondary w-100"
<button type="button"
class="btn btn-secondary w-100"
(click)="copyBatchUrls('all')">
<fa-icon [icon]="faCopy" class="me-2"></fa-icon>
Copy URLs
@ -245,7 +245,7 @@
</div>
</div>
</form>
<!-- Batch Import Modal -->
<div class="modal fade" tabindex="-1" role="dialog" [ngClass]="{'show': batchImportModalOpen}" [ngStyle]="{'display': batchImportModalOpen ? 'block' : 'none'}">
<div class="modal-dialog" role="document">
@ -273,7 +273,7 @@
</div>
</div>
</div>
<div *ngIf="downloads.loading" class="alert alert-info" role="alert">
Connecting to server...
@ -346,17 +346,49 @@
<td>
<app-slave-checkbox [id]="download.key" [master]="doneMasterCheckbox" [checkable]="download.value"></app-slave-checkbox>
</td>
<td>
<td [ngClass]="{'rename-container': renameState[download.key]?.editing}">
<div style="display: inline-block; width: 1.5rem;">
<fa-icon *ngIf="download.value.status == 'finished'" [icon]="faCheckCircle" class="text-success"></fa-icon>
<fa-icon *ngIf="download.value.status == 'error'" [icon]="faTimesCircle" class="text-danger"></fa-icon>
</div>
<span ngbTooltip="{{download.value.msg}} | {{download.value.error}}"><a *ngIf="!!download.value.filename; else noDownloadLink" href="{{buildDownloadLink(download.value)}}" target="_blank">{{ download.value.title }}</a></span>
<ng-template #noDownloadLink>
{{download.value.title}}
<span *ngIf="download.value.msg"><br>{{download.value.msg}}</span>
<span *ngIf="download.value.error"><br>Error: {{download.value.error}}</span>
<ng-container *ngIf="download.value.status === 'finished' && renameState[download.key]?.editing; else viewDownloadTitle">
<div class="rename-inline">
<input class="form-control form-control-sm rename-input"
[disabled]="renameState[download.key]?.submitting"
[(ngModel)]="renameState[download.key].value"
[ngModelOptions]="{standalone: true}"
(keyup.enter)="submitRename(download.key, download.value)"
(keyup.escape)="cancelRename(download.key)"
placeholder="New name">
<div class="rename-actions">
<button type="button"
class="btn btn-sm btn-primary"
(click)="submitRename(download.key, download.value)"
[disabled]="renameState[download.key]?.submitting">
Save
</button>
<button type="button"
class="btn btn-sm btn-secondary"
(click)="cancelRename(download.key)"
[disabled]="renameState[download.key]?.submitting">
Cancel
</button>
</div>
</div>
</ng-container>
<ng-template #viewDownloadTitle>
<span ngbTooltip="{{download.value.msg}} | {{download.value.error}}">
<a *ngIf="!!download.value.filename; else noDownloadLink" href="{{buildDownloadLink(download.value)}}" target="_blank">{{ download.value.filename || download.value.title }}</a>
</span>
<ng-template #noDownloadLink>
{{download.value.title}}
<span *ngIf="download.value.msg"><br>{{download.value.msg}}</span>
<span *ngIf="download.value.error"><br>Error: {{download.value.error}}</span>
</ng-template>
</ng-template>
<div *ngIf="renameState[download.key]?.error" class="text-danger small mt-2">
{{ renameState[download.key].error }}
</div>
</td>
<td>
<span *ngIf="download.value.size">{{ download.value.size | fileSize }}</span>
@ -366,6 +398,7 @@
<button *ngIf="download.value.status == 'error'" type="button" class="btn btn-link" (click)="retryDownload(download.key, download.value)"><fa-icon [icon]="faRedoAlt"></fa-icon></button>
<a *ngIf="download.value.filename" href="{{buildDownloadLink(download.value)}}" download class="btn btn-link"><fa-icon [icon]="faDownload"></fa-icon></a>
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
<button *ngIf="download.value.status == 'finished' && !renameState[download.key]?.editing" type="button" class="btn btn-link" (click)="beginRename(download.key, download.value)"><fa-icon [icon]="faPen"></fa-icon></button>
<button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
</div>
</td>

View file

@ -209,3 +209,26 @@ main
span
white-space: nowrap
.rename-inline
display: flex
align-items: center
gap: 0.5rem
width: 100%
max-width: 100%
flex-wrap: nowrap
.rename-input
flex: 1 1 auto
min-width: 10rem
max-width: 100%
.rename-actions
display: flex
gap: 0.5rem
flex-shrink: 0
.rename-container
display: flex
align-items: center
padding: 1.1rem 0.5rem

View file

@ -1,7 +1,7 @@
import { Component, ViewChild, ElementRef, AfterViewInit } 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 } from '@fortawesome/free-solid-svg-icons';
import { faRedoAlt, faSun, faMoon, faCircleHalfStroke, faCheck, faExternalLinkAlt, faDownload, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faPen } 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';
@ -12,6 +12,13 @@ import { Formats, Format, Quality } from './formats';
import { Theme, Themes } from './theme';
import {KeyValue} from "@angular/common";
interface RenameState {
editing: boolean;
value: string;
error?: string;
submitting?: boolean;
}
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
@ -50,6 +57,7 @@ export class AppComponent implements AfterViewInit {
completedDownloads = 0;
failedDownloads = 0;
totalSpeed = 0;
renameState: Record<string, RenameState> = {};
@ViewChild('queueMasterCheckbox') queueMasterCheckbox: MasterCheckboxComponent;
@ViewChild('queueDelSelected') queueDelSelected: ElementRef;
@ -77,6 +85,7 @@ export class AppComponent implements AfterViewInit {
faGithub = faGithub;
faClock = faClock;
faTachometerAlt = faTachometerAlt;
faPen = faPen;
constructor(public downloads: DownloadsService, private cookieService: CookieService, private http: HttpClient) {
this.format = cookieService.get('metube_format') || 'any';
@ -325,6 +334,78 @@ export class AppComponent implements AfterViewInit {
});
}
private getFilenameStem(filename: string): string {
if (!filename) {
return '';
}
const base = filename.split('/').pop();
if (!base) {
return '';
}
const lastDot = base.lastIndexOf('.');
return lastDot > 0 ? base.substring(0, lastDot) : base;
}
private getFilenameExtension(filename: string): string {
if (!filename) {
return '';
}
const base = filename.split('/').pop();
if (!base) {
return '';
}
const lastDot = base.lastIndexOf('.');
return lastDot > -1 ? base.substring(lastDot) : '';
}
beginRename(key: string, download: Download) {
if (!download.filename || download.status !== 'finished') {
return;
}
this.renameState[key] = {
editing: true,
value: this.getFilenameStem(download.filename)
};
}
cancelRename(key: string) {
delete this.renameState[key];
}
submitRename(key: string, download: Download) {
const state = this.renameState[key];
if (!state || state.submitting) {
return;
}
const trimmed = state.value.trim();
if (!trimmed) {
state.error = 'Name cannot be empty';
return;
}
if (/[\\/]/.test(trimmed) || trimmed.includes('..')) {
state.error = 'Invalid characters in name';
return;
}
const ext = this.getFilenameExtension(download.filename);
if (ext && trimmed.toLowerCase().endsWith(ext.toLowerCase())) {
state.error = 'Do not include the file extension';
return;
}
state.submitting = true;
state.error = undefined;
this.downloads.rename(download.url, trimmed).subscribe((status: Status) => {
state.submitting = false;
if (status.status === 'ok') {
if (status.filename) {
download.filename = status.filename;
}
this.cancelRename(key);
} else {
state.error = status.msg || 'Rename failed';
}
});
}
buildDownloadLink(download: Download) {
let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"];
if (download.quality == 'audio' || download.filename.endsWith('.mp3')) {

View file

@ -7,6 +7,7 @@ import { MeTubeSocket } from './metube-socket';
export interface Status {
status: string;
msg?: string;
filename?: string;
}
export interface Download {
@ -87,6 +88,20 @@ export class DownloadsService {
this.done.delete(data);
this.doneChanged.next(null);
});
socket.fromEvent('renamed').subscribe((strdata: string) => {
let data: Download = JSON.parse(strdata);
const existing = this.done.get(data.url);
let merged: Download;
if (existing) {
merged = { ...existing, ...data };
merged.checked = existing.checked;
merged.deleting = existing.deleting;
} else {
merged = data;
}
this.done.set(data.url, merged);
this.doneChanged.next(null);
});
socket.fromEvent('configuration').subscribe((strdata: string) => {
let data = JSON.parse(strdata);
console.debug("got configuration:", data);
@ -125,6 +140,12 @@ export class DownloadsService {
return this.http.post('delete', {where: where, ids: ids});
}
public rename(id: string, newName: string) {
return this.http.post<Status>('rename', { id: id, new_name: newName }).pipe(
catchError(this.handleHTTPError)
);
}
public startByFilter(where: string, filter: (dl: Download) => boolean) {
let ids: string[] = [];
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });