diff --git a/app/main.py b/app/main.py index 73132a7..a2d87f6 100644 --- a/app/main.py +++ b/app/main.py @@ -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': []} diff --git a/app/ytdl.py b/app/ytdl.py index 2c241cb..4baa5d6 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -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())) diff --git a/ui/src/app/app.component.html b/ui/src/app/app.component.html index a536154..426d077 100644 --- a/ui/src/app/app.component.html +++ b/ui/src/app/app.component.html @@ -73,17 +73,17 @@
- - @@ -137,10 +137,10 @@
Auto Start -
@@ -182,24 +182,24 @@
Items Limit -
- @@ -213,24 +213,24 @@
-
-
-
- +
- + diff --git a/ui/src/app/app.component.sass b/ui/src/app/app.component.sass index 1a3e6ce..796e7ce 100644 --- a/ui/src/app/app.component.sass +++ b/ui/src/app/app.component.sass @@ -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 diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts index 1f9b144..7419ce6 100644 --- a/ui/src/app/app.component.ts +++ b/ui/src/app/app.component.ts @@ -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 = {}; @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')) { diff --git a/ui/src/app/downloads.service.ts b/ui/src/app/downloads.service.ts index cf63a5e..d64e1e0 100644 --- a/ui/src/app/downloads.service.ts +++ b/ui/src/app/downloads.service.ts @@ -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('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) });