feat: Add AGENTS.md and new downloaders with metadata enrichment

- Add AGENTS.md for agentic coding guidelines
- Add Oneupload and Smoothpre video player downloaders
- Add MetadataEnrichment service with Kitsu API fallback
- Add tests for metadata enrichment and provider detection
- Update .gitignore to ignore runtime config files
This commit is contained in:
root
2026-02-24 20:14:31 +00:00
parent da5403a307
commit 2482a1fe58
7 changed files with 2119 additions and 0 deletions
+442
View File
@@ -0,0 +1,442 @@
"""
Tests for metadata enrichment with Kitsu API fallback.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import datetime, timedelta
from app.metadata_enrichment import MetadataEnricher
from app.models import AnimeMetadata
@pytest.fixture
async def enricher(temp_dir):
"""Create a MetadataEnricher instance with temp cache dir."""
enricher = MetadataEnricher(cache_dir=temp_dir)
yield enricher
await enricher.close()
@pytest.fixture
def mock_kitsu_api():
"""Mock Kitsu API responses in raw Kitsu format."""
mock_data = {
'title': 'Naruto',
'title_japanese': 'ナルト',
'title_english': 'Naruto',
'synopsis': 'A test synopsis from Kitsu',
'genres': ['Action', 'Adventure'],
'score': 8.5,
'year': 2002,
'episodes': 220,
'status': 'Finished Airing',
'images': {
'jpg': {
'large_image_url': 'https://kitsu.io/naruto-poster.jpg',
'image_url': 'https://kitsu.io/naruto-poster-small.jpg'
},
'webp': {
'large_image_url': 'https://kitsu.io/naruto-banner.jpg'
}
}
}
return mock_data
@pytest.fixture
def mock_kitsu_api_raw():
"""Mock raw Kitsu API response format."""
return {
'mal_id': 123,
'title': 'Naruto',
'title_japanese': 'ナルト',
'title_english': 'Naruto',
'episodes': 220,
'status': 'Finished Airing',
'score': 8.5,
'synopsis': 'A test synopsis from Kitsu',
'genres': ['Action', 'Adventure'],
'images': {
'jpg': {
'image_url': 'https://kitsu.io/naruto-poster-small.jpg',
'large_image_url': 'https://kitsu.io/naruto-poster.jpg'
},
'webp': {
'image_url': 'https://kitsu.io/naruto-poster-small.webp',
'large_image_url': 'https://kitsu.io/naruto-banner.jpg'
}
},
'url': 'https://kitsu.io/anime/123',
'subtype': 'TV',
'year': 2002
}
class TestMetadataEnricher:
"""Test MetadataEnricher functionality."""
def test_init_creates_cache_dir(self, enricher, temp_dir):
"""Test that enricher creates cache directory."""
assert enricher.cache_dir == temp_dir
assert enricher.cache_file == temp_dir / "metadata_cache.json"
def test_get_cache_key(self, enricher):
"""Test cache key generation."""
key1 = enricher._get_cache_key("Naruto", "https://example.com/naruto")
key2 = enricher._get_cache_key("Naruto", "https://example.com/naruto")
key3 = enricher._get_cache_key("Naruto", "https://example.com/sasuke")
# Same inputs should produce same key
assert key1 == key2
# Different URL should produce different key
assert key1 != key3
def test_get_missing_fields(self, enricher):
"""Test identification of missing fields."""
# Complete metadata
complete = {
'synopsis': 'Test synopsis',
'genres': ['Action'],
'rating': '8.5/10',
'release_year': 2020,
'studio': 'Studio Pierrot',
'poster_image': 'https://example.com/poster.jpg',
'banner_image': 'https://example.com/banner.jpg',
'total_episodes': 12,
'status': 'Completed',
'alternative_titles': ['Japanese Title'] # Now required for completeness
}
missing = enricher._get_missing_fields(complete)
assert len(missing) == 0
# Incomplete metadata
incomplete = {
'synopsis': 'Test synopsis',
'genres': [] # Empty list counts as missing
}
missing = enricher._get_missing_fields(incomplete)
assert 'rating' in missing
assert 'release_year' in missing
# Note: studio is not in KITSU_FIELDS, so it won't be detected as missing
assert 'status' in missing
assert 'genres' in missing # Empty list is considered missing
assert len(missing) >= 4
def test_convert_kitsu_to_metadata(self, enricher, mock_kitsu_api):
"""Test conversion of Kitsu API response to metadata format."""
metadata = enricher._convert_kitsu_to_metadata(mock_kitsu_api)
assert metadata['synopsis'] == 'A test synopsis from Kitsu'
assert metadata['genres'] == ['Action', 'Adventure']
assert metadata['rating'] == '8.5/10'
assert metadata['release_year'] == 2002
assert metadata['poster_image'] == 'https://kitsu.io/naruto-poster.jpg'
assert metadata['banner_image'] == 'https://kitsu.io/naruto-banner.jpg'
assert metadata['total_episodes'] == 220
assert metadata['status'] == 'Completed'
assert 'ナルト' in metadata['alternative_titles']
assert 'Naruto' in metadata['alternative_titles']
def test_convert_kitsu_status_translation(self, enricher):
"""Test Kitsu status translation."""
test_cases = [
('Airing', 'Ongoing'),
('Finished Airing', 'Completed'),
('To Be Aired', 'Upcoming'),
]
for kitsu_status, expected_status in test_cases:
metadata = enricher._convert_kitsu_to_metadata({
'status': kitsu_status
})
assert metadata['status'] == expected_status
def test_merge_metadata_prefer_provider(self, enricher, mock_kitsu_api):
"""Test that provider metadata takes priority over Kitsu."""
provider_meta = {
'synopsis': 'Provider synopsis (better)',
'genres': ['Action'],
'rating': '9.0/10', # Different from Kitsu
'release_year': 2002,
'studio': 'Studio Pierrot', # Not in Kitsu
}
kitsu_meta = enricher._convert_kitsu_to_metadata(mock_kitsu_api)
merged = enricher._merge_metadata(provider_meta, kitsu_meta)
# Provider data should be preserved
assert merged['synopsis'] == 'Provider synopsis (better)'
assert merged['rating'] == '9.0/10'
assert merged['studio'] == 'Studio Pierrot'
# Kitsu data should fill gaps
assert merged['total_episodes'] == 220
assert merged['status'] == 'Completed'
def test_calculate_quality_score(self, enricher):
"""Test metadata quality score calculation."""
# Complete metadata should have high score
complete = {
'synopsis': 'A detailed synopsis of the anime with lots of information',
'genres': ['Action', 'Adventure', 'Fantasy'],
'rating': '8.5/10',
'release_year': 2020,
'studio': 'Studio Pierrot',
'poster_image': 'https://example.com/poster.jpg',
'banner_image': 'https://example.com/banner.jpg',
'total_episodes': 12,
'status': 'Completed',
'alternative_titles': ['Japanese Title']
}
score = enricher._calculate_quality_score(complete)
assert score > 0.8 # Should be high quality
# Minimal metadata should have low score
minimal = {
'synopsis': 'Short',
'genres': ['Action']
}
score = enricher._calculate_quality_score(minimal)
assert score < 0.5 # Should be low quality
@pytest.mark.asyncio
async def test_enrich_metadata_with_kitsu_fallback(self, enricher, mock_kitsu_api_raw):
"""Test enrichment with Kitsu API fallback."""
provider_metadata = {
'synopsis': 'Provider synopsis',
'genres': ['Action'],
# Missing: rating, release_year, poster_image, etc.
}
# Mock the Kitsu API search to return raw format
with patch.object(enricher.kitsu_api, 'search_anime', return_value=[mock_kitsu_api_raw]):
result = await enricher.enrich_metadata(
provider_metadata=provider_metadata,
title='Naruto',
url='https://example.com/naruto',
use_kitsu_fallback=True
)
# Should have Kitsu data
assert result.rating == '8.5/10'
assert result.release_year == 2002
assert result.poster_image is not None
assert result.total_episodes == 220
assert result.status == 'Completed'
# Should preserve provider data
assert result.synopsis == 'Provider synopsis'
@pytest.mark.asyncio
async def test_enrich_metadata_without_kitsu_fallback(self, enricher):
"""Test enrichment without Kitsu fallback."""
provider_metadata = {
'synopsis': 'Provider synopsis',
'genres': ['Action'],
}
result = await enricher.enrich_metadata(
provider_metadata=provider_metadata,
title='Naruto',
url='https://example.com/naruto',
use_kitsu_fallback=False
)
# Should only have provider data
assert result.synopsis == 'Provider synopsis'
assert result.genres == ['Action']
assert result.rating is None # No Kitsu fallback
assert result.release_year is None
@pytest.mark.asyncio
async def test_enrich_metadata_caching(self, enricher, mock_kitsu_api_raw):
"""Test that enriched metadata is cached."""
provider_metadata = {
'synopsis': 'Provider synopsis',
'genres': ['Action'],
}
with patch.object(enricher.kitsu_api, 'search_anime', return_value=[mock_kitsu_api_raw]) as mock_search:
# First call should fetch from Kitsu
result1 = await enricher.enrich_metadata(
provider_metadata=provider_metadata,
title='Naruto',
url='https://example.com/naruto',
use_kitsu_fallback=True
)
assert mock_search.call_count == 1
# Second call should use cache
result2 = await enricher.enrich_metadata(
provider_metadata=provider_metadata,
title='Naruto',
url='https://example.com/naruto',
use_kitsu_fallback=True
)
assert mock_search.call_count == 1 # No additional call
# Results should be identical
assert result1.model_dump() == result2.model_dump()
@pytest.mark.asyncio
async def test_enrich_search_results(self, enricher, mock_kitsu_api_raw):
"""Test enrichment of multiple search results."""
search_results = [
{
'title': 'Naruto',
'url': 'https://example.com/naruto',
'metadata': {
'synopsis': 'Brief synopsis',
'genres': ['Action']
}
},
{
'title': 'One Piece',
'url': 'https://example.com/onepiece',
'metadata': {
'synopsis': 'Another synopsis',
'genres': ['Adventure']
}
},
{
'title': 'No Metadata',
'url': 'https://example.com/nometa'
}
]
with patch.object(enricher.kitsu_api, 'search_anime', return_value=[mock_kitsu_api_raw]):
enriched = await enricher.enrich_search_results(
results=search_results,
use_kitsu_fallback=True
)
# Should enrich results with metadata
assert len(enriched) == 3
# First result should be enriched
assert enriched[0]['metadata']['rating'] == '8.5/10'
assert enriched[0]['metadata']['release_year'] == 2002
# Second result should also be enriched
assert enriched[1]['metadata']['rating'] == '8.5/10'
# Third result should have no metadata field
assert 'metadata' not in enriched[2] or enriched[2].get('metadata') is None
@pytest.mark.asyncio
async def test_cache_expiry(self, enricher, mock_kitsu_api_raw):
"""Test that expired cache entries are removed."""
provider_metadata = {'synopsis': 'Test'}
# Add an expired entry to cache
cache_key = enricher._get_cache_key('Test', 'https://example.com/test')
enricher._cache[cache_key] = {
'metadata': provider_metadata,
'cached_at': (datetime.now() - timedelta(hours=25)).isoformat() # Expired
}
enricher._cache_dirty = True
with patch.object(enricher.kitsu_api, 'search_anime', return_value=[mock_kitsu_api_raw]) as mock_search:
# Should fetch from Kitsu since cache is expired
result = await enricher.enrich_metadata(
provider_metadata=provider_metadata,
title='Test',
url='https://example.com/test',
use_kitsu_fallback=True
)
assert mock_search.call_count == 1
assert result.rating == '8.5/10'
@pytest.mark.asyncio
async def test_close_saves_cache(self, enricher):
"""Test that closing the enricher saves the cache."""
# Add something to cache
cache_key = 'test_key'
enricher._cache[cache_key] = {
'metadata': {'test': 'data'},
'cached_at': datetime.now().isoformat()
}
enricher._cache_dirty = True
await enricher.close()
# Cache file should exist
assert enricher.cache_file.exists()
@pytest.mark.asyncio
async def test_fetch_from_kitsu_error_handling(self, enricher):
"""Test error handling when Kitsu API fails."""
provider_metadata = {'synopsis': 'Test'}
with patch.object(enricher, '_fetch_from_kitsu', side_effect=Exception("API Error")):
result = await enricher.enrich_metadata(
provider_metadata=provider_metadata,
title='NonExistent Anime',
url='https://example.com/nonexistent',
use_kitsu_fallback=True
)
# Should return provider metadata despite error
assert result.synopsis == 'Test'
assert result.rating is None
class TestMetadataEnrichmentIntegration:
"""Integration tests for metadata enrichment."""
@pytest.mark.asyncio
@pytest.mark.slow
async def test_kitsu_api_integration(self):
"""Test actual Kitsu API integration (marked as slow)."""
enricher = MetadataEnricher()
try:
# Search for a well-known anime
results = await enricher.kitsu_api.search_anime('Naruto', limit=1)
assert len(results) > 0
assert 'title' in results[0]
assert 'synopsis' in results[0] or 'genres' in results[0]
finally:
await enricher.close()
@pytest.mark.asyncio
@pytest.mark.slow
async def test_full_enrichment_flow(self):
"""Test complete enrichment flow with real data (marked as slow)."""
enricher = MetadataEnricher()
try:
# Simulate provider metadata with gaps
provider_metadata = {
'synopsis': 'Naruto Uzumaki wants to be the best ninja.',
'genres': ['Action'],
# Missing many fields
}
result = await enricher.enrich_metadata(
provider_metadata=provider_metadata,
title='Naruto',
url='https://test.com/naruto',
use_kitsu_fallback=True
)
# Should have enriched data
assert result.synopsis is not None
assert len(result.genres) > 0
# Kitsu might have filled some gaps
# (We can't assert specific fields as Kitsu responses may vary)
quality_score = result.model_dump().get('_quality_score', 0)
assert quality_score >= 0
finally:
await enricher.close()
+479
View File
@@ -0,0 +1,479 @@
"""
Unit tests for provider detection and routing
Tests URL-to-provider matching and downloader factory
"""
import pytest
from app.providers import (
detect_provider_from_url,
ANIME_PROVIDERS,
FILE_HOSTS
)
from app.downloaders import get_downloader, get_anime_site, get_series_site, get_video_player
class TestDetectProviderFromURL:
"""Tests for detect_provider_from_url function"""
def test_detect_anime_sama(self):
"""Test detection of Anime-Sama provider"""
urls = [
"https://anime-sama.si/catalogue/naruto/s1/vostfr/",
"https://www.anime-sama.fi/anime/test",
"https://anime-sama.pw/test",
]
for url in urls:
provider = detect_provider_from_url(url)
assert provider is not None
assert provider["name"] == "anime-sama"
def test_detect_neko_sama(self):
"""Test detection of Neko-Sama provider"""
urls = [
"https://neko-sama.fr/anime/naruto",
"https://www.neko-sama.fr/anime/one-piece",
]
for url in urls:
provider = detect_provider_from_url(url)
assert provider is not None
assert provider["name"] == "neko-sama"
def test_detect_anime_ultime(self):
"""Test detection of Anime-Ultime provider"""
urls = [
"https://anime-ultime.net/fiche-anime/naruto",
"https://www.anime-ultime.net/anime/test",
]
for url in urls:
provider = detect_provider_from_url(url)
assert provider is not None
assert provider["name"] == "anime-ultime"
def test_detect_vostfree(self):
"""Test detection of Vostfree provider"""
urls = [
"https://vostfree.cc/anime/naruto",
"https://www.vostfree.cc/anime/test",
]
for url in urls:
provider = detect_provider_from_url(url)
assert provider is not None
assert provider["name"] == "vostfree"
def test_detect_french_manga(self):
"""Test detection of French-Manga provider"""
urls = [
"https://french-manga.net/anime/naruto",
"https://www.french-manga.net/anime/test",
]
for url in urls:
provider = detect_provider_from_url(url)
assert provider is not None
assert provider["name"] == "french-manga"
def test_detect_fs7(self):
"""Test detection of FS7 (French Stream) provider"""
urls = [
"https://fs7.space/series/test",
"https://www.fs7.space/series/breaking-bad",
]
for url in urls:
provider = detect_provider_from_url(url)
assert provider is not None
assert provider["name"] == "fs7"
def test_detect_file_hosts(self):
"""Test detection of file hosting services"""
test_cases = [
("https://doodstream.com/test/abc", "doodstream"),
("https://ds2play.com/test/abc", "doodstream"),
("https://rapidfile.com/test/abc", "rapidfile"),
("https://uptobox.com/test/abc", "uptobox"),
("https://1fichier.com/test", "unfichier"),
("https://vidmoly.to/test", "vidmoly"),
("https://sendvid.com/test", "sendvid"),
("https://sibnet.ru/test", "sibnet"),
("https://lpayer.com/test", "lpayer"),
("https://vidzy.com/test", "vidzy"),
("https://luluv.com/test", "luluv"),
("https://uqload.com/test", "uqload"),
]
for url, expected_name in test_cases:
provider = detect_provider_from_url(url)
assert provider is not None, f"Failed to detect {expected_name} from {url}"
assert provider["name"] == expected_name
def test_detect_unknown_provider(self):
"""Test that unknown URLs return None"""
unknown_urls = [
"https://unknown-site.com/test",
"https://google.com/search",
"https://example.com/anime",
]
for url in unknown_urls:
provider = detect_provider_from_url(url)
assert provider is None
def test_detect_empty_url(self):
"""Test detection with empty URL"""
assert detect_provider_from_url("") is None
assert detect_provider_from_url(None) is None
def test_detect_case_insensitive(self):
"""Test that detection is case-insensitive for domains"""
url = "https://Anime-Sama.si/test"
provider = detect_provider_from_url(url)
assert provider is not None
assert provider["name"] == "anime-sama"
def test_detect_with_path_and_query(self):
"""Test detection with complex paths and query strings"""
urls = [
"https://anime-sama.si/catalogue/naruto/s1/vostfr/?page=1",
"https://neko-sama.fr/anime/one-piece?ep=1",
"https://doodstream.com/e/abc123#start=0",
]
for url in urls:
provider = detect_provider_from_url(url)
assert provider is not None
def test_provider_structure(self):
"""Test that detected provider has correct structure"""
provider = detect_provider_from_url("https://anime-sama.si/test")
assert "name" in provider
assert "icon" in provider
assert "color" in provider
assert "domains" in provider
assert isinstance(provider["domains"], list)
class TestAnimeProvidersConfig:
"""Tests for ANIME_PROVIDERS configuration"""
def test_anime_providers_structure(self):
"""Test that all anime providers have required fields"""
for provider_name, provider_data in ANIME_PROVIDERS.items():
assert "name" in provider_data
assert "domains" in provider_data
assert "icon" in provider_data
assert "color" in provider_data
assert "url_pattern" in provider_data
assert isinstance(provider_data["domains"], list)
def test_known_anime_providers_exist(self):
"""Test that known anime providers are configured"""
known_providers = [
"anime-sama",
"neko-sama",
"anime-ultime",
"vostfree",
"french-manga"
]
for provider in known_providers:
assert provider in ANIME_PROVIDERS
def test_anime_provider_domains(self):
"""Test that anime providers have valid domains"""
for provider_data in ANIME_PROVIDERS.values():
assert len(provider_data["domains"]) > 0
for domain in provider_data["domains"]:
assert isinstance(domain, str)
assert "." in domain # Basic domain validation
def test_anime_provider_url_patterns(self):
"""Test that URL patterns are valid"""
for provider_data in ANIME_PROVIDERS.values():
pattern = provider_data["url_pattern"]
assert isinstance(pattern, str)
assert len(pattern) > 0
class TestFileHostsConfig:
"""Tests for FILE_HOSTS configuration"""
def test_file_hosts_structure(self):
"""Test that all file hosts have required fields"""
for host_name, host_data in FILE_HOSTS.items():
assert "name" in host_data
assert "domains" in host_data
assert "icon" in host_data
assert "color" in host_data
assert isinstance(host_data["domains"], list)
def test_known_file_hosts_exist(self):
"""Test that known file hosts are configured"""
known_hosts = [
"unfichier",
"doodstream",
"rapidfile",
"uptobox",
"vidmoly",
"sendvid",
"sibnet",
"lpayer",
"vidzy",
"luluv",
"uqload"
]
for host in known_hosts:
assert host in FILE_HOSTS
def test_file_host_domains(self):
"""Test that file hosts have valid domains"""
for host_data in FILE_HOSTS.values():
assert len(host_data["domains"]) > 0
for domain in host_data["domains"]:
assert isinstance(domain, str)
assert "." in domain
class TestGetDownloader:
"""Tests for get_downloader factory function"""
@pytest.mark.asyncio
async def test_get_anime_site_downloader(self):
"""Test getting anime site downloader"""
url = "https://anime-sama.si/catalogue/naruto/"
downloader = await get_downloader(url)
assert downloader is not None
# Should return an anime site downloader
@pytest.mark.asyncio
async def test_get_series_site_downloader(self):
"""Test getting series site downloader"""
url = "https://fs7.space/series/test"
downloader = await get_downloader(url)
assert downloader is not None
# Should return a series site downloader
@pytest.mark.asyncio
async def test_get_video_player_downloader(self):
"""Test getting video player downloader"""
url = "https://doodstream.com/e/abc123"
downloader = await get_downloader(url)
assert downloader is not None
# Should return a video player downloader
@pytest.mark.asyncio
async def test_get_unknown_url_downloader(self):
"""Test getting generic downloader for unknown URL"""
url = "https://unknown-site.com/video"
downloader = await get_downloader(url)
assert downloader is not None
# Should return GenericDownloader
class TestGetAnimeSite:
"""Tests for get_anime_site factory function"""
@pytest.mark.asyncio
async def test_get_anime_sama_site(self):
"""Test getting Anime-Sama site"""
from app.downloaders.anime_sites import AnimeSamaDownloader
url = "https://anime-sama.si/catalogue/naruto/"
downloader = await get_anime_site(url)
assert isinstance(downloader, AnimeSamaDownloader)
@pytest.mark.asyncio
async def test_get_neko_sama_site(self):
"""Test getting Neko-Sama site"""
from app.downloaders.anime_sites import NekoSamaDownloader
url = "https://neko-sama.fr/anime/one-piece"
downloader = await get_anime_site(url)
assert isinstance(downloader, NekoSamaDownloader)
@pytest.mark.asyncio
async def test_get_anime_site_with_series_url(self):
"""Test that series URL returns None for anime site"""
url = "https://fs7.space/series/test"
downloader = await get_anime_site(url)
assert downloader is None
@pytest.mark.asyncio
async def test_get_anime_site_with_video_player_url(self):
"""Test that video player URL returns None for anime site"""
url = "https://doodstream.com/e/abc123"
downloader = await get_anime_site(url)
assert downloader is None
class TestGetSeriesSite:
"""Tests for get_series_site factory function"""
@pytest.mark.asyncio
async def test_get_fs7_site(self):
"""Test getting FS7 series site"""
from app.downloaders.series_sites import FS7Downloader
url = "https://fs7.space/series/test"
downloader = await get_series_site(url)
assert isinstance(downloader, FS7Downloader)
@pytest.mark.asyncio
async def test_get_series_site_with_anime_url(self):
"""Test that anime URL returns None for series site"""
url = "https://anime-sama.si/catalogue/naruto/"
downloader = await get_series_site(url)
assert downloader is None
@pytest.mark.asyncio
async def test_get_series_site_with_video_player_url(self):
"""Test that video player URL returns None for series site"""
url = "https://doodstream.com/e/abc123"
downloader = await get_series_site(url)
assert downloader is None
class TestGetVideoPlayer:
"""Tests for get_video_player factory function"""
@pytest.mark.asyncio
async def test_get_doodstream_player(self):
"""Test getting Doodstream player"""
from app.downloaders.video_players import DoodstreamDownloader
url = "https://doodstream.com/e/abc123"
player = await get_video_player(url)
assert isinstance(player, DoodstreamDownloader)
@pytest.mark.asyncio
async def test_get_unfichier_player(self):
"""Test getting 1fichier player"""
from app.downloaders.video_players import UnFichierDownloader
url = "https://1fichier.com/?abc123"
player = await get_video_player(url)
assert isinstance(player, UnFichierDownloader)
@pytest.mark.asyncio
async def test_get_vidmoly_player(self):
"""Test getting VidMoly player"""
from app.downloaders.video_players import VidMolyDownloader
url = "https://vidmoly.to/abc123"
player = await get_video_player(url)
assert isinstance(player, VidMolyDownloader)
@pytest.mark.asyncio
async def test_get_video_player_with_anime_url(self):
"""Test that anime site URL returns None for video player"""
url = "https://anime-sama.si/catalogue/naruto/"
player = await get_video_player(url)
assert player is None
@pytest.mark.asyncio
async def test_get_video_player_with_unknown_url(self):
"""Test that unknown URL returns None for video player"""
url = "https://unknown-site.com/video"
player = await get_video_player(url)
assert player is None
class TestDownloaderPriority:
"""Tests for downloader priority and routing"""
@pytest.mark.asyncio
async def test_anime_site_has_priority_over_series(self):
"""Test that anime sites are checked before series sites"""
# This is implicit in the get_downloader implementation
# We just verify it works correctly
url = "https://anime-sama.si/catalogue/naruto/"
downloader = await get_downloader(url)
assert downloader is not None
# Should be an anime site, not series site or video player
from app.downloaders.anime_sites import BaseAnimeSite
assert isinstance(downloader, BaseAnimeSite)
@pytest.mark.asyncio
async def test_series_site_has_priority_over_video_player(self):
"""Test that series sites are checked before video players"""
url = "https://fs7.space/series/test"
downloader = await get_downloader(url)
assert downloader is not None
# Should be a series site, not video player
from app.downloaders.series_sites import BaseSeriesSite
assert isinstance(downloader, BaseSeriesSite)
class TestProviderDomains:
"""Tests for provider domain matching"""
def test_anime_sama_domains(self):
"""Test Anime-Sama domain variations"""
from app.downloaders.anime_sites import AnimeSamaDownloader
downloader = AnimeSamaDownloader()
# These should be handled
assert downloader.can_handle("https://anime-sama.si/test")
assert downloader.can_handle("https://www.anime-sama.fi/test")
# These should not
assert not downloader.can_handle("https://neko-sama.fr/test")
assert not downloader.can_handle("https://doodstream.com/test")
def test_neko_sama_domains(self):
"""Test Neko-Sama domain variations"""
from app.downloaders.anime_sites import NekoSamaDownloader
downloader = NekoSamaDownloader()
assert downloader.can_handle("https://neko-sama.fr/anime/test")
assert not downloader.can_handle("https://anime-sama.si/test")
def test_doodstream_domains(self):
"""Test Doodstream domain variations"""
from app.downloaders.video_players import DoodstreamDownloader
downloader = DoodstreamDownloader()
assert downloader.can_handle("https://doodstream.com/e/abc")
assert downloader.can_handle("https://ds2play.com/e/abc")
assert not downloader.can_handle("https://vidmoly.to/abc")
def test_subdomain_handling(self):
"""Test that subdomains are handled correctly"""
from app.downloaders.anime_sites import AnimeSamaDownloader
downloader = AnimeSamaDownloader()
# With and without www
assert downloader.can_handle("https://anime-sama.si/test")
assert downloader.can_handle("https://www.anime-sama.si/test")
def test_protocol_handling(self):
"""Test that both HTTP and HTTPS are handled"""
from app.downloaders.anime_sites import AnimeSamaDownloader
downloader = AnimeSamaDownloader()
assert downloader.can_handle("https://anime-sama.si/test")
# HTTP should also work (though less secure)
assert downloader.can_handle("http://anime-sama.si/test")
class TestProviderEdgeCases:
"""Tests for edge cases in provider detection"""
def test_url_with_port(self):
"""Test URL with port number"""
provider = detect_provider_from_url("https://anime-sama.si:443/test")
assert provider is not None
assert provider["name"] == "anime-sama"
def test_url_with_fragment(self):
"""Test URL with fragment identifier"""
provider = detect_provider_from_url("https://anime-sama.si/test#section")
assert provider is not None
assert provider["name"] == "anime-sama"
def test_url_with_auth(self):
"""Test URL with authentication (should not happen in practice)"""
# URLs with auth @ should still be detected
provider = detect_provider_from_url("https://user:pass@anime-sama.si/test")
# Detection might fail due to parsing, but shouldn't crash
assert provider is not None or provider is None
def test_idn_domains(self):
"""Test internationalized domain names"""
# Most providers use ASCII domains, but let's test the logic
url = "https://xn--anime-sama-test.si/catalogue/test"
provider = detect_provider_from_url(url)
# Should not crash
def test_punycode_domains(self):
"""Test punycode-encoded domains"""
# ASCII encoding of international domains
url = "https://anime-sama.si/catalogue/test"
provider = detect_provider_from_url(url)
assert provider is not None