Merge pull request #566 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled

fix: improve exception handling in status tracker
This commit is contained in:
Abdulmohsen 2026-02-13 22:07:05 +03:00 committed by GitHub
commit c36295d76f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1275 additions and 487 deletions

View file

@ -124,6 +124,7 @@
"Microformat",
"microformats",
"mkvtoolsnix",
"MMDD",
"movflags",
"mpegts",
"msvideo",
@ -264,5 +265,7 @@
"url": "./app/schema/tasks.json"
}
],
"python.testing.cwd": "app/tests"
"python.testing.pytestArgs": [
"app"
]
}

91
API.md
View file

@ -31,6 +31,7 @@ This document describes the available endpoints and their usage. All endpoints r
- [POST /api/history/cancel](#post-apihistorycancel)
- [DELETE /api/history/{id}/archive](#delete-apihistoryidarchive)
- [POST /api/history/{id}/archive](#post-apihistoryidarchive)
- [POST /api/history/{id}/nfo](#post-apihistoryidnfo)
- [GET /api/archiver](#get-apiarchiver)
- [POST /api/archiver](#post-apiarchiver)
- [DELETE /api/archiver](#delete-apiarchiver)
@ -770,6 +771,58 @@ or an error:
---
### POST /api/history/{id}/nfo
**Purpose**: Generate an NFO metadata file for a completed download.
**Path Parameter**:
- `id`: Item ID from the history.
**Body** (optional):
```json
{
"type": "tv",
"overwrite": false
}
```
**Body Parameters**:
- `type` (string, optional): NFO format type. Either `"tv"` or `"movie"`. Default: `"tv"`.
- `overwrite` (boolean, optional): Overwrite existing NFO file. Default: `false`.
**Response** (success):
```json
{
"message": "NFO file created",
"nfo_file": "/path/to/file.nfo"
}
```
**Response** (NFO already exists):
```json
{
"error": "NFO file already exists."
}
```
**Error Responses**:
- `400 Bad Request` if:
- Item has no downloaded file
- `type` is not `"tv"` or `"movie"`
- Failed to collect NFO data (e.g., invalid/no date)
- `404 Not Found` if the item or media file doesn't exist
- `409 Conflict` if NFO file already exists and `overwrite` is `false`
- `500 Internal Server Error` if failed to extract metadata from URL
**Notes**:
- Fetches fresh metadata from the source URL using yt-dlp
- Creates NFO file in the same directory as the media file with `.nfo` extension
- Requires a valid upload/release date in the metadata to create NFO
- The NFO format follows Kodi/Jellyfin/Emby compatible structure
- TV mode generates `<episodedetails>` XML
- Movie mode generates `<movie>` XML
---
### GET /api/archiver
**Purpose**: Read entries from the download archive associated with a preset.
@ -1523,6 +1576,13 @@ Binary image data with the appropriate `Content-Type`.
**Path Parameter**:
- `path` = Relative path within the download directory (URL-encoded).
**Query Parameters**:
- `page` (optional): Page number (1-indexed). Default: `1`.
- `per_page` (optional): Items per page. Default: `config.default_pagination`, Max: `1000`.
- `sort_by` (optional): Sort field. Options: `name`, `size`, `date`, `type`. Default: `name`.
- `sort_order` (optional): Sort direction. Options: `asc`, `desc`. Default: `asc`.
- `search` (optional): Filter by filename (case-insensitive).
**Response**:
```json
{
@ -1534,12 +1594,12 @@ Binary image data with the appropriate `Content-Type`.
"name": "filename.mp4",
"path": "/filename.mp4",
"size": 123456789,
"mimetype": "mime/type",
"mime": "mime/type",
"mtime": "2023-01-01T12:00:00Z",
"ctime": "2023-01-01T12:00:00Z",
"is_dir": true|false,
"is_file": true|false,
...
"is_symlink": true|false
},
{
"type": "dir",
@ -1548,11 +1608,36 @@ Binary image data with the appropriate `Content-Type`.
"path": "/Season 2025",
...
}
]
],
"pagination": {
"page": 1,
"per_page": 50,
"total": 123,
"total_pages": 3,
"has_next": true,
"has_prev": false
}
}
```
**Examples**:
```bash
# Get first page of root directory
GET /api/file/browser/
# Get second page of videos folder, sorted by size descending
GET /api/file/browser/videos?page=2&sort_by=size&sort_order=desc
# Search for mp4 files
GET /api/file/browser/videos?search=mp4
# Sort by date, newest first
GET /api/file/browser/videos?sort_by=date&sort_order=desc
```
- Returns `403 Forbidden` if file browser is disabled.
- Returns `404 Not Found` if the path doesn't exist.
- Returns `400 Bad Request` if the path is not a directory.
---

View file

@ -814,16 +814,29 @@ def get(
return data
def get_files(base_path: Path | str, dir: str | None = None):
def get_files(
base_path: Path | str,
dir: str | None = None,
page: int = 1,
per_page: int = 0,
sort_by: str = "name",
sort_order: str = "asc",
search: str | None = None,
) -> tuple[list, int]:
"""
Get directory contents.
Get directory contents with optional pagination, sorting, and search.
Args:
base_path (Path|str): Base download path.
dir (str): Directory to check.
page (int): Page number (1-indexed). Ignored if per_page is 0.
per_page (int): Items per page. If 0, returns all items.
sort_by (str): Sort field: name, size, date, type.
sort_order (str): Sort direction: asc, desc.
search (str|None): Filter by filename (case-insensitive).
Returns:
list: List of files and directories.
tuple[list, int]: List of file/directory dicts and total count (before pagination).
Raises:
OSError: If the directory is invalid or not a directory.
@ -909,7 +922,26 @@ def get_files(base_path: Path | str, dir: str | None = None):
}
)
return contents
total: int = len(contents)
if search:
search_lower: str = search.lower()
contents = [c for c in contents if search_lower in c["name"].lower()]
if sort_by == "name":
contents.sort(key=lambda x: x["name"].lower(), reverse=(sort_order.lower() == "desc"))
elif sort_by == "size":
contents.sort(key=lambda x: x["size"], reverse=(sort_order.lower() == "desc"))
elif sort_by == "date":
contents.sort(key=lambda x: x["mtime"], reverse=(sort_order.lower() == "desc"))
elif sort_by == "type":
contents.sort(key=lambda x: x["content_type"], reverse=(sort_order.lower() == "desc"))
if per_page > 0:
offset: int = (page - 1) * per_page
contents = contents[offset : offset + per_page]
return contents, total
def clean_item(item: dict, keys: list | tuple) -> tuple[dict, bool]:

View file

@ -301,7 +301,6 @@ class Download:
self.info.status = "cancelled"
return ret
self._status_tracker.put_terminator()
await self._status_tracker.drain_queue()
return ret

View file

@ -46,6 +46,18 @@ class HookHandlers:
)
def postprocessor_hook(self, data: dict[str, Any]) -> None:
info_dict = data.get("info_dict", {})
filepath = info_dict.get("filepath")
status: dict[str, Any] = {
"id": self.id,
"action": "postprocessing",
**{k: v for k, v in data.items() if k in YTDLP_PROGRESS_FIELDS},
"status": "postprocessing",
}
if filepath:
status["filepath"] = filepath
if self.debug:
try:
d_safe = create_debug_safe_dict(data)
@ -54,14 +66,7 @@ class HookHandlers:
except Exception as e:
self.logger.debug(f"PP Hook: Error creating debug info: {e}")
self.status_queue.put(
{
"id": self.id,
"action": "postprocessing",
**{k: v for k, v in data.items() if k in YTDLP_PROGRESS_FIELDS},
"status": "postprocessing",
}
)
self.status_queue.put(status)
def post_hook(self, filename: str) -> None:
self.status_queue.put({"id": self.id, "status": "finished", "final_name": filename})

View file

@ -2,6 +2,7 @@
import asyncio
import logging
import queue
import time
from email.utils import formatdate
from pathlib import Path
@ -52,8 +53,45 @@ class StatusTracker:
self._notify: EventBus = EventBus.get_instance()
self.tmpfilename: str | None = None
self.final_update = False
self._terminator_sent: bool = False
self._candidate_filepath: Path | None = None
self.update_task: asyncio.Task | None = None
async def _finalize_file(self, filepath: Path) -> None:
"""
Set filename, file_size, and run ffprobe on completed file.
Args:
filepath: Path to the finalized download file
"""
self.info.datetime = str(formatdate(time.time()))
self.info.filename = safe_relative_path(filepath, Path(self.download_dir), self.temp_path)
self.final_update = True
self.logger.debug(f"Final file name: '{filepath}'.")
if filepath.is_file() and filepath.exists():
try:
self.info.file_size = filepath.stat().st_size
except FileNotFoundError:
self.info.file_size = 0
try:
from app.features.streaming.library.ffprobe import ffprobe
ff = await ffprobe(filepath)
self.info.extras["is_video"] = ff.has_video()
self.info.extras["is_audio"] = ff.has_audio()
if ff.has_video() or ff.has_audio():
self.info.extras["duration"] = int(
float(ff.metadata.get("duration", self.info.extras.get("duration", 0.0)))
)
except Exception as e:
self.info.extras["is_video"] = True
self.info.extras["is_audio"] = True
self.logger.exception(e)
self.logger.error(f"Failed to run ffprobe. {e}")
async def process_status_update(self, status: StatusDict) -> None:
"""
Process a single status update from the download subprocess.
@ -85,16 +123,22 @@ class StatusTracker:
self.info.msg = status.get("msg")
self.info.postprocessor = status.get("postprocessor", None)
if "error" == self.info.status and "error" in status:
if filepath := status.get("filepath"):
self._candidate_filepath = Path(filepath)
if self.info.status == "error" and "error" in status:
self.info.error = status.get("error")
if self._candidate_filepath and self._candidate_filepath.exists():
self.logger.debug(f"Cleaning up partial file: {self._candidate_filepath}")
await self._finalize_file(self._candidate_filepath)
self._notify.emit(
Events.LOG_ERROR,
data=self.info,
title="Download Error",
message=f"'{self.info.title}' failed to download. {self.info.error}",
)
if "downloaded_bytes" in status and status.get("downloaded_bytes", 0) > 0:
elif "downloaded_bytes" in status and status.get("downloaded_bytes", 0) > 0:
self.info.downloaded_bytes = status.get("downloaded_bytes")
total: float | None = status.get("total_bytes") or status.get("total_bytes_estimate")
if total:
@ -108,34 +152,9 @@ class StatusTracker:
self.info.eta = status.get("eta")
if final_name := status.get("final_name", None):
final_name = Path(final_name)
self.info.datetime = str(formatdate(time.time()))
self._candidate_filepath = None
await self._finalize_file(Path(final_name))
self.info.status = "finished"
self.final_update = True
self.info.filename = safe_relative_path(final_name, Path(self.download_dir), self.temp_path)
self.logger.debug(f"Final file name: '{final_name}'.")
if final_name.is_file() and final_name.exists():
try:
self.info.file_size = final_name.stat().st_size
except FileNotFoundError:
self.info.file_size = 0
try:
from app.features.streaming.library.ffprobe import ffprobe
ff = await ffprobe(final_name)
self.info.extras["is_video"] = ff.has_video()
self.info.extras["is_audio"] = ff.has_audio()
if ff.has_video() or ff.has_audio():
self.info.extras["duration"] = int(
float(ff.metadata.get("duration", self.info.extras.get("duration", 0.0)))
)
except Exception as e:
self.info.extras["is_video"] = True
self.info.extras["is_audio"] = True
self.logger.exception(e)
self.logger.error(f"Failed to run ffprobe. {e}")
self._notify.emit(Events.ITEM_UPDATED, data=self.info)
@ -152,7 +171,7 @@ class StatusTracker:
if status is None or isinstance(status, Terminator):
return
await self.process_status_update(status)
except (asyncio.CancelledError, OSError, FileNotFoundError):
except (asyncio.CancelledError, OSError, FileNotFoundError, EOFError, BrokenPipeError, ConnectionError):
return
async def drain_queue(self, max_iterations: int = 50) -> None:
@ -165,7 +184,6 @@ class StatusTracker:
max_iterations: Maximum number of items to drain
"""
self.logger.debug("Draining status queue.")
try:
drain_count: int = max_iterations + (
self.status_queue.qsize() if hasattr(self.status_queue, "qsize") else 5
@ -174,18 +192,16 @@ class StatusTracker:
drain_count = max_iterations + 5
for i in range(drain_count):
if self.final_update:
self.logger.info(f"({max_iterations}/{i}) Draining stopped. Final update received.")
break
try:
if self.debug:
self.logger.debug(f"({max_iterations}/{i}) Draining the status queue...")
if self.final_update:
if self.debug:
self.logger.debug(f"({max_iterations}/{i}) Draining stopped. Final update received.")
break
next_status = self.status_queue.get(timeout=0.1)
if next_status is None or isinstance(next_status, Terminator):
continue
await self.process_status_update(next_status)
except Exception: # noqa: S112
except (queue.Empty, BrokenPipeError, ConnectionRefusedError, EOFError, OSError):
continue
def cancel_update_task(self) -> None:
@ -198,5 +214,11 @@ class StatusTracker:
def put_terminator(self) -> None:
"""Put a terminator sentinel in the status queue."""
if self.status_queue:
if not self.status_queue or self._terminator_sent:
return
try:
self.status_queue.put(Terminator())
self._terminator_sent = True
except (BrokenPipeError, ConnectionRefusedError, ConnectionResetError, EOFError, OSError):
if self.debug:
self.logger.debug("Status queue closed, could not send terminator.")

View file

@ -7,6 +7,7 @@ from urllib.parse import unquote_plus
from aiohttp import web
from aiohttp.web import Request, Response
from app.features.core.utils import build_pagination, normalize_pagination
from app.features.streaming.library.ffprobe import ffprobe
from app.library.cache import Cache
from app.library.config import Config
@ -134,16 +135,15 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re
req_path: str = request.match_info.get("path")
req_path: str = "/" if not req_path else unquote_plus(req_path)
# Normalize requested path to always be inside download root.
raw_req: str = (req_path or "").strip()
root_dir: Path = Path(config.download_path).resolve()
if raw_req in ("", "/"):
test: Path = root_dir
rel_for_listing = "/"
rel_for_listing: str = "/"
else:
# Strip leading slash so joinpath doesn't ignore the base path.
test = root_dir.joinpath(raw_req.lstrip("/")).resolve(strict=False)
rel_for_listing = raw_req.lstrip("/")
rel_for_listing: str = raw_req.lstrip("/")
try:
test.relative_to(root_dir)
@ -163,10 +163,34 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re
)
try:
page, per_page = normalize_pagination(request)
sort_by: str = request.query.get("sort_by", "name")
sort_order: str = request.query.get("sort_order", "asc")
search: str | None = request.query.get("search")
if sort_by not in ("name", "size", "date", "type"):
sort_by = "name"
if sort_order not in ("asc", "desc"):
sort_order = "asc"
contents, total = get_files(
base_path=root_dir,
dir=rel_for_listing,
page=page,
per_page=per_page,
sort_by=sort_by,
sort_order=sort_order,
search=search,
)
total_pages: int = (total + per_page - 1) // per_page if total > 0 else 1
return web.json_response(
data={
"path": rel_for_listing,
"contents": get_files(base_path=root_dir, dir=rel_for_listing),
"contents": contents,
"pagination": build_pagination(total, page, per_page, total_pages),
},
status=web.HTTPOk.status_code,
dumps=encoder.encode,

View file

@ -637,3 +637,96 @@ async def item_archive_delete(request: Request, queue: DownloadQueue, notify: Ev
data={"message": f"item '{item.info.title}' removed from archive."},
status=web.HTTPOk.status_code,
)
@route("POST", r"api/history/{id}/nfo", "history.item.nfo.generate")
async def item_nfo_generate(request: Request, queue: DownloadQueue) -> Response:
"""
Generate NFO file for an existing download.
Args:
request: The request object
queue: DownloadQueue instance
Request Body:
type: 'tv' or 'movie' (default: 'tv')
overwrite: bool (default: false)
Returns:
Response with NFO file path on success
"""
from app.features.ytdlp.extractor import fetch_info
from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP
if not (id := request.match_info.get("id")):
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
try:
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)
except KeyError:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
if not item.info.filename:
return web.json_response(
data={"error": "item has no downloaded file."},
status=web.HTTPBadRequest.status_code,
)
filepath = item.info.get_file()
if not filepath or not filepath.exists():
return web.json_response(
data={"error": f"file '{item.info.filename}' not found."},
status=web.HTTPNotFound.status_code,
)
post = {}
if request.body_exists:
try:
post = await request.json() or {}
except Exception:
post = {}
mode: str = str(post.get("type", "tv")).lower()
if mode not in ("tv", "movie"):
return web.json_response(
data={"error": "type must be 'tv' or 'movie'."},
status=web.HTTPBadRequest.status_code,
)
try:
(info_dict, _logs) = await fetch_info(
config=item.info.get_ytdlp_opts().get_all(),
url=item.info.url,
no_archive=True,
follow_redirect=True,
)
if not info_dict:
return web.json_response(
data={"error": "failed to extract metadata from URL."},
status=web.HTTPInternalServerError.status_code,
)
result = NFOMakerPP.generate_nfo(
info_dict=info_dict,
filepath=filepath,
mode=mode,
overwrite=bool(post.get("overwrite", False)),
logger=LOG,
)
return web.json_response(
data={"message": result["message"], "nfo_file": result.get("nfo_file")}
if result["success"]
else {"error": result["message"]},
status=web.HTTPOk.status_code if result["success"] else web.HTTPBadRequest.status_code,
)
except Exception as e:
LOG.exception(f"Failed to generate NFO for item '{id}': {e}")
return web.json_response(
data={"error": f"failed to generate NFO: {e!s}"},
status=web.HTTPInternalServerError.status_code,
)

127
app/tests/test_nfo_maker.py Normal file
View file

@ -0,0 +1,127 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP
TEST_DIR = Path("var/tmp/tests_nfo")
TEST_DIR.mkdir(parents=True, exist_ok=True)
def _clean_file(path: Path) -> None:
try:
path.unlink()
except FileNotFoundError:
pass
def sample_info_tv() -> dict:
return {
"id": "abc123",
"title": "Test Show - S01E02",
"uploader": "uploader_name",
"upload_date": "20230102",
"season_number": 1,
"episode_number": 2,
"description": "First line.\n#tag\n00:01:23 Intro",
"extractor": "youtube",
"filename": str(TEST_DIR / "test_show_s01e02.mkv"),
}
def sample_info_movie() -> dict:
return {
"id": "mov001",
"title": "Test Movie",
"uploader": "studio",
"release_date": "20221225",
"duration": 7200,
"thumbnail": "http://example.com/thumb.jpg",
"webpage_url": "http://example.com/trailer",
"filename": str(TEST_DIR / "test_movie.mp4"),
}
def test_generate_nfo_tv_mode(tmp_path):
# arrange
TEST_DIR.mkdir(parents=True, exist_ok=True)
info = sample_info_tv()
media_file = Path(info["filename"])
media_file.write_text("dummy")
nfo_path = media_file.with_suffix(".nfo")
_clean_file(nfo_path)
# act
res = NFOMakerPP.generate_nfo(info_dict=info, filepath=media_file, mode="tv", overwrite=True, prefix=True)
# assert
assert res["success"] is True
assert nfo_path.exists()
content = nfo_path.read_text(encoding="utf-8")
assert "<episodedetails>" in content
assert "<title>Test Show - S01E02</title>" in content
# description cleaned (no timestamps / hashtags)
assert "Intro" not in content
assert "#tag" not in content
# also ensure PostProcessor.run wrapper produces the same result and syncs mtime
_clean_file(nfo_path)
pp = NFOMakerPP(None, mode="tv", prefix=True)
media_mtime = media_file.stat().st_mtime
_, info_out = pp.run(info.copy())
assert nfo_path.exists()
nfo_mtime = nfo_path.stat().st_mtime
assert abs(nfo_mtime - media_mtime) < 2.0
# cleanup
media_file.unlink()
nfo_path.unlink()
def test_generate_nfo_movie_mode_and_run_wrapper(tmp_path):
# arrange
TEST_DIR.mkdir(parents=True, exist_ok=True)
info = sample_info_movie()
media_file = Path(info["filename"])
media_file.write_text("dummy-movie")
nfo_path = media_file.with_suffix(".nfo")
_clean_file(nfo_path)
# act: generate_nfo static
res = NFOMakerPP.generate_nfo(info_dict=info, filepath=media_file, mode="movie", overwrite=True, prefix=False)
# assert static helper
assert res["success"] is True
assert nfo_path.exists()
content = nfo_path.read_text(encoding="utf-8")
assert "<movie>" in content
assert "<title>Test Movie</title>" in content
assert "<runtime>7200</runtime>" in content or "<runtime>7200" in content
# cleanup nfo and re-run via PP.run wrapper
nfo_path.unlink()
# call through instance run() to ensure wrapper works (mtime sync path exercised)
pp = NFOMakerPP(None, mode="movie", prefix=False)
# ensure file exists
assert media_file.exists()
# capture media file mtime before run
media_mtime = media_file.stat().st_mtime
_, info_out = pp.run(info.copy())
# run() should not raise and should produce nfo file
assert nfo_path.exists()
# verify mtime was synced (allow small rounding differences)
nfo_mtime = nfo_path.stat().st_mtime
assert abs(nfo_mtime - media_mtime) < 2.0
# cleanup
media_file.unlink()
nfo_path.unlink()

View file

@ -1488,13 +1488,13 @@ class TestGetFiles:
def test_get_files_root(self):
"""Test getting files from root directory."""
result = get_files(self.base_path)
result, total = get_files(self.base_path)
assert isinstance(result, list)
assert len(result) > 0
def test_get_files_subdir(self):
"""Test getting files from subdirectory."""
result = get_files(self.base_path, "subdir")
result, total = get_files(self.base_path, "subdir")
assert isinstance(result, list)

View file

@ -124,57 +124,105 @@ class NFOMakerPP(PostProcessor):
self.report_warning("No info provided to NFO Maker.")
return [], {}
if self.mode not in self._MODE:
self.report_warning(f"Invalid mode '{self.mode}'.")
return [], info
# prefer explicit final path if present, else fall back to filename
base_path = info.get("filename")
if not base_path:
self.report_warning("No 'filename' provided, skipping NFO creation.")
return [], info
base_path = Path(base_path)
result = NFOMakerPP.generate_nfo(
info_dict=info,
filepath=base_path,
mode=self.mode,
overwrite=False,
prefix=self.prefix,
logger=getattr(self, "_downloader", None) or None,
)
nfo_file = base_path.with_suffix(".nfo")
if nfo_file.exists():
self.report_warning(f"NFO file '{nfo_file!s}' already exists, skipping creation.")
if not result.get("success"):
self.report_warning(result.get("message", "NFO generation failed"))
return [], info
try:
nfo_data = self._collect_nfo_data(info)
except Exception as e:
if self._downloader:
self._downloader.report_error(f"NFO data collection failed: {e}")
return [], info
if 1 > len(nfo_data):
self.report_warning("No metadata found to write to NFO file.")
return [], info
# derive year from any date if missing
if ("year" not in nfo_data) and any(k in nfo_data for k in self._DATE_FIELDS):
try:
first_date: str = next((str(nfo_data[k]) for k in self._DATE_FIELDS if nfo_data.get(k)), "")
if first_date:
nfo_data["year"] = first_date.split("-", maxsplit=1)[0]
except Exception as e:
self.report_warning(f"Error extracting year from date: {e}")
status: bool = self._write_episode_info(nfo_file, base_path, nfo_data)
if status and nfo_file.exists() and base_path.exists:
try:
mtime: float = base_path.stat().st_mtime
self.try_utime(str(nfo_file), mtime, mtime)
except Exception as e:
self.report_warning(f"Failed to sync NFO mtime: {e}")
nfo_path = Path(base_path).with_suffix(".nfo")
if nfo_path.exists() and Path(base_path).exists():
try:
mtime: float = Path(base_path).stat().st_mtime
self.try_utime(str(nfo_path), mtime, mtime)
except Exception as e:
self.report_warning(f"Failed to sync NFO mtime: {e}")
except Exception:
pass
return [], info
def _collect_nfo_data(self, info: dict) -> dict[str, Any]:
@staticmethod
def generate_nfo(
*,
info_dict: dict,
filepath: str | Path,
mode: str = "tv",
overwrite: bool = False,
prefix: bool = True,
logger: Any | None = None,
) -> dict[str, Any]:
"""
Public helper to generate an NFO outside the PostProcessor cycle.
Returns:
dict: {"success": bool, "message": str, "nfo_file": Optional[str]}
"""
try:
if not info_dict:
return {"success": False, "message": "no metadata provided"}
mode = str(mode).lower()
if mode not in NFOMakerPP._MODE:
return {"success": False, "message": f"invalid mode '{mode}'"}
base_path = Path(filepath)
nfo_file = base_path.with_suffix(".nfo")
if nfo_file.exists() and not overwrite:
return {"success": False, "message": "NFO file already exists"}
nfo_data = NFOMakerPP._collect_nfo_data(info_dict, mode, None)
if 1 > len(nfo_data):
return {"success": False, "message": "no metadata found to write to NFO file"}
# derive year from any date if missing
if ("year" not in nfo_data) and any(k in nfo_data for k in NFOMakerPP._DATE_FIELDS):
try:
first_date: str = next((str(nfo_data[k]) for k in NFOMakerPP._DATE_FIELDS if nfo_data.get(k)), "")
if first_date:
nfo_data["year"] = first_date.split("-", maxsplit=1)[0]
except Exception:
pass
ok = NFOMakerPP._write_file(logger, nfo_file, base_path, nfo_data, prefix, mode)
if ok and nfo_file.exists():
return {"success": True, "message": "NFO file created", "nfo_file": str(nfo_file)}
return {"success": False, "message": "failed to write NFO"}
except Exception as e:
if logger and hasattr(logger, "exception"):
logger.exception(f"generate_nfo failed: {e}")
return {"success": False, "message": f"generate_nfo failed: {e!s}"}
@staticmethod
def _collect_nfo_data(info: dict, mode: str, downloader: Any | None = None) -> dict[str, Any]:
"""
Collect replacement data for templates without depending on an instance.
Arguments:
info: extracted info dict
mode: 'tv' or 'movie'
downloader: optional downloader/logger to report errors
"""
data: dict[str, Any] = {}
for nfo_name, spec in self._MODE[self.mode].get("mapping", {}).items():
for nfo_name, spec in NFOMakerPP._MODE[mode].get("mapping", {}).items():
try:
values = spec if isinstance(spec, tuple) else (spec,)
resolved_key = None
@ -190,7 +238,7 @@ class NFOMakerPP(PostProcessor):
if raw is None:
continue
resolved_key = field
resolved_val = self._coerce_value(raw, fmt)
resolved_val = NFOMakerPP._coerce_value(raw, fmt)
if resolved_val not in (None, ""):
break
continue
@ -206,8 +254,8 @@ class NFOMakerPP(PostProcessor):
continue
# normalize dates if source key is a known date
if resolved_key in self._DATE_FIELDS:
resolved_val = self._normalize_date(resolved_val)
if resolved_key in NFOMakerPP._DATE_FIELDS:
resolved_val = NFOMakerPP._normalize_date(resolved_val)
# collapse multiline descriptions
if "description" == resolved_key and isinstance(resolved_val, str):
@ -217,44 +265,73 @@ class NFOMakerPP(PostProcessor):
data[nfo_name] = resolved_val
except Exception as e:
if self._downloader:
self._downloader.report_error(f"Error processing {nfo_name} -> {spec}: {e}")
if downloader:
try:
downloader.report_error(f"Error processing {nfo_name} -> {spec}: {e}")
except Exception:
pass
return data
def _write_episode_info(self, nfo_file: Path, real_file: Path, data: dict) -> bool:
@staticmethod
def _write_file(reporter: Any | None, nfo_file: Path, real_file: Path, data: dict, prefix: bool, mode: str) -> bool:
"""
Write episode/movie NFO using independent parameters.
reporter: object with optional methods `to_screen(msg)` and `report_warning(msg)`; if a logger is given uses .info/.warning
prefix: whether to prefix episode numbers
mode: 'tv' or 'movie'
"""
aired = str(
data.get("aired") or data.get("premiered") or data.get("release_date") or data.get("upload_date") or ""
)
aired = self._normalize_date(aired) if aired else ""
aired = NFOMakerPP._normalize_date(aired) if aired else ""
if not aired or 3 > len(aired.split("-")):
self.report_warning("Invalid aired/premiered date, skipping NFO creation.")
if reporter and hasattr(reporter, "report_warning"):
reporter.report_warning("Invalid aired/premiered date, skipping NFO creation.")
elif reporter and hasattr(reporter, "warning"):
reporter.warning("Invalid aired/premiered date, skipping NFO creation.")
return False
year, month, day = aired.split("-")
if not (year and month and day):
self.report_warning("Invalid aired date parts, skipping NFO creation.")
if reporter and hasattr(reporter, "report_warning"):
reporter.report_warning("Invalid aired date parts, skipping NFO creation.")
elif reporter and hasattr(reporter, "warning"):
reporter.warning("Invalid aired date parts, skipping NFO creation.")
return False
self.to_screen(f"Creating {self.mode} NFO file at {nfo_file!s}")
if reporter and hasattr(reporter, "to_screen"):
reporter.to_screen(f"Creating {mode} NFO file at {nfo_file!s}")
elif reporter and hasattr(reporter, "info"):
reporter.info(f"Creating {mode} NFO file at {nfo_file!s}")
nfo_file.parent.mkdir(parents=True, exist_ok=True)
nfo_file.touch(exist_ok=True)
dt = datetime(int(year), int(month), int(day), tzinfo=UTC)
data = dict(data) # do not mutate original
data["unique_id"] = self._build_unique_id(dt, real_file)
data["unique_id"] = NFOMakerPP._build_unique_id(dt, real_file)
self._write(nfo_file=nfo_file, text=self._MODE[self.mode].get("template", ""), repl=data)
NFOMakerPP._write(
reporter,
nfo_file=nfo_file,
text=NFOMakerPP._MODE[mode].get("template", ""),
repl=data,
prefix=prefix,
mode=mode,
)
return True
@staticmethod
def _build_unique_id(dt: datetime, file: Path) -> str:
# 1MMDD + 4-digit stable hash from lowercase stem
h = hashlib.sha256(file.stem.lower().encode("utf-8")).hexdigest()
h: str = hashlib.sha256(file.stem.lower().encode("utf-8")).hexdigest()
ascii_stream = "".join(str(ord(c)) for c in h)
suffix = ascii_stream[:4] if 4 <= len(ascii_stream) else ascii_stream.ljust(4, "9")
return f"1{dt.strftime('%m')}{dt.strftime('%d')}{suffix}"
suffix: str = ascii_stream[:4] if 4 <= len(ascii_stream) else ascii_stream.ljust(4, "9")
return f"1{dt.strftime('%m%d')}{suffix}"
@staticmethod
def _normalize_date(val: Any) -> str:
@ -311,27 +388,26 @@ class NFOMakerPP(PostProcessor):
return raw
def _write(self, nfo_file: Path, text: str, repl: dict[str, Any]) -> None:
@staticmethod
def _write(reporter: Any | None, nfo_file: Path, text: str, repl: dict[str, Any], prefix: bool, mode: str) -> None:
"""
Write the NFO file, replacing placeholders in the template.
Write rendered template to disk; independent of PostProcessor instance.
Args:
nfo_file (Path): Path to the NFO file.
text (str): Template text with placeholders.
repl (dict[str, Any]): Replacement dictionary.
reporter: optional object with `to_screen`/`report_warning` or a logger with `info`/`warning`.
prefix: whether to prefix episodes
mode: 'tv' or 'movie'
"""
safe_repl: dict[str, Any] = {}
for k, v in repl.items():
safe_repl[k] = NFOMakerPP._escape_text(v)
# replace placeholders
rendered = text
for key, value in safe_repl.items():
if value is None:
continue
if self.prefix and key in ("episode",):
if prefix and key in ("episode",):
try:
value: str = f"1{value}"
except Exception:
@ -339,17 +415,22 @@ class NFOMakerPP(PostProcessor):
rendered = rendered.replace(f"{{{key}}}", str(value))
# remove any unresolved placeholder lines
mapping = self._MODE[self.mode].get("mapping", {})
mapping = NFOMakerPP._MODE[mode].get("mapping", {})
unresolved_keys: Iterable[str] = set({*mapping, *safe_repl.keys()})
pattern: re.Pattern[str] = re.compile(rf".*{{(?:{'|'.join(map(re.escape, unresolved_keys))})}}.*")
rendered: str = "\n".join(line for line in rendered.splitlines() if not pattern.fullmatch(line))
try:
nfo_file.write_text(rendered, encoding="utf-8")
self.to_screen(f"NFO file written successfully at {nfo_file!s}")
if reporter and hasattr(reporter, "to_screen"):
reporter.to_screen(f"NFO file written successfully at {nfo_file!s}")
elif reporter and hasattr(reporter, "info"):
reporter.info(f"NFO file written successfully at {nfo_file!s}")
except Exception as e:
self.report_warning(f"Error writing NFO file: {e}")
if reporter and hasattr(reporter, "report_warning"):
reporter.report_warning(f"Error writing NFO file: {e}")
elif reporter and hasattr(reporter, "warning"):
reporter.warning(f"Error writing NFO file: {e}")
@staticmethod
def _escape_text(text: Any) -> Any:

View file

@ -182,8 +182,10 @@ onBeforeUnmount(() => {
/* Override Bulma's display: none */
position: static;
/* Don't use absolute positioning inside fixed container */
max-height: 300px;
max-height: 350px;
overflow-y: auto;
overflow-x: hidden;
min-width: max-content;
padding-top: 4px;
}

View file

@ -161,13 +161,13 @@
</td>
<td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered">
<div class="control" v-if="item.status != 'finished' || !item.filename">
<div class="control" v-if="!item.filename">
<button class="button is-warning is-fullwidth is-small" v-tooltip="'Retry download'"
@click="async () => await retryItem(item, true)">
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
</button>
</div>
<div class="control" v-if="item.filename && item.status === 'finished'">
<div class="control" v-if="item.filename">
<a class="button is-link is-fullwidth is-small" :href="makeDownload(config, item)"
v-tooltip="'Download video'" :download="item.filename?.split('/').reverse()[0]">
<span class="icon"><i class="fa-solid fa-download" /></span>
@ -181,11 +181,24 @@
</div>
<div class="control is-expanded" v-if="item.url">
<Dropdown icons="fa-solid fa-cogs" :button_classes="'is-small'" label="Actions">
<template v-if="'finished' === item.status && item.filename">
<template v-if="item.filename">
<NuxtLink @click="playVideo(item)" class="dropdown-item">
<span class="icon"><i class="fa-solid fa-play" /></span>
<span>Play video</span>
</NuxtLink>
<template v-if="'error' === item.status">
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item has-text-warning"
@click="async () => await retryItem(item, true)">
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
<span>Retry download</span>
</NuxtLink>
</template>
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item has-text-info" @click="generateNfo(item)">
<span class="icon"><i class="fa-solid fa-file-code" /></span>
<span>Generate NFO</span>
</NuxtLink>
<hr class="dropdown-divider" />
</template>
<template v-else-if="isEmbedable(item.url)">
@ -289,7 +302,7 @@
</header>
<div v-if="showThumbnails" class="card-image">
<figure :class="['image', thumbnail_ratio]">
<span v-if="'finished' === item.status && item.filename" @click="playVideo(item)" class="play-overlay">
<span v-if="item.filename" @click="playVideo(item)" class="play-overlay">
<div class="play-icon"></div>
<img @load="pImg" @error="onImgError" :src="getImage(config.app.download_path, item)"
v-if="getImage(config.app.download_path, item)" />
@ -334,7 +347,7 @@
</div>
</div>
<div class="columns is-mobile is-multiline">
<div class="column is-half-mobile" v-if="item.status != 'finished' || !item.filename">
<div class="column is-half-mobile" v-if="!item.filename">
<a class="button is-warning is-fullwidth" @click="async () => retryItem(item, false)">
<span class="icon-text is-block">
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
@ -343,7 +356,7 @@
</a>
</div>
<div class="column is-half-mobile" v-if="item.filename && item.status === 'finished'">
<div class="column is-half-mobile" v-if="item.filename">
<a class="button is-link is-fullwidth" :href="makeDownload(config, item)"
:download="item.filename?.split('/').reverse()[0]">
<span class="icon-text is-block">
@ -364,11 +377,23 @@
<div class="column">
<Dropdown icons="fa-solid fa-cogs" label="Actions">
<template v-if="'finished' === item.status && item.filename">
<template v-if="item.filename">
<NuxtLink @click="playVideo(item)" class="dropdown-item">
<span class="icon"><i class="fa-solid fa-play" /></span>
<span>Play video</span>
</NuxtLink>
<template v-if="'error' === item.status">
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item has-text-warning" @click="async () => await retryItem(item, true)">
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
<span>Retry download</span>
</NuxtLink>
</template>
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item has-text-info" @click="generateNfo(item)">
<span class="icon"><i class="fa-solid fa-file-code" /></span>
<span>Generate NFO</span>
</NuxtLink>
<hr class="dropdown-divider" />
</template>
@ -679,7 +704,7 @@ const hasDownloaded = computed(() => {
}
for (const key in stateStore.history) {
const element = stateStore.history[key] as StoreItem
if (element.status === 'finished' && element.filename) {
if (element.filename) {
return true
}
}
@ -763,6 +788,11 @@ const setIconColor = (item: StoreItem) => {
if ('cancelled' === item.status || "skip" === item.status) {
return 'has-text-warning'
}
if ('error' === item.status && item.filename) {
return 'has-text-warning'
}
return 'has-text-danger'
}
@ -774,6 +804,9 @@ const setStatus = (item: StoreItem) => {
return item.is_live ? 'Streamed' : 'Completed'
}
if ('error' === item.status) {
if (item.filename) {
return 'Partial Error'
}
return 'Error'
}
if ('cancelled' === item.status) {
@ -917,7 +950,7 @@ const downloadSelected = async () => {
continue
}
const item = stateStore.get('history', item_id, {} as StoreItem) as StoreItem
if ('finished' !== item.status || !item.filename) {
if (!item.filename) {
continue
}
files_list.push(item.folder ? item.folder + '/' + item.filename : item.filename)
@ -1002,4 +1035,22 @@ const is_queued = (item: StoreItem) => {
}
return item.live_in || item.extras?.live_in || item.extras?.release_in ? 'fa-spin fa-spin-10' : ''
}
const generateNfo = async (item: StoreItem) => {
try {
toast.info('Generating please wait...', { timeout: 2000 })
const response = await request(`/api/history/${item._id}/nfo`, {
method: 'POST',
body: JSON.stringify({ type: 'tv', overwrite: true }),
})
const data = await response.json()
if (!response.ok) {
toast.error(data.error || 'Failed to generate NFO')
return
}
toast.success(data.message || 'NFO file generated')
} catch (e: any) {
toast.error(`Error: ${e.message}`)
}
}
</script>

View file

@ -101,11 +101,9 @@
<span v-if="getDurationLabel(entry.item)" class="queue-thumb__badge">
{{ getDurationLabel(entry.item) }}
</span>
<span
v-if="entry.item.filename && entry.item.status === 'finished' || isEmbedable(entry.item.url)"
class="queue-thumb__overlay">
<span v-if="entry.item.filename || isEmbedable(entry.item.url)" class="queue-thumb__overlay">
<span class="icon circle-border"
:class="{ 'has-text-danger': isEmbedable(entry.item.url) && (!entry.item.filename || entry.item.status !== 'finished') }">
:class="{ 'has-text-danger': isEmbedable(entry.item.url) && !entry.item.filename }">
<i class="fas fa-play" />
</span>
</span>
@ -171,8 +169,8 @@
<span class="icon"><i class="fas fa-download" /></span>
<span>Download</span>
</a>
<button v-if="entry.item.status != 'finished' || !entry.item.filename" class="button is-info is-light"
type="button" @click="async () => await requeueItem(entry.item)">
<button v-if="!entry.item.filename" class="button is-info is-light" type="button"
@click="async () => await requeueItem(entry.item)">
<span class="icon"><i class="fas fa-rotate-right" /></span>
<span>Requeue</span>
</button>
@ -202,7 +200,7 @@
<div class="modal-background" @click="closePlayer"></div>
<div class="modal-content is-unbounded-model">
<VideoPlayer type="default" :isMuted="false" autoplay="true" :isControls="true" :item="videoItem"
class="is-fullwidth" @closeModel="closePlayer" />
class="is-fullwidth" @closeModel="closePlayer" @error="async (error: string) => await box.alert(error)" />
</div>
<button class="modal-close is-large" aria-label="close" @click="closePlayer"></button>
</div>
@ -214,6 +212,10 @@
</div>
<button class="modal-close is-large" aria-label="close" @click="closePlayer"></button>
</div>
<ClientOnly>
<Dialog />
</ClientOnly>
</div>
</template>
@ -246,6 +248,7 @@ const toast = useNotification()
const dlFields = useStorage<Record<string, any>>('dl_fields', {})
const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
const isMobile = useMediaQuery({ maxWidth: 1024 })
const box = useConfirm()
const { app, paused, presets } = storeToRefs(configStore)
const { queue, history } = storeToRefs(stateStore)
@ -422,7 +425,7 @@ const resolveThumbnail = (entry: DisplayEntry): string => {
}
const openPlayer = (item: StoreItem): void => {
if (item.filename && item.status === 'finished') {
if (item.filename) {
videoItem.value = item
return
}
@ -508,6 +511,9 @@ const getStatusLabel = (item: StoreItem): string => {
if (null === item.status) {
return 'Queued'
}
if ('error' === item.status && item.filename) {
return 'Partial Error'
}
return statusOverrides[item.status] ?? ucFirst(item.status.replace(/_/g, ' '))
}
@ -526,6 +532,9 @@ const getStatusClass = (item: StoreItem): string => {
if (null === item.status) {
return 'has-text-grey'
}
if ('error' === item.status && item.filename) {
return 'has-text-warning'
}
return statusColorMap[item.status] ?? 'has-text-info'
}

View file

@ -0,0 +1,406 @@
import { ref, readonly, computed, toRaw } from 'vue'
import { useNotification } from '~/composables/useNotification'
import { useConfigStore } from '~/stores/ConfigStore'
import { request, parse_api_error, sTrim, encodePath } from '~/utils'
import type { FileItem, Pagination } from '~/types/filebrowser'
const items = ref<FileItem[]>([])
const path = ref<string>('/')
const pagination = ref<Pagination>({
page: 1,
per_page: 50,
total: 0,
total_pages: 0,
has_next: false,
has_prev: false,
})
const isLoading = ref<boolean>(false)
const lastError = ref<string | null>(null)
const selectedElms = ref<string[]>([])
const masterSelectAll = ref<boolean>(false)
const sort_by = ref<string>('name')
const sort_order = ref<string>('asc')
const search = ref<string>('')
const throwInstead = ref(false)
const notify = useNotification()
const readJson = async (response: Response): Promise<unknown> => {
try {
const clone = response.clone()
return await clone.json()
}
catch {
return null
}
}
const ensureSuccess = async (response: Response): Promise<void> => {
if (response.ok) {
return
}
const payload = await readJson(response)
const message = await parse_api_error(payload)
throw new Error(message)
}
const handleError = (error: unknown): void => {
const message = error instanceof Error ? error.message : 'Unexpected error occurred.'
lastError.value = message
notify.error(message)
}
const buildQueryParams = (page?: number): string => {
const config = useConfigStore()
const params = new URLSearchParams()
params.set('page', String(page ?? pagination.value.page))
params.set('per_page', String(config.app.default_pagination || 50))
params.set('sort_by', sort_by.value)
params.set('sort_order', sort_order.value)
if (search.value) {
params.set('search', search.value)
}
return params.toString()
}
const loadContents = async (dir: string = '/', page: number = 1): Promise<boolean> => {
isLoading.value = true
try {
if (typeof dir !== 'string') {
dir = '/'
}
dir = encodePath(sTrim(dir, '/'))
const query = buildQueryParams(page)
const response = await request(`/api/file/browser/${sTrim(dir, '/')}?${query}`)
await ensureSuccess(response)
const data = await response.json()
items.value = data.contents || []
path.value = data.path || '/'
if (data.pagination) {
pagination.value = data.pagination
}
selectedElms.value = []
masterSelectAll.value = false
lastError.value = null
return true
}
catch (error) {
handleError(error)
if (throwInstead.value) throw error
return false
}
finally {
isLoading.value = false
}
}
const changeSort = async (by: string): Promise<void> => {
if (!['name', 'size', 'date', 'type'].includes(by)) {
return
}
if (by !== sort_by.value) {
sort_by.value = by
}
else {
sort_order.value = sort_order.value === 'asc' ? 'desc' : 'asc'
}
await loadContents(path.value, 1)
}
const setSearch = async (value: string): Promise<void> => {
search.value = value
await loadContents(path.value, 1)
}
const setSortBy = (value: string): void => {
sort_by.value = value
}
const setSortOrder = (value: string): void => {
sort_order.value = value
}
const setSearchValue = (value: string): void => {
search.value = value
}
const setPage = (value: number): void => {
pagination.value.page = value
}
const changePage = async (page: number): Promise<void> => {
await loadContents(path.value, page)
}
const performAction = async (
item: FileItem,
action: string,
payload: Record<string, unknown>,
callback?: (item: FileItem, action: string, data: Record<string, unknown>, source: FileItem) => void,
multiple: boolean = false
): Promise<boolean> => {
try {
const response = await request('/api/file/actions', {
method: 'POST',
body: JSON.stringify([{ path: item.path, action, ...payload }]),
})
await ensureSuccess(response)
const results = await response.json() as Array<{ path: string, status: boolean, error?: string, [key: string]: unknown }>
for (const result of results) {
if (!multiple && result.path !== item.path) {
continue
}
if (!multiple && !result.status) {
notify.error(`Failed to perform action: ${result.error || 'Unknown error'}`)
return false
}
if (callback && typeof callback === 'function') {
if (!multiple) {
callback(item, action, payload as Record<string, unknown>, item)
}
else {
const matchedItem = items.value.find(it => it.path === result.path)
if (matchedItem) {
callback(matchedItem, action, result as Record<string, unknown>, toRaw(item))
}
}
}
}
return true
}
catch (error) {
handleError(error)
if (throwInstead.value) throw error
return false
}
}
const performMassAction = async (
actions: Array<{ path: string, action: string, [key: string]: unknown }>,
callback?: (result: { path: string, status: boolean, error?: string }) => void
): Promise<boolean> => {
try {
const response = await request('/api/file/actions', {
method: 'POST',
body: JSON.stringify(actions),
})
await ensureSuccess(response)
const results = await response.json() as Array<{ path: string, status: boolean, error?: string }>
for (const result of results) {
if (!result.status) {
notify.error(`Failed to perform action on '${result.path}': ${result.error || 'Unknown error'}`)
continue
}
if (callback && typeof callback === 'function') {
callback(result)
}
}
return true
}
catch (error) {
handleError(error)
if (throwInstead.value) throw error
return false
}
}
const createDirectory = async (dir: string, newDir: string): Promise<boolean> => {
const trimmedDir = sTrim(newDir, '/')
if (!trimmedDir || trimmedDir === dir) {
return false
}
const success = await performAction(
{ path: dir || '/' } as FileItem,
'directory',
{ new_dir: trimmedDir },
() => {
notify.success(`Successfully created '${trimmedDir}'.`)
}
)
return success
}
const renameItem = async (item: FileItem, newName: string): Promise<boolean> => {
const trimmedName = newName.trim()
if (!trimmedName || trimmedName === item.name) {
return false
}
return await performAction(
item,
'rename',
{ new_name: trimmedName },
(it, _, data) => {
const source = data as { new_path?: string }
if (source.new_path) {
it.name = source.new_path.split('/').pop() || trimmedName
it.path = source.new_path
}
notify.success(`Renamed '${item.name}'.`)
},
true
)
}
const deleteItem = async (item: FileItem): Promise<boolean> => {
return await performAction(
item,
'delete',
{},
() => {
items.value = items.value.filter(i => i.path !== item.path)
notify.warning(`Deleted '${item.name}'.`)
}
)
}
const moveItem = async (item: FileItem, newPath: string): Promise<boolean> => {
const trimmedPath = sTrim(newPath, '/') || '/'
if (!trimmedPath || trimmedPath === item.path) {
return false
}
return await performAction(
item,
'move',
{ new_path: trimmedPath },
(it, _, data) => {
const source = data as { new_path?: string; path?: string }
if (source.path) {
items.value = items.value.filter(i => i.path !== source.path)
}
notify.success(`Moved '${item.name}' to '${trimmedPath}'.`)
},
true
)
}
const deleteSelected = async (): Promise<boolean> => {
if (selectedElms.value.length < 1) {
notify.error('No items selected.')
return false
}
const actions = selectedElms.value.map(p => {
return { path: p, action: 'delete' }
})
const success = await performMassAction(actions, (result) => {
const item = items.value.find(it => it.path === result.path)
if (item) {
items.value = items.value.filter(it => it.path !== result.path)
notify.warning(`Deleted '${item.name}'.`)
}
})
if (success) {
selectedElms.value = []
}
return success
}
const moveSelected = async (newPath: string): Promise<boolean> => {
if (selectedElms.value.length < 1) {
notify.error('No items selected.')
return false
}
const trimmedPath = sTrim(newPath, '/') || '/'
const actions = selectedElms.value.map(p => ({
path: p,
action: 'move',
new_path: trimmedPath,
}))
const success = await performMassAction(actions, (result) => {
items.value = items.value.filter(it => it.path !== result.path)
notify.success(`Moved '${result.path}' to '${trimmedPath}'.`)
})
if (success) {
selectedElms.value = []
}
return success
}
const clearError = (): void => {
lastError.value = null
}
const reset = (): void => {
items.value = []
path.value = '/'
pagination.value = {
page: 1,
per_page: 50,
total: 0,
total_pages: 0,
has_next: false,
has_prev: false,
}
selectedElms.value = []
masterSelectAll.value = false
search.value = ''
lastError.value = null
}
const filteredItems = computed<FileItem[]>(() => {
return items.value
})
export const useBrowser = () => ({
items: readonly(items),
path: readonly(path),
pagination: readonly(pagination),
isLoading: readonly(isLoading),
lastError: readonly(lastError),
selectedElms,
masterSelectAll,
sort_by,
sort_order,
search,
filteredItems,
loadContents,
changeSort,
setSearch,
setSortBy,
setSortOrder,
setSearchValue,
setPage,
changePage,
createDirectory,
renameItem,
deleteItem,
moveItem,
deleteSelected,
moveSelected,
performAction,
performMassAction,
clearError,
reset,
throwInstead,
})

View file

@ -5,8 +5,8 @@
<span class="title is-4">
<nav class="breadcrumb is-inline-block">
<ul>
<li v-for="(item, index) in makeBreadCrumb(path)" :key="item.link"
:class="{ 'is-active': index === makeBreadCrumb(path).length - 1 }">
<li v-for="(item, index) in makeBreadCrumb(browserPath)" :key="item.link"
:class="{ 'is-active': index === makeBreadCrumb(browserPath).length - 1 }">
<a :href="item.link" @click.prevent="reloadContent(item.path)" v-text="item.name" />
</li>
<li class="is-active" v-if="isLoading">
@ -19,14 +19,14 @@
</span>
<div class="is-pulled-right">
<div class="field is-grouped">
<div class="control has-icons-left" v-if="show_filter && items && items.length > 0">
<input type="search" v-model.lazy="search" class="input" id="search" placeholder="Filter">
<div class="control has-icons-left" v-if="show_filter">
<input type="search" v-model.lazy="localSearch" class="input" id="search" placeholder="Filter">
<span class="icon is-left">
<i class="fas fa-filter"></i>
</span>
</div>
<div class="control" v-if="items && items.length > 0">
<div class="control">
<button class="button is-danger is-light" @click="toggleFilter">
<span class="icon"><i class="fas fa-filter" /></span>
<span v-if="!isMobile">Filter</span>
@ -34,7 +34,7 @@
</div>
<p class="control">
<button class="button is-info is-light" @click="createDirectory(path)"
<button class="button is-info is-light" @click="handleCreateDirectory"
v-if="config.app.browser_control_enabled">
<span class="icon"><i class="fas fa-folder-plus" /></span>
<span v-if="!isMobile">New Folder</span>
@ -50,10 +50,10 @@
</span>
</button>
</p>
<p class="control" v-if="items && items.length > 0">
<p class="control" v-if="filteredItems && filteredItems.length > 0">
<Dropdown label="Sort&nbsp;&nbsp;" icons="fa-solid fa-sort" :hide_label_on_mobile="true">
<NuxtLink class="dropdown-item" :class="{ 'is-active': 'type' === sort_by }"
@click="changeSort('type')">
@click="handleChangeSort('type')">
<span class="icon"><i class="fa-solid fa-hashtag" /></span>
<span>Type</span>
<span class="icon is-pulled-right" v-if="'type' === sort_by">
@ -63,7 +63,7 @@
</NuxtLink>
<NuxtLink class="dropdown-item" :class="{ 'is-active': 'name' === sort_by }"
@click="changeSort('name')">
@click="handleChangeSort('name')">
<span class="icon"><i class="fa-solid fa-arrow-down-a-z" /></span>
<span>Name</span>
<span class="icon is-pulled-right" v-if="'name' === sort_by">
@ -73,7 +73,7 @@
</NuxtLink>
<NuxtLink class="dropdown-item" :class="{ 'is-active': 'size' === sort_by }"
@click="changeSort('size')">
@click="handleChangeSort('size')">
<span class="icon"><i class="fa-solid fa-weight" /></span>
<span>Size</span>
<span class="icon is-pulled-right" v-if="'size' === sort_by">
@ -83,7 +83,7 @@
</NuxtLink>
<NuxtLink class="dropdown-item" :class="{ 'is-active': 'date' === sort_by }"
@click="changeSort('date')">
@click="handleChangeSort('date')">
<span class="icon"><i class="fa-solid fa-calendar" /></span>
<span>Date</span>
<span class="icon is-pulled-right" v-if="'date' === sort_by">
@ -94,7 +94,7 @@
</Dropdown>
</p>
<p class="control">
<button class="button is-info" @click="reloadContent(path, true)" :class="{ 'is-loading': isLoading }"
<button class="button is-info" @click="reloadContent(browserPath)" :class="{ 'is-loading': isLoading }"
:disabled="isLoading">
<span class="icon"><i class="fas fa-refresh" /></span>
<span v-if="!isMobile">Reload</span>
@ -106,11 +106,11 @@
</div>
<div class="columns is-mobile is-multiline is-justify-content-flex-end"
v-if="config.app.browser_control_enabled && items && items.length > 0">
v-if="config.app.browser_control_enabled && filteredItems && filteredItems.length > 0">
<div class="column is-narrow">
<button type="button" class="button" @click="masterSelectAll = !masterSelectAll"
:class="{ 'has-text-primary': !masterSelectAll, 'has-text-danger': masterSelectAll }"
:disabled="isLoading || items.length < 1">
:disabled="isLoading || filteredItems.length < 1">
<span class="icon">
<i :class="!masterSelectAll ? 'fa-regular fa-square-check' : 'fa-regular fa-square'" />
</span>
@ -124,13 +124,13 @@
<div class="column is-2-tablet is-5-mobile">
<Dropdown label="Actions" icons="fa-solid fa-list">
<a class="dropdown-item has-text-danger"
@click="(selectedElms.length > 0 && !isLoading) ? deleteSelected() : null"
@click="(selectedElms.length > 0 && !isLoading) ? handleDeleteSelected() : null"
:style="{ opacity: (selectedElms.length < 1 || isLoading) ? 0.5 : 1, cursor: (selectedElms.length < 1 || isLoading) ? 'not-allowed' : 'pointer' }">
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
<span>Delete{{ selectedElms.length > 0 ? ` (${selectedElms.length})` : '' }}</span>
</a>
<a class="dropdown-item has-text-link"
@click="(selectedElms.length > 0 && !isLoading) ? moveSelected() : null"
@click="(selectedElms.length > 0 && !isLoading) ? handleMoveSelected() : null"
:style="{ opacity: (selectedElms.length < 1 || isLoading) ? 0.5 : 1, cursor: (selectedElms.length < 1 || isLoading) ? 'not-allowed' : 'pointer' }">
<span class="icon"><i class="fa-solid fa-arrows-alt" /></span>
<span>Move{{ selectedElms.length > 0 ? ` (${selectedElms.length})` : '' }}</span>
@ -139,6 +139,13 @@
</div>
</div>
<div class="columns is-multiline" v-if="pagination.total_pages > 1">
<div class="column is-12">
<Pager :page="pagination.page" :last_page="pagination.total_pages" :isLoading="isLoading"
@navigate="handlePageChange" />
</div>
</div>
<div class="columns is-multiline">
<template v-if="'list' === display_style">
<div class="column is-12" v-if="filteredItems && filteredItems.length > 0">
@ -194,11 +201,11 @@
<td class="is-text-overflow is-vcentered">
<div class="field is-grouped">
<div class="control is-text-overflow is-expanded">
<a :href="uri(`/browser/${item.path}`)" v-if="'dir' === item.content_type"
<a :href="uri(`/browser/${item.path}`)" v-if="'dir' === item.content_type" v-tooltip="item.name"
@click.prevent="handleClick(item)">
{{ item.name }}
</a>
<a :href="makeDownload({}, { filename: item.path, folder: '' })"
<a :href="makeDownload({}, { filename: item.path, folder: '' })" v-tooltip="item.name"
@click.prevent="handleClick(item)" v-else>
{{ item.name }}
</a>
@ -341,16 +348,16 @@
</div>
</template>
<div class="column is-12" v-if="search && filteredItems.length < 1">
<Message class="is-warning" title="No results" icon="fas fa-filter" :useClose="true" @close="() => search = ''"
v-if="search">
<div class="column is-12" v-if="localSearch && filteredItems.length < 1 && !isLoading">
<Message class="is-warning" title="No results" icon="fas fa-filter" :useClose="true"
@close="() => localSearch = ''">
<p class="is-block">
No results found for '<span class="is-underlined is-bold">{{ search }}</span>'.
No results found for '<span class="is-underlined is-bold">{{ localSearch }}</span>'.
</p>
</Message>
</div>
<div class="column is-12" v-if="!items || items.length < 1">
<div class="column is-12" v-else-if="!filteredItems || filteredItems.length < 1">
<Message title="Loading content" class="is-info" icon="fas fa-refresh fa-spin" v-if="isLoading">
Loading file browser contents...
</Message>
@ -369,6 +376,13 @@
</div>
</div>
<div class="columns is-multiline" v-if="pagination.total_pages > 1">
<div class="column is-12">
<Pager :page="pagination.page" :last_page="pagination.total_pages" :isLoading="isLoading"
@navigate="handlePageChange" />
</div>
</div>
<div class="modal is-active" v-if="model_item">
<div class="modal-background" @click="closeModel"></div>
<div class="modal-content is-unbounded-model">
@ -389,34 +403,80 @@
<script setup lang="ts">
import moment from 'moment'
import { useStorage } from '@vueuse/core'
import type { FileItem, FileBrowserResponse } from '~/types/filebrowser'
import type { FileItem } from '~/types/filebrowser'
const route = useRoute()
const toast = useNotification()
const config = useConfigStore()
const isMobile = useMediaQuery({ maxWidth: 1024 })
const dialog = useDialog()
const browser = useBrowser()
const isMobile = useMediaQuery({ maxWidth: 1024 })
const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.95)
const sort_by = useStorage('sort_by', 'name')
const sort_order = useStorage('sort_order', 'asc')
const display_style = useStorage<string>('browser_display_style', 'list')
const show_filter = ref<boolean>(false)
const localSearch = ref<string>('')
const selectedElms = ref<string[]>([])
const masterSelectAll = ref(false)
const items = browser.items
const browserPath = browser.path
const pagination = browser.pagination
const isLoading = browser.isLoading
const selectedElms = browser.selectedElms
const masterSelectAll = browser.masterSelectAll
const sort_by = browser.sort_by
const sort_order = browser.sort_order
const filteredItems = browser.filteredItems
const isLoading = ref<boolean>(false)
const items = ref<FileItem[]>([])
const path = ref<string>((() => {
const initialPath = (() => {
const slug = route.params.slug
if (Array.isArray(slug) && slug.length > 0) {
return '/' + slug.join('/')
}
return '/'
})())
const search = ref<string>('')
const show_filter = ref<boolean>(false)
})()
const isUpdating = ref(false)
const buildStateUrl = (dir: string, page?: number): string => {
const params = new URLSearchParams()
const p = page ?? pagination.value.page
if (p > 1) {
params.set('page', String(p))
}
if (sort_by.value !== 'name') {
params.set('sort_by', sort_by.value)
}
if (sort_order.value !== 'asc') {
params.set('sort_order', sort_order.value)
}
if (localSearch.value) {
params.set('search', localSearch.value)
}
const queryString = params.toString()
const normalizedDir = dir.replace(/^\/+/, '').replace(/\/+$/, '')
const basePath = normalizedDir ? `/browser/${normalizedDir}` : '/browser'
return queryString ? `${basePath}?${queryString}` : basePath
}
const syncFromUrl = (): { page: number } => {
const query = route.query
const page = parseInt(query.page as string, 10) || 1
if (query.sort_by && ['name', 'size', 'date', 'type'].includes(query.sort_by as string)) {
browser.setSortBy(query.sort_by as string)
}
if (query.sort_order && ['asc', 'desc'].includes(query.sort_order as string)) {
browser.setSortOrder(query.sort_order as string)
}
if (query.search && typeof query.search === 'string') {
browser.setSearchValue(query.search)
localSearch.value = query.search
show_filter.value = true
}
return { page }
}
const reset_dialog = () => ({
visible: false,
@ -425,9 +485,10 @@ const reset_dialog = () => ({
message: '',
html_message: '',
options: [],
});
})
const dialog_confirm = ref(reset_dialog())
const model_item = ref<any>()
watch(masterSelectAll, v => {
if (v) {
@ -437,52 +498,26 @@ watch(masterSelectAll, v => {
}
})
const filteredItems = computed<FileItem[]>(() => {
if (!search.value) {
return sortedItems(items.value)
}
const searchLower = search.value.toLowerCase()
return sortedItems(items.value.filter((item: FileItem) => deepIncludes(item, searchLower, new WeakSet())))
watch(localSearch, async (val) => {
if (isUpdating.value) return
await browser.setSearch(val)
updateUrl(browserPath.value, 1)
})
const sortedItems = (items: FileItem[]): FileItem[] => {
if (!items || items.length < 1) {
return []
}
if ('name' === sort_by.value) {
return items.sort((a, b) => {
return 'asc' === sort_order.value ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)
})
}
if (sort_by.value === 'size') {
return items.sort((a, b) => {
return 'asc' === sort_order.value ? a.size - b.size : b.size - a.size
})
}
if (sort_by.value === 'date') {
return items.sort((a, b) => {
const aDate = new Date(a.mtime)
const bDate = new Date(b.mtime)
return 'asc' === sort_order.value ? aDate.getTime() - bDate.getTime() : bDate.getTime() - aDate.getTime()
})
}
if (sort_by.value === 'type') {
return items.sort((a, b) => {
return 'asc' === sort_order.value ? a.content_type.localeCompare(b.content_type) : b.content_type.localeCompare(a.content_type)
})
}
return items
}
const model_item = ref<any>()
const closeModel = (): void => { model_item.value = null }
const updateUrl = (dir: string, page?: number): void => {
const normalizedDir = dir.replace(/^\/+/, '').replace(/\/+$/, '')
const displayDir = normalizedDir ? normalizedDir : '/'
const title = `File Browser: /${sTrim(displayDir, '/')}`
const stateUrl = buildStateUrl(dir, page)
const fullUrl = window.location.origin + stateUrl
if (fullUrl !== window.location.href) {
history.replaceState({ path: normalizedDir || '/', title: title }, title, stateUrl)
}
useHead({ title: decodeURIComponent(title) })
}
const handleClick = (item: FileItem): void => {
if (true === ['video', 'audio'].includes(item.content_type)) {
model_item.value = {
@ -515,8 +550,8 @@ const handleClick = (item: FileItem): void => {
}
if ('dir' === item.content_type) {
if (search.value) {
search.value = ''
if (localSearch.value) {
localSearch.value = ''
show_filter.value = false
}
@ -528,63 +563,38 @@ const handleClick = (item: FileItem): void => {
}
const reloadContent = async (dir: string = '/', fromMounted: boolean = false): Promise<void> => {
isUpdating.value = true
try {
isLoading.value = true
const page = fromMounted ? syncFromUrl().page : 1
const success = await browser.loadContents(dir, page)
if (typeof dir !== 'string') {
dir = '/'
}
dir = encodePath(sTrim(dir, '/'))
const response = await request(`/api/file/browser/${sTrim(dir, '/')}`)
if (fromMounted && !response.ok) {
if (fromMounted && !success) {
return
}
items.value = []
search.value = ''
selectedElms.value = []
show_filter.value = false
masterSelectAll.value = false
const data = await response.json() as FileBrowserResponse
if (!data?.contents || data.contents.length > 0) {
items.value = data.contents
}
if (data?.path) {
path.value = sTrim(data.path, '/')
}
const title = `File Browser: /${dir}`
const stateUrl = uri(`/browser/${dir}`)
if (false === fromMounted) {
history.pushState({ path: dir, title: title }, title, stateUrl)
}
useHead({ title: decodeURIComponent(title) })
} catch (e: any) {
if (fromMounted) {
return
}
console.error(e)
toast.error('Failed to load file browser contents.')
updateUrl(dir, page)
} finally {
isLoading.value = false
isUpdating.value = false
}
}
const handlePageChange = async (page: number): Promise<void> => {
await browser.changePage(page)
updateUrl(browserPath.value, page)
}
const handleChangeSort = async (by: string): Promise<void> => {
await browser.changeSort(by)
updateUrl(browserPath.value, 1)
}
const event_handler = (e: Event): void => {
const evt = e as PopStateEvent
if (!evt.state) {
return
}
if (evt.state.path === path.value || evt.state.path === window.location.pathname) {
if (evt.state.path === browserPath.value && window.location.search === route.fullPath.split('?')[1]) {
return
}
@ -593,7 +603,7 @@ const event_handler = (e: Event): void => {
onMounted(async () => {
document.addEventListener('popstate', event_handler as EventListener)
await reloadContent(path.value, true)
await reloadContent(initialPath, true)
})
onBeforeUnmount(() => document.removeEventListener('popstate', event_handler as EventListener))
@ -610,7 +620,6 @@ const makeBreadCrumb = (path: string): { name: string, link: string, path: strin
path: baseLink
})
// -- explode path and create links
const parts = path.split('/')
parts.forEach((part, index) => {
const path = baseLink + parts.slice(0, index + 1).join('/')
@ -655,36 +664,24 @@ const setIcon = (item: FileItem): string => {
return 'fa-file'
}
const changeSort = (by: string): void => {
if (!['name', 'size', 'date', 'type'].includes(by)) {
return
}
if (by !== sort_by.value) {
sort_by.value = by
}
sort_order.value = 'asc' === sort_order.value ? 'desc' : 'asc'
}
const toggleFilter = (): void => {
show_filter.value = !show_filter.value
if (!show_filter.value) {
search.value = ''
localSearch.value = ''
return
}
awaitElement('#search', (e: Element) => (e as HTMLElement).focus())
}
const createDirectory = async (dir: string): Promise<void> => {
const handleCreateDirectory = async (): Promise<void> => {
if (!config.app.browser_control_enabled) {
return
}
const { status, value: newDir } = await dialog.promptDialog({
title: 'Create New Directory',
message: `Enter name for new directory in '${dir || '/'}':`,
message: `Enter name for new directory in '${browserPath.value || '/'}':`,
initial: '',
confirmText: 'Create',
cancelText: 'Cancel',
@ -694,15 +691,10 @@ const createDirectory = async (dir: string): Promise<void> => {
return
}
const new_dir = sTrim(newDir, '/')
if (!new_dir || new_dir === dir) {
return
const success = await browser.createDirectory(browserPath.value, newDir)
if (success) {
await reloadContent(browserPath.value, true)
}
await actionRequest({ path: dir || '/' } as FileItem, 'directory', { new_dir: new_dir }, (_item, _action, _data) => {
reloadContent(path.value, true)
toast.success(`Successfully created '${new_dir}'.`)
})
}
const handleAction = async (action: string, item: FileItem): Promise<void> => {
@ -723,18 +715,10 @@ const handleAction = async (action: string, item: FileItem): Promise<void> => {
return
}
const new_name = newName.trim()
if (!new_name || new_name === item.name) {
return
const success = await browser.renameItem(item, newName)
if (success) {
await reloadContent(browserPath.value, true)
}
await actionRequest(item, 'rename', { new_name: new_name }, (item, _, data, source) => {
if (item.path === source.path) {
toast.success(`Renamed '${item.name}'.`)
}
item.name = basename(data.new_path)
item.path = data.new_path
}, true)
return
}
@ -752,11 +736,7 @@ const handleAction = async (action: string, item: FileItem): Promise<void> => {
return
}
await actionRequest(item, 'delete', {}, (item, _action, _data) => {
items.value = items.value.filter(i => i.path !== item.path)
toast.warning(`Deleted '${item.name}'.`)
})
await browser.deleteItem(item)
return
}
@ -774,120 +754,15 @@ const handleAction = async (action: string, item: FileItem): Promise<void> => {
return
}
const new_path = sTrim(newPath, '/') || '/'
if (!new_path || new_path === item.path) {
return
const success = await browser.moveItem(item, newPath)
if (success) {
await reloadContent(browserPath.value, true)
}
await actionRequest(item, 'move', { new_path: new_path }, (item, _, data, source) => {
if (item.path === source.path) {
toast.success(`Moved '${item.name}' to '${data.new_path}'.`)
}
items.value = items.value.filter(i => i.path !== data.path)
}, true)
return
}
}
const actionRequest = async (
item: FileItem,
action: string,
req: Record<string, any>,
cb: (item: FileItem, action: string, data: any, source: FileItem, req: any) => void,
multiple: boolean = false
): Promise<any> => {
if (!config.app.browser_control_enabled) {
return
}
if (!item || !action || !req) {
return
}
try {
const response = await request(`/api/file/actions`, {
method: 'POST',
body: JSON.stringify([{ path: item.path, action: action, ...req }]),
})
if (!response.ok) {
const error = await response.json()
toast.error(`Failed to perform action: ${error.error || 'Unknown error'}`)
return
}
const json = await response.json() as Array<{ path: string, status: boolean, error?: string }>
json.forEach(i => {
if (false === multiple && i.path !== item.path) {
return
}
if (false === multiple && true !== i.status) {
toast.error(`Failed to perform action: ${i.error || 'Unknown error'}`)
return
}
if (cb && typeof cb === 'function') {
if (false === multiple) {
return cb(item, action, req, item, req)
}
items.value.forEach(it => {
if (it.path === i.path) {
return cb(it, action, i, toRaw(item), req)
}
})
}
});
return response
} catch (error: any) {
console.error(error)
toast.error(`Failed to perform action: ${error.message}`)
}
}
const massAction = async (items: Array<{ path: string, action: string }>, cb: (item: any) => void): Promise<any> => {
if (!config.app.browser_control_enabled) {
return
}
if (!items || items.length < 1) {
return
}
try {
const response = await request(`/api/file/actions`, {
method: 'POST',
body: JSON.stringify(items),
})
if (!response.ok) {
const error = await response.json()
toast.error(`Failed to perform action: ${error.error || 'Unknown error'}`)
return
}
const json = await response.json() as Array<{ path: string, status: boolean, error?: string }>
json.forEach(i => {
if (true !== i.status) {
toast.error(`Failed to perform action on '${i.path}': ${i.error || 'Unknown error'}`)
return
}
if (cb && typeof cb === 'function') {
cb(i)
}
});
return response
} catch (error: any) {
console.error(error)
toast.error(`Failed to perform action: ${error.message}`)
}
}
const deleteSelected = async () => {
const handleDeleteSelected = async () => {
if (selectedElms.value.length < 1) {
toast.error('No items selected.')
return
@ -903,7 +778,6 @@ const deleteSelected = async () => {
return `<li><span class="icon"><i class="fa-solid ${setIcon(item)}"></i></span> <span class="${color}">${item.name}</span></li>`
}).join('') + `</ul>`
const { status } = await dialog.confirmDialog({
title: 'Delete Confirmation',
message: `Delete the following items?`,
@ -918,27 +792,10 @@ const deleteSelected = async () => {
return
}
const actions = [] as Array<{ action: string, path: string }>
selectedElms.value.forEach(p => {
const item = items.value.find(i => i.path === p)
if (!item) {
return;
}
actions.push({ path: item.path, action: 'delete' })
})
await massAction(actions, i => {
const item = items.value.find(it => it.path === i.path)
if (!item) {
return
}
items.value = items.value.filter(it => it.path !== i.path)
toast.warning(`Deleted '${item.name}'.`)
})
await browser.deleteSelected()
}
const moveSelected = async () => {
const handleMoveSelected = async () => {
if (selectedElms.value.length < 1) {
toast.error('No items selected.')
return
@ -947,35 +804,17 @@ const moveSelected = async () => {
const { status, value: newPath } = await dialog.promptDialog({
title: 'Move Items',
message: `Enter new path for selected items:`,
initial: path.value || '/',
initial: browserPath.value || '/',
confirmText: 'Move',
confirmColor: 'is-danger',
cancelText: 'Cancel',
})
if (true !== status || !newPath || newPath === path.value) {
if (true !== status || !newPath || newPath === browserPath.value) {
selectedElms.value = []
return
}
const actions = [] as Array<{ action: string, path: string, new_path: string }>
selectedElms.value.forEach(p => {
const item = items.value.find(i => i.path === p)
if (!item) {
return;
}
actions.push({
path: item.path,
action: 'move',
new_path: sTrim(newPath, '/') || '/',
})
})
await massAction(actions, i => {
items.value = items.value.filter(it => it.path !== i.path)
toast.success(`Moved '${i.path}' to '${sTrim(newPath, '/')}'.`)
})
await browser.moveSelected(newPath)
}
</script>

View file

@ -1,3 +1,12 @@
type Pagination = {
page: number
per_page: number
total: number
total_pages: number
has_next: boolean
has_prev: boolean
}
type FileItem = {
type: 'file' | 'dir' | 'link'
content_type: 'image' | 'video' | 'text' | 'subtitle' | 'metadata' | 'dir' | string
@ -15,6 +24,7 @@ type FileItem = {
type FileBrowserResponse = {
path: string
contents: FileItem[]
pagination: Pagination
}
export type { FileItem, FileBrowserResponse }
export type { FileItem, FileBrowserResponse, Pagination }

56
uv.lock
View file

@ -807,11 +807,11 @@ wheels = [
[[package]]
name = "markdown"
version = "3.10.1"
version = "3.10.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b7/b1/af95bcae8549f1f3fd70faacb29075826a0d689a27f232e8cee315efa053/markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a", size = 365402 }
sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/1b/6ef961f543593969d25b2afe57a3564200280528caa9bd1082eecdd7b3bc/markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3", size = 107684 },
{ url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180 },
]
[[package]]
@ -939,11 +939,11 @@ wheels = [
[[package]]
name = "platformdirs"
version = "4.5.1"
version = "4.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715 }
sdist = { url = "https://files.pythonhosted.org/packages/71/25/ccd8e88fcd16a4eb6343a8b4b9635e6f3928a7ebcd82822a14d20e3ca29f/platformdirs-4.7.0.tar.gz", hash = "sha256:fd1a5f8599c85d49b9ac7d6e450bc2f1aaf4a23f1fe86d09952fe20ad365cf36", size = 23118 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731 },
{ url = "https://files.pythonhosted.org/packages/cb/e3/1eddccb2c39ecfbe09b3add42a04abcc3fa5b468aa4224998ffb8a7e9c8f/platformdirs-4.7.0-py3-none-any.whl", hash = "sha256:1ed8db354e344c5bb6039cd727f096af975194b508e37177719d562b2b540ee6", size = 18983 },
]
[[package]]
@ -1608,27 +1608,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.0"
version = "0.15.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893 }
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332 },
{ url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189 },
{ url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384 },
{ url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363 },
{ url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736 },
{ url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415 },
{ url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643 },
{ url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787 },
{ url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797 },
{ url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133 },
{ url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646 },
{ url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750 },
{ url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120 },
{ url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636 },
{ url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945 },
{ url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657 },
{ url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753 },
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819 },
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618 },
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518 },
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811 },
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169 },
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491 },
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280 },
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336 },
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288 },
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681 },
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401 },
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452 },
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900 },
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302 },
{ url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555 },
{ url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956 },
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604 },
]
[[package]]
@ -1653,11 +1653,11 @@ wheels = [
[[package]]
name = "setuptools"
version = "80.10.2"
version = "82.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343 }
sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234 },
{ url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468 },
]
[[package]]