From d14336ff51c9e4413085f410838ac78b7b6e09da Mon Sep 17 00:00:00 2001 From: tigerenwork Date: Sun, 9 Aug 2026 14:59:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=9B=B4=E6=94=B9?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E6=96=87=E4=BB=B6=E5=A4=B9=E5=B9=B6=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E5=B7=B2=E5=AE=8C=E6=88=90=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEPLOY.md | 4 +- app/main.py | 12 ++++ app/ytdl.py | 80 ++++++++++++++++++++++++ ui/src/app/app.component.html | 105 ++++++++++++++++++++++++++++++-- ui/src/app/app.component.sass | 10 +++ ui/src/app/app.component.ts | 97 +++++++++++++++++++++++++---- ui/src/app/downloads.service.ts | 24 ++++++-- 7 files changed, 307 insertions(+), 25 deletions(-) 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 @@