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 @@