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

@ -155,6 +155,10 @@ class Notifier(DownloadQueueNotifier):
log.info(f"Notifier: Download cleared - {id}") log.info(f"Notifier: Download cleared - {id}")
await sio.emit('cleared', serializer.encode(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()) dqueue = DownloadQueue(config, Notifier())
app.on_startup.append(lambda app: dqueue.initialize()) app.on_startup.append(lambda app: dqueue.initialize())
@ -258,6 +262,24 @@ 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 + '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') @routes.get(config.URL_PREFIX + 'history')
async def history(request): async def history(request):
history = { 'done': [], 'queue': [], 'pending': []} history = { 'done': [], 'queue': [], 'pending': []}

View file

@ -30,6 +30,9 @@ class DownloadQueueNotifier:
async def cleared(self, id): async def cleared(self, id):
raise NotImplementedError raise NotImplementedError
async def renamed(self, dl):
raise NotImplementedError
class DownloadInfo: class DownloadInfo:
def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit): def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit):
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}' self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}'
@ -458,6 +461,67 @@ class DownloadQueue:
await self.notifier.cleared(id) await self.notifier.cleared(id)
return {'status': 'ok'} 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): def get(self):
return (list((k, v.info) for k, v in self.queue.items()) + 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.pending.items()),

View file

@ -346,17 +346,49 @@
<td> <td>
<app-slave-checkbox [id]="download.key" [master]="doneMasterCheckbox" [checkable]="download.value"></app-slave-checkbox> <app-slave-checkbox [id]="download.key" [master]="doneMasterCheckbox" [checkable]="download.value"></app-slave-checkbox>
</td> </td>
<td> <td [ngClass]="{'rename-container': renameState[download.key]?.editing}">
<div style="display: inline-block; width: 1.5rem;"> <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 == 'finished'" [icon]="faCheckCircle" class="text-success"></fa-icon>
<fa-icon *ngIf="download.value.status == 'error'" [icon]="faTimesCircle" class="text-danger"></fa-icon> <fa-icon *ngIf="download.value.status == 'error'" [icon]="faTimesCircle" class="text-danger"></fa-icon>
</div> </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-container *ngIf="download.value.status === 'finished' && renameState[download.key]?.editing; else viewDownloadTitle">
<ng-template #noDownloadLink> <div class="rename-inline">
{{download.value.title}} <input class="form-control form-control-sm rename-input"
<span *ngIf="download.value.msg"><br>{{download.value.msg}}</span> [disabled]="renameState[download.key]?.submitting"
<span *ngIf="download.value.error"><br>Error: {{download.value.error}}</span> [(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> </ng-template>
<div *ngIf="renameState[download.key]?.error" class="text-danger small mt-2">
{{ renameState[download.key].error }}
</div>
</td> </td>
<td> <td>
<span *ngIf="download.value.size">{{ download.value.size | fileSize }}</span> <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> <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 *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> <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> <button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
</div> </div>
</td> </td>

View file

@ -209,3 +209,26 @@ main
span span
white-space: nowrap 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 { Component, ViewChild, ElementRef, AfterViewInit } 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 } 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 { 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';
@ -12,6 +12,13 @@ import { Formats, Format, Quality } from './formats';
import { Theme, Themes } from './theme'; import { Theme, Themes } from './theme';
import {KeyValue} from "@angular/common"; import {KeyValue} from "@angular/common";
interface RenameState {
editing: boolean;
value: string;
error?: string;
submitting?: boolean;
}
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
templateUrl: './app.component.html', templateUrl: './app.component.html',
@ -50,6 +57,7 @@ export class AppComponent implements AfterViewInit {
completedDownloads = 0; completedDownloads = 0;
failedDownloads = 0; failedDownloads = 0;
totalSpeed = 0; totalSpeed = 0;
renameState: Record<string, RenameState> = {};
@ViewChild('queueMasterCheckbox') queueMasterCheckbox: MasterCheckboxComponent; @ViewChild('queueMasterCheckbox') queueMasterCheckbox: MasterCheckboxComponent;
@ViewChild('queueDelSelected') queueDelSelected: ElementRef; @ViewChild('queueDelSelected') queueDelSelected: ElementRef;
@ -77,6 +85,7 @@ export class AppComponent implements AfterViewInit {
faGithub = faGithub; faGithub = faGithub;
faClock = faClock; faClock = faClock;
faTachometerAlt = faTachometerAlt; faTachometerAlt = faTachometerAlt;
faPen = faPen;
constructor(public downloads: DownloadsService, private cookieService: CookieService, private http: HttpClient) { constructor(public downloads: DownloadsService, private cookieService: CookieService, private http: HttpClient) {
this.format = cookieService.get('metube_format') || 'any'; 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) { buildDownloadLink(download: Download) {
let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"]; let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"];
if (download.quality == 'audio' || download.filename.endsWith('.mp3')) { if (download.quality == 'audio' || download.filename.endsWith('.mp3')) {

View file

@ -7,6 +7,7 @@ import { MeTubeSocket } from './metube-socket';
export interface Status { export interface Status {
status: string; status: string;
msg?: string; msg?: string;
filename?: string;
} }
export interface Download { export interface Download {
@ -87,6 +88,20 @@ export class DownloadsService {
this.done.delete(data); this.done.delete(data);
this.doneChanged.next(null); 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) => { socket.fromEvent('configuration').subscribe((strdata: string) => {
let data = JSON.parse(strdata); let data = JSON.parse(strdata);
console.debug("got configuration:", data); console.debug("got configuration:", data);
@ -125,6 +140,12 @@ export class DownloadsService {
return this.http.post('delete', {where: where, ids: ids}); 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) { public startByFilter(where: string, filter: (dl: Download) => boolean) {
let ids: string[] = []; let ids: string[] = [];
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) }); this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });