diff --git a/API.md b/API.md index 8a42afce..3d735f60 100644 --- a/API.md +++ b/API.md @@ -24,6 +24,7 @@ This document describes the available endpoints and their usage. All endpoints r - [DELETE /api/history](#delete-apihistory) - [POST /api/history/{id}](#post-apihistoryid) - [GET /api/history/{id}](#get-apihistoryid) + - [POST /api/history/{id}/rename](#post-apihistoryidrename) - [GET /api/history](#get-apihistory) - [GET /api/history/live](#get-apihistorylive) - [POST /api/history/start](#post-apihistorystart) @@ -56,7 +57,7 @@ This document describes the available endpoints and their usage. All endpoints r - [GET /api/player/segments/{segment}/{file:.\*}.ts](#get-apiplayersegmentssegmentfilets) - [GET /api/player/subtitle/{file:.\*}.vtt](#get-apiplayersubtitlefilevtt) - [GET /api/player/subtitles/manifest/{file:.\*}](#get-apiplayersubtitlesmanifestfile) - - [GET /api/player/subtitles/{source_format}/{file:.\*}](#get-apiplayersubtitlessource_formatfile) + - [GET /api/player/subtitles/{source\_format}/{file:.\*}](#get-apiplayersubtitlessource_formatfile) - [GET /api/thumbnail](#get-apithumbnail) - [GET /api/file/ffprobe/{file:.\*}](#get-apifileffprobefile) - [GET /api/file/info/{file:.\*}](#get-apifileinfofile) @@ -532,6 +533,41 @@ or an error: --- +### POST /api/history/{id}/rename +**Purpose**: Rename a downloaded history file and its sidecars. + +**Path Parameter**: +- `id` = Unique history item ID. + +**Body**: +```json +{ + "new_name": "renamed_video.mp4" +} +``` + +**Response**: +```json +{ + "_id": "", + "title": "Video Title", + "url": "https://youtube.com/watch?v=...", + .... +} +``` + +**Error Responses**: +- `400 Bad Request` if `id` or `new_name` is missing, or the item has no downloaded file. +- `404 Not Found` if the item does not exist. +- `409 Conflict` if the rename destination already exists. +- `500 Internal Server Error` if the filesystem rename fails unexpectedly. + +**Notes**: +- Uses the same file rename behavior as the file browser actions. +- Renames matching sidecar files together with the main media file. + +--- + ### GET /api/history **Purpose**: Returns the download queue and/or download history with optional pagination support. diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index 95bf9c8a..e956b5c0 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -256,7 +256,7 @@ class HttpAPI: response: Response = await handler(request) - contentType: str = response.headers.get("content-type", "") + contentType: str = str(response.headers.get("content-type", "")) if contentType.startswith("text/html") and getattr(response, "_path", None): rewrite_path: str = base_path.rstrip("/") async with await anyio.open_file(response._path, "rb") as f: diff --git a/app/routes/api/history.py b/app/routes/api/history.py index 1da5baa7..a063cfc5 100644 --- a/app/routes/api/history.py +++ b/app/routes/api/history.py @@ -1,5 +1,6 @@ import asyncio import logging +from pathlib import Path from typing import TYPE_CHECKING, Any from aiohttp import web @@ -10,10 +11,12 @@ from app.features.presets.service import Presets from app.library.config import Config from app.library.DataStore import StoreType from app.library.downloads import Download, DownloadQueue +from app.library.downloads.utils import safe_relative_path from app.library.encoder import Encoder from app.library.Events import EventBus, Events from app.library.ItemDTO import Item from app.library.router import route +from app.library.Utils import calc_download_path, get_file_sidecar, rename_file if TYPE_CHECKING: from library.downloads import Download @@ -287,9 +290,9 @@ async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder) -> } if "finished" == item.info.status and (filename := item.info.get_file()): - from app.features.streaming.library.ffprobe import ffprobe - try: + from app.features.streaming.library.ffprobe import ffprobe + info["ffprobe"] = await ffprobe(filename) except Exception: pass @@ -302,6 +305,70 @@ async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder) -> return web.json_response(data=info, status=web.HTTPOk.status_code, dumps=encoder.encode) +@route("POST", r"api/history/{id}/rename", "history.item.rename") +async def item_rename( + request: Request, + queue: DownloadQueue, + encoder: Encoder, + notify: EventBus, + config: Config, +) -> Response: + if not (id := request.match_info.get("id")): + return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code) + + item: Download | None = await queue.done.get_by_id(id) + if not item: + return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code) + + try: + post: dict = await request.json() + if not post: + return web.json_response(data={"error": "no data provided."}, status=web.HTTPBadRequest.status_code) + except Exception: + return web.json_response(data={"error": "invalid JSON body."}, status=web.HTTPBadRequest.status_code) + + new_name: str = str(post.get("new_name") or "").strip() + if not new_name: + return web.json_response(data={"error": "new_name is required."}, status=web.HTTPBadRequest.status_code) + + filepath: Path | None = item.info.get_file(download_path=Path(config.download_path)) + if not filepath: + return web.json_response(data={"error": "item has no downloaded file."}, status=web.HTTPBadRequest.status_code) + + try: + renamed, sidecar_renamed = rename_file(filepath, new_name) + except ValueError as e: + return web.json_response(data={"error": str(e)}, status=web.HTTPConflict.status_code) + except OSError as e: + LOG.exception(e) + return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) + + item_dir: Path = ( + Path(item.info.download_dir) + if item.info.download_dir + else Path(calc_download_path(base_path=config.download_path, folder=item.info.folder, create_path=False)) + ) + item.info.filename = safe_relative_path(renamed, item_dir, Path(config.download_path)) + + try: + item.info.sidecar = get_file_sidecar(renamed) + except Exception: + item.info.sidecar = {} + + await queue.done.put(item, no_notify=True) + notify.emit(Events.ITEM_UPDATED, data=item.info) + + sidecar_count: int = len(sidecar_renamed) + sidecar_msg: str = f" and {sidecar_count} sidecar file/s" if sidecar_count > 0 else "" + LOG.info(f"Renamed file '{filepath}' to '{renamed}'{sidecar_msg}") + + return web.json_response( + data=item.info, + status=web.HTTPOk.status_code, + dumps=encoder.encode, + ) + + @route("POST", "api/history/{id}", "item_update") async def item_update(request: Request, queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response: """ diff --git a/app/tests/test_history_routes.py b/app/tests/test_history_routes.py index ddefce14..91c98016 100644 --- a/app/tests/test_history_routes.py +++ b/app/tests/test_history_routes.py @@ -1,12 +1,16 @@ import json +from pathlib import Path +from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, Mock import pytest -from app.library.encoder import Encoder from app.library.DataStore import StoreType -from app.routes.api.history import items_delete +from app.library.ItemDTO import ItemDTO +from app.library.encoder import Encoder +from app.routes.api.history import item_rename, items_delete +from app.tests.helpers import temporary_test_dir class _FakeRequest: @@ -14,11 +18,39 @@ class _FakeRequest: self._payload = payload or {} self.query: dict[str, str] = {} self.match_info: dict[str, str] = {} + self.body_exists = bool(payload) async def json(self) -> dict[str, Any]: return self._payload +def _make_download( + *, + filename: str | None = None, + folder: str = "", + download_dir: str | None = None, + status: str = "finished", +) -> SimpleNamespace: + base_dir = download_dir or "/downloads" + original_post_init = ItemDTO.__post_init__ + ItemDTO.__post_init__ = lambda self: None + + try: + item = ItemDTO( + id="test-id", + title="Test Video", + url="https://example.com/watch?v=test-id", + folder=folder, + status=status, + filename=filename, + download_dir=base_dir, + ) + finally: + ItemDTO.__post_init__ = original_post_init + + return SimpleNamespace(info=item) + + @pytest.mark.asyncio async def test_items_delete_uses_bulk_history_status_clear() -> None: request = _FakeRequest(payload={"type": StoreType.HISTORY.value, "status": "finished,skip", "remove_file": False}) @@ -47,3 +79,112 @@ async def test_items_delete_uses_bulk_history_id_clear() -> None: queue.clear_bulk.assert_awaited_once_with(["a", "b"], remove_file=False) body = json.loads(response.body.decode("utf-8")) assert body == {"items": {}, "deleted": 2} + + +@pytest.mark.asyncio +async def test_item_rename_requires_new_name() -> None: + request = _FakeRequest(payload={}) + request.match_info["id"] = "item-1" + queue = SimpleNamespace( + done=SimpleNamespace(get_by_id=AsyncMock(return_value=_make_download(filename="video.mp4"))) + ) + encoder = Encoder() + notify = Mock() + config = SimpleNamespace(download_path="/downloads") + + response = await item_rename(request, queue, encoder, notify, config) + + assert response.status == 400 + body = json.loads(response.body.decode("utf-8")) + assert body == {"error": "no data provided."} + + +@pytest.mark.asyncio +async def test_item_rename_returns_not_found_when_item_missing() -> None: + request = _FakeRequest(payload={"new_name": "renamed.mp4"}) + request.match_info["id"] = "missing" + queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=None))) + encoder = Encoder() + notify = Mock() + config = SimpleNamespace(download_path="/downloads") + + response = await item_rename(request, queue, encoder, notify, config) + + assert response.status == 404 + body = json.loads(response.body.decode("utf-8")) + assert body == {"error": "item 'missing' not found."} + + +@pytest.mark.asyncio +async def test_item_rename_requires_existing_downloaded_file() -> None: + request = _FakeRequest(payload={"new_name": "renamed.mp4"}) + request.match_info["id"] = "item-1" + item = _make_download(filename="video.mp4") + queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=item))) + encoder = Encoder() + notify = Mock() + config = SimpleNamespace(download_path="/downloads") + + item.info.get_file = lambda download_path=None: None + + response = await item_rename(request, queue, encoder, notify, config) + + assert response.status == 400 + body = json.loads(response.body.decode("utf-8")) + assert body == {"error": "item has no downloaded file."} + + +@pytest.mark.asyncio +async def test_item_rename_renames_file_and_sidecars() -> None: + with temporary_test_dir("history-rename") as temp_dir: + media = temp_dir / "video.mp4" + subtitle = temp_dir / "video.en.srt" + media.write_text("video") + subtitle.write_text("subtitle") + + request = _FakeRequest(payload={"new_name": "renamed.mp4"}) + request.match_info["id"] = "item-1" + item = _make_download(filename="video.mp4", download_dir=str(temp_dir)) + item.info._id = "item-1" + queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=item), put=AsyncMock())) + encoder = Encoder() + notify = Mock() + config = SimpleNamespace(download_path=str(temp_dir)) + + response = await item_rename(request, queue, encoder, notify, config) + + assert response.status == 200 + body = json.loads(response.body.decode("utf-8")) + assert body["filename"] == "renamed.mp4" + assert item.info.filename == "renamed.mp4" + assert (temp_dir / "renamed.mp4").exists() + assert (temp_dir / "renamed.en.srt").exists() + assert not media.exists() + assert not subtitle.exists() + queue.done.put.assert_awaited_once_with(item, no_notify=True) + notify.emit.assert_called_once() + + +@pytest.mark.asyncio +async def test_item_rename_returns_conflict_on_collision() -> None: + with temporary_test_dir("history-rename-conflict") as temp_dir: + media = temp_dir / "video.mp4" + conflict = temp_dir / "renamed.mp4" + media.write_text("video") + conflict.write_text("existing") + + request = _FakeRequest(payload={"new_name": "renamed.mp4"}) + request.match_info["id"] = "item-1" + item = _make_download(filename="video.mp4", download_dir=str(temp_dir)) + queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=item), put=AsyncMock())) + encoder = Encoder() + notify = Mock() + config = SimpleNamespace(download_path=str(temp_dir)) + + response = await item_rename(request, queue, encoder, notify, config) + + assert response.status == 409 + body = json.loads(response.body.decode("utf-8")) + assert body == {"error": "Destination 'renamed.mp4' already exists"} + queue.done.put.assert_not_awaited() + notify.emit.assert_not_called() diff --git a/ui/app/components/Simple.vue b/ui/app/components/Simple.vue index 3629ea2c..5e89e89e 100644 --- a/ui/app/components/Simple.vue +++ b/ui/app/components/Simple.vue @@ -32,7 +32,7 @@ :loading="isRefreshing" :disabled="isRefreshing" :square="isMobile" - @click="() => void refreshQueue()" + @click="() => refreshQueue()" > Reload Queue @@ -329,7 +329,7 @@ variant="soft" size="xs" icon="i-lucide-play-circle" - @click="void stateStore.startItems([item._id])" + @click="() => stateStore.startItems([item._id])" > Start @@ -340,7 +340,7 @@ variant="soft" size="xs" icon="i-lucide-pause" - @click="void stateStore.pauseItems([item._id])" + @click="() => stateStore.pauseItems([item._id])" > Pause @@ -350,7 +350,7 @@ variant="outline" size="xs" icon="i-lucide-x" - @click="void stateStore.cancelItems([item._id])" + @click="() => stateStore.cancelItems([item._id])" > {{ item.is_live ? 'Stop' : 'Cancel' }} @@ -400,7 +400,7 @@ :sibling-count="0" @update:page=" (page) => - loadHistory(page, { + load(page, { order: 'DESC', perPage: configStore.app.default_pagination, }) @@ -415,7 +415,7 @@ :loading="historyIsLoading" :disabled="historyIsLoading" @click=" - void reloadHistory({ order: 'DESC', perPage: configStore.app.default_pagination }) + () => reload({ order: 'DESC', perPage: configStore.app.default_pagination }) " > Reload @@ -542,7 +542,7 @@ variant="soft" size="xs" icon="i-lucide-rotate-cw" - @click="() => void requeueItem(item)" + @click="() => requeueItem(item)" > Requeue @@ -552,7 +552,7 @@ variant="outline" size="xs" icon="i-lucide-trash" - @click="() => void deleteHistoryItem(item)" + @click="() => deleteHistoryItem(item)" > Delete @@ -589,7 +589,7 @@ :sibling-count="0" @update:page=" (page) => - loadHistory(page, { + load(page, { order: 'DESC', perPage: configStore.app.default_pagination, }) @@ -711,10 +711,10 @@ const { items: historyItems, pagination, isLoading, - loadHistory, - reloadHistory, - deleteHistoryItems, - historyMoveHandler, + load, + reload, + remove, + moveHandler, } = useHistoryState(); const embedUrl = ref(''); @@ -1261,20 +1261,18 @@ const requeueItem = async (item: StoreItem): Promise => { payload.extras = JSON.parse(JSON.stringify(item.extras)); } - await deleteHistoryItems({ ids: [item._id], removeFile: false }); - await reloadHistory({ order: 'DESC', perPage: configStore.app.default_pagination }); + await remove({ ids: [item._id], removeFile: false }); + await reload({ order: 'DESC', perPage: configStore.app.default_pagination }); await stateStore.addDownload(payload); }; const deleteHistoryItem = async (item: StoreItem): Promise => { - await deleteHistoryItems({ ids: [item._id], removeFile: app.value.remove_files }); - await reloadHistory({ order: 'DESC', perPage: configStore.app.default_pagination }); + await remove({ ids: [item._id], removeFile: app.value.remove_files }); + await reload({ order: 'DESC', perPage: configStore.app.default_pagination }); toast.info('Removed from history queue.'); }; -const handleHistoryItemMoved = historyMoveHandler( - () => simpleMode.value && historyInitialized.value, -); +const handleHistoryItemMoved = moveHandler(() => simpleMode.value && historyInitialized.value); const showMessage = (item: StoreItem): boolean => { if (!item?.msg || item.msg === item?.error) { @@ -1303,7 +1301,7 @@ onMounted(async () => { await normalizeSimpleRoute(); historyInitialized.value = true; socketStore.on('item_moved', handleHistoryItemMoved); - await loadHistory(1, { order: 'DESC', perPage: configStore.app.default_pagination }); + await load(1, { order: 'DESC', perPage: configStore.app.default_pagination }); if (!socketStore.isConnected && autoRefreshEnabled.value) { startAutoRefresh(); @@ -1389,7 +1387,7 @@ watch( return; } - void loadHistory(1, { order: 'DESC', perPage: configStore.app.default_pagination }); + void load(1, { order: 'DESC', perPage: configStore.app.default_pagination }); }, ); diff --git a/ui/app/composables/useHistoryState.ts b/ui/app/composables/useHistoryState.ts index 82dae5cb..c10781a6 100644 --- a/ui/app/composables/useHistoryState.ts +++ b/ui/app/composables/useHistoryState.ts @@ -77,7 +77,7 @@ const buildQuery = ( return params.toString(); }; -const loadHistory = async (page: number = 1, options: HistoryLoadOptions = {}): Promise => { +const load = async (page: number = 1, options: HistoryLoadOptions = {}): Promise => { const { order = 'DESC', status, perPage = pageSize.value } = options; isLoading.value = true; @@ -104,12 +104,12 @@ const loadHistory = async (page: number = 1, options: HistoryLoadOptions = {}): } }; -const reloadHistory = async (options: HistoryLoadOptions = {}): Promise => { +const reload = async (options: HistoryLoadOptions = {}): Promise => { const targetPage = isLoaded.value ? pagination.value.page : 1; - await loadHistory(targetPage, options); + await load(targetPage, options); }; -const deleteHistoryItems = async ( +const remove = async ( options: { ids?: string[]; status?: string; @@ -155,7 +155,39 @@ const deleteHistoryItems = async ( } }; -const resetHistory = (): void => { +const rename = async (item: StoreItem, newName: string): Promise => { + const trimmedName = newName.trim(); + if (!trimmedName || trimmedName === item.filename?.split('/').pop()) { + return false; + } + + try { + const response = await request(`/api/history/${item._id}/rename`, { + method: 'POST', + body: JSON.stringify({ new_name: trimmedName }), + }); + + await ensureSuccess(response); + + const updated = (await response.json()) as StoreItem; + const index = items.value.findIndex((entry) => entry._id === updated._id); + + if (index !== -1) { + items.value = [...items.value.slice(0, index), updated, ...items.value.slice(index + 1)]; + } + + lastError.value = null; + return true; + } catch (error) { + handleError(error); + if (throwInstead.value) { + throw error; + } + return false; + } +}; + +const reset = (): void => { items.value = []; pagination.value = { page: 1, @@ -169,7 +201,7 @@ const resetHistory = (): void => { lastError.value = null; }; -const addHistoryItem = (item: StoreItem): void => { +const upsert = (item: StoreItem): void => { const existingIndex = items.value.findIndex((existing) => existing._id === item._id); if (existingIndex !== -1) { @@ -195,7 +227,7 @@ const addHistoryItem = (item: StoreItem): void => { pagination.value.has_next = pagination.value.page < pagination.value.total_pages; }; -const historyMoveHandler = ( +const moveHandler = ( shouldHandle: () => boolean = () => isLoaded.value, ): ((payload: WSEP['item_moved']) => void) => { return (payload: WSEP['item_moved']): void => { @@ -203,7 +235,7 @@ const historyMoveHandler = ( return; } - addHistoryItem(payload.data.item); + upsert(payload.data.item); }; }; @@ -215,11 +247,12 @@ export const useHistoryState = () => { isLoaded, lastError, pageSize, - loadHistory, - reloadHistory, - deleteHistoryItems, - resetHistory, - upsertHistoryItem: addHistoryItem, - historyMoveHandler, + load, + reload, + remove, + rename, + reset, + upsert, + moveHandler, }; }; diff --git a/ui/app/pages/history.vue b/ui/app/pages/history.vue index 70e50219..9937fb02 100644 --- a/ui/app/pages/history.vue +++ b/ui/app/pages/history.vue @@ -50,7 +50,7 @@ icon="i-lucide-refresh-cw" :loading="isLoading" :disabled="isLoading" - @click="void reloadHistory({ order: 'DESC', perPage: config.app.default_pagination })" + @click="() => reload({ order: 'DESC', perPage: config.app.default_pagination })" > Reload @@ -78,7 +78,7 @@ show-edges :sibling-count="0" @update:page=" - (page) => loadHistory(page, { order: 'DESC', perPage: config.app.default_pagination }) + (page) => load(page, { order: 'DESC', perPage: config.app.default_pagination }) " /> @@ -314,7 +314,7 @@ variant="outline" size="xs" icon="i-lucide-rotate-cw" - @click="void retryItem(item, true)" + @click="() => retryItem(item, true)" > Retry @@ -337,7 +337,7 @@ variant="outline" size="xs" icon="i-lucide-trash" - @click="void removeItem(item)" + @click="() => removeItem(item)" > Remove @@ -608,7 +608,7 @@ variant="outline" icon="i-lucide-rotate-cw" class="w-full justify-center" - @click="void retryItem(item, false)" + @click="() => retryItem(item, false)" > Retry @@ -631,7 +631,7 @@ variant="outline" icon="i-lucide-trash" class="w-full justify-center" - @click="void removeItem(item)" + @click="() => removeItem(item)" > {{ config.app.remove_files ? 'Remove' : 'Clear' }} @@ -703,7 +703,7 @@ show-edges :sibling-count="0" @update:page=" - (page) => loadHistory(page, { order: 'DESC', perPage: config.app.default_pagination }) + (page) => load(page, { order: 'DESC', perPage: config.app.default_pagination }) " /> @@ -786,7 +786,7 @@ const stateStore = useQueueState(); const socketStore = useAppSocket(); const toast = useNotification(); const box = useConfirm(); -const { confirmDialog } = useDialog(); +const { confirmDialog, promptDialog } = useDialog(); const { toggleExpand, expandClass } = useExpandableMeta(); const pendingDownloadFormItem = useState>( 'pending-download-form-item', @@ -797,10 +797,11 @@ const { pagination, isLoading, isLoaded, - loadHistory, - reloadHistory, - deleteHistoryItems, - historyMoveHandler, + load, + reload, + remove, + rename, + moveHandler, } = useHistoryState(); const show_thumbnail = useStorage('show_thumbnail', true); @@ -837,11 +838,11 @@ const paginationInfo = computed(() => ({ isLoaded: isLoaded.value, })); -const handleHistoryItemMoved = historyMoveHandler(); +const handleHistoryItemMoved = moveHandler(); onMounted(async () => { socketStore.on('item_moved', handleHistoryItemMoved); - await loadHistory(1, { order: 'DESC', perPage: config.app.default_pagination }); + await load(1, { order: 'DESC', perPage: config.app.default_pagination }); }); onBeforeUnmount(() => { @@ -1048,6 +1049,12 @@ const itemActionGroups = (item: StoreItem): Array> icon: 'i-lucide-file-code-2', onSelect: () => void generateNfo(item), }); + + mediaActions.push({ + label: 'Rename file', + icon: 'i-lucide-pencil', + onSelect: () => void renameFile(item), + }); } else if (isEmbedable(item.url)) { mediaActions.push({ label: 'Play video', @@ -1127,9 +1134,9 @@ const deleteSelectedItems = async (): Promise => { return; } - await deleteHistoryItems({ ids: [...selectedElms.value], removeFile: config.app.remove_files }); + await remove({ ids: [...selectedElms.value], removeFile: config.app.remove_files }); selectedElms.value = []; - await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination }); + await reload({ order: 'DESC', perPage: config.app.default_pagination }); }; const clearCompleted = async (): Promise => { @@ -1141,9 +1148,9 @@ const clearCompleted = async (): Promise => { selectedElms.value = []; - await deleteHistoryItems({ status: 'finished,skip', removeFile: false }); + await remove({ status: 'finished,skip', removeFile: false }); - await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination }); + await reload({ order: 'DESC', perPage: config.app.default_pagination }); }; const clearIncomplete = async (): Promise => { @@ -1152,8 +1159,8 @@ const clearIncomplete = async (): Promise => { } selectedElms.value = []; - await deleteHistoryItems({ status: '!finished', removeFile: false }); - await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination }); + await remove({ status: '!finished', removeFile: false }); + await reload({ order: 'DESC', perPage: config.app.default_pagination }); }; const setIcon = (item: StoreItem): string => { @@ -1307,10 +1314,10 @@ const archiveItem = async (item: StoreItem, opts = {}): Promise => { } if ((opts as { remove_history?: boolean })?.remove_history) { - await deleteHistoryItems({ ids: [item._id], removeFile: false }); + await remove({ ids: [item._id], removeFile: false }); } - await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination }); + await reload({ order: 'DESC', perPage: config.app.default_pagination }); }; const removeItem = async (item: StoreItem): Promise => { @@ -1324,13 +1331,13 @@ const removeItem = async (item: StoreItem): Promise => { return; } - await deleteHistoryItems({ ids: [item._id], removeFile: config.app.remove_files }); + await remove({ ids: [item._id], removeFile: config.app.remove_files }); if (selectedElms.value.includes(item._id || '')) { selectedElms.value = selectedElms.value.filter((entry) => entry !== item._id); } - await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination }); + await reload({ order: 'DESC', perPage: config.app.default_pagination }); }; const retryItem = async ( @@ -1349,13 +1356,13 @@ const retryItem = async ( auto_start: item.auto_start, }; - await deleteHistoryItems({ ids: [item._id], removeFile: remove_file }); + await remove({ ids: [item._id], removeFile: remove_file }); if (selectedElms.value.includes(item._id || '')) { selectedElms.value = selectedElms.value.filter((entry) => entry !== item._id); } - await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination }); + await reload({ order: 'DESC', perPage: config.app.default_pagination }); if (true === re_add) { toast.info('Cleared the item from history, and added it to the new download form.'); @@ -1532,10 +1539,10 @@ const removeFromArchive = async ( } if (opts?.remove_history) { - await deleteHistoryItems({ ids: [item._id], removeFile: file_delete }); + await remove({ ids: [item._id], removeFile: file_delete }); } - await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination }); + await reload({ order: 'DESC', perPage: config.app.default_pagination }); }; const isQueuedAnimation = (item: StoreItem): string => { @@ -1565,4 +1572,33 @@ const generateNfo = async (item: StoreItem): Promise => { toast.error(`Error: ${error.message}`); } }; + +const renameFile = async (item: StoreItem): Promise => { + if (!item.filename) { + return; + } + + const currentName = item.filename.split('/').pop() || item.filename; + const { status, value: newName } = await promptDialog({ + title: 'Rename File', + message: `Enter new name for '${currentName}' (and its sidecars):`, + initial: currentName, + confirmText: 'Rename', + cancelText: 'Cancel', + }); + + if (!status) { + return; + } + + const trimmedName = (newName || '').trim(); + if (!trimmedName || trimmedName === currentName) { + return; + } + + const success = await rename(item, trimmedName); + if (success) { + toast.success(`Renamed '${currentName}'.`); + } +}; diff --git a/uv.lock b/uv.lock index 4a7ac0ba..48a6a6a4 100644 --- a/uv.lock +++ b/uv.lock @@ -1405,11 +1405,11 @@ wheels = [ [[package]] name = "pytz" -version = "2026.1.post1" +version = "2026.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, ] [[package]]