Merge pull request #598 from arabcoders/dev

feat: extra placeholders for output template
This commit is contained in:
Abdulmohsen 2026-05-05 10:53:05 +03:00 committed by GitHub
commit b35ac1831a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
86 changed files with 6462 additions and 2636 deletions

138
API.md
View file

@ -24,6 +24,7 @@ This document describes the available endpoints and their usage. All endpoints r
- [DELETE /api/history](#delete-apihistory) - [DELETE /api/history](#delete-apihistory)
- [POST /api/history/{id}](#post-apihistoryid) - [POST /api/history/{id}](#post-apihistoryid)
- [GET /api/history/{id}](#get-apihistoryid) - [GET /api/history/{id}](#get-apihistoryid)
- [POST /api/history/{id}/rename](#post-apihistoryidrename)
- [GET /api/history](#get-apihistory) - [GET /api/history](#get-apihistory)
- [GET /api/history/live](#get-apihistorylive) - [GET /api/history/live](#get-apihistorylive)
- [POST /api/history/start](#post-apihistorystart) - [POST /api/history/start](#post-apihistorystart)
@ -55,6 +56,8 @@ This document describes the available endpoints and their usage. All endpoints r
- [GET /api/player/m3u8/{mode}/{file:.\*}.m3u8](#get-apiplayerm3u8modefilem3u8) - [GET /api/player/m3u8/{mode}/{file:.\*}.m3u8](#get-apiplayerm3u8modefilem3u8)
- [GET /api/player/segments/{segment}/{file:.\*}.ts](#get-apiplayersegmentssegmentfilets) - [GET /api/player/segments/{segment}/{file:.\*}.ts](#get-apiplayersegmentssegmentfilets)
- [GET /api/player/subtitle/{file:.\*}.vtt](#get-apiplayersubtitlefilevtt) - [GET /api/player/subtitle/{file:.\*}.vtt](#get-apiplayersubtitlefilevtt)
- [GET /api/player/subtitles/manifest/{file:.\*}](#get-apiplayersubtitlesmanifestfile)
- [GET /api/player/subtitles/{source\_format}/{file:.\*}](#get-apiplayersubtitlessource_formatfile)
- [GET /api/thumbnail](#get-apithumbnail) - [GET /api/thumbnail](#get-apithumbnail)
- [GET /api/file/ffprobe/{file:.\*}](#get-apifileffprobefile) - [GET /api/file/ffprobe/{file:.\*}](#get-apifileffprobefile)
- [GET /api/file/info/{file:.\*}](#get-apifileinfofile) - [GET /api/file/info/{file:.\*}](#get-apifileinfofile)
@ -98,6 +101,7 @@ This document describes the available endpoints and their usage. All endpoints r
- [GET /api/system/configuration](#get-apisystemconfiguration) - [GET /api/system/configuration](#get-apisystemconfiguration)
- [GET /api/system/limits](#get-apisystemlimits) - [GET /api/system/limits](#get-apisystemlimits)
- [POST /api/system/terminal](#post-apisystemterminal) - [POST /api/system/terminal](#post-apisystemterminal)
- [GET /api/system/terminal](#get-apisystemterminal)
- [GET /api/system/terminal/active](#get-apisystemterminalactive) - [GET /api/system/terminal/active](#get-apisystemterminalactive)
- [GET /api/system/terminal/{session\_id}](#get-apisystemterminalsession_id) - [GET /api/system/terminal/{session\_id}](#get-apisystemterminalsession_id)
- [DELETE /api/system/terminal/{session\_id}](#delete-apisystemterminalsession_id) - [DELETE /api/system/terminal/{session\_id}](#delete-apisystemterminalsession_id)
@ -529,6 +533,41 @@ or an error:
--- ---
### POST /api/history/{id}/rename
**Purpose**: Rename a downloaded history file and its sidecars.
**Path Parameter**:
- `id` = Unique history item ID.
**Body**:
```json
{
"new_name": "renamed_video.mp4"
}
```
**Response**:
```json
{
"_id": "<uuid>",
"title": "Video Title",
"url": "https://youtube.com/watch?v=...",
....
}
```
**Error Responses**:
- `400 Bad Request` if `id` or `new_name` is missing, or the item has no downloaded file.
- `404 Not Found` if the item does not exist.
- `409 Conflict` if the rename destination already exists.
- `500 Internal Server Error` if the filesystem rename fails unexpectedly.
**Notes**:
- Uses the same file rename behavior as the file browser actions.
- Renames matching sidecar files together with the main media file.
---
### GET /api/history ### GET /api/history
**Purpose**: Returns the download queue and/or download history with optional pagination support. **Purpose**: Returns the download queue and/or download history with optional pagination support.
@ -1550,6 +1589,51 @@ Binary TS data (`Content-Type: video/mpegts`).
--- ---
### GET /api/player/subtitles/manifest/{file:.*}
**Purpose**: Returns subtitle track metadata for a local media file.
**Path Parameter**:
- `file` = Relative path of the media file within the `download_path`.
**Response**:
```json
{
"subtitles": [
{
"lang": "en",
"name": "VTT (0) - en",
"source_format": "vtt",
"delivery_format": "vtt",
"renderer": "native",
"url": "/api/player/subtitles/vtt/path/to/video.en.vtt"
},
{
"lang": "en",
"name": "ASS (1) - en",
"source_format": "ass",
"delivery_format": "ass",
"renderer": "assjs",
"url": "/api/player/subtitles/ass/path/to/video.en.ass"
}
]
}
```
---
### GET /api/player/subtitles/{source_format}/{file:.*}
**Purpose**: Delivers a subtitle file using its preferred playback format.
**Path Parameters**:
- `source_format` = `vtt`, `srt`, or `ass`.
- `file` = Relative path of the subtitle file.
**Response**:
- `text/vtt; charset=UTF-8` for `vtt` and `srt` sources.
- `text/x-ssa; charset=UTF-8` for `ass` sources.
---
### GET /api/thumbnail ### GET /api/thumbnail
**Purpose**: Proxy/fetch a remote thumbnail image. **Purpose**: Proxy/fetch a remote thumbnail image.
@ -1593,21 +1677,16 @@ Binary image data with the appropriate `Content-Type`.
}, },
"mimetype": "video/mp4", "mimetype": "video/mp4",
"sidecar": { "sidecar": {
"subtitles": [ "subtitle": [
{ {
"file": "filename.xxx.vtt", "file": "filename.xxx.ass",
"lang": "xxx", "lang": "xxx",
"name": "VTT 0 - XXX|end", "name": "ASS (0) - xxx"
}, },
... ...
}
], ],
"video": [],
"audio": [],
"image": [], "image": [],
"text": [], "text": []
"metadata": [],
...
} }
} }
``` ```
@ -2579,6 +2658,36 @@ or an error:
--- ---
### GET /api/system/terminal
**Purpose**: List recent terminal sessions.
**Response**:
```json
{
"items": [
{
"session_id": "3a8c5f7e2d3b4a8f9c0d1e2f3a4b5c6d",
"command": "--help",
"status": "completed",
"created_at": 1713000000.0,
"started_at": 1713000000.0,
"finished_at": 1713000004.0,
"expires_at": 1713086404.0,
"available_until": 1713086404.0,
"exit_code": 0,
"last_sequence": 15
}
]
}
```
**Notes**:
- Expired sessions are removed automatically later.
- `403 Forbidden` if console is disabled.
---
### GET /api/system/terminal/active ### GET /api/system/terminal/active
**Purpose**: Return the currently active terminal session metadata, or `null` if no session is active. **Purpose**: Return the currently active terminal session metadata, or `null` if no session is active.
@ -2606,7 +2715,7 @@ or
--- ---
### GET /api/system/terminal/{session_id} ### GET /api/system/terminal/{session_id}
**Purpose**: Return metadata for a specific terminal session while it is active or still within the replay/drain window. **Purpose**: Return metadata for a specific terminal session.
**Response**: **Response**:
```json ```json
@ -2617,7 +2726,7 @@ or
"created_at": 1713000000.0, "created_at": 1713000000.0,
"started_at": 1713000000.0, "started_at": 1713000000.0,
"finished_at": 1713000004.0, "finished_at": 1713000004.0,
"expires_at": 1713000034.0, "expires_at": 1713086404.0,
"exit_code": 0, "exit_code": 0,
"last_sequence": 15 "last_sequence": 15
} }
@ -2641,8 +2750,7 @@ or
**Notes**: **Notes**:
- This only applies to the currently active session. - This only applies to the currently active session.
- The client should stay attached to the stream to receive the final `close` event and refreshed terminal status. - Cancelled sessions are marked as `interrupted`.
- Cancelled sessions finalize as `interrupted` and remain replayable until the drain window expires.
- `403 Forbidden` if console is disabled. - `403 Forbidden` if console is disabled.
- `404 Not Found` if the session does not exist or has already expired. - `404 Not Found` if the session does not exist or has already expired.
@ -2674,10 +2782,6 @@ If both `since` and `Last-Event-ID` are present, the larger value is used.
{ "exitcode": 0 } { "exitcode": 0 }
``` ```
**Notes**:
- Replay/restore works while the session is still running or until the finished session expires.
- Finished sessions are removed lazily after the transcript drain window elapses.
- `403 Forbidden` if console is disabled. - `403 Forbidden` if console is disabled.
- `400 Bad Request` if the replay cursor is invalid. - `400 Bad Request` if the replay cursor is invalid.
- `404 Not Found` if the session does not exist or has already expired. - `404 Not Found` if the session does not exist or has already expired.

22
FAQ.md
View file

@ -167,6 +167,28 @@ YTP_YTDLP_VERSION=2025.07.21 or master or nightly
Then restart the container to apply the changes. Then restart the container to apply the changes.
# Custom output template placeholders
YTPTube supports custom `ytp_*` placeholders in `yt-dlp` output template via the following syntax `%(ytp_*:<args>)s`.
## Currently available extra placeholders are:
- `ytp_random`: random mixed letters and digits,
- `N` A number is required to specify the length of the random string, for example `%(ytp_random:8)s` will generate a random string of 8 characters.
- if the args followed by `:s` it will generate random letters only, if followed by `:d` it will generate random digits only.
## Examples of the custom placeholders in action:
- Template: `%(title)s [%(ytp_random:8)s].%(ext)s`
- Example result: `My Video [A7k2Pq9Z].mp4`
- Template: `%(uploader)s/%(ytp_random:6:d)s - %(title)s.%(ext)s`
- Example result: `MyChannel/483920 - My Video.mp4`
- Template: `%(playlist)s/%(ytp_random:10:s)s/%(title)s.%(ext)s`
- Example result: `Favorites/QwErTyUiOp/My Video.mp4`
> [!NOTE]
> `%(ytp_` placeholders are a YTPTube extension and not avaliable via console or directly via yt-dlp.
# How can I monitor sites without RSS feeds? # How can I monitor sites without RSS feeds?
YTPTube includes a **generic task handler** that turns JSON definitions into site-specific scrapers. You can use it YTPTube includes a **generic task handler** that turns JSON definitions into site-specific scrapers. You can use it

View file

@ -53,7 +53,7 @@ class TestAllowInternalUrlsScope:
Config._reset_singleton() Config._reset_singleton()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_conditions_test_rejects_internal_url_when_disallowed(self) -> None: async def test_rejects_internal_url(self) -> None:
config = Config.get_instance() config = Config.get_instance()
config.allow_internal_urls = False config.allow_internal_urls = False
encoder = Encoder() encoder = Encoder()

View file

@ -99,7 +99,7 @@ class TestPresetsRepository:
assert total_pages == 1, "Should compute pages from the filtered total" assert total_pages == 1, "Should compute pages from the filtered total"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_paginated_supports_multiple_sort_fields(self, repo): async def test_list_paginated_multi_sort(self, repo):
await repo.create({"name": "Charlie", "priority": 2}) await repo.create({"name": "Charlie", "priority": 2})
await repo.create({"name": "Alpha", "priority": 1}) await repo.create({"name": "Alpha", "priority": 1})
await repo.create({"name": "Bravo", "priority": 1}) await repo.create({"name": "Bravo", "priority": 1})
@ -113,24 +113,24 @@ class TestPresetsRepository:
], "Should support multiple sort fields and directions" ], "Should support multiple sort fields and directions"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_paginated_rejects_invalid_sort_field(self, repo): async def test_list_paginated_bad_sort(self, repo):
with pytest.raises(ValueError, match="sort must use supported fields"): with pytest.raises(ValueError, match="sort must use supported fields"):
await repo.list_paginated(page=1, per_page=10, sort="cli", order="asc") await repo.list_paginated(page=1, per_page=10, sort="cli", order="asc")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_paginated_rejects_invalid_sort_direction(self, repo): async def test_list_paginated_bad_order(self, repo):
with pytest.raises(ValueError, match="order must be 'asc' or 'desc'"): with pytest.raises(ValueError, match="order must be 'asc' or 'desc'"):
await repo.list_paginated(page=1, per_page=10, sort="name", order="sideways") await repo.list_paginated(page=1, per_page=10, sort="name", order="sideways")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_paginated_rejects_mismatched_sort_and_order_lengths(self, repo): async def test_list_paginated_mismatched_order(self, repo):
with pytest.raises(ValueError, match="order must provide one direction or match the number of sort fields"): with pytest.raises(ValueError, match="order must provide one direction or match the number of sort fields"):
await repo.list_paginated(page=1, per_page=10, sort="priority,name", order="asc,desc,asc") await repo.list_paginated(page=1, per_page=10, sort="priority,name", order="asc,desc,asc")
@pytest.mark.asyncio @pytest.mark.asyncio
class TestPresetRoutes: class TestPresetRoutes:
async def test_list_route_supports_sort_params(self, repo): async def test_list_route_sort(self, repo):
await repo.create({"name": "Alpha", "priority": 1}) await repo.create({"name": "Alpha", "priority": 1})
await repo.create({"name": "Bravo", "priority": 1}) await repo.create({"name": "Bravo", "priority": 1})
await repo.create({"name": "Charlie", "priority": 2}) await repo.create({"name": "Charlie", "priority": 2})
@ -144,7 +144,7 @@ class TestPresetRoutes:
assert response.status == web.HTTPOk.status_code, "Should return 200 for valid sorting" assert response.status == web.HTTPOk.status_code, "Should return 200 for valid sorting"
assert [item["name"] for item in payload["items"]] == ["bravo", "alpha", "charlie"], "Should sort response" assert [item["name"] for item in payload["items"]] == ["bravo", "alpha", "charlie"], "Should sort response"
async def test_list_route_rejects_invalid_sort_field(self, repo): async def test_list_route_bad_sort(self, repo):
request = MagicMock(spec=Request) request = MagicMock(spec=Request)
request.query = {"sort": "cli", "order": "asc"} request.query = {"sort": "cli", "order": "asc"}
@ -154,7 +154,7 @@ class TestPresetRoutes:
assert response.status == web.HTTPBadRequest.status_code, "Should reject unsupported sort field" assert response.status == web.HTTPBadRequest.status_code, "Should reject unsupported sort field"
assert "sort" in payload["error"], "Should explain invalid sort field" assert "sort" in payload["error"], "Should explain invalid sort field"
async def test_list_route_rejects_invalid_sort_direction(self, repo): async def test_list_route_bad_order(self, repo):
request = MagicMock(spec=Request) request = MagicMock(spec=Request)
request.query = {"sort": "name", "order": "sideways"} request.query = {"sort": "name", "order": "sideways"}
@ -164,7 +164,7 @@ class TestPresetRoutes:
assert response.status == web.HTTPBadRequest.status_code, "Should reject unsupported sort direction" assert response.status == web.HTTPBadRequest.status_code, "Should reject unsupported sort direction"
assert "order" in payload["error"], "Should explain invalid sort direction" assert "order" in payload["error"], "Should explain invalid sort direction"
async def test_list_route_supports_excluding_defaults(self, repo): async def test_list_route_exclude_defaults(self, repo):
await repo.create({"name": "System Default", "default": True, "priority": 10}) await repo.create({"name": "System Default", "default": True, "priority": 10})
await repo.create({"name": "Custom Preset", "priority": 1}) await repo.create({"name": "Custom Preset", "priority": 1})

View file

@ -1,4 +1,7 @@
from __future__ import annotations
import logging import logging
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import anyio import anyio
@ -6,10 +9,37 @@ import pysubs2
from pysubs2.formats.substation import SubstationFormat from pysubs2.formats.substation import SubstationFormat
from pysubs2.time import ms_to_times from pysubs2.time import ms_to_times
from app.library.Utils import ALLOWED_SUBS_EXTENSIONS from app.library.Utils import ALLOWED_SUBS_EXTENSIONS, get_file_sidecar
LOG: logging.Logger = logging.getLogger("player.subtitle") LOG: logging.Logger = logging.getLogger("player.subtitle")
SOURCE_FORMATS: tuple[str, ...] = ("vtt", "srt", "ass")
DELIVERY_FORMATS: dict[str, str] = {
"vtt": "vtt",
"srt": "vtt",
"ass": "ass",
}
RENDERERS: dict[str, str] = {
"vtt": "native",
"srt": "native",
"ass": "assjs",
}
MEDIA_TYPES: dict[str, str] = {
"vtt": "text/vtt; charset=UTF-8",
"ass": "text/x-ssa; charset=UTF-8",
}
TEXT_ENCODINGS: tuple[str, ...] = ("utf-8-sig", "utf-16", "utf-16-le", "utf-16-be", "cp1252")
@dataclass(frozen=True, slots=True)
class SubtitleTrack:
file: Path
lang: str
name: str
source_format: str
delivery_format: str
renderer: str
def ms_to_timestamp(ms: int) -> str: def ms_to_timestamp(ms: int) -> str:
ms = max(0, ms) ms = max(0, ms)
@ -22,14 +52,46 @@ SubstationFormat.ms_to_timestamp = ms_to_timestamp
class Subtitle: class Subtitle:
@staticmethod
def normalize_format(source_format: str) -> str | None:
fmt = source_format.strip().lower().removeprefix(".")
if fmt not in SOURCE_FORMATS:
return None
return fmt
async def read_text(self, file: Path) -> str:
async with await anyio.open_file(file, "rb") as f:
subtitle_bytes = await f.read()
return self.decode_bytes(subtitle_bytes)
@staticmethod
def decode_bytes(subtitle_bytes: bytes) -> str:
for encoding in TEXT_ENCODINGS:
try:
return subtitle_bytes.decode(encoding)
except UnicodeDecodeError:
continue
return subtitle_bytes.decode("utf-8", errors="replace")
def media_type(self, file: Path) -> str:
fmt = self.normalize_format(file.suffix)
if fmt is None:
msg = f"File '{file}' subtitle type is not supported."
raise Exception(msg)
return MEDIA_TYPES[DELIVERY_FORMATS[fmt]]
async def make(self, file: Path) -> str: async def make(self, file: Path) -> str:
if file.suffix not in ALLOWED_SUBS_EXTENSIONS: fmt = self.normalize_format(file.suffix)
if fmt is None or file.suffix not in ALLOWED_SUBS_EXTENSIONS:
msg: str = f"File '{file}' subtitle type is not supported." msg: str = f"File '{file}' subtitle type is not supported."
raise Exception(msg) raise Exception(msg)
if file.suffix == ".vtt": if fmt == "vtt":
async with await anyio.open_file(file) as f: return await self.read_text(file)
return await f.read()
subs: pysubs2.SSAFile = pysubs2.load(path=str(file)) subs: pysubs2.SSAFile = pysubs2.load(path=str(file))
@ -47,3 +109,45 @@ class Subtitle:
pass pass
return subs.to_string("vtt") return subs.to_string("vtt")
async def make_delivery(self, file: Path) -> tuple[str, str]:
fmt = self.normalize_format(file.suffix)
if fmt is None or file.suffix not in ALLOWED_SUBS_EXTENSIONS:
msg: str = f"File '{file}' subtitle type is not supported."
raise Exception(msg)
if fmt == "ass":
return await self.read_text(file), self.media_type(file)
return await self.make(file), self.media_type(file)
def get_subtitle_tracks(file: Path) -> list[SubtitleTrack]:
sidecars = get_file_sidecar(file).get("subtitle", [])
indexed_tracks: list[tuple[int, SubtitleTrack]] = []
for index, item in enumerate(sidecars):
track_file = item.get("file")
if not isinstance(track_file, Path):
continue
fmt = Subtitle.normalize_format(track_file.suffix)
if fmt is None:
continue
indexed_tracks.append(
(
index,
SubtitleTrack(
file=track_file,
lang=str(item.get("lang") or "und"),
name=str(item.get("name") or track_file.name),
source_format=fmt,
delivery_format=DELIVERY_FORMATS[fmt],
renderer=RENDERERS[fmt],
),
)
)
indexed_tracks.sort(key=lambda item: (SOURCE_FORMATS.index(item[1].source_format), item[0]))
return [track for _, track in indexed_tracks]

View file

@ -9,7 +9,7 @@ from aiohttp.web import Request, Response
from app.features.streaming.library.m3u8 import M3u8 from app.features.streaming.library.m3u8 import M3u8
from app.features.streaming.library.playlist import Playlist from app.features.streaming.library.playlist import Playlist
from app.features.streaming.library.segments import Segments from app.features.streaming.library.segments import Segments
from app.features.streaming.library.subtitle import Subtitle from app.features.streaming.library.subtitle import Subtitle, get_subtitle_tracks
from app.features.streaming.types import StreamingError from app.features.streaming.types import StreamingError
from app.library.config import Config from app.library.config import Config
from app.library.router import route from app.library.router import route
@ -277,3 +277,134 @@ async def subtitles_get(request: Request, config: Config, app: web.Application)
}, },
status=web.HTTPOk.status_code, status=web.HTTPOk.status_code,
) )
@route("GET", "api/player/subtitles/manifest/{file:.*}", "subtitles_manifest_get")
async def subtitles_manifest_get(request: Request, config: Config, app: web.Application) -> Response:
"""
Get subtitle track metadata for a media file.
Args:
request (Request): The request object.
config (Config): The configuration instance.
app (web.Application): The aiohttp application instance.
Returns:
Response: The response object.
"""
file: str = request.match_info.get("file")
if not file:
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
realFile, status = get_file(download_path=config.download_path, file=file)
if web.HTTPFound.status_code == status:
return Response(
status=status,
headers={
"Location": str(
app.router["subtitles_manifest_get"].url_for(
file=str(realFile).replace(config.download_path, "").strip("/")
)
),
},
)
if web.HTTPNotFound.status_code == status:
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
tracks = [
{
"lang": track.lang,
"name": track.name,
"source_format": track.source_format,
"delivery_format": track.delivery_format,
"renderer": track.renderer,
"url": str(
app.router["subtitles_track_get"].url_for(
source_format=track.source_format,
file=str(track.file).replace(config.download_path, "").strip("/"),
)
),
}
for track in get_subtitle_tracks(realFile)
]
return web.json_response(data={"subtitles": tracks}, status=web.HTTPOk.status_code)
@route("GET", "api/player/subtitles/{source_format}/{file:.*}", "subtitles_track_get")
async def subtitles_track_get(request: Request, config: Config, app: web.Application) -> Response:
"""
Get a subtitle file using its preferred delivery format.
Args:
request (Request): The request object.
config (Config): The configuration instance.
app (web.Application): The aiohttp application instance.
Returns:
Response: The response object.
"""
file: str = request.match_info.get("file")
source_format: str | None = request.match_info.get("source_format")
if not file:
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
fmt = Subtitle.normalize_format(source_format or "")
if fmt is None:
return web.json_response(
data={"error": "Only vtt, srt, and ass subtitle formats are supported."},
status=web.HTTPBadRequest.status_code,
)
realFile, status = get_file(download_path=config.download_path, file=file)
if web.HTTPFound.status_code == status:
return Response(
status=status,
headers={
"Location": str(
app.router["subtitles_track_get"].url_for(
source_format=fmt,
file=str(realFile).replace(config.download_path, "").strip("/"),
)
),
},
)
if web.HTTPNotFound.status_code == status:
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
if Subtitle.normalize_format(realFile.suffix) != fmt:
return web.json_response(
data={"error": f"Subtitle file '{file}' does not match requested source format '{fmt}'."},
status=web.HTTPBadRequest.status_code,
)
mtime = realFile.stat().st_mtime
if request.if_modified_since and request.if_modified_since.timestamp() == mtime:
lastMod = time.strftime("%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple())
return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod})
body, content_type = await Subtitle().make_delivery(file=realFile)
return web.Response(
body=body,
headers={
"Content-Type": content_type,
"X-Accel-Buffering": "no",
"Access-Control-Allow-Origin": "*",
"Pragma": "public",
"Cache-Control": f"public, max-age={time.time() + 31536000}",
"Last-Modified": time.strftime(
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple()
),
"Expires": time.strftime(
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple()
),
},
status=web.HTTPOk.status_code,
)

View file

@ -84,13 +84,11 @@ class TestFFProbe:
# First call should execute the function # First call should execute the function
result1 = await ffprobe(str(self.test_file)) result1 = await ffprobe(str(self.test_file))
assert result1 is not None
assert isinstance(result1.metadata, dict) assert isinstance(result1.metadata, dict)
first_call_count = call_count first_call_count = call_count
# Second call with same argument should use cached result # Second call with same argument should use cached result
result2 = await ffprobe(str(self.test_file)) result2 = await ffprobe(str(self.test_file))
assert result2 is not None
assert isinstance(result2.metadata, dict) assert isinstance(result2.metadata, dict)
assert call_count == first_call_count, ( assert call_count == first_call_count, (
@ -119,7 +117,7 @@ class TestFFProbe:
# Test with Path object # Test with Path object
result = await ffprobe(self.test_file) result = await ffprobe(self.test_file)
assert result is not None assert result.metadata == {"duration": "10.0"}
def test_ffprobe_result_properties(self): def test_ffprobe_result_properties(self):
"""Test FFProbeResult object properties.""" """Test FFProbeResult object properties."""

View file

@ -64,7 +64,7 @@ async def test_make_stream_basic_ok_codecs(tmp_path: Path, monkeypatch: pytest.M
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_make_stream_transcode_flags_and_remainder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: async def test_make_stream_transcode_flags(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
base = tmp_path / "dl" base = tmp_path / "dl"
base.mkdir() base.mkdir()
media = base / "v.mp4" media = base / "v.mp4"
@ -107,7 +107,7 @@ async def test_make_stream_transcode_flags_and_remainder(tmp_path: Path, monkeyp
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_make_stream_raises_without_duration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: async def test_make_stream_no_duration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
base = tmp_path / "dl" base = tmp_path / "dl"
base.mkdir() base.mkdir()
media = base / "v.mp4" media = base / "v.mp4"

View file

@ -99,7 +99,7 @@ async def test_make_playlist_with_subtitles(tmp_path: Path, monkeypatch: pytest.
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_make_playlist_raises_without_duration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: async def test_make_playlist_no_duration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
base = tmp_path / "downloads" base = tmp_path / "downloads"
base.mkdir() base.mkdir()
media = base / "file.mp4" media = base / "file.mp4"

View file

@ -172,7 +172,7 @@ class _FakeProcFail(_FakeProc):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_build_ffmpeg_args_no_dri_falls_back_to_software(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: async def test_build_ffmpeg_args_no_dri(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
media = tmp_path / "file.mp4" media = tmp_path / "file.mp4"
media.write_bytes(b"data") media.write_bytes(b"data")
@ -219,7 +219,7 @@ async def test_build_ffmpeg_args_no_dri_falls_back_to_software(tmp_path: Path, m
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stream_gpu_failure_falls_back_to_software( async def test_stream_gpu_fallback(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
async def fake_ffprobe(_file: Path): async def fake_ffprobe(_file: Path):

View file

@ -4,7 +4,7 @@ from unittest.mock import patch
import pytest import pytest
from app.features.streaming.library.subtitle import Subtitle, ms_to_timestamp from app.features.streaming.library.subtitle import Subtitle, get_subtitle_tracks, ms_to_timestamp
class TestMsToTimestamp: class TestMsToTimestamp:
@ -24,12 +24,12 @@ class TestMsToTimestamp:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_make_unsupported_extension(tmp_path: Path) -> None: async def test_make_unsupported_extension(tmp_path: Path) -> None:
srt = tmp_path / "sub.txt" file = tmp_path / "sub.txt"
srt.write_text("not a subtitle") file.write_text("not a subtitle")
sub = Subtitle() subtitle = Subtitle()
with pytest.raises(Exception, match="subtitle type is not supported"): with pytest.raises(Exception, match="subtitle type is not supported"):
await sub.make(srt) await subtitle.make(file)
@pytest.mark.asyncio @pytest.mark.asyncio
@ -38,11 +38,51 @@ async def test_make_vtt_reads_file(tmp_path: Path) -> None:
content = "WEBVTT\n\n00:00:00.00 --> 00:00:01.00\nHello" content = "WEBVTT\n\n00:00:00.00 --> 00:00:01.00\nHello"
vtt.write_text(content) vtt.write_text(content)
sub = Subtitle() subtitle = Subtitle()
out = await sub.make(vtt) out = await subtitle.make(vtt)
assert out == content assert out == content
@pytest.mark.asyncio
async def test_make_delivery_ass(tmp_path: Path) -> None:
ass = tmp_path / "file.ass"
content = "[Script Info]\nTitle: Demo\n"
ass.write_text(content, encoding="utf-8")
subtitle = Subtitle()
out, media_type = await subtitle.make_delivery(ass)
assert out == content
assert media_type == "text/x-ssa; charset=UTF-8"
def test_tracks_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
media = tmp_path / "video.mkv"
media.write_text("x", encoding="utf-8")
ass_file = tmp_path / "video.ass"
ass_file.write_text("ass", encoding="utf-8")
vtt_file = tmp_path / "video.vtt"
vtt_file.write_text("WEBVTT\n\n", encoding="utf-8")
srt_file = tmp_path / "video.en.srt"
srt_file.write_text("1\n00:00:00,000 --> 00:00:01,000\nHello\n", encoding="utf-8")
monkeypatch.setattr(
"app.features.streaming.library.subtitle.get_file_sidecar",
lambda _file: {
"subtitle": [
{"file": ass_file, "lang": "en", "name": "ASS"},
{"file": srt_file, "lang": "en", "name": "SRT"},
{"file": vtt_file, "lang": "en", "name": "VTT"},
]
},
)
tracks = get_subtitle_tracks(media)
assert [track.source_format for track in tracks] == ["vtt", "srt", "ass"]
assert [track.delivery_format for track in tracks] == ["vtt", "vtt", "ass"]
assert [track.renderer for track in tracks] == ["native", "native", "assjs"]
class _DummySubs: class _DummySubs:
def __init__(self, events): def __init__(self, events):
self.events = events self.events = events
@ -61,13 +101,13 @@ async def test_make_no_events_raises(tmp_path: Path) -> None:
with patch("app.features.streaming.library.subtitle.pysubs2.load") as mock_load: with patch("app.features.streaming.library.subtitle.pysubs2.load") as mock_load:
mock_load.return_value = _DummySubs(events=[]) mock_load.return_value = _DummySubs(events=[])
sub = Subtitle() subtitle = Subtitle()
with pytest.raises(Exception, match="No subtitle events were found"): with pytest.raises(Exception, match="No subtitle events were found"):
await sub.make(srt) await subtitle.make(srt)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_make_single_event_returns_vtt(tmp_path: Path) -> None: async def test_make_single_vtt(tmp_path: Path) -> None:
srt = tmp_path / "sub.ass" srt = tmp_path / "sub.ass"
srt.write_text("dummy") srt.write_text("dummy")
@ -75,14 +115,14 @@ async def test_make_single_event_returns_vtt(tmp_path: Path) -> None:
d = _DummySubs(events=[single]) d = _DummySubs(events=[single])
with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d): with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d):
sub = Subtitle() subtitle = Subtitle()
out = await sub.make(srt) out = await subtitle.make(srt)
assert out == "OUT" assert out == "OUT"
assert d.snapshot == [1000], "Snapshot should contain the single event" assert d.snapshot == [1000], "Snapshot should contain the single event"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_make_two_events_pop_first_when_ends_equal(tmp_path: Path) -> None: async def test_make_two_events_same_end(tmp_path: Path) -> None:
srt = tmp_path / "sub.srt" srt = tmp_path / "sub.srt"
srt.write_text("dummy") srt.write_text("dummy")
@ -91,14 +131,14 @@ async def test_make_two_events_pop_first_when_ends_equal(tmp_path: Path) -> None
d = _DummySubs(events=[e1, e2]) d = _DummySubs(events=[e1, e2])
with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d): with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d):
sub = Subtitle() subtitle = Subtitle()
out = await sub.make(srt) out = await subtitle.make(srt)
assert out == "OUT" assert out == "OUT"
assert d.snapshot == [5000], "Since ends are equal, first should be popped => only last remains" assert d.snapshot == [5000], "Since ends are equal, first should be popped => only last remains"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_make_two_events_no_pop_when_different(tmp_path: Path) -> None: async def test_make_two_events_keep_both(tmp_path: Path) -> None:
srt = tmp_path / "sub.srt" srt = tmp_path / "sub.srt"
srt.write_text("dummy") srt.write_text("dummy")
@ -107,7 +147,7 @@ async def test_make_two_events_no_pop_when_different(tmp_path: Path) -> None:
d = _DummySubs(events=[e1, e2]) d = _DummySubs(events=[e1, e2])
with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d): with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d):
sub = Subtitle() subtitle = Subtitle()
out = await sub.make(srt) out = await subtitle.make(srt)
assert out == "OUT" assert out == "OUT"
assert d.snapshot == [5000, 6000], "Both remain since ends differ" assert d.snapshot == [5000, 6000], "Both remain since ends differ"

View file

@ -0,0 +1,132 @@
from pathlib import Path
from types import SimpleNamespace
import pytest
from aiohttp import web
from aiohttp.test_utils import make_mocked_request
from app.features.streaming import router
from app.library.config import Config
class _DummyRoute:
def __init__(self, pattern: str) -> None:
self.pattern = pattern
def url_for(self, **params: str) -> str:
path = self.pattern
for key, value in params.items():
path = path.replace(f"{{{key}:.*}}", value)
path = path.replace(f"{{{key}}}", value)
return path
class _DummyRouter(dict[str, _DummyRoute]):
def __init__(self) -> None:
super().__init__(
{
"subtitles_manifest_get": _DummyRoute("/api/player/subtitles/manifest/{file:.*}"),
"subtitles_track_get": _DummyRoute("/api/player/subtitles/{source_format}/{file:.*}"),
"subtitles_get": _DummyRoute("/api/player/subtitle/{file:.*}.vtt"),
}
)
def _make_request(path: str, *, match_info: dict[str, str]) -> web.Request:
return make_mocked_request("GET", path, app=SimpleNamespace(router=_DummyRouter()), match_info=match_info)
@pytest.mark.asyncio
async def test_subtitles_manifest_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
config = Config.get_instance()
config.download_path = str(tmp_path)
media = tmp_path / "video.mp4"
media.write_text("x", encoding="utf-8")
monkeypatch.setattr(
"app.features.streaming.router.get_file",
lambda **_kwargs: (media, web.HTTPOk.status_code),
)
monkeypatch.setattr(
"app.features.streaming.router.get_subtitle_tracks",
lambda _file: [
SimpleNamespace(
lang="en",
name="English VTT",
source_format="vtt",
delivery_format="vtt",
renderer="native",
file=tmp_path / "video.vtt",
),
SimpleNamespace(
lang="en",
name="English ASS",
source_format="ass",
delivery_format="ass",
renderer="assjs",
file=tmp_path / "video.ass",
),
],
)
req = _make_request(
"/api/player/subtitles/manifest/video.mp4",
match_info={"file": "video.mp4"},
)
response = await router.subtitles_manifest_get(req, config, req.app)
assert response.status == web.HTTPOk.status_code
body = response.text
assert '"source_format": "vtt"' in body
assert '"renderer": "native"' in body
assert '"source_format": "ass"' in body
assert '"renderer": "assjs"' in body
assert body.index('"source_format": "vtt"') < body.index('"source_format": "ass"')
@pytest.mark.asyncio
async def test_subtitles_track_get_ass(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
config = Config.get_instance()
config.download_path = str(tmp_path)
subtitle = tmp_path / "video.ass"
subtitle.write_text("[Script Info]\nTitle: Demo\n", encoding="utf-8")
monkeypatch.setattr(
"app.features.streaming.router.get_file",
lambda **_kwargs: (subtitle, web.HTTPOk.status_code),
)
req = _make_request(
"/api/player/subtitles/ass/video.ass",
match_info={"source_format": "ass", "file": "video.ass"},
)
response = await router.subtitles_track_get(req, config, req.app)
assert response.status == web.HTTPOk.status_code
assert response.text == "[Script Info]\nTitle: Demo\n"
assert response.headers["Content-Type"] == "text/x-ssa; charset=UTF-8"
@pytest.mark.asyncio
async def test_subtitles_track_bad_format(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
config = Config.get_instance()
config.download_path = str(tmp_path)
subtitle = tmp_path / "video.ass"
subtitle.write_text("[Script Info]\nTitle: Demo\n", encoding="utf-8")
monkeypatch.setattr(
"app.features.streaming.router.get_file",
lambda **_kwargs: (subtitle, web.HTTPOk.status_code),
)
req = _make_request(
"/api/player/subtitles/vtt/video.ass",
match_info={"source_format": "vtt", "file": "video.ass"},
)
response = await router.subtitles_track_get(req, config, req.app)
assert response.status == web.HTTPBadRequest.status_code
assert b"does not match requested source format" in response.body

View file

@ -21,7 +21,7 @@ def reset_generic_handler(monkeypatch):
monkeypatch.setattr(GenericTaskHandler, "_sources_mtime", {}) monkeypatch.setattr(GenericTaskHandler, "_sources_mtime", {})
def test_build_task_definition_parses_valid_payload(): def test_build_def_payload():
definition = TaskDefinition( definition = TaskDefinition(
id=1, id=1,
name="example", name="example",
@ -40,16 +40,12 @@ def test_build_task_definition_parses_valid_payload():
), ),
) )
assert definition is not None, "TaskDefinition should be created" assert definition.name == "example"
assert "example" == definition.name, "Name should match" assert definition.match_url == ["https://example.com/articles/*"]
assert "https://example.com/articles/*" in definition.match_url, "Match URL should be in list" assert definition.definition.parse["link"]["expression"] == ".article a.link::attr(href)"
assert "link" in definition.definition.parse, "Parse should contain link field"
assert ".article a.link::attr(href)" == definition.definition.parse["link"]["expression"], (
"Link expression should match"
)
def test_build_task_definition_handles_container(): def test_build_def_container():
definition = TaskDefinition( definition = TaskDefinition(
id=2, id=2,
name="container", name="container",
@ -73,13 +69,11 @@ def test_build_task_definition_handles_container():
), ),
) )
assert definition is not None, "TaskDefinition should be created" assert definition.definition.parse["items"]["selector"] == ".cards .card"
assert "items" in definition.definition.parse, "Parse should contain items container" assert definition.definition.parse["items"]["fields"]["link"]["attribute"] == "href"
assert ".cards .card" == definition.definition.parse["items"]["selector"], "Items selector should match"
assert "link" in definition.definition.parse["items"]["fields"], "Items fields should contain link"
def test_build_task_definition_handles_json(): def test_build_def_json():
definition = TaskDefinition( definition = TaskDefinition(
id=3, id=3,
name="json-def", name="json-def",
@ -104,16 +98,12 @@ def test_build_task_definition_handles_json():
), ),
) )
assert definition is not None, "TaskDefinition should be created" assert definition.definition.response.type == "json"
assert "json" == definition.definition.response.type, "Response type should be json" assert definition.definition.parse["items"]["type"] == "jsonpath"
assert "items" in definition.definition.parse, "Parse should contain items container" assert definition.definition.parse["items"]["fields"]["link"]["type"] == "jsonpath"
assert "jsonpath" == definition.definition.parse["items"]["type"], "Items type should be jsonpath"
assert "jsonpath" == definition.definition.parse["items"]["fields"]["link"]["type"], (
"Link field type should be jsonpath"
)
def test_parse_items_extracts_values(): def test_parse_items_basic():
definition = TaskDefinition( definition = TaskDefinition(
id=4, id=4,
name="example", name="example",
@ -146,14 +136,16 @@ def test_parse_items_extracts_values():
items = GenericTaskHandler._parse_items(definition, html, "https://example.com/base/") items = GenericTaskHandler._parse_items(definition, html, "https://example.com/base/")
assert 2 == len(items), "Should extract 2 items" assert len(items) == 2
assert "https://example.com/article-101" == items[0]["link"], "First item link should be absolute URL" assert items[0] == {
assert "First Title" == items[0]["title"], "First item title should match" "link": "https://example.com/article-101",
assert "101" == items[0]["id"], "First item id should match" "title": "First Title",
assert "https://example.com/article-102" == items[1]["link"], "Second item link should match" "id": "101",
}
assert items[1]["link"] == "https://example.com/article-102"
def test_parse_items_handles_nested_card_layout(): def test_parse_items_cards():
definition = TaskDefinition( definition = TaskDefinition(
id=5, id=5,
name="nested", name="nested",
@ -234,19 +226,21 @@ def test_parse_items_handles_nested_card_layout():
items = GenericTaskHandler._parse_items(definition, html, "https://example.com") items = GenericTaskHandler._parse_items(definition, html, "https://example.com")
assert 2 == len(items), "Should extract 2 items" assert len(items) == 2
assert "https://example.com/poems/view/111" == items[0]["link"], "First item link should match" assert items[0] == {
assert "First Poem" == items[0]["title"], "First item title should match" "link": "https://example.com/poems/view/111",
assert "Poet Alpha" == items[0]["poet"], "First item poet should match" "title": "First Poem",
assert "Category One" == items[0]["category"], "First item category should match" "poet": "Poet Alpha",
"category": "Category One",
assert "https://example.com/poems/view/222" == items[1]["link"], "Second item link should match" }
assert "Second Poem" == items[1]["title"], "Second item title should match" assert items[1] == {
assert "Poet Beta" == items[1]["poet"], "Second item poet should match" "link": "https://example.com/poems/view/222",
assert "category" not in items[1], "Second item should not have category" "title": "Second Poem",
"poet": "Poet Beta",
}
def test_parse_items_handles_json_container(): def test_parse_items_json():
definition = TaskDefinition( definition = TaskDefinition(
id=6, id=6,
name="json", name="json",
@ -287,14 +281,10 @@ def test_parse_items_handles_json_container():
json_data=payload, json_data=payload,
) )
assert 2 == len(items), "Should extract 2 items (third missing link)" assert items == [
assert "https://example.com/video/1" == items[0]["link"], "First item link should be absolute" {"link": "https://example.com/video/1", "title": "First", "id": "1"},
assert "First" == items[0]["title"], "First item title should match" {"link": "https://example.com/video/2", "title": "Second", "id": "2"},
assert "1" == items[0]["id"], "First item id should match" ]
assert "https://example.com/video/2" == items[1]["link"], "Second item link should match"
assert "Second" == items[1]["title"], "Second item title should match"
assert "2" == items[1]["id"], "Second item id should match"
@pytest.mark.asyncio @pytest.mark.asyncio
@ -345,14 +335,14 @@ async def test_generic_task_handler_inspect(monkeypatch):
task = HandleTask(id=1, name="Inspect", url="https://example.com/api") task = HandleTask(id=1, name="Inspect", url="https://example.com/api")
result: TaskResult | TaskFailure = await GenericTaskHandler.extract(task) result: TaskResult | TaskFailure = await GenericTaskHandler.extract(task)
assert isinstance(result, TaskResult), "Result should be TaskResult" assert isinstance(result, TaskResult)
assert 1 == len(result.items), "Should have 1 item" assert len(result.items) == 1
item = result.items[0] item = result.items[0]
assert "https://example.com/video/1" == item.url, "Item URL should match" assert item.url == "https://example.com/video/1"
assert "First" == item.title, "Item title should match" assert item.title == "First"
def test_parse_items_handles_json_top_level_list(): def test_parse_items_json_list():
definition = TaskDefinition( definition = TaskDefinition(
id=8, id=8,
name="json-list", name="json-list",
@ -389,8 +379,7 @@ def test_parse_items_handles_json_top_level_list():
json_data=payload, json_data=payload,
) )
assert 2 == len(items), "Should extract 2 items" assert items == [
assert "https://example.com/video/1" == items[0]["link"], "First item link should match" {"link": "https://example.com/video/1", "title": "First"},
assert "First" == items[0]["title"], "First item title should match" {"link": "https://example.com/video/2", "title": "Second"},
assert "https://example.com/video/2" == items[1]["link"], "Second item link should match" ]
assert "Second" == items[1]["title"], "Second item title should match"

View file

@ -142,7 +142,7 @@ async def test_tver_handler_extract(monkeypatch):
@pytest.mark.parametrize(("url", "should_match"), TverHandler.tests()) @pytest.mark.parametrize(("url", "should_match"), TverHandler.tests())
def test_tver_handler_parse(url: str, should_match: bool): def test_parse(url: str, should_match: bool):
"""Test tver URL parsing.""" """Test tver URL parsing."""
result = TverHandler.parse(url) result = TverHandler.parse(url)
if should_match: if should_match:
@ -153,7 +153,7 @@ def test_tver_handler_parse(url: str, should_match: bool):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tver_handler_can_handle(): async def test_can_handle():
"""Test tver handler can_handle method.""" """Test tver handler can_handle method."""
task_valid = HandleTask(id=1, name="Test", url="https://tver.jp/series/sr8sb9pnhc", preset="default") task_valid = HandleTask(id=1, name="Test", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
task_invalid = HandleTask(id=2, name="Test", url="https://youtube.com/watch?v=123", preset="default") task_invalid = HandleTask(id=2, name="Test", url="https://youtube.com/watch?v=123", preset="default")

View file

@ -151,6 +151,22 @@ def _serialize(model: Any) -> dict:
return _model(model).model_dump() return _model(model).model_dump()
async def _require_timer_or_handler(task: Task, handler: TaskHandle) -> None:
if task.timer:
return
if task.handler_enabled is False:
msg = "Task requires a timer when the handler is disabled."
raise ValueError(msg)
result = await handler.inspect(url=task.url, preset=task.preset, static_only=True)
if isinstance(result, TaskResult) and result.metadata.get("matched") is True:
return
msg = "Task requires a timer when no supported handler matches the URL."
raise ValueError(msg)
@route("GET", "api/tasks/", "tasks_list") @route("GET", "api/tasks/", "tasks_list")
async def tasks_list(request: Request, repo: TasksRepository, encoder: Encoder) -> Response: async def tasks_list(request: Request, repo: TasksRepository, encoder: Encoder) -> Response:
page, per_page = normalize_pagination(request) page, per_page = normalize_pagination(request)
@ -166,7 +182,13 @@ async def tasks_list(request: Request, repo: TasksRepository, encoder: Encoder)
@route("POST", "api/tasks/", "tasks_add") @route("POST", "api/tasks/", "tasks_add")
async def tasks_add(request: Request, repo: TasksRepository, encoder: Encoder, notify: EventBus) -> Response: async def tasks_add(
request: Request,
repo: TasksRepository,
encoder: Encoder,
notify: EventBus,
handler: TaskHandle,
) -> Response:
data = await request.json() data = await request.json()
if isinstance(data, dict): if isinstance(data, dict):
@ -205,6 +227,7 @@ async def tasks_add(request: Request, repo: TasksRepository, encoder: Encoder, n
status=web.HTTPConflict.status_code, status=web.HTTPConflict.status_code,
) )
pending_tasks: list[dict[str, Any]] = []
created_tasks: list[dict[str, Any]] = [] created_tasks: list[dict[str, Any]] = []
base_settings = first_task.model_dump(exclude={"id", "name", "url", "created_at", "updated_at"}) base_settings = first_task.model_dump(exclude={"id", "name", "url", "created_at", "updated_at"})
@ -243,11 +266,23 @@ async def tasks_add(request: Request, repo: TasksRepository, encoder: Encoder, n
try: try:
validated = Task.model_validate(task_dict) validated = Task.model_validate(task_dict)
task_data = validated.model_dump()
except ValidationError as exc: except ValidationError as exc:
LOG.warning(f"Skipping task {idx}: validation failed - {exc}") return web.json_response(
continue data={"error": "Failed to validate task.", "detail": format_validation_errors(exc)},
status=web.HTTPBadRequest.status_code,
)
try:
await _require_timer_or_handler(validated, handler)
except ValueError as exc:
return web.json_response(
data={"error": str(exc)},
status=web.HTTPBadRequest.status_code,
)
pending_tasks.append(validated.model_dump())
for idx, task_data in enumerate(pending_tasks):
try: try:
created = await repo.create(task_data) created = await repo.create(task_data)
saved = _serialize(created) saved = _serialize(created)
@ -304,7 +339,13 @@ async def tasks_delete(request: Request, repo: TasksRepository, encoder: Encoder
@route("PATCH", r"api/tasks/{id:\d+}", "tasks_patch") @route("PATCH", r"api/tasks/{id:\d+}", "tasks_patch")
async def tasks_patch(request: Request, repo: TasksRepository, encoder: Encoder, notify: EventBus) -> Response: async def tasks_patch(
request: Request,
repo: TasksRepository,
encoder: Encoder,
notify: EventBus,
handler: TaskHandle,
) -> Response:
if not (identifier := request.match_info.get("id")): if not (identifier := request.match_info.get("id")):
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code) return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
@ -334,6 +375,16 @@ async def tasks_patch(request: Request, repo: TasksRepository, encoder: Encoder,
status=web.HTTPConflict.status_code, status=web.HTTPConflict.status_code,
) )
merged = _model(model).model_copy(update=validated.model_dump(exclude_unset=True))
try:
await _require_timer_or_handler(merged, handler)
except ValueError as exc:
return web.json_response(
data={"error": str(exc)},
status=web.HTTPBadRequest.status_code,
)
updated = _serialize(await repo.update(model.id, validated.model_dump(exclude_unset=True))) updated = _serialize(await repo.update(model.id, validated.model_dump(exclude_unset=True)))
notify.emit(Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.UPDATE, data=updated)) notify.emit(Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.UPDATE, data=updated))
return web.json_response( return web.json_response(
@ -344,7 +395,13 @@ async def tasks_patch(request: Request, repo: TasksRepository, encoder: Encoder,
@route("PUT", r"api/tasks/{id:\d+}", "tasks_update") @route("PUT", r"api/tasks/{id:\d+}", "tasks_update")
async def tasks_update(request: Request, repo: TasksRepository, encoder: Encoder, notify: EventBus) -> Response: async def tasks_update(
request: Request,
repo: TasksRepository,
encoder: Encoder,
notify: EventBus,
handler: TaskHandle,
) -> Response:
if not (identifier := request.match_info.get("id")): if not (identifier := request.match_info.get("id")):
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code) return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
@ -374,6 +431,14 @@ async def tasks_update(request: Request, repo: TasksRepository, encoder: Encoder
status=web.HTTPConflict.status_code, status=web.HTTPConflict.status_code,
) )
try:
await _require_timer_or_handler(validated, handler)
except ValueError as exc:
return web.json_response(
data={"error": str(exc)},
status=web.HTTPBadRequest.status_code,
)
updated = _serialize(await repo.update(model.id, validated.model_dump(exclude_unset=True))) updated = _serialize(await repo.update(model.id, validated.model_dump(exclude_unset=True)))
notify.emit(Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.UPDATE, data=updated)) notify.emit(Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.UPDATE, data=updated))

View file

@ -0,0 +1,183 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
import pytest_asyncio
from aiohttp import web
from aiohttp.test_utils import make_mocked_request
from app.features.tasks import router
from app.features.tasks.definitions.results import TaskFailure, TaskResult
from app.features.tasks.repository import TasksRepository
from app.library.encoder import Encoder
from app.library.sqlite_store import SqliteStore
from app.tests.helpers import make_in_memory_db_path
@pytest_asyncio.fixture
async def repo():
TasksRepository._reset_singleton()
SqliteStore._reset_singleton()
store = SqliteStore(db_path=make_in_memory_db_path("tasks-router"))
await store.get_connection()
repository = TasksRepository.get_instance()
yield repository
await store.close()
TasksRepository._reset_singleton()
SqliteStore._reset_singleton()
@pytest.fixture(autouse=True)
def patch_get_info(monkeypatch: pytest.MonkeyPatch) -> None:
async def _fake_get_info(_url: str, _preset: str) -> tuple[None, None]:
return (None, None)
monkeypatch.setattr(router, "_get_info", _fake_get_info)
def _json_request(method: str, path: str, payload: object, *, match_info: dict[str, str] | None = None) -> web.Request:
request = make_mocked_request(method, path, match_info=match_info or {}, app=SimpleNamespace())
async def _json() -> object:
return payload
request.json = _json # type: ignore[attr-defined]
return request
class _Notify:
def emit(self, *_args, **_kwargs) -> None:
return None
class _Handler:
def __init__(self, matched: bool | dict[str, bool]) -> None:
self._matched = matched
async def inspect(self, *, url: str, preset: str | None = None, static_only: bool = False, **_kwargs):
del preset, static_only
if isinstance(self._matched, dict):
matched = self._matched.get(url, False)
else:
matched = self._matched
if matched:
return TaskResult(metadata={"matched": True, "handler": "TestHandler"})
return TaskFailure(message="No handler", metadata={"matched": False, "handler": None})
@pytest.mark.asyncio
async def test_add_requires_timer_without_handler(repo) -> None:
request = _json_request(
"POST",
"/api/tasks/",
{"name": "No Timer", "url": "https://example.com/channel"},
)
response = await router.tasks_add(request, repo, Encoder(), _Notify(), _Handler(matched=False))
assert response.status == web.HTTPBadRequest.status_code
assert b"requires a timer" in response.body
assert await repo.list() == []
@pytest.mark.asyncio
async def test_add_all_or_nothing(repo) -> None:
request = _json_request(
"POST",
"/api/tasks/",
[
{"name": "First", "url": "https://example.com/first"},
{"url": "https://example.com/second"},
],
)
response = await router.tasks_add(
request,
repo,
Encoder(),
_Notify(),
_Handler({"https://example.com/first": True, "https://example.com/second": False}),
)
assert response.status == web.HTTPBadRequest.status_code
assert b"requires a timer" in response.body
assert await repo.list() == []
@pytest.mark.asyncio
async def test_add_allows_handler_only(repo) -> None:
request = _json_request(
"POST",
"/api/tasks/",
{"name": "Handler Only", "url": "https://example.com/feed"},
)
response = await router.tasks_add(request, repo, Encoder(), _Notify(), _Handler(matched=True))
assert response.status == web.HTTPOk.status_code
items = await repo.list()
assert len(items) == 1
assert items[0].name == "Handler Only"
@pytest.mark.asyncio
async def test_update_requires_timer_without_handler(repo) -> None:
item = await repo.create({"name": "Needs Timer", "url": "https://example.com/a", "timer": "0 0 * * *"})
request = _json_request(
"PUT",
f"/api/tasks/{item.id}",
{"name": item.name, "url": item.url, "timer": "", "preset": "", "folder": "", "template": "", "cli": ""},
match_info={"id": str(item.id)},
)
response = await router.tasks_update(request, repo, Encoder(), _Notify(), _Handler(matched=False))
assert response.status == web.HTTPBadRequest.status_code
refreshed = await repo.get(item.id)
assert refreshed is not None
assert refreshed.timer == "0 0 * * *"
@pytest.mark.asyncio
async def test_patch_requires_timer_without_handler(repo) -> None:
item = await repo.create({"name": "Patch Timer", "url": "https://example.com/b", "timer": "0 0 * * *"})
request = _json_request(
"PATCH",
f"/api/tasks/{item.id}",
{"timer": ""},
match_info={"id": str(item.id)},
)
response = await router.tasks_patch(request, repo, Encoder(), _Notify(), _Handler(matched=False))
assert response.status == web.HTTPBadRequest.status_code
refreshed = await repo.get(item.id)
assert refreshed is not None
assert refreshed.timer == "0 0 * * *"
@pytest.mark.asyncio
async def test_patch_requires_timer_when_handler_disabled(repo) -> None:
item = await repo.create({"name": "Disabled Handler", "url": "https://example.com/c", "timer": "0 0 * * *"})
request = _json_request(
"PATCH",
f"/api/tasks/{item.id}",
{"timer": "", "handler_enabled": False},
match_info={"id": str(item.id)},
)
response = await router.tasks_patch(request, repo, Encoder(), _Notify(), _Handler(matched=True))
assert response.status == web.HTTPBadRequest.status_code
assert b"handler is disabled" in response.body

View file

@ -0,0 +1,113 @@
from __future__ import annotations
import re
import secrets
import string
from typing import TYPE_CHECKING, Any
from yt_dlp.utils._utils import STR_FORMAT_RE_TMPL, STR_FORMAT_TYPES
if TYPE_CHECKING:
from collections.abc import Callable
OUTTMPL_RX: re.Pattern[str] = re.compile(STR_FORMAT_RE_TMPL.format("[^)]*", f"[{STR_FORMAT_TYPES}ljhqBUDS]"))
CALL_RX: re.Pattern[str] = re.compile(r"^(?P<name>ytp_[A-Za-z0-9_]+)(?P<args>(?::[A-Za-z0-9_]+)*)(?P<rest>.*)$")
OPERATORS: tuple[str, ...] = (",", "&", "|", ">", "+", "-", "*")
def random_text(args: tuple[str, ...], _info_dict: dict[str, Any], _state: dict[str, Any]) -> str:
if not args:
msg = "ytp_random requires a length argument. Use %(ytp_random:<length>)s."
raise ValueError(msg)
if len(args) > 2:
msg = "ytp_random accepts at most 2 arguments: length and optional mode."
raise ValueError(msg)
try:
length = int(args[0])
except ValueError as exc:
msg: str = f"ytp_random length must be an integer, got {args[0]!r}."
raise ValueError(msg) from exc
if length < 1:
msg = "ytp_random length must be greater than 0."
raise ValueError(msg)
mode = args[1].lower() if len(args) > 1 else "mixed"
if mode in ("mixed", "m"):
alphabet = string.ascii_letters + string.digits
elif mode in ("d", "digit", "digits", "int", "ints", "number", "numbers"):
alphabet = string.digits
elif mode in ("s", "str", "string", "strings", "alpha", "letter", "letters"):
alphabet = string.ascii_letters
else:
msg = f"ytp_random mode must be one of mixed, d, or s. Got {args[1]!r}."
raise ValueError(msg)
return "".join(secrets.choice(alphabet) for _ in range(length))
CALLS: dict[str, Callable[[tuple[str, ...], dict[str, Any], dict[str, Any]], Any]] = {
"ytp_random": random_text,
}
def split_call(key: str) -> tuple[str, tuple[str, ...], str] | None:
if not key.startswith("ytp_"):
return None
if not (match := CALL_RX.match(key)):
msg = f"Invalid YTPTube output template callable {key!r}."
raise ValueError(msg)
name: str | Any = match.group("name")
rest: str | Any = match.group("rest") or ""
if rest and rest[0] not in OPERATORS:
msg = f"Invalid YTPTube output template callable {key!r}."
raise ValueError(msg)
if name not in CALLS:
msg = f"Unsupported YTPTube output template callable {name!r}."
raise ValueError(msg)
raw_args: str | Any = match.group("args")
args: tuple[str | Any, ...] = tuple(part for part in raw_args.split(":") if part)
return name, args, rest
def rewrite_outtmpl(
outtmpl: str,
info_dict: dict[str, Any],
cache: dict[str, Any] | None = None,
) -> tuple[str, dict[str, Any]]:
if "%(ytp_" not in outtmpl:
return outtmpl, info_dict
state: dict[str, Any] = {}
values: dict[str, Any] = {} if cache is None else cache
fields: dict[str, str] = {}
enriched = dict(info_dict)
def replace(match: re.Match[str]) -> str:
if not (key := match.group("key")):
return match.group(0)
parsed = split_call(key)
if not parsed:
return match.group(0)
name, args, rest = parsed
call_key: str = ":".join((name, *args)) if args else name
if call_key not in values:
values[call_key] = CALLS[name](args, enriched, state)
if call_key not in fields:
fields[call_key] = f"__ytptube_outtmpl_{len(fields)}"
synthetic_key: str = fields[call_key]
enriched[synthetic_key] = values[call_key]
return f"{match.group('prefix')}%({synthetic_key}{rest}){match.group('format')}"
return OUTTMPL_RX.sub(replace, outtmpl), enriched

View file

@ -20,7 +20,7 @@ class TestProcessPoolConfiguration:
assert kwargs == {"mp_context": context} assert kwargs == {"mp_context": context}
get_context.assert_called_once_with("fork") get_context.assert_called_once_with("fork")
def test_uses_default_context_when_not_frozen_linux(self, monkeypatch): def test_default_context_linux(self, monkeypatch):
monkeypatch.setattr("app.features.ytdlp.extractor.sys.platform", "linux") monkeypatch.setattr("app.features.ytdlp.extractor.sys.platform", "linux")
monkeypatch.delattr("app.features.ytdlp.extractor.sys.frozen", raising=False) monkeypatch.delattr("app.features.ytdlp.extractor.sys.frozen", raising=False)
@ -30,7 +30,7 @@ class TestProcessPoolConfiguration:
assert _get_process_pool_kwargs() == {} assert _get_process_pool_kwargs() == {}
get_context.assert_not_called() get_context.assert_not_called()
def test_initializes_process_pool_with_context_kwargs(self, monkeypatch): def test_init_pool_kwargs(self, monkeypatch):
context = object() context = object()
monkeypatch.setattr("app.features.ytdlp.extractor._get_process_pool_kwargs", lambda: {"mp_context": context}) monkeypatch.setattr("app.features.ytdlp.extractor._get_process_pool_kwargs", lambda: {"mp_context": context})

View file

@ -195,7 +195,7 @@ class TestMiniFilter(unittest.TestCase):
self._test("filesize>=1GiB", {"filesize": 2**30}, expected_result=True, test_name="filesize_1gib_positive") self._test("filesize>=1GiB", {"filesize": 2**30}, expected_result=True, test_name="filesize_1gib_positive")
self._test("filesize>=1GiB", {"filesize": 1000000}, expected_result=False, test_name="filesize_1gib_negative") self._test("filesize>=1GiB", {"filesize": 1000000}, expected_result=False, test_name="filesize_1gib_negative")
def test_complex_duration_units_with_or_and_grouping(self): def test_duration_or_grouping(self):
"""Test complex expressions with duration units, OR operations, and grouping. Skip yt-dlp due to known parsing bugs.""" """Test complex expressions with duration units, OR operations, and grouping. Skip yt-dlp due to known parsing bugs."""
# Test grouping with duration units # Test grouping with duration units
expr = "(filesize>1MB & duration<10m) || uploader='BBC'" expr = "(filesize>1MB & duration<10m) || uploader='BBC'"
@ -310,7 +310,7 @@ class TestMiniFilter(unittest.TestCase):
self._test(expr, {"title": "a(b)c"}, expected_result=True, test_name="quoted_parentheses_match") self._test(expr, {"title": "a(b)c"}, expected_result=True, test_name="quoted_parentheses_match")
self._test(expr, {"title": "abc"}, expected_result=False, test_name="quoted_parentheses_no_match") self._test(expr, {"title": "abc"}, expected_result=False, test_name="quoted_parentheses_no_match")
def test_escaped_ampersand_inside_quoted_regex(self): def test_quoted_regex_ampersand(self):
expr = r"description~='(?i)\bcats \& dogs\b'" expr = r"description~='(?i)\bcats \& dogs\b'"
parser = MiniFilter(expr) parser = MiniFilter(expr)

View file

@ -1,5 +1,8 @@
from unittest.mock import MagicMock, Mock, patch from unittest.mock import MagicMock, Mock, patch
import pytest
from app.features.ytdlp.outtmpl import rewrite_outtmpl
from app.features.ytdlp.patches import patch_windows_popen_wait from app.features.ytdlp.patches import patch_windows_popen_wait
from app.features.ytdlp.utils import _DATA from app.features.ytdlp.utils import _DATA
from app.features.ytdlp.ytdlp import YTDLP, _ArchiveProxy, ytdlp_options from app.features.ytdlp.ytdlp import YTDLP, _ArchiveProxy, ytdlp_options
@ -20,7 +23,7 @@ class TestArchiveProxy:
assert p2.add("") is False assert p2.add("") is False
@patch("app.features.ytdlp.archiver.Archiver.get_instance") @patch("app.features.ytdlp.archiver.Archiver.get_instance")
def test_contains_and_add_delegate_to_archiver(self, mock_get_instance) -> None: def test_delegates_to_archiver(self, mock_get_instance) -> None:
arch = MagicMock() arch = MagicMock()
mock_get_instance.return_value = arch mock_get_instance.return_value = arch
@ -44,7 +47,7 @@ class TestArchiveProxy:
class TestYtDlpOptions: class TestYtDlpOptions:
def test_options_structure_and_no_suppresshelp(self) -> None: def test_options_shape(self) -> None:
opts = ytdlp_options() opts = ytdlp_options()
assert isinstance(opts, list) assert isinstance(opts, list)
@ -91,7 +94,7 @@ class TestYTDLP:
return ytdlp return ytdlp
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__") @patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
def test_init_patches_download_archive_param(self, mock_super_init) -> None: def test_init_archive_param(self, mock_super_init) -> None:
"""Test that __init__ removes download_archive before calling super, then restores it.""" """Test that __init__ removes download_archive before calling super, then restores it."""
mock_super_init.return_value = None mock_super_init.return_value = None
@ -118,7 +121,7 @@ class TestYTDLP:
assert ytdlp.archive._file == "/tmp/archive.txt" assert ytdlp.archive._file == "/tmp/archive.txt"
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__") @patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
def test_init_handles_no_download_archive(self, mock_super_init) -> None: def test_init_no_archive(self, mock_super_init) -> None:
"""Test __init__ works correctly when download_archive is not in params.""" """Test __init__ works correctly when download_archive is not in params."""
mock_super_init.return_value = None mock_super_init.return_value = None
@ -136,7 +139,7 @@ class TestYTDLP:
assert not ytdlp.archive assert not ytdlp.archive
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__") @patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
def test_init_handles_none_params(self, mock_super_init) -> None: def test_init_none_params(self, mock_super_init) -> None:
"""Test __init__ handles None params gracefully.""" """Test __init__ handles None params gracefully."""
mock_super_init.return_value = None mock_super_init.return_value = None
@ -147,7 +150,7 @@ class TestYTDLP:
assert not ytdlp.archive assert not ytdlp.archive
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__") @patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
def test_init_patches_windows_popen_wait_once(self, mock_super_init) -> None: def test_init_patches_wait(self, mock_super_init) -> None:
mock_super_init.return_value = None mock_super_init.return_value = None
class FakePopen: class FakePopen:
@ -160,7 +163,7 @@ class TestYTDLP:
assert getattr(FakePopen, "_ytptube_wait_patched", False) is True assert getattr(FakePopen, "_ytptube_wait_patched", False) is True
def test_windows_wait_patch_uses_polling_for_blocking_wait(self) -> None: def test_wait_patch_polls(self) -> None:
calls: list[float | None] = [] calls: list[float | None] = []
class FakePopen: class FakePopen:
@ -183,7 +186,7 @@ class TestYTDLP:
assert result == 0 assert result == 0
assert calls == [0.1, 0.1, 0.1] assert calls == [0.1, 0.1, 0.1]
def test_windows_wait_patch_preserves_explicit_timeout(self) -> None: def test_wait_patch_timeout(self) -> None:
calls: list[float | None] = [] calls: list[float | None] = []
class FakePopen: class FakePopen:
@ -202,7 +205,7 @@ class TestYTDLP:
assert calls == [5] assert calls == [5]
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL._delete_downloaded_files") @patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL._delete_downloaded_files")
def test_delete_downloaded_files_skips_when_interrupted(self, mock_super_delete) -> None: def test_delete_interrupted(self, mock_super_delete) -> None:
"""Test _delete_downloaded_files skips cleanup when _interrupted is True.""" """Test _delete_downloaded_files skips cleanup when _interrupted is True."""
with patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__", return_value=None): with patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__", return_value=None):
ytdlp = YTDLP(params={}) ytdlp = YTDLP(params={})
@ -221,7 +224,7 @@ class TestYTDLP:
assert result is None assert result is None
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL._delete_downloaded_files") @patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL._delete_downloaded_files")
def test_delete_downloaded_files_calls_super_when_not_interrupted(self, mock_super_delete) -> None: def test_delete_calls_super(self, mock_super_delete) -> None:
"""Test _delete_downloaded_files calls super when not interrupted.""" """Test _delete_downloaded_files calls super when not interrupted."""
mock_super_delete.return_value = "cleanup_result" mock_super_delete.return_value = "cleanup_result"
@ -236,7 +239,7 @@ class TestYTDLP:
# Should return super's result # Should return super's result
assert result == "cleanup_result" assert result == "cleanup_result"
def test_record_download_archive_does_nothing_without_download_archive_param(self) -> None: def test_record_archive_missing(self) -> None:
"""Test record_download_archive returns early when download_archive is not set.""" """Test record_download_archive returns early when download_archive is not set."""
ytdlp = self._create_ytdlp(params={}) ytdlp = self._create_ytdlp(params={})
ytdlp.archive = Mock() ytdlp.archive = Mock()
@ -246,7 +249,7 @@ class TestYTDLP:
# Should not interact with archive # Should not interact with archive
ytdlp.archive.add.assert_not_called() ytdlp.archive.add.assert_not_called()
def test_record_download_archive_adds_archive_id(self) -> None: def test_record_archive_adds_id(self) -> None:
"""Test record_download_archive adds the archive ID.""" """Test record_download_archive adds the archive ID."""
ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"}) ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"})
ytdlp.write_debug = Mock() ytdlp.write_debug = Mock()
@ -264,7 +267,7 @@ class TestYTDLP:
ytdlp.archive.add.assert_called_once_with("youtube test123") ytdlp.archive.add.assert_called_once_with("youtube test123")
ytdlp.write_debug.assert_called_with("Adding to archive: youtube test123") ytdlp.write_debug.assert_called_with("Adding to archive: youtube test123")
def test_record_download_archive_handles_old_archive_ids(self) -> None: def test_record_archive_old_ids(self) -> None:
"""Test record_download_archive adds _old_archive_ids when present.""" """Test record_download_archive adds _old_archive_ids when present."""
ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"}) ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"})
ytdlp.write_debug = Mock() ytdlp.write_debug = Mock()
@ -291,7 +294,7 @@ class TestYTDLP:
# Should not add duplicate (youtube new123 appears only once) # Should not add duplicate (youtube new123 appears only once)
assert calls.count("youtube new123") == 1 assert calls.count("youtube new123") == 1
def test_record_download_archive_skips_empty_old_archive_ids(self) -> None: def test_record_archive_empty_old_ids(self) -> None:
"""Test record_download_archive handles empty or invalid _old_archive_ids.""" """Test record_download_archive handles empty or invalid _old_archive_ids."""
ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"}) ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"})
ytdlp.write_debug = Mock() ytdlp.write_debug = Mock()
@ -317,7 +320,7 @@ class TestYTDLP:
ytdlp.record_download_archive(info_dict) ytdlp.record_download_archive(info_dict)
assert ytdlp.archive.add.call_count == 1 # Only main ID assert ytdlp.archive.add.call_count == 1 # Only main ID
def test_record_download_archive_returns_early_on_empty_archive_id(self) -> None: def test_record_archive_empty_id(self) -> None:
"""Test record_download_archive returns early when _make_archive_id returns empty.""" """Test record_download_archive returns early when _make_archive_id returns empty."""
ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"}) ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"})
ytdlp.archive = Mock() ytdlp.archive = Mock()
@ -327,3 +330,116 @@ class TestYTDLP:
# Should not add anything # Should not add anything
ytdlp.archive.add.assert_not_called() ytdlp.archive.add.assert_not_called()
def test_outtmpl_callable(self) -> None:
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})
result = ytdlp.evaluate_outtmpl("%(ytp_random:8)s", {"title": "x"})
assert len(result) == 8
assert result.isalnum()
def test_outtmpl_reuses_value(self) -> None:
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})
result = ytdlp.evaluate_outtmpl("%(ytp_random:8)s/%(ytp_random:8)s", {"title": "x"})
first, second = result.split("/")
assert first == second
def test_outtmpl_new_value(self) -> None:
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})
first = ytdlp.evaluate_outtmpl("%(ytp_random:8)s", {"title": "x"})
second = ytdlp.evaluate_outtmpl("%(ytp_random:8)s", {"title": "y"})
assert first != second
def test_prepare_filename_sidecars(self) -> None:
ytdlp = YTDLP(
params={
"outtmpl": {
"default": "%(ytp_random:6)s.%(ext)s",
"thumbnail": "%(ytp_random:6)s.%(ext)s",
"subtitle": "%(ytp_random:6)s.%(ext)s",
"infojson": "%(ytp_random:6)s.%(ext)s",
}
}
)
info = {"id": "abc123", "title": "Example", "ext": "mp4"}
default_name = ytdlp.prepare_filename(info)
thumbnail_name = ytdlp.prepare_filename(info, "thumbnail")
subtitle_name = ytdlp.prepare_filename(info, "subtitle")
infojson_name = ytdlp.prepare_filename(info, "infojson")
default_base = default_name.rsplit(".", 1)[0]
thumbnail_base = thumbnail_name.rsplit(".", 1)[0]
subtitle_base = subtitle_name.rsplit(".", 1)[0]
infojson_base = infojson_name.removesuffix(".info.json")
assert default_base == thumbnail_base
assert default_base == subtitle_base
assert default_base == infojson_base
def test_prepare_filename_resets(self) -> None:
ytdlp = YTDLP(params={"outtmpl": {"default": "%(ytp_random:8)s.%(ext)s"}})
first = ytdlp.prepare_filename({"id": "one", "title": "One", "ext": "mp4"})
second = ytdlp.prepare_filename({"id": "two", "title": "Two", "ext": "mp4"})
assert first != second
@pytest.mark.parametrize(
("template", "expected"),
[
("%(ytp_random:8|fallback)s", 8),
("%(ytp_random:8&{} - |)s", 11),
("%(ytp_random:8)S", 8),
],
)
def test_outtmpl_suffix(self, template: str, expected: int) -> None:
ytdlp = YTDLP(
params={
"outtmpl": {"default": "%(title)s"},
"restrictfilenames": True,
},
)
result = ytdlp.evaluate_outtmpl(template, {"title": "x"})
assert len(result) == expected
def test_outtmpl_modes(self) -> None:
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})
digits = ytdlp.evaluate_outtmpl("%(ytp_random:6:d)s", {"title": "x"})
letters = ytdlp.evaluate_outtmpl("%(ytp_random:6:s)s", {"title": "x"})
assert digits.isdigit()
assert letters.isalpha()
def test_outtmpl_unknown_callable(self) -> None:
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})
with pytest.raises(ValueError, match="Unsupported YTPTube output template callable"):
ytdlp.prepare_outtmpl("%(ytp_unknown:8)s", {"title": "x"})
def test_outtmpl_invalid_length(self) -> None:
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})
with pytest.raises(ValueError, match="ytp_random length must be an integer"):
ytdlp.prepare_outtmpl("%(ytp_random:nope)s", {"title": "x"})
class TestOuttmpl:
def test_rewrite_outtmpl_cache(self) -> None:
cache: dict[str, object] = {}
template = "%(ytp_random:8)s/%(ytp_random:8)s.%(ext)s"
rewritten, info = rewrite_outtmpl(template, {"ext": "mp4"}, cache=cache)
assert rewritten == "%(__ytptube_outtmpl_0)s/%(__ytptube_outtmpl_0)s.%(ext)s"
assert len(cache) == 1
assert info["__ytptube_outtmpl_0"] == next(iter(cache.values()))

View file

@ -25,7 +25,7 @@ class TestYTDLPOpts:
assert opts._item_cli == [] assert opts._item_cli == []
assert opts._preset_cli == "" assert opts._preset_cli == ""
def test_get_instance_returns_reset_instance(self): def test_get_instance(self):
"""Test that get_instance returns a reset YTDLPOpts instance.""" """Test that get_instance returns a reset YTDLPOpts instance."""
with patch("app.features.ytdlp.ytdlp_opts.Config"): with patch("app.features.ytdlp.ytdlp_opts.Config"):
opts = YTDLPOpts.get_instance() opts = YTDLPOpts.get_instance()
@ -51,7 +51,7 @@ class TestYTDLPOpts:
assert "--format best" in opts._item_cli assert "--format best" in opts._item_cli
mock_converter.assert_called_once_with(args="--format best", level=False) mock_converter.assert_called_once_with(args="--format best", level=False)
def test_add_cli_with_invalid_args_raises_error(self): def test_add_cli_invalid(self):
"""Test that invalid CLI arguments raise ValueError.""" """Test that invalid CLI arguments raise ValueError."""
with ( with (
patch("app.features.ytdlp.ytdlp_opts.Config"), patch("app.features.ytdlp.ytdlp_opts.Config"),
@ -63,7 +63,7 @@ class TestYTDLPOpts:
with pytest.raises(ValueError, match="Invalid command options for yt-dlp were given"): with pytest.raises(ValueError, match="Invalid command options for yt-dlp were given"):
opts.add_cli("--invalid-arg", from_user=True) opts.add_cli("--invalid-arg", from_user=True)
def test_add_cli_with_empty_args_returns_self(self): def test_add_cli_empty(self):
"""Test that empty or invalid args return self without processing.""" """Test that empty or invalid args return self without processing."""
with patch("app.features.ytdlp.ytdlp_opts.Config"): with patch("app.features.ytdlp.ytdlp_opts.Config"):
opts = YTDLPOpts() opts = YTDLPOpts()
@ -95,7 +95,7 @@ class TestYTDLPOpts:
assert opts._item_opts["format"] == "best" assert opts._item_opts["format"] == "best"
assert opts._item_opts["quality"] == "720p" assert opts._item_opts["quality"] == "720p"
def test_add_with_user_config_filters_bad_options(self): def test_add_filters_bad_options(self):
"""Test that user config filters out dangerous options.""" """Test that user config filters out dangerous options."""
with patch("app.features.ytdlp.ytdlp_opts.Config"): with patch("app.features.ytdlp.ytdlp_opts.Config"):
opts = YTDLPOpts() opts = YTDLPOpts()
@ -211,7 +211,7 @@ class TestYTDLPOpts:
assert opts._preset_opts["cookiefile"] == expected_path assert opts._preset_opts["cookiefile"] == expected_path
mock_preset.get_cookies_file.assert_called_once_with(config=mock_config_instance) mock_preset.get_cookies_file.assert_called_once_with(config=mock_config_instance)
def test_preset_with_invalid_cli_raises_error(self): def test_preset_invalid_cli(self):
"""Test that preset with invalid CLI raises ValueError.""" """Test that preset with invalid CLI raises ValueError."""
with ( with (
patch("app.features.ytdlp.ytdlp_opts.Config"), patch("app.features.ytdlp.ytdlp_opts.Config"),
@ -289,7 +289,7 @@ class TestYTDLPOpts:
expected_cli = "--quality 720p\n--format best" expected_cli = "--quality 720p\n--format best"
mock_converter.assert_called_once_with(args=expected_cli, level=True) mock_converter.assert_called_once_with(args=expected_cli, level=True)
def test_get_all_handles_format_special_cases(self): def test_get_all_format_cases(self):
"""Test get_all handles special format values correctly.""" """Test get_all handles special format values correctly."""
with ( with (
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config, patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
@ -325,7 +325,7 @@ class TestYTDLPOpts:
result = opts.get_all(keep=True) result = opts.get_all(keep=True)
assert result["format"] == "best" assert result["format"] == "best"
def test_get_all_with_invalid_cli_raises_error(self): def test_get_all_invalid_cli(self):
"""Test get_all raises error for invalid CLI arguments.""" """Test get_all raises error for invalid CLI arguments."""
with ( with (
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config, patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
@ -578,7 +578,7 @@ class TestARGSMerger:
assert "--output" in merger.args assert "--output" in merger.args
assert "--socket-timeout" in merger.args assert "--socket-timeout" in merger.args
def test_add_filters_complex_commented_extractor_args(self): def test_add_complex_comments(self):
"""Test filtering of complex real-world commented extractor-args.""" """Test filtering of complex real-world commented extractor-args."""
from app.features.ytdlp.ytdlp_opts import ARGSMerger from app.features.ytdlp.ytdlp_opts import ARGSMerger
@ -631,7 +631,7 @@ class TestARGSMerger:
assert "--format" in merger.args assert "--format" in merger.args
assert "bestvideo[height<=1080]+bestaudio/best" in merger.args assert "bestvideo[height<=1080]+bestaudio/best" in merger.args
def test_add_empty_string_returns_self(self): def test_add_empty(self):
"""Test that adding empty string returns self without modifying args.""" """Test that adding empty string returns self without modifying args."""
from app.features.ytdlp.ytdlp_opts import ARGSMerger from app.features.ytdlp.ytdlp_opts import ARGSMerger
@ -641,7 +641,7 @@ class TestARGSMerger:
assert result is merger assert result is merger
assert merger.args == [] assert merger.args == []
def test_add_short_string_returns_self(self): def test_add_short(self):
"""Test that adding short string (len < 2) returns self without modifying args.""" """Test that adding short string (len < 2) returns self without modifying args."""
from app.features.ytdlp.ytdlp_opts import ARGSMerger from app.features.ytdlp.ytdlp_opts import ARGSMerger
@ -651,7 +651,7 @@ class TestARGSMerger:
assert result is merger assert result is merger
assert merger.args == [] assert merger.args == []
def test_add_non_string_returns_self(self): def test_add_non_string(self):
"""Test that adding non-string returns self without modifying args.""" """Test that adding non-string returns self without modifying args."""
from app.features.ytdlp.ytdlp_opts import ARGSMerger from app.features.ytdlp.ytdlp_opts import ARGSMerger
@ -769,7 +769,7 @@ class TestYTDLPCli:
assert cli.item is item assert cli.item is item
assert cli._config is mock_config_instance assert cli._config is mock_config_instance
def test_constructor_with_invalid_type_raises_error(self): def test_constructor_invalid_item(self):
"""Test YTDLPCli constructor raises error with non-Item type.""" """Test YTDLPCli constructor raises error with non-Item type."""
from app.features.ytdlp.ytdlp_opts import YTDLPCli from app.features.ytdlp.ytdlp_opts import YTDLPCli

View file

@ -48,14 +48,14 @@ class TestLogWrapper:
with pytest.raises(TypeError, match=r"Target must be a logging\.Logger instance or a callable"): with pytest.raises(TypeError, match=r"Target must be a logging\.Logger instance or a callable"):
lw.add_target(123) # type: ignore[arg-type] lw.add_target(123) # type: ignore[arg-type]
def test_add_target_name_inference_and_custom(self) -> None: def test_add_target_names(self) -> None:
lw = LogWrapper() lw = LogWrapper()
logger, _ = make_logger("one") logger, _ = make_logger("one")
# Name inferred from logger # Name inferred from logger
lw.add_target(logger) lw.add_target(logger)
assert lw.targets[-1].name == "one" assert lw.targets[-1].name == "one"
assert lw.has_targets() is True assert lw.has_targets()
# Name inferred from callable # Name inferred from callable
def sink(level: int, msg: str, *args: Any, **kwargs: Any) -> None: # noqa: ARG001 def sink(level: int, msg: str, *args: Any, **kwargs: Any) -> None: # noqa: ARG001
@ -129,7 +129,7 @@ class TestExtractYtdlpLogs:
logs = ["INFO: Downloading", "ERROR: Failed", "WARNING: Deprecated"] logs = ["INFO: Downloading", "ERROR: Failed", "WARNING: Deprecated"]
filters = [re.compile(r"ERROR")] filters = [re.compile(r"ERROR")]
result = extract_ytdlp_logs(logs, filters) result = extract_ytdlp_logs(logs, filters)
assert len(result) >= 0 # Should filter based on patterns assert result == ["ERROR: Failed"]
def test_extract_ytdlp_logs_empty(self): def test_extract_ytdlp_logs_empty(self):
"""Test with empty logs.""" """Test with empty logs."""
@ -146,8 +146,8 @@ class TestYtdlpReject:
yt_params = {} yt_params = {}
passed, message = ytdlp_reject(entry, yt_params) passed, message = ytdlp_reject(entry, yt_params)
assert isinstance(passed, bool) assert passed is True
assert isinstance(message, str) assert message == ""
def test_ytdlp_reject_with_filters(self): def test_ytdlp_reject_with_filters(self):
"""Test rejection with filters.""" """Test rejection with filters."""
@ -158,8 +158,9 @@ class TestYtdlpReject:
yt_params["daterange"].__contains__ = MagicMock(return_value=False) yt_params["daterange"].__contains__ = MagicMock(return_value=False)
passed, message = ytdlp_reject(entry, yt_params) passed, message = ytdlp_reject(entry, yt_params)
assert isinstance(passed, bool) assert passed is False
assert isinstance(message, str) assert "20230101" in message
assert "not in range" in message
class TestParseOuttmpl: class TestParseOuttmpl:
@ -256,7 +257,7 @@ class TestParseOuttmpl:
assert result == "Test Channel/Best Videos/005 - Amazing Content [abc123xyz].mp4" assert result == "Test Channel/Best Videos/005 - Amazing Content [abc123xyz].mp4"
def test_parse_outtmpl_with_special_characters(self): def test_parse_outtmpl_special_chars(self):
"""Test template parsing handles special characters in values.""" """Test template parsing handles special characters in values."""
template = "%(title)s.%(ext)s" template = "%(title)s.%(ext)s"
@ -284,7 +285,7 @@ class TestParseOuttmpl:
assert result == "My Playlist/Video Title.webm" assert result == "My Playlist/Video Title.webm"
def test_parse_outtmpl_with_restrict_filename(self): def test_parse_outtmpl_restrict(self):
"""Test template parsing with restrict_filename parameter.""" """Test template parsing with restrict_filename parameter."""
template = "%(uploader)s/%(title)s.%(ext)s" template = "%(uploader)s/%(title)s.%(ext)s"
@ -302,19 +303,19 @@ class TestParseOuttmpl:
class TestGetThumbnail: class TestGetThumbnail:
def test_returns_none_for_empty_list(self): def test_empty_list(self):
"""Test that None is returned for an empty thumbnail list.""" """Test that None is returned for an empty thumbnail list."""
assert get_thumbnail([]) is None assert get_thumbnail([]) is None
def test_returns_none_for_non_list(self): def test_non_list(self):
"""Test that None is returned for non-list input.""" """Test that None is returned for non-list input."""
assert get_thumbnail(None) is None assert get_thumbnail(None) is None
assert get_thumbnail("not a list") is None assert get_thumbnail("not a list") is None
assert get_thumbnail({"not": "list"}) is None assert get_thumbnail({"not": "list"}) is None
def test_returns_highest_preference_thumbnail(self): def test_thumbnail_preference(self):
"""Test that the thumbnail with highest preference is returned.""" """Test that the thumbnail with highest preference is returned."""
thumbnails = [ thumbnails = [
@ -326,7 +327,7 @@ class TestGetThumbnail:
result = get_thumbnail(thumbnails) result = get_thumbnail(thumbnails)
assert result == {"url": "high.jpg", "preference": 10, "width": 200, "height": 200} assert result == {"url": "high.jpg", "preference": 10, "width": 200, "height": 200}
def test_returns_highest_width_when_preference_equal(self): def test_thumbnail_width(self):
"""Test that the thumbnail with highest width is returned when preference is equal.""" """Test that the thumbnail with highest width is returned when preference is equal."""
thumbnails = [ thumbnails = [
@ -338,7 +339,7 @@ class TestGetThumbnail:
result = get_thumbnail(thumbnails) result = get_thumbnail(thumbnails)
assert result == {"url": "large.jpg", "preference": 1, "width": 200, "height": 200} assert result == {"url": "large.jpg", "preference": 1, "width": 200, "height": 200}
def test_handles_missing_attributes(self): def test_missing_attrs(self):
"""Test that thumbnails with missing attributes are handled correctly.""" """Test that thumbnails with missing attributes are handled correctly."""
thumbnails = [ thumbnails = [
@ -349,7 +350,7 @@ class TestGetThumbnail:
result = get_thumbnail(thumbnails) result = get_thumbnail(thumbnails)
assert result["url"] == "with_pref.jpg" assert result["url"] == "with_pref.jpg"
def test_returns_first_when_all_equal(self): def test_all_equal(self):
"""Test that any thumbnail is returned when all attributes are equal.""" """Test that any thumbnail is returned when all attributes are equal."""
thumbnails = [ thumbnails = [
@ -357,17 +358,15 @@ class TestGetThumbnail:
{"url": "second.jpg"}, {"url": "second.jpg"},
] ]
result = get_thumbnail(thumbnails) assert get_thumbnail(thumbnails) == {"url": "second.jpg"}
assert result is not None
assert result["url"] in ["first.jpg", "second.jpg"]
class TestGetExtras: class TestGetExtras:
def test_returns_empty_dict_for_none(self): def test_none(self):
"""Test that empty dict is returned for None input.""" """Test that empty dict is returned for None input."""
assert get_extras(None) == {} assert get_extras(None) == {}
def test_returns_empty_dict_for_non_dict(self): def test_non_dict(self):
"""Test that empty dict is returned for non-dict input.""" """Test that empty dict is returned for non-dict input."""
assert get_extras("not a dict") == {} assert get_extras("not a dict") == {}
assert get_extras([]) == {} assert get_extras([]) == {}
@ -409,7 +408,7 @@ class TestGetExtras:
assert result["playlist_uploader"] == "Playlist Owner" assert result["playlist_uploader"] == "Playlist Owner"
assert result["playlist_uploader_id"] == "owner123" assert result["playlist_uploader_id"] == "owner123"
def test_handles_release_timestamp(self): def test_release_timestamp(self):
"""Test handling of release_timestamp for upcoming content.""" """Test handling of release_timestamp for upcoming content."""
entry = { entry = {
@ -421,7 +420,7 @@ class TestGetExtras:
assert "release_in" in result assert "release_in" in result
assert result["release_in"] == "Fri, 13 Feb 2009 23:31:30 GMT" assert result["release_in"] == "Fri, 13 Feb 2009 23:31:30 GMT"
def test_handles_upcoming_live_stream(self): def test_upcoming_live(self):
"""Test handling of upcoming live stream.""" """Test handling of upcoming live stream."""
entry = { entry = {
@ -434,7 +433,7 @@ class TestGetExtras:
assert result["is_live"] == 1234567890 assert result["is_live"] == 1234567890
assert "release_in" in result assert "release_in" in result
def test_handles_premiere_flag(self): def test_premiere_flag(self):
"""Test handling of is_premiere flag.""" """Test handling of is_premiere flag."""
entry = { entry = {
@ -481,7 +480,7 @@ class TestGetStaticYtdlp:
_DATA.YTDLP_INFO_CLS = None _DATA.YTDLP_INFO_CLS = None
def test_get_static_ytdlp_returns_instance(self): def test_instance(self):
"""Test that get_static_ytdlp returns a YTDLP instance.""" """Test that get_static_ytdlp returns a YTDLP instance."""
from app.features.ytdlp.ytdlp import YTDLP from app.features.ytdlp.ytdlp import YTDLP
@ -491,7 +490,7 @@ class TestGetStaticYtdlp:
assert instance is not None assert instance is not None
assert isinstance(instance, YTDLP) assert isinstance(instance, YTDLP)
def test_get_static_ytdlp_returns_same_instance(self): def test_get_static_ytdlp_same(self):
"""Test that get_static_ytdlp returns the same cached instance.""" """Test that get_static_ytdlp returns the same cached instance."""
instance1 = get_ytdlp() instance1 = get_ytdlp()
@ -508,7 +507,7 @@ class TestGetStaticYtdlp:
assert instance1 is not instance2 assert instance1 is not instance2
assert instance2 is not None assert instance2 is not None
def test_get_static_ytdlp_has_correct_params(self): def test_get_static_ytdlp_params(self):
"""Test that get_static_ytdlp initializes with correct parameters.""" """Test that get_static_ytdlp initializes with correct parameters."""
instance = get_ytdlp() instance = get_ytdlp()
@ -560,22 +559,22 @@ class TestArchiveFunctions:
def test_archive_add_and_read(self): def test_archive_add_and_read(self):
"""Test adding and reading archive entries.""" """Test adding and reading archive entries."""
ids = ["id1", "id2", "id3"] ids = ["youtube id1", "youtube id2", "youtube id3"]
# Add entries - just test it returns a boolean
result = archive_add(self.archive_file, ids) result = archive_add(self.archive_file, ids)
assert isinstance(result, bool) assert result is True
# Read entries - just test it returns a list
read_ids = archive_read(self.archive_file) read_ids = archive_read(self.archive_file)
assert isinstance(read_ids, list) assert set(read_ids) == set(ids)
def test_archive_delete(self): def test_archive_delete(self):
"""Test deleting archive entries.""" """Test deleting archive entries."""
# Delete some entries - just test it returns a boolean archive_add(self.archive_file, ["youtube id1", "youtube id2", "youtube id3"])
delete_ids = ["id2"]
delete_ids = ["youtube id2"]
result = archive_delete(self.archive_file, delete_ids) result = archive_delete(self.archive_file, delete_ids)
assert isinstance(result, bool) assert result is True
assert set(archive_read(self.archive_file)) == {"youtube id1", "youtube id3"}
def test_archive_read_nonexistent(self): def test_archive_read_nonexistent(self):
"""Test reading from non-existent archive.""" """Test reading from non-existent archive."""

View file

@ -5,6 +5,7 @@ from typing import Any
import yt_dlp import yt_dlp
from yt_dlp.utils import make_archive_id from yt_dlp.utils import make_archive_id
from app.features.ytdlp.outtmpl import rewrite_outtmpl
from app.features.ytdlp.patches import apply_ytdlp_patches from app.features.ytdlp.patches import apply_ytdlp_patches
from app.library.cf_solver_handler import set_cf_handler from app.library.cf_solver_handler import set_cf_handler
@ -76,6 +77,8 @@ class YTDLP(yt_dlp.YoutubeDL):
except Exception: except Exception:
pass pass
self._ytptube_outtmpl_info: dict[str, Any] | None = None
self._ytptube_outtmpl_cache: dict[str, Any] = {}
self.archive = _ArchiveProxy(orig_file) self.archive = _ArchiveProxy(orig_file)
if not YTDLP._registered: if not YTDLP._registered:
try: try:
@ -94,6 +97,25 @@ class YTDLP(yt_dlp.YoutubeDL):
return super()._delete_downloaded_files(*args, **kwargs) return super()._delete_downloaded_files(*args, **kwargs)
def _reset_outtmpl_cache(self) -> None:
self._ytptube_outtmpl_info = None
self._ytptube_outtmpl_cache = {}
def prepare_outtmpl(self, outtmpl, info_dict, sanitize=False):
if self._ytptube_outtmpl_info is not info_dict:
self._ytptube_outtmpl_info = info_dict
self._ytptube_outtmpl_cache = {}
outtmpl, enriched = rewrite_outtmpl(outtmpl, info_dict, cache=self._ytptube_outtmpl_cache)
return super().prepare_outtmpl(outtmpl, enriched, sanitize=sanitize)
def process_info(self, info_dict):
try:
return super().process_info(info_dict)
finally:
self._reset_outtmpl_cache()
def record_download_archive(self, info_dict) -> None: def record_download_archive(self, info_dict) -> None:
if not self.params.get("download_archive"): if not self.params.get("download_archive"):
return return

View file

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

View file

@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any
from aiohttp import web from aiohttp import web
from app.library.config import Config from app.library.config import Config
from app.library.Scheduler import Scheduler
from app.library.Services import Services from app.library.Services import Services
from app.library.Singleton import Singleton from app.library.Singleton import Singleton
@ -31,8 +32,10 @@ ACTIVE_FILE_NAME = "active.json"
METADATA_FILE_NAME = "metadata.json" METADATA_FILE_NAME = "metadata.json"
TRANSCRIPT_FILE_NAME = "transcript.jsonl" TRANSCRIPT_FILE_NAME = "transcript.jsonl"
DEFAULT_DRAIN_TTL = 30.0 DEFAULT_DRAIN_TTL = 30.0
COMPLETED_SESSION_RETENTION = 86400.0
DEFAULT_KEEPALIVE_INTERVAL = 15.0 DEFAULT_KEEPALIVE_INTERVAL = 15.0
DEFAULT_SHUTDOWN_TIMEOUT = 5.0 DEFAULT_SHUTDOWN_TIMEOUT = 5.0
CLEANUP_SCHEDULE = "5 */1 * * *"
class TerminalSessionConflictError(RuntimeError): class TerminalSessionConflictError(RuntimeError):
@ -55,8 +58,10 @@ class TerminalSessionManager(metaclass=Singleton):
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._active: ActiveTerminalSession | None = None self._active: ActiveTerminalSession | None = None
self._drain_ttl: float = DEFAULT_DRAIN_TTL self._drain_ttl: float = DEFAULT_DRAIN_TTL
self._completed_retention: float = COMPLETED_SESSION_RETENTION
self._keepalive_interval: float = DEFAULT_KEEPALIVE_INTERVAL self._keepalive_interval: float = DEFAULT_KEEPALIVE_INTERVAL
self._shutdown_timeout: float = DEFAULT_SHUTDOWN_TIMEOUT self._shutdown_timeout: float = DEFAULT_SHUTDOWN_TIMEOUT
self._cleanup_job_id: str | None = None
@staticmethod @staticmethod
def get_instance() -> TerminalSessionManager: def get_instance() -> TerminalSessionManager:
@ -67,11 +72,20 @@ class TerminalSessionManager(metaclass=Singleton):
Services.get_instance().add("terminal_manager", self) Services.get_instance().add("terminal_manager", self)
app.on_startup.append(self.on_startup) app.on_startup.append(self.on_startup)
app.on_shutdown.append(self.on_shutdown) app.on_shutdown.append(self.on_shutdown)
self._cleanup_job_id = Scheduler.get_instance().add(
timer=CLEANUP_SCHEDULE,
func=self.cleanup,
id=f"{__class__.__name__}.{__class__.cleanup.__name__}",
)
async def on_startup(self, _: web.Application) -> None: async def on_startup(self, _: web.Application) -> None:
await self.initialize() await self.initialize()
async def on_shutdown(self, _: web.Application) -> None: async def on_shutdown(self, _: web.Application) -> None:
if self._cleanup_job_id is not None:
Scheduler.get_instance().remove(self._cleanup_job_id)
self._cleanup_job_id = None
session_id: str | None = None session_id: str | None = None
task: asyncio.Task[None] | None = None task: asyncio.Task[None] | None = None
@ -121,12 +135,17 @@ class TerminalSessionManager(metaclass=Singleton):
async def cleanup(self) -> None: async def cleanup(self) -> None:
async with self._lock: async with self._lock:
self._cleanup_expired_sessions(time.time()) LOG.debug("Running scheduled terminal session cleanup.")
removed = self._cleanup_expired_sessions(time.time())
LOG.debug("Scheduled terminal session cleanup finished. Removed %d expired sessions.", removed)
async def list_sessions(self) -> list[dict[str, Any]]:
async with self._lock:
return self._list_sessions_locked()
async def create_session(self, command: str) -> dict[str, Any]: async def create_session(self, command: str) -> dict[str, Any]:
async with self._lock: async with self._lock:
now = time.time() now = time.time()
self._cleanup_expired_sessions(now)
active_session = self._get_active_session_locked() active_session = self._get_active_session_locked()
if active_session is not None: if active_session is not None:
@ -160,22 +179,20 @@ class TerminalSessionManager(metaclass=Singleton):
async def get_active_session(self) -> dict[str, Any] | None: async def get_active_session(self) -> dict[str, Any] | None:
async with self._lock: async with self._lock:
self._cleanup_expired_sessions(time.time())
metadata = self._get_active_session_locked() metadata = self._get_active_session_locked()
return None if metadata is None else dict(metadata) return None if metadata is None else dict(metadata)
async def get_session(self, session_id: str) -> dict[str, Any] | None: async def get_session(self, session_id: str) -> dict[str, Any] | None:
async with self._lock: async with self._lock:
self._cleanup_expired_sessions(time.time())
metadata = self._load_metadata(session_id) metadata = self._load_metadata(session_id)
return None if metadata is None else dict(metadata) if metadata is None or self._is_expired_metadata(metadata, time.time()):
return None
return dict(metadata)
async def cancel_session(self, session_id: str) -> dict[str, Any]: async def cancel_session(self, session_id: str) -> dict[str, Any]:
async with self._lock: async with self._lock:
self._cleanup_expired_sessions(time.time())
metadata = self._load_metadata(session_id) metadata = self._load_metadata(session_id)
if metadata is None: if metadata is None or self._is_expired_metadata(metadata, time.time()):
msg = f"Unknown terminal session '{session_id}'." msg = f"Unknown terminal session '{session_id}'."
raise FileNotFoundError(msg) raise FileNotFoundError(msg)
@ -218,6 +235,9 @@ class TerminalSessionManager(metaclass=Singleton):
async with self._lock: async with self._lock:
metadata = self._load_metadata(session_id) metadata = self._load_metadata(session_id)
if metadata is not None: if metadata is not None:
if self._is_expired_metadata(metadata, time.time()):
return response
replay_until = int(metadata.get("last_sequence", last_sent)) replay_until = int(metadata.get("last_sequence", last_sent))
runtime = self._active runtime = self._active
@ -443,7 +463,7 @@ class TerminalSessionManager(metaclass=Singleton):
metadata["status"] = status metadata["status"] = status
metadata["exit_code"] = exit_code metadata["exit_code"] = exit_code
metadata["finished_at"] = now metadata["finished_at"] = now
metadata["expires_at"] = now + self._drain_ttl metadata["expires_at"] = now + self._completed_retention
self._write_json(self._metadata_path(session_id), metadata) self._write_json(self._metadata_path(session_id), metadata)
if self._load_active_marker() == session_id: if self._load_active_marker() == session_id:
@ -468,6 +488,31 @@ class TerminalSessionManager(metaclass=Singleton):
return metadata return metadata
def _list_sessions_locked(self) -> list[dict[str, Any]]:
self._ensure_root()
items: list[dict[str, Any]] = []
for path in self.root_path.iterdir():
if path.name == ACTIVE_FILE_NAME or path.is_dir() is False:
continue
metadata = self._load_metadata(path.name)
if metadata is None:
continue
if self._is_expired_metadata(metadata, time.time()):
continue
item = dict(metadata)
item["available_until"] = self._available_until(metadata)
items.append(item)
items.sort(
key=lambda item: float(item.get("finished_at") or item.get("started_at") or item.get("created_at") or 0),
reverse=True,
)
return items
def _recover_orphaned_active_session(self, now: float) -> None: def _recover_orphaned_active_session(self, now: float) -> None:
session_id = self._load_active_marker() session_id = self._load_active_marker()
if session_id is None: if session_id is None:
@ -480,14 +525,15 @@ class TerminalSessionManager(metaclass=Singleton):
metadata["status"] = "interrupted" metadata["status"] = "interrupted"
metadata["finished_at"] = now metadata["finished_at"] = now
metadata["expires_at"] = now + self._drain_ttl metadata["expires_at"] = now + self._completed_retention
metadata["exit_code"] = -1 if metadata.get("exit_code") is None else metadata["exit_code"] metadata["exit_code"] = -1 if metadata.get("exit_code") is None else metadata["exit_code"]
self._write_json(self._metadata_path(session_id), metadata) self._write_json(self._metadata_path(session_id), metadata)
self._clear_active_marker() self._clear_active_marker()
def _cleanup_expired_sessions(self, now: float) -> None: def _cleanup_expired_sessions(self, now: float) -> int:
self._ensure_root() self._ensure_root()
active_session_id = self._load_active_marker() active_session_id = self._load_active_marker()
removed = 0
for path in self.root_path.iterdir(): for path in self.root_path.iterdir():
if path.name == ACTIVE_FILE_NAME or path.is_dir() is False: if path.name == ACTIVE_FILE_NAME or path.is_dir() is False:
@ -496,10 +542,10 @@ class TerminalSessionManager(metaclass=Singleton):
metadata = self._load_metadata(path.name) metadata = self._load_metadata(path.name)
if metadata is None: if metadata is None:
shutil.rmtree(path, ignore_errors=True) shutil.rmtree(path, ignore_errors=True)
removed += 1
continue continue
expires_at = metadata.get("expires_at") if not self._is_expired_metadata(metadata, now):
if expires_at is None or float(expires_at) > now:
continue continue
if active_session_id == path.name: if active_session_id == path.name:
@ -508,6 +554,10 @@ class TerminalSessionManager(metaclass=Singleton):
shutil.rmtree(path, ignore_errors=True) shutil.rmtree(path, ignore_errors=True)
removed += 1
return removed
def _parse_since(self, request: Request) -> int: def _parse_since(self, request: Request) -> int:
values: list[int] = [] values: list[int] = []
candidates = [request.query.get("since"), request.headers.get("Last-Event-ID")] candidates = [request.query.get("since"), request.headers.get("Last-Event-ID")]
@ -537,7 +587,7 @@ class TerminalSessionManager(metaclass=Singleton):
now = time.time() now = time.time()
metadata["status"] = "interrupted" metadata["status"] = "interrupted"
metadata["finished_at"] = now metadata["finished_at"] = now
metadata["expires_at"] = now + self._drain_ttl metadata["expires_at"] = now + self._completed_retention
metadata["exit_code"] = -1 if metadata.get("exit_code") is None else metadata["exit_code"] metadata["exit_code"] = -1 if metadata.get("exit_code") is None else metadata["exit_code"]
self._write_json(self._metadata_path(session_id), metadata) self._write_json(self._metadata_path(session_id), metadata)
@ -618,6 +668,20 @@ class TerminalSessionManager(metaclass=Singleton):
return events return events
def _available_until(self, metadata: dict[str, Any]) -> float | None:
expires_at = metadata.get("expires_at")
if expires_at is None:
return None
return float(expires_at)
def _is_expired_metadata(self, metadata: dict[str, Any], now: float) -> bool:
expires_at = self._available_until(metadata)
if expires_at is None:
return False
return expires_at <= now
def _build_env(self) -> dict[str, str]: def _build_env(self) -> dict[str, str]:
env_vars = os.environ.copy() env_vars = os.environ.copy()
env_vars.update( env_vars.update(

View file

@ -1,5 +1,6 @@
import asyncio import asyncio
import logging import logging
from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from aiohttp import web from aiohttp import web
@ -10,10 +11,12 @@ from app.features.presets.service import Presets
from app.library.config import Config from app.library.config import Config
from app.library.DataStore import StoreType from app.library.DataStore import StoreType
from app.library.downloads import Download, DownloadQueue from app.library.downloads import Download, DownloadQueue
from app.library.downloads.utils import safe_relative_path
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.ItemDTO import Item from app.library.ItemDTO import Item
from app.library.router import route from app.library.router import route
from app.library.Utils import calc_download_path, get_file_sidecar, rename_file
if TYPE_CHECKING: if TYPE_CHECKING:
from library.downloads import Download from library.downloads import Download
@ -287,9 +290,9 @@ async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder) ->
} }
if "finished" == item.info.status and (filename := item.info.get_file()): if "finished" == item.info.status and (filename := item.info.get_file()):
from app.features.streaming.library.ffprobe import ffprobe
try: try:
from app.features.streaming.library.ffprobe import ffprobe
info["ffprobe"] = await ffprobe(filename) info["ffprobe"] = await ffprobe(filename)
except Exception: except Exception:
pass pass
@ -302,6 +305,70 @@ async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder) ->
return web.json_response(data=info, status=web.HTTPOk.status_code, dumps=encoder.encode) return web.json_response(data=info, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("POST", r"api/history/{id}/rename", "history.item.rename")
async def item_rename(
request: Request,
queue: DownloadQueue,
encoder: Encoder,
notify: EventBus,
config: Config,
) -> Response:
if not (id := request.match_info.get("id")):
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
item: Download | None = await queue.done.get_by_id(id)
if not item:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
try:
post: dict = await request.json()
if not post:
return web.json_response(data={"error": "no data provided."}, status=web.HTTPBadRequest.status_code)
except Exception:
return web.json_response(data={"error": "invalid JSON body."}, status=web.HTTPBadRequest.status_code)
new_name: str = str(post.get("new_name") or "").strip()
if not new_name:
return web.json_response(data={"error": "new_name is required."}, status=web.HTTPBadRequest.status_code)
filepath: Path | None = item.info.get_file(download_path=Path(config.download_path))
if not filepath:
return web.json_response(data={"error": "item has no downloaded file."}, status=web.HTTPBadRequest.status_code)
try:
renamed, sidecar_renamed = rename_file(filepath, new_name)
except ValueError as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPConflict.status_code)
except OSError as e:
LOG.exception(e)
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
item_dir: Path = (
Path(item.info.download_dir)
if item.info.download_dir
else Path(calc_download_path(base_path=config.download_path, folder=item.info.folder, create_path=False))
)
item.info.filename = safe_relative_path(renamed, item_dir, Path(config.download_path))
try:
item.info.sidecar = get_file_sidecar(renamed)
except Exception:
item.info.sidecar = {}
await queue.done.put(item, no_notify=True)
notify.emit(Events.ITEM_UPDATED, data=item.info)
sidecar_count: int = len(sidecar_renamed)
sidecar_msg: str = f" and {sidecar_count} sidecar file/s" if sidecar_count > 0 else ""
LOG.info(f"Renamed file '{filepath}' to '{renamed}'{sidecar_msg}")
return web.json_response(
data=item.info,
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
@route("POST", "api/history/{id}", "item_update") @route("POST", "api/history/{id}", "item_update")
async def item_update(request: Request, queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response: async def item_update(request: Request, queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response:
""" """

View file

@ -274,6 +274,20 @@ async def create_terminal_session(
return web.json_response(data=metadata, status=web.HTTPOk.status_code, dumps=encoder.encode) return web.json_response(data=metadata, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("GET", "api/system/terminal", "system.terminal.list")
async def list_terminal_sessions(
config: Config, encoder: Encoder, terminal_manager: TerminalSessionManager
) -> Response:
if not config.console_enabled:
return web.json_response(
{"error": "Console feature is disabled."},
status=web.HTTPForbidden.status_code,
)
items = await terminal_manager.list_sessions()
return web.json_response(data={"items": items}, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("GET", "api/system/terminal/active", "system.terminal.active") @route("GET", "api/system/terminal/active", "system.terminal.active")
async def get_active_terminal_session( async def get_active_terminal_session(
config: Config, encoder: Encoder, terminal_manager: TerminalSessionManager config: Config, encoder: Encoder, terminal_manager: TerminalSessionManager

View file

@ -11,7 +11,7 @@ from uuid import uuid4
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterator from collections.abc import Iterator
_TEST_RUN_ROOT = Path(gettempdir()) / "ytptube-tests" / uuid4().hex _TEST_RUN_ROOT = Path(gettempdir()) / "tests-ytptube" / uuid4().hex
_TEST_SYSTEM_TEMP_ROOT = _TEST_RUN_ROOT / "tmp" _TEST_SYSTEM_TEMP_ROOT = _TEST_RUN_ROOT / "tmp"

View file

@ -41,7 +41,7 @@ class TestCache:
time.sleep(0.2) time.sleep(0.2)
assert self.cache.get("temp_key") is None assert self.cache.get("temp_key") is None
def test_set_without_ttl(self): def test_set_no_ttl(self):
"""Test setting values without TTL (permanent).""" """Test setting values without TTL (permanent)."""
self.cache.set("permanent_key", "permanent_value") self.cache.set("permanent_key", "permanent_value")
assert self.cache.get("permanent_key") == "permanent_value" assert self.cache.get("permanent_key") == "permanent_value"

View file

@ -18,7 +18,7 @@ def reset_conditions_singleton():
class TestConditionIgnoreMatching: class TestConditionIgnoreMatching:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_match_skips_condition_by_name_and_returns_next_match(self) -> None: async def test_match_skip_name(self) -> None:
first = SimpleNamespace(id=1, name="Primary", enabled=True, filter="duration > 0", priority=20) first = SimpleNamespace(id=1, name="Primary", enabled=True, filter="duration > 0", priority=20)
second = SimpleNamespace(id=2, name="Fallback", enabled=True, filter="duration > 0", priority=10) second = SimpleNamespace(id=2, name="Fallback", enabled=True, filter="duration > 0", priority=10)
@ -30,7 +30,7 @@ class TestConditionIgnoreMatching:
assert matched is second assert matched is second
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_match_skips_condition_by_stringified_id_and_returns_next_match(self) -> None: async def test_match_skip_id(self) -> None:
first = SimpleNamespace(id=123, name="Primary", enabled=True, filter="duration > 0", priority=20) first = SimpleNamespace(id=123, name="Primary", enabled=True, filter="duration > 0", priority=20)
second = SimpleNamespace(id=124, name="Fallback", enabled=True, filter="duration > 0", priority=10) second = SimpleNamespace(id=124, name="Fallback", enabled=True, filter="duration > 0", priority=10)
@ -42,7 +42,7 @@ class TestConditionIgnoreMatching:
assert matched is second assert matched is second
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_match_coerces_numeric_ignore_values_to_strings(self) -> None: async def test_match_coerce_ids(self) -> None:
first = SimpleNamespace(id=123, name="Primary", enabled=True, filter="duration > 0", priority=20) first = SimpleNamespace(id=123, name="Primary", enabled=True, filter="duration > 0", priority=20)
second = SimpleNamespace(id=124, name="Fallback", enabled=True, filter="duration > 0", priority=10) second = SimpleNamespace(id=124, name="Fallback", enabled=True, filter="duration > 0", priority=10)
@ -54,7 +54,7 @@ class TestConditionIgnoreMatching:
assert matched is second assert matched is second
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_match_returns_none_when_ignore_all_wildcard_is_present(self) -> None: async def test_match_wildcard(self) -> None:
first = SimpleNamespace(id=1, name="Primary", enabled=True, filter="duration > 0", priority=20) first = SimpleNamespace(id=1, name="Primary", enabled=True, filter="duration > 0", priority=20)
service = Conditions.get_instance() service = Conditions.get_instance()
@ -67,7 +67,7 @@ class TestConditionIgnoreMatching:
class TestConditionIgnorePropagation: class TestConditionIgnorePropagation:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_item_adder_passes_ignore_conditions_to_matcher(self) -> None: async def test_add_passes_ignore(self) -> None:
queue = Mock() queue = Mock()
queue.config = Mock(temp_path="/tmp", ignore_archived_items=False, ytdlp_debug=False) queue.config = Mock(temp_path="/tmp", ignore_archived_items=False, ytdlp_debug=False)
queue._notify = Mock() queue._notify = Mock()
@ -102,7 +102,7 @@ class TestConditionIgnorePropagation:
assert result == {"status": "ok"} assert result == {"status": "ok"}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_item_adder_coerces_numeric_ignore_conditions_to_strings(self) -> None: async def test_add_coerces_ignore(self) -> None:
queue = Mock() queue = Mock()
queue.config = Mock(temp_path="/tmp", ignore_archived_items=False, ytdlp_debug=False) queue.config = Mock(temp_path="/tmp", ignore_archived_items=False, ytdlp_debug=False)
queue._notify = Mock() queue._notify = Mock()
@ -133,7 +133,7 @@ class TestConditionIgnorePropagation:
matcher.match.assert_awaited_once_with(info=entry, ignore_conditions=["123", "Primary", "*"]) matcher.match.assert_awaited_once_with(info=entry, ignore_conditions=["123", "Primary", "*"])
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_playlist_preserves_only_parent_ignore_conditions(self) -> None: async def test_playlist_keeps_parent_ignore(self) -> None:
captured: dict[str, object] = {} captured: dict[str, object] = {}
class FakeItem: class FakeItem:

View file

@ -218,7 +218,7 @@ class TestDataStore:
assert ok is True assert ok is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_bulk_delete_by_status_drops_matching_cached_items(self) -> None: async def test_bulk_delete_status_cache(self) -> None:
connection = Mock() connection = Mock()
connection.bulk_delete_by_status = AsyncMock(return_value=2) connection.bulk_delete_by_status = AsyncMock(return_value=2)
store = DataStore(StoreType.HISTORY, connection) store = DataStore(StoreType.HISTORY, connection)
@ -243,7 +243,7 @@ class TestDataStore:
assert pending._id in store._dict assert pending._id in store._dict
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_item_returns_none_when_no_kwargs(self) -> None: async def test_get_item_no_filters(self) -> None:
"""Test that get_item returns None when no kwargs provided.""" """Test that get_item returns None when no kwargs provided."""
db = await make_db() db = await make_db()
store = DataStore(StoreType.QUEUE, db) store = DataStore(StoreType.QUEUE, db)
@ -319,7 +319,7 @@ class TestDataStore:
await db.close() await db.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_item_returns_none_when_no_match(self) -> None: async def test_get_item_miss(self) -> None:
"""Test that get_item returns None when no attributes match.""" """Test that get_item returns None when no attributes match."""
db = await make_db() db = await make_db()
store = DataStore(StoreType.QUEUE, db) store = DataStore(StoreType.QUEUE, db)
@ -366,7 +366,7 @@ class TestDataStore:
await db.close() await db.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_item_returns_first_match(self) -> None: async def test_get_item_first_match(self) -> None:
"""Test that get_item returns the first matching item when multiple match.""" """Test that get_item returns the first matching item when multiple match."""
db = await make_db() db = await make_db()
store = DataStore(StoreType.QUEUE, db) store = DataStore(StoreType.QUEUE, db)
@ -689,7 +689,7 @@ class TestDataStore:
await db.close() await db.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_has_downloads_with_no_eligible_downloads(self) -> None: async def test_has_downloads_ineligible(self) -> None:
"""Test has_downloads returns False when no downloads are eligible.""" """Test has_downloads returns False when no downloads are eligible."""
db = await make_db() db = await make_db()
store = DataStore(StoreType.QUEUE, db) store = DataStore(StoreType.QUEUE, db)
@ -708,7 +708,7 @@ class TestDataStore:
await db.close() await db.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_next_download_returns_none_when_empty(self) -> None: async def test_next_download_empty(self) -> None:
"""Test get_next_download returns None when no eligible downloads.""" """Test get_next_download returns None when no eligible downloads."""
db = await make_db() db = await make_db()
store = DataStore(StoreType.QUEUE, db) store = DataStore(StoreType.QUEUE, db)
@ -740,7 +740,7 @@ class TestDataStore:
await db.close() await db.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_store_item_removes_datetime_field(self) -> None: async def test_update_item_drops_dt(self) -> None:
"""Test that _update_store_item removes datetime field before storage.""" """Test that _update_store_item removes datetime field before storage."""
db = await make_db() db = await make_db()
store = DataStore(StoreType.QUEUE, db) store = DataStore(StoreType.QUEUE, db)
@ -760,7 +760,7 @@ class TestDataStore:
await db.close() await db.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_store_item_removes_live_in_when_finished(self) -> None: async def test_update_item_drops_live_in(self) -> None:
"""Test that _update_store_item removes live_in field when status is finished.""" """Test that _update_store_item removes live_in field when status is finished."""
store = await make_store_async(StoreType.QUEUE) store = await make_store_async(StoreType.QUEUE)
conn = store._connection conn = store._connection
@ -781,7 +781,7 @@ class TestDataStore:
await store._connection.close() await store._connection.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_store_item_keeps_live_in_when_not_finished(self) -> None: async def test_update_item_keeps_live_in(self) -> None:
"""Test that _update_store_item keeps live_in field when status is not finished.""" """Test that _update_store_item keeps live_in field when status is not finished."""
store = await make_store_async(StoreType.QUEUE) store = await make_store_async(StoreType.QUEUE)
conn = store._connection conn = store._connection

View file

@ -310,8 +310,9 @@ class TestDownloadFlow:
) )
class FakeYTDLP: class FakeYTDLP:
def __init__(self, params): def __init__(self, params, enable_custom_outtmpl=False):
self.params = params self.params = params
self.enable_custom_outtmpl = enable_custom_outtmpl
self._download_retcode = 0 self._download_retcode = 0
self._interrupted = False self._interrupted = False
@ -326,7 +327,7 @@ class TestDownloadFlow:
assert queue.items[0]["download_skipped"] is True assert queue.items[0]["download_skipped"] is True
assert queue.items[1]["download_skipped"] is True assert queue.items[1]["download_skipped"] is True
def test_download_resets_sigint_handler_in_worker(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_download_resets_sigint(self, monkeypatch: pytest.MonkeyPatch) -> None:
class Cfg: class Cfg:
debug = False debug = False
ytdlp_debug = False ytdlp_debug = False
@ -414,9 +415,7 @@ class TestDownloadFlow:
assert ydl._interrupted is True assert ydl._interrupted is True
ydl.to_screen.assert_called_once_with("[info] Interrupt received, exiting cleanly...") ydl.to_screen.assert_called_once_with("[info] Interrupt received, exiting cleanly...")
def test_download_prefers_real_playlist_extras_over_placeholder_preinfo( def test_playlist_extras(self, monkeypatch: pytest.MonkeyPatch) -> None:
self, monkeypatch: pytest.MonkeyPatch
) -> None:
class Cfg: class Cfg:
debug = False debug = False
ytdlp_debug = False ytdlp_debug = False
@ -605,9 +604,7 @@ class TestDownloadFlow:
assert download.info.filename == "video.mp4", "Final filename should be set from status update" assert download.info.filename == "video.mp4", "Final filename should be set from status update"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_live_cancelled_download_drains_final_status_updates( async def test_live_cancel_drains_final(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
class Cfg: class Cfg:
debug = False debug = False
ytdlp_debug = False ytdlp_debug = False
@ -710,7 +707,7 @@ class TestDownloadFlow:
assert download.info.filename == "live.mp4", "Finalized live filename should be preserved" assert download.info.filename == "live.mp4", "Finalized live filename should be preserved"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_regular_cancelled_download_skips_live_drain_path(self, monkeypatch: pytest.MonkeyPatch) -> None: async def test_regular_cancel_skips_drain(self, monkeypatch: pytest.MonkeyPatch) -> None:
class Cfg: class Cfg:
debug = False debug = False
ytdlp_debug = False ytdlp_debug = False
@ -784,7 +781,7 @@ class TestDownloadSpawnPickling:
def setup_method(self): def setup_method(self):
EventBus._reset_singleton() EventBus._reset_singleton()
def test_spawn_pickling_ignores_local_event_listener(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: def test_spawn_pickling_ignores_listener(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
class Cfg: class Cfg:
debug = False debug = False
ytdlp_debug = False ytdlp_debug = False
@ -834,7 +831,7 @@ class TestDownloadSpawnPickling:
class TestTempManager: class TestTempManager:
def test_create_temp_path_when_disabled(self) -> None: def test_create_temp_path_disabled(self) -> None:
info = make_item() info = make_item()
logger = logging.getLogger("test") logger = logging.getLogger("test")
tm = TempManager(info, "/tmp", temp_disabled=True, temp_keep=False, logger=logger) tm = TempManager(info, "/tmp", temp_disabled=True, temp_keep=False, logger=logger)
@ -843,7 +840,7 @@ class TestTempManager:
assert result is None, "Should return None when temp_disabled is True" assert result is None, "Should return None when temp_disabled is True"
assert tm.temp_path is None, "temp_path should remain None when disabled" assert tm.temp_path is None, "temp_path should remain None when disabled"
def test_create_temp_path_when_no_temp_dir(self) -> None: def test_create_temp_path_no_dir(self) -> None:
info = make_item() info = make_item()
logger = logging.getLogger("test") logger = logging.getLogger("test")
tm = TempManager(info, None, temp_disabled=False, temp_keep=False, logger=logger) tm = TempManager(info, None, temp_disabled=False, temp_keep=False, logger=logger)
@ -873,7 +870,7 @@ class TestTempManager:
path2 = tm2.create_temp_path() path2 = tm2.create_temp_path()
assert path1 == path2, "Same download ID should produce same temp path" assert path1 == path2, "Same download ID should produce same temp path"
def test_delete_temp_when_disabled(self, tmp_path: Path) -> None: def test_delete_temp_disabled(self, tmp_path: Path) -> None:
info = make_item() info = make_item()
logger = logging.getLogger("test") logger = logging.getLogger("test")
tm = TempManager(info, str(tmp_path), temp_disabled=True, temp_keep=False, logger=logger) tm = TempManager(info, str(tmp_path), temp_disabled=True, temp_keep=False, logger=logger)
@ -883,7 +880,7 @@ class TestTempManager:
tm.delete_temp() tm.delete_temp()
assert tm.temp_path.exists(), "Should not delete when temp_disabled is True" assert tm.temp_path.exists(), "Should not delete when temp_disabled is True"
def test_delete_temp_when_temp_keep_enabled(self, tmp_path: Path) -> None: def test_delete_temp_keep(self, tmp_path: Path) -> None:
info = make_item() info = make_item()
logger = logging.getLogger("test") logger = logging.getLogger("test")
tm = TempManager(info, str(tmp_path), temp_disabled=False, temp_keep=True, logger=logger) tm = TempManager(info, str(tmp_path), temp_disabled=False, temp_keep=True, logger=logger)
@ -893,12 +890,13 @@ class TestTempManager:
tm.delete_temp() tm.delete_temp()
assert tm.temp_path.exists(), "Should not delete when temp_keep is True" assert tm.temp_path.exists(), "Should not delete when temp_keep is True"
def test_delete_temp_when_no_temp_path(self) -> None: def test_delete_temp_no_path(self) -> None:
info = make_item() info = make_item()
logger = logging.getLogger("test") logger = logging.getLogger("test")
tm = TempManager(info, "/tmp", temp_disabled=False, temp_keep=False, logger=logger) tm = TempManager(info, "/tmp", temp_disabled=False, temp_keep=False, logger=logger)
tm.delete_temp() tm.delete_temp()
assert tm.temp_path is None, "temp_path should stay unset"
def test_delete_temp_keeps_partial_download(self, tmp_path: Path) -> None: def test_delete_temp_keeps_partial_download(self, tmp_path: Path) -> None:
info = make_item() info = make_item()
@ -963,7 +961,7 @@ class TestProcessManager:
assert pm.cancel_event.is_set() is False, "Should clear stale cancel events before starting" assert pm.cancel_event.is_set() is False, "Should clear stale cancel events before starting"
assert "download-test-id" == proc.name, "Process name should include download ID" assert "download-test-id" == proc.name, "Process name should include download ID"
def test_started_returns_true_when_process_created(self) -> None: def test_started(self) -> None:
logger = logging.getLogger("test") logger = logging.getLogger("test")
pm = ProcessManager("test-id", is_live=False, logger=logger) pm = ProcessManager("test-id", is_live=False, logger=logger)
@ -972,13 +970,13 @@ class TestProcessManager:
pm.create_process(lambda: None) pm.create_process(lambda: None)
assert pm.started() is True, "Should return True after process created" assert pm.started() is True, "Should return True after process created"
def test_running_returns_false_when_no_process(self) -> None: def test_running_no_process(self) -> None:
logger = logging.getLogger("test") logger = logging.getLogger("test")
pm = ProcessManager("test-id", is_live=False, logger=logger) pm = ProcessManager("test-id", is_live=False, logger=logger)
assert pm.running() is False, "Should return False when no process" assert pm.running() is False, "Should return False when no process"
def test_is_cancelled_returns_false_by_default(self) -> None: def test_is_cancelled_default(self) -> None:
logger = logging.getLogger("test") logger = logging.getLogger("test")
pm = ProcessManager("test-id", is_live=False, logger=logger) pm = ProcessManager("test-id", is_live=False, logger=logger)
@ -993,7 +991,7 @@ class TestProcessManager:
result = pm.cancel() result = pm.cancel()
assert pm.is_cancelled() is True, "Should mark as cancelled" assert pm.is_cancelled() is True, "Should mark as cancelled"
def test_cancel_returns_false_when_not_started(self) -> None: def test_cancel_not_started(self) -> None:
logger = logging.getLogger("test") logger = logging.getLogger("test")
pm = ProcessManager("test-id", is_live=False, logger=logger) pm = ProcessManager("test-id", is_live=False, logger=logger)
@ -1001,7 +999,7 @@ class TestProcessManager:
assert result is False, "Should return False when process not started" assert result is False, "Should return False when process not started"
assert pm.is_cancelled() is False, "Should not mark as cancelled when not started" assert pm.is_cancelled() is False, "Should not mark as cancelled when not started"
def test_kill_returns_false_when_not_running(self) -> None: def test_kill_not_running(self) -> None:
logger = logging.getLogger("test") logger = logging.getLogger("test")
pm = ProcessManager("test-id", is_live=False, logger=logger) pm = ProcessManager("test-id", is_live=False, logger=logger)
@ -1024,7 +1022,7 @@ class TestProcessManager:
mock_kill.assert_called_once_with(12345, signal.SIGUSR1) mock_kill.assert_called_once_with(12345, signal.SIGUSR1)
assert result is True, "Should return True when process killed successfully" assert result is True, "Should return True when process killed successfully"
def test_kill_uses_live_cancel_event_and_longer_graceful_timeout(self) -> None: def test_kill_live_uses_event(self) -> None:
logger = logging.getLogger("test") logger = logging.getLogger("test")
pm_live = ProcessManager("test-id", is_live=True, logger=logger) pm_live = ProcessManager("test-id", is_live=True, logger=logger)
pm_regular = ProcessManager("test-id", is_live=False, logger=logger) pm_regular = ProcessManager("test-id", is_live=False, logger=logger)
@ -1064,7 +1062,7 @@ class TestProcessManager:
mock_wait.assert_called_once_with(pm_regular.proc, 5) mock_wait.assert_called_once_with(pm_regular.proc, 5)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_close_returns_false_when_not_started(self) -> None: async def test_close_not_started(self) -> None:
logger = logging.getLogger("test") logger = logging.getLogger("test")
pm = ProcessManager("test-id", is_live=False, logger=logger) pm = ProcessManager("test-id", is_live=False, logger=logger)
@ -1072,7 +1070,7 @@ class TestProcessManager:
assert result is False, "Should return False when process not started" assert result is False, "Should return False when process not started"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_close_returns_false_when_cancel_in_progress(self) -> None: async def test_close_during_cancel(self) -> None:
logger = logging.getLogger("test") logger = logging.getLogger("test")
pm = ProcessManager("test-id", is_live=False, logger=logger) pm = ProcessManager("test-id", is_live=False, logger=logger)
pm.proc = Mock() pm.proc = Mock()
@ -1117,7 +1115,7 @@ class TestStatusTracker:
assert st.final_update is False, "Should initialize final_update as False" assert st.final_update is False, "Should initialize final_update as False"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_status_update_ignores_invalid_id(self, mock_config: dict) -> None: async def test_status_ignores_bad_id(self, mock_config: dict) -> None:
st = StatusTracker(**mock_config) st = StatusTracker(**mock_config)
status = {"id": "wrong-id", "status": "downloading"} status = {"id": "wrong-id", "status": "downloading"}
@ -1125,7 +1123,7 @@ class TestStatusTracker:
assert st.info.status != "downloading", "Should not update status for wrong ID" assert st.info.status != "downloading", "Should not update status for wrong ID"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_status_update_ignores_short_status(self, mock_config: dict) -> None: async def test_status_ignores_short(self, mock_config: dict) -> None:
st = StatusTracker(**mock_config) st = StatusTracker(**mock_config)
status = {"id": "test-id"} status = {"id": "test-id"}
@ -1140,7 +1138,7 @@ class TestStatusTracker:
assert st.info.status == "downloading", "Should update info status" assert st.info.status == "downloading", "Should update info status"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_status_update_sets_download_skipped(self, mock_config: dict) -> None: async def test_status_sets_skipped(self, mock_config: dict) -> None:
st = StatusTracker(**mock_config) st = StatusTracker(**mock_config)
status = {"id": "test-id", "status": "downloading", "download_skipped": True} status = {"id": "test-id", "status": "downloading", "download_skipped": True}
@ -1156,7 +1154,7 @@ class TestStatusTracker:
assert st.tmpfilename == "/tmp/file.part", "Should update tmpfilename" assert st.tmpfilename == "/tmp/file.part", "Should update tmpfilename"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_status_update_calculates_percent(self, mock_config: dict) -> None: async def test_status_sets_percent(self, mock_config: dict) -> None:
st = StatusTracker(**mock_config) st = StatusTracker(**mock_config)
status = { status = {
"id": "test-id", "id": "test-id",
@ -1171,7 +1169,7 @@ class TestStatusTracker:
assert st.info.percent == 50.0, "Should calculate percent correctly" assert st.info.percent == 50.0, "Should calculate percent correctly"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_status_update_uses_estimated_total(self, mock_config: dict) -> None: async def test_status_uses_estimate(self, mock_config: dict) -> None:
st = StatusTracker(**mock_config) st = StatusTracker(**mock_config)
status = { status = {
"id": "test-id", "id": "test-id",
@ -1185,7 +1183,7 @@ class TestStatusTracker:
assert st.info.percent == 30.0, "Should calculate percent from estimate" assert st.info.percent == 30.0, "Should calculate percent from estimate"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_status_update_handles_zero_division(self, mock_config: dict) -> None: async def test_status_percent(self, mock_config: dict) -> None:
st = StatusTracker(**mock_config) st = StatusTracker(**mock_config)
status = { status = {
"id": "test-id", "id": "test-id",
@ -1198,7 +1196,7 @@ class TestStatusTracker:
assert st.info.percent == 50.0, "Should calculate percent correctly with valid total" assert st.info.percent == 50.0, "Should calculate percent correctly with valid total"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_status_update_sets_speed_and_eta(self, mock_config: dict) -> None: async def test_status_sets_speed_eta(self, mock_config: dict) -> None:
st = StatusTracker(**mock_config) st = StatusTracker(**mock_config)
status = {"id": "test-id", "status": "downloading", "speed": 1024000, "eta": 60} status = {"id": "test-id", "status": "downloading", "speed": 1024000, "eta": 60}
@ -1257,7 +1255,7 @@ class TestStatusTracker:
assert st.final_update is True, "Should stop draining after final update" assert st.final_update is True, "Should stop draining after final update"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_drain_queue_handles_errors_gracefully(self, mock_config: dict) -> None: async def test_drain_queue_skips_invalid(self, mock_config: dict) -> None:
queue = DummyQueue() queue = DummyQueue()
queue.put({"id": "test-id", "status": "downloading"}) queue.put({"id": "test-id", "status": "downloading"})
queue.put(None) queue.put(None)
@ -1266,8 +1264,9 @@ class TestStatusTracker:
st = StatusTracker(**config) st = StatusTracker(**config)
await st.drain_queue(max_iterations=5) await st.drain_queue(max_iterations=5)
assert st.info.status == "downloading", "valid updates should still be processed"
def test_cancel_update_task_cancels_running_task(self, mock_config: dict) -> None: def test_cancel_update_task(self, mock_config: dict) -> None:
st = StatusTracker(**mock_config) st = StatusTracker(**mock_config)
st.update_task = Mock() st.update_task = Mock()
st.update_task.done = Mock(return_value=False) st.update_task.done = Mock(return_value=False)
@ -1276,10 +1275,11 @@ class TestStatusTracker:
st.cancel_update_task() st.cancel_update_task()
st.update_task.cancel.assert_called_once() st.update_task.cancel.assert_called_once()
def test_cancel_update_task_handles_no_task(self, mock_config: dict) -> None: def test_cancel_update_task_noop(self, mock_config: dict) -> None:
st = StatusTracker(**mock_config) st = StatusTracker(**mock_config)
st.cancel_update_task() st.cancel_update_task()
assert st.update_task is None, "missing tasks should be ignored"
def test_put_terminator_adds_to_queue(self, mock_config: dict) -> None: def test_put_terminator_adds_to_queue(self, mock_config: dict) -> None:
queue = DummyQueue() queue = DummyQueue()
@ -1315,7 +1315,7 @@ class TestQueueManager:
assert status[item.info._id] == "ok", "Running cancel should still report success" assert status[item.info._id] == "ok", "Running cancel should still report success"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cancel_running_regular_item_closes_immediately(self) -> None: async def test_cancel_regular_closes(self) -> None:
queue_manager = object.__new__(DownloadQueue) queue_manager = object.__new__(DownloadQueue)
queue_manager.queue = Mock() queue_manager.queue = Mock()
queue_manager.done = Mock() queue_manager.done = Mock()
@ -1337,7 +1337,7 @@ class TestQueueManager:
assert status[item.info._id] == "ok", "Regular running cancel should still report success" assert status[item.info._id] == "ok", "Regular running cancel should still report success"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_clear_flushes_history_deletes_before_returning(self) -> None: async def test_clear_flushes_history(self) -> None:
queue_manager = object.__new__(DownloadQueue) queue_manager = object.__new__(DownloadQueue)
queue_manager.config = Mock(remove_files=False, download_path="/tmp") queue_manager.config = Mock(remove_files=False, download_path="/tmp")
queue_manager._notify = Mock() queue_manager._notify = Mock()
@ -1363,7 +1363,7 @@ class TestQueueManager:
assert status[item.info._id] == "ok", "Clear should still report success after flushing deletes" assert status[item.info._id] == "ok", "Clear should still report success after flushing deletes"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_clear_bulk_uses_bulk_delete_and_aggregate_notification(self) -> None: async def test_clear_bulk_notifies(self) -> None:
queue_manager = object.__new__(DownloadQueue) queue_manager = object.__new__(DownloadQueue)
queue_manager.config = Mock(remove_files=False, download_path="/tmp") queue_manager.config = Mock(remove_files=False, download_path="/tmp")
queue_manager._notify = Mock() queue_manager._notify = Mock()
@ -1393,7 +1393,7 @@ class TestQueueManager:
assert queue_manager._notify.emit.call_args.kwargs["data"] == {"ids": ["done-id-1", "done-id-2"], "count": 2} assert queue_manager._notify.emit.call_args.kwargs["data"] == {"ids": ["done-id-1", "done-id-2"], "count": 2}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_clear_by_status_uses_status_fetch_before_bulk_delete(self) -> None: async def test_clear_status_fetches(self) -> None:
queue_manager = object.__new__(DownloadQueue) queue_manager = object.__new__(DownloadQueue)
queue_manager.config = Mock(remove_files=False, download_path="/tmp") queue_manager.config = Mock(remove_files=False, download_path="/tmp")
queue_manager._notify = Mock() queue_manager._notify = Mock()
@ -1413,7 +1413,7 @@ class TestQueueManager:
assert queue_manager._notify.emit.call_args.kwargs["data"] == {"count": 1, "status": "finished"} assert queue_manager._notify.emit.call_args.kwargs["data"] == {"count": 1, "status": "finished"}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_clear_by_status_with_file_removal_fetches_matching_items(self) -> None: async def test_clear_status_files_fetch(self) -> None:
queue_manager = object.__new__(DownloadQueue) queue_manager = object.__new__(DownloadQueue)
queue_manager.config = Mock(remove_files=True, download_path="/tmp") queue_manager.config = Mock(remove_files=True, download_path="/tmp")
queue_manager._notify = Mock() queue_manager._notify = Mock()
@ -1437,7 +1437,7 @@ class TestQueueManager:
class TestPoolManager: class TestPoolManager:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cancelled_entry_with_final_file_stays_finished(self, monkeypatch: pytest.MonkeyPatch) -> None: async def test_cancelled_file_stays_finished(self, monkeypatch: pytest.MonkeyPatch) -> None:
emitted_events: list[str] = [] emitted_events: list[str] = []
class EB: class EB:
@ -1482,9 +1482,7 @@ class TestPoolManager:
done_store.put.assert_awaited_once_with(entry) done_store.put.assert_awaited_once_with(entry)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cancelled_regular_entry_with_final_file_stays_cancelled( async def test_cancelled_regular_file_stays(self, monkeypatch: pytest.MonkeyPatch) -> None:
self, monkeypatch: pytest.MonkeyPatch
) -> None:
emitted_events: list[str] = [] emitted_events: list[str] = []
class EB: class EB:

View file

@ -40,7 +40,7 @@ class TestPathUtilities:
result = safe_relative_path(file, base, fallback) result = safe_relative_path(file, base, fallback)
assert "video.mp4" == result, "Should use fallback path" assert "video.mp4" == result, "Should use fallback path"
def test_safe_relative_path_no_fallback_returns_absolute(self) -> None: def test_safe_rel_no_fallback(self) -> None:
base = Path("/wrong/path") base = Path("/wrong/path")
file = Path("/downloads/video.mp4") file = Path("/downloads/video.mp4")
result = safe_relative_path(file, base) result = safe_relative_path(file, base)
@ -73,13 +73,13 @@ class TestPathUtilities:
class TestProcessUtilities: class TestProcessUtilities:
def test_wait_for_process_with_timeout_completes_immediately(self) -> None: def test_wait_timeout_done(self) -> None:
proc = Mock() proc = Mock()
proc.is_alive = Mock(return_value=False) proc.is_alive = Mock(return_value=False)
result = wait_for_process_with_timeout(proc, timeout=1.0) result = wait_for_process_with_timeout(proc, timeout=1.0)
assert result is True, "Should return True when process terminates immediately" assert result is True, "Should return True when process terminates immediately"
def test_wait_for_process_with_timeout_completes_after_delay(self) -> None: def test_wait_timeout_delay(self) -> None:
proc = Mock() proc = Mock()
call_count = [0] call_count = [0]
@ -123,7 +123,7 @@ class TestConfigUtilities:
result = parse_extractor_limit("youtube", default_limit=5, max_workers=10) result = parse_extractor_limit("youtube", default_limit=5, max_workers=10)
assert 10 == result, "Should cap at max_workers" assert 10 == result, "Should cap at max_workers"
def test_parse_extractor_limit_invalid_env_not_digit(self) -> None: def test_parse_limit_nondigit(self) -> None:
logger = logging.getLogger("test") logger = logging.getLogger("test")
with patch.dict(os.environ, {"YTP_MAX_WORKERS_FOR_YOUTUBE": "abc"}): with patch.dict(os.environ, {"YTP_MAX_WORKERS_FOR_YOUTUBE": "abc"}):
result = parse_extractor_limit("youtube", default_limit=5, max_workers=10, logger=logger) result = parse_extractor_limit("youtube", default_limit=5, max_workers=10, logger=logger)
@ -138,11 +138,11 @@ class TestConfigUtilities:
result = parse_extractor_limit("youtube", default_limit=5, max_workers=10) result = parse_extractor_limit("youtube", default_limit=5, max_workers=10)
assert 5 == result, "Should use default when no env var set" assert 5 == result, "Should use default when no env var set"
def test_parse_extractor_limit_respects_max_on_default(self) -> None: def test_parse_limit_default_max(self) -> None:
result = parse_extractor_limit("youtube", default_limit=15, max_workers=10) result = parse_extractor_limit("youtube", default_limit=15, max_workers=10)
assert 10 == result, "Should cap default at max_workers" assert 10 == result, "Should cap default at max_workers"
def test_parse_extractor_limit_logs_warning_on_invalid(self) -> None: def test_parse_limit_warns(self) -> None:
logger = Mock() logger = Mock()
with patch.dict(os.environ, {"YTP_MAX_WORKERS_FOR_YOUTUBE": "invalid"}): with patch.dict(os.environ, {"YTP_MAX_WORKERS_FOR_YOUTUBE": "invalid"}):
parse_extractor_limit("youtube", default_limit=5, max_workers=10, logger=logger) parse_extractor_limit("youtube", default_limit=5, max_workers=10, logger=logger)
@ -195,7 +195,7 @@ class TestDataUtilities:
assert "custom_field" not in result["info_dict"], "Should exclude custom field" assert "custom_field" not in result["info_dict"], "Should exclude custom field"
assert "123" == result["info_dict"]["id"], "Should include id" assert "123" == result["info_dict"]["id"], "Should include id"
def test_create_debug_safe_dict_filters_none_and_functions(self) -> None: def test_debug_safe_filters_none(self) -> None:
data = { data = {
"status": "downloading", "status": "downloading",
"info_dict": {"id": "123", "none_value": None, "lambda_value": lambda: None, "title": "Video"}, "info_dict": {"id": "123", "none_value": None, "lambda_value": lambda: None, "title": "Video"},
@ -212,7 +212,7 @@ class TestDataUtilities:
class TestStateUtilities: class TestStateUtilities:
def test_is_download_stale_terminal_status_finished(self) -> None: def test_stale_finished(self) -> None:
result = is_download_stale( result = is_download_stale(
started_time=int(time.time()) - 500, current_status="finished", is_running=False, auto_start=True started_time=int(time.time()) - 500, current_status="finished", is_running=False, auto_start=True
) )
@ -224,19 +224,19 @@ class TestStateUtilities:
) )
assert result is False, "Error downloads are never stale" assert result is False, "Error downloads are never stale"
def test_is_download_stale_terminal_status_cancelled(self) -> None: def test_stale_cancelled(self) -> None:
result = is_download_stale( result = is_download_stale(
started_time=int(time.time()) - 500, current_status="cancelled", is_running=False, auto_start=True started_time=int(time.time()) - 500, current_status="cancelled", is_running=False, auto_start=True
) )
assert result is False, "Cancelled downloads are never stale" assert result is False, "Cancelled downloads are never stale"
def test_is_download_stale_terminal_status_downloading(self) -> None: def test_stale_downloading(self) -> None:
result = is_download_stale( result = is_download_stale(
started_time=int(time.time()) - 500, current_status="downloading", is_running=False, auto_start=True started_time=int(time.time()) - 500, current_status="downloading", is_running=False, auto_start=True
) )
assert result is False, "Downloading status is never stale" assert result is False, "Downloading status is never stale"
def test_is_download_stale_terminal_status_postprocessing(self) -> None: def test_stale_postprocessing(self) -> None:
result = is_download_stale( result = is_download_stale(
started_time=int(time.time()) - 500, current_status="postprocessing", is_running=False, auto_start=True started_time=int(time.time()) - 500, current_status="postprocessing", is_running=False, auto_start=True
) )
@ -303,7 +303,7 @@ class TestTaskExceptionHandling:
logger.error.assert_not_called() logger.error.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_handle_task_exception_ignores_successful(self) -> None: async def test_task_exception_success(self) -> None:
logger = Mock() logger = Mock()
async def successful_task(): async def successful_task():

View file

@ -57,7 +57,7 @@ class TestEncoder:
result = self.encoder.default(obj) result = self.encoder.default(obj)
assert result == {"name": "test", "value": 42} assert result == {"name": "test", "value": 42}
def test_object_without_dict_fallback_to_default(self): def test_object_default(self):
"""Test that objects without __dict__ fall back to default JSONEncoder.""" """Test that objects without __dict__ fall back to default JSONEncoder."""
# This should raise TypeError since complex is not JSON serializable # This should raise TypeError since complex is not JSON serializable
with pytest.raises(TypeError): with pytest.raises(TypeError):

View file

@ -460,7 +460,7 @@ class TestEvent:
assert f"handler3_{i}" in results assert f"handler3_{i}" in results
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mixed_sync_async_handlers_execution_order(self): async def test_mixed_handlers_order(self):
"""Test that mixed sync/async handlers execute properly without blocking.""" """Test that mixed sync/async handlers execute properly without blocking."""
bus = EventBus() bus = EventBus()
execution_times = [] execution_times = []
@ -511,7 +511,7 @@ class TestEvent:
class TestEventListener: class TestEventListener:
"""Test the EventListener class.""" """Test the EventListener class."""
def test_event_listener_creation_with_sync_callback(self): def test_listener_sync_init(self):
"""Test creating EventListener with synchronous callback.""" """Test creating EventListener with synchronous callback."""
def sync_callback(event, name, **kwargs): # noqa: ARG001 def sync_callback(event, name, **kwargs): # noqa: ARG001
@ -523,7 +523,7 @@ class TestEventListener:
assert listener.call_back == sync_callback assert listener.call_back == sync_callback
assert listener.is_coroutine is False assert listener.is_coroutine is False
def test_event_listener_creation_with_async_callback(self): def test_listener_async_init(self):
"""Test creating EventListener with asynchronous callback.""" """Test creating EventListener with asynchronous callback."""
async def async_callback(event, name, **kwargs): # noqa: ARG001 async def async_callback(event, name, **kwargs): # noqa: ARG001

View file

@ -1,12 +1,16 @@
import json import json
from pathlib import Path
from types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
import pytest import pytest
from app.library.encoder import Encoder
from app.library.DataStore import StoreType from app.library.DataStore import StoreType
from app.routes.api.history import items_delete from app.library.ItemDTO import ItemDTO
from app.library.encoder import Encoder
from app.routes.api.history import item_rename, items_delete
from app.tests.helpers import temporary_test_dir
class _FakeRequest: class _FakeRequest:
@ -14,13 +18,41 @@ class _FakeRequest:
self._payload = payload or {} self._payload = payload or {}
self.query: dict[str, str] = {} self.query: dict[str, str] = {}
self.match_info: dict[str, str] = {} self.match_info: dict[str, str] = {}
self.body_exists = bool(payload)
async def json(self) -> dict[str, Any]: async def json(self) -> dict[str, Any]:
return self._payload return self._payload
def _make_download(
*,
filename: str | None = None,
folder: str = "",
download_dir: str | None = None,
status: str = "finished",
) -> SimpleNamespace:
base_dir = download_dir or "/downloads"
original_post_init = ItemDTO.__post_init__
ItemDTO.__post_init__ = lambda self: None
try:
item = ItemDTO(
id="test-id",
title="Test Video",
url="https://example.com/watch?v=test-id",
folder=folder,
status=status,
filename=filename,
download_dir=base_dir,
)
finally:
ItemDTO.__post_init__ = original_post_init
return SimpleNamespace(info=item)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_items_delete_uses_bulk_history_status_clear() -> None: async def test_items_delete_status() -> None:
request = _FakeRequest(payload={"type": StoreType.HISTORY.value, "status": "finished,skip", "remove_file": False}) request = _FakeRequest(payload={"type": StoreType.HISTORY.value, "status": "finished,skip", "remove_file": False})
queue = Mock() queue = Mock()
queue.clear_by_status = AsyncMock(return_value={"deleted": 12}) queue.clear_by_status = AsyncMock(return_value={"deleted": 12})
@ -35,7 +67,7 @@ async def test_items_delete_uses_bulk_history_status_clear() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_items_delete_uses_bulk_history_id_clear() -> None: async def test_items_delete_ids() -> None:
request = _FakeRequest(payload={"type": StoreType.HISTORY.value, "ids": ["a", "b"], "remove_file": False}) request = _FakeRequest(payload={"type": StoreType.HISTORY.value, "ids": ["a", "b"], "remove_file": False})
queue = Mock() queue = Mock()
queue.clear_bulk = AsyncMock(return_value={"deleted": 2}) queue.clear_bulk = AsyncMock(return_value={"deleted": 2})
@ -47,3 +79,112 @@ async def test_items_delete_uses_bulk_history_id_clear() -> None:
queue.clear_bulk.assert_awaited_once_with(["a", "b"], remove_file=False) queue.clear_bulk.assert_awaited_once_with(["a", "b"], remove_file=False)
body = json.loads(response.body.decode("utf-8")) body = json.loads(response.body.decode("utf-8"))
assert body == {"items": {}, "deleted": 2} assert body == {"items": {}, "deleted": 2}
@pytest.mark.asyncio
async def test_item_rename_needs_name() -> None:
request = _FakeRequest(payload={})
request.match_info["id"] = "item-1"
queue = SimpleNamespace(
done=SimpleNamespace(get_by_id=AsyncMock(return_value=_make_download(filename="video.mp4")))
)
encoder = Encoder()
notify = Mock()
config = SimpleNamespace(download_path="/downloads")
response = await item_rename(request, queue, encoder, notify, config)
assert response.status == 400
body = json.loads(response.body.decode("utf-8"))
assert body == {"error": "no data provided."}
@pytest.mark.asyncio
async def test_item_rename_missing() -> None:
request = _FakeRequest(payload={"new_name": "renamed.mp4"})
request.match_info["id"] = "missing"
queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=None)))
encoder = Encoder()
notify = Mock()
config = SimpleNamespace(download_path="/downloads")
response = await item_rename(request, queue, encoder, notify, config)
assert response.status == 404
body = json.loads(response.body.decode("utf-8"))
assert body == {"error": "item 'missing' not found."}
@pytest.mark.asyncio
async def test_item_rename_needs_file() -> None:
request = _FakeRequest(payload={"new_name": "renamed.mp4"})
request.match_info["id"] = "item-1"
item = _make_download(filename="video.mp4")
queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=item)))
encoder = Encoder()
notify = Mock()
config = SimpleNamespace(download_path="/downloads")
item.info.get_file = lambda download_path=None: None
response = await item_rename(request, queue, encoder, notify, config)
assert response.status == 400
body = json.loads(response.body.decode("utf-8"))
assert body == {"error": "item has no downloaded file."}
@pytest.mark.asyncio
async def test_item_rename_sidecars() -> None:
with temporary_test_dir("history-rename") as temp_dir:
media = temp_dir / "video.mp4"
subtitle = temp_dir / "video.en.srt"
media.write_text("video")
subtitle.write_text("subtitle")
request = _FakeRequest(payload={"new_name": "renamed.mp4"})
request.match_info["id"] = "item-1"
item = _make_download(filename="video.mp4", download_dir=str(temp_dir))
item.info._id = "item-1"
queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=item), put=AsyncMock()))
encoder = Encoder()
notify = Mock()
config = SimpleNamespace(download_path=str(temp_dir))
response = await item_rename(request, queue, encoder, notify, config)
assert response.status == 200
body = json.loads(response.body.decode("utf-8"))
assert body["filename"] == "renamed.mp4"
assert item.info.filename == "renamed.mp4"
assert (temp_dir / "renamed.mp4").exists()
assert (temp_dir / "renamed.en.srt").exists()
assert not media.exists()
assert not subtitle.exists()
queue.done.put.assert_awaited_once_with(item, no_notify=True)
notify.emit.assert_called_once()
@pytest.mark.asyncio
async def test_item_rename_conflict() -> None:
with temporary_test_dir("history-rename-conflict") as temp_dir:
media = temp_dir / "video.mp4"
conflict = temp_dir / "renamed.mp4"
media.write_text("video")
conflict.write_text("existing")
request = _FakeRequest(payload={"new_name": "renamed.mp4"})
request.match_info["id"] = "item-1"
item = _make_download(filename="video.mp4", download_dir=str(temp_dir))
queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=item), put=AsyncMock()))
encoder = Encoder()
notify = Mock()
config = SimpleNamespace(download_path=str(temp_dir))
response = await item_rename(request, queue, encoder, notify, config)
assert response.status == 409
body = json.loads(response.body.decode("utf-8"))
assert body == {"error": "Destination 'renamed.mp4' already exists"}
queue.done.put.assert_not_awaited()
notify.emit.assert_not_called()

View file

@ -356,7 +356,7 @@ class TestAsyncClient:
assert isinstance(client._transport, CFAsyncTransport) assert isinstance(client._transport, CFAsyncTransport)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_async_client_cf_disabled(self): async def test_async_client_custom_cf_off(self):
"""Test creating async client with CF disabled.""" """Test creating async client with CF disabled."""
async with async_client(enable_cf=False) as client: async with async_client(enable_cf=False) as client:
assert isinstance(client, httpx.AsyncClient) assert isinstance(client, httpx.AsyncClient)
@ -381,7 +381,7 @@ class TestAsyncClient:
assert client._transport.base is custom assert client._transport.base is custom
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_async_client_custom_transport_cf_disabled(self): async def test_async_client_cf_disabled(self):
"""Test creating async client with custom transport and CF disabled.""" """Test creating async client with custom transport and CF disabled."""
custom = httpx.AsyncHTTPTransport() custom = httpx.AsyncHTTPTransport()
async with async_client(enable_cf=False, transport=custom) as client: async with async_client(enable_cf=False, transport=custom) as client:
@ -400,7 +400,7 @@ class TestSyncClient:
assert isinstance(client._transport, CFTransport) assert isinstance(client._transport, CFTransport)
client.close() client.close()
def test_sync_client_cf_disabled(self): def test_sync_client_custom_cf_off(self):
"""Test creating sync client with CF disabled.""" """Test creating sync client with CF disabled."""
client = sync_client(enable_cf=False) client = sync_client(enable_cf=False)
assert isinstance(client, httpx.Client) assert isinstance(client, httpx.Client)
@ -425,7 +425,7 @@ class TestSyncClient:
assert client._transport.base is custom assert client._transport.base is custom
client.close() client.close()
def test_sync_client_custom_transport_cf_disabled(self): def test_sync_client_cf_disabled(self):
"""Test creating sync client with custom transport and CF disabled.""" """Test creating sync client with custom transport and CF disabled."""
custom = httpx.HTTPTransport() custom = httpx.HTTPTransport()
client = sync_client(enable_cf=False, transport=custom) client = sync_client(enable_cf=False, transport=custom)

View file

@ -43,7 +43,7 @@ class TestItemFormatAndBasics:
assert item.cli == "--embed-metadata" assert item.cli == "--embed-metadata"
@patch("app.features.presets.service.Presets.get_instance") @patch("app.features.presets.service.Presets.get_instance")
def test_format_raises_for_missing_url_and_invalid_preset(self, mock_presets_get): def test_format_bad_input(self, mock_presets_get):
# Missing url # Missing url
with pytest.raises(ValueError, match="url param is required"): with pytest.raises(ValueError, match="url param is required"):
Item.format({}) Item.format({})
@ -113,7 +113,7 @@ class TestItemDTO:
@patch("app.library.ItemDTO.get_archive_id") @patch("app.library.ItemDTO.get_archive_id")
@patch("app.library.ItemDTO.YTDLPOpts") @patch("app.library.ItemDTO.YTDLPOpts")
@patch("app.library.ItemDTO.archive_read") @patch("app.library.ItemDTO.archive_read")
def test_post_init_does_not_infer_download_skipped_flag(self, mock_read, mock_opts, mock_get_id): def test_post_init_keeps_skipped(self, mock_read, mock_opts, mock_get_id):
mock_get_id.return_value = {"archive_id": "arch", "id": "arch", "ie_key": "YT"} mock_get_id.return_value = {"archive_id": "arch", "id": "arch", "ie_key": "YT"}
mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value
mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value
@ -128,7 +128,7 @@ class TestItemDTO:
assert dto.download_skipped is False assert dto.download_skipped is False
@patch("app.library.ItemDTO.archive_read") @patch("app.library.ItemDTO.archive_read")
def test_serialize_triggers_archive_status_when_finished(self, mock_read): def test_serialize_archives_finished(self, mock_read):
# Given a finished item with archive info # Given a finished item with archive info
dto = ItemDTO(id="vid", title="t", url="u", folder="f") dto = ItemDTO(id="vid", title="t", url="u", folder="f")
dto.archive_id = "arch" dto.archive_id = "arch"
@ -143,7 +143,7 @@ class TestItemDTO:
assert key not in data assert key not in data
@patch("app.library.ItemDTO.YTDLPOpts") @patch("app.library.ItemDTO.YTDLPOpts")
def test_serialize_does_not_recompute_download_skipped(self, mock_opts): def test_serialize_keeps_skipped(self, mock_opts):
mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value
mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value
mock_opts.get_instance.return_value.get_all.return_value = {} mock_opts.get_instance.return_value.get_all.return_value = {}
@ -299,7 +299,7 @@ class TestItemDTO:
assert result is expected_sidecar assert result is expected_sidecar
assert dto.sidecar is expected_sidecar assert dto.sidecar is expected_sidecar
def test_get_file_sidecar_returns_existing_when_no_file(self): def test_sidecar_no_file(self):
with patch.object(ItemDTO, "__post_init__", lambda _: None): with patch.object(ItemDTO, "__post_init__", lambda _: None):
dto = ItemDTO(id="sidecar-none", title="Title", url="u", folder="f") dto = ItemDTO(id="sidecar-none", title="Title", url="u", folder="f")
@ -317,7 +317,7 @@ class TestItemDTO:
assert result is existing assert result is existing
assert dto.sidecar is existing assert dto.sidecar is existing
def test_get_preset_returns_preset_instance(self): def test_get_preset_hit(self):
"""Test ItemDTO.get_preset returns the Preset instance.""" """Test ItemDTO.get_preset returns the Preset instance."""
from app.features.presets.schemas import Preset from app.features.presets.schemas import Preset
@ -335,7 +335,7 @@ class TestItemDTO:
assert result is mock_preset assert result is mock_preset
assert result.name == "test-preset" assert result.name == "test-preset"
def test_get_preset_uses_default_when_no_preset_set(self): def test_get_preset_default(self):
"""Test ItemDTO.get_preset uses 'default' when preset is empty.""" """Test ItemDTO.get_preset uses 'default' when preset is empty."""
from app.features.presets.schemas import Preset from app.features.presets.schemas import Preset
@ -352,7 +352,7 @@ class TestItemDTO:
mock_presets.return_value.get.assert_called_once_with("default") mock_presets.return_value.get.assert_called_once_with("default")
assert result is mock_preset assert result is mock_preset
def test_get_preset_returns_none_when_not_found(self): def test_get_preset_miss(self):
"""Test ItemDTO.get_preset returns None when preset not found.""" """Test ItemDTO.get_preset returns None when preset not found."""
with patch.object(ItemDTO, "__post_init__", lambda _: None): with patch.object(ItemDTO, "__post_init__", lambda _: None):
dto = ItemDTO(id="vid", title="t", url="u", folder="f", preset="nonexistent") dto = ItemDTO(id="vid", title="t", url="u", folder="f", preset="nonexistent")
@ -376,7 +376,7 @@ class TestItemAddExtras:
assert item.extras["key1"] == "value1" assert item.extras["key1"] == "value1"
def test_add_extras_when_extras_is_none(self): def test_add_extras_none(self):
"""Test adding extras when extras is None.""" """Test adding extras when extras is None."""
item = Item(url="https://example.com") item = Item(url="https://example.com")
item.extras = None item.extras = None

View file

@ -38,7 +38,7 @@ class TestPackages:
class TestPackageInstallerInit: class TestPackageInstallerInit:
def test_init_with_explicit_path_adds_to_sys_path(self, tmp_path: Path) -> None: def test_init_adds_sys_path(self, tmp_path: Path) -> None:
p = tmp_path / "site" p = tmp_path / "site"
p.mkdir() p.mkdir()
@ -58,7 +58,7 @@ class TestPackageInstallerInit:
assert installer.user_site.exists() is True assert installer.user_site.exists() is True
assert str(installer.user_site) in sys.path assert str(installer.user_site) in sys.path
def test_init_without_path_or_env(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_init_no_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("YTP_CONFIG_PATH", raising=False) monkeypatch.delenv("YTP_CONFIG_PATH", raising=False)
installer = PackageInstaller(pkg_path=None) installer = PackageInstaller(pkg_path=None)
# No user_site is set when no path or env provided # No user_site is set when no path or env provided
@ -206,7 +206,7 @@ class TestInstallCmd:
class TestActionAndCheck: class TestActionAndCheck:
@patch.object(PackageInstaller, "_install_pkg") @patch.object(PackageInstaller, "_install_pkg")
@patch.object(PackageInstaller, "_get_installed_version") @patch.object(PackageInstaller, "_get_installed_version")
def test_action_skips_when_same_pinned(self, mock_get_installed, mock_install, tmp_path: Path) -> None: def test_action_skip_pinned(self, mock_get_installed, mock_install, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path) inst = PackageInstaller(pkg_path=tmp_path)
mock_get_installed.return_value = "1.2.3" mock_get_installed.return_value = "1.2.3"
# compare_versions normal equality should hold # compare_versions normal equality should hold
@ -216,9 +216,7 @@ class TestActionAndCheck:
@patch.object(PackageInstaller, "_install_pkg") @patch.object(PackageInstaller, "_install_pkg")
@patch.object(PackageInstaller, "_get_installed_version") @patch.object(PackageInstaller, "_get_installed_version")
@patch.object(PackageInstaller, "_get_latest_version") @patch.object(PackageInstaller, "_get_latest_version")
def test_action_upgrade_skip_when_latest( def test_action_skip_latest(self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path) -> None:
self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path
) -> None:
inst = PackageInstaller(pkg_path=tmp_path) inst = PackageInstaller(pkg_path=tmp_path)
mock_get_installed.return_value = "2.0.0" mock_get_installed.return_value = "2.0.0"
mock_get_latest.return_value = "2.0.0" mock_get_latest.return_value = "2.0.0"
@ -228,9 +226,7 @@ class TestActionAndCheck:
@patch.object(PackageInstaller, "_install_pkg") @patch.object(PackageInstaller, "_install_pkg")
@patch.object(PackageInstaller, "_get_installed_version") @patch.object(PackageInstaller, "_get_installed_version")
@patch.object(PackageInstaller, "_get_latest_version") @patch.object(PackageInstaller, "_get_latest_version")
def test_action_upgrade_runs_when_newer_available( def test_action_upgrade_newer(self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path) -> None:
self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path
) -> None:
inst = PackageInstaller(pkg_path=tmp_path) inst = PackageInstaller(pkg_path=tmp_path)
mock_get_installed.return_value = "1.0.0" mock_get_installed.return_value = "1.0.0"
mock_get_latest.return_value = "1.1.0" mock_get_latest.return_value = "1.1.0"
@ -239,13 +235,13 @@ class TestActionAndCheck:
@patch.object(PackageInstaller, "_install_pkg") @patch.object(PackageInstaller, "_install_pkg")
@patch.object(PackageInstaller, "_get_installed_version") @patch.object(PackageInstaller, "_get_installed_version")
def test_action_install_when_not_installed(self, mock_get_installed, mock_install, tmp_path: Path) -> None: def test_action_install_missing(self, mock_get_installed, mock_install, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path) inst = PackageInstaller(pkg_path=tmp_path)
mock_get_installed.return_value = None mock_get_installed.return_value = None
inst.action("pkg") inst.action("pkg")
mock_install.assert_called_once_with("pkg", version=None) mock_install.assert_called_once_with("pkg", version=None)
def test_check_with_no_packages_or_no_user_site(self, tmp_path: Path) -> None: def test_check_no_packages_or_path(self, tmp_path: Path) -> None:
# No packages # No packages
inst = PackageInstaller(pkg_path=tmp_path) inst = PackageInstaller(pkg_path=tmp_path)
pkgs = Packages(env=None, file=None, upgrade=False) pkgs = Packages(env=None, file=None, upgrade=False)
@ -257,7 +253,7 @@ class TestActionAndCheck:
inst2.check(pkgs2) inst2.check(pkgs2)
@patch.object(PackageInstaller, "action") @patch.object(PackageInstaller, "action")
def test_check_calls_action_and_handles_errors(self, mock_action, tmp_path: Path) -> None: def test_check_runs_all(self, mock_action, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path) inst = PackageInstaller(pkg_path=tmp_path)
pkgs = Packages(env="foo bar", file=None, upgrade=True) pkgs = Packages(env="foo bar", file=None, upgrade=True)
@ -272,3 +268,5 @@ class TestActionAndCheck:
# Should not raise # Should not raise
inst.check(pkgs) inst.check(pkgs)
assert mock_action.call_count == 2 assert mock_action.call_count == 2
mock_action.assert_any_call("foo", upgrade=True)
mock_action.assert_any_call("bar", upgrade=True)

View file

@ -90,7 +90,7 @@ class TestAddRoute:
assert r.method == "POST" assert r.method == "POST"
assert r.path == "/api/create/" assert r.path == "/api/create/"
def test_add_route_socket_without_alias(self) -> None: def test_add_route_socket_no_alias(self) -> None:
async def s(): async def s():
return "s" return "s"
@ -109,7 +109,7 @@ class TestAddRoute:
class TestGetters: class TestGetters:
def test_get_routes_returns_copy_like_mapping(self) -> None: def test_get_routes_copy(self) -> None:
async def h(): async def h():
return "x" return "x"

View file

@ -184,7 +184,7 @@ class TestScheduler:
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("app.library.Scheduler.Cron", new=DummyCron) @patch("app.library.Scheduler.Cron", new=DummyCron)
async def test_attach_registers_shutdown_and_handles_schedule_add_event(self) -> None: async def test_attach_registers_events(self) -> None:
from aiohttp import web from aiohttp import web
app = web.Application() app = web.Application()
@ -224,7 +224,7 @@ class TestScheduler:
assert kwargs["id"] == "evt-job" assert kwargs["id"] == "evt-job"
@patch("app.library.Scheduler.Cron") @patch("app.library.Scheduler.Cron")
def test_add_executes_function_when_cron_runs(self, cron_patch) -> None: def test_add_executes_on_start(self, cron_patch) -> None:
# Cron stub that auto-runs the function on creation when start=True # Cron stub that auto-runs the function on creation when start=True
class AutoRunCron(DummyCron): class AutoRunCron(DummyCron):
def __init__( def __init__(

View file

@ -75,7 +75,7 @@ class TestServices:
assert services.get("service3") == "value3" assert services.get("service3") == "value3"
assert len(services.get_all()) == 3 assert len(services.get_all()) == 3
def test_get_all_returns_copy(self): def test_get_all_copy(self):
"""Test that get_all returns a copy, not the original dict.""" """Test that get_all returns a copy, not the original dict."""
services = Services() services = Services()
services.add("test", "value") services.add("test", "value")

View file

@ -32,7 +32,7 @@ def make_item(idx: int, *, status: str = "finished", cli: str = "", download_ski
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sessionmaker_returns_valid_sessionmaker() -> None: async def test_sessionmaker_ready() -> None:
"""Test that sessionmaker() returns a working async_sessionmaker.""" """Test that sessionmaker() returns a working async_sessionmaker."""
SqliteStore._reset_singleton() SqliteStore._reset_singleton()
store = SqliteStore.get_instance(db_path=make_in_memory_db_path("sessionmaker")) store = SqliteStore.get_instance(db_path=make_in_memory_db_path("sessionmaker"))
@ -120,7 +120,7 @@ async def test_enqueue_upsert_and_fetch_saved():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_enqueue_upsert_persists_download_skipped_flag(): async def test_enqueue_upsert_skipped():
store = await make_store() store = await make_store()
item = make_item(2, download_skipped=True) item = make_item(2, download_skipped=True)
@ -237,7 +237,7 @@ async def test_enqueue_delete_removes_row():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_enqueue_bulk_delete_returns_count_and_bulk_path(): async def test_enqueue_bulk_delete():
store = await make_store() store = await make_store()
items = [make_item(i) for i in range(3)] items = [make_item(i) for i in range(3)]
for itm in items: for itm in items:
@ -258,7 +258,7 @@ async def test_enqueue_bulk_delete_returns_count_and_bulk_path():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_many_by_ids_and_status_and_bulk_delete_by_status(): async def test_ids_status_bulk_delete():
store = await make_store() store = await make_store()
finished = [make_item(i, status="finished") for i in range(2)] finished = [make_item(i, status="finished") for i in range(2)]
pending = [make_item(i + 10, status="pending") for i in range(2)] pending = [make_item(i + 10, status="pending") for i in range(2)]
@ -296,7 +296,7 @@ async def test_get_many_by_ids_and_status_and_bulk_delete_by_status():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_paginate_out_of_range_returns_last_page(): async def test_paginate_last_page():
store = await make_store() store = await make_store()
for i in range(7): for i in range(7):
await store.enqueue_upsert("history", make_item(i)) await store.enqueue_upsert("history", make_item(i))
@ -311,7 +311,7 @@ async def test_paginate_out_of_range_returns_last_page():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_item_returns_none_without_kwargs(): async def test_get_item_no_filters():
store = await make_store() store = await make_store()
await store.enqueue_upsert("queue", make_item(1)) await store.enqueue_upsert("queue", make_item(1))
await store.flush() await store.flush()
@ -387,7 +387,7 @@ async def test_exists_and_get_by_key_and_url():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_exists_and_get_raise_without_key_or_url(): async def test_exists_and_get_require_lookup():
store = await make_store() store = await make_store()
with pytest.raises(KeyError): with pytest.raises(KeyError):
await store.exists("queue") await store.exists("queue")

View file

@ -34,7 +34,7 @@ def _configure_static_root(static_root: Path) -> None:
class TestServeStaticFile: class TestServeStaticFile:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_nested_document_route_falls_back_to_spa_shell(self, tmp_path: Path) -> None: async def test_nested_doc_falls_back(self, tmp_path: Path) -> None:
config = Config.get_instance() config = Config.get_instance()
index_file = tmp_path / "index.html" index_file = tmp_path / "index.html"
index_file.write_text("<html>root shell</html>", encoding="utf-8") index_file.write_text("<html>root shell</html>", encoding="utf-8")
@ -46,7 +46,7 @@ class TestServeStaticFile:
assert response._path == index_file assert response._path == index_file
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_generated_nested_index_is_not_preferred_over_root_shell(self, tmp_path: Path) -> None: async def test_nested_index_uses_root(self, tmp_path: Path) -> None:
config = Config.get_instance() config = Config.get_instance()
root_index = tmp_path / "index.html" root_index = tmp_path / "index.html"
nested_index = tmp_path / "docs" / "readme" / "index.html" nested_index = tmp_path / "docs" / "readme" / "index.html"
@ -82,7 +82,7 @@ class TestServeStaticFile:
assert b"missing.js" in body assert b"missing.js" in body
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_missing_asset_with_unknown_suffix_does_not_fall_back(self, tmp_path: Path) -> None: async def test_missing_unknown_no_fallback(self, tmp_path: Path) -> None:
config = Config.get_instance() config = Config.get_instance()
(tmp_path / "index.html").write_text("<html>root shell</html>", encoding="utf-8") (tmp_path / "index.html").write_text("<html>root shell</html>", encoding="utf-8")
assets_dir = tmp_path / "assets" assets_dir = tmp_path / "assets"
@ -103,7 +103,7 @@ class TestServeStaticFile:
assert b"missing.abcd123" in body assert b"missing.abcd123" in body
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_symlink_outside_static_root_does_not_resolve(self, tmp_path: Path) -> None: async def test_symlink_outside_rejected(self, tmp_path: Path) -> None:
config = Config.get_instance() config = Config.get_instance()
(tmp_path / "index.html").write_text("<html>root shell</html>", encoding="utf-8") (tmp_path / "index.html").write_text("<html>root shell</html>", encoding="utf-8")
outside_dir = tmp_path.parent / "outside-static-root" outside_dir = tmp_path.parent / "outside-static-root"
@ -144,7 +144,7 @@ class TestServeStaticFile:
assert b"/api/missing" in body assert b"/api/missing" in body
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dotted_browser_path_returns_not_found(self, tmp_path: Path) -> None: async def test_dotted_browser_path_404(self, tmp_path: Path) -> None:
config = Config.get_instance() config = Config.get_instance()
(tmp_path / "index.html").write_text("<html>root shell</html>", encoding="utf-8") (tmp_path / "index.html").write_text("<html>root shell</html>", encoding="utf-8")
_configure_static_root(tmp_path) _configure_static_root(tmp_path)
@ -157,7 +157,7 @@ class TestServeStaticFile:
assert response.status == web.HTTPNotFound.status_code assert response.status == web.HTTPNotFound.status_code
assert b"/browser/foo/bar.txt" in body assert b"/browser/foo/bar.txt" in body
def test_registers_only_root_and_catch_all_routes(self, tmp_path: Path) -> None: def test_registers_root_routes(self, tmp_path: Path) -> None:
config = Config.get_instance() config = Config.get_instance()
static_root = tmp_path / "ui-exported" static_root = tmp_path / "ui-exported"
static_root.mkdir() static_root.mkdir()

View file

@ -121,7 +121,7 @@ class TestSystemLimitsEndpoint:
Config._reset_singleton() Config._reset_singleton()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_system_limits_returns_user_facing_limits(self): async def test_system_limits_public(self):
config = Config.get_instance() config = Config.get_instance()
config.max_workers = 10 config.max_workers = 10
config.max_workers_per_extractor = 3 config.max_workers_per_extractor = 3
@ -188,7 +188,7 @@ class TestSystemLimitsEndpoint:
"available": 3, "available": 3,
} }
def test_config_reads_prevent_live_premiere_boolean_env(self): def test_config_reads_live_premiere(self):
with patch.dict("os.environ", {"YTP_PREVENT_LIVE_PREMIERE": "false"}, clear=False): with patch.dict("os.environ", {"YTP_PREVENT_LIVE_PREMIERE": "false"}, clear=False):
Config._reset_singleton() Config._reset_singleton()
config = Config.get_instance() config = Config.get_instance()

View file

@ -1,10 +1,13 @@
import asyncio import asyncio
import json import json
from pathlib import Path from pathlib import Path
import time
from typing import Any, cast from typing import Any, cast
import pytest import pytest
from aiohttp import web
from app.library.Scheduler import Scheduler
from app.library.Services import Services from app.library.Services import Services
from app.library.TerminalSessionManager import TerminalSessionManager from app.library.TerminalSessionManager import TerminalSessionManager
from app.library.config import Config from app.library.config import Config
@ -13,6 +16,7 @@ from app.routes.api.system import (
cancel_terminal_session, cancel_terminal_session,
create_terminal_session, create_terminal_session,
get_active_terminal_session, get_active_terminal_session,
list_terminal_sessions,
get_terminal_session, get_terminal_session,
stream_terminal_session, stream_terminal_session,
) )
@ -133,6 +137,7 @@ class _TerminableProc:
@pytest.fixture @pytest.fixture
def terminal_setup(tmp_path: Path) -> tuple[Config, TerminalSessionManager, Encoder]: def terminal_setup(tmp_path: Path) -> tuple[Config, TerminalSessionManager, Encoder]:
Scheduler._reset_singleton()
Services._reset_singleton() Services._reset_singleton()
Config._reset_singleton() Config._reset_singleton()
TerminalSessionManager._reset_singleton() TerminalSessionManager._reset_singleton()
@ -150,8 +155,18 @@ def terminal_setup(tmp_path: Path) -> tuple[Config, TerminalSessionManager, Enco
class TestTerminalSessionRoutes: class TestTerminalSessionRoutes:
def test_attach_registers_cleanup_job(self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder]) -> None:
_config, manager, _encoder = terminal_setup
app = web.Application()
manager.attach(app)
scheduler = Scheduler.get_instance()
assert scheduler.has(f"{TerminalSessionManager.__name__}.{TerminalSessionManager.cleanup.__name__}")
assert manager._cleanup_job_id == f"{TerminalSessionManager.__name__}.{TerminalSessionManager.cleanup.__name__}"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_start_returns_session_metadata_and_active_conflict( async def test_start_conflict_meta(
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
config, manager, encoder = terminal_setup config, manager, encoder = terminal_setup
@ -191,7 +206,7 @@ class TestTerminalSessionRoutes:
await task await task
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stream_endpoint_replays_persisted_events_and_resume_cursor( async def test_stream_replays_resume(
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
config, manager, encoder = terminal_setup config, manager, encoder = terminal_setup
@ -241,11 +256,11 @@ class TestTerminalSessionRoutes:
assert "id: 3" in resumed_payload assert "id: 3" in resumed_payload
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_completed_session_expires_after_drain_window( async def test_completed_expires(
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
config, manager, encoder = terminal_setup config, manager, encoder = terminal_setup
manager._drain_ttl = 0.05 manager._completed_retention = 0.05
await manager.initialize() await manager.initialize()
async def fake_create_subprocess_exec(*_args, **_kwargs): async def fake_create_subprocess_exec(*_args, **_kwargs):
@ -271,10 +286,130 @@ class TestTerminalSessionRoutes:
expired = await manager.get_session(session_id) expired = await manager.get_session(session_id)
assert expired is None assert expired is None
assert (manager.root_path / session_id).exists()
await manager.cleanup()
assert not (manager.root_path / session_id).exists() assert not (manager.root_path / session_id).exists()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_shutdown_interrupts_active_session_and_clears_active_marker( async def test_list_keeps_recent_done(
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
) -> None:
config, manager, encoder = terminal_setup
manager._completed_retention = 0.2
manager._drain_ttl = 0.01
await manager.initialize()
async def fake_create_subprocess_exec(*_args, **_kwargs):
return _CompletedProc([b"done\n"])
monkeypatch.setattr(
"app.library.TerminalSessionManager.asyncio.create_subprocess_exec", fake_create_subprocess_exec
)
monkeypatch.setattr(manager, "_open_pty", lambda: None)
start_request = _FakeRequest(payload={"command": "--help"}, can_read_body=True)
start_response = await create_terminal_session(start_request, config, encoder, manager)
session_id = json.loads(start_response.body.decode("utf-8"))["session_id"]
assert manager._active is not None
await manager._active.task
await asyncio.sleep(0.03)
listed_response = await list_terminal_sessions(config, encoder, manager)
listed_payload = json.loads(listed_response.body.decode("utf-8"))
assert 200 == listed_response.status
assert 1 == len(listed_payload["items"])
assert session_id == listed_payload["items"][0]["session_id"]
assert "completed" == listed_payload["items"][0]["status"]
assert listed_payload["items"][0]["available_until"] is not None
persisted = await manager.get_session(session_id)
assert persisted is not None
await asyncio.sleep(0.19)
expired = await manager.get_session(session_id)
assert expired is None
assert (manager.root_path / session_id).exists()
await manager.cleanup()
assert not (manager.root_path / session_id).exists()
@pytest.mark.asyncio
async def test_list_orders_newest(self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder]) -> None:
config, manager, encoder = terminal_setup
await manager.initialize()
first_id = "first"
second_id = "second"
expired_id = "expired"
for session_id in [first_id, second_id, expired_id]:
session_dir = manager.root_path / session_id
session_dir.mkdir(parents=True, exist_ok=True)
manager._transcript_path(session_id).touch(exist_ok=True)
manager._write_json(
manager._metadata_path(first_id),
{
"session_id": first_id,
"command": "--first",
"status": "completed",
"created_at": 10.0,
"started_at": 11.0,
"finished_at": 20.0,
"expires_at": time.time() + 60,
"exit_code": 0,
"last_sequence": 2,
},
)
manager._write_json(
manager._metadata_path(second_id),
{
"session_id": second_id,
"command": "--second",
"status": "running",
"created_at": 30.0,
"started_at": 31.0,
"finished_at": None,
"expires_at": None,
"exit_code": None,
"last_sequence": 4,
},
)
manager._write_json(
manager._metadata_path(expired_id),
{
"session_id": expired_id,
"command": "--expired",
"status": "completed",
"created_at": 1.0,
"started_at": 2.0,
"finished_at": 3.0,
"expires_at": time.time() - 1,
"exit_code": 1,
"last_sequence": 1,
},
)
listed_response = await list_terminal_sessions(config, encoder, manager)
listed_payload = json.loads(listed_response.body.decode("utf-8"))
assert 200 == listed_response.status
assert [second_id, first_id] == [item["session_id"] for item in listed_payload["items"]]
assert (manager.root_path / expired_id).exists()
await manager.cleanup()
assert not (manager.root_path / expired_id).exists()
@pytest.mark.asyncio
async def test_shutdown_clears_active(
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
config, manager, encoder = terminal_setup config, manager, encoder = terminal_setup
@ -314,7 +449,7 @@ class TestTerminalSessionRoutes:
assert -15 == transcript[-1]["data"]["exitcode"] assert -15 == transcript[-1]["data"]["exitcode"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stream_endpoint_emits_keepalive_for_silent_active_session( async def test_stream_keepalive(
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
config, manager, encoder = terminal_setup config, manager, encoder = terminal_setup
@ -352,7 +487,7 @@ class TestTerminalSessionRoutes:
assert 'data: {"exitcode": 0}' in stream_payload assert 'data: {"exitcode": 0}' in stream_payload
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cancel_endpoint_interrupts_active_session( async def test_cancel_active(
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
config, manager, encoder = terminal_setup config, manager, encoder = terminal_setup
@ -398,7 +533,7 @@ class TestTerminalSessionRoutes:
assert -15 == transcript[-1]["data"]["exitcode"] assert -15 == transcript[-1]["data"]["exitcode"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cancel_endpoint_returns_conflict_for_inactive_session( async def test_cancel_inactive_conflict(
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
config, manager, encoder = terminal_setup config, manager, encoder = terminal_setup
@ -427,9 +562,7 @@ class TestTerminalSessionRoutes:
assert b"not active" in cancel_response.body.lower() assert b"not active" in cancel_response.body.lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cancel_endpoint_returns_not_found_for_unknown_session( async def test_cancel_unknown(self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder]) -> None:
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder]
) -> None:
config, manager, encoder = terminal_setup config, manager, encoder = terminal_setup
await manager.initialize() await manager.initialize()

View file

@ -10,14 +10,14 @@ from app.tests.helpers import (
) )
def test_make_test_disk_path_uses_test_run_root() -> None: def test_disk_path_root() -> None:
path = make_test_disk_path("artifacts", "example.txt") path = make_test_disk_path("artifacts", "example.txt")
assert path.parent.exists() assert path.parent.exists()
assert path.is_relative_to(get_test_run_root()) assert path.is_relative_to(get_test_run_root())
def test_make_test_temp_dir_creates_directory() -> None: def test_temp_dir_created() -> None:
path = make_test_temp_dir("helpers") path = make_test_temp_dir("helpers")
assert path.exists() assert path.exists()
@ -25,7 +25,7 @@ def test_make_test_temp_dir_creates_directory() -> None:
assert path.is_relative_to(get_test_run_root()) assert path.is_relative_to(get_test_run_root())
def test_tmp_path_runs_under_custom_temp_root(tmp_path: Path) -> None: def test_tmp_path_root(tmp_path: Path) -> None:
expected_root = get_test_run_root() / "pytest" expected_root = get_test_run_root() / "pytest"
assert tmp_path.is_relative_to(expected_root) assert tmp_path.is_relative_to(expected_root)

View file

@ -21,7 +21,7 @@ class TestUpdateChecker:
EventBus._reset_singleton() EventBus._reset_singleton()
Cache._reset_singleton() Cache._reset_singleton()
def test_attach_schedules_check_when_enabled(self): def test_attach_enabled(self):
"""Test that attach schedules update check when config.check_for_updates is True.""" """Test that attach schedules update check when config.check_for_updates is True."""
import asyncio import asyncio
@ -50,7 +50,7 @@ class TestUpdateChecker:
scheduler.remove(checker._job_id) scheduler.remove(checker._job_id)
loop.close() loop.close()
def test_attach_skips_scheduling_when_disabled(self): def test_attach_disabled(self):
"""Test that attach skips scheduling when config.check_for_updates is False.""" """Test that attach skips scheduling when config.check_for_updates is False."""
from app.library.config import Config from app.library.config import Config
from app.library.UpdateChecker import UpdateChecker from app.library.UpdateChecker import UpdateChecker
@ -111,7 +111,7 @@ class TestUpdateChecker:
loop.close() loop.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_check_for_updates_skips_when_disabled(self): async def test_check_for_updates_disabled(self):
"""Test that check_for_updates skips when config.check_for_updates is False.""" """Test that check_for_updates skips when config.check_for_updates is False."""
from app.library.config import Config from app.library.config import Config
from app.library.UpdateChecker import UpdateChecker from app.library.UpdateChecker import UpdateChecker
@ -255,7 +255,7 @@ class TestUpdateChecker:
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("app.library.UpdateChecker.get_async_client") @patch("app.library.UpdateChecker.get_async_client")
async def test_check_ytdlp_version_handles_http_error(self, mock_client): async def test_check_ytdlp_version_http_error(self, mock_client):
"""Test that yt-dlp check handles HTTP errors gracefully.""" """Test that yt-dlp check handles HTTP errors gracefully."""
from app.library.config import Config from app.library.config import Config
from app.library.UpdateChecker import UpdateChecker from app.library.UpdateChecker import UpdateChecker

View file

@ -1,5 +1,6 @@
import asyncio import asyncio
import copy import copy
import importlib
import re import re
import shutil import shutil
import uuid import uuid
@ -30,7 +31,6 @@ from app.library.Utils import (
is_private_address, is_private_address,
list_folders, list_folders,
load_cookies, load_cookies,
load_modules,
merge_dict, merge_dict,
move_file, move_file,
parse_tags, parse_tags,
@ -48,7 +48,7 @@ from app.tests.helpers import make_test_temp_dir, temporary_test_dir
class TestTimedLruCache: class TestTimedLruCache:
"""Test the timed_lru_cache decorator.""" """Test the timed_lru_cache decorator."""
def test_timed_lru_cache_basic_functionality(self): def test_basic(self):
"""Test that timed_lru_cache caches function results.""" """Test that timed_lru_cache caches function results."""
call_count = 0 call_count = 0
@ -332,39 +332,39 @@ class TestCalcDownloadPath:
result = calc_download_path(str(self.base_path), None, create_path=False) result = calc_download_path(str(self.base_path), None, create_path=False)
assert result == str(self.base_path), "Should return base path when folder is None" assert result == str(self.base_path), "Should return base path when folder is None"
def test_calc_download_path_removes_leading_slash(self): def test_calc_path_strips_leading_slash(self):
"""Test that leading slash is removed from folder.""" """Test that leading slash is removed from folder."""
folder = "/test_folder" folder = "/test_folder"
result = calc_download_path(str(self.base_path), folder, create_path=False) result = calc_download_path(str(self.base_path), folder, create_path=False)
expected = str(self.base_path / "test_folder") expected = str(self.base_path / "test_folder")
assert result == expected, "Should remove leading slash from folder" assert result == expected, "Should remove leading slash from folder"
def test_calc_download_path_path_traversal_dotdot(self): def test_calc_path_dotdot(self):
"""Test path traversal prevention with .. sequences.""" """Test path traversal prevention with .. sequences."""
folder = "../outside" folder = "../outside"
with pytest.raises(Exception, match="must resolve inside the base download folder"): with pytest.raises(Exception, match="must resolve inside the base download folder"):
calc_download_path(str(self.base_path), folder, create_path=False) calc_download_path(str(self.base_path), folder, create_path=False)
def test_calc_download_path_path_traversal_nested_dotdot(self): def test_calc_path_nested_dotdot(self):
"""Test path traversal prevention with nested .. sequences.""" """Test path traversal prevention with nested .. sequences."""
folder = "safe/../../outside" folder = "safe/../../outside"
with pytest.raises(Exception, match="must resolve inside the base download folder"): with pytest.raises(Exception, match="must resolve inside the base download folder"):
calc_download_path(str(self.base_path), folder, create_path=False) calc_download_path(str(self.base_path), folder, create_path=False)
def test_calc_download_path_path_traversal_multiple_dotdot(self): def test_calc_path_multi_dotdot(self):
"""Test path traversal prevention with multiple .. sequences.""" """Test path traversal prevention with multiple .. sequences."""
folder = "../../../etc/passwd" folder = "../../../etc/passwd"
with pytest.raises(Exception, match="must resolve inside the base download folder"): with pytest.raises(Exception, match="must resolve inside the base download folder"):
calc_download_path(str(self.base_path), folder, create_path=False) calc_download_path(str(self.base_path), folder, create_path=False)
def test_calc_download_path_path_traversal_absolute_path(self): def test_calc_path_absolute(self):
"""Test that absolute paths are made safe by removing leading slash.""" """Test that absolute paths are made safe by removing leading slash."""
folder = "/etc/passwd" folder = "/etc/passwd"
result = calc_download_path(str(self.base_path), folder, create_path=False) result = calc_download_path(str(self.base_path), folder, create_path=False)
expected = str(self.base_path / "etc/passwd") expected = str(self.base_path / "etc/passwd")
assert result == expected, "Should remove leading slash and treat as relative path" assert result == expected, "Should remove leading slash and treat as relative path"
def test_calc_download_path_path_traversal_absolute_with_dotdot(self): def test_calc_path_absolute_dotdot(self):
"""Test path traversal with absolute path containing .. sequences.""" """Test path traversal with absolute path containing .. sequences."""
folder = "/../../../etc/passwd" folder = "/../../../etc/passwd"
with pytest.raises(Exception, match="must resolve inside the base download folder"): with pytest.raises(Exception, match="must resolve inside the base download folder"):
@ -376,7 +376,7 @@ class TestCalcDownloadPath:
with pytest.raises(Exception, match="must resolve inside the base download folder"): with pytest.raises(Exception, match="must resolve inside the base download folder"):
calc_download_path(str(self.base_path), folder, create_path=False) calc_download_path(str(self.base_path), folder, create_path=False)
def test_calc_download_path_path_traversal_url_encoded(self): def test_calc_path_url_encoded(self):
"""Test path traversal prevention with URL encoded sequences.""" """Test path traversal prevention with URL encoded sequences."""
folder = "safe%2F..%2F..%2Funsafe" # safe/../unsafe encoded folder = "safe%2F..%2F..%2Funsafe" # safe/../unsafe encoded
# This should be handled at a higher level, but let's test it anyway # This should be handled at a higher level, but let's test it anyway
@ -449,7 +449,7 @@ class TestCalcDownloadPath:
with pytest.raises(Exception, match="must resolve inside the base download folder"): with pytest.raises(Exception, match="must resolve inside the base download folder"):
calc_download_path(str(self.base_path), folder, create_path=False) calc_download_path(str(self.base_path), folder, create_path=False)
def test_calc_download_path_carriage_return_attack(self): def test_calc_path_carriage_return(self):
"""Test that carriage returns in path traversal are prevented.""" """Test that carriage returns in path traversal are prevented."""
folder = "folder\r../../../etc/passwd" folder = "folder\r../../../etc/passwd"
with pytest.raises(Exception, match="must resolve inside the base download folder"): with pytest.raises(Exception, match="must resolve inside the base download folder"):
@ -479,7 +479,7 @@ class TestCalcDownloadPath:
with pytest.raises(Exception, match="must resolve inside the base download folder"): with pytest.raises(Exception, match="must resolve inside the base download folder"):
calc_download_path(str(self.base_path), folder, create_path=False) calc_download_path(str(self.base_path), folder, create_path=False)
def test_calc_download_path_url_encoded_traversal_safe(self): def test_calc_path_url_encoded_safe(self):
"""Test that URL encoded path traversal is treated as literal (safe).""" """Test that URL encoded path traversal is treated as literal (safe)."""
folder = "folder..%2F..%2F..%2Fetc%2Fpasswd" # ..%2F = ../ encoded folder = "folder..%2F..%2F..%2Fetc%2Fpasswd" # ..%2F = ../ encoded
result = calc_download_path(str(self.base_path), folder, create_path=False) result = calc_download_path(str(self.base_path), folder, create_path=False)
@ -494,7 +494,7 @@ class TestCalcDownloadPath:
expected = str(self.base_path / folder) expected = str(self.base_path / folder)
assert result == expected, "Backslashes should be treated as literal characters on Unix" assert result == expected, "Backslashes should be treated as literal characters on Unix"
def test_calc_download_path_mixed_separators_attack(self): def test_calc_path_mixed_separators(self):
"""Test path traversal with mixed separators.""" """Test path traversal with mixed separators."""
folder = "folder/../../../etc/passwd" folder = "folder/../../../etc/passwd"
with pytest.raises(Exception, match="must resolve inside the base download folder"): with pytest.raises(Exception, match="must resolve inside the base download folder"):
@ -520,7 +520,7 @@ class TestCalcDownloadPath:
# Clean up # Clean up
shutil.rmtree(sibling_dir, ignore_errors=True) shutil.rmtree(sibling_dir, ignore_errors=True)
def test_calc_download_path_symlink_attack_outside(self): def test_calc_path_symlink_outside(self):
"""Test that symlinks pointing outside base directory are blocked.""" """Test that symlinks pointing outside base directory are blocked."""
# Create a symlink pointing outside the base directory # Create a symlink pointing outside the base directory
outside_dir = Path(self.temp_dir).parent / "outside_target" outside_dir = Path(self.temp_dir).parent / "outside_target"
@ -539,7 +539,7 @@ class TestCalcDownloadPath:
symlink_path.unlink() symlink_path.unlink()
shutil.rmtree(outside_dir, ignore_errors=True) shutil.rmtree(outside_dir, ignore_errors=True)
def test_calc_download_path_symlink_attack_with_traversal(self): def test_calc_path_symlink_traversal(self):
"""Test symlink combined with path traversal.""" """Test symlink combined with path traversal."""
# Create a directory outside base # Create a directory outside base
outside_dir = Path(self.temp_dir).parent / "target_dir" outside_dir = Path(self.temp_dir).parent / "target_dir"
@ -563,7 +563,7 @@ class TestCalcDownloadPath:
shutil.rmtree(safe_dir, ignore_errors=True) shutil.rmtree(safe_dir, ignore_errors=True)
shutil.rmtree(outside_dir, ignore_errors=True) shutil.rmtree(outside_dir, ignore_errors=True)
def test_calc_download_path_symlink_safe_internal(self): def test_calc_path_symlink_internal(self):
"""Test that symlinks pointing inside base directory are allowed.""" """Test that symlinks pointing inside base directory are allowed."""
# Create target directory inside base # Create target directory inside base
target_dir = self.base_path / "target" target_dir = self.base_path / "target"
@ -602,7 +602,7 @@ class TestCalcDownloadPath:
expected = str(self.base_path / deep_path) expected = str(self.base_path / deep_path)
assert result == expected, "Should handle deeply nested paths" assert result == expected, "Should handle deeply nested paths"
def test_calc_download_path_directory_with_spaces(self): def test_calc_path_spaces(self):
"""Test paths with multiple spaces and special spacing.""" """Test paths with multiple spaces and special spacing."""
folder = "folder with multiple spaces" folder = "folder with multiple spaces"
result = calc_download_path(str(self.base_path), folder, create_path=True) result = calc_download_path(str(self.base_path), folder, create_path=True)
@ -697,7 +697,7 @@ class TestMergeDict:
assert "__builtins__" not in result, "__builtins__ should be filtered out" assert "__builtins__" not in result, "__builtins__ should be filtered out"
assert result["normal"] == "value", "Normal values should be preserved" assert result["normal"] == "value", "Normal values should be preserved"
def test_merge_dict_blocks_multiple_dunder_pollution(self): def test_merge_dict_blocks_dunders(self):
"""Test that multiple dangerous dunder attributes are blocked.""" """Test that multiple dangerous dunder attributes are blocked."""
source = { source = {
"__class__": "malicious", "__class__": "malicious",
@ -863,7 +863,7 @@ class TestMergeDict:
assert len(result["data"]) == 5000 assert len(result["data"]) == 5000
assert result["data"] == large_list assert result["data"] == large_list
def test_merge_dict_list_merging_with_size_limits(self): def test_merge_dict_list_limits(self):
"""Test list merging respects size limits.""" """Test list merging respects size limits."""
source = {"items": list(range(3000))} source = {"items": list(range(3000))}
destination = {"items": list(range(2000, 5000))} # 3000 items destination = {"items": list(range(2000, 5000))} # 3000 items
@ -921,7 +921,7 @@ class TestMergeDict:
result = merge_dict(source, {}, max_depth=10000, max_list_size=1000000) result = merge_dict(source, {}, max_depth=10000, max_list_size=1000000)
assert result["a"]["b"]["c"]["data"] == "nested" assert result["a"]["b"]["c"]["data"] == "nested"
def test_merge_dict_limits_with_circular_reference_protection(self): def test_merge_dict_circular_guard(self):
"""Test that limits work together with circular reference protection.""" """Test that limits work together with circular reference protection."""
source = {"data": {}} source = {"data": {}}
source["data"]["circular"] = source # Create circular reference source["data"]["circular"] = source # Create circular reference
@ -1088,9 +1088,7 @@ class TestValidateUuid:
def test_wrong_version(self): def test_wrong_version(self):
"""Test UUID4 against version 1 check.""" """Test UUID4 against version 1 check."""
test_uuid = str(uuid.uuid4()) test_uuid = str(uuid.uuid4())
# Version check is not strict in this function - it may return True for any valid UUID assert validate_uuid(test_uuid, 1) is True
result = validate_uuid(test_uuid, 1)
assert isinstance(result, bool) # Just check it returns a boolean
class TestStripNewline: class TestStripNewline:
@ -1222,14 +1220,13 @@ class TestValidateUrl:
def test_validate_url_basic(self): def test_validate_url_basic(self):
"""Test basic URL validation functionality.""" """Test basic URL validation functionality."""
# Test without actual validation due to missing yarl dependency if importlib.util.find_spec("yarl") is None:
# Just check the function exists and handles the missing dependency gracefully with pytest.raises(ModuleNotFoundError):
try: validate_url("https://example.com")
result = validate_url("https://example.com") return
assert isinstance(result, bool)
except ModuleNotFoundError: result = validate_url("https://example.com", allow_internal=True)
# Expected when yarl is not installed assert result is True
assert True
class TestGetFileSidecar: class TestGetFileSidecar:
@ -1300,32 +1297,36 @@ class TestArgConverter:
def test_arg_converter_basic(self): def test_arg_converter_basic(self):
"""Test basic arg_converter functionality.""" """Test basic arg_converter functionality."""
try: if importlib.util.find_spec("yt_dlp") is None:
result = arg_converter("--quiet --match-filters 'duration<2min' --download-archive archive.txt") with pytest.raises(ModuleNotFoundError):
assert isinstance(result, dict) arg_converter("--quiet --match-filters 'duration<2min' --download-archive archive.txt")
assert result.get("quiet") is True, "quiet should be True" return
assert result.get("download_archive") == "archive.txt"
assert "match_filter" in result, "match_filters should be in result" result = arg_converter("--quiet --match-filters 'duration<2min' --download-archive archive.txt")
except (ModuleNotFoundError, AttributeError, ImportError): assert isinstance(result, dict)
# Expected when yt_dlp is not available or differs assert result.get("quiet") is True, "quiet should be True"
assert True assert result.get("download_archive") == "archive.txt"
assert "match_filter" in result, "match_filters should be in result"
def test_arg_converter_empty_args(self): def test_arg_converter_empty_args(self):
"""Test arg_converter with empty args.""" """Test arg_converter with empty args."""
try: if importlib.util.find_spec("yt_dlp") is None:
result = arg_converter("") with pytest.raises(ModuleNotFoundError):
assert isinstance(result, dict) arg_converter("")
except (ModuleNotFoundError, AttributeError, ImportError): return
assert True
result = arg_converter("")
assert isinstance(result, dict)
def test_arg_converter_replace_in_metadata(self): def test_arg_converter_replace_in_metadata(self):
"""Test arg_converter handles replace-in-metadata without assertions.""" """Test arg_converter handles replace-in-metadata without assertions."""
try: if importlib.util.find_spec("yt_dlp") is None:
result = arg_converter("--replace-in-metadata title foo bar") with pytest.raises(ModuleNotFoundError):
except (ModuleNotFoundError, AttributeError, ImportError): arg_converter("--replace-in-metadata title foo bar")
assert True
return return
result = arg_converter("--replace-in-metadata title foo bar")
postprocessors = result.get("postprocessors", []) postprocessors = result.get("postprocessors", [])
assert postprocessors, "Expected metadata parser postprocessor to be present" assert postprocessors, "Expected metadata parser postprocessor to be present"
@ -1584,11 +1585,10 @@ class TestLoadCookies:
try: try:
valid, jar = load_cookies(str(self.cookie_file)) valid, jar = load_cookies(str(self.cookie_file))
assert isinstance(valid, bool) assert valid is False
assert jar is not None or not valid assert jar is not None
except ValueError: except ValueError:
# Expected for invalid cookie files return
assert True
class TestStrToDt: class TestStrToDt:
@ -1596,20 +1596,23 @@ class TestStrToDt:
def test_str_to_dt_basic(self): def test_str_to_dt_basic(self):
"""Test basic string to datetime conversion.""" """Test basic string to datetime conversion."""
try: if importlib.util.find_spec("dateparser") is None:
result = str_to_dt("2023-01-02 12:00:00 UTC") with pytest.raises(ModuleNotFoundError):
assert isinstance(result, datetime) str_to_dt("2023-01-02 12:00:00 UTC")
except ModuleNotFoundError: return
# Expected when dateparser is not available
assert True result = str_to_dt("2023-01-02 12:00:00 UTC")
assert isinstance(result, datetime)
def test_str_to_dt_relative(self): def test_str_to_dt_relative(self):
"""Test relative time string.""" """Test relative time string."""
try: if importlib.util.find_spec("dateparser") is None:
result = str_to_dt("1 hour ago") with pytest.raises(ModuleNotFoundError):
assert isinstance(result, datetime) str_to_dt("1 hour ago")
except (ModuleNotFoundError, ValueError): return
assert True
result = str_to_dt("1 hour ago")
assert isinstance(result, datetime)
class TestInitClass: class TestInitClass:
@ -1633,37 +1636,6 @@ class TestInitClass:
assert result.unused == "default" # Should use default assert result.unused == "default" # Should use default
class TestLoadModules:
"""Test the load_modules function."""
def setup_method(self):
"""Set up test module structure."""
self.temp_dir = str(make_test_temp_dir("load-modules"))
self.root_path = Path(self.temp_dir)
self.module_dir = self.root_path / "test_modules"
self.module_dir.mkdir()
# Create test module files
(self.module_dir / "__init__.py").write_text("")
(self.module_dir / "test_module.py").write_text("# Test module")
def teardown_method(self):
"""Clean up after tests."""
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_load_modules_basic(self):
"""Test basic module loading."""
try:
load_modules(self.root_path, self.module_dir)
# Should not raise an exception
assert True
except Exception:
# Module loading might fail in test environment
assert True
class TestGetChannelImages: class TestGetChannelImages:
"""Test the get_channel_images function.""" """Test the get_channel_images function."""
@ -1711,7 +1683,7 @@ class TestGetChannelImages:
assert "icon" in result assert "icon" in result
assert result["icon"] == "http://example.com/icon.jpg" assert result["icon"] == "http://example.com/icon.jpg"
def test_get_channel_images_landscape_from_banner_uncropped(self): def test_channel_images_landscape_banner(self):
"""Test extracting landscape from banner uncropped.""" """Test extracting landscape from banner uncropped."""
from app.library.Utils import get_channel_images from app.library.Utils import get_channel_images
@ -1859,23 +1831,27 @@ class TestArgConverterAdvanced:
def test_arg_converter_with_removed_options(self): def test_arg_converter_with_removed_options(self):
"""Test arg_converter with removed options tracking.""" """Test arg_converter with removed options tracking."""
try: if importlib.util.find_spec("yt_dlp") is None:
removed = [] with pytest.raises(ModuleNotFoundError):
result = arg_converter("--quiet --skip-download", level=True, removed_options=removed) arg_converter("--quiet --skip-download", level=True, removed_options=[])
return
assert isinstance(result, dict) removed = []
except (ModuleNotFoundError, AttributeError, ImportError): result = arg_converter("--quiet --skip-download", level=True, removed_options=removed)
assert True
assert isinstance(result, dict)
assert removed
def test_arg_converter_dumps_enabled(self): def test_arg_converter_dumps_enabled(self):
"""Test arg_converter with dumps flag enabled.""" """Test arg_converter with dumps flag enabled."""
try: if importlib.util.find_spec("yt_dlp") is None:
result = arg_converter("--format best", dumps=True) with pytest.raises(ModuleNotFoundError):
arg_converter("--format best", dumps=True)
return
# With dumps=True, result should still be a dict with JSON-serializable content result = arg_converter("--format best", dumps=True)
assert isinstance(result, (dict, list))
except (ModuleNotFoundError, AttributeError, ImportError): assert isinstance(result, (dict, list))
assert True
class TestCreateCookiesFile: class TestCreateCookiesFile:
@ -1910,7 +1886,7 @@ class TestCreateCookiesFile:
@patch("app.library.config.Config") @patch("app.library.config.Config")
@patch("app.library.Utils.load_cookies") @patch("app.library.Utils.load_cookies")
def test_create_cookies_file_without_path(self, mock_load_cookies, mock_config): def test_create_cookies_file_auto_path(self, mock_load_cookies, mock_config):
"""Test creating a cookies file without a specific path (auto temp file).""" """Test creating a cookies file without a specific path (auto temp file)."""
from app.library.Utils import create_cookies_file from app.library.Utils import create_cookies_file
@ -1940,7 +1916,7 @@ class TestCreateCookiesFile:
create_cookies_file("invalid_data", file=cookie_path) create_cookies_file("invalid_data", file=cookie_path)
@patch("app.library.Utils.load_cookies") @patch("app.library.Utils.load_cookies")
def test_create_cookies_file_creates_parent_directory(self, mock_load_cookies): def test_create_cookies_parent_dir(self, mock_load_cookies):
"""Test that create_cookies_file creates parent directories as needed.""" """Test that create_cookies_file creates parent directories as needed."""
from app.library.Utils import create_cookies_file from app.library.Utils import create_cookies_file
@ -1955,7 +1931,7 @@ class TestCreateCookiesFile:
assert cookie_path.parent == Path(self.test_path / "a" / "b" / "c") assert cookie_path.parent == Path(self.test_path / "a" / "b" / "c")
@patch("app.library.Utils.load_cookies") @patch("app.library.Utils.load_cookies")
def test_create_cookies_file_with_special_characters(self, mock_load_cookies): def test_create_cookies_special_chars(self, mock_load_cookies):
"""Test create_cookies_file with special characters in cookie data.""" """Test create_cookies_file with special characters in cookie data."""
from app.library.Utils import create_cookies_file from app.library.Utils import create_cookies_file
@ -2099,7 +2075,7 @@ class TestRenameFile:
assert subtitle_file.exists() assert subtitle_file.exists()
assert conflicting_sidecar.exists() assert conflicting_sidecar.exists()
def test_rename_preserves_sidecar_extensions(self, tmp_path: Path): def test_rename_sidecar_extensions(self, tmp_path: Path):
"""Test that rename preserves complex sidecar extensions.""" """Test that rename preserves complex sidecar extensions."""
# Create test files with complex extensions # Create test files with complex extensions
test_file = tmp_path / "video.mp4" test_file = tmp_path / "video.mp4"

View file

@ -2,6 +2,12 @@
<div <div
class="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col gap-4 px-3 py-4 sm:px-4 sm:py-5" class="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col gap-4 px-3 py-4 sm:px-4 sm:py-5"
> >
<div
class="pointer-events-none fixed inset-0 z-20 bg-black/45 backdrop-blur-[1px] transition-all duration-500 ease-out"
:class="lightsOut ? 'opacity-100' : 'opacity-0'"
aria-hidden="true"
/>
<div <div
class="transition-transform duration-300" class="transition-transform duration-300"
:class="{ :class="{
@ -32,7 +38,7 @@
:loading="isRefreshing" :loading="isRefreshing"
:disabled="isRefreshing" :disabled="isRefreshing"
:square="isMobile" :square="isMobile"
@click="() => void refreshQueue()" @click="() => refreshQueue()"
> >
<span v-if="!isMobile">Reload Queue</span> <span v-if="!isMobile">Reload Queue</span>
</UButton> </UButton>
@ -329,7 +335,7 @@
variant="soft" variant="soft"
size="xs" size="xs"
icon="i-lucide-play-circle" icon="i-lucide-play-circle"
@click="void stateStore.startItems([item._id])" @click="() => stateStore.startItems([item._id])"
> >
Start Start
</UButton> </UButton>
@ -340,7 +346,7 @@
variant="soft" variant="soft"
size="xs" size="xs"
icon="i-lucide-pause" icon="i-lucide-pause"
@click="void stateStore.pauseItems([item._id])" @click="() => stateStore.pauseItems([item._id])"
> >
Pause Pause
</UButton> </UButton>
@ -350,7 +356,7 @@
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-x" icon="i-lucide-x"
@click="void stateStore.cancelItems([item._id])" @click="() => stateStore.cancelItems([item._id])"
> >
{{ item.is_live ? 'Stop' : 'Cancel' }} {{ item.is_live ? 'Stop' : 'Cancel' }}
</UButton> </UButton>
@ -400,7 +406,7 @@
:sibling-count="0" :sibling-count="0"
@update:page=" @update:page="
(page) => (page) =>
loadHistory(page, { load(page, {
order: 'DESC', order: 'DESC',
perPage: configStore.app.default_pagination, perPage: configStore.app.default_pagination,
}) })
@ -415,7 +421,7 @@
:loading="historyIsLoading" :loading="historyIsLoading"
:disabled="historyIsLoading" :disabled="historyIsLoading"
@click=" @click="
void reloadHistory({ order: 'DESC', perPage: configStore.app.default_pagination }) () => reload({ order: 'DESC', perPage: configStore.app.default_pagination })
" "
> >
Reload Reload
@ -542,7 +548,7 @@
variant="soft" variant="soft"
size="xs" size="xs"
icon="i-lucide-rotate-cw" icon="i-lucide-rotate-cw"
@click="() => void requeueItem(item)" @click="() => requeueItem(item)"
> >
Requeue Requeue
</UButton> </UButton>
@ -552,7 +558,7 @@
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-trash" icon="i-lucide-trash"
@click="() => void deleteHistoryItem(item)" @click="() => deleteHistoryItem(item)"
> >
Delete Delete
</UButton> </UButton>
@ -589,7 +595,7 @@
:sibling-count="0" :sibling-count="0"
@update:page=" @update:page="
(page) => (page) =>
loadHistory(page, { load(page, {
order: 'DESC', order: 'DESC',
perPage: configStore.app.default_pagination, perPage: configStore.app.default_pagination,
}) })
@ -615,7 +621,10 @@
:open="Boolean(videoItem)" :open="Boolean(videoItem)"
title="Video" title="Video"
:dismissible="true" :dismissible="true"
:ui="{ content: 'w-full sm:max-w-5xl', body: 'p-0' }" :ui="{
content: lightsOut ? 'w-full sm:max-w-5xl shadow-2xl' : 'w-full sm:max-w-5xl',
body: 'p-0',
}"
@update:open="(open) => !open && closePlayer()" @update:open="(open) => !open && closePlayer()"
> >
<template #body> <template #body>
@ -628,6 +637,7 @@
class="w-full" class="w-full"
@closeModel="closePlayer" @closeModel="closePlayer"
@error="async (error: string) => await box.alert(error)" @error="async (error: string) => await box.alert(error)"
@playback-state-change="(playing: boolean) => (playingNow = playing)"
/> />
</template> </template>
</UModal> </UModal>
@ -711,14 +721,15 @@ const {
items: historyItems, items: historyItems,
pagination, pagination,
isLoading, isLoading,
loadHistory, load,
reloadHistory, reload,
deleteHistoryItems, remove,
historyMoveHandler, moveHandler,
} = useHistoryState(); } = useHistoryState();
const embedUrl = ref(''); const embedUrl = ref('');
const videoItem = ref<StoreItem | null>(null); const videoItem = ref<StoreItem | null>(null);
const playingNow = ref(false);
const autoRefreshInterval = ref<ReturnType<typeof setInterval> | null>(null); const autoRefreshInterval = ref<ReturnType<typeof setInterval> | null>(null);
const formUrl = ref(''); const formUrl = ref('');
@ -768,6 +779,7 @@ const historyEntries = computed<StoreItem[]>(() => historyItems.value);
const hasAnyItems = computed(() => queueItems.value.length > 0 || historyEntries.value.length > 0); const hasAnyItems = computed(() => queueItems.value.length > 0 || historyEntries.value.length > 0);
const showSections = computed(() => hasAnyItems.value || historyIsLoading.value); const showSections = computed(() => hasAnyItems.value || historyIsLoading.value);
const isFormDisabled = computed(() => addInProgress.value); const isFormDisabled = computed(() => addInProgress.value);
const lightsOut = computed(() => Boolean(videoItem.value && playingNow.value));
const formContainerClass = computed(() => { const formContainerClass = computed(() => {
if (showSections.value) { if (showSections.value) {
return 'max-w-full'; return 'max-w-full';
@ -1027,6 +1039,7 @@ const openPlayer = (item: StoreItem): void => {
}; };
const closePlayer = (): void => { const closePlayer = (): void => {
playingNow.value = false;
embedUrl.value = ''; embedUrl.value = '';
videoItem.value = null; videoItem.value = null;
}; };
@ -1261,20 +1274,18 @@ const requeueItem = async (item: StoreItem): Promise<void> => {
payload.extras = JSON.parse(JSON.stringify(item.extras)); payload.extras = JSON.parse(JSON.stringify(item.extras));
} }
await deleteHistoryItems({ ids: [item._id], removeFile: false }); await remove({ ids: [item._id], removeFile: false });
await reloadHistory({ order: 'DESC', perPage: configStore.app.default_pagination }); await reload({ order: 'DESC', perPage: configStore.app.default_pagination });
await stateStore.addDownload(payload); await stateStore.addDownload(payload);
}; };
const deleteHistoryItem = async (item: StoreItem): Promise<void> => { const deleteHistoryItem = async (item: StoreItem): Promise<void> => {
await deleteHistoryItems({ ids: [item._id], removeFile: app.value.remove_files }); await remove({ ids: [item._id], removeFile: app.value.remove_files });
await reloadHistory({ order: 'DESC', perPage: configStore.app.default_pagination }); await reload({ order: 'DESC', perPage: configStore.app.default_pagination });
toast.info('Removed from history queue.'); toast.info('Removed from history queue.');
}; };
const handleHistoryItemMoved = historyMoveHandler( const handleHistoryItemMoved = moveHandler(() => simpleMode.value && historyInitialized.value);
() => simpleMode.value && historyInitialized.value,
);
const showMessage = (item: StoreItem): boolean => { const showMessage = (item: StoreItem): boolean => {
if (!item?.msg || item.msg === item?.error) { if (!item?.msg || item.msg === item?.error) {
@ -1303,7 +1314,7 @@ onMounted(async () => {
await normalizeSimpleRoute(); await normalizeSimpleRoute();
historyInitialized.value = true; historyInitialized.value = true;
socketStore.on('item_moved', handleHistoryItemMoved); socketStore.on('item_moved', handleHistoryItemMoved);
await loadHistory(1, { order: 'DESC', perPage: configStore.app.default_pagination }); await load(1, { order: 'DESC', perPage: configStore.app.default_pagination });
if (!socketStore.isConnected && autoRefreshEnabled.value) { if (!socketStore.isConnected && autoRefreshEnabled.value) {
startAutoRefresh(); startAutoRefresh();
@ -1389,7 +1400,7 @@ watch(
return; return;
} }
void loadHistory(1, { order: 'DESC', perPage: configStore.app.default_pagination }); void load(1, { order: 'DESC', perPage: configStore.app.default_pagination });
}, },
); );
</script> </script>

View file

@ -367,7 +367,9 @@
<UIcon name="i-lucide-rss" class="size-4 text-toned" /> <UIcon name="i-lucide-rss" class="size-4 text-toned" />
<p class="text-sm font-semibold text-default">Enable Handler</p> <p class="text-sm font-semibold text-default">Enable Handler</p>
</div> </div>
<p class="text-xs text-toned">Handlers run regardless of task timer.</p> <p class="text-xs text-toned">
Tasks without a supported handler must use a timer.
</p>
</div> </div>
<USwitch v-model="form.handler_enabled" :disabled="addInProgress" /> <USwitch v-model="form.handler_enabled" :disabled="addInProgress" />
</div> </div>
@ -443,6 +445,10 @@
scheduled downloads to yt-dlp. Disable <code>Enable Handler</code> to disable RSS scheduled downloads to yt-dlp. Disable <code>Enable Handler</code> to disable RSS
monitoring. monitoring.
</li> </li>
<li>
<strong>Timer Requirement:</strong> if no supported handler matches the URL, you must
set a CRON timer before saving.
</li>
</ul> </ul>
</template> </template>
</UAlert> </UAlert>
@ -523,6 +529,7 @@ const emitter = defineEmits<{
const toast = useNotification(); const toast = useNotification();
const config = useYtpConfig(); const config = useYtpConfig();
const dialog = useDialog(); const dialog = useDialog();
const tasksComposable = useTasks();
const { findPreset, getPresetDefault, selectItems } = usePresetOptions(); const { findPreset, getPresetDefault, selectItems } = usePresetOptions();
const showImport = useStorage('showTaskImport', false); const showImport = useStorage('showTaskImport', false);
@ -786,6 +793,13 @@ const checkInfo = async (): Promise<void> => {
form.cli = form.cli.trim(); form.cli = form.cli.trim();
} }
try {
await requireTimerForTask(form);
} catch (error: any) {
toast.error(error.message);
return;
}
if (urls.length === 1) { if (urls.length === 1) {
emitter('submit', { emitter('submit', {
reference: toRaw(props.reference), reference: toRaw(props.reference),
@ -811,12 +825,32 @@ const checkInfo = async (): Promise<void> => {
} as Task; } as Task;
} }
return { url } as Task; return {
url,
preset: form.preset,
timer: form.timer,
handler_enabled: form.handler_enabled,
} as Task;
});
try {
await Promise.all(tasks.map((item) => requireTimerForTask(item)));
} catch (error: any) {
toast.error(error.message);
return;
}
const submitTasks: Task[] = tasks.map((item, idx) => {
if (idx === 0) {
return item;
}
return { url: item.url } as Task;
}); });
emitter('submit', { emitter('submit', {
reference: toRaw(props.reference), reference: toRaw(props.reference),
task: tasks, task: submitTasks,
archive_all: archiveAllAfterAdd.value, archive_all: archiveAllAfterAdd.value,
}); });
}; };
@ -943,6 +977,34 @@ const convertCurrentUrl = async (): Promise<void> => {
form.url = await convert_url(form.url); form.url = await convert_url(form.url);
}; };
const requireTimerForTask = async (
item: Pick<Task, 'url' | 'preset' | 'timer' | 'handler_enabled'>,
): Promise<void> => {
if (item.timer?.trim()) {
return;
}
if (item.handler_enabled === false) {
throw new Error('This task needs a CRON timer because the handler is disabled.');
}
const result = await tasksComposable.inspectTaskHandler({
url: item.url,
preset: item.preset,
static_only: true,
});
if (!result) {
throw new Error('Failed to verify handler support. Set a CRON timer or try again.');
}
if (result?.matched) {
return;
}
throw new Error('This task needs a CRON timer because no supported handler matches the URL.');
};
const getDefault = (type: 'cookies' | 'cli' | 'template' | 'folder', ret: string = '') => { const getDefault = (type: 'cookies' | 'cli' | 'template' | 'folder', ret: string = '') => {
if (false !== hasFormatInConfig.value || !form.preset) { if (false !== hasFormatInConfig.value || !form.preset) {
return ret; return ret;

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,7 @@ import type { EventSourceMessage } from '@microsoft/fetch-event-source';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import type { TerminalSessionItem, TerminalSessionsResponse } from '~/types';
import { parse_api_error, parse_api_response, request, uri } from '~/utils'; import { parse_api_error, parse_api_response, request, uri } from '~/utils';
type ConsoleSessionStatus = type ConsoleSessionStatus =
@ -48,10 +49,25 @@ type CancelConsoleSessionResult = {
message?: string; message?: string;
}; };
type RecentSessionItem = {
sessionId: string;
command: string;
status: 'starting' | 'running' | 'completed';
createdAt: number | null;
startedAt: number | null;
finishedAt: number | null;
expiresAt: number | null;
availableUntil: number | null;
exitCode: number | null;
lastSequence: number;
};
const STORAGE_KEY = 'console_session_state'; const STORAGE_KEY = 'console_session_state';
const HIDDEN_RECENT_SESSIONS_KEY = 'console_hidden_recent_sessions';
const MAX_TRANSCRIPT_CHUNKS = 1500; const MAX_TRANSCRIPT_CHUNKS = 1500;
const MAX_TRANSCRIPT_CHARS = 120000; const MAX_TRANSCRIPT_CHARS = 120000;
const RECONNECT_DELAY_MS = 1500; const RECONNECT_DELAY_MS = 1500;
const RECENT_SESSIONS_MAX = 30;
const DEFAULT_STATE: ConsoleSessionState = { const DEFAULT_STATE: ConsoleSessionState = {
sessionId: null, sessionId: null,
@ -160,6 +176,8 @@ const normalizePersistedState = (
}; };
const sessionState = useStorage<ConsoleSessionState>(STORAGE_KEY, { ...DEFAULT_STATE }); const sessionState = useStorage<ConsoleSessionState>(STORAGE_KEY, { ...DEFAULT_STATE });
const hiddenRecentSessions = useStorage<Array<string>>(HIDDEN_RECENT_SESSIONS_KEY, []);
const recentSessions = ref<Array<RecentSessionItem>>([]);
const streamController = ref<AbortController | null>(null); const streamController = ref<AbortController | null>(null);
const reconnectTimer = ref<ReturnType<typeof setTimeout> | null>(null); const reconnectTimer = ref<ReturnType<typeof setTimeout> | null>(null);
const connectionNonce = ref(0); const connectionNonce = ref(0);
@ -176,6 +194,68 @@ const updateState = (patch: Partial<ConsoleSessionState>): void => {
); );
}; };
const normalizeHiddenRecentSessions = (): Array<string> => {
return hiddenRecentSessions.value
.filter((value): value is string => typeof value === 'string')
.map((value) => value.trim())
.filter((value) => Boolean(value));
};
const setHiddenRecentSessions = (items: Array<string>): void => {
hiddenRecentSessions.value = Array.from(new Set(items));
};
const isRecentSessionHidden = (sessionId: string): boolean => {
return normalizeHiddenRecentSessions().includes(sessionId);
};
const sortRecentSessions = (items: Array<RecentSessionItem>): Array<RecentSessionItem> => {
return items.slice().sort((left, right) => {
const leftTime = left.finishedAt ?? left.startedAt ?? left.createdAt ?? 0;
const rightTime = right.finishedAt ?? right.startedAt ?? right.createdAt ?? 0;
return rightTime - leftTime;
});
};
const trimRecentSessions = (items: Array<RecentSessionItem>): Array<RecentSessionItem> => {
return items.slice(0, RECENT_SESSIONS_MAX);
};
const setRecentSessions = (items: Array<RecentSessionItem>): void => {
recentSessions.value = trimRecentSessions(
sortRecentSessions(items.filter((item) => !isRecentSessionHidden(item.sessionId))),
);
};
const upsertRecentSession = (item: RecentSessionItem): void => {
if (isRecentSessionHidden(item.sessionId)) {
return;
}
setRecentSessions([
item,
...recentSessions.value.filter((entry) => entry.sessionId !== item.sessionId),
]);
};
const hideRecentSession = (sessionId: string): void => {
const normalized = sessionId.trim();
if (!normalized) {
return;
}
setHiddenRecentSessions([...normalizeHiddenRecentSessions(), normalized]);
setRecentSessions(recentSessions.value.filter((item) => item.sessionId !== normalized));
};
const clearRecentSessions = (): void => {
setHiddenRecentSessions([
...normalizeHiddenRecentSessions(),
...recentSessions.value.map((item) => item.sessionId),
]);
recentSessions.value = [];
};
const appendTranscript = (chunk: string): void => { const appendTranscript = (chunk: string): void => {
if (!chunk) { if (!chunk) {
return; return;
@ -215,6 +295,7 @@ const clearTranscript = (): void => {
const markExpired = (): void => { const markExpired = (): void => {
stopStream(); stopStream();
updateState({ status: 'expired', error: '' }); updateState({ status: 'expired', error: '' });
syncCurrentSessionToRecentSessions();
}; };
const finishSession = ( const finishSession = (
@ -227,6 +308,7 @@ const finishSession = (
exitCode, exitCode,
error: '', error: '',
}); });
syncCurrentSessionToRecentSessions();
}; };
const scheduleReconnect = (): void => { const scheduleReconnect = (): void => {
@ -289,6 +371,97 @@ const parseResponseError = async (response: Response): Promise<string> => {
return response.statusText || 'Request failed.'; return response.statusText || 'Request failed.';
}; };
const normalizeRecentSessionStatus = (
status: string | null | undefined,
): RecentSessionItem['status'] => {
switch (normalizeStatus(status, 'finished')) {
case 'starting':
return 'starting';
case 'running':
case 'reconnecting':
return 'running';
default:
return 'completed';
}
};
const recentSessionFromPayload = (payload: TerminalSessionItem): RecentSessionItem | null => {
if (typeof payload.session_id !== 'string' || !payload.session_id.trim()) {
return null;
}
if (typeof payload.command !== 'string') {
return null;
}
return {
sessionId: payload.session_id,
command: payload.command,
status: normalizeRecentSessionStatus(payload.status),
createdAt: typeof payload.created_at === 'number' ? payload.created_at : null,
startedAt: typeof payload.started_at === 'number' ? payload.started_at : null,
finishedAt: typeof payload.finished_at === 'number' ? payload.finished_at : null,
expiresAt: typeof payload.expires_at === 'number' ? payload.expires_at : null,
availableUntil: typeof payload.available_until === 'number' ? payload.available_until : null,
exitCode: typeof payload.exit_code === 'number' ? payload.exit_code : null,
lastSequence: typeof payload.last_sequence === 'number' ? payload.last_sequence : 0,
};
};
const syncCurrentSessionToRecentSessions = (): void => {
if (!sessionState.value.sessionId || !sessionState.value.command.trim()) {
return;
}
const existing =
recentSessions.value.find((item) => item.sessionId === sessionState.value.sessionId) ?? null;
const isFinished = ['finished', 'interrupted', 'error', 'expired'].includes(
sessionState.value.status,
);
const lastSequence =
sessionState.value.lastEventId && /^\d+$/.test(sessionState.value.lastEventId)
? Number(sessionState.value.lastEventId)
: (existing?.lastSequence ?? 0);
upsertRecentSession({
sessionId: sessionState.value.sessionId,
command: sessionState.value.command,
status: normalizeRecentSessionStatus(sessionState.value.status),
createdAt: existing?.createdAt ?? null,
startedAt: existing?.startedAt ?? null,
finishedAt: isFinished
? (existing?.finishedAt ?? Date.now() / 1000)
: (existing?.finishedAt ?? null),
expiresAt: existing?.expiresAt ?? null,
availableUntil: existing?.availableUntil ?? null,
exitCode: sessionState.value.exitCode ?? existing?.exitCode ?? null,
lastSequence,
});
};
const fetchRecentSessions = async (): Promise<void> => {
try {
const response = await request('/api/system/terminal');
if (!response.ok) {
return;
}
const payload = await parse_api_response<TerminalSessionsResponse>(response.json());
const items = Array.isArray(payload?.items)
? payload.items
.map((item) => recentSessionFromPayload(item))
.filter((item): item is RecentSessionItem => item !== null)
: [];
setRecentSessions(items);
syncCurrentSessionToRecentSessions();
} catch {
return;
}
};
const refreshSessionMetadata = async (): Promise<void> => { const refreshSessionMetadata = async (): Promise<void> => {
if (!sessionState.value.sessionId) { if (!sessionState.value.sessionId) {
return; return;
@ -303,10 +476,14 @@ const refreshSessionMetadata = async (): Promise<void> => {
} }
updateState(normalizeResponse(payload)); updateState(normalizeResponse(payload));
syncCurrentSessionToRecentSessions();
} catch (error) { } catch (error) {
if (error instanceof ConsoleSessionExpiredError) { if (error instanceof ConsoleSessionExpiredError) {
markExpired(); markExpired();
return;
} }
return;
} }
}; };
@ -400,6 +577,7 @@ const connectStream = async (): Promise<void> => {
onopen: async (response) => { onopen: async (response) => {
if (response.ok) { if (response.ok) {
updateState({ status: 'running', error: '' }); updateState({ status: 'running', error: '' });
syncCurrentSessionToRecentSessions();
return; return;
} }
@ -434,6 +612,7 @@ const connectStream = async (): Promise<void> => {
if (event.event === 'status') { if (event.event === 'status') {
updateState(normalizeResponse(payload as ConsoleSessionResponse)); updateState(normalizeResponse(payload as ConsoleSessionResponse));
syncCurrentSessionToRecentSessions();
return; return;
} }
@ -501,6 +680,7 @@ const connectStream = async (): Promise<void> => {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
appendTranscript(`Error: ${message}\n`); appendTranscript(`Error: ${message}\n`);
updateState({ status: 'error', error: message }); updateState({ status: 'error', error: message });
syncCurrentSessionToRecentSessions();
} finally { } finally {
if (streamController.value === controller) { if (streamController.value === controller) {
streamController.value = null; streamController.value = null;
@ -517,12 +697,15 @@ const restoreSession = async (): Promise<void> => {
if (payload) { if (payload) {
updateState(normalizeResponse(payload)); updateState(normalizeResponse(payload));
syncCurrentSessionToRecentSessions();
} }
if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) { if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) {
await connectStream(); await connectStream();
} }
await fetchRecentSessions();
return; return;
} }
@ -531,28 +714,36 @@ const restoreSession = async (): Promise<void> => {
sessionState.value.command || sessionState.value.command ||
sessionState.value.transcript.length > 0 sessionState.value.transcript.length > 0
) { ) {
await fetchRecentSessions();
return; return;
} }
const active = await readSessionResponse('/api/system/terminal/active', { allowMissing: true }); const active = await readSessionResponse('/api/system/terminal/active', { allowMissing: true });
if (!active) { if (!active) {
await fetchRecentSessions();
return; return;
} }
updateState(normalizeResponse(active)); updateState(normalizeResponse(active));
syncCurrentSessionToRecentSessions();
if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) { if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) {
await connectStream(); await connectStream();
} }
await fetchRecentSessions();
} catch (error) { } catch (error) {
if (error instanceof ConsoleSessionExpiredError) { if (error instanceof ConsoleSessionExpiredError) {
markExpired(); markExpired();
await fetchRecentSessions();
return; return;
} }
if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) { if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) {
await connectStream(); await connectStream();
} }
await fetchRecentSessions();
} }
}; };
@ -594,16 +785,20 @@ const startSession = async ({
]), ]),
error: '', error: '',
}); });
syncCurrentSessionToRecentSessions();
if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) { if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) {
await connectStream(); await connectStream();
} }
await fetchRecentSessions();
return Boolean(sessionState.value.sessionId); return Boolean(sessionState.value.sessionId);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
appendTranscript(`Error: ${message}\n`); appendTranscript(`Error: ${message}\n`);
updateState({ status: 'error', error: message }); updateState({ status: 'error', error: message });
syncCurrentSessionToRecentSessions();
return false; return false;
} }
}; };
@ -623,6 +818,7 @@ const cancelSession = async (): Promise<CancelConsoleSessionResult> => {
if (response.status === 404) { if (response.status === 404) {
await refreshSessionMetadata(); await refreshSessionMetadata();
await fetchRecentSessions();
return { status: 'missing', message: 'Terminal session not found.' }; return { status: 'missing', message: 'Terminal session not found.' };
} }
@ -631,6 +827,7 @@ const cancelSession = async (): Promise<CancelConsoleSessionResult> => {
} }
updateState({ error: '' }); updateState({ error: '' });
await fetchRecentSessions();
return { status: 'cancelled' }; return { status: 'cancelled' };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
@ -642,6 +839,32 @@ const cancelSession = async (): Promise<CancelConsoleSessionResult> => {
const resetState = (): void => { const resetState = (): void => {
stopStream(); stopStream();
sessionState.value = { ...DEFAULT_STATE }; sessionState.value = { ...DEFAULT_STATE };
recentSessions.value = [];
hiddenRecentSessions.value = [];
};
const replaySession = async (item: RecentSessionItem): Promise<boolean> => {
stopStream();
updateState({
sessionId: item.sessionId,
command: item.command,
status: item.status === 'running' ? 'reconnecting' : 'finished',
lastEventId: null,
exitCode: item.exitCode,
transcript: [],
error: '',
});
syncCurrentSessionToRecentSessions();
await connectStream();
await fetchRecentSessions();
if (sessionState.value.status === 'expired') {
hideRecentSession(item.sessionId);
return false;
}
return sessionState.value.status !== 'error';
}; };
const useConsoleSession = () => { const useConsoleSession = () => {
@ -649,7 +872,12 @@ const useConsoleSession = () => {
state: sessionState, state: sessionState,
bufferedTranscript: computed(() => sessionState.value.transcript.slice()), bufferedTranscript: computed(() => sessionState.value.transcript.slice()),
isLoading: computed(() => isActiveSessionStatus(sessionState.value.status)), isLoading: computed(() => isActiveSessionStatus(sessionState.value.status)),
recentSessions: computed(() => recentSessions.value.slice()),
clearTranscript, clearTranscript,
clearRecentSessions,
fetchRecentSessions,
hideRecentSession,
replaySession,
restoreSession, restoreSession,
startSession, startSession,
cancelSession, cancelSession,

View file

@ -77,7 +77,7 @@ const buildQuery = (
return params.toString(); return params.toString();
}; };
const loadHistory = async (page: number = 1, options: HistoryLoadOptions = {}): Promise<void> => { const load = async (page: number = 1, options: HistoryLoadOptions = {}): Promise<void> => {
const { order = 'DESC', status, perPage = pageSize.value } = options; const { order = 'DESC', status, perPage = pageSize.value } = options;
isLoading.value = true; isLoading.value = true;
@ -104,12 +104,12 @@ const loadHistory = async (page: number = 1, options: HistoryLoadOptions = {}):
} }
}; };
const reloadHistory = async (options: HistoryLoadOptions = {}): Promise<void> => { const reload = async (options: HistoryLoadOptions = {}): Promise<void> => {
const targetPage = isLoaded.value ? pagination.value.page : 1; const targetPage = isLoaded.value ? pagination.value.page : 1;
await loadHistory(targetPage, options); await load(targetPage, options);
}; };
const deleteHistoryItems = async ( const remove = async (
options: { options: {
ids?: string[]; ids?: string[];
status?: string; status?: string;
@ -155,7 +155,39 @@ const deleteHistoryItems = async (
} }
}; };
const resetHistory = (): void => { const rename = async (item: StoreItem, newName: string): Promise<boolean> => {
const trimmedName = newName.trim();
if (!trimmedName || trimmedName === item.filename?.split('/').pop()) {
return false;
}
try {
const response = await request(`/api/history/${item._id}/rename`, {
method: 'POST',
body: JSON.stringify({ new_name: trimmedName }),
});
await ensureSuccess(response);
const updated = (await response.json()) as StoreItem;
const index = items.value.findIndex((entry) => entry._id === updated._id);
if (index !== -1) {
items.value = [...items.value.slice(0, index), updated, ...items.value.slice(index + 1)];
}
lastError.value = null;
return true;
} catch (error) {
handleError(error);
if (throwInstead.value) {
throw error;
}
return false;
}
};
const reset = (): void => {
items.value = []; items.value = [];
pagination.value = { pagination.value = {
page: 1, page: 1,
@ -169,7 +201,7 @@ const resetHistory = (): void => {
lastError.value = null; lastError.value = null;
}; };
const addHistoryItem = (item: StoreItem): void => { const upsert = (item: StoreItem): void => {
const existingIndex = items.value.findIndex((existing) => existing._id === item._id); const existingIndex = items.value.findIndex((existing) => existing._id === item._id);
if (existingIndex !== -1) { if (existingIndex !== -1) {
@ -195,7 +227,7 @@ const addHistoryItem = (item: StoreItem): void => {
pagination.value.has_next = pagination.value.page < pagination.value.total_pages; pagination.value.has_next = pagination.value.page < pagination.value.total_pages;
}; };
const historyMoveHandler = ( const moveHandler = (
shouldHandle: () => boolean = () => isLoaded.value, shouldHandle: () => boolean = () => isLoaded.value,
): ((payload: WSEP['item_moved']) => void) => { ): ((payload: WSEP['item_moved']) => void) => {
return (payload: WSEP['item_moved']): void => { return (payload: WSEP['item_moved']): void => {
@ -203,7 +235,7 @@ const historyMoveHandler = (
return; return;
} }
addHistoryItem(payload.data.item); upsert(payload.data.item);
}; };
}; };
@ -215,11 +247,12 @@ export const useHistoryState = () => {
isLoaded, isLoaded,
lastError, lastError,
pageSize, pageSize,
loadHistory, load,
reloadHistory, reload,
deleteHistoryItems, remove,
resetHistory, rename,
upsertHistoryItem: addHistoryItem, reset,
historyMoveHandler, upsert,
moveHandler,
}; };
}; };

View file

@ -5,21 +5,21 @@
import { ref } from 'vue'; import { ref } from 'vue';
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import { import {
handlePlayPause, playPause,
handleRewind, rewind,
handleForward, forward,
handleMute, mute,
handleVolumeChange, changeVolume,
handlePlaybackSpeedChange, changeSpeed,
handleFrameStep, frameStep,
handleSeekToPercent, seekToPercent,
handleSeekBackward, seekBackward,
handleSeekForward, seekForward,
handleFullscreen, fullscreen,
handlePictureInPicture, pictureInPicture,
handleToggleCaptions, toggleCaptions,
shouldHandleKeyboardShortcut, shouldHandleKeyboardShortcut,
isModifierKey, modifierKey,
} from '~/utils/keyboard'; } from '~/utils/keyboard';
import type { KeyboardShortcutContext } from '~/types/video'; import type { KeyboardShortcutContext } from '~/types/video';
@ -51,7 +51,7 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
} }
// Skip if modifier keys are pressed (except for shortcuts that need them) // Skip if modifier keys are pressed (except for shortcuts that need them)
if (isModifierKey(event) && !['f', 'p', '?'].includes(event.key.toLowerCase())) { if (modifierKey(event) && !['f', 'p', '?'].includes(event.key.toLowerCase())) {
return; return;
} }
@ -64,67 +64,67 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
case ' ': case ' ':
case 'k': case 'k':
event.preventDefault(); event.preventDefault();
handlePlayPause(ctx); playPause(ctx);
break; break;
// Rewind 10 seconds (J key) // Rewind 10 seconds (J key)
case 'j': case 'j':
event.preventDefault(); event.preventDefault();
handleRewind(ctx, 10); rewind(ctx, 10);
break; break;
// Forward 10 seconds (L key) // Forward 10 seconds (L key)
case 'l': case 'l':
event.preventDefault(); event.preventDefault();
handleForward(ctx, 10); forward(ctx, 10);
break; break;
// Seek backward 5 seconds (left arrow) // Seek backward 5 seconds (left arrow)
case 'arrowleft': case 'arrowleft':
event.preventDefault(); event.preventDefault();
handleSeekBackward(ctx, 5); seekBackward(ctx, 5);
break; break;
// Seek forward 5 seconds (right arrow) // Seek forward 5 seconds (right arrow)
case 'arrowright': case 'arrowright':
event.preventDefault(); event.preventDefault();
handleSeekForward(ctx, 5); seekForward(ctx, 5);
break; break;
// Increase volume (up arrow) // Increase volume (up arrow)
case 'arrowup': case 'arrowup':
event.preventDefault(); event.preventDefault();
handleVolumeChange(ctx, 0.1); changeVolume(ctx, 0.1);
break; break;
// Decrease volume (down arrow) // Decrease volume (down arrow)
case 'arrowdown': case 'arrowdown':
event.preventDefault(); event.preventDefault();
handleVolumeChange(ctx, -0.1); changeVolume(ctx, -0.1);
break; break;
// Mute/Unmute // Mute/Unmute
case 'm': case 'm':
event.preventDefault(); event.preventDefault();
handleMute(ctx); mute(ctx);
break; break;
// Toggle fullscreen // Toggle fullscreen
case 'f': case 'f':
event.preventDefault(); event.preventDefault();
handleFullscreen(video); fullscreen(video);
break; break;
// Picture-in-Picture // Picture-in-Picture
case 'p': case 'p':
event.preventDefault(); event.preventDefault();
await handlePictureInPicture(video); await pictureInPicture(video);
break; break;
// Toggle captions // Toggle captions
case 'c': case 'c':
event.preventDefault(); event.preventDefault();
handleToggleCaptions(video); toggleCaptions(video);
break; break;
// Frame advance (period key) / Increase playback speed (> or ') // Frame advance (period key) / Increase playback speed (> or ')
@ -132,9 +132,9 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
case "'": { case "'": {
event.preventDefault(); event.preventDefault();
if ('.' === key) { if ('.' === key) {
handleFrameStep(ctx, 'forward'); frameStep(ctx, 'forward');
} else { } else {
handlePlaybackSpeedChange(ctx, 0.25); changeSpeed(ctx, 0.25);
} }
break; break;
} }
@ -144,9 +144,9 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
case ';': { case ';': {
event.preventDefault(); event.preventDefault();
if (',' === key) { if (',' === key) {
handleFrameStep(ctx, 'backward'); frameStep(ctx, 'backward');
} else { } else {
handlePlaybackSpeedChange(ctx, -0.25); changeSpeed(ctx, -0.25);
} }
break; break;
} }
@ -164,7 +164,7 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
case '9': { case '9': {
event.preventDefault(); event.preventDefault();
const percent = parseInt(key) * 10; const percent = parseInt(key) * 10;
handleSeekToPercent(ctx, percent); seekToPercent(ctx, percent);
break; break;
} }

View file

@ -0,0 +1,44 @@
import { computed } from 'vue';
import { useStorage } from '@vueuse/core';
function clampVolume(volume: number): number {
if (!Number.isFinite(volume)) return 1;
return Math.min(1, Math.max(0, volume));
}
export function usePlayerMediaVolume() {
const volume = useStorage<number>('player_volume', 1);
const muted = useStorage<boolean>('player_muted', false);
const effectiveVolume = computed(() => {
return muted.value ? 0 : clampVolume(volume.value);
});
function setVolume(nextVolume: number) {
const value = clampVolume(nextVolume);
volume.value = value;
muted.value = value <= 0;
}
function changeVolume(delta: number) {
setVolume(volume.value + delta);
}
function toggleMute() {
if (muted.value || effectiveVolume.value <= 0) {
volume.value = volume.value > 0 ? clampVolume(volume.value) : 1;
muted.value = false;
return;
}
muted.value = true;
}
return {
volume,
muted,
effectiveVolume,
setVolume,
changeVolume,
toggleMute,
};
}

View file

@ -0,0 +1,3 @@
export function usePlayerShortcutHelp() {
return useState<boolean>('player-shortcut-help', () => false);
}

View file

@ -0,0 +1,247 @@
import {
getCurrentScope,
onScopeDispose,
ref,
watch,
type MaybeRefOrGetter,
type Ref,
toValue,
} from 'vue';
import {
clampMediaTime,
clampMediaVolume,
hasModifierKey,
shouldHandleKeyboardShortcut,
} from '~/utils/keyboard';
type UsePlayerShortcutsOptions = {
enabled: MaybeRefOrGetter<boolean>;
media: MaybeRefOrGetter<HTMLMediaElement | null>;
video: MaybeRefOrGetter<HTMLVideoElement | null>;
adjustVolume?: (delta: number) => void;
canToggleSubs: MaybeRefOrGetter<boolean>;
helpOpen?: Ref<boolean>;
toggleSubtitles: () => void;
toggleFullscreen: () => Promise<void> | void;
toggleMute?: () => void;
closePlayer?: () => void;
};
export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) {
const showHelp = options.helpOpen || ref(false);
function togglePlayPause(media: HTMLMediaElement) {
if (media.paused) {
void media.play().catch(() => {});
return;
}
media.pause();
}
function stepFrame(media: HTMLMediaElement, direction: 'forward' | 'backward') {
if (!media.paused) {
media.pause();
}
const frameStep = direction === 'forward' ? 0.033 : -0.033;
clampMediaTime(media, media.currentTime + frameStep);
}
function toggleNativeSubtitles(video: HTMLVideoElement) {
const tracks = Array.from(video.textTracks);
const subtitleTrack = tracks.find(
(track) => track.kind === 'subtitles' || track.kind === 'captions',
);
if (!subtitleTrack) {
return;
}
subtitleTrack.mode = subtitleTrack.mode === 'showing' ? 'hidden' : 'showing';
}
async function handleKeyDown(event: KeyboardEvent) {
if (!toValue(options.enabled) || !shouldHandleKeyboardShortcut(event)) {
return;
}
const media = toValue(options.media);
if (!media) {
return;
}
const key = event.key.toLowerCase();
if (hasModifierKey(event) && !['f', '?', '/'].includes(key)) {
return;
}
switch (key) {
case ' ':
case 'k':
event.preventDefault();
event.stopPropagation();
togglePlayPause(media);
break;
case 'j':
event.preventDefault();
event.stopPropagation();
clampMediaTime(media, media.currentTime - 10);
break;
case 'l':
event.preventDefault();
event.stopPropagation();
clampMediaTime(media, media.currentTime + 10);
break;
case 'arrowleft':
event.preventDefault();
event.stopPropagation();
clampMediaTime(media, media.currentTime - 5);
break;
case 'arrowright':
event.preventDefault();
event.stopPropagation();
clampMediaTime(media, media.currentTime + 5);
break;
case 'home':
event.preventDefault();
event.stopPropagation();
media.currentTime = 0;
break;
case 'end':
event.preventDefault();
event.stopPropagation();
if (Number.isFinite(media.duration)) {
media.currentTime = media.duration;
}
break;
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9': {
event.preventDefault();
event.stopPropagation();
if (Number.isFinite(media.duration) && media.duration > 0) {
media.currentTime = (parseInt(key, 10) / 10) * media.duration;
}
break;
}
case 'arrowup':
event.preventDefault();
event.stopPropagation();
if (options.adjustVolume) {
options.adjustVolume(0.1);
break;
}
media.volume = clampMediaVolume(media.volume + 0.1);
media.muted = false;
break;
case 'arrowdown':
event.preventDefault();
event.stopPropagation();
if (options.adjustVolume) {
options.adjustVolume(-0.1);
break;
}
media.volume = clampMediaVolume(media.volume - 0.1);
if (media.volume <= 0) {
media.muted = true;
}
break;
case 'm':
event.preventDefault();
event.stopPropagation();
if (options.toggleMute) {
options.toggleMute();
break;
}
media.muted = !media.muted;
break;
case ';':
event.preventDefault();
event.stopPropagation();
media.playbackRate = Math.max(0.25, media.playbackRate - 0.25);
break;
case "'":
event.preventDefault();
event.stopPropagation();
media.playbackRate = Math.min(2, media.playbackRate + 0.25);
break;
case ',':
event.preventDefault();
event.stopPropagation();
stepFrame(media, 'backward');
break;
case '.':
event.preventDefault();
event.stopPropagation();
stepFrame(media, 'forward');
break;
case 'f':
event.preventDefault();
event.stopPropagation();
await options.toggleFullscreen();
break;
case 'c': {
if (!toValue(options.canToggleSubs)) {
return;
}
event.preventDefault();
event.stopPropagation();
const video = toValue(options.video);
if (video?.textTracks.length) {
toggleNativeSubtitles(video);
}
options.toggleSubtitles();
break;
}
case '?':
case '/':
event.preventDefault();
event.stopPropagation();
showHelp.value = !showHelp.value;
break;
case 'escape':
event.preventDefault();
event.stopPropagation();
if (showHelp.value) {
showHelp.value = false;
break;
}
options.closePlayer?.();
break;
default:
break;
}
}
document.addEventListener('keydown', handleKeyDown, { capture: true });
watch(
() => toValue(options.enabled),
(enabled) => {
if (!enabled) {
showHelp.value = false;
}
},
);
if (getCurrentScope()) {
onScopeDispose(() => {
document.removeEventListener('keydown', handleKeyDown, { capture: true });
});
}
return {
showHelp,
};
}

View file

@ -0,0 +1,220 @@
import {
computed,
getCurrentScope,
onScopeDispose,
ref,
watch,
type MaybeRefOrGetter,
toValue,
} from 'vue';
import type { SubtitleManifestResponse, SubtitleTrack } from '~/types/subtitles';
import { parse_api_error, request } from '~/utils';
type AssRendererInstance = {
destroy(): unknown;
show(): unknown;
};
type AssRendererConstructor = new (
content: string,
video: HTMLVideoElement,
options: { container: HTMLElement; resampling: 'video_height' },
) => AssRendererInstance;
type UsePlayerSubtitlesOptions = {
manifestUrl: MaybeRefOrGetter<string>;
isVideo: MaybeRefOrGetter<boolean>;
canPlay: MaybeRefOrGetter<boolean>;
shouldRender: MaybeRefOrGetter<boolean>;
assLayoutVersion?: MaybeRefOrGetter<number>;
video: MaybeRefOrGetter<HTMLVideoElement | null>;
overlay: MaybeRefOrGetter<HTMLElement | null>;
fetchText?: (url: string) => Promise<string>;
loadRenderer?: () => Promise<AssRendererConstructor>;
};
async function defaultFetchSubtitleText(url: string): Promise<string> {
const res = await request(url, { headers: { Accept: 'text/plain, text/vtt, text/x-ssa' } });
if (!res.ok) {
throw new Error('Subtitle fetch failed');
}
return res.text();
}
async function defaultLoadAssRenderer(): Promise<AssRendererConstructor> {
const mod = await import('assjs');
return mod.default as AssRendererConstructor;
}
export function usePlayerSubtitles(options: UsePlayerSubtitlesOptions) {
const fetchText = options.fetchText || defaultFetchSubtitleText;
const loadRenderer = options.loadRenderer || defaultLoadAssRenderer;
const tracks = ref<SubtitleTrack[]>([]);
const subtitleLoading = ref(false);
const subtitleLoadError = ref('');
const subtitleEnabled = ref(true);
const selectedTrack = computed(() => tracks.value[0] || null);
const nativeSubtitleTrack = computed(() => {
const track = selectedTrack.value;
return subtitleEnabled.value && track?.renderer === 'native' ? track : null;
});
const usesAssTrack = computed(() => selectedTrack.value?.renderer === 'assjs');
const hasSubtitles = computed(() => tracks.value.length > 0);
let assRenderer: AssRendererInstance | null = null;
let subtitleRequestId = 0;
let assRequestId = 0;
let cachedAssSubtitleUrl = '';
let cachedAssSubtitleContent = '';
function destroyAssRenderer() {
assRenderer?.destroy();
assRenderer = null;
}
async function loadTracks() {
const manifestUrl = toValue(options.manifestUrl);
const isVideo = toValue(options.isVideo);
const canPlay = toValue(options.canPlay);
const requestId = ++subtitleRequestId;
assRequestId += 1;
destroyAssRenderer();
tracks.value = [];
subtitleLoadError.value = '';
if (!manifestUrl || !isVideo || !canPlay) {
subtitleLoading.value = false;
return;
}
subtitleLoading.value = true;
try {
const res = await request(manifestUrl);
const payload = (await res.json()) as SubtitleManifestResponse | { error?: string };
if (!res.ok) {
throw new Error(await parse_api_error(payload));
}
if (requestId !== subtitleRequestId) {
return;
}
tracks.value = (payload as SubtitleManifestResponse).subtitles || [];
if (tracks.value.length > 0) {
subtitleEnabled.value = true;
}
} catch {
if (requestId !== subtitleRequestId) {
return;
}
subtitleLoadError.value = 'Failed to load subtitles for this video.';
} finally {
if (requestId === subtitleRequestId) {
subtitleLoading.value = false;
}
}
}
async function syncAssRenderer() {
const track = selectedTrack.value;
const shouldRender = toValue(options.shouldRender);
const video = toValue(options.video);
const overlay = toValue(options.overlay);
const requestId = ++assRequestId;
destroyAssRenderer();
if (
!track ||
track.renderer !== 'assjs' ||
!subtitleEnabled.value ||
!shouldRender ||
!video ||
!overlay
) {
return;
}
try {
const subtitleContent =
cachedAssSubtitleUrl === track.url ? cachedAssSubtitleContent : await fetchText(track.url);
if (requestId !== assRequestId) {
return;
}
if (cachedAssSubtitleUrl !== track.url) {
cachedAssSubtitleUrl = track.url;
cachedAssSubtitleContent = subtitleContent;
}
const Ass = await loadRenderer();
if (requestId !== assRequestId) {
return;
}
assRenderer = new Ass(subtitleContent, video, {
container: overlay,
resampling: 'video_height',
}) as AssRendererInstance;
assRenderer.show();
video.dispatchEvent(new Event('seeking'));
if (!video.paused) {
video.dispatchEvent(new Event('playing'));
}
subtitleLoadError.value = '';
} catch {
if (requestId === assRequestId) {
subtitleLoadError.value = 'Failed to render ASS subtitles in the browser.';
}
destroyAssRenderer();
}
}
watch(
() => [toValue(options.manifestUrl), toValue(options.isVideo), toValue(options.canPlay)],
() => {
void loadTracks();
},
{ immediate: true },
);
watch(
() => [
selectedTrack.value?.url || '',
selectedTrack.value?.renderer || '',
subtitleEnabled.value,
toValue(options.shouldRender),
toValue(options.assLayoutVersion) || 0,
toValue(options.video),
toValue(options.overlay),
],
() => {
void syncAssRenderer();
},
{ immediate: true },
);
if (getCurrentScope()) {
onScopeDispose(() => {
assRequestId += 1;
destroyAssRenderer();
});
}
return {
subtitleTracks: tracks,
subtitleLoading,
subtitleLoadError,
subtitleEnabled,
selectedSubtitleTrack: selectedTrack,
nativeSubtitleTrack,
usesAssSubtitleTrack: usesAssTrack,
hasSubtitles,
loadSelectedSubtitles: loadTracks,
};
}

View file

@ -1,5 +1,11 @@
<template> <template>
<div class="w-full min-w-0 max-w-full space-y-6"> <div class="w-full min-w-0 max-w-full space-y-6">
<div
class="pointer-events-none fixed inset-0 z-20 bg-black/45 backdrop-blur-[1px] transition-all duration-500 ease-out"
:class="lightsOut ? 'opacity-100' : 'opacity-0'"
aria-hidden="true"
/>
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="flex min-w-0 items-start gap-3"> <div class="flex min-w-0 items-start gap-3">
<span <span
@ -502,6 +508,7 @@
:item="model_item" :item="model_item"
class="w-full" class="w-full"
@closeModel="closeModel" @closeModel="closeModel"
@playback-state-change="(playing: boolean) => (playingNow = playing)"
/> />
<GetInfo <GetInfo
@ -620,6 +627,7 @@ const initialPath = (() => {
const isUpdating = ref(false); const isUpdating = ref(false);
const model_item = ref<any | null>(null); const model_item = ref<any | null>(null);
const playingNow = ref(false);
const previewTitle = computed(() => { const previewTitle = computed(() => {
if (!model_item.value) { if (!model_item.value) {
@ -640,7 +648,10 @@ const previewTitle = computed(() => {
const previewModalUi = computed(() => { const previewModalUi = computed(() => {
if (model_item.value?.type === 'video') { if (model_item.value?.type === 'video') {
return { content: 'sm:max-w-5xl', body: 'p-0' }; return {
content: lightsOut.value ? 'sm:max-w-5xl shadow-2xl' : 'sm:max-w-5xl',
body: 'p-0',
};
} }
if (model_item.value?.type === 'image') { if (model_item.value?.type === 'image') {
@ -649,6 +660,7 @@ const previewModalUi = computed(() => {
return { content: 'sm:max-w-4xl', body: 'p-0' }; return { content: 'sm:max-w-4xl', body: 'p-0' };
}); });
const lightsOut = computed(() => Boolean(model_item.value?.type === 'video' && playingNow.value));
const buildStateUrl = (dir: string, page?: number): string => { const buildStateUrl = (dir: string, page?: number): string => {
const params = new URLSearchParams(); const params = new URLSearchParams();
@ -730,6 +742,7 @@ watch(localSearch, async (value) => {
}); });
const closeModel = (): void => { const closeModel = (): void => {
playingNow.value = false;
model_item.value = null; model_item.value = null;
}; };

View file

@ -177,27 +177,27 @@
class="flex items-center gap-2 text-sm font-semibold text-highlighted" class="flex items-center gap-2 text-sm font-semibold text-highlighted"
> >
<UIcon name="i-lucide-history" class="size-4 text-toned" /> <UIcon name="i-lucide-history" class="size-4 text-toned" />
<span>Command history</span> <span>Recent runs</span>
</div> </div>
<UButton <UButton
color="neutral" color="neutral"
variant="outline" variant="outline"
size="sm" size="sm"
icon="i-lucide-trash" icon="i-lucide-eye-off"
:disabled="historyEntries.length < 1" :disabled="recentSessionEntries.length < 1"
@click="() => void clearHistory()" @click="() => void hideRecentSessions()"
> >
Clear history Hide recent runs
</UButton> </UButton>
</div> </div>
<UAlert <UAlert
v-if="historyEntries.length < 1" v-if="recentSessionEntries.length < 1"
color="info" color="info"
variant="soft" variant="soft"
icon="i-lucide-clock-3" icon="i-lucide-clock-3"
title="Command history is empty" title="No recent console sessions"
/> />
<div <div
@ -205,34 +205,119 @@
class="max-h-96 w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default" class="max-h-96 w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
> >
<div class="w-full max-w-full overflow-auto overscroll-contain"> <div class="w-full max-w-full overflow-auto overscroll-contain">
<table class="min-w-155 w-full text-sm"> <table class="w-full text-sm">
<tbody class="divide-y divide-default"> <tbody class="divide-y divide-default">
<tr <tr
v-for="(cmd, index) in historyEntries" v-for="item in recentSessionEntries"
:key="`${index}-${cmd}`" :key="item.sessionId"
class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0" class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
> >
<td class="px-3 py-3 align-middle"> <td class="px-3 py-3 align-middle">
<button <div class="space-y-2">
type="button" <button
class="block w-full text-left font-mono text-xs text-default hover:text-highlighted" type="button"
@click="() => void loadCommand(cmd)" class="block w-full text-left font-mono text-xs text-default hover:text-highlighted"
> @click="() => void replayRecentSession(item)"
{{ cmd.replace(/\n/g, ' ') }} >
</button> {{ item.command }}
</button>
<div
class="flex flex-wrap items-center gap-2 text-[11px] text-toned"
>
<UBadge
:color="recentSessionStatusColor(item)"
variant="soft"
size="sm"
>
<span
v-if="recentSessionStatusSpinning(item)"
class="inline-flex items-center gap-1.5"
>
<UIcon
:name="recentSessionStatusIcon(item)"
class="size-3.5 animate-spin"
/>
<span>{{ recentSessionStatusLabel(item) }}</span>
</span>
<span v-else class="inline-flex items-center gap-1.5">
<UIcon
:name="recentSessionStatusIcon(item)"
class="size-3.5"
/>
<span>{{ recentSessionStatusLabel(item) }}</span>
</span>
</UBadge>
<UTooltip
v-if="item.finishedAt || item.startedAt || item.createdAt"
:text="
moment
.unix(
item.finishedAt ||
item.startedAt ||
item.createdAt ||
0,
)
.format('YYYY-M-DD H:mm Z')
"
>
<span
class="inline-flex items-center gap-1.5 cursor-help"
>
<UIcon name="i-lucide-clock-3" class="size-3.5" />
<time
:datetime="
moment
.unix(
item.finishedAt ||
item.startedAt ||
item.createdAt ||
0,
)
.toISOString()
"
>
{{
moment
.unix(
item.finishedAt ||
item.startedAt ||
item.createdAt ||
0,
)
.fromNow()
}}
</time>
</span>
</UTooltip>
</div>
</div>
</td> </td>
<td <td
class="w-12 px-3 py-3 text-center align-middle whitespace-nowrap" class="w-28 px-3 py-3 text-center align-middle whitespace-nowrap"
> >
<UButton <div class="flex items-center justify-end gap-1">
color="neutral" <UButton
variant="ghost" color="neutral"
size="xs" variant="ghost"
icon="i-lucide-x" size="xs"
square icon="i-lucide-terminal"
@click="removeFromHistory(index)" @click="() => void loadCommand(item.command)"
/> >
Fill
</UButton>
<UButton
color="neutral"
variant="ghost"
size="xs"
icon="i-lucide-x"
square
@click="hideRecentSession(item.sessionId)"
/>
</div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -315,6 +400,7 @@
</style> </style>
<script setup lang="ts"> <script setup lang="ts">
import moment from 'moment';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { FitAddon } from '@xterm/addon-fit'; import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css'; import '@xterm/xterm/css/xterm.css';
@ -327,7 +413,6 @@ import type { AutoCompleteOptions } from '~/types/autocomplete';
import { disableOpacity, enableOpacity } from '~/utils'; import { disableOpacity, enableOpacity } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation'; import { requirePageShell } from '~/utils/topLevelNavigation';
const MAX_HISTORY_ITEMS = 50;
const ACTIVE_SESSION_STATUSES = ['starting', 'running', 'reconnecting']; const ACTIVE_SESSION_STATUSES = ['starting', 'running', 'reconnecting'];
let flushFrame: number | null = null; let flushFrame: number | null = null;
@ -351,7 +436,6 @@ const terminal_window = useTemplateRef<HTMLDivElement>('terminal_window');
const commandInput = ref<InstanceType<typeof InputAutocomplete> | null>(null); const commandInput = ref<InstanceType<typeof InputAutocomplete> | null>(null);
const commandTextarea = ref<InstanceType<typeof TextareaAutocomplete> | null>(null); const commandTextarea = ref<InstanceType<typeof TextareaAutocomplete> | null>(null);
const storedCommand = useStorage<string>('console_command', ''); const storedCommand = useStorage<string>('console_command', '');
const commandHistory = useStorage<string[]>('console_command_history', []);
const pageCardUi = { const pageCardUi = {
root: 'flex min-h-0 flex-1 w-full bg-transparent', root: 'flex min-h-0 flex-1 w-full bg-transparent',
@ -371,6 +455,7 @@ const ytDlpOptions = computed<AutoCompleteOptions>(() =>
); );
const bufferedTranscript = consoleSession.bufferedTranscript; const bufferedTranscript = consoleSession.bufferedTranscript;
const recentSessions = consoleSession.recentSessions;
const sessionStatus = computed(() => consoleSession.state.value.status); const sessionStatus = computed(() => consoleSession.state.value.status);
const sessionError = computed(() => consoleSession.state.value.error); const sessionError = computed(() => consoleSession.state.value.error);
const sessionExitCode = computed(() => consoleSession.state.value.exitCode); const sessionExitCode = computed(() => consoleSession.state.value.exitCode);
@ -380,7 +465,7 @@ const runnableCommand = computed(() => displayCommand.value.replace(/\n/g, ' ').
const hasValidCommand = computed(() => Boolean(runnableCommand.value)); const hasValidCommand = computed(() => Boolean(runnableCommand.value));
const isLoading = computed(() => consoleSession.isLoading.value); const isLoading = computed(() => consoleSession.isLoading.value);
const isMultiLineInput = computed(() => Boolean(command.value && command.value.includes('\n'))); const isMultiLineInput = computed(() => Boolean(command.value && command.value.includes('\n')));
const historyEntries = computed(() => commandHistory.value); const recentSessionEntries = computed(() => recentSessions.value);
const canManualReconnect = computed(() => sessionStatus.value === 'reconnecting'); const canManualReconnect = computed(() => sessionStatus.value === 'reconnecting');
const showCancelButton = computed(() => const showCancelButton = computed(() =>
['starting', 'running', 'reconnecting'].includes(sessionStatus.value), ['starting', 'running', 'reconnecting'].includes(sessionStatus.value),
@ -481,6 +566,63 @@ const sessionStatusIcon = computed(() => {
} }
}); });
const sessionStatusSpinning = computed(() => ACTIVE_SESSION_STATUSES.includes(sessionStatus.value)); const sessionStatusSpinning = computed(() => ACTIVE_SESSION_STATUSES.includes(sessionStatus.value));
const isRecentSessionFailed = (item: (typeof recentSessions.value)[number]): boolean => {
return item.status === 'completed' && item.exitCode !== null && item.exitCode !== 0;
};
const recentSessionStatusLabel = (item: (typeof recentSessions.value)[number]): string => {
if (isRecentSessionFailed(item)) {
return 'Failed';
}
switch (item.status) {
case 'starting':
return 'Starting';
case 'running':
return 'Running';
default:
return 'Completed';
}
};
const recentSessionStatusColor = (
item: (typeof recentSessions.value)[number],
): 'success' | 'error' | 'info' | 'neutral' => {
if (isRecentSessionFailed(item)) {
return 'error';
}
switch (item.status) {
case 'starting':
case 'running':
return 'info';
default:
return item.exitCode === 0 ? 'success' : 'neutral';
}
};
const recentSessionStatusIcon = (item: (typeof recentSessions.value)[number]): string => {
if (isRecentSessionFailed(item)) {
return 'i-lucide-triangle-alert';
}
switch (item.status) {
case 'starting':
case 'running':
return 'i-lucide-loader-circle';
default:
return item.exitCode === 0 ? 'i-lucide-circle-check' : 'i-lucide-circle-dot';
}
};
const recentSessionStatusSpinning = (item: (typeof recentSessions.value)[number]): boolean => {
return ['starting', 'running'].includes(item.status);
};
watch(command, (value) => { watch(command, (value) => {
storedCommand.value = value; storedCommand.value = value;
}); });
@ -765,13 +907,6 @@ const handlePaste = async (event: ClipboardEvent): Promise<void> => {
} }
}; };
const addToHistory = (cmd: string): void => {
commandHistory.value = [cmd, ...commandHistory.value.filter((item) => item !== cmd)].slice(
0,
MAX_HISTORY_ITEMS,
);
};
const runCommand = async (): Promise<void> => { const runCommand = async (): Promise<void> => {
if (!canStartCommand.value) { if (!canStartCommand.value) {
return; return;
@ -795,8 +930,6 @@ const runCommand = async (): Promise<void> => {
await ensureTerminal(); await ensureTerminal();
addToHistory(displayCommand.value);
const started = await consoleSession.startSession({ const started = await consoleSession.startSession({
command: runnableCommand.value, command: runnableCommand.value,
displayCommand: displayCommand.value, displayCommand: displayCommand.value,
@ -871,14 +1004,19 @@ const loadCommand = async (cmd: string): Promise<void> => {
await focusInput(); await focusInput();
}; };
const clearHistory = async (): Promise<void> => { const hideRecentSession = (sessionId: string): void => {
if (historyEntries.value.length < 1) { consoleSession.hideRecentSession(sessionId);
};
const hideRecentSessions = async (): Promise<void> => {
if (recentSessionEntries.value.length < 1) {
return; return;
} }
const { status } = await dialog.confirmDialog({ const { status } = await dialog.confirmDialog({
title: 'Confirm Action', title: 'Confirm Action',
message: 'Clear commands history?', message: 'Hide the current recent runs from this browser?',
confirmText: 'Hide runs',
confirmColor: 'error', confirmColor: 'error',
}); });
@ -886,11 +1024,20 @@ const clearHistory = async (): Promise<void> => {
return; return;
} }
commandHistory.value = []; consoleSession.clearRecentSessions();
await focusInput();
}; };
const removeFromHistory = (index: number): void => { const replayRecentSession = async (item: (typeof recentSessions.value)[number]): Promise<void> => {
commandHistory.value = commandHistory.value.filter((_, i) => i !== index); try {
await consoleSession.replaySession(item);
restoreBufferedTerminalOutput();
} catch {
consoleSession.hideRecentSession(item.sessionId);
} finally {
await nextTick();
await focusInput();
}
}; };
onMounted(async () => { onMounted(async () => {
@ -907,6 +1054,7 @@ onMounted(async () => {
await ensureTerminal(); await ensureTerminal();
await consoleSession.restoreSession(); await consoleSession.restoreSession();
await consoleSession.fetchRecentSessions();
restoreBufferedTerminalOutput(); restoreBufferedTerminalOutput();
if (storedCommand.value.trim()) { if (storedCommand.value.trim()) {

View file

@ -1,5 +1,11 @@
<template> <template>
<main class="w-full min-w-0 max-w-full space-y-6"> <main class="w-full min-w-0 max-w-full space-y-6">
<div
class="pointer-events-none fixed inset-0 z-20 bg-black/45 backdrop-blur-[1px] transition-all duration-500 ease-out"
:class="lightsOut ? 'opacity-100' : 'opacity-0'"
aria-hidden="true"
/>
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="flex min-w-0 items-center gap-3"> <div class="flex min-w-0 items-center gap-3">
<span <span
@ -50,7 +56,7 @@
icon="i-lucide-refresh-cw" icon="i-lucide-refresh-cw"
:loading="isLoading" :loading="isLoading"
:disabled="isLoading" :disabled="isLoading"
@click="void reloadHistory({ order: 'DESC', perPage: config.app.default_pagination })" @click="() => reload({ order: 'DESC', perPage: config.app.default_pagination })"
> >
<span>Reload</span> <span>Reload</span>
</UButton> </UButton>
@ -78,7 +84,7 @@
show-edges show-edges
:sibling-count="0" :sibling-count="0"
@update:page=" @update:page="
(page) => loadHistory(page, { order: 'DESC', perPage: config.app.default_pagination }) (page) => load(page, { order: 'DESC', perPage: config.app.default_pagination })
" "
/> />
</div> </div>
@ -314,7 +320,7 @@
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-rotate-cw" icon="i-lucide-rotate-cw"
@click="void retryItem(item, true)" @click="() => retryItem(item, true)"
> >
Retry Retry
</UButton> </UButton>
@ -337,7 +343,7 @@
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-trash" icon="i-lucide-trash"
@click="void removeItem(item)" @click="() => removeItem(item)"
> >
Remove Remove
</UButton> </UButton>
@ -608,7 +614,7 @@
variant="outline" variant="outline"
icon="i-lucide-rotate-cw" icon="i-lucide-rotate-cw"
class="w-full justify-center" class="w-full justify-center"
@click="void retryItem(item, false)" @click="() => retryItem(item, false)"
> >
Retry Retry
</UButton> </UButton>
@ -631,7 +637,7 @@
variant="outline" variant="outline"
icon="i-lucide-trash" icon="i-lucide-trash"
class="w-full justify-center" class="w-full justify-center"
@click="void removeItem(item)" @click="() => removeItem(item)"
> >
{{ config.app.remove_files ? 'Remove' : 'Clear' }} {{ config.app.remove_files ? 'Remove' : 'Clear' }}
</UButton> </UButton>
@ -703,7 +709,7 @@
show-edges show-edges
:sibling-count="0" :sibling-count="0"
@update:page=" @update:page="
(page) => loadHistory(page, { order: 'DESC', perPage: config.app.default_pagination }) (page) => load(page, { order: 'DESC', perPage: config.app.default_pagination })
" "
/> />
</div> </div>
@ -714,8 +720,8 @@
:open="Boolean(video_item)" :open="Boolean(video_item)"
:dismissible="true" :dismissible="true"
:title="video_item?.title || 'Player'" :title="video_item?.title || 'Player'"
:ui="{ content: 'sm:max-w-5xl', body: 'p-0' }" :ui="{ content: lightsOut ? 'sm:max-w-5xl shadow-2xl' : 'sm:max-w-5xl', body: 'p-0' }"
@update:open="(open) => !open && (video_item = null)" @update:open="(open) => !open && closeVideo()"
> >
<template #body> <template #body>
<VideoPlayer <VideoPlayer
@ -725,8 +731,9 @@
:isControls="true" :isControls="true"
:item="video_item" :item="video_item"
class="w-full" class="w-full"
@closeModel="video_item = null" @closeModel="closeVideo()"
@error="async (error: string) => await box.alert(error)" @error="async (error: string) => await box.alert(error)"
@playback-state-change="(playing: boolean) => (playingNow = playing)"
/> />
</template> </template>
</UModal> </UModal>
@ -786,7 +793,7 @@ const stateStore = useQueueState();
const socketStore = useAppSocket(); const socketStore = useAppSocket();
const toast = useNotification(); const toast = useNotification();
const box = useConfirm(); const box = useConfirm();
const { confirmDialog } = useDialog(); const { confirmDialog, promptDialog } = useDialog();
const { toggleExpand, expandClass } = useExpandableMeta(); const { toggleExpand, expandClass } = useExpandableMeta();
const pendingDownloadFormItem = useState<item_request | Record<string, never>>( const pendingDownloadFormItem = useState<item_request | Record<string, never>>(
'pending-download-form-item', 'pending-download-form-item',
@ -797,10 +804,11 @@ const {
pagination, pagination,
isLoading, isLoading,
isLoaded, isLoaded,
loadHistory, load,
reloadHistory, reload,
deleteHistoryItems, remove,
historyMoveHandler, rename,
moveHandler,
} = useHistoryState(); } = useHistoryState();
const show_thumbnail = useStorage<boolean>('show_thumbnail', true); const show_thumbnail = useStorage<boolean>('show_thumbnail', true);
@ -825,23 +833,25 @@ const selectedElms = ref<string[]>([]);
const masterSelectAll = ref(false); const masterSelectAll = ref(false);
const embed_url = ref(''); const embed_url = ref('');
const video_item = ref<StoreItem | null>(null); const video_item = ref<StoreItem | null>(null);
const playingNow = ref(false);
const expandedMessages = reactive<Record<string, Set<string>>>({}); const expandedMessages = reactive<Record<string, Set<string>>>({});
const contentStyle = computed<'grid' | 'list'>(() => const contentStyle = computed<'grid' | 'list'>(() =>
isMobile.value ? 'grid' : display_style.value, isMobile.value ? 'grid' : display_style.value,
); );
const showThumbnails = computed(() => show_thumbnail.value && !hideThumbnail.value); const showThumbnails = computed(() => show_thumbnail.value && !hideThumbnail.value);
const lightsOut = computed(() => Boolean(video_item.value && playingNow.value));
const paginationInfo = computed(() => ({ const paginationInfo = computed(() => ({
...pagination.value, ...pagination.value,
isLoading: isLoading.value, isLoading: isLoading.value,
isLoaded: isLoaded.value, isLoaded: isLoaded.value,
})); }));
const handleHistoryItemMoved = historyMoveHandler(); const handleHistoryItemMoved = moveHandler();
onMounted(async () => { onMounted(async () => {
socketStore.on('item_moved', handleHistoryItemMoved); socketStore.on('item_moved', handleHistoryItemMoved);
await loadHistory(1, { order: 'DESC', perPage: config.app.default_pagination }); await load(1, { order: 'DESC', perPage: config.app.default_pagination });
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
@ -877,6 +887,11 @@ const close_info = (): void => {
info_view.value.useUrl = false; info_view.value.useUrl = false;
}; };
const closeVideo = (): void => {
playingNow.value = false;
video_item.value = null;
};
const view_info = ( const view_info = (
url: string, url: string,
useUrl: boolean = false, useUrl: boolean = false,
@ -1048,6 +1063,12 @@ const itemActionGroups = (item: StoreItem): Array<Array<Record<string, unknown>>
icon: 'i-lucide-file-code-2', icon: 'i-lucide-file-code-2',
onSelect: () => void generateNfo(item), onSelect: () => void generateNfo(item),
}); });
mediaActions.push({
label: 'Rename file',
icon: 'i-lucide-pencil',
onSelect: () => void renameFile(item),
});
} else if (isEmbedable(item.url)) { } else if (isEmbedable(item.url)) {
mediaActions.push({ mediaActions.push({
label: 'Play video', label: 'Play video',
@ -1127,9 +1148,9 @@ const deleteSelectedItems = async (): Promise<void> => {
return; return;
} }
await deleteHistoryItems({ ids: [...selectedElms.value], removeFile: config.app.remove_files }); await remove({ ids: [...selectedElms.value], removeFile: config.app.remove_files });
selectedElms.value = []; selectedElms.value = [];
await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination }); await reload({ order: 'DESC', perPage: config.app.default_pagination });
}; };
const clearCompleted = async (): Promise<void> => { const clearCompleted = async (): Promise<void> => {
@ -1141,9 +1162,9 @@ const clearCompleted = async (): Promise<void> => {
selectedElms.value = []; selectedElms.value = [];
await deleteHistoryItems({ status: 'finished,skip', removeFile: false }); await remove({ status: 'finished,skip', removeFile: false });
await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination }); await reload({ order: 'DESC', perPage: config.app.default_pagination });
}; };
const clearIncomplete = async (): Promise<void> => { const clearIncomplete = async (): Promise<void> => {
@ -1152,8 +1173,8 @@ const clearIncomplete = async (): Promise<void> => {
} }
selectedElms.value = []; selectedElms.value = [];
await deleteHistoryItems({ status: '!finished', removeFile: false }); await remove({ status: '!finished', removeFile: false });
await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination }); await reload({ order: 'DESC', perPage: config.app.default_pagination });
}; };
const setIcon = (item: StoreItem): string => { const setIcon = (item: StoreItem): string => {
@ -1307,10 +1328,10 @@ const archiveItem = async (item: StoreItem, opts = {}): Promise<void> => {
} }
if ((opts as { remove_history?: boolean })?.remove_history) { if ((opts as { remove_history?: boolean })?.remove_history) {
await deleteHistoryItems({ ids: [item._id], removeFile: false }); await remove({ ids: [item._id], removeFile: false });
} }
await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination }); await reload({ order: 'DESC', perPage: config.app.default_pagination });
}; };
const removeItem = async (item: StoreItem): Promise<void> => { const removeItem = async (item: StoreItem): Promise<void> => {
@ -1324,13 +1345,13 @@ const removeItem = async (item: StoreItem): Promise<void> => {
return; return;
} }
await deleteHistoryItems({ ids: [item._id], removeFile: config.app.remove_files }); await remove({ ids: [item._id], removeFile: config.app.remove_files });
if (selectedElms.value.includes(item._id || '')) { if (selectedElms.value.includes(item._id || '')) {
selectedElms.value = selectedElms.value.filter((entry) => entry !== item._id); selectedElms.value = selectedElms.value.filter((entry) => entry !== item._id);
} }
await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination }); await reload({ order: 'DESC', perPage: config.app.default_pagination });
}; };
const retryItem = async ( const retryItem = async (
@ -1349,13 +1370,13 @@ const retryItem = async (
auto_start: item.auto_start, auto_start: item.auto_start,
}; };
await deleteHistoryItems({ ids: [item._id], removeFile: remove_file }); await remove({ ids: [item._id], removeFile: remove_file });
if (selectedElms.value.includes(item._id || '')) { if (selectedElms.value.includes(item._id || '')) {
selectedElms.value = selectedElms.value.filter((entry) => entry !== item._id); selectedElms.value = selectedElms.value.filter((entry) => entry !== item._id);
} }
await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination }); await reload({ order: 'DESC', perPage: config.app.default_pagination });
if (true === re_add) { if (true === re_add) {
toast.info('Cleared the item from history, and added it to the new download form.'); toast.info('Cleared the item from history, and added it to the new download form.');
@ -1532,10 +1553,10 @@ const removeFromArchive = async (
} }
if (opts?.remove_history) { if (opts?.remove_history) {
await deleteHistoryItems({ ids: [item._id], removeFile: file_delete }); await remove({ ids: [item._id], removeFile: file_delete });
} }
await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination }); await reload({ order: 'DESC', perPage: config.app.default_pagination });
}; };
const isQueuedAnimation = (item: StoreItem): string => { const isQueuedAnimation = (item: StoreItem): string => {
@ -1565,4 +1586,33 @@ const generateNfo = async (item: StoreItem): Promise<void> => {
toast.error(`Error: ${error.message}`); toast.error(`Error: ${error.message}`);
} }
}; };
const renameFile = async (item: StoreItem): Promise<void> => {
if (!item.filename) {
return;
}
const currentName = item.filename.split('/').pop() || item.filename;
const { status, value: newName } = await promptDialog({
title: 'Rename File',
message: `Enter new name for '${currentName}' (and its sidecars):`,
initial: currentName,
confirmText: 'Rename',
cancelText: 'Cancel',
});
if (!status) {
return;
}
const trimmedName = (newName || '').trim();
if (!trimmedName || trimmedName === currentName) {
return;
}
const success = await rename(item, trimmedName);
if (success) {
toast.success(`Renamed '${currentName}'.`);
}
};
</script> </script>

View file

@ -661,8 +661,8 @@
<code>Actions &gt; Archive All</code> to mark existing items as already downloaded. <code>Actions &gt; Archive All</code> to mark existing items as already downloaded.
</li> </li>
<li> <li>
<strong>Custom Handlers:</strong> Leave timer empty for custom handler definitions. The <strong>Timer Requirement:</strong> Tasks must have either a valid timer or a supported
handler runs hourly and doesn't require timer. handler. If the URL does not match a handler, set a CRON timer.
</li> </li>
<li> <li>
<strong>Generate metadata:</strong> will attempt first to save metadata based on the <strong>Generate metadata:</strong> will attempt first to save metadata based on the

View file

@ -203,7 +203,7 @@
</div> </div>
</div> </div>
<p class="description">Getting the interface ready. Please wait a moment...</p> <p class="description">Getting the interface ready.</p>
<div class="loader" aria-hidden="true"></div> <div class="loader" aria-hidden="true"></div>
<p class="hint">Please wait...</p> <p class="hint">Please wait...</p>

View file

@ -16,4 +16,29 @@ type version_check = {
}; };
}; };
export { ImportedItem, version_check }; type TerminalSessionStatus = 'starting' | 'running' | 'completed' | 'interrupted' | 'failed';
type TerminalSessionItem = {
session_id: string;
command: string;
status: TerminalSessionStatus;
created_at: number | null;
started_at: number | null;
finished_at: number | null;
expires_at: number | null;
available_until: number | null;
exit_code: number | null;
last_sequence: number;
};
type TerminalSessionsResponse = {
items: Array<TerminalSessionItem>;
};
export {
ImportedItem,
TerminalSessionItem,
TerminalSessionStatus,
TerminalSessionsResponse,
version_check,
};

12
ui/app/types/subtitles.ts Normal file
View file

@ -0,0 +1,12 @@
export type SubtitleTrack = {
lang: string;
name: string;
source_format: 'vtt' | 'srt' | 'ass';
delivery_format: 'vtt' | 'ass';
renderer: 'native' | 'assjs';
url: string;
};
export type SubtitleManifestResponse = {
subtitles: SubtitleTrack[];
};

View file

@ -2,14 +2,14 @@ type KeyboardShortcutContext = {
video: HTMLVideoElement; video: HTMLVideoElement;
}; };
type video_track_element = { type VideoTrackElement = {
file: string; file: string;
kind: string; kind: string;
label: string; label: string;
lang: string; lang: string;
}; };
type video_source_element = { type VideoSourceElement = {
src: string; src: string;
type: string; type: string;
onerror: (e: Event) => void; onerror: (e: Event) => void;
@ -68,7 +68,7 @@ type FFProbeResult = {
is_audio: boolean; is_audio: boolean;
}; };
type file_info = { type FileInfo = {
title: string; title: string;
ffprobe: FFProbeResult; ffprobe: FFProbeResult;
mimetype: string; mimetype: string;
@ -80,10 +80,17 @@ type file_info = {
error?: string; error?: string;
}; };
type PlayerSourceElement = {
src: string;
type?: string;
onerror?: (e: Event) => void;
};
export type { export type {
video_track_element, VideoTrackElement,
video_source_element, VideoSourceElement,
PlayerSourceElement,
FFProbeResult, FFProbeResult,
file_info, FileInfo,
KeyboardShortcutContext, KeyboardShortcutContext,
}; };

View file

@ -0,0 +1,53 @@
type FullscreenCapableElement = HTMLElement & {
webkitRequestFullscreen?: () => Promise<void> | void;
};
type FullscreenCapableDocument = Document & {
webkitFullscreenElement?: Element | null;
webkitExitFullscreen?: () => Promise<void> | void;
};
export function getFullscreenElement(doc: Document = document): Element | null {
const fullscreenDocument = doc as FullscreenCapableDocument;
return fullscreenDocument.fullscreenElement || fullscreenDocument.webkitFullscreenElement || null;
}
export function canRequestFullscreen(
element: HTMLElement | null | undefined,
): element is HTMLElement {
return Boolean(
element &&
(typeof element.requestFullscreen === 'function' ||
typeof (element as FullscreenCapableElement).webkitRequestFullscreen === 'function'),
);
}
export async function requestElementFullscreen(element: HTMLElement): Promise<void> {
const fullscreenElement = element as FullscreenCapableElement;
if (typeof fullscreenElement.requestFullscreen === 'function') {
await fullscreenElement.requestFullscreen();
return;
}
if (typeof fullscreenElement.webkitRequestFullscreen === 'function') {
await fullscreenElement.webkitRequestFullscreen();
return;
}
throw new Error('Fullscreen API unavailable');
}
export async function exitDocumentFullscreen(doc: Document = document): Promise<void> {
const fullscreenDocument = doc as FullscreenCapableDocument;
if (typeof fullscreenDocument.exitFullscreen === 'function') {
await fullscreenDocument.exitFullscreen();
return;
}
if (typeof fullscreenDocument.webkitExitFullscreen === 'function') {
await fullscreenDocument.webkitExitFullscreen();
return;
}
throw new Error('Fullscreen API unavailable');
}

View file

@ -1,6 +1,19 @@
import type { KeyboardShortcutContext } from '~/types/video'; import type { KeyboardShortcutContext } from '~/types/video';
export const handlePlayPause = (ctx: KeyboardShortcutContext) => { export const clampMediaTime = (media: HTMLMediaElement, nextTime: number) => {
const duration =
Number.isFinite(media.duration) && media.duration > 0 ? media.duration : Infinity;
media.currentTime = Math.min(Math.max(nextTime, 0), duration);
};
export const clampMediaVolume = (volume: number) => {
return Math.min(1, Math.max(0, volume));
};
export const hasModifierKey = (event: KeyboardEvent): boolean =>
event.ctrlKey || event.metaKey || event.altKey;
export const playPause = (ctx: KeyboardShortcutContext) => {
if (ctx.video.paused) { if (ctx.video.paused) {
ctx.video.play(); ctx.video.play();
} else { } else {
@ -8,30 +21,29 @@ export const handlePlayPause = (ctx: KeyboardShortcutContext) => {
} }
}; };
export const handleRewind = (ctx: KeyboardShortcutContext, seconds: number = 10) => { export const rewind = (ctx: KeyboardShortcutContext, seconds: number = 10) => {
ctx.video.currentTime = Math.max(0, ctx.video.currentTime - seconds); ctx.video.currentTime = Math.max(0, ctx.video.currentTime - seconds);
}; };
export const handleForward = (ctx: KeyboardShortcutContext, seconds: number = 10) => { export const forward = (ctx: KeyboardShortcutContext, seconds: number = 10) => {
ctx.video.currentTime = Math.min(ctx.video.duration, ctx.video.currentTime + seconds); ctx.video.currentTime = Math.min(ctx.video.duration, ctx.video.currentTime + seconds);
}; };
export const handleMute = (ctx: KeyboardShortcutContext) => { export const mute = (ctx: KeyboardShortcutContext) => {
ctx.video.muted = !ctx.video.muted; ctx.video.muted = !ctx.video.muted;
}; };
export const handleVolumeChange = (ctx: KeyboardShortcutContext, delta: number) => { export const changeVolume = (ctx: KeyboardShortcutContext, delta: number) => {
const newVolume = Math.max(0, Math.min(1, ctx.video.volume + delta)); const volume = clampMediaVolume(ctx.video.volume + delta);
ctx.video.volume = newVolume; ctx.video.volume = volume;
}; };
export const handlePlaybackSpeedChange = (ctx: KeyboardShortcutContext, delta: number) => { export const changeSpeed = (ctx: KeyboardShortcutContext, delta: number) => {
const currentSpeed = ctx.video.playbackRate; const speed = ctx.video.playbackRate;
const newSpeed = Math.max(0.25, Math.min(2, currentSpeed + delta)); ctx.video.playbackRate = Math.max(0.25, Math.min(2, speed + delta));
ctx.video.playbackRate = newSpeed;
}; };
export const handleFrameStep = ( export const frameStep = (
ctx: KeyboardShortcutContext, ctx: KeyboardShortcutContext,
direction: 'forward' | 'backward' = 'forward', direction: 'forward' | 'backward' = 'forward',
) => { ) => {
@ -47,21 +59,21 @@ export const handleFrameStep = (
); );
}; };
export const handleSeekToPercent = (ctx: KeyboardShortcutContext, percent: number) => { export const seekToPercent = (ctx: KeyboardShortcutContext, percent: number) => {
ctx.video.currentTime = (percent / 100) * ctx.video.duration; ctx.video.currentTime = (percent / 100) * ctx.video.duration;
}; };
export const handleSeekBackward = (ctx: KeyboardShortcutContext, seconds: number = 5) => { export const seekBackward = (ctx: KeyboardShortcutContext, seconds: number = 5) => {
ctx.video.currentTime = Math.max(0, ctx.video.currentTime - seconds); clampMediaTime(ctx.video, ctx.video.currentTime - seconds);
}; };
export const handleSeekForward = (ctx: KeyboardShortcutContext, seconds: number = 5) => { export const seekForward = (ctx: KeyboardShortcutContext, seconds: number = 5) => {
ctx.video.currentTime = Math.min(ctx.video.duration, ctx.video.currentTime + seconds); clampMediaTime(ctx.video, ctx.video.currentTime + seconds);
}; };
export const handleFullscreen = (videoElement: HTMLVideoElement) => { export const fullscreen = (video: HTMLVideoElement) => {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
videoElement.requestFullscreen().catch((err) => { video.requestFullscreen().catch((err) => {
console.error(`Error attempting to enable fullscreen: ${err.message}`); console.error(`Error attempting to enable fullscreen: ${err.message}`);
}); });
} else { } else {
@ -69,20 +81,20 @@ export const handleFullscreen = (videoElement: HTMLVideoElement) => {
} }
}; };
export const handlePictureInPicture = async (videoElement: HTMLVideoElement) => { export const pictureInPicture = async (video: HTMLVideoElement) => {
try { try {
if (document.pictureInPictureElement) { if (document.pictureInPictureElement) {
await document.exitPictureInPicture(); await document.exitPictureInPicture();
} else if (document.pictureInPictureEnabled) { } else if (document.pictureInPictureEnabled) {
await videoElement.requestPictureInPicture(); await video.requestPictureInPicture();
} }
} catch (error) { } catch (error) {
console.error(`Picture-in-Picture error: ${error}`); console.error(`Picture-in-Picture error: ${error}`);
} }
}; };
export const handleToggleCaptions = (videoElement: HTMLVideoElement) => { export const toggleCaptions = (video: HTMLVideoElement) => {
const textTracks = videoElement.textTracks; const textTracks = video.textTracks;
if (0 === textTracks.length) { if (0 === textTracks.length) {
return; return;
@ -112,5 +124,4 @@ export const shouldHandleKeyboardShortcut = (event: KeyboardEvent): boolean => {
return true; return true;
}; };
export const isModifierKey = (event: KeyboardEvent): boolean => export const modifierKey = (event: KeyboardEvent): boolean => hasModifierKey(event);
event.ctrlKey || event.metaKey || event.altKey;

File diff suppressed because it is too large Load diff

View file

@ -90,6 +90,10 @@ export default defineNuxtConfig({
'marked-base-url', 'marked-base-url',
'marked-alert', 'marked-alert',
'marked-gfm-heading-id', 'marked-gfm-heading-id',
'hls.js',
'assjs',
'@vue/devtools-core',
'@vue/devtools-kit',
], ],
}, },
server: { server: {

View file

@ -21,42 +21,43 @@
}, },
"web-types": "./web-types.json", "web-types": "./web-types.json",
"dependencies": { "dependencies": {
"@iconify-json/lucide": "^1.2.102", "@iconify-json/lucide": "^1.2.103",
"@microsoft/fetch-event-source": "^2.0.1", "@microsoft/fetch-event-source": "^2.0.1",
"@nuxt/eslint": "^1.15.2", "@nuxt/eslint": "^1.15.2",
"@nuxt/eslint-config": "^1.15.2", "@nuxt/eslint-config": "^1.15.2",
"@nuxt/ui": "^4.6.1", "@nuxt/ui": "^4.7.0",
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
"@vueuse/nuxt": "^14.2.1", "@vueuse/nuxt": "^14.2.1",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"assjs": "^0.1.6",
"cron-parser": "^5.5.0", "cron-parser": "^5.5.0",
"cronstrue": "^3.14.0", "cronstrue": "^3.14.0",
"hls.js": "^1.6.16", "hls.js": "^1.6.16",
"marked": "^18.0.0", "marked": "^18.0.2",
"marked-alert": "^2.1.2", "marked-alert": "^2.1.2",
"marked-base-url": "^1.1.9", "marked-base-url": "^1.1.9",
"marked-gfm-heading-id": "^4.1.4", "marked-gfm-heading-id": "^4.1.4",
"moment": "^2.30.1", "moment": "^2.30.1",
"nuxt": "^4.4.2", "nuxt": "^4.4.2",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.4",
"vue": "^3.5.32", "vue": "^3.5.33",
"vue-router": "^5.0.4" "vue-router": "^5.0.6"
}, },
"trustedDependencies": [ "trustedDependencies": [
"esbuild", "esbuild",
"@parcel/watcher" "@parcel/watcher"
], ],
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.12", "@types/bun": "^1.3.13",
"@types/node": "25.6.0", "@types/node": "25.6.0",
"@types/jsdom": "^28.0.1", "@types/jsdom": "^28.0.1",
"@typescript-eslint/parser": "^8.58.2", "@typescript-eslint/parser": "^8.59.0",
"eslint": "^10.2.0", "eslint": "^10.2.1",
"jsdom": "^29.0.2", "jsdom": "^29.0.2",
"oxfmt": "^0.45.0", "oxfmt": "^0.46.0",
"typescript": "^6.0.2", "typescript": "^6.0.3",
"vue-eslint-parser": "^10.4.0", "vue-eslint-parser": "^10.4.0",
"vue-tsc": "^3.2.6" "vue-tsc": "^3.2.7"
} }
} }

View file

@ -78,7 +78,7 @@ describe('useConditions', () => {
}) })
describe('loadConditions', () => { describe('loadConditions', () => {
it('loads conditions with pagination successfully', async () => { it('load_conditions', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -101,7 +101,7 @@ describe('useConditions', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('sorts conditions by priority then name', async () => { it('sort_priority', async () => {
const items = [ const items = [
{ ...mockCondition, id: 1, name: 'B', priority: 2 }, { ...mockCondition, id: 1, name: 'B', priority: 2 },
{ ...mockCondition, id: 2, name: 'A', priority: 2 }, { ...mockCondition, id: 2, name: 'A', priority: 2 },
@ -136,7 +136,7 @@ describe('useConditions', () => {
}) })
describe('getCondition', () => { describe('getCondition', () => {
it('fetches a single condition successfully', async () => { it('get_condition', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -157,7 +157,7 @@ describe('useConditions', () => {
}) })
describe('createCondition', () => { describe('createCondition', () => {
it('creates a condition successfully', async () => { it('create_condition', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -183,7 +183,7 @@ describe('useConditions', () => {
}) })
describe('updateCondition', () => { describe('updateCondition', () => {
it('updates a condition successfully', async () => { it('update_condition', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -202,7 +202,7 @@ describe('useConditions', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('removes id field from condition before sending', async () => { it('strip_update_id', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -216,13 +216,13 @@ describe('useConditions', () => {
await conditions.updateCondition(1, mockCondition) await conditions.updateCondition(1, mockCondition)
const requestBody = JSON.parse((requestSpy.mock.calls[0][1] as any).body) const requestBody = JSON.parse((requestSpy.mock.calls[0][1] as any).body)
expect(requestBody.id).toBeUndefined() expect('id' in requestBody).toBe(false)
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
}) })
describe('patchCondition', () => { describe('patchCondition', () => {
it('patches a condition successfully', async () => { it('patch_condition', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -242,7 +242,7 @@ describe('useConditions', () => {
}) })
describe('deleteCondition', () => { describe('deleteCondition', () => {
it('deletes a condition successfully', async () => { it('delete_condition', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -265,7 +265,7 @@ describe('useConditions', () => {
}) })
describe('testCondition', () => { describe('testCondition', () => {
it('tests a condition successfully', async () => { it('test_condition', async () => {
const testResponse = { const testResponse = {
status: true, status: true,
condition: 'duration > 60', condition: 'duration > 60',
@ -292,7 +292,7 @@ describe('useConditions', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('handles test errors', async () => { it('store_test_error', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -309,7 +309,7 @@ describe('useConditions', () => {
}) })
expect(result).toBeNull() expect(result).toBeNull()
expect(conditions.lastError.value).toBeTruthy() expect(conditions.lastError.value).toBe('Invalid URL')
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
}) })

View file

@ -109,7 +109,7 @@ afterEach(() => {
}) })
describe('useConsoleSession', () => { describe('useConsoleSession', () => {
it('starts a session, persists prompt transcript, and stores streamed output', async () => { it('start_session', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -166,7 +166,7 @@ describe('useConsoleSession', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('restores a running session using both since and Last-Event-ID', async () => { it('restore_with_cursor', async () => {
const session = useConsoleSession() const session = useConsoleSession()
session.state.value = { session.state.value = {
sessionId: 'sess-restore', sessionId: 'sess-restore',
@ -196,7 +196,6 @@ describe('useConsoleSession', () => {
expect(fetchEventSourceMock).toHaveBeenCalledTimes(1) expect(fetchEventSourceMock).toHaveBeenCalledTimes(1)
const streamCall = fetchEventSourceMock.mock.calls[0] const streamCall = fetchEventSourceMock.mock.calls[0]
expect(streamCall).toBeDefined()
if (!streamCall) { if (!streamCall) {
throw new Error('Expected fetchEventSource to be called once.') throw new Error('Expected fetchEventSource to be called once.')
} }
@ -209,7 +208,7 @@ describe('useConsoleSession', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('marks the session expired and stops retry setup on stream not found', async () => { it('mark_expired_404', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -243,7 +242,7 @@ describe('useConsoleSession', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('restores a persisted session even after a local stream error state', async () => { it('restore_after_stream_error', async () => {
const session = useConsoleSession() const session = useConsoleSession()
session.state.value = { session.state.value = {
sessionId: 'sess-reconnect', sessionId: 'sess-reconnect',
@ -277,7 +276,7 @@ describe('useConsoleSession', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('deduplicates replayed events when reconnect resumes from the last event id', async () => { it('dedupe_replayed_events', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -333,7 +332,7 @@ describe('useConsoleSession', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('refreshes final metadata after close so interrupted sessions keep their backend status', async () => { it('refresh_final_status', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -382,7 +381,7 @@ describe('useConsoleSession', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('requests cancellation for the active session without dropping local state early', async () => { it('cancel_without_reset', async () => {
const session = useConsoleSession() const session = useConsoleSession()
session.state.value = { session.state.value = {
sessionId: 'sess-stop', sessionId: 'sess-stop',
@ -410,20 +409,15 @@ describe('useConsoleSession', () => {
expect(session.state.value.status).toBe('running') expect(session.state.value.status).toBe('running')
expect(session.state.value.error).toBe('') expect(session.state.value.error).toBe('')
const requestCall = requestSpy.mock.calls.at(-1) const deleteCall = requestSpy.mock.calls.find(
expect(requestCall).toBeDefined() (call) => call[0] === '/api/system/terminal/sess-stop' && call[1]?.method === 'DELETE',
if (!requestCall) { )
throw new Error('Expected request to be called for session cancellation.') expect(deleteCall).toEqual(['/api/system/terminal/sess-stop', { method: 'DELETE' }])
}
const [path, options] = requestCall as [string, { method: string }]
expect(path).toBe('/api/system/terminal/sess-stop')
expect(options.method).toBe('DELETE')
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('disconnects the local stream without issuing a cancel request', async () => { it('disconnect_local', async () => {
const session = useConsoleSession() const session = useConsoleSession()
session.state.value = { session.state.value = {
sessionId: 'sess-detach', sessionId: 'sess-detach',
@ -454,7 +448,7 @@ describe('useConsoleSession', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('refreshes metadata when cancel targets a missing session', async () => { it('refresh_missing_cancel', async () => {
const session = useConsoleSession() const session = useConsoleSession()
session.state.value = { session.state.value = {
sessionId: 'sess-missing', sessionId: 'sess-missing',
@ -483,7 +477,7 @@ describe('useConsoleSession', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('clears visible transcript without dropping the active session cursor', () => { it('clear_transcript', () => {
const session = useConsoleSession() const session = useConsoleSession()
session.state.value = { session.state.value = {
sessionId: 'sess-active', sessionId: 'sess-active',
@ -502,4 +496,128 @@ describe('useConsoleSession', () => {
expect(session.state.value.status).toBe('running') expect(session.state.value.status).toBe('running')
expect(session.state.value.lastEventId).toBe('15') expect(session.state.value.lastEventId).toBe('15')
}) })
it('filter_hidden_recents', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: {
items: [
{
session_id: 'sess-newer',
command: '--version',
status: 'completed',
created_at: 10,
started_at: 11,
finished_at: 20,
expires_at: 100,
available_until: 100,
exit_code: 0,
last_sequence: 2,
},
{
session_id: 'sess-hidden',
command: '--help',
status: 'running',
created_at: 1,
started_at: 2,
finished_at: null,
expires_at: null,
available_until: null,
exit_code: null,
last_sequence: 1,
},
],
},
}),
)
const session = useConsoleSession()
session.hideRecentSession('sess-hidden')
await session.fetchRecentSessions()
expect(session.recentSessions.value).toEqual([
{
sessionId: 'sess-newer',
command: '--version',
status: 'completed',
createdAt: 10,
startedAt: 11,
finishedAt: 20,
expiresAt: 100,
availableUntil: 100,
exitCode: 0,
lastSequence: 2,
},
])
requestSpy.mockRestore()
})
it('replay_recent_session', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: {
items: [
{
session_id: 'sess-replay',
command: '--help',
status: 'completed',
created_at: 10,
started_at: 11,
finished_at: 12,
expires_at: 100,
available_until: 100,
exit_code: 0,
last_sequence: 2,
},
],
},
}),
)
fetchEventSourceMock.mockImplementationOnce(async (_url, options) => {
await options.onopen(new Response('', { status: 200 }))
options.onmessage({
event: 'output',
id: '1',
data: JSON.stringify({ line: 'restored output' }),
})
options.onmessage({
event: 'close',
id: '2',
data: JSON.stringify({ exitcode: 0 }),
})
})
const session = useConsoleSession()
const replayed = await session.replaySession({
sessionId: 'sess-replay',
command: '--help',
status: 'completed',
createdAt: 10,
startedAt: 11,
finishedAt: 12,
expiresAt: 100,
availableUntil: 100,
exitCode: 0,
lastSequence: 2,
})
await Promise.resolve()
await Promise.resolve()
expect(replayed).toBe(true)
expect(session.state.value.sessionId).toBe('sess-replay')
expect(session.state.value.transcript).toEqual(['restored output\n'])
const streamCall = fetchEventSourceMock.mock.calls.at(-1)
expect(streamCall?.[0]).toContain('/base-path/api/system/terminal/sess-replay/stream')
requestSpy.mockRestore()
})
}) })

View file

@ -55,7 +55,7 @@ afterEach(() => {
}); });
describe('useDirtyCloseGuard', () => { describe('useDirtyCloseGuard', () => {
it('confirms discard on route leave when the editor is open and dirty', async () => { it('confirm_route_leave', async () => {
const open = ref(true); const open = ref(true);
const dirty = ref(true); const dirty = ref(true);
const onDiscard = mock(() => {}); const onDiscard = mock(() => {});
@ -75,7 +75,7 @@ describe('useDirtyCloseGuard', () => {
expect(open.value).toBe(false); expect(open.value).toBe(false);
}); });
it('blocks route updates when the user keeps editing', async () => { it('block_route_update', async () => {
const open = ref(true); const open = ref(true);
const dirty = ref(true); const dirty = ref(true);
@ -94,7 +94,7 @@ describe('useDirtyCloseGuard', () => {
expect(open.value).toBe(true); expect(open.value).toBe(true);
}); });
it('only prevents browser unload while the editor is open and dirty, then removes the listener on unmount', () => { it('guard_beforeunload', () => {
const open = ref(true); const open = ref(true);
const dirty = ref(true); const dirty = ref(true);
@ -130,7 +130,7 @@ describe('useDirtyCloseGuard', () => {
expect(afterUnmountEvent.defaultPrevented).toBe(false); expect(afterUnmountEvent.defaultPrevented).toBe(false);
}); });
it('dedupes concurrent close requests into one discard dialog', async () => { it('dedupe_close_prompt', async () => {
const open = ref(true); const open = ref(true);
const dirty = ref(true); const dirty = ref(true);
const onDiscard = mock(() => {}); const onDiscard = mock(() => {});
@ -162,7 +162,7 @@ describe('useDirtyCloseGuard', () => {
expect(open.value).toBe(false); expect(open.value).toBe(false);
}); });
it('does not guard route changes when the editor is clean', async () => { it('skip_clean_editor', async () => {
const open = ref(true); const open = ref(true);
const dirty = ref(false); const dirty = ref(false);

View file

@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import { ref } from 'vue';
describe('usePlayerShortcuts', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
afterEach(() => {
document.body.innerHTML = '';
});
it('toggle_subs_c', async () => {
const { usePlayerShortcuts } = await import('~/composables/usePlayerShortcuts');
const addEventListenerSpy = spyOn(document, 'addEventListener');
const subtitleTrack = { kind: 'subtitles', mode: 'showing' } as TextTrack;
const videoElement = {
paused: true,
currentTime: 0,
duration: 100,
playbackRate: 1,
volume: 1,
muted: false,
play: async () => {},
pause: () => {},
textTracks: [subtitleTrack],
} as unknown as HTMLVideoElement;
const subtitleEnabled = ref(true);
usePlayerShortcuts({
enabled: ref(true),
media: ref(videoElement),
video: ref(videoElement),
canToggleSubs: ref(true),
toggleSubtitles: () => {
subtitleEnabled.value = !subtitleEnabled.value;
},
toggleFullscreen: () => {},
});
const handler = addEventListenerSpy.mock.calls.find((call) => call[0] === 'keydown')?.[1];
if (!handler || typeof handler !== 'function') {
throw new Error('Expected keydown handler to be registered');
}
const preventDefault = mock(() => {});
const stopPropagation = mock(() => {});
handler({
key: 'c',
target: document.body,
preventDefault,
stopPropagation,
ctrlKey: false,
metaKey: false,
altKey: false,
} as unknown as KeyboardEvent);
expect(subtitleTrack.mode).toBe('hidden');
expect(subtitleEnabled.value).toBe(false);
addEventListenerSpy.mockRestore();
});
it('close_help_first', async () => {
const { usePlayerShortcuts } = await import('~/composables/usePlayerShortcuts');
const media = {
paused: true,
currentTime: 0,
duration: 100,
playbackRate: 1,
volume: 1,
muted: false,
play: async () => {},
pause: () => {},
textTracks: [],
} as unknown as HTMLMediaElement;
const showHelp = ref(true);
const closePlayer = mock(() => {});
usePlayerShortcuts({
enabled: ref(true),
media: ref(media),
video: ref(null),
canToggleSubs: ref(false),
helpOpen: showHelp,
toggleSubtitles: () => {},
toggleFullscreen: () => {},
closePlayer,
});
document.body.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(showHelp.value).toBe(false);
expect(closePlayer).toHaveBeenCalledTimes(0);
document.body.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(closePlayer).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,291 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import { nextTick, ref } from 'vue';
type MockResponseInput = {
ok: boolean;
status: number;
jsonData?: unknown;
textData?: string;
};
const runtimeConfig = {
app: {
baseURL: '/',
},
};
const testGlobals = globalThis as typeof globalThis & {
useRuntimeConfig?: () => typeof runtimeConfig;
useNotification?: () => { error: ReturnType<typeof mock> };
};
const notificationErrorMock = mock(() => {});
testGlobals.useRuntimeConfig = () => runtimeConfig;
testGlobals.useNotification = () => ({ error: notificationErrorMock });
mock.module('#imports', () => ({
useRuntimeConfig: () => runtimeConfig,
useNotification: () => ({ error: notificationErrorMock }),
}));
function createMockResponse({ ok, status, jsonData, textData }: MockResponseInput): Response {
return {
ok,
status,
headers: new Headers({ 'Content-Type': 'application/json' }),
redirected: false,
statusText: ok ? 'OK' : 'Error',
type: 'basic',
url: '',
body: null,
bodyUsed: false,
clone() {
return this;
},
async json() {
return jsonData;
},
async text() {
return textData ?? JSON.stringify(jsonData ?? {});
},
arrayBuffer: async () => new ArrayBuffer(0),
blob: async () => new Blob(),
formData: async () => new FormData(),
} as Response;
}
async function flushPromises(times = 4) {
for (let index = 0; index < times; index += 1) {
await Promise.resolve();
await nextTick();
}
}
describe('usePlayerSubtitles', () => {
const fetchMock = mock(async (_input: RequestInfo | URL) => createMockResponse({ ok: true, status: 200, jsonData: {} }));
const assShowMock = mock(() => {});
const assDestroyMock = mock(() => {});
const assConstructorMock = mock(() => ({
show: assShowMock,
destroy: assDestroyMock,
}));
beforeEach(() => {
runtimeConfig.app.baseURL = '/';
fetchMock.mockClear();
assShowMock.mockClear();
assDestroyMock.mockClear();
assConstructorMock.mockClear();
notificationErrorMock.mockClear();
globalThis.fetch = fetchMock as typeof fetch;
});
afterEach(() => {
delete (globalThis as { fetch?: typeof fetch }).fetch;
});
it('load_native_track', async () => {
fetchMock.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: {
subtitles: [
{
lang: 'en',
name: 'English',
source_format: 'vtt',
delivery_format: 'vtt',
renderer: 'native',
url: '/api/player/subtitles/vtt/video.vtt',
},
{
lang: 'en',
name: 'Styled',
source_format: 'ass',
delivery_format: 'ass',
renderer: 'assjs',
url: '/api/player/subtitles/ass/video.ass',
},
],
},
}),
);
const { usePlayerSubtitles } = await import('~/composables/usePlayerSubtitles');
const manifestUrl = ref('/api/player/subtitles/manifest/video%20file.mkv');
const isVideo = ref(true);
const canPlay = ref(true);
const shouldRender = ref(false);
const video = ref<HTMLVideoElement | null>(document.createElement('video'));
const overlay = ref<HTMLElement | null>(document.createElement('div'));
const { hasSubtitles, nativeSubtitleTrack, selectedSubtitleTrack, usesAssSubtitleTrack } =
usePlayerSubtitles({
manifestUrl,
isVideo,
canPlay,
shouldRender,
video,
overlay,
});
await flushPromises();
expect(fetchMock).toHaveBeenCalledWith('/api/player/subtitles/manifest/video%20file.mkv', expect.anything());
expect(hasSubtitles.value).toBe(true);
expect(selectedSubtitleTrack.value?.source_format).toBe('vtt');
expect(nativeSubtitleTrack.value?.url).toBe('/api/player/subtitles/vtt/video.vtt');
expect(usesAssSubtitleTrack.value).toBe(false);
});
it('keep_manifest_path', async () => {
fetchMock.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: { subtitles: [] },
}),
);
const { usePlayerSubtitles } = await import('~/composables/usePlayerSubtitles');
usePlayerSubtitles({
manifestUrl: ref(
'/api/player/subtitles/manifest/youtube/Channel%20Name/Season%202026/video%20file.mkv',
),
isVideo: ref(true),
canPlay: ref(true),
shouldRender: ref(false),
video: ref<HTMLVideoElement | null>(document.createElement('video')),
overlay: ref<HTMLElement | null>(document.createElement('div')),
});
await flushPromises();
expect(fetchMock).toHaveBeenCalledWith(
'/api/player/subtitles/manifest/youtube/Channel%20Name/Season%202026/video%20file.mkv',
expect.anything(),
);
});
it('mount_ass_renderer', async () => {
fetchMock.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: {
subtitles: [
{
lang: 'en',
name: 'Styled',
source_format: 'ass',
delivery_format: 'ass',
renderer: 'assjs',
url: '/api/player/subtitles/ass/video.ass',
},
],
},
}),
);
const fetchText = mock(async () => '[Script Info]\nTitle: Demo\n');
const loadRenderer = mock(async () => assConstructorMock as any);
const { usePlayerSubtitles } = await import('~/composables/usePlayerSubtitles');
const manifestUrl = ref('/api/player/subtitles/manifest/video.mkv');
const isVideo = ref(true);
const canPlay = ref(true);
const shouldRender = ref(false);
const video = ref<HTMLVideoElement | null>(document.createElement('video'));
const overlay = ref<HTMLElement | null>(document.createElement('div'));
const { usesAssSubtitleTrack } = usePlayerSubtitles({
manifestUrl,
isVideo,
canPlay,
shouldRender,
video,
overlay,
fetchText,
loadRenderer,
});
await flushPromises();
expect(usesAssSubtitleTrack.value).toBe(true);
expect(assConstructorMock).not.toHaveBeenCalled();
shouldRender.value = true;
await flushPromises(5);
expect(fetchText).toHaveBeenCalledWith('/api/player/subtitles/ass/video.ass');
expect(loadRenderer).toHaveBeenCalledTimes(1);
expect(assConstructorMock).toHaveBeenCalledTimes(1);
expect(assShowMock).toHaveBeenCalledTimes(1);
manifestUrl.value = '/api/player/subtitles/manifest/second.mkv';
fetchMock.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: { subtitles: [] },
}),
);
await flushPromises();
expect(assDestroyMock.mock.calls.length).toBeGreaterThanOrEqual(1);
});
it('remount_on_layout_change', async () => {
fetchMock.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: {
subtitles: [
{
lang: 'en',
name: 'Styled',
source_format: 'ass',
delivery_format: 'ass',
renderer: 'assjs',
url: '/api/player/subtitles/ass/video.ass',
},
],
},
}),
);
const fetchText = mock(async () => '[Script Info]\nTitle: Demo\n');
const loadRenderer = mock(async () => assConstructorMock as any);
const { usePlayerSubtitles } = await import('~/composables/usePlayerSubtitles');
const assLayoutVersion = ref(0);
usePlayerSubtitles({
manifestUrl: ref('/api/player/subtitles/manifest/video.mkv'),
isVideo: ref(true),
canPlay: ref(true),
shouldRender: ref(true),
assLayoutVersion,
video: ref<HTMLVideoElement | null>(document.createElement('video')),
overlay: ref<HTMLElement | null>(document.createElement('div')),
fetchText,
loadRenderer,
});
await flushPromises(5);
expect(fetchText).toHaveBeenCalledTimes(1);
expect(assConstructorMock).toHaveBeenCalledTimes(1);
assLayoutVersion.value += 1;
await flushPromises(5);
expect(fetchText).toHaveBeenCalledTimes(1);
expect(assDestroyMock.mock.calls.length).toBeGreaterThanOrEqual(1);
expect(assConstructorMock).toHaveBeenCalledTimes(2);
});
});

View file

@ -40,7 +40,7 @@ const setConfigStore = (presets: Preset[]) => {
} }
describe('usePresetOptions', () => { describe('usePresetOptions', () => {
it('groups custom presets before default presets by default', () => { it('group_custom_first', () => {
setConfigStore([ setConfigStore([
buildPreset('default_video', true), buildPreset('default_video', true),
buildPreset('custom_audio', false), buildPreset('custom_audio', false),
@ -56,7 +56,7 @@ describe('usePresetOptions', () => {
]) ])
}) })
it('supports default-first grouping when requested', () => { it('group_default_first', () => {
setConfigStore([ setConfigStore([
buildPreset('default_video', true), buildPreset('default_video', true),
buildPreset('custom_audio', false), buildPreset('custom_audio', false),

View file

@ -81,7 +81,7 @@ describe('usePresets', () => {
}) })
describe('loadPresets', () => { describe('loadPresets', () => {
it('loads presets with pagination successfully', async () => { it('load_presets', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -104,7 +104,7 @@ describe('usePresets', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('requests custom presets without defaults when asked', async () => { it('exclude_defaults', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -124,7 +124,7 @@ describe('usePresets', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('sorts presets by priority then name', async () => { it('sort_priority', async () => {
const items = [ const items = [
{ ...mockPreset, id: 1, name: 'B', priority: 2 }, { ...mockPreset, id: 1, name: 'B', priority: 2 },
{ ...mockPreset, id: 2, name: 'A', priority: 2 }, { ...mockPreset, id: 2, name: 'A', priority: 2 },
@ -159,7 +159,7 @@ describe('usePresets', () => {
}) })
describe('getPreset', () => { describe('getPreset', () => {
it('fetches a single preset successfully', async () => { it('get_preset', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -180,7 +180,7 @@ describe('usePresets', () => {
}) })
describe('createPreset', () => { describe('createPreset', () => {
it('creates a preset successfully', async () => { it('create_preset', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -209,7 +209,7 @@ describe('usePresets', () => {
}) })
describe('updatePreset', () => { describe('updatePreset', () => {
it('updates a preset successfully', async () => { it('update_preset', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -226,7 +226,7 @@ describe('usePresets', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('strips id from update payload', async () => { it('strip_update_id', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -240,14 +240,14 @@ describe('usePresets', () => {
await presets.updatePreset(1, { ...mockPreset, default: true }) await presets.updatePreset(1, { ...mockPreset, default: true })
const requestBody = JSON.parse((requestSpy.mock.calls[0]![1] as any).body) const requestBody = JSON.parse((requestSpy.mock.calls[0]![1] as any).body)
expect(requestBody.id).toBeUndefined() expect('id' in requestBody).toBe(false)
expect(requestBody.default).toBe(false) expect(requestBody.default).toBe(false)
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
}) })
describe('patchPreset', () => { describe('patchPreset', () => {
it('patches a preset successfully', async () => { it('patch_preset', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -264,7 +264,7 @@ describe('usePresets', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('strips id and default from patch payload', async () => { it('sanitize_patch', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -278,14 +278,14 @@ describe('usePresets', () => {
await presets.patchPreset(1, { id: 10, default: true }) await presets.patchPreset(1, { id: 10, default: true })
const requestBody = JSON.parse((requestSpy.mock.calls[0]![1] as any).body) const requestBody = JSON.parse((requestSpy.mock.calls[0]![1] as any).body)
expect(requestBody.id).toBeUndefined() expect('id' in requestBody).toBe(false)
expect(requestBody.default).toBe(false) expect(requestBody.default).toBe(false)
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
}) })
describe('deletePreset', () => { describe('deletePreset', () => {
it('deletes a preset successfully', async () => { it('delete_preset', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({

View file

@ -72,7 +72,7 @@ describe('useTaskDefinitions', () => {
errorMock.mockClear() errorMock.mockClear()
}) })
it('sorts definitions by priority then name', async () => { it('sort_priority', async () => {
const items = [ const items = [
{ id: 1, name: 'B', priority: 2, updated_at: 1 }, { id: 1, name: 'B', priority: 2, updated_at: 1 },
{ id: 2, name: 'A', priority: 2, updated_at: 2 }, { id: 2, name: 'A', priority: 2, updated_at: 2 },
@ -90,7 +90,7 @@ describe('useTaskDefinitions', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('handles empty payload', async () => { it('handle_empty_payload', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(createMockResponse({ requestSpy.mockResolvedValueOnce(createMockResponse({
ok: true, ok: true,
@ -104,7 +104,7 @@ describe('useTaskDefinitions', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('throws loadDefinitions error when throwInstead is true', async () => { it('throw_load_error', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(createMockResponse({ requestSpy.mockResolvedValueOnce(createMockResponse({
ok: false, ok: false,
@ -117,7 +117,7 @@ describe('useTaskDefinitions', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('returns null on getDefinition error', async () => { it('store_get_error', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(createMockResponse({ requestSpy.mockResolvedValueOnce(createMockResponse({
ok: false, ok: false,
@ -132,7 +132,7 @@ describe('useTaskDefinitions', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('creates definition successfully', async () => { it('create_definition', async () => {
const payload: TaskDefinitionDetailed = { const payload: TaskDefinitionDetailed = {
id: 2, id: 2,
name: 'New', name: 'New',
@ -152,7 +152,7 @@ describe('useTaskDefinitions', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('removes definition on deleteDefinition', async () => { it('remove_deleted_definition', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(createMockResponse({ requestSpy.mockResolvedValueOnce(createMockResponse({
ok: true, ok: true,

View file

@ -85,7 +85,7 @@ describe('useTasks', () => {
}) })
describe('loadTasks', () => { describe('loadTasks', () => {
it('loads tasks with pagination successfully', async () => { it('load_tasks', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -108,7 +108,7 @@ describe('useTasks', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('sorts tasks by name A-Z', async () => { it('sort_name', async () => {
const items = [ const items = [
{ ...mockTask, id: 1, name: 'Zebra' }, { ...mockTask, id: 1, name: 'Zebra' },
{ ...mockTask, id: 2, name: 'Alpha' }, { ...mockTask, id: 2, name: 'Alpha' },
@ -137,7 +137,7 @@ describe('useTasks', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('handles empty tasks list', async () => { it('handle_empty_list', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -158,7 +158,7 @@ describe('useTasks', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('handles errors during load', async () => { it('store_load_error', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -171,11 +171,11 @@ describe('useTasks', () => {
const tasks = useTasks() const tasks = useTasks()
await tasks.loadTasks() await tasks.loadTasks()
expect(tasks.lastError.value).toBeTruthy() expect(tasks.lastError.value).toBe('Server error')
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('respects page and per_page parameters', async () => { it('pass_pagination', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -197,7 +197,7 @@ describe('useTasks', () => {
}) })
describe('getTask', () => { describe('getTask', () => {
it('fetches a single task successfully', async () => { it('get_task', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -215,7 +215,7 @@ describe('useTasks', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('handles 404 not found', async () => { it('store_404_error', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -229,13 +229,13 @@ describe('useTasks', () => {
const result = await tasks.getTask(999) const result = await tasks.getTask(999)
expect(result).toBeNull() expect(result).toBeNull()
expect(tasks.lastError.value).toBeTruthy() expect(tasks.lastError.value).toBe('Task not found')
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
}) })
describe('createTask', () => { describe('createTask', () => {
it('creates a task successfully', async () => { it('create_task', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -258,10 +258,33 @@ describe('useTasks', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('store_timer_required_error', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 400,
jsonData: { error: 'Task requires a timer when no supported handler matches the URL.' },
}),
)
const tasks = useTasks()
const newTask = { ...mockTask }
delete (newTask as any).id
const result = await tasks.createTask(newTask)
expect(result).toBeNull()
expect(tasks.lastError.value).toBe(
'Task requires a timer when no supported handler matches the URL.',
)
requestSpy.mockRestore()
})
}) })
describe('updateTask', () => { describe('updateTask', () => {
it('updates a task successfully', async () => { it('update_task', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -280,7 +303,27 @@ describe('useTasks', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('removes id field from task before sending', async () => { it('store_update_timer_error', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 400,
jsonData: { error: 'Task requires a timer when the handler is disabled.' },
}),
)
const tasks = useTasks()
const updated = { ...mockTask, timer: '', handler_enabled: false }
const result = await tasks.updateTask(1, updated)
expect(result).toBeNull()
expect(tasks.lastError.value).toBe('Task requires a timer when the handler is disabled.')
requestSpy.mockRestore()
})
it('strip_update_id', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -301,7 +344,7 @@ describe('useTasks', () => {
}) })
describe('patchTask', () => { describe('patchTask', () => {
it('patches a task successfully', async () => { it('patch_task', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -318,10 +361,30 @@ describe('useTasks', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('store_patch_timer_error', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 400,
jsonData: { error: 'Task requires a timer when no supported handler matches the URL.' },
}),
)
const tasks = useTasks()
const result = await tasks.patchTask(1, { timer: '' })
expect(result).toBeNull()
expect(tasks.lastError.value).toBe(
'Task requires a timer when no supported handler matches the URL.',
)
requestSpy.mockRestore()
})
}) })
describe('deleteTask', () => { describe('deleteTask', () => {
it('deletes a task successfully', async () => { it('delete_task', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -344,7 +407,7 @@ describe('useTasks', () => {
}) })
describe('inspectTaskHandler', () => { describe('inspectTaskHandler', () => {
it('inspects task handler successfully', async () => { it('inspect_handler', async () => {
const inspectResponse: TaskInspectResponse = { const inspectResponse: TaskInspectResponse = {
matched: true, matched: true,
handler: 'YoutubeHandler', handler: 'YoutubeHandler',
@ -383,7 +446,7 @@ describe('useTasks', () => {
fetchSpy.mockRestore() fetchSpy.mockRestore()
}) })
it('handles handler not supported', async () => { it('handle_unsupported_handler', async () => {
const inspectResponse: TaskInspectResponse = { const inspectResponse: TaskInspectResponse = {
matched: false, matched: false,
handler: null, handler: null,
@ -411,7 +474,7 @@ describe('useTasks', () => {
fetchSpy.mockRestore() fetchSpy.mockRestore()
}) })
it('handles inspect errors', async () => { it('store_inspect_error', async () => {
const fetchSpy = spyOn(globalThis, 'fetch') const fetchSpy = spyOn(globalThis, 'fetch')
fetchSpy.mockRejectedValueOnce(new Error('Network error')) fetchSpy.mockRejectedValueOnce(new Error('Network error'))
@ -421,13 +484,13 @@ describe('useTasks', () => {
}) })
expect(result).toBeNull() expect(result).toBeNull()
expect(tasks.lastError.value).toBeTruthy() expect(tasks.lastError.value).toBe('Network error')
fetchSpy.mockRestore() fetchSpy.mockRestore()
}) })
}) })
describe('markTaskItems', () => { describe('markTaskItems', () => {
it('marks all items as downloaded successfully', async () => { it('mark_items', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -445,7 +508,7 @@ describe('useTasks', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('handles mark errors', async () => { it('store_mark_error', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -459,13 +522,13 @@ describe('useTasks', () => {
const result = await tasks.markTaskItems(999) const result = await tasks.markTaskItems(999)
expect(result).toBeNull() expect(result).toBeNull()
expect(tasks.lastError.value).toBeTruthy() expect(tasks.lastError.value).toBe('Task not found')
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
}) })
describe('unmarkTaskItems', () => { describe('unmarkTaskItems', () => {
it('unmarks items successfully', async () => { it('unmark_items', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -483,7 +546,7 @@ describe('useTasks', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('handles unmark errors', async () => { it('store_unmark_error', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -497,13 +560,13 @@ describe('useTasks', () => {
const result = await tasks.unmarkTaskItems(999) const result = await tasks.unmarkTaskItems(999)
expect(result).toBeNull() expect(result).toBeNull()
expect(tasks.lastError.value).toBeTruthy() expect(tasks.lastError.value).toBe('Task not found')
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
}) })
describe('generateTaskMetadata', () => { describe('generateTaskMetadata', () => {
it('generates metadata successfully', async () => { it('generate_metadata', async () => {
const metadataResponse: TaskMetadataResponse = { const metadataResponse: TaskMetadataResponse = {
status: 'success', status: 'success',
generated: ['tvshow.nfo', 'info.json', 'poster.jpg'], generated: ['tvshow.nfo', 'info.json', 'poster.jpg'],
@ -527,7 +590,7 @@ describe('useTasks', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('handles metadata generation errors', async () => { it('store_metadata_error', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -541,13 +604,13 @@ describe('useTasks', () => {
const result = await tasks.generateTaskMetadata(1) const result = await tasks.generateTaskMetadata(1)
expect(result).toBeNull() expect(result).toBeNull()
expect(tasks.lastError.value).toBeTruthy() expect(tasks.lastError.value).toBe('Failed to generate metadata')
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
}) })
describe('error handling', () => { describe('error handling', () => {
it('throws when throwInstead is true', async () => { it('throw_on_error', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -564,7 +627,7 @@ describe('useTasks', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('clears error on clearError call', async () => { it('clear_error', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -579,7 +642,7 @@ describe('useTasks', () => {
await tasks.loadTasks() await tasks.loadTasks()
expect(tasks.lastError.value).toBeTruthy() expect(tasks.lastError.value).toBe('Server error')
tasks.clearError() tasks.clearError()
@ -587,7 +650,7 @@ describe('useTasks', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('parses API validation errors correctly', async () => { it('parse_validation_errors', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -611,7 +674,7 @@ describe('useTasks', () => {
const result = await tasks.createTask(newTask) const result = await tasks.createTask(newTask)
expect(result).toBeNull() expect(result).toBeNull()
expect(tasks.lastError.value).toBeTruthy() expect(tasks.lastError.value).toBe('name: Field required, url: Invalid URL format')
expect(tasks.lastError.value).toContain('name: Field required') expect(tasks.lastError.value).toContain('name: Field required')
expect(tasks.lastError.value).toContain('url: Invalid URL format') expect(tasks.lastError.value).toContain('url: Invalid URL format')
requestSpy.mockRestore() requestSpy.mockRestore()
@ -619,7 +682,7 @@ describe('useTasks', () => {
}) })
describe('addInProgress state', () => { describe('addInProgress state', () => {
it('sets addInProgress during create operation', async () => { it('set_add_in_progress', async () => {
let inProgressDuringCall = false let inProgressDuringCall = false
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
@ -644,7 +707,7 @@ describe('useTasks', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('resets addInProgress on error', async () => { it('reset_add_in_progress', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -666,7 +729,7 @@ describe('useTasks', () => {
}) })
describe('isLoading state', () => { describe('isLoading state', () => {
it('sets isLoading during loadTasks operation', async () => { it('set_loading', async () => {
let loadingDuringCall = false let loadingDuringCall = false
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
@ -693,7 +756,7 @@ describe('useTasks', () => {
}) })
describe('task list updates', () => { describe('task list updates', () => {
it('updates existing task in list', async () => { it('update_list_item', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
@ -727,7 +790,7 @@ describe('useTasks', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('removes deleted task from list', async () => { it('remove_list_item', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({

View file

@ -63,7 +63,7 @@ afterEach(async () => {
}); });
describe('modal opacity plugin', () => { describe('modal opacity plugin', () => {
it('ignores a settings-only overlay', async () => { it('ignore_settings_overlay', async () => {
startPlugin(); startPlugin();
const settingsPanel = document.createElement('div'); const settingsPanel = document.createElement('div');
@ -77,7 +77,7 @@ describe('modal opacity plugin', () => {
expect(syncOpacityMock).not.toHaveBeenCalled(); expect(syncOpacityMock).not.toHaveBeenCalled();
}); });
it('restores opacity when a normal overlay closes back to settings-only', async () => { it('restore_after_close', async () => {
startPlugin(); startPlugin();
const settingsPanel = document.createElement('div'); const settingsPanel = document.createElement('div');
@ -99,7 +99,7 @@ describe('modal opacity plugin', () => {
expect(enableOpacityMock).toHaveBeenCalledTimes(1); expect(enableOpacityMock).toHaveBeenCalledTimes(1);
}); });
it('resyncs opacity when overlays change while already locked', async () => { it('resync_while_locked', async () => {
startPlugin(); startPlugin();
document.body.append(createOverlay()); document.body.append(createOverlay());
@ -112,7 +112,7 @@ describe('modal opacity plugin', () => {
expect(syncOpacityMock).toHaveBeenCalledTimes(1); expect(syncOpacityMock).toHaveBeenCalledTimes(1);
}); });
it('does not unlock opacity on beforeunload when reload is canceled', async () => { it('keep_lock_beforeunload', async () => {
startPlugin(); startPlugin();
document.body.append(createOverlay()); document.body.append(createOverlay());

View file

@ -132,81 +132,81 @@ afterEach(() => {
}); });
describe('object access helpers', () => { describe('object access helpers', () => {
it('ag returns nested value or default value', () => { it('ag_nested_value', () => {
const payload = { a: { b: { c: 42 } } }; const payload = { a: { b: { c: 42 } } };
expect(utils.ag(payload, 'a.b.c')).toBe(42); expect(utils.ag(payload, 'a.b.c')).toBe(42);
expect(utils.ag(payload, 'a.b.x', 'fallback')).toBe('fallback'); expect(utils.ag(payload, 'a.b.x', 'fallback')).toBe('fallback');
expect(utils.ag(payload, 'missing', () => 'fn-default')).toBe('fn-default'); expect(utils.ag(payload, 'missing', () => 'fn-default')).toBe('fn-default');
}); });
it('ag_set sets nested path creating objects as needed', () => { it('ag_set_path', () => {
const payload: Record<string, unknown> = {}; const payload: Record<string, unknown> = {};
utils.ag_set(payload, 'a.b.c', 99); utils.ag_set(payload, 'a.b.c', 99);
expect(payload).toEqual({ a: { b: { c: 99 } } }); expect(payload).toEqual({ a: { b: { c: 99 } } });
}); });
it('cleanObject removes requested keys', () => { it('clean_object_keys', () => {
const source = { id: 1, keep: true, drop: false }; const source = { id: 1, keep: true, drop: false };
expect(utils.cleanObject(source, ['drop'])).toEqual({ id: 1, keep: true }); expect(utils.cleanObject(source, ['drop'])).toEqual({ id: 1, keep: true });
expect(utils.cleanObject(source, [])).toEqual(source); expect(utils.cleanObject(source, [])).toEqual(source);
}); });
it('stripPath removes base prefix and leading slashes', () => { it('strip_path_base', () => {
expect(utils.stripPath('/data/downloads', '/data/downloads/video.mp4')).toBe('video.mp4'); expect(utils.stripPath('/data/downloads', '/data/downloads/video.mp4')).toBe('video.mp4');
expect(utils.stripPath('', '/var/files/test.txt')).toBe('/var/files/test.txt'); expect(utils.stripPath('', '/var/files/test.txt')).toBe('/var/files/test.txt');
}); });
}); });
describe('string manipulation helpers', () => { describe('string manipulation helpers', () => {
it('r replaces tokens with context values', () => { it('replace_tokens', () => {
const result = utils.r('Hello {user.name}!', { user: { name: 'YTPTube' } }); const result = utils.r('Hello {user.name}!', { user: { name: 'YTPTube' } });
expect(result).toBe('Hello YTPTube!'); expect(result).toBe('Hello YTPTube!');
}); });
it('iTrim trims delimiters at requested positions', () => { it('itrim_edges', () => {
expect(utils.iTrim('--value--', '-', 'both')).toBe('value'); expect(utils.iTrim('--value--', '-', 'both')).toBe('value');
expect(utils.iTrim('::value', ':', 'start')).toBe('value'); expect(utils.iTrim('::value', ':', 'start')).toBe('value');
expect(utils.iTrim('value::', ':', 'end')).toBe('value'); expect(utils.iTrim('value::', ':', 'end')).toBe('value');
}); });
it('iTrim handles forward slash delimiter', () => { it('itrim_slash', () => {
expect(utils.iTrim('//value//', '/', 'both')).toBe('value'); expect(utils.iTrim('//value//', '/', 'both')).toBe('value');
expect(utils.iTrim('/value', '/', 'start')).toBe('value'); expect(utils.iTrim('/value', '/', 'start')).toBe('value');
expect(utils.iTrim('value/', '/', 'end')).toBe('value'); expect(utils.iTrim('value/', '/', 'end')).toBe('value');
expect(utils.iTrim('///multiple///', '/', 'both')).toBe('multiple'); expect(utils.iTrim('///multiple///', '/', 'both')).toBe('multiple');
}); });
it('iTrim handles backslash delimiter', () => { it('itrim_backslash', () => {
expect(utils.iTrim('\\\\value\\\\', '\\', 'both')).toBe('value'); expect(utils.iTrim('\\\\value\\\\', '\\', 'both')).toBe('value');
expect(utils.iTrim('\\value', '\\', 'start')).toBe('value'); expect(utils.iTrim('\\value', '\\', 'start')).toBe('value');
expect(utils.iTrim('value\\', '\\', 'end')).toBe('value'); expect(utils.iTrim('value\\', '\\', 'end')).toBe('value');
}); });
it('iTrim handles hyphen delimiter', () => { it('itrim_hyphen', () => {
expect(utils.iTrim('--value--', '-', 'both')).toBe('value'); expect(utils.iTrim('--value--', '-', 'both')).toBe('value');
expect(utils.iTrim('-value', '-', 'start')).toBe('value'); expect(utils.iTrim('-value', '-', 'start')).toBe('value');
expect(utils.iTrim('value-', '-', 'end')).toBe('value'); expect(utils.iTrim('value-', '-', 'end')).toBe('value');
expect(utils.iTrim('---multiple---', '-', 'both')).toBe('multiple'); expect(utils.iTrim('---multiple---', '-', 'both')).toBe('multiple');
}); });
it('iTrim handles caret delimiter', () => { it('itrim_caret', () => {
expect(utils.iTrim('^^value^^', '^', 'both')).toBe('value'); expect(utils.iTrim('^^value^^', '^', 'both')).toBe('value');
expect(utils.iTrim('^value', '^', 'start')).toBe('value'); expect(utils.iTrim('^value', '^', 'start')).toBe('value');
expect(utils.iTrim('value^', '^', 'end')).toBe('value'); expect(utils.iTrim('value^', '^', 'end')).toBe('value');
}); });
it('iTrim handles bracket delimiters', () => { it('itrim_brackets', () => {
expect(utils.iTrim('[[value]]', '[', 'both')).toBe('value]]'); expect(utils.iTrim('[[value]]', '[', 'both')).toBe('value]]');
expect(utils.iTrim(']]value[[', ']', 'both')).toBe('value[['); expect(utils.iTrim(']]value[[', ']', 'both')).toBe('value[[');
}); });
it('iTrim handles dot delimiter', () => { it('itrim_dot', () => {
expect(utils.iTrim('..value..', '.', 'both')).toBe('value'); expect(utils.iTrim('..value..', '.', 'both')).toBe('value');
expect(utils.iTrim('.value', '.', 'start')).toBe('value'); expect(utils.iTrim('.value', '.', 'start')).toBe('value');
expect(utils.iTrim('value.', '.', 'end')).toBe('value'); expect(utils.iTrim('value.', '.', 'end')).toBe('value');
}); });
it('iTrim handles special regex characters', () => { it('itrim_regex_chars', () => {
expect(utils.iTrim('**value**', '*', 'both')).toBe('value'); expect(utils.iTrim('**value**', '*', 'both')).toBe('value');
expect(utils.iTrim('++value++', '+', 'both')).toBe('value'); expect(utils.iTrim('++value++', '+', 'both')).toBe('value');
expect(utils.iTrim('??value??', '?', 'both')).toBe('value'); expect(utils.iTrim('??value??', '?', 'both')).toBe('value');
@ -215,96 +215,96 @@ describe('string manipulation helpers', () => {
expect(utils.iTrim('((value))', ')', 'both')).toBe('((value'); expect(utils.iTrim('((value))', ')', 'both')).toBe('((value');
}); });
it('iTrim handles empty string', () => { it('itrim_empty', () => {
expect(utils.iTrim('', '/', 'both')).toBe(''); expect(utils.iTrim('', '/', 'both')).toBe('');
}); });
it('iTrim throws error when delimiter is empty', () => { it('itrim_empty_delimiter', () => {
expect(() => utils.iTrim('value', '', 'both')).toThrow('Delimiter is required'); expect(() => utils.iTrim('value', '', 'both')).toThrow('Delimiter is required');
}); });
it('iTrim preserves middle occurrences', () => { it('itrim_preserve_middle', () => {
expect(utils.iTrim('/path/to/file/', '/', 'both')).toBe('path/to/file'); expect(utils.iTrim('/path/to/file/', '/', 'both')).toBe('path/to/file');
expect(utils.iTrim('//path//to//file//', '/', 'both')).toBe('path//to//file'); expect(utils.iTrim('//path//to//file//', '/', 'both')).toBe('path//to//file');
}); });
it('encodePath safely encodes components', () => { it('encode_path', () => {
expect(utils.encodePath('folder#1/video name.mp4')).toBe('folder%231/video%20name.mp4'); expect(utils.encodePath('folder#1/video name.mp4')).toBe('folder%231/video%20name.mp4');
}); });
it('encodePath handles % character correctly', () => { it('encode_percent', () => {
expect(utils.encodePath('How to enjoy Shin Ramyun 100%.opus')).toBe( expect(utils.encodePath('How to enjoy Shin Ramyun 100%.opus')).toBe(
'How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus', 'How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus',
); );
}); });
it('encodePath handles multiple special characters', () => { it('encode_specials', () => {
expect(utils.encodePath('100% complete [HD] #1.mp4')).toBe( expect(utils.encodePath('100% complete [HD] #1.mp4')).toBe(
'100%25%20complete%20%5BHD%5D%20%231.mp4', '100%25%20complete%20%5BHD%5D%20%231.mp4',
); );
}); });
it('encodePath handles paths with % character', () => { it('keep_segments', () => {
expect(utils.encodePath('folder/How to enjoy Shin Ramyun 100%.opus')).toBe( expect(utils.encodePath('folder/How to enjoy Shin Ramyun 100%.opus')).toBe(
'folder/How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus', 'folder/How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus',
); );
}); });
it('encodePath handles already encoded strings', () => { it('preserve_encoded', () => {
expect(utils.encodePath('How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus')).toBe( expect(utils.encodePath('How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus')).toBe(
'How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus', 'How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus',
); );
}); });
it('encodePath handles mixed encoded and unencoded', () => { it('mix_encoded_input', () => {
expect(utils.encodePath('folder/file%20name 100%.mp4')).toBe('folder/file%20name%20100%25.mp4'); expect(utils.encodePath('folder/file%20name 100%.mp4')).toBe('folder/file%20name%20100%25.mp4');
}); });
it('encodePath handles special characters &, =, ?', () => { it('encode_query_chars', () => {
expect(utils.encodePath('query?param=value&key=100%.mp4')).toBe( expect(utils.encodePath('query?param=value&key=100%.mp4')).toBe(
'query%3Fparam%3Dvalue%26key%3D100%25.mp4', 'query%3Fparam%3Dvalue%26key%3D100%25.mp4',
); );
}); });
it('encodePath handles empty string', () => { it('encode_empty', () => {
expect(utils.encodePath('')).toBe(''); expect(utils.encodePath('')).toBe('');
}); });
it('encodePath handles simple filename', () => { it('encode_simple_filename', () => {
expect(utils.encodePath('video.mp4')).toBe('video.mp4'); expect(utils.encodePath('video.mp4')).toBe('video.mp4');
}); });
it('encodePath handles unicode characters', () => { it('encode_unicode', () => {
expect(utils.encodePath('视频文件.mp4')).toBe('%E8%A7%86%E9%A2%91%E6%96%87%E4%BB%B6.mp4'); expect(utils.encodePath('视频文件.mp4')).toBe('%E8%A7%86%E9%A2%91%E6%96%87%E4%BB%B6.mp4');
}); });
it('encodePath handles parentheses', () => { it('encode_parentheses', () => {
expect(utils.encodePath('video (1080p).mp4')).toBe('video%20(1080p).mp4'); expect(utils.encodePath('video (1080p).mp4')).toBe('video%20(1080p).mp4');
}); });
it('removeANSIColors strips escape codes', () => { it('strip_ansi', () => {
const sample = '\u001b[31mError\u001b[0m'; const sample = '\u001b[31mError\u001b[0m';
expect(utils.removeANSIColors(sample)).toBe('Error'); expect(utils.removeANSIColors(sample)).toBe('Error');
}); });
it('basename returns final segment optionally trimming extension', () => { it('basename_trim_ext', () => {
expect(utils.basename('/downloads/video.mp4')).toBe('video.mp4'); expect(utils.basename('/downloads/video.mp4')).toBe('video.mp4');
expect(utils.basename('/downloads/video.mp4', '.mp4')).toBe('video'); expect(utils.basename('/downloads/video.mp4', '.mp4')).toBe('video');
expect(utils.basename('', '.mp4')).toBe(''); expect(utils.basename('', '.mp4')).toBe('');
}); });
it('dirname returns parent directory', () => { it('dirname_parent', () => {
expect(utils.dirname('/downloads/video.mp4')).toBe('/downloads'); expect(utils.dirname('/downloads/video.mp4')).toBe('/downloads');
expect(utils.dirname('video.mp4')).toBe('.'); expect(utils.dirname('video.mp4')).toBe('.');
expect(utils.dirname('/file')).toBe('/'); expect(utils.dirname('/file')).toBe('/');
}); });
it('formatBytes returns human readable strings', () => { it('format_bytes', () => {
expect(utils.formatBytes(0)).toBe('0 Bytes'); expect(utils.formatBytes(0)).toBe('0 Bytes');
expect(utils.formatBytes(1024)).toBe('1 KiB'); expect(utils.formatBytes(1024)).toBe('1 KiB');
}); });
it('formatTime renders hh:mm:ss or mm:ss', () => { it('format_time', () => {
expect(utils.formatTime(59)).toBe('59'); expect(utils.formatTime(59)).toBe('59');
expect(utils.formatTime(90)).toBe('01:30'); expect(utils.formatTime(90)).toBe('01:30');
expect(utils.formatTime(3661)).toBe('01:01:01'); expect(utils.formatTime(3661)).toBe('01:01:01');
@ -312,47 +312,47 @@ describe('string manipulation helpers', () => {
}); });
describe('data conversion helpers', () => { describe('data conversion helpers', () => {
it('has_data detects arrays, objects, and json strings', () => { it('has_data', () => {
expect(utils.has_data({ key: 'value' })).toBe(true); expect(utils.has_data({ key: 'value' })).toBe(true);
expect(utils.has_data('""')).toBe(false); expect(utils.has_data('""')).toBe(false);
expect(utils.has_data('[1,2]')).toBe(true); expect(utils.has_data('[1,2]')).toBe(true);
expect(utils.has_data('')).toBe(false); expect(utils.has_data('')).toBe(false);
}); });
it('encode and decode provide reversible transformation', () => { it('encode_decode_roundtrip', () => {
const payload = { name: 'YTPTube', count: 2 }; const payload = { name: 'YTPTube', count: 2 };
const encoded = utils.encode(payload); const encoded = utils.encode(payload);
expect(typeof encoded).toBe('string'); expect(typeof encoded).toBe('string');
expect(utils.decode(encoded)).toEqual(payload); expect(utils.decode(encoded)).toEqual(payload);
}); });
it('getQueryParams parses query strings', () => { it('parse_query_params', () => {
expect(utils.getQueryParams('?a=1&b=two')).toEqual({ a: '1', b: 'two' }); expect(utils.getQueryParams('?a=1&b=two')).toEqual({ a: '1', b: 'two' });
}); });
it('uri prefixes runtime base path', () => { it('prefix_runtime_base', () => {
runtimeConfig.app.baseURL = '/base-path'; runtimeConfig.app.baseURL = '/base-path';
expect(utils.uri('/api/test')).toBe('/base-path/api/test'); expect(utils.uri('/api/test')).toBe('/base-path/api/test');
runtimeConfig.app.baseURL = '/'; runtimeConfig.app.baseURL = '/';
expect(utils.uri('/api/test')).toBe('/api/test'); expect(utils.uri('/api/test')).toBe('/api/test');
}); });
it('makeDownload builds expected url with folder and filename', () => { it('build_file_url', () => {
runtimeConfig.app.baseURL = '/base-path'; runtimeConfig.app.baseURL = '/base-path';
const url = utils.makeDownload({}, { folder: 'music', filename: 'song.mp3' }); const url = utils.makeDownload({}, { folder: 'music', filename: 'song.mp3' });
expect(url).toBe('/base-path/api/download/music/song.mp3'); expect(url).toBe('/base-path/api/download/music/song.mp3');
}); });
it('makeDownload handles m3u8 base path', () => { it('build_m3u8_url', () => {
const url = utils.makeDownload({}, { filename: 'playlist' }, 'm3u8'); const url = utils.makeDownload({}, { filename: 'playlist' }, 'm3u8');
expect(url).toBe('/base-path/api/player/m3u8/video/playlist.m3u8'); expect(url).toBe('/base-path/api/player/m3u8/video/playlist.m3u8');
}); });
it('isDownloadSkipped detects finished skipped-download items', () => { it('detect_download_skipped', () => {
expect(utils.isDownloadSkipped({ status: 'finished', download_skipped: true } as any)).toBe(true); expect(utils.isDownloadSkipped({ status: 'finished', download_skipped: true } as any)).toBe(true);
}); });
it('isDownloadSkipped ignores unfinished or unflagged items', () => { it('ignore_non_skipped', () => {
expect(utils.isDownloadSkipped({ status: 'finished', download_skipped: false } as any)).toBe(false); expect(utils.isDownloadSkipped({ status: 'finished', download_skipped: false } as any)).toBe(false);
expect(utils.isDownloadSkipped({ status: 'downloading', download_skipped: true } as any)).toBe(false); expect(utils.isDownloadSkipped({ status: 'downloading', download_skipped: true } as any)).toBe(false);
expect(utils.isDownloadSkipped(undefined as any)).toBe(false); expect(utils.isDownloadSkipped(undefined as any)).toBe(false);
@ -360,7 +360,7 @@ describe('data conversion helpers', () => {
}); });
describe('dom and browser helpers', () => { describe('dom and browser helpers', () => {
it('copyText uses clipboard API and notifies success', async () => { it('write_clipboard', async () => {
utils.copyText('sample'); utils.copyText('sample');
await Promise.resolve(); await Promise.resolve();
@ -372,13 +372,13 @@ describe('dom and browser helpers', () => {
expect(notificationMock.success).toHaveBeenCalledWith('Text copied to clipboard.'); expect(notificationMock.success).toHaveBeenCalledWith('Text copied to clipboard.');
}); });
it('disableOpacity toggles body opacity when enabled', () => { it('lock_opacity', () => {
const result = utils.disableOpacity(); const result = utils.disableOpacity();
expect(result).toBe(true); expect(result).toBe(true);
expect(document.body.style.opacity).toBe('1'); expect(document.body.style.opacity).toBe('1');
}); });
it('disableOpacity returns false when background disabled', () => { it('skip_disabled_bg', () => {
document.body.removeAttribute('style'); document.body.removeAttribute('style');
storageMap.clear(); storageMap.clear();
const originalOpacity = document.body.style.opacity; const originalOpacity = document.body.style.opacity;
@ -398,7 +398,7 @@ describe('dom and browser helpers', () => {
expect(document.body.style.opacity || '').toBe(originalOpacity || ''); expect(document.body.style.opacity || '').toBe(originalOpacity || '');
}); });
it('enableOpacity applies stored opacity value', () => { it('restore_opacity', () => {
utils.disableOpacity(); utils.disableOpacity();
useStorageFn.mockImplementation((key: string, defaultValue: any) => { useStorageFn.mockImplementation((key: string, defaultValue: any) => {
if (!storageMap.has(key)) { if (!storageMap.has(key)) {
@ -413,7 +413,7 @@ describe('dom and browser helpers', () => {
expect(document.body.style.opacity).toBe('0.75'); expect(document.body.style.opacity).toBe('0.75');
}); });
it('syncOpacity keeps full opacity while a lock is active', () => { it('keep_opacity_lock', () => {
utils.disableOpacity(); utils.disableOpacity();
storageMap.set('random_bg_opacity', { value: 0.35 }); storageMap.set('random_bg_opacity', { value: 0.35 });
@ -423,7 +423,7 @@ describe('dom and browser helpers', () => {
expect(document.body.style.opacity).toBe('1'); expect(document.body.style.opacity).toBe('1');
}); });
it('syncOpacity clears body opacity when background is disabled', () => { it('clear_disabled_bg', () => {
document.body.style.opacity = '0.8'; document.body.style.opacity = '0.8';
storageMap.set('random_bg', { value: false }); storageMap.set('random_bg', { value: false });
@ -435,7 +435,7 @@ describe('dom and browser helpers', () => {
}); });
describe('network and id helpers', () => { describe('network and id helpers', () => {
it('request prefixes relative urls and sets defaults', async () => { it('prefix_base_url', async () => {
const responseMock = { status: 200 } as Response; const responseMock = { status: 200 } as Response;
fetchMock.mockResolvedValue(responseMock); fetchMock.mockResolvedValue(responseMock);
@ -452,7 +452,7 @@ describe('network and id helpers', () => {
expect((options as Record<string, unknown>).withCredentials).toBe(true); expect((options as Record<string, unknown>).withCredentials).toBe(true);
}); });
it('convertCliOptions posts payload and returns parsed json', async () => { it('post_cli_args', async () => {
const jsonMock = mock().mockResolvedValue({ success: true }); const jsonMock = mock().mockResolvedValue({ success: true });
const responseMock = { status: 200, json: jsonMock }; const responseMock = { status: 200, json: jsonMock };
fetchMock.mockResolvedValue(responseMock); fetchMock.mockResolvedValue(responseMock);
@ -468,7 +468,7 @@ describe('network and id helpers', () => {
expect(result).toEqual({ success: true }); expect(result).toEqual({ success: true });
}); });
it('convertCliOptions throws on non-200 response', async () => { it('throw_cli_error', async () => {
const jsonMock = mock().mockResolvedValue({ error: 'fail' }); const jsonMock = mock().mockResolvedValue({ error: 'fail' });
const responseMock = { status: 400, json: jsonMock }; const responseMock = { status: 400, json: jsonMock };
fetchMock.mockResolvedValue(responseMock); fetchMock.mockResolvedValue(responseMock);
@ -479,13 +479,13 @@ describe('network and id helpers', () => {
}); });
describe('async helpers', () => { describe('async helpers', () => {
it('awaiter resolves when test becomes truthy', async () => { it('resolve_truthy', async () => {
const values = [false, false, 'done']; const values = [false, false, 'done'];
const result = await utils.awaiter(() => values.shift(), 500, 0.01); const result = await utils.awaiter(() => values.shift(), 500, 0.01);
expect(result).toBe('done'); expect(result).toBe('done');
}); });
it('awaiter returns false when timeout reached', async () => { it('timeout', async () => {
const result = await utils.awaiter(() => false, 50, 0.01); const result = await utils.awaiter(() => false, 50, 0.01);
expect(result).toBe(false); expect(result).toBe(false);
}); });

View file

@ -9,7 +9,7 @@ import {
} from '~/utils/sidebarSwipe'; } from '~/utils/sidebarSwipe';
describe('sidebarSwipe', () => { describe('sidebarSwipe', () => {
it('detects apple mobile webkit navigators', () => { it('detect_apple_touch', () => {
expect( expect(
isAppleMobileTouchNavigator({ isAppleMobileTouchNavigator({
userAgent: userAgent:
@ -38,7 +38,7 @@ describe('sidebarSwipe', () => {
).toBe(false); ).toBe(false);
}); });
it('reserves the ios navigation edge and starts the sidebar swipe just inside it', () => { it('reserve_ios_edge', () => {
const nav = { const nav = {
userAgent: userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 Version/18.4 Mobile/15E148 Safari/604.1', 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 Version/18.4 Mobile/15E148 Safari/604.1',
@ -54,7 +54,7 @@ describe('sidebarSwipe', () => {
); );
}); });
it('keeps the original left-edge open band on non-apple mobile browsers', () => { it('keep_default_edge', () => {
const nav = { const nav = {
userAgent: userAgent:
'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/147.0.0.0 Mobile Safari/537.36', 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/147.0.0.0 Mobile Safari/537.36',
@ -67,7 +67,7 @@ describe('sidebarSwipe', () => {
expect(canStartSidebarOpenSwipe(MOBILE_SIDEBAR_EDGE_WIDTH + 1, nav)).toBe(false); expect(canStartSidebarOpenSwipe(MOBILE_SIDEBAR_EDGE_WIDTH + 1, nav)).toBe(false);
}); });
it('returns close while the sidebar is already open', () => { it('return_close', () => {
expect(getSidebarSwipeMode(true, 4)).toBe('close'); expect(getSidebarSwipeMode(true, 4)).toBe('close');
}); });
}); });

View file

@ -6,7 +6,7 @@ function normalize(filters: string[]): Set<string> {
} }
describe("MatchFilterParser", () => { describe("MatchFilterParser", () => {
it("handles simple AND", () => { it("handle_simple_and", () => {
const parser = new MatchFilterParser("filesize>1000000 & duration<600"); const parser = new MatchFilterParser("filesize>1000000 & duration<600");
expect(parser.evaluate({ filesize: 2_000_000, duration: 200 })).toBe(true); expect(parser.evaluate({ filesize: 2_000_000, duration: 200 })).toBe(true);
@ -14,7 +14,7 @@ describe("MatchFilterParser", () => {
expect(parser.evaluate({ filesize: 2_000_000, duration: 800 })).toBe(false); expect(parser.evaluate({ filesize: 2_000_000, duration: 800 })).toBe(false);
}); });
it("handles OR", () => { it("handle_or", () => {
const parser = new MatchFilterParser("uploader='BBC' || uploader='NHK'"); const parser = new MatchFilterParser("uploader='BBC' || uploader='NHK'");
expect(parser.evaluate({ uploader: "BBC" })).toBe(true); expect(parser.evaluate({ uploader: "BBC" })).toBe(true);
@ -22,7 +22,7 @@ describe("MatchFilterParser", () => {
expect(parser.evaluate({ uploader: "CNN" })).toBe(false); expect(parser.evaluate({ uploader: "CNN" })).toBe(false);
}); });
it("handles grouping", () => { it("handle_grouping", () => {
const parser = new MatchFilterParser("(filesize>1000000 & duration<600) || uploader='BBC'"); const parser = new MatchFilterParser("(filesize>1000000 & duration<600) || uploader='BBC'");
expect(parser.evaluate({ filesize: 2_000_000, duration: 200 })).toBe(true); expect(parser.evaluate({ filesize: 2_000_000, duration: 200 })).toBe(true);
@ -30,14 +30,14 @@ describe("MatchFilterParser", () => {
expect(parser.evaluate({ filesize: 500_000, duration: 200, uploader: "CNN" })).toBe(false); expect(parser.evaluate({ filesize: 500_000, duration: 200, uploader: "CNN" })).toBe(false);
}); });
it("handles unary presence", () => { it("handle_unary_presence", () => {
expect(new MatchFilterParser("duration").evaluate({ duration: 100 })).toBe(true); expect(new MatchFilterParser("duration").evaluate({ duration: 100 })).toBe(true);
expect(new MatchFilterParser("duration").evaluate({})).toBe(false); expect(new MatchFilterParser("duration").evaluate({})).toBe(false);
expect(new MatchFilterParser("!duration").evaluate({})).toBe(true); expect(new MatchFilterParser("!duration").evaluate({})).toBe(true);
expect(new MatchFilterParser("!duration").evaluate({ duration: 100 })).toBe(false); expect(new MatchFilterParser("!duration").evaluate({ duration: 100 })).toBe(false);
}); });
it("parses duration with numeric values", () => { it("parse_duration", () => {
expect(new MatchFilterParser("duration<120").evaluate({ duration: 30 })).toBe(true); expect(new MatchFilterParser("duration<120").evaluate({ duration: 30 })).toBe(true);
expect(new MatchFilterParser("duration<120").evaluate({ duration: 200 })).toBe(false); expect(new MatchFilterParser("duration<120").evaluate({ duration: 200 })).toBe(false);
@ -48,7 +48,7 @@ describe("MatchFilterParser", () => {
expect(new MatchFilterParser("duration<3600").evaluate({ duration: 3700 })).toBe(false); expect(new MatchFilterParser("duration<3600").evaluate({ duration: 3700 })).toBe(false);
}); });
it("parses filesize with numeric values", () => { it("parse_filesize", () => {
expect(new MatchFilterParser("filesize>1000000").evaluate({ filesize: 2_000_000 })).toBe(true); expect(new MatchFilterParser("filesize>1000000").evaluate({ filesize: 2_000_000 })).toBe(true);
expect(new MatchFilterParser("filesize>1000000").evaluate({ filesize: 500_000 })).toBe(false); expect(new MatchFilterParser("filesize>1000000").evaluate({ filesize: 500_000 })).toBe(false);
@ -56,7 +56,7 @@ describe("MatchFilterParser", () => {
expect(new MatchFilterParser("filesize>=1073741824").evaluate({ filesize: 1000000 })).toBe(false); expect(new MatchFilterParser("filesize>=1073741824").evaluate({ filesize: 1000000 })).toBe(false);
}); });
it("handles string operators", () => { it("handle_string_operators", () => {
const d = { uploader: "BBC News Channel" }; const d = { uploader: "BBC News Channel" };
expect(new MatchFilterParser("uploader*='News'").evaluate(d)).toBe(true); expect(new MatchFilterParser("uploader*='News'").evaluate(d)).toBe(true);
@ -70,7 +70,7 @@ describe("MatchFilterParser", () => {
expect(new MatchFilterParser("uploader~='News\\s+Network'").evaluate(d)).toBe(false); expect(new MatchFilterParser("uploader~='News\\s+Network'").evaluate(d)).toBe(false);
}); });
it("handles spaces around operators", () => { it("handle_operator_spaces", () => {
const d = { const d = {
"channel_id": "UC-7oMv6E4Uz2tF51w5Sj49w", "channel_id": "UC-7oMv6E4Uz2tF51w5Sj49w",
"uploader": "BBC" "uploader": "BBC"
@ -85,7 +85,7 @@ describe("MatchFilterParser", () => {
expect(new MatchFilterParser("channel_id = 'UCfmrcEdes7yDtEISGPM1T-A' & availability = subscriber_only").evaluate(d)).toBe(false); expect(new MatchFilterParser("channel_id = 'UCfmrcEdes7yDtEISGPM1T-A' & availability = subscriber_only").evaluate(d)).toBe(false);
}); });
it("reproduces original bug report", () => { it("reproduce_bug_report", () => {
const testData = { const testData = {
"age_limit": 0, "age_limit": 0,
"comment_count": 6, "comment_count": 6,
@ -102,7 +102,7 @@ describe("MatchFilterParser", () => {
expect(new MatchFilterParser("channel_id = 'UC-7oMv6E4Uz2tF51w5Sj49w'").evaluate(testData)).toBe(true); expect(new MatchFilterParser("channel_id = 'UC-7oMv6E4Uz2tF51w5Sj49w'").evaluate(testData)).toBe(true);
}); });
it("tests OR operator precedence", () => { it("or_precedence", () => {
const testData = { const testData = {
"age_limit": 0, "age_limit": 0,
"fps": 120, "fps": 120,
@ -134,7 +134,7 @@ describe("MatchFilterParser", () => {
expect(new MatchFilterParser(expr2).evaluate(testDataEdge)).toBe(true); expect(new MatchFilterParser(expr2).evaluate(testDataEdge)).toBe(true);
}); });
it("tests complex OR precedence scenarios", () => { it("handle_chained_or", () => {
const testDataOrOnly = { const testDataOrOnly = {
"age_limit": 1, "age_limit": 1,
"fps": 60, "fps": 60,
@ -163,7 +163,7 @@ describe("MatchFilterParser", () => {
expect(new MatchFilterParser(exprChain).evaluate(testDataOnlyOr)).toBe(true); expect(new MatchFilterParser(exprChain).evaluate(testDataOnlyOr)).toBe(true);
}); });
it("exports filters correctly", () => { it("export_filters", () => {
const simpleParser = new MatchFilterParser("filesize>1000000 & duration<600"); const simpleParser = new MatchFilterParser("filesize>1000000 & duration<600");
const simpleExported = simpleParser.export(); const simpleExported = simpleParser.export();
expect(simpleExported).toEqual(["filesize>1000000&duration<600"]); expect(simpleExported).toEqual(["filesize>1000000&duration<600"]);
@ -177,12 +177,6 @@ describe("MatchFilterParser", () => {
expect(normalize(complexExported)).toEqual(normalize(["filesize>1000000&duration<600", "uploader='BBC'"])); expect(normalize(complexExported)).toEqual(normalize(["filesize>1000000&duration<600", "uploader='BBC'"]));
const testData = { filesize: 2000000, duration: 300 }; const testData = { filesize: 2000000, duration: 300 };
for (const filter of complexExported) { expect(complexExported.some((filter) => new MatchFilterParser(filter).evaluate(testData))).toBe(true);
const filterParser = new MatchFilterParser(filter);
if (filterParser.evaluate(testData)) {
expect(true).toBe(true);
break;
}
}
}); });
}); });

2244
uv.lock

File diff suppressed because it is too large Load diff