feat: add NFO generation endpoint and UI integration for downloads

This commit is contained in:
arabcoders 2026-02-13 20:38:33 +03:00
parent a3fc7f6abb
commit 19d5d60de1
8 changed files with 477 additions and 77 deletions

View file

@ -124,6 +124,7 @@
"Microformat",
"microformats",
"mkvtoolsnix",
"MMDD",
"movflags",
"mpegts",
"msvideo",

53
API.md
View file

@ -31,6 +31,7 @@ This document describes the available endpoints and their usage. All endpoints r
- [POST /api/history/cancel](#post-apihistorycancel)
- [DELETE /api/history/{id}/archive](#delete-apihistoryidarchive)
- [POST /api/history/{id}/archive](#post-apihistoryidarchive)
- [POST /api/history/{id}/nfo](#post-apihistoryidnfo)
- [GET /api/archiver](#get-apiarchiver)
- [POST /api/archiver](#post-apiarchiver)
- [DELETE /api/archiver](#delete-apiarchiver)
@ -770,6 +771,58 @@ or an error:
---
### POST /api/history/{id}/nfo
**Purpose**: Generate an NFO metadata file for a completed download.
**Path Parameter**:
- `id`: Item ID from the history.
**Body** (optional):
```json
{
"type": "tv",
"overwrite": false
}
```
**Body Parameters**:
- `type` (string, optional): NFO format type. Either `"tv"` or `"movie"`. Default: `"tv"`.
- `overwrite` (boolean, optional): Overwrite existing NFO file. Default: `false`.
**Response** (success):
```json
{
"message": "NFO file created",
"nfo_file": "/path/to/file.nfo"
}
```
**Response** (NFO already exists):
```json
{
"error": "NFO file already exists."
}
```
**Error Responses**:
- `400 Bad Request` if:
- Item has no downloaded file
- `type` is not `"tv"` or `"movie"`
- Failed to collect NFO data (e.g., invalid/no date)
- `404 Not Found` if the item or media file doesn't exist
- `409 Conflict` if NFO file already exists and `overwrite` is `false`
- `500 Internal Server Error` if failed to extract metadata from URL
**Notes**:
- Fetches fresh metadata from the source URL using yt-dlp
- Creates NFO file in the same directory as the media file with `.nfo` extension
- Requires a valid upload/release date in the metadata to create NFO
- The NFO format follows Kodi/Jellyfin/Emby compatible structure
- TV mode generates `<episodedetails>` XML
- Movie mode generates `<movie>` XML
---
### GET /api/archiver
**Purpose**: Read entries from the download archive associated with a preset.

View file

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

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

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

View file

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

View file

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

View file

@ -188,12 +188,18 @@
</NuxtLink>
<template v-if="'error' === item.status">
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item has-text-warning" @click="async () => await retryItem(item, true)">
<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)">
<NuxtLink class="dropdown-item has-text-danger"
@ -384,6 +390,11 @@
</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)">
@ -750,9 +761,6 @@ const setIcon = (item: StoreItem) => {
return item.is_live ? 'fa-solid fa-globe' : 'fa-solid fa-circle-check'
}
if ('error' === item.status) {
if (item.filename) {
return 'fa-solid fa-file-circle-xmark'
}
return 'fa-solid fa-circle-xmark'
}
if ('cancelled' === item.status) {
@ -780,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'
}
@ -791,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) {
@ -1019,4 +1035,22 @@ const is_queued = (item: StoreItem) => {
}
return item.live_in || item.extras?.live_in || item.extras?.release_in ? 'fa-spin fa-spin-10' : ''
}
const generateNfo = async (item: StoreItem) => {
try {
toast.info('Generating please wait...', { timeout: 2000 })
const response = await request(`/api/history/${item._id}/nfo`, {
method: 'POST',
body: JSON.stringify({ type: 'tv', overwrite: true }),
})
const data = await response.json()
if (!response.ok) {
toast.error(data.error || 'Failed to generate NFO')
return
}
toast.success(data.message || 'NFO file generated')
} catch (e: any) {
toast.error(`Error: ${e.message}`)
}
}
</script>

View file

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