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
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:
commit
c36295d76f
18 changed files with 1275 additions and 487 deletions
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
|
@ -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
91
API.md
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
127
app/tests/test_nfo_maker.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
||||
|
|
|
|||
406
ui/app/composables/useBrowser.ts
Normal file
406
ui/app/composables/useBrowser.ts
Normal 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,
|
||||
})
|
||||
|
|
@ -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 " 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>
|
||||
|
|
|
|||
12
ui/app/types/filebrowser.d.ts
vendored
12
ui/app/types/filebrowser.d.ts
vendored
|
|
@ -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
56
uv.lock
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Reference in a new issue