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

Refactor: stagger tasks creation for playlist processing
This commit is contained in:
Abdulmohsen 2025-12-30 20:50:44 +03:00 committed by GitHub
commit 18673f0a55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 330 additions and 567 deletions

View file

@ -75,6 +75,10 @@ jobs:
working-directory: ui
run: pnpm run typecheck
- name: Run frontend tsc
working-directory: ui
run: pnpm run lint:tsc
- name: Run frontend tests
working-directory: ui
run: pnpm run test:ci

View file

@ -109,6 +109,10 @@ jobs:
working-directory: ui
run: pnpm run typecheck
- name: Run frontend tsc
working-directory: ui
run: pnpm run lint:tsc
- name: Run frontend tests
working-directory: ui
run: pnpm run test:ci

View file

@ -322,8 +322,6 @@ class DownloadQueue(metaclass=Singleton):
playlistCount = entry.get("playlist_count")
playlistCount: int = int(playlistCount) if playlistCount else len(entries)
results = []
playlist_keys: dict[str, Any] = {
"playlist_count": playlistCount,
"playlist": entry.get("title") or entry.get("id"),
@ -339,18 +337,16 @@ class DownloadQueue(metaclass=Singleton):
}
async def playlist_processor(i: int, etr: dict):
acquired = False
try:
item_name: str = (
f"'{entry.get('title')}: {i}/{playlist_keys['n_entries']}' - '{etr.get('id')}: {etr.get('title')}'"
)
LOG.debug(f"Waiting to acquire lock for {item_name}")
await self.processors.acquire()
acquired = True
LOG.debug(f"Acquired lock for {item_name}")
if self.is_paused():
LOG.warning(f"Download is paused. Skipping processing of '{item_name}'.")
return {"status": "ok"}
LOG.info(f"Processing '{item_name}'.")
_status, _msg = ytdlp_reject(entry=etr, yt_params=yt_params)
@ -382,26 +378,44 @@ class DownloadQueue(metaclass=Singleton):
return await self.add(item=newItem, already=already)
finally:
self.processors.release()
if acquired:
self.processors.release()
max_downloads: int = -1
ytdlp_opts: dict[str, Any] = item.get_ytdlp_opts().get_all()
if ytdlp_opts.get("max_downloads") and isinstance(ytdlp_opts.get("max_downloads"), int):
max_downloads: int = ytdlp_opts.get("max_downloads")
tasks: list[asyncio.Task] = []
batch_size: int = max(50, int(self.config.playlist_items_concurrency) * 10)
async def run_batch(batch: list[tuple[int, dict]]) -> list[dict]:
tasks: list[asyncio.Task] = []
for i, etr in batch:
task = asyncio.create_task(
playlist_processor(i, etr),
name=f"playlist_processor_{etr.get('id')}_{i}",
)
task.add_done_callback(self._handle_task_exception)
tasks.append(task)
batch_results: list[dict] = await asyncio.gather(*tasks)
await asyncio.sleep(0)
return batch_results
results: list[dict] = []
batch: list[tuple[int, dict]] = []
for i, etr in enumerate(entries, start=1):
if max_downloads > 0 and i > max_downloads:
break
task = asyncio.create_task(
playlist_processor(i, etr),
name=f"playlist_processor_{etr.get('id')}_{i}",
)
task.add_done_callback(self._handle_task_exception)
tasks.append(task)
batch.append((i, etr))
if len(batch) >= batch_size:
results.extend(await run_batch(batch))
batch.clear()
results: list[dict] = await asyncio.gather(*tasks)
if batch:
results.extend(await run_batch(batch))
log_msg: str = f"Playlist '{playlist_name}' processing completed with '{len(results)}' entries."
if max_downloads > 0 and len(entries) > max_downloads:

View file

@ -14,7 +14,7 @@ from datetime import UTC, datetime, timedelta
from functools import lru_cache, wraps
from http.cookiejar import MozillaCookieJar
from pathlib import Path
from typing import Any, TypeVar
from typing import Any
from Crypto.Cipher import AES
from yt_dlp.utils import age_restricted
@ -85,9 +85,6 @@ TAG_REGEX: re.Pattern[str] = re.compile(r"%{([^:}]+)(?::([^}]*))?}c")
DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
"Regex to match ISO 8601 datetime strings."
T = TypeVar("T")
"Generic type variable."
class StreamingError(Exception):
"""Raised when an error occurs during streaming."""
@ -1543,7 +1540,7 @@ def delete_dir(dir: Path) -> bool:
return False
def init_class(cls: type[T], data: dict, _fields=None) -> T:
def init_class[T](cls: type[T], data: dict, _fields=None) -> T:
"""
Initialize a class instance with data from a dictionary, filtering out keys not present in the class fields.

View file

@ -237,7 +237,7 @@ class FFProbeResult:
@timed_lru_cache(ttl_seconds=300, max_size=128)
async def ffprobe(file: Path|str) -> FFProbeResult:
async def ffprobe(file: Path | str) -> FFProbeResult:
"""
Run ffprobe on a file and return the parsed data as a dictionary.

View file

@ -1,4 +1,9 @@
# flake8: noqa: S608
"""
This module is a fork of the Caribou library
(https://pypi.org/project/caribou/) modified to work for our specific use case.
"""
import contextlib
import datetime
import glob
@ -18,6 +23,7 @@ UTC_LENGTH = 14
__version__ = "1.0.0"
class Error(Exception):
"""Base class for all Caribou errors."""

View file

@ -272,4 +272,3 @@ def find_all(items: list[dict], **conditions) -> list[dict]:
"""
return filter_items(items, **conditions)

View file

@ -33,6 +33,7 @@ if TYPE_CHECKING:
LOG: logging.Logger = logging.getLogger(__name__)
CACHE: Cache = Cache()
@dataclass(slots=True)
class MatchRule:
"""Represents a single URL matcher compiled to regex."""
@ -720,12 +721,16 @@ class GenericTaskHandler(BaseHandler):
)
if not info:
LOG.error(f"[{definition.name}]: '{task.name}': Failed to extract info for URL '{url}' to generate archive ID. Skipping.")
LOG.error(
f"[{definition.name}]: '{task.name}': Failed to extract info for URL '{url}' to generate archive ID. Skipping."
)
CACHE.set(cache_key, None)
continue
if not info.get("id") or not info.get("extractor_key"):
LOG.error(f"[{definition.name}]: '{task.name}': Incomplete info extracted for URL '{url}' to generate archive ID. Skipping.")
LOG.error(
f"[{definition.name}]: '{task.name}': Incomplete info extracted for URL '{url}' to generate archive ID. Skipping."
)
CACHE.set(cache_key, None)
continue

View file

@ -96,7 +96,7 @@ class TwitchHandler(BaseHandler):
archive_id: str = entry.get("archive_id")
task_items.append(TaskItem(url=url, title=entry.get("title"), archive_id=archive_id))
return TaskResult( items=task_items, metadata={ "feed_url": feed_url, "has_entries": has_items } )
return TaskResult(items=task_items, metadata={"feed_url": feed_url, "has_entries": has_items})
@staticmethod
def parse(url: str) -> str | None:

View file

@ -5,6 +5,7 @@ Migration Name: add_status_index
Migration Version: 20251116173731
"""
async def upgrade(connection):
"""
Add index on json_extract(data, '$.status') for better query performance.
@ -17,6 +18,7 @@ async def upgrade(connection):
"""
await connection.execute(sql)
async def downgrade(connection):
"""
Remove the status index.

View file

@ -27,9 +27,7 @@ async def add_url(queue: DownloadQueue, notify: EventBus, sid: str, data: dict):
)
except ValueError as e:
LOG.exception(e)
notify.emit(
Events.LOG_ERROR, data={"preset": item.preset}, title="Error Adding URL", message=str(e), to=sid
)
notify.emit(Events.LOG_ERROR, data={"preset": item.preset}, title="Error Adding URL", message=str(e), to=sid)
@route(RouteType.SOCKET, "item_cancel", "item_cancel")

View file

@ -172,6 +172,7 @@ class TestCache:
def test_async_set(self):
"""Test async set method using asyncio.run."""
async def async_test():
await self.cache.aset("async_key", "async_value")
assert self.cache.get("async_key") == "async_value"
@ -180,6 +181,7 @@ class TestCache:
def test_async_set_with_ttl(self):
"""Test async set with TTL using asyncio.run."""
async def async_test():
await self.cache.aset("async_temp", "async_value", ttl=0.1)
assert self.cache.get("async_temp") == "async_value"

View file

@ -48,11 +48,7 @@ class TestCondition:
extras = {"key": "value", "number": 42}
condition = Condition(
id=test_id,
name="full_test",
filter="uploader = 'test'",
cli="--format best",
extras=extras
id=test_id, name="full_test", filter="uploader = 'test'", cli="--format best", extras=extras
)
assert condition.id == test_id
@ -64,10 +60,7 @@ class TestCondition:
def test_condition_serialize(self):
"""Test condition serialization to dict."""
condition = Condition(
name="serialize_test",
filter="title ~= 'test'",
cli="--audio-quality 0",
extras={"tag": "music"}
name="serialize_test", filter="title ~= 'test'", cli="--audio-quality 0", extras={"tag": "music"}
)
serialized = condition.serialize()
@ -93,11 +86,7 @@ class TestCondition:
def test_condition_get_method(self):
"""Test condition get method for accessing fields."""
condition = Condition(
name="get_test",
filter="view_count > 1000",
extras={"category": "popular"}
)
condition = Condition(name="get_test", filter="view_count > 1000", extras={"category": "popular"})
assert condition.get("name") == "get_test"
assert condition.get("filter") == "view_count > 1000"
@ -165,7 +154,7 @@ class TestConditions:
# Add some test conditions
conditions._items = [
Condition(name="test1", filter="duration > 60"),
Condition(name="test2", filter="uploader = 'test'")
Condition(name="test2", filter="uploader = 'test'"),
]
result = conditions.clear()
@ -219,15 +208,15 @@ class TestConditions:
"name": "short_videos",
"filter": "duration < 300",
"cli": "--format worst",
"extras": {"category": "short"}
"extras": {"category": "short"},
},
{
"id": str(uuid.uuid4()),
"name": "music_videos",
"filter": "title ~= 'music'",
"cli": "--audio-quality 0",
"extras": {"type": "audio"}
}
"extras": {"type": "audio"},
},
]
file_path.write_text(json.dumps(test_data, indent=4))
@ -254,12 +243,7 @@ class TestConditions:
file_path = Path(temp_dir) / "no_id_conditions.json"
# Create test data without ID
test_data = [
{
"name": "no_id_test",
"filter": "duration > 120"
}
]
test_data = [{"name": "no_id_test", "filter": "duration > 120"}]
file_path.write_text(json.dumps(test_data))
@ -280,13 +264,7 @@ class TestConditions:
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "no_extras_conditions.json"
test_data = [
{
"id": str(uuid.uuid4()),
"name": "no_extras_test",
"filter": "uploader = 'test'"
}
]
test_data = [{"id": str(uuid.uuid4()), "name": "no_extras_test", "filter": "uploader = 'test'"}]
file_path.write_text(json.dumps(test_data))
@ -321,7 +299,7 @@ class TestConditions:
# Missing required fields
test_data = [
{"id": "valid", "name": "valid", "filter": "duration > 60"},
{"invalid": "data"} # Missing required fields
{"invalid": "data"}, # Missing required fields
]
file_path.write_text(json.dumps(test_data))
@ -345,7 +323,7 @@ class TestConditions:
"name": "valid_test",
"filter": "duration > 60",
"cli": "--format best",
"extras": {"key": "value"}
"extras": {"key": "value"},
}
result = conditions.validate(valid_condition)
@ -357,10 +335,7 @@ class TestConditions:
file_path = Path(temp_dir) / "validate_obj_test.json"
conditions = Conditions(file=file_path)
valid_condition = Condition(
name="valid_obj_test",
filter="uploader = 'test'"
)
valid_condition = Condition(name="valid_obj_test", filter="uploader = 'test'")
result = conditions.validate(valid_condition)
assert result is True
@ -371,10 +346,7 @@ class TestConditions:
file_path = Path(temp_dir) / "no_id_validate.json"
conditions = Conditions(file=file_path)
invalid_condition = {
"name": "no_id_test",
"filter": "duration > 60"
}
invalid_condition = {"name": "no_id_test", "filter": "duration > 60"}
with pytest.raises(ValueError, match="No id found"):
conditions.validate(invalid_condition)
@ -385,10 +357,7 @@ class TestConditions:
file_path = Path(temp_dir) / "no_name_validate.json"
conditions = Conditions(file=file_path)
invalid_condition = {
"id": str(uuid.uuid4()),
"filter": "duration > 60"
}
invalid_condition = {"id": str(uuid.uuid4()), "filter": "duration > 60"}
with pytest.raises(ValueError, match="No name found"):
conditions.validate(invalid_condition)
@ -399,10 +368,7 @@ class TestConditions:
file_path = Path(temp_dir) / "no_filter_validate.json"
conditions = Conditions(file=file_path)
invalid_condition = {
"id": str(uuid.uuid4()),
"name": "no_filter_test"
}
invalid_condition = {"id": str(uuid.uuid4()), "name": "no_filter_test"}
with pytest.raises(ValueError, match="No filter found"):
conditions.validate(invalid_condition)
@ -419,7 +385,7 @@ class TestConditions:
"name": "invalid_filter_test",
"filter": "duration > & < 60", # Invalid syntax with consecutive operators
"cli": "",
"extras": {}
"extras": {},
}
with pytest.raises(ValueError, match="Invalid filter"):
@ -435,7 +401,7 @@ class TestConditions:
"id": str(uuid.uuid4()),
"name": "invalid_cli_test",
"filter": "duration > 60",
"cli": "--invalid-option-that-does-not-exist"
"cli": "--invalid-option-that-does-not-exist",
}
with pytest.raises(ValueError, match="Invalid command options"):
@ -451,7 +417,7 @@ class TestConditions:
"id": str(uuid.uuid4()),
"name": "invalid_extras_test",
"filter": "duration > 60",
"extras": "not a dict"
"extras": "not a dict",
}
with pytest.raises(ValueError, match="Extras must be a dictionary"):
@ -474,7 +440,7 @@ class TestConditions:
test_conditions = [
Condition(name="save_test1", filter="duration > 60"),
Condition(name="save_test2", filter="uploader = 'test'")
Condition(name="save_test2", filter="uploader = 'test'"),
]
result = conditions.save(test_conditions)
@ -495,13 +461,7 @@ class TestConditions:
conditions = Conditions(file=file_path)
test_conditions = [
{
"id": str(uuid.uuid4()),
"name": "dict_test",
"filter": "duration < 300",
"cli": "",
"extras": {}
}
{"id": str(uuid.uuid4()), "name": "dict_test", "filter": "duration < 300", "cli": "", "extras": {}}
]
conditions.save(test_conditions)

View file

@ -77,6 +77,7 @@ class TestDownloadHooks:
return Cfg
monkeypatch.setattr("app.library.Download.Config", Cfg)
# EventBus.get_instance is used during __init__ and start, we don't hit start here
class EB:
@staticmethod
@ -113,7 +114,17 @@ class TestDownloadHooks:
assert ev["action"] == "progress"
# ensure only whitelisted keys included
assert "other" not in ev
for k in ("tmpfilename", "filename", "status", "msg", "total_bytes", "total_bytes_estimate", "downloaded_bytes", "speed", "eta"):
for k in (
"tmpfilename",
"filename",
"status",
"msg",
"total_bytes",
"total_bytes_estimate",
"downloaded_bytes",
"speed",
"eta",
):
assert k in ev
def test_postprocessor_hook_movefiles_sets_final_name(self, tmp_path: Path) -> None:

View file

@ -54,6 +54,7 @@ class TestEncoder:
def test_object_with_serialize_method(self):
"""Test that objects with serialize method use it."""
class CustomObject:
def serialize(self):
return {"custom": "data", "type": "test"}
@ -64,6 +65,7 @@ class TestEncoder:
def test_object_with_dict_fallback(self):
"""Test that objects without serialize method fall back to __dict__."""
class SimpleObject:
def __init__(self):
self.name = "test"
@ -81,12 +83,7 @@ class TestEncoder:
def test_json_dumps_integration(self):
"""Test full JSON serialization with various types."""
data = {
"path": Path("/tmp/test.txt"),
"date": date(2024, 1, 1),
"number": 42,
"string": "test"
}
data = {"path": Path("/tmp/test.txt"), "date": date(2024, 1, 1), "number": 42, "string": "test"}
result = json.dumps(data, cls=Encoder)
parsed = json.loads(result)
@ -98,15 +95,13 @@ class TestEncoder:
def test_json_dumps_with_custom_object(self):
"""Test JSON serialization with custom objects."""
class TestObject:
def __init__(self):
self.name = "test"
self.items = [1, 2, 3]
data = {
"object": TestObject(),
"regular": "data"
}
data = {"object": TestObject(), "regular": "data"}
result = json.dumps(data, cls=Encoder)
parsed = json.loads(result)
@ -117,6 +112,7 @@ class TestEncoder:
def test_nested_serialization(self):
"""Test serialization of nested structures with various types."""
class CustomObj:
def serialize(self):
return {"serialized": True}
@ -125,10 +121,7 @@ class TestEncoder:
"paths": [Path("/tmp/1.txt"), Path("/tmp/2.txt")],
"dates": [date(2024, 1, 1), date(2024, 12, 31)],
"custom": CustomObj(),
"nested": {
"path": Path("/nested/path"),
"date": date(2024, 6, 15)
}
"nested": {"path": Path("/nested/path"), "date": date(2024, 6, 15)},
}
result = json.dumps(data, cls=Encoder)
@ -149,6 +142,7 @@ class TestEncoder:
# Mock isinstance to return True for DateRange
import builtins
original_isinstance = builtins.isinstance
def mock_isinstance(obj, cls):

View file

@ -17,6 +17,7 @@ class TestFFProbe:
def teardown_method(self):
"""Clean up test files."""
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
@pytest.mark.asyncio
@ -64,6 +65,7 @@ class TestFFProbe:
# Mock subprocess to avoid actual ffprobe execution
call_count = 0
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
def create_mock_process(*_args, **_kwargs):
nonlocal call_count
call_count += 1
@ -130,20 +132,11 @@ class TestFFProbe:
assert result.metadata == {}
# Test adding streams
video_stream = FFStream({
"index": 0,
"codec_type": "video",
"codec_name": "h264",
"width": 1920,
"height": 1080
})
video_stream = FFStream(
{"index": 0, "codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080}
)
audio_stream = FFStream({
"index": 1,
"codec_type": "audio",
"codec_name": "aac",
"channels": 2
})
audio_stream = FFStream({"index": 1, "codec_type": "audio", "codec_name": "aac", "channels": 2})
result.video.append(video_stream)
result.audio.append(audio_stream)
@ -158,13 +151,9 @@ class TestFFProbe:
from app.library.ffprobe import FFStream
# Test video stream
video_stream = FFStream({
"codec_type": "video",
"codec_name": "h264",
"width": 1920,
"height": 1080,
"duration": "10.5"
})
video_stream = FFStream(
{"codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080, "duration": "10.5"}
)
assert video_stream.is_video()
assert not video_stream.is_audio()
@ -172,12 +161,7 @@ class TestFFProbe:
assert not video_stream.is_attachment()
# Test audio stream
audio_stream = FFStream({
"codec_type": "audio",
"codec_name": "aac",
"channels": 2,
"sample_rate": "44100"
})
audio_stream = FFStream({"codec_type": "audio", "codec_name": "aac", "channels": 2, "sample_rate": "44100"})
assert not audio_stream.is_video()
assert audio_stream.is_audio()
@ -185,10 +169,7 @@ class TestFFProbe:
assert not audio_stream.is_attachment()
# Test subtitle stream
subtitle_stream = FFStream({
"codec_type": "subtitle",
"codec_name": "subrip"
})
subtitle_stream = FFStream({"codec_type": "subtitle", "codec_name": "subrip"})
assert not subtitle_stream.is_video()
assert not subtitle_stream.is_audio()
@ -196,10 +177,7 @@ class TestFFProbe:
assert not subtitle_stream.is_attachment()
# Test attachment stream
attachment_stream = FFStream({
"codec_type": "attachment",
"codec_name": "ttf"
})
attachment_stream = FFStream({"codec_type": "attachment", "codec_name": "ttf"})
assert not attachment_stream.is_video()
assert not attachment_stream.is_audio()

View file

@ -252,7 +252,9 @@ class TestItemDTO:
}
with (
patch("app.library.ItemDTO.ItemDTO.get_file", autospec=True, return_value=Path("/downloads/video.mp4")) as mock_get_file,
patch(
"app.library.ItemDTO.ItemDTO.get_file", autospec=True, return_value=Path("/downloads/video.mp4")
) as mock_get_file,
patch("app.library.ItemDTO.get_file_sidecar", return_value=expected_sidecar) as mock_utils_sidecar,
):
result = dto.get_file_sidecar()

View file

@ -63,11 +63,7 @@ class TestTargetRequest:
def test_target_request_creation_defaults(self):
"""Test creating a TargetRequest with defaults."""
request = TargetRequest(
type="json",
method="POST",
url="https://example.com/webhook"
)
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
assert request.type == "json"
assert request.method == "POST"
@ -79,14 +75,10 @@ class TestTargetRequest:
"""Test creating a TargetRequest with headers."""
headers = [
TargetRequestHeader(key="Authorization", value="Bearer token"),
TargetRequestHeader(key="Content-Type", value="application/json")
TargetRequestHeader(key="Content-Type", value="application/json"),
]
request = TargetRequest(
type="json",
method="POST",
url="https://example.com/webhook",
headers=headers,
data_key="payload"
type="json", method="POST", url="https://example.com/webhook", headers=headers, data_key="payload"
)
assert len(request.headers) == 2
@ -97,11 +89,7 @@ class TestTargetRequest:
"""Test serializing a TargetRequest."""
headers = [TargetRequestHeader(key="X-Token", value="abc123")]
request = TargetRequest(
type="json",
method="PUT",
url="https://api.example.com/notify",
headers=headers,
data_key="content"
type="json", method="PUT", url="https://api.example.com/notify", headers=headers, data_key="content"
)
serialized = request.serialize()
@ -110,17 +98,13 @@ class TestTargetRequest:
"method": "PUT",
"url": "https://api.example.com/notify",
"data_key": "content",
"headers": [{"key": "X-Token", "value": "abc123"}]
"headers": [{"key": "X-Token", "value": "abc123"}],
}
assert serialized == expected
def test_target_request_json(self):
"""Test JSON serialization of TargetRequest."""
request = TargetRequest(
type="form",
method="POST",
url="https://webhook.site/test"
)
request = TargetRequest(type="form", method="POST", url="https://webhook.site/test")
json_str = request.json()
parsed = json.loads(json_str)
@ -130,11 +114,7 @@ class TestTargetRequest:
def test_target_request_get(self):
"""Test get method of TargetRequest."""
request = TargetRequest(
type="json",
method="POST",
url="https://example.com/webhook"
)
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
assert request.get("type") == "json"
assert request.get("method") == "POST"
@ -146,16 +126,8 @@ class TestTarget:
def test_target_creation_minimal(self):
"""Test creating a Target with minimal required fields."""
request = TargetRequest(
type="json",
method="POST",
url="https://example.com/webhook"
)
target = Target(
id=str(uuid.uuid4()),
name="Test Webhook",
request=request
)
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
target = Target(id=str(uuid.uuid4()), name="Test Webhook", request=request)
assert target.name == "Test Webhook"
assert target.on == []
@ -165,17 +137,13 @@ class TestTarget:
def test_target_creation_full(self):
"""Test creating a Target with all fields."""
target_id = str(uuid.uuid4())
request = TargetRequest(
type="json",
method="POST",
url="https://example.com/webhook"
)
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
target = Target(
id=target_id,
name="Full Test Webhook",
on=["item_completed", "item_failed"],
presets=["default", "audio_only"],
request=request
request=request,
)
assert target.id == target_id
@ -186,18 +154,8 @@ class TestTarget:
def test_target_serialize(self):
"""Test serializing a Target."""
target_id = str(uuid.uuid4())
request = TargetRequest(
type="json",
method="POST",
url="https://example.com/webhook"
)
target = Target(
id=target_id,
name="Test Target",
on=["item_completed"],
presets=["default"],
request=request
)
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
target = Target(id=target_id, name="Test Target", on=["item_completed"], presets=["default"], request=request)
serialized = target.serialize()
assert serialized["id"] == target_id
@ -208,16 +166,8 @@ class TestTarget:
def test_target_json(self):
"""Test JSON serialization of Target."""
request = TargetRequest(
type="json",
method="POST",
url="https://example.com/webhook"
)
target = Target(
id=str(uuid.uuid4()),
name="JSON Test",
request=request
)
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
target = Target(id=str(uuid.uuid4()), name="JSON Test", request=request)
json_str = target.json()
parsed = json.loads(json_str)
@ -225,16 +175,8 @@ class TestTarget:
def test_target_get(self):
"""Test get method of Target."""
request = TargetRequest(
type="json",
method="POST",
url="https://example.com/webhook"
)
target = Target(
id=str(uuid.uuid4()),
name="Get Test",
request=request
)
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
target = Target(id=str(uuid.uuid4()), name="Get Test", request=request)
assert target.get("name") == "Get Test"
assert target.get("nonexistent", "default") == "default"
@ -299,11 +241,7 @@ class TestNotification:
@patch("app.library.Notifications.BackgroundWorker")
def test_notification_singleton(self, mock_background_worker, mock_config):
"""Test that Notification follows singleton pattern."""
mock_config.get_instance.return_value = Mock(
debug=False,
config_path="/tmp",
app_version="1.0.0"
)
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
mock_background_worker.get_instance.return_value = Mock()
instance1 = Notification.get_instance()
@ -316,11 +254,7 @@ class TestNotification:
@patch("app.library.Notifications.BackgroundWorker")
def test_notification_init_default_params(self, mock_background_worker, mock_config):
"""Test Notification initialization with default parameters."""
mock_config_instance = Mock(
debug=False,
config_path="/tmp/test",
app_version="1.0.0"
)
mock_config_instance = Mock(debug=False, config_path="/tmp/test", app_version="1.0.0")
mock_config.get_instance.return_value = mock_config_instance
mock_background_worker.get_instance.return_value = Mock()
@ -337,11 +271,7 @@ class TestNotification:
def test_notification_init_custom_params(self, mock_background_worker, mock_config):
"""Test Notification initialization with custom parameters."""
_ = mock_background_worker, mock_config # Suppress unused variable warnings
mock_config_instance = Mock(
debug=True,
config_path="/custom/path",
app_version="2.0.0"
)
mock_config_instance = Mock(debug=True, config_path="/custom/path", app_version="2.0.0")
mock_client = Mock(spec=httpx.AsyncClient)
mock_encoder = Mock(spec=Encoder)
mock_worker = Mock(spec=BackgroundWorker)
@ -354,7 +284,7 @@ class TestNotification:
client=mock_client,
encoder=mock_encoder,
config=mock_config_instance,
background_worker=mock_worker
background_worker=mock_worker,
)
assert notification._debug is True
@ -365,12 +295,11 @@ class TestNotification:
def test_get_targets_empty(self):
"""Test get_targets when no targets are loaded."""
with patch("app.library.Notifications.Config") as mock_config, \
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0"
)
with (
patch("app.library.Notifications.Config") as mock_config,
patch("app.library.Notifications.BackgroundWorker") as mock_worker,
):
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
mock_worker.get_instance.return_value = Mock()
notification = Notification.get_instance()
@ -381,12 +310,11 @@ class TestNotification:
def test_clear_targets(self):
"""Test clearing notification targets."""
with patch("app.library.Notifications.Config") as mock_config, \
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0"
)
with (
patch("app.library.Notifications.Config") as mock_config,
patch("app.library.Notifications.BackgroundWorker") as mock_worker,
):
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
mock_worker.get_instance.return_value = Mock()
notification = Notification.get_instance()
@ -405,25 +333,15 @@ class TestNotification:
@patch("app.library.Notifications.BackgroundWorker")
def test_save_targets(self, mock_worker, mock_config):
"""Test saving targets to file."""
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0"
)
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
mock_worker.get_instance.return_value = Mock()
with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file:
temp_path = temp_file.name
# Create a test target
request = TargetRequest(
type="json",
method="POST",
url="https://example.com/webhook"
)
target = Target(
id=str(uuid.uuid4()),
name="Test Target",
request=request
)
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
target = Target(id=str(uuid.uuid4()), name="Test Target", request=request)
notification = Notification.get_instance(file=temp_path)
result = notification.save([target])
@ -445,9 +363,7 @@ class TestNotification:
@patch("app.library.Notifications.BackgroundWorker")
def test_load_targets_empty_file(self, mock_worker, mock_config):
"""Test loading targets from empty file."""
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0"
)
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
mock_worker.get_instance.return_value = Mock()
with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file:
@ -468,9 +384,7 @@ class TestNotification:
@patch("app.library.Notifications.Presets")
def test_load_targets_valid_file(self, mock_presets, mock_worker, mock_config):
"""Test loading targets from valid file."""
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0"
)
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
mock_worker.get_instance.return_value = Mock()
# Mock preset with name attribute
@ -478,19 +392,21 @@ class TestNotification:
mock_preset.name = "default"
mock_presets.get_instance.return_value.get_all.return_value = [mock_preset]
target_data = [{
"id": str(uuid.uuid4()),
"name": "Test Webhook",
"on": ["item_completed"],
"presets": ["default"],
"request": {
"type": "json",
"method": "POST",
"url": "https://example.com/webhook",
"data_key": "data",
"headers": []
target_data = [
{
"id": str(uuid.uuid4()),
"name": "Test Webhook",
"on": ["item_completed"],
"presets": ["default"],
"request": {
"type": "json",
"method": "POST",
"url": "https://example.com/webhook",
"data_key": "data",
"headers": [],
},
}
}]
]
with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file:
json.dump(target_data, temp_file)
@ -508,12 +424,11 @@ class TestNotification:
def test_make_target(self):
"""Test make_target method."""
with patch("app.library.Notifications.Config") as mock_config, \
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0"
)
with (
patch("app.library.Notifications.Config") as mock_config,
patch("app.library.Notifications.BackgroundWorker") as mock_worker,
):
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
mock_worker.get_instance.return_value = Mock()
notification = Notification.get_instance()
@ -528,8 +443,8 @@ class TestNotification:
"method": "POST",
"url": "https://example.com/webhook",
"data_key": "payload",
"headers": [{"key": "Authorization", "value": "Bearer token"}]
}
"headers": [{"key": "Authorization", "value": "Bearer token"}],
},
}
target = notification.make_target(target_dict)
@ -548,14 +463,13 @@ class TestNotification:
target_dict = {
"id": str(uuid.uuid4()),
"name": "Valid Target",
"request": {
"url": "https://example.com/webhook"
}
"request": {"url": "https://example.com/webhook"},
}
with patch("app.library.Notifications.NotificationEvents.get_events") as mock_events, \
patch("app.library.Notifications.Presets") as mock_presets:
with (
patch("app.library.Notifications.NotificationEvents.get_events") as mock_events,
patch("app.library.Notifications.Presets") as mock_presets,
):
mock_events.return_value.values.return_value = ["item_completed"]
mock_presets.get_instance.return_value.get_all.return_value = []
@ -564,12 +478,7 @@ class TestNotification:
def test_validate_target_missing_id(self):
"""Test validate method with missing ID."""
target_dict = {
"name": "Missing ID Target",
"request": {
"url": "https://example.com/webhook"
}
}
target_dict = {"name": "Missing ID Target", "request": {"url": "https://example.com/webhook"}}
with pytest.raises(ValueError, match=r"Invalid notification target\. No ID found\."):
Notification.validate(target_dict)
@ -579,9 +488,7 @@ class TestNotification:
target_dict = {
"id": "invalid-uuid",
"name": "Invalid ID Target",
"request": {
"url": "https://example.com/webhook"
}
"request": {"url": "https://example.com/webhook"},
}
with pytest.raises(ValueError, match=r"Invalid notification target\. No ID found\."):
@ -589,33 +496,21 @@ class TestNotification:
def test_validate_target_missing_name(self):
"""Test validate method with missing name."""
target_dict = {
"id": str(uuid.uuid4()),
"request": {
"url": "https://example.com/webhook"
}
}
target_dict = {"id": str(uuid.uuid4()), "request": {"url": "https://example.com/webhook"}}
with pytest.raises(ValueError, match=r"Invalid notification target\. No name found\."):
Notification.validate(target_dict)
def test_validate_target_missing_request(self):
"""Test validate method with missing request."""
target_dict = {
"id": str(uuid.uuid4()),
"name": "Missing Request Target"
}
target_dict = {"id": str(uuid.uuid4()), "name": "Missing Request Target"}
with pytest.raises(ValueError, match=r"Invalid notification target\. No request details found\."):
Notification.validate(target_dict)
def test_validate_target_missing_url(self):
"""Test validate method with missing URL."""
target_dict = {
"id": str(uuid.uuid4()),
"name": "Missing URL Target",
"request": {}
}
target_dict = {"id": str(uuid.uuid4()), "name": "Missing URL Target", "request": {}}
with pytest.raises(ValueError, match=r"Invalid notification target\. No URL found\."):
Notification.validate(target_dict)
@ -627,8 +522,8 @@ class TestNotification:
"name": "Invalid Method Target",
"request": {
"url": "https://example.com/webhook",
"method": "GET" # Only POST and PUT are allowed
}
"method": "GET", # Only POST and PUT are allowed
},
}
with pytest.raises(ValueError, match=r"Invalid notification target\. Invalid method found\."):
@ -641,8 +536,8 @@ class TestNotification:
"name": "Invalid Type Target",
"request": {
"url": "https://example.com/webhook",
"type": "xml" # Only json and form are allowed
}
"type": "xml", # Only json and form are allowed
},
}
with pytest.raises(ValueError, match=r"Invalid notification target\. Invalid type found\."):
@ -653,9 +548,7 @@ class TestNotification:
@patch("app.library.Notifications.EventBus")
def test_attach(self, mock_eventbus, mock_worker, mock_config):
"""Test attach method."""
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0"
)
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
mock_worker.get_instance.return_value = Mock()
mock_eventbus_instance = Mock()
mock_eventbus.get_instance.return_value = mock_eventbus_instance
@ -674,16 +567,11 @@ class TestNotification:
@patch("app.library.Notifications.BackgroundWorker")
async def test_send_no_targets(self, mock_worker, mock_config):
"""Test send method with no targets."""
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0"
)
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
mock_worker.get_instance.return_value = Mock()
notification = Notification.get_instance()
event = Event(
event="test",
data={"test": "data"}
)
event = Event(event="test", data={"test": "data"})
result = await notification.send(event)
assert result == []
@ -693,28 +581,18 @@ class TestNotification:
@patch("app.library.Notifications.BackgroundWorker")
async def test_send_invalid_event_data(self, mock_worker, mock_config):
"""Test send method with invalid event data."""
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0"
)
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
mock_worker.get_instance.return_value = Mock()
notification = Notification.get_instance()
# Add a target
request = TargetRequest(
type="json",
method="POST",
url="https://example.com/webhook"
)
target = Target(
id=str(uuid.uuid4()),
name="Test Target",
request=request
)
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
target = Target(id=str(uuid.uuid4()), name="Test Target", request=request)
notification._targets = [target]
event = Event(
event="test",
data="invalid_string_data" # Should be ItemDTO or dict
data="invalid_string_data", # Should be ItemDTO or dict
)
result = await notification.send(event)
@ -725,9 +603,7 @@ class TestNotification:
@patch("app.library.Notifications.BackgroundWorker")
async def test_send_with_http_target(self, mock_worker, mock_config):
"""Test send method with HTTP target."""
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0"
)
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
mock_worker.get_instance.return_value = Mock()
# Mock HTTP client
@ -740,29 +616,15 @@ class TestNotification:
notification = Notification.get_instance(client=mock_client)
# Add HTTP target
request = TargetRequest(
type="json",
method="POST",
url="https://example.com/webhook"
)
target = Target(
id=str(uuid.uuid4()),
name="Test HTTP Target",
request=request
)
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
target = Target(id=str(uuid.uuid4()), name="Test HTTP Target", request=request)
notification._targets = [target]
# Create test event
item_dto = ItemDTO(
id="test_id",
url="https://youtube.com/watch?v=test",
title="Test Video",
folder="/downloads"
)
event = Event(
event="item_completed",
data=item_dto
id="test_id", url="https://youtube.com/watch?v=test", title="Test Video", folder="/downloads"
)
event = Event(event="item_completed", data=item_dto)
result = await notification.send(event)
@ -777,35 +639,21 @@ class TestNotification:
async def test_send_with_apprise_target(self, mock_worker, mock_config):
"""Test send method with Apprise target."""
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0",
apprise_config="/tmp/apprise.conf"
debug=False, config_path="/tmp", app_version="1.0.0", apprise_config="/tmp/apprise.conf"
)
mock_worker.get_instance.return_value = Mock()
notification = Notification.get_instance()
# Add Apprise target (non-HTTP URL)
request = TargetRequest(
type="json",
method="POST",
url="discord://webhook_id/webhook_token"
)
target = Target(
id=str(uuid.uuid4()),
name="Test Discord Target",
request=request
)
request = TargetRequest(type="json", method="POST", url="discord://webhook_id/webhook_token")
target = Target(id=str(uuid.uuid4()), name="Test Discord Target", request=request)
notification._targets = [target]
# Create test event
event = Event(
event="item_completed",
data={"test": "data"}
)
with patch("app.library.Notifications.Path") as mock_path, \
patch("builtins.__import__") as mock_import:
event = Event(event="item_completed", data={"test": "data"})
with patch("app.library.Notifications.Path") as mock_path, patch("builtins.__import__") as mock_import:
# Mock apprise config file doesn't exist
mock_path.return_value.exists.return_value = False
@ -825,103 +673,70 @@ class TestNotification:
def test_check_preset_no_presets(self):
"""Test _check_preset method with target having no preset filters."""
with patch("app.library.Notifications.Config") as mock_config, \
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0"
)
with (
patch("app.library.Notifications.Config") as mock_config,
patch("app.library.Notifications.BackgroundWorker") as mock_worker,
):
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
mock_worker.get_instance.return_value = Mock()
notification = Notification.get_instance()
request = TargetRequest(
type="json",
method="POST",
url="https://example.com/webhook"
)
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
target = Target(
id=str(uuid.uuid4()),
name="No Preset Filter",
presets=[], # No preset filter
request=request
request=request,
)
event = Event(
event="item_completed",
data={"preset": "default"}
)
event = Event(event="item_completed", data={"preset": "default"})
result = notification._check_preset(target, event)
assert result is True # Should pass when no preset filter
def test_check_preset_with_matching_preset(self):
"""Test _check_preset method with matching preset."""
with patch("app.library.Notifications.Config") as mock_config, \
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0"
)
with (
patch("app.library.Notifications.Config") as mock_config,
patch("app.library.Notifications.BackgroundWorker") as mock_worker,
):
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
mock_worker.get_instance.return_value = Mock()
notification = Notification.get_instance()
request = TargetRequest(
type="json",
method="POST",
url="https://example.com/webhook"
)
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
target = Target(
id=str(uuid.uuid4()),
name="Preset Filter",
presets=["default", "audio_only"],
request=request
id=str(uuid.uuid4()), name="Preset Filter", presets=["default", "audio_only"], request=request
)
# Test with ItemDTO
item_dto = ItemDTO(
id="test_id",
url="https://youtube.com/test",
title="Test Video",
folder="/downloads",
preset="default"
)
event = Event(
event="item_completed",
data=item_dto
id="test_id", url="https://youtube.com/test", title="Test Video", folder="/downloads", preset="default"
)
event = Event(event="item_completed", data=item_dto)
result = notification._check_preset(target, event)
assert result is True
def test_check_preset_with_non_matching_preset(self):
"""Test _check_preset method with non-matching preset."""
with patch("app.library.Notifications.Config") as mock_config, \
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0"
)
with (
patch("app.library.Notifications.Config") as mock_config,
patch("app.library.Notifications.BackgroundWorker") as mock_worker,
):
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
mock_worker.get_instance.return_value = Mock()
notification = Notification.get_instance()
request = TargetRequest(
type="json",
method="POST",
url="https://example.com/webhook"
)
target = Target(
id=str(uuid.uuid4()),
name="Preset Filter",
presets=["audio_only"],
request=request
)
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
target = Target(id=str(uuid.uuid4()), name="Preset Filter", presets=["audio_only"], request=request)
event = Event(
event="item_completed",
data={"preset": "video_only"} # Different preset
data={"preset": "video_only"}, # Different preset
)
result = notification._check_preset(target, event)
@ -929,35 +744,23 @@ class TestNotification:
def test_emit_invalid_event(self):
"""Test emit method with invalid event."""
with patch("app.library.Notifications.Config") as mock_config, \
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0"
)
with (
patch("app.library.Notifications.Config") as mock_config,
patch("app.library.Notifications.BackgroundWorker") as mock_worker,
):
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
mock_worker_instance = Mock()
mock_worker.get_instance.return_value = mock_worker_instance
notification = Notification.get_instance()
# Add a target
request = TargetRequest(
type="json",
method="POST",
url="https://example.com/webhook"
)
target = Target(
id=str(uuid.uuid4()),
name="Test Target",
request=request
)
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
target = Target(id=str(uuid.uuid4()), name="Test Target", request=request)
notification._targets = [target]
# Create invalid event
event = Event(
event="invalid_event",
data={"test": "data"}
)
event = Event(event="invalid_event", data={"test": "data"})
with patch("app.library.Notifications.NotificationEvents.is_valid", return_value=False):
result = notification.emit(event, None)
@ -968,35 +771,23 @@ class TestNotification:
def test_emit_valid_event(self):
"""Test emit method with valid event."""
with patch("app.library.Notifications.Config") as mock_config, \
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0"
)
with (
patch("app.library.Notifications.Config") as mock_config,
patch("app.library.Notifications.BackgroundWorker") as mock_worker,
):
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
mock_worker_instance = Mock()
mock_worker.get_instance.return_value = mock_worker_instance
notification = Notification.get_instance()
# Add a target
request = TargetRequest(
type="json",
method="POST",
url="https://example.com/webhook"
)
target = Target(
id=str(uuid.uuid4()),
name="Test Target",
request=request
)
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
target = Target(id=str(uuid.uuid4()), name="Test Target", request=request)
notification._targets = [target]
# Create valid event
event = Event(
event="item_completed",
data={"test": "data"}
)
event = Event(event="item_completed", data={"test": "data"})
with patch("app.library.Notifications.NotificationEvents.is_valid", return_value=True):
result = notification.emit(event, None)
@ -1008,12 +799,11 @@ class TestNotification:
@pytest.mark.asyncio
async def test_noop(self):
"""Test noop method."""
with patch("app.library.Notifications.Config") as mock_config, \
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0"
)
with (
patch("app.library.Notifications.Config") as mock_config,
patch("app.library.Notifications.BackgroundWorker") as mock_worker,
):
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
mock_worker.get_instance.return_value = Mock()
notification = Notification.get_instance()

View file

@ -1,6 +1,5 @@
"""Tests for the generic operations module."""
from app.library.operations import (
Operation,
filter_items,

View file

@ -216,7 +216,9 @@ 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_upgrade_skip_when_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"
@ -226,7 +228,9 @@ 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_runs_when_newer_available(
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"

View file

@ -27,6 +27,7 @@ async def test_make_playlist_no_subtitles(tmp_path: Path, monkeypatch: pytest.Mo
# Patch module-level quote to be robust for Path objects
from urllib.parse import quote as _std_quote
monkeypatch.setattr("app.library.Playlist.quote", lambda v: _std_quote(str(v)))
pl = Playlist(download_path=base, url="http://localhost/")
@ -67,6 +68,7 @@ async def test_make_playlist_with_subtitles(tmp_path: Path, monkeypatch: pytest.
monkeypatch.setattr("app.library.Playlist.get_file_sidecar", lambda _f: sidecar)
from urllib.parse import quote as _std_quote
monkeypatch.setattr("app.library.Playlist.quote", lambda v: _std_quote(str(v)))
pl = Playlist(download_path=base, url="https://server/")

View file

@ -228,7 +228,17 @@ class TestScheduler:
def test_add_executes_function_when_cron_runs(self, cron_patch) -> None:
# Cron stub that auto-runs the function on creation when start=True
class AutoRunCron(DummyCron):
def __init__(self, *_, spec: str, func: callable, args: tuple = (), kwargs: dict | None = None, uuid: str | None = None, start: bool | None = None, loop: asyncio.AbstractEventLoop | None = None):
def __init__(
self,
*_,
spec: str,
func: callable,
args: tuple = (),
kwargs: dict | None = None,
uuid: str | None = None,
start: bool | None = None,
loop: asyncio.AbstractEventLoop | None = None,
):
super().__init__(spec=spec, func=func, args=args, kwargs=kwargs, uuid=uuid, start=start, loop=loop)
if start:
# Simulate immediate execution
@ -253,7 +263,17 @@ class TestScheduler:
async def test_event_schedule_runs_function(self, cron_patch) -> None:
# Cron stub that auto-runs the function on creation
class AutoRunCron(DummyCron):
def __init__(self, *_, spec: str, func: callable, args: tuple = (), kwargs: dict | None = None, uuid: str | None = None, start: bool | None = None, loop: asyncio.AbstractEventLoop | None = None):
def __init__(
self,
*_,
spec: str,
func: callable,
args: tuple = (),
kwargs: dict | None = None,
uuid: str | None = None,
start: bool | None = None,
loop: asyncio.AbstractEventLoop | None = None,
):
super().__init__(spec=spec, func=func, args=args, kwargs=kwargs, uuid=uuid, start=start, loop=loop)
if start:
self.func(*self.args, **self.kwargs)

View file

@ -178,9 +178,7 @@ async def test_build_ffmpeg_args_no_dri_falls_back_to_software(tmp_path: Path, m
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
# Simulate no /dev/dri present but GPU encoders otherwise available
monkeypatch.setattr("app.library.SegmentEncoders.has_dri_devices", lambda: False)
monkeypatch.setattr(
"app.library.SegmentEncoders.ffmpeg_encoders", lambda: {"h264_nvenc", "h264_qsv", "h264_amf"}
)
monkeypatch.setattr("app.library.SegmentEncoders.ffmpeg_encoders", lambda: {"h264_nvenc", "h264_qsv", "h264_amf"})
# reset encoder cache to ensure clean selection in this test
from app.library.Segments import Segments as _Seg
@ -257,8 +255,7 @@ async def test_stream_gpu_failure_falls_back_to_software(
assert len(resp.data) > 0
# Ensure we logged the reason for GPU failure (message text may vary)
assert any(
("Hardware encoder failed" in r.message) or ("transcoding has failed" in r.message)
for r in caplog.records
("Hardware encoder failed" in r.message) or ("transcoding has failed" in r.message) for r in caplog.records
)
assert any("nvenc failure" in r.message for r in caplog.records)
# Verify second invocation switched codec to a safe fallback (software)
@ -269,9 +266,7 @@ async def test_stream_gpu_failure_falls_back_to_software(
@pytest.mark.asyncio
async def test_stream_gpu_fallback_switches_codec(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
async def test_stream_gpu_fallback_switches_codec(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
async def fake_ffprobe(_file: Path):
return DummyFF(v=True, a=True)

View file

@ -77,11 +77,7 @@ class TestServices:
def test_add_all_services(self):
"""Test adding multiple services at once."""
services = Services()
services_dict = {
"service1": "value1",
"service2": "value2",
"service3": "value3"
}
services_dict = {"service1": "value1", "service2": "value2", "service3": "value3"}
services.add_all(services_dict)
@ -394,6 +390,7 @@ class TestServices:
# Lambda function replacement
def lambda_handler(x):
return f"Lambda: {x}"
services.add("x", "lambda_value")
result = services.handle_sync(lambda_handler)
assert result == "Lambda: lambda_value"
@ -448,10 +445,7 @@ class TestServices:
services = Services()
services.add("existing", "original")
new_services = {
"existing": "overwritten",
"new": "value"
}
new_services = {"existing": "overwritten", "new": "value"}
services.add_all(new_services)
@ -531,7 +525,9 @@ class TestServices:
assert result == "anon: value"
# Function with minimal signature info
def minimal(param): return param
def minimal(param):
return param
result = services.handle_sync(minimal)
assert result == "value"

View file

@ -29,6 +29,7 @@ class TestSingleton:
def test_singleton_same_instance(self):
"""Test that Singleton returns same instance for same class."""
class TestClass(metaclass=Singleton):
def __init__(self, value=None):
self.value = value
@ -44,6 +45,7 @@ class TestSingleton:
def test_singleton_different_classes(self):
"""Test that Singleton creates different instances for different classes."""
class ClassA(metaclass=Singleton):
def __init__(self):
self.name = "A"
@ -68,6 +70,7 @@ class TestSingleton:
def test_threadsafe_same_instance(self):
"""Test that ThreadSafe returns same instance for same class."""
class TestClass(metaclass=ThreadSafe):
def __init__(self, value=None):
self.value = value
@ -83,6 +86,7 @@ class TestSingleton:
def test_threadsafe_different_classes(self):
"""Test that ThreadSafe creates different instances for different classes."""
class ClassA(metaclass=ThreadSafe):
def __init__(self):
self.name = "A"
@ -153,6 +157,7 @@ class TestSingleton:
def test_singleton_inheritance(self):
"""Test singleton behavior with inheritance."""
class BaseClass(metaclass=Singleton):
def __init__(self):
self.base_value = "base"
@ -182,6 +187,7 @@ class TestSingleton:
def test_threadsafe_inheritance(self):
"""Test threadsafe singleton behavior with inheritance."""
class BaseClass(metaclass=ThreadSafe):
def __init__(self):
self.base_value = "base"
@ -211,6 +217,7 @@ class TestSingleton:
def test_singleton_with_args_and_kwargs(self):
"""Test singleton behavior with various constructor arguments."""
class ConfigClass(metaclass=Singleton):
def __init__(self, name, value=None, **kwargs):
self.name = name
@ -231,6 +238,7 @@ class TestSingleton:
def test_threadsafe_with_args_and_kwargs(self):
"""Test threadsafe singleton behavior with various constructor arguments."""
class ConfigClass(metaclass=ThreadSafe):
def __init__(self, name, value=None, **kwargs):
self.name = name
@ -252,4 +260,5 @@ class TestSingleton:
if __name__ == "__main__":
import pytest
pytest.main([__file__])

View file

@ -103,6 +103,7 @@ async def test_paginate_order_and_bounds():
assert items_desc[0][0] == _list[14]._id
await store.close()
@pytest.mark.asyncio
async def test_get_by_id():
store = await make_store()
@ -112,6 +113,7 @@ async def test_get_by_id():
assert item._id == (await store.get_by_id("queue", item._id))._id
await store.close()
@pytest.mark.asyncio
async def test_shutdown_closes_worker_queue():
store = await make_store()
@ -213,6 +215,7 @@ async def test_get_item_matches_conditions():
assert no_match is None
await store.close()
@pytest.mark.asyncio
async def test_on_shutdown_closes_connection():
store = await make_store()

View file

@ -888,9 +888,7 @@ class TestTasks:
@patch("app.library.Tasks.EventBus")
@patch("app.library.Tasks.Scheduler")
@patch("app.library.Tasks.DownloadQueue")
async def test_tasks_runner_disabled_task(
self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config
):
async def test_tasks_runner_disabled_task(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
"""Test that disabled tasks are skipped by the runner."""
mock_config_instance = Mock(
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"

View file

@ -130,7 +130,10 @@ async def test_tver_handler_extract(monkeypatch):
assert "木村拓哉がタクシー 運転手!目黒蓮が木村と二人旅" == result.items[0].title
assert result.items[1].url == "https://tver.jp/episodes/epejwb9mvx"
assert "木村拓哉がタクシー運転手!蒼井優&上戸彩高校の同級生コンビがディズニーリゾートを大満喫!" == result.items[1].title
assert (
"木村拓哉がタクシー運転手!蒼井優&上戸彩高校の同級生コンビがディズニーリゾートを大満喫!"
== result.items[1].title
)
assert result.metadata.get("has_entries") is True
assert "callSeriesEpisodes" in result.metadata.get("feed_url", "")

View file

@ -1,4 +1,3 @@
from unittest.mock import MagicMock, Mock, patch
from app.library.Utils import REMOVE_KEYS

View file

@ -75,40 +75,33 @@ exclude = [
"venv",
]
# Same as Black.
line-length = 120
indent-width = 4
# Assume Python 3.11
target-version = "py311"
target-version = "py313"
[project.optional-dependencies]
installer = ["pyinstaller"]
[tool.ruff.lint]
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or
# McCabe complexity (`C901`) by default.
#select = ["E4", "E7", "E9", "F", "G", "W"]
#ignore = ["PTH", "G0", "G1", "G201"]
select = [
"ALL", # include all the rules, including new ones
]
# Ignore some rules in test files.
per-file-ignores = { "test_*.py" = ["D"] }
select = ["ALL"]
per-file-ignores = { "test_*.py" = [
"ALL",
], "migrations/versions/*.py" = [
"ALL",
] }
ignore = [
#### modules
"ANN", # flake8-annotations
"COM", # flake8-commas
"C90", # mccabe complexity
"DJ", # django
"EXE", # flake8-executable
"T10", # debugger
"TID", # flake8-tidy-imports
"ANN",
"COM",
"C90",
"DJ",
"EXE",
"T10",
"TID",
"PTH",
#### specific rules
"D100", # ignore missing docs
"D100",
"D101",
"D102",
"D103",
@ -122,14 +115,13 @@ ignore = [
"D400",
"D401",
"D415",
"E402", # false positives for local imports
"E501", # line too long
"TRY003", # external messages in exceptions are too verbose
"E402",
"E501",
"TRY003",
"TD002",
"TD003",
"FIX002", # too verbose descriptions of todos,
"N806", # variable names should be snake_case
"PTH", # prefer using absolute imports
"FIX002",
"N806",
"PGH003",
"D404",
"PLR0913",
@ -165,49 +157,25 @@ ignore = [
"PLC0415",
"S603",
"D203",
"TRY004", # Like it's our choice to use ValuesError :|
"TRY004",
"PT011",
"RUF001", # We like unicode chars.
"S311", # Not used for cryptography
"RUF001",
"S311",
]
# Allow fix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
[tool.ruff.format]
# Like Black, use double quotes for strings.
quote-style = "double"
# Like Black, indent with spaces, rather than tabs.
indent-style = "space"
# Like Black, respect magic trailing commas.
skip-magic-trailing-comma = false
# Like Black, automatically detect the appropriate line ending.
line-ending = "auto"
# Enable auto-formatting of code examples in docstrings. Markdown,
# reStructuredText code/literal blocks and doctests are all supported.
#
# This is currently disabled by default, but it is planned for this
# to be opt-out in the future.
docstring-code-format = false
# Set the line length limit used when formatting code snippets in
# docstrings.
#
# This only has an effect when the `docstring-code-format` setting is
# enabled.
docstring-code-line-length = "dynamic"
[tool.autopep8]
--max-line-length = 120
[tool.uv]
link-mode = "copy"

View file

@ -14,7 +14,8 @@
"lint:fix": "eslint . --fix",
"test": "vitest run",
"test:watch": "vitest",
"test:ci": "vitest run --silent --reporter=dot"
"test:ci": "vitest run --silent --reporter=dot",
"lint:tsc": "vue-tsc --noEmit"
},
"web-types": "./web-types.json",
"dependencies": {