feat: add rename to history items.
This commit is contained in:
parent
80316df952
commit
285e661667
8 changed files with 384 additions and 73 deletions
38
API.md
38
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": "<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
|
||||
**Purpose**: Returns the download queue and/or download history with optional pagination support.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
:loading="isRefreshing"
|
||||
:disabled="isRefreshing"
|
||||
:square="isMobile"
|
||||
@click="() => void refreshQueue()"
|
||||
@click="() => refreshQueue()"
|
||||
>
|
||||
<span v-if="!isMobile">Reload Queue</span>
|
||||
</UButton>
|
||||
|
|
@ -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
|
||||
</UButton>
|
||||
|
|
@ -340,7 +340,7 @@
|
|||
variant="soft"
|
||||
size="xs"
|
||||
icon="i-lucide-pause"
|
||||
@click="void stateStore.pauseItems([item._id])"
|
||||
@click="() => stateStore.pauseItems([item._id])"
|
||||
>
|
||||
Pause
|
||||
</UButton>
|
||||
|
|
@ -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' }}
|
||||
</UButton>
|
||||
|
|
@ -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
|
||||
</UButton>
|
||||
|
|
@ -552,7 +552,7 @@
|
|||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-trash"
|
||||
@click="() => void deleteHistoryItem(item)"
|
||||
@click="() => deleteHistoryItem(item)"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
|
|
@ -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<void> => {
|
|||
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<void> => {
|
||||
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 });
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ const buildQuery = (
|
|||
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;
|
||||
|
||||
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;
|
||||
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<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 = [];
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 })"
|
||||
>
|
||||
<span>Reload</span>
|
||||
</UButton>
|
||||
|
|
@ -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 })
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -314,7 +314,7 @@
|
|||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-rotate-cw"
|
||||
@click="void retryItem(item, true)"
|
||||
@click="() => retryItem(item, true)"
|
||||
>
|
||||
Retry
|
||||
</UButton>
|
||||
|
|
@ -337,7 +337,7 @@
|
|||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-trash"
|
||||
@click="void removeItem(item)"
|
||||
@click="() => removeItem(item)"
|
||||
>
|
||||
Remove
|
||||
</UButton>
|
||||
|
|
@ -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
|
||||
</UButton>
|
||||
|
|
@ -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' }}
|
||||
</UButton>
|
||||
|
|
@ -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 })
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -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<item_request | Record<string, never>>(
|
||||
'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<boolean>('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<Array<Record<string, unknown>>
|
|||
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<void> => {
|
|||
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<void> => {
|
||||
|
|
@ -1141,9 +1148,9 @@ const clearCompleted = async (): Promise<void> => {
|
|||
|
||||
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> => {
|
||||
|
|
@ -1152,8 +1159,8 @@ const clearIncomplete = async (): Promise<void> => {
|
|||
}
|
||||
|
||||
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<void> => {
|
|||
}
|
||||
|
||||
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> => {
|
||||
|
|
@ -1324,13 +1331,13 @@ const removeItem = async (item: StoreItem): Promise<void> => {
|
|||
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<void> => {
|
|||
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>
|
||||
|
|
|
|||
6
uv.lock
6
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]]
|
||||
|
|
|
|||
Loading…
Reference in a new issue