CI / lint (push) Canceled after 0s
CI / typecheck (push) Canceled after 0s
CI / unit-tests (push) Canceled after 0s
CI / integration-tests (push) Canceled after 0s
CI / container-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
Collections were bare stubs (name only) lazily created the moment a
movie's belongs_to_collection reference was seen. Adds a real
ingest_collection pipeline: TMDb's /collection/{id} for overview and
poster/backdrop artwork, plus ordered CollectionMovie membership --
linking only movies already ingested into Aurora, the same
never-fabricate-a-stub rule movie recommendations already follow.
get_or_create_collection now refreshes name/overview on an existing
stub instead of only creating, guarded so a bare movie-stub call
(overview=None) never clobbers real data a full ingestion already
filled in.
New POST /api/v1/admin/ingest/collection endpoint and
ingest_collection_task; GET /api/v1/collections/{id} needed no changes
since its read path (collection_service.py) already assembled the full
detail shape.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
454 lines
16 KiB
Python
454 lines
16 KiB
Python
"""Unit tests for the TMDb normalizer (app/services/metadata/normalizer.py)
|
|
— section 13. Uses hand-built payload fragments shaped like real TMDb
|
|
responses rather than calling the network."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
from app.services.metadata.normalizer import (
|
|
normalize_collection,
|
|
normalize_movie,
|
|
normalize_person,
|
|
normalize_season,
|
|
normalize_series,
|
|
)
|
|
|
|
FAKE_MOVIE = {
|
|
"id": 603,
|
|
"title": "The Matrix",
|
|
"original_title": "The Matrix",
|
|
"overview": "A computer hacker learns...",
|
|
"tagline": "Welcome to the Real World",
|
|
"status": "Released",
|
|
"original_language": "en",
|
|
"release_date": "1999-03-30",
|
|
"runtime": 136,
|
|
"budget": 63000000,
|
|
"revenue": 463517383,
|
|
"popularity": 45.5,
|
|
"vote_average": 8.2,
|
|
"vote_count": 24000,
|
|
"adult": False,
|
|
"imdb_id": "tt0133093",
|
|
"genres": [{"id": 28, "name": "Action"}, {"id": 878, "name": "Science Fiction"}],
|
|
"production_companies": [
|
|
{
|
|
"id": 79,
|
|
"name": "Village Roadshow Pictures",
|
|
"origin_country": "US",
|
|
"logo_path": "/village-roadshow-logo.png",
|
|
}
|
|
],
|
|
"poster_path": "/poster.jpg",
|
|
"backdrop_path": "/backdrop.jpg",
|
|
"belongs_to_collection": {"id": 2344, "name": "The Matrix Collection"},
|
|
"credits": {
|
|
"cast": [
|
|
{
|
|
"id": 6384,
|
|
"name": "Keanu Reeves",
|
|
"character": "Neo",
|
|
"order": 0,
|
|
"profile_path": "/keanu.jpg",
|
|
"gender": 2,
|
|
}
|
|
],
|
|
"crew": [
|
|
{
|
|
"id": 9339,
|
|
"name": "Lana Wachowski",
|
|
"department": "Directing",
|
|
"job": "Director",
|
|
"gender": 1,
|
|
}
|
|
],
|
|
},
|
|
"images": {
|
|
"posters": [
|
|
{"file_path": "/poster.jpg", "width": 2000, "height": 3000, "aspect_ratio": 0.667, "iso_639_1": "en", "vote_average": 8.1}
|
|
],
|
|
"backdrops": [],
|
|
"logos": [],
|
|
},
|
|
"videos": {
|
|
"results": [
|
|
{
|
|
"id": "abc123",
|
|
"type": "Trailer",
|
|
"site": "YouTube",
|
|
"key": "vKQi3bBA1y8",
|
|
"iso_639_1": "en",
|
|
"iso_3166_1": "US",
|
|
"official": True,
|
|
"published_at": "1999-03-01T00:00:00.000Z",
|
|
}
|
|
]
|
|
},
|
|
"keywords": {"keywords": [{"id": 1, "name": "dystopia"}]},
|
|
"alternative_titles": {"titles": [{"title": "Matrix", "iso_3166_1": "US"}]},
|
|
"external_ids": {"imdb_id": "tt0133093"},
|
|
"recommendations": {"results": [{"id": 604}]},
|
|
}
|
|
|
|
FAKE_PERSON = {
|
|
"id": 6384,
|
|
"name": "Keanu Reeves",
|
|
"biography": "Keanu Charles Reeves is a Canadian actor.",
|
|
"birthday": "1964-09-02",
|
|
"deathday": None,
|
|
"place_of_birth": "Beirut, Lebanon",
|
|
"gender": 2,
|
|
"known_for_department": "Acting",
|
|
"popularity": 30.2,
|
|
"also_known_as": ["Keanu Charles Reeves"],
|
|
"profile_path": "/keanu.jpg",
|
|
"images": {"profiles": [{"file_path": "/keanu.jpg", "width": 1000, "height": 1500, "aspect_ratio": 0.667, "iso_639_1": None, "vote_average": 5.0}]},
|
|
"external_ids": {"imdb_id": "nm0000206"},
|
|
}
|
|
|
|
|
|
def test_normalize_movie_maps_core_fields():
|
|
movie = normalize_movie(FAKE_MOVIE)
|
|
assert movie.external_id == "603"
|
|
assert movie.title == "The Matrix"
|
|
assert movie.status == "released"
|
|
assert movie.release_date == date(1999, 3, 30)
|
|
assert movie.runtime_minutes == 136
|
|
assert movie.budget == 63000000
|
|
assert movie.adult is False
|
|
|
|
|
|
def test_normalize_movie_maps_genres_and_keywords():
|
|
movie = normalize_movie(FAKE_MOVIE)
|
|
assert movie.genres == ["Action", "Science Fiction"]
|
|
assert movie.keywords == ["dystopia"]
|
|
assert movie.alternate_titles == ["Matrix"]
|
|
|
|
|
|
def test_normalize_movie_maps_cast_and_crew():
|
|
movie = normalize_movie(FAKE_MOVIE)
|
|
assert len(movie.cast) == 1
|
|
assert movie.cast[0].external_id == "6384"
|
|
assert movie.cast[0].department == "Acting"
|
|
assert movie.cast[0].character == "Neo"
|
|
assert movie.cast[0].gender == "male"
|
|
|
|
assert len(movie.crew) == 1
|
|
assert movie.crew[0].department == "Directing"
|
|
assert movie.crew[0].job == "Director"
|
|
assert movie.crew[0].gender == "female"
|
|
|
|
|
|
def test_normalize_movie_maps_artwork_and_videos():
|
|
movie = normalize_movie(FAKE_MOVIE)
|
|
poster_types = [a.artwork_type for a in movie.artwork]
|
|
assert poster_types.count("poster") >= 1
|
|
assert all(a.url.startswith("https://image.tmdb.org/t/p/original/") for a in movie.artwork)
|
|
|
|
assert len(movie.videos) == 1
|
|
assert movie.videos[0].url == "https://www.youtube.com/watch?v=vKQi3bBA1y8"
|
|
assert movie.videos[0].is_official is True
|
|
|
|
|
|
def test_normalize_movie_marks_exactly_one_primary_poster_and_backdrop():
|
|
# is_primary picks out TMDb's top-level poster_path/backdrop_path (the
|
|
# image auto-downloaded for local hosting) from the full alternates
|
|
# list -- not just "the first one".
|
|
movie = normalize_movie(FAKE_MOVIE)
|
|
primary_posters = [a for a in movie.artwork if a.artwork_type == "poster" and a.is_primary]
|
|
primary_backdrops = [
|
|
a for a in movie.artwork if a.artwork_type == "backdrop" and a.is_primary
|
|
]
|
|
assert len(primary_posters) == 1
|
|
assert primary_posters[0].provider_asset_id == "/poster.jpg"
|
|
assert len(primary_backdrops) == 1
|
|
assert primary_backdrops[0].provider_asset_id == "/backdrop.jpg"
|
|
|
|
|
|
def test_normalize_movie_maps_company_logo_path():
|
|
movie = normalize_movie(FAKE_MOVIE)
|
|
assert movie.companies[0].logo_path == "/village-roadshow-logo.png"
|
|
|
|
|
|
def test_normalize_movie_company_without_logo_is_none():
|
|
minimal = {**FAKE_MOVIE, "production_companies": [{"id": 1, "name": "No Logo Studio"}]}
|
|
movie = normalize_movie(minimal)
|
|
assert movie.companies[0].logo_path is None
|
|
|
|
|
|
def test_normalize_movie_external_ids_never_include_tmdb_itself():
|
|
# TMDb's own id is handled separately by the ingestion pipeline via
|
|
# `normalized.external_id`, not folded into `external_ids` (section 3:
|
|
# Aurora doesn't conflate its own identity with provider identity).
|
|
movie = normalize_movie(FAKE_MOVIE)
|
|
assert "tmdb" not in movie.external_ids
|
|
assert movie.external_ids["imdb"] == "tt0133093"
|
|
|
|
|
|
def test_normalize_movie_maps_collection_and_recommendations():
|
|
movie = normalize_movie(FAKE_MOVIE)
|
|
assert movie.collection == ("2344", "The Matrix Collection")
|
|
assert movie.recommendation_external_ids == ["604"]
|
|
|
|
|
|
def test_normalize_movie_handles_missing_optional_sections():
|
|
minimal = {"id": 1, "title": "Minimal Movie"}
|
|
movie = normalize_movie(minimal)
|
|
assert movie.title == "Minimal Movie"
|
|
assert movie.genres == []
|
|
assert movie.cast == []
|
|
assert movie.artwork == []
|
|
assert movie.collection is None
|
|
|
|
|
|
def test_normalize_person_maps_core_fields():
|
|
person = normalize_person(FAKE_PERSON)
|
|
assert person.external_id == "6384"
|
|
assert person.name == "Keanu Reeves"
|
|
assert person.birthday == date(1964, 9, 2)
|
|
assert person.deathday is None
|
|
assert person.gender == "male"
|
|
assert person.external_ids["imdb"] == "nm0000206"
|
|
assert "Keanu Charles Reeves" in person.aliases
|
|
|
|
|
|
def test_normalize_person_profile_artwork_deduplicated():
|
|
person = normalize_person(FAKE_PERSON)
|
|
# profile_path duplicates an entry already in images.profiles; it must
|
|
# not be inserted twice.
|
|
paths = [a.provider_asset_id for a in person.artwork]
|
|
assert paths.count("/keanu.jpg") == 1
|
|
# ...and since it wasn't inserted as a second row, the existing one must
|
|
# be the one marked primary instead -- not silently left unmarked.
|
|
assert next(a for a in person.artwork if a.provider_asset_id == "/keanu.jpg").is_primary
|
|
|
|
|
|
FAKE_SERIES = {
|
|
"id": 1396,
|
|
"name": "Ozymandias",
|
|
"original_name": "Ozymandias",
|
|
"overview": "A chemistry teacher turns to crime.",
|
|
"tagline": "Change the equation.",
|
|
"status": "Ended",
|
|
"original_language": "en",
|
|
"first_air_date": "2008-01-20",
|
|
"last_air_date": "2013-09-29",
|
|
"number_of_seasons": 5,
|
|
"number_of_episodes": 62,
|
|
"popularity": 120.5,
|
|
"vote_average": 8.9,
|
|
"vote_count": 15000,
|
|
"genres": [{"id": 18, "name": "Drama"}, {"id": 80, "name": "Crime"}],
|
|
"created_by": [{"id": 66633, "name": "Vince Gilligan", "gender": 2}],
|
|
"networks": [{"id": 174, "name": "AMC", "origin_country": "US", "logo_path": "/amc-logo.png"}],
|
|
"production_companies": [
|
|
{"id": 2605, "name": "Gran Via Productions", "origin_country": "US", "logo_path": None}
|
|
],
|
|
"poster_path": "/poster.jpg",
|
|
"backdrop_path": "/backdrop.jpg",
|
|
"credits": {
|
|
"cast": [
|
|
{
|
|
"id": 17419,
|
|
"name": "Bryan Cranston",
|
|
"character": "Walter White",
|
|
"order": 0,
|
|
"gender": 2,
|
|
}
|
|
],
|
|
"crew": [],
|
|
},
|
|
"images": {"posters": [], "backdrops": [], "logos": []},
|
|
"videos": {"results": []},
|
|
"keywords": {"results": [{"id": 1, "name": "drug dealer"}]},
|
|
"alternative_titles": {"results": [{"title": "Breaking Bad", "iso_3166_1": "US"}]},
|
|
"external_ids": {"imdb_id": "tt0903747", "tvdb_id": 81189},
|
|
"seasons": [
|
|
{
|
|
"season_number": 1,
|
|
"name": "Season 1",
|
|
"overview": "The first season.",
|
|
"air_date": "2008-01-20",
|
|
"episode_count": 7,
|
|
"poster_path": "/season1.jpg",
|
|
},
|
|
{
|
|
# TMDb includes a "specials" season with number 0 — must not be
|
|
# dropped.
|
|
"season_number": 0,
|
|
"name": "Specials",
|
|
"overview": None,
|
|
"air_date": None,
|
|
"episode_count": 3,
|
|
"poster_path": None,
|
|
},
|
|
],
|
|
}
|
|
|
|
FAKE_SEASON = {
|
|
"season_number": 1,
|
|
"name": "Season 1",
|
|
"overview": "The first season.",
|
|
"air_date": "2008-01-20",
|
|
"poster_path": "/season1.jpg",
|
|
"images": {"posters": []},
|
|
"external_ids": {"tvdb_id": 12345},
|
|
"episodes": [
|
|
{
|
|
"id": 62085,
|
|
"episode_number": 1,
|
|
"name": "Pilot",
|
|
"overview": "Walter White begins his transformation.",
|
|
"air_date": "2008-01-20",
|
|
"runtime": 58,
|
|
"still_path": "/still1.jpg",
|
|
"crew": [
|
|
{"id": 66633, "name": "Vince Gilligan", "department": "Directing", "job": "Director", "gender": 2}
|
|
],
|
|
"guest_stars": [
|
|
{"id": 99999, "name": "Some Guest", "character": "Random Guy", "order": 5, "gender": 2}
|
|
],
|
|
},
|
|
{
|
|
"id": 62086,
|
|
"episode_number": 2,
|
|
"name": "Cat's in the Bag...",
|
|
"overview": None,
|
|
"air_date": "2008-01-27",
|
|
"runtime": 48,
|
|
"still_path": None,
|
|
"crew": [],
|
|
"guest_stars": [],
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
def test_normalize_series_maps_core_fields():
|
|
series = normalize_series(FAKE_SERIES)
|
|
assert series.external_id == "1396"
|
|
assert series.title == "Ozymandias"
|
|
assert series.status == "ended"
|
|
assert series.first_air_date == date(2008, 1, 20)
|
|
assert series.number_of_seasons == 5
|
|
|
|
|
|
def test_normalize_series_maps_networks_vs_production_companies():
|
|
series = normalize_series(FAKE_SERIES)
|
|
assert [n.name for n in series.networks] == ["AMC"]
|
|
assert [c.name for c in series.companies] == ["Gran Via Productions"]
|
|
|
|
|
|
def test_normalize_series_maps_network_logo_path():
|
|
series = normalize_series(FAKE_SERIES)
|
|
assert series.networks[0].logo_path == "/amc-logo.png"
|
|
# Gran Via's fixture entry has no logo_path at all -- must come through
|
|
# as None, not KeyError.
|
|
assert series.companies[0].logo_path is None
|
|
|
|
|
|
def test_normalize_series_maps_creators_separately_from_crew():
|
|
series = normalize_series(FAKE_SERIES)
|
|
assert len(series.creators) == 1
|
|
assert series.creators[0].name == "Vince Gilligan"
|
|
assert series.creators[0].department == "Creator"
|
|
assert all(c.department != "Creator" for c in series.crew)
|
|
|
|
|
|
def test_normalize_series_keywords_and_alt_titles_use_tv_shaped_keys():
|
|
# TMDb nests these under "results" for /tv, unlike "keywords"/"titles"
|
|
# for /movie — a normalizer bug here would silently return empty lists.
|
|
series = normalize_series(FAKE_SERIES)
|
|
assert series.keywords == ["drug dealer"]
|
|
assert series.alternate_titles == ["Breaking Bad"]
|
|
|
|
|
|
def test_normalize_series_keeps_season_zero_specials():
|
|
series = normalize_series(FAKE_SERIES)
|
|
season_numbers = sorted(s.season_number for s in series.seasons)
|
|
assert season_numbers == [0, 1]
|
|
|
|
|
|
def test_normalize_series_external_ids_include_tvdb():
|
|
series = normalize_series(FAKE_SERIES)
|
|
assert series.external_ids["imdb"] == "tt0903747"
|
|
assert series.external_ids["tvdb"] == "81189"
|
|
|
|
|
|
def test_normalize_season_maps_episodes():
|
|
season = normalize_season(FAKE_SEASON)
|
|
assert season.season_number == 1
|
|
assert len(season.episodes) == 2
|
|
assert season.episodes[0].external_id == "62085"
|
|
assert season.episodes[0].title == "Pilot"
|
|
assert season.episodes[0].runtime_minutes == 58
|
|
|
|
|
|
def test_normalize_season_separates_guest_stars_from_main_cast():
|
|
season = normalize_season(FAKE_SEASON)
|
|
episode = season.episodes[0]
|
|
assert episode.cast == []
|
|
assert len(episode.guest_stars) == 1
|
|
assert episode.guest_stars[0].character == "Random Guy"
|
|
|
|
|
|
def test_normalize_season_maps_episode_crew_and_stills():
|
|
season = normalize_season(FAKE_SEASON)
|
|
episode = season.episodes[0]
|
|
assert episode.crew[0].job == "Director"
|
|
assert len(episode.stills) == 1
|
|
assert episode.stills[0].artwork_type == "still"
|
|
|
|
|
|
def test_normalize_season_handles_episode_without_still():
|
|
season = normalize_season(FAKE_SEASON)
|
|
assert season.episodes[1].stills == []
|
|
|
|
|
|
FAKE_COLLECTION = {
|
|
"id": 2344,
|
|
"name": "The Matrix Collection",
|
|
"overview": "A group of films...",
|
|
"poster_path": "/collection-poster.jpg",
|
|
"backdrop_path": "/collection-backdrop.jpg",
|
|
"parts": [
|
|
{"id": 605, "title": "The Matrix Revolutions", "release_date": "2003-11-05"},
|
|
{"id": 603, "title": "The Matrix", "release_date": "1999-03-30"},
|
|
{"id": 604, "title": "The Matrix Reloaded", "release_date": "2003-05-15"},
|
|
{"id": 999, "title": "Untitled Prequel", "release_date": ""},
|
|
],
|
|
}
|
|
|
|
|
|
def test_normalize_collection_maps_basic_fields():
|
|
collection = normalize_collection(FAKE_COLLECTION)
|
|
assert collection.external_id == "2344"
|
|
assert collection.name == "The Matrix Collection"
|
|
assert collection.overview == "A group of films..."
|
|
|
|
|
|
def test_normalize_collection_maps_poster_and_backdrop_as_primary_artwork():
|
|
collection = normalize_collection(FAKE_COLLECTION)
|
|
poster = next(a for a in collection.artwork if a.artwork_type == "poster")
|
|
backdrop = next(a for a in collection.artwork if a.artwork_type == "backdrop")
|
|
assert poster.is_primary is True
|
|
assert poster.url.endswith("/collection-poster.jpg")
|
|
assert backdrop.is_primary is True
|
|
assert backdrop.url.endswith("/collection-backdrop.jpg")
|
|
|
|
|
|
def test_normalize_collection_orders_parts_by_release_date_not_array_order():
|
|
collection = normalize_collection(FAKE_COLLECTION)
|
|
# Input order is [605, 603, 604, 999] -- release-date order puts the
|
|
# undated "999" entry last rather than fabricating a sort position.
|
|
assert collection.part_external_ids == ["603", "604", "605", "999"]
|
|
|
|
|
|
def test_normalize_collection_handles_missing_overview_and_parts():
|
|
minimal = {"id": 1, "name": "Minimal Collection"}
|
|
collection = normalize_collection(minimal)
|
|
assert collection.overview is None
|
|
assert collection.artwork == []
|
|
assert collection.part_external_ids == []
|