Merge pull request #598 from arabcoders/dev
feat: extra placeholders for output template
This commit is contained in:
commit
b35ac1831a
86 changed files with 6462 additions and 2636 deletions
138
API.md
138
API.md
|
|
@ -24,6 +24,7 @@ This document describes the available endpoints and their usage. All endpoints r
|
|||
- [DELETE /api/history](#delete-apihistory)
|
||||
- [POST /api/history/{id}](#post-apihistoryid)
|
||||
- [GET /api/history/{id}](#get-apihistoryid)
|
||||
- [POST /api/history/{id}/rename](#post-apihistoryidrename)
|
||||
- [GET /api/history](#get-apihistory)
|
||||
- [GET /api/history/live](#get-apihistorylive)
|
||||
- [POST /api/history/start](#post-apihistorystart)
|
||||
|
|
@ -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/segments/{segment}/{file:.\*}.ts](#get-apiplayersegmentssegmentfilets)
|
||||
- [GET /api/player/subtitle/{file:.\*}.vtt](#get-apiplayersubtitlefilevtt)
|
||||
- [GET /api/player/subtitles/manifest/{file:.\*}](#get-apiplayersubtitlesmanifestfile)
|
||||
- [GET /api/player/subtitles/{source\_format}/{file:.\*}](#get-apiplayersubtitlessource_formatfile)
|
||||
- [GET /api/thumbnail](#get-apithumbnail)
|
||||
- [GET /api/file/ffprobe/{file:.\*}](#get-apifileffprobefile)
|
||||
- [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/limits](#get-apisystemlimits)
|
||||
- [POST /api/system/terminal](#post-apisystemterminal)
|
||||
- [GET /api/system/terminal](#get-apisystemterminal)
|
||||
- [GET /api/system/terminal/active](#get-apisystemterminalactive)
|
||||
- [GET /api/system/terminal/{session\_id}](#get-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
|
||||
**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
|
||||
**Purpose**: Proxy/fetch a remote thumbnail image.
|
||||
|
||||
|
|
@ -1593,21 +1677,16 @@ Binary image data with the appropriate `Content-Type`.
|
|||
},
|
||||
"mimetype": "video/mp4",
|
||||
"sidecar": {
|
||||
"subtitles": [
|
||||
"subtitle": [
|
||||
{
|
||||
"file": "filename.xxx.vtt",
|
||||
"file": "filename.xxx.ass",
|
||||
"lang": "xxx",
|
||||
"name": "VTT 0 - XXX|end",
|
||||
"name": "ASS (0) - xxx"
|
||||
},
|
||||
...
|
||||
}
|
||||
],
|
||||
"video": [],
|
||||
"audio": [],
|
||||
"image": [],
|
||||
"text": [],
|
||||
"metadata": [],
|
||||
...
|
||||
"text": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -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
|
||||
**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}
|
||||
**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**:
|
||||
```json
|
||||
|
|
@ -2617,7 +2726,7 @@ or
|
|||
"created_at": 1713000000.0,
|
||||
"started_at": 1713000000.0,
|
||||
"finished_at": 1713000004.0,
|
||||
"expires_at": 1713000034.0,
|
||||
"expires_at": 1713086404.0,
|
||||
"exit_code": 0,
|
||||
"last_sequence": 15
|
||||
}
|
||||
|
|
@ -2641,8 +2750,7 @@ or
|
|||
|
||||
**Notes**:
|
||||
- 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 finalize as `interrupted` and remain replayable until the drain window expires.
|
||||
- Cancelled sessions are marked as `interrupted`.
|
||||
|
||||
- `403 Forbidden` if console is disabled.
|
||||
- `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 }
|
||||
```
|
||||
|
||||
**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.
|
||||
- `400 Bad Request` if the replay cursor is invalid.
|
||||
- `404 Not Found` if the session does not exist or has already expired.
|
||||
|
|
|
|||
22
FAQ.md
22
FAQ.md
|
|
@ -167,6 +167,28 @@ YTP_YTDLP_VERSION=2025.07.21 or master or nightly
|
|||
|
||||
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?
|
||||
|
||||
YTPTube includes a **generic task handler** that turns JSON definitions into site-specific scrapers. You can use it
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ class TestAllowInternalUrlsScope:
|
|||
Config._reset_singleton()
|
||||
|
||||
@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.allow_internal_urls = False
|
||||
encoder = Encoder()
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ class TestPresetsRepository:
|
|||
assert total_pages == 1, "Should compute pages from the filtered total"
|
||||
|
||||
@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": "Alpha", "priority": 1})
|
||||
await repo.create({"name": "Bravo", "priority": 1})
|
||||
|
|
@ -113,24 +113,24 @@ class TestPresetsRepository:
|
|||
], "Should support multiple sort fields and directions"
|
||||
|
||||
@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"):
|
||||
await repo.list_paginated(page=1, per_page=10, sort="cli", order="asc")
|
||||
|
||||
@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'"):
|
||||
await repo.list_paginated(page=1, per_page=10, sort="name", order="sideways")
|
||||
|
||||
@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"):
|
||||
await repo.list_paginated(page=1, per_page=10, sort="priority,name", order="asc,desc,asc")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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": "Bravo", "priority": 1})
|
||||
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 [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.query = {"sort": "cli", "order": "asc"}
|
||||
|
||||
|
|
@ -154,7 +154,7 @@ class TestPresetRoutes:
|
|||
assert response.status == web.HTTPBadRequest.status_code, "Should reject unsupported 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.query = {"sort": "name", "order": "sideways"}
|
||||
|
||||
|
|
@ -164,7 +164,7 @@ class TestPresetRoutes:
|
|||
assert response.status == web.HTTPBadRequest.status_code, "Should reject unsupported 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": "Custom Preset", "priority": 1})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
|
|
@ -6,10 +9,37 @@ import pysubs2
|
|||
from pysubs2.formats.substation import SubstationFormat
|
||||
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")
|
||||
|
||||
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:
|
||||
ms = max(0, ms)
|
||||
|
|
@ -22,14 +52,46 @@ SubstationFormat.ms_to_timestamp = ms_to_timestamp
|
|||
|
||||
|
||||
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:
|
||||
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."
|
||||
raise Exception(msg)
|
||||
|
||||
if file.suffix == ".vtt":
|
||||
async with await anyio.open_file(file) as f:
|
||||
return await f.read()
|
||||
if fmt == "vtt":
|
||||
return await self.read_text(file)
|
||||
|
||||
subs: pysubs2.SSAFile = pysubs2.load(path=str(file))
|
||||
|
||||
|
|
@ -47,3 +109,45 @@ class Subtitle:
|
|||
pass
|
||||
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from aiohttp.web import Request, Response
|
|||
from app.features.streaming.library.m3u8 import M3u8
|
||||
from app.features.streaming.library.playlist import Playlist
|
||||
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.library.config import Config
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -84,13 +84,11 @@ class TestFFProbe:
|
|||
|
||||
# First call should execute the function
|
||||
result1 = await ffprobe(str(self.test_file))
|
||||
assert result1 is not None
|
||||
assert isinstance(result1.metadata, dict)
|
||||
first_call_count = call_count
|
||||
|
||||
# Second call with same argument should use cached result
|
||||
result2 = await ffprobe(str(self.test_file))
|
||||
assert result2 is not None
|
||||
assert isinstance(result2.metadata, dict)
|
||||
|
||||
assert call_count == first_call_count, (
|
||||
|
|
@ -119,7 +117,7 @@ class TestFFProbe:
|
|||
|
||||
# Test with Path object
|
||||
result = await ffprobe(self.test_file)
|
||||
assert result is not None
|
||||
assert result.metadata == {"duration": "10.0"}
|
||||
|
||||
def test_ffprobe_result_properties(self):
|
||||
"""Test FFProbeResult object properties."""
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ async def test_make_stream_basic_ok_codecs(tmp_path: Path, monkeypatch: pytest.M
|
|||
|
||||
|
||||
@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.mkdir()
|
||||
media = base / "v.mp4"
|
||||
|
|
@ -107,7 +107,7 @@ async def test_make_stream_transcode_flags_and_remainder(tmp_path: Path, monkeyp
|
|||
|
||||
|
||||
@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.mkdir()
|
||||
media = base / "v.mp4"
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ async def test_make_playlist_with_subtitles(tmp_path: Path, monkeypatch: pytest.
|
|||
|
||||
|
||||
@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.mkdir()
|
||||
media = base / "file.mp4"
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ class _FakeProcFail(_FakeProc):
|
|||
|
||||
|
||||
@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.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
|
||||
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
|
||||
) -> None:
|
||||
async def fake_ffprobe(_file: Path):
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from unittest.mock import patch
|
|||
|
||||
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:
|
||||
|
|
@ -24,12 +24,12 @@ class TestMsToTimestamp:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_unsupported_extension(tmp_path: Path) -> None:
|
||||
srt = tmp_path / "sub.txt"
|
||||
srt.write_text("not a subtitle")
|
||||
file = tmp_path / "sub.txt"
|
||||
file.write_text("not a subtitle")
|
||||
|
||||
sub = Subtitle()
|
||||
subtitle = Subtitle()
|
||||
with pytest.raises(Exception, match="subtitle type is not supported"):
|
||||
await sub.make(srt)
|
||||
await subtitle.make(file)
|
||||
|
||||
|
||||
@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"
|
||||
vtt.write_text(content)
|
||||
|
||||
sub = Subtitle()
|
||||
out = await sub.make(vtt)
|
||||
subtitle = Subtitle()
|
||||
out = await subtitle.make(vtt)
|
||||
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:
|
||||
def __init__(self, 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:
|
||||
mock_load.return_value = _DummySubs(events=[])
|
||||
sub = Subtitle()
|
||||
subtitle = Subtitle()
|
||||
with pytest.raises(Exception, match="No subtitle events were found"):
|
||||
await sub.make(srt)
|
||||
await subtitle.make(srt)
|
||||
|
||||
|
||||
@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.write_text("dummy")
|
||||
|
||||
|
|
@ -75,14 +115,14 @@ async def test_make_single_event_returns_vtt(tmp_path: Path) -> None:
|
|||
d = _DummySubs(events=[single])
|
||||
|
||||
with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d):
|
||||
sub = Subtitle()
|
||||
out = await sub.make(srt)
|
||||
subtitle = Subtitle()
|
||||
out = await subtitle.make(srt)
|
||||
assert out == "OUT"
|
||||
assert d.snapshot == [1000], "Snapshot should contain the single event"
|
||||
|
||||
|
||||
@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.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])
|
||||
|
||||
with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d):
|
||||
sub = Subtitle()
|
||||
out = await sub.make(srt)
|
||||
subtitle = Subtitle()
|
||||
out = await subtitle.make(srt)
|
||||
assert out == "OUT"
|
||||
assert d.snapshot == [5000], "Since ends are equal, first should be popped => only last remains"
|
||||
|
||||
|
||||
@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.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])
|
||||
|
||||
with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d):
|
||||
sub = Subtitle()
|
||||
out = await sub.make(srt)
|
||||
subtitle = Subtitle()
|
||||
out = await subtitle.make(srt)
|
||||
assert out == "OUT"
|
||||
assert d.snapshot == [5000, 6000], "Both remain since ends differ"
|
||||
|
|
|
|||
132
app/features/streaming/tests/test_subtitle_routes.py
Normal file
132
app/features/streaming/tests/test_subtitle_routes.py
Normal 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
|
||||
|
|
@ -21,7 +21,7 @@ def reset_generic_handler(monkeypatch):
|
|||
monkeypatch.setattr(GenericTaskHandler, "_sources_mtime", {})
|
||||
|
||||
|
||||
def test_build_task_definition_parses_valid_payload():
|
||||
def test_build_def_payload():
|
||||
definition = TaskDefinition(
|
||||
id=1,
|
||||
name="example",
|
||||
|
|
@ -40,16 +40,12 @@ def test_build_task_definition_parses_valid_payload():
|
|||
),
|
||||
)
|
||||
|
||||
assert definition is not None, "TaskDefinition should be created"
|
||||
assert "example" == definition.name, "Name should match"
|
||||
assert "https://example.com/articles/*" in definition.match_url, "Match URL should be in list"
|
||||
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"
|
||||
)
|
||||
assert definition.name == "example"
|
||||
assert definition.match_url == ["https://example.com/articles/*"]
|
||||
assert definition.definition.parse["link"]["expression"] == ".article a.link::attr(href)"
|
||||
|
||||
|
||||
def test_build_task_definition_handles_container():
|
||||
def test_build_def_container():
|
||||
definition = TaskDefinition(
|
||||
id=2,
|
||||
name="container",
|
||||
|
|
@ -73,13 +69,11 @@ def test_build_task_definition_handles_container():
|
|||
),
|
||||
)
|
||||
|
||||
assert definition is not None, "TaskDefinition should be created"
|
||||
assert "items" in definition.definition.parse, "Parse should contain items container"
|
||||
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"
|
||||
assert definition.definition.parse["items"]["selector"] == ".cards .card"
|
||||
assert definition.definition.parse["items"]["fields"]["link"]["attribute"] == "href"
|
||||
|
||||
|
||||
def test_build_task_definition_handles_json():
|
||||
def test_build_def_json():
|
||||
definition = TaskDefinition(
|
||||
id=3,
|
||||
name="json-def",
|
||||
|
|
@ -104,16 +98,12 @@ def test_build_task_definition_handles_json():
|
|||
),
|
||||
)
|
||||
|
||||
assert definition is not None, "TaskDefinition should be created"
|
||||
assert "json" == definition.definition.response.type, "Response type should be json"
|
||||
assert "items" in definition.definition.parse, "Parse should contain items container"
|
||||
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"
|
||||
)
|
||||
assert definition.definition.response.type == "json"
|
||||
assert definition.definition.parse["items"]["type"] == "jsonpath"
|
||||
assert definition.definition.parse["items"]["fields"]["link"]["type"] == "jsonpath"
|
||||
|
||||
|
||||
def test_parse_items_extracts_values():
|
||||
def test_parse_items_basic():
|
||||
definition = TaskDefinition(
|
||||
id=4,
|
||||
name="example",
|
||||
|
|
@ -146,14 +136,16 @@ def test_parse_items_extracts_values():
|
|||
|
||||
items = GenericTaskHandler._parse_items(definition, html, "https://example.com/base/")
|
||||
|
||||
assert 2 == len(items), "Should extract 2 items"
|
||||
assert "https://example.com/article-101" == items[0]["link"], "First item link should be absolute URL"
|
||||
assert "First Title" == items[0]["title"], "First item title should match"
|
||||
assert "101" == items[0]["id"], "First item id should match"
|
||||
assert "https://example.com/article-102" == items[1]["link"], "Second item link should match"
|
||||
assert len(items) == 2
|
||||
assert items[0] == {
|
||||
"link": "https://example.com/article-101",
|
||||
"title": "First Title",
|
||||
"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(
|
||||
id=5,
|
||||
name="nested",
|
||||
|
|
@ -234,19 +226,21 @@ def test_parse_items_handles_nested_card_layout():
|
|||
|
||||
items = GenericTaskHandler._parse_items(definition, html, "https://example.com")
|
||||
|
||||
assert 2 == len(items), "Should extract 2 items"
|
||||
assert "https://example.com/poems/view/111" == items[0]["link"], "First item link should match"
|
||||
assert "First Poem" == items[0]["title"], "First item title should match"
|
||||
assert "Poet Alpha" == items[0]["poet"], "First item poet should match"
|
||||
assert "Category One" == items[0]["category"], "First item category should match"
|
||||
|
||||
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 "Poet Beta" == items[1]["poet"], "Second item poet should match"
|
||||
assert "category" not in items[1], "Second item should not have category"
|
||||
assert len(items) == 2
|
||||
assert items[0] == {
|
||||
"link": "https://example.com/poems/view/111",
|
||||
"title": "First Poem",
|
||||
"poet": "Poet Alpha",
|
||||
"category": "Category One",
|
||||
}
|
||||
assert items[1] == {
|
||||
"link": "https://example.com/poems/view/222",
|
||||
"title": "Second Poem",
|
||||
"poet": "Poet Beta",
|
||||
}
|
||||
|
||||
|
||||
def test_parse_items_handles_json_container():
|
||||
def test_parse_items_json():
|
||||
definition = TaskDefinition(
|
||||
id=6,
|
||||
name="json",
|
||||
|
|
@ -287,14 +281,10 @@ def test_parse_items_handles_json_container():
|
|||
json_data=payload,
|
||||
)
|
||||
|
||||
assert 2 == len(items), "Should extract 2 items (third missing link)"
|
||||
assert "https://example.com/video/1" == items[0]["link"], "First item link should be absolute"
|
||||
assert "First" == items[0]["title"], "First item title should match"
|
||||
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"
|
||||
assert items == [
|
||||
{"link": "https://example.com/video/1", "title": "First", "id": "1"},
|
||||
{"link": "https://example.com/video/2", "title": "Second", "id": "2"},
|
||||
]
|
||||
|
||||
|
||||
@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")
|
||||
result: TaskResult | TaskFailure = await GenericTaskHandler.extract(task)
|
||||
|
||||
assert isinstance(result, TaskResult), "Result should be TaskResult"
|
||||
assert 1 == len(result.items), "Should have 1 item"
|
||||
assert isinstance(result, TaskResult)
|
||||
assert len(result.items) == 1
|
||||
item = result.items[0]
|
||||
assert "https://example.com/video/1" == item.url, "Item URL should match"
|
||||
assert "First" == item.title, "Item title should match"
|
||||
assert item.url == "https://example.com/video/1"
|
||||
assert item.title == "First"
|
||||
|
||||
|
||||
def test_parse_items_handles_json_top_level_list():
|
||||
def test_parse_items_json_list():
|
||||
definition = TaskDefinition(
|
||||
id=8,
|
||||
name="json-list",
|
||||
|
|
@ -389,8 +379,7 @@ def test_parse_items_handles_json_top_level_list():
|
|||
json_data=payload,
|
||||
)
|
||||
|
||||
assert 2 == len(items), "Should extract 2 items"
|
||||
assert "https://example.com/video/1" == items[0]["link"], "First item link should match"
|
||||
assert "First" == items[0]["title"], "First item title 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 items == [
|
||||
{"link": "https://example.com/video/1", "title": "First"},
|
||||
{"link": "https://example.com/video/2", "title": "Second"},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ async def test_tver_handler_extract(monkeypatch):
|
|||
|
||||
|
||||
@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."""
|
||||
result = TverHandler.parse(url)
|
||||
if should_match:
|
||||
|
|
@ -153,7 +153,7 @@ def test_tver_handler_parse(url: str, should_match: bool):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tver_handler_can_handle():
|
||||
async def test_can_handle():
|
||||
"""Test tver handler can_handle method."""
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -151,6 +151,22 @@ def _serialize(model: Any) -> dict:
|
|||
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")
|
||||
async def tasks_list(request: Request, repo: TasksRepository, encoder: Encoder) -> Response:
|
||||
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")
|
||||
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()
|
||||
|
||||
if isinstance(data, dict):
|
||||
|
|
@ -205,6 +227,7 @@ async def tasks_add(request: Request, repo: TasksRepository, encoder: Encoder, n
|
|||
status=web.HTTPConflict.status_code,
|
||||
)
|
||||
|
||||
pending_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"})
|
||||
|
||||
|
|
@ -243,11 +266,23 @@ async def tasks_add(request: Request, repo: TasksRepository, encoder: Encoder, n
|
|||
|
||||
try:
|
||||
validated = Task.model_validate(task_dict)
|
||||
task_data = validated.model_dump()
|
||||
except ValidationError as exc:
|
||||
LOG.warning(f"Skipping task {idx}: validation failed - {exc}")
|
||||
continue
|
||||
return web.json_response(
|
||||
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:
|
||||
created = await repo.create(task_data)
|
||||
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")
|
||||
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")):
|
||||
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,
|
||||
)
|
||||
|
||||
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)))
|
||||
notify.emit(Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.UPDATE, data=updated))
|
||||
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")
|
||||
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")):
|
||||
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,
|
||||
)
|
||||
|
||||
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)))
|
||||
notify.emit(Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.UPDATE, data=updated))
|
||||
|
||||
|
|
|
|||
183
app/features/tasks/tests/test_tasks_router.py
Normal file
183
app/features/tasks/tests/test_tasks_router.py
Normal 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
|
||||
113
app/features/ytdlp/outtmpl.py
Normal file
113
app/features/ytdlp/outtmpl.py
Normal 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
|
||||
|
|
@ -20,7 +20,7 @@ class TestProcessPoolConfiguration:
|
|||
assert kwargs == {"mp_context": context}
|
||||
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.delattr("app.features.ytdlp.extractor.sys.frozen", raising=False)
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ class TestProcessPoolConfiguration:
|
|||
assert _get_process_pool_kwargs() == {}
|
||||
get_context.assert_not_called()
|
||||
|
||||
def test_initializes_process_pool_with_context_kwargs(self, monkeypatch):
|
||||
def test_init_pool_kwargs(self, monkeypatch):
|
||||
context = object()
|
||||
monkeypatch.setattr("app.features.ytdlp.extractor._get_process_pool_kwargs", lambda: {"mp_context": context})
|
||||
|
||||
|
|
|
|||
|
|
@ -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": 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 grouping with duration units
|
||||
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": "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'"
|
||||
|
||||
parser = MiniFilter(expr)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
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.utils import _DATA
|
||||
from app.features.ytdlp.ytdlp import YTDLP, _ArchiveProxy, ytdlp_options
|
||||
|
|
@ -20,7 +23,7 @@ class TestArchiveProxy:
|
|||
assert p2.add("") is False
|
||||
|
||||
@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()
|
||||
mock_get_instance.return_value = arch
|
||||
|
||||
|
|
@ -44,7 +47,7 @@ class TestArchiveProxy:
|
|||
|
||||
|
||||
class TestYtDlpOptions:
|
||||
def test_options_structure_and_no_suppresshelp(self) -> None:
|
||||
def test_options_shape(self) -> None:
|
||||
opts = ytdlp_options()
|
||||
|
||||
assert isinstance(opts, list)
|
||||
|
|
@ -91,7 +94,7 @@ class TestYTDLP:
|
|||
return ytdlp
|
||||
|
||||
@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."""
|
||||
mock_super_init.return_value = None
|
||||
|
||||
|
|
@ -118,7 +121,7 @@ class TestYTDLP:
|
|||
assert ytdlp.archive._file == "/tmp/archive.txt"
|
||||
|
||||
@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."""
|
||||
mock_super_init.return_value = None
|
||||
|
||||
|
|
@ -136,7 +139,7 @@ class TestYTDLP:
|
|||
assert not ytdlp.archive
|
||||
|
||||
@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."""
|
||||
mock_super_init.return_value = None
|
||||
|
||||
|
|
@ -147,7 +150,7 @@ class TestYTDLP:
|
|||
assert not ytdlp.archive
|
||||
|
||||
@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
|
||||
|
||||
class FakePopen:
|
||||
|
|
@ -160,7 +163,7 @@ class TestYTDLP:
|
|||
|
||||
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] = []
|
||||
|
||||
class FakePopen:
|
||||
|
|
@ -183,7 +186,7 @@ class TestYTDLP:
|
|||
assert result == 0
|
||||
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] = []
|
||||
|
||||
class FakePopen:
|
||||
|
|
@ -202,7 +205,7 @@ class TestYTDLP:
|
|||
assert calls == [5]
|
||||
|
||||
@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."""
|
||||
with patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__", return_value=None):
|
||||
ytdlp = YTDLP(params={})
|
||||
|
|
@ -221,7 +224,7 @@ class TestYTDLP:
|
|||
assert result is None
|
||||
|
||||
@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."""
|
||||
mock_super_delete.return_value = "cleanup_result"
|
||||
|
||||
|
|
@ -236,7 +239,7 @@ class TestYTDLP:
|
|||
# Should return super's 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."""
|
||||
ytdlp = self._create_ytdlp(params={})
|
||||
ytdlp.archive = Mock()
|
||||
|
|
@ -246,7 +249,7 @@ class TestYTDLP:
|
|||
# Should not interact with archive
|
||||
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."""
|
||||
ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"})
|
||||
ytdlp.write_debug = Mock()
|
||||
|
|
@ -264,7 +267,7 @@ class TestYTDLP:
|
|||
ytdlp.archive.add.assert_called_once_with("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."""
|
||||
ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"})
|
||||
ytdlp.write_debug = Mock()
|
||||
|
|
@ -291,7 +294,7 @@ class TestYTDLP:
|
|||
# Should not add duplicate (youtube new123 appears only once)
|
||||
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."""
|
||||
ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"})
|
||||
ytdlp.write_debug = Mock()
|
||||
|
|
@ -317,7 +320,7 @@ class TestYTDLP:
|
|||
ytdlp.record_download_archive(info_dict)
|
||||
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."""
|
||||
ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"})
|
||||
ytdlp.archive = Mock()
|
||||
|
|
@ -327,3 +330,116 @@ class TestYTDLP:
|
|||
|
||||
# Should not add anything
|
||||
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()))
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class TestYTDLPOpts:
|
|||
assert opts._item_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."""
|
||||
with patch("app.features.ytdlp.ytdlp_opts.Config"):
|
||||
opts = YTDLPOpts.get_instance()
|
||||
|
|
@ -51,7 +51,7 @@ class TestYTDLPOpts:
|
|||
assert "--format best" in opts._item_cli
|
||||
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."""
|
||||
with (
|
||||
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"):
|
||||
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."""
|
||||
with patch("app.features.ytdlp.ytdlp_opts.Config"):
|
||||
opts = YTDLPOpts()
|
||||
|
|
@ -95,7 +95,7 @@ class TestYTDLPOpts:
|
|||
assert opts._item_opts["format"] == "best"
|
||||
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."""
|
||||
with patch("app.features.ytdlp.ytdlp_opts.Config"):
|
||||
opts = YTDLPOpts()
|
||||
|
|
@ -211,7 +211,7 @@ class TestYTDLPOpts:
|
|||
assert opts._preset_opts["cookiefile"] == expected_path
|
||||
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."""
|
||||
with (
|
||||
patch("app.features.ytdlp.ytdlp_opts.Config"),
|
||||
|
|
@ -289,7 +289,7 @@ class TestYTDLPOpts:
|
|||
expected_cli = "--quality 720p\n--format best"
|
||||
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."""
|
||||
with (
|
||||
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
|
||||
|
|
@ -325,7 +325,7 @@ class TestYTDLPOpts:
|
|||
result = opts.get_all(keep=True)
|
||||
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."""
|
||||
with (
|
||||
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
|
||||
|
|
@ -578,7 +578,7 @@ class TestARGSMerger:
|
|||
assert "--output" 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."""
|
||||
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
||||
|
||||
|
|
@ -631,7 +631,7 @@ class TestARGSMerger:
|
|||
assert "--format" 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."""
|
||||
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
||||
|
||||
|
|
@ -641,7 +641,7 @@ class TestARGSMerger:
|
|||
assert result is merger
|
||||
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."""
|
||||
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
||||
|
||||
|
|
@ -651,7 +651,7 @@ class TestARGSMerger:
|
|||
assert result is merger
|
||||
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."""
|
||||
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
||||
|
||||
|
|
@ -769,7 +769,7 @@ class TestYTDLPCli:
|
|||
assert cli.item is item
|
||||
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."""
|
||||
from app.features.ytdlp.ytdlp_opts import YTDLPCli
|
||||
|
||||
|
|
|
|||
|
|
@ -48,14 +48,14 @@ class TestLogWrapper:
|
|||
with pytest.raises(TypeError, match=r"Target must be a logging\.Logger instance or a callable"):
|
||||
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()
|
||||
logger, _ = make_logger("one")
|
||||
|
||||
# Name inferred from logger
|
||||
lw.add_target(logger)
|
||||
assert lw.targets[-1].name == "one"
|
||||
assert lw.has_targets() is True
|
||||
assert lw.has_targets()
|
||||
|
||||
# Name inferred from callable
|
||||
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"]
|
||||
filters = [re.compile(r"ERROR")]
|
||||
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):
|
||||
"""Test with empty logs."""
|
||||
|
|
@ -146,8 +146,8 @@ class TestYtdlpReject:
|
|||
yt_params = {}
|
||||
|
||||
passed, message = ytdlp_reject(entry, yt_params)
|
||||
assert isinstance(passed, bool)
|
||||
assert isinstance(message, str)
|
||||
assert passed is True
|
||||
assert message == ""
|
||||
|
||||
def test_ytdlp_reject_with_filters(self):
|
||||
"""Test rejection with filters."""
|
||||
|
|
@ -158,8 +158,9 @@ class TestYtdlpReject:
|
|||
yt_params["daterange"].__contains__ = MagicMock(return_value=False)
|
||||
|
||||
passed, message = ytdlp_reject(entry, yt_params)
|
||||
assert isinstance(passed, bool)
|
||||
assert isinstance(message, str)
|
||||
assert passed is False
|
||||
assert "20230101" in message
|
||||
assert "not in range" in message
|
||||
|
||||
|
||||
class TestParseOuttmpl:
|
||||
|
|
@ -256,7 +257,7 @@ class TestParseOuttmpl:
|
|||
|
||||
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."""
|
||||
|
||||
template = "%(title)s.%(ext)s"
|
||||
|
|
@ -284,7 +285,7 @@ class TestParseOuttmpl:
|
|||
|
||||
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."""
|
||||
|
||||
template = "%(uploader)s/%(title)s.%(ext)s"
|
||||
|
|
@ -302,19 +303,19 @@ class TestParseOuttmpl:
|
|||
|
||||
|
||||
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."""
|
||||
|
||||
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."""
|
||||
|
||||
assert get_thumbnail(None) is None
|
||||
assert get_thumbnail("not a 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."""
|
||||
|
||||
thumbnails = [
|
||||
|
|
@ -326,7 +327,7 @@ class TestGetThumbnail:
|
|||
result = get_thumbnail(thumbnails)
|
||||
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."""
|
||||
|
||||
thumbnails = [
|
||||
|
|
@ -338,7 +339,7 @@ class TestGetThumbnail:
|
|||
result = get_thumbnail(thumbnails)
|
||||
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."""
|
||||
|
||||
thumbnails = [
|
||||
|
|
@ -349,7 +350,7 @@ class TestGetThumbnail:
|
|||
result = get_thumbnail(thumbnails)
|
||||
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."""
|
||||
|
||||
thumbnails = [
|
||||
|
|
@ -357,17 +358,15 @@ class TestGetThumbnail:
|
|||
{"url": "second.jpg"},
|
||||
]
|
||||
|
||||
result = get_thumbnail(thumbnails)
|
||||
assert result is not None
|
||||
assert result["url"] in ["first.jpg", "second.jpg"]
|
||||
assert get_thumbnail(thumbnails) == {"url": "second.jpg"}
|
||||
|
||||
|
||||
class TestGetExtras:
|
||||
def test_returns_empty_dict_for_none(self):
|
||||
def test_none(self):
|
||||
"""Test that empty dict is returned for None input."""
|
||||
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."""
|
||||
assert get_extras("not a dict") == {}
|
||||
assert get_extras([]) == {}
|
||||
|
|
@ -409,7 +408,7 @@ class TestGetExtras:
|
|||
assert result["playlist_uploader"] == "Playlist Owner"
|
||||
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."""
|
||||
|
||||
entry = {
|
||||
|
|
@ -421,7 +420,7 @@ class TestGetExtras:
|
|||
assert "release_in" in result
|
||||
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."""
|
||||
|
||||
entry = {
|
||||
|
|
@ -434,7 +433,7 @@ class TestGetExtras:
|
|||
assert result["is_live"] == 1234567890
|
||||
assert "release_in" in result
|
||||
|
||||
def test_handles_premiere_flag(self):
|
||||
def test_premiere_flag(self):
|
||||
"""Test handling of is_premiere flag."""
|
||||
|
||||
entry = {
|
||||
|
|
@ -481,7 +480,7 @@ class TestGetStaticYtdlp:
|
|||
|
||||
_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."""
|
||||
from app.features.ytdlp.ytdlp import YTDLP
|
||||
|
||||
|
|
@ -491,7 +490,7 @@ class TestGetStaticYtdlp:
|
|||
assert instance is not None
|
||||
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."""
|
||||
|
||||
instance1 = get_ytdlp()
|
||||
|
|
@ -508,7 +507,7 @@ class TestGetStaticYtdlp:
|
|||
assert instance1 is not instance2
|
||||
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."""
|
||||
|
||||
instance = get_ytdlp()
|
||||
|
|
@ -560,22 +559,22 @@ class TestArchiveFunctions:
|
|||
|
||||
def test_archive_add_and_read(self):
|
||||
"""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)
|
||||
assert isinstance(result, bool)
|
||||
assert result is True
|
||||
|
||||
# Read entries - just test it returns a list
|
||||
read_ids = archive_read(self.archive_file)
|
||||
assert isinstance(read_ids, list)
|
||||
assert set(read_ids) == set(ids)
|
||||
|
||||
def test_archive_delete(self):
|
||||
"""Test deleting archive entries."""
|
||||
# Delete some entries - just test it returns a boolean
|
||||
delete_ids = ["id2"]
|
||||
archive_add(self.archive_file, ["youtube id1", "youtube id2", "youtube id3"])
|
||||
|
||||
delete_ids = ["youtube id2"]
|
||||
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):
|
||||
"""Test reading from non-existent archive."""
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from typing import Any
|
|||
import yt_dlp
|
||||
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.library.cf_solver_handler import set_cf_handler
|
||||
|
||||
|
|
@ -76,6 +77,8 @@ class YTDLP(yt_dlp.YoutubeDL):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
self._ytptube_outtmpl_info: dict[str, Any] | None = None
|
||||
self._ytptube_outtmpl_cache: dict[str, Any] = {}
|
||||
self.archive = _ArchiveProxy(orig_file)
|
||||
if not YTDLP._registered:
|
||||
try:
|
||||
|
|
@ -94,6 +97,25 @@ class YTDLP(yt_dlp.YoutubeDL):
|
|||
|
||||
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:
|
||||
if not self.params.get("download_archive"):
|
||||
return
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ class HttpAPI:
|
|||
|
||||
response: Response = await handler(request)
|
||||
|
||||
contentType: str = response.headers.get("content-type", "")
|
||||
contentType: str = str(response.headers.get("content-type", ""))
|
||||
if contentType.startswith("text/html") and getattr(response, "_path", None):
|
||||
rewrite_path: str = base_path.rstrip("/")
|
||||
async with await anyio.open_file(response._path, "rb") as f:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any
|
|||
from aiohttp import web
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.Scheduler import Scheduler
|
||||
from app.library.Services import Services
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
|
|
@ -31,8 +32,10 @@ ACTIVE_FILE_NAME = "active.json"
|
|||
METADATA_FILE_NAME = "metadata.json"
|
||||
TRANSCRIPT_FILE_NAME = "transcript.jsonl"
|
||||
DEFAULT_DRAIN_TTL = 30.0
|
||||
COMPLETED_SESSION_RETENTION = 86400.0
|
||||
DEFAULT_KEEPALIVE_INTERVAL = 15.0
|
||||
DEFAULT_SHUTDOWN_TIMEOUT = 5.0
|
||||
CLEANUP_SCHEDULE = "5 */1 * * *"
|
||||
|
||||
|
||||
class TerminalSessionConflictError(RuntimeError):
|
||||
|
|
@ -55,8 +58,10 @@ class TerminalSessionManager(metaclass=Singleton):
|
|||
self._lock = asyncio.Lock()
|
||||
self._active: ActiveTerminalSession | None = None
|
||||
self._drain_ttl: float = DEFAULT_DRAIN_TTL
|
||||
self._completed_retention: float = COMPLETED_SESSION_RETENTION
|
||||
self._keepalive_interval: float = DEFAULT_KEEPALIVE_INTERVAL
|
||||
self._shutdown_timeout: float = DEFAULT_SHUTDOWN_TIMEOUT
|
||||
self._cleanup_job_id: str | None = None
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> TerminalSessionManager:
|
||||
|
|
@ -67,11 +72,20 @@ class TerminalSessionManager(metaclass=Singleton):
|
|||
Services.get_instance().add("terminal_manager", self)
|
||||
app.on_startup.append(self.on_startup)
|
||||
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:
|
||||
await self.initialize()
|
||||
|
||||
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
|
||||
task: asyncio.Task[None] | None = None
|
||||
|
||||
|
|
@ -121,12 +135,17 @@ class TerminalSessionManager(metaclass=Singleton):
|
|||
|
||||
async def cleanup(self) -> None:
|
||||
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 with self._lock:
|
||||
now = time.time()
|
||||
self._cleanup_expired_sessions(now)
|
||||
|
||||
active_session = self._get_active_session_locked()
|
||||
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 with self._lock:
|
||||
self._cleanup_expired_sessions(time.time())
|
||||
metadata = self._get_active_session_locked()
|
||||
return None if metadata is None else dict(metadata)
|
||||
|
||||
async def get_session(self, session_id: str) -> dict[str, Any] | None:
|
||||
async with self._lock:
|
||||
self._cleanup_expired_sessions(time.time())
|
||||
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 with self._lock:
|
||||
self._cleanup_expired_sessions(time.time())
|
||||
|
||||
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}'."
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
|
|
@ -218,6 +235,9 @@ class TerminalSessionManager(metaclass=Singleton):
|
|||
async with self._lock:
|
||||
metadata = self._load_metadata(session_id)
|
||||
if metadata is not None:
|
||||
if self._is_expired_metadata(metadata, time.time()):
|
||||
return response
|
||||
|
||||
replay_until = int(metadata.get("last_sequence", last_sent))
|
||||
|
||||
runtime = self._active
|
||||
|
|
@ -443,7 +463,7 @@ class TerminalSessionManager(metaclass=Singleton):
|
|||
metadata["status"] = status
|
||||
metadata["exit_code"] = exit_code
|
||||
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)
|
||||
|
||||
if self._load_active_marker() == session_id:
|
||||
|
|
@ -468,6 +488,31 @@ class TerminalSessionManager(metaclass=Singleton):
|
|||
|
||||
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:
|
||||
session_id = self._load_active_marker()
|
||||
if session_id is None:
|
||||
|
|
@ -480,14 +525,15 @@ class TerminalSessionManager(metaclass=Singleton):
|
|||
|
||||
metadata["status"] = "interrupted"
|
||||
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"]
|
||||
self._write_json(self._metadata_path(session_id), metadata)
|
||||
self._clear_active_marker()
|
||||
|
||||
def _cleanup_expired_sessions(self, now: float) -> None:
|
||||
def _cleanup_expired_sessions(self, now: float) -> int:
|
||||
self._ensure_root()
|
||||
active_session_id = self._load_active_marker()
|
||||
removed = 0
|
||||
|
||||
for path in self.root_path.iterdir():
|
||||
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)
|
||||
if metadata is None:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
removed += 1
|
||||
continue
|
||||
|
||||
expires_at = metadata.get("expires_at")
|
||||
if expires_at is None or float(expires_at) > now:
|
||||
if not self._is_expired_metadata(metadata, now):
|
||||
continue
|
||||
|
||||
if active_session_id == path.name:
|
||||
|
|
@ -508,6 +554,10 @@ class TerminalSessionManager(metaclass=Singleton):
|
|||
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
removed += 1
|
||||
|
||||
return removed
|
||||
|
||||
def _parse_since(self, request: Request) -> int:
|
||||
values: list[int] = []
|
||||
candidates = [request.query.get("since"), request.headers.get("Last-Event-ID")]
|
||||
|
|
@ -537,7 +587,7 @@ class TerminalSessionManager(metaclass=Singleton):
|
|||
now = time.time()
|
||||
metadata["status"] = "interrupted"
|
||||
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"]
|
||||
self._write_json(self._metadata_path(session_id), metadata)
|
||||
|
||||
|
|
@ -618,6 +668,20 @@ class TerminalSessionManager(metaclass=Singleton):
|
|||
|
||||
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]:
|
||||
env_vars = os.environ.copy()
|
||||
env_vars.update(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiohttp import web
|
||||
|
|
@ -10,10 +11,12 @@ from app.features.presets.service import Presets
|
|||
from app.library.config import Config
|
||||
from app.library.DataStore import StoreType
|
||||
from app.library.downloads import Download, DownloadQueue
|
||||
from app.library.downloads.utils import safe_relative_path
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.ItemDTO import Item
|
||||
from app.library.router import route
|
||||
from app.library.Utils import calc_download_path, get_file_sidecar, rename_file
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from library.downloads import Download
|
||||
|
|
@ -287,9 +290,9 @@ async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder) ->
|
|||
}
|
||||
|
||||
if "finished" == item.info.status and (filename := item.info.get_file()):
|
||||
from app.features.streaming.library.ffprobe import ffprobe
|
||||
|
||||
try:
|
||||
from app.features.streaming.library.ffprobe import ffprobe
|
||||
|
||||
info["ffprobe"] = await ffprobe(filename)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -302,6 +305,70 @@ async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder) ->
|
|||
return web.json_response(data=info, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("POST", r"api/history/{id}/rename", "history.item.rename")
|
||||
async def item_rename(
|
||||
request: Request,
|
||||
queue: DownloadQueue,
|
||||
encoder: Encoder,
|
||||
notify: EventBus,
|
||||
config: Config,
|
||||
) -> Response:
|
||||
if not (id := request.match_info.get("id")):
|
||||
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
item: Download | None = await queue.done.get_by_id(id)
|
||||
if not item:
|
||||
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
try:
|
||||
post: dict = await request.json()
|
||||
if not post:
|
||||
return web.json_response(data={"error": "no data provided."}, status=web.HTTPBadRequest.status_code)
|
||||
except Exception:
|
||||
return web.json_response(data={"error": "invalid JSON body."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
new_name: str = str(post.get("new_name") or "").strip()
|
||||
if not new_name:
|
||||
return web.json_response(data={"error": "new_name is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
filepath: Path | None = item.info.get_file(download_path=Path(config.download_path))
|
||||
if not filepath:
|
||||
return web.json_response(data={"error": "item has no downloaded file."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
renamed, sidecar_renamed = rename_file(filepath, new_name)
|
||||
except ValueError as e:
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPConflict.status_code)
|
||||
except OSError as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
|
||||
|
||||
item_dir: Path = (
|
||||
Path(item.info.download_dir)
|
||||
if item.info.download_dir
|
||||
else Path(calc_download_path(base_path=config.download_path, folder=item.info.folder, create_path=False))
|
||||
)
|
||||
item.info.filename = safe_relative_path(renamed, item_dir, Path(config.download_path))
|
||||
|
||||
try:
|
||||
item.info.sidecar = get_file_sidecar(renamed)
|
||||
except Exception:
|
||||
item.info.sidecar = {}
|
||||
|
||||
await queue.done.put(item, no_notify=True)
|
||||
notify.emit(Events.ITEM_UPDATED, data=item.info)
|
||||
|
||||
sidecar_count: int = len(sidecar_renamed)
|
||||
sidecar_msg: str = f" and {sidecar_count} sidecar file/s" if sidecar_count > 0 else ""
|
||||
LOG.info(f"Renamed file '{filepath}' to '{renamed}'{sidecar_msg}")
|
||||
|
||||
return web.json_response(
|
||||
data=item.info,
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("POST", "api/history/{id}", "item_update")
|
||||
async def item_update(request: Request, queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -274,6 +274,20 @@ async def create_terminal_session(
|
|||
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")
|
||||
async def get_active_terminal_session(
|
||||
config: Config, encoder: Encoder, terminal_manager: TerminalSessionManager
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from uuid import uuid4
|
|||
if TYPE_CHECKING:
|
||||
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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class TestCache:
|
|||
time.sleep(0.2)
|
||||
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)."""
|
||||
self.cache.set("permanent_key", "permanent_value")
|
||||
assert self.cache.get("permanent_key") == "permanent_value"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ def reset_conditions_singleton():
|
|||
|
||||
class TestConditionIgnoreMatching:
|
||||
@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)
|
||||
second = SimpleNamespace(id=2, name="Fallback", enabled=True, filter="duration > 0", priority=10)
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ class TestConditionIgnoreMatching:
|
|||
assert matched is second
|
||||
|
||||
@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)
|
||||
second = SimpleNamespace(id=124, name="Fallback", enabled=True, filter="duration > 0", priority=10)
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ class TestConditionIgnoreMatching:
|
|||
assert matched is second
|
||||
|
||||
@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)
|
||||
second = SimpleNamespace(id=124, name="Fallback", enabled=True, filter="duration > 0", priority=10)
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ class TestConditionIgnoreMatching:
|
|||
assert matched is second
|
||||
|
||||
@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)
|
||||
|
||||
service = Conditions.get_instance()
|
||||
|
|
@ -67,7 +67,7 @@ class TestConditionIgnoreMatching:
|
|||
|
||||
class TestConditionIgnorePropagation:
|
||||
@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.config = Mock(temp_path="/tmp", ignore_archived_items=False, ytdlp_debug=False)
|
||||
queue._notify = Mock()
|
||||
|
|
@ -102,7 +102,7 @@ class TestConditionIgnorePropagation:
|
|||
assert result == {"status": "ok"}
|
||||
|
||||
@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.config = Mock(temp_path="/tmp", ignore_archived_items=False, ytdlp_debug=False)
|
||||
queue._notify = Mock()
|
||||
|
|
@ -133,7 +133,7 @@ class TestConditionIgnorePropagation:
|
|||
matcher.match.assert_awaited_once_with(info=entry, ignore_conditions=["123", "Primary", "*"])
|
||||
|
||||
@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] = {}
|
||||
|
||||
class FakeItem:
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ class TestDataStore:
|
|||
assert ok is True
|
||||
|
||||
@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.bulk_delete_by_status = AsyncMock(return_value=2)
|
||||
store = DataStore(StoreType.HISTORY, connection)
|
||||
|
|
@ -243,7 +243,7 @@ class TestDataStore:
|
|||
assert pending._id in store._dict
|
||||
|
||||
@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."""
|
||||
db = await make_db()
|
||||
store = DataStore(StoreType.QUEUE, db)
|
||||
|
|
@ -319,7 +319,7 @@ class TestDataStore:
|
|||
await db.close()
|
||||
|
||||
@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."""
|
||||
db = await make_db()
|
||||
store = DataStore(StoreType.QUEUE, db)
|
||||
|
|
@ -366,7 +366,7 @@ class TestDataStore:
|
|||
await db.close()
|
||||
|
||||
@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."""
|
||||
db = await make_db()
|
||||
store = DataStore(StoreType.QUEUE, db)
|
||||
|
|
@ -689,7 +689,7 @@ class TestDataStore:
|
|||
await db.close()
|
||||
|
||||
@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."""
|
||||
db = await make_db()
|
||||
store = DataStore(StoreType.QUEUE, db)
|
||||
|
|
@ -708,7 +708,7 @@ class TestDataStore:
|
|||
await db.close()
|
||||
|
||||
@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."""
|
||||
db = await make_db()
|
||||
store = DataStore(StoreType.QUEUE, db)
|
||||
|
|
@ -740,7 +740,7 @@ class TestDataStore:
|
|||
await db.close()
|
||||
|
||||
@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."""
|
||||
db = await make_db()
|
||||
store = DataStore(StoreType.QUEUE, db)
|
||||
|
|
@ -760,7 +760,7 @@ class TestDataStore:
|
|||
await db.close()
|
||||
|
||||
@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."""
|
||||
store = await make_store_async(StoreType.QUEUE)
|
||||
conn = store._connection
|
||||
|
|
@ -781,7 +781,7 @@ class TestDataStore:
|
|||
await store._connection.close()
|
||||
|
||||
@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."""
|
||||
store = await make_store_async(StoreType.QUEUE)
|
||||
conn = store._connection
|
||||
|
|
|
|||
|
|
@ -310,8 +310,9 @@ class TestDownloadFlow:
|
|||
)
|
||||
|
||||
class FakeYTDLP:
|
||||
def __init__(self, params):
|
||||
def __init__(self, params, enable_custom_outtmpl=False):
|
||||
self.params = params
|
||||
self.enable_custom_outtmpl = enable_custom_outtmpl
|
||||
self._download_retcode = 0
|
||||
self._interrupted = False
|
||||
|
||||
|
|
@ -326,7 +327,7 @@ class TestDownloadFlow:
|
|||
assert queue.items[0]["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:
|
||||
debug = False
|
||||
ytdlp_debug = False
|
||||
|
|
@ -414,9 +415,7 @@ class TestDownloadFlow:
|
|||
assert ydl._interrupted is True
|
||||
ydl.to_screen.assert_called_once_with("[info] Interrupt received, exiting cleanly...")
|
||||
|
||||
def test_download_prefers_real_playlist_extras_over_placeholder_preinfo(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def test_playlist_extras(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class Cfg:
|
||||
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"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_cancelled_download_drains_final_status_updates(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
async def test_live_cancel_drains_final(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
class Cfg:
|
||||
debug = False
|
||||
ytdlp_debug = False
|
||||
|
|
@ -710,7 +707,7 @@ class TestDownloadFlow:
|
|||
assert download.info.filename == "live.mp4", "Finalized live filename should be preserved"
|
||||
|
||||
@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:
|
||||
debug = False
|
||||
ytdlp_debug = False
|
||||
|
|
@ -784,7 +781,7 @@ class TestDownloadSpawnPickling:
|
|||
def setup_method(self):
|
||||
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:
|
||||
debug = False
|
||||
ytdlp_debug = False
|
||||
|
|
@ -834,7 +831,7 @@ class TestDownloadSpawnPickling:
|
|||
|
||||
|
||||
class TestTempManager:
|
||||
def test_create_temp_path_when_disabled(self) -> None:
|
||||
def test_create_temp_path_disabled(self) -> None:
|
||||
info = make_item()
|
||||
logger = logging.getLogger("test")
|
||||
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 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()
|
||||
logger = logging.getLogger("test")
|
||||
tm = TempManager(info, None, temp_disabled=False, temp_keep=False, logger=logger)
|
||||
|
|
@ -873,7 +870,7 @@ class TestTempManager:
|
|||
path2 = tm2.create_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()
|
||||
logger = logging.getLogger("test")
|
||||
tm = TempManager(info, str(tmp_path), temp_disabled=True, temp_keep=False, logger=logger)
|
||||
|
|
@ -883,7 +880,7 @@ class TestTempManager:
|
|||
tm.delete_temp()
|
||||
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()
|
||||
logger = logging.getLogger("test")
|
||||
tm = TempManager(info, str(tmp_path), temp_disabled=False, temp_keep=True, logger=logger)
|
||||
|
|
@ -893,12 +890,13 @@ class TestTempManager:
|
|||
tm.delete_temp()
|
||||
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()
|
||||
logger = logging.getLogger("test")
|
||||
tm = TempManager(info, "/tmp", temp_disabled=False, temp_keep=False, logger=logger)
|
||||
|
||||
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:
|
||||
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 "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")
|
||||
pm = ProcessManager("test-id", is_live=False, logger=logger)
|
||||
|
||||
|
|
@ -972,13 +970,13 @@ class TestProcessManager:
|
|||
pm.create_process(lambda: None)
|
||||
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")
|
||||
pm = ProcessManager("test-id", is_live=False, logger=logger)
|
||||
|
||||
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")
|
||||
pm = ProcessManager("test-id", is_live=False, logger=logger)
|
||||
|
||||
|
|
@ -993,7 +991,7 @@ class TestProcessManager:
|
|||
result = pm.cancel()
|
||||
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")
|
||||
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 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")
|
||||
pm = ProcessManager("test-id", is_live=False, logger=logger)
|
||||
|
||||
|
|
@ -1024,7 +1022,7 @@ class TestProcessManager:
|
|||
mock_kill.assert_called_once_with(12345, signal.SIGUSR1)
|
||||
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")
|
||||
pm_live = ProcessManager("test-id", is_live=True, 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)
|
||||
|
||||
@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")
|
||||
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"
|
||||
|
||||
@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")
|
||||
pm = ProcessManager("test-id", is_live=False, logger=logger)
|
||||
pm.proc = Mock()
|
||||
|
|
@ -1117,7 +1115,7 @@ class TestStatusTracker:
|
|||
assert st.final_update is False, "Should initialize final_update as False"
|
||||
|
||||
@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)
|
||||
status = {"id": "wrong-id", "status": "downloading"}
|
||||
|
||||
|
|
@ -1125,7 +1123,7 @@ class TestStatusTracker:
|
|||
assert st.info.status != "downloading", "Should not update status for wrong ID"
|
||||
|
||||
@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)
|
||||
status = {"id": "test-id"}
|
||||
|
||||
|
|
@ -1140,7 +1138,7 @@ class TestStatusTracker:
|
|||
assert st.info.status == "downloading", "Should update info status"
|
||||
|
||||
@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)
|
||||
status = {"id": "test-id", "status": "downloading", "download_skipped": True}
|
||||
|
||||
|
|
@ -1156,7 +1154,7 @@ class TestStatusTracker:
|
|||
assert st.tmpfilename == "/tmp/file.part", "Should update tmpfilename"
|
||||
|
||||
@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)
|
||||
status = {
|
||||
"id": "test-id",
|
||||
|
|
@ -1171,7 +1169,7 @@ class TestStatusTracker:
|
|||
assert st.info.percent == 50.0, "Should calculate percent correctly"
|
||||
|
||||
@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)
|
||||
status = {
|
||||
"id": "test-id",
|
||||
|
|
@ -1185,7 +1183,7 @@ class TestStatusTracker:
|
|||
assert st.info.percent == 30.0, "Should calculate percent from estimate"
|
||||
|
||||
@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)
|
||||
status = {
|
||||
"id": "test-id",
|
||||
|
|
@ -1198,7 +1196,7 @@ class TestStatusTracker:
|
|||
assert st.info.percent == 50.0, "Should calculate percent correctly with valid total"
|
||||
|
||||
@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)
|
||||
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"
|
||||
|
||||
@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.put({"id": "test-id", "status": "downloading"})
|
||||
queue.put(None)
|
||||
|
|
@ -1266,8 +1264,9 @@ class TestStatusTracker:
|
|||
st = StatusTracker(**config)
|
||||
|
||||
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.update_task = Mock()
|
||||
st.update_task.done = Mock(return_value=False)
|
||||
|
|
@ -1276,10 +1275,11 @@ class TestStatusTracker:
|
|||
st.cancel_update_task()
|
||||
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.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:
|
||||
queue = DummyQueue()
|
||||
|
|
@ -1315,7 +1315,7 @@ class TestQueueManager:
|
|||
assert status[item.info._id] == "ok", "Running cancel should still report success"
|
||||
|
||||
@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.queue = Mock()
|
||||
queue_manager.done = Mock()
|
||||
|
|
@ -1337,7 +1337,7 @@ class TestQueueManager:
|
|||
assert status[item.info._id] == "ok", "Regular running cancel should still report success"
|
||||
|
||||
@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.config = Mock(remove_files=False, download_path="/tmp")
|
||||
queue_manager._notify = Mock()
|
||||
|
|
@ -1363,7 +1363,7 @@ class TestQueueManager:
|
|||
assert status[item.info._id] == "ok", "Clear should still report success after flushing deletes"
|
||||
|
||||
@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.config = Mock(remove_files=False, download_path="/tmp")
|
||||
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}
|
||||
|
||||
@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.config = Mock(remove_files=False, download_path="/tmp")
|
||||
queue_manager._notify = Mock()
|
||||
|
|
@ -1413,7 +1413,7 @@ class TestQueueManager:
|
|||
assert queue_manager._notify.emit.call_args.kwargs["data"] == {"count": 1, "status": "finished"}
|
||||
|
||||
@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.config = Mock(remove_files=True, download_path="/tmp")
|
||||
queue_manager._notify = Mock()
|
||||
|
|
@ -1437,7 +1437,7 @@ class TestQueueManager:
|
|||
|
||||
class TestPoolManager:
|
||||
@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] = []
|
||||
|
||||
class EB:
|
||||
|
|
@ -1482,9 +1482,7 @@ class TestPoolManager:
|
|||
done_store.put.assert_awaited_once_with(entry)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_regular_entry_with_final_file_stays_cancelled(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def test_cancelled_regular_file_stays(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
emitted_events: list[str] = []
|
||||
|
||||
class EB:
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class TestPathUtilities:
|
|||
result = safe_relative_path(file, base, fallback)
|
||||
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")
|
||||
file = Path("/downloads/video.mp4")
|
||||
result = safe_relative_path(file, base)
|
||||
|
|
@ -73,13 +73,13 @@ class TestPathUtilities:
|
|||
|
||||
|
||||
class TestProcessUtilities:
|
||||
def test_wait_for_process_with_timeout_completes_immediately(self) -> None:
|
||||
def test_wait_timeout_done(self) -> None:
|
||||
proc = Mock()
|
||||
proc.is_alive = Mock(return_value=False)
|
||||
result = wait_for_process_with_timeout(proc, timeout=1.0)
|
||||
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()
|
||||
call_count = [0]
|
||||
|
||||
|
|
@ -123,7 +123,7 @@ class TestConfigUtilities:
|
|||
result = parse_extractor_limit("youtube", default_limit=5, max_workers=10)
|
||||
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")
|
||||
with patch.dict(os.environ, {"YTP_MAX_WORKERS_FOR_YOUTUBE": "abc"}):
|
||||
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)
|
||||
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)
|
||||
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()
|
||||
with patch.dict(os.environ, {"YTP_MAX_WORKERS_FOR_YOUTUBE": "invalid"}):
|
||||
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 "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 = {
|
||||
"status": "downloading",
|
||||
"info_dict": {"id": "123", "none_value": None, "lambda_value": lambda: None, "title": "Video"},
|
||||
|
|
@ -212,7 +212,7 @@ class TestDataUtilities:
|
|||
|
||||
|
||||
class TestStateUtilities:
|
||||
def test_is_download_stale_terminal_status_finished(self) -> None:
|
||||
def test_stale_finished(self) -> None:
|
||||
result = is_download_stale(
|
||||
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"
|
||||
|
||||
def test_is_download_stale_terminal_status_cancelled(self) -> None:
|
||||
def test_stale_cancelled(self) -> None:
|
||||
result = is_download_stale(
|
||||
started_time=int(time.time()) - 500, current_status="cancelled", is_running=False, auto_start=True
|
||||
)
|
||||
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(
|
||||
started_time=int(time.time()) - 500, current_status="downloading", is_running=False, auto_start=True
|
||||
)
|
||||
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(
|
||||
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()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_task_exception_ignores_successful(self) -> None:
|
||||
async def test_task_exception_success(self) -> None:
|
||||
logger = Mock()
|
||||
|
||||
async def successful_task():
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class TestEncoder:
|
|||
result = self.encoder.default(obj)
|
||||
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."""
|
||||
# This should raise TypeError since complex is not JSON serializable
|
||||
with pytest.raises(TypeError):
|
||||
|
|
|
|||
|
|
@ -460,7 +460,7 @@ class TestEvent:
|
|||
assert f"handler3_{i}" in results
|
||||
|
||||
@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."""
|
||||
bus = EventBus()
|
||||
execution_times = []
|
||||
|
|
@ -511,7 +511,7 @@ class TestEvent:
|
|||
class TestEventListener:
|
||||
"""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."""
|
||||
|
||||
def sync_callback(event, name, **kwargs): # noqa: ARG001
|
||||
|
|
@ -523,7 +523,7 @@ class TestEventListener:
|
|||
assert listener.call_back == sync_callback
|
||||
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."""
|
||||
|
||||
async def async_callback(event, name, **kwargs): # noqa: ARG001
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.DataStore import StoreType
|
||||
from app.routes.api.history import items_delete
|
||||
from app.library.ItemDTO import ItemDTO
|
||||
from app.library.encoder import Encoder
|
||||
from app.routes.api.history import item_rename, items_delete
|
||||
from app.tests.helpers import temporary_test_dir
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
|
|
@ -14,13 +18,41 @@ class _FakeRequest:
|
|||
self._payload = payload or {}
|
||||
self.query: dict[str, str] = {}
|
||||
self.match_info: dict[str, str] = {}
|
||||
self.body_exists = bool(payload)
|
||||
|
||||
async def json(self) -> dict[str, Any]:
|
||||
return self._payload
|
||||
|
||||
|
||||
def _make_download(
|
||||
*,
|
||||
filename: str | None = None,
|
||||
folder: str = "",
|
||||
download_dir: str | None = None,
|
||||
status: str = "finished",
|
||||
) -> SimpleNamespace:
|
||||
base_dir = download_dir or "/downloads"
|
||||
original_post_init = ItemDTO.__post_init__
|
||||
ItemDTO.__post_init__ = lambda self: None
|
||||
|
||||
try:
|
||||
item = ItemDTO(
|
||||
id="test-id",
|
||||
title="Test Video",
|
||||
url="https://example.com/watch?v=test-id",
|
||||
folder=folder,
|
||||
status=status,
|
||||
filename=filename,
|
||||
download_dir=base_dir,
|
||||
)
|
||||
finally:
|
||||
ItemDTO.__post_init__ = original_post_init
|
||||
|
||||
return SimpleNamespace(info=item)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_items_delete_uses_bulk_history_status_clear() -> None:
|
||||
async def test_items_delete_status() -> None:
|
||||
request = _FakeRequest(payload={"type": StoreType.HISTORY.value, "status": "finished,skip", "remove_file": False})
|
||||
queue = Mock()
|
||||
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
|
||||
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})
|
||||
queue = Mock()
|
||||
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)
|
||||
body = json.loads(response.body.decode("utf-8"))
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -356,7 +356,7 @@ class TestAsyncClient:
|
|||
assert isinstance(client._transport, CFAsyncTransport)
|
||||
|
||||
@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."""
|
||||
async with async_client(enable_cf=False) as client:
|
||||
assert isinstance(client, httpx.AsyncClient)
|
||||
|
|
@ -381,7 +381,7 @@ class TestAsyncClient:
|
|||
assert client._transport.base is custom
|
||||
|
||||
@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."""
|
||||
custom = httpx.AsyncHTTPTransport()
|
||||
async with async_client(enable_cf=False, transport=custom) as client:
|
||||
|
|
@ -400,7 +400,7 @@ class TestSyncClient:
|
|||
assert isinstance(client._transport, CFTransport)
|
||||
client.close()
|
||||
|
||||
def test_sync_client_cf_disabled(self):
|
||||
def test_sync_client_custom_cf_off(self):
|
||||
"""Test creating sync client with CF disabled."""
|
||||
client = sync_client(enable_cf=False)
|
||||
assert isinstance(client, httpx.Client)
|
||||
|
|
@ -425,7 +425,7 @@ class TestSyncClient:
|
|||
assert client._transport.base is custom
|
||||
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."""
|
||||
custom = httpx.HTTPTransport()
|
||||
client = sync_client(enable_cf=False, transport=custom)
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class TestItemFormatAndBasics:
|
|||
assert item.cli == "--embed-metadata"
|
||||
|
||||
@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
|
||||
with pytest.raises(ValueError, match="url param is required"):
|
||||
Item.format({})
|
||||
|
|
@ -113,7 +113,7 @@ class TestItemDTO:
|
|||
@patch("app.library.ItemDTO.get_archive_id")
|
||||
@patch("app.library.ItemDTO.YTDLPOpts")
|
||||
@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_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
|
||||
|
|
@ -128,7 +128,7 @@ class TestItemDTO:
|
|||
assert dto.download_skipped is False
|
||||
|
||||
@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
|
||||
dto = ItemDTO(id="vid", title="t", url="u", folder="f")
|
||||
dto.archive_id = "arch"
|
||||
|
|
@ -143,7 +143,7 @@ class TestItemDTO:
|
|||
assert key not in data
|
||||
|
||||
@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.add_cli.return_value = mock_opts.get_instance.return_value
|
||||
mock_opts.get_instance.return_value.get_all.return_value = {}
|
||||
|
|
@ -299,7 +299,7 @@ class TestItemDTO:
|
|||
assert result 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):
|
||||
dto = ItemDTO(id="sidecar-none", title="Title", url="u", folder="f")
|
||||
|
||||
|
|
@ -317,7 +317,7 @@ class TestItemDTO:
|
|||
assert result 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."""
|
||||
from app.features.presets.schemas import Preset
|
||||
|
||||
|
|
@ -335,7 +335,7 @@ class TestItemDTO:
|
|||
assert result is mock_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."""
|
||||
from app.features.presets.schemas import Preset
|
||||
|
||||
|
|
@ -352,7 +352,7 @@ class TestItemDTO:
|
|||
mock_presets.return_value.get.assert_called_once_with("default")
|
||||
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."""
|
||||
with patch.object(ItemDTO, "__post_init__", lambda _: None):
|
||||
dto = ItemDTO(id="vid", title="t", url="u", folder="f", preset="nonexistent")
|
||||
|
|
@ -376,7 +376,7 @@ class TestItemAddExtras:
|
|||
|
||||
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."""
|
||||
item = Item(url="https://example.com")
|
||||
item.extras = None
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ class TestPackages:
|
|||
|
||||
|
||||
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.mkdir()
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ class TestPackageInstallerInit:
|
|||
assert installer.user_site.exists() is True
|
||||
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)
|
||||
installer = PackageInstaller(pkg_path=None)
|
||||
# No user_site is set when no path or env provided
|
||||
|
|
@ -206,7 +206,7 @@ class TestInstallCmd:
|
|||
class TestActionAndCheck:
|
||||
@patch.object(PackageInstaller, "_install_pkg")
|
||||
@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)
|
||||
mock_get_installed.return_value = "1.2.3"
|
||||
# compare_versions normal equality should hold
|
||||
|
|
@ -216,9 +216,7 @@ class TestActionAndCheck:
|
|||
@patch.object(PackageInstaller, "_install_pkg")
|
||||
@patch.object(PackageInstaller, "_get_installed_version")
|
||||
@patch.object(PackageInstaller, "_get_latest_version")
|
||||
def test_action_upgrade_skip_when_latest(
|
||||
self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path
|
||||
) -> None:
|
||||
def test_action_skip_latest(self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
mock_get_installed.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, "_get_installed_version")
|
||||
@patch.object(PackageInstaller, "_get_latest_version")
|
||||
def test_action_upgrade_runs_when_newer_available(
|
||||
self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path
|
||||
) -> None:
|
||||
def test_action_upgrade_newer(self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
mock_get_installed.return_value = "1.0.0"
|
||||
mock_get_latest.return_value = "1.1.0"
|
||||
|
|
@ -239,13 +235,13 @@ class TestActionAndCheck:
|
|||
|
||||
@patch.object(PackageInstaller, "_install_pkg")
|
||||
@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)
|
||||
mock_get_installed.return_value = None
|
||||
inst.action("pkg")
|
||||
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
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
pkgs = Packages(env=None, file=None, upgrade=False)
|
||||
|
|
@ -257,7 +253,7 @@ class TestActionAndCheck:
|
|||
inst2.check(pkgs2)
|
||||
|
||||
@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)
|
||||
pkgs = Packages(env="foo bar", file=None, upgrade=True)
|
||||
|
||||
|
|
@ -272,3 +268,5 @@ class TestActionAndCheck:
|
|||
# Should not raise
|
||||
inst.check(pkgs)
|
||||
assert mock_action.call_count == 2
|
||||
mock_action.assert_any_call("foo", upgrade=True)
|
||||
mock_action.assert_any_call("bar", upgrade=True)
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ class TestAddRoute:
|
|||
assert r.method == "POST"
|
||||
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():
|
||||
return "s"
|
||||
|
||||
|
|
@ -109,7 +109,7 @@ class TestAddRoute:
|
|||
|
||||
|
||||
class TestGetters:
|
||||
def test_get_routes_returns_copy_like_mapping(self) -> None:
|
||||
def test_get_routes_copy(self) -> None:
|
||||
async def h():
|
||||
return "x"
|
||||
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ class TestScheduler:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
@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
|
||||
|
||||
app = web.Application()
|
||||
|
|
@ -224,7 +224,7 @@ class TestScheduler:
|
|||
assert kwargs["id"] == "evt-job"
|
||||
|
||||
@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
|
||||
class AutoRunCron(DummyCron):
|
||||
def __init__(
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ class TestServices:
|
|||
assert services.get("service3") == "value3"
|
||||
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."""
|
||||
services = Services()
|
||||
services.add("test", "value")
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ def make_item(idx: int, *, status: str = "finished", cli: str = "", download_ski
|
|||
|
||||
|
||||
@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."""
|
||||
SqliteStore._reset_singleton()
|
||||
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
|
||||
async def test_enqueue_upsert_persists_download_skipped_flag():
|
||||
async def test_enqueue_upsert_skipped():
|
||||
store = await make_store()
|
||||
item = make_item(2, download_skipped=True)
|
||||
|
||||
|
|
@ -237,7 +237,7 @@ async def test_enqueue_delete_removes_row():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_bulk_delete_returns_count_and_bulk_path():
|
||||
async def test_enqueue_bulk_delete():
|
||||
store = await make_store()
|
||||
items = [make_item(i) for i in range(3)]
|
||||
for itm in items:
|
||||
|
|
@ -258,7 +258,7 @@ async def test_enqueue_bulk_delete_returns_count_and_bulk_path():
|
|||
|
||||
|
||||
@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()
|
||||
finished = [make_item(i, status="finished") 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
|
||||
async def test_paginate_out_of_range_returns_last_page():
|
||||
async def test_paginate_last_page():
|
||||
store = await make_store()
|
||||
for i in range(7):
|
||||
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
|
||||
async def test_get_item_returns_none_without_kwargs():
|
||||
async def test_get_item_no_filters():
|
||||
store = await make_store()
|
||||
await store.enqueue_upsert("queue", make_item(1))
|
||||
await store.flush()
|
||||
|
|
@ -387,7 +387,7 @@ async def test_exists_and_get_by_key_and_url():
|
|||
|
||||
|
||||
@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()
|
||||
with pytest.raises(KeyError):
|
||||
await store.exists("queue")
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ def _configure_static_root(static_root: Path) -> None:
|
|||
|
||||
class TestServeStaticFile:
|
||||
@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()
|
||||
index_file = tmp_path / "index.html"
|
||||
index_file.write_text("<html>root shell</html>", encoding="utf-8")
|
||||
|
|
@ -46,7 +46,7 @@ class TestServeStaticFile:
|
|||
assert response._path == index_file
|
||||
|
||||
@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()
|
||||
root_index = tmp_path / "index.html"
|
||||
nested_index = tmp_path / "docs" / "readme" / "index.html"
|
||||
|
|
@ -82,7 +82,7 @@ class TestServeStaticFile:
|
|||
assert b"missing.js" in body
|
||||
|
||||
@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()
|
||||
(tmp_path / "index.html").write_text("<html>root shell</html>", encoding="utf-8")
|
||||
assets_dir = tmp_path / "assets"
|
||||
|
|
@ -103,7 +103,7 @@ class TestServeStaticFile:
|
|||
assert b"missing.abcd123" in body
|
||||
|
||||
@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()
|
||||
(tmp_path / "index.html").write_text("<html>root shell</html>", encoding="utf-8")
|
||||
outside_dir = tmp_path.parent / "outside-static-root"
|
||||
|
|
@ -144,7 +144,7 @@ class TestServeStaticFile:
|
|||
assert b"/api/missing" in body
|
||||
|
||||
@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()
|
||||
(tmp_path / "index.html").write_text("<html>root shell</html>", encoding="utf-8")
|
||||
_configure_static_root(tmp_path)
|
||||
|
|
@ -157,7 +157,7 @@ class TestServeStaticFile:
|
|||
assert response.status == web.HTTPNotFound.status_code
|
||||
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()
|
||||
static_root = tmp_path / "ui-exported"
|
||||
static_root.mkdir()
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ class TestSystemLimitsEndpoint:
|
|||
Config._reset_singleton()
|
||||
|
||||
@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.max_workers = 10
|
||||
config.max_workers_per_extractor = 3
|
||||
|
|
@ -188,7 +188,7 @@ class TestSystemLimitsEndpoint:
|
|||
"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):
|
||||
Config._reset_singleton()
|
||||
config = Config.get_instance()
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
|
||||
from app.library.Scheduler import Scheduler
|
||||
from app.library.Services import Services
|
||||
from app.library.TerminalSessionManager import TerminalSessionManager
|
||||
from app.library.config import Config
|
||||
|
|
@ -13,6 +16,7 @@ from app.routes.api.system import (
|
|||
cancel_terminal_session,
|
||||
create_terminal_session,
|
||||
get_active_terminal_session,
|
||||
list_terminal_sessions,
|
||||
get_terminal_session,
|
||||
stream_terminal_session,
|
||||
)
|
||||
|
|
@ -133,6 +137,7 @@ class _TerminableProc:
|
|||
|
||||
@pytest.fixture
|
||||
def terminal_setup(tmp_path: Path) -> tuple[Config, TerminalSessionManager, Encoder]:
|
||||
Scheduler._reset_singleton()
|
||||
Services._reset_singleton()
|
||||
Config._reset_singleton()
|
||||
TerminalSessionManager._reset_singleton()
|
||||
|
|
@ -150,8 +155,18 @@ def terminal_setup(tmp_path: Path) -> tuple[Config, TerminalSessionManager, Enco
|
|||
|
||||
|
||||
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
|
||||
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
|
||||
) -> None:
|
||||
config, manager, encoder = terminal_setup
|
||||
|
|
@ -191,7 +206,7 @@ class TestTerminalSessionRoutes:
|
|||
await task
|
||||
|
||||
@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
|
||||
) -> None:
|
||||
config, manager, encoder = terminal_setup
|
||||
|
|
@ -241,11 +256,11 @@ class TestTerminalSessionRoutes:
|
|||
assert "id: 3" in resumed_payload
|
||||
|
||||
@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
|
||||
) -> None:
|
||||
config, manager, encoder = terminal_setup
|
||||
manager._drain_ttl = 0.05
|
||||
manager._completed_retention = 0.05
|
||||
await manager.initialize()
|
||||
|
||||
async def fake_create_subprocess_exec(*_args, **_kwargs):
|
||||
|
|
@ -271,10 +286,130 @@ class TestTerminalSessionRoutes:
|
|||
|
||||
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_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
|
||||
) -> None:
|
||||
config, manager, encoder = terminal_setup
|
||||
|
|
@ -314,7 +449,7 @@ class TestTerminalSessionRoutes:
|
|||
assert -15 == transcript[-1]["data"]["exitcode"]
|
||||
|
||||
@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
|
||||
) -> None:
|
||||
config, manager, encoder = terminal_setup
|
||||
|
|
@ -352,7 +487,7 @@ class TestTerminalSessionRoutes:
|
|||
assert 'data: {"exitcode": 0}' in stream_payload
|
||||
|
||||
@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
|
||||
) -> None:
|
||||
config, manager, encoder = terminal_setup
|
||||
|
|
@ -398,7 +533,7 @@ class TestTerminalSessionRoutes:
|
|||
assert -15 == transcript[-1]["data"]["exitcode"]
|
||||
|
||||
@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
|
||||
) -> None:
|
||||
config, manager, encoder = terminal_setup
|
||||
|
|
@ -427,9 +562,7 @@ class TestTerminalSessionRoutes:
|
|||
assert b"not active" in cancel_response.body.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_endpoint_returns_not_found_for_unknown_session(
|
||||
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder]
|
||||
) -> None:
|
||||
async def test_cancel_unknown(self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder]) -> None:
|
||||
config, manager, encoder = terminal_setup
|
||||
await manager.initialize()
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
assert path.parent.exists()
|
||||
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")
|
||||
|
||||
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())
|
||||
|
||||
|
||||
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"
|
||||
|
||||
assert tmp_path.is_relative_to(expected_root)
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class TestUpdateChecker:
|
|||
EventBus._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."""
|
||||
import asyncio
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ class TestUpdateChecker:
|
|||
scheduler.remove(checker._job_id)
|
||||
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."""
|
||||
from app.library.config import Config
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
|
@ -111,7 +111,7 @@ class TestUpdateChecker:
|
|||
loop.close()
|
||||
|
||||
@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."""
|
||||
from app.library.config import Config
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
|
@ -255,7 +255,7 @@ class TestUpdateChecker:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
@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."""
|
||||
from app.library.config import Config
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import asyncio
|
||||
import copy
|
||||
import importlib
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
|
|
@ -30,7 +31,6 @@ from app.library.Utils import (
|
|||
is_private_address,
|
||||
list_folders,
|
||||
load_cookies,
|
||||
load_modules,
|
||||
merge_dict,
|
||||
move_file,
|
||||
parse_tags,
|
||||
|
|
@ -48,7 +48,7 @@ from app.tests.helpers import make_test_temp_dir, temporary_test_dir
|
|||
class TestTimedLruCache:
|
||||
"""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."""
|
||||
call_count = 0
|
||||
|
||||
|
|
@ -332,39 +332,39 @@ class TestCalcDownloadPath:
|
|||
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"
|
||||
|
||||
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."""
|
||||
folder = "/test_folder"
|
||||
result = calc_download_path(str(self.base_path), folder, create_path=False)
|
||||
expected = str(self.base_path / "test_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."""
|
||||
folder = "../outside"
|
||||
with pytest.raises(Exception, match="must resolve inside the base download folder"):
|
||||
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."""
|
||||
folder = "safe/../../outside"
|
||||
with pytest.raises(Exception, match="must resolve inside the base download folder"):
|
||||
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."""
|
||||
folder = "../../../etc/passwd"
|
||||
with pytest.raises(Exception, match="must resolve inside the base download folder"):
|
||||
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."""
|
||||
folder = "/etc/passwd"
|
||||
result = calc_download_path(str(self.base_path), folder, create_path=False)
|
||||
expected = str(self.base_path / "etc/passwd")
|
||||
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."""
|
||||
folder = "/../../../etc/passwd"
|
||||
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"):
|
||||
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."""
|
||||
folder = "safe%2F..%2F..%2Funsafe" # safe/../unsafe encoded
|
||||
# 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"):
|
||||
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."""
|
||||
folder = "folder\r../../../etc/passwd"
|
||||
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"):
|
||||
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)."""
|
||||
folder = "folder..%2F..%2F..%2Fetc%2Fpasswd" # ..%2F = ../ encoded
|
||||
result = calc_download_path(str(self.base_path), folder, create_path=False)
|
||||
|
|
@ -494,7 +494,7 @@ class TestCalcDownloadPath:
|
|||
expected = str(self.base_path / folder)
|
||||
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."""
|
||||
folder = "folder/../../../etc/passwd"
|
||||
with pytest.raises(Exception, match="must resolve inside the base download folder"):
|
||||
|
|
@ -520,7 +520,7 @@ class TestCalcDownloadPath:
|
|||
# Clean up
|
||||
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."""
|
||||
# Create a symlink pointing outside the base directory
|
||||
outside_dir = Path(self.temp_dir).parent / "outside_target"
|
||||
|
|
@ -539,7 +539,7 @@ class TestCalcDownloadPath:
|
|||
symlink_path.unlink()
|
||||
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."""
|
||||
# Create a directory outside base
|
||||
outside_dir = Path(self.temp_dir).parent / "target_dir"
|
||||
|
|
@ -563,7 +563,7 @@ class TestCalcDownloadPath:
|
|||
shutil.rmtree(safe_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."""
|
||||
# Create target directory inside base
|
||||
target_dir = self.base_path / "target"
|
||||
|
|
@ -602,7 +602,7 @@ class TestCalcDownloadPath:
|
|||
expected = str(self.base_path / deep_path)
|
||||
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."""
|
||||
folder = "folder with multiple spaces"
|
||||
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 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."""
|
||||
source = {
|
||||
"__class__": "malicious",
|
||||
|
|
@ -863,7 +863,7 @@ class TestMergeDict:
|
|||
assert len(result["data"]) == 5000
|
||||
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."""
|
||||
source = {"items": list(range(3000))}
|
||||
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)
|
||||
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."""
|
||||
source = {"data": {}}
|
||||
source["data"]["circular"] = source # Create circular reference
|
||||
|
|
@ -1088,9 +1088,7 @@ class TestValidateUuid:
|
|||
def test_wrong_version(self):
|
||||
"""Test UUID4 against version 1 check."""
|
||||
test_uuid = str(uuid.uuid4())
|
||||
# Version check is not strict in this function - it may return True for any valid UUID
|
||||
result = validate_uuid(test_uuid, 1)
|
||||
assert isinstance(result, bool) # Just check it returns a boolean
|
||||
assert validate_uuid(test_uuid, 1) is True
|
||||
|
||||
|
||||
class TestStripNewline:
|
||||
|
|
@ -1222,14 +1220,13 @@ class TestValidateUrl:
|
|||
|
||||
def test_validate_url_basic(self):
|
||||
"""Test basic URL validation functionality."""
|
||||
# Test without actual validation due to missing yarl dependency
|
||||
# Just check the function exists and handles the missing dependency gracefully
|
||||
try:
|
||||
result = validate_url("https://example.com")
|
||||
assert isinstance(result, bool)
|
||||
except ModuleNotFoundError:
|
||||
# Expected when yarl is not installed
|
||||
assert True
|
||||
if importlib.util.find_spec("yarl") is None:
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
validate_url("https://example.com")
|
||||
return
|
||||
|
||||
result = validate_url("https://example.com", allow_internal=True)
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestGetFileSidecar:
|
||||
|
|
@ -1300,32 +1297,36 @@ class TestArgConverter:
|
|||
|
||||
def test_arg_converter_basic(self):
|
||||
"""Test basic arg_converter functionality."""
|
||||
try:
|
||||
result = arg_converter("--quiet --match-filters 'duration<2min' --download-archive archive.txt")
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("quiet") is True, "quiet should be True"
|
||||
assert result.get("download_archive") == "archive.txt"
|
||||
assert "match_filter" in result, "match_filters should be in result"
|
||||
except (ModuleNotFoundError, AttributeError, ImportError):
|
||||
# Expected when yt_dlp is not available or differs
|
||||
assert True
|
||||
if importlib.util.find_spec("yt_dlp") is None:
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
arg_converter("--quiet --match-filters 'duration<2min' --download-archive archive.txt")
|
||||
return
|
||||
|
||||
result = arg_converter("--quiet --match-filters 'duration<2min' --download-archive archive.txt")
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("quiet") is True, "quiet should be 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):
|
||||
"""Test arg_converter with empty args."""
|
||||
try:
|
||||
result = arg_converter("")
|
||||
assert isinstance(result, dict)
|
||||
except (ModuleNotFoundError, AttributeError, ImportError):
|
||||
assert True
|
||||
if importlib.util.find_spec("yt_dlp") is None:
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
arg_converter("")
|
||||
return
|
||||
|
||||
result = arg_converter("")
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_arg_converter_replace_in_metadata(self):
|
||||
"""Test arg_converter handles replace-in-metadata without assertions."""
|
||||
try:
|
||||
result = arg_converter("--replace-in-metadata title foo bar")
|
||||
except (ModuleNotFoundError, AttributeError, ImportError):
|
||||
assert True
|
||||
if importlib.util.find_spec("yt_dlp") is None:
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
arg_converter("--replace-in-metadata title foo bar")
|
||||
return
|
||||
|
||||
result = arg_converter("--replace-in-metadata title foo bar")
|
||||
|
||||
postprocessors = result.get("postprocessors", [])
|
||||
assert postprocessors, "Expected metadata parser postprocessor to be present"
|
||||
|
||||
|
|
@ -1584,11 +1585,10 @@ class TestLoadCookies:
|
|||
|
||||
try:
|
||||
valid, jar = load_cookies(str(self.cookie_file))
|
||||
assert isinstance(valid, bool)
|
||||
assert jar is not None or not valid
|
||||
assert valid is False
|
||||
assert jar is not None
|
||||
except ValueError:
|
||||
# Expected for invalid cookie files
|
||||
assert True
|
||||
return
|
||||
|
||||
|
||||
class TestStrToDt:
|
||||
|
|
@ -1596,20 +1596,23 @@ class TestStrToDt:
|
|||
|
||||
def test_str_to_dt_basic(self):
|
||||
"""Test basic string to datetime conversion."""
|
||||
try:
|
||||
result = str_to_dt("2023-01-02 12:00:00 UTC")
|
||||
assert isinstance(result, datetime)
|
||||
except ModuleNotFoundError:
|
||||
# Expected when dateparser is not available
|
||||
assert True
|
||||
if importlib.util.find_spec("dateparser") is None:
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
str_to_dt("2023-01-02 12:00:00 UTC")
|
||||
return
|
||||
|
||||
result = str_to_dt("2023-01-02 12:00:00 UTC")
|
||||
assert isinstance(result, datetime)
|
||||
|
||||
def test_str_to_dt_relative(self):
|
||||
"""Test relative time string."""
|
||||
try:
|
||||
result = str_to_dt("1 hour ago")
|
||||
assert isinstance(result, datetime)
|
||||
except (ModuleNotFoundError, ValueError):
|
||||
assert True
|
||||
if importlib.util.find_spec("dateparser") is None:
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
str_to_dt("1 hour ago")
|
||||
return
|
||||
|
||||
result = str_to_dt("1 hour ago")
|
||||
assert isinstance(result, datetime)
|
||||
|
||||
|
||||
class TestInitClass:
|
||||
|
|
@ -1633,37 +1636,6 @@ class TestInitClass:
|
|||
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:
|
||||
"""Test the get_channel_images function."""
|
||||
|
||||
|
|
@ -1711,7 +1683,7 @@ class TestGetChannelImages:
|
|||
assert "icon" in result
|
||||
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."""
|
||||
from app.library.Utils import get_channel_images
|
||||
|
||||
|
|
@ -1859,23 +1831,27 @@ class TestArgConverterAdvanced:
|
|||
|
||||
def test_arg_converter_with_removed_options(self):
|
||||
"""Test arg_converter with removed options tracking."""
|
||||
try:
|
||||
removed = []
|
||||
result = arg_converter("--quiet --skip-download", level=True, removed_options=removed)
|
||||
if importlib.util.find_spec("yt_dlp") is None:
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
arg_converter("--quiet --skip-download", level=True, removed_options=[])
|
||||
return
|
||||
|
||||
assert isinstance(result, dict)
|
||||
except (ModuleNotFoundError, AttributeError, ImportError):
|
||||
assert True
|
||||
removed = []
|
||||
result = arg_converter("--quiet --skip-download", level=True, removed_options=removed)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert removed
|
||||
|
||||
def test_arg_converter_dumps_enabled(self):
|
||||
"""Test arg_converter with dumps flag enabled."""
|
||||
try:
|
||||
result = arg_converter("--format best", dumps=True)
|
||||
if importlib.util.find_spec("yt_dlp") is None:
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
arg_converter("--format best", dumps=True)
|
||||
return
|
||||
|
||||
# With dumps=True, result should still be a dict with JSON-serializable content
|
||||
assert isinstance(result, (dict, list))
|
||||
except (ModuleNotFoundError, AttributeError, ImportError):
|
||||
assert True
|
||||
result = arg_converter("--format best", dumps=True)
|
||||
|
||||
assert isinstance(result, (dict, list))
|
||||
|
||||
|
||||
class TestCreateCookiesFile:
|
||||
|
|
@ -1910,7 +1886,7 @@ class TestCreateCookiesFile:
|
|||
|
||||
@patch("app.library.config.Config")
|
||||
@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)."""
|
||||
from app.library.Utils import create_cookies_file
|
||||
|
||||
|
|
@ -1940,7 +1916,7 @@ class TestCreateCookiesFile:
|
|||
create_cookies_file("invalid_data", file=cookie_path)
|
||||
|
||||
@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."""
|
||||
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")
|
||||
|
||||
@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."""
|
||||
from app.library.Utils import create_cookies_file
|
||||
|
||||
|
|
@ -2099,7 +2075,7 @@ class TestRenameFile:
|
|||
assert subtitle_file.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."""
|
||||
# Create test files with complex extensions
|
||||
test_file = tmp_path / "video.mp4"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
<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"
|
||||
>
|
||||
<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="transition-transform duration-300"
|
||||
:class="{
|
||||
|
|
@ -32,7 +38,7 @@
|
|||
:loading="isRefreshing"
|
||||
:disabled="isRefreshing"
|
||||
:square="isMobile"
|
||||
@click="() => void refreshQueue()"
|
||||
@click="() => refreshQueue()"
|
||||
>
|
||||
<span v-if="!isMobile">Reload Queue</span>
|
||||
</UButton>
|
||||
|
|
@ -329,7 +335,7 @@
|
|||
variant="soft"
|
||||
size="xs"
|
||||
icon="i-lucide-play-circle"
|
||||
@click="void stateStore.startItems([item._id])"
|
||||
@click="() => stateStore.startItems([item._id])"
|
||||
>
|
||||
Start
|
||||
</UButton>
|
||||
|
|
@ -340,7 +346,7 @@
|
|||
variant="soft"
|
||||
size="xs"
|
||||
icon="i-lucide-pause"
|
||||
@click="void stateStore.pauseItems([item._id])"
|
||||
@click="() => stateStore.pauseItems([item._id])"
|
||||
>
|
||||
Pause
|
||||
</UButton>
|
||||
|
|
@ -350,7 +356,7 @@
|
|||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-x"
|
||||
@click="void stateStore.cancelItems([item._id])"
|
||||
@click="() => stateStore.cancelItems([item._id])"
|
||||
>
|
||||
{{ item.is_live ? 'Stop' : 'Cancel' }}
|
||||
</UButton>
|
||||
|
|
@ -400,7 +406,7 @@
|
|||
:sibling-count="0"
|
||||
@update:page="
|
||||
(page) =>
|
||||
loadHistory(page, {
|
||||
load(page, {
|
||||
order: 'DESC',
|
||||
perPage: configStore.app.default_pagination,
|
||||
})
|
||||
|
|
@ -415,7 +421,7 @@
|
|||
:loading="historyIsLoading"
|
||||
:disabled="historyIsLoading"
|
||||
@click="
|
||||
void reloadHistory({ order: 'DESC', perPage: configStore.app.default_pagination })
|
||||
() => reload({ order: 'DESC', perPage: configStore.app.default_pagination })
|
||||
"
|
||||
>
|
||||
Reload
|
||||
|
|
@ -542,7 +548,7 @@
|
|||
variant="soft"
|
||||
size="xs"
|
||||
icon="i-lucide-rotate-cw"
|
||||
@click="() => void requeueItem(item)"
|
||||
@click="() => requeueItem(item)"
|
||||
>
|
||||
Requeue
|
||||
</UButton>
|
||||
|
|
@ -552,7 +558,7 @@
|
|||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-trash"
|
||||
@click="() => void deleteHistoryItem(item)"
|
||||
@click="() => deleteHistoryItem(item)"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
|
|
@ -589,7 +595,7 @@
|
|||
:sibling-count="0"
|
||||
@update:page="
|
||||
(page) =>
|
||||
loadHistory(page, {
|
||||
load(page, {
|
||||
order: 'DESC',
|
||||
perPage: configStore.app.default_pagination,
|
||||
})
|
||||
|
|
@ -615,7 +621,10 @@
|
|||
:open="Boolean(videoItem)"
|
||||
title="Video"
|
||||
: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()"
|
||||
>
|
||||
<template #body>
|
||||
|
|
@ -628,6 +637,7 @@
|
|||
class="w-full"
|
||||
@closeModel="closePlayer"
|
||||
@error="async (error: string) => await box.alert(error)"
|
||||
@playback-state-change="(playing: boolean) => (playingNow = playing)"
|
||||
/>
|
||||
</template>
|
||||
</UModal>
|
||||
|
|
@ -711,14 +721,15 @@ const {
|
|||
items: historyItems,
|
||||
pagination,
|
||||
isLoading,
|
||||
loadHistory,
|
||||
reloadHistory,
|
||||
deleteHistoryItems,
|
||||
historyMoveHandler,
|
||||
load,
|
||||
reload,
|
||||
remove,
|
||||
moveHandler,
|
||||
} = useHistoryState();
|
||||
|
||||
const embedUrl = ref('');
|
||||
const videoItem = ref<StoreItem | null>(null);
|
||||
const playingNow = ref(false);
|
||||
const autoRefreshInterval = ref<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
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 showSections = computed(() => hasAnyItems.value || historyIsLoading.value);
|
||||
const isFormDisabled = computed(() => addInProgress.value);
|
||||
const lightsOut = computed(() => Boolean(videoItem.value && playingNow.value));
|
||||
const formContainerClass = computed(() => {
|
||||
if (showSections.value) {
|
||||
return 'max-w-full';
|
||||
|
|
@ -1027,6 +1039,7 @@ const openPlayer = (item: StoreItem): void => {
|
|||
};
|
||||
|
||||
const closePlayer = (): void => {
|
||||
playingNow.value = false;
|
||||
embedUrl.value = '';
|
||||
videoItem.value = null;
|
||||
};
|
||||
|
|
@ -1261,20 +1274,18 @@ const requeueItem = async (item: StoreItem): Promise<void> => {
|
|||
payload.extras = JSON.parse(JSON.stringify(item.extras));
|
||||
}
|
||||
|
||||
await deleteHistoryItems({ ids: [item._id], removeFile: false });
|
||||
await reloadHistory({ order: 'DESC', perPage: configStore.app.default_pagination });
|
||||
await remove({ ids: [item._id], removeFile: false });
|
||||
await reload({ order: 'DESC', perPage: configStore.app.default_pagination });
|
||||
await stateStore.addDownload(payload);
|
||||
};
|
||||
|
||||
const deleteHistoryItem = async (item: StoreItem): Promise<void> => {
|
||||
await deleteHistoryItems({ ids: [item._id], removeFile: app.value.remove_files });
|
||||
await reloadHistory({ order: 'DESC', perPage: configStore.app.default_pagination });
|
||||
await remove({ ids: [item._id], removeFile: app.value.remove_files });
|
||||
await reload({ order: 'DESC', perPage: configStore.app.default_pagination });
|
||||
toast.info('Removed from history queue.');
|
||||
};
|
||||
|
||||
const handleHistoryItemMoved = historyMoveHandler(
|
||||
() => simpleMode.value && historyInitialized.value,
|
||||
);
|
||||
const handleHistoryItemMoved = moveHandler(() => simpleMode.value && historyInitialized.value);
|
||||
|
||||
const showMessage = (item: StoreItem): boolean => {
|
||||
if (!item?.msg || item.msg === item?.error) {
|
||||
|
|
@ -1303,7 +1314,7 @@ onMounted(async () => {
|
|||
await normalizeSimpleRoute();
|
||||
historyInitialized.value = true;
|
||||
socketStore.on('item_moved', handleHistoryItemMoved);
|
||||
await loadHistory(1, { order: 'DESC', perPage: configStore.app.default_pagination });
|
||||
await load(1, { order: 'DESC', perPage: configStore.app.default_pagination });
|
||||
|
||||
if (!socketStore.isConnected && autoRefreshEnabled.value) {
|
||||
startAutoRefresh();
|
||||
|
|
@ -1389,7 +1400,7 @@ watch(
|
|||
return;
|
||||
}
|
||||
|
||||
void loadHistory(1, { order: 'DESC', perPage: configStore.app.default_pagination });
|
||||
void load(1, { order: 'DESC', perPage: configStore.app.default_pagination });
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -367,7 +367,9 @@
|
|||
<UIcon name="i-lucide-rss" class="size-4 text-toned" />
|
||||
<p class="text-sm font-semibold text-default">Enable Handler</p>
|
||||
</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>
|
||||
<USwitch v-model="form.handler_enabled" :disabled="addInProgress" />
|
||||
</div>
|
||||
|
|
@ -443,6 +445,10 @@
|
|||
scheduled downloads to yt-dlp. Disable <code>Enable Handler</code> to disable RSS
|
||||
monitoring.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Timer Requirement:</strong> if no supported handler matches the URL, you must
|
||||
set a CRON timer before saving.
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
|
@ -523,6 +529,7 @@ const emitter = defineEmits<{
|
|||
const toast = useNotification();
|
||||
const config = useYtpConfig();
|
||||
const dialog = useDialog();
|
||||
const tasksComposable = useTasks();
|
||||
const { findPreset, getPresetDefault, selectItems } = usePresetOptions();
|
||||
const showImport = useStorage('showTaskImport', false);
|
||||
|
||||
|
|
@ -786,6 +793,13 @@ const checkInfo = async (): Promise<void> => {
|
|||
form.cli = form.cli.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
await requireTimerForTask(form);
|
||||
} catch (error: any) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (urls.length === 1) {
|
||||
emitter('submit', {
|
||||
reference: toRaw(props.reference),
|
||||
|
|
@ -811,12 +825,32 @@ const checkInfo = async (): Promise<void> => {
|
|||
} 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', {
|
||||
reference: toRaw(props.reference),
|
||||
task: tasks,
|
||||
task: submitTasks,
|
||||
archive_all: archiveAllAfterAdd.value,
|
||||
});
|
||||
};
|
||||
|
|
@ -943,6 +977,34 @@ const convertCurrentUrl = async (): Promise<void> => {
|
|||
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 = '') => {
|
||||
if (false !== hasFormatInConfig.value || !form.preset) {
|
||||
return ret;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3,6 +3,7 @@ import type { EventSourceMessage } from '@microsoft/fetch-event-source';
|
|||
import { useStorage } from '@vueuse/core';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import type { TerminalSessionItem, TerminalSessionsResponse } from '~/types';
|
||||
import { parse_api_error, parse_api_response, request, uri } from '~/utils';
|
||||
|
||||
type ConsoleSessionStatus =
|
||||
|
|
@ -48,10 +49,25 @@ type CancelConsoleSessionResult = {
|
|||
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 HIDDEN_RECENT_SESSIONS_KEY = 'console_hidden_recent_sessions';
|
||||
const MAX_TRANSCRIPT_CHUNKS = 1500;
|
||||
const MAX_TRANSCRIPT_CHARS = 120000;
|
||||
const RECONNECT_DELAY_MS = 1500;
|
||||
const RECENT_SESSIONS_MAX = 30;
|
||||
|
||||
const DEFAULT_STATE: ConsoleSessionState = {
|
||||
sessionId: null,
|
||||
|
|
@ -160,6 +176,8 @@ const normalizePersistedState = (
|
|||
};
|
||||
|
||||
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 reconnectTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
||||
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 => {
|
||||
if (!chunk) {
|
||||
return;
|
||||
|
|
@ -215,6 +295,7 @@ const clearTranscript = (): void => {
|
|||
const markExpired = (): void => {
|
||||
stopStream();
|
||||
updateState({ status: 'expired', error: '' });
|
||||
syncCurrentSessionToRecentSessions();
|
||||
};
|
||||
|
||||
const finishSession = (
|
||||
|
|
@ -227,6 +308,7 @@ const finishSession = (
|
|||
exitCode,
|
||||
error: '',
|
||||
});
|
||||
syncCurrentSessionToRecentSessions();
|
||||
};
|
||||
|
||||
const scheduleReconnect = (): void => {
|
||||
|
|
@ -289,6 +371,97 @@ const parseResponseError = async (response: Response): Promise<string> => {
|
|||
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> => {
|
||||
if (!sessionState.value.sessionId) {
|
||||
return;
|
||||
|
|
@ -303,10 +476,14 @@ const refreshSessionMetadata = async (): Promise<void> => {
|
|||
}
|
||||
|
||||
updateState(normalizeResponse(payload));
|
||||
syncCurrentSessionToRecentSessions();
|
||||
} catch (error) {
|
||||
if (error instanceof ConsoleSessionExpiredError) {
|
||||
markExpired();
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -400,6 +577,7 @@ const connectStream = async (): Promise<void> => {
|
|||
onopen: async (response) => {
|
||||
if (response.ok) {
|
||||
updateState({ status: 'running', error: '' });
|
||||
syncCurrentSessionToRecentSessions();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -434,6 +612,7 @@ const connectStream = async (): Promise<void> => {
|
|||
|
||||
if (event.event === 'status') {
|
||||
updateState(normalizeResponse(payload as ConsoleSessionResponse));
|
||||
syncCurrentSessionToRecentSessions();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -501,6 +680,7 @@ const connectStream = async (): Promise<void> => {
|
|||
const message = error instanceof Error ? error.message : String(error);
|
||||
appendTranscript(`Error: ${message}\n`);
|
||||
updateState({ status: 'error', error: message });
|
||||
syncCurrentSessionToRecentSessions();
|
||||
} finally {
|
||||
if (streamController.value === controller) {
|
||||
streamController.value = null;
|
||||
|
|
@ -517,12 +697,15 @@ const restoreSession = async (): Promise<void> => {
|
|||
|
||||
if (payload) {
|
||||
updateState(normalizeResponse(payload));
|
||||
syncCurrentSessionToRecentSessions();
|
||||
}
|
||||
|
||||
if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) {
|
||||
await connectStream();
|
||||
}
|
||||
|
||||
await fetchRecentSessions();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -531,28 +714,36 @@ const restoreSession = async (): Promise<void> => {
|
|||
sessionState.value.command ||
|
||||
sessionState.value.transcript.length > 0
|
||||
) {
|
||||
await fetchRecentSessions();
|
||||
return;
|
||||
}
|
||||
|
||||
const active = await readSessionResponse('/api/system/terminal/active', { allowMissing: true });
|
||||
if (!active) {
|
||||
await fetchRecentSessions();
|
||||
return;
|
||||
}
|
||||
|
||||
updateState(normalizeResponse(active));
|
||||
syncCurrentSessionToRecentSessions();
|
||||
|
||||
if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) {
|
||||
await connectStream();
|
||||
}
|
||||
|
||||
await fetchRecentSessions();
|
||||
} catch (error) {
|
||||
if (error instanceof ConsoleSessionExpiredError) {
|
||||
markExpired();
|
||||
await fetchRecentSessions();
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) {
|
||||
await connectStream();
|
||||
}
|
||||
|
||||
await fetchRecentSessions();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -594,16 +785,20 @@ const startSession = async ({
|
|||
]),
|
||||
error: '',
|
||||
});
|
||||
syncCurrentSessionToRecentSessions();
|
||||
|
||||
if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) {
|
||||
await connectStream();
|
||||
}
|
||||
|
||||
await fetchRecentSessions();
|
||||
|
||||
return Boolean(sessionState.value.sessionId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
appendTranscript(`Error: ${message}\n`);
|
||||
updateState({ status: 'error', error: message });
|
||||
syncCurrentSessionToRecentSessions();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
@ -623,6 +818,7 @@ const cancelSession = async (): Promise<CancelConsoleSessionResult> => {
|
|||
|
||||
if (response.status === 404) {
|
||||
await refreshSessionMetadata();
|
||||
await fetchRecentSessions();
|
||||
return { status: 'missing', message: 'Terminal session not found.' };
|
||||
}
|
||||
|
||||
|
|
@ -631,6 +827,7 @@ const cancelSession = async (): Promise<CancelConsoleSessionResult> => {
|
|||
}
|
||||
|
||||
updateState({ error: '' });
|
||||
await fetchRecentSessions();
|
||||
return { status: 'cancelled' };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
|
@ -642,6 +839,32 @@ const cancelSession = async (): Promise<CancelConsoleSessionResult> => {
|
|||
const resetState = (): void => {
|
||||
stopStream();
|
||||
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 = () => {
|
||||
|
|
@ -649,7 +872,12 @@ const useConsoleSession = () => {
|
|||
state: sessionState,
|
||||
bufferedTranscript: computed(() => sessionState.value.transcript.slice()),
|
||||
isLoading: computed(() => isActiveSessionStatus(sessionState.value.status)),
|
||||
recentSessions: computed(() => recentSessions.value.slice()),
|
||||
clearTranscript,
|
||||
clearRecentSessions,
|
||||
fetchRecentSessions,
|
||||
hideRecentSession,
|
||||
replaySession,
|
||||
restoreSession,
|
||||
startSession,
|
||||
cancelSession,
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ const buildQuery = (
|
|||
return params.toString();
|
||||
};
|
||||
|
||||
const loadHistory = async (page: number = 1, options: HistoryLoadOptions = {}): Promise<void> => {
|
||||
const load = async (page: number = 1, options: HistoryLoadOptions = {}): Promise<void> => {
|
||||
const { order = 'DESC', status, perPage = pageSize.value } = options;
|
||||
|
||||
isLoading.value = true;
|
||||
|
|
@ -104,12 +104,12 @@ const loadHistory = async (page: number = 1, options: HistoryLoadOptions = {}):
|
|||
}
|
||||
};
|
||||
|
||||
const reloadHistory = async (options: HistoryLoadOptions = {}): Promise<void> => {
|
||||
const reload = async (options: HistoryLoadOptions = {}): Promise<void> => {
|
||||
const targetPage = isLoaded.value ? pagination.value.page : 1;
|
||||
await loadHistory(targetPage, options);
|
||||
await load(targetPage, options);
|
||||
};
|
||||
|
||||
const deleteHistoryItems = async (
|
||||
const remove = async (
|
||||
options: {
|
||||
ids?: string[];
|
||||
status?: string;
|
||||
|
|
@ -155,7 +155,39 @@ const deleteHistoryItems = async (
|
|||
}
|
||||
};
|
||||
|
||||
const resetHistory = (): void => {
|
||||
const rename = async (item: StoreItem, newName: string): Promise<boolean> => {
|
||||
const trimmedName = newName.trim();
|
||||
if (!trimmedName || trimmedName === item.filename?.split('/').pop()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(`/api/history/${item._id}/rename`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ new_name: trimmedName }),
|
||||
});
|
||||
|
||||
await ensureSuccess(response);
|
||||
|
||||
const updated = (await response.json()) as StoreItem;
|
||||
const index = items.value.findIndex((entry) => entry._id === updated._id);
|
||||
|
||||
if (index !== -1) {
|
||||
items.value = [...items.value.slice(0, index), updated, ...items.value.slice(index + 1)];
|
||||
}
|
||||
|
||||
lastError.value = null;
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
if (throwInstead.value) {
|
||||
throw error;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const reset = (): void => {
|
||||
items.value = [];
|
||||
pagination.value = {
|
||||
page: 1,
|
||||
|
|
@ -169,7 +201,7 @@ const resetHistory = (): void => {
|
|||
lastError.value = null;
|
||||
};
|
||||
|
||||
const addHistoryItem = (item: StoreItem): void => {
|
||||
const upsert = (item: StoreItem): void => {
|
||||
const existingIndex = items.value.findIndex((existing) => existing._id === item._id);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
|
|
@ -195,7 +227,7 @@ const addHistoryItem = (item: StoreItem): void => {
|
|||
pagination.value.has_next = pagination.value.page < pagination.value.total_pages;
|
||||
};
|
||||
|
||||
const historyMoveHandler = (
|
||||
const moveHandler = (
|
||||
shouldHandle: () => boolean = () => isLoaded.value,
|
||||
): ((payload: WSEP['item_moved']) => void) => {
|
||||
return (payload: WSEP['item_moved']): void => {
|
||||
|
|
@ -203,7 +235,7 @@ const historyMoveHandler = (
|
|||
return;
|
||||
}
|
||||
|
||||
addHistoryItem(payload.data.item);
|
||||
upsert(payload.data.item);
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -215,11 +247,12 @@ export const useHistoryState = () => {
|
|||
isLoaded,
|
||||
lastError,
|
||||
pageSize,
|
||||
loadHistory,
|
||||
reloadHistory,
|
||||
deleteHistoryItems,
|
||||
resetHistory,
|
||||
upsertHistoryItem: addHistoryItem,
|
||||
historyMoveHandler,
|
||||
load,
|
||||
reload,
|
||||
remove,
|
||||
rename,
|
||||
reset,
|
||||
upsert,
|
||||
moveHandler,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,21 +5,21 @@
|
|||
import { ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import {
|
||||
handlePlayPause,
|
||||
handleRewind,
|
||||
handleForward,
|
||||
handleMute,
|
||||
handleVolumeChange,
|
||||
handlePlaybackSpeedChange,
|
||||
handleFrameStep,
|
||||
handleSeekToPercent,
|
||||
handleSeekBackward,
|
||||
handleSeekForward,
|
||||
handleFullscreen,
|
||||
handlePictureInPicture,
|
||||
handleToggleCaptions,
|
||||
playPause,
|
||||
rewind,
|
||||
forward,
|
||||
mute,
|
||||
changeVolume,
|
||||
changeSpeed,
|
||||
frameStep,
|
||||
seekToPercent,
|
||||
seekBackward,
|
||||
seekForward,
|
||||
fullscreen,
|
||||
pictureInPicture,
|
||||
toggleCaptions,
|
||||
shouldHandleKeyboardShortcut,
|
||||
isModifierKey,
|
||||
modifierKey,
|
||||
} from '~/utils/keyboard';
|
||||
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)
|
||||
if (isModifierKey(event) && !['f', 'p', '?'].includes(event.key.toLowerCase())) {
|
||||
if (modifierKey(event) && !['f', 'p', '?'].includes(event.key.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -64,67 +64,67 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
|
|||
case ' ':
|
||||
case 'k':
|
||||
event.preventDefault();
|
||||
handlePlayPause(ctx);
|
||||
playPause(ctx);
|
||||
break;
|
||||
|
||||
// Rewind 10 seconds (J key)
|
||||
case 'j':
|
||||
event.preventDefault();
|
||||
handleRewind(ctx, 10);
|
||||
rewind(ctx, 10);
|
||||
break;
|
||||
|
||||
// Forward 10 seconds (L key)
|
||||
case 'l':
|
||||
event.preventDefault();
|
||||
handleForward(ctx, 10);
|
||||
forward(ctx, 10);
|
||||
break;
|
||||
|
||||
// Seek backward 5 seconds (left arrow)
|
||||
case 'arrowleft':
|
||||
event.preventDefault();
|
||||
handleSeekBackward(ctx, 5);
|
||||
seekBackward(ctx, 5);
|
||||
break;
|
||||
|
||||
// Seek forward 5 seconds (right arrow)
|
||||
case 'arrowright':
|
||||
event.preventDefault();
|
||||
handleSeekForward(ctx, 5);
|
||||
seekForward(ctx, 5);
|
||||
break;
|
||||
|
||||
// Increase volume (up arrow)
|
||||
case 'arrowup':
|
||||
event.preventDefault();
|
||||
handleVolumeChange(ctx, 0.1);
|
||||
changeVolume(ctx, 0.1);
|
||||
break;
|
||||
|
||||
// Decrease volume (down arrow)
|
||||
case 'arrowdown':
|
||||
event.preventDefault();
|
||||
handleVolumeChange(ctx, -0.1);
|
||||
changeVolume(ctx, -0.1);
|
||||
break;
|
||||
|
||||
// Mute/Unmute
|
||||
case 'm':
|
||||
event.preventDefault();
|
||||
handleMute(ctx);
|
||||
mute(ctx);
|
||||
break;
|
||||
|
||||
// Toggle fullscreen
|
||||
case 'f':
|
||||
event.preventDefault();
|
||||
handleFullscreen(video);
|
||||
fullscreen(video);
|
||||
break;
|
||||
|
||||
// Picture-in-Picture
|
||||
case 'p':
|
||||
event.preventDefault();
|
||||
await handlePictureInPicture(video);
|
||||
await pictureInPicture(video);
|
||||
break;
|
||||
|
||||
// Toggle captions
|
||||
case 'c':
|
||||
event.preventDefault();
|
||||
handleToggleCaptions(video);
|
||||
toggleCaptions(video);
|
||||
break;
|
||||
|
||||
// Frame advance (period key) / Increase playback speed (> or ')
|
||||
|
|
@ -132,9 +132,9 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
|
|||
case "'": {
|
||||
event.preventDefault();
|
||||
if ('.' === key) {
|
||||
handleFrameStep(ctx, 'forward');
|
||||
frameStep(ctx, 'forward');
|
||||
} else {
|
||||
handlePlaybackSpeedChange(ctx, 0.25);
|
||||
changeSpeed(ctx, 0.25);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -144,9 +144,9 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
|
|||
case ';': {
|
||||
event.preventDefault();
|
||||
if (',' === key) {
|
||||
handleFrameStep(ctx, 'backward');
|
||||
frameStep(ctx, 'backward');
|
||||
} else {
|
||||
handlePlaybackSpeedChange(ctx, -0.25);
|
||||
changeSpeed(ctx, -0.25);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -164,7 +164,7 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
|
|||
case '9': {
|
||||
event.preventDefault();
|
||||
const percent = parseInt(key) * 10;
|
||||
handleSeekToPercent(ctx, percent);
|
||||
seekToPercent(ctx, percent);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
|
|||
44
ui/app/composables/usePlayerMediaVolume.ts
Normal file
44
ui/app/composables/usePlayerMediaVolume.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
3
ui/app/composables/usePlayerShortcutHelp.ts
Normal file
3
ui/app/composables/usePlayerShortcutHelp.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function usePlayerShortcutHelp() {
|
||||
return useState<boolean>('player-shortcut-help', () => false);
|
||||
}
|
||||
247
ui/app/composables/usePlayerShortcuts.ts
Normal file
247
ui/app/composables/usePlayerShortcuts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
220
ui/app/composables/usePlayerSubtitles.ts
Normal file
220
ui/app/composables/usePlayerSubtitles.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,11 @@
|
|||
<template>
|
||||
<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 min-w-0 items-start gap-3">
|
||||
<span
|
||||
|
|
@ -502,6 +508,7 @@
|
|||
:item="model_item"
|
||||
class="w-full"
|
||||
@closeModel="closeModel"
|
||||
@playback-state-change="(playing: boolean) => (playingNow = playing)"
|
||||
/>
|
||||
|
||||
<GetInfo
|
||||
|
|
@ -620,6 +627,7 @@ const initialPath = (() => {
|
|||
const isUpdating = ref(false);
|
||||
|
||||
const model_item = ref<any | null>(null);
|
||||
const playingNow = ref(false);
|
||||
|
||||
const previewTitle = computed(() => {
|
||||
if (!model_item.value) {
|
||||
|
|
@ -640,7 +648,10 @@ const previewTitle = computed(() => {
|
|||
|
||||
const previewModalUi = computed(() => {
|
||||
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') {
|
||||
|
|
@ -649,6 +660,7 @@ const previewModalUi = computed(() => {
|
|||
|
||||
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 params = new URLSearchParams();
|
||||
|
|
@ -730,6 +742,7 @@ watch(localSearch, async (value) => {
|
|||
});
|
||||
|
||||
const closeModel = (): void => {
|
||||
playingNow.value = false;
|
||||
model_item.value = null;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -177,27 +177,27 @@
|
|||
class="flex items-center gap-2 text-sm font-semibold text-highlighted"
|
||||
>
|
||||
<UIcon name="i-lucide-history" class="size-4 text-toned" />
|
||||
<span>Command history</span>
|
||||
<span>Recent runs</span>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-trash"
|
||||
:disabled="historyEntries.length < 1"
|
||||
@click="() => void clearHistory()"
|
||||
icon="i-lucide-eye-off"
|
||||
:disabled="recentSessionEntries.length < 1"
|
||||
@click="() => void hideRecentSessions()"
|
||||
>
|
||||
Clear history
|
||||
Hide recent runs
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="historyEntries.length < 1"
|
||||
v-if="recentSessionEntries.length < 1"
|
||||
color="info"
|
||||
variant="soft"
|
||||
icon="i-lucide-clock-3"
|
||||
title="Command history is empty"
|
||||
title="No recent console sessions"
|
||||
/>
|
||||
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<tr
|
||||
v-for="(cmd, index) in historyEntries"
|
||||
:key="`${index}-${cmd}`"
|
||||
v-for="item in recentSessionEntries"
|
||||
:key="item.sessionId"
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-left font-mono text-xs text-default hover:text-highlighted"
|
||||
@click="() => void loadCommand(cmd)"
|
||||
>
|
||||
{{ cmd.replace(/\n/g, ' ') }}
|
||||
</button>
|
||||
<div class="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-left font-mono text-xs text-default hover:text-highlighted"
|
||||
@click="() => void replayRecentSession(item)"
|
||||
>
|
||||
{{ 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
|
||||
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
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="i-lucide-x"
|
||||
square
|
||||
@click="removeFromHistory(index)"
|
||||
/>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="i-lucide-terminal"
|
||||
@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>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
@ -315,6 +400,7 @@
|
|||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import moment from 'moment';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
|
@ -327,7 +413,6 @@ import type { AutoCompleteOptions } from '~/types/autocomplete';
|
|||
import { disableOpacity, enableOpacity } from '~/utils';
|
||||
import { requirePageShell } from '~/utils/topLevelNavigation';
|
||||
|
||||
const MAX_HISTORY_ITEMS = 50;
|
||||
const ACTIVE_SESSION_STATUSES = ['starting', 'running', 'reconnecting'];
|
||||
|
||||
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 commandTextarea = ref<InstanceType<typeof TextareaAutocomplete> | null>(null);
|
||||
const storedCommand = useStorage<string>('console_command', '');
|
||||
const commandHistory = useStorage<string[]>('console_command_history', []);
|
||||
|
||||
const pageCardUi = {
|
||||
root: 'flex min-h-0 flex-1 w-full bg-transparent',
|
||||
|
|
@ -371,6 +455,7 @@ const ytDlpOptions = computed<AutoCompleteOptions>(() =>
|
|||
);
|
||||
|
||||
const bufferedTranscript = consoleSession.bufferedTranscript;
|
||||
const recentSessions = consoleSession.recentSessions;
|
||||
const sessionStatus = computed(() => consoleSession.state.value.status);
|
||||
const sessionError = computed(() => consoleSession.state.value.error);
|
||||
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 isLoading = computed(() => consoleSession.isLoading.value);
|
||||
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 showCancelButton = computed(() =>
|
||||
['starting', 'running', 'reconnecting'].includes(sessionStatus.value),
|
||||
|
|
@ -481,6 +566,63 @@ const sessionStatusIcon = computed(() => {
|
|||
}
|
||||
});
|
||||
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) => {
|
||||
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> => {
|
||||
if (!canStartCommand.value) {
|
||||
return;
|
||||
|
|
@ -795,8 +930,6 @@ const runCommand = async (): Promise<void> => {
|
|||
|
||||
await ensureTerminal();
|
||||
|
||||
addToHistory(displayCommand.value);
|
||||
|
||||
const started = await consoleSession.startSession({
|
||||
command: runnableCommand.value,
|
||||
displayCommand: displayCommand.value,
|
||||
|
|
@ -871,14 +1004,19 @@ const loadCommand = async (cmd: string): Promise<void> => {
|
|||
await focusInput();
|
||||
};
|
||||
|
||||
const clearHistory = async (): Promise<void> => {
|
||||
if (historyEntries.value.length < 1) {
|
||||
const hideRecentSession = (sessionId: string): void => {
|
||||
consoleSession.hideRecentSession(sessionId);
|
||||
};
|
||||
|
||||
const hideRecentSessions = async (): Promise<void> => {
|
||||
if (recentSessionEntries.value.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = await dialog.confirmDialog({
|
||||
title: 'Confirm Action',
|
||||
message: 'Clear commands history?',
|
||||
message: 'Hide the current recent runs from this browser?',
|
||||
confirmText: 'Hide runs',
|
||||
confirmColor: 'error',
|
||||
});
|
||||
|
||||
|
|
@ -886,11 +1024,20 @@ const clearHistory = async (): Promise<void> => {
|
|||
return;
|
||||
}
|
||||
|
||||
commandHistory.value = [];
|
||||
consoleSession.clearRecentSessions();
|
||||
await focusInput();
|
||||
};
|
||||
|
||||
const removeFromHistory = (index: number): void => {
|
||||
commandHistory.value = commandHistory.value.filter((_, i) => i !== index);
|
||||
const replayRecentSession = async (item: (typeof recentSessions.value)[number]): Promise<void> => {
|
||||
try {
|
||||
await consoleSession.replaySession(item);
|
||||
restoreBufferedTerminalOutput();
|
||||
} catch {
|
||||
consoleSession.hideRecentSession(item.sessionId);
|
||||
} finally {
|
||||
await nextTick();
|
||||
await focusInput();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
|
|
@ -907,6 +1054,7 @@ onMounted(async () => {
|
|||
|
||||
await ensureTerminal();
|
||||
await consoleSession.restoreSession();
|
||||
await consoleSession.fetchRecentSessions();
|
||||
restoreBufferedTerminalOutput();
|
||||
|
||||
if (storedCommand.value.trim()) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
<template>
|
||||
<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 min-w-0 items-center gap-3">
|
||||
<span
|
||||
|
|
@ -50,7 +56,7 @@
|
|||
icon="i-lucide-refresh-cw"
|
||||
:loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
@click="void reloadHistory({ order: 'DESC', perPage: config.app.default_pagination })"
|
||||
@click="() => reload({ order: 'DESC', perPage: config.app.default_pagination })"
|
||||
>
|
||||
<span>Reload</span>
|
||||
</UButton>
|
||||
|
|
@ -78,7 +84,7 @@
|
|||
show-edges
|
||||
:sibling-count="0"
|
||||
@update:page="
|
||||
(page) => loadHistory(page, { order: 'DESC', perPage: config.app.default_pagination })
|
||||
(page) => load(page, { order: 'DESC', perPage: config.app.default_pagination })
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -314,7 +320,7 @@
|
|||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-rotate-cw"
|
||||
@click="void retryItem(item, true)"
|
||||
@click="() => retryItem(item, true)"
|
||||
>
|
||||
Retry
|
||||
</UButton>
|
||||
|
|
@ -337,7 +343,7 @@
|
|||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-trash"
|
||||
@click="void removeItem(item)"
|
||||
@click="() => removeItem(item)"
|
||||
>
|
||||
Remove
|
||||
</UButton>
|
||||
|
|
@ -608,7 +614,7 @@
|
|||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
class="w-full justify-center"
|
||||
@click="void retryItem(item, false)"
|
||||
@click="() => retryItem(item, false)"
|
||||
>
|
||||
Retry
|
||||
</UButton>
|
||||
|
|
@ -631,7 +637,7 @@
|
|||
variant="outline"
|
||||
icon="i-lucide-trash"
|
||||
class="w-full justify-center"
|
||||
@click="void removeItem(item)"
|
||||
@click="() => removeItem(item)"
|
||||
>
|
||||
{{ config.app.remove_files ? 'Remove' : 'Clear' }}
|
||||
</UButton>
|
||||
|
|
@ -703,7 +709,7 @@
|
|||
show-edges
|
||||
:sibling-count="0"
|
||||
@update:page="
|
||||
(page) => loadHistory(page, { order: 'DESC', perPage: config.app.default_pagination })
|
||||
(page) => load(page, { order: 'DESC', perPage: config.app.default_pagination })
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -714,8 +720,8 @@
|
|||
:open="Boolean(video_item)"
|
||||
:dismissible="true"
|
||||
:title="video_item?.title || 'Player'"
|
||||
:ui="{ content: 'sm:max-w-5xl', body: 'p-0' }"
|
||||
@update:open="(open) => !open && (video_item = null)"
|
||||
:ui="{ content: lightsOut ? 'sm:max-w-5xl shadow-2xl' : 'sm:max-w-5xl', body: 'p-0' }"
|
||||
@update:open="(open) => !open && closeVideo()"
|
||||
>
|
||||
<template #body>
|
||||
<VideoPlayer
|
||||
|
|
@ -725,8 +731,9 @@
|
|||
:isControls="true"
|
||||
:item="video_item"
|
||||
class="w-full"
|
||||
@closeModel="video_item = null"
|
||||
@closeModel="closeVideo()"
|
||||
@error="async (error: string) => await box.alert(error)"
|
||||
@playback-state-change="(playing: boolean) => (playingNow = playing)"
|
||||
/>
|
||||
</template>
|
||||
</UModal>
|
||||
|
|
@ -786,7 +793,7 @@ const stateStore = useQueueState();
|
|||
const socketStore = useAppSocket();
|
||||
const toast = useNotification();
|
||||
const box = useConfirm();
|
||||
const { confirmDialog } = useDialog();
|
||||
const { confirmDialog, promptDialog } = useDialog();
|
||||
const { toggleExpand, expandClass } = useExpandableMeta();
|
||||
const pendingDownloadFormItem = useState<item_request | Record<string, never>>(
|
||||
'pending-download-form-item',
|
||||
|
|
@ -797,10 +804,11 @@ const {
|
|||
pagination,
|
||||
isLoading,
|
||||
isLoaded,
|
||||
loadHistory,
|
||||
reloadHistory,
|
||||
deleteHistoryItems,
|
||||
historyMoveHandler,
|
||||
load,
|
||||
reload,
|
||||
remove,
|
||||
rename,
|
||||
moveHandler,
|
||||
} = useHistoryState();
|
||||
|
||||
const show_thumbnail = useStorage<boolean>('show_thumbnail', true);
|
||||
|
|
@ -825,23 +833,25 @@ const selectedElms = ref<string[]>([]);
|
|||
const masterSelectAll = ref(false);
|
||||
const embed_url = ref('');
|
||||
const video_item = ref<StoreItem | null>(null);
|
||||
const playingNow = ref(false);
|
||||
const expandedMessages = reactive<Record<string, Set<string>>>({});
|
||||
|
||||
const contentStyle = computed<'grid' | 'list'>(() =>
|
||||
isMobile.value ? 'grid' : display_style.value,
|
||||
);
|
||||
const showThumbnails = computed(() => show_thumbnail.value && !hideThumbnail.value);
|
||||
const lightsOut = computed(() => Boolean(video_item.value && playingNow.value));
|
||||
const paginationInfo = computed(() => ({
|
||||
...pagination.value,
|
||||
isLoading: isLoading.value,
|
||||
isLoaded: isLoaded.value,
|
||||
}));
|
||||
|
||||
const handleHistoryItemMoved = historyMoveHandler();
|
||||
const handleHistoryItemMoved = moveHandler();
|
||||
|
||||
onMounted(async () => {
|
||||
socketStore.on('item_moved', handleHistoryItemMoved);
|
||||
await loadHistory(1, { order: 'DESC', perPage: config.app.default_pagination });
|
||||
await load(1, { order: 'DESC', perPage: config.app.default_pagination });
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
|
@ -877,6 +887,11 @@ const close_info = (): void => {
|
|||
info_view.value.useUrl = false;
|
||||
};
|
||||
|
||||
const closeVideo = (): void => {
|
||||
playingNow.value = false;
|
||||
video_item.value = null;
|
||||
};
|
||||
|
||||
const view_info = (
|
||||
url: string,
|
||||
useUrl: boolean = false,
|
||||
|
|
@ -1048,6 +1063,12 @@ const itemActionGroups = (item: StoreItem): Array<Array<Record<string, unknown>>
|
|||
icon: 'i-lucide-file-code-2',
|
||||
onSelect: () => void generateNfo(item),
|
||||
});
|
||||
|
||||
mediaActions.push({
|
||||
label: 'Rename file',
|
||||
icon: 'i-lucide-pencil',
|
||||
onSelect: () => void renameFile(item),
|
||||
});
|
||||
} else if (isEmbedable(item.url)) {
|
||||
mediaActions.push({
|
||||
label: 'Play video',
|
||||
|
|
@ -1127,9 +1148,9 @@ const deleteSelectedItems = async (): Promise<void> => {
|
|||
return;
|
||||
}
|
||||
|
||||
await deleteHistoryItems({ ids: [...selectedElms.value], removeFile: config.app.remove_files });
|
||||
await remove({ ids: [...selectedElms.value], removeFile: config.app.remove_files });
|
||||
selectedElms.value = [];
|
||||
await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination });
|
||||
await reload({ order: 'DESC', perPage: config.app.default_pagination });
|
||||
};
|
||||
|
||||
const clearCompleted = async (): Promise<void> => {
|
||||
|
|
@ -1141,9 +1162,9 @@ const clearCompleted = async (): Promise<void> => {
|
|||
|
||||
selectedElms.value = [];
|
||||
|
||||
await deleteHistoryItems({ status: 'finished,skip', removeFile: false });
|
||||
await remove({ status: 'finished,skip', removeFile: false });
|
||||
|
||||
await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination });
|
||||
await reload({ order: 'DESC', perPage: config.app.default_pagination });
|
||||
};
|
||||
|
||||
const clearIncomplete = async (): Promise<void> => {
|
||||
|
|
@ -1152,8 +1173,8 @@ const clearIncomplete = async (): Promise<void> => {
|
|||
}
|
||||
|
||||
selectedElms.value = [];
|
||||
await deleteHistoryItems({ status: '!finished', removeFile: false });
|
||||
await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination });
|
||||
await remove({ status: '!finished', removeFile: false });
|
||||
await reload({ order: 'DESC', perPage: config.app.default_pagination });
|
||||
};
|
||||
|
||||
const setIcon = (item: StoreItem): string => {
|
||||
|
|
@ -1307,10 +1328,10 @@ const archiveItem = async (item: StoreItem, opts = {}): Promise<void> => {
|
|||
}
|
||||
|
||||
if ((opts as { remove_history?: boolean })?.remove_history) {
|
||||
await deleteHistoryItems({ ids: [item._id], removeFile: false });
|
||||
await remove({ ids: [item._id], removeFile: false });
|
||||
}
|
||||
|
||||
await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination });
|
||||
await reload({ order: 'DESC', perPage: config.app.default_pagination });
|
||||
};
|
||||
|
||||
const removeItem = async (item: StoreItem): Promise<void> => {
|
||||
|
|
@ -1324,13 +1345,13 @@ const removeItem = async (item: StoreItem): Promise<void> => {
|
|||
return;
|
||||
}
|
||||
|
||||
await deleteHistoryItems({ ids: [item._id], removeFile: config.app.remove_files });
|
||||
await remove({ ids: [item._id], removeFile: config.app.remove_files });
|
||||
|
||||
if (selectedElms.value.includes(item._id || '')) {
|
||||
selectedElms.value = selectedElms.value.filter((entry) => entry !== item._id);
|
||||
}
|
||||
|
||||
await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination });
|
||||
await reload({ order: 'DESC', perPage: config.app.default_pagination });
|
||||
};
|
||||
|
||||
const retryItem = async (
|
||||
|
|
@ -1349,13 +1370,13 @@ const retryItem = async (
|
|||
auto_start: item.auto_start,
|
||||
};
|
||||
|
||||
await deleteHistoryItems({ ids: [item._id], removeFile: remove_file });
|
||||
await remove({ ids: [item._id], removeFile: remove_file });
|
||||
|
||||
if (selectedElms.value.includes(item._id || '')) {
|
||||
selectedElms.value = selectedElms.value.filter((entry) => entry !== item._id);
|
||||
}
|
||||
|
||||
await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination });
|
||||
await reload({ order: 'DESC', perPage: config.app.default_pagination });
|
||||
|
||||
if (true === re_add) {
|
||||
toast.info('Cleared the item from history, and added it to the new download form.');
|
||||
|
|
@ -1532,10 +1553,10 @@ const removeFromArchive = async (
|
|||
}
|
||||
|
||||
if (opts?.remove_history) {
|
||||
await deleteHistoryItems({ ids: [item._id], removeFile: file_delete });
|
||||
await remove({ ids: [item._id], removeFile: file_delete });
|
||||
}
|
||||
|
||||
await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination });
|
||||
await reload({ order: 'DESC', perPage: config.app.default_pagination });
|
||||
};
|
||||
|
||||
const isQueuedAnimation = (item: StoreItem): string => {
|
||||
|
|
@ -1565,4 +1586,33 @@ const generateNfo = async (item: StoreItem): Promise<void> => {
|
|||
toast.error(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const renameFile = async (item: StoreItem): Promise<void> => {
|
||||
if (!item.filename) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentName = item.filename.split('/').pop() || item.filename;
|
||||
const { status, value: newName } = await promptDialog({
|
||||
title: 'Rename File',
|
||||
message: `Enter new name for '${currentName}' (and its sidecars):`,
|
||||
initial: currentName,
|
||||
confirmText: 'Rename',
|
||||
cancelText: 'Cancel',
|
||||
});
|
||||
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedName = (newName || '').trim();
|
||||
if (!trimmedName || trimmedName === currentName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await rename(item, trimmedName);
|
||||
if (success) {
|
||||
toast.success(`Renamed '${currentName}'.`);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -661,8 +661,8 @@
|
|||
<code>Actions > Archive All</code> to mark existing items as already downloaded.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Custom Handlers:</strong> Leave timer empty for custom handler definitions. The
|
||||
handler runs hourly and doesn't require timer.
|
||||
<strong>Timer Requirement:</strong> Tasks must have either a valid timer or a supported
|
||||
handler. If the URL does not match a handler, set a CRON timer.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Generate metadata:</strong> will attempt first to save metadata based on the
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@
|
|||
</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>
|
||||
<p class="hint">Please wait...</p>
|
||||
|
|
|
|||
27
ui/app/types/index.d.ts
vendored
27
ui/app/types/index.d.ts
vendored
|
|
@ -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
12
ui/app/types/subtitles.ts
Normal 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[];
|
||||
};
|
||||
19
ui/app/types/video.d.ts
vendored
19
ui/app/types/video.d.ts
vendored
|
|
@ -2,14 +2,14 @@ type KeyboardShortcutContext = {
|
|||
video: HTMLVideoElement;
|
||||
};
|
||||
|
||||
type video_track_element = {
|
||||
type VideoTrackElement = {
|
||||
file: string;
|
||||
kind: string;
|
||||
label: string;
|
||||
lang: string;
|
||||
};
|
||||
|
||||
type video_source_element = {
|
||||
type VideoSourceElement = {
|
||||
src: string;
|
||||
type: string;
|
||||
onerror: (e: Event) => void;
|
||||
|
|
@ -68,7 +68,7 @@ type FFProbeResult = {
|
|||
is_audio: boolean;
|
||||
};
|
||||
|
||||
type file_info = {
|
||||
type FileInfo = {
|
||||
title: string;
|
||||
ffprobe: FFProbeResult;
|
||||
mimetype: string;
|
||||
|
|
@ -80,10 +80,17 @@ type file_info = {
|
|||
error?: string;
|
||||
};
|
||||
|
||||
type PlayerSourceElement = {
|
||||
src: string;
|
||||
type?: string;
|
||||
onerror?: (e: Event) => void;
|
||||
};
|
||||
|
||||
export type {
|
||||
video_track_element,
|
||||
video_source_element,
|
||||
VideoTrackElement,
|
||||
VideoSourceElement,
|
||||
PlayerSourceElement,
|
||||
FFProbeResult,
|
||||
file_info,
|
||||
FileInfo,
|
||||
KeyboardShortcutContext,
|
||||
};
|
||||
|
|
|
|||
53
ui/app/utils/fullscreen.ts
Normal file
53
ui/app/utils/fullscreen.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -1,6 +1,19 @@
|
|||
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) {
|
||||
ctx.video.play();
|
||||
} 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);
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
export const handleMute = (ctx: KeyboardShortcutContext) => {
|
||||
export const mute = (ctx: KeyboardShortcutContext) => {
|
||||
ctx.video.muted = !ctx.video.muted;
|
||||
};
|
||||
|
||||
export const handleVolumeChange = (ctx: KeyboardShortcutContext, delta: number) => {
|
||||
const newVolume = Math.max(0, Math.min(1, ctx.video.volume + delta));
|
||||
ctx.video.volume = newVolume;
|
||||
export const changeVolume = (ctx: KeyboardShortcutContext, delta: number) => {
|
||||
const volume = clampMediaVolume(ctx.video.volume + delta);
|
||||
ctx.video.volume = volume;
|
||||
};
|
||||
|
||||
export const handlePlaybackSpeedChange = (ctx: KeyboardShortcutContext, delta: number) => {
|
||||
const currentSpeed = ctx.video.playbackRate;
|
||||
const newSpeed = Math.max(0.25, Math.min(2, currentSpeed + delta));
|
||||
ctx.video.playbackRate = newSpeed;
|
||||
export const changeSpeed = (ctx: KeyboardShortcutContext, delta: number) => {
|
||||
const speed = ctx.video.playbackRate;
|
||||
ctx.video.playbackRate = Math.max(0.25, Math.min(2, speed + delta));
|
||||
};
|
||||
|
||||
export const handleFrameStep = (
|
||||
export const frameStep = (
|
||||
ctx: KeyboardShortcutContext,
|
||||
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;
|
||||
};
|
||||
|
||||
export const handleSeekBackward = (ctx: KeyboardShortcutContext, seconds: number = 5) => {
|
||||
ctx.video.currentTime = Math.max(0, ctx.video.currentTime - seconds);
|
||||
export const seekBackward = (ctx: KeyboardShortcutContext, seconds: number = 5) => {
|
||||
clampMediaTime(ctx.video, ctx.video.currentTime - seconds);
|
||||
};
|
||||
|
||||
export const handleSeekForward = (ctx: KeyboardShortcutContext, seconds: number = 5) => {
|
||||
ctx.video.currentTime = Math.min(ctx.video.duration, ctx.video.currentTime + seconds);
|
||||
export const seekForward = (ctx: KeyboardShortcutContext, seconds: number = 5) => {
|
||||
clampMediaTime(ctx.video, ctx.video.currentTime + seconds);
|
||||
};
|
||||
|
||||
export const handleFullscreen = (videoElement: HTMLVideoElement) => {
|
||||
export const fullscreen = (video: HTMLVideoElement) => {
|
||||
if (!document.fullscreenElement) {
|
||||
videoElement.requestFullscreen().catch((err) => {
|
||||
video.requestFullscreen().catch((err) => {
|
||||
console.error(`Error attempting to enable fullscreen: ${err.message}`);
|
||||
});
|
||||
} else {
|
||||
|
|
@ -69,20 +81,20 @@ export const handleFullscreen = (videoElement: HTMLVideoElement) => {
|
|||
}
|
||||
};
|
||||
|
||||
export const handlePictureInPicture = async (videoElement: HTMLVideoElement) => {
|
||||
export const pictureInPicture = async (video: HTMLVideoElement) => {
|
||||
try {
|
||||
if (document.pictureInPictureElement) {
|
||||
await document.exitPictureInPicture();
|
||||
} else if (document.pictureInPictureEnabled) {
|
||||
await videoElement.requestPictureInPicture();
|
||||
await video.requestPictureInPicture();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Picture-in-Picture error: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleToggleCaptions = (videoElement: HTMLVideoElement) => {
|
||||
const textTracks = videoElement.textTracks;
|
||||
export const toggleCaptions = (video: HTMLVideoElement) => {
|
||||
const textTracks = video.textTracks;
|
||||
|
||||
if (0 === textTracks.length) {
|
||||
return;
|
||||
|
|
@ -112,5 +124,4 @@ export const shouldHandleKeyboardShortcut = (event: KeyboardEvent): boolean => {
|
|||
return true;
|
||||
};
|
||||
|
||||
export const isModifierKey = (event: KeyboardEvent): boolean =>
|
||||
event.ctrlKey || event.metaKey || event.altKey;
|
||||
export const modifierKey = (event: KeyboardEvent): boolean => hasModifierKey(event);
|
||||
|
|
|
|||
437
ui/bun.lock
437
ui/bun.lock
File diff suppressed because it is too large
Load diff
|
|
@ -90,6 +90,10 @@ export default defineNuxtConfig({
|
|||
'marked-base-url',
|
||||
'marked-alert',
|
||||
'marked-gfm-heading-id',
|
||||
'hls.js',
|
||||
'assjs',
|
||||
'@vue/devtools-core',
|
||||
'@vue/devtools-kit',
|
||||
],
|
||||
},
|
||||
server: {
|
||||
|
|
|
|||
|
|
@ -21,42 +21,43 @@
|
|||
},
|
||||
"web-types": "./web-types.json",
|
||||
"dependencies": {
|
||||
"@iconify-json/lucide": "^1.2.102",
|
||||
"@iconify-json/lucide": "^1.2.103",
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"@nuxt/eslint": "^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/nuxt": "^14.2.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"assjs": "^0.1.6",
|
||||
"cron-parser": "^5.5.0",
|
||||
"cronstrue": "^3.14.0",
|
||||
"hls.js": "^1.6.16",
|
||||
"marked": "^18.0.0",
|
||||
"marked": "^18.0.2",
|
||||
"marked-alert": "^2.1.2",
|
||||
"marked-base-url": "^1.1.9",
|
||||
"marked-gfm-heading-id": "^4.1.4",
|
||||
"moment": "^2.30.1",
|
||||
"nuxt": "^4.4.2",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.4"
|
||||
"tailwindcss": "^4.2.4",
|
||||
"vue": "^3.5.33",
|
||||
"vue-router": "^5.0.6"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
"@parcel/watcher"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.12",
|
||||
"@types/bun": "^1.3.13",
|
||||
"@types/node": "25.6.0",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"@typescript-eslint/parser": "^8.58.2",
|
||||
"eslint": "^10.2.0",
|
||||
"@typescript-eslint/parser": "^8.59.0",
|
||||
"eslint": "^10.2.1",
|
||||
"jsdom": "^29.0.2",
|
||||
"oxfmt": "^0.45.0",
|
||||
"typescript": "^6.0.2",
|
||||
"oxfmt": "^0.46.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vue-eslint-parser": "^10.4.0",
|
||||
"vue-tsc": "^3.2.6"
|
||||
"vue-tsc": "^3.2.7"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ describe('useConditions', () => {
|
|||
})
|
||||
|
||||
describe('loadConditions', () => {
|
||||
it('loads conditions with pagination successfully', async () => {
|
||||
it('load_conditions', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -101,7 +101,7 @@ describe('useConditions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('sorts conditions by priority then name', async () => {
|
||||
it('sort_priority', async () => {
|
||||
const items = [
|
||||
{ ...mockCondition, id: 1, name: 'B', priority: 2 },
|
||||
{ ...mockCondition, id: 2, name: 'A', priority: 2 },
|
||||
|
|
@ -136,7 +136,7 @@ describe('useConditions', () => {
|
|||
})
|
||||
|
||||
describe('getCondition', () => {
|
||||
it('fetches a single condition successfully', async () => {
|
||||
it('get_condition', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -157,7 +157,7 @@ describe('useConditions', () => {
|
|||
})
|
||||
|
||||
describe('createCondition', () => {
|
||||
it('creates a condition successfully', async () => {
|
||||
it('create_condition', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -183,7 +183,7 @@ describe('useConditions', () => {
|
|||
})
|
||||
|
||||
describe('updateCondition', () => {
|
||||
it('updates a condition successfully', async () => {
|
||||
it('update_condition', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -202,7 +202,7 @@ describe('useConditions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('removes id field from condition before sending', async () => {
|
||||
it('strip_update_id', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -216,13 +216,13 @@ describe('useConditions', () => {
|
|||
await conditions.updateCondition(1, mockCondition)
|
||||
|
||||
const requestBody = JSON.parse((requestSpy.mock.calls[0][1] as any).body)
|
||||
expect(requestBody.id).toBeUndefined()
|
||||
expect('id' in requestBody).toBe(false)
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('patchCondition', () => {
|
||||
it('patches a condition successfully', async () => {
|
||||
it('patch_condition', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -242,7 +242,7 @@ describe('useConditions', () => {
|
|||
})
|
||||
|
||||
describe('deleteCondition', () => {
|
||||
it('deletes a condition successfully', async () => {
|
||||
it('delete_condition', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -265,7 +265,7 @@ describe('useConditions', () => {
|
|||
})
|
||||
|
||||
describe('testCondition', () => {
|
||||
it('tests a condition successfully', async () => {
|
||||
it('test_condition', async () => {
|
||||
const testResponse = {
|
||||
status: true,
|
||||
condition: 'duration > 60',
|
||||
|
|
@ -292,7 +292,7 @@ describe('useConditions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles test errors', async () => {
|
||||
it('store_test_error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -309,7 +309,7 @@ describe('useConditions', () => {
|
|||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(conditions.lastError.value).toBeTruthy()
|
||||
expect(conditions.lastError.value).toBe('Invalid URL')
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ afterEach(() => {
|
|||
})
|
||||
|
||||
describe('useConsoleSession', () => {
|
||||
it('starts a session, persists prompt transcript, and stores streamed output', async () => {
|
||||
it('start_session', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -166,7 +166,7 @@ describe('useConsoleSession', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('restores a running session using both since and Last-Event-ID', async () => {
|
||||
it('restore_with_cursor', async () => {
|
||||
const session = useConsoleSession()
|
||||
session.state.value = {
|
||||
sessionId: 'sess-restore',
|
||||
|
|
@ -196,7 +196,6 @@ describe('useConsoleSession', () => {
|
|||
expect(fetchEventSourceMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
const streamCall = fetchEventSourceMock.mock.calls[0]
|
||||
expect(streamCall).toBeDefined()
|
||||
if (!streamCall) {
|
||||
throw new Error('Expected fetchEventSource to be called once.')
|
||||
}
|
||||
|
|
@ -209,7 +208,7 @@ describe('useConsoleSession', () => {
|
|||
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')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -243,7 +242,7 @@ describe('useConsoleSession', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('restores a persisted session even after a local stream error state', async () => {
|
||||
it('restore_after_stream_error', async () => {
|
||||
const session = useConsoleSession()
|
||||
session.state.value = {
|
||||
sessionId: 'sess-reconnect',
|
||||
|
|
@ -277,7 +276,7 @@ describe('useConsoleSession', () => {
|
|||
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')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -333,7 +332,7 @@ describe('useConsoleSession', () => {
|
|||
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')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -382,7 +381,7 @@ describe('useConsoleSession', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('requests cancellation for the active session without dropping local state early', async () => {
|
||||
it('cancel_without_reset', async () => {
|
||||
const session = useConsoleSession()
|
||||
session.state.value = {
|
||||
sessionId: 'sess-stop',
|
||||
|
|
@ -410,20 +409,15 @@ describe('useConsoleSession', () => {
|
|||
expect(session.state.value.status).toBe('running')
|
||||
expect(session.state.value.error).toBe('')
|
||||
|
||||
const requestCall = requestSpy.mock.calls.at(-1)
|
||||
expect(requestCall).toBeDefined()
|
||||
if (!requestCall) {
|
||||
throw new Error('Expected request to be called for session cancellation.')
|
||||
}
|
||||
|
||||
const [path, options] = requestCall as [string, { method: string }]
|
||||
expect(path).toBe('/api/system/terminal/sess-stop')
|
||||
expect(options.method).toBe('DELETE')
|
||||
const deleteCall = requestSpy.mock.calls.find(
|
||||
(call) => call[0] === '/api/system/terminal/sess-stop' && call[1]?.method === 'DELETE',
|
||||
)
|
||||
expect(deleteCall).toEqual(['/api/system/terminal/sess-stop', { method: 'DELETE' }])
|
||||
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('disconnects the local stream without issuing a cancel request', async () => {
|
||||
it('disconnect_local', async () => {
|
||||
const session = useConsoleSession()
|
||||
session.state.value = {
|
||||
sessionId: 'sess-detach',
|
||||
|
|
@ -454,7 +448,7 @@ describe('useConsoleSession', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('refreshes metadata when cancel targets a missing session', async () => {
|
||||
it('refresh_missing_cancel', async () => {
|
||||
const session = useConsoleSession()
|
||||
session.state.value = {
|
||||
sessionId: 'sess-missing',
|
||||
|
|
@ -483,7 +477,7 @@ describe('useConsoleSession', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('clears visible transcript without dropping the active session cursor', () => {
|
||||
it('clear_transcript', () => {
|
||||
const session = useConsoleSession()
|
||||
session.state.value = {
|
||||
sessionId: 'sess-active',
|
||||
|
|
@ -502,4 +496,128 @@ describe('useConsoleSession', () => {
|
|||
expect(session.state.value.status).toBe('running')
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
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 dirty = ref(true);
|
||||
const onDiscard = mock(() => {});
|
||||
|
|
@ -75,7 +75,7 @@ describe('useDirtyCloseGuard', () => {
|
|||
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 dirty = ref(true);
|
||||
|
||||
|
|
@ -94,7 +94,7 @@ describe('useDirtyCloseGuard', () => {
|
|||
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 dirty = ref(true);
|
||||
|
||||
|
|
@ -130,7 +130,7 @@ describe('useDirtyCloseGuard', () => {
|
|||
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 dirty = ref(true);
|
||||
const onDiscard = mock(() => {});
|
||||
|
|
@ -162,7 +162,7 @@ describe('useDirtyCloseGuard', () => {
|
|||
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 dirty = ref(false);
|
||||
|
||||
|
|
|
|||
102
ui/tests/composables/usePlayerShortcuts.test.ts
Normal file
102
ui/tests/composables/usePlayerShortcuts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
291
ui/tests/composables/usePlayerSubtitles.test.ts
Normal file
291
ui/tests/composables/usePlayerSubtitles.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -40,7 +40,7 @@ const setConfigStore = (presets: Preset[]) => {
|
|||
}
|
||||
|
||||
describe('usePresetOptions', () => {
|
||||
it('groups custom presets before default presets by default', () => {
|
||||
it('group_custom_first', () => {
|
||||
setConfigStore([
|
||||
buildPreset('default_video', true),
|
||||
buildPreset('custom_audio', false),
|
||||
|
|
@ -56,7 +56,7 @@ describe('usePresetOptions', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('supports default-first grouping when requested', () => {
|
||||
it('group_default_first', () => {
|
||||
setConfigStore([
|
||||
buildPreset('default_video', true),
|
||||
buildPreset('custom_audio', false),
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ describe('usePresets', () => {
|
|||
})
|
||||
|
||||
describe('loadPresets', () => {
|
||||
it('loads presets with pagination successfully', async () => {
|
||||
it('load_presets', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -104,7 +104,7 @@ describe('usePresets', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('requests custom presets without defaults when asked', async () => {
|
||||
it('exclude_defaults', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -124,7 +124,7 @@ describe('usePresets', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('sorts presets by priority then name', async () => {
|
||||
it('sort_priority', async () => {
|
||||
const items = [
|
||||
{ ...mockPreset, id: 1, name: 'B', priority: 2 },
|
||||
{ ...mockPreset, id: 2, name: 'A', priority: 2 },
|
||||
|
|
@ -159,7 +159,7 @@ describe('usePresets', () => {
|
|||
})
|
||||
|
||||
describe('getPreset', () => {
|
||||
it('fetches a single preset successfully', async () => {
|
||||
it('get_preset', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -180,7 +180,7 @@ describe('usePresets', () => {
|
|||
})
|
||||
|
||||
describe('createPreset', () => {
|
||||
it('creates a preset successfully', async () => {
|
||||
it('create_preset', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -209,7 +209,7 @@ describe('usePresets', () => {
|
|||
})
|
||||
|
||||
describe('updatePreset', () => {
|
||||
it('updates a preset successfully', async () => {
|
||||
it('update_preset', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -226,7 +226,7 @@ describe('usePresets', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('strips id from update payload', async () => {
|
||||
it('strip_update_id', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -240,14 +240,14 @@ describe('usePresets', () => {
|
|||
await presets.updatePreset(1, { ...mockPreset, default: true })
|
||||
|
||||
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)
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('patchPreset', () => {
|
||||
it('patches a preset successfully', async () => {
|
||||
it('patch_preset', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -264,7 +264,7 @@ describe('usePresets', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('strips id and default from patch payload', async () => {
|
||||
it('sanitize_patch', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -278,14 +278,14 @@ describe('usePresets', () => {
|
|||
await presets.patchPreset(1, { id: 10, default: true })
|
||||
|
||||
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)
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deletePreset', () => {
|
||||
it('deletes a preset successfully', async () => {
|
||||
it('delete_preset', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ describe('useTaskDefinitions', () => {
|
|||
errorMock.mockClear()
|
||||
})
|
||||
|
||||
it('sorts definitions by priority then name', async () => {
|
||||
it('sort_priority', async () => {
|
||||
const items = [
|
||||
{ id: 1, name: 'B', priority: 2, updated_at: 1 },
|
||||
{ id: 2, name: 'A', priority: 2, updated_at: 2 },
|
||||
|
|
@ -90,7 +90,7 @@ describe('useTaskDefinitions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles empty payload', async () => {
|
||||
it('handle_empty_payload', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(createMockResponse({
|
||||
ok: true,
|
||||
|
|
@ -104,7 +104,7 @@ describe('useTaskDefinitions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('throws loadDefinitions error when throwInstead is true', async () => {
|
||||
it('throw_load_error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(createMockResponse({
|
||||
ok: false,
|
||||
|
|
@ -117,7 +117,7 @@ describe('useTaskDefinitions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns null on getDefinition error', async () => {
|
||||
it('store_get_error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(createMockResponse({
|
||||
ok: false,
|
||||
|
|
@ -132,7 +132,7 @@ describe('useTaskDefinitions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('creates definition successfully', async () => {
|
||||
it('create_definition', async () => {
|
||||
const payload: TaskDefinitionDetailed = {
|
||||
id: 2,
|
||||
name: 'New',
|
||||
|
|
@ -152,7 +152,7 @@ describe('useTaskDefinitions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('removes definition on deleteDefinition', async () => {
|
||||
it('remove_deleted_definition', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(createMockResponse({
|
||||
ok: true,
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ describe('useTasks', () => {
|
|||
})
|
||||
|
||||
describe('loadTasks', () => {
|
||||
it('loads tasks with pagination successfully', async () => {
|
||||
it('load_tasks', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -108,7 +108,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('sorts tasks by name A-Z', async () => {
|
||||
it('sort_name', async () => {
|
||||
const items = [
|
||||
{ ...mockTask, id: 1, name: 'Zebra' },
|
||||
{ ...mockTask, id: 2, name: 'Alpha' },
|
||||
|
|
@ -137,7 +137,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles empty tasks list', async () => {
|
||||
it('handle_empty_list', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -158,7 +158,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles errors during load', async () => {
|
||||
it('store_load_error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -171,11 +171,11 @@ describe('useTasks', () => {
|
|||
const tasks = useTasks()
|
||||
await tasks.loadTasks()
|
||||
|
||||
expect(tasks.lastError.value).toBeTruthy()
|
||||
expect(tasks.lastError.value).toBe('Server error')
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('respects page and per_page parameters', async () => {
|
||||
it('pass_pagination', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -197,7 +197,7 @@ describe('useTasks', () => {
|
|||
})
|
||||
|
||||
describe('getTask', () => {
|
||||
it('fetches a single task successfully', async () => {
|
||||
it('get_task', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -215,7 +215,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles 404 not found', async () => {
|
||||
it('store_404_error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -229,13 +229,13 @@ describe('useTasks', () => {
|
|||
const result = await tasks.getTask(999)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(tasks.lastError.value).toBeTruthy()
|
||||
expect(tasks.lastError.value).toBe('Task not found')
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTask', () => {
|
||||
it('creates a task successfully', async () => {
|
||||
it('create_task', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -258,10 +258,33 @@ describe('useTasks', () => {
|
|||
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', () => {
|
||||
it('updates a task successfully', async () => {
|
||||
it('update_task', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -280,7 +303,27 @@ describe('useTasks', () => {
|
|||
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')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -301,7 +344,7 @@ describe('useTasks', () => {
|
|||
})
|
||||
|
||||
describe('patchTask', () => {
|
||||
it('patches a task successfully', async () => {
|
||||
it('patch_task', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -318,10 +361,30 @@ describe('useTasks', () => {
|
|||
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', () => {
|
||||
it('deletes a task successfully', async () => {
|
||||
it('delete_task', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -344,7 +407,7 @@ describe('useTasks', () => {
|
|||
})
|
||||
|
||||
describe('inspectTaskHandler', () => {
|
||||
it('inspects task handler successfully', async () => {
|
||||
it('inspect_handler', async () => {
|
||||
const inspectResponse: TaskInspectResponse = {
|
||||
matched: true,
|
||||
handler: 'YoutubeHandler',
|
||||
|
|
@ -383,7 +446,7 @@ describe('useTasks', () => {
|
|||
fetchSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles handler not supported', async () => {
|
||||
it('handle_unsupported_handler', async () => {
|
||||
const inspectResponse: TaskInspectResponse = {
|
||||
matched: false,
|
||||
handler: null,
|
||||
|
|
@ -411,7 +474,7 @@ describe('useTasks', () => {
|
|||
fetchSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles inspect errors', async () => {
|
||||
it('store_inspect_error', async () => {
|
||||
const fetchSpy = spyOn(globalThis, 'fetch')
|
||||
fetchSpy.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
|
|
@ -421,13 +484,13 @@ describe('useTasks', () => {
|
|||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(tasks.lastError.value).toBeTruthy()
|
||||
expect(tasks.lastError.value).toBe('Network error')
|
||||
fetchSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('markTaskItems', () => {
|
||||
it('marks all items as downloaded successfully', async () => {
|
||||
it('mark_items', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -445,7 +508,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles mark errors', async () => {
|
||||
it('store_mark_error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -459,13 +522,13 @@ describe('useTasks', () => {
|
|||
const result = await tasks.markTaskItems(999)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(tasks.lastError.value).toBeTruthy()
|
||||
expect(tasks.lastError.value).toBe('Task not found')
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('unmarkTaskItems', () => {
|
||||
it('unmarks items successfully', async () => {
|
||||
it('unmark_items', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -483,7 +546,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles unmark errors', async () => {
|
||||
it('store_unmark_error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -497,13 +560,13 @@ describe('useTasks', () => {
|
|||
const result = await tasks.unmarkTaskItems(999)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(tasks.lastError.value).toBeTruthy()
|
||||
expect(tasks.lastError.value).toBe('Task not found')
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateTaskMetadata', () => {
|
||||
it('generates metadata successfully', async () => {
|
||||
it('generate_metadata', async () => {
|
||||
const metadataResponse: TaskMetadataResponse = {
|
||||
status: 'success',
|
||||
generated: ['tvshow.nfo', 'info.json', 'poster.jpg'],
|
||||
|
|
@ -527,7 +590,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles metadata generation errors', async () => {
|
||||
it('store_metadata_error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -541,13 +604,13 @@ describe('useTasks', () => {
|
|||
const result = await tasks.generateTaskMetadata(1)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(tasks.lastError.value).toBeTruthy()
|
||||
expect(tasks.lastError.value).toBe('Failed to generate metadata')
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws when throwInstead is true', async () => {
|
||||
it('throw_on_error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -564,7 +627,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('clears error on clearError call', async () => {
|
||||
it('clear_error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -579,7 +642,7 @@ describe('useTasks', () => {
|
|||
|
||||
await tasks.loadTasks()
|
||||
|
||||
expect(tasks.lastError.value).toBeTruthy()
|
||||
expect(tasks.lastError.value).toBe('Server error')
|
||||
|
||||
tasks.clearError()
|
||||
|
||||
|
|
@ -587,7 +650,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('parses API validation errors correctly', async () => {
|
||||
it('parse_validation_errors', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -611,7 +674,7 @@ describe('useTasks', () => {
|
|||
const result = await tasks.createTask(newTask)
|
||||
|
||||
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('url: Invalid URL format')
|
||||
requestSpy.mockRestore()
|
||||
|
|
@ -619,7 +682,7 @@ describe('useTasks', () => {
|
|||
})
|
||||
|
||||
describe('addInProgress state', () => {
|
||||
it('sets addInProgress during create operation', async () => {
|
||||
it('set_add_in_progress', async () => {
|
||||
let inProgressDuringCall = false
|
||||
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
|
|
@ -644,7 +707,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('resets addInProgress on error', async () => {
|
||||
it('reset_add_in_progress', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -666,7 +729,7 @@ describe('useTasks', () => {
|
|||
})
|
||||
|
||||
describe('isLoading state', () => {
|
||||
it('sets isLoading during loadTasks operation', async () => {
|
||||
it('set_loading', async () => {
|
||||
let loadingDuringCall = false
|
||||
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
|
|
@ -693,7 +756,7 @@ describe('useTasks', () => {
|
|||
})
|
||||
|
||||
describe('task list updates', () => {
|
||||
it('updates existing task in list', async () => {
|
||||
it('update_list_item', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -727,7 +790,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('removes deleted task from list', async () => {
|
||||
it('remove_list_item', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ afterEach(async () => {
|
|||
});
|
||||
|
||||
describe('modal opacity plugin', () => {
|
||||
it('ignores a settings-only overlay', async () => {
|
||||
it('ignore_settings_overlay', async () => {
|
||||
startPlugin();
|
||||
|
||||
const settingsPanel = document.createElement('div');
|
||||
|
|
@ -77,7 +77,7 @@ describe('modal opacity plugin', () => {
|
|||
expect(syncOpacityMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores opacity when a normal overlay closes back to settings-only', async () => {
|
||||
it('restore_after_close', async () => {
|
||||
startPlugin();
|
||||
|
||||
const settingsPanel = document.createElement('div');
|
||||
|
|
@ -99,7 +99,7 @@ describe('modal opacity plugin', () => {
|
|||
expect(enableOpacityMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resyncs opacity when overlays change while already locked', async () => {
|
||||
it('resync_while_locked', async () => {
|
||||
startPlugin();
|
||||
|
||||
document.body.append(createOverlay());
|
||||
|
|
@ -112,7 +112,7 @@ describe('modal opacity plugin', () => {
|
|||
expect(syncOpacityMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not unlock opacity on beforeunload when reload is canceled', async () => {
|
||||
it('keep_lock_beforeunload', async () => {
|
||||
startPlugin();
|
||||
|
||||
document.body.append(createOverlay());
|
||||
|
|
|
|||
|
|
@ -132,81 +132,81 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe('object access helpers', () => {
|
||||
it('ag returns nested value or default value', () => {
|
||||
it('ag_nested_value', () => {
|
||||
const payload = { a: { b: { c: 42 } } };
|
||||
expect(utils.ag(payload, 'a.b.c')).toBe(42);
|
||||
expect(utils.ag(payload, 'a.b.x', 'fallback')).toBe('fallback');
|
||||
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> = {};
|
||||
utils.ag_set(payload, '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 };
|
||||
expect(utils.cleanObject(source, ['drop'])).toEqual({ id: 1, keep: true });
|
||||
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('', '/var/files/test.txt')).toBe('/var/files/test.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('string manipulation helpers', () => {
|
||||
it('r replaces tokens with context values', () => {
|
||||
it('replace_tokens', () => {
|
||||
const result = utils.r('Hello {user.name}!', { user: { name: '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', ':', 'start')).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', '/', 'start')).toBe('value');
|
||||
expect(utils.iTrim('value/', '/', 'end')).toBe('value');
|
||||
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', '\\', 'start')).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', '-', 'start')).toBe('value');
|
||||
expect(utils.iTrim('value-', '-', 'end')).toBe('value');
|
||||
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', '^', 'start')).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[[');
|
||||
});
|
||||
|
||||
it('iTrim handles dot delimiter', () => {
|
||||
it('itrim_dot', () => {
|
||||
expect(utils.iTrim('..value..', '.', 'both')).toBe('value');
|
||||
expect(utils.iTrim('.value', '.', 'start')).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');
|
||||
|
|
@ -215,96 +215,96 @@ describe('string manipulation helpers', () => {
|
|||
expect(utils.iTrim('((value))', ')', 'both')).toBe('((value');
|
||||
});
|
||||
|
||||
it('iTrim handles empty string', () => {
|
||||
it('itrim_empty', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
it('encodePath safely encodes components', () => {
|
||||
it('encode_path', () => {
|
||||
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(
|
||||
'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(
|
||||
'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(
|
||||
'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(
|
||||
'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');
|
||||
});
|
||||
|
||||
it('encodePath handles special characters &, =, ?', () => {
|
||||
it('encode_query_chars', () => {
|
||||
expect(utils.encodePath('query?param=value&key=100%.mp4')).toBe(
|
||||
'query%3Fparam%3Dvalue%26key%3D100%25.mp4',
|
||||
);
|
||||
});
|
||||
|
||||
it('encodePath handles empty string', () => {
|
||||
it('encode_empty', () => {
|
||||
expect(utils.encodePath('')).toBe('');
|
||||
});
|
||||
|
||||
it('encodePath handles simple filename', () => {
|
||||
it('encode_simple_filename', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
it('encodePath handles parentheses', () => {
|
||||
it('encode_parentheses', () => {
|
||||
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';
|
||||
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', '.mp4')).toBe('video');
|
||||
expect(utils.basename('', '.mp4')).toBe('');
|
||||
});
|
||||
|
||||
it('dirname returns parent directory', () => {
|
||||
it('dirname_parent', () => {
|
||||
expect(utils.dirname('/downloads/video.mp4')).toBe('/downloads');
|
||||
expect(utils.dirname('video.mp4')).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(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(90)).toBe('01:30');
|
||||
expect(utils.formatTime(3661)).toBe('01:01:01');
|
||||
|
|
@ -312,47 +312,47 @@ describe('string manipulation 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('""')).toBe(false);
|
||||
expect(utils.has_data('[1,2]')).toBe(true);
|
||||
expect(utils.has_data('')).toBe(false);
|
||||
});
|
||||
|
||||
it('encode and decode provide reversible transformation', () => {
|
||||
it('encode_decode_roundtrip', () => {
|
||||
const payload = { name: 'YTPTube', count: 2 };
|
||||
const encoded = utils.encode(payload);
|
||||
expect(typeof encoded).toBe('string');
|
||||
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' });
|
||||
});
|
||||
|
||||
it('uri prefixes runtime base path', () => {
|
||||
it('prefix_runtime_base', () => {
|
||||
runtimeConfig.app.baseURL = '/base-path';
|
||||
expect(utils.uri('/api/test')).toBe('/base-path/api/test');
|
||||
runtimeConfig.app.baseURL = '/';
|
||||
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';
|
||||
const url = utils.makeDownload({}, { folder: 'music', filename: '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');
|
||||
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);
|
||||
});
|
||||
|
||||
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: 'downloading', download_skipped: true } 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', () => {
|
||||
it('copyText uses clipboard API and notifies success', async () => {
|
||||
it('write_clipboard', async () => {
|
||||
utils.copyText('sample');
|
||||
|
||||
await Promise.resolve();
|
||||
|
|
@ -372,13 +372,13 @@ describe('dom and browser helpers', () => {
|
|||
expect(notificationMock.success).toHaveBeenCalledWith('Text copied to clipboard.');
|
||||
});
|
||||
|
||||
it('disableOpacity toggles body opacity when enabled', () => {
|
||||
it('lock_opacity', () => {
|
||||
const result = utils.disableOpacity();
|
||||
expect(result).toBe(true);
|
||||
expect(document.body.style.opacity).toBe('1');
|
||||
});
|
||||
|
||||
it('disableOpacity returns false when background disabled', () => {
|
||||
it('skip_disabled_bg', () => {
|
||||
document.body.removeAttribute('style');
|
||||
storageMap.clear();
|
||||
const originalOpacity = document.body.style.opacity;
|
||||
|
|
@ -398,7 +398,7 @@ describe('dom and browser helpers', () => {
|
|||
expect(document.body.style.opacity || '').toBe(originalOpacity || '');
|
||||
});
|
||||
|
||||
it('enableOpacity applies stored opacity value', () => {
|
||||
it('restore_opacity', () => {
|
||||
utils.disableOpacity();
|
||||
useStorageFn.mockImplementation((key: string, defaultValue: any) => {
|
||||
if (!storageMap.has(key)) {
|
||||
|
|
@ -413,7 +413,7 @@ describe('dom and browser helpers', () => {
|
|||
expect(document.body.style.opacity).toBe('0.75');
|
||||
});
|
||||
|
||||
it('syncOpacity keeps full opacity while a lock is active', () => {
|
||||
it('keep_opacity_lock', () => {
|
||||
utils.disableOpacity();
|
||||
storageMap.set('random_bg_opacity', { value: 0.35 });
|
||||
|
||||
|
|
@ -423,7 +423,7 @@ describe('dom and browser helpers', () => {
|
|||
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';
|
||||
storageMap.set('random_bg', { value: false });
|
||||
|
||||
|
|
@ -435,7 +435,7 @@ describe('dom and browser 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;
|
||||
fetchMock.mockResolvedValue(responseMock);
|
||||
|
||||
|
|
@ -452,7 +452,7 @@ describe('network and id helpers', () => {
|
|||
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 responseMock = { status: 200, json: jsonMock };
|
||||
fetchMock.mockResolvedValue(responseMock);
|
||||
|
|
@ -468,7 +468,7 @@ describe('network and id helpers', () => {
|
|||
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 responseMock = { status: 400, json: jsonMock };
|
||||
fetchMock.mockResolvedValue(responseMock);
|
||||
|
|
@ -479,13 +479,13 @@ describe('network and id helpers', () => {
|
|||
});
|
||||
|
||||
describe('async helpers', () => {
|
||||
it('awaiter resolves when test becomes truthy', async () => {
|
||||
it('resolve_truthy', async () => {
|
||||
const values = [false, false, 'done'];
|
||||
const result = await utils.awaiter(() => values.shift(), 500, 0.01);
|
||||
expect(result).toBe('done');
|
||||
});
|
||||
|
||||
it('awaiter returns false when timeout reached', async () => {
|
||||
it('timeout', async () => {
|
||||
const result = await utils.awaiter(() => false, 50, 0.01);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
} from '~/utils/sidebarSwipe';
|
||||
|
||||
describe('sidebarSwipe', () => {
|
||||
it('detects apple mobile webkit navigators', () => {
|
||||
it('detect_apple_touch', () => {
|
||||
expect(
|
||||
isAppleMobileTouchNavigator({
|
||||
userAgent:
|
||||
|
|
@ -38,7 +38,7 @@ describe('sidebarSwipe', () => {
|
|||
).toBe(false);
|
||||
});
|
||||
|
||||
it('reserves the ios navigation edge and starts the sidebar swipe just inside it', () => {
|
||||
it('reserve_ios_edge', () => {
|
||||
const nav = {
|
||||
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',
|
||||
|
|
@ -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 = {
|
||||
userAgent:
|
||||
'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);
|
||||
});
|
||||
|
||||
it('returns close while the sidebar is already open', () => {
|
||||
it('return_close', () => {
|
||||
expect(getSidebarSwipeMode(true, 4)).toBe('close');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ function normalize(filters: string[]): Set<string> {
|
|||
}
|
||||
|
||||
describe("MatchFilterParser", () => {
|
||||
it("handles simple AND", () => {
|
||||
it("handle_simple_and", () => {
|
||||
const parser = new MatchFilterParser("filesize>1000000 & duration<600");
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("handles OR", () => {
|
||||
it("handle_or", () => {
|
||||
const parser = new MatchFilterParser("uploader='BBC' || uploader='NHK'");
|
||||
|
||||
expect(parser.evaluate({ uploader: "BBC" })).toBe(true);
|
||||
|
|
@ -22,7 +22,7 @@ describe("MatchFilterParser", () => {
|
|||
expect(parser.evaluate({ uploader: "CNN" })).toBe(false);
|
||||
});
|
||||
|
||||
it("handles grouping", () => {
|
||||
it("handle_grouping", () => {
|
||||
const parser = new MatchFilterParser("(filesize>1000000 & duration<600) || uploader='BBC'");
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("handles unary presence", () => {
|
||||
it("handle_unary_presence", () => {
|
||||
expect(new MatchFilterParser("duration").evaluate({ duration: 100 })).toBe(true);
|
||||
expect(new MatchFilterParser("duration").evaluate({})).toBe(false);
|
||||
expect(new MatchFilterParser("!duration").evaluate({})).toBe(true);
|
||||
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: 200 })).toBe(false);
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ describe("MatchFilterParser", () => {
|
|||
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: 500_000 })).toBe(false);
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ describe("MatchFilterParser", () => {
|
|||
expect(new MatchFilterParser("filesize>=1073741824").evaluate({ filesize: 1000000 })).toBe(false);
|
||||
});
|
||||
|
||||
it("handles string operators", () => {
|
||||
it("handle_string_operators", () => {
|
||||
const d = { uploader: "BBC News Channel" };
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("handles spaces around operators", () => {
|
||||
it("handle_operator_spaces", () => {
|
||||
const d = {
|
||||
"channel_id": "UC-7oMv6E4Uz2tF51w5Sj49w",
|
||||
"uploader": "BBC"
|
||||
|
|
@ -85,7 +85,7 @@ describe("MatchFilterParser", () => {
|
|||
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 = {
|
||||
"age_limit": 0,
|
||||
"comment_count": 6,
|
||||
|
|
@ -102,7 +102,7 @@ describe("MatchFilterParser", () => {
|
|||
expect(new MatchFilterParser("channel_id = 'UC-7oMv6E4Uz2tF51w5Sj49w'").evaluate(testData)).toBe(true);
|
||||
});
|
||||
|
||||
it("tests OR operator precedence", () => {
|
||||
it("or_precedence", () => {
|
||||
const testData = {
|
||||
"age_limit": 0,
|
||||
"fps": 120,
|
||||
|
|
@ -134,7 +134,7 @@ describe("MatchFilterParser", () => {
|
|||
expect(new MatchFilterParser(expr2).evaluate(testDataEdge)).toBe(true);
|
||||
});
|
||||
|
||||
it("tests complex OR precedence scenarios", () => {
|
||||
it("handle_chained_or", () => {
|
||||
const testDataOrOnly = {
|
||||
"age_limit": 1,
|
||||
"fps": 60,
|
||||
|
|
@ -163,7 +163,7 @@ describe("MatchFilterParser", () => {
|
|||
expect(new MatchFilterParser(exprChain).evaluate(testDataOnlyOr)).toBe(true);
|
||||
});
|
||||
|
||||
it("exports filters correctly", () => {
|
||||
it("export_filters", () => {
|
||||
const simpleParser = new MatchFilterParser("filesize>1000000 & duration<600");
|
||||
const simpleExported = simpleParser.export();
|
||||
expect(simpleExported).toEqual(["filesize>1000000&duration<600"]);
|
||||
|
|
@ -177,12 +177,6 @@ describe("MatchFilterParser", () => {
|
|||
expect(normalize(complexExported)).toEqual(normalize(["filesize>1000000&duration<600", "uploader='BBC'"]));
|
||||
|
||||
const testData = { filesize: 2000000, duration: 300 };
|
||||
for (const filter of complexExported) {
|
||||
const filterParser = new MatchFilterParser(filter);
|
||||
if (filterParser.evaluate(testData)) {
|
||||
expect(true).toBe(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(complexExported.some((filter) => new MatchFilterParser(filter).evaluate(testData))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue