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 working-directory: ui
run: pnpm run typecheck run: pnpm run typecheck
- name: Run frontend tsc
working-directory: ui
run: pnpm run lint:tsc
- name: Run frontend tests - name: Run frontend tests
working-directory: ui working-directory: ui
run: pnpm run test:ci run: pnpm run test:ci

View file

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

View file

@ -322,8 +322,6 @@ class DownloadQueue(metaclass=Singleton):
playlistCount = entry.get("playlist_count") playlistCount = entry.get("playlist_count")
playlistCount: int = int(playlistCount) if playlistCount else len(entries) playlistCount: int = int(playlistCount) if playlistCount else len(entries)
results = []
playlist_keys: dict[str, Any] = { playlist_keys: dict[str, Any] = {
"playlist_count": playlistCount, "playlist_count": playlistCount,
"playlist": entry.get("title") or entry.get("id"), "playlist": entry.get("title") or entry.get("id"),
@ -339,18 +337,16 @@ class DownloadQueue(metaclass=Singleton):
} }
async def playlist_processor(i: int, etr: dict): async def playlist_processor(i: int, etr: dict):
acquired = False
try: try:
item_name: str = ( item_name: str = (
f"'{entry.get('title')}: {i}/{playlist_keys['n_entries']}' - '{etr.get('id')}: {etr.get('title')}'" 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}") LOG.debug(f"Waiting to acquire lock for {item_name}")
await self.processors.acquire() await self.processors.acquire()
acquired = True
LOG.debug(f"Acquired lock for {item_name}") 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}'.") LOG.info(f"Processing '{item_name}'.")
_status, _msg = ytdlp_reject(entry=etr, yt_params=yt_params) _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) return await self.add(item=newItem, already=already)
finally: finally:
self.processors.release() if acquired:
self.processors.release()
max_downloads: int = -1 max_downloads: int = -1
ytdlp_opts: dict[str, Any] = item.get_ytdlp_opts().get_all() 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): if ytdlp_opts.get("max_downloads") and isinstance(ytdlp_opts.get("max_downloads"), int):
max_downloads: int = ytdlp_opts.get("max_downloads") 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): for i, etr in enumerate(entries, start=1):
if max_downloads > 0 and i > max_downloads: if max_downloads > 0 and i > max_downloads:
break break
task = asyncio.create_task( batch.append((i, etr))
playlist_processor(i, etr), if len(batch) >= batch_size:
name=f"playlist_processor_{etr.get('id')}_{i}", results.extend(await run_batch(batch))
) batch.clear()
task.add_done_callback(self._handle_task_exception)
tasks.append(task)
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." log_msg: str = f"Playlist '{playlist_name}' processing completed with '{len(results)}' entries."
if max_downloads > 0 and len(entries) > max_downloads: 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 functools import lru_cache, wraps
from http.cookiejar import MozillaCookieJar from http.cookiejar import MozillaCookieJar
from pathlib import Path from pathlib import Path
from typing import Any, TypeVar from typing import Any
from Crypto.Cipher import AES from Crypto.Cipher import AES
from yt_dlp.utils import age_restricted 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?") 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." "Regex to match ISO 8601 datetime strings."
T = TypeVar("T")
"Generic type variable."
class StreamingError(Exception): class StreamingError(Exception):
"""Raised when an error occurs during streaming.""" """Raised when an error occurs during streaming."""
@ -1543,7 +1540,7 @@ def delete_dir(dir: Path) -> bool:
return False 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. 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) @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. Run ffprobe on a file and return the parsed data as a dictionary.

View file

@ -1,4 +1,9 @@
# flake8: noqa: S608 # 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 contextlib
import datetime import datetime
import glob import glob
@ -18,6 +23,7 @@ UTC_LENGTH = 14
__version__ = "1.0.0" __version__ = "1.0.0"
class Error(Exception): class Error(Exception):
"""Base class for all Caribou errors.""" """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) return filter_items(items, **conditions)

View file

@ -33,6 +33,7 @@ if TYPE_CHECKING:
LOG: logging.Logger = logging.getLogger(__name__) LOG: logging.Logger = logging.getLogger(__name__)
CACHE: Cache = Cache() CACHE: Cache = Cache()
@dataclass(slots=True) @dataclass(slots=True)
class MatchRule: class MatchRule:
"""Represents a single URL matcher compiled to regex.""" """Represents a single URL matcher compiled to regex."""
@ -720,12 +721,16 @@ class GenericTaskHandler(BaseHandler):
) )
if not info: 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) CACHE.set(cache_key, None)
continue continue
if not info.get("id") or not info.get("extractor_key"): 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) CACHE.set(cache_key, None)
continue continue

View file

@ -96,7 +96,7 @@ class TwitchHandler(BaseHandler):
archive_id: str = entry.get("archive_id") archive_id: str = entry.get("archive_id")
task_items.append(TaskItem(url=url, title=entry.get("title"), archive_id=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 @staticmethod
def parse(url: str) -> str | None: def parse(url: str) -> str | None:

View file

@ -5,6 +5,7 @@ Migration Name: add_status_index
Migration Version: 20251116173731 Migration Version: 20251116173731
""" """
async def upgrade(connection): async def upgrade(connection):
""" """
Add index on json_extract(data, '$.status') for better query performance. Add index on json_extract(data, '$.status') for better query performance.
@ -17,6 +18,7 @@ async def upgrade(connection):
""" """
await connection.execute(sql) await connection.execute(sql)
async def downgrade(connection): async def downgrade(connection):
""" """
Remove the status index. 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: except ValueError as e:
LOG.exception(e) LOG.exception(e)
notify.emit( notify.emit(Events.LOG_ERROR, data={"preset": item.preset}, title="Error Adding URL", message=str(e), to=sid)
Events.LOG_ERROR, data={"preset": item.preset}, title="Error Adding URL", message=str(e), to=sid
)
@route(RouteType.SOCKET, "item_cancel", "item_cancel") @route(RouteType.SOCKET, "item_cancel", "item_cancel")

View file

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

View file

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

View file

@ -77,6 +77,7 @@ class TestDownloadHooks:
return Cfg return Cfg
monkeypatch.setattr("app.library.Download.Config", Cfg) monkeypatch.setattr("app.library.Download.Config", Cfg)
# EventBus.get_instance is used during __init__ and start, we don't hit start here # EventBus.get_instance is used during __init__ and start, we don't hit start here
class EB: class EB:
@staticmethod @staticmethod
@ -113,7 +114,17 @@ class TestDownloadHooks:
assert ev["action"] == "progress" assert ev["action"] == "progress"
# ensure only whitelisted keys included # ensure only whitelisted keys included
assert "other" not in ev 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 assert k in ev
def test_postprocessor_hook_movefiles_sets_final_name(self, tmp_path: Path) -> None: 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): def test_object_with_serialize_method(self):
"""Test that objects with serialize method use it.""" """Test that objects with serialize method use it."""
class CustomObject: class CustomObject:
def serialize(self): def serialize(self):
return {"custom": "data", "type": "test"} return {"custom": "data", "type": "test"}
@ -64,6 +65,7 @@ class TestEncoder:
def test_object_with_dict_fallback(self): def test_object_with_dict_fallback(self):
"""Test that objects without serialize method fall back to __dict__.""" """Test that objects without serialize method fall back to __dict__."""
class SimpleObject: class SimpleObject:
def __init__(self): def __init__(self):
self.name = "test" self.name = "test"
@ -81,12 +83,7 @@ class TestEncoder:
def test_json_dumps_integration(self): def test_json_dumps_integration(self):
"""Test full JSON serialization with various types.""" """Test full JSON serialization with various types."""
data = { data = {"path": Path("/tmp/test.txt"), "date": date(2024, 1, 1), "number": 42, "string": "test"}
"path": Path("/tmp/test.txt"),
"date": date(2024, 1, 1),
"number": 42,
"string": "test"
}
result = json.dumps(data, cls=Encoder) result = json.dumps(data, cls=Encoder)
parsed = json.loads(result) parsed = json.loads(result)
@ -98,15 +95,13 @@ class TestEncoder:
def test_json_dumps_with_custom_object(self): def test_json_dumps_with_custom_object(self):
"""Test JSON serialization with custom objects.""" """Test JSON serialization with custom objects."""
class TestObject: class TestObject:
def __init__(self): def __init__(self):
self.name = "test" self.name = "test"
self.items = [1, 2, 3] self.items = [1, 2, 3]
data = { data = {"object": TestObject(), "regular": "data"}
"object": TestObject(),
"regular": "data"
}
result = json.dumps(data, cls=Encoder) result = json.dumps(data, cls=Encoder)
parsed = json.loads(result) parsed = json.loads(result)
@ -117,6 +112,7 @@ class TestEncoder:
def test_nested_serialization(self): def test_nested_serialization(self):
"""Test serialization of nested structures with various types.""" """Test serialization of nested structures with various types."""
class CustomObj: class CustomObj:
def serialize(self): def serialize(self):
return {"serialized": True} return {"serialized": True}
@ -125,10 +121,7 @@ class TestEncoder:
"paths": [Path("/tmp/1.txt"), Path("/tmp/2.txt")], "paths": [Path("/tmp/1.txt"), Path("/tmp/2.txt")],
"dates": [date(2024, 1, 1), date(2024, 12, 31)], "dates": [date(2024, 1, 1), date(2024, 12, 31)],
"custom": CustomObj(), "custom": CustomObj(),
"nested": { "nested": {"path": Path("/nested/path"), "date": date(2024, 6, 15)},
"path": Path("/nested/path"),
"date": date(2024, 6, 15)
}
} }
result = json.dumps(data, cls=Encoder) result = json.dumps(data, cls=Encoder)
@ -149,6 +142,7 @@ class TestEncoder:
# Mock isinstance to return True for DateRange # Mock isinstance to return True for DateRange
import builtins import builtins
original_isinstance = builtins.isinstance original_isinstance = builtins.isinstance
def mock_isinstance(obj, cls): def mock_isinstance(obj, cls):

View file

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

View file

@ -252,7 +252,9 @@ class TestItemDTO:
} }
with ( 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, patch("app.library.ItemDTO.get_file_sidecar", return_value=expected_sidecar) as mock_utils_sidecar,
): ):
result = dto.get_file_sidecar() result = dto.get_file_sidecar()

View file

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

View file

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

View file

@ -216,7 +216,9 @@ class TestActionAndCheck:
@patch.object(PackageInstaller, "_install_pkg") @patch.object(PackageInstaller, "_install_pkg")
@patch.object(PackageInstaller, "_get_installed_version") @patch.object(PackageInstaller, "_get_installed_version")
@patch.object(PackageInstaller, "_get_latest_version") @patch.object(PackageInstaller, "_get_latest_version")
def test_action_upgrade_skip_when_latest(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) inst = PackageInstaller(pkg_path=tmp_path)
mock_get_installed.return_value = "2.0.0" mock_get_installed.return_value = "2.0.0"
mock_get_latest.return_value = "2.0.0" mock_get_latest.return_value = "2.0.0"
@ -226,7 +228,9 @@ class TestActionAndCheck:
@patch.object(PackageInstaller, "_install_pkg") @patch.object(PackageInstaller, "_install_pkg")
@patch.object(PackageInstaller, "_get_installed_version") @patch.object(PackageInstaller, "_get_installed_version")
@patch.object(PackageInstaller, "_get_latest_version") @patch.object(PackageInstaller, "_get_latest_version")
def test_action_upgrade_runs_when_newer_available(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) inst = PackageInstaller(pkg_path=tmp_path)
mock_get_installed.return_value = "1.0.0" mock_get_installed.return_value = "1.0.0"
mock_get_latest.return_value = "1.1.0" mock_get_latest.return_value = "1.1.0"

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 # Patch module-level quote to be robust for Path objects
from urllib.parse import quote as _std_quote from urllib.parse import quote as _std_quote
monkeypatch.setattr("app.library.Playlist.quote", lambda v: _std_quote(str(v))) monkeypatch.setattr("app.library.Playlist.quote", lambda v: _std_quote(str(v)))
pl = Playlist(download_path=base, url="http://localhost/") 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) monkeypatch.setattr("app.library.Playlist.get_file_sidecar", lambda _f: sidecar)
from urllib.parse import quote as _std_quote from urllib.parse import quote as _std_quote
monkeypatch.setattr("app.library.Playlist.quote", lambda v: _std_quote(str(v))) monkeypatch.setattr("app.library.Playlist.quote", lambda v: _std_quote(str(v)))
pl = Playlist(download_path=base, url="https://server/") 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: def test_add_executes_function_when_cron_runs(self, cron_patch) -> None:
# Cron stub that auto-runs the function on creation when start=True # Cron stub that auto-runs the function on creation when start=True
class AutoRunCron(DummyCron): class AutoRunCron(DummyCron):
def __init__(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) super().__init__(spec=spec, func=func, args=args, kwargs=kwargs, uuid=uuid, start=start, loop=loop)
if start: if start:
# Simulate immediate execution # Simulate immediate execution
@ -253,7 +263,17 @@ class TestScheduler:
async def test_event_schedule_runs_function(self, cron_patch) -> None: async def test_event_schedule_runs_function(self, cron_patch) -> None:
# Cron stub that auto-runs the function on creation # Cron stub that auto-runs the function on creation
class AutoRunCron(DummyCron): 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) super().__init__(spec=spec, func=func, args=args, kwargs=kwargs, uuid=uuid, start=start, loop=loop)
if start: if start:
self.func(*self.args, **self.kwargs) 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) monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
# Simulate no /dev/dri present but GPU encoders otherwise available # 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.has_dri_devices", lambda: False)
monkeypatch.setattr( monkeypatch.setattr("app.library.SegmentEncoders.ffmpeg_encoders", lambda: {"h264_nvenc", "h264_qsv", "h264_amf"})
"app.library.SegmentEncoders.ffmpeg_encoders", lambda: {"h264_nvenc", "h264_qsv", "h264_amf"}
)
# reset encoder cache to ensure clean selection in this test # reset encoder cache to ensure clean selection in this test
from app.library.Segments import Segments as _Seg 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 assert len(resp.data) > 0
# Ensure we logged the reason for GPU failure (message text may vary) # Ensure we logged the reason for GPU failure (message text may vary)
assert any( assert any(
("Hardware encoder failed" in r.message) or ("transcoding has failed" in r.message) ("Hardware encoder failed" in r.message) or ("transcoding has failed" in r.message) for r in caplog.records
for r in caplog.records
) )
assert any("nvenc failure" 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) # 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 @pytest.mark.asyncio
async def test_stream_gpu_fallback_switches_codec( async def test_stream_gpu_fallback_switches_codec(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
async def fake_ffprobe(_file: Path): async def fake_ffprobe(_file: Path):
return DummyFF(v=True, a=True) return DummyFF(v=True, a=True)

View file

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

View file

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

View file

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

View file

@ -888,9 +888,7 @@ class TestTasks:
@patch("app.library.Tasks.EventBus") @patch("app.library.Tasks.EventBus")
@patch("app.library.Tasks.Scheduler") @patch("app.library.Tasks.Scheduler")
@patch("app.library.Tasks.DownloadQueue") @patch("app.library.Tasks.DownloadQueue")
async def test_tasks_runner_disabled_task( async def test_tasks_runner_disabled_task(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config
):
"""Test that disabled tasks are skipped by the runner.""" """Test that disabled tasks are skipped by the runner."""
mock_config_instance = Mock( mock_config_instance = Mock(
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *" 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[0].title
assert result.items[1].url == "https://tver.jp/episodes/epejwb9mvx" 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 result.metadata.get("has_entries") is True
assert "callSeriesEpisodes" in result.metadata.get("feed_url", "") assert "callSeriesEpisodes" in result.metadata.get("feed_url", "")

View file

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

View file

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

View file

@ -14,7 +14,8 @@
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "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", "web-types": "./web-types.json",
"dependencies": { "dependencies": {