Initial commit: TVH2Jellyfin application
A Docker-based Python application that transfers completed recordings from TVHeadend to Jellyfin media storage via SCP. Features: - Monitors TVHeadend for completed recordings via JSON API - Transfers files via SCP with SSH key authentication - Organizes files by show name - Deletes recordings from TVHeadend after successful transfer - Tracks transferred files to avoid duplicates - Dry run mode for testing without making changes
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
117
tests/test_episode_resolver.py
Normal file
117
tests/test_episode_resolver.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Tests for episode resolver."""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from tvh2jellyfin.episode_resolver import EpisodeResolver
|
||||
from tvh2jellyfin.tvdb import EpisodeInfo
|
||||
|
||||
|
||||
class TestEpisodeResolver:
|
||||
"""Tests for EpisodeResolver."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_episode_info(self):
|
||||
return EpisodeInfo(
|
||||
series_id=456,
|
||||
series_name="Test Show",
|
||||
season=1,
|
||||
episode=5,
|
||||
episode_name="Pilot",
|
||||
air_date="2024-01-15",
|
||||
)
|
||||
|
||||
def test_lookup_tvdb_success(self, mock_episode_info):
|
||||
"""Test successful lookup from TVDB."""
|
||||
mock_tvdb = Mock()
|
||||
mock_tvdb.lookup_episode.return_value = mock_episode_info
|
||||
|
||||
resolver = EpisodeResolver(tvdb_client=mock_tvdb, tvmaze_client=None)
|
||||
result, source = resolver.lookup("Test Show", 1705312800)
|
||||
|
||||
assert result is not None
|
||||
assert result.season == 1
|
||||
assert result.episode == 5
|
||||
assert source == "tvdb"
|
||||
|
||||
def test_lookup_tvmaze_fallback(self, mock_episode_info):
|
||||
"""Test fallback to TVmaze when TVDB fails."""
|
||||
mock_tvdb = Mock()
|
||||
mock_tvdb.lookup_episode.return_value = None
|
||||
|
||||
mock_tvmaze = Mock()
|
||||
mock_tvmaze.lookup_episode.return_value = mock_episode_info
|
||||
|
||||
resolver = EpisodeResolver(tvdb_client=mock_tvdb, tvmaze_client=mock_tvmaze)
|
||||
result, source = resolver.lookup("Test Show", 1705312800)
|
||||
|
||||
assert result is not None
|
||||
assert source == "tvmaze"
|
||||
|
||||
def test_lookup_tvdb_exception_fallback(self, mock_episode_info):
|
||||
"""Test fallback to TVmaze when TVDB raises exception."""
|
||||
mock_tvdb = Mock()
|
||||
mock_tvdb.lookup_episode.side_effect = Exception("API Error")
|
||||
|
||||
mock_tvmaze = Mock()
|
||||
mock_tvmaze.lookup_episode.return_value = mock_episode_info
|
||||
|
||||
resolver = EpisodeResolver(tvdb_client=mock_tvdb, tvmaze_client=mock_tvmaze)
|
||||
result, source = resolver.lookup("Test Show", 1705312800)
|
||||
|
||||
assert result is not None
|
||||
assert source == "tvmaze"
|
||||
|
||||
def test_lookup_both_fail(self):
|
||||
"""Test when both sources fail."""
|
||||
mock_tvdb = Mock()
|
||||
mock_tvdb.lookup_episode.return_value = None
|
||||
|
||||
mock_tvmaze = Mock()
|
||||
mock_tvmaze.lookup_episode.return_value = None
|
||||
|
||||
resolver = EpisodeResolver(tvdb_client=mock_tvdb, tvmaze_client=mock_tvmaze)
|
||||
result, source = resolver.lookup("Unknown Show", 1705312800)
|
||||
|
||||
assert result is None
|
||||
assert source == "none"
|
||||
|
||||
def test_lookup_no_clients(self):
|
||||
"""Test when no clients are available."""
|
||||
resolver = EpisodeResolver(tvdb_client=None, tvmaze_client=None)
|
||||
result, source = resolver.lookup("Test Show", 1705312800)
|
||||
|
||||
assert result is None
|
||||
assert source == "none"
|
||||
|
||||
def test_lookup_tvmaze_only(self, mock_episode_info):
|
||||
"""Test with only TVmaze client."""
|
||||
mock_tvmaze = Mock()
|
||||
mock_tvmaze.lookup_episode.return_value = mock_episode_info
|
||||
|
||||
resolver = EpisodeResolver(tvdb_client=None, tvmaze_client=mock_tvmaze)
|
||||
result, source = resolver.lookup("Test Show", 1705312800)
|
||||
|
||||
assert result is not None
|
||||
assert source == "tvmaze"
|
||||
|
||||
def test_is_available_with_tvdb(self):
|
||||
"""Test is_available with TVDB client."""
|
||||
resolver = EpisodeResolver(tvdb_client=Mock(), tvmaze_client=None)
|
||||
assert resolver.is_available() is True
|
||||
|
||||
def test_is_available_with_tvmaze(self):
|
||||
"""Test is_available with TVmaze client."""
|
||||
resolver = EpisodeResolver(tvdb_client=None, tvmaze_client=Mock())
|
||||
assert resolver.is_available() is True
|
||||
|
||||
def test_is_available_with_both(self):
|
||||
"""Test is_available with both clients."""
|
||||
resolver = EpisodeResolver(tvdb_client=Mock(), tvmaze_client=Mock())
|
||||
assert resolver.is_available() is True
|
||||
|
||||
def test_is_available_with_none(self):
|
||||
"""Test is_available with no clients."""
|
||||
resolver = EpisodeResolver(tvdb_client=None, tvmaze_client=None)
|
||||
assert resolver.is_available() is False
|
||||
150
tests/test_jellyfin.py
Normal file
150
tests/test_jellyfin.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Tests for Jellyfin client."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from tvh2jellyfin.jellyfin import JellyfinClient, JellyfinConfig
|
||||
|
||||
|
||||
class TestJellyfinConfig:
|
||||
"""Tests for JellyfinConfig dataclass."""
|
||||
|
||||
def test_config_defaults(self):
|
||||
"""Test default values."""
|
||||
config = JellyfinConfig(
|
||||
url="http://localhost:8096",
|
||||
api_key="test_key",
|
||||
)
|
||||
assert config.library_id is None
|
||||
assert config.enabled is True
|
||||
|
||||
def test_config_with_library_id(self):
|
||||
"""Test config with specific library ID."""
|
||||
config = JellyfinConfig(
|
||||
url="http://localhost:8096",
|
||||
api_key="test_key",
|
||||
library_id="abc123",
|
||||
)
|
||||
assert config.library_id == "abc123"
|
||||
|
||||
|
||||
class TestJellyfinClient:
|
||||
"""Tests for Jellyfin API client."""
|
||||
|
||||
@pytest.fixture
|
||||
def config(self):
|
||||
return JellyfinConfig(
|
||||
url="http://localhost:8096",
|
||||
api_key="test_api_key",
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def config_with_library(self):
|
||||
return JellyfinConfig(
|
||||
url="http://localhost:8096",
|
||||
api_key="test_api_key",
|
||||
library_id="lib123",
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def client(self, config):
|
||||
return JellyfinClient(config)
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_library(self, config_with_library):
|
||||
return JellyfinClient(config_with_library)
|
||||
|
||||
def test_url_normalization(self):
|
||||
"""Test that trailing slashes are removed from URL."""
|
||||
config = JellyfinConfig(
|
||||
url="http://localhost:8096/",
|
||||
api_key="test_key",
|
||||
)
|
||||
client = JellyfinClient(config)
|
||||
assert client.base_url == "http://localhost:8096"
|
||||
|
||||
def test_headers_set(self, client):
|
||||
"""Test that auth headers are set correctly."""
|
||||
assert client.session.headers["X-MediaBrowser-Token"] == "test_api_key"
|
||||
assert client.session.headers["Content-Type"] == "application/json"
|
||||
|
||||
@patch("requests.Session.get")
|
||||
def test_test_connection_success(self, mock_get, client):
|
||||
"""Test successful connection test."""
|
||||
mock_get.return_value.json.return_value = {
|
||||
"ServerName": "Test Server",
|
||||
"Version": "10.8.0",
|
||||
}
|
||||
mock_get.return_value.raise_for_status = Mock()
|
||||
|
||||
result = client.test_connection()
|
||||
|
||||
assert result is True
|
||||
mock_get.assert_called_once()
|
||||
assert "/System/Info" in mock_get.call_args[0][0]
|
||||
|
||||
@patch("requests.Session.get")
|
||||
def test_test_connection_failure(self, mock_get, client):
|
||||
"""Test connection test failure."""
|
||||
mock_get.side_effect = requests.exceptions.ConnectionError("Connection refused")
|
||||
|
||||
result = client.test_connection()
|
||||
|
||||
assert result is False
|
||||
|
||||
@patch("requests.Session.post")
|
||||
def test_trigger_library_scan_full(self, mock_post, client):
|
||||
"""Test triggering full library scan."""
|
||||
mock_post.return_value.raise_for_status = Mock()
|
||||
|
||||
result = client.trigger_library_scan()
|
||||
|
||||
assert result is True
|
||||
mock_post.assert_called_once()
|
||||
assert "/Library/Refresh" in mock_post.call_args[0][0]
|
||||
|
||||
@patch("requests.Session.post")
|
||||
def test_trigger_library_scan_specific_library(self, mock_post, client_with_library):
|
||||
"""Test triggering scan for specific library."""
|
||||
mock_post.return_value.raise_for_status = Mock()
|
||||
|
||||
result = client_with_library.trigger_library_scan()
|
||||
|
||||
assert result is True
|
||||
mock_post.assert_called_once()
|
||||
assert "/Items/lib123/Refresh" in mock_post.call_args[0][0]
|
||||
|
||||
@patch("requests.Session.post")
|
||||
def test_trigger_library_scan_failure(self, mock_post, client):
|
||||
"""Test library scan trigger failure."""
|
||||
mock_post.side_effect = requests.exceptions.RequestException("API error")
|
||||
|
||||
result = client.trigger_library_scan()
|
||||
|
||||
assert result is False
|
||||
|
||||
@patch("requests.Session.get")
|
||||
def test_get_libraries_success(self, mock_get, client):
|
||||
"""Test getting list of libraries."""
|
||||
mock_get.return_value.json.return_value = [
|
||||
{"Name": "TV Shows", "ItemId": "tv123"},
|
||||
{"Name": "Movies", "ItemId": "mov456"},
|
||||
]
|
||||
mock_get.return_value.raise_for_status = Mock()
|
||||
|
||||
result = client.get_libraries()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0] == {"name": "TV Shows", "id": "tv123"}
|
||||
assert result[1] == {"name": "Movies", "id": "mov456"}
|
||||
|
||||
@patch("requests.Session.get")
|
||||
def test_get_libraries_failure(self, mock_get, client):
|
||||
"""Test getting libraries when API fails."""
|
||||
mock_get.side_effect = requests.exceptions.RequestException("API error")
|
||||
|
||||
result = client.get_libraries()
|
||||
|
||||
assert result == []
|
||||
216
tests/test_organizer.py
Normal file
216
tests/test_organizer.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""Tests for organizer module."""
|
||||
|
||||
import pytest
|
||||
|
||||
from tvh2jellyfin.organizer import (
|
||||
generate_destination_path,
|
||||
generate_destination_path_with_episode,
|
||||
sanitize_directory_name,
|
||||
sanitize_filename,
|
||||
)
|
||||
from tvh2jellyfin.tvdb import EpisodeInfo
|
||||
from tvh2jellyfin.tvheadend import Recording
|
||||
|
||||
|
||||
class TestSanitizeFilename:
|
||||
"""Tests for filename sanitization."""
|
||||
|
||||
def test_removes_invalid_characters(self):
|
||||
assert sanitize_filename('Test: "Show"') == "Test Show"
|
||||
# Multiple spaces get normalized to single space
|
||||
assert sanitize_filename("Show <1> | Part 2") == "Show 1 Part 2"
|
||||
|
||||
def test_normalizes_spaces(self):
|
||||
assert sanitize_filename("Test Show") == "Test Show"
|
||||
assert sanitize_filename("Test_Show") == "Test Show"
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert sanitize_filename(" Test Show ") == "Test Show"
|
||||
|
||||
def test_empty_returns_unknown(self):
|
||||
assert sanitize_filename("") == "Unknown"
|
||||
assert sanitize_filename(" ") == "Unknown"
|
||||
|
||||
|
||||
class TestSanitizeDirectoryName:
|
||||
"""Tests for directory name sanitization."""
|
||||
|
||||
def test_removes_trailing_dots(self):
|
||||
assert sanitize_directory_name("Test Show.") == "Test Show"
|
||||
assert sanitize_directory_name("Test Show...") == "Test Show"
|
||||
|
||||
def test_handles_empty(self):
|
||||
assert sanitize_directory_name("") == "Unknown"
|
||||
|
||||
|
||||
class TestGenerateDestinationPath:
|
||||
"""Tests for destination path generation."""
|
||||
|
||||
@pytest.fixture
|
||||
def recording_with_subtitle(self):
|
||||
return Recording(
|
||||
uuid="123",
|
||||
title="Test Show",
|
||||
subtitle="Episode Title",
|
||||
filename="/recordings/test.ts",
|
||||
filesize=1000000,
|
||||
channel="BBC One",
|
||||
start=1705312800,
|
||||
stop=1705316400,
|
||||
status="Completed OK",
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def recording_without_subtitle(self):
|
||||
return Recording(
|
||||
uuid="456",
|
||||
title="Another Show",
|
||||
subtitle="",
|
||||
filename="/recordings/another.ts",
|
||||
filesize=2000000,
|
||||
channel="ITV",
|
||||
start=1705312800,
|
||||
stop=1705316400,
|
||||
status="Completed OK",
|
||||
)
|
||||
|
||||
def test_with_subtitle(self, recording_with_subtitle):
|
||||
dest_dir, filename = generate_destination_path(
|
||||
recording_with_subtitle, "/media/tv"
|
||||
)
|
||||
assert dest_dir == "/media/tv/Test Show"
|
||||
assert filename == "Test Show - Episode Title.ts"
|
||||
|
||||
def test_without_subtitle(self, recording_without_subtitle):
|
||||
dest_dir, filename = generate_destination_path(
|
||||
recording_without_subtitle, "/media/tv"
|
||||
)
|
||||
assert dest_dir == "/media/tv/Another Show"
|
||||
# Should include timestamp
|
||||
assert filename.startswith("Another Show - ")
|
||||
assert filename.endswith(".ts")
|
||||
|
||||
|
||||
class TestGenerateDestinationPathWithEpisode:
|
||||
"""Tests for destination path generation with episode info."""
|
||||
|
||||
@pytest.fixture
|
||||
def recording(self):
|
||||
return Recording(
|
||||
uuid="123",
|
||||
title="Coronation Street",
|
||||
subtitle="",
|
||||
filename="/recordings/Coronation-Street/Coronation Street2026-01-06.ts",
|
||||
filesize=1000000,
|
||||
channel="ITV",
|
||||
start=1767744000, # 2026-01-06
|
||||
stop=1767747600,
|
||||
status="Completed OK",
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def episode_info(self):
|
||||
return EpisodeInfo(
|
||||
series_id=456,
|
||||
series_name="Coronation Street",
|
||||
season=67,
|
||||
episode=4,
|
||||
episode_name="Episode 4",
|
||||
air_date="2026-01-06",
|
||||
)
|
||||
|
||||
def test_with_episode_info(self, recording, episode_info):
|
||||
"""Test Jellyfin naming format with episode info."""
|
||||
dest_dir, filename = generate_destination_path_with_episode(
|
||||
recording, "/media/tv", episode_info
|
||||
)
|
||||
assert dest_dir == "/media/tv/Coronation Street/Season 67"
|
||||
assert filename == "Coronation Street - S67E04.ts"
|
||||
|
||||
def test_without_episode_info(self, recording):
|
||||
"""Test fallback when episode info not available."""
|
||||
dest_dir, filename = generate_destination_path_with_episode(
|
||||
recording, "/media/tv", None
|
||||
)
|
||||
assert dest_dir == "/media/tv/Coronation Street"
|
||||
# Should use fallback logic (timestamp)
|
||||
assert filename.startswith("Coronation Street - ")
|
||||
assert filename.endswith(".ts")
|
||||
|
||||
def test_uses_canonical_series_name(self, recording):
|
||||
"""Test that canonical series name from TVDB is used."""
|
||||
episode_info = EpisodeInfo(
|
||||
series_id=456,
|
||||
series_name="Doctor Who (2005)", # TVDB canonical name
|
||||
season=14,
|
||||
episode=1,
|
||||
episode_name="The Church on Ruby Road",
|
||||
air_date="2023-12-25",
|
||||
)
|
||||
|
||||
# Recording might have different name
|
||||
recording_with_diff_name = Recording(
|
||||
uuid="789",
|
||||
title="Doctor Who", # TVHeadend name
|
||||
subtitle="",
|
||||
filename="/recordings/doctor-who.ts",
|
||||
filesize=3000000,
|
||||
channel="BBC One",
|
||||
start=1703512800,
|
||||
stop=1703516400,
|
||||
status="Completed OK",
|
||||
)
|
||||
|
||||
dest_dir, filename = generate_destination_path_with_episode(
|
||||
recording_with_diff_name, "/media/tv", episode_info
|
||||
)
|
||||
|
||||
# Should use TVDB canonical name with season folder
|
||||
assert dest_dir == "/media/tv/Doctor Who (2005)/Season 14"
|
||||
assert filename == "Doctor Who (2005) - S14E01.ts"
|
||||
|
||||
def test_large_episode_numbers(self, recording):
|
||||
"""Test with season/episode numbers > 99."""
|
||||
episode_info = EpisodeInfo(
|
||||
series_id=456,
|
||||
series_name="Long Running Show",
|
||||
season=67,
|
||||
episode=125,
|
||||
episode_name="Episode",
|
||||
air_date="2026-01-06",
|
||||
)
|
||||
|
||||
dest_dir, filename = generate_destination_path_with_episode(
|
||||
recording, "/media/tv", episode_info
|
||||
)
|
||||
|
||||
assert filename == "Long Running Show - S67E125.ts"
|
||||
|
||||
def test_preserves_file_extension(self, recording):
|
||||
"""Test that file extension is preserved."""
|
||||
recording_mkv = Recording(
|
||||
uuid="123",
|
||||
title="Test Show",
|
||||
subtitle="",
|
||||
filename="/recordings/test.mkv",
|
||||
filesize=1000000,
|
||||
channel="BBC",
|
||||
start=1705312800,
|
||||
stop=1705316400,
|
||||
status="Completed OK",
|
||||
)
|
||||
|
||||
episode_info = EpisodeInfo(
|
||||
series_id=456,
|
||||
series_name="Test Show",
|
||||
season=1,
|
||||
episode=1,
|
||||
episode_name="Pilot",
|
||||
air_date="2024-01-15",
|
||||
)
|
||||
|
||||
_, filename = generate_destination_path_with_episode(
|
||||
recording_mkv, "/media/tv", episode_info
|
||||
)
|
||||
|
||||
assert filename.endswith(".mkv")
|
||||
190
tests/test_tvdb.py
Normal file
190
tests/test_tvdb.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Tests for TheTVDB client."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tvh2jellyfin.tvdb import EpisodeInfo, TVDBClient, TVDBConfig
|
||||
|
||||
|
||||
class TestEpisodeInfo:
|
||||
"""Tests for EpisodeInfo dataclass."""
|
||||
|
||||
def test_formatted_episode_single_digit(self):
|
||||
"""Test SxxExx formatting with single digit numbers."""
|
||||
info = EpisodeInfo(
|
||||
series_id=123,
|
||||
series_name="Test Show",
|
||||
season=1,
|
||||
episode=5,
|
||||
episode_name="Pilot",
|
||||
air_date="2024-01-15",
|
||||
)
|
||||
assert info.formatted_episode == "S01E05"
|
||||
|
||||
def test_formatted_episode_double_digit(self):
|
||||
"""Test SxxExx formatting with double digit numbers."""
|
||||
info = EpisodeInfo(
|
||||
series_id=123,
|
||||
series_name="Test Show",
|
||||
season=12,
|
||||
episode=25,
|
||||
episode_name="Episode",
|
||||
air_date="2024-01-15",
|
||||
)
|
||||
assert info.formatted_episode == "S12E25"
|
||||
|
||||
def test_formatted_episode_large_numbers(self):
|
||||
"""Test formatting with season/episode > 99."""
|
||||
info = EpisodeInfo(
|
||||
series_id=123,
|
||||
series_name="Long Running Show",
|
||||
season=67,
|
||||
episode=125,
|
||||
episode_name="Episode",
|
||||
air_date="2024-01-15",
|
||||
)
|
||||
assert info.formatted_episode == "S67E125"
|
||||
|
||||
|
||||
class TestTVDBClient:
|
||||
"""Tests for TVDB API client."""
|
||||
|
||||
@pytest.fixture
|
||||
def config(self):
|
||||
return TVDBConfig(
|
||||
api_key="test_key",
|
||||
pin="",
|
||||
cache_ttl=3600,
|
||||
show_mapping_file=None,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def client(self, config):
|
||||
with patch.object(TVDBClient, "_authenticate"):
|
||||
return TVDBClient(config)
|
||||
|
||||
def test_cache_valid(self, client):
|
||||
"""Test cache validity check."""
|
||||
import time
|
||||
|
||||
# Recent timestamp should be valid
|
||||
assert client._is_cache_valid(time.time() - 100)
|
||||
|
||||
# Old timestamp should be invalid
|
||||
assert not client._is_cache_valid(time.time() - 7200)
|
||||
|
||||
@patch("requests.Session.post")
|
||||
def test_authenticate(self, mock_post, config):
|
||||
"""Test authentication flow."""
|
||||
mock_post.return_value.json.return_value = {"data": {"token": "test_token"}}
|
||||
mock_post.return_value.raise_for_status = Mock()
|
||||
|
||||
client = TVDBClient(config)
|
||||
client._authenticate()
|
||||
|
||||
assert client._token == "test_token"
|
||||
mock_post.assert_called_once()
|
||||
|
||||
@patch("requests.Session.get")
|
||||
@patch("requests.Session.post")
|
||||
def test_search_series_success(self, mock_post, mock_get, config):
|
||||
"""Test successful series search."""
|
||||
mock_post.return_value.json.return_value = {"data": {"token": "test_token"}}
|
||||
mock_post.return_value.raise_for_status = Mock()
|
||||
|
||||
mock_get.return_value.json.return_value = {
|
||||
"data": [{"tvdb_id": 456, "name": "The Test Show"}]
|
||||
}
|
||||
mock_get.return_value.raise_for_status = Mock()
|
||||
|
||||
client = TVDBClient(config)
|
||||
result = client.search_series("Test Show")
|
||||
|
||||
assert result is not None
|
||||
assert result[0] == 456
|
||||
assert result[1] == "The Test Show"
|
||||
|
||||
@patch("requests.Session.get")
|
||||
@patch("requests.Session.post")
|
||||
def test_search_series_not_found(self, mock_post, mock_get, config):
|
||||
"""Test series search with no results."""
|
||||
mock_post.return_value.json.return_value = {"data": {"token": "test_token"}}
|
||||
mock_post.return_value.raise_for_status = Mock()
|
||||
|
||||
mock_get.return_value.json.return_value = {"data": []}
|
||||
mock_get.return_value.raise_for_status = Mock()
|
||||
|
||||
client = TVDBClient(config)
|
||||
result = client.search_series("Nonexistent Show")
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch("tvh2jellyfin.tvdb.requests.Session")
|
||||
def test_lookup_episode_success(self, mock_session_class, config):
|
||||
"""Test successful episode lookup."""
|
||||
from datetime import datetime
|
||||
|
||||
mock_session = Mock()
|
||||
mock_session_class.return_value = mock_session
|
||||
|
||||
# Use a timestamp and derive the expected air date from it
|
||||
timestamp = 1767744000
|
||||
expected_air_date = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d")
|
||||
|
||||
# Mock authentication
|
||||
mock_session.post.return_value.json.return_value = {"data": {"token": "test_token"}}
|
||||
mock_session.post.return_value.raise_for_status = Mock()
|
||||
|
||||
# Mock search and episode responses
|
||||
mock_session.get.return_value.raise_for_status = Mock()
|
||||
mock_session.get.return_value.json.side_effect = [
|
||||
{"data": [{"tvdb_id": 456, "name": "Coronation Street"}]},
|
||||
{
|
||||
"data": {
|
||||
"name": "Coronation Street",
|
||||
"episodes": [
|
||||
{
|
||||
"seasonNumber": 67,
|
||||
"number": 4,
|
||||
"name": "Episode 4",
|
||||
"aired": expected_air_date,
|
||||
}
|
||||
],
|
||||
},
|
||||
"links": {},
|
||||
},
|
||||
]
|
||||
|
||||
client = TVDBClient(config)
|
||||
result = client.lookup_episode("Coronation Street", timestamp)
|
||||
|
||||
assert result is not None
|
||||
assert result.season == 67
|
||||
assert result.episode == 4
|
||||
assert result.formatted_episode == "S67E04"
|
||||
|
||||
def test_show_mapping_file(self, tmp_path, config):
|
||||
"""Test show name mapping from file."""
|
||||
mapping_file = tmp_path / "mapping.json"
|
||||
mapping_file.write_text('{"Doctor Who": "Doctor Who (2005)"}')
|
||||
|
||||
config.show_mapping_file = str(mapping_file)
|
||||
|
||||
with patch.object(TVDBClient, "_authenticate"):
|
||||
client = TVDBClient(config)
|
||||
|
||||
assert client._show_mapping.get("Doctor Who") == "Doctor Who (2005)"
|
||||
|
||||
def test_caching_prevents_duplicate_calls(self, client):
|
||||
"""Test that cache prevents duplicate API calls."""
|
||||
import time
|
||||
|
||||
# Manually populate cache
|
||||
client._series_cache["test show"] = (123, "Test Show", time.time())
|
||||
|
||||
# Search should use cache, not make API call
|
||||
result = client.search_series("test show")
|
||||
|
||||
assert result == (123, "Test Show")
|
||||
121
tests/test_tvmaze.py
Normal file
121
tests/test_tvmaze.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Tests for TVmaze client."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tvh2jellyfin.tvmaze import TVMazeClient, TVMazeConfig
|
||||
|
||||
|
||||
class TestTVMazeClient:
|
||||
"""Tests for TVmaze API client."""
|
||||
|
||||
@pytest.fixture
|
||||
def config(self):
|
||||
return TVMazeConfig(cache_ttl=3600, enabled=True)
|
||||
|
||||
@pytest.fixture
|
||||
def client(self, config):
|
||||
return TVMazeClient(config)
|
||||
|
||||
@patch("requests.Session.get")
|
||||
def test_search_series_success(self, mock_get, client):
|
||||
"""Test successful series search."""
|
||||
mock_get.return_value.status_code = 200
|
||||
mock_get.return_value.json.return_value = {
|
||||
"id": 789,
|
||||
"name": "Coronation Street",
|
||||
}
|
||||
mock_get.return_value.raise_for_status = Mock()
|
||||
|
||||
result = client.search_series("Coronation Street")
|
||||
|
||||
assert result is not None
|
||||
assert result[0] == 789
|
||||
assert result[1] == "Coronation Street"
|
||||
|
||||
@patch("requests.Session.get")
|
||||
def test_search_series_not_found(self, mock_get, client):
|
||||
"""Test series search with 404 response."""
|
||||
mock_get.return_value.status_code = 404
|
||||
|
||||
result = client.search_series("Nonexistent Show")
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch("requests.Session.get")
|
||||
def test_get_episode_by_date_success(self, mock_get, client):
|
||||
"""Test successful episode lookup by date."""
|
||||
mock_get.return_value.status_code = 200
|
||||
mock_get.return_value.json.return_value = [
|
||||
{"season": 67, "number": 4, "name": "Episode 4"}
|
||||
]
|
||||
mock_get.return_value.raise_for_status = Mock()
|
||||
|
||||
result = client.get_episode_by_date(789, "2026-01-06", "Coronation Street")
|
||||
|
||||
assert result is not None
|
||||
assert result.season == 67
|
||||
assert result.episode == 4
|
||||
assert result.series_name == "Coronation Street"
|
||||
|
||||
@patch("requests.Session.get")
|
||||
def test_get_episode_by_date_not_found(self, mock_get, client):
|
||||
"""Test episode lookup with no results."""
|
||||
mock_get.return_value.status_code = 404
|
||||
|
||||
result = client.get_episode_by_date(789, "2026-01-01", "Coronation Street")
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch("requests.Session.get")
|
||||
def test_lookup_episode_full_flow(self, mock_get, client):
|
||||
"""Test full episode lookup flow."""
|
||||
# Set up responses for search and episode lookup
|
||||
mock_get.return_value.status_code = 200
|
||||
mock_get.return_value.json.side_effect = [
|
||||
{"id": 789, "name": "Coronation Street"}, # Search response
|
||||
[{"season": 67, "number": 4, "name": "Episode 4"}], # Episode response
|
||||
]
|
||||
mock_get.return_value.raise_for_status = Mock()
|
||||
|
||||
# Timestamp for 2026-01-06
|
||||
result = client.lookup_episode("Coronation Street", 1767744000)
|
||||
|
||||
assert result is not None
|
||||
assert result.series_name == "Coronation Street"
|
||||
assert result.formatted_episode == "S67E04"
|
||||
|
||||
def test_caching_series(self, client):
|
||||
"""Test that series cache prevents duplicate calls."""
|
||||
import time
|
||||
|
||||
# Manually populate cache
|
||||
client._series_cache["coronation street"] = (789, "Coronation Street", time.time())
|
||||
|
||||
# Should use cache
|
||||
result = client.search_series("coronation street")
|
||||
|
||||
assert result == (789, "Coronation Street")
|
||||
|
||||
def test_caching_episodes(self, client):
|
||||
"""Test that episode cache prevents duplicate calls."""
|
||||
import time
|
||||
|
||||
from tvh2jellyfin.tvdb import EpisodeInfo
|
||||
|
||||
cached_episode = EpisodeInfo(
|
||||
series_id=789,
|
||||
series_name="Coronation Street",
|
||||
season=67,
|
||||
episode=4,
|
||||
episode_name="Episode 4",
|
||||
air_date="2026-01-06",
|
||||
)
|
||||
client._episode_cache["789:2026-01-06"] = (cached_episode, time.time())
|
||||
|
||||
# Should use cache
|
||||
result = client.get_episode_by_date(789, "2026-01-06", "Coronation Street")
|
||||
|
||||
assert result is not None
|
||||
assert result.season == 67
|
||||
Reference in New Issue
Block a user