feat(client): initial implementation of jellyfin htpc client with yatse remote and youtube playback

This commit is contained in:
bart
2026-11-02 21:33:42 +01:00
parent 8d6270038f
commit f66f874bae
23 changed files with 2246 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
import tempfile
from pathlib import Path
from src.config import AppConfig
def test_default_config():
cfg = AppConfig.load()
assert cfg.jellyfin.client_name == "Jellyfin HTPC Client"
assert cfg.player.hwdec == "auto"
assert cfg.kodi_rpc.http_port == 8080
assert cfg.web_api.port == 8097
def test_custom_yaml_config():
yaml_content = """
jellyfin:
server_url: "https://media.lan"
username: "myuser"
password: "mypassword"
player:
hwdec: "vaapi"
fullscreen: false
kodi_rpc:
http_port: 8088
"""
with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as f:
f.write(yaml_content)
f_path = f.name
try:
cfg = AppConfig.load(f_path)
assert cfg.jellyfin.server_url == "https://media.lan"
assert cfg.jellyfin.username == "myuser"
assert cfg.player.hwdec == "vaapi"
assert cfg.player.fullscreen is False
assert cfg.kodi_rpc.http_port == 8088
finally:
Path(f_path).unlink()
+83
View File
@@ -0,0 +1,83 @@
import pytest
from unittest.mock import AsyncMock, MagicMock
from src.config import KodiRpcConfig
from src.player.models import MediaMetadata, PlaybackState, PlayerStatus
from src.player.mpv_controller import MpvController
from src.remotes.kodi_rpc import KodiRpcServer
@pytest.fixture
def mock_player():
player = MagicMock(spec=MpvController)
player.status = PlayerStatus(
state=PlaybackState.PLAYING,
current_time=65.5,
duration=300.0,
volume=85,
muted=False,
metadata=MediaMetadata(
url="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
title="Never Gonna Give You Up",
artist="Rick Astley",
duration=300.0,
)
)
player.play_url = AsyncMock()
player.pause = AsyncMock()
player.resume = AsyncMock()
player.play_pause = AsyncMock()
player.stop = AsyncMock()
player.seek = AsyncMock()
player.set_volume = AsyncMock()
player.set_mute = AsyncMock()
player.toggle_mute = AsyncMock()
player.add_listener = MagicMock()
return player
@pytest.mark.asyncio
async def test_kodi_rpc_ping(mock_player):
rpc = KodiRpcServer(KodiRpcConfig(), mock_player)
resp = await rpc._dispatch({"jsonrpc": "2.0", "method": "JSONRPC.Ping", "id": 1})
assert resp["result"] == "pong"
assert resp["id"] == 1
@pytest.mark.asyncio
async def test_kodi_rpc_active_players(mock_player):
rpc = KodiRpcServer(KodiRpcConfig(), mock_player)
resp = await rpc._dispatch({"jsonrpc": "2.0", "method": "Player.GetActivePlayers", "id": 2})
assert len(resp["result"]) == 1
assert resp["result"][0]["playerid"] == 1
assert resp["result"][0]["type"] == "video"
@pytest.mark.asyncio
async def test_kodi_rpc_player_properties(mock_player):
rpc = KodiRpcServer(KodiRpcConfig(), mock_player)
resp = await rpc._dispatch({
"jsonrpc": "2.0",
"method": "Player.GetProperties",
"params": {"playerid": 1, "properties": ["percentage", "time", "totaltime", "speed"]},
"id": 3
})
result = resp["result"]
assert result["speed"] == 1
assert result["time"]["minutes"] == 1
assert result["time"]["seconds"] == 5
assert result["totaltime"]["minutes"] == 5
@pytest.mark.asyncio
async def test_kodi_rpc_open_youtube(mock_player):
rpc = KodiRpcServer(KodiRpcConfig(), mock_player)
resp = await rpc._dispatch({
"jsonrpc": "2.0",
"method": "Player.Open",
"params": {"item": {"file": "plugin://plugin.video.youtube/play/?video_id=dQw4w9WgXcQ"}},
"id": 4
})
assert resp["result"] == "OK"
assert mock_player.play_url.called
args, kwargs = mock_player.play_url.call_args
assert "dQw4w9WgXcQ" in args[0]
+42
View File
@@ -0,0 +1,42 @@
import pytest
from src.utils.youtube import extract_youtube_video_id, is_youtube_url, get_canonical_youtube_url
def test_extract_standard_watch_url():
url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
assert extract_youtube_video_id(url) == "dQw4w9WgXcQ"
assert is_youtube_url(url) is True
def test_extract_short_url():
url = "https://youtu.be/dQw4w9WgXcQ?si=abcdef123456"
assert extract_youtube_video_id(url) == "dQw4w9WgXcQ"
assert is_youtube_url(url) is True
def test_extract_shorts_url():
url = "https://youtube.com/shorts/dQw4w9WgXcQ"
assert extract_youtube_video_id(url) == "dQw4w9WgXcQ"
assert is_youtube_url(url) is True
def test_extract_kodi_plugin_url():
url = "plugin://plugin.video.youtube/play/?video_id=dQw4w9WgXcQ"
assert extract_youtube_video_id(url) == "dQw4w9WgXcQ"
assert is_youtube_url(url) is True
def test_extract_embed_url():
url = "https://www.youtube.com/embed/dQw4w9WgXcQ"
assert extract_youtube_video_id(url) == "dQw4w9WgXcQ"
assert is_youtube_url(url) is True
def test_non_youtube_url():
url = "https://example.com/video.mp4"
assert extract_youtube_video_id(url) is None
assert is_youtube_url(url) is False
def test_canonical_url():
assert get_canonical_youtube_url("dQw4w9WgXcQ") == "https://www.youtube.com/watch?v=dQw4w9WgXcQ"