Files
NixReaperandClaude Sonnet 5 6c95e18aad
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
Add watch providers (where to stream/rent/buy)
New TMDb integration end to end: append_to_response "watch/providers",
normalizer parsing grouped by country/monetization type, a WatchProvider
catalog entry (external-id keyed, same get_or_create pattern as Company/
Collection/Person) + polymorphic WatchProviderOffering table (new
migration 0002), ingestion write (including provider logos via the
existing Artwork table), and API exposure -- embedded on movie/series
detail plus a dedicated /watch-providers sub-route, shaped to mirror
TMDb's own response for easy porting.

Note: this data comes from JustWatch via TMDb and carries its own
attribution requirement separate from TMDb's general one -- flagged in
docs/api.md, must be satisfied before showing this in the frontend.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 04:59:02 -05:00

634 lines
22 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}]},
"release_dates": {
"results": [
{
"iso_3166_1": "US",
"release_dates": [
{
"certification": "R",
"release_date": "1999-03-31T00:00:00.000Z",
"type": 3,
"note": "",
},
{
"certification": "",
"release_date": "1999-03-01T00:00:00.000Z",
"type": 1,
"note": "Premiere",
},
],
},
{
"iso_3166_1": "GB",
"release_dates": [
{"certification": "15", "release_date": "1999-06-11T00:00:00.000Z", "type": 3}
],
},
{
"iso_3166_1": "MX",
"release_dates": [
{"certification": "B", "release_date": "1999-04-02T00:00:00.000Z", "type": 3}
],
},
]
},
"watch/providers": {
"results": {
"US": {
"link": "https://www.themoviedb.org/movie/603-the-matrix/watch?locale=US",
"flatrate": [
{
"provider_id": 8,
"provider_name": "Netflix",
"logo_path": "/netflix.jpg",
"display_priority": 0,
}
],
"rent": [
{
"provider_id": 2,
"provider_name": "Apple TV",
"logo_path": "/appletv.jpg",
"display_priority": 1,
}
],
},
"GB": {
"link": "https://www.themoviedb.org/movie/603-the-matrix/watch?locale=GB",
"flatrate": [
{"provider_id": 8, "provider_name": "Netflix", "logo_path": "/netflix.jpg"}
],
},
}
},
}
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 len(movie.alternate_titles) == 1
assert movie.alternate_titles[0].title == "Matrix"
assert movie.alternate_titles[0].country_code == "US"
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_alternate_titles_capture_type_and_skip_titleless_entries():
with_type = {
**FAKE_MOVIE,
"alternative_titles": {
"titles": [
{"title": "Matrix", "iso_3166_1": "US", "type": "working title"},
{"iso_3166_1": "FR"}, # no "title" key at all -- must be skipped
]
},
}
movie = normalize_movie(with_type)
assert len(movie.alternate_titles) == 1
assert movie.alternate_titles[0].type == "working title"
def test_normalize_movie_maps_release_dates_and_certification():
movie = normalize_movie(FAKE_MOVIE)
assert len(movie.release_dates) == 4
us_theatrical = next(
rd for rd in movie.release_dates if rd.country_code == "US" and rd.release_type == "theatrical"
)
assert us_theatrical.release_date == date(1999, 3, 31)
assert us_theatrical.certification == ("MPA", "R")
us_premiere = next(rd for rd in movie.release_dates if rd.release_type == "premiere")
assert us_premiere.certification is None
assert us_premiere.note == "Premiere"
gb_entry = next(rd for rd in movie.release_dates if rd.country_code == "GB")
assert gb_entry.certification == ("BBFC", "15")
mx_entry = next(rd for rd in movie.release_dates if rd.country_code == "MX")
# MX has no entry in the small known certification-system map -- falls
# back to the country code itself rather than fabricating a body name.
assert mx_entry.certification == ("MX", "B")
def test_normalize_movie_release_dates_skip_entries_without_a_date():
minimal = {
**FAKE_MOVIE,
"release_dates": {
"results": [{"iso_3166_1": "US", "release_dates": [{"certification": "R", "release_date": ""}]}]
},
}
movie = normalize_movie(minimal)
assert movie.release_dates == []
def test_normalize_movie_maps_watch_providers_grouped_by_country_and_type():
movie = normalize_movie(FAKE_MOVIE)
assert len(movie.watch_providers) == 3
us_flatrate = next(
wp for wp in movie.watch_providers if wp.country_code == "US" and wp.monetization_type == "flatrate"
)
assert us_flatrate.provider.name == "Netflix"
assert us_flatrate.provider.external_id == "8"
assert us_flatrate.display_priority == 0
assert us_flatrate.link == "https://www.themoviedb.org/movie/603-the-matrix/watch?locale=US"
us_rent = next(wp for wp in movie.watch_providers if wp.monetization_type == "rent")
assert us_rent.provider.name == "Apple TV"
gb_flatrate = next(wp for wp in movie.watch_providers if wp.country_code == "GB")
assert gb_flatrate.link == "https://www.themoviedb.org/movie/603-the-matrix/watch?locale=GB"
assert gb_flatrate.display_priority is None
def test_normalize_movie_handles_missing_watch_providers():
minimal = {**FAKE_MOVIE, "watch/providers": {}}
movie = normalize_movie(minimal)
assert movie.watch_providers == []
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},
"content_ratings": {
"results": [{"iso_3166_1": "US", "rating": "TV-MA"}, {"iso_3166_1": "GB", "rating": "15"}]
},
"watch/providers": {
"results": {
"US": {
"link": "https://www.themoviedb.org/tv/1396-breaking-bad/watch?locale=US",
"flatrate": [
{"provider_id": 1899, "provider_name": "Netflix", "logo_path": "/netflix.jpg"}
],
}
}
},
"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 len(series.alternate_titles) == 1
assert series.alternate_titles[0].title == "Breaking Bad"
assert series.alternate_titles[0].country_code == "US"
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_series_anchors_content_ratings_to_first_air_date():
# TMDb's /tv content_ratings carries no date of its own -- each rating
# is anchored to the show's first_air_date (release_type="tv") rather
# than left with no release_date at all.
series = normalize_series(FAKE_SERIES)
assert len(series.release_dates) == 2
us = next(rd for rd in series.release_dates if rd.country_code == "US")
assert us.release_date == date(2008, 1, 20)
assert us.release_type == "tv"
assert us.certification == ("MPA", "TV-MA")
def test_normalize_series_content_ratings_skipped_without_first_air_date():
no_air_date = {**FAKE_SERIES, "first_air_date": None}
series = normalize_series(no_air_date)
assert series.release_dates == []
def test_normalize_series_maps_watch_providers():
series = normalize_series(FAKE_SERIES)
assert len(series.watch_providers) == 1
assert series.watch_providers[0].provider.name == "Netflix"
assert series.watch_providers[0].country_code == "US"
assert series.watch_providers[0].monetization_type == "flatrate"
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 == []