diff --git a/DEPLOY.md b/DEPLOY.md index 9afea6d..e6f2f52 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -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 . ``` diff --git a/app/main.py b/app/main.py index d46d6be..142908c 100644 --- a/app/main.py +++ b/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': []} diff --git a/app/ytdl.py b/app/ytdl.py index 99f523f..dcf20fa 100644 --- a/app/ytdl.py +++ b/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): diff --git a/ui/src/app/app.component.html b/ui/src/app/app.component.html index 9b6ad53..f4ef54d 100644 --- a/ui/src/app/app.component.html +++ b/ui/src/app/app.component.html @@ -316,7 +316,12 @@