feat: add rename to history items.

This commit is contained in:
arabcoders 2026-05-04 20:29:34 +03:00
parent 80316df952
commit 285e661667
8 changed files with 384 additions and 73 deletions

38
API.md
View file

@ -24,6 +24,7 @@ This document describes the available endpoints and their usage. All endpoints r
- [DELETE /api/history](#delete-apihistory) - [DELETE /api/history](#delete-apihistory)
- [POST /api/history/{id}](#post-apihistoryid) - [POST /api/history/{id}](#post-apihistoryid)
- [GET /api/history/{id}](#get-apihistoryid) - [GET /api/history/{id}](#get-apihistoryid)
- [POST /api/history/{id}/rename](#post-apihistoryidrename)
- [GET /api/history](#get-apihistory) - [GET /api/history](#get-apihistory)
- [GET /api/history/live](#get-apihistorylive) - [GET /api/history/live](#get-apihistorylive)
- [POST /api/history/start](#post-apihistorystart) - [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/segments/{segment}/{file:.\*}.ts](#get-apiplayersegmentssegmentfilets)
- [GET /api/player/subtitle/{file:.\*}.vtt](#get-apiplayersubtitlefilevtt) - [GET /api/player/subtitle/{file:.\*}.vtt](#get-apiplayersubtitlefilevtt)
- [GET /api/player/subtitles/manifest/{file:.\*}](#get-apiplayersubtitlesmanifestfile) - [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/thumbnail](#get-apithumbnail)
- [GET /api/file/ffprobe/{file:.\*}](#get-apifileffprobefile) - [GET /api/file/ffprobe/{file:.\*}](#get-apifileffprobefile)
- [GET /api/file/info/{file:.\*}](#get-apifileinfofile) - [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": "<uuid>",
"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 ### GET /api/history
**Purpose**: Returns the download queue and/or download history with optional pagination support. **Purpose**: Returns the download queue and/or download history with optional pagination support.

View file

@ -256,7 +256,7 @@ class HttpAPI:
response: Response = await handler(request) 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): if contentType.startswith("text/html") and getattr(response, "_path", None):
rewrite_path: str = base_path.rstrip("/") rewrite_path: str = base_path.rstrip("/")
async with await anyio.open_file(response._path, "rb") as f: async with await anyio.open_file(response._path, "rb") as f:

View file

@ -1,5 +1,6 @@
import asyncio import asyncio
import logging import logging
from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from aiohttp import web from aiohttp import web
@ -10,10 +11,12 @@ from app.features.presets.service import Presets
from app.library.config import Config from app.library.config import Config
from app.library.DataStore import StoreType from app.library.DataStore import StoreType
from app.library.downloads import Download, DownloadQueue 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.encoder import Encoder
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.ItemDTO import Item from app.library.ItemDTO import Item
from app.library.router import route from app.library.router import route
from app.library.Utils import calc_download_path, get_file_sidecar, rename_file
if TYPE_CHECKING: if TYPE_CHECKING:
from library.downloads import Download 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()): if "finished" == item.info.status and (filename := item.info.get_file()):
from app.features.streaming.library.ffprobe import ffprobe
try: try:
from app.features.streaming.library.ffprobe import ffprobe
info["ffprobe"] = await ffprobe(filename) info["ffprobe"] = await ffprobe(filename)
except Exception: except Exception:
pass 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) 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") @route("POST", "api/history/{id}", "item_update")
async def item_update(request: Request, queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response: async def item_update(request: Request, queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response:
""" """

View file

@ -1,12 +1,16 @@
import json import json
from pathlib import Path
from types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
import pytest import pytest
from app.library.encoder import Encoder
from app.library.DataStore import StoreType 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: class _FakeRequest:
@ -14,11 +18,39 @@ class _FakeRequest:
self._payload = payload or {} self._payload = payload or {}
self.query: dict[str, str] = {} self.query: dict[str, str] = {}
self.match_info: dict[str, str] = {} self.match_info: dict[str, str] = {}
self.body_exists = bool(payload)
async def json(self) -> dict[str, Any]: async def json(self) -> dict[str, Any]:
return self._payload 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 @pytest.mark.asyncio
async def test_items_delete_uses_bulk_history_status_clear() -> None: async def test_items_delete_uses_bulk_history_status_clear() -> None:
request = _FakeRequest(payload={"type": StoreType.HISTORY.value, "status": "finished,skip", "remove_file": False}) 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) queue.clear_bulk.assert_awaited_once_with(["a", "b"], remove_file=False)
body = json.loads(response.body.decode("utf-8")) body = json.loads(response.body.decode("utf-8"))
assert body == {"items": {}, "deleted": 2} 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()

View file

@ -32,7 +32,7 @@
:loading="isRefreshing" :loading="isRefreshing"
:disabled="isRefreshing" :disabled="isRefreshing"
:square="isMobile" :square="isMobile"
@click="() => void refreshQueue()" @click="() => refreshQueue()"
> >
<span v-if="!isMobile">Reload Queue</span> <span v-if="!isMobile">Reload Queue</span>
</UButton> </UButton>
@ -329,7 +329,7 @@
variant="soft" variant="soft"
size="xs" size="xs"
icon="i-lucide-play-circle" icon="i-lucide-play-circle"
@click="void stateStore.startItems([item._id])" @click="() => stateStore.startItems([item._id])"
> >
Start Start
</UButton> </UButton>
@ -340,7 +340,7 @@
variant="soft" variant="soft"
size="xs" size="xs"
icon="i-lucide-pause" icon="i-lucide-pause"
@click="void stateStore.pauseItems([item._id])" @click="() => stateStore.pauseItems([item._id])"
> >
Pause Pause
</UButton> </UButton>
@ -350,7 +350,7 @@
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-x" icon="i-lucide-x"
@click="void stateStore.cancelItems([item._id])" @click="() => stateStore.cancelItems([item._id])"
> >
{{ item.is_live ? 'Stop' : 'Cancel' }} {{ item.is_live ? 'Stop' : 'Cancel' }}
</UButton> </UButton>
@ -400,7 +400,7 @@
:sibling-count="0" :sibling-count="0"
@update:page=" @update:page="
(page) => (page) =>
loadHistory(page, { load(page, {
order: 'DESC', order: 'DESC',
perPage: configStore.app.default_pagination, perPage: configStore.app.default_pagination,
}) })
@ -415,7 +415,7 @@
:loading="historyIsLoading" :loading="historyIsLoading"
:disabled="historyIsLoading" :disabled="historyIsLoading"
@click=" @click="
void reloadHistory({ order: 'DESC', perPage: configStore.app.default_pagination }) () => reload({ order: 'DESC', perPage: configStore.app.default_pagination })
" "
> >
Reload Reload
@ -542,7 +542,7 @@
variant="soft" variant="soft"
size="xs" size="xs"
icon="i-lucide-rotate-cw" icon="i-lucide-rotate-cw"
@click="() => void requeueItem(item)" @click="() => requeueItem(item)"
> >
Requeue Requeue
</UButton> </UButton>
@ -552,7 +552,7 @@
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-trash" icon="i-lucide-trash"
@click="() => void deleteHistoryItem(item)" @click="() => deleteHistoryItem(item)"
> >
Delete Delete
</UButton> </UButton>
@ -589,7 +589,7 @@
:sibling-count="0" :sibling-count="0"
@update:page=" @update:page="
(page) => (page) =>
loadHistory(page, { load(page, {
order: 'DESC', order: 'DESC',
perPage: configStore.app.default_pagination, perPage: configStore.app.default_pagination,
}) })
@ -711,10 +711,10 @@ const {
items: historyItems, items: historyItems,
pagination, pagination,
isLoading, isLoading,
loadHistory, load,
reloadHistory, reload,
deleteHistoryItems, remove,
historyMoveHandler, moveHandler,
} = useHistoryState(); } = useHistoryState();
const embedUrl = ref(''); const embedUrl = ref('');
@ -1261,20 +1261,18 @@ const requeueItem = async (item: StoreItem): Promise<void> => {
payload.extras = JSON.parse(JSON.stringify(item.extras)); payload.extras = JSON.parse(JSON.stringify(item.extras));
} }
await deleteHistoryItems({ ids: [item._id], removeFile: false }); await remove({ ids: [item._id], removeFile: false });
await reloadHistory({ order: 'DESC', perPage: configStore.app.default_pagination }); await reload({ order: 'DESC', perPage: configStore.app.default_pagination });
await stateStore.addDownload(payload); await stateStore.addDownload(payload);
}; };
const deleteHistoryItem = async (item: StoreItem): Promise<void> => { const deleteHistoryItem = async (item: StoreItem): Promise<void> => {
await deleteHistoryItems({ ids: [item._id], removeFile: app.value.remove_files }); await remove({ ids: [item._id], removeFile: app.value.remove_files });
await reloadHistory({ order: 'DESC', perPage: configStore.app.default_pagination }); await reload({ order: 'DESC', perPage: configStore.app.default_pagination });
toast.info('Removed from history queue.'); toast.info('Removed from history queue.');
}; };
const handleHistoryItemMoved = historyMoveHandler( const handleHistoryItemMoved = moveHandler(() => simpleMode.value && historyInitialized.value);
() => simpleMode.value && historyInitialized.value,
);
const showMessage = (item: StoreItem): boolean => { const showMessage = (item: StoreItem): boolean => {
if (!item?.msg || item.msg === item?.error) { if (!item?.msg || item.msg === item?.error) {
@ -1303,7 +1301,7 @@ onMounted(async () => {
await normalizeSimpleRoute(); await normalizeSimpleRoute();
historyInitialized.value = true; historyInitialized.value = true;
socketStore.on('item_moved', handleHistoryItemMoved); 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) { if (!socketStore.isConnected && autoRefreshEnabled.value) {
startAutoRefresh(); startAutoRefresh();
@ -1389,7 +1387,7 @@ watch(
return; return;
} }
void loadHistory(1, { order: 'DESC', perPage: configStore.app.default_pagination }); void load(1, { order: 'DESC', perPage: configStore.app.default_pagination });
}, },
); );
</script> </script>

View file

@ -77,7 +77,7 @@ const buildQuery = (
return params.toString(); return params.toString();
}; };
const loadHistory = async (page: number = 1, options: HistoryLoadOptions = {}): Promise<void> => { const load = async (page: number = 1, options: HistoryLoadOptions = {}): Promise<void> => {
const { order = 'DESC', status, perPage = pageSize.value } = options; const { order = 'DESC', status, perPage = pageSize.value } = options;
isLoading.value = true; isLoading.value = true;
@ -104,12 +104,12 @@ const loadHistory = async (page: number = 1, options: HistoryLoadOptions = {}):
} }
}; };
const reloadHistory = async (options: HistoryLoadOptions = {}): Promise<void> => { const reload = async (options: HistoryLoadOptions = {}): Promise<void> => {
const targetPage = isLoaded.value ? pagination.value.page : 1; const targetPage = isLoaded.value ? pagination.value.page : 1;
await loadHistory(targetPage, options); await load(targetPage, options);
}; };
const deleteHistoryItems = async ( const remove = async (
options: { options: {
ids?: string[]; ids?: string[];
status?: string; status?: string;
@ -155,7 +155,39 @@ const deleteHistoryItems = async (
} }
}; };
const resetHistory = (): void => { const rename = async (item: StoreItem, newName: string): Promise<boolean> => {
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 = []; items.value = [];
pagination.value = { pagination.value = {
page: 1, page: 1,
@ -169,7 +201,7 @@ const resetHistory = (): void => {
lastError.value = null; lastError.value = null;
}; };
const addHistoryItem = (item: StoreItem): void => { const upsert = (item: StoreItem): void => {
const existingIndex = items.value.findIndex((existing) => existing._id === item._id); const existingIndex = items.value.findIndex((existing) => existing._id === item._id);
if (existingIndex !== -1) { if (existingIndex !== -1) {
@ -195,7 +227,7 @@ const addHistoryItem = (item: StoreItem): void => {
pagination.value.has_next = pagination.value.page < pagination.value.total_pages; pagination.value.has_next = pagination.value.page < pagination.value.total_pages;
}; };
const historyMoveHandler = ( const moveHandler = (
shouldHandle: () => boolean = () => isLoaded.value, shouldHandle: () => boolean = () => isLoaded.value,
): ((payload: WSEP['item_moved']) => void) => { ): ((payload: WSEP['item_moved']) => void) => {
return (payload: WSEP['item_moved']): void => { return (payload: WSEP['item_moved']): void => {
@ -203,7 +235,7 @@ const historyMoveHandler = (
return; return;
} }
addHistoryItem(payload.data.item); upsert(payload.data.item);
}; };
}; };
@ -215,11 +247,12 @@ export const useHistoryState = () => {
isLoaded, isLoaded,
lastError, lastError,
pageSize, pageSize,
loadHistory, load,
reloadHistory, reload,
deleteHistoryItems, remove,
resetHistory, rename,
upsertHistoryItem: addHistoryItem, reset,
historyMoveHandler, upsert,
moveHandler,
}; };
}; };

View file

@ -50,7 +50,7 @@
icon="i-lucide-refresh-cw" icon="i-lucide-refresh-cw"
:loading="isLoading" :loading="isLoading"
:disabled="isLoading" :disabled="isLoading"
@click="void reloadHistory({ order: 'DESC', perPage: config.app.default_pagination })" @click="() => reload({ order: 'DESC', perPage: config.app.default_pagination })"
> >
<span>Reload</span> <span>Reload</span>
</UButton> </UButton>
@ -78,7 +78,7 @@
show-edges show-edges
:sibling-count="0" :sibling-count="0"
@update:page=" @update:page="
(page) => loadHistory(page, { order: 'DESC', perPage: config.app.default_pagination }) (page) => load(page, { order: 'DESC', perPage: config.app.default_pagination })
" "
/> />
</div> </div>
@ -314,7 +314,7 @@
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-rotate-cw" icon="i-lucide-rotate-cw"
@click="void retryItem(item, true)" @click="() => retryItem(item, true)"
> >
Retry Retry
</UButton> </UButton>
@ -337,7 +337,7 @@
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-trash" icon="i-lucide-trash"
@click="void removeItem(item)" @click="() => removeItem(item)"
> >
Remove Remove
</UButton> </UButton>
@ -608,7 +608,7 @@
variant="outline" variant="outline"
icon="i-lucide-rotate-cw" icon="i-lucide-rotate-cw"
class="w-full justify-center" class="w-full justify-center"
@click="void retryItem(item, false)" @click="() => retryItem(item, false)"
> >
Retry Retry
</UButton> </UButton>
@ -631,7 +631,7 @@
variant="outline" variant="outline"
icon="i-lucide-trash" icon="i-lucide-trash"
class="w-full justify-center" class="w-full justify-center"
@click="void removeItem(item)" @click="() => removeItem(item)"
> >
{{ config.app.remove_files ? 'Remove' : 'Clear' }} {{ config.app.remove_files ? 'Remove' : 'Clear' }}
</UButton> </UButton>
@ -703,7 +703,7 @@
show-edges show-edges
:sibling-count="0" :sibling-count="0"
@update:page=" @update:page="
(page) => loadHistory(page, { order: 'DESC', perPage: config.app.default_pagination }) (page) => load(page, { order: 'DESC', perPage: config.app.default_pagination })
" "
/> />
</div> </div>
@ -786,7 +786,7 @@ const stateStore = useQueueState();
const socketStore = useAppSocket(); const socketStore = useAppSocket();
const toast = useNotification(); const toast = useNotification();
const box = useConfirm(); const box = useConfirm();
const { confirmDialog } = useDialog(); const { confirmDialog, promptDialog } = useDialog();
const { toggleExpand, expandClass } = useExpandableMeta(); const { toggleExpand, expandClass } = useExpandableMeta();
const pendingDownloadFormItem = useState<item_request | Record<string, never>>( const pendingDownloadFormItem = useState<item_request | Record<string, never>>(
'pending-download-form-item', 'pending-download-form-item',
@ -797,10 +797,11 @@ const {
pagination, pagination,
isLoading, isLoading,
isLoaded, isLoaded,
loadHistory, load,
reloadHistory, reload,
deleteHistoryItems, remove,
historyMoveHandler, rename,
moveHandler,
} = useHistoryState(); } = useHistoryState();
const show_thumbnail = useStorage<boolean>('show_thumbnail', true); const show_thumbnail = useStorage<boolean>('show_thumbnail', true);
@ -837,11 +838,11 @@ const paginationInfo = computed(() => ({
isLoaded: isLoaded.value, isLoaded: isLoaded.value,
})); }));
const handleHistoryItemMoved = historyMoveHandler(); const handleHistoryItemMoved = moveHandler();
onMounted(async () => { onMounted(async () => {
socketStore.on('item_moved', handleHistoryItemMoved); 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(() => { onBeforeUnmount(() => {
@ -1048,6 +1049,12 @@ const itemActionGroups = (item: StoreItem): Array<Array<Record<string, unknown>>
icon: 'i-lucide-file-code-2', icon: 'i-lucide-file-code-2',
onSelect: () => void generateNfo(item), onSelect: () => void generateNfo(item),
}); });
mediaActions.push({
label: 'Rename file',
icon: 'i-lucide-pencil',
onSelect: () => void renameFile(item),
});
} else if (isEmbedable(item.url)) { } else if (isEmbedable(item.url)) {
mediaActions.push({ mediaActions.push({
label: 'Play video', label: 'Play video',
@ -1127,9 +1134,9 @@ const deleteSelectedItems = async (): Promise<void> => {
return; return;
} }
await deleteHistoryItems({ ids: [...selectedElms.value], removeFile: config.app.remove_files }); await remove({ ids: [...selectedElms.value], removeFile: config.app.remove_files });
selectedElms.value = []; selectedElms.value = [];
await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination }); await reload({ order: 'DESC', perPage: config.app.default_pagination });
}; };
const clearCompleted = async (): Promise<void> => { const clearCompleted = async (): Promise<void> => {
@ -1141,9 +1148,9 @@ const clearCompleted = async (): Promise<void> => {
selectedElms.value = []; 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<void> => { const clearIncomplete = async (): Promise<void> => {
@ -1152,8 +1159,8 @@ const clearIncomplete = async (): Promise<void> => {
} }
selectedElms.value = []; selectedElms.value = [];
await deleteHistoryItems({ status: '!finished', removeFile: false }); await remove({ status: '!finished', removeFile: false });
await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination }); await reload({ order: 'DESC', perPage: config.app.default_pagination });
}; };
const setIcon = (item: StoreItem): string => { const setIcon = (item: StoreItem): string => {
@ -1307,10 +1314,10 @@ const archiveItem = async (item: StoreItem, opts = {}): Promise<void> => {
} }
if ((opts as { remove_history?: boolean })?.remove_history) { 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<void> => { const removeItem = async (item: StoreItem): Promise<void> => {
@ -1324,13 +1331,13 @@ const removeItem = async (item: StoreItem): Promise<void> => {
return; 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 || '')) { if (selectedElms.value.includes(item._id || '')) {
selectedElms.value = selectedElms.value.filter((entry) => entry !== 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 ( const retryItem = async (
@ -1349,13 +1356,13 @@ const retryItem = async (
auto_start: item.auto_start, 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 || '')) { if (selectedElms.value.includes(item._id || '')) {
selectedElms.value = selectedElms.value.filter((entry) => entry !== 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) { if (true === re_add) {
toast.info('Cleared the item from history, and added it to the new download form.'); 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) { 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 => { const isQueuedAnimation = (item: StoreItem): string => {
@ -1565,4 +1572,33 @@ const generateNfo = async (item: StoreItem): Promise<void> => {
toast.error(`Error: ${error.message}`); toast.error(`Error: ${error.message}`);
} }
}; };
const renameFile = async (item: StoreItem): Promise<void> => {
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}'.`);
}
};
</script> </script>

View file

@ -1405,11 +1405,11 @@ wheels = [
[[package]] [[package]]
name = "pytz" name = "pytz"
version = "2026.1.post1" version = "2026.2"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]