diff --git a/DEPLOY.md b/DEPLOY.md
index abebf6c..8082a59 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.20 \
- -t 192.168.2.212:3000/tigeren/metube:1.20 \
+ --build-arg VERSION=1.21 \
+ -t 192.168.2.212:3000/tigeren/metube:1.21 \
--push .
```
diff --git a/app/main.py b/app/main.py
index 5900976..383d4b2 100644
--- a/app/main.py
+++ b/app/main.py
@@ -4,6 +4,7 @@
import os
import sys
import asyncio
+import shutil
from pathlib import Path
from aiohttp import web
from aiohttp.log import access_logger
@@ -505,6 +506,64 @@ async def library_delete(request):
return web.Response(text=serializer.encode({'status': 'ok', 'deleted': deleted, 'errors': errors}))
+@routes.post(config.URL_PREFIX + 'library/move')
+async def library_move(request):
+ post = await request.json()
+ paths = post.get('paths')
+ folder = post.get('folder', '')
+ if not paths:
+ raise web.HTTPBadRequest()
+ root = _library_root()
+ if root is None:
+ raise web.HTTPBadRequest(text='LIBRARY_DIR is not configured')
+ target_dir = os.path.realpath(os.path.join(root, folder or ''))
+ if target_dir != root and not target_dir.startswith(root + os.sep):
+ raise web.HTTPBadRequest(text='Invalid library folder')
+
+ def _move():
+ os.makedirs(target_dir, exist_ok=True)
+ moved = 0
+ errors = []
+ for rel in paths:
+ _, src = _resolve_library_path(rel)
+ if src is None or not os.path.isfile(src):
+ errors.append(f'{rel}: invalid path')
+ continue
+ if os.path.realpath(os.path.dirname(src)) == target_dir:
+ continue
+ dst = os.path.join(target_dir, os.path.basename(src))
+ if os.path.exists(dst):
+ errors.append(f'{rel}: target already exists')
+ continue
+ try:
+ shutil.move(src, dst)
+ except OSError as e:
+ errors.append(f'{rel}: {e}')
+ continue
+ meta_src = library_meta_path(src)
+ if os.path.isfile(meta_src):
+ try:
+ shutil.move(meta_src, library_meta_path(dst))
+ except OSError:
+ pass
+ moved += 1
+ # Clean up emptied parent directories, never the library root itself
+ parent = os.path.dirname(src)
+ while parent != root and parent.startswith(root + os.sep):
+ try:
+ os.rmdir(parent)
+ except OSError:
+ break
+ parent = os.path.dirname(parent)
+ return moved, errors
+
+ moved, errors = await asyncio.get_running_loop().run_in_executor(None, _move)
+ if moved:
+ _invalidate_library_folders()
+ log.info(f"Library move request processed: moved={moved} errors={len(errors)}")
+ return web.Response(text=serializer.encode({'status': 'ok', 'moved': moved, 'errors': errors}))
+
+
@routes.get(config.URL_PREFIX + 'library/thumbnail')
async def library_thumbnail(request):
"""Thumbnail for a library file: ffmpeg frame extraction, cached on disk."""
diff --git a/ui/src/app/app.component.html b/ui/src/app/app.component.html
index 3136496..9505f70 100644
--- a/ui/src/app/app.component.html
+++ b/ui/src/app/app.component.html
@@ -94,9 +94,12 @@