feat(explore): surface the catalog data providers already return

Explore shelf cards drew a poster, a title and a year. An audit of all six
catalog sources found the rest was lost at two boundaries — the wire-to-DTO
mapping and the DTO-to-CatalogItem mapping — and then simply not drawn: the
grid card fell through every branch of buildMetadataSubtitle to the year-only
case, while the list card used by search already composed certification,
runtime and rating from fields the synthesized MediaItem already held.

Extend CatalogItem with the neutral facts every provider had been dropping:
attributed rating sources, leaderboard ranks that keep their season window,
audience counters that keep their timeframe, broadcast slots, next-episode air
times, server availability and request state, exact release dates, alternate
titles, format, source material, studios, countries, languages, credits, tags,
links, artwork variants, play state, gallery art and background prose. Replace
fetchCast and fetchRelated with one fetchDetail returning the enriched item,
its cast, its recommendations and labelled franchise relations without adding
a request: sources needing two calls keep two and run them concurrently with
isolated failures.

Map those fields in all six sources, widening only field selections that cost
no extra round trip — MAL's fields list, AniList's selection set and a bounded
row cast that lets detail skip its character call, Trakt's guest stars, Seerr's
language parameter and TMDB size ladder, and Plex's includeUserState. Plex hub
artwork widens only on TV, where the spotlight is its only consumer, because it
doubles the payload.

Render them: a rating-first caption and bounded badges on the shelf card,
labelled sections on the detail screen, provider hub styles and result counts
on shelves, and logo, banner and accent art in the TV spotlight.

Verified against live Plex, AniList, Simkl and MAL responses, and on a Pixel 7.
This commit is contained in:
edde746
2026-07-29 06:47:54 +02:00
parent 0ef71e6489
commit 27acbaf435
97 changed files with 12945 additions and 880 deletions
+564
View File
@@ -0,0 +1,564 @@
// ISO 3166-1 alpha-2 country names, from
// https://raw.githubusercontent.com/lukes/ISO-3166-Countries-with-Regional-Codes/master/all/all.json
// with widely-used short forms substituted for unwieldy official long names
// (for example "United Kingdom" rather than "United Kingdom of Great Britain
// and Northern Ireland"), because these render in metadata chips.
//
// Static data, like lib/data/iso_639_data.dart. There is no in-repository
// regeneration path; refresh by re-fetching the source above and re-applying
// the short forms and aliases.
//
// English only. The app ships no localized country catalog, and Dart's intl
// does not provide one offline; callers fall back to the raw code, which is
// itself recognizable.
const countryNames = <String, String>{
'AD': "Andorra",
'AE': "United Arab Emirates",
'AF': "Afghanistan",
'AG': "Antigua and Barbuda",
'AI': "Anguilla",
'AL': "Albania",
'AM': "Armenia",
'AO': "Angola",
'AQ': "Antarctica",
'AR': "Argentina",
'AS': "American Samoa",
'AT': "Austria",
'AU': "Australia",
'AW': "Aruba",
'AX': "Åland Islands",
'AZ': "Azerbaijan",
'BA': "Bosnia and Herzegovina",
'BB': "Barbados",
'BD': "Bangladesh",
'BE': "Belgium",
'BF': "Burkina Faso",
'BG': "Bulgaria",
'BH': "Bahrain",
'BI': "Burundi",
'BJ': "Benin",
'BL': "Saint Barthélemy",
'BM': "Bermuda",
'BN': "Brunei",
'BO': "Bolivia",
'BQ': "Bonaire, Sint Eustatius and Saba",
'BR': "Brazil",
'BS': "Bahamas",
'BT': "Bhutan",
'BV': "Bouvet Island",
'BW': "Botswana",
'BY': "Belarus",
'BZ': "Belize",
'CA': "Canada",
'CC': "Cocos (Keeling) Islands",
'CD': "DR Congo",
'CF': "Central African Republic",
'CG': "Congo",
'CH': "Switzerland",
'CI': "Côte d'Ivoire",
'CK': "Cook Islands",
'CL': "Chile",
'CM': "Cameroon",
'CN': "China",
'CO': "Colombia",
'CR': "Costa Rica",
'CU': "Cuba",
'CV': "Cabo Verde",
'CW': "Curaçao",
'CX': "Christmas Island",
'CY': "Cyprus",
'CZ': "Czechia",
'DE': "Germany",
'DJ': "Djibouti",
'DK': "Denmark",
'DM': "Dominica",
'DO': "Dominican Republic",
'DZ': "Algeria",
'EC': "Ecuador",
'EE': "Estonia",
'EG': "Egypt",
'EH': "Western Sahara",
'ER': "Eritrea",
'ES': "Spain",
'ET': "Ethiopia",
'FI': "Finland",
'FJ': "Fiji",
'FK': "Falkland Islands",
'FM': "Micronesia",
'FO': "Faroe Islands",
'FR': "France",
'GA': "Gabon",
'GB': "United Kingdom",
'GD': "Grenada",
'GE': "Georgia",
'GF': "French Guiana",
'GG': "Guernsey",
'GH': "Ghana",
'GI': "Gibraltar",
'GL': "Greenland",
'GM': "Gambia",
'GN': "Guinea",
'GP': "Guadeloupe",
'GQ': "Equatorial Guinea",
'GR': "Greece",
'GS': "South Georgia and the South Sandwich Islands",
'GT': "Guatemala",
'GU': "Guam",
'GW': "Guinea-Bissau",
'GY': "Guyana",
'HK': "Hong Kong",
'HM': "Heard Island and McDonald Islands",
'HN': "Honduras",
'HR': "Croatia",
'HT': "Haiti",
'HU': "Hungary",
'ID': "Indonesia",
'IE': "Ireland",
'IL': "Israel",
'IM': "Isle of Man",
'IN': "India",
'IO': "British Indian Ocean Territory",
'IQ': "Iraq",
'IR': "Iran",
'IS': "Iceland",
'IT': "Italy",
'JE': "Jersey",
'JM': "Jamaica",
'JO': "Jordan",
'JP': "Japan",
'KE': "Kenya",
'KG': "Kyrgyzstan",
'KH': "Cambodia",
'KI': "Kiribati",
'KM': "Comoros",
'KN': "Saint Kitts and Nevis",
'KP': "North Korea",
'KR': "South Korea",
'KW': "Kuwait",
'KY': "Cayman Islands",
'KZ': "Kazakhstan",
'LA': "Laos",
'LB': "Lebanon",
'LC': "Saint Lucia",
'LI': "Liechtenstein",
'LK': "Sri Lanka",
'LR': "Liberia",
'LS': "Lesotho",
'LT': "Lithuania",
'LU': "Luxembourg",
'LV': "Latvia",
'LY': "Libya",
'MA': "Morocco",
'MC': "Monaco",
'MD': "Moldova",
'ME': "Montenegro",
'MF': "Saint Martin (French part)",
'MG': "Madagascar",
'MH': "Marshall Islands",
'MK': "North Macedonia",
'ML': "Mali",
'MM': "Myanmar",
'MN': "Mongolia",
'MO': "Macao",
'MP': "Northern Mariana Islands",
'MQ': "Martinique",
'MR': "Mauritania",
'MS': "Montserrat",
'MT': "Malta",
'MU': "Mauritius",
'MV': "Maldives",
'MW': "Malawi",
'MX': "Mexico",
'MY': "Malaysia",
'MZ': "Mozambique",
'NA': "Namibia",
'NC': "New Caledonia",
'NE': "Niger",
'NF': "Norfolk Island",
'NG': "Nigeria",
'NI': "Nicaragua",
'NL': "Netherlands, Kingdom of the",
'NO': "Norway",
'NP': "Nepal",
'NR': "Nauru",
'NU': "Niue",
'NZ': "New Zealand",
'OM': "Oman",
'PA': "Panama",
'PE': "Peru",
'PF': "French Polynesia",
'PG': "Papua New Guinea",
'PH': "Philippines",
'PK': "Pakistan",
'PL': "Poland",
'PM': "Saint Pierre and Miquelon",
'PN': "Pitcairn",
'PR': "Puerto Rico",
'PS': "Palestine",
'PT': "Portugal",
'PW': "Palau",
'PY': "Paraguay",
'QA': "Qatar",
'RE': "Réunion",
'RO': "Romania",
'RS': "Serbia",
'RU': "Russia",
'RW': "Rwanda",
'SA': "Saudi Arabia",
'SB': "Solomon Islands",
'SC': "Seychelles",
'SD': "Sudan",
'SE': "Sweden",
'SG': "Singapore",
'SH': "Saint Helena, Ascension and Tristan da Cunha",
'SI': "Slovenia",
'SJ': "Svalbard and Jan Mayen",
'SK': "Slovakia",
'SL': "Sierra Leone",
'SM': "San Marino",
'SN': "Senegal",
'SO': "Somalia",
'SR': "Suriname",
'SS': "South Sudan",
'ST': "Sao Tome and Principe",
'SV': "El Salvador",
'SX': "Sint Maarten (Dutch part)",
'SY': "Syria",
'SZ': "Eswatini",
'TC': "Turks and Caicos Islands",
'TD': "Chad",
'TF': "French Southern Territories",
'TG': "Togo",
'TH': "Thailand",
'TJ': "Tajikistan",
'TK': "Tokelau",
'TL': "Timor-Leste",
'TM': "Turkmenistan",
'TN': "Tunisia",
'TO': "Tonga",
'TR': "Türkiye",
'TT': "Trinidad and Tobago",
'TV': "Tuvalu",
'TW': "Taiwan",
'TZ': "Tanzania",
'UA': "Ukraine",
'UG': "Uganda",
'UM': "United States Minor Outlying Islands",
'US': "United States",
'UY': "Uruguay",
'UZ': "Uzbekistan",
'VA': "Vatican City",
'VC': "Saint Vincent and the Grenadines",
'VE': "Venezuela",
'VG': "British Virgin Islands",
'VI': "U.S. Virgin Islands",
'VN': "Vietnam",
'VU': "Vanuatu",
'WF': "Wallis and Futuna",
'WS': "Samoa",
'YE': "Yemen",
'YT': "Mayotte",
'ZA': "South Africa",
'ZM': "Zambia",
'ZW': "Zimbabwe",
};
/// Reverse index for providers that send a country *name* instead of a code.
/// Plex Discover returns `Country` child tags such as `United States of
/// America`, so a mapper has to normalize before storing alpha-2.
///
/// Keyed by lowercased name, covering the official ISO form, the short form
/// used above, and colloquial names providers actually emit.
const countryCodesByName = <String, String>{
"afghanistan": 'AF',
"albania": 'AL',
"algeria": 'DZ',
"america": 'US',
"american samoa": 'AS',
"andorra": 'AD',
"angola": 'AO',
"anguilla": 'AI',
"antarctica": 'AQ',
"antigua and barbuda": 'AG',
"argentina": 'AR',
"armenia": 'AM',
"aruba": 'AW',
"australia": 'AU',
"austria": 'AT',
"azerbaijan": 'AZ',
"bahamas": 'BS',
"bahrain": 'BH',
"bangladesh": 'BD',
"barbados": 'BB',
"belarus": 'BY',
"belgium": 'BE',
"belize": 'BZ',
"benin": 'BJ',
"bermuda": 'BM',
"bhutan": 'BT',
"bolivia": 'BO',
"bolivia, plurinational state of": 'BO',
"bonaire, sint eustatius and saba": 'BQ',
"bosnia and herzegovina": 'BA',
"botswana": 'BW',
"bouvet island": 'BV',
"brazil": 'BR',
"britain": 'GB',
"british indian ocean territory": 'IO',
"british virgin islands": 'VG',
"brunei": 'BN',
"brunei darussalam": 'BN',
"bulgaria": 'BG',
"burkina faso": 'BF',
"burma": 'MM',
"burundi": 'BI',
"cabo verde": 'CV',
"cambodia": 'KH',
"cameroon": 'CM',
"canada": 'CA',
"cape verde": 'CV',
"cayman islands": 'KY',
"central african republic": 'CF',
"chad": 'TD',
"chile": 'CL',
"china": 'CN',
"christmas island": 'CX',
"cocos (keeling) islands": 'CC',
"colombia": 'CO',
"comoros": 'KM',
"congo": 'CG',
"congo, democratic republic of the": 'CD',
"cook islands": 'CK',
"costa rica": 'CR',
"croatia": 'HR',
"cuba": 'CU',
"curaçao": 'CW',
"cyprus": 'CY',
"czech republic": 'CZ',
"czechia": 'CZ',
"côte d'ivoire": 'CI',
"denmark": 'DK',
"djibouti": 'DJ',
"dominica": 'DM',
"dominican republic": 'DO',
"dr congo": 'CD',
"east timor": 'TL',
"ecuador": 'EC',
"egypt": 'EG',
"el salvador": 'SV',
"england": 'GB',
"equatorial guinea": 'GQ',
"eritrea": 'ER',
"estonia": 'EE',
"eswatini": 'SZ',
"ethiopia": 'ET',
"falkland islands": 'FK',
"falkland islands (malvinas)": 'FK',
"faroe islands": 'FO',
"fiji": 'FJ',
"finland": 'FI',
"france": 'FR',
"french guiana": 'GF',
"french polynesia": 'PF',
"french southern territories": 'TF',
"gabon": 'GA',
"gambia": 'GM',
"georgia": 'GE',
"germany": 'DE',
"ghana": 'GH',
"gibraltar": 'GI',
"great britain": 'GB',
"greece": 'GR',
"greenland": 'GL',
"grenada": 'GD',
"guadeloupe": 'GP',
"guam": 'GU',
"guatemala": 'GT',
"guernsey": 'GG',
"guinea": 'GN',
"guinea-bissau": 'GW',
"guyana": 'GY',
"haiti": 'HT',
"heard island and mcdonald islands": 'HM',
"holy see": 'VA',
"honduras": 'HN',
"hong kong": 'HK',
"hong kong sar china": 'HK',
"hungary": 'HU',
"iceland": 'IS',
"india": 'IN',
"indonesia": 'ID',
"iran": 'IR',
"iran, islamic republic of": 'IR',
"iraq": 'IQ',
"ireland": 'IE',
"isle of man": 'IM',
"israel": 'IL',
"italy": 'IT',
"ivory coast": 'CI',
"jamaica": 'JM',
"japan": 'JP',
"jersey": 'JE',
"jordan": 'JO',
"kazakhstan": 'KZ',
"kenya": 'KE',
"kiribati": 'KI',
"korea, democratic people's republic of": 'KP',
"korea, republic of": 'KR',
"kuwait": 'KW',
"kyrgyzstan": 'KG',
"lao people's democratic republic": 'LA',
"laos": 'LA',
"latvia": 'LV',
"lebanon": 'LB',
"lesotho": 'LS',
"liberia": 'LR',
"libya": 'LY',
"liechtenstein": 'LI',
"lithuania": 'LT',
"luxembourg": 'LU',
"macao": 'MO',
"macao sar china": 'MO',
"macedonia": 'MK',
"madagascar": 'MG',
"malawi": 'MW',
"malaysia": 'MY',
"maldives": 'MV',
"mali": 'ML',
"malta": 'MT',
"marshall islands": 'MH',
"martinique": 'MQ',
"mauritania": 'MR',
"mauritius": 'MU',
"mayotte": 'YT',
"mexico": 'MX',
"micronesia": 'FM',
"micronesia, federated states of": 'FM',
"moldova": 'MD',
"moldova, republic of": 'MD',
"monaco": 'MC',
"mongolia": 'MN',
"montenegro": 'ME',
"montserrat": 'MS',
"morocco": 'MA',
"mozambique": 'MZ',
"myanmar": 'MM',
"namibia": 'NA',
"nauru": 'NR',
"nepal": 'NP',
"netherlands, kingdom of the": 'NL',
"new caledonia": 'NC',
"new zealand": 'NZ',
"nicaragua": 'NI',
"niger": 'NE',
"nigeria": 'NG',
"niue": 'NU',
"norfolk island": 'NF',
"north korea": 'KP',
"north macedonia": 'MK',
"northern mariana islands": 'MP',
"norway": 'NO',
"oman": 'OM',
"pakistan": 'PK',
"palau": 'PW',
"palestine": 'PS',
"palestine, state of": 'PS',
"panama": 'PA',
"papua new guinea": 'PG',
"paraguay": 'PY',
"peru": 'PE',
"philippines": 'PH',
"pitcairn": 'PN',
"poland": 'PL',
"portugal": 'PT',
"puerto rico": 'PR',
"qatar": 'QA',
"romania": 'RO',
"russia": 'RU',
"russian federation": 'RU',
"rwanda": 'RW',
"réunion": 'RE',
"saint barthélemy": 'BL',
"saint helena, ascension and tristan da cunha": 'SH',
"saint kitts and nevis": 'KN',
"saint lucia": 'LC',
"saint martin (french part)": 'MF',
"saint pierre and miquelon": 'PM',
"saint vincent and the grenadines": 'VC',
"samoa": 'WS',
"san marino": 'SM',
"sao tome and principe": 'ST',
"saudi arabia": 'SA',
"senegal": 'SN',
"serbia": 'RS',
"seychelles": 'SC',
"sierra leone": 'SL',
"singapore": 'SG',
"sint maarten (dutch part)": 'SX',
"slovakia": 'SK',
"slovenia": 'SI',
"solomon islands": 'SB',
"somalia": 'SO',
"south africa": 'ZA',
"south georgia and the south sandwich islands": 'GS',
"south korea": 'KR',
"south sudan": 'SS',
"spain": 'ES',
"sri lanka": 'LK',
"sudan": 'SD',
"suriname": 'SR',
"svalbard and jan mayen": 'SJ',
"swaziland": 'SZ',
"sweden": 'SE',
"switzerland": 'CH',
"syria": 'SY',
"syrian arab republic": 'SY',
"taiwan": 'TW',
"taiwan, province of china": 'TW',
"tajikistan": 'TJ',
"tanzania": 'TZ',
"tanzania, united republic of": 'TZ',
"thailand": 'TH',
"the netherlands": 'NL',
"timor-leste": 'TL',
"togo": 'TG',
"tokelau": 'TK',
"tonga": 'TO',
"trinidad and tobago": 'TT',
"tunisia": 'TN',
"turkmenistan": 'TM',
"turks and caicos islands": 'TC',
"tuvalu": 'TV',
"türkiye": 'TR',
"u.k.": 'GB',
"u.s. virgin islands": 'VI',
"u.s.a.": 'US',
"uganda": 'UG',
"uk": 'GB',
"ukraine": 'UA',
"united arab emirates": 'AE',
"united kingdom": 'GB',
"united kingdom of great britain and northern ireland": 'GB',
"united states": 'US',
"united states minor outlying islands": 'UM',
"united states of america": 'US',
"uruguay": 'UY',
"us": 'US',
"usa": 'US',
"uzbekistan": 'UZ',
"vanuatu": 'VU',
"vatican city": 'VA',
"venezuela": 'VE',
"venezuela, bolivarian republic of": 'VE',
"viet nam": 'VN',
"vietnam": 'VN',
"virgin islands (british)": 'VG',
"virgin islands (u.s.)": 'VI',
"wallis and futuna": 'WF',
"western sahara": 'EH',
"yemen": 'YE',
"zambia": 'ZM',
"zimbabwe": 'ZW',
"åland islands": 'AX',
};
+144 -1
View File
@@ -1021,7 +1021,150 @@
"searchHint": "${source} daxilində axtar",
"searchEmpty": "\"${query}\" üçün nəticə tapılmadı",
"searchPrompt": "${source} vasitəsilə kino və seriallar axtarın.",
"searchFailed": "Axtarış uğursuz oldu. Bağlantınızı yoxlayın."
"searchFailed": "Axtarış uğursuz oldu. Bağlantınızı yoxlayın.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"one": "",
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "Canlı TV",
+144 -1
View File
@@ -1021,7 +1021,150 @@
"searchHint": "Търсене в ${source}",
"searchEmpty": "Няма резултати за \"${query}\"",
"searchPrompt": "Търси филми и сериали в ${source}.",
"searchFailed": "Търсенето се провали. Провери връзката си и опитай отново."
"searchFailed": "Търсенето се провали. Провери връзката си и опитай отново.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"one": "",
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "Телевизия на живо",
+144 -1
View File
@@ -1021,7 +1021,150 @@
"searchHint": "Søg i ${source}",
"searchEmpty": "Ingen resultater for \"${query}\"",
"searchPrompt": "Søg efter film og serier på ${source}.",
"searchFailed": "Søgningen mislykkedes. Tjek din forbindelse, og prøv igen."
"searchFailed": "Søgningen mislykkedes. Tjek din forbindelse, og prøv igen.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"one": "",
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "Live TV",
+144 -1
View File
@@ -1021,7 +1021,150 @@
"searchHint": "${source} durchsuchen",
"searchEmpty": "Keine Ergebnisse für „${query}“",
"searchPrompt": "Suche nach Filmen und Serien auf ${source}.",
"searchFailed": "Suche fehlgeschlagen. Prüfe deine Verbindung und versuche es erneut."
"searchFailed": "Suche fehlgeschlagen. Prüfe deine Verbindung und versuche es erneut.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"one": "",
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "Live-TV",
+144 -1
View File
@@ -1021,7 +1021,150 @@
"searchHint": "Search ${source}",
"searchEmpty": "No results for \"${query}\"",
"searchPrompt": "Search for movies and shows on ${source}.",
"searchFailed": "Search failed. Check your connection and try again."
"searchFailed": "Search failed. Check your connection and try again.",
"badge": {
"rankPopular": "#${n} popular",
"rankAiring": "#${n} airing",
"rankRated": "#${n} rated",
"rankFavorited": "#${n} favorited",
"rankTrending": "#${n} trending",
"rankSeasonal": "#${n} in ${season}",
"watchingNow": "${n} watching",
"available": "Available",
"partiallyAvailable": "Partly available",
"availableIn4k": "4K available",
"requested": "Requested",
"pendingApproval": "Pending approval",
"processing": "Processing",
"declined": "Declined",
"requestFailed": "Request failed",
"requested4k": "4K requested",
"seasonsAvailable": "${available}/${total} seasons",
"nextEpisodeIn": "Ep ${episode} in ${duration}",
"nextAiringIn": "Next in ${duration}",
"episodesShort": "${n} eps",
"minutesPerEpisode": "${n} min/ep",
"adult": "18+"
},
"stats": {
"listed": "${n} listed",
"viewersDay": "${n} watched today",
"viewersWeek": "${n} watched this week",
"viewersMonth": "${n} watched this month",
"viewersYear": "${n} watched this year",
"viewersAllTime": "${n} viewers",
"planning": "${n} planning to watch",
"favorited": "${n} favorites",
"dropRate": "${percent} dropped it",
"comments": {
"one": "${n} comment",
"other": "${n} comments"
},
"votes": "${n} votes",
"watching": "${n} watching it",
"completed": "${n} completed",
"onHold": "${n} on hold",
"dropped": "${n} dropped"
},
"season": {
"winter": "Winter",
"spring": "Spring",
"summer": "Summer",
"fall": "Fall",
"withYear": "${season} ${year}"
},
"format": {
"tv": "TV",
"tvShort": "TV Short",
"movie": "Movie",
"special": "Special",
"ova": "OVA",
"ona": "ONA",
"music": "Music",
"other": "Other"
},
"sourceMaterial": {
"original": "Original",
"manga": "Manga",
"lightNovel": "Light novel",
"novel": "Novel",
"visualNovel": "Visual novel",
"game": "Game",
"webComic": "Web comic",
"musicRelease": "Music",
"otherMedia": "Other"
},
"creditRole": {
"director": "Director",
"writer": "Writer",
"producer": "Producer",
"creator": "Creator",
"composer": "Composer"
},
"ratingSource": {
"critic": "Critics",
"audience": "Audience",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Rotten Tomatoes critics",
"rottenTomatoesAudience": "Rotten Tomatoes audience"
},
"broadcast": "Airs ${day} at ${time}",
"broadcastWithZone": "Airs ${day} at ${time} ${timezone}",
"detail": {
"originalTitle": "Original title",
"alsoKnownAs": "Also known as",
"studios": "Studios",
"country": "Country",
"language": "Language",
"released": "Released",
"physicalRelease": "On disc",
"ended": "Ended",
"addedOn": "Added ${date}",
"yourRating": "Your rating",
"budget": "Budget",
"revenue": "Box office",
"contentAdvisory": "Age guidance",
"tags": "Tags",
"revealSpoilerTags": "Show spoiler tags",
"links": "Links",
"watchOn": "Watch on",
"watchTrailer": "Watch trailer",
"openOn": "Open on ${site}",
"crew": "Crew",
"ratings": "Ratings",
"schedule": "Schedule",
"recommendedByUsers": {
"one": "Recommended by ${n} user",
"other": "Recommended by ${n} users"
},
"recommendedBy": "Recommended by ${who}",
"favoritedBy": "Favorited by ${who}",
"unairedEpisodes": "${n} not aired yet",
"recommendedByPercent": "Recommended by ${percent} of viewers",
"gallery": "Gallery",
"background": "Background"
},
"totalResults": {
"one": "${n} result",
"other": "${n} results"
},
"relation": {
"prequel": "Prequel",
"sequel": "Sequel",
"sideStory": "Side story",
"spinOff": "Spin-off",
"alternativeVersion": "Alternative version",
"summary": "Summary",
"parentStory": "Parent story",
"adaptation": "Adaptation",
"other": "Related"
}
},
"liveTv": {
"title": "Live TV",
+144 -1
View File
@@ -1021,7 +1021,150 @@
"searchHint": "Buscar en ${source}",
"searchEmpty": "Sin resultados para \"${query}\"",
"searchPrompt": "Busca películas y series en ${source}.",
"searchFailed": "La búsqueda falló. Comprueba tu conexión e inténtalo de nuevo."
"searchFailed": "La búsqueda falló. Comprueba tu conexión e inténtalo de nuevo.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"one": "",
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "TV en vivo",
+144 -1
View File
@@ -1021,7 +1021,150 @@
"searchHint": "Rechercher dans ${source}",
"searchEmpty": "Aucun résultat pour \"${query}\"",
"searchPrompt": "Recherchez des films et des séries sur ${source}.",
"searchFailed": "Échec de la recherche. Vérifiez votre connexion et réessayez."
"searchFailed": "Échec de la recherche. Vérifiez votre connexion et réessayez.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"one": "",
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "TV en direct",
+144 -1
View File
@@ -1021,7 +1021,150 @@
"searchHint": "Keresés itt: ${source}",
"searchEmpty": "Nincs találat a következőre: \"${query}\"",
"searchPrompt": "Filmek és sorozatok keresése a következőn: ${source}.",
"searchFailed": "A keresés nem sikerült. Ellenőrizd a kapcsolatot és próbáld újra."
"searchFailed": "A keresés nem sikerült. Ellenőrizd a kapcsolatot és próbáld újra.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"one": "",
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "Élő TV",
+144 -1
View File
@@ -1021,7 +1021,150 @@
"searchHint": "Cerca su ${source}",
"searchEmpty": "Nessun risultato per \"${query}\"",
"searchPrompt": "Cerca film e serie TV su ${source}.",
"searchFailed": "Ricerca fallita. Controlla la connessione e riprova."
"searchFailed": "Ricerca fallita. Controlla la connessione e riprova.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"one": "",
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "TV in diretta",
+141 -1
View File
@@ -1019,7 +1019,147 @@
"searchHint": "${source}を検索",
"searchEmpty": "「${query}」の結果が見つかりません",
"searchPrompt": "${source}で映画やテレビ番組を検索します。",
"searchFailed": "検索に失敗しました。接続を確認してもう一度お試しください。"
"searchFailed": "検索に失敗しました。接続を確認してもう一度お試しください。",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "ライブTV",
+144 -1
View File
@@ -1021,7 +1021,150 @@
"searchHint": "${source} ішінен іздеу",
"searchEmpty": "\"${query}\" бойынша нәтиже табылмады",
"searchPrompt": "${source} арқылы фильмдер мен сериалдарды іздеңіз.",
"searchFailed": "Іздеу қатесі. Қосылымды тексеріңіз."
"searchFailed": "Іздеу қатесі. Қосылымды тексеріңіз.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"one": "",
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "Тікелей TV",
+141 -1
View File
@@ -1019,7 +1019,147 @@
"searchHint": "${source}에서 검색",
"searchEmpty": "「${query}」에 대한 결과가 없습니다",
"searchPrompt": "${source}에서 영화와 TV 프로그램을 검색하세요.",
"searchFailed": "검색에 실패했습니다. 연결을 확인하고 다시 시도하세요."
"searchFailed": "검색에 실패했습니다. 연결을 확인하고 다시 시도하세요.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "실시간 TV",
+144 -1
View File
@@ -1021,7 +1021,150 @@
"searchHint": "Søk i ${source}",
"searchEmpty": "Ingen treff for \"${query}\"",
"searchPrompt": "Søk etter filmer og serier på ${source}.",
"searchFailed": "Søk mislyktes. Sjekk tilkoblingen og prøv igjen."
"searchFailed": "Søk mislyktes. Sjekk tilkoblingen og prøv igjen.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"one": "",
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "Direkte-TV",
+144 -1
View File
@@ -1021,7 +1021,150 @@
"searchHint": "Zoeken in ${source}",
"searchEmpty": "Geen resultaten voor \"${query}\"",
"searchPrompt": "Zoek naar films en series op ${source}.",
"searchFailed": "Zoeken mislukt. Controleer je verbinding en probeer opnieuw."
"searchFailed": "Zoeken mislukt. Controleer je verbinding en probeer opnieuw.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"one": "",
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "Live-tv",
+150 -1
View File
@@ -1025,7 +1025,156 @@
"searchHint": "Szukaj w ${source}",
"searchEmpty": "Brak wyników dla \"${query}\"",
"searchPrompt": "Szukaj filmów i seriali w ${source}.",
"searchFailed": "Wyszukiwanie nie powiodło się. Sprawdź połączenie i spróbuj ponownie."
"searchFailed": "Wyszukiwanie nie powiodło się. Sprawdź połączenie i spróbuj ponownie.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"one": "",
"few": "",
"many": "",
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"few": "",
"many": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"one": "",
"few": "",
"many": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "TV na żywo",
+144 -1
View File
@@ -1021,7 +1021,150 @@
"searchHint": "Buscar em ${source}",
"searchEmpty": "Nenhum resultado para \"${query}\"",
"searchPrompt": "Busque filmes e séries em ${source}.",
"searchFailed": "Falha na busca. Verifique sua conexão e tente novamente."
"searchFailed": "Falha na busca. Verifique sua conexão e tente novamente.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"one": "",
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "TV ao Vivo",
+150 -1
View File
@@ -1025,7 +1025,156 @@
"searchHint": "Поиск в ${source}",
"searchEmpty": "Нет результатов по запросу \"${query}\"",
"searchPrompt": "Ищите фильмы и сериалы в ${source}.",
"searchFailed": "Ошибка поиска. Проверьте подключение и повторите попытку."
"searchFailed": "Ошибка поиска. Проверьте подключение и повторите попытку.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"one": "",
"few": "",
"many": "",
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"few": "",
"many": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"one": "",
"few": "",
"many": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "ТВ в прямом эфире",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 22
/// Strings: 32750 (1488 per locale)
/// Strings: 32869 (1494 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+570 -4
View File
@@ -2930,6 +2930,30 @@ class Translations$explore$en {
/// en: 'Search failed. Check your connection and try again.'
String get searchFailed => 'Search failed. Check your connection and try again.';
late final Translations$explore$badge$en badge = Translations$explore$badge$en.internal(_root);
late final Translations$explore$stats$en stats = Translations$explore$stats$en.internal(_root);
late final Translations$explore$season$en season = Translations$explore$season$en.internal(_root);
late final Translations$explore$format$en format = Translations$explore$format$en.internal(_root);
late final Translations$explore$sourceMaterial$en sourceMaterial = Translations$explore$sourceMaterial$en.internal(_root);
late final Translations$explore$creditRole$en creditRole = Translations$explore$creditRole$en.internal(_root);
late final Translations$explore$ratingSource$en ratingSource = Translations$explore$ratingSource$en.internal(_root);
/// en: 'Airs ${day} at ${time}'
String broadcast({required Object day, required Object time}) => 'Airs ${day} at ${time}';
/// en: 'Airs ${day} at ${time} ${timezone}'
String broadcastWithZone({required Object day, required Object time, required Object timezone}) => 'Airs ${day} at ${time} ${timezone}';
late final Translations$explore$detail$en detail = Translations$explore$detail$en.internal(_root);
/// en: '(one) {${n} result} (other) {${n} results}'
String totalResults({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n,
one: '${n} result',
other: '${n} results',
);
late final Translations$explore$relation$en relation = Translations$explore$relation$en.internal(_root);
}
// Path: liveTv
@@ -5038,6 +5062,432 @@ class Translations$explore$status$en {
String get upcoming => 'Upcoming';
}
// Path: explore.badge
class Translations$explore$badge$en {
Translations$explore$badge$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: '#${n} popular'
String rankPopular({required Object n}) => '#${n} popular';
/// en: '#${n} airing'
String rankAiring({required Object n}) => '#${n} airing';
/// en: '#${n} rated'
String rankRated({required Object n}) => '#${n} rated';
/// en: '#${n} favorited'
String rankFavorited({required Object n}) => '#${n} favorited';
/// en: '#${n} trending'
String rankTrending({required Object n}) => '#${n} trending';
/// en: '#${n} in ${season}'
String rankSeasonal({required Object n, required Object season}) => '#${n} in ${season}';
/// en: '${n} watching'
String watchingNow({required Object n}) => '${n} watching';
/// en: 'Available'
String get available => 'Available';
/// en: 'Partly available'
String get partiallyAvailable => 'Partly available';
/// en: '4K available'
String get availableIn4k => '4K available';
/// en: 'Requested'
String get requested => 'Requested';
/// en: 'Pending approval'
String get pendingApproval => 'Pending approval';
/// en: 'Processing'
String get processing => 'Processing';
/// en: 'Declined'
String get declined => 'Declined';
/// en: 'Request failed'
String get requestFailed => 'Request failed';
/// en: '4K requested'
String get requested4k => '4K requested';
/// en: '${available}/${total} seasons'
String seasonsAvailable({required Object available, required Object total}) => '${available}/${total} seasons';
/// en: 'Ep ${episode} in ${duration}'
String nextEpisodeIn({required Object episode, required Object duration}) => 'Ep ${episode} in ${duration}';
/// en: 'Next in ${duration}'
String nextAiringIn({required Object duration}) => 'Next in ${duration}';
/// en: '${n} eps'
String episodesShort({required Object n}) => '${n} eps';
/// en: '${n} min/ep'
String minutesPerEpisode({required Object n}) => '${n} min/ep';
/// en: '18+'
String get adult => '18+';
}
// Path: explore.stats
class Translations$explore$stats$en {
Translations$explore$stats$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: '${n} listed'
String listed({required Object n}) => '${n} listed';
/// en: '${n} watched today'
String viewersDay({required Object n}) => '${n} watched today';
/// en: '${n} watched this week'
String viewersWeek({required Object n}) => '${n} watched this week';
/// en: '${n} watched this month'
String viewersMonth({required Object n}) => '${n} watched this month';
/// en: '${n} watched this year'
String viewersYear({required Object n}) => '${n} watched this year';
/// en: '${n} viewers'
String viewersAllTime({required Object n}) => '${n} viewers';
/// en: '${n} planning to watch'
String planning({required Object n}) => '${n} planning to watch';
/// en: '${n} favorites'
String favorited({required Object n}) => '${n} favorites';
/// en: '${percent} dropped it'
String dropRate({required Object percent}) => '${percent} dropped it';
/// en: '(one) {${n} comment} (other) {${n} comments}'
String comments({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n,
one: '${n} comment',
other: '${n} comments',
);
/// en: '${n} votes'
String votes({required Object n}) => '${n} votes';
/// en: '${n} watching it'
String watching({required Object n}) => '${n} watching it';
/// en: '${n} completed'
String completed({required Object n}) => '${n} completed';
/// en: '${n} on hold'
String onHold({required Object n}) => '${n} on hold';
/// en: '${n} dropped'
String dropped({required Object n}) => '${n} dropped';
}
// Path: explore.season
class Translations$explore$season$en {
Translations$explore$season$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Winter'
String get winter => 'Winter';
/// en: 'Spring'
String get spring => 'Spring';
/// en: 'Summer'
String get summer => 'Summer';
/// en: 'Fall'
String get fall => 'Fall';
/// en: '${season} ${year}'
String withYear({required Object season, required Object year}) => '${season} ${year}';
}
// Path: explore.format
class Translations$explore$format$en {
Translations$explore$format$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'TV'
String get tv => 'TV';
/// en: 'TV Short'
String get tvShort => 'TV Short';
/// en: 'Movie'
String get movie => 'Movie';
/// en: 'Special'
String get special => 'Special';
/// en: 'OVA'
String get ova => 'OVA';
/// en: 'ONA'
String get ona => 'ONA';
/// en: 'Music'
String get music => 'Music';
/// en: 'Other'
String get other => 'Other';
}
// Path: explore.sourceMaterial
class Translations$explore$sourceMaterial$en {
Translations$explore$sourceMaterial$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Original'
String get original => 'Original';
/// en: 'Manga'
String get manga => 'Manga';
/// en: 'Light novel'
String get lightNovel => 'Light novel';
/// en: 'Novel'
String get novel => 'Novel';
/// en: 'Visual novel'
String get visualNovel => 'Visual novel';
/// en: 'Game'
String get game => 'Game';
/// en: 'Web comic'
String get webComic => 'Web comic';
/// en: 'Music'
String get musicRelease => 'Music';
/// en: 'Other'
String get otherMedia => 'Other';
}
// Path: explore.creditRole
class Translations$explore$creditRole$en {
Translations$explore$creditRole$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Director'
String get director => 'Director';
/// en: 'Writer'
String get writer => 'Writer';
/// en: 'Producer'
String get producer => 'Producer';
/// en: 'Creator'
String get creator => 'Creator';
/// en: 'Composer'
String get composer => 'Composer';
}
// Path: explore.ratingSource
class Translations$explore$ratingSource$en {
Translations$explore$ratingSource$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Critics'
String get critic => 'Critics';
/// en: 'Audience'
String get audience => 'Audience';
/// en: 'IMDb'
String get imdb => 'IMDb';
/// en: 'TMDB'
String get tmdb => 'TMDB';
/// en: 'Rotten Tomatoes'
String get rottenTomatoes => 'Rotten Tomatoes';
/// en: 'Simkl'
String get simkl => 'Simkl';
/// en: 'MyAnimeList'
String get mal => 'MyAnimeList';
/// en: 'AniList'
String get anilist => 'AniList';
/// en: 'Trakt'
String get trakt => 'Trakt';
/// en: 'Rotten Tomatoes critics'
String get rottenTomatoesCritic => 'Rotten Tomatoes critics';
/// en: 'Rotten Tomatoes audience'
String get rottenTomatoesAudience => 'Rotten Tomatoes audience';
}
// Path: explore.detail
class Translations$explore$detail$en {
Translations$explore$detail$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Original title'
String get originalTitle => 'Original title';
/// en: 'Also known as'
String get alsoKnownAs => 'Also known as';
/// en: 'Studios'
String get studios => 'Studios';
/// en: 'Country'
String get country => 'Country';
/// en: 'Language'
String get language => 'Language';
/// en: 'Released'
String get released => 'Released';
/// en: 'On disc'
String get physicalRelease => 'On disc';
/// en: 'Ended'
String get ended => 'Ended';
/// en: 'Added ${date}'
String addedOn({required Object date}) => 'Added ${date}';
/// en: 'Your rating'
String get yourRating => 'Your rating';
/// en: 'Budget'
String get budget => 'Budget';
/// en: 'Box office'
String get revenue => 'Box office';
/// en: 'Age guidance'
String get contentAdvisory => 'Age guidance';
/// en: 'Tags'
String get tags => 'Tags';
/// en: 'Show spoiler tags'
String get revealSpoilerTags => 'Show spoiler tags';
/// en: 'Links'
String get links => 'Links';
/// en: 'Watch on'
String get watchOn => 'Watch on';
/// en: 'Watch trailer'
String get watchTrailer => 'Watch trailer';
/// en: 'Open on ${site}'
String openOn({required Object site}) => 'Open on ${site}';
/// en: 'Crew'
String get crew => 'Crew';
/// en: 'Ratings'
String get ratings => 'Ratings';
/// en: 'Schedule'
String get schedule => 'Schedule';
/// en: '(one) {Recommended by ${n} user} (other) {Recommended by ${n} users}'
String recommendedByUsers({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n,
one: 'Recommended by ${n} user',
other: 'Recommended by ${n} users',
);
/// en: 'Recommended by ${who}'
String recommendedBy({required Object who}) => 'Recommended by ${who}';
/// en: 'Favorited by ${who}'
String favoritedBy({required Object who}) => 'Favorited by ${who}';
/// en: '${n} not aired yet'
String unairedEpisodes({required Object n}) => '${n} not aired yet';
/// en: 'Recommended by ${percent} of viewers'
String recommendedByPercent({required Object percent}) => 'Recommended by ${percent} of viewers';
/// en: 'Gallery'
String get gallery => 'Gallery';
/// en: 'Background'
String get background => 'Background';
}
// Path: explore.relation
class Translations$explore$relation$en {
Translations$explore$relation$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Prequel'
String get prequel => 'Prequel';
/// en: 'Sequel'
String get sequel => 'Sequel';
/// en: 'Side story'
String get sideStory => 'Side story';
/// en: 'Spin-off'
String get spinOff => 'Spin-off';
/// en: 'Alternative version'
String get alternativeVersion => 'Alternative version';
/// en: 'Summary'
String get summary => 'Summary';
/// en: 'Parent story'
String get parentStory => 'Parent story';
/// en: 'Adaptation'
String get adaptation => 'Adaptation';
/// en: 'Related'
String get other => 'Related';
}
// Path: downloads.backgroundWarning
class Translations$downloads$backgroundWarning$en {
Translations$downloads$backgroundWarning$en.internal(this._root);
@@ -6410,6 +6860,124 @@ extension on Translations {
'explore.searchEmpty' => ({required Object query}) => 'No results for "${query}"',
'explore.searchPrompt' => ({required Object source}) => 'Search for movies and shows on ${source}.',
'explore.searchFailed' => 'Search failed. Check your connection and try again.',
'explore.badge.rankPopular' => ({required Object n}) => '#${n} popular',
'explore.badge.rankAiring' => ({required Object n}) => '#${n} airing',
'explore.badge.rankRated' => ({required Object n}) => '#${n} rated',
'explore.badge.rankFavorited' => ({required Object n}) => '#${n} favorited',
'explore.badge.rankTrending' => ({required Object n}) => '#${n} trending',
'explore.badge.rankSeasonal' => ({required Object n, required Object season}) => '#${n} in ${season}',
'explore.badge.watchingNow' => ({required Object n}) => '${n} watching',
'explore.badge.available' => 'Available',
'explore.badge.partiallyAvailable' => 'Partly available',
'explore.badge.availableIn4k' => '4K available',
'explore.badge.requested' => 'Requested',
'explore.badge.pendingApproval' => 'Pending approval',
'explore.badge.processing' => 'Processing',
'explore.badge.declined' => 'Declined',
'explore.badge.requestFailed' => 'Request failed',
'explore.badge.requested4k' => '4K requested',
'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} seasons',
'explore.badge.nextEpisodeIn' => ({required Object episode, required Object duration}) => 'Ep ${episode} in ${duration}',
'explore.badge.nextAiringIn' => ({required Object duration}) => 'Next in ${duration}',
'explore.badge.episodesShort' => ({required Object n}) => '${n} eps',
'explore.badge.minutesPerEpisode' => ({required Object n}) => '${n} min/ep',
'explore.badge.adult' => '18+',
'explore.stats.listed' => ({required Object n}) => '${n} listed',
'explore.stats.viewersDay' => ({required Object n}) => '${n} watched today',
'explore.stats.viewersWeek' => ({required Object n}) => '${n} watched this week',
'explore.stats.viewersMonth' => ({required Object n}) => '${n} watched this month',
'explore.stats.viewersYear' => ({required Object n}) => '${n} watched this year',
'explore.stats.viewersAllTime' => ({required Object n}) => '${n} viewers',
'explore.stats.planning' => ({required Object n}) => '${n} planning to watch',
'explore.stats.favorited' => ({required Object n}) => '${n} favorites',
'explore.stats.dropRate' => ({required Object percent}) => '${percent} dropped it',
'explore.stats.comments' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: '${n} comment', other: '${n} comments', ),
'explore.stats.votes' => ({required Object n}) => '${n} votes',
'explore.stats.watching' => ({required Object n}) => '${n} watching it',
'explore.stats.completed' => ({required Object n}) => '${n} completed',
'explore.stats.onHold' => ({required Object n}) => '${n} on hold',
'explore.stats.dropped' => ({required Object n}) => '${n} dropped',
'explore.season.winter' => 'Winter',
'explore.season.spring' => 'Spring',
'explore.season.summer' => 'Summer',
'explore.season.fall' => 'Fall',
'explore.season.withYear' => ({required Object season, required Object year}) => '${season} ${year}',
'explore.format.tv' => 'TV',
'explore.format.tvShort' => 'TV Short',
'explore.format.movie' => 'Movie',
'explore.format.special' => 'Special',
'explore.format.ova' => 'OVA',
'explore.format.ona' => 'ONA',
'explore.format.music' => 'Music',
'explore.format.other' => 'Other',
'explore.sourceMaterial.original' => 'Original',
'explore.sourceMaterial.manga' => 'Manga',
'explore.sourceMaterial.lightNovel' => 'Light novel',
'explore.sourceMaterial.novel' => 'Novel',
'explore.sourceMaterial.visualNovel' => 'Visual novel',
'explore.sourceMaterial.game' => 'Game',
'explore.sourceMaterial.webComic' => 'Web comic',
'explore.sourceMaterial.musicRelease' => 'Music',
'explore.sourceMaterial.otherMedia' => 'Other',
'explore.creditRole.director' => 'Director',
'explore.creditRole.writer' => 'Writer',
'explore.creditRole.producer' => 'Producer',
'explore.creditRole.creator' => 'Creator',
'explore.creditRole.composer' => 'Composer',
'explore.ratingSource.critic' => 'Critics',
'explore.ratingSource.audience' => 'Audience',
'explore.ratingSource.imdb' => 'IMDb',
'explore.ratingSource.tmdb' => 'TMDB',
'explore.ratingSource.rottenTomatoes' => 'Rotten Tomatoes',
'explore.ratingSource.simkl' => 'Simkl',
'explore.ratingSource.mal' => 'MyAnimeList',
'explore.ratingSource.anilist' => 'AniList',
'explore.ratingSource.trakt' => 'Trakt',
'explore.ratingSource.rottenTomatoesCritic' => 'Rotten Tomatoes critics',
'explore.ratingSource.rottenTomatoesAudience' => 'Rotten Tomatoes audience',
'explore.broadcast' => ({required Object day, required Object time}) => 'Airs ${day} at ${time}',
'explore.broadcastWithZone' => ({required Object day, required Object time, required Object timezone}) => 'Airs ${day} at ${time} ${timezone}',
'explore.detail.originalTitle' => 'Original title',
'explore.detail.alsoKnownAs' => 'Also known as',
'explore.detail.studios' => 'Studios',
'explore.detail.country' => 'Country',
'explore.detail.language' => 'Language',
'explore.detail.released' => 'Released',
'explore.detail.physicalRelease' => 'On disc',
'explore.detail.ended' => 'Ended',
_ => null,
} ?? switch (path) {
'explore.detail.addedOn' => ({required Object date}) => 'Added ${date}',
'explore.detail.yourRating' => 'Your rating',
'explore.detail.budget' => 'Budget',
'explore.detail.revenue' => 'Box office',
'explore.detail.contentAdvisory' => 'Age guidance',
'explore.detail.tags' => 'Tags',
'explore.detail.revealSpoilerTags' => 'Show spoiler tags',
'explore.detail.links' => 'Links',
'explore.detail.watchOn' => 'Watch on',
'explore.detail.watchTrailer' => 'Watch trailer',
'explore.detail.openOn' => ({required Object site}) => 'Open on ${site}',
'explore.detail.crew' => 'Crew',
'explore.detail.ratings' => 'Ratings',
'explore.detail.schedule' => 'Schedule',
'explore.detail.recommendedByUsers' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'Recommended by ${n} user', other: 'Recommended by ${n} users', ),
'explore.detail.recommendedBy' => ({required Object who}) => 'Recommended by ${who}',
'explore.detail.favoritedBy' => ({required Object who}) => 'Favorited by ${who}',
'explore.detail.unairedEpisodes' => ({required Object n}) => '${n} not aired yet',
'explore.detail.recommendedByPercent' => ({required Object percent}) => 'Recommended by ${percent} of viewers',
'explore.detail.gallery' => 'Gallery',
'explore.detail.background' => 'Background',
'explore.totalResults' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: '${n} result', other: '${n} results', ),
'explore.relation.prequel' => 'Prequel',
'explore.relation.sequel' => 'Sequel',
'explore.relation.sideStory' => 'Side story',
'explore.relation.spinOff' => 'Spin-off',
'explore.relation.alternativeVersion' => 'Alternative version',
'explore.relation.summary' => 'Summary',
'explore.relation.parentStory' => 'Parent story',
'explore.relation.adaptation' => 'Adaptation',
'explore.relation.other' => 'Related',
'liveTv.title' => 'Live TV',
'liveTv.guide' => 'Guide',
'liveTv.noChannels' => 'No channels available',
@@ -6495,8 +7063,6 @@ extension on Translations {
'collections.removeFromCollectionConfirm' => ({required Object title}) => 'Remove "${title}" from this collection?',
'collections.removedFromCollection' => 'Removed from collection',
'collections.removeFromCollectionFailed' => 'Failed to remove from collection',
_ => null,
} ?? switch (path) {
'collections.removeFromCollectionError' => ({required Object error}) => 'Error removing from collection: ${error}',
'collections.searchCollections' => 'Search collections...',
'playlists.title' => 'Playlists',
@@ -6893,6 +7459,8 @@ extension on Translations {
'metadataEdit.artworkOption' => ({required Object index}) => 'Artwork option ${index}',
'metadataEdit.selectedArtworkOption' => ({required Object index}) => 'Artwork option ${index}, selected',
'metadataEdit.notSet' => 'Not set',
_ => null,
} ?? switch (path) {
'metadataEdit.libraryDefault' => 'Library default',
'metadataEdit.accountDefault' => 'Account default',
'metadataEdit.seriesDefault' => 'Series default',
@@ -7009,8 +7577,6 @@ extension on Translations {
'services.deviceCode.copyCode' => 'Copy activation code',
'services.deviceCode.waitingForAuthorization' => 'Waiting for authorization…',
'services.deviceCode.codeCopied' => 'Code copied',
_ => null,
} ?? switch (path) {
'services.oauthProxy.title' => ({required Object service}) => 'Sign in to ${service}',
'services.oauthProxy.body' => 'Scan this QR code or open the URL on any device.',
'services.oauthProxy.openToSignIn' => ({required Object service}) => 'Open ${service} to sign in',
+144 -1
View File
@@ -1021,7 +1021,150 @@
"searchHint": "Sök i ${source}",
"searchEmpty": "Inga resultat för \"${query}\"",
"searchPrompt": "Sök efter filmer och serier på ${source}.",
"searchFailed": "Sökningen misslyckades. Kontrollera din anslutning och försök igen."
"searchFailed": "Sökningen misslyckades. Kontrollera din anslutning och försök igen.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"one": "",
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "Live-TV",
+144 -1
View File
@@ -1021,7 +1021,150 @@
"searchHint": "${source} üzerinde ara",
"searchEmpty": "\"${query}\" için sonuç bulunamadı",
"searchPrompt": "${source} üzerinde film ve dizileri arayın.",
"searchFailed": "Arama başarısız. Bağlantınızı kontrol edip tekrar deneyin."
"searchFailed": "Arama başarısız. Bağlantınızı kontrol edip tekrar deneyin.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"one": "",
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "Canlı TV",
+144 -1
View File
@@ -1021,7 +1021,150 @@
"searchHint": "${source} ichidan qidirish",
"searchEmpty": "\"${query}\" boʻyicha natija topilmadi",
"searchPrompt": "${source} orqali filmlar va seriallarni qidiring.",
"searchFailed": "Qidiruv xatoligi. Ulanishni tekshiring."
"searchFailed": "Qidiruv xatoligi. Ulanishni tekshiring.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"one": "",
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "Jonli TV",
+141 -1
View File
@@ -1019,7 +1019,147 @@
"searchHint": "搜尋 ${source}",
"searchEmpty": "沒有「${query}」的結果",
"searchPrompt": "在 ${source} 搜尋電影與影集。",
"searchFailed": "搜尋失敗。請檢查網路連線後重試。"
"searchFailed": "搜尋失敗。請檢查網路連線後重試。",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "直播電視",
+141 -1
View File
@@ -1019,7 +1019,147 @@
"searchHint": "搜索 ${source}",
"searchEmpty": "没有“${query}”的结果",
"searchPrompt": "在 ${source} 上搜索电影和剧集。",
"searchFailed": "搜索失败。请检查网络连接后重试。"
"searchFailed": "搜索失败。请检查网络连接后重试。",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"comments": {
"other": ""
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"background": ""
},
"totalResults": {
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
"title": "直播电视",
+228 -14
View File
@@ -1,4 +1,24 @@
import '../../utils/json_utils.dart';
import '../../utils/trailer_urls.dart';
typedef AnilistNextAiringEpisode = ({int? episode, int airingAt, int? timeUntilAiring});
typedef AnilistRanking = ({
int rank,
String? type,
String? format,
int? year,
String? season,
bool allTime,
String? context,
});
typedef AnilistTag = ({String name, int? rank, bool isMediaSpoiler});
typedef AnilistLink = ({String label, String url, String? thumbnail});
typedef AnilistStaffCredit = ({String name, String role});
typedef AnilistCharacter = ({String name, String? role, String? imageUrl});
/// Anime metadata returned by the exact field selections in [AnilistClient].
///
@@ -6,62 +26,103 @@ import '../../utils/json_utils.dart';
/// are expected whenever a query requests a smaller shape (for example the
/// planning-list membership snapshot).
class AnilistMedia {
static const int _maxRetainedTags = 20;
final int? id;
final int? idMal;
final String? titleEnglish;
final String? titleRomaji;
final String? titleUserPreferred;
final String? titleNative;
final String? titleUserPreferred;
final List<String>? synonyms;
final String? format;
final String? status;
final int? episodes;
final int? duration;
final String? description;
final int? averageScore;
final int? meanScore;
final int? popularity;
final int? favourites;
final int? trending;
final String? season;
final int? seasonYear;
final int? startYear;
final int? startMonth;
final int? startDay;
final int? endYear;
final int? endMonth;
final int? endDay;
final List<String>? genres;
final List<String>? synonyms;
final bool isAdult;
final String? source;
final String? countryOfOrigin;
final String? coverImageExtraLarge;
final String? coverImageLarge;
final String? coverImageColor;
final String? bannerImage;
final List<String>? mainStudios;
final String? trailerId;
final String? trailerSite;
final AnilistNextAiringEpisode? nextAiringEpisode;
final List<AnilistRanking>? rankings;
final List<AnilistTag>? tags;
final List<AnilistLink>? externalLinks;
final List<AnilistLink>? streamingEpisodes;
final List<AnilistStaffCredit>? staffCredits;
final List<AnilistCharacter>? characters;
const AnilistMedia({
this.id,
this.idMal,
this.titleEnglish,
this.titleRomaji,
this.titleUserPreferred,
this.titleNative,
this.titleUserPreferred,
this.synonyms,
this.format,
this.status,
this.episodes,
this.duration,
this.description,
this.averageScore,
this.meanScore,
this.popularity,
this.favourites,
this.trending,
this.season,
this.seasonYear,
this.startYear,
this.startMonth,
this.startDay,
this.endYear,
this.endMonth,
this.endDay,
this.genres,
this.synonyms,
this.isAdult = false,
this.source,
this.countryOfOrigin,
this.coverImageExtraLarge,
this.coverImageLarge,
this.coverImageColor,
this.bannerImage,
this.mainStudios,
this.trailerId,
this.trailerSite,
this.nextAiringEpisode,
this.rankings,
this.tags,
this.externalLinks,
this.streamingEpisodes,
this.staffCredits,
this.characters,
});
factory AnilistMedia.fromJson(Map<String, dynamic> json) {
final title = json['title'];
final coverImage = json['coverImage'];
final startDate = json['startDate'];
final endDate = json['endDate'];
final studios = json['studios'];
final trailer = json['trailer'];
@@ -70,33 +131,68 @@ class AnilistMedia {
idMal: flexibleInt(json['idMal']),
titleEnglish: title is Map ? title['english'] as String? : null,
titleRomaji: title is Map ? title['romaji'] as String? : null,
titleUserPreferred: title is Map ? title['userPreferred'] as String? : null,
titleNative: title is Map ? title['native'] as String? : null,
titleUserPreferred: title is Map ? title['userPreferred'] as String? : null,
synonyms: _stringList(json['synonyms']),
format: json['format'] as String?,
status: json['status'] as String?,
episodes: flexibleInt(json['episodes']),
duration: flexibleInt(json['duration']),
description: stripHtml(json['description'] as String?),
averageScore: flexibleInt(json['averageScore']),
meanScore: flexibleInt(json['meanScore']),
popularity: flexibleInt(json['popularity']),
favourites: flexibleInt(json['favourites']),
trending: flexibleInt(json['trending']),
season: json['season'] as String?,
seasonYear: flexibleInt(json['seasonYear']),
startYear: startDate is Map ? flexibleInt(startDate['year']) : null,
startMonth: startDate is Map ? flexibleInt(startDate['month']) : null,
startDay: startDate is Map ? flexibleInt(startDate['day']) : null,
endYear: endDate is Map ? flexibleInt(endDate['year']) : null,
endMonth: endDate is Map ? flexibleInt(endDate['month']) : null,
endDay: endDate is Map ? flexibleInt(endDate['day']) : null,
genres: _stringList(json['genres']),
synonyms: _stringList(json['synonyms']),
isAdult: json['isAdult'] == true,
source: json['source'] as String?,
countryOfOrigin: json['countryOfOrigin'] as String?,
coverImageExtraLarge: coverImage is Map ? coverImage['extraLarge'] as String? : null,
coverImageLarge: coverImage is Map ? coverImage['large'] as String? : null,
coverImageColor: coverImage is Map ? coverImage['color'] as String? : null,
bannerImage: json['bannerImage'] as String?,
mainStudios: studios is Map ? _studioNames(studios['nodes']) : null,
trailerId: trailer is Map ? trailer['id'] as String? : null,
trailerSite: trailer is Map ? trailer['site'] as String? : null,
nextAiringEpisode: _nextAiringEpisode(json['nextAiringEpisode']),
rankings: _rankings(json['rankings']),
tags: _tags(json['tags']),
externalLinks: _links(json['externalLinks']),
streamingEpisodes: _links(json['streamingEpisodes'], streaming: true),
staffCredits: _staffCredits(json['staff']),
characters: _characters(json['characters']),
);
}
String get displayTitle => _nonEmpty(titleEnglish) ?? _nonEmpty(titleUserPreferred) ?? _nonEmpty(titleRomaji) ?? '';
String get displayTitle => _nonEmpty(titleUserPreferred) ?? _nonEmpty(titleEnglish) ?? _nonEmpty(titleRomaji) ?? '';
List<String>? get alternateTitles {
final chosen = displayTitle.toLowerCase();
final values = <String>[];
for (final candidate in [titleEnglish, titleRomaji, ...?synonyms]) {
final title = _nonEmpty(candidate);
if (title == null || title.toLowerCase() == chosen) continue;
if (values.any((value) => value.toLowerCase() == title.toLowerCase())) continue;
values.add(title);
}
return values.isEmpty ? null : values;
}
int? get year => seasonYear ?? startYear;
DateTime? get releaseDate => _date(startYear, startMonth, startDay);
DateTime? get finalEpisodeDate => _date(endYear, endMonth, endDay);
String? get posterUrl => _nonEmpty(coverImageExtraLarge) ?? _nonEmpty(coverImageLarge);
String? get backdropUrl => _nonEmpty(bannerImage);
@@ -106,6 +202,11 @@ class AnilistMedia {
return score == null || score <= 0 ? null : score / 10;
}
double? get meanRating {
final score = meanScore;
return score == null || score <= 0 ? null : score / 10;
}
int? get votes => null;
int? get runtimeMinutes => duration == null || duration! <= 0 ? null : duration;
@@ -121,7 +222,7 @@ class AnilistMedia {
String? get trailerUrl {
final id = _nonEmpty(trailerId);
if (id == null || trailerSite?.toLowerCase() != 'youtube') return null;
return 'https://www.youtube.com/watch?v=$id';
return youTubeTrailerUrl(id);
}
bool get isMovie => format == 'MOVIE';
@@ -143,25 +244,138 @@ class AnilistMedia {
return plain.isEmpty ? null : plain;
}
static String? _nonEmpty(String? value) => value == null || value.isEmpty ? null : value;
static String? _nonEmpty(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}
static DateTime? _date(int? year, int? month, int? day) {
if (year == null || year <= 0 || month == null || day == null) return null;
final date = DateTime.utc(year, month, day);
return date.year == year && date.month == month && date.day == day ? date : null;
}
static List<String>? _stringList(Object? value) {
if (value is! List) return null;
final strings = [
for (final item in value)
if (item is String && item.isNotEmpty) item,
if (item is String)
if (_nonEmpty(item) case final String string) string,
];
return strings.isEmpty ? null : strings;
}
static List<String>? _studioNames(Object? value) {
if (value is! List) return null;
final names = [
for (final node in value)
if (node is Map && node['name'] is String && (node['name'] as String).isNotEmpty) node['name'] as String,
];
final names = <String>[];
for (final node in value) {
if (node is! Map || node['name'] is! String) continue;
final name = _nonEmpty(node['name'] as String);
if (name != null && !names.contains(name)) names.add(name);
}
return names.isEmpty ? null : names;
}
static AnilistNextAiringEpisode? _nextAiringEpisode(Object? value) {
if (value is! Map) return null;
final airingAt = flexibleInt(value['airingAt']);
if (airingAt == null || airingAt <= 0) return null;
return (
episode: flexibleInt(value['episode']),
airingAt: airingAt,
timeUntilAiring: flexibleInt(value['timeUntilAiring']),
);
}
static List<AnilistRanking>? _rankings(Object? value) {
if (value is! List) return null;
final rankings = <AnilistRanking>[];
for (final raw in value) {
if (raw is! Map) continue;
final rank = flexibleInt(raw['rank']);
if (rank == null || rank <= 0) continue;
rankings.add((
rank: rank,
type: raw['type'] as String?,
format: raw['format'] as String?,
year: flexibleInt(raw['year']),
season: raw['season'] as String?,
allTime: raw['allTime'] == true,
context: raw['context'] as String?,
));
}
return rankings.isEmpty ? null : rankings;
}
static List<AnilistTag>? _tags(Object? value) {
if (value is! List) return null;
final tags = <AnilistTag>[];
for (final raw in value) {
if (raw is! Map || raw['name'] is! String) continue;
final name = _nonEmpty(raw['name'] as String);
if (name == null) continue;
tags.add((name: name, rank: flexibleInt(raw['rank']), isMediaSpoiler: raw['isMediaSpoiler'] == true));
if (tags.length == _maxRetainedTags) break;
}
return tags.isEmpty ? null : tags;
}
static List<AnilistLink>? _links(Object? value, {bool streaming = false}) {
if (value is! List) return null;
final links = <AnilistLink>[];
for (final raw in value) {
if (raw is! Map) continue;
final url = raw['url'] is String ? _httpUrl(raw['url'] as String) : null;
final label = _nonEmpty(raw['site'] as String?) ?? (streaming ? _nonEmpty(raw['title'] as String?) : null);
if (url == null || label == null) continue;
links.add((
label: label,
url: url,
thumbnail: streaming && raw['thumbnail'] is String ? _nonEmpty(raw['thumbnail'] as String) : null,
));
}
return links.isEmpty ? null : links;
}
static String? _httpUrl(String value) {
final trimmed = _nonEmpty(value);
final uri = trimmed == null ? null : Uri.tryParse(trimmed);
return uri != null && (uri.scheme == 'http' || uri.scheme == 'https') ? trimmed : null;
}
static List<AnilistCharacter>? _characters(Object? value) {
final edges = value is Map ? value['edges'] : null;
if (edges is! List) return null;
final characters = <AnilistCharacter>[];
for (final edge in edges) {
if (edge is! Map) continue;
final node = edge['node'];
final name = node is Map ? node['name'] : null;
final fullName = name is Map && name['full'] is String ? _nonEmpty(name['full'] as String) : null;
if (fullName == null) continue;
final image = node is Map ? node['image'] : null;
final large = image is Map && image['large'] is String ? _nonEmpty(image['large'] as String) : null;
final medium = image is Map && image['medium'] is String ? _nonEmpty(image['medium'] as String) : null;
final role = edge['role'] is String ? _nonEmpty(edge['role'] as String) : null;
characters.add((name: fullName, role: role, imageUrl: large ?? medium));
}
return characters;
}
static List<AnilistStaffCredit>? _staffCredits(Object? value) {
final edges = value is Map ? value['edges'] : null;
if (edges is! List) return null;
final credits = <AnilistStaffCredit>[];
for (final edge in edges) {
if (edge is! Map || edge['role'] is! String) continue;
final node = edge['node'];
final name = node is Map ? node['name'] : null;
final fullName = name is Map && name['full'] is String ? _nonEmpty(name['full'] as String) : null;
final role = _nonEmpty(edge['role'] as String);
if (fullName != null && role != null) credits.add((name: fullName, role: role));
}
return credits.isEmpty ? null : credits;
}
}
typedef AnilistPage = ({List<AnilistMedia> items, bool hasMore});
@@ -10,4 +10,20 @@ class CatalogCastMember {
final String? imageUrl;
const CatalogCastMember({required this.name, this.secondary, this.imageUrl});
Map<String, Object?> toJson() => {
'name': name,
if (secondary != null) 'secondary': secondary,
if (imageUrl != null) 'imageUrl': imageUrl,
};
static CatalogCastMember? fromJson(Map<String, Object?> json) {
final name = json['name'] as String?;
if (name == null) return null;
return CatalogCastMember(
name: name,
secondary: json['secondary'] as String?,
imageUrl: json['imageUrl'] as String?,
);
}
}
+393 -1
View File
@@ -2,6 +2,8 @@ import '../../media/media_backend.dart';
import '../../media/media_item.dart';
import '../../media/media_kind.dart';
import '../../utils/external_ids.dart';
import 'catalog_cast_member.dart';
import 'catalog_metadata.dart';
/// External catalog providers that can back the Explore tab.
enum CatalogSourceId { plex, trakt, mal, anilist, simkl, seerr }
@@ -100,6 +102,20 @@ class CatalogItemIds {
ExternalIds toExternalIds() => ExternalIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb);
/// Union of both id sets, preferring this one per key. A row entry and a
/// detail body routinely carry different subsets of the same identity.
CatalogItemIds mergedWith(CatalogItemIds other) => CatalogItemIds(
plex: plex ?? other.plex,
trakt: trakt ?? other.trakt,
slug: slug ?? other.slug,
mal: mal ?? other.mal,
anilist: anilist ?? other.anilist,
simkl: simkl ?? other.simkl,
imdb: imdb ?? other.imdb,
tmdb: tmdb ?? other.tmdb,
tvdb: tvdb ?? other.tvdb,
);
Map<String, Object?> toJson() => {
if (plex != null) 'plex': plex,
if (trakt != null) 'trakt': trakt,
@@ -170,10 +186,152 @@ class CatalogItem {
final String? network;
final CatalogItemIds ids;
/// Absolute https URLs served by the provider's CDN.
/// Absolute https URLs served by the provider's CDN. These stay the default
/// asset for every surface; see [posterVariants] for size selection.
final String? posterUrl;
final String? backdropUrl;
/// Width-keyed alternates for [posterUrl] and [backdropUrl], keyed by the
/// asset's real pixel width.
///
/// Providers serve the same artwork at several sizes (TMDB `w342`/`w500`,
/// Simkl's suffixed variants). The mapper cannot pick correctly because it
/// does not know the surface: a 92-pixel search thumbnail and a 340-pixel
/// high-DPR TV card have opposite needs. Mappers publish what exists,
/// widgets choose with [posterFor]/[backdropFor] once they have measured.
final Map<int, String>? posterVariants;
final Map<int, String>? backdropVariants;
/// Transparent title treatment (Trakt `images.logo`), for spotlight art.
final String? logoUrl;
/// Wide banner art (Plex `banner`), distinct from the 16:9 backdrop.
final String? bannerUrl;
/// Dominant cover colour as `#rrggbb` (AniList `coverImage.color`), for
/// per-card accent tinting.
final String? accentColor;
/// Scores beyond [rating], each with its own source label.
final List<CatalogRatingSource>? ratings;
/// Leaderboard positions. A list because providers return several windows
/// at once (all-time and seasonal).
final List<CatalogRank>? ranks;
/// Community-size counters; null when the provider returns none.
final CatalogAudience? audience;
/// Recurring weekly slot. Never render this as a next-episode date.
final CatalogBroadcast? broadcast;
/// The next episode's actual air time, when the provider knows it.
final CatalogNextEpisode? nextEpisode;
/// What the user's own servers have, and what has been requested.
final CatalogServerState? serverState;
/// Exact premiere/release date; [year] stays the coarse fallback.
final DateTime? releaseDate;
/// Home-media release (Simkl movie `released_dvd`).
final DateTime? physicalReleaseDate;
/// Final air date for an ended show.
final DateTime? endDate;
/// When the user added it to the list this item came from (Trakt
/// `listed_at`, Simkl watchlist `added_to_watchlist`).
final DateTime? addedAt;
/// The user's own score, 0-10, on providers that return it with the list.
final double? userRating;
/// Native-language or production title when it differs from [title].
final String? originalTitle;
final String? tagline;
/// Anime broadcast season — "Fall 2025".
///
/// Distinct from [season], which records *which season of a parent series*
/// an entry maps to for library matching. This one is a calendar window.
final CatalogSeasonInfo? broadcastSeason;
/// Finer-grained release format than [kind] (OVA, ONA, TV short, ...).
final CatalogFormat? format;
/// What the title was adapted from.
final CatalogSourceMaterial? sourceMaterial;
/// Production studios. [network] remains the single broadcast network.
final List<String>? studios;
/// ISO 3166-1 alpha-2 country codes.
final List<String>? countries;
/// ISO 639-1 language codes; the first is the original language.
final List<String>? languages;
/// Directors, writers and producers from a detail payload.
final List<CatalogCredit>? credits;
/// Ranked descriptive tags, spoilers included and flagged.
final List<CatalogTag>? tags;
/// Provider and streaming links.
final List<CatalogLink>? links;
/// Episodes announced but not yet broadcast (Simkl
/// `not_aired_episodes_count`). Distinct from [episodeCount], which counts
/// what exists, and from any watched count.
final int? unairedEpisodeCount;
/// Who recommended this title and why (Trakt `favorited_by` /
/// `recommended_by` on personalised rows). The provenance is the point of a
/// social recommendation, so this is a list of people, not a count.
final List<CatalogRecommender>? recommenders;
/// Age-appropriateness prose beyond [certification] (Plex Common Sense).
final String? contentAdvisory;
/// Production budget and box-office revenue in USD.
final int? budget;
final int? revenue;
/// Provider-flagged adult content (MAL `nsfw`, TMDB `adult`).
final bool? isAdult;
/// How many users recommended this item *from another item*. Only set on
/// entries returned by a related/recommendation call.
final int? recommendationCount;
/// Share of users who recommended this item *from another item*, 0-1
/// (Simkl `users_percent`, sent as a percentage and normalized by the
/// mapper). Independent of [recommendationCount]: a count and a share
/// answer different questions and neither can be derived from the other.
final double? recommendationPercent;
/// Play state the catalog provider itself reports.
final CatalogPlayState? playState;
/// Provider search relevance. Ordering input only — never rendered.
final double? relevance;
/// Extra artwork the provider ships alongside the poster (MAL `pictures`).
/// Plain URLs: these providers publish no pixel widths, so they cannot go
/// through [posterVariants], whose keys must be real widths.
final List<String>? gallery;
/// Production/background prose (MAL `background`) — trivia about how the
/// title came to exist, distinct from the plot [overview].
final String? background;
/// Cast carried on the row item itself, for providers whose row query can
/// select it without an extra request (AniList). Lets a detail screen skip
/// its lazy cast call; null means "not known yet", not "no cast".
final List<CatalogCastMember>? cast;
const CatalogItem({
required this.source,
required this.kind,
@@ -194,11 +352,132 @@ class CatalogItem {
required this.ids,
this.posterUrl,
this.backdropUrl,
this.posterVariants,
this.backdropVariants,
this.logoUrl,
this.bannerUrl,
this.accentColor,
this.ratings,
this.ranks,
this.audience,
this.broadcast,
this.nextEpisode,
this.serverState,
this.releaseDate,
this.physicalReleaseDate,
this.endDate,
this.addedAt,
this.userRating,
this.originalTitle,
this.tagline,
this.broadcastSeason,
this.format,
this.sourceMaterial,
this.studios,
this.countries,
this.languages,
this.credits,
this.tags,
this.links,
this.contentAdvisory,
this.budget,
this.revenue,
this.isAdult,
this.recommendationCount,
this.recommendationPercent,
this.playState,
this.relevance,
this.gallery,
this.background,
this.cast,
this.unairedEpisodeCount,
this.recommenders,
});
/// Returns this row item enriched by a [detail] payload for the same title.
///
/// Detail wins for descriptive content: a detail body carries the untruncated
/// overview, the full rating and ids the row object never had. The row wins
/// only for values that exist *because of the row it came from* — leaderboard
/// position, recommendation provenance, when the user listed it, their own
/// score, and search relevance — none of which a detail endpoint knows.
/// Ids merge per key so a row's imdb id survives a detail body that only
/// returns tmdb.
CatalogItem enrichedWith(CatalogItem detail) => CatalogItem(
source: source,
kind: kind,
title: detail.title.isNotEmpty ? detail.title : title,
year: detail.year ?? year,
overview: detail.overview ?? overview,
runtimeMinutes: detail.runtimeMinutes ?? runtimeMinutes,
rating: detail.rating ?? rating,
votes: detail.votes ?? votes,
genres: detail.genres ?? genres,
certification: detail.certification ?? certification,
trailerUrl: detail.trailerUrl ?? trailerUrl,
airStatus: detail.airStatus ?? airStatus,
episodeCount: detail.episodeCount ?? episodeCount,
network: detail.network ?? network,
ids: ids.mergedWith(detail.ids),
posterUrl: detail.posterUrl ?? posterUrl,
backdropUrl: detail.backdropUrl ?? backdropUrl,
posterVariants: detail.posterVariants ?? posterVariants,
backdropVariants: detail.backdropVariants ?? backdropVariants,
logoUrl: detail.logoUrl ?? logoUrl,
bannerUrl: detail.bannerUrl ?? bannerUrl,
accentColor: detail.accentColor ?? accentColor,
ratings: detail.ratings ?? ratings,
// Union rather than replace: the row and the detail body each carry
// counters the other omits.
audience: audience?.mergedWith(detail.audience ?? const CatalogAudience()) ?? detail.audience,
broadcast: detail.broadcast ?? broadcast,
nextEpisode: detail.nextEpisode ?? nextEpisode,
serverState: detail.serverState ?? serverState,
releaseDate: detail.releaseDate ?? releaseDate,
physicalReleaseDate: detail.physicalReleaseDate ?? physicalReleaseDate,
endDate: detail.endDate ?? endDate,
originalTitle: detail.originalTitle ?? originalTitle,
altTitles: altTitles.isNotEmpty ? altTitles : detail.altTitles,
tagline: detail.tagline ?? tagline,
broadcastSeason: detail.broadcastSeason ?? broadcastSeason,
format: detail.format ?? format,
sourceMaterial: detail.sourceMaterial ?? sourceMaterial,
studios: detail.studios ?? studios,
countries: detail.countries ?? countries,
languages: detail.languages ?? languages,
credits: detail.credits ?? credits,
tags: detail.tags ?? tags,
links: detail.links ?? links,
contentAdvisory: detail.contentAdvisory ?? contentAdvisory,
budget: detail.budget ?? budget,
revenue: detail.revenue ?? revenue,
isAdult: detail.isAdult ?? isAdult,
playState: detail.playState ?? playState,
unairedEpisodeCount: detail.unairedEpisodeCount ?? unairedEpisodeCount,
// Row-only context: a detail endpoint cannot know these.
ranks: ranks ?? detail.ranks,
addedAt: addedAt ?? detail.addedAt,
userRating: userRating ?? detail.userRating,
recommendationCount: recommendationCount ?? detail.recommendationCount,
recommendationPercent: recommendationPercent ?? detail.recommendationPercent,
relevance: relevance ?? detail.relevance,
gallery: detail.gallery ?? gallery,
background: detail.background ?? background,
cast: detail.cast ?? cast,
recommenders: recommenders ?? detail.recommenders,
);
/// Kind-namespaced identity key for caches and dedupe.
String get identityKey => '${kind.id}/${ids.canonicalKey}';
/// The narrowest poster at least [targetPx] wide, falling back to the
/// widest variant and finally to [posterUrl].
String? posterFor(int targetPx) => _variantFor(posterUrl, posterVariants, targetPx);
/// The narrowest backdrop at least [targetPx] wide, falling back to the
/// widest variant and finally to [backdropUrl].
String? backdropFor(int targetPx) => _variantFor(backdropUrl, backdropVariants, targetPx);
/// Cache key for anything whose answer is season-specific — see
/// [CatalogItemIds.entryKey], which [identityKey] deliberately does not use.
String get entryIdentityKey => '${kind.id}/${ids.entryKey}';
@@ -247,6 +526,46 @@ class CatalogItem {
'ids': ids.toJson(),
if (posterUrl != null) 'posterUrl': posterUrl,
if (backdropUrl != null) 'backdropUrl': backdropUrl,
if (posterVariants != null) 'posterVariants': {for (final e in posterVariants!.entries) '${e.key}': e.value},
if (backdropVariants != null) 'backdropVariants': {for (final e in backdropVariants!.entries) '${e.key}': e.value},
if (logoUrl != null) 'logoUrl': logoUrl,
if (bannerUrl != null) 'bannerUrl': bannerUrl,
if (accentColor != null) 'accentColor': accentColor,
if (ratings != null) 'ratings': [for (final r in ratings!) r.toJson()],
if (ranks != null) 'ranks': [for (final r in ranks!) r.toJson()],
if (audience != null) 'audience': audience!.toJson(),
if (broadcast != null) 'broadcast': broadcast!.toJson(),
if (nextEpisode != null) 'nextEpisode': nextEpisode!.toJson(),
if (serverState != null) 'serverState': serverState!.toJson(),
if (releaseDate != null) 'releaseDate': releaseDate!.toIso8601String(),
if (physicalReleaseDate != null) 'physicalReleaseDate': physicalReleaseDate!.toIso8601String(),
if (endDate != null) 'endDate': endDate!.toIso8601String(),
if (addedAt != null) 'addedAt': addedAt!.toIso8601String(),
if (userRating != null) 'userRating': userRating,
if (originalTitle != null) 'originalTitle': originalTitle,
if (tagline != null) 'tagline': tagline,
if (broadcastSeason != null) 'broadcastSeason': broadcastSeason!.toJson(),
if (format != null) 'format': format!.name,
if (sourceMaterial != null) 'sourceMaterial': sourceMaterial!.name,
if (studios != null) 'studios': studios,
if (countries != null) 'countries': countries,
if (languages != null) 'languages': languages,
if (credits != null) 'credits': [for (final c in credits!) c.toJson()],
if (tags != null) 'tags': [for (final t in tags!) t.toJson()],
if (links != null) 'links': [for (final l in links!) l.toJson()],
if (unairedEpisodeCount != null) 'unairedEpisodeCount': unairedEpisodeCount,
if (recommenders != null) 'recommenders': [for (final r in recommenders!) r.toJson()],
if (contentAdvisory != null) 'contentAdvisory': contentAdvisory,
if (budget != null) 'budget': budget,
if (revenue != null) 'revenue': revenue,
if (isAdult != null) 'isAdult': isAdult,
if (recommendationCount != null) 'recommendationCount': recommendationCount,
if (recommendationPercent != null) 'recommendationPercent': recommendationPercent,
if (playState != null) 'playState': playState!.toJson(),
if (relevance != null) 'relevance': relevance,
if (gallery != null) 'gallery': gallery,
if (background != null) 'background': background,
if (cast != null) 'cast': [for (final c in cast!) c.toJson()],
};
factory CatalogItem.fromJson(Map<String, Object?> json) => CatalogItem(
@@ -278,5 +597,78 @@ class CatalogItem {
ids: CatalogItemIds.fromJson((json['ids'] as Map?)?.cast<String, Object?>() ?? const {}),
posterUrl: json['posterUrl'] as String?,
backdropUrl: json['backdropUrl'] as String?,
posterVariants: _decodeVariants(json['posterVariants']),
backdropVariants: _decodeVariants(json['backdropVariants']),
logoUrl: json['logoUrl'] as String?,
bannerUrl: json['bannerUrl'] as String?,
accentColor: json['accentColor'] as String?,
ratings: decodeCatalogList(json['ratings'], CatalogRatingSource.fromJson),
ranks: decodeCatalogList(json['ranks'], CatalogRank.fromJson),
audience: _decodeObject(json['audience'], CatalogAudience.fromJson),
broadcast: _decodeObject(json['broadcast'], CatalogBroadcast.fromJson),
nextEpisode: _decodeObject(json['nextEpisode'], CatalogNextEpisode.fromJson),
serverState: _decodeObject(json['serverState'], CatalogServerState.fromJson),
releaseDate: _decodeDate(json['releaseDate']),
physicalReleaseDate: _decodeDate(json['physicalReleaseDate']),
endDate: _decodeDate(json['endDate']),
addedAt: _decodeDate(json['addedAt']),
userRating: (json['userRating'] as num?)?.toDouble(),
originalTitle: json['originalTitle'] as String?,
tagline: json['tagline'] as String?,
broadcastSeason: _decodeObject(json['broadcastSeason'], CatalogSeasonInfo.fromJson),
format: CatalogFormat.values.asNameMap()[json['format']],
sourceMaterial: CatalogSourceMaterial.values.asNameMap()[json['sourceMaterial']],
studios: (json['studios'] as List?)?.cast<String>(),
countries: (json['countries'] as List?)?.cast<String>(),
languages: (json['languages'] as List?)?.cast<String>(),
credits: decodeCatalogList(json['credits'], CatalogCredit.fromJson),
tags: decodeCatalogList(json['tags'], CatalogTag.fromJson),
links: decodeCatalogList(json['links'], CatalogLink.fromJson),
contentAdvisory: json['contentAdvisory'] as String?,
budget: json['budget'] as int?,
revenue: json['revenue'] as int?,
isAdult: json['isAdult'] as bool?,
recommendationCount: json['recommendationCount'] as int?,
recommendationPercent: (json['recommendationPercent'] as num?)?.toDouble(),
playState: _decodeObject(json['playState'], CatalogPlayState.fromJson),
unairedEpisodeCount: json['unairedEpisodeCount'] as int?,
recommenders: decodeCatalogList(json['recommenders'], CatalogRecommender.fromJson),
relevance: (json['relevance'] as num?)?.toDouble(),
gallery: (json['gallery'] as List?)?.cast<String>(),
background: json['background'] as String?,
cast: decodeCatalogList(json['cast'], CatalogCastMember.fromJson),
);
}
T? _decodeObject<T>(Object? raw, T? Function(Map<String, Object?>) decode) =>
raw is Map ? decode(raw.cast<String, Object?>()) : null;
DateTime? _decodeDate(Object? raw) => raw is String ? DateTime.tryParse(raw) : null;
Map<int, String>? _decodeVariants(Object? raw) {
if (raw is! Map) return null;
final out = <int, String>{};
for (final entry in raw.entries) {
final width = int.tryParse('${entry.key}');
final url = entry.value;
if (width != null && url is String) out[width] = url;
}
return out.isEmpty ? null : out;
}
/// Picks the narrowest variant that still covers [targetPx].
///
/// When no variant is wide enough the default asset wins, because it is the
/// provider's canonical (usually largest) rendition and upscaling a too-small
/// image looks worse than spending a few extra kilobytes. Mappers that know
/// the default's own pixel width should register it in the map as well, so it
/// competes on equal terms instead of only being a last resort.
String? _variantFor(String? fallback, Map<int, String>? variants, int targetPx) {
if (variants == null || variants.isEmpty) return fallback;
int? best;
for (final width in variants.keys) {
if (width >= targetPx && (best == null || width < best)) best = width;
}
if (best == null) return fallback ?? variants[variants.keys.reduce((a, b) => a > b ? a : b)];
return variants[best];
}
+515
View File
@@ -0,0 +1,515 @@
/// Value objects for the richer catalog facts providers already return on the
/// requests the Explore tab makes today.
///
/// These live beside [CatalogItem] rather than inside it so the item stays
/// readable: every type here is a small, provider-neutral shape that more than
/// one backend can fill. Serialization matches [CatalogItem]'s hand-written
/// style — no `json_serializable` — because catalog objects only ever
/// round-trip through `MediaItem.raw` inside a single session.
library;
/// One provider score with its own source label.
///
/// Providers routinely return several: Simkl carries Simkl/IMDb/MAL side by
/// side, Plex carries a critic score next to an audience score. The neutral
/// [CatalogItem.rating] keeps the provider's headline number; this list keeps
/// the rest with attribution so the UI can label them.
class CatalogRatingSource {
/// Stable, lowercase source key: `simkl`, `imdb`, `mal`, `anilist`, `tmdb`,
/// `trakt`, `critic`, `audience`. Rendered through a label map, never raw.
final String source;
/// Normalized to 0-10 by the mapper, matching [CatalogItem.rating].
final double value;
/// How many users the score is based on, when the provider says.
final int? votes;
const CatalogRatingSource({required this.source, required this.value, this.votes});
Map<String, Object?> toJson() => {'source': source, 'value': value, if (votes != null) 'votes': votes};
static CatalogRatingSource? fromJson(Map<String, Object?> json) {
final source = json['source'] as String?;
final value = (json['value'] as num?)?.toDouble();
if (source == null || value == null) return null;
return CatalogRatingSource(source: source, value: value, votes: json['votes'] as int?);
}
}
/// What a leaderboard position is a position *in*.
///
/// MAL returns one rank per ranking endpoint, AniList returns a `rankings`
/// array tagged by type, Simkl returns a trending rank. Normalizing the scope
/// lets one badge render `#3 airing` or `#12 most popular` from any of them.
enum CatalogRankScope { popular, airing, rated, favorited, trending, seasonal }
/// A leaderboard position within [scope], over either all time or one
/// season/year window.
///
/// Providers return several at once — AniList's `rankings` array tags each
/// entry with type, format, year, season and an `allTime` flag — so an item
/// holds a list of these, not one. Dropping the window would turn "#3 most
/// popular this season" into a false all-time claim.
class CatalogRank {
final int rank;
final CatalogRankScope scope;
/// True for an all-time chart; false when [year]/[season] bound it.
final bool allTime;
final int? year;
final CatalogSeasonName? season;
const CatalogRank({required this.rank, required this.scope, this.allTime = true, this.year, this.season});
Map<String, Object?> toJson() => {
'rank': rank,
'scope': scope.name,
if (!allTime) 'allTime': false,
if (year != null) 'year': year,
if (season != null) 'season': season!.name,
};
static CatalogRank? fromJson(Map<String, Object?> json) {
final rank = json['rank'] as int?;
final scope = CatalogRankScope.values.asNameMap()[json['scope']];
if (rank == null || scope == null) return null;
return CatalogRank(
rank: rank,
scope: scope,
allTime: json['allTime'] != false,
year: json['year'] as int?,
season: CatalogSeasonName.values.asNameMap()[json['season']],
);
}
}
/// The window a windowed counter covers. Providers report popularity over a
/// period, and rendering such a number without its period is a false claim.
enum CatalogAudiencePeriod { day, week, month, year, allTime }
/// Community-size counters. Every field is optional because no provider
/// returns all of them: Trakt has live watchers and comments, MAL/AniList have
/// list membership and favourites, Simkl has windowed viewers, planning and a
/// drop rate.
class CatalogAudience {
/// Users watching *right now* (Trakt trending `watchers`).
final int? watchingNow;
/// Users with the title on any list (MAL `num_list_users`, AniList
/// `popularity`).
final int? listed;
/// Viewers within [viewersPeriod] (Simkl `watched`). This is *not* a
/// completion count: Simkl's trending rows count viewers in the row's
/// timeframe and its Best rows count viewers this month. Never render it
/// without the period label.
final int? viewers;
final CatalogAudiencePeriod? viewersPeriod;
/// Users who plan to watch it (Simkl `plan_to_watch`).
final int? planning;
/// Users with it in progress on their list (MAL
/// `statistics.status.watching`). A list-status count, not the live
/// concurrent-viewer figure in [watchingNow] — do not conflate them.
final int? watching;
/// Users who finished it (MAL `statistics.status.completed`). Unlike
/// [viewers] this is a genuine lifetime status count, not a window.
final int? completed;
/// Users who paused it (MAL `statistics.status.on_hold`).
final int? onHold;
/// Users who abandoned it (MAL `statistics.status.dropped`). A count, where
/// [dropRate] is a share.
final int? dropped;
/// Recent-activity score (AniList `trending`): how much the community is
/// talking about it right now. A momentum signal, not a rank and not a
/// headcount — it has no unit and must be rendered comparatively or not at
/// all.
final int? trendingActivity;
/// Users who favourited it (AniList `favourites`).
final int? favorited;
/// Share of users who dropped it, 0-1 (Simkl `drop_rate`, sent as a
/// percentage and normalized by the mapper).
final double? dropRate;
/// Provider comment count (Trakt `comment_count`).
final int? comments;
const CatalogAudience({
this.watchingNow,
this.listed,
this.viewers,
this.viewersPeriod,
this.planning,
this.watching,
this.completed,
this.onHold,
this.dropped,
this.trendingActivity,
this.favorited,
this.dropRate,
this.comments,
});
bool get isEmpty =>
watchingNow == null &&
listed == null &&
viewers == null &&
planning == null &&
watching == null &&
completed == null &&
onHold == null &&
dropped == null &&
trendingActivity == null &&
favorited == null &&
dropRate == null &&
comments == null;
Map<String, Object?> toJson() => {
if (watchingNow != null) 'watchingNow': watchingNow,
if (listed != null) 'listed': listed,
if (viewers != null) 'viewers': viewers,
if (viewersPeriod != null) 'viewersPeriod': viewersPeriod!.name,
if (planning != null) 'planning': planning,
if (watching != null) 'watching': watching,
if (completed != null) 'completed': completed,
if (onHold != null) 'onHold': onHold,
if (dropped != null) 'dropped': dropped,
if (trendingActivity != null) 'trendingActivity': trendingActivity,
if (favorited != null) 'favorited': favorited,
if (dropRate != null) 'dropRate': dropRate,
if (comments != null) 'comments': comments,
};
/// Field-wise union, preferring [other]'s values.
///
/// Counters genuinely arrive from different responses: a Simkl trending row
/// supplies windowed [viewers] and [planning] that its detail body does not
/// return, while the detail body supplies [dropRate] and MAL's status
/// counts. Replacing the whole object would silently drop whichever side
/// spoke first. [viewersPeriod] travels with [viewers] so a count is never
/// relabelled with someone else's window.
CatalogAudience mergedWith(CatalogAudience other) => CatalogAudience(
watchingNow: other.watchingNow ?? watchingNow,
listed: other.listed ?? listed,
viewers: other.viewers ?? viewers,
viewersPeriod: other.viewers != null ? other.viewersPeriod : viewersPeriod,
planning: other.planning ?? planning,
watching: other.watching ?? watching,
completed: other.completed ?? completed,
onHold: other.onHold ?? onHold,
dropped: other.dropped ?? dropped,
trendingActivity: other.trendingActivity ?? trendingActivity,
favorited: other.favorited ?? favorited,
dropRate: other.dropRate ?? dropRate,
comments: other.comments ?? comments,
);
factory CatalogAudience.fromJson(Map<String, Object?> json) => CatalogAudience(
watchingNow: json['watchingNow'] as int?,
listed: json['listed'] as int?,
viewers: json['viewers'] as int?,
viewersPeriod: CatalogAudiencePeriod.values.asNameMap()[json['viewersPeriod']],
planning: json['planning'] as int?,
watching: json['watching'] as int?,
completed: json['completed'] as int?,
onHold: json['onHold'] as int?,
dropped: json['dropped'] as int?,
trendingActivity: json['trendingActivity'] as int?,
favorited: json['favorited'] as int?,
dropRate: (json['dropRate'] as num?)?.toDouble(),
comments: json['comments'] as int?,
);
}
/// A recurring weekly broadcast slot (Trakt `airs`, MAL `broadcast`).
///
/// This is a schedule, not a date: it says "Tuesdays at 21:00" and must never
/// be rendered as the next episode's air date. Use [CatalogNextEpisode] for
/// that.
class CatalogBroadcast {
/// ISO-8601 weekday, Monday = 1 through Sunday = 7.
final int? weekday;
/// Local broadcast time as `HH:mm` in [timezone].
final String? time;
/// IANA zone name, e.g. `America/New_York` or `Asia/Tokyo`.
final String? timezone;
const CatalogBroadcast({this.weekday, this.time, this.timezone});
bool get isEmpty => weekday == null && time == null;
Map<String, Object?> toJson() => {
if (weekday != null) 'weekday': weekday,
if (time != null) 'time': time,
if (timezone != null) 'timezone': timezone,
};
factory CatalogBroadcast.fromJson(Map<String, Object?> json) => CatalogBroadcast(
weekday: json['weekday'] as int?,
time: json['time'] as String?,
timezone: json['timezone'] as String?,
);
}
/// The next episode's actual air time (AniList `nextAiringEpisode`).
class CatalogNextEpisode {
final int? episode;
final DateTime airsAt;
const CatalogNextEpisode({required this.airsAt, this.episode});
/// Remaining time from [now], clamped at zero once the slot has passed.
Duration timeUntil(DateTime now) {
final delta = airsAt.difference(now);
return delta.isNegative ? Duration.zero : delta;
}
Map<String, Object?> toJson() => {
'airsAt': airsAt.toUtc().toIso8601String(),
if (episode != null) 'episode': episode,
};
static CatalogNextEpisode? fromJson(Map<String, Object?> json) {
final airsAt = DateTime.tryParse(json['airsAt'] as String? ?? '');
if (airsAt == null) return null;
return CatalogNextEpisode(airsAt: airsAt, episode: json['episode'] as int?);
}
}
/// Whether the title exists on the user's own media servers, per quality tier.
enum CatalogAvailability { unavailable, partiallyAvailable, available }
/// Where a request for the title stands in the approval/download pipeline.
enum CatalogRequestState { pending, approved, processing, declined, failed }
/// Seerr's server-side knowledge about a discovery result.
///
/// Availability and request state are deliberately independent: a show can be
/// fully available in HD while a 4K request for it is still pending approval,
/// and collapsing both into one ladder would lose that.
class CatalogServerState {
final CatalogAvailability? availability;
final CatalogAvailability? availability4k;
final CatalogRequestState? request;
final CatalogRequestState? request4k;
/// Seasons already on the server, when the provider breaks it down.
final int? availableSeasons;
final int? totalSeasons;
const CatalogServerState({
this.availability,
this.availability4k,
this.request,
this.request4k,
this.availableSeasons,
this.totalSeasons,
});
bool get isEmpty =>
availability == null &&
availability4k == null &&
request == null &&
request4k == null &&
availableSeasons == null &&
totalSeasons == null;
Map<String, Object?> toJson() => {
if (availability != null) 'availability': availability!.name,
if (availability4k != null) 'availability4k': availability4k!.name,
if (request != null) 'request': request!.name,
if (request4k != null) 'request4k': request4k!.name,
if (availableSeasons != null) 'availableSeasons': availableSeasons,
if (totalSeasons != null) 'totalSeasons': totalSeasons,
};
factory CatalogServerState.fromJson(Map<String, Object?> json) => CatalogServerState(
availability: CatalogAvailability.values.asNameMap()[json['availability']],
availability4k: CatalogAvailability.values.asNameMap()[json['availability4k']],
request: CatalogRequestState.values.asNameMap()[json['request']],
request4k: CatalogRequestState.values.asNameMap()[json['request4k']],
availableSeasons: json['availableSeasons'] as int?,
totalSeasons: json['totalSeasons'] as int?,
);
}
/// Anime broadcast season (AniList `season`/`seasonYear`, MAL `start_season`).
enum CatalogSeasonName { winter, spring, summer, fall }
class CatalogSeasonInfo {
final CatalogSeasonName name;
final int? year;
const CatalogSeasonInfo({required this.name, this.year});
Map<String, Object?> toJson() => {'name': name.name, if (year != null) 'year': year};
static CatalogSeasonInfo? fromJson(Map<String, Object?> json) {
final name = CatalogSeasonName.values.asNameMap()[json['name']];
if (name == null) return null;
return CatalogSeasonInfo(name: name, year: json['year'] as int?);
}
/// Accepts AniList's `FALL`/`SUMMER` and MAL's `fall`/`summer` alike.
static CatalogSeasonName? parseName(String? raw) =>
raw == null ? null : CatalogSeasonName.values.asNameMap()[raw.toLowerCase()];
}
/// Release format, finer-grained than [MediaKind] (AniList `format`, MAL
/// `media_type`). Kept separate so `MediaKind.show` can still be an OVA.
enum CatalogFormat { tv, tvShort, movie, special, ova, ona, music, other }
/// What the title was adapted from (AniList/MAL `source`).
enum CatalogSourceMaterial { original, manga, lightNovel, novel, visualNovel, game, webComic, musicRelease, otherMedia }
/// A non-cast credit: director, writer, producer or creator.
enum CatalogCreditRole { director, writer, producer, creator, composer }
class CatalogCredit {
final String name;
final CatalogCreditRole role;
const CatalogCredit({required this.name, required this.role});
Map<String, Object?> toJson() => {'name': name, 'role': role.name};
static CatalogCredit? fromJson(Map<String, Object?> json) {
final name = json['name'] as String?;
final role = CatalogCreditRole.values.asNameMap()[json['role']];
if (name == null || role == null) return null;
return CatalogCredit(name: name, role: role);
}
}
/// A ranked descriptive tag (AniList `tags`). Distinct from genres: there are
/// many, they are user-ranked, and some spoil the plot.
class CatalogTag {
final String name;
/// 0-100 community relevance, when the provider ranks them.
final int? rank;
final bool isSpoiler;
const CatalogTag({required this.name, this.rank, this.isSpoiler = false});
Map<String, Object?> toJson() => {'name': name, if (rank != null) 'rank': rank, if (isSpoiler) 'isSpoiler': true};
static CatalogTag? fromJson(Map<String, Object?> json) {
final name = json['name'] as String?;
if (name == null) return null;
return CatalogTag(name: name, rank: json['rank'] as int?, isSpoiler: json['isSpoiler'] == true);
}
}
/// An outbound link the provider supplies (AniList `externalLinks` and
/// `streamingEpisodes`, Simkl's canonical `url`).
class CatalogLink {
/// Site name as the provider gives it — a proper noun, never translated.
final String label;
final String url;
/// Marks a link that plays the title rather than describing it, so the UI
/// can group "watch on" separately from "read about".
final bool isStreaming;
const CatalogLink({required this.label, required this.url, this.isStreaming = false});
Map<String, Object?> toJson() => {'label': label, 'url': url, if (isStreaming) 'isStreaming': true};
static CatalogLink? fromJson(Map<String, Object?> json) {
final label = json['label'] as String?;
final url = json['url'] as String?;
if (label == null || url == null) return null;
return CatalogLink(label: label, url: url, isStreaming: json['isStreaming'] == true);
}
}
/// Play state the catalog provider itself knows about (Plex Discover
/// `includeUserState=1`). Independent of any connected media server's state.
class CatalogPlayState {
final int? viewCount;
final int? viewOffsetMs;
final int? viewedLeafCount;
const CatalogPlayState({this.viewCount, this.viewOffsetMs, this.viewedLeafCount});
bool get isEmpty => viewCount == null && viewOffsetMs == null && viewedLeafCount == null;
Map<String, Object?> toJson() => {
if (viewCount != null) 'viewCount': viewCount,
if (viewOffsetMs != null) 'viewOffsetMs': viewOffsetMs,
if (viewedLeafCount != null) 'viewedLeafCount': viewedLeafCount,
};
factory CatalogPlayState.fromJson(Map<String, Object?> json) => CatalogPlayState(
viewCount: json['viewCount'] as int?,
viewOffsetMs: json['viewOffsetMs'] as int?,
viewedLeafCount: json['viewedLeafCount'] as int?,
);
}
/// Decodes a JSON list of objects into value objects, dropping malformed
/// siblings rather than failing the whole item — catalog payloads are
/// best-effort API data, not a strict persisted discriminator.
List<T>? decodeCatalogList<T>(Object? raw, T? Function(Map<String, Object?>) decode) {
if (raw is! List) return null;
final out = <T>[];
for (final entry in raw) {
if (entry is! Map) continue;
final decoded = decode(entry.cast<String, Object?>());
if (decoded != null) out.add(decoded);
}
return out.isEmpty ? null : out;
}
/// Why a person appears against a recommended title.
enum CatalogRecommendationReason { favorited, recommended }
/// A user whose activity produced a recommendation (Trakt `favorited_by` and
/// `recommended_by` on personalised recommendation rows).
///
/// Counting these arrays would misstate the contract: the provenance — who,
/// and what they said about it — is the entire value of a social
/// recommendation.
class CatalogRecommender {
final String username;
final String? name;
/// Free-text note the user attached to the recommendation.
final String? note;
final CatalogRecommendationReason reason;
const CatalogRecommender({required this.username, required this.reason, this.name, this.note});
/// Display name when the user set one, otherwise the handle.
String get displayName => name?.isNotEmpty == true ? name! : username;
Map<String, Object?> toJson() => {
'username': username,
'reason': reason.name,
if (name != null) 'name': name,
if (note != null) 'note': note,
};
static CatalogRecommender? fromJson(Map<String, Object?> json) {
final username = json['username'] as String?;
final reason = CatalogRecommendationReason.values.asNameMap()[json['reason']];
if (username == null || reason == null) return null;
return CatalogRecommender(
username: username,
reason: reason,
name: json['name'] as String?,
note: json['note'] as String?,
);
}
}
+152 -3
View File
@@ -14,8 +14,10 @@ class MalPicture {
const MalPicture({this.medium, this.large});
String? get primary {
final url = large ?? medium;
return url == null || url.isEmpty ? null : url;
final largeUrl = large?.trim();
if (largeUrl != null && largeUrl.isNotEmpty) return largeUrl;
final mediumUrl = medium?.trim();
return mediumUrl == null || mediumUrl.isEmpty ? null : mediumUrl;
}
factory MalPicture.fromJson(Map<String, dynamic> json) => _$MalPictureFromJson(json);
@@ -54,12 +56,26 @@ class MalStudio {
class MalStartSeason {
@JsonKey(fromJson: flexibleInt)
final int? year;
final String? season;
const MalStartSeason({this.year});
const MalStartSeason({this.year, this.season});
factory MalStartSeason.fromJson(Map<String, dynamic> json) => _$MalStartSeasonFromJson(json);
}
@JsonSerializable(createFactory: false, createToJson: false)
class MalBroadcast {
@JsonKey(name: 'day_of_the_week')
final String? dayOfTheWeek;
@JsonKey(name: 'start_time')
final String? startTime;
const MalBroadcast({this.dayOfTheWeek, this.startTime});
factory MalBroadcast.fromJson(Map<String, dynamic> json) =>
MalBroadcast(dayOfTheWeek: json['day_of_the_week'] as String?, startTime: json['start_time'] as String?);
}
/// An anime summary node from MAL API v2 catalog endpoints
/// (`/users/@me/animelist`, `/anime/suggestions`, `/anime/ranking`), with the
/// fields Plezy requests (see `MalClient.catalogFields`).
@@ -115,6 +131,23 @@ class MalAnime {
@JsonKey(name: 'num_scoring_users', fromJson: flexibleInt)
final int? numScoringUsers;
final MalBroadcast? broadcast;
@JsonKey(fromJson: flexibleInt)
final int? popularity;
@JsonKey(name: 'num_list_users', fromJson: flexibleInt)
final int? numListUsers;
/// MAL score rank, distinct from `/anime/ranking`'s entry-level leaderboard
/// position.
@JsonKey(fromJson: flexibleInt)
final int? rank;
/// `white` / `gray` / `black`.
final String? nsfw;
final String? source;
@JsonKey(name: 'end_date')
final String? endDate;
const MalAnime({
this.id,
this.title,
@@ -132,6 +165,13 @@ class MalAnime {
this.status,
this.studios,
this.numScoringUsers,
this.broadcast,
this.popularity,
this.numListUsers,
this.rank,
this.nsfw,
this.source,
this.endDate,
});
bool get isMovie => mediaType == 'movie';
@@ -173,3 +213,112 @@ class MalAnime {
factory MalAnime.fromJson(Map<String, dynamic> json) => _$MalAnimeFromJson(json);
}
/// One community recommendation from an anime detail body.
class MalAnimeRecommendation {
final MalAnime anime;
final int? count;
const MalAnimeRecommendation({required this.anime, this.count});
static MalAnimeRecommendation? fromEntry(Object? raw) {
if (raw is! Map) return null;
final node = raw['node'];
if (node is! Map) return null;
return MalAnimeRecommendation(
anime: MalAnime.fromJson(node.cast<String, dynamic>()),
count: flexibleInt(raw['num_recommendations']),
);
}
}
/// One labelled franchise edge from an anime detail body.
class MalAnimeRelation {
final MalAnime anime;
final String? type;
const MalAnimeRelation({required this.anime, this.type});
static MalAnimeRelation? fromEntry(Object? raw) {
if (raw is! Map) return null;
final node = raw['node'];
if (node is! Map) return null;
final type = raw['relation_type'];
return MalAnimeRelation(
anime: MalAnime.fromJson(node.cast<String, dynamic>()),
type: type is String && type.isNotEmpty ? type : null,
);
}
}
/// MAL list-status counts from an anime detail body.
class MalStatistics {
final int? numListUsers;
final int? watching;
final int? completed;
final int? onHold;
final int? dropped;
final int? planToWatch;
const MalStatistics({this.numListUsers, this.watching, this.completed, this.onHold, this.dropped, this.planToWatch});
factory MalStatistics.fromJson(Map<dynamic, dynamic> json) {
final status = json['status'];
final statuses = status is Map ? status : const <String, dynamic>{};
return MalStatistics(
numListUsers: flexibleInt(json['num_list_users']),
watching: flexibleInt(statuses['watching']),
completed: flexibleInt(statuses['completed']),
onHold: flexibleInt(statuses['on_hold']),
dropped: flexibleInt(statuses['dropped']),
planToWatch: flexibleInt(statuses['plan_to_watch']),
);
}
}
/// The single `GET /anime/{id}` body used for detail enrichment,
/// recommendations and labelled franchise relations.
class MalAnimeDetail {
final MalAnime anime;
final List<MalAnimeRecommendation> recommendations;
final List<MalAnimeRelation> relations;
final MalStatistics? statistics;
final List<MalPicture> pictures;
final String? background;
const MalAnimeDetail({
required this.anime,
this.recommendations = const [],
this.relations = const [],
this.statistics,
this.pictures = const [],
this.background,
});
factory MalAnimeDetail.fromJson(Map<String, dynamic> json, {int relatedLimit = 20}) {
final recommendations = json['recommendations'];
final relations = json['related_anime'];
final statistics = json['statistics'];
final pictures = json['pictures'];
final rawBackground = json['background'];
final background = rawBackground is String ? rawBackground.trim() : null;
return MalAnimeDetail(
anime: MalAnime.fromJson(json),
recommendations: [
if (recommendations is List)
for (final raw in recommendations.take(relatedLimit)) ?MalAnimeRecommendation.fromEntry(raw),
],
relations: [
if (relations is List)
for (final raw in relations.take(relatedLimit)) ?MalAnimeRelation.fromEntry(raw),
],
statistics: statistics is Map ? MalStatistics.fromJson(statistics) : null,
pictures: [
if (pictures is List)
for (final raw in pictures)
if (raw is Map) MalPicture.fromJson(raw.cast<String, dynamic>()),
],
background: background == null || background.isEmpty ? null : background,
);
}
}
+13 -1
View File
@@ -28,7 +28,10 @@ MalStudio _$MalStudioFromJson(Map<String, dynamic> json) =>
MalStudio(name: json['name'] as String?);
MalStartSeason _$MalStartSeasonFromJson(Map<String, dynamic> json) =>
MalStartSeason(year: flexibleInt(json['year']));
MalStartSeason(
year: flexibleInt(json['year']),
season: json['season'] as String?,
);
MalAnime _$MalAnimeFromJson(Map<String, dynamic> json) => MalAnime(
id: flexibleInt(json['id']),
@@ -59,4 +62,13 @@ MalAnime _$MalAnimeFromJson(Map<String, dynamic> json) => MalAnime(
?.map((e) => MalStudio.fromJson(e as Map<String, dynamic>))
.toList(),
numScoringUsers: flexibleInt(json['num_scoring_users']),
broadcast: json['broadcast'] == null
? null
: MalBroadcast.fromJson(json['broadcast'] as Map<String, dynamic>),
popularity: flexibleInt(json['popularity']),
numListUsers: flexibleInt(json['num_list_users']),
rank: flexibleInt(json['rank']),
nsfw: json['nsfw'] as String?,
source: json['source'] as String?,
endDate: json['end_date'] as String?,
);
+228 -5
View File
@@ -1,23 +1,221 @@
import 'package:json_annotation/json_annotation.dart';
import '../../utils/json_utils.dart';
import 'seerr_media.dart';
part 'seerr_details.g.dart';
/// Full detail from `GET /movie/{tmdbId}` and `GET /tv/{tmdbId}` — the subset
/// the catalog surfaces need (credits, availability, seasons). `seasons` is
/// absent on movies.
/// Full detail from `GET /movie/{tmdbId}` and `GET /tv/{tmdbId}`.
///
/// Both endpoints share the discovery fields below. Movie-only and TV-only
/// values remain nullable because Seerr deployments can omit TMDB data.
@JsonSerializable(createToJson: false)
class SeerrDetails {
final int? id;
final String? imdbId;
final bool? adult;
final String? title;
final String? name;
final String? originalTitle;
final String? originalName;
final String? originalLanguage;
final String? overview;
final String? posterPath;
final String? backdropPath;
final String? releaseDate;
final String? firstAirDate;
final int? runtime;
final List<int>? episodeRunTime;
final int? budget;
final int? revenue;
final double? voteAverage;
final int? voteCount;
final double? popularity;
final String? lastAirDate;
final List<String>? languages;
final List<SeerrSpokenLanguage>? spokenLanguages;
final List<SeerrNamedValue>? genres;
final List<SeerrNamedValue>? networks;
final List<SeerrNamedValue>? productionCompanies;
final List<SeerrProductionCountry>? productionCountries;
final List<String>? originCountry;
final List<SeerrNamedValue>? createdBy;
final List<SeerrNamedValue>? keywords;
final int? numberOfEpisodes;
final String? status;
final String? tagline;
final SeerrReleaseInfo? releases;
final SeerrContentRatingInfo? contentRatings;
final SeerrExternalIds? externalIds;
final List<SeerrRelatedVideo>? relatedVideos;
final List<SeerrSeason>? seasons;
final SeerrCredits? credits;
final SeerrMediaInfo? mediaInfo;
const SeerrDetails({this.seasons, this.credits, this.mediaInfo});
const SeerrDetails({
this.id,
this.imdbId,
this.adult,
this.title,
this.name,
this.originalTitle,
this.originalName,
this.originalLanguage,
this.overview,
this.posterPath,
this.backdropPath,
this.releaseDate,
this.firstAirDate,
this.runtime,
this.episodeRunTime,
this.budget,
this.revenue,
this.voteAverage,
this.voteCount,
this.popularity,
this.lastAirDate,
this.languages,
this.spokenLanguages,
this.genres,
this.networks,
this.productionCompanies,
this.productionCountries,
this.originCountry,
this.createdBy,
this.keywords,
this.numberOfEpisodes,
this.status,
this.tagline,
this.releases,
this.contentRatings,
this.externalIds,
this.relatedVideos,
this.seasons,
this.credits,
this.mediaInfo,
});
/// Localized title, falling back to the original when the requested
/// language has no translation. TMDB answers a `language` query with an
/// empty string rather than omitting the field, so `??` alone is not enough.
String get displayTitle => firstNonBlank([title, name, originalTitle, originalName]) ?? '';
String? get displayOriginalTitle => originalTitle ?? originalName;
String? get date => releaseDate ?? firstAirDate;
factory SeerrDetails.fromJson(Map<String, dynamic> json) => _$SeerrDetailsFromJson(json);
}
/// TMDB object carrying an integer id and a localized display name.
class SeerrNamedValue {
final int? id;
final String? name;
const SeerrNamedValue({this.id, this.name});
factory SeerrNamedValue.fromJson(Map<String, dynamic> json) =>
SeerrNamedValue(id: (json['id'] as num?)?.toInt(), name: json['name'] as String?);
}
class SeerrProductionCountry {
final String? countryCode;
final String? name;
const SeerrProductionCountry({this.countryCode, this.name});
factory SeerrProductionCountry.fromJson(Map<String, dynamic> json) =>
SeerrProductionCountry(countryCode: json['iso_3166_1'] as String?, name: json['name'] as String?);
}
class SeerrSpokenLanguage {
final String? languageCode;
final String? name;
const SeerrSpokenLanguage({this.languageCode, this.name});
factory SeerrSpokenLanguage.fromJson(Map<String, dynamic> json) =>
SeerrSpokenLanguage(languageCode: json['iso_639_1'] as String?, name: json['name'] as String?);
}
class SeerrReleaseInfo {
final List<SeerrReleaseCountry>? results;
const SeerrReleaseInfo({this.results});
factory SeerrReleaseInfo.fromJson(Map<String, dynamic> json) =>
SeerrReleaseInfo(results: _decodeObjectList(json['results'], SeerrReleaseCountry.fromJson));
}
class SeerrReleaseCountry {
final String? countryCode;
final String? rating;
final List<SeerrReleaseDate>? releaseDates;
const SeerrReleaseCountry({this.countryCode, this.rating, this.releaseDates});
factory SeerrReleaseCountry.fromJson(Map<String, dynamic> json) => SeerrReleaseCountry(
countryCode: json['iso_3166_1'] as String?,
rating: json['rating'] as String?,
releaseDates: _decodeObjectList(json['release_dates'], SeerrReleaseDate.fromJson),
);
}
class SeerrReleaseDate {
final String? certification;
const SeerrReleaseDate({this.certification});
factory SeerrReleaseDate.fromJson(Map<String, dynamic> json) =>
SeerrReleaseDate(certification: json['certification'] as String?);
}
class SeerrContentRatingInfo {
final List<SeerrContentRating>? results;
const SeerrContentRatingInfo({this.results});
factory SeerrContentRatingInfo.fromJson(Map<String, dynamic> json) =>
SeerrContentRatingInfo(results: _decodeObjectList(json['results'], SeerrContentRating.fromJson));
}
class SeerrContentRating {
final String? countryCode;
final String? rating;
const SeerrContentRating({this.countryCode, this.rating});
factory SeerrContentRating.fromJson(Map<String, dynamic> json) =>
SeerrContentRating(countryCode: json['iso_3166_1'] as String?, rating: json['rating'] as String?);
}
class SeerrExternalIds {
final String? imdbId;
final int? tvdbId;
const SeerrExternalIds({this.imdbId, this.tvdbId});
factory SeerrExternalIds.fromJson(Map<String, dynamic> json) =>
SeerrExternalIds(imdbId: json['imdbId'] as String?, tvdbId: (json['tvdbId'] as num?)?.toInt());
}
class SeerrRelatedVideo {
final String? url;
final String? key;
final String? type;
final String? site;
const SeerrRelatedVideo({this.url, this.key, this.type, this.site});
factory SeerrRelatedVideo.fromJson(Map<String, dynamic> json) => SeerrRelatedVideo(
url: json['url'] as String?,
key: json['key'] as String?,
type: json['type'] as String?,
site: json['site'] as String?,
);
}
/// One TMDB season entry (`TvDetails.seasons[]`). Season 0 is specials.
@JsonSerializable(createToJson: false)
class SeerrSeason {
@@ -34,7 +232,10 @@ class SeerrSeason {
@JsonSerializable(createToJson: false)
class SeerrCredits {
final List<SeerrCastMember>? cast;
const SeerrCredits({this.cast});
final List<SeerrCrewMember>? crew;
const SeerrCredits({this.cast, this.crew});
factory SeerrCredits.fromJson(Map<String, dynamic> json) => _$SeerrCreditsFromJson(json);
}
@@ -48,3 +249,25 @@ class SeerrCastMember {
factory SeerrCastMember.fromJson(Map<String, dynamic> json) => _$SeerrCastMemberFromJson(json);
}
class SeerrCrewMember {
final String? name;
final String? job;
final String? department;
const SeerrCrewMember({this.name, this.job, this.department});
factory SeerrCrewMember.fromJson(Map<String, dynamic> json) => SeerrCrewMember(
name: json['name'] as String?,
job: json['job'] as String?,
department: json['department'] as String?,
);
}
List<T>? _decodeObjectList<T>(Object? raw, T Function(Map<String, dynamic>) decode) {
if (raw is! List) return null;
return [
for (final item in raw)
if (item is Map<String, dynamic>) decode(item),
];
}
+70
View File
@@ -7,6 +7,73 @@ part of 'seerr_details.dart';
// **************************************************************************
SeerrDetails _$SeerrDetailsFromJson(Map<String, dynamic> json) => SeerrDetails(
id: (json['id'] as num?)?.toInt(),
imdbId: json['imdbId'] as String?,
adult: json['adult'] as bool?,
title: json['title'] as String?,
name: json['name'] as String?,
originalTitle: json['originalTitle'] as String?,
originalName: json['originalName'] as String?,
originalLanguage: json['originalLanguage'] as String?,
overview: json['overview'] as String?,
posterPath: json['posterPath'] as String?,
backdropPath: json['backdropPath'] as String?,
releaseDate: json['releaseDate'] as String?,
firstAirDate: json['firstAirDate'] as String?,
runtime: (json['runtime'] as num?)?.toInt(),
episodeRunTime: (json['episodeRunTime'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList(),
budget: (json['budget'] as num?)?.toInt(),
revenue: (json['revenue'] as num?)?.toInt(),
voteAverage: (json['voteAverage'] as num?)?.toDouble(),
voteCount: (json['voteCount'] as num?)?.toInt(),
popularity: (json['popularity'] as num?)?.toDouble(),
lastAirDate: json['lastAirDate'] as String?,
languages: (json['languages'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
spokenLanguages: (json['spokenLanguages'] as List<dynamic>?)
?.map((e) => SeerrSpokenLanguage.fromJson(e as Map<String, dynamic>))
.toList(),
genres: (json['genres'] as List<dynamic>?)
?.map((e) => SeerrNamedValue.fromJson(e as Map<String, dynamic>))
.toList(),
networks: (json['networks'] as List<dynamic>?)
?.map((e) => SeerrNamedValue.fromJson(e as Map<String, dynamic>))
.toList(),
productionCompanies: (json['productionCompanies'] as List<dynamic>?)
?.map((e) => SeerrNamedValue.fromJson(e as Map<String, dynamic>))
.toList(),
productionCountries: (json['productionCountries'] as List<dynamic>?)
?.map((e) => SeerrProductionCountry.fromJson(e as Map<String, dynamic>))
.toList(),
originCountry: (json['originCountry'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
createdBy: (json['createdBy'] as List<dynamic>?)
?.map((e) => SeerrNamedValue.fromJson(e as Map<String, dynamic>))
.toList(),
keywords: (json['keywords'] as List<dynamic>?)
?.map((e) => SeerrNamedValue.fromJson(e as Map<String, dynamic>))
.toList(),
numberOfEpisodes: (json['numberOfEpisodes'] as num?)?.toInt(),
status: json['status'] as String?,
tagline: json['tagline'] as String?,
releases: json['releases'] == null
? null
: SeerrReleaseInfo.fromJson(json['releases'] as Map<String, dynamic>),
contentRatings: json['contentRatings'] == null
? null
: SeerrContentRatingInfo.fromJson(
json['contentRatings'] as Map<String, dynamic>,
),
externalIds: json['externalIds'] == null
? null
: SeerrExternalIds.fromJson(json['externalIds'] as Map<String, dynamic>),
relatedVideos: (json['relatedVideos'] as List<dynamic>?)
?.map((e) => SeerrRelatedVideo.fromJson(e as Map<String, dynamic>))
.toList(),
seasons: (json['seasons'] as List<dynamic>?)
?.map((e) => SeerrSeason.fromJson(e as Map<String, dynamic>))
.toList(),
@@ -29,6 +96,9 @@ SeerrCredits _$SeerrCreditsFromJson(Map<String, dynamic> json) => SeerrCredits(
cast: (json['cast'] as List<dynamic>?)
?.map((e) => SeerrCastMember.fromJson(e as Map<String, dynamic>))
.toList(),
crew: (json['crew'] as List<dynamic>?)
?.map((e) => SeerrCrewMember.fromJson(e as Map<String, dynamic>))
.toList(),
);
SeerrCastMember _$SeerrCastMemberFromJson(Map<String, dynamic> json) =>
+20 -1
View File
@@ -1,5 +1,7 @@
import 'package:json_annotation/json_annotation.dart';
import '../../utils/json_utils.dart';
import 'seerr_request.dart';
part 'seerr_media.g.dart';
@@ -40,6 +42,12 @@ class SeerrMedia {
final double? voteAverage;
final int? voteCount;
final SeerrMediaInfo? mediaInfo;
final double? popularity;
final String? originalLanguage;
final String? originalTitle;
final String? originalName;
final bool? adult;
final List<String>? originCountry;
const SeerrMedia({
required this.id,
@@ -54,14 +62,25 @@ class SeerrMedia {
this.voteAverage,
this.voteCount,
this.mediaInfo,
this.popularity,
this.originalLanguage,
this.originalTitle,
this.originalName,
this.adult,
this.originCountry,
});
bool get isMovie => mediaType == 'movie';
String get displayTitle => title ?? name ?? '';
/// Localized title, falling back to the original when the requested
/// language has no translation. TMDB answers a `language` query with an
/// empty string rather than omitting the field, so `??` alone is not enough.
String get displayTitle => firstNonBlank([title, name, originalTitle, originalName]) ?? '';
String? get date => releaseDate ?? firstAirDate;
String? get displayOriginalTitle => originalTitle ?? originalName;
int? get year {
final d = date;
if (d == null || d.length < 4) return null;
+8
View File
@@ -21,6 +21,14 @@ SeerrMedia _$SeerrMediaFromJson(Map<String, dynamic> json) => SeerrMedia(
mediaInfo: json['mediaInfo'] == null
? null
: SeerrMediaInfo.fromJson(json['mediaInfo'] as Map<String, dynamic>),
popularity: (json['popularity'] as num?)?.toDouble(),
originalLanguage: json['originalLanguage'] as String?,
originalTitle: json['originalTitle'] as String?,
originalName: json['originalName'] as String?,
adult: json['adult'] as bool?,
originCountry: (json['originCountry'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
);
SeerrMediaInfo _$SeerrMediaInfoFromJson(Map<String, dynamic> json) =>
+4 -1
View File
@@ -4,8 +4,9 @@
class SeerrPage<T> {
final List<T> items;
final bool hasMore;
final int? totalResults;
const SeerrPage({required this.items, required this.hasMore});
const SeerrPage({required this.items, required this.hasMore, this.totalResults});
/// [skip] drops results the mapper can't represent (person entries in
/// mixed trending/search results) — return null from [fromItem] for those.
@@ -13,6 +14,7 @@ class SeerrPage<T> {
final info = json['pageInfo'] is Map<String, dynamic> ? json['pageInfo'] as Map<String, dynamic> : json;
final page = (info['page'] as num?)?.toInt() ?? 1;
final totalPages = ((info['pages'] ?? info['totalPages']) as num?)?.toInt() ?? page;
final totalResults = ((json['totalResults'] ?? info['totalResults']) as num?)?.toInt();
final results = json['results'];
return SeerrPage(
items: [
@@ -22,6 +24,7 @@ class SeerrPage<T> {
if (fromItem(item) case final T parsed) parsed,
],
hasMore: page < totalPages,
totalResults: totalResults,
);
}
}
+16 -1
View File
@@ -40,14 +40,29 @@ class SimklAllItemsMedia {
class SimklAllItemsEntry {
/// User-list status (`plantowatch`, `watching`, ...), not production state.
final String? status;
@JsonKey(name: 'added_to_watchlist_at')
final String? addedAt;
@JsonKey(name: 'user_rating', fromJson: flexibleDouble)
final double? userRating;
final SimklAllItemsMedia? show;
final SimklAllItemsMedia? movie;
@JsonKey(name: 'anime_type')
final String? animeType;
@JsonKey(name: 'total_episodes_count', fromJson: flexibleInt)
final int? totalEpisodes;
@JsonKey(name: 'not_aired_episodes_count', fromJson: flexibleInt)
final int? notAiredEpisodes;
const SimklAllItemsEntry({this.status, this.show, this.movie, this.animeType, this.totalEpisodes});
const SimklAllItemsEntry({
this.status,
this.addedAt,
this.userRating,
this.show,
this.movie,
this.animeType,
this.totalEpisodes,
this.notAiredEpisodes,
});
SimklAllItemsMedia? get media => movie ?? show;
@@ -25,6 +25,8 @@ SimklAllItemsMedia _$SimklAllItemsMediaFromJson(Map<String, dynamic> json) =>
SimklAllItemsEntry _$SimklAllItemsEntryFromJson(Map<String, dynamic> json) =>
SimklAllItemsEntry(
status: json['status'] as String?,
addedAt: json['added_to_watchlist_at'] as String?,
userRating: flexibleDouble(json['user_rating']),
show: json['show'] == null
? null
: SimklAllItemsMedia.fromJson(json['show'] as Map<String, dynamic>),
@@ -33,4 +35,5 @@ SimklAllItemsEntry _$SimklAllItemsEntryFromJson(Map<String, dynamic> json) =>
: SimklAllItemsMedia.fromJson(json['movie'] as Map<String, dynamic>),
animeType: json['anime_type'] as String?,
totalEpisodes: flexibleInt(json['total_episodes_count']),
notAiredEpisodes: flexibleInt(json['not_aired_episodes_count']),
);
+4 -1
View File
@@ -9,13 +9,16 @@ part 'simkl_best_item.g.dart';
@JsonSerializable(createToJson: false)
class SimklBestItem {
final String? title;
final String? url;
@JsonKey(fromJson: flexibleInt)
final int? watched;
@JsonKey(fromJson: flexibleInt)
final int? year;
final String? poster;
final SimklIds ids;
final SimklRatings? ratings;
const SimklBestItem({this.title, this.year, this.poster, required this.ids, this.ratings});
const SimklBestItem({this.title, this.url, this.watched, this.year, this.poster, required this.ids, this.ratings});
factory SimklBestItem.fromJson(Map<String, dynamic> json) => _$SimklBestItemFromJson(json);
}
+2
View File
@@ -9,6 +9,8 @@ part of 'simkl_best_item.dart';
SimklBestItem _$SimklBestItemFromJson(Map<String, dynamic> json) =>
SimklBestItem(
title: json['title'] as String?,
url: json['url'] as String?,
watched: flexibleInt(json['watched']),
year: flexibleInt(json['year']),
poster: json['poster'] as String?,
ids: SimklIds.fromJson(json['ids'] as Map<String, dynamic>),
+139
View File
@@ -0,0 +1,139 @@
import '../../utils/json_utils.dart';
import 'simkl_ids.dart';
import 'simkl_rating.dart';
import 'simkl_recommendation.dart';
import 'simkl_title.dart';
/// Rich movie, TV, or anime record returned by Simkl's detail endpoints.
class SimklDetail {
final String? title;
final int? year;
final String? type;
final String? animeType;
final String? originalTitle;
final String? englishTitle;
final List<String>? alternateTitles;
final String? url;
final String? poster;
final String? fanart;
final SimklIds ids;
final int? rank;
final String? dropRate;
final String? released;
final String? physicalRelease;
final String? firstAired;
final String? lastAired;
final SimklAirs? airs;
final int? runtime;
final String? certification;
final int? budget;
final int? revenue;
final String? overview;
final List<String>? genres;
final String? country;
final String? language;
final int? totalEpisodes;
final String? status;
final String? network;
final String? director;
final List<String>? studios;
final String? seasonNameYear;
final SimklRatings? ratings;
final String? trailer;
final List<SimklRecommendation> recommendations;
const SimklDetail({
this.title,
this.year,
this.type,
this.animeType,
this.originalTitle,
this.englishTitle,
this.alternateTitles,
this.url,
this.poster,
this.fanart,
required this.ids,
this.rank,
this.dropRate,
this.released,
this.physicalRelease,
this.firstAired,
this.lastAired,
this.airs,
this.runtime,
this.certification,
this.budget,
this.revenue,
this.overview,
this.genres,
this.country,
this.language,
this.totalEpisodes,
this.status,
this.network,
this.director,
this.studios,
this.seasonNameYear,
this.ratings,
this.trailer,
this.recommendations = const [],
});
factory SimklDetail.fromJson(Map<String, dynamic> json) => SimklDetail(
title: json['title'] as String?,
year: flexibleInt(json['year']),
type: json['type'] as String?,
animeType: json['anime_type'] as String?,
originalTitle: json['title_romaji'] as String?,
englishTitle: json['en_title'] as String?,
alternateTitles: simklTitleNames(json['alt_titles']),
url: json['url'] as String?,
poster: json['poster'] as String?,
fanart: json['fanart'] as String?,
ids: parseFlexibleJsonObject(json['ids'], SimklIds.fromJson) ?? const SimklIds(),
rank: flexibleInt(json['rank']),
dropRate: json['droprate']?.toString(),
released: json['released'] as String?,
physicalRelease: (json['released_dvd'] ?? json['dvd_date']) as String?,
firstAired: json['first_aired'] as String?,
lastAired: json['last_aired'] as String?,
airs: parseFlexibleJsonObject(json['airs'], SimklAirs.fromJson),
runtime: flexibleInt(json['runtime']),
certification: json['certification'] as String?,
budget: flexibleInt(json['budget']),
revenue: flexibleInt(json['revenue']),
overview: json['overview'] as String?,
genres: flexibleStringList(json['genres']),
country: json['country'] as String?,
language: json['language'] as String?,
totalEpisodes: flexibleInt(json['total_episodes']),
status: json['status'] as String?,
network: json['network'] as String?,
director: json['director'] as String?,
studios: simklTitleNames(json['studios']),
seasonNameYear: json['season_name_year'] as String?,
ratings: parseFlexibleJsonObject(json['ratings'], SimklRatings.fromJson),
trailer: _firstTrailer(json['trailers']),
recommendations: parseFlexibleJsonList(json['users_recommendations'], SimklRecommendation.fromJson),
);
static String? _firstTrailer(Object? raw) {
for (final trailer in flexibleMapList(raw)) {
final youtube = trailer['youtube'];
if (youtube is String && youtube.trim().isNotEmpty) return youtube.trim();
}
return null;
}
}
class SimklAirs {
final String? day;
final String? time;
final String? timezone;
const SimklAirs({this.day, this.time, this.timezone});
factory SimklAirs.fromJson(Map<String, dynamic> json) =>
SimklAirs(day: json['day'] as String?, time: json['time'] as String?, timezone: json['timezone'] as String?);
}
+18
View File
@@ -3,7 +3,25 @@ String? simklPosterUrl(String? hash) {
return 'https://simkl.in/posters/${hash}_m.webp';
}
Map<int, String>? simklPosterVariants(String? hash) {
if (hash == null || hash.isEmpty) return null;
final base = 'https://simkl.in/posters/$hash';
return {
40: '${base}_s.webp',
84: '${base}_cm.webp',
170: '${base}_c.webp',
190: '${base}_ca.webp',
340: '${base}_m.webp',
};
}
String? simklFanartUrl(String? hash) {
if (hash == null || hash.isEmpty) return null;
return 'https://simkl.in/fanart/${hash}_medium.webp';
}
Map<int, String>? simklFanartVariants(String? hash) {
if (hash == null || hash.isEmpty) return null;
final base = 'https://simkl.in/fanart/$hash';
return {48: '${base}_s48.webp', 600: '${base}_w.webp', 960: '${base}_mobile.webp', 1920: '${base}_medium.webp'};
}
+19 -1
View File
@@ -8,13 +8,31 @@ part 'simkl_recommendation.g.dart';
@JsonSerializable(createToJson: false)
class SimklRecommendation {
final String? title;
@JsonKey(name: 'en_title')
final String? englishTitle;
@JsonKey(fromJson: flexibleInt)
final int? year;
final String? poster;
final String? type;
@JsonKey(name: 'anime_type')
final String? animeType;
@JsonKey(name: 'users_count', fromJson: flexibleInt)
final int? usersCount;
@JsonKey(name: 'users_percent')
final String? usersPercent;
final SimklIds ids;
const SimklRecommendation({this.title, this.year, this.poster, this.type, required this.ids});
const SimklRecommendation({
this.title,
this.englishTitle,
this.year,
this.poster,
this.type,
this.animeType,
this.usersCount,
this.usersPercent,
required this.ids,
});
factory SimklRecommendation.fromJson(Map<String, dynamic> json) => _$SimklRecommendationFromJson(json);
}
@@ -9,8 +9,12 @@ part of 'simkl_recommendation.dart';
SimklRecommendation _$SimklRecommendationFromJson(Map<String, dynamic> json) =>
SimklRecommendation(
title: json['title'] as String?,
englishTitle: json['en_title'] as String?,
year: flexibleInt(json['year']),
poster: json['poster'] as String?,
type: json['type'] as String?,
animeType: json['anime_type'] as String?,
usersCount: flexibleInt(json['users_count']),
usersPercent: json['users_percent'] as String?,
ids: SimklIds.fromJson(json['ids'] as Map<String, dynamic>),
);
+11
View File
@@ -9,6 +9,13 @@ part 'simkl_search_result.g.dart';
@JsonSerializable(createToJson: false)
class SimklSearchResult {
final String? title;
@JsonKey(name: 'title_en')
final String? titleEn;
@JsonKey(name: 'title_romaji')
final String? titleRomaji;
@JsonKey(name: 'all_titles', fromJson: flexibleStringList)
final List<String>? allTitles;
final String? url;
@JsonKey(fromJson: flexibleInt)
final int? year;
@@ -25,6 +32,10 @@ class SimklSearchResult {
const SimklSearchResult({
this.title,
this.titleEn,
this.titleRomaji,
this.allTitles,
this.url,
this.year,
this.type,
this.endpointType,
@@ -9,6 +9,10 @@ part of 'simkl_search_result.dart';
SimklSearchResult _$SimklSearchResultFromJson(Map<String, dynamic> json) =>
SimklSearchResult(
title: json['title'] as String?,
titleEn: json['title_en'] as String?,
titleRomaji: json['title_romaji'] as String?,
allTitles: flexibleStringList(json['all_titles']),
url: json['url'] as String?,
year: flexibleInt(json['year']),
type: json['type'] as String?,
endpointType: json['endpoint_type'] as String?,
+7
View File
@@ -0,0 +1,7 @@
import '../../utils/json_utils.dart';
/// Extracts displayable names from Simkl's localized-title objects.
///
/// Some endpoints instead return a bare list of strings, so both shapes are
/// accepted and malformed siblings are dropped.
List<String>? simklTitleNames(Object? raw) => stringListFromRaw(raw, mapKey: 'name', nullIfEmpty: true);
+31 -1
View File
@@ -1,47 +1,77 @@
import 'package:json_annotation/json_annotation.dart';
import '../../utils/json_utils.dart';
import '../../utils/trailer_urls.dart';
import 'simkl_ids.dart';
import 'simkl_rating.dart';
import 'simkl_title.dart';
part 'simkl_trending_item.g.dart';
@JsonSerializable(createToJson: false)
class SimklTrendingItem {
final String? title;
@JsonKey(name: 'title_romaji')
final String? titleRomaji;
@JsonKey(name: 'alt_titles', fromJson: simklTitleNames)
final List<String>? alternateTitles;
final String? url;
final String? poster;
final String? fanart;
@JsonKey(name: 'release_date')
final String? releaseDate;
@JsonKey(fromJson: flexibleInt)
final int? rank;
@JsonKey(name: 'drop_rate')
final String? dropRate;
@JsonKey(fromJson: flexibleInt)
final int? watched;
@JsonKey(name: 'plan_to_watch', fromJson: flexibleInt)
final int? planToWatch;
final String? runtime;
final String? status;
final List<String>? genres;
final String? trailer;
final String? overview;
final SimklRatings? ratings;
final String? country;
@JsonKey(name: 'original_language')
final String? originalLanguage;
@JsonKey(name: 'total_episodes', fromJson: flexibleInt)
final int? totalEpisodes;
final String? network;
@JsonKey(name: 'anime_type')
final String? animeType;
@JsonKey(name: 'dvd_date')
final String? dvdDate;
final String? theater;
final SimklIds ids;
const SimklTrendingItem({
this.title,
this.titleRomaji,
this.alternateTitles,
this.url,
this.poster,
this.fanart,
this.releaseDate,
this.rank,
this.dropRate,
this.watched,
this.planToWatch,
this.runtime,
this.status,
this.genres,
this.trailer,
this.overview,
this.ratings,
this.country,
this.originalLanguage,
this.totalEpisodes,
this.network,
this.animeType,
this.dvdDate,
this.theater,
required this.ids,
});
@@ -62,7 +92,7 @@ class SimklTrendingItem {
return total > 0 ? total : null;
}
String? get trailerUrl => trailer == null || trailer!.isEmpty ? null : 'https://www.youtube.com/watch?v=$trailer';
String? get trailerUrl => youTubeTrailerUrl(trailer);
factory SimklTrendingItem.fromJson(Map<String, dynamic> json) => _$SimklTrendingItemFromJson(json);
}
@@ -9,10 +9,16 @@ part of 'simkl_trending_item.dart';
SimklTrendingItem _$SimklTrendingItemFromJson(Map<String, dynamic> json) =>
SimklTrendingItem(
title: json['title'] as String?,
titleRomaji: json['title_romaji'] as String?,
alternateTitles: simklTitleNames(json['alt_titles']),
url: json['url'] as String?,
poster: json['poster'] as String?,
fanart: json['fanart'] as String?,
releaseDate: json['release_date'] as String?,
rank: flexibleInt(json['rank']),
dropRate: json['drop_rate'] as String?,
watched: flexibleInt(json['watched']),
planToWatch: flexibleInt(json['plan_to_watch']),
runtime: json['runtime'] as String?,
status: json['status'] as String?,
genres: (json['genres'] as List<dynamic>?)
@@ -23,8 +29,12 @@ SimklTrendingItem _$SimklTrendingItemFromJson(Map<String, dynamic> json) =>
ratings: json['ratings'] == null
? null
: SimklRatings.fromJson(json['ratings'] as Map<String, dynamic>),
country: json['country'] as String?,
originalLanguage: json['original_language'] as String?,
totalEpisodes: flexibleInt(json['total_episodes']),
network: json['network'] as String?,
animeType: json['anime_type'] as String?,
dvdDate: json['dvd_date'] as String?,
theater: json['theater'] as String?,
ids: SimklIds.fromJson(json['ids'] as Map<String, dynamic>),
);
+16 -1
View File
@@ -29,9 +29,24 @@ class TraktPerson {
@JsonSerializable(createToJson: false)
class TraktCastEntry {
final List<String>? characters;
@JsonKey(name: 'episode_count')
final int? episodeCount;
final List<String>? jobs;
final String? job;
final TraktPerson? person;
const TraktCastEntry({this.characters, this.person});
const TraktCastEntry({this.characters, this.episodeCount, this.jobs, this.job, this.person});
factory TraktCastEntry.fromJson(Map<String, dynamic> json) => _$TraktCastEntryFromJson(json);
}
/// Cast, guest stars and flattened crew returned by a people endpoint.
///
/// Crew is flattened from Trakt's department-keyed object by [TraktClient].
class TraktPeople {
final List<TraktCastEntry> cast;
final List<TraktCastEntry> guestStars;
final List<TraktCastEntry> crew;
const TraktPeople({this.cast = const [], this.guestStars = const [], this.crew = const []});
}
+3
View File
@@ -25,6 +25,9 @@ TraktCastEntry _$TraktCastEntryFromJson(Map<String, dynamic> json) =>
characters: (json['characters'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
episodeCount: (json['episode_count'] as num?)?.toInt(),
jobs: (json['jobs'] as List<dynamic>?)?.map((e) => e as String).toList(),
job: json['job'] as String?,
person: json['person'] == null
? null
: TraktPerson.fromJson(json['person'] as Map<String, dynamic>),
+58 -3
View File
@@ -5,11 +5,36 @@ import 'trakt_images.dart';
part 'trakt_catalog_media.g.dart';
/// A show's recurring broadcast slot from Trakt's `airs` object.
class TraktAirs {
final String? day;
final String? time;
final String? timezone;
const TraktAirs({this.day, this.time, this.timezone});
factory TraktAirs.fromJson(Map<String, dynamic> json) =>
TraktAirs(day: json['day'] as String?, time: json['time'] as String?, timezone: json['timezone'] as String?);
}
/// Recommendation provenance attached to a personalised recommendation row.
class TraktRecommendationUser {
final String? username;
final String? name;
final String? notes;
const TraktRecommendationUser({this.username, this.name, this.notes});
factory TraktRecommendationUser.fromJson(Map<String, dynamic> json) => TraktRecommendationUser(
username: json['username'] as String?,
name: json['name'] as String?,
notes: json['notes'] as String?,
);
}
/// A movie or show summary from Trakt's catalog endpoints (`extended=full`).
///
/// Trakt uses the same field names for movie and show objects; the fields
/// exclusive to one type (movie `released`, show `first_aired`, ...) are not
/// needed for the Explore surfaces, so a single class covers both. Whether an
/// Trakt uses the same field names for movie and show objects. Whether an
/// instance is a movie or a show is known from the endpoint or wrapper key it
/// was parsed from (see [TraktCatalogEntry]).
@JsonSerializable(createToJson: false)
@@ -18,6 +43,12 @@ class TraktCatalogMedia {
final int? year;
final TraktIds ids;
final String? overview;
final String? tagline;
@JsonKey(name: 'original_title')
final String? originalTitle;
final String? released;
@JsonKey(name: 'first_aired')
final String? firstAired;
/// Runtime in minutes.
final int? runtime;
@@ -28,6 +59,17 @@ class TraktCatalogMedia {
final List<String>? genres;
final String? certification;
final String? trailer;
@JsonKey(name: 'comment_count')
final int? commentCount;
final String? language;
final List<String>? languages;
@JsonKey(name: 'available_translations')
final List<String>? availableTranslations;
final String? country;
@JsonKey(name: 'favorited_by')
final List<TraktRecommendationUser>? favoritedBy;
@JsonKey(name: 'recommended_by')
final List<TraktRecommendationUser>? recommendedBy;
/// Shows: `returning series` / `continuing` / `in production` / `planned` /
/// `upcoming` / `pilot` / `canceled` / `ended`. Movies: `released` /
@@ -38,6 +80,7 @@ class TraktCatalogMedia {
final String? network;
@JsonKey(name: 'aired_episodes')
final int? airedEpisodes;
final TraktAirs? airs;
final TraktImages? images;
const TraktCatalogMedia({
@@ -45,15 +88,27 @@ class TraktCatalogMedia {
this.year,
required this.ids,
this.overview,
this.tagline,
this.originalTitle,
this.released,
this.firstAired,
this.runtime,
this.rating,
this.votes,
this.genres,
this.certification,
this.trailer,
this.commentCount,
this.language,
this.languages,
this.availableTranslations,
this.country,
this.favoritedBy,
this.recommendedBy,
this.status,
this.network,
this.airedEpisodes,
this.airs,
this.images,
});
+42 -21
View File
@@ -6,24 +6,45 @@ part of 'trakt_catalog_media.dart';
// JsonSerializableGenerator
// **************************************************************************
TraktCatalogMedia _$TraktCatalogMediaFromJson(Map<String, dynamic> json) =>
TraktCatalogMedia(
title: json['title'] as String?,
year: (json['year'] as num?)?.toInt(),
ids: TraktIds.fromJson(json['ids'] as Map<String, dynamic>),
overview: json['overview'] as String?,
runtime: (json['runtime'] as num?)?.toInt(),
rating: (json['rating'] as num?)?.toDouble(),
votes: (json['votes'] as num?)?.toInt(),
genres: (json['genres'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
certification: json['certification'] as String?,
trailer: json['trailer'] as String?,
status: json['status'] as String?,
network: json['network'] as String?,
airedEpisodes: (json['aired_episodes'] as num?)?.toInt(),
images: json['images'] == null
? null
: TraktImages.fromJson(json['images'] as Map<String, dynamic>),
);
TraktCatalogMedia _$TraktCatalogMediaFromJson(
Map<String, dynamic> json,
) => TraktCatalogMedia(
title: json['title'] as String?,
year: (json['year'] as num?)?.toInt(),
ids: TraktIds.fromJson(json['ids'] as Map<String, dynamic>),
overview: json['overview'] as String?,
tagline: json['tagline'] as String?,
originalTitle: json['original_title'] as String?,
released: json['released'] as String?,
firstAired: json['first_aired'] as String?,
runtime: (json['runtime'] as num?)?.toInt(),
rating: (json['rating'] as num?)?.toDouble(),
votes: (json['votes'] as num?)?.toInt(),
genres: (json['genres'] as List<dynamic>?)?.map((e) => e as String).toList(),
certification: json['certification'] as String?,
trailer: json['trailer'] as String?,
commentCount: (json['comment_count'] as num?)?.toInt(),
language: json['language'] as String?,
languages: (json['languages'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
availableTranslations: (json['available_translations'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
country: json['country'] as String?,
favoritedBy: (json['favorited_by'] as List<dynamic>?)
?.map((e) => TraktRecommendationUser.fromJson(e as Map<String, dynamic>))
.toList(),
recommendedBy: (json['recommended_by'] as List<dynamic>?)
?.map((e) => TraktRecommendationUser.fromJson(e as Map<String, dynamic>))
.toList(),
status: json['status'] as String?,
network: json['network'] as String?,
airedEpisodes: (json['aired_episodes'] as num?)?.toInt(),
airs: json['airs'] == null
? null
: TraktAirs.fromJson(json['airs'] as Map<String, dynamic>),
images: json['images'] == null
? null
: TraktImages.fromJson(json['images'] as Map<String, dynamic>),
);
+2
View File
@@ -21,6 +21,8 @@ class TraktImages {
String? get primaryBackdrop => firstUrl(fanart) ?? firstUrl(thumb);
String? get primaryLogo => firstUrl(logo);
/// First URL of a Trakt image array, https-prefixed (Trakt serves
/// protocol-less URLs). Shared with person headshots.
static String? firstUrl(List<String>? urls) {
+6 -1
View File
@@ -27,6 +27,7 @@ import '../services/trakt/trakt_client.dart';
import 'seerr_account_provider.dart';
import 'trakt_account_provider.dart';
import 'trackers_provider.dart';
import '../utils/platform_detector.dart';
import '../utils/app_logger.dart';
typedef PlexDiscoverSessionSupplier = Future<PlexDiscoverSession?> Function();
@@ -94,7 +95,11 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie
final PlexDiscoverSessionSupplier? plexSessionSupplier;
final _CatalogSourceBinding<PlexDiscoverSession, PlexCatalogSource> _plex = _CatalogSourceBinding(
(session) => PlexCatalogSource(PlexDiscoverClient(session)),
// Widened hub artwork (`excludeElements=Media` instead of `Media,Image`)
// costs +104.95% — 27,287 to 55,925 bytes for 26 items, uncached, with up
// to six hubs hydrated concurrently. Its only consumer is the TV
// spotlight's logo/banner treatment, so only TV pays for it.
(session) => PlexCatalogSource(PlexDiscoverClient(session), includeImageVariants: PlatformDetector.isTV()),
equals: (previous, next) => previous == next,
);
final _CatalogSourceBinding<TraktClient, TraktCatalogSource> _trakt = _CatalogSourceBinding(TraktCatalogSource.new);
+24 -5
View File
@@ -18,11 +18,20 @@ enum ExploreLoadState { initial, loading, loaded, error }
class ExploreRowHub {
final CatalogRowId? row;
final String? providerHubId;
final CatalogHubStyle? style;
final int? totalResults;
final MediaHub hub;
const ExploreRowHub.catalogRow({required CatalogRowId this.row, required this.hub}) : providerHubId = null;
const ExploreRowHub.catalogRow({required CatalogRowId this.row, required this.hub, this.totalResults})
: providerHubId = null,
style = null;
const ExploreRowHub.providerHub({required String this.providerHubId, required this.hub}) : row = null;
const ExploreRowHub.providerHub({
required String this.providerHubId,
required this.hub,
this.style,
this.totalResults,
}) : row = null;
}
/// Owns the Explore tab's fixed rows and provider-defined hubs, converted to
@@ -96,27 +105,30 @@ class ExploreProvider extends ChangeNotifier with DisposableChangeNotifierMixin
if (page.items.isNotEmpty)
ExploreRowHub.catalogRow(
row: row,
totalResults: page.totalResults,
hub: MediaHub(
id: 'explore:${source.id.name}:${row.name}',
identifier: 'explore.${row.name}',
title: rowTitle(row),
type: 'mixed',
items: [for (final item in page.items) item.toMediaItem()],
size: page.items.length,
size: page.totalResults ?? page.items.length,
more: page.hasMore,
),
),
for (final providerHub in _providerHubs)
if (providerHub.page.items.isNotEmpty)
if (_rendersProviderHub(providerHub) && providerHub.page.items.isNotEmpty)
ExploreRowHub.providerHub(
providerHubId: providerHub.id,
style: providerHub.style,
totalResults: providerHub.page.totalResults,
hub: MediaHub(
id: 'explore:${source.id.name}:hub:${providerHub.id}',
identifier: 'explore.hub.${providerHub.id}',
title: providerHub.title,
type: 'mixed',
items: [for (final item in providerHub.page.items) item.toMediaItem()],
size: providerHub.page.items.length,
size: providerHub.page.totalResults ?? providerHub.page.items.length,
more: providerHub.page.hasMore,
),
),
@@ -126,6 +138,13 @@ class ExploreProvider extends ChangeNotifier with DisposableChangeNotifierMixin
return hubs;
}
static bool _rendersProviderHub(CatalogHub hub) {
// Plex's availabilityPlatforms entries are streaming services, not
// titles. Until Explore has a platform-specific row, skipping the hub is
// preferable to presenting service logos as movie posters.
return hub.style != CatalogHubStyle.availabilityPlatforms;
}
static String rowTitle(CatalogRowId row) => switch (row) {
CatalogRowId.watchlist => t.explore.rows.watchlist,
CatalogRowId.recommendedMovies => t.explore.rows.recommendedMovies,
File diff suppressed because it is too large Load Diff
+1
View File
@@ -379,6 +379,7 @@ class ExploreScreenState extends State<ExploreScreen>
child: HubSection(
key: _orderedHubKeys[i],
hub: rowHubs[i].hub,
totalResults: rowHubs[i].totalResults,
focusMemory: _hubFocusMemory,
icon: _rowIcon(rowHubs[i].row),
loadMoreItems: rowHubs[i].hub.more ? () => _explore.loadAllForHub(rowHubs[i]) : null,
+203 -14
View File
@@ -4,7 +4,9 @@ import '../../media/media_kind.dart';
import '../../models/anilist/anilist_media.dart';
import '../../models/catalog/catalog_cast_member.dart';
import '../../models/catalog/catalog_item.dart';
import '../../models/catalog/catalog_metadata.dart';
import '../../models/trackers/fribb_mapping_row.dart';
import '../../utils/country_codes.dart';
import '../../utils/external_ids.dart';
import '../trackers/anilist/anilist_client.dart';
import '../trackers/fribb_mapping_store.dart';
@@ -17,6 +19,8 @@ import 'catalog_watchlist_machinery.dart';
/// The client is owned and rebound by `TrackersProvider`; this source only
/// borrows it. Fribb bridges AniList/MAL identities to media-server IDs.
class AnilistCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
static final RegExp _accentColorPattern = RegExp(r'^#[0-9a-fA-F]{6}$');
final AnilistClient _client;
final FribbMappingLookup _fribb;
final FutureCoalescer<int> _viewerIdLoad = FutureCoalescer<int>();
@@ -118,7 +122,10 @@ class AnilistCatalogSource with CatalogWatchlistMachinery implements CatalogSour
source: CatalogSourceId.anilist,
kind: anime.isMovie ? MediaKind.movie : MediaKind.show,
title: anime.displayTitle,
altTitles: [
// A set literal: insertion-ordered and deduped. Providers repeat titles
// across fields — AniList lists the English title again under `synonyms` —
// and a duplicate here costs the reverse library lookup a wasted request.
altTitles: <String>{
for (final title in <String?>[
anime.titleEnglish,
anime.titleUserPreferred,
@@ -127,7 +134,7 @@ class AnilistCatalogSource with CatalogWatchlistMachinery implements CatalogSour
...?anime.synonyms,
])
if (title != null && title.isNotEmpty && title != anime.displayTitle) title,
],
}.toList(),
year: anime.year,
overview: anime.description,
runtimeMinutes: anime.runtimeMinutes,
@@ -150,27 +157,209 @@ class AnilistCatalogSource with CatalogWatchlistMachinery implements CatalogSour
: ExternalSeasonRef(tvdb: row.tvdbSeason, tmdb: row.tmdbSeason),
posterUrl: anime.posterUrl,
backdropUrl: anime.backdropUrl,
accentColor: _accentColorFor(anime.coverImageColor),
ratings: _ratingsFor(anime.meanRating),
ranks: _ranksFor(anime.rankings),
audience: _audienceFor(anime),
nextEpisode: _nextEpisodeFor(anime.nextAiringEpisode),
releaseDate: anime.releaseDate,
endDate: anime.finalEpisodeDate,
originalTitle: _differentTitle(anime.titleNative, anime.displayTitle),
broadcastSeason: _seasonFor(anime.season, anime.seasonYear),
format: _formatFor(anime.format),
sourceMaterial: _sourceMaterialFor(anime.source),
studios: anime.mainStudios,
countries: _countriesFor(anime.countryOfOrigin),
credits: _creditsFor(anime.staffCredits),
tags: _tagsFor(anime.tags),
links: _linksFor(anime.externalLinks, anime.streamingEpisodes),
cast: _castFor(anime.characters),
);
@override
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async {
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async {
final anilistId = item.ids.anilist;
if (anilistId == null) return const [];
final characters = await _client.getAnimeCharacters(anilistId, limit: limit);
return [
for (final character in characters)
if (character.name.isNotEmpty)
CatalogCastMember(name: character.name, secondary: character.role, imageUrl: character.imageUrl),
if (anilistId == null) return CatalogDetail(item: item);
final cachedCast = item.cast;
final response = await _client.getAnimeDetail(
anilistId,
castLimit: castLimit,
relatedLimit: relatedLimit,
includeCharacters: cachedCast == null,
);
final relationEdges = [
for (final edge in response.relations)
if (edge.item.id != null) edge,
];
final mapped = await Future.wait([
_toCatalogItems(response.recommendations),
_toCatalogItems([for (final edge in relationEdges) edge.item]),
]);
final groupedRelations = <CatalogRelationType, List<CatalogItem>>{};
for (var i = 0; i < relationEdges.length; i++) {
final type = _relationTypeFor(relationEdges[i].relationType);
groupedRelations.putIfAbsent(type, () => []).add(mapped[1][i]);
}
final detailItem = response.item;
final mappedDetail = detailItem == null ? null : _toCatalogItem(detailItem, null);
return CatalogDetail(
item: mappedDetail == null ? item : item.enrichedWith(mappedDetail),
cast: cachedCast ?? mappedDetail?.cast ?? const [],
related: mapped[0],
relations: [for (final entry in groupedRelations.entries) CatalogRelation(type: entry.key, items: entry.value)],
);
}
@override
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async {
final anilistId = item.ids.anilist;
if (anilistId == null) return const [];
return _toCatalogItems(await _client.getAnimeRecommendations(anilistId, limit: limit));
static List<CatalogCastMember>? _castFor(List<AnilistCharacter>? characters) => characters == null
? null
: [
for (final character in characters)
CatalogCastMember(name: character.name, secondary: character.role, imageUrl: character.imageUrl),
];
static List<CatalogRatingSource>? _ratingsFor(double? meanRating) =>
meanRating == null ? null : [CatalogRatingSource(source: 'anilist', value: meanRating)];
static CatalogNextEpisode? _nextEpisodeFor(AnilistNextAiringEpisode? airing) => airing == null
? null
: CatalogNextEpisode(
episode: airing.episode,
airsAt: DateTime.fromMillisecondsSinceEpoch(airing.airingAt * Duration.millisecondsPerSecond, isUtc: true),
);
static CatalogSeasonInfo? _seasonFor(String? season, int? year) {
final name = CatalogSeasonInfo.parseName(season);
return name == null ? null : CatalogSeasonInfo(name: name, year: year);
}
static List<CatalogRank>? _ranksFor(List<AnilistRanking>? rankings) {
if (rankings == null) return null;
final ranks = [
for (final ranking in rankings)
if (_rankScopeFor(ranking.type) case final CatalogRankScope scope)
CatalogRank(
rank: ranking.rank,
scope: scope,
allTime: ranking.allTime,
year: ranking.year,
season: CatalogSeasonInfo.parseName(ranking.season),
),
];
return ranks.isEmpty ? null : ranks;
}
static CatalogRankScope? _rankScopeFor(String? type) => switch (type) {
'POPULAR' => CatalogRankScope.popular,
'RATED' => CatalogRankScope.rated,
_ => null,
};
static CatalogAudience? _audienceFor(AnilistMedia anime) {
if (anime.popularity == null && anime.favourites == null && anime.trending == null) return null;
return CatalogAudience(listed: anime.popularity, favorited: anime.favourites, trendingActivity: anime.trending);
}
static String? _accentColorFor(String? value) {
final color = value?.trim();
return color != null && _accentColorPattern.hasMatch(color) ? color.toLowerCase() : null;
}
static String? _differentTitle(String? value, String displayTitle) {
final title = value?.trim();
if (title == null || title.isEmpty || title.toLowerCase() == displayTitle.toLowerCase()) return null;
return title;
}
static CatalogFormat? _formatFor(String? format) => switch (format) {
null => null,
'TV' => CatalogFormat.tv,
'TV_SHORT' => CatalogFormat.tvShort,
'MOVIE' => CatalogFormat.movie,
'SPECIAL' => CatalogFormat.special,
'OVA' => CatalogFormat.ova,
'ONA' => CatalogFormat.ona,
'MUSIC' => CatalogFormat.music,
_ => CatalogFormat.other,
};
static CatalogSourceMaterial? _sourceMaterialFor(String? source) => switch (source) {
null => null,
'ORIGINAL' => CatalogSourceMaterial.original,
'MANGA' || 'COMIC' || 'DOUJINSHI' => CatalogSourceMaterial.manga,
'LIGHT_NOVEL' => CatalogSourceMaterial.lightNovel,
'NOVEL' || 'WEB_NOVEL' => CatalogSourceMaterial.novel,
'VISUAL_NOVEL' => CatalogSourceMaterial.visualNovel,
'VIDEO_GAME' || 'GAME' => CatalogSourceMaterial.game,
'WEB_COMIC' => CatalogSourceMaterial.webComic,
'MUSIC' => CatalogSourceMaterial.musicRelease,
_ => CatalogSourceMaterial.otherMedia,
};
static List<String>? _countriesFor(String? country) {
final code = country == null ? null : CountryCodes.normalizeCode(country);
return code == null || code.isEmpty ? null : [code];
}
static List<CatalogCredit>? _creditsFor(List<AnilistStaffCredit>? staff) {
if (staff == null) return null;
final credits = <CatalogCredit>[];
for (final member in staff) {
final role = _creditRoleFor(member.role);
if (role == null) continue;
final credit = CatalogCredit(name: member.name, role: role);
if (!credits.any((existing) => existing.name == credit.name && existing.role == credit.role)) {
credits.add(credit);
}
}
return credits.isEmpty ? null : credits;
}
static CatalogCreditRole? _creditRoleFor(String raw) {
final role = raw.trim().toLowerCase();
if (role == 'director' || role == 'series director') return CatalogCreditRole.director;
if (role == 'writer' || role == 'series composition' || role.contains('script') || role.contains('screenplay')) {
return CatalogCreditRole.writer;
}
if (role == 'music' || role.contains('composer') || role.contains('music composition')) {
return CatalogCreditRole.composer;
}
return null;
}
static List<CatalogTag>? _tagsFor(List<AnilistTag>? tags) => tags == null
? null
: [for (final tag in tags) CatalogTag(name: tag.name, rank: tag.rank, isSpoiler: tag.isMediaSpoiler)];
static List<CatalogLink>? _linksFor(List<AnilistLink>? external, List<AnilistLink>? streaming) {
final links = <CatalogLink>[];
for (final link in external ?? const <AnilistLink>[]) {
links.add(CatalogLink(label: link.label, url: link.url));
}
for (final link in streaming ?? const <AnilistLink>[]) {
final catalogLink = CatalogLink(label: link.label, url: link.url, isStreaming: true);
final existing = links.indexWhere((candidate) => candidate.url == link.url);
if (existing == -1) {
links.add(catalogLink);
} else {
links[existing] = catalogLink;
}
}
return links.isEmpty ? null : links;
}
static CatalogRelationType _relationTypeFor(String? type) => switch (type) {
'PREQUEL' => CatalogRelationType.prequel,
'SEQUEL' => CatalogRelationType.sequel,
'SIDE_STORY' => CatalogRelationType.sideStory,
'SPIN_OFF' => CatalogRelationType.spinOff,
'ALTERNATIVE' => CatalogRelationType.alternativeVersion,
'SUMMARY' => CatalogRelationType.summary,
'PARENT' => CatalogRelationType.parentStory,
'ADAPTATION' => CatalogRelationType.adaptation,
_ => CatalogRelationType.other,
};
@override
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async {
if (!external.hasAny) return null;
+84 -11
View File
@@ -48,7 +48,28 @@ class CatalogPage {
final List<CatalogItem> items;
final bool hasMore;
const CatalogPage({required this.items, this.hasMore = false});
/// Provider-reported size of the whole result set, when the envelope says
/// (Seerr `totalResults`). Null when the provider only reports `hasMore`.
final int? totalResults;
const CatalogPage({required this.items, this.hasMore = false, this.totalResults});
}
/// How a provider intends a hub to be presented. Providers tag hubs (Plex
/// `style`), and rendering every one as the same shelf discards that.
///
/// Only values a provider was actually observed to send are declared. A live
/// Plex Home response returned 17 renderable hubs: 5 `shelf`, 1
/// `availabilityPlatforms`, 11 with no style at all. Absent stays absent —
/// null means "the provider expressed no preference", which is not the same
/// as `shelf`, even though both render identically today. Do not add a
/// speculative style before a provider is seen sending it.
enum CatalogHubStyle {
/// The default horizontal poster shelf.
shelf,
/// A hub whose entries are streaming services/platforms rather than titles.
availabilityPlatforms,
}
/// One provider-defined Explore shelf. Unlike [CatalogRowId], hubs are
@@ -58,7 +79,64 @@ class CatalogHub {
final String title;
final CatalogPage page;
const CatalogHub({required this.id, required this.title, required this.page});
/// The provider's presentation hint, or null when it sent none.
final CatalogHubStyle? style;
const CatalogHub({required this.id, required this.title, required this.page, this.style});
}
/// Everything a catalog detail screen needs from the item's own provider.
///
/// The contract is a request-count ceiling, not a single response: an
/// implementation must issue no more requests than the separate cast and
/// related calls it replaces, and [item] must be enriched purely from bodies
/// it was already fetching. Providers differ — Simkl's detail body already
/// carries recommendations, while Plex (`/library/metadata/{id}` plus
/// `/related`) and Seerr (detail plus `/recommendations`) genuinely need two.
/// Where two calls remain, run them concurrently and isolate their failures:
/// a failed related call must still yield the enriched item and its cast.
class CatalogDetail {
/// The opening item enriched with everything the detail body added, via
/// [CatalogItem.enrichedWith]. Equal to the input item when the provider
/// has no detail endpoint.
final CatalogItem item;
/// Actors with characters, or characters with roles, in billing order.
final List<CatalogCastMember> cast;
/// "More like this" — recommendations, which are a similarity judgement and
/// carry no relationship to the item.
final List<CatalogItem> related;
/// Franchise relations, grouped and labelled by how each group relates to
/// the item (MAL `related_anime`, AniList `relations`). Deliberately not
/// merged into [related]: "sequel" is a fact about the work, "recommended"
/// is an opinion about taste, and flattening them would lose the label the
/// UI needs to head each shelf.
final List<CatalogRelation> relations;
const CatalogDetail({required this.item, this.cast = const [], this.related = const [], this.relations = const []});
}
/// How a group of titles relates to the item they were fetched from.
enum CatalogRelationType {
prequel,
sequel,
sideStory,
spinOff,
alternativeVersion,
summary,
parentStory,
adaptation,
other,
}
/// One labelled group of franchise relations.
class CatalogRelation {
final CatalogRelationType type;
final List<CatalogItem> items;
const CatalogRelation({required this.type, required this.items});
}
/// Optional capability for catalog providers that expose dynamic hub rows.
@@ -95,15 +173,10 @@ abstract class CatalogSource {
/// rejects queries under 3 characters).
Future<List<CatalogItem>> search(String query, {int limit = 30});
/// Cast of an item for its detail screen (actors with characters, or MAL
/// characters with roles), in billing order. One request, fetched lazily
/// on detail open; empty when the provider has none for this item.
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20});
/// "More like this" titles for an item's detail screen (Trakt related,
/// MAL recommendations, Seerr/TMDB recommendations). One request, fetched
/// lazily on detail open; empty when the provider has none.
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20});
/// Enriched item, cast and related titles for a detail screen, in as few
/// requests as the provider allows. Fetched lazily on detail open; a
/// provider with nothing to add returns the item unchanged and empty lists.
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20});
/// Load the full watchlist membership snapshot (coalesced; cached for the
/// session). [isOnWatchlist] returns null until this has completed once.
+273 -55
View File
@@ -3,8 +3,10 @@ import 'package:collection/collection.dart';
import '../../media/media_kind.dart';
import '../../models/catalog/catalog_cast_member.dart';
import '../../models/catalog/catalog_item.dart';
import '../../models/catalog/catalog_metadata.dart';
import '../../models/mal/mal_anime.dart';
import '../../models/trackers/fribb_mapping_row.dart';
import '../../utils/app_logger.dart';
import '../../utils/external_ids.dart';
import '../trackers/fribb_mapping_store.dart';
import '../trackers/mal/mal_client.dart';
@@ -74,7 +76,15 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
CatalogRowId.upcomingMovies ||
CatalogRowId.upcomingShows => throw ArgumentError('MAL does not serve ${row.name}'),
};
return CatalogPage(items: await _toCatalogItems(res.items), hasMore: res.hasMore);
final rankingScope = switch (row) {
CatalogRowId.airingAnime => CatalogRankScope.airing,
CatalogRowId.popularAnime => CatalogRankScope.popular,
_ => null,
};
return CatalogPage(
items: await _toCatalogItems(res.items, rankingRanks: res.rankingRanks, rankingScope: rankingScope),
hasMore: res.hasMore,
);
}
/// MAL rejects queries under 3 characters (`invalid q`) — return empty
@@ -88,21 +98,83 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
}
@override
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async {
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async {
final malId = item.ids.mal;
if (malId == null) return const [];
return _toCatalogItems(await _client.getAnimeRecommendations(malId, limit: limit));
if (malId == null) return CatalogDetail(item: item);
final responses = await Future.wait<Object?>([_loadAnimeDetail(malId, relatedLimit), _loadCast(malId, castLimit)]);
final detail = responses[0] as MalAnimeDetail?;
final cast = responses[1] as List<CatalogCastMember>;
if (detail == null) return CatalogDetail(item: item, cast: cast);
final recommendations = [
for (final recommendation in detail.recommendations)
if (recommendation.anime.id != null) recommendation,
];
final relationEntries = [
for (final relation in detail.relations)
if (relation.anime.id != null) relation,
];
final mapped = await Future.wait([
_toCatalogItems(
[for (final recommendation in recommendations) recommendation.anime],
recommendationCounts: [for (final recommendation in recommendations) recommendation.count],
),
_toCatalogItems([for (final relation in relationEntries) relation.anime]),
]);
final related = mapped[0];
final relationItems = mapped[1];
final groupedRelations = <CatalogRelationType, List<CatalogItem>>{};
for (var i = 0; i < relationEntries.length; i++) {
groupedRelations
.putIfAbsent(_relationTypeFor(relationEntries[i].type), () => <CatalogItem>[])
.add(relationItems[i]);
}
final detailPosterUrl = detail.anime.mainPicture?.primary ?? item.posterUrl;
final detailItem = _toCatalogItem(
detail.anime,
null,
statistics: detail.statistics,
gallery: _galleryFor(detail.pictures, detailPosterUrl),
background: detail.background,
);
return CatalogDetail(
item: item.enrichedWith(detailItem),
cast: cast,
related: related,
relations: [for (final entry in groupedRelations.entries) CatalogRelation(type: entry.key, items: entry.value)],
);
}
/// Enrich concurrently so all items share one Fribb index load (per-item
/// awaits would retry the download for every item when it is failing).
Future<List<CatalogItem>> _toCatalogItems(List<MalAnime> anime) async {
final withIds = [
for (final entry in anime)
if (entry.id != null) entry,
];
Future<List<CatalogItem>> _toCatalogItems(
List<MalAnime> anime, {
List<int?> rankingRanks = const [],
CatalogRankScope? rankingScope,
List<int?> recommendationCounts = const [],
}) async {
final withIds = <MalAnime>[];
final originalIndexes = <int>[];
for (var i = 0; i < anime.length; i++) {
if (anime[i].id == null) continue;
withIds.add(anime[i]);
originalIndexes.add(i);
}
final rows = await Future.wait([for (final entry in withIds) _fribb.lookupByMal(entry.id!)]);
return [for (var i = 0; i < withIds.length; i++) _toCatalogItem(withIds[i], rows[i])];
return [
for (var i = 0; i < withIds.length; i++)
_toCatalogItem(
withIds[i],
rows[i],
rankingRank: originalIndexes[i] < rankingRanks.length ? rankingRanks[originalIndexes[i]] : null,
rankingScope: rankingScope,
recommendationCount: originalIndexes[i] < recommendationCounts.length
? recommendationCounts[originalIndexes[i]]
: null,
),
];
}
/// Normalize MAL's status strings. `finished_airing` on a movie maps to
@@ -114,53 +186,199 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
_ => null,
};
CatalogItem _toCatalogItem(MalAnime anime, FribbMappingRow? row) => CatalogItem(
source: CatalogSourceId.mal,
kind: anime.isMovie ? MediaKind.movie : MediaKind.show,
title: anime.displayTitle,
altTitles: [
for (final title in <String?>[
anime.alternativeTitles?.en,
anime.title,
anime.alternativeTitles?.ja,
...?anime.alternativeTitles?.synonyms,
])
if (title != null && title.isNotEmpty && title != anime.displayTitle) title,
],
year: anime.year,
overview: anime.synopsis,
runtimeMinutes: anime.runtimeMinutes,
rating: anime.mean,
votes: anime.numScoringUsers,
genres: anime.genreNames,
certification: anime.certification,
airStatus: airStatusFor(anime),
episodeCount: anime.isMovie || (anime.numEpisodes ?? 0) <= 0 ? null : anime.numEpisodes,
network: anime.primaryStudio,
ids: CatalogItemIds(
mal: anime.id,
imdb: row?.imdbIds?.firstOrNull,
tmdb: row?.tmdbIds?.firstOrNull,
tvdb: row?.tvdbId,
),
season: row == null || (row.tvdbSeason == null && row.tmdbSeason == null)
? null
: ExternalSeasonRef(tvdb: row.tvdbSeason, tmdb: row.tmdbSeason),
posterUrl: anime.mainPicture?.primary,
);
@override
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async {
final malId = item.ids.mal;
if (malId == null) return const [];
final res = await _client.getAnimeCharacters(malId, limit: limit);
return [
for (final character in res.items)
if (character.name.isNotEmpty)
CatalogCastMember(name: character.name, secondary: character.role, imageUrl: character.imageUrl),
];
CatalogItem _toCatalogItem(
MalAnime anime,
FribbMappingRow? row, {
int? rankingRank,
CatalogRankScope? rankingScope,
int? recommendationCount,
MalStatistics? statistics,
List<String>? gallery,
String? background,
}) {
final title = anime.displayTitle;
final originalTitle = anime.title;
return CatalogItem(
source: CatalogSourceId.mal,
kind: anime.isMovie ? MediaKind.movie : MediaKind.show,
title: title,
// Set literal: insertion-ordered and deduped. MAL repeats the English
// and romaji titles inside `synonyms`, and a duplicate candidate costs
// the reverse library lookup a wasted request.
altTitles: <String>{
for (final candidate in <String?>[
anime.alternativeTitles?.en,
anime.title,
anime.alternativeTitles?.ja,
...?anime.alternativeTitles?.synonyms,
])
if (candidate != null && candidate.isNotEmpty && candidate != title) candidate,
}.toList(),
year: anime.year,
overview: anime.synopsis,
runtimeMinutes: anime.runtimeMinutes,
rating: anime.mean,
votes: anime.numScoringUsers,
genres: anime.genreNames,
certification: anime.certification,
airStatus: airStatusFor(anime),
episodeCount: anime.isMovie || (anime.numEpisodes ?? 0) <= 0 ? null : anime.numEpisodes,
network: anime.primaryStudio,
ids: CatalogItemIds(
mal: anime.id,
anilist: row?.anilistId,
simkl: row?.simklId,
imdb: row?.imdbIds?.firstOrNull,
tmdb: row?.tmdbIds?.firstOrNull,
tvdb: row?.tvdbId,
),
// Which season of the parent series this entry maps to, for the reverse
// library lookup — distinct from the calendar broadcastSeason below.
season: row == null || (row.tvdbSeason == null && row.tmdbSeason == null)
? null
: ExternalSeasonRef(tvdb: row.tvdbSeason, tmdb: row.tmdbSeason),
posterUrl: anime.mainPicture?.primary,
ranks: _ranksFor(anime, rankingRank: rankingRank, rankingScope: rankingScope),
audience: _audienceFor(anime, statistics),
broadcast: _broadcastFor(anime.broadcast),
endDate: _dateFor(anime.endDate),
originalTitle: originalTitle != null && originalTitle.isNotEmpty && originalTitle != title ? originalTitle : null,
broadcastSeason: _seasonFor(anime.startSeason),
sourceMaterial: _sourceMaterialFor(anime.source),
isAdult: _isAdultFor(anime.nsfw),
recommendationCount: recommendationCount,
gallery: gallery,
background: background,
);
}
Future<MalAnimeDetail?> _loadAnimeDetail(int malId, int relatedLimit) async {
try {
return await _client.getAnimeDetail(malId, relatedLimit: relatedLimit);
} catch (e, st) {
appLogger.w('MAL: anime detail load failed', error: e, stackTrace: st);
return null;
}
}
Future<List<CatalogCastMember>> _loadCast(int malId, int limit) async {
try {
final res = await _client.getAnimeCharacters(malId, limit: limit);
return [
for (final character in res.items)
if (character.name.isNotEmpty)
CatalogCastMember(name: character.name, secondary: character.role, imageUrl: character.imageUrl),
];
} catch (e, st) {
appLogger.w('MAL: character load failed', error: e, stackTrace: st);
return const [];
}
}
static List<CatalogRank>? _ranksFor(MalAnime anime, {int? rankingRank, CatalogRankScope? rankingScope}) {
final ranks = <CatalogRank>[];
var rowRankIsPopular = false;
if (rankingRank != null && rankingRank > 0 && rankingScope != null) {
ranks.add(CatalogRank(rank: rankingRank, scope: rankingScope, allTime: true));
rowRankIsPopular = rankingScope == CatalogRankScope.popular;
}
final popularity = anime.popularity;
if (popularity != null && popularity > 0 && !rowRankIsPopular) {
ranks.add(CatalogRank(rank: popularity, scope: CatalogRankScope.popular, allTime: true));
}
final scoreRank = anime.rank;
if (scoreRank != null && scoreRank > 0) {
ranks.add(CatalogRank(rank: scoreRank, scope: CatalogRankScope.rated, allTime: true));
}
return ranks.isEmpty ? null : ranks;
}
static CatalogAudience? _audienceFor(MalAnime anime, MalStatistics? statistics) {
final audience = CatalogAudience(
listed: statistics?.numListUsers ?? anime.numListUsers,
planning: statistics?.planToWatch,
watching: statistics?.watching,
completed: statistics?.completed,
onHold: statistics?.onHold,
dropped: statistics?.dropped,
);
return audience.isEmpty ? null : audience;
}
static CatalogBroadcast? _broadcastFor(MalBroadcast? broadcast) {
if (broadcast == null) return null;
final weekday = switch (broadcast.dayOfTheWeek?.toLowerCase()) {
'monday' => DateTime.monday,
'tuesday' => DateTime.tuesday,
'wednesday' => DateTime.wednesday,
'thursday' => DateTime.thursday,
'friday' => DateTime.friday,
'saturday' => DateTime.saturday,
'sunday' => DateTime.sunday,
_ => null,
};
final time = _normalizedTime(broadcast.startTime);
if (weekday == null && time == null) return null;
return CatalogBroadcast(weekday: weekday, time: time, timezone: 'Asia/Tokyo');
}
static String? _normalizedTime(String? value) {
if (value == null || value.length != 5 || value[2] != ':') return null;
final hour = int.tryParse(value.substring(0, 2));
final minute = int.tryParse(value.substring(3));
if (hour == null || hour < 0 || hour > 23 || minute == null || minute < 0 || minute > 59) {
return null;
}
return value;
}
static CatalogSeasonInfo? _seasonFor(MalStartSeason? season) {
final name = CatalogSeasonInfo.parseName(season?.season);
return name == null ? null : CatalogSeasonInfo(name: name, year: season?.year);
}
static CatalogSourceMaterial? _sourceMaterialFor(String? source) => switch (source) {
null || '' => null,
'original' => CatalogSourceMaterial.original,
'manga' || '4_koma_manga' || 'digital_manga' => CatalogSourceMaterial.manga,
'light_novel' => CatalogSourceMaterial.lightNovel,
'novel' => CatalogSourceMaterial.novel,
'visual_novel' => CatalogSourceMaterial.visualNovel,
'game' || 'card_game' => CatalogSourceMaterial.game,
'web_manga' => CatalogSourceMaterial.webComic,
'music' => CatalogSourceMaterial.musicRelease,
_ => CatalogSourceMaterial.otherMedia,
};
static bool? _isAdultFor(String? nsfw) => switch (nsfw) {
'gray' || 'black' => true,
'white' => false,
_ => null,
};
static DateTime? _dateFor(String? value) => value == null ? null : DateTime.tryParse(value);
static List<String>? _galleryFor(List<MalPicture> pictures, String? posterUrl) {
final urls = <String>{};
for (final picture in pictures) {
final url = picture.primary;
if (url != null && url != posterUrl) urls.add(url);
}
return urls.isEmpty ? null : urls.toList(growable: false);
}
static CatalogRelationType _relationTypeFor(String? type) => switch (type) {
'prequel' => CatalogRelationType.prequel,
'sequel' => CatalogRelationType.sequel,
'side_story' => CatalogRelationType.sideStory,
'spin_off' => CatalogRelationType.spinOff,
'alternative_version' => CatalogRelationType.alternativeVersion,
'summary' => CatalogRelationType.summary,
'parent_story' => CatalogRelationType.parentStory,
'adaptation' => CatalogRelationType.adaptation,
_ => CatalogRelationType.other,
};
/// Reverse-map a library item's external ids to its MAL entry. A show-level
/// id can resolve to several rows (split-cour anime, one row per season);
/// prefer season 1 — "add this show" means its first season on MAL. Null
+257 -35
View File
@@ -1,7 +1,10 @@
import '../../media/media_kind.dart';
import '../../models/catalog/catalog_cast_member.dart';
import '../../models/catalog/catalog_item.dart';
import '../../models/catalog/catalog_metadata.dart';
import '../../utils/external_ids.dart';
import '../../utils/app_logger.dart';
import '../../utils/country_codes.dart';
import '../../utils/json_utils.dart';
import '../plex_discover_client.dart';
import 'catalog_source.dart';
@@ -12,9 +15,10 @@ import 'catalog_watchlist_machinery.dart';
/// Home ▸ Trending tab).
class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource, CatalogHubSource {
final PlexDiscoverClient _client;
final bool includeImageVariants;
final Map<String, String> _hubKeys = {};
PlexCatalogSource(this._client);
PlexCatalogSource(this._client, {this.includeImageVariants = false});
@override
CatalogSourceId get id => CatalogSourceId.plex;
@@ -41,23 +45,29 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource,
Future<CatalogPage> fetchRow(CatalogRowId row, {int page = 1, int limit = 25}) async {
if (row != CatalogRowId.watchlist) throw ArgumentError('Plex does not serve ${row.name}');
final response = await _client.getWatchlist(page: page, limit: limit);
return CatalogPage(items: _fromMetadata(response.items), hasMore: response.hasMore);
return CatalogPage(
items: _fromMetadata(response.items),
hasMore: response.hasMore,
totalResults: response.totalResults,
);
}
@override
Future<List<CatalogHub>> fetchHubs({int limit = 25}) async {
final fetched = await _client.getHomeHubs(limit: limit);
final fetched = await _client.getHomeHubs(limit: limit, includeImageVariants: includeImageVariants);
final keys = <String, String>{};
final result = <CatalogHub>[];
for (final hub in fetched) {
final items = _fromMetadata(hub.page.items);
if (items.isEmpty) continue;
final style = _hubStyleFor(hub.style);
keys[hub.id] = hub.key;
result.add(
CatalogHub(
id: hub.id,
title: hub.title,
page: CatalogPage(items: items, hasMore: hub.page.hasMore),
style: style,
page: CatalogPage(items: items, hasMore: hub.page.hasMore, totalResults: hub.page.totalResults),
),
);
}
@@ -73,15 +83,15 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource,
Future<CatalogPage> fetchHub(String id, {int page = 1, int limit = 25}) async {
final key = _hubKeys[id];
if (key == null || page > 1) return const CatalogPage(items: []);
final response = await _client.getHub(key, limit: limit);
return CatalogPage(items: _fromMetadata(response.items), hasMore: false);
final response = await _client.getHub(key, limit: limit, includeImageVariants: includeImageVariants);
return CatalogPage(items: _fromMetadata(response.items), hasMore: false, totalResults: response.totalResults);
}
@override
Future<List<CatalogItem>> search(String query, {int limit = 30}) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
return _fromMetadata(await _client.search(trimmed, limit: limit));
return _fromSearchResults(await _client.search(trimmed, limit: limit));
}
@override
@@ -111,27 +121,51 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource,
}
@override
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async {
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async {
final ratingKey = item.ids.plex;
if (ratingKey == null) return const [];
final metadata = await _client.getMetadata(ratingKey);
if (metadata == null) return const [];
return [
for (final role in flexibleMapList(metadata['Role']).take(limit))
if (_nonEmptyString(role['tag'] ?? role['name']) case final String name)
CatalogCastMember(
name: name,
secondary: _nonEmptyString(role['role']),
imageUrl: _nonEmptyString(role['thumb']),
),
];
if (ratingKey == null || ratingKey.isEmpty) return CatalogDetail(item: item);
final metadataFuture = _loadDetailMetadata(ratingKey);
final relatedFuture = _loadRelatedMetadata(ratingKey);
final metadata = await metadataFuture;
final relatedMetadata = await relatedFuture;
final detailItem = metadata == null ? null : _toCatalogItem(metadata);
final safeCastLimit = castLimit < 0 ? 0 : castLimit;
final safeRelatedLimit = relatedLimit < 0 ? 0 : relatedLimit;
return CatalogDetail(
item: detailItem == null ? item : item.enrichedWith(detailItem),
cast: metadata == null
? const []
: [
for (final role in flexibleMapList(metadata['Role']).take(safeCastLimit))
if (_nonEmptyString(role['tag'] ?? role['name']) case final String name)
CatalogCastMember(
name: name,
secondary: _nonEmptyString(role['role']),
imageUrl: _nonEmptyString(role['thumb']),
),
],
related: _fromMetadata(relatedMetadata).take(safeRelatedLimit).toList(),
);
}
@override
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async {
final ratingKey = item.ids.plex;
if (ratingKey == null) return const [];
return _fromMetadata(await _client.getRelated(ratingKey)).take(limit).toList();
Future<Map<String, dynamic>?> _loadDetailMetadata(String ratingKey) async {
try {
return await _client.getMetadata(ratingKey);
} catch (error, stackTrace) {
appLogger.w('Plex: detail metadata failed', error: error, stackTrace: stackTrace);
return null;
}
}
Future<List<Map<String, dynamic>>> _loadRelatedMetadata(String ratingKey) async {
try {
return await _client.getRelated(ratingKey);
} catch (error, stackTrace) {
appLogger.w('Plex: related metadata failed', error: error, stackTrace: stackTrace);
return const [];
}
}
@override
@@ -162,41 +196,229 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource,
return items;
}
CatalogItem? _toCatalogItem(Map<String, dynamic> metadata) {
List<CatalogItem> _fromSearchResults(List<PlexDiscoverSearchResult> results) {
final items = <CatalogItem>[];
final seen = <String>{};
for (final result in results) {
final item = _toCatalogItem(result.metadata, relevance: result.score);
if (item != null && seen.add(item.identityKey)) items.add(item);
}
return items;
}
CatalogItem? _toCatalogItem(Map<String, dynamic> metadata, {double? relevance}) {
final kind = _kindFor(metadata['type']);
final title = _nonEmptyString(metadata['title']);
final ids = _idsFor(metadata);
if (kind == null || title == null || ids.plex == null) return null;
final genres = [
for (final genre in flexibleMapList(metadata['Genre']))
if (_nonEmptyString(genre['tag']) case final String name) name,
];
final genres = _tagsFor(metadata['Genre']);
final studios = _tagsFor(metadata['Studio']);
final countries = _countriesFor(metadata['Country']);
final credits = _creditsFor(metadata);
final ratings = _ratingsFor(metadata);
final durationMs = flexibleInt(metadata['duration']);
final continuing = flexibleBoolNullable(metadata['isContinuingSeries']);
final nextAirDate = kind == MediaKind.show
? _date(metadata['nextEpisodeOriginallyAvailableAt']) ?? _date(metadata['nextSeasonOriginallyAvailableAt'])
: null;
final playState = CatalogPlayState(
viewCount: flexibleInt(metadata['viewCount']),
viewOffsetMs: flexibleInt(metadata['viewOffset']),
viewedLeafCount: flexibleInt(metadata['viewedLeafCount']),
);
String? coverPoster;
String? coverArt;
String? background;
String? clearLogo;
String? clearLogoWide;
String? imageBanner;
for (final image in flexibleMapList(metadata['Image'])) {
final type = _nonEmptyString(image['type']);
final url = _nonEmptyString(image['url']);
if (type == null || url == null) continue;
switch (type) {
case 'coverPoster':
coverPoster ??= url;
case 'coverArt':
coverArt ??= url;
case 'background':
background ??= url;
case 'clearLogo':
clearLogo ??= url;
case 'clearLogoWide':
clearLogoWide ??= url;
case 'banner':
imageBanner ??= url;
}
}
final headlineRating = _normalizedRating(metadata['rating']);
final audienceRating = _normalizedRating(metadata['audienceRating']);
return CatalogItem(
source: CatalogSourceId.plex,
kind: kind,
title: title,
year: flexibleInt(metadata['year']),
overview: _nonEmptyString(metadata['summary']),
overview: _overviewFor(metadata),
runtimeMinutes: durationMs == null ? null : Duration(milliseconds: durationMs).inMinutes,
rating: flexibleDouble(metadata['rating'] ?? metadata['audienceRating']),
genres: genres.isEmpty ? null : genres,
rating: headlineRating ?? audienceRating,
genres: genres,
certification: _nonEmptyString(metadata['contentRating']),
airStatus: kind == MediaKind.show && continuing != null
? continuing
? CatalogAirStatus.airing
: CatalogAirStatus.ended
: null,
episodeCount: kind == MediaKind.show ? flexibleInt(metadata['leafCount']) : null,
network: _nonEmptyString(metadata['studio'] ?? metadata['network']),
ids: ids,
posterUrl: _nonEmptyString(metadata['thumb']),
backdropUrl: _nonEmptyString(metadata['art']),
posterUrl: _nonEmptyString(metadata['thumb']) ?? coverPoster ?? coverArt,
backdropUrl: _nonEmptyString(metadata['art']) ?? background,
logoUrl: clearLogoWide ?? clearLogo,
bannerUrl: _nonEmptyString(metadata['banner']) ?? imageBanner,
ratings: ratings,
nextEpisode: nextAirDate == null ? null : CatalogNextEpisode(airsAt: nextAirDate),
releaseDate: _date(metadata['originallyAvailableAt']),
endDate: kind == MediaKind.show && continuing == false
? _date(metadata['lastEpisodeOriginallyAvailableAt']) ?? _date(metadata['lastSeasonOriginallyAvailableAt'])
: null,
originalTitle: _nonEmptyString(metadata['originalTitle']),
tagline: _nonEmptyString(metadata['tagline']),
studios: studios,
countries: countries,
credits: credits,
contentAdvisory: _contentAdvisoryFor(metadata),
budget: flexibleInt(metadata['budget']),
revenue: flexibleInt(metadata['revenue']),
playState: playState.isEmpty ? null : playState,
relevance: relevance,
);
}
static List<String>? _tagsFor(Object? value) {
final tags = <String>[];
final seen = <String>{};
for (final entry in flexibleMapList(value)) {
final tag = _nonEmptyString(entry['tag'] ?? entry['name']);
if (tag != null && seen.add(tag)) tags.add(tag);
}
return tags.isEmpty ? null : tags;
}
static List<String>? _countriesFor(Object? value) {
final countries = <String>[];
final seen = <String>{};
for (final entry in flexibleMapList(value)) {
final tag = _nonEmptyString(entry['tag'] ?? entry['name']);
if (tag == null) continue;
final country = CountryCodes.normalizeCode(tag);
if (country.isNotEmpty && seen.add(country)) countries.add(country);
}
return countries.isEmpty ? null : countries;
}
static List<CatalogCredit>? _creditsFor(Map<String, dynamic> metadata) {
final credits = <CatalogCredit>[];
final seen = <String>{};
void addCredits(Object? value, CatalogCreditRole role) {
for (final entry in flexibleMapList(value)) {
final name = _nonEmptyString(entry['tag'] ?? entry['name'] ?? entry['role']);
if (name != null && seen.add('${role.name}\u0000$name')) {
credits.add(CatalogCredit(name: name, role: role));
}
}
}
addCredits(metadata['Director'], CatalogCreditRole.director);
addCredits(metadata['Writer'], CatalogCreditRole.writer);
addCredits(metadata['Producer'], CatalogCreditRole.producer);
return credits.isEmpty ? null : credits;
}
static List<CatalogRatingSource>? _ratingsFor(Map<String, dynamic> metadata) {
final ratings = <CatalogRatingSource>[];
final imdbVotes = flexibleInt(metadata['imdbRatingCount']);
void add(Object? rawValue, {required String fallbackSource, Object? image, Object? type}) {
final value = _normalizedRating(rawValue);
if (value == null) return;
final source = _ratingSource(image: image, type: type, fallback: fallbackSource);
if (ratings.any((rating) => rating.source == source && rating.value == value)) return;
ratings.add(CatalogRatingSource(source: source, value: value, votes: source == 'imdb' ? imdbVotes : null));
}
add(metadata['rating'], fallbackSource: 'critic', image: metadata['ratingImage']);
add(metadata['audienceRating'], fallbackSource: 'audience', image: metadata['audienceRatingImage']);
for (final rating in flexibleMapList(metadata['Rating'])) {
add(rating['value'], fallbackSource: 'audience', image: rating['image'], type: rating['type']);
}
return ratings.isEmpty ? null : ratings;
}
static String _ratingSource({Object? image, Object? type, required String fallback}) {
final scheme = Uri.tryParse(_nonEmptyString(image) ?? '')?.scheme.toLowerCase();
if (scheme == 'imdb') return 'imdb';
if (scheme == 'themoviedb' || scheme == 'tmdb') return 'tmdb';
final ratingType = _nonEmptyString(type)?.toLowerCase() ?? fallback;
if (scheme == 'rottentomatoes') {
return ratingType == 'critic' ? 'rottenTomatoesCritic' : 'rottenTomatoesAudience';
}
return switch (ratingType) {
'critic' => 'critic',
'audience' => 'audience',
_ => fallback,
};
}
static double? _normalizedRating(Object? value) {
final rating = flexibleDouble(value);
if (rating == null || !rating.isFinite || rating < 0 || rating > 100) return null;
return rating > 10 ? rating / 10 : rating;
}
static String? _overviewFor(Map<String, dynamic> metadata) {
var overview = _nonEmptyString(metadata['summary']);
for (final summary in flexibleMapList(metadata['Summary'])) {
final candidate = _nonEmptyString(summary['tag'] ?? summary['summary']);
if (candidate != null && (overview == null || candidate.length > overview.length)) {
overview = candidate;
}
}
return overview;
}
static String? _contentAdvisoryFor(Map<String, dynamic> metadata) {
final advisories = <String>[];
final seen = <String>{};
for (final commonSense in flexibleMapList(metadata['CommonSenseMedia'])) {
final oneLiner = _nonEmptyString(commonSense['oneLiner']);
int? age;
for (final rating in flexibleMapList(commonSense['AgeRating'])) {
age ??= flexibleInt(rating['age']);
}
final advisory = [if (age != null) '$age+', ?oneLiner].join(' · ');
if (advisory.isNotEmpty && seen.add(advisory)) advisories.add(advisory);
}
return advisories.isEmpty ? null : advisories.join('\n');
}
static DateTime? _date(Object? value) => value is String ? DateTime.tryParse(value) : null;
static MediaKind? _kindFor(Object? type) => switch (type) {
'movie' => MediaKind.movie,
'show' => MediaKind.show,
_ => null,
};
static CatalogHubStyle? _hubStyleFor(Object? style) => switch (style) {
'shelf' => CatalogHubStyle.shelf,
'availabilityPlatforms' => CatalogHubStyle.availabilityPlatforms,
_ => null,
};
static CatalogItemIds _idsFor(Map<String, dynamic> metadata) {
String? imdb;
int? tmdb;
+387 -28
View File
@@ -2,11 +2,16 @@ import 'package:flutter/foundation.dart';
import '../../media/media_kind.dart';
import '../../models/catalog/catalog_cast_member.dart';
import '../../models/catalog/catalog_metadata.dart';
import '../../models/catalog/catalog_item.dart';
import '../../models/seerr/seerr_details.dart';
import '../../models/seerr/seerr_media.dart';
import '../../models/seerr/seerr_page.dart';
import '../../models/seerr/seerr_request.dart';
import '../../utils/app_logger.dart';
import '../../utils/country_codes.dart';
import '../../utils/external_ids.dart';
import '../../utils/trailer_urls.dart';
import '../seerr/seerr_client.dart';
import '../seerr/seerr_constants.dart';
import 'catalog_source.dart';
@@ -83,13 +88,23 @@ class SeerrCatalogSource implements CatalogSource {
}
@override
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async {
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async {
final tmdbId = item.ids.tmdb;
if (tmdbId == null) return const [];
final page = item.kind == MediaKind.movie
? await client.getMovieRecommendations(tmdbId)
: await client.getTvRecommendations(tmdbId);
return _toPage(page).items.take(limit).toList();
if (tmdbId == null) return CatalogDetail(item: item);
// Start both retained calls before awaiting either. Each helper isolates
// its own failure so detail metadata and recommendations degrade
// independently.
final detailsFuture = _fetchDetails(item.kind, tmdbId);
final relatedFuture = _fetchRecommendations(item.kind, tmdbId, relatedLimit);
final details = await detailsFuture;
final related = await relatedFuture;
return CatalogDetail(
item: details == null ? item : item.enrichedWith(_toDetailCatalogItem(details, item.kind)),
cast: details == null ? const [] : _toCast(details, castLimit),
related: related,
);
}
/// Seerr requests key on TMDB ids, so any library item carrying one is in
@@ -99,27 +114,6 @@ class SeerrCatalogSource implements CatalogSource {
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async =>
external.tmdb == null ? null : CatalogItemIds(tmdb: external.tmdb, imdb: external.imdb, tvdb: external.tvdb);
@override
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async {
final tmdbId = item.ids.tmdb;
if (tmdbId == null) return const [];
final SeerrCredits? credits;
if (item.kind == MediaKind.movie) {
credits = (await client.getMovie(tmdbId)).credits;
} else {
credits = (await client.getTv(tmdbId)).credits;
}
return [
for (final member in (credits?.cast ?? const <SeerrCastMember>[]).take(limit))
if (member.name case final String name when name.isNotEmpty)
CatalogCastMember(
name: name,
secondary: member.character,
imageUrl: tmdbImageUrl(member.profilePath, 'w300'),
),
];
}
// Seerr has no watchlist: membership is always unknown and mutations are
// programming errors (the action is hidden when supportsWatchlist is false).
@@ -142,6 +136,7 @@ class SeerrCatalogSource implements CatalogSource {
if (m.displayTitle.isNotEmpty) _toCatalogItem(m),
],
hasMore: page.hasMore,
totalResults: page.totalResults,
);
CatalogItem _toCatalogItem(SeerrMedia m) => CatalogItem(
@@ -149,14 +144,378 @@ class SeerrCatalogSource implements CatalogSource {
kind: m.isMovie ? MediaKind.movie : MediaKind.show,
title: m.displayTitle,
year: m.year,
overview: m.overview,
overview: _nonEmpty(m.overview),
rating: m.voteAverage,
votes: m.voteCount,
ids: CatalogItemIds(tmdb: m.id),
posterUrl: tmdbImageUrl(m.posterPath, 'w600_and_h900_bestv2'),
backdropUrl: tmdbImageUrl(m.backdropPath, 'w1920_and_h800_multi_faces'),
posterVariants: tmdbPosterVariants(m.posterPath),
backdropVariants: tmdbBackdropVariants(m.backdropPath),
serverState: _serverState(m.mediaInfo),
releaseDate: _date(m.date),
originalTitle: _originalTitle(m.displayOriginalTitle, m.displayTitle),
languages: _languageList(m.originalLanguage),
countries: _countryCodes(m.originCountry),
isAdult: m.isMovie ? m.adult : null,
relevance: m.popularity,
);
Future<SeerrDetails?> _fetchDetails(MediaKind kind, int tmdbId) async {
try {
return kind == MediaKind.movie ? await client.getMovie(tmdbId) : await client.getTv(tmdbId);
} catch (error) {
appLogger.w('Seerr: detail load failed for tmdb:$tmdbId', error: error);
return null;
}
}
Future<List<CatalogItem>> _fetchRecommendations(MediaKind kind, int tmdbId, int limit) async {
try {
final page = kind == MediaKind.movie
? await client.getMovieRecommendations(tmdbId)
: await client.getTvRecommendations(tmdbId);
return _toPage(page).items.take(limit).toList();
} catch (error) {
appLogger.w('Seerr: recommendations load failed for tmdb:$tmdbId', error: error);
return const [];
}
}
CatalogItem _toDetailCatalogItem(SeerrDetails details, MediaKind kind) => CatalogItem(
source: CatalogSourceId.seerr,
kind: kind,
title: details.displayTitle,
year: _year(details.date),
overview: _nonEmpty(details.overview),
runtimeMinutes: _positive(details.runtime) ?? _firstPositive(details.episodeRunTime),
rating: details.voteAverage,
votes: details.voteCount,
genres: _names(details.genres),
certification: _certification(details, kind),
trailerUrl: kind == MediaKind.movie ? _trailerUrl(details.relatedVideos) : null,
airStatus: _airStatus(details.status, kind),
episodeCount: kind == MediaKind.show ? _episodeCount(details) : null,
network: kind == MediaKind.show ? _firstName(details.networks) : null,
ids: CatalogItemIds(
tmdb: _positive(details.id),
imdb: _nonEmpty(details.imdbId) ?? _nonEmpty(details.externalIds?.imdbId),
tvdb: _positive(details.externalIds?.tvdbId),
),
posterUrl: tmdbImageUrl(details.posterPath, 'w600_and_h900_bestv2'),
backdropUrl: tmdbImageUrl(details.backdropPath, 'w1920_and_h800_multi_faces'),
posterVariants: tmdbPosterVariants(details.posterPath),
backdropVariants: tmdbBackdropVariants(details.backdropPath),
serverState: _serverState(details.mediaInfo),
releaseDate: _date(details.date),
originalTitle: _originalTitle(details.displayOriginalTitle, details.displayTitle),
tagline: _nonEmpty(details.tagline),
endDate: _endDate(details, kind),
studios: _names(details.productionCompanies),
countries: _detailCountries(details),
languages: _detailLanguages(details),
credits: _credits(details),
tags: _tags(details.keywords),
budget: _positive(details.budget),
revenue: _positive(details.revenue),
isAdult: kind == MediaKind.movie ? details.adult : null,
relevance: details.popularity,
);
List<CatalogCastMember> _toCast(SeerrDetails details, int limit) => [
for (final member in (details.credits?.cast ?? const <SeerrCastMember>[]).take(limit))
if (_nonEmpty(member.name) case final String name)
CatalogCastMember(
name: name,
secondary: _nonEmpty(member.character),
imageUrl: tmdbImageUrl(member.profilePath, 'w300'),
),
];
static CatalogServerState? _serverState(SeerrMediaInfo? info) {
if (info == null) return null;
int? availableSeasons;
int? totalSeasons;
final seasons = info.seasons;
if (seasons != null && seasons.isNotEmpty) {
var available = 0;
var total = 0;
for (final season in seasons) {
if (season.seasonNumber <= 0) continue;
total++;
if (season.status == SeerrMediaStatus.available) available++;
}
if (total > 0) {
availableSeasons = available;
totalSeasons = total;
}
}
final state = CatalogServerState(
availability: _availability(info.status),
availability4k: _availability(info.status4k),
request: _requestState(info, is4k: false, mediaStatus: info.status),
request4k: _requestState(info, is4k: true, mediaStatus: info.status4k),
availableSeasons: availableSeasons,
totalSeasons: totalSeasons,
);
return state.isEmpty ? null : state;
}
static CatalogAvailability? _availability(SeerrMediaStatus status) => switch (status) {
SeerrMediaStatus.available => CatalogAvailability.available,
SeerrMediaStatus.partiallyAvailable => CatalogAvailability.partiallyAvailable,
SeerrMediaStatus.pending ||
SeerrMediaStatus.processing ||
SeerrMediaStatus.deleted => CatalogAvailability.unavailable,
SeerrMediaStatus.unknown => null,
};
static CatalogRequestState? _requestState(
SeerrMediaInfo info, {
required bool is4k,
required SeerrMediaStatus mediaStatus,
}) {
var pendingApproval = false;
var approved = false;
var declined = false;
for (final request in info.requests ?? const <SeerrRequest>[]) {
if ((request.is4k ?? false) != is4k) continue;
switch (request.status) {
case SeerrRequestStatus.pending:
pendingApproval = true;
case SeerrRequestStatus.approved:
approved = true;
case SeerrRequestStatus.declined:
declined = true;
}
}
// These two `pending` values are unrelated. Request.pending means waiting
// for approval; MediaInfo.pending means an approved request is queued in
// the acquisition pipeline. Keep that distinction and precedence explicit.
if (pendingApproval) return CatalogRequestState.pending;
if (mediaStatus == SeerrMediaStatus.processing) return CatalogRequestState.processing;
if (approved || mediaStatus == SeerrMediaStatus.pending) return CatalogRequestState.approved;
if (declined) return CatalogRequestState.declined;
return null;
}
static String? _certification(SeerrDetails details, MediaKind kind) {
if (kind == MediaKind.movie) {
return _preferredCountryValue(
details.releases?.results,
(entry) => entry.countryCode,
(entry) =>
_nonEmpty(entry.rating) ??
entry.releaseDates?.map((date) => _nonEmpty(date.certification)).nonNulls.firstOrNull,
);
}
return _preferredCountryValue(
details.contentRatings?.results,
(entry) => entry.countryCode,
(entry) => _nonEmpty(entry.rating),
);
}
static String? _preferredCountryValue<T>(
List<T>? entries,
String? Function(T entry) country,
String? Function(T entry) value,
) {
if (entries == null) return null;
for (final entry in entries) {
if (country(entry)?.toUpperCase() != 'US') continue;
if (value(entry) case final String result) return result;
}
for (final entry in entries) {
if (value(entry) case final String result) return result;
}
return null;
}
static String? _trailerUrl(List<SeerrRelatedVideo>? videos) {
if (videos == null) return null;
for (final video in videos) {
if (video.type?.toLowerCase() != 'trailer') continue;
if (_nonEmpty(video.url) case final String url) return url;
if (_nonEmpty(video.key) case final String key when video.site?.toLowerCase() == 'youtube') {
return youTubeTrailerUrl(key);
}
}
return null;
}
static CatalogAirStatus? _airStatus(String? raw, MediaKind kind) {
return switch (raw?.trim().toLowerCase()) {
'returning series' || 'airing' => CatalogAirStatus.airing,
'ended' => CatalogAirStatus.ended,
'canceled' || 'cancelled' => CatalogAirStatus.canceled,
'planned' || 'pilot' || 'rumored' || 'in production' || 'post production' => CatalogAirStatus.upcoming,
'released' when kind == MediaKind.show => CatalogAirStatus.ended,
_ => null,
};
}
static DateTime? _endDate(SeerrDetails details, MediaKind kind) {
if (kind != MediaKind.show) return null;
final status = _airStatus(details.status, kind);
return status == CatalogAirStatus.ended || status == CatalogAirStatus.canceled ? _date(details.lastAirDate) : null;
}
static int? _episodeCount(SeerrDetails details) {
if (_positive(details.numberOfEpisodes) case final int count) return count;
var total = 0;
var found = false;
for (final season in details.seasons ?? const <SeerrSeason>[]) {
if (_positive(season.episodeCount) case final int count) {
total += count;
found = true;
}
}
return found ? total : null;
}
static List<String>? _countryCodes(Iterable<String?>? values) {
if (values == null) return null;
final result = <String>[];
final seen = <String>{};
for (final value in values) {
final trimmed = _nonEmpty(value);
if (trimmed == null) continue;
final normalized = CountryCodes.normalizeCode(trimmed);
if (normalized.isNotEmpty && seen.add(normalized)) result.add(normalized);
}
return result.isEmpty ? null : result;
}
static List<String>? _detailCountries(SeerrDetails details) => _countryCodes([
...?details.originCountry,
for (final value in details.productionCountries ?? const <SeerrProductionCountry>[])
value.countryCode ?? value.name,
]);
static List<String>? _detailLanguages(SeerrDetails details) => _languageCodes([
details.originalLanguage,
...?details.languages,
for (final language in details.spokenLanguages ?? const <SeerrSpokenLanguage>[]) language.languageCode,
]);
static List<String>? _languageCodes(Iterable<String?> values) {
final result = <String>[];
final seen = <String>{};
for (final value in values) {
final normalized = _nonEmpty(value)?.toLowerCase();
if (normalized != null && seen.add(normalized)) result.add(normalized);
}
return result.isEmpty ? null : result;
}
static List<CatalogCredit>? _credits(SeerrDetails details) {
final result = <CatalogCredit>[];
final seen = <String>{};
void add(String? rawName, CatalogCreditRole? role) {
final name = _nonEmpty(rawName);
if (name == null || role == null || !seen.add('${role.name}\u0000$name')) return;
result.add(CatalogCredit(name: name, role: role));
}
for (final creator in details.createdBy ?? const <SeerrNamedValue>[]) {
add(creator.name, CatalogCreditRole.creator);
}
for (final member in details.credits?.crew ?? const <SeerrCrewMember>[]) {
add(member.name, _creditRole(member.job, member.department));
}
return result.isEmpty ? null : result;
}
static CatalogCreditRole? _creditRole(String? job, String? department) {
return switch (job?.trim().toLowerCase()) {
'director' => CatalogCreditRole.director,
'writer' || 'screenplay' || 'story' || 'teleplay' => CatalogCreditRole.writer,
'producer' || 'executive producer' || 'co-producer' => CatalogCreditRole.producer,
'composer' || 'original music composer' => CatalogCreditRole.composer,
_ when department?.trim().toLowerCase() == 'writing' => CatalogCreditRole.writer,
_ => null,
};
}
static List<CatalogTag>? _tags(List<SeerrNamedValue>? keywords) {
final names = _names(keywords);
return names == null ? null : [for (final name in names) CatalogTag(name: name)];
}
static List<String>? _names(List<SeerrNamedValue>? values) {
if (values == null) return null;
final names = [
for (final value in values)
if (_nonEmpty(value.name) case final String name) name,
];
return names.isEmpty ? null : names;
}
static String? _firstName(List<SeerrNamedValue>? values) {
if (values == null) return null;
for (final value in values) {
if (_nonEmpty(value.name) case final String name) return name;
}
return null;
}
static List<String>? _languageList(String? language) => _languageCodes([language]);
static String? _originalTitle(String? original, String displayed) {
final value = _nonEmpty(original);
return value == null || value == displayed ? null : value;
}
static DateTime? _date(String? raw) => raw == null ? null : DateTime.tryParse(raw);
static int? _year(String? raw) {
if (raw == null || raw.length < 4) return null;
return int.tryParse(raw.substring(0, 4));
}
static int? _positive(int? value) => value != null && value > 0 ? value : null;
static int? _firstPositive(List<int>? values) {
if (values == null) return null;
for (final value in values) {
if (value > 0) return value;
}
return null;
}
static String? _nonEmpty(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}
static Map<int, String>? tmdbPosterVariants(String? path) {
if (path == null || path.isEmpty) return null;
return {
92: tmdbImageUrl(path, 'w92')!,
154: tmdbImageUrl(path, 'w154')!,
185: tmdbImageUrl(path, 'w185')!,
342: tmdbImageUrl(path, 'w342')!,
500: tmdbImageUrl(path, 'w500')!,
600: tmdbImageUrl(path, 'w600_and_h900_bestv2')!,
780: tmdbImageUrl(path, 'w780')!,
};
}
static Map<int, String>? tmdbBackdropVariants(String? path) {
if (path == null || path.isEmpty) return null;
return {
300: tmdbImageUrl(path, 'w300')!,
780: tmdbImageUrl(path, 'w780')!,
1280: tmdbImageUrl(path, 'w1280')!,
1920: tmdbImageUrl(path, 'w1920_and_h800_multi_faces')!,
};
}
/// Seerr serves TMDB relative paths (`/abc.jpg`); images come straight off
/// the TMDB CDN at the same sizes the Seerr web UI uses.
static String? tmdbImageUrl(String? path, String size) =>
+298 -29
View File
@@ -1,18 +1,21 @@
import '../../media/media_kind.dart';
import '../../models/catalog/catalog_cast_member.dart';
import '../../models/catalog/catalog_metadata.dart';
import '../../models/catalog/catalog_item.dart';
import '../../models/simkl/simkl_all_items_entry.dart';
import '../../models/simkl/simkl_best_item.dart';
import '../../models/simkl/simkl_detail.dart';
import '../../models/simkl/simkl_ids.dart';
import '../../models/simkl/simkl_images.dart';
import '../../models/simkl/simkl_recommendation.dart';
import '../../models/simkl/simkl_rating.dart';
import '../../models/simkl/simkl_search_result.dart';
import '../../models/simkl/simkl_trending_item.dart';
import '../../utils/country_codes.dart';
import '../../utils/external_ids.dart';
import '../trackers/simkl/simkl_client.dart';
import '../trackers/simkl/simkl_constants.dart';
import '../trackers/tracker_exceptions.dart';
import '../trackers/future_coalescer.dart';
import '../../utils/trailer_urls.dart';
import 'catalog_source.dart';
import 'catalog_watchlist_machinery.dart';
@@ -29,6 +32,7 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource
final Map<CatalogRowId, DateTime> _rowCacheLoadedAt = {};
final KeyedFutureCoalescer<CatalogRowId, List<CatalogItem>> _rowLoads = KeyedFutureCoalescer();
final FutureCoalescer<SimklAllItems> _watchlistLoad = FutureCoalescer();
final Set<int> _animeIds = {};
SimklAllItems? _watchlistCache;
int _watchlistCacheGeneration = 0;
@@ -170,7 +174,7 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource
static CatalogAirStatus? airStatusFor(String? status, MediaKind kind) => switch (status?.toLowerCase()) {
'airing' || 'ongoing' => CatalogAirStatus.airing,
'ended' => kind == MediaKind.movie ? null : CatalogAirStatus.ended,
'tba' => CatalogAirStatus.upcoming,
'premiere' || 'tba' => CatalogAirStatus.upcoming,
_ => null,
};
@@ -180,13 +184,187 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource
return MediaKind.show;
}
static CatalogFormat? _formatFor(SimklCatalogType type, String? raw) {
if (type != SimklCatalogType.anime) return null;
return switch (raw?.trim().toLowerCase()) {
'tv' => CatalogFormat.tv,
'tv short' || 'tv_short' || 'tv-short' => CatalogFormat.tvShort,
'movie' => CatalogFormat.movie,
'special' => CatalogFormat.special,
'ova' => CatalogFormat.ova,
'ona' => CatalogFormat.ona,
'music' || 'music video' => CatalogFormat.music,
null || '' => null,
_ => CatalogFormat.other,
};
}
SimklCatalogType _detailTypeFor(CatalogItem item) {
if (item.format != null || _animeIds.contains(item.ids.simkl) || item.ids.mal != null || item.ids.anilist != null) {
return SimklCatalogType.anime;
}
return item.kind == MediaKind.movie ? SimklCatalogType.movies : SimklCatalogType.tv;
}
void _rememberType(SimklCatalogType type, SimklIds ids) {
final simklId = ids.simkl;
if (type == SimklCatalogType.anime && simklId != null) _animeIds.add(simklId);
}
static List<CatalogRatingSource>? _ratingsFor(SimklRatings? ratings) {
if (ratings == null) return null;
final result = <CatalogRatingSource>[];
for (final (source, rating) in [('simkl', ratings.simkl), ('imdb', ratings.imdb), ('mal', ratings.mal)]) {
final value = rating?.rating;
if (value == null || value < 0 || value > 10) continue;
final votes = rating!.votes;
result.add(CatalogRatingSource(source: source, value: value, votes: votes != null && votes >= 0 ? votes : null));
}
return result.isEmpty ? null : result;
}
static CatalogAudience? _audienceFor({
int? viewers,
CatalogAudiencePeriod? viewersPeriod,
int? planning,
String? dropRate,
}) {
final normalizedViewers = viewers != null && viewers >= 0 ? viewers : null;
final normalizedPlanning = planning != null && planning >= 0 ? planning : null;
final normalizedDropRate = _percentFraction(dropRate);
if (normalizedViewers == null && normalizedPlanning == null && normalizedDropRate == null) return null;
return CatalogAudience(
viewers: normalizedViewers,
viewersPeriod: normalizedViewers == null ? null : viewersPeriod,
planning: normalizedPlanning,
dropRate: normalizedDropRate,
);
}
static double? _percentFraction(String? raw) {
final value = double.tryParse(raw?.trim().replaceAll('%', '') ?? '');
if (value == null || value < 0 || value > 100) return null;
return value / 100;
}
static List<CatalogRank>? _rankFor(int? rank, CatalogRankScope scope, {required bool allTime}) =>
rank == null || rank <= 0 ? null : [CatalogRank(rank: rank, scope: scope, allTime: allTime)];
static String? _originalTitle(String title, String? raw) {
final value = raw?.trim();
if (value == null || value.isEmpty || value.toLowerCase() == title.trim().toLowerCase()) return null;
return value;
}
static List<String>? _alternateTitles(String title, List<String>? raw, {String? originalTitle, String? extra}) {
final seen = <String>{title.trim().toLowerCase(), if (originalTitle != null) originalTitle.toLowerCase()};
final result = <String>[];
for (final candidate in [...?raw, ?extra]) {
final value = candidate.trim();
if (value.isEmpty || !seen.add(value.toLowerCase())) continue;
result.add(value);
}
return result.isEmpty ? null : result;
}
static List<CatalogLink>? _linksFor(String? raw) {
final parsed = Uri.tryParse(raw?.trim() ?? '');
if (parsed == null || parsed.toString().isEmpty) return null;
final resolved = parsed.hasScheme ? parsed : Uri.parse('https://simkl.com').resolveUri(parsed);
if (resolved.scheme != 'http' && resolved.scheme != 'https') return null;
return [CatalogLink(label: 'Simkl', url: resolved.toString())];
}
static DateTime? _date(String? raw) {
final value = raw?.trim();
if (value == null || value.isEmpty) return null;
final iso = DateTime.tryParse(value);
if (iso != null) return iso;
final match = RegExp(r'^(\d{1,2})/(\d{1,2})/(\d{4})$').firstMatch(value);
if (match == null) return null;
final month = int.parse(match.group(1)!);
final day = int.parse(match.group(2)!);
final year = int.parse(match.group(3)!);
return DateTime.tryParse(
'${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}',
);
}
static List<String>? _countryFor(String? raw) {
final value = raw?.trim();
return value == null || value.isEmpty ? null : [CountryCodes.normalizeCode(value)];
}
static List<String>? _languageFor(String? raw) {
final value = raw?.trim();
return value == null || value.isEmpty ? null : [value.toLowerCase()];
}
static List<CatalogCredit>? _creditsFor(String? director) {
final name = director?.trim();
return name == null || name.isEmpty ? null : [CatalogCredit(name: name, role: CatalogCreditRole.director)];
}
static CatalogBroadcast? _broadcastFor(SimklAirs? airs) {
if (airs == null) return null;
final weekday = _weekdayFor(airs.day);
final time = _broadcastTime(airs.time);
if (weekday == null && time == null) return null;
final timezone = airs.timezone?.trim();
return CatalogBroadcast(
weekday: weekday,
time: time,
timezone: timezone == null || timezone.isEmpty ? null : timezone,
);
}
static int? _weekdayFor(String? raw) => switch (raw?.trim().toLowerCase()) {
'monday' => DateTime.monday,
'tuesday' => DateTime.tuesday,
'wednesday' => DateTime.wednesday,
'thursday' => DateTime.thursday,
'friday' => DateTime.friday,
'saturday' => DateTime.saturday,
'sunday' => DateTime.sunday,
_ => null,
};
static String? _broadcastTime(String? raw) {
final match = RegExp(r'^(\d{1,2}):(\d{2})\s*([AP]M)?$', caseSensitive: false).firstMatch(raw?.trim() ?? '');
if (match == null) return null;
var hour = int.parse(match.group(1)!);
final minute = int.parse(match.group(2)!);
final period = match.group(3)?.toUpperCase();
if (minute > 59 || (period == null && hour > 23) || (period != null && (hour < 1 || hour > 12))) {
return null;
}
if (period != null) {
hour %= 12;
if (period == 'PM') hour += 12;
}
return '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}';
}
static CatalogSeasonInfo? _seasonFor(String? raw) {
final parts = raw?.trim().split(RegExp(r'\s+'));
if (parts == null || parts.isEmpty) return null;
final name = CatalogSeasonInfo.parseName(parts.first);
if (name == null) return null;
return CatalogSeasonInfo(name: name, year: parts.length > 1 ? int.tryParse(parts.last) : null);
}
static String? _trailerUrl(String? youtube) => youTubeTrailerUrl(youtube);
CatalogItem _catalogItemFromTrending(SimklTrendingItem item, SimklCatalogType type) {
_rememberType(type, item.ids);
final kind = _kindFor(type, animeType: item.animeType);
final rating = item.ratings?.primary;
final title = item.title ?? '';
final originalTitle = _originalTitle(title, item.titleRomaji);
return CatalogItem(
source: CatalogSourceId.simkl,
kind: kind,
title: item.title ?? '',
title: title,
year: item.year,
overview: item.overview,
runtimeMinutes: item.runtimeMinutes,
@@ -200,16 +378,37 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource
ids: item.ids.toCatalogItemIds(),
posterUrl: simklPosterUrl(item.poster),
backdropUrl: simklFanartUrl(item.fanart),
posterVariants: simklPosterVariants(item.poster),
backdropVariants: simklFanartVariants(item.fanart),
ratings: _ratingsFor(item.ratings),
ranks: _rankFor(item.rank, CatalogRankScope.trending, allTime: false),
audience: _audienceFor(
viewers: item.watched,
viewersPeriod: CatalogAudiencePeriod.week,
planning: item.planToWatch,
dropRate: item.dropRate,
),
releaseDate: _date(item.theater ?? item.releaseDate),
physicalReleaseDate: _date(item.dvdDate),
originalTitle: originalTitle,
altTitles: _alternateTitles(title, item.alternateTitles, originalTitle: originalTitle) ?? const [],
format: _formatFor(type, item.animeType),
countries: _countryFor(item.country),
languages: _languageFor(item.originalLanguage),
links: _linksFor(item.url),
);
}
CatalogItem _catalogItemFromSearch(SimklSearchResult item, SimklCatalogType type) {
_rememberType(type, item.ids);
final kind = _kindFor(type, animeType: item.type);
final rating = item.ratings?.primary;
final title = item.title ?? '';
final originalTitle = _originalTitle(title, item.titleRomaji);
return CatalogItem(
source: CatalogSourceId.simkl,
kind: kind,
title: item.title ?? '',
title: title,
year: item.year,
rating: rating?.rating,
votes: rating?.votes,
@@ -217,10 +416,17 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource
episodeCount: kind == MediaKind.movie || (item.episodeCount ?? 0) <= 0 ? null : item.episodeCount,
ids: item.ids.toCatalogItemIds(),
posterUrl: simklPosterUrl(item.poster),
posterVariants: simklPosterVariants(item.poster),
ratings: _ratingsFor(item.ratings),
originalTitle: originalTitle,
altTitles: _alternateTitles(title, item.allTitles, originalTitle: originalTitle, extra: item.titleEn) ?? const [],
format: _formatFor(type, item.type),
links: _linksFor(item.url),
);
}
CatalogItem _catalogItemFromBest(SimklBestItem item, SimklCatalogType type) {
_rememberType(type, item.ids);
final kind = _kindFor(type);
final rating = item.ratings?.primary;
return CatalogItem(
@@ -232,11 +438,17 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource
votes: rating?.votes,
ids: item.ids.toCatalogItemIds(),
posterUrl: simklPosterUrl(item.poster),
posterVariants: simklPosterVariants(item.poster),
ratings: _ratingsFor(item.ratings),
audience: _audienceFor(viewers: item.watched, viewersPeriod: CatalogAudiencePeriod.month),
format: _formatFor(type, null),
links: _linksFor(item.url),
);
}
CatalogItem _catalogItemFromAllItems(SimklAllItemsEntry entry, SimklCatalogType type) {
final media = entry.media!;
_rememberType(type, media.ids);
final kind = _kindFor(type, animeType: entry.animeType);
return CatalogItem(
source: CatalogSourceId.simkl,
@@ -252,46 +464,102 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource
ids: media.ids.toCatalogItemIds(),
posterUrl: simklPosterUrl(media.poster),
backdropUrl: simklFanartUrl(media.fanart),
posterVariants: simklPosterVariants(media.poster),
backdropVariants: simklFanartVariants(media.fanart),
addedAt: _date(entry.addedAt),
userRating: entry.userRating,
format: _formatFor(type, entry.animeType),
unairedEpisodeCount: kind == MediaKind.movie || (entry.notAiredEpisodes ?? 0) <= 0
? null
: entry.notAiredEpisodes,
);
}
CatalogItem _catalogItemFromRecommendation(SimklRecommendation item) {
final kind = item.type == 'movie' ? MediaKind.movie : MediaKind.show;
final type = switch (item.type?.toLowerCase()) {
'movie' => SimklCatalogType.movies,
'anime' => SimklCatalogType.anime,
_ => SimklCatalogType.tv,
};
_rememberType(type, item.ids);
final kind = _kindFor(type, animeType: item.animeType);
final title = item.title ?? '';
return CatalogItem(
source: CatalogSourceId.simkl,
kind: kind,
title: item.title ?? '',
title: title,
year: item.year,
ids: item.ids.toCatalogItemIds(),
posterUrl: simklPosterUrl(item.poster),
posterVariants: simklPosterVariants(item.poster),
altTitles: _alternateTitles(title, null, extra: item.englishTitle) ?? const [],
format: _formatFor(type, item.animeType),
recommendationCount: item.usersCount != null && item.usersCount! >= 0 ? item.usersCount : null,
recommendationPercent: _percentFraction(item.usersPercent),
);
}
CatalogItem _catalogItemFromDetail(SimklDetail detail, SimklCatalogType type) {
final kind = _kindFor(type, animeType: detail.animeType);
final rating = detail.ratings?.primary;
final title = detail.title ?? '';
final originalTitle = _originalTitle(title, detail.originalTitle);
return CatalogItem(
source: CatalogSourceId.simkl,
kind: kind,
title: title,
year: detail.year,
overview: detail.overview,
runtimeMinutes: detail.runtime,
rating: rating?.rating,
votes: rating?.votes,
genres: detail.genres,
certification: detail.certification,
trailerUrl: _trailerUrl(detail.trailer),
airStatus: airStatusFor(detail.status, kind),
episodeCount: kind == MediaKind.movie || (detail.totalEpisodes ?? 0) <= 0 ? null : detail.totalEpisodes,
network: detail.network,
ids: detail.ids.toCatalogItemIds(),
posterUrl: simklPosterUrl(detail.poster),
backdropUrl: simklFanartUrl(detail.fanart),
posterVariants: simklPosterVariants(detail.poster),
backdropVariants: simklFanartVariants(detail.fanart),
ratings: _ratingsFor(detail.ratings),
ranks: _rankFor(detail.rank, CatalogRankScope.popular, allTime: true),
audience: _audienceFor(dropRate: detail.dropRate),
broadcast: _broadcastFor(detail.airs),
releaseDate: _date(detail.released ?? detail.firstAired),
physicalReleaseDate: _date(detail.physicalRelease),
endDate: _date(detail.lastAired),
originalTitle: originalTitle,
altTitles:
_alternateTitles(title, detail.alternateTitles, originalTitle: originalTitle, extra: detail.englishTitle) ??
const [],
broadcastSeason: _seasonFor(detail.seasonNameYear),
format: _formatFor(type, detail.animeType),
studios: detail.studios,
countries: _countryFor(detail.country),
languages: _languageFor(detail.language),
credits: _creditsFor(detail.director),
links: _linksFor(detail.url),
budget: detail.budget,
revenue: detail.revenue,
);
}
@override
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async => const [];
@override
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async {
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async {
final simklId = item.ids.simkl;
if (simklId == null) return const [];
final primary = item.kind == MediaKind.movie ? SimklCatalogType.movies : SimklCatalogType.tv;
List<SimklRecommendation> recommendations;
try {
recommendations = await _client.getRecommendations(primary, simklId);
// Current Simkl detail endpoints return `[]` (HTTP 200), not 404, for
// an ID belonging to a different media bucket. Anime is therefore the
// fallback for both an explicit 404 and an empty primary response.
if (recommendations.isEmpty) {
recommendations = await _client.getRecommendations(SimklCatalogType.anime, simklId);
}
} on TrackerApiException catch (error) {
if (error.statusCode != 404) rethrow;
recommendations = await _client.getRecommendations(SimklCatalogType.anime, simklId);
}
return [
for (final recommendation in recommendations.take(limit))
if (recommendation.ids.hasAny) _catalogItemFromRecommendation(recommendation),
if (simklId == null) return CatalogDetail(item: item);
final type = _detailTypeFor(item);
final detail = await _client.getDetail(type, simklId);
if (detail == null) return CatalogDetail(item: item);
final related = <CatalogItem>[
if (relatedLimit > 0)
for (final recommendation in detail.recommendations.take(relatedLimit))
if (recommendation.ids.hasAny) _catalogItemFromRecommendation(recommendation),
];
return CatalogDetail(item: item.enrichedWith(_catalogItemFromDetail(detail, type)), related: related);
}
@override
@@ -354,6 +622,7 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource
_invalidateWatchlistCache();
_rowCache.clear();
_rowCacheLoadedAt.clear();
_animeIds.clear();
disposeWatchlistMachinery();
}
}
+220 -41
View File
@@ -1,9 +1,14 @@
import '../../i18n/strings.g.dart';
import '../../media/media_kind.dart';
import '../../models/catalog/catalog_cast_member.dart';
import '../../models/catalog/catalog_item.dart';
import '../../models/catalog/catalog_metadata.dart';
import '../../models/trakt/trakt_cast_entry.dart';
import '../../models/trakt/trakt_catalog_entry.dart';
import '../../models/trakt/trakt_catalog_media.dart';
import '../../models/trakt/trakt_ids.dart';
import '../../utils/app_logger.dart';
import '../../utils/country_codes.dart';
import '../../utils/external_ids.dart';
import '../trakt/trakt_client.dart';
import '../trakt/trakt_constants.dart';
@@ -54,7 +59,7 @@ class TraktCatalogSource with CatalogWatchlistMachinery implements CatalogSource
switch (row) {
case CatalogRowId.watchlist:
final res = await _client.getWatchlist(page: page, limit: limit);
return CatalogPage(items: _fromEntries(res.items), hasMore: res.hasMore);
return CatalogPage(items: _fromEntries(res.items), hasMore: res.hasMore, totalResults: res.itemCount);
case CatalogRowId.recommendedMovies:
return CatalogPage(
items: _fromMedia(await _client.getRecommended(TraktCatalogType.movies, limit: limit), MediaKind.movie),
@@ -68,19 +73,29 @@ class TraktCatalogSource with CatalogWatchlistMachinery implements CatalogSource
return CatalogPage(
items: _fromEntries(res.items, kind: MediaKind.movie),
hasMore: res.hasMore,
totalResults: res.itemCount,
);
case CatalogRowId.trendingShows:
final res = await _client.getTrending(TraktCatalogType.shows, page: page, limit: limit);
return CatalogPage(
items: _fromEntries(res.items, kind: MediaKind.show),
hasMore: res.hasMore,
totalResults: res.itemCount,
);
case CatalogRowId.popularMovies:
final res = await _client.getPopular(TraktCatalogType.movies, page: page, limit: limit);
return CatalogPage(items: _fromMedia(res.items, MediaKind.movie), hasMore: res.hasMore);
return CatalogPage(
items: _fromMedia(res.items, MediaKind.movie),
hasMore: res.hasMore,
totalResults: res.itemCount,
);
case CatalogRowId.popularShows:
final res = await _client.getPopular(TraktCatalogType.shows, page: page, limit: limit);
return CatalogPage(items: _fromMedia(res.items, MediaKind.show), hasMore: res.hasMore);
return CatalogPage(
items: _fromMedia(res.items, MediaKind.show),
hasMore: res.hasMore,
totalResults: res.itemCount,
);
case CatalogRowId.trendingAnime:
case CatalogRowId.suggestedAnime:
case CatalogRowId.airingAnime:
@@ -105,28 +120,107 @@ class TraktCatalogSource with CatalogWatchlistMachinery implements CatalogSource
external.hasAny ? CatalogItemIds.fromExternal(external) : null;
@override
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async {
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async {
final id = item.ids.trakt?.toString() ?? item.ids.slug ?? item.ids.imdb;
if (id == null) return const [];
if (id == null) return CatalogDetail(item: item);
final type = item.kind == MediaKind.movie ? TraktCatalogType.movies : TraktCatalogType.shows;
final cast = await _client.getPeople(type, id);
return [
for (final entry in cast.take(limit))
if (entry.person?.name case final String name when name.isNotEmpty)
CatalogCastMember(
name: name,
secondary: entry.characters?.firstOrNull,
imageUrl: entry.person?.images?.primaryHeadshot,
),
];
// Start both requests before awaiting either. Trakt keeps people and
// related titles on separate endpoints, so two calls are the minimum.
final peopleFuture = _withoutFailure(_client.getPeople(type, id), 'people', item);
final relatedFuture = _withoutFailure(_client.getRelated(type, id, limit: relatedLimit), 'related', item);
final people = await peopleFuture;
final related = await relatedFuture;
final credits = _creditsFrom(people?.crew);
final enrichedItem = credits.isEmpty
? item
: item.enrichedWith(
CatalogItem(
source: CatalogSourceId.trakt,
kind: item.kind,
title: '',
ids: const CatalogItemIds(),
credits: credits,
),
);
return CatalogDetail(
item: enrichedItem,
cast: _castFrom(people, castLimit),
related: related == null ? const [] : _fromMedia(related, item.kind),
);
}
@override
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async {
final id = item.ids.trakt?.toString() ?? item.ids.slug ?? item.ids.imdb;
if (id == null) return const [];
final type = item.kind == MediaKind.movie ? TraktCatalogType.movies : TraktCatalogType.shows;
return _fromMedia(await _client.getRelated(type, id, limit: limit), item.kind);
Future<T?> _withoutFailure<T>(Future<T> request, String requestName, CatalogItem item) async {
try {
return await request;
} catch (error, stackTrace) {
appLogger.d(
'Trakt: catalog $requestName load failed for ${item.identityKey}',
error: error,
stackTrace: stackTrace,
);
return null;
}
}
List<CatalogCastMember> _castFrom(TraktPeople? people, int limit) {
if (people == null || limit <= 0) return const [];
final cast = <CatalogCastMember>[];
// `guest_stars` can be a very large response. Regular cast stays first and
// the combined retained list is bounded even though Trakt sends all rows.
for (final entry in people.cast.followedBy(people.guestStars)) {
if (cast.length >= limit) break;
if (entry.person?.name case final String name when name.isNotEmpty) {
cast.add(
CatalogCastMember(
name: name,
secondary: _castSecondary(entry),
imageUrl: entry.person?.images?.primaryHeadshot,
),
);
}
}
return cast;
}
static String? _castSecondary(TraktCastEntry entry) {
final characters = entry.characters?.where((character) => character.isNotEmpty).join(', ');
final episodeCount = entry.episodeCount;
final parts = [
if (characters != null && characters.isNotEmpty) characters,
if (episodeCount != null) t.explore.badge.episodesShort(n: episodeCount),
];
return parts.isEmpty ? null : parts.join(' · ');
}
static List<CatalogCredit> _creditsFrom(List<TraktCastEntry>? crew) {
if (crew == null || crew.isEmpty) return const [];
final credits = <CatalogCredit>[];
final seen = <String>{};
for (final role in const [CatalogCreditRole.director, CatalogCreditRole.writer, CatalogCreditRole.producer]) {
for (final entry in crew) {
final name = entry.person?.name;
if (name == null || name.isEmpty || !_hasCreditRole(entry, role)) continue;
final key = '${role.name}/${name.toLowerCase()}';
if (seen.add(key)) credits.add(CatalogCredit(name: name, role: role));
}
}
return credits;
}
static bool _hasCreditRole(TraktCastEntry entry, CatalogCreditRole role) {
final job = entry.job;
final jobs = <String>[
if (job != null) job.toLowerCase(),
for (final job in entry.jobs ?? const <String>[]) job.toLowerCase(),
];
return switch (role) {
CatalogCreditRole.director => jobs.any((job) => job == 'director' || job == 'co-director'),
CatalogCreditRole.writer => jobs.any((job) => const {'writer', 'screenplay', 'story', 'teleplay'}.contains(job)),
CatalogCreditRole.producer => jobs.any((job) => job == 'producer' || job.endsWith(' producer')),
_ => false,
};
}
@override
@@ -156,7 +250,12 @@ class TraktCatalogSource with CatalogWatchlistMachinery implements CatalogSource
List<CatalogItem> _fromEntries(List<TraktCatalogEntry> entries, {MediaKind? kind}) => [
for (final entry in entries)
if (entry.media != null && _entryKind(entry, kind) != null && entry.media!.ids.hasAny)
_toCatalogItem(entry.media!, _entryKind(entry, kind)!),
_toCatalogItem(
entry.media!,
_entryKind(entry, kind)!,
watchers: entry.watchers,
addedAt: _parseDate(entry.listedAt),
),
];
List<CatalogItem> _fromMedia(List<TraktCatalogMedia> media, MediaKind kind) => [
@@ -191,25 +290,105 @@ class TraktCatalogSource with CatalogWatchlistMachinery implements CatalogSource
_ => null,
};
CatalogItem _toCatalogItem(TraktCatalogMedia m, MediaKind kind) => CatalogItem(
source: CatalogSourceId.trakt,
kind: kind,
title: m.title ?? '',
year: m.year,
overview: m.overview,
runtimeMinutes: m.runtime,
rating: m.rating,
votes: m.votes,
genres: m.genres,
certification: m.certification,
trailerUrl: m.trailer,
airStatus: airStatusFor(m.status),
episodeCount: m.airedEpisodes,
network: m.network,
ids: CatalogItemIds(trakt: m.ids.trakt, slug: m.ids.slug, imdb: m.ids.imdb, tmdb: m.ids.tmdb, tvdb: m.ids.tvdb),
posterUrl: m.images?.primaryPoster,
backdropUrl: m.images?.primaryBackdrop,
);
CatalogItem _toCatalogItem(TraktCatalogMedia media, MediaKind kind, {int? watchers, DateTime? addedAt}) {
final audience = CatalogAudience(watchingNow: watchers, comments: media.commentCount);
final broadcast = CatalogBroadcast(
weekday: weekdayFor(media.airs?.day),
time: _broadcastTime(media.airs?.time),
timezone: media.airs?.timezone,
);
return CatalogItem(
source: CatalogSourceId.trakt,
kind: kind,
title: media.title ?? '',
year: media.year,
overview: media.overview,
runtimeMinutes: media.runtime,
rating: media.rating,
votes: media.votes,
genres: media.genres,
certification: media.certification,
trailerUrl: media.trailer,
airStatus: airStatusFor(media.status),
episodeCount: media.airedEpisodes,
network: media.network,
ids: CatalogItemIds(
trakt: media.ids.trakt,
slug: media.ids.slug,
imdb: media.ids.imdb,
tmdb: media.ids.tmdb,
tvdb: media.ids.tvdb,
),
posterUrl: media.images?.primaryPoster,
backdropUrl: media.images?.primaryBackdrop,
logoUrl: media.images?.primaryLogo,
audience: audience.isEmpty ? null : audience,
broadcast: broadcast.isEmpty ? null : broadcast,
releaseDate: _parseDate(media.released ?? media.firstAired),
addedAt: addedAt,
originalTitle: media.originalTitle,
tagline: media.tagline,
countries: media.country == null || media.country!.isEmpty ? null : [CountryCodes.normalizeCode(media.country!)],
languages: _languagesFor(media),
recommenders: _recommendersFor(media),
);
}
static int? weekdayFor(String? day) => switch (day?.toLowerCase()) {
'monday' => DateTime.monday,
'tuesday' => DateTime.tuesday,
'wednesday' => DateTime.wednesday,
'thursday' => DateTime.thursday,
'friday' => DateTime.friday,
'saturday' => DateTime.saturday,
'sunday' => DateTime.sunday,
_ => null,
};
static String? _broadcastTime(String? time) {
if (time == null || time.length < 5 || time[2] != ':') return null;
final hour = int.tryParse(time.substring(0, 2));
final minute = int.tryParse(time.substring(3, 5));
if (hour == null || hour < 0 || hour > 23 || minute == null || minute < 0 || minute > 59) {
return null;
}
return time.substring(0, 5);
}
static DateTime? _parseDate(String? raw) => raw == null ? null : DateTime.tryParse(raw);
static List<String>? _languagesFor(TraktCatalogMedia media) {
final languages = <String>{
if (media.language case final String language when language.isNotEmpty) language.toLowerCase(),
...?media.languages?.where((language) => language.isNotEmpty).map((language) => language.toLowerCase()),
...?media.availableTranslations
?.where((language) => language.isNotEmpty)
.map((language) => language.toLowerCase()),
};
return languages.isEmpty ? null : languages.toList(growable: false);
}
static List<CatalogRecommender>? _recommendersFor(TraktCatalogMedia media) {
final recommenders = <CatalogRecommender>[
for (final user in media.favoritedBy ?? const <TraktRecommendationUser>[])
if (user.username case final String username when username.isNotEmpty)
CatalogRecommender(
username: username,
name: user.name,
note: user.notes,
reason: CatalogRecommendationReason.favorited,
),
for (final user in media.recommendedBy ?? const <TraktRecommendationUser>[])
if (user.username case final String username when username.isNotEmpty)
CatalogRecommender(
username: username,
name: user.name,
note: user.notes,
reason: CatalogRecommendationReason.recommended,
),
];
return recommenders.isEmpty ? null : recommenders;
}
@override
void dispose() {
+57 -17
View File
@@ -29,17 +29,34 @@ class PlexDiscoverSession {
class PlexDiscoverPage {
final List<Map<String, dynamic>> items;
final bool hasMore;
final int? totalResults;
const PlexDiscoverPage({required this.items, this.hasMore = false});
const PlexDiscoverPage({required this.items, this.hasMore = false, this.totalResults});
}
class PlexDiscoverSearchResult {
final Map<String, dynamic> metadata;
final double? score;
const PlexDiscoverSearchResult({required this.metadata, this.score});
}
class PlexDiscoverHub {
final String id;
final String key;
final String title;
final String? type;
final String? style;
final PlexDiscoverPage page;
const PlexDiscoverHub({required this.id, required this.key, required this.title, required this.page});
const PlexDiscoverHub({
required this.id,
required this.key,
required this.title,
required this.page,
this.type,
this.style,
});
}
class PlexDiscoverException implements Exception {
@@ -79,8 +96,9 @@ class PlexDiscoverClient {
);
final container = _mediaContainer(data!);
final items = flexibleMapList(container['Metadata']);
final total = flexibleInt(container['totalSize']) ?? flexibleInt(container['size']) ?? items.length;
return PlexDiscoverPage(items: items, hasMore: offset + items.length < total);
final reportedTotal = flexibleInt(container['totalSize']);
final total = reportedTotal ?? flexibleInt(container['size']) ?? items.length;
return PlexDiscoverPage(items: items, hasMore: offset + items.length < total, totalResults: reportedTotal);
}
/// Discover's Home shelves — the rows Plex's own web client renders on its
@@ -93,19 +111,24 @@ class PlexDiscoverClient {
/// produce a catalog item are dropped before spending that request:
/// `directory` shelves list browse categories (genre/decade/award) and
/// `clip` shelves list trailers.
Future<List<PlexDiscoverHub>> getHomeHubs({int limit = 25, int concurrency = 6}) async {
Future<List<PlexDiscoverHub>> getHomeHubs({
int limit = 25,
int concurrency = 6,
bool includeImageVariants = false,
}) async {
final safeLimit = limit.clamp(1, 100);
final data = await _request('GET', '/hubs/sections/home', query: {'includeMeta': 1});
final container = _mediaContainer(data!);
final candidates = <({String id, String key, String title})>[];
final candidates = <({String id, String key, String title, String? type, String? style})>[];
final seen = <String>{};
for (final hub in flexibleMapList(container['Hub'])) {
if (_unrenderableHubTypes.contains(hub['type'])) continue;
final type = _nonEmptyString(hub['type']);
if (_unrenderableHubTypes.contains(type)) continue;
final key = _nonEmptyString(hub['key'] ?? hub['hubKey']);
final id = _nonEmptyString(hub['hubIdentifier']) ?? key;
final title = _nonEmptyString(hub['title']);
if (key == null || id == null || title == null || !seen.add(id)) continue;
candidates.add((id: id, key: key, title: title));
candidates.add((id: id, key: key, title: title, type: type, style: _nonEmptyString(hub['style'])));
}
if (candidates.isEmpty) return const [];
@@ -123,7 +146,7 @@ class PlexDiscoverClient {
final candidate = candidates[index];
try {
// One over the shelf size is what tells View All there is more.
pages[index] = await getHub(candidate.key, limit: safeLimit + 1);
pages[index] = await getHub(candidate.key, limit: safeLimit + 1, includeImageVariants: includeImageVariants);
} catch (error, stackTrace) {
firstError ??= error;
firstStackTrace ??= stackTrace;
@@ -144,7 +167,13 @@ class PlexDiscoverClient {
id: candidates[i].id,
key: candidates[i].key,
title: candidates[i].title,
page: PlexDiscoverPage(items: page.items.take(safeLimit).toList(), hasMore: page.items.length > safeLimit),
type: candidates[i].type,
style: candidates[i].style,
page: PlexDiscoverPage(
items: page.items.take(safeLimit).toList(),
hasMore: page.items.length > safeLimit || (page.totalResults != null && page.totalResults! > safeLimit),
totalResults: page.totalResults,
),
),
];
}
@@ -152,23 +181,33 @@ class PlexDiscoverClient {
/// One Discover hub in full. The provider ignores container offsets on hub
/// keys and truncates with `limit` instead, so a hub is always a single
/// page and [PlexDiscoverPage.hasMore] never reports one.
Future<PlexDiscoverPage> getHub(String key, {int limit = 100}) async {
Future<PlexDiscoverPage> getHub(String key, {int limit = 100, bool includeImageVariants = false}) async {
final safeLimit = limit.clamp(1, 500);
final data = await _request(
'GET',
key,
// Streaming parts and image variants roughly double the payload of a
// 25-item shelf and nothing in the catalog layer reads them.
query: {'limit': safeLimit, 'includeMeta': 1, 'excludeElements': 'Media,Image'},
query: {
'limit': safeLimit,
'includeMeta': 1,
'includeUserState': 1,
// Allowing Image on the measured 26-item hub grew 27,287 -> 55,925
// bytes (+104.95%). Discover is uncached and Home hydrates up to six
// hubs concurrently, so spotlight/logo consumers must opt in per
// request and ordinary shelves stay narrow.
'excludeElements': includeImageVariants ? 'Media' : 'Media,Image',
},
);
final container = _mediaContainer(data!);
final hub = firstFlexibleMap(container['Hub']);
final containerItems = flexibleMapList(container['Metadata']);
final rawItems = containerItems.isNotEmpty ? containerItems : flexibleMapList(hub?['Metadata']);
return PlexDiscoverPage(items: rawItems.take(safeLimit).toList());
return PlexDiscoverPage(
items: rawItems.take(safeLimit).toList(),
totalResults: flexibleInt(container['totalSize']),
);
}
Future<List<Map<String, dynamic>>> search(String query, {int limit = 30}) async {
Future<List<PlexDiscoverSearchResult>> search(String query, {int limit = 30}) async {
final data = await _request(
'GET',
'/library/search',
@@ -185,7 +224,8 @@ class PlexDiscoverClient {
return [
for (final group in flexibleMapList(container['SearchResults']))
for (final result in flexibleMapList(group['SearchResult']))
if (firstFlexibleMap(result['Metadata']) case final Map<String, dynamic> metadata) metadata,
if (firstFlexibleMap(result['Metadata']) case final Map<String, dynamic> metadata)
PlexDiscoverSearchResult(metadata: metadata, score: flexibleDouble(result['score'])),
];
}
+7 -3
View File
@@ -3,6 +3,9 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../i18n/app_locale_utils.dart';
import '../../i18n/strings.g.dart';
import '../../models/seerr/seerr_details.dart';
import '../../models/seerr/seerr_media.dart';
import '../../models/seerr/seerr_page.dart';
@@ -103,7 +106,7 @@ class SeerrClient {
/// `/search` — Seerr's TMDB-backed catalog search (mixed results, person
/// entries dropped).
Future<SeerrPage<SeerrMedia>> search(String query, {int page = 1}) async {
final data = await _request('GET', '/search', query: {'query': query, 'page': page});
final data = await _request('GET', '/search', query: {'query': query, 'page': page, 'language': _language});
return _parseMediaPage(data, null);
}
@@ -116,7 +119,7 @@ class SeerrClient {
_mediaPage('/tv/$tmdbId/recommendations', page, 'tv');
Future<SeerrPage<SeerrMedia>> _mediaPage(String path, int page, String? coerceMediaType) async {
final data = await _request('GET', path, query: {'page': page});
final data = await _request('GET', path, query: {'page': page, 'language': _language});
return _parseMediaPage(data, coerceMediaType);
}
@@ -135,7 +138,7 @@ class SeerrClient {
Future<SeerrDetails> getTv(int tmdbId) => _details('/tv/$tmdbId');
Future<SeerrDetails> _details(String path) async {
final data = await _request('GET', path);
final data = await _request('GET', path, query: {'language': _language});
return SeerrDetails.fromJson(data as Map<String, dynamic>);
}
@@ -171,6 +174,7 @@ class SeerrClient {
}
// ---------- Internals ----------
String get _language => LocaleSettings.currentLocale.plexLanguageCode;
Future<dynamic> _request(
String method,
+244 -59
View File
@@ -13,7 +13,14 @@ import '../tracker_http_client.dart';
import '../tracker_session.dart';
import 'anilist_constants.dart';
typedef AnilistCharacter = ({String name, String? role, String? imageUrl});
typedef AnilistRelationEdge = ({String? relationType, AnilistMedia item});
typedef AnilistDetailResponse = ({
AnilistMedia? item,
List<AnilistCharacter> characters,
List<AnilistMedia> recommendations,
List<AnilistRelationEdge> relations,
});
/// GraphQL client for AniList.
///
@@ -21,6 +28,124 @@ typedef AnilistCharacter = ({String name, String? role, String? imageUrl});
/// [onSessionInvalidated] clears it so the user re-auths.
class AnilistClient implements DisposableTrackerClient {
static const String catalogMediaFields = '''
id
idMal
title {
english
romaji
native
userPreferred
}
synonyms
format
status
episodes
duration
description
averageScore
meanScore
popularity
favourites
trending
season
seasonYear
startDate {
year
month
day
}
endDate {
year
month
day
}
genres
isAdult
source
countryOfOrigin
coverImage {
extraLarge
large
color
}
bannerImage
studios(isMain: true) {
nodes {
name
}
}
trailer {
id
site
}
nextAiringEpisode {
episode
airingAt
timeUntilAiring
}
rankings {
rank
type
format
year
season
allTime
context
}
''';
/// Public unauthenticated A/B on 2026-07-29 used the current trending
/// Page query at its real 25-item row size, with identical media ids:
/// 65,586 B without cast and 104,260 B with this six-character connection.
/// That is +38,674 B (+58.97%, 1.59x total) in uncompressed response bytes.
static const String catalogRowCharacterFields =
'''
characters(
page: 1
perPage: ${AnilistConstants.catalogRowCastLimit}
sort: [ROLE, RELEVANCE]
) {
edges {
role
node {
name {
full
}
image {
large
medium
}
}
}
}
''';
static const String catalogRowMediaFields =
'''
$catalogMediaFields
$catalogRowCharacterFields
''';
static const String catalogDetailCharacterFields = '''
characters(page: 1, perPage: \$castPerPage, sort: [ROLE, RELEVANCE]) {
edges {
role
node {
name {
full
}
image {
large
medium
}
}
}
}
''';
/// Recommendation and relation cards keep the previous compact media shape:
/// recursively selecting every row/detail field would multiply nested data.
static const String catalogRelatedMediaFields = '''
id
idMal
title {
@@ -59,6 +184,37 @@ class AnilistClient implements DisposableTrackerClient {
}
''';
/// AniList does not offer tag pagination. Keep its selection leaf-only and
/// retain at most 20 tags in [AnilistMedia]; the variable response array is
/// accepted only on the one-item detail path, never across a 25-50 item row.
static const String catalogDetailMediaFields = '''
tags {
name
rank
isMediaSpoiler
}
externalLinks {
site
url
}
streamingEpisodes {
title
thumbnail
url
site
}
staff(page: 1, perPage: \$staffPerPage) {
edges {
role
node {
name {
full
}
}
}
}
''';
final TrackerSession _session;
final TrackerHttpClient _http;
final void Function() onSessionInvalidated;
@@ -167,16 +323,22 @@ class AnilistClient implements DisposableTrackerClient {
}
Future<AnilistPage> getTrendingAnime({int page = 1, int limit = 25}) =>
_getAnimePage(sort: 'TRENDING_DESC', page: page, limit: limit);
_getAnimePage(sort: 'TRENDING_DESC', page: page, limit: limit, includeRowCast: true);
Future<AnilistPage> getPopularAnime({int page = 1, int limit = 25}) =>
_getAnimePage(sort: 'POPULARITY_DESC', page: page, limit: limit);
_getAnimePage(sort: 'POPULARITY_DESC', page: page, limit: limit, includeRowCast: true);
Future<AnilistPage> getSeasonalAnime(String season, int seasonYear, {int page = 1, int limit = 25}) =>
_getAnimePage(sort: 'POPULARITY_DESC', season: season, seasonYear: seasonYear, page: page, limit: limit);
Future<AnilistPage> getSeasonalAnime(String season, int seasonYear, {int page = 1, int limit = 25}) => _getAnimePage(
sort: 'POPULARITY_DESC',
season: season,
seasonYear: seasonYear,
page: page,
limit: limit,
includeRowCast: true,
);
Future<AnilistPage> searchAnime(String search, {int page = 1, int limit = 30}) =>
_getAnimePage(sort: 'SEARCH_MATCH', search: search, page: page, limit: limit);
_getAnimePage(sort: 'SEARCH_MATCH', search: search, page: page, limit: limit, includeRowCast: false);
Future<AnilistPage> _getAnimePage({
required String sort,
@@ -185,7 +347,9 @@ class AnilistClient implements DisposableTrackerClient {
int? seasonYear,
required int page,
required int limit,
required bool includeRowCast,
}) async {
final fields = includeRowCast ? catalogRowMediaFields : catalogMediaFields;
final mediaQuery =
'''
query(
@@ -208,7 +372,7 @@ class AnilistClient implements DisposableTrackerClient {
season: \$season
seasonYear: \$seasonYear
) {
$catalogMediaFields
$fields
}
}
}
@@ -250,7 +414,7 @@ class AnilistClient implements DisposableTrackerClient {
required int perChunk,
required bool idsOnly,
}) async {
final fields = idsOnly ? 'id idMal' : catalogMediaFields;
final fields = idsOnly ? 'id idMal' : catalogRowMediaFields;
final listQuery =
'''
query(\$userId: Int!, \$chunk: Int, \$perChunk: Int) {
@@ -323,68 +487,73 @@ class AnilistClient implements DisposableTrackerClient {
await query(mutation, variables: {'mediaId': mediaId, 'status': status});
}
Future<List<AnilistCharacter>> getAnimeCharacters(int id, {int limit = 20}) async {
const characterQuery = '''
query(\$id: Int!, \$perPage: Int) {
Media(id: \$id, type: ANIME) {
characters(page: 1, perPage: \$perPage) {
edges {
role
node {
name {
full
}
image {
large
medium
}
}
}
}
}
}
''';
final data = await query(characterQuery, variables: {'id': id, 'perPage': limit.clamp(1, 50).toInt()});
final media = data['Media'];
final characters = media is Map ? media['characters'] : null;
final edges = characters is Map ? characters['edges'] : null;
if (edges is! List) return const [];
return [
for (final edge in edges)
if (edge is Map)
if (edge['node'] case final Map node)
if (node['name'] case final Map name)
if (name['full'] case final String full)
if (full.isNotEmpty)
(
name: full,
role: edge['role'] as String?,
imageUrl: node['image'] is Map
? ((node['image'] as Map)['large'] as String? ?? (node['image'] as Map)['medium'] as String?)
: null,
),
];
}
Future<List<AnilistMedia>> getAnimeRecommendations(int id, {int limit = 20}) async {
final recommendationQuery =
Future<AnilistDetailResponse> getAnimeDetail(
int id, {
int castLimit = 20,
int relatedLimit = 20,
bool includeCharacters = true,
}) async {
final castVariable = includeCharacters ? r'$castPerPage: Int' : '';
final characterFields = includeCharacters ? catalogDetailCharacterFields : '';
final detailQuery =
'''
query(\$id: Int!, \$perPage: Int) {
query(
\$id: Int!
$castVariable
\$relatedPerPage: Int
\$staffPerPage: Int
) {
Media(id: \$id, type: ANIME) {
recommendations(page: 1, perPage: \$perPage) {
$catalogMediaFields
$catalogDetailMediaFields
$characterFields
recommendations(page: 1, perPage: \$relatedPerPage) {
nodes {
mediaRecommendation {
$catalogMediaFields
$catalogRelatedMediaFields
}
}
}
relations(page: 1, perPage: \$relatedPerPage) {
edges {
relationType(version: 2)
node {
$catalogRelatedMediaFields
}
}
}
}
}
''';
final data = await query(recommendationQuery, variables: {'id': id, 'perPage': limit.clamp(1, 50).toInt()});
final data = await query(
detailQuery,
variables: {
'id': id,
if (includeCharacters) 'castPerPage': castLimit.clamp(1, 50).toInt(),
'relatedPerPage': relatedLimit.clamp(1, 50).toInt(),
'staffPerPage': 8,
},
);
final media = data['Media'];
final recommendations = media is Map ? media['recommendations'] : null;
final nodes = recommendations is Map ? recommendations['nodes'] : null;
if (media is! Map<String, dynamic>) {
return (
item: null,
characters: const <AnilistCharacter>[],
recommendations: const <AnilistMedia>[],
relations: const <AnilistRelationEdge>[],
);
}
final item = AnilistMedia.fromJson(media);
return (
item: item,
characters: item.characters ?? const <AnilistCharacter>[],
recommendations: _recommendationsFrom(media['recommendations']),
relations: _relationsFrom(media['relations']),
);
}
static List<AnilistMedia> _recommendationsFrom(Object? value) {
final nodes = value is Map ? value['nodes'] : null;
if (nodes is! List) return const [];
final items = <AnilistMedia>[];
for (final node in nodes) {
@@ -397,6 +566,22 @@ class AnilistClient implements DisposableTrackerClient {
return items;
}
static List<AnilistRelationEdge> _relationsFrom(Object? value) {
final edges = value is Map ? value['edges'] : null;
if (edges is! List) return const [];
final relations = <AnilistRelationEdge>[];
for (final edge in edges) {
if (edge is! Map) continue;
final node = edge['node'];
if (node is! Map<String, dynamic>) continue;
final item = AnilistMedia.fromJson(node);
if (!item.isAdult) {
relations.add((relationType: edge['relationType'] as String?, item: item));
}
}
return relations;
}
Future<Map<String, dynamic>> query(String query, {Map<String, dynamic>? variables}) async {
final uri = Uri.parse(AnilistConstants.apiBase);
final headers = AnilistConstants.headers(accessToken: _session.accessToken);
@@ -7,6 +7,7 @@ class AnilistConstants {
AnilistConstants._();
static const String apiBase = 'https://graphql.anilist.co';
static const int catalogRowCastLimit = 6;
static Map<String, String> headers({String? accessToken}) => {
'Content-Type': 'application/json',
+18 -16
View File
@@ -80,11 +80,21 @@ class MalClient implements DisposableTrackerClient {
}
}
/// Anime summary fields the Explore catalog requests on list endpoints.
/// Anime summary fields shared by every Explore list and nested detail node.
///
/// The compact metadata widening was measured at +9.2% for 25 items
/// (45,895 -> 50,111 bytes) and adds no request.
static const String catalogFields =
'id,title,main_picture,alternative_titles,start_date,synopsis,mean,'
'genres,media_type,rating,num_episodes,average_episode_duration,start_season,'
'status,studios,num_scoring_users';
'status,studios,num_scoring_users,broadcast,popularity,num_list_users,rank,'
'nsfw,source,end_date';
/// The existing detail request widened by +22.3% in the measured sample
/// (18,624 -> 22,772 bytes), still with no additional round trip.
static const String detailFields =
'$catalogFields,recommendations%7B$catalogFields%7D,'
'related_anime%7B$catalogFields%7D,statistics,pictures,background';
static const String _characterFields = 'role,main_picture,first_name,last_name';
@@ -115,20 +125,12 @@ class MalClient implements DisposableTrackerClient {
Future<MalPage<MalAnime>> searchAnime(String query, {int page = 1, int limit = 30}) =>
_getAnimePage('/anime', {'q': query}, page: page, limit: limit);
/// Community "users also liked" titles from the anime detail's
/// `recommendations` field, with the catalog fields selected on the nested
/// nodes (`fields=recommendations{...}` — braces percent-encoded, MAL
/// accepts the nested selector).
Future<List<MalAnime>> getAnimeRecommendations(int animeId, {int limit = 20}) async {
final res = await _request('GET', '/anime/$animeId?fields=recommendations%7B$catalogFields%7D');
if (res is! Map) return const [];
final recommendations = res['recommendations'];
if (recommendations is! List) return const [];
return [
for (final entry in recommendations.take(limit))
if (entry is Map<String, dynamic> && entry['node'] is Map<String, dynamic>)
MalAnime.fromJson(entry['node'] as Map<String, dynamic>),
];
/// The anime detail body, including its community recommendations,
/// franchise relations and status statistics.
Future<MalAnimeDetail?> getAnimeDetail(int animeId, {int relatedLimit = 20}) async {
final res = await _request('GET', '/anime/$animeId?fields=$detailFields');
if (res is! Map) return null;
return MalAnimeDetail.fromJson(res.cast<String, dynamic>(), relatedLimit: relatedLimit);
}
Future<MalPage<MalAnime>> _getAnimePage(
+25 -4
View File
@@ -6,17 +6,28 @@ class MalPage<T> {
final List<T> items;
final bool hasMore;
const MalPage({required this.items, this.hasMore = false});
/// Entry-level `/anime/ranking` positions, aligned with [items].
final List<int?> rankingRanks;
const MalPage({required this.items, this.hasMore = false, this.rankingRanks = const []});
static MalPage<T> fromJson<T>(Object? json, T Function(Map<String, dynamic> node) fromNode) {
if (json is! Map) return MalPage(items: List<T>.empty());
final data = json['data'];
final items = <T>[
final entries = [
if (data is List)
for (final entry in data)
if (entry is Map && entry['node'] is Map) fromNode((entry['node'] as Map).cast<String, dynamic>()),
if (entry is Map && entry['node'] is Map) entry,
];
return MalPage(items: items, hasMore: _hasNext(json));
final items = [for (final entry in entries) fromNode((entry['node'] as Map).cast<String, dynamic>())];
final rankingRanks = [
for (final entry in entries)
switch (entry['ranking']) {
final Map ranking => _positiveInt(ranking['rank']),
_ => null,
},
];
return MalPage(items: items, hasMore: _hasNext(json), rankingRanks: rankingRanks);
}
/// Like [fromJson] but hands the parser the whole `data[]` entry, for
@@ -33,6 +44,16 @@ class MalPage<T> {
return MalPage(items: items, hasMore: _hasNext(json));
}
static int? _positiveInt(Object? value) {
final parsed = switch (value) {
final int number => number,
final num number => number.toInt(),
final String text => int.tryParse(text),
_ => null,
};
return parsed != null && parsed > 0 ? parsed : null;
}
static bool _hasNext(Map<dynamic, dynamic> json) {
final paging = json['paging'];
final next = paging is Map ? paging['next'] : null;
@@ -4,7 +4,7 @@ import 'package:http/http.dart' as http;
import '../../../models/simkl/simkl_all_items_entry.dart';
import '../../../models/simkl/simkl_best_item.dart';
import '../../../models/simkl/simkl_recommendation.dart';
import '../../../models/simkl/simkl_detail.dart';
import '../../../models/simkl/simkl_search_result.dart';
import '../../../models/simkl/simkl_trending_item.dart';
import '../../trakt/trakt_page.dart';
@@ -113,15 +113,9 @@ class SimklClient implements DisposableTrackerClient {
await _request('POST', '/sync/add-to-list', body: body);
}
Future<List<SimklRecommendation>> getRecommendations(SimklCatalogType urlType, int simklId) async {
Future<SimklDetail?> getDetail(SimklCatalogType urlType, int simklId) async {
final decoded = await _request('GET', '/${urlType.detailPath}/$simklId');
if (decoded is! Map) return const [];
final recommendations = decoded['users_recommendations'];
if (recommendations is! List) return const [];
return [
for (final item in recommendations)
if (item is Map<String, dynamic>) SimklRecommendation.fromJson(item),
];
return decoded is Map<String, dynamic> ? SimklDetail.fromJson(decoded) : null;
}
Future<dynamic> _request(
+26 -8
View File
@@ -150,15 +150,20 @@ class TraktClient implements DisposableTrackerClient {
return _decodeMedia(res.body);
}
/// Cast credits of a title (`GET /{movies|shows}/{id}/people`), in billing
/// order. Crew is not parsed. [id] is a Trakt numeric id or slug.
Future<List<TraktCastEntry>> getPeople(TraktCatalogType type, String id) async {
final res = await _requestResponse('GET', '/${type.name}/$id/people?$_catalogExtended');
/// People for a title (`GET /{movies|shows}/{id}/people`). Show requests
/// include `guest_stars`; Trakt documents that this returns every guest star
/// with at least one episode and can be a lot of data, so callers must bound
/// the cast they retain.
Future<TraktPeople> getPeople(TraktCatalogType type, String id) async {
final extended = type == TraktCatalogType.shows ? '$_catalogExtended,guest_stars' : _catalogExtended;
final res = await _requestResponse('GET', '/${type.name}/$id/people?$extended');
final decoded = TrackerHttpClient.decodeJson(res.body);
if (decoded is! Map) return const [];
final cast = decoded['cast'];
if (cast is! List) return const [];
return [for (final e in cast.whereType<Map<String, dynamic>>()) TraktCastEntry.fromJson(e)];
if (decoded is! Map) return const TraktPeople();
return TraktPeople(
cast: _decodePeopleEntries(decoded['cast']),
guestStars: _decodePeopleEntries(decoded['guest_stars']),
crew: _decodeCrewEntries(decoded['crew']),
);
}
/// Body shape: `{"movies":[{"ids":{...}}],"shows":[{"ids":{...}}]}`.
@@ -167,6 +172,19 @@ class TraktClient implements DisposableTrackerClient {
Future<void> removeFromWatchlist(Map<String, dynamic> body) => _request('POST', '/sync/watchlist/remove', body: body);
static List<TraktCastEntry> _decodePeopleEntries(Object? raw) {
if (raw is! List) return const [];
return [for (final entry in raw.whereType<Map<String, dynamic>>()) TraktCastEntry.fromJson(entry)];
}
static List<TraktCastEntry> _decodeCrewEntries(Object? raw) {
if (raw is! Map) return const [];
return [
for (final department in raw.values.whereType<List>())
for (final entry in department.whereType<Map<String, dynamic>>()) TraktCastEntry.fromJson(entry),
];
}
static List<TraktCatalogEntry> _decodeEntries(String body) {
final decoded = TrackerHttpClient.decodeJson(body);
if (decoded is! List) return const [];
+2 -2
View File
@@ -6,7 +6,7 @@ class TraktPage<T> {
final List<T> items;
final int page;
final int pageCount;
final int itemCount;
final int? itemCount;
const TraktPage({required this.items, required this.page, required this.pageCount, required this.itemCount});
@@ -18,6 +18,6 @@ class TraktPage<T> {
items: items,
page: int.tryParse(res.headers['x-pagination-page'] ?? '') ?? 1,
pageCount: int.tryParse(res.headers['x-pagination-page-count'] ?? '') ?? 1,
itemCount: int.tryParse(res.headers['x-pagination-item-count'] ?? '') ?? items.length,
itemCount: int.tryParse(res.headers['x-pagination-item-count'] ?? ''),
);
}
+42
View File
@@ -0,0 +1,42 @@
import '../data/iso_3166_data.dart';
/// Resolves ISO 3166-1 alpha-2 country codes to display names, mirroring
/// [LanguageCodes] for the country dimension.
///
/// Catalog providers return production countries as bare codes (Plex
/// `Country`, Simkl `country`, AniList `countryOfOrigin`, TMDB
/// `origin_country`). Names are English-only for the same reason the ISO 639
/// catalog is: no localized catalog ships with the app.
class CountryCodes {
/// Display name for [code], or the normalized code when it is not a known
/// alpha-2 value. Returning the code is deliberate — `SU` is more useful to
/// a reader than an empty chip.
static String getDisplayName(String code) => getCountryName(code) ?? code.toUpperCase().trim();
/// Display name for [code], or null when it is not a known alpha-2 value.
static String? getCountryName(String code) {
final normalized = code.toUpperCase().trim();
if (normalized.length != 2) return null;
return countryNames[normalized];
}
/// Alpha-2 code for a country [name], or null when unrecognized.
///
/// Providers are inconsistent about which side of this mapping they send:
/// Plex Discover returns `Country` tags as display names such as
/// `United States of America`, while TMDB and AniList send codes. The index
/// covers official ISO forms, the readable short forms, and colloquial
/// names.
static String? getCountryCode(String name) {
final normalized = name.trim();
if (normalized.isEmpty) return null;
if (normalized.length == 2 && countryNames.containsKey(normalized.toUpperCase())) {
return normalized.toUpperCase();
}
return countryCodesByName[normalized.toLowerCase()];
}
/// Best-effort alpha-2 for a provider value that may be either a code or a
/// name. Falls back to the trimmed uppercase input so nothing is dropped.
static String normalizeCode(String value) => getCountryCode(value) ?? value.toUpperCase().trim();
}
+15
View File
@@ -146,3 +146,18 @@ List<String>? stringListFromRaw(Object? raw, {String? mapKey, bool stringify = f
}
List<T>? nullIfEmptyList<T>(List<T> values) => values.isEmpty ? null : values;
/// First entry that is neither null nor blank after trimming, else null.
///
/// Remote APIs distinguish "absent" from "present but empty" inconsistently.
/// TMDB in particular answers a `language=` query for an untranslated title or
/// overview with an empty string rather than omitting the field, so `??`
/// fallback chains silently select the blank. Use this where a localized value
/// must degrade to an untranslated one.
String? firstNonBlank(Iterable<String?> values) {
for (final value in values) {
final trimmed = value?.trim();
if (trimmed != null && trimmed.isNotEmpty) return trimmed;
}
return null;
}
+28
View File
@@ -0,0 +1,28 @@
/// Builds watch URLs for provider trailer references.
///
/// Four catalog providers return a bare YouTube video id (AniList
/// `trailer.id`, Simkl `trailer`, Seerr/TMDB `videos[].key`) and each used to
/// interpolate the same watch URL, so the host lived in four places.
///
/// The host is deliberately absent from `android/app/src/main/res/xml/
/// network_security_config.xml`: these URLs are handed to the platform browser
/// through `url_launcher` and are never fetched by the app's own HTTP stack,
/// so they are not fixed endpoints to pin. `test/android/
/// network_security_config_test.dart` enforces that distinction for the
/// services that *do* make requests.
library;
const _youTubeWatchPrefix = 'https://www.youtube.com/watch?v=';
/// Resolves a provider trailer reference to an absolute URL.
///
/// Accepts either a bare YouTube video id or an already-absolute URL, since
/// providers are inconsistent about which they send — Seerr returns a full
/// `url` on some entries and only a `key` on others. Returns null for a
/// missing or blank reference.
String? youTubeTrailerUrl(String? reference) {
final value = reference?.trim();
if (value == null || value.isEmpty) return null;
if (Uri.tryParse(value)?.hasScheme == true) return value;
return '$_youTubeWatchPrefix$value';
}
+46 -4
View File
@@ -3,15 +3,27 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../i18n/strings.g.dart';
import '../models/catalog/catalog_item.dart';
import '../providers/catalog_sources_provider.dart';
import '../utils/catalog_navigation_helper.dart';
import '../utils/app_logger.dart';
import '../utils/snackbar_helper.dart';
import 'app_menu.dart';
enum _CatalogMenuAction { viewDetails, toggleWatchlist }
enum _CatalogMenuActionType { viewDetails, toggleWatchlist, openUrl }
class _CatalogMenuAction {
final _CatalogMenuActionType type;
final String? url;
const _CatalogMenuAction(this.type, {this.url});
static const viewDetails = _CatalogMenuAction(_CatalogMenuActionType.viewDetails);
static const toggleWatchlist = _CatalogMenuAction(_CatalogMenuActionType.toggleWatchlist);
}
/// Watchlist mutations keyed by source+item so a re-opened menu can't
/// double-fire while one is still in flight (the detail screens keep their
@@ -45,6 +57,19 @@ Future<void> showCatalogItemMenu(BuildContext context, CatalogItem item, {Offset
focusFirstItem: position == null,
entries: [
AppMenuItem(value: _CatalogMenuAction.viewDetails, label: t.mediaMenu.viewDetails, icon: Symbols.info_rounded),
if (item.trailerUrl case final trailerUrl? when trailerUrl.isNotEmpty)
AppMenuItem(
value: _CatalogMenuAction(_CatalogMenuActionType.openUrl, url: trailerUrl),
label: t.explore.detail.watchTrailer,
icon: Symbols.play_circle_rounded,
),
for (final link in item.links ?? const [])
if (link.label.isNotEmpty && link.url.isNotEmpty)
AppMenuItem(
value: _CatalogMenuAction(_CatalogMenuActionType.openUrl, url: link.url),
label: t.explore.detail.openOn(site: link.label),
icon: Symbols.open_in_new_rounded,
),
if (onWatchlist != null)
AppMenuItem(
value: _CatalogMenuAction.toggleWatchlist,
@@ -55,10 +80,10 @@ Future<void> showCatalogItemMenu(BuildContext context, CatalogItem item, {Offset
);
if (action == null || !context.mounted) return;
switch (action) {
case _CatalogMenuAction.viewDetails:
switch (action.type) {
case _CatalogMenuActionType.viewDetails:
await navigateToCatalogItem(context, item);
case _CatalogMenuAction.toggleWatchlist:
case _CatalogMenuActionType.toggleWatchlist:
// Re-read membership: it can have changed while the menu was open
// (snapshot load, another surface's toggle).
final current = source!.isOnWatchlist(item.kind, item.ids) ?? onWatchlist ?? false;
@@ -75,5 +100,22 @@ Future<void> showCatalogItemMenu(BuildContext context, CatalogItem item, {Offset
} finally {
_watchlistMutationsInFlight.remove(mutationKey);
}
case _CatalogMenuActionType.openUrl:
final url = action.url;
if (url != null) await _launchCatalogUrl(url);
}
}
Future<void> _launchCatalogUrl(String value) async {
final uri = Uri.tryParse(value);
if (uri == null || (uri.scheme != 'http' && uri.scheme != 'https')) {
appLogger.w('Catalog context menu ignored an invalid external URL');
return;
}
try {
final launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!launched) appLogger.w('Catalog context menu could not launch an external URL');
} catch (error, stackTrace) {
appLogger.w('Catalog context menu failed to launch an external URL', error: error, stackTrace: stackTrace);
}
}
+16
View File
@@ -55,6 +55,9 @@ class HubSection extends StatefulWidget {
final bool showServerName;
final Future<List<MediaItem>> Function()? loadMoreItems;
/// Provider-reported result count shown alongside the existing hub title.
final int? totalResults;
/// Reports the current focused media item. Used by TV spotlight layouts.
final ValueChanged<MediaItem>? onFocusedItemChanged;
@@ -103,6 +106,7 @@ class HubSection extends StatefulWidget {
bool? usesContinueWatchingAction,
this.showServerName = false,
this.loadMoreItems,
this.totalResults,
this.onFocusedItemChanged,
this.onItemTap,
this.onItemLongPress,
@@ -447,6 +451,18 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
Flexible(
child: Text(widget.hub.title, style: titleStyle, overflow: .ellipsis, maxLines: 1),
),
if (widget.totalResults case final totalResults?) ...[
SizedBox(width: isTv ? 12 : 8),
Text(
t.explore.totalResults(n: totalResults),
maxLines: 1,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.62),
fontSize: isTv ? 17 : null,
fontWeight: FontWeight.w600,
),
),
],
if (widget.showServerName && widget.hub.serverName != null) ...[
const SizedBox(width: 8),
Text(
+392 -65
View File
@@ -3,11 +3,14 @@ import 'dart:ui';
import '../media/ids.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../focus/card_focus_scope.dart';
import '../focus/focus_theme.dart';
import '../focus/input_mode_tracker.dart';
import '../i18n/app_locale_utils.dart';
import '../media/catalog_item_ref.dart';
import '../media/media_item.dart';
import '../media/media_item_types.dart';
@@ -15,6 +18,7 @@ import '../media/media_kind.dart';
import '../media/media_playlist.dart';
import '../mixins/context_menu_tap_mixin.dart';
import '../models/catalog/catalog_item.dart';
import '../models/catalog/catalog_metadata.dart';
import '../providers/download_provider.dart';
import '../providers/watch_state_store.dart';
import '../services/download_storage_service.dart';
@@ -50,6 +54,86 @@ void _rememberFailedPosterUrl(String? url) {
}
}
const int _catalogBadgeLimit = 3;
String? _compactNumberLocale;
NumberFormat? _compactNumberFormatter;
String? _percentNumberLocale;
NumberFormat? _percentNumberFormatter;
String _formatCompactCatalogNumber(num value) {
final locale = LocaleSettings.currentLocale.intlLocaleName;
if (_compactNumberFormatter == null || _compactNumberLocale != locale) {
_compactNumberLocale = locale;
_compactNumberFormatter = NumberFormat.compact(locale: locale);
}
return _compactNumberFormatter!.format(value);
}
String _formatCatalogPercent(double value) {
final locale = LocaleSettings.currentLocale.intlLocaleName;
if (_percentNumberFormatter == null || _percentNumberLocale != locale) {
_percentNumberLocale = locale;
_percentNumberFormatter = NumberFormat.percentPattern(locale);
}
return _percentNumberFormatter!.format(value);
}
/// Composes the "PG-13 • 2006 • 2h 10min • 7.7★" line under a card.
///
/// [compact] is for the poster grid, where the line is one ellipsized row a
/// third of the screen wide. Measured on a Pixel 7, the full order spends the
/// whole row on `PG-13 • 2006 • 2h 10mi…` and truncates the rating away —
/// the one value this line exists to show. The compact form therefore leads
/// with the rating and drops certification, edition, genres and studio, all
/// of which the detail screen and the search list still render in full.
String _buildMediaMetadataLine(MediaItem item, {CatalogItem? catalogItem, bool compact = false}) {
final parts = <String>[];
if (item.kind == MediaKind.collection) {
final count = item.childCount ?? item.leafCount;
if (count != null && count > 0) {
parts.add(t.playlists.itemCount(count: count));
}
return parts.join('');
}
String? ratingPart() {
if (item.rating case final rating?) {
final votes = compact ? null : catalogItem?.votes;
final voteSuffix = votes != null && votes > 0 ? ' (${_formatCompactCatalogNumber(votes)})' : '';
return '${formatRating(rating)}$voteSuffix';
}
return null;
}
if (compact) {
if (ratingPart() case final rating?) parts.add(rating);
if (item.year case final year?) parts.add('$year');
if (item.durationMs case final durationMs?) parts.add(formatDurationTextual(durationMs));
return parts.join('');
}
if (item.contentRating case final contentRating? when contentRating.isNotEmpty) {
final formatted = formatContentRating(contentRating);
if (formatted.isNotEmpty) parts.add(formatted);
}
if (item.year case final year?) parts.add('$year');
if (item.editionTitle case final editionTitle?) parts.add(editionTitle);
if (item.durationMs case final durationMs?) parts.add(formatDurationTextual(durationMs));
if (ratingPart() case final rating?) parts.add(rating);
if (catalogItem != null) {
final genres = catalogItem.genres;
if (genres != null && genres.isNotEmpty) {
parts.add(genres.length == 1 ? genres.first : '${genres.first}, ${genres[1]}');
}
}
final studio = item.studio ?? catalogItem?.network;
if (studio != null && studio.isNotEmpty) parts.add(studio);
return parts.join('');
}
/// The single announcement used for a media card.
///
/// TV browse rails also use this label for their fixed semantic selection
@@ -186,9 +270,26 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
_handleTap(context, _effectiveItemForAction(context));
}
CatalogItem? get _catalogItem {
final item = widget.item;
return item is MediaItem && item.isCatalogItem ? item.catalogItem : null;
CatalogItem? _cachedCatalogItem;
Color? _catalogAccent;
CatalogItem? get _catalogItem => _cachedCatalogItem;
@override
void initState() {
super.initState();
_cacheCatalogItem(widget.item);
}
@override
void didUpdateWidget(covariant MediaCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.item, widget.item)) _cacheCatalogItem(widget.item);
}
void _cacheCatalogItem(Object item) {
_cachedCatalogItem = item is MediaItem ? item.catalogItem : null;
_catalogAccent = _parseCatalogAccent(_cachedCatalogItem?.accentColor);
}
// Catalog stand-ins get the catalog menu at the same seams (long-press,
@@ -327,6 +428,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
showServerName: widget.showServerName,
episodePosterModeOverride: widget.episodePosterModeOverride,
cardShapeOverride: widget.cardShapeOverride,
catalogItem: _catalogItem,
enableDetailLinks: enableDetailLinks,
);
@@ -375,18 +477,30 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
String? localPosterPath, {
required bool preserveDetailSemantics,
}) {
final catalogItem = _catalogItem;
final now = catalogItem?.nextEpisode == null ? null : DateTime.now();
final badgeLabels = _buildCatalogBadgeLabels(catalogItem, now);
final Widget card;
if (widget.fullBleedImage) {
card = LayoutBuilder(
builder: (context, constraints) {
final cardWidth = widget.width ?? (constraints.hasBoundedWidth ? constraints.maxWidth : null);
final cardHeight = widget.height ?? (constraints.hasBoundedHeight ? constraints.maxHeight : null);
if (cardHeight == null) return _buildStandardGridCard(context, item, localPosterPath);
return _buildFullBleedGridCard(context, item, localPosterPath, width: cardWidth, height: cardHeight);
if (cardHeight == null) {
return _buildStandardGridCard(context, item, localPosterPath, badgeLabels: badgeLabels);
}
return _buildFullBleedGridCard(
context,
item,
localPosterPath,
width: cardWidth,
height: cardHeight,
badgeLabels: badgeLabels,
);
},
);
} else {
card = _buildStandardGridCard(context, item, localPosterPath);
card = _buildStandardGridCard(context, item, localPosterPath, badgeLabels: badgeLabels);
}
return preserveDetailSemantics ? card : MergeSemantics(child: card);
@@ -398,6 +512,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
String? localPosterPath, {
required double? width,
required double height,
required List<String> badgeLabels,
}) {
return SizedBox(
width: width,
@@ -410,7 +525,8 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
onSecondaryTap: showContextMenuFromTap,
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: ExcludeSemantics(
child: CardFocusBorder(
child: _CatalogFocusBorder(
accentColor: _catalogAccent,
borderRadius: _posterFocusRadius(context, item),
child: _clipPosterImage(
context,
@@ -426,11 +542,13 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
mixedHubContext: widget.mixedHubContext,
episodePosterModeOverride: widget.episodePosterModeOverride,
cardShapeOverride: widget.cardShapeOverride,
catalogItem: _catalogItem,
knownWidth: width,
knownHeight: height,
artworkDim: widget.artworkDim,
),
if (item is MediaItem && _showsWatchedIndicator(item)) WatchedIndicator(item: item),
if (badgeLabels.isNotEmpty) _CatalogBadges(labels: badgeLabels),
],
),
),
@@ -440,7 +558,12 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
);
}
Widget _buildStandardGridCard(BuildContext context, Object item, String? localPosterPath) {
Widget _buildStandardGridCard(
BuildContext context,
Object item,
String? localPosterPath, {
required List<String> badgeLabels,
}) {
// Compute actual poster dimensions from card dimensions
final posterWidth = widget.width != null ? widget.width! - 6 : null;
final posterHeight = widget.height;
@@ -448,7 +571,8 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
// The focus border hugs the poster (captions stay outside it), matching
// the full-bleed card treatment.
final poster = ExcludeSemantics(
child: CardFocusBorder(
child: _CatalogFocusBorder(
accentColor: _catalogAccent,
borderRadius: _posterFocusRadius(context, item),
child: Stack(
children: [
@@ -463,12 +587,14 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
mixedHubContext: widget.mixedHubContext,
episodePosterModeOverride: widget.episodePosterModeOverride,
cardShapeOverride: widget.cardShapeOverride,
knownWidth: posterHeight != null ? posterWidth : null,
catalogItem: _catalogItem,
knownWidth: _catalogItem != null ? posterWidth : (posterHeight != null ? posterWidth : null),
knownHeight: posterHeight,
artworkDim: widget.artworkDim,
),
),
if (item is MediaItem && _showsWatchedIndicator(item)) WatchedIndicator(item: item),
if (badgeLabels.isNotEmpty) _CatalogBadges(labels: badgeLabels),
],
),
),
@@ -520,6 +646,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
item,
isOffline: widget.isOffline,
enableDetailLinks: widget.onTap == null,
catalogItem: _catalogItem,
),
],
),
@@ -544,6 +671,7 @@ class _MediaCardList extends StatelessWidget {
final EpisodePosterMode? episodePosterModeOverride;
final CardShape? cardShapeOverride;
final bool enableDetailLinks;
final CatalogItem? catalogItem;
const _MediaCardList({
required this.item,
@@ -558,6 +686,7 @@ class _MediaCardList extends StatelessWidget {
this.showServerName = false,
this.episodePosterModeOverride,
this.cardShapeOverride,
this.catalogItem,
required this.enableDetailLinks,
});
@@ -587,60 +716,19 @@ class _MediaCardList extends StatelessWidget {
int get _summaryMaxLines => density <= 2 ? 2 : density; // 2, 2, 3, 4, 5
String _buildMetadataLine() {
final parts = <String>[];
if (item is MediaPlaylist) {
final playlist = item as MediaPlaylist;
if (playlist.leafCount != null && playlist.leafCount! > 0) {
parts.add(t.playlists.itemCount(count: playlist.leafCount!));
final current = item;
if (current is MediaPlaylist) {
final parts = <String>[];
if (current.leafCount != null && current.leafCount! > 0) {
parts.add(t.playlists.itemCount(count: current.leafCount!));
}
if (playlist.durationMs != null) {
parts.add(formatDurationTextual(playlist.durationMs!));
}
if (playlist.smart) {
parts.add(t.playlists.smartPlaylist);
}
} else if (item is MediaItem) {
final mi = item as MediaItem;
if (mi.kind == MediaKind.collection) {
final count = mi.childCount ?? mi.leafCount;
if (count != null && count > 0) {
parts.add(t.playlists.itemCount(count: count));
}
} else {
if (mi.contentRating != null && mi.contentRating!.isNotEmpty) {
final rating = formatContentRating(mi.contentRating);
if (rating.isNotEmpty) {
parts.add(rating);
}
}
if (mi.year != null) {
parts.add('${mi.year}');
}
if (mi.editionTitle case final editionTitle?) {
parts.add(editionTitle);
}
if (mi.durationMs != null) {
parts.add(formatDurationTextual(mi.durationMs!));
}
if (mi.rating != null) {
parts.add('${formatRating(mi.rating!)}');
}
if (mi.studio != null && mi.studio!.isNotEmpty) {
parts.add(mi.studio!);
}
if (current.durationMs case final durationMs?) {
parts.add(formatDurationTextual(durationMs));
}
if (current.smart) parts.add(t.playlists.smartPlaylist);
return parts.join('');
}
return parts.join('');
return current is MediaItem ? _buildMediaMetadataLine(current, catalogItem: catalogItem) : '';
}
String? _buildSubtitleText() {
@@ -725,6 +813,9 @@ class _MediaCardList extends StatelessWidget {
localPosterPath: localPosterPath,
episodePosterModeOverride: episodePosterModeOverride,
cardShapeOverride: cardShapeOverride,
catalogItem: catalogItem,
knownWidth: catalogItem == null ? null : _posterWidth(),
knownHeight: catalogItem == null ? null : _posterHeight(),
),
),
if (item is MediaItem && _showsWatchedIndicator(item as MediaItem))
@@ -886,6 +977,7 @@ Widget _buildPosterImage(
bool mixedHubContext = false,
EpisodePosterMode? episodePosterModeOverride,
CardShape? cardShapeOverride,
CatalogItem? catalogItem,
double? knownWidth,
double? knownHeight,
Animation<double>? artworkDim,
@@ -909,10 +1001,6 @@ Widget _buildPosterImage(
final hideSpoilers = SettingsService.instance.read(SettingsService.hideSpoilers);
final shouldBlur =
hideSpoilers && item.shouldHideSpoiler && episodePosterMode == EpisodePosterMode.episodeThumbnail;
final primaryPosterUrl = item.posterThumb(mode: episodePosterMode, mixedHubContext: mixedHubContext);
final posterFallbackUrl = item.posterThumbFallback(mode: episodePosterMode, mixedHubContext: mixedHubContext);
final useRememberedFallback = posterFallbackUrl != null && _hasFailedPosterUrl(primaryPosterUrl);
final posterUrl = useRememberedFallback ? posterFallbackUrl : primaryPosterUrl;
final mediaClient = isOffline ? null : context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId));
final fallbackIcon = _mediaPosterFallbackIcon(item);
final imageType = switch (cardShapeOverride) {
@@ -921,6 +1009,22 @@ Widget _buildPosterImage(
CardShape.poster => ImageType.poster,
null => MediaImageHelper.cardImageType(item, episodePosterMode, mixedHubContext: mixedHubContext),
};
final defaultPosterUrl = item.posterThumb(mode: episodePosterMode, mixedHubContext: mixedHubContext);
final defaultFallbackUrl = item.posterThumbFallback(mode: episodePosterMode, mixedHubContext: mixedHubContext);
final targetPx = knownWidth != null && knownWidth.isFinite && knownWidth > 0
? (knownWidth * MediaQuery.devicePixelRatioOf(context)).ceil()
: null;
final catalogArtworkUrl = targetPx == null
? null
: imageType == ImageType.thumb
? catalogItem?.backdropFor(targetPx)
: catalogItem?.posterFor(targetPx);
final primaryPosterUrl = catalogArtworkUrl ?? defaultPosterUrl;
final posterFallbackUrl = catalogArtworkUrl != null && catalogArtworkUrl != defaultPosterUrl
? defaultPosterUrl ?? defaultFallbackUrl
: defaultFallbackUrl;
final useRememberedFallback = posterFallbackUrl != null && _hasFailedPosterUrl(primaryPosterUrl);
final posterUrl = useRememberedFallback ? posterFallbackUrl : primaryPosterUrl;
OptimizedMediaImage buildImage(
String? path,
@@ -1012,11 +1116,21 @@ class _MediaCardHelpers {
MediaItem mi, {
bool isOffline = false,
bool enableDetailLinks = true,
CatalogItem? catalogItem,
}) {
final subtitleStyle = Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 11, height: 1.1);
if (catalogItem != null) {
final metadata = _buildMediaMetadataLine(mi, catalogItem: catalogItem, compact: true);
if (metadata.isNotEmpty) {
return ExcludeSemantics(
child: Text(metadata, maxLines: 1, overflow: .ellipsis, style: subtitleStyle),
);
}
}
// For collections, show item count
if (mi.kind == MediaKind.collection) {
final count = mi.childCount ?? mi.leafCount;
@@ -1096,6 +1210,219 @@ class _MediaCardHelpers {
}
}
Color? _parseCatalogAccent(String? value) {
if (value == null || value.length != 7 || value.codeUnitAt(0) != 0x23) return null;
final rgb = int.tryParse(value.substring(1), radix: 16);
return rgb == null ? null : Color(0xff000000 | rgb);
}
String _catalogSeasonName(CatalogSeasonName season) => switch (season) {
CatalogSeasonName.winter => t.explore.season.winter,
CatalogSeasonName.spring => t.explore.season.spring,
CatalogSeasonName.summer => t.explore.season.summer,
CatalogSeasonName.fall => t.explore.season.fall,
};
String? _catalogRankBadge(CatalogItem item) {
String? allTimeLabel;
for (final rank in item.ranks ?? const <CatalogRank>[]) {
final contextual = !rank.allTime || rank.scope == CatalogRankScope.seasonal;
if (contextual) {
final season = rank.season;
final year = rank.year;
if (season == null && year == null) continue;
final seasonLabel = season == null
? '$year'
: year == null
? _catalogSeasonName(season)
: t.explore.season.withYear(season: _catalogSeasonName(season), year: year);
return t.explore.badge.rankSeasonal(n: rank.rank, season: seasonLabel);
}
allTimeLabel ??= switch (rank.scope) {
CatalogRankScope.popular => t.explore.badge.rankPopular(n: rank.rank),
CatalogRankScope.airing => t.explore.badge.rankAiring(n: rank.rank),
CatalogRankScope.rated => t.explore.badge.rankRated(n: rank.rank),
CatalogRankScope.favorited => t.explore.badge.rankFavorited(n: rank.rank),
CatalogRankScope.trending => t.explore.badge.rankTrending(n: rank.rank),
CatalogRankScope.seasonal => null,
};
}
return allTimeLabel;
}
String? _catalogAvailabilityBadge(CatalogServerState? state) {
if (state == null) return null;
if (state.availability4k == CatalogAvailability.available) return t.explore.badge.availableIn4k;
if (state.availability == CatalogAvailability.available) return t.explore.badge.available;
final availableSeasons = state.availableSeasons;
final totalSeasons = state.totalSeasons;
if (availableSeasons != null && totalSeasons != null && availableSeasons > 0 && totalSeasons > 0) {
return t.explore.badge.seasonsAvailable(available: availableSeasons, total: totalSeasons);
}
if (state.availability == CatalogAvailability.partiallyAvailable ||
state.availability4k == CatalogAvailability.partiallyAvailable) {
return t.explore.badge.partiallyAvailable;
}
return null;
}
String? _catalogRequestBadge(CatalogServerState? state) {
if (state == null) return null;
final is4k = state.request4k != null;
final request = state.request4k ?? state.request;
return switch (request) {
CatalogRequestState.pending => t.explore.badge.pendingApproval,
CatalogRequestState.approved => is4k ? t.explore.badge.requested4k : t.explore.badge.requested,
CatalogRequestState.processing => t.explore.badge.processing,
CatalogRequestState.declined => t.explore.badge.declined,
CatalogRequestState.failed => t.explore.badge.requestFailed,
null => null,
};
}
String? _catalogNextEpisodeBadge(CatalogItem item, DateTime? now) {
final nextEpisode = item.nextEpisode;
if (nextEpisode == null || now == null || !nextEpisode.airsAt.isAfter(now)) return null;
final duration = formatDurationTextual(nextEpisode.timeUntil(now).inMilliseconds);
final episode = nextEpisode.episode;
return episode == null
? t.explore.badge.nextAiringIn(duration: duration)
: t.explore.badge.nextEpisodeIn(episode: episode, duration: duration);
}
String? _catalogViewersBadge(CatalogAudience? audience) {
final viewers = audience?.viewers;
final period = audience?.viewersPeriod;
if (viewers == null || viewers <= 0 || period == null) return null;
final count = _formatCompactCatalogNumber(viewers);
return switch (period) {
CatalogAudiencePeriod.day => t.explore.stats.viewersDay(n: count),
CatalogAudiencePeriod.week => t.explore.stats.viewersWeek(n: count),
CatalogAudiencePeriod.month => t.explore.stats.viewersMonth(n: count),
CatalogAudiencePeriod.year => t.explore.stats.viewersYear(n: count),
CatalogAudiencePeriod.allTime => t.explore.stats.viewersAllTime(n: count),
};
}
String? _catalogEpisodeBadge(CatalogItem item) {
if (item.kind != MediaKind.show) return null;
final episodeCount = item.episodeCount;
final runtimeMinutes = item.runtimeMinutes;
final parts = <String>[
if (episodeCount != null && episodeCount > 0) t.explore.badge.episodesShort(n: episodeCount),
if (runtimeMinutes != null && runtimeMinutes > 0) t.explore.badge.minutesPerEpisode(n: runtimeMinutes),
];
return parts.isEmpty ? null : parts.join('');
}
List<String> _buildCatalogBadgeLabels(CatalogItem? item, DateTime? now) {
if (item == null) return const [];
final labels = <String>[];
void add(String? label) {
if (label != null && label.isNotEmpty && labels.length < _catalogBadgeLimit) labels.add(label);
}
// Server availability and request state are independent, so each gets its
// own high-priority slot. This preserves cases such as HD available plus a
// pending 4K request instead of collapsing them into one misleading ladder.
add(_catalogAvailabilityBadge(item.serverState));
add(_catalogRequestBadge(item.serverState));
final recommendationCount = item.recommendationCount;
if (recommendationCount != null && recommendationCount > 0) {
add(t.explore.detail.recommendedByUsers(n: recommendationCount));
} else {
final recommendationPercent = item.recommendationPercent;
if (recommendationPercent != null && recommendationPercent > 0 && recommendationPercent <= 1) {
add(t.explore.detail.recommendedByPercent(percent: _formatCatalogPercent(recommendationPercent)));
}
}
// Row-specific provenance follows server state, then safety, airing,
// leaderboard, live audience, windowed audience, and episodic shape.
if (item.isAdult == true) add(t.explore.badge.adult);
add(_catalogNextEpisodeBadge(item, now));
add(_catalogRankBadge(item));
final audience = item.audience;
final watchingNow = audience?.watchingNow;
if (watchingNow != null && watchingNow > 0) {
add(t.explore.badge.watchingNow(n: _formatCompactCatalogNumber(watchingNow)));
}
add(_catalogViewersBadge(audience));
add(_catalogEpisodeBadge(item));
return labels;
}
class _CatalogBadges extends StatelessWidget {
final List<String> labels;
const _CatalogBadges({required this.labels});
@override
Widget build(BuildContext context) {
return Positioned(
key: const Key('catalog-badges'),
top: 6,
left: 6,
right: 6,
child: Column(
crossAxisAlignment: .start,
children: [
for (var index = 0; index < labels.length; index++) ...[
if (index > 0) const SizedBox(height: 3),
DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.76),
borderRadius: BorderRadius.circular(4),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3),
child: Text(
labels[index],
maxLines: 1,
overflow: .ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 9, fontWeight: .w700, height: 1),
),
),
),
],
],
),
);
}
}
class _CatalogFocusBorder extends StatelessWidget {
final Color? accentColor;
final double borderRadius;
final Widget child;
const _CatalogFocusBorder({required this.accentColor, required this.borderRadius, required this.child});
@override
Widget build(BuildContext context) {
final color = accentColor;
if (color == null) return CardFocusBorder(borderRadius: borderRadius, child: child);
final showFocus = CardFocusScope.maybeOf(context);
if (showFocus == null) return child;
return AnimatedContainer(
duration: FocusTheme.getAnimationDuration(context),
curve: Curves.easeOutCubic,
foregroundDecoration: FocusTheme.focusDecoration(
context,
isFocused: showFocus,
borderRadius: borderRadius,
borderStrokeAlign: BorderSide.strokeAlignOutside,
color: color,
),
child: child,
);
}
}
/// "S# E# · Episode title" with the season number linking to the season.
Widget _buildEpisodeSubtitleRow(
BuildContext context,
+5
View File
@@ -37,6 +37,9 @@ class TvSpotlightBackground extends StatelessWidget {
final String? Function(String? artworkPath)? localArtworkPathResolver;
final bool allowNetwork;
/// Optional caller-owned fact appended to the existing metadata line.
final Widget? metadataTrailing;
const TvSpotlightBackground({
super.key,
required this.item,
@@ -52,6 +55,7 @@ class TvSpotlightBackground extends StatelessWidget {
this.showInfo = true,
this.localArtworkPathResolver,
this.allowNetwork = true,
this.metadataTrailing,
});
double _scale(BuildContext context) => TvLayoutConstants.scaleOf(context);
@@ -341,6 +345,7 @@ class TvSpotlightBackground extends StatelessWidget {
} else if (media.year != null) {
addTextPart(media.year.toString());
}
if (metadataTrailing case final metadata?) addWidgetPart(metadata);
if (children.isEmpty) return const SizedBox.shrink();
+130 -3
View File
@@ -1,13 +1,17 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../i18n/strings.g.dart';
import '../media/catalog_item_ref.dart';
import '../media/media_hub.dart';
import '../media/media_item.dart';
import '../media/media_server_client.dart';
import '../models/catalog/catalog_item.dart';
import '../navigation/main_screen_scope.dart';
import '../services/settings_service.dart';
import '../utils/debouncer.dart';
import '../utils/layout_constants.dart';
import '../utils/formatters.dart';
import 'tv_browse_rail.dart';
import 'tv_spotlight_background.dart';
@@ -118,15 +122,14 @@ class TvSpotlightScaffold extends StatelessWidget {
valueListenable: spotlightListenable,
builder: (context, _, _) {
final spotlight = resolveSpotlight();
return TvSpotlightBackground(
return _CatalogSpotlightBackground(
item: spotlight,
client: resolveClient(spotlight),
hideSpoilers: hideSpoilers ?? settings.read(SettingsService.hideSpoilers),
contentTop: spotlightTop,
contentBottom: spotlightBottom,
contentLeft: spotlightLeft + foregroundLeft,
compact: true,
showPrimaryAction: false,
targetWidthPx: (size.width * MediaQuery.devicePixelRatioOf(context)).ceil(),
);
},
),
@@ -143,6 +146,130 @@ class TvSpotlightScaffold extends StatelessWidget {
}
}
class _CatalogSpotlightBackground extends StatefulWidget {
const _CatalogSpotlightBackground({
required this.item,
required this.client,
required this.hideSpoilers,
required this.contentTop,
required this.contentBottom,
required this.contentLeft,
required this.targetWidthPx,
});
final MediaItem? item;
final MediaServerClient? client;
final bool hideSpoilers;
final double contentTop;
final double contentBottom;
final double contentLeft;
final int targetWidthPx;
@override
State<_CatalogSpotlightBackground> createState() => _CatalogSpotlightBackgroundState();
}
class _CatalogSpotlightBackgroundState extends State<_CatalogSpotlightBackground> {
CatalogItem? _catalogItem;
MediaItem? _renderItem;
Color? _accentColor;
@override
void initState() {
super.initState();
_rehydrateCatalogItem();
}
@override
void didUpdateWidget(_CatalogSpotlightBackground oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(widget.item, oldWidget.item)) {
_rehydrateCatalogItem();
} else if (widget.targetWidthPx != oldWidget.targetWidthPx) {
_projectArtwork();
}
}
void _rehydrateCatalogItem() {
_catalogItem = widget.item?.catalogItem;
_projectArtwork();
}
void _projectArtwork() {
final item = widget.item;
final catalogItem = _catalogItem;
if (item == null || catalogItem == null) {
_renderItem = item;
_accentColor = null;
return;
}
final bannerUrl = catalogItem.bannerUrl;
final backdrop = bannerUrl != null && bannerUrl.isNotEmpty
? bannerUrl
: catalogItem.backdropFor(widget.targetWidthPx);
final logoUrl = catalogItem.logoUrl;
_renderItem = item.copyWith(
artPath: backdrop ?? item.artPath,
backdropPaths: backdrop == null ? item.backdropPaths : [backdrop],
clearLogoPath: logoUrl != null && logoUrl.isNotEmpty ? logoUrl : null,
);
_accentColor = _parseAccentColor(catalogItem.accentColor);
}
Color? _parseAccentColor(String? value) {
final match = RegExp(r'^#([0-9a-fA-F]{6})$').firstMatch(value ?? '');
final hex = match?.group(1);
if (hex == null) return null;
return Color(0xff000000 | int.parse(hex, radix: 16));
}
Widget? _buildNextEpisodeMetadata(BuildContext context) {
final nextEpisode = _catalogItem?.nextEpisode;
if (nextEpisode == null) return null;
final duration = formatDurationTextual(nextEpisode.timeUntil(DateTime.now()).inMilliseconds);
final episode = nextEpisode.episode;
final label = episode == null
? t.explore.badge.nextAiringIn(duration: duration)
: t.explore.badge.nextEpisodeIn(episode: episode, duration: duration);
final scale = TvLayoutConstants.scaleOf(context);
return Text(
label,
maxLines: 1,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16 * scale,
fontWeight: FontWeight.w700,
letterSpacing: 0.1,
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accentColor = _accentColor;
final background = TvSpotlightBackground(
item: _renderItem,
client: widget.client,
hideSpoilers: widget.hideSpoilers,
contentTop: widget.contentTop,
contentBottom: widget.contentBottom,
contentLeft: widget.contentLeft,
compact: true,
showPrimaryAction: false,
metadataTrailing: _buildNextEpisodeMetadata(context),
);
if (accentColor == null) return background;
return Theme(
data: theme.copyWith(
scaffoldBackgroundColor: Color.alphaBlend(accentColor.withValues(alpha: 0.18), theme.scaffoldBackgroundColor),
),
child: background,
);
}
}
/// Pins a toolbar to the top of the viewport across the full bleed width,
/// sliding with the sidebar so it stays put while the content box translates.
///
+50
View File
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/models/catalog/catalog_metadata.dart';
import 'package:plezy/utils/external_ids.dart';
void main() {
@@ -137,5 +138,54 @@ void main() {
expect(decoded.altTitles, isEmpty);
expect(decoded.season, isNull);
});
test('enrichedWith unions audience counters instead of replacing them', () {
// A Simkl trending row supplies windowed viewers and planning; its
// detail body supplies only a drop rate. Replacing the object wholesale
// silently dropped the row's counters.
const row = CatalogItem(
source: CatalogSourceId.simkl,
kind: MediaKind.show,
title: 'House of the Dragon',
ids: CatalogItemIds(simkl: 1197910),
audience: CatalogAudience(viewers: 7603, viewersPeriod: CatalogAudiencePeriod.week, planning: 8422),
);
const detail = CatalogItem(
source: CatalogSourceId.simkl,
kind: MediaKind.show,
title: 'House of the Dragon',
ids: CatalogItemIds(simkl: 1197910),
audience: CatalogAudience(dropRate: 0.031),
);
final merged = row.enrichedWith(detail).audience!;
expect(merged.viewers, 7603);
expect(merged.viewersPeriod, CatalogAudiencePeriod.week);
expect(merged.planning, 8422);
expect(merged.dropRate, 0.031);
});
test('enrichedWith lets detail replace a row value and merges ids per key', () {
const row = CatalogItem(
source: CatalogSourceId.seerr,
kind: MediaKind.movie,
title: 'The Matrix',
ids: CatalogItemIds(imdb: 'tt0133093'),
ranks: [CatalogRank(rank: 3, scope: CatalogRankScope.trending, allTime: false)],
);
const detail = CatalogItem(
source: CatalogSourceId.seerr,
kind: MediaKind.movie,
title: 'The Matrix',
overview: 'A full synopsis the row never carried.',
ids: CatalogItemIds(tmdb: 603),
);
final merged = row.enrichedWith(detail);
expect(merged.overview, 'A full synopsis the row never carried.');
expect(merged.ids.imdb, 'tt0133093', reason: 'row-only id must survive');
expect(merged.ids.tmdb, 603, reason: 'detail id must be adopted');
expect(merged.ranks?.single.rank, 3, reason: 'a rank is row context a detail body cannot know');
});
});
}
+50 -5
View File
@@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/models/catalog/catalog_cast_member.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/providers/catalog_sources_provider.dart';
import 'package:plezy/providers/explore_provider.dart';
@@ -67,9 +66,8 @@ class _FakeSource implements CatalogSource {
@override
Future<List<CatalogItem>> search(String query, {int limit = 30}) async => const [];
@override
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async => const [];
@override
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async => const [];
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async =>
CatalogDetail(item: item);
@override
Future<void> ensureWatchlistLoaded() async {}
@override
@@ -91,6 +89,8 @@ class _FakeHubSource extends _FakeSource implements CatalogHubSource {
int hubFetches = 0;
int hubFailuresRemaining = 0;
bool returnEmptyHubs = false;
CatalogHubStyle? hubStyle;
int? hubTotalResults;
CatalogItem _hubItem(String title) => CatalogItem(
source: id,
@@ -111,7 +111,8 @@ class _FakeHubSource extends _FakeSource implements CatalogHubSource {
CatalogHub(
id: 'trending-plex',
title: 'Trending on Plex',
page: CatalogPage(items: [_hubItem('Initial Recommendation')], hasMore: true),
style: hubStyle,
page: CatalogPage(items: [_hubItem('Initial Recommendation')], hasMore: true, totalResults: hubTotalResults),
),
];
}
@@ -208,6 +209,50 @@ void main() {
expect(allItems.map((item) => item.title), ['Recommendation Page 1', 'Recommendation Page 2']);
});
test('null and shelf styles retain the existing shelf appearance', () async {
final source = _FakeHubSource(CatalogSourceId.plex);
addTearDown(source.dispose);
sources.setActive(source);
await _pumpMicrotasks();
final unstyled = explore.rowHubs.last;
expect(unstyled.style, isNull);
source.hubStyle = CatalogHubStyle.shelf;
await explore.load();
final shelf = explore.rowHubs.last;
expect(shelf.style, CatalogHubStyle.shelf);
expect(shelf.hub.title, unstyled.hub.title);
expect(shelf.hub.type, unstyled.hub.type);
expect(shelf.hub.items.map((item) => item.title), unstyled.hub.items.map((item) => item.title));
expect(shelf.hub.more, unstyled.hub.more);
});
test('availability platform hubs are skipped instead of rendered as title posters', () async {
final source = _FakeHubSource(CatalogSourceId.plex)..hubStyle = CatalogHubStyle.availabilityPlatforms;
addTearDown(source.dispose);
sources.setActive(source);
await _pumpMicrotasks();
expect(explore.rowHubs, hasLength(1));
expect(explore.rowHubs.single.row, CatalogRowId.watchlist);
expect(explore.rowHubs.single.hub.items.single.title, 'plex:watchlist');
expect(explore.rowHubs.where((hub) => hub.providerHubId != null), isEmpty);
});
test('provider totalResults reaches the rendered hub without replacing its loaded items', () async {
final source = _FakeHubSource(CatalogSourceId.plex)..hubTotalResults = 347;
addTearDown(source.dispose);
sources.setActive(source);
await _pumpMicrotasks();
final providerHub = explore.rowHubs.last;
expect(providerHub.totalResults, 347);
expect(providerHub.hub.size, 347);
expect(providerHub.hub.items, hasLength(1));
});
test('mutation during the initial load is caught up by ensureFresh', () async {
final source = _FakeSource(CatalogSourceId.trakt)..gate = true;
addTearDown(source.dispose);
+413 -18
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focusable_action_bar.dart';
import 'package:plezy/i18n/strings.g.dart';
@@ -9,6 +10,7 @@ import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/models/catalog/catalog_cast_member.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/models/catalog/catalog_metadata.dart';
import 'package:plezy/providers/catalog_sources_provider.dart';
import 'package:plezy/screens/catalog_item_detail_screen.dart';
import 'package:plezy/services/catalog/catalog_source.dart';
@@ -19,6 +21,7 @@ import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/overlay_sheet.dart';
import 'package:plezy/widgets/media_card.dart';
import 'package:plezy/widgets/optimized_media_image.dart';
import 'package:provider/provider.dart';
import '../test_helpers/media_items.dart';
@@ -27,7 +30,7 @@ import '../test_helpers/prefs.dart';
class _FakeCatalogSource implements CatalogSource {
final WatchlistChangeNotifier _watchlistChanges = WatchlistChangeNotifier();
_FakeCatalogSource({bool watchlistLoading = false})
_FakeCatalogSource({bool watchlistLoading = false, this.detail, this.detailError, this.detailCompleter})
: _watchlistValue = watchlistLoading ? null : false,
_watchlistLoad = watchlistLoading ? Completer<void>() : null;
@@ -35,6 +38,10 @@ class _FakeCatalogSource implements CatalogSource {
final Completer<void>? _watchlistLoad;
int addToWatchlistCalls = 0;
final CatalogDetail? detail;
final Object? detailError;
final Completer<CatalogDetail>? detailCompleter;
int fetchDetailCalls = 0;
@override
CatalogSourceId get id => CatalogSourceId.trakt;
@@ -48,20 +55,29 @@ class _FakeCatalogSource implements CatalogSource {
Listenable get watchlistChanges => _watchlistChanges;
@override
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async => const [
CatalogCastMember(name: 'First Actor', secondary: 'Lead'),
CatalogCastMember(name: 'Second Actor', secondary: 'Support'),
];
@override
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async => const [
CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Related Movie',
ids: CatalogItemIds(tmdb: 2),
),
];
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async {
fetchDetailCalls++;
final completer = detailCompleter;
if (completer != null) return completer.future;
final error = detailError;
if (error != null) throw error;
return detail ??
CatalogDetail(
item: item,
cast: const [
CatalogCastMember(name: 'First Actor', secondary: 'Lead'),
CatalogCastMember(name: 'Second Actor', secondary: 'Support'),
],
related: const [
CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Related Movie',
ids: CatalogItemIds(tmdb: 2),
),
],
);
}
@override
Future<void> ensureWatchlistLoaded() async {
@@ -123,6 +139,7 @@ Future<void> _pumpDetail(
_FakeCatalogSource source, {
List<MediaItem> matches = const [],
bool pushedRoute = false,
CatalogItem item = _item,
}) async {
final sources = _FakeCatalogSourcesProvider(source);
final serverManager = MultiServerManager();
@@ -148,12 +165,12 @@ Future<void> _pumpDetail(
body: TextButton(
onPressed: () => Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: (_) => const CatalogItemDetailScreen(item: _item))),
).push(MaterialPageRoute<void>(builder: (_) => CatalogItemDetailScreen(item: item))),
child: const Text('Open catalog'),
),
),
)
: const CatalogItemDetailScreen(item: _item),
: CatalogItemDetailScreen(item: item),
),
),
),
@@ -168,8 +185,10 @@ Future<void> _pumpDetail(
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
setUpAll(() async {
LocaleSettings.setLocaleSync(AppLocale.en);
// The facts section formats dates; `main.dart` does this at startup.
await initializeDateFormatting('en');
});
setUp(() async {
@@ -183,6 +202,382 @@ void main() {
TvDetectionService.debugSetAppleTVOverride(null);
});
testWidgets('fetchDetail replaces the opening item with its enriched item once loaded', (tester) async {
final detailCompleter = Completer<CatalogDetail>();
final source = _FakeCatalogSource(detailCompleter: detailCompleter);
await _pumpDetail(tester, source);
expect(find.text('Catalog Movie'), findsOneWidget);
expect(find.text('Enriched overview'), findsNothing);
detailCompleter.complete(
const CatalogDetail(
item: CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Enriched Catalog Movie',
overview: 'Enriched overview',
ids: CatalogItemIds(tmdb: 1),
),
),
);
await tester.pumpAndSettle();
expect(source.fetchDetailCalls, 1);
expect(find.text('Enriched Catalog Movie'), findsOneWidget);
expect(find.text('Enriched overview'), findsOneWidget);
expect(find.text('Catalog Movie'), findsNothing);
});
testWidgets('fetchDetail failure leaves the opening item rendered', (tester) async {
final source = _FakeCatalogSource(detailError: StateError('detail unavailable'));
await _pumpDetail(tester, source);
expect(source.fetchDetailCalls, 1);
expect(find.text('Catalog Movie'), findsOneWidget);
expect(find.text('Overview'), findsOneWidget);
expect(find.text(t.explore.cast), findsNothing);
expect(find.text(t.discover.moreLikeThis), findsNothing);
});
testWidgets('spoiler tags stay hidden until the focusable reveal action is pressed', (tester) async {
const item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Tagged Movie',
ids: CatalogItemIds(tmdb: 10),
tags: [
CatalogTag(name: 'Found family', rank: 80),
CatalogTag(name: 'Secret identity', rank: 95, isSpoiler: true),
],
);
final source = _FakeCatalogSource(detail: const CatalogDetail(item: item));
await _pumpDetail(tester, source, item: item);
expect(find.text('Found family'), findsOneWidget);
expect(find.text('Secret identity'), findsNothing);
expect(find.text(t.explore.detail.revealSpoilerTags), findsOneWidget);
await tester.tap(find.text(t.explore.detail.revealSpoilerTags));
await tester.pump();
expect(find.text('Secret identity'), findsOneWidget);
expect(find.text(t.explore.detail.revealSpoilerTags), findsNothing);
});
testWidgets('ratings row labels every supported score source and its vote count', (tester) async {
const item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Rated Movie',
ids: CatalogItemIds(tmdb: 11),
ratings: [
CatalogRatingSource(source: 'simkl', value: 8.1, votes: 11),
CatalogRatingSource(source: 'imdb', value: 7.9, votes: 12),
CatalogRatingSource(source: 'mal', value: 8.3, votes: 13),
CatalogRatingSource(source: 'critic', value: 7.2, votes: 14),
CatalogRatingSource(source: 'audience', value: 8.8, votes: 15),
],
);
final source = _FakeCatalogSource(detail: const CatalogDetail(item: item));
await _pumpDetail(tester, source, item: item);
expect(find.text(t.explore.detail.ratings), findsOneWidget);
expect(find.text('Simkl 8.1 (11 votes)'), findsOneWidget);
expect(find.text('IMDb 7.9 (12 votes)'), findsOneWidget);
expect(find.text('MyAnimeList 8.3 (13 votes)'), findsOneWidget);
expect(find.text('Critics 7.2 (14 votes)'), findsOneWidget);
expect(find.text('Audience 8.8 (15 votes)'), findsOneWidget);
});
testWidgets('seasonal rank keeps its season window instead of claiming all-time rank', (tester) async {
const item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.show,
title: 'Seasonal Show',
ids: CatalogItemIds(tmdb: 12),
ranks: [
CatalogRank(
rank: 7,
scope: CatalogRankScope.popular,
allTime: false,
year: 2025,
season: CatalogSeasonName.fall,
),
],
);
final source = _FakeCatalogSource(detail: const CatalogDetail(item: item));
await _pumpDetail(tester, source, item: item);
expect(find.text('#7 in Fall 2025'), findsOneWidget);
expect(find.text('#7 popular'), findsNothing);
});
testWidgets('windowed viewers render only when their period is present', (tester) async {
const missingPeriod = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Missing Period',
ids: CatalogItemIds(tmdb: 13),
audience: CatalogAudience(listed: 3, viewers: 42),
);
final firstSource = _FakeCatalogSource(detail: const CatalogDetail(item: missingPeriod));
await _pumpDetail(tester, firstSource, item: missingPeriod);
expect(find.text('3 listed'), findsOneWidget);
expect(find.textContaining('42'), findsNothing);
const weekly = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Weekly Viewers',
ids: CatalogItemIds(tmdb: 14),
audience: CatalogAudience(viewers: 42, viewersPeriod: CatalogAudiencePeriod.week),
);
// Unmount first: pumping a second detail screen at the same tree position
// would reuse the existing State, so `initState` would never re-run and
// the screen would keep the previous item.
await tester.pumpWidget(const SizedBox.shrink());
final secondSource = _FakeCatalogSource(detail: const CatalogDetail(item: weekly));
await _pumpDetail(tester, secondSource, item: weekly);
expect(find.text('42 watched this week'), findsOneWidget);
});
testWidgets('trailer action appears only after an item supplies a trailer URL', (tester) async {
final detailCompleter = Completer<CatalogDetail>();
final source = _FakeCatalogSource(detailCompleter: detailCompleter);
await _pumpDetail(tester, source);
expect(find.byTooltip(t.explore.detail.watchTrailer), findsNothing);
detailCompleter.complete(
const CatalogDetail(
item: CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Catalog Movie',
trailerUrl: 'https://example.com/trailer',
ids: CatalogItemIds(tmdb: 1),
),
),
);
await tester.pumpAndSettle();
expect(find.byTooltip(t.explore.detail.watchTrailer), findsOneWidget);
});
testWidgets('gallery and background sections render when populated', (tester) async {
const galleryUrls = [
'https://cdn.myanimelist.net/images/anime/gallery-1.jpg',
'https://cdn.myanimelist.net/images/anime/gallery-2.jpg',
];
const item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Production Movie',
ids: CatalogItemIds(tmdb: 15),
gallery: galleryUrls,
background: 'Filmed over three winters.',
);
final source = _FakeCatalogSource(detail: const CatalogDetail(item: item));
await _pumpDetail(tester, source, item: item);
expect(find.text(t.explore.detail.background), findsOneWidget);
expect(find.text('Filmed over three winters.'), findsOneWidget);
expect(find.text(t.explore.detail.gallery), findsOneWidget);
final galleryFinder = find.byKey(const Key('catalog_detail_gallery'));
expect(galleryFinder, findsOneWidget);
expect(tester.widget<ListView>(galleryFinder).scrollDirection, Axis.horizontal);
final images = tester
.widgetList<OptimizedMediaImage>(find.descendant(of: galleryFinder, matching: find.byType(OptimizedMediaImage)))
.map((image) => image.imagePath);
expect(images, galleryUrls);
});
testWidgets('all-null metadata renders without an empty optional section header', (tester) async {
const item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Bare Movie',
ids: CatalogItemIds(tmdb: 15),
);
final source = _FakeCatalogSource(detail: const CatalogDetail(item: item));
await _pumpDetail(tester, source, item: item);
expect(find.text('Bare Movie'), findsOneWidget);
expect(find.text(t.explore.detail.ratings), findsNothing);
expect(find.text(t.explore.detail.schedule), findsNothing);
expect(find.text(t.explore.detail.crew), findsNothing);
expect(find.text(t.explore.detail.tags), findsNothing);
expect(find.text(t.explore.detail.links), findsNothing);
expect(find.text(t.explore.detail.watchOn), findsNothing);
expect(find.text(t.explore.cast), findsNothing);
expect(find.text(t.discover.moreLikeThis), findsNothing);
expect(find.text(t.explore.detail.gallery), findsNothing);
expect(find.text(t.explore.detail.background), findsNothing);
});
testWidgets('franchise relations keep their labelled shelf separate from recommendations', (tester) async {
const relationItem = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'The Sequel',
ids: CatalogItemIds(tmdb: 17),
);
const recommendation = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'A Similar Movie',
ids: CatalogItemIds(tmdb: 18),
);
final source = _FakeCatalogSource(
detail: const CatalogDetail(
item: _item,
related: [recommendation],
relations: [
CatalogRelation(type: CatalogRelationType.sequel, items: [relationItem]),
],
),
);
await _pumpDetail(tester, source);
expect(find.text(t.explore.relation.sequel), findsOneWidget);
expect(find.text(t.discover.moreLikeThis), findsOneWidget);
expect(find.text('The Sequel'), findsOneWidget);
expect(find.text('A Similar Movie'), findsOneWidget);
});
testWidgets('social recommendation keeps its person, reason, and note', (tester) async {
const item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Social Movie',
ids: CatalogItemIds(tmdb: 19),
recommenders: [
CatalogRecommender(
username: 'pat',
name: 'Pat',
note: 'A thoughtful recommendation.',
reason: CatalogRecommendationReason.recommended,
),
],
);
final source = _FakeCatalogSource(detail: const CatalogDetail(item: item));
await _pumpDetail(tester, source, item: item);
expect(find.text('Recommended by Pat'), findsOneWidget);
expect(find.text('A thoughtful recommendation.'), findsOneWidget);
});
testWidgets('extended facts render in their labelled sections with localized values', (tester) async {
final item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.show,
title: 'Fact-rich Show',
ids: CatalogItemIds(tmdb: 20),
broadcastSeason: CatalogSeasonInfo(name: CatalogSeasonName.fall, year: 2025),
format: CatalogFormat.ova,
sourceMaterial: CatalogSourceMaterial.lightNovel,
studios: ['Studio One'],
countries: ['US'],
languages: ['ja'],
credits: [
CatalogCredit(name: 'A. Director', role: CatalogCreditRole.director),
CatalogCredit(name: 'W. Writer', role: CatalogCreditRole.writer),
],
broadcast: CatalogBroadcast(weekday: DateTime.tuesday, time: '21:00', timezone: 'Asia/Tokyo'),
nextEpisode: CatalogNextEpisode(episode: 4, airsAt: DateTime.utc(2100)),
serverState: CatalogServerState(
availability: CatalogAvailability.available,
request: CatalogRequestState.pending,
availableSeasons: 2,
totalSeasons: 3,
),
audience: CatalogAudience(dropRate: 0.25),
releaseDate: DateTime.utc(2024, 1, 2),
physicalReleaseDate: DateTime.utc(2024, 4, 5),
endDate: DateTime.utc(2025, 6, 7),
addedAt: DateTime.utc(2024, 2, 3),
userRating: 9,
originalTitle: 'Original Fact Title',
altTitles: ['Alternate Fact Title'],
contentAdvisory: 'Suitable for older teens.',
budget: 1000000,
revenue: 2500000,
links: [
CatalogLink(label: 'StreamCo', url: 'https://example.com/watch', isStreaming: true),
CatalogLink(label: 'Official Site', url: 'https://example.com'),
],
);
final source = _FakeCatalogSource(detail: CatalogDetail(item: item));
await _pumpDetail(tester, source, item: item);
expect(find.text('Fall 2025'), findsOneWidget);
expect(find.text('OVA'), findsOneWidget);
expect(find.text('Light novel'), findsOneWidget);
expect(find.text('25% dropped it'), findsOneWidget);
expect(find.text('Available'), findsOneWidget);
expect(find.text('Pending approval'), findsOneWidget);
expect(find.text('2/3 seasons'), findsOneWidget);
expect(find.text('Airs Tuesday at 21:00 Asia/Tokyo'), findsOneWidget);
expect(find.textContaining('Ep 4 in'), findsOneWidget);
expect(find.text('United States'), findsOneWidget);
expect(find.text('Japanese'), findsOneWidget);
expect(find.text(t.explore.detail.crew), findsOneWidget);
expect(find.text('A. Director'), findsOneWidget);
expect(find.text('W. Writer'), findsOneWidget);
expect(find.text(t.explore.detail.watchOn), findsOneWidget);
expect(find.text(t.explore.detail.links), findsOneWidget);
expect(find.text('Open on StreamCo'), findsOneWidget);
expect(find.text('Open on Official Site'), findsOneWidget);
expect(find.text('Original Fact Title'), findsOneWidget);
expect(find.text('Alternate Fact Title'), findsOneWidget);
expect(find.text('Suitable for older teens.'), findsOneWidget);
expect(find.textContaining('1,000,000'), findsOneWidget);
expect(find.textContaining('2,500,000'), findsOneWidget);
});
testWidgets('D-pad includes spoiler reveal and outbound links after the main action bar', (tester) async {
const item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Interactive Movie',
ids: CatalogItemIds(tmdb: 21),
trailerUrl: 'https://example.com/trailer',
tags: [CatalogTag(name: 'Spoiler', isSpoiler: true)],
links: [
CatalogLink(label: 'StreamCo', url: 'https://example.com/watch', isStreaming: true),
CatalogLink(label: 'Official Site', url: 'https://example.com'),
],
);
final source = _FakeCatalogSource(detail: const CatalogDetail(item: item));
await _pumpDetail(tester, source, item: item);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_spoiler_tags');
await tester.sendKeyEvent(LogicalKeyboardKey.select);
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_external_link_0');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_external_link_1');
});
testWidgets('D-pad traverses from actions through cast and back from recommendations', (tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1280, 720);
+34 -7
View File
@@ -1,10 +1,11 @@
import 'package:flutter/material.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/models/catalog/catalog_cast_member.dart';
import 'package:plezy/models/catalog/catalog_metadata.dart';
import 'package:plezy/providers/catalog_sources_provider.dart';
import 'package:plezy/screens/catalog_item_detail_screen.dart';
import 'package:plezy/screens/catalog_search_screen.dart';
@@ -24,6 +25,7 @@ import '../test_helpers/prefs.dart';
class _FakeSearchSource implements CatalogSource {
final queries = <String>[];
bool failNext = false;
CatalogItem? result;
@override
CatalogSourceId get id => CatalogSourceId.trakt;
@@ -42,15 +44,14 @@ class _FakeSearchSource implements CatalogSource {
throw Exception('boom');
}
return [
CatalogItem(source: id, kind: MediaKind.movie, title: 'result: $query', ids: const CatalogItemIds(tmdb: 1)),
result ??
CatalogItem(source: id, kind: MediaKind.movie, title: 'result: $query', ids: const CatalogItemIds(tmdb: 1)),
];
}
@override
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async => const [];
@override
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async => const [];
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async =>
CatalogDetail(item: item);
@override
void dispose() {}
@@ -73,8 +74,10 @@ Future<void> _pump(WidgetTester tester, _FakeSearchSource source) async {
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
setUpAll(() async {
LocaleSettings.setLocaleSync(AppLocale.en);
// Catalog result cards format dates; `main.dart` does this at startup.
await initializeDateFormatting('en');
});
setUp(() async {
@@ -166,6 +169,30 @@ void main() {
expect(_appMenuList(), findsNothing);
expect(find.byType(CatalogItemDetailScreen), findsOneWidget);
});
testWidgets('catalog result menu exposes trailer and provider links', (tester) async {
final source = _FakeSearchSource()
..result = const CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'result: menu',
ids: CatalogItemIds(tmdb: 1),
trailerUrl: 'https://example.com/trailer',
links: [
CatalogLink(label: 'Trakt', url: 'https://example.com/trakt'),
CatalogLink(label: 'Stream Co', url: 'https://example.com/watch', isStreaming: true),
],
);
await _pumpMenuSearch(tester, source, platform: TargetPlatform.macOS);
await _searchForMenuResult(tester);
tester.state<MediaCardState>(find.byType(MediaCard)).showContextMenu();
await tester.pumpAndSettle();
expect(find.text(t.explore.detail.watchTrailer), findsOneWidget);
expect(find.text(t.explore.detail.openOn(site: 'Trakt')), findsOneWidget);
expect(find.text(t.explore.detail.openOn(site: 'Stream Co')), findsOneWidget);
});
}
dynamic _state(WidgetTester tester) => tester.state<State<CatalogSearchScreen>>(find.byType(CatalogSearchScreen));
+169 -14
View File
@@ -2,10 +2,12 @@ import 'dart:ui' show SemanticsAction;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/models/catalog/catalog_metadata.dart';
import 'package:plezy/providers/catalog_sources_provider.dart';
import 'package:plezy/providers/explore_provider.dart';
import 'package:plezy/screens/explore_screen.dart';
@@ -15,12 +17,23 @@ import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/catalog_source_logo.dart';
import 'package:plezy/widgets/search_input_field.dart';
import 'package:plezy/widgets/fitting_title_text.dart';
import 'package:plezy/widgets/optimized_media_image.dart' show ClearLogoImage;
import 'package:plezy/widgets/tv_spotlight_background.dart';
import 'package:provider/provider.dart';
import '../test_helpers/prefs.dart';
class _FakeCatalogSource implements CatalogSource, CatalogHubSource {
_FakeCatalogSource(this.id, this.displayName, this.itemId, {this.providerHubTitle});
_FakeCatalogSource(
this.id,
this.displayName,
this.itemId, {
this.providerHubTitle,
this.providerHubStyle,
this.rowItem,
this.rowTotalResults,
});
@override
final CatalogSourceId id;
@@ -30,6 +43,9 @@ class _FakeCatalogSource implements CatalogSource, CatalogHubSource {
final int? itemId;
final String? providerHubTitle;
final CatalogHubStyle? providerHubStyle;
final CatalogItem? rowItem;
final int? rowTotalResults;
final WatchlistChangeNotifier _watchlistChanges = WatchlistChangeNotifier();
/// Search bookkeeping: [searchTitles] overrides the single default hit so a
@@ -49,16 +65,20 @@ class _FakeCatalogSource implements CatalogSource, CatalogHubSource {
@override
Future<CatalogPage> fetchRow(CatalogRowId row, {int page = 1, int limit = 25}) async {
final item =
rowItem ??
(itemId == null
? null
: CatalogItem(
source: id,
kind: MediaKind.movie,
title: '$displayName Movie',
ids: CatalogItemIds(tmdb: itemId),
));
return CatalogPage(
items: [
if (itemId case final itemId?)
CatalogItem(
source: id,
kind: MediaKind.movie,
title: '$displayName Movie',
ids: CatalogItemIds(tmdb: itemId),
),
],
items: [?item],
hasMore: rowTotalResults != null && rowTotalResults! > 1,
totalResults: rowTotalResults,
);
}
@@ -85,6 +105,7 @@ class _FakeCatalogSource implements CatalogSource, CatalogHubSource {
CatalogHub(
id: 'plex-recommendation',
title: title,
style: providerHubStyle,
page: CatalogPage(
items: [
CatalogItem(
@@ -139,6 +160,9 @@ Future<_FakeCatalogSourcesProvider> _pumpExplore(
int? traktItemId = 1,
int? malItemId = 2,
bool? tv,
CatalogItem? traktItem,
int? traktTotalResults,
CatalogHubStyle? plexHubStyle,
}) async {
if (tv != null) TvDetectionService.debugSetAppleTVOverride(tv);
tester.view.devicePixelRatio = 1;
@@ -146,11 +170,23 @@ Future<_FakeCatalogSourcesProvider> _pumpExplore(
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
final trakt = _FakeCatalogSource(CatalogSourceId.trakt, 'Trakt', traktItemId);
final trakt = _FakeCatalogSource(
CatalogSourceId.trakt,
'Trakt',
traktItemId,
rowItem: traktItem,
rowTotalResults: traktTotalResults,
);
final mal = _FakeCatalogSource(CatalogSourceId.mal, 'MyAnimeList', malItemId);
final anilist = _FakeCatalogSource(CatalogSourceId.anilist, 'AniList', 3);
final simkl = _FakeCatalogSource(CatalogSourceId.simkl, 'Simkl', 4);
final plex = _FakeCatalogSource(CatalogSourceId.plex, 'Plex', 5, providerHubTitle: 'Trending on Plex');
final plex = _FakeCatalogSource(
CatalogSourceId.plex,
'Plex',
5,
providerHubTitle: 'Trending on Plex',
providerHubStyle: plexHubStyle,
);
final seerr = _FakeCatalogSource(CatalogSourceId.seerr, 'Seerr', 6);
final sources = _FakeCatalogSourcesProvider([trakt, mal, anilist, simkl, plex, seerr]);
final explore = ExploreProvider(sources);
@@ -184,8 +220,10 @@ _FakeCatalogSource _fakeSource(_FakeCatalogSourcesProvider sources, CatalogSourc
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
setUpAll(() async {
LocaleSettings.setLocaleSync(AppLocale.en);
// The spotlight countdown formats dates; `main.dart` does this at startup.
await initializeDateFormatting('en');
});
setUp(() async {
@@ -273,7 +311,7 @@ void main() {
expect(find.text('AniList Movie'), findsAtLeast(1));
});
testWidgets('Plex provider-defined hub renders as an Explore shelf', (tester) async {
testWidgets('a null-style Plex provider hub keeps the existing Explore shelf', (tester) async {
final sources = await _pumpExplore(tester);
await sources.setActiveSource(CatalogSourceId.plex);
@@ -284,6 +322,123 @@ void main() {
expect(find.text('Plex Recommendation'), findsAtLeast(1));
});
testWidgets('an explicit shelf-style Plex hub keeps the existing Explore shelf', (tester) async {
final sources = await _pumpExplore(tester, plexHubStyle: CatalogHubStyle.shelf);
await sources.setActiveSource(CatalogSourceId.plex);
await tester.pumpAndSettle();
expect(find.text('Trending on Plex'), findsOneWidget);
expect(find.text('Plex Recommendation'), findsAtLeast(1));
});
testWidgets('an availability-platforms hub is not rendered as a title shelf', (tester) async {
final sources = await _pumpExplore(tester, tv: false, plexHubStyle: CatalogHubStyle.availabilityPlatforms);
await sources.setActiveSource(CatalogSourceId.plex);
await tester.pumpAndSettle();
expect(find.text('Plex Movie'), findsAtLeast(1));
expect(find.text('Trending on Plex'), findsNothing);
expect(find.text('Plex Recommendation'), findsNothing);
});
testWidgets('a provider total result count reaches the existing shelf header', (tester) async {
await _pumpExplore(tester, tv: false, traktTotalResults: 87);
expect(find.text(t.explore.totalResults(n: 87)), findsOneWidget);
});
group('TV catalog spotlight', () {
testWidgets('prefers logo and banner art, applies the accent tint, and shows the next episode', (tester) async {
final item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.show,
title: 'Logo Series',
ids: const CatalogItemIds(tmdb: 101),
logoUrl: 'https://images.example/logo.png',
bannerUrl: 'https://images.example/banner.jpg',
backdropUrl: 'https://images.example/default.jpg',
backdropVariants: const {1920: 'https://images.example/backdrop-1920.jpg'},
accentColor: '#336699',
nextEpisode: CatalogNextEpisode(episode: 8, airsAt: DateTime.now().add(const Duration(days: 2))),
);
await _pumpExplore(tester, traktItem: item);
final spotlightFinder = find.byType(TvSpotlightBackground);
final spotlight = tester.widget<TvSpotlightBackground>(spotlightFinder);
expect(spotlight.item?.clearLogoPath, 'https://images.example/logo.png');
expect(spotlight.item?.artPath, 'https://images.example/banner.jpg');
expect(find.descendant(of: spotlightFinder, matching: find.byType(ClearLogoImage)), findsOneWidget);
// Scoped to the spotlight: the shelf card underneath renders its own
// countdown badge from the same item, so an unscoped finder matches two.
expect(
find.descendant(
of: spotlightFinder,
matching: find.byWidgetPredicate(
(widget) => widget is Text && (widget.data?.startsWith('Ep 8 in ') ?? false),
),
),
findsOneWidget,
);
final baseColor = monoTheme(dark: true).scaffoldBackgroundColor;
final expectedTint = Color.alphaBlend(const Color(0xff336699).withValues(alpha: 0.18), baseColor);
expect(Theme.of(tester.element(spotlightFinder)).scaffoldBackgroundColor, expectedTint);
});
testWidgets('falls back to the plain text title when a catalog logo is absent', (tester) async {
final item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Plain Title',
ids: const CatalogItemIds(tmdb: 102),
backdropUrl: 'https://images.example/backdrop.jpg',
);
await _pumpExplore(tester, traktItem: item);
final spotlightFinder = find.byType(TvSpotlightBackground);
final spotlight = tester.widget<TvSpotlightBackground>(spotlightFinder);
expect(spotlight.item?.clearLogoPath, isNull);
expect(find.descendant(of: spotlightFinder, matching: find.byType(FittingTitleText)), findsOneWidget);
expect(find.descendant(of: spotlightFinder, matching: find.text('Plain Title')), findsOneWidget);
});
testWidgets('selects backdrop variants from logical width and device pixel ratio', (tester) async {
final item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Variant Movie',
ids: const CatalogItemIds(tmdb: 103),
backdropUrl: 'https://images.example/default.jpg',
backdropVariants: const {
900: 'https://images.example/backdrop-900.jpg',
1500: 'https://images.example/backdrop-1500.jpg',
2500: 'https://images.example/backdrop-2500.jpg',
},
);
await _pumpExplore(tester, traktItem: item);
String? selectedBackdrop() =>
tester.widget<TvSpotlightBackground>(find.byType(TvSpotlightBackground)).item?.artPath;
tester.view.physicalSize = const Size(800, 720);
await tester.pump();
expect(selectedBackdrop(), 'https://images.example/backdrop-900.jpg');
tester.view.physicalSize = const Size(1200, 720);
await tester.pump();
expect(selectedBackdrop(), 'https://images.example/backdrop-1500.jpg');
tester.view.devicePixelRatio = 2;
tester.view.physicalSize = const Size(2400, 1440);
await tester.pump();
expect(selectedBackdrop(), 'https://images.example/backdrop-2500.jpg');
});
});
testWidgets('TV source switcher remains focused when the active source has no rows', (tester) async {
final sources = await _pumpExplore(tester, traktItemId: null);
@@ -7,6 +7,8 @@ import 'package:http/testing.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/models/anilist/anilist_media.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/models/catalog/catalog_metadata.dart';
import 'package:plezy/models/catalog/catalog_cast_member.dart';
import 'package:plezy/models/trackers/fribb_mapping_row.dart';
import 'package:plezy/services/catalog/anilist_catalog_source.dart';
import 'package:plezy/services/catalog/catalog_source.dart';
@@ -57,26 +59,60 @@ Map<String, dynamic> _media({
}) => {
'id': id,
'idMal': ?idMal,
'title': {'english': title, 'romaji': 'Shingeki no Kyojin', 'userPreferred': 'Preferred'},
'title': {'english': title, 'romaji': 'Shingeki no Kyojin', 'native': '進撃の巨人', 'userPreferred': 'Preferred'},
'synonyms': ['The Advancing Giants', 'Preferred'],
'format': format,
'status': status,
'episodes': 25,
'duration': 24,
'description': '<b>Humanity</b><br>fights &amp; survives.',
'averageScore': 84,
'meanScore': 82,
'popularity': 812345,
'favourites': 54321,
'trending': 987,
'season': 'SPRING',
'seasonYear': 2013,
'startDate': {'year': 2013},
'startDate': {'year': 2013, 'month': 4, 'day': 7},
'endDate': {'year': 2013, 'month': 9, 'day': 29},
'genres': ['Action', 'Drama'],
'isAdult': isAdult,
'coverImage': {'extraLarge': 'https://img.anilist.co/poster/$id.jpg'},
'source': 'MANGA',
'countryOfOrigin': 'JP',
'coverImage': {'extraLarge': 'https://img.anilist.co/poster/$id.jpg', 'color': '#D88932'},
'bannerImage': 'https://img.anilist.co/banner/$id.jpg',
'studios': {
'nodes': [
{'name': 'Wit Studio'},
{'name': 'Production I.G'},
],
},
'trailer': {'id': 'abc123', 'site': 'youtube'},
'nextAiringEpisode': {'episode': 8, 'airingAt': 2000000000, 'timeUntilAiring': 86400},
'rankings': [
{'rank': 1, 'type': 'POPULAR', 'format': 'TV', 'allTime': true, 'context': 'Most Popular All Time'},
{
'rank': 3,
'type': 'RATED',
'format': 'TV',
'year': 2013,
'season': 'SPRING',
'allTime': false,
'context': 'Highest Rated Spring 2013',
},
],
};
Map<String, dynamic> _characters() => {
'edges': [
{
'role': 'MAIN',
'node': {
'name': {'full': 'Mikasa Ackerman'},
'image': {'large': 'https://img.anilist.co/mikasa.jpg'},
},
},
],
};
http.Response _data(Map<String, dynamic> data, {int status = 200, Map<String, String>? headers}) =>
@@ -109,19 +145,46 @@ void main() {
);
group('AnilistMedia', () {
test('parses requested fields and strips AniList HTML', () {
final media = AnilistMedia.fromJson(_media(id: 1, idMal: 16498));
test('parses requested fields and honors the user-preferred title', () {
final media = AnilistMedia.fromJson(_media(id: 1, idMal: 16498)..['characters'] = _characters());
expect(media.displayTitle, 'Attack on Titan');
expect(media.displayTitle, 'Preferred');
expect(media.alternateTitles, ['Attack on Titan', 'Shingeki no Kyojin', 'The Advancing Giants']);
expect(media.description, 'Humanity\nfights & survives.');
expect(media.year, 2013);
expect(media.releaseDate, DateTime.utc(2013, 4, 7));
expect(media.finalEpisodeDate, DateTime.utc(2013, 9, 29));
expect(media.posterUrl, 'https://img.anilist.co/poster/1.jpg');
expect(media.backdropUrl, 'https://img.anilist.co/banner/1.jpg');
expect(media.rating, 8.4);
expect(media.meanRating, 8.2);
expect(media.runtimeMinutes, 24);
expect(media.network, 'Wit Studio');
expect(media.mainStudios, ['Wit Studio', 'Production I.G']);
expect(media.trailerUrl, 'https://www.youtube.com/watch?v=abc123');
expect(media.isMovie, isFalse);
expect(media.characters?.single.name, 'Mikasa Ackerman');
expect(media.characters?.single.role, 'MAIN');
expect(media.characters?.single.imageUrl, 'https://img.anilist.co/mikasa.jpg');
});
test('title fallbacks and missing optional metadata remain nullable', () {
final media = AnilistMedia.fromJson({
'id': 1,
'title': {'english': 'English', 'romaji': 'Romaji', 'userPreferred': ' '},
'streamingEpisodes': <Map<String, dynamic>>[],
});
expect(media.displayTitle, 'English');
expect(media.alternateTitles, ['Romaji']);
expect(media.nextAiringEpisode, isNull);
expect(media.rankings, isNull);
expect(media.mainStudios, isNull);
expect(media.coverImageColor, isNull);
expect(media.streamingEpisodes, isNull);
expect(media.releaseDate, isNull);
expect(media.finalEpisodeDate, isNull);
expect(media.characters, isNull);
});
test('stripHtml handles line breaks, tags, entities, and empty input', () {
@@ -188,7 +251,7 @@ void main() {
client.dispose();
});
test('trending query clamps page size and enriches every external id', () async {
test('trending row maps rich metadata while clamping the page size', () async {
responder = (request) {
final body = _requestBody(request);
final variables = body['variables'] as Map<String, dynamic>;
@@ -198,7 +261,7 @@ void main() {
return _data({
'Page': {
'pageInfo': {'hasNextPage': true},
'media': [_media(id: 16498, idMal: 16498)],
'media': [_media(id: 16498, idMal: 16498)..['characters'] = _characters()],
},
});
};
@@ -216,6 +279,38 @@ void main() {
expect(item.overview, 'Humanity\nfights & survives.');
expect(item.airStatus, CatalogAirStatus.airing);
expect(item.episodeCount, 25);
expect(item.title, 'Preferred');
expect(item.originalTitle, '進撃の巨人');
expect(item.altTitles, ['Attack on Titan', 'Shingeki no Kyojin', '進撃の巨人', 'The Advancing Giants']);
expect(item.format, CatalogFormat.tv);
expect(item.studios, ['Wit Studio', 'Production I.G']);
expect(item.network, 'Wit Studio');
expect(item.broadcastSeason?.name, CatalogSeasonName.spring);
expect(item.broadcastSeason?.year, 2013);
expect(item.accentColor, '#d88932');
expect(item.releaseDate, DateTime.utc(2013, 4, 7));
expect(item.endDate, DateTime.utc(2013, 9, 29));
expect(item.sourceMaterial, CatalogSourceMaterial.manga);
expect(item.countries, ['JP']);
expect(item.ratings?.single.source, 'anilist');
expect(item.ratings?.single.value, 8.2);
expect(item.audience?.listed, 812345);
expect(item.audience?.favorited, 54321);
expect(item.audience?.trendingActivity, 987);
expect(item.nextEpisode?.episode, 8);
expect(item.nextEpisode?.airsAt, DateTime.fromMillisecondsSinceEpoch(2000000000000, isUtc: true));
expect(item.ranks, hasLength(2));
expect(item.ranks?[0].scope, CatalogRankScope.popular);
expect(item.ranks?[0].allTime, isTrue);
expect(item.ranks?[0].year, isNull);
expect(item.ranks?[1].scope, CatalogRankScope.rated);
expect(item.ranks?[1].allTime, isFalse);
expect(item.ranks?[1].year, 2013);
expect(item.ranks?[1].season, CatalogSeasonName.spring);
expect(item.cast, hasLength(1));
expect(item.cast?.single.name, 'Mikasa Ackerman');
expect(item.cast?.single.secondary, 'MAIN');
expect(item.cast?.single.imageUrl, 'https://img.anilist.co/mikasa.jpg');
});
test('sequel entries preserve alternate-title order and both Fribb season numbers', () async {
@@ -241,8 +336,11 @@ void main() {
final item = (await source.fetchRow(CatalogRowId.trendingAnime)).items.single;
expect(item.title, 'Attack on Titan Season 3');
expect(item.altTitles, ['Preferred Season 3', 'Shingeki no Kyojin Season 3', '進撃の巨人 Season 3', 'AoT 3']);
// The display title is now `userPreferred` (AniList honours the viewer's
// title-language setting). The English title stays in `altTitles`, so the
// reverse library lookup still has it to match on.
expect(item.title, 'Preferred Season 3');
expect(item.altTitles, ['Attack on Titan Season 3', 'Shingeki no Kyojin Season 3', '進撃の巨人 Season 3', 'AoT 3']);
expect(item.season, const ExternalSeasonRef(tvdb: 3, tmdb: 2));
});
@@ -322,6 +420,86 @@ void main() {
expect(query, isNot(contains(r'id\nidMal')));
});
test('row and detail documents select metadata on the deliberate request path', () async {
responder = (request) {
final query = _requestBody(request)['query'] as String;
if (query.contains('Page(')) {
return _data({
'Page': {
'pageInfo': {'hasNextPage': false},
'media': <Map<String, dynamic>>[],
},
});
}
return _data({'Media': <String, dynamic>{}});
};
await client.getTrendingAnime();
final rowQuery = _requestBody(requests.single)['query'] as String;
expect(rowQuery, contains('nextAiringEpisode {'));
expect(rowQuery, contains('rankings {'));
expect(rowQuery, contains('episode'));
expect(rowQuery, contains('airingAt'));
expect(rowQuery, contains('timeUntilAiring'));
expect(rowQuery, contains('rank'));
expect(rowQuery, contains('type'));
expect(rowQuery, contains('format'));
expect(rowQuery, contains('year'));
expect(rowQuery, contains('season'));
expect(rowQuery, contains('allTime'));
expect(rowQuery, contains('context'));
expect(rowQuery, contains('meanScore'));
expect(rowQuery, contains('popularity'));
expect(rowQuery, contains('favourites'));
expect(rowQuery, contains('trending'));
expect(rowQuery, contains('native'));
expect(rowQuery, contains('synonyms'));
expect(rowQuery, contains('source'));
expect(rowQuery, contains('countryOfOrigin'));
expect(rowQuery, contains('endDate {'));
expect(rowQuery, contains('color'));
expect(rowQuery, contains('month'));
expect(rowQuery, contains('day'));
expect(rowQuery, isNot(contains('externalLinks {')));
expect(rowQuery, isNot(contains('streamingEpisodes {')));
expect(rowQuery, contains('characters('));
expect(rowQuery, contains('perPage: 6'));
expect(rowQuery, contains('sort: [ROLE, RELEVANCE]'));
expect(rowQuery, contains('role'));
expect(rowQuery, contains('full'));
expect(rowQuery, contains('large'));
expect(rowQuery, contains('medium'));
expect(rowQuery, isNot(contains('voiceActors')));
expect(rowQuery, isNot(contains('mediaConnection')));
expect(rowQuery, isNot(contains('relations(')));
expect(rowQuery, isNot(contains('staff(')));
expect(rowQuery, isNot(contains('tags {')));
requests.clear();
await client.getAnimeDetail(16498, castLimit: 200, relatedLimit: 0);
final detailBody = _requestBody(requests.single);
final detailQuery = detailBody['query'] as String;
final variables = detailBody['variables'] as Map<String, dynamic>;
expect(detailQuery, contains('tags {'));
expect(detailQuery, contains('externalLinks {'));
expect(detailQuery, contains('streamingEpisodes {'));
expect(detailQuery, contains('name'));
expect(detailQuery, contains('rank'));
expect(detailQuery, contains('isMediaSpoiler'));
expect(detailQuery, contains('site'));
expect(detailQuery, contains('url'));
expect(detailQuery, contains('title'));
expect(detailQuery, contains('thumbnail'));
expect(detailQuery, contains('staff(page: 1, perPage: \$staffPerPage)'));
expect(detailQuery, contains('characters(page: 1, perPage: \$castPerPage, sort: [ROLE, RELEVANCE])'));
expect(detailQuery, contains('recommendations(page: 1, perPage: \$relatedPerPage)'));
expect(detailQuery, contains('relations(page: 1, perPage: \$relatedPerPage)'));
expect(detailQuery, contains('relationType(version: 2)'));
expect(variables['castPerPage'], 50);
expect(variables['relatedPerPage'], 1);
expect(variables['staffPerPage'], 8);
});
test('unsupported rows throw instead of silently returning empty', () {
expect(() => source.fetchRow(CatalogRowId.recommendedMovies), throwsA(isA<ArgumentError>()));
});
@@ -331,10 +509,13 @@ void main() {
expect(empty, isEmpty);
expect(requests, isEmpty);
await source.search(' a ');
final results = await source.search(' a ');
expect(requests, hasLength(1));
final variables = _requestBody(requests.single)['variables'] as Map<String, dynamic>;
expect(results.single.cast, isNull);
final body = _requestBody(requests.single);
final variables = body['variables'] as Map<String, dynamic>;
expect(variables['search'], 'a');
expect(body['query'] as String, isNot(contains('characters(')));
});
test('watchlist snapshot matches the MAL identity form alone', () async {
@@ -438,34 +619,132 @@ void main() {
expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(anilist: 16498)), isTrue);
});
test('cast and related map characters and enriched recommendations', () async {
test('fetchDetail without cached cast requests live characters with the consolidated detail', () async {
responder = (request) {
final query = _requestBody(request)['query'] as String;
if (query.contains('characters(')) {
return _data({
'Media': {
'characters': {
'edges': [
{
'role': 'MAIN',
'node': {
'name': {'full': 'Mikasa Ackerman'},
'image': {'large': 'https://img.anilist.co/mikasa.jpg'},
},
},
],
final media = _media(id: 16498, idMal: 16498)
..addAll({
'tags': [
{'name': 'Military', 'rank': 88, 'isMediaSpoiler': false},
{'name': 'Hidden Identity', 'rank': 72, 'isMediaSpoiler': true},
],
'externalLinks': [
{'site': 'AniList', 'url': 'https://anilist.co/anime/16498'},
{'site': 'Unsafe', 'url': 'file:///tmp/not-opened'},
],
'streamingEpisodes': [
{
'title': 'Episode 1',
'thumbnail': 'https://img.example/episode-1.jpg',
'url': 'https://crunchyroll.example/episode-1',
'site': 'Crunchyroll',
},
],
'staff': {
'edges': [
{
'role': 'Director',
'node': {
'name': {'full': 'Tetsuro Araki'},
},
},
{
'role': 'Series Composition',
'node': {
'name': {'full': 'Yasuko Kobayashi'},
},
},
{
'role': 'Music',
'node': {
'name': {'full': 'Hiroyuki Sawano'},
},
},
{
'role': 'Character Design',
'node': {
'name': {'full': 'Kyoji Asano'},
},
},
],
},
'characters': {
'edges': [
{
'role': 'MAIN',
'node': {
'name': {'full': 'Mikasa Ackerman'},
'image': {'large': 'https://img.anilist.co/mikasa.jpg'},
},
},
],
},
});
}
return _data({
'Media': {
'recommendations': {
'nodes': [
{'mediaRecommendation': _media(id: 21519, idMal: 32281, title: 'Your Name.', format: 'MOVIE')},
{'mediaRecommendation': _media(id: 999, title: 'Adult', isAdult: true)},
],
},
'relations': {
'edges': [
{'relationType': 'SEQUEL', 'node': _media(id: 35760, idMal: 35760, title: 'Attack on Titan Season 3')},
{'relationType': 'SOURCE', 'node': _media(id: 1000, title: 'Attack on Titan Manga')},
],
},
});
return _data({'Media': media});
};
const item = CatalogItem(
source: CatalogSourceId.anilist,
kind: MediaKind.show,
title: 'Row title',
ids: CatalogItemIds(anilist: 16498),
ranks: [CatalogRank(rank: 99, scope: CatalogRankScope.trending)],
);
final detail = await source.fetchDetail(item);
expect(requests, hasLength(1));
expect(detail.item.title, 'Preferred');
expect(detail.item.ranks?.single.rank, 99);
expect(detail.cast.single.name, 'Mikasa Ackerman');
expect(detail.cast.single.secondary, 'MAIN');
final detailBody = _requestBody(requests.single);
final detailQuery = detailBody['query'] as String;
final detailVariables = detailBody['variables'] as Map<String, dynamic>;
expect(detailQuery, contains('characters(page: 1, perPage: \$castPerPage, sort: [ROLE, RELEVANCE])'));
expect(detailVariables['castPerPage'], 20);
expect(detail.related, hasLength(1));
expect(detail.related.single.kind, MediaKind.movie);
expect(detail.related.single.ids.tmdb, 372058);
expect(detail.relations, hasLength(2));
expect(detail.relations[0].type, CatalogRelationType.sequel);
expect(detail.relations[0].items.single.ids.anilist, 35760);
expect(detail.relations[1].type, CatalogRelationType.other);
expect(detail.relations[1].items.single.ids.anilist, 1000);
expect(detail.item.credits?.map((credit) => credit.role), [
CatalogCreditRole.director,
CatalogCreditRole.writer,
CatalogCreditRole.composer,
]);
expect(detail.item.tags, hasLength(2));
expect(detail.item.tags?[1].isSpoiler, isTrue);
expect(detail.item.links, hasLength(2));
expect(detail.item.links?[0].label, 'AniList');
expect(detail.item.links?[0].isStreaming, isFalse);
expect(detail.item.links?[1].label, 'Crunchyroll');
expect(detail.item.links?[1].isStreaming, isTrue);
});
test('fetchDetail serves cached row cast without selecting characters', () async {
responder = (request) {
final body = _requestBody(request);
expect(body['query'] as String, isNot(contains('characters(')));
final variables = body['variables'] as Map<String, dynamic>;
expect(variables.containsKey('castPerPage'), isFalse);
return _data({
'Media': {
'id': 16498,
'title': {'userPreferred': 'Attack on Titan'},
},
});
};
@@ -474,16 +753,62 @@ void main() {
kind: MediaKind.show,
title: 'Attack on Titan',
ids: CatalogItemIds(anilist: 16498),
cast: [
CatalogCastMember(name: 'Mikasa Ackerman', secondary: 'MAIN', imageUrl: 'https://img.anilist.co/mikasa.jpg'),
],
);
final cast = await source.fetchCast(item);
final related = await source.fetchRelated(item);
final detail = await source.fetchDetail(item);
expect(cast.single.name, 'Mikasa Ackerman');
expect(cast.single.secondary, 'MAIN');
expect(related, hasLength(1));
expect(related.single.kind, MediaKind.movie);
expect(related.single.ids.tmdb, 372058);
expect(requests, hasLength(1));
expect(detail.cast, hasLength(1));
expect(detail.cast.single.name, 'Mikasa Ackerman');
expect(detail.cast.single.secondary, 'MAIN');
expect(detail.cast.single.imageUrl, 'https://img.anilist.co/mikasa.jpg');
});
test('fetchDetail treats absent and empty optional collections as normal', () async {
responder = (_) => _data({
'Media': {
'id': 16498,
'title': {'userPreferred': 'Attack on Titan'},
'externalLinks': <Map<String, dynamic>>[],
'streamingEpisodes': <Map<String, dynamic>>[],
},
});
const item = CatalogItem(
source: CatalogSourceId.anilist,
kind: MediaKind.show,
title: 'Attack on Titan',
ids: CatalogItemIds(anilist: 16498),
);
final detail = await source.fetchDetail(item);
expect(detail.item.links, isNull);
expect(detail.item.tags, isNull);
expect(detail.item.credits, isNull);
expect(detail.cast, isEmpty);
expect(detail.related, isEmpty);
expect(detail.relations, isEmpty);
expect(requests, hasLength(1));
});
test('fetchDetail needs no request when the item has no AniList id', () async {
const item = CatalogItem(
source: CatalogSourceId.anilist,
kind: MediaKind.show,
title: 'Unmapped',
ids: CatalogItemIds(mal: 1),
);
final detail = await source.fetchDetail(item);
expect(detail.item, same(item));
expect(detail.cast, isEmpty);
expect(detail.related, isEmpty);
expect(detail.relations, isEmpty);
expect(requests, isEmpty);
});
});
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/models/catalog/catalog_metadata.dart';
import 'package:plezy/models/trackers/fribb_mapping_row.dart';
import 'package:plezy/services/catalog/catalog_source.dart';
import 'package:plezy/services/catalog/mal_catalog_source.dart';
@@ -51,6 +52,7 @@ Map<String, dynamic> _node({
List<String>? synonyms,
String mediaType = 'tv',
String status = 'finished_airing',
Map<String, dynamic> extra = const {},
}) => {
'id': id,
'title': title,
@@ -63,18 +65,43 @@ Map<String, dynamic> _node({
'studios': [
{'id': 858, 'name': 'Wit Studio'},
],
...extra,
};
Map<String, dynamic> _pageBody(List<Map<String, dynamic>> nodes, {bool hasMore = false}) => {
Map<String, dynamic> _pageBody(
List<Map<String, dynamic>> nodes, {
bool hasMore = false,
List<int?> rankingRanks = const [],
}) => {
'data': [
for (final node in nodes) {'node': node},
for (var i = 0; i < nodes.length; i++)
{
'node': nodes[i],
if (i < rankingRanks.length && rankingRanks[i] != null) 'ranking': {'rank': rankingRanks[i]},
},
],
'paging': {if (hasMore) 'next': 'https://api.myanimelist.net/v2/whatever?offset=2'},
};
const _expectedCatalogFields =
'id,title,main_picture,alternative_titles,start_date,synopsis,mean,'
'genres,media_type,rating,num_episodes,average_episode_duration,start_season,'
'status,studios,num_scoring_users,broadcast,popularity,num_list_users,rank,'
'nsfw,source,end_date';
const _expectedDetailFields =
'$_expectedCatalogFields,recommendations{$_expectedCatalogFields},'
'related_anime{$_expectedCatalogFields},statistics,pictures,background';
void main() {
// Attack on Titan: split-cour show — one Fribb row per season, same tvdb id.
const aotSeason1 = FribbMappingRow(malId: 16498, tvdbId: 267440, tvdbSeason: 1, imdbIds: ['tt2560140']);
const aotSeason1 = FribbMappingRow(
malId: 16498,
anilistId: 16498,
simklId: 43665,
tvdbId: 267440,
tvdbSeason: 1,
imdbIds: ['tt2560140'],
);
const aotSeason3 = FribbMappingRow(
malId: 35760,
tvdbId: 267440,
@@ -103,7 +130,23 @@ void main() {
return http.Response(
json.encode(
_pageBody([
_node(id: 16498, title: 'Shingeki no Kyojin', en: 'Attack on Titan'),
_node(
id: 16498,
title: 'Shingeki no Kyojin',
en: 'Attack on Titan',
ja: '進撃の巨人',
synonyms: const ['AoT'],
extra: const {
'start_season': {'year': 2013, 'season': 'spring'},
'broadcast': {'day_of_the_week': 'sunday', 'start_time': '23:30'},
'popularity': 1,
'num_list_users': 4100000,
'rank': 2,
'nsfw': 'black',
'source': 'manga',
'end_date': '2021-04-19',
},
),
_node(id: 32281, title: 'Kimi no Na wa.', en: 'Your Name.', mediaType: 'movie'),
]),
),
@@ -126,7 +169,7 @@ void main() {
final request = requests.single;
expect(request.url.path, '/v2/users/@me/animelist');
expect(request.url.queryParameters['status'], 'plan_to_watch');
expect(request.url.queryParameters['fields'], contains('alternative_titles'));
expect(request.url.queryParameters['fields'], _expectedCatalogFields);
expect(page.items, hasLength(2));
final show = page.items[0];
@@ -135,6 +178,8 @@ void main() {
expect(show.ids.mal, 16498);
expect(show.ids.tvdb, 267440);
expect(show.ids.imdb, 'tt2560140');
expect(show.ids.anilist, 16498);
expect(show.ids.simkl, 43665);
expect(show.source, CatalogSourceId.mal);
// List-endpoint metadata flows through to the item.
@@ -142,6 +187,22 @@ void main() {
expect(show.episodeCount, 25);
expect(show.votes, 2326268);
expect(show.network, 'Wit Studio');
expect(show.originalTitle, 'Shingeki no Kyojin');
expect(show.altTitles, ['Shingeki no Kyojin', '進撃の巨人', 'AoT']);
expect(show.broadcastSeason?.name, CatalogSeasonName.spring);
expect(show.broadcastSeason?.year, 2013);
expect(show.broadcast?.weekday, DateTime.sunday);
expect(show.broadcast?.time, '23:30');
expect(show.broadcast?.timezone, 'Asia/Tokyo');
expect(show.isAdult, isTrue);
expect(show.sourceMaterial, CatalogSourceMaterial.manga);
expect(show.endDate, DateTime(2021, 4, 19));
expect(show.audience?.listed, 4100000);
expect(show.ranks, hasLength(2));
expect(show.ranks?[0].scope, CatalogRankScope.popular);
expect(show.ranks?[0].rank, 1);
expect(show.ranks?[1].scope, CatalogRankScope.rated);
expect(show.ranks?[1].rank, 2);
final movie = page.items[1];
expect(movie.kind, MediaKind.movie);
@@ -151,6 +212,13 @@ void main() {
// finished_airing on a movie is noise, and movies have no episode chip.
expect(movie.airStatus, isNull);
expect(movie.episodeCount, isNull);
expect(movie.season, isNull);
expect(movie.broadcast, isNull);
expect(movie.isAdult, isNull);
expect(movie.sourceMaterial, isNull);
expect(movie.endDate, isNull);
expect(movie.ranks, isNull);
expect(movie.audience, isNull);
});
test('sequel entries preserve alternate-title order and both Fribb season numbers', () async {
@@ -179,64 +247,46 @@ void main() {
expect(item.season, const ExternalSeasonRef(tvdb: 3, tmdb: 2));
});
test('fetchCast maps MAL characters with joined names and roles', () async {
test('ranking sidecars map to the scope implied by each row', () async {
handlers.add(
(request) => http.Response(
json.encode({
'data': [
{
'node': {
'id': 11,
'first_name': 'Edward',
'last_name': 'Elric',
'main_picture': {'medium': 'https://cdn.myanimelist.net/images/characters/9/72533.jpg'},
},
'role': 'Main',
},
{
'node': {'id': 63, 'first_name': '', 'last_name': 'Winry'},
'role': 'Supporting',
},
{
'node': {'id': 99}, // nameless — skipped
'role': 'Supporting',
},
],
'paging': <String, dynamic>{},
}),
json.encode(
_pageBody([_node(id: 16498, title: 'Shingeki no Kyojin')], hasMore: true, rankingRanks: const [12]),
),
200,
),
);
handlers.add(
(request) => http.Response(
json.encode(
_pageBody(
[_node(id: 32281, title: 'Kimi no Na wa.', mediaType: 'movie')],
rankingRanks: const [7],
),
),
200,
),
);
final cast = await source.fetchCast(
const CatalogItem(
source: CatalogSourceId.mal,
kind: MediaKind.show,
title: 'Fullmetal Alchemist: Brotherhood',
ids: CatalogItemIds(mal: 5114),
),
);
final airing = await source.fetchRow(CatalogRowId.airingAnime, page: 3, limit: 50);
final popular = await source.fetchRow(CatalogRowId.popularAnime);
final request = requests.single;
expect(request.url.path, '/v2/anime/5114/characters');
expect(request.url.queryParameters['fields'], contains('first_name'));
expect(cast, hasLength(2));
expect(cast[0].name, 'Edward Elric');
expect(cast[0].secondary, 'Main');
expect(cast[0].imageUrl, 'https://cdn.myanimelist.net/images/characters/9/72533.jpg');
expect(cast[1].name, 'Winry');
});
expect(requests[0].url.path, '/v2/anime/ranking');
expect(requests[0].url.queryParameters['ranking_type'], 'airing');
expect(requests[0].url.queryParameters['limit'], '50');
expect(requests[0].url.queryParameters['offset'], '100');
expect(requests[0].url.queryParameters['fields'], _expectedCatalogFields);
expect(airing.hasMore, isTrue);
expect(airing.items.single.ranks, hasLength(1));
expect(airing.items.single.ranks?.single.rank, 12);
expect(airing.items.single.ranks?.single.scope, CatalogRankScope.airing);
expect(airing.items.single.ranks?.single.allTime, isTrue);
test('fetchRow(airingAnime) hits the ranking endpoint and pages by offset', () async {
handlers.add((request) => http.Response(json.encode(_pageBody([], hasMore: true)), 200));
final page = await source.fetchRow(CatalogRowId.airingAnime, page: 3, limit: 50);
final request = requests.single;
expect(request.url.path, '/v2/anime/ranking');
expect(request.url.queryParameters['ranking_type'], 'airing');
expect(request.url.queryParameters['limit'], '50');
expect(request.url.queryParameters['offset'], '100');
expect(page.hasMore, isTrue);
expect(requests[1].url.queryParameters['ranking_type'], 'bypopularity');
expect(popular.items.single.ranks, hasLength(1));
expect(popular.items.single.ranks?.single.rank, 7);
expect(popular.items.single.ranks?.single.scope, CatalogRankScope.popular);
expect(popular.items.single.ranks?.single.allTime, isTrue);
});
test('fetchRow throws on rows MAL does not serve', () {
@@ -346,44 +396,189 @@ void main() {
expect(requests, isEmpty);
});
test('fetchRelated reads the nested recommendations field and enriches via Fribb', () async {
handlers.add((request) {
expect(request.url.path, '/v2/anime/16498');
expect(request.url.queryParameters['fields'], startsWith('recommendations{'));
test('fetchDetail uses two requests and maps enrichment, cast, recommendations, and relations', () async {
http.Response respond(http.Request request) {
if (request.url.path.endsWith('/characters')) {
return http.Response(
json.encode({
'data': [
{
'node': {
'id': 11,
'first_name': 'Edward',
'last_name': 'Elric',
'main_picture': {'medium': 'https://cdn.myanimelist.net/images/characters/9/72533.jpg'},
},
'role': 'Main',
},
{
'node': {'id': 63, 'first_name': '', 'last_name': 'Winry'},
'role': 'Supporting',
},
{
'node': {'id': 99},
'role': 'Supporting',
},
],
'paging': <String, dynamic>{},
}),
200,
);
}
return http.Response(
json.encode({
'id': 16498,
..._node(
id: 16498,
title: 'Shingeki no Kyojin',
en: 'Attack on Titan',
extra: const {'synopsis': 'Full detail synopsis', 'num_list_users': 4100000},
),
'recommendations': [
{
'node': _node(id: 32281, title: 'Kimi no Na wa.', en: 'Your Name.', mediaType: 'movie'),
'num_recommendations': 42,
},
],
'related_anime': [
{
'node': _node(id: 35760, title: 'Shingeki no Kyojin Season 3'),
'relation_type': 'sequel',
'relation_type_formatted': 'Sequel',
},
],
'statistics': {
'num_list_users': 4100000,
'status': {
'watching': '120000',
'completed': '3500000',
'on_hold': '40000',
'dropped': '90000',
'plan_to_watch': '350000',
},
},
'pictures': [
{
'medium': 'https://cdn.myanimelist.net/images/anime/16498-poster-medium.jpg',
'large': 'https://cdn.myanimelist.net/images/anime/16498.jpg',
},
{
'medium': 'https://cdn.myanimelist.net/images/anime/16498-gallery-medium.jpg',
'large': 'https://cdn.myanimelist.net/images/anime/16498-gallery-large.jpg',
},
{'medium': 'https://cdn.myanimelist.net/images/anime/16498-medium-only.jpg'},
{'medium': 'https://cdn.myanimelist.net/images/anime/16498-fallback-medium.jpg', 'large': ''},
{'medium': '', 'large': ''},
],
'background': 'Created from the original manga.',
}),
200,
);
});
}
final item = CatalogItem(
handlers
..add(respond)
..add(respond);
const item = CatalogItem(
source: CatalogSourceId.mal,
kind: MediaKind.show,
title: 'Attack on Titan',
ids: const CatalogItemIds(mal: 16498),
overview: 'Row synopsis',
ids: CatalogItemIds(mal: 16498),
ranks: [CatalogRank(rank: 12, scope: CatalogRankScope.airing)],
);
final related = await source.fetchRelated(item);
expect(related.single.title, 'Your Name.');
expect(related.single.kind, MediaKind.movie);
expect(related.single.ids.tmdb, 372058);
final detail = await source.fetchDetail(item);
expect(requests, hasLength(2));
final detailRequest = requests.singleWhere((request) => request.url.path == '/v2/anime/16498');
final castRequest = requests.singleWhere((request) => request.url.path.endsWith('/characters'));
expect(detailRequest.url.queryParameters['fields'], _expectedDetailFields);
expect(castRequest.url.queryParameters['limit'], '20');
expect(castRequest.url.queryParameters['fields'], contains('first_name'));
expect(detail.item.overview, 'Full detail synopsis');
expect(detail.item.ranks?.single.rank, 12);
expect(detail.item.audience?.listed, 4100000);
expect(detail.item.audience?.watching, 120000);
expect(detail.item.audience?.completed, 3500000);
expect(detail.item.audience?.onHold, 40000);
expect(detail.item.audience?.dropped, 90000);
expect(detail.item.audience?.planning, 350000);
expect(detail.item.gallery, [
'https://cdn.myanimelist.net/images/anime/16498-gallery-large.jpg',
'https://cdn.myanimelist.net/images/anime/16498-medium-only.jpg',
'https://cdn.myanimelist.net/images/anime/16498-fallback-medium.jpg',
]);
expect(detail.item.background, 'Created from the original manga.');
expect(detail.item.posterVariants, isNull);
expect(detail.cast, hasLength(2));
expect(detail.cast[0].name, 'Edward Elric');
expect(detail.cast[0].secondary, 'Main');
expect(detail.cast[0].imageUrl, 'https://cdn.myanimelist.net/images/characters/9/72533.jpg');
expect(detail.cast[1].name, 'Winry');
expect(detail.related, hasLength(1));
expect(detail.related.single.title, 'Your Name.');
expect(detail.related.single.kind, MediaKind.movie);
expect(detail.related.single.ids.tmdb, 372058);
expect(detail.related.single.recommendationCount, 42);
expect(detail.relations, hasLength(1));
expect(detail.relations.single.type, CatalogRelationType.sequel);
expect(detail.relations.single.items.single.ids.mal, 35760);
expect(detail.relations.single.items.single.title, 'Shingeki no Kyojin Season 3');
});
test('fetchRelated without a mal id returns empty without a request', () async {
final item = CatalogItem(
test('fetchDetail normalizes a blank background and pictures without URLs to null', () async {
http.Response respond(http.Request request) {
if (request.url.path.endsWith('/characters')) {
return http.Response(json.encode({'data': <Object>[], 'paging': <String, dynamic>{}}), 200);
}
return http.Response(
json.encode({
..._node(id: 16498, title: 'Shingeki no Kyojin'),
'pictures': [
<String, dynamic>{},
{'medium': ' ', 'large': ''},
],
'background': ' \n\t ',
}),
200,
);
}
handlers
..add(respond)
..add(respond);
const item = CatalogItem(
source: CatalogSourceId.mal,
kind: MediaKind.show,
title: 'Attack on Titan',
ids: CatalogItemIds(mal: 16498),
);
final detail = await source.fetchDetail(item);
expect(detail.item.gallery, isNull);
expect(detail.item.background, isNull);
expect(detail.item.posterVariants, isNull);
});
test('fetchDetail without a mal id returns the unchanged item without a request', () async {
const item = CatalogItem(
source: CatalogSourceId.mal,
kind: MediaKind.show,
title: 'Unknown',
ids: const CatalogItemIds(tmdb: 1),
ids: CatalogItemIds(tmdb: 1),
);
expect(await source.fetchRelated(item), isEmpty);
final detail = await source.fetchDetail(item);
expect(identical(detail.item, item), isTrue);
expect(detail.cast, isEmpty);
expect(detail.related, isEmpty);
expect(detail.relations, isEmpty);
expect(requests, isEmpty);
});
});
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/models/catalog/catalog_metadata.dart';
import 'package:plezy/services/catalog/catalog_source.dart';
import 'package:plezy/services/catalog/plex_catalog_source.dart';
import 'package:plezy/services/plex_discover_client.dart';
@@ -43,11 +44,12 @@ Map<String, Object?> _metadata({
/// Discover answers `/hubs/sections/<section>` with placeholders only — the
/// shelf identity, never its items.
Map<String, Object?> _placeholderHub(String id, String title, {String type = 'mixed'}) => {
Map<String, Object?> _placeholderHub(String id, String title, {String type = 'mixed', String? style}) => {
'hubIdentifier': id,
'key': '/hubs/sections/home/${id.split('.').last}?source=home',
'title': title,
'type': type,
'style': ?style,
'placeholder': true,
'size': 0,
'more': true,
@@ -85,6 +87,7 @@ void main() {
expect(captured.headers['X-Plex-Token'], 'profile-token');
expect(captured.headers['X-Plex-Client-Identifier'], 'client-id');
expect(page.hasMore, isTrue);
expect(page.totalResults, 27);
final item = page.items.single;
expect(item.source, CatalogSourceId.plex);
@@ -97,6 +100,77 @@ void main() {
expect(item.genres, ['Science Fiction']);
});
test('maps every attributed score and leaves absent optional metadata null', () async {
final source = PlexCatalogSource(
PlexDiscoverClient(
_session,
httpClient: MockClient(
(_) async => jsonResponse({
'MediaContainer': {
'totalSize': 2,
'Metadata': [
{
..._metadata(),
'rating': 9.4,
'ratingImage': 'rottentomatoes://image.rating.ripe',
'audienceRating': 9.7,
'audienceRatingImage': 'rottentomatoes://image.rating.upright',
'imdbRatingCount': 250858,
'Rating': [
{'image': 'imdb://image.rating', 'type': 'audience', 'value': 8.5},
{'image': 'rottentomatoes://image.rating.ripe', 'type': 'critic', 'value': 94},
{'image': 'rottentomatoes://image.rating.upright', 'type': 'audience', 'value': 9.7},
{'image': 'themoviedb://image.rating', 'type': 'audience', 'value': 8},
],
'originallyAvailableAt': '2010-07-16',
'originalTitle': 'Origine',
'tagline': 'Your mind is the scene of the crime.',
'banner': 'https://metadata-static.plex.tv/banner.jpg',
'budget': 160000000,
'revenue': '839000000',
},
{
// Distinct external ids: the mapper dedupes on identity,
// and reusing the default imdb/tmdb would collapse this
// row into the scored one above.
..._metadata(ratingKey: 'plex-movie-2', title: 'No Score', imdb: 'tt0000002', tmdb: 2),
'rating': null,
},
],
},
}),
),
),
);
addTearDown(source.dispose);
final page = await source.fetchRow(CatalogRowId.watchlist);
final item = page.items.first;
final ratings = {for (final rating in item.ratings!) rating.source: (value: rating.value, votes: rating.votes)};
expect(item.rating, 9.4);
expect(ratings, {
'rottenTomatoesCritic': (value: 9.4, votes: null),
'rottenTomatoesAudience': (value: 9.7, votes: null),
'imdb': (value: 8.5, votes: 250858),
'tmdb': (value: 8.0, votes: null),
});
expect(item.releaseDate, DateTime(2010, 7, 16));
expect(item.originalTitle, 'Origine');
expect(item.tagline, 'Your mind is the scene of the crime.');
expect(item.bannerUrl, 'https://metadata-static.plex.tv/banner.jpg');
expect(item.budget, 160000000);
expect(item.revenue, 839000000);
final absent = page.items.last;
expect(absent.rating, isNull);
expect(absent.ratings, isNull);
expect(absent.releaseDate, isNull);
expect(absent.playState, isNull);
expect(absent.posterVariants, isNull);
expect(absent.backdropVariants, isNull);
});
test('home shelves are hydrated from their placeholder keys', () async {
final requests = <http.Request>[];
final source = PlexCatalogSource(
@@ -111,10 +185,11 @@ void main() {
return jsonResponse({
'MediaContainer': {
'Hub': [
_placeholderHub('home.trending-plex', 'Trending on Plex'),
_placeholderHub('home.trending-plex', 'Trending on Plex', style: 'shelf'),
_placeholderHub('home.genres', 'Browse by Genre', type: 'directory'),
_placeholderHub('home.new-trailers', 'New Trailers', type: 'clip'),
_placeholderHub('home.people', 'People'),
_placeholderHub('home.platforms', 'Available On', style: 'availabilityPlatforms'),
_placeholderHub('home.chris-nolan', 'The Films of Sir Christopher Nolan'),
],
},
@@ -124,7 +199,11 @@ void main() {
'MediaContainer': {
'Metadata': [
_metadata(),
_metadata(ratingKey: 'plex-show-1', type: 'show', title: 'Severance'),
{
..._metadata(ratingKey: 'plex-show-1', type: 'show', title: 'Severance'),
'isContinuingSeries': true,
'nextEpisodeOriginallyAvailableAt': '2026-08-04',
},
_metadata(ratingKey: 'plex-movie-2', title: 'Interstellar'),
],
},
@@ -137,6 +216,19 @@ void main() {
],
},
});
case '/hubs/sections/home/platforms':
return jsonResponse({
'MediaContainer': {
'Metadata': [
{
..._metadata(ratingKey: 'platform-1', title: 'A Platform Title'),
'viewCount': 2,
'viewOffset': 12345,
'viewedLeafCount': 7,
},
],
},
});
case '/hubs/sections/home/chris-nolan':
return jsonResponse({
'MediaContainer': {
@@ -158,22 +250,69 @@ void main() {
'/hubs/sections/home',
'/hubs/sections/home/trending-plex',
'/hubs/sections/home/people',
'/hubs/sections/home/platforms',
'/hubs/sections/home/chris-nolan',
]);
expect(requests[1].url.queryParameters, containsPair('limit', '3'));
expect(requests[1].url.queryParameters, containsPair('includeMeta', '1'));
expect(requests[1].url.queryParameters, containsPair('source', 'home'));
final shelfRequest = requests.singleWhere((request) => request.url.path == '/hubs/sections/home/trending-plex');
final platformRequest = requests.singleWhere((request) => request.url.path == '/hubs/sections/home/platforms');
expect(shelfRequest.url.queryParameters, containsPair('limit', '3'));
expect(shelfRequest.url.queryParameters, containsPair('includeMeta', '1'));
expect(shelfRequest.url.queryParameters, containsPair('includeUserState', '1'));
expect(shelfRequest.url.queryParameters, containsPair('source', 'home'));
expect(shelfRequest.url.queryParameters, containsPair('excludeElements', 'Media,Image'));
expect(platformRequest.url.queryParameters, containsPair('excludeElements', 'Media,Image'));
// The people-only shelf maps to nothing and drops out; provider order
// and titles survive for the rest.
expect(hubs.map((hub) => hub.id), ['home.trending-plex', 'home.chris-nolan']);
// The people-only shelf maps to nothing and drops out. Explicit styles
// survive, while an absent hint remains null instead of becoming shelf.
expect(hubs.map((hub) => hub.id), ['home.trending-plex', 'home.platforms', 'home.chris-nolan']);
expect(hubs.map((hub) => hub.style), [CatalogHubStyle.shelf, CatalogHubStyle.availabilityPlatforms, null]);
expect(hubs.first.title, 'Trending on Plex');
expect(hubs.first.page.items.map((item) => item.title), ['Inception', 'Severance']);
expect(hubs.first.page.hasMore, isTrue);
final show = hubs.first.page.items.last;
expect(show.airStatus, CatalogAirStatus.airing);
expect(show.nextEpisode?.airsAt, DateTime(2026, 8, 4));
expect(show.endDate, isNull);
final platformItem = hubs[1].page.items.single;
expect(platformItem.playState?.viewCount, 2);
expect(platformItem.playState?.viewOffsetMs, 12345);
expect(platformItem.playState?.viewedLeafCount, 7);
expect(hubs.last.page.items.single.title, 'The Prestige');
expect(hubs.last.page.hasMore, isFalse);
});
test('source forwards the explicit hub Image opt-in', () async {
final requests = <http.Request>[];
final source = PlexCatalogSource(
PlexDiscoverClient(
_session,
httpClient: MockClient((request) async {
requests.add(request);
if (request.url.path == '/hubs/sections/home') {
return jsonResponse({
'MediaContainer': {
'Hub': [_placeholderHub('home.spotlight', 'Spotlight')],
},
});
}
return jsonResponse({
'MediaContainer': {
'Metadata': [_metadata()],
},
});
}),
),
includeImageVariants: true,
);
addTearDown(source.dispose);
await source.fetchHubs();
expect(requests.last.url.queryParameters, containsPair('includeUserState', '1'));
expect(requests.last.url.queryParameters, containsPair('excludeElements', 'Media'));
});
test('View All takes a shelf in one request because Discover ignores offsets', () async {
final requests = <http.Request>[];
final source = PlexCatalogSource(
@@ -286,8 +425,8 @@ void main() {
'SearchResults': [
{
'SearchResult': [
{'Metadata': _metadata()},
{'Metadata': _metadata()},
{'score': 0.91, 'Metadata': _metadata()},
{'score': 0.42, 'Metadata': _metadata()},
{
'Metadata': {'ratingKey': 'person-1', 'type': 'person', 'title': 'A Person'},
},
@@ -310,6 +449,7 @@ void main() {
expect(captured.url.queryParameters, containsPair('searchProviders', 'discover'));
expect(results, hasLength(1));
expect(results.single.ids.plex, 'plex-movie-1');
expect(results.single.relevance, 0.91);
});
test('watchlist snapshot and mutation use the advertised action endpoint', () async {
@@ -375,44 +515,42 @@ void main() {
expect(requests.map((request) => request.url.path), ['/library/metadata/matches', '/actions/addToWatchlist']);
});
test('external-id matching enables cast and related detail flows', () async {
test('external-id matching and fetchDetail return enriched item, cast, and related', () async {
final requests = <http.Request>[];
final metadataResponse = Completer<http.Response>();
var relatedRequested = false;
final source = PlexCatalogSource(
PlexDiscoverClient(
_session,
httpClient: MockClient((request) async {
httpClient: MockClient((request) {
requests.add(request);
switch (request.url.path) {
case '/library/metadata/matches':
expect(request.url.queryParameters['guid'], 'imdb://tt1375666');
return jsonResponse({
'MediaContainer': {
'Metadata': [_metadata(type: 'show')],
},
});
return Future.value(
jsonResponse({
'MediaContainer': {
'Metadata': [_metadata(type: 'show')],
},
}),
);
case '/library/metadata/plex-movie-1':
return jsonResponse({
'MediaContainer': {
'Metadata': [
{
..._metadata(type: 'show'),
'Role': [
{'tag': 'Ken Watanabe', 'role': 'Saito', 'thumb': 'https://images.plex.tv/ken.jpg'},
],
},
],
},
});
return metadataResponse.future;
case '/library/metadata/plex-movie-1/related':
return jsonResponse({
'MediaContainer': {
'Hub': [
{
'Metadata': [_metadata(ratingKey: 'related-1', title: 'Interstellar')],
},
],
},
});
relatedRequested = true;
return Future.value(
jsonResponse({
'MediaContainer': {
'Hub': [
{
'Metadata': [_metadata(ratingKey: 'related-1', title: 'Interstellar')],
},
],
},
}),
);
}
return jsonResponse({'error': 'unexpected'}, status: 500);
return Future.value(jsonResponse({'error': 'unexpected'}, status: 500));
}),
),
);
@@ -426,14 +564,201 @@ void main() {
source: CatalogSourceId.plex,
kind: MediaKind.show,
title: 'Inception',
overview: 'Row overview.',
ids: CatalogItemIds(plex: 'plex-movie-1'),
relevance: 0.73,
);
final detailFuture = source.fetchDetail(item);
await Future<void>.delayed(Duration.zero);
expect(relatedRequested, isTrue, reason: 'metadata and related requests must start concurrently');
metadataResponse.complete(
jsonResponse({
'MediaContainer': {
'Metadata': [
{
..._metadata(type: 'show'),
'summary': 'Short summary.',
'Summary': [
{'type': 'default', 'tag': 'A complete and much longer summary from detail metadata.'},
],
'Role': [
{'tag': 'Ken Watanabe', 'role': 'Saito', 'thumb': 'https://images.plex.tv/ken.jpg'},
],
'Director': [
{'tag': 'Christopher Nolan'},
],
'Writer': [
{'tag': 'Jonathan Nolan'},
],
'Producer': [
{'tag': 'Emma Thomas'},
],
'Country': [
{'tag': 'United Kingdom'},
{'tag': 'United States of America'},
],
'Studio': [
{'tag': 'Warner Bros.'},
],
'Genre': [
{'tag': 'Science Fiction'},
{'tag': 'Thriller'},
],
'Rating': [
{'image': 'imdb://image.rating', 'type': 'audience', 'value': 8.5},
],
'imdbRatingCount': 250858,
'CommonSenseMedia': [
{
'oneLiner': 'Complex themes and sustained peril.',
'AgeRating': [
{'age': 15, 'rating': 5, 'type': 'official'},
],
},
],
'originallyAvailableAt': '2010-07-16',
'originalTitle': 'Origine',
'tagline': 'Your mind is the scene of the crime.',
'isContinuingSeries': false,
'lastEpisodeOriginallyAvailableAt': '2010-12-01',
'thumb': null,
'art': null,
'banner': null,
'Image': [
{
'type': 'clearLogoWide',
'alt': 'Inception',
'url': 'https://metadata-static.plex.tv/inception-logo.png',
},
{
'type': 'coverPoster',
'alt': 'Inception',
'url': 'https://metadata-static.plex.tv/inception-poster.jpg',
},
{
'type': 'background',
'alt': 'Inception',
'url': 'https://metadata-static.plex.tv/inception-background.jpg',
},
{'type': 'banner', 'alt': 'Inception', 'url': 'https://assets.fanart.tv/inception-banner.jpg'},
],
},
],
},
}),
);
final detail = await detailFuture;
expect(detail.item.overview, 'A complete and much longer summary from detail metadata.');
expect(detail.item.relevance, 0.73);
expect(detail.item.genres, ['Science Fiction', 'Thriller']);
expect(detail.item.studios, ['Warner Bros.']);
expect(detail.item.countries, ['GB', 'US']);
expect(
{for (final credit in detail.item.credits!) credit.role: credit.name},
{
CatalogCreditRole.director: 'Christopher Nolan',
CatalogCreditRole.writer: 'Jonathan Nolan',
CatalogCreditRole.producer: 'Emma Thomas',
},
);
expect(detail.item.contentAdvisory, '15+ · Complex themes and sustained peril.');
expect(detail.item.releaseDate, DateTime(2010, 7, 16));
expect(detail.item.endDate, DateTime(2010, 12, 1));
expect(detail.item.airStatus, CatalogAirStatus.ended);
expect(detail.item.originalTitle, 'Origine');
expect(detail.item.tagline, 'Your mind is the scene of the crime.');
expect(detail.item.logoUrl, 'https://metadata-static.plex.tv/inception-logo.png');
expect(detail.item.posterUrl, 'https://metadata-static.plex.tv/inception-poster.jpg');
expect(detail.item.backdropUrl, 'https://metadata-static.plex.tv/inception-background.jpg');
expect(detail.item.bannerUrl, 'https://assets.fanart.tv/inception-banner.jpg');
expect(detail.item.posterVariants, isNull);
expect(detail.item.backdropVariants, isNull);
expect(
detail.item.ratings,
contains(
isA<CatalogRatingSource>()
.having((rating) => rating.source, 'source', 'imdb')
.having((rating) => rating.value, 'value', 8.5)
.having((rating) => rating.votes, 'votes', 250858),
),
);
expect(detail.cast.single.name, 'Ken Watanabe');
expect(detail.cast.single.secondary, 'Saito');
expect(detail.related.single.title, 'Interstellar');
expect(requests.where((request) => request.url.path == '/library/metadata/plex-movie-1'), hasLength(1));
expect(requests.where((request) => request.url.path == '/library/metadata/plex-movie-1/related'), hasLength(1));
});
test('fetchDetail keeps enrichment and cast when the related call fails', () async {
final source = PlexCatalogSource(
PlexDiscoverClient(
_session,
httpClient: MockClient((request) async {
if (request.url.path.endsWith('/related')) {
return jsonResponse({'error': 'related unavailable'}, status: 503);
}
return jsonResponse({
'MediaContainer': {
'Metadata': [
{
..._metadata(),
'summary': 'Detailed overview.',
'Director': [
{'tag': 'Christopher Nolan'},
],
'Role': [
{'tag': 'Ken Watanabe', 'role': 'Saito'},
],
},
],
},
});
}),
),
);
addTearDown(source.dispose);
const item = CatalogItem(
source: CatalogSourceId.plex,
kind: MediaKind.movie,
title: 'Inception',
ids: CatalogItemIds(plex: 'plex-movie-1'),
);
final cast = await source.fetchCast(item);
final related = await source.fetchRelated(item);
expect(cast.single.name, 'Ken Watanabe');
expect(cast.single.secondary, 'Saito');
expect(related.single.title, 'Interstellar');
final detail = await source.fetchDetail(item);
expect(detail.item.overview, 'Detailed overview.');
expect(detail.item.credits?.single.name, 'Christopher Nolan');
expect(detail.cast.single.name, 'Ken Watanabe');
expect(detail.related, isEmpty);
});
test('fetchDetail without a Plex rating key returns the row without requests', () async {
var requested = false;
final source = PlexCatalogSource(
PlexDiscoverClient(
_session,
httpClient: MockClient((_) async {
requested = true;
return jsonResponse({'error': 'unexpected'}, status: 500);
}),
),
);
addTearDown(source.dispose);
const item = CatalogItem(
source: CatalogSourceId.plex,
kind: MediaKind.movie,
title: 'Inception',
ids: CatalogItemIds(imdb: 'tt1375666'),
);
final detail = await source.fetchDetail(item);
expect(identical(detail.item, item), isTrue);
expect(detail.cast, isEmpty);
expect(detail.related, isEmpty);
expect(requested, isFalse);
});
test('Discover requests have a bounded duration', () async {
@@ -1,7 +1,10 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/testing.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/models/catalog/catalog_metadata.dart';
import 'package:plezy/models/seerr/seerr_session.dart';
import 'package:plezy/services/catalog/catalog_source.dart';
import 'package:plezy/services/catalog/seerr_catalog_source.dart';
@@ -37,26 +40,37 @@ SeerrCatalogSource _source(MockClient mock) {
void main() {
group('SeerrCatalogSource', () {
test('trending row keeps movies and shows, drops people, maps TMDB images', () async {
test('trending maps row metadata, total results, and the responsive TMDB image ladders', () async {
final source = _source(
MockClient((request) async {
expect(request.url.path, '/api/v1/discover/trending');
return jsonResponse({
'page': 1,
'totalPages': 3,
'totalResults': 41,
'results': [
{
'id': 603,
'mediaType': 'movie',
'title': 'The Matrix',
'originalTitle': 'Matrix, The',
'originalLanguage': 'en',
'releaseDate': '1999-03-30',
'posterPath': '/matrix.jpg',
'backdropPath': '/matrix-backdrop.jpg',
'voteAverage': 8.2,
'voteCount': 26000,
'popularity': 42.5,
'adult': false,
},
{'id': 9, 'mediaType': 'person', 'name': 'Keanu Reeves'},
{'id': 1396, 'mediaType': 'tv', 'name': 'Breaking Bad', 'firstAirDate': '2008-01-20'},
{
'id': 1396,
'mediaType': 'tv',
'name': 'Breaking Bad',
'firstAirDate': '2008-01-20',
'originCountry': ['us', 'GB'],
},
],
});
}),
@@ -64,6 +78,7 @@ void main() {
final page = await source.fetchRow(CatalogRowId.trending);
expect(page.hasMore, isTrue);
expect(page.totalResults, 41);
expect(page.items, hasLength(2));
final matrix = page.items.first;
@@ -71,21 +86,168 @@ void main() {
expect(matrix.kind, MediaKind.movie);
expect(matrix.title, 'The Matrix');
expect(matrix.year, 1999);
expect(matrix.releaseDate, DateTime(1999, 3, 30));
expect(matrix.rating, 8.2);
expect(matrix.votes, 26000);
expect(matrix.ids.tmdb, 603);
expect(matrix.originalTitle, 'Matrix, The');
expect(matrix.languages, ['en']);
expect(matrix.isAdult, isFalse);
expect(matrix.relevance, 42.5);
expect(matrix.posterUrl, 'https://image.tmdb.org/t/p/w600_and_h900_bestv2/matrix.jpg');
expect(matrix.backdropUrl, 'https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/matrix-backdrop.jpg');
expect(matrix.posterVariants?.keys, [92, 154, 185, 342, 500, 600, 780]);
expect(matrix.posterVariants?[342], 'https://image.tmdb.org/t/p/w342/matrix.jpg');
expect(matrix.posterVariants?[600], matrix.posterUrl);
expect(matrix.backdropVariants?.keys, [300, 780, 1280, 1920]);
expect(matrix.backdropVariants?[1920], matrix.backdropUrl);
expect(page.items.last.kind, MediaKind.show);
expect(page.items.last.title, 'Breaking Bad');
expect(page.items.last.countries, ['US', 'GB']);
expect(page.items.last.posterVariants, isNull);
expect(page.items.last.serverState, isNull);
});
test('maps availability and request state independently with explicit pending semantics', () async {
final source = _source(
MockClient(
(request) async => jsonResponse({
'page': 1,
'totalPages': 1,
'results': [
{
'id': 1,
'mediaType': 'movie',
'title': 'Available',
'mediaInfo': {'status': 5},
},
{
'id': 2,
'mediaType': 'tv',
'name': 'Partial',
'mediaInfo': {
'status': 4,
'seasons': [
{'seasonNumber': 0, 'status': 5},
{'seasonNumber': 1, 'status': 5},
{'seasonNumber': 2, 'status': 4},
{'seasonNumber': 3, 'status': 1},
],
},
},
{
'id': 3,
'mediaType': 'movie',
'title': 'HD available, 4K pending',
'mediaInfo': {
'status': 5,
'status4k': 2,
'requests': [
{'id': 31, 'status': 1, 'is4k': true},
],
},
},
{
'id': 4,
'mediaType': 'movie',
'title': 'Requested',
'mediaInfo': {'status': 2},
},
{
'id': 5,
'mediaType': 'movie',
'title': 'Pending approval',
'mediaInfo': {
'status': 1,
'requests': [
{'id': 51, 'status': 1, 'is4k': false},
],
},
},
{
'id': 6,
'mediaType': 'movie',
'title': 'Declined',
'mediaInfo': {
'status': 1,
'requests': [
{'id': 61, 'status': 3, 'is4k': false},
],
},
},
{
'id': 7,
'mediaType': 'movie',
'title': 'Processing',
'mediaInfo': {
'status': 3,
'requests': [
{'id': 71, 'status': 2, 'is4k': false},
],
},
},
],
}),
),
);
final page = await source.fetchRow(CatalogRowId.trending);
final byTitle = {for (final item in page.items) item.title: item.serverState};
expect(byTitle['Available']?.availability, CatalogAvailability.available);
expect(byTitle['Available']?.request, isNull);
expect(byTitle['Partial']?.availability, CatalogAvailability.partiallyAvailable);
expect(byTitle['Partial']?.availableSeasons, 1);
expect(byTitle['Partial']?.totalSeasons, 3);
final pending4k = byTitle['HD available, 4K pending'];
expect(pending4k?.availability, CatalogAvailability.available);
expect(pending4k?.availability4k, CatalogAvailability.unavailable);
expect(pending4k?.request, isNull);
expect(pending4k?.request4k, CatalogRequestState.pending);
// MediaInfo.status=2 is acquisition-pipeline "pending", not approval
// pending. Only requests[].status=1 maps to CatalogRequestState.pending.
expect(byTitle['Requested']?.availability, CatalogAvailability.unavailable);
expect(byTitle['Requested']?.request, CatalogRequestState.approved);
expect(byTitle['Pending approval']?.availability, isNull);
expect(byTitle['Pending approval']?.request, CatalogRequestState.pending);
expect(byTitle['Declined']?.request, CatalogRequestState.declined);
expect(byTitle['Processing']?.availability, CatalogAvailability.unavailable);
expect(byTitle['Processing']?.request, CatalogRequestState.processing);
});
test('preserves full dates and retains the coarse year fallback for malformed input', () async {
final source = _source(
MockClient(
(request) async => jsonResponse({
'page': 1,
'totalPages': 1,
'results': [
{'id': 1, 'title': 'Valid', 'releaseDate': '2027-04-09'},
{'id': 2, 'title': 'Malformed', 'releaseDate': '2028-not-a-date'},
{'id': 3, 'title': 'Empty', 'releaseDate': ''},
],
}),
),
);
final page = await source.fetchRow(CatalogRowId.upcomingMovies);
expect(page.items[0].releaseDate, DateTime(2027, 4, 9));
expect(page.items[0].year, 2027);
expect(page.items[1].releaseDate, isNull);
expect(page.items[1].year, 2028);
expect(page.items[2].releaseDate, isNull);
expect(page.items[2].year, isNull);
});
test('single-type rows hit their endpoint and coerce the kind', () async {
final paths = <String>[];
late Uri uri;
final source = _source(
MockClient((request) async {
paths.add('${request.url.path}?${request.url.query}');
uri = request.url;
return jsonResponse({
'page': 2,
'totalPages': 2,
@@ -97,9 +259,12 @@ void main() {
);
final page = await source.fetchRow(CatalogRowId.upcomingMovies, page: 2);
expect(paths.single, '/api/v1/discover/movies/upcoming?page=2');
expect(uri.path, '/api/v1/discover/movies/upcoming');
expect(uri.queryParameters['page'], '2');
expect(uri.queryParameters['language'], isNotEmpty);
expect(page.items.single.kind, MediaKind.movie);
expect(page.hasMore, isFalse);
expect(page.totalResults, isNull);
});
test('rows Seerr does not serve throw', () {
@@ -116,36 +281,112 @@ void main() {
expect(await source.resolveItemIds(MediaKind.movie, const ExternalIds(imdb: 'tt0133093')), isNull);
});
test('fetchCast reads credits off the detail endpoint', () async {
test('fetchDetail runs both GETs concurrently and enriches TV metadata, cast, and recommendations', () async {
final started = <String>{};
final bothStarted = Completer<void>();
final source = _source(
MockClient((request) async {
started.add(request.url.path);
if (!bothStarted.isCompleted && started.length == 2) bothStarted.complete();
await bothStarted.future;
if (request.url.path == '/api/v1/tv/1396/recommendations') {
return jsonResponse({
'page': 1,
'totalPages': 1,
'results': [
{'id': 60059, 'name': 'Better Call Saul', 'firstAirDate': '2015-02-08'},
],
});
}
expect(request.url.path, '/api/v1/tv/1396');
return jsonResponse({
'id': 1396,
'name': 'Breaking Bad',
'originalName': 'Breaking Bad Original',
'originalLanguage': 'en',
'languages': ['en', 'es'],
'originCountry': ['gb'],
'firstAirDate': '2008-01-20',
'lastAirDate': '2013-09-29',
'episodeRunTime': [47],
'numberOfEpisodes': 62,
'genres': [
{'id': 18, 'name': 'Drama'},
{'id': 80, 'name': 'Crime'},
],
'networks': [
{'id': 174, 'name': 'AMC'},
],
'productionCompanies': [
{'id': 11073, 'name': 'Sony Pictures Television'},
],
'productionCountries': [
{'iso_3166_1': 'us', 'name': 'United States of America'},
],
'status': 'Ended',
'tagline': 'All bad things must come to an end.',
'contentRatings': {
'results': [
{'iso_3166_1': 'US', 'rating': 'TV-14'},
],
},
'externalIds': {'imdbId': 'tt0903747', 'tvdbId': 81189},
'createdBy': [
{'id': 1, 'name': 'Vince Gilligan'},
],
'keywords': [
{'id': 1, 'name': 'new mexico'},
],
'credits': {
'cast': [
{'name': 'Bryan Cranston', 'character': 'Walter White', 'profilePath': '/bc.jpg'},
{'name': '', 'character': 'nobody'},
{'name': 'Aaron Paul', 'character': 'Jesse Pinkman'},
],
'crew': [
{'name': 'Michelle MacLaren', 'job': 'Director', 'department': 'Directing'},
],
},
});
}),
);
final item = CatalogItem(
final rowItem = CatalogItem(
source: CatalogSourceId.seerr,
kind: MediaKind.show,
title: 'Breaking Bad',
title: 'Row title',
ids: const CatalogItemIds(tmdb: 1396),
);
final cast = await source.fetchCast(item);
expect(cast, hasLength(2));
expect(cast.first.name, 'Bryan Cranston');
expect(cast.first.secondary, 'Walter White');
expect(cast.first.imageUrl, 'https://image.tmdb.org/t/p/w300/bc.jpg');
expect(cast.last.imageUrl, isNull);
final detail = await source.fetchDetail(rowItem);
expect(started, {'/api/v1/tv/1396', '/api/v1/tv/1396/recommendations'});
expect(detail.item.title, 'Breaking Bad');
expect(detail.item.runtimeMinutes, 47);
expect(detail.item.genres, ['Drama', 'Crime']);
expect(detail.item.certification, 'TV-14');
expect(detail.item.airStatus, CatalogAirStatus.ended);
expect(detail.item.episodeCount, 62);
expect(detail.item.network, 'AMC');
expect(detail.item.ids.imdb, 'tt0903747');
expect(detail.item.ids.tvdb, 81189);
expect(detail.item.releaseDate, DateTime(2008, 1, 20));
expect(detail.item.endDate, DateTime(2013, 9, 29));
expect(detail.item.originalTitle, 'Breaking Bad Original');
expect(detail.item.tagline, 'All bad things must come to an end.');
expect(detail.item.studios, ['Sony Pictures Television']);
expect(detail.item.countries, ['GB', 'US']);
expect(detail.item.languages, ['en', 'es']);
expect(detail.item.credits?.map((credit) => credit.name), containsAll(['Vince Gilligan', 'Michelle MacLaren']));
expect(detail.item.tags?.single.name, 'new mexico');
expect(detail.cast, hasLength(2));
expect(detail.cast.first.name, 'Bryan Cranston');
expect(detail.cast.first.secondary, 'Walter White');
expect(detail.cast.first.imageUrl, 'https://image.tmdb.org/t/p/w300/bc.jpg');
expect(detail.cast.last.imageUrl, isNull);
expect(detail.related.single.title, 'Better Call Saul');
expect(detail.related.single.kind, MediaKind.show);
});
test('search proxies /search and filters persons', () async {
@@ -168,9 +409,97 @@ void main() {
expect(items.single.ids.tmdb, 603);
});
test('fetchRelated proxies the recommendations endpoint and coerces the kind', () async {
test('fetchDetail keeps movie enrichment when recommendations fail', () async {
final source = _source(
MockClient((request) async {
if (request.url.path == '/api/v1/movie/603/recommendations') {
return jsonResponse({'message': 'recommendations unavailable'}, status: 500);
}
expect(request.url.path, '/api/v1/movie/603');
return jsonResponse({
'id': 603,
'imdbId': 'tt0133093',
'title': 'The Matrix',
'originalTitle': 'The Matrix Original',
'originalLanguage': 'en',
'releaseDate': '1999-03-30',
'runtime': 136,
'adult': true,
'budget': 63000000,
'revenue': 466000000,
'genres': [
{'id': 28, 'name': 'Action'},
{'id': 878, 'name': 'Science Fiction'},
],
'productionCompanies': [
{'id': 79, 'name': 'Village Roadshow Pictures'},
],
'productionCountries': [
{'name': 'United States of America'},
],
'spokenLanguages': [
{'iso_639_1': 'en', 'name': 'English'},
],
'status': 'Released',
'tagline': 'Welcome to the Real World.',
'releases': {
'results': [
{
'iso_3166_1': 'CA',
'release_dates': [
{'certification': '14A'},
],
},
{
'iso_3166_1': 'US',
'release_dates': [
{'certification': 'R'},
],
},
],
},
'relatedVideos': [
{'site': 'YouTube', 'key': 'clip', 'type': 'Clip'},
{'site': 'YouTube', 'key': 'm8e-FF8MsqU', 'type': 'Trailer'},
],
'credits': {
'crew': [
{'name': 'Lana Wachowski', 'job': 'Director', 'department': 'Directing'},
],
},
});
}),
);
final item = CatalogItem(
source: CatalogSourceId.seerr,
kind: MediaKind.movie,
title: 'Row title',
ids: const CatalogItemIds(tmdb: 603),
);
final detail = await source.fetchDetail(item);
expect(detail.related, isEmpty);
expect(detail.item.title, 'The Matrix');
expect(detail.item.runtimeMinutes, 136);
expect(detail.item.certification, 'R');
expect(detail.item.trailerUrl, 'https://www.youtube.com/watch?v=m8e-FF8MsqU');
expect(detail.item.airStatus, isNull);
expect(detail.item.ids.imdb, 'tt0133093');
expect(detail.item.isAdult, isTrue);
expect(detail.item.budget, 63000000);
expect(detail.item.revenue, 466000000);
expect(detail.item.studios, ['Village Roadshow Pictures']);
expect(detail.item.countries, ['US']);
expect(detail.item.languages, ['en']);
expect(detail.item.credits?.single.role, CatalogCreditRole.director);
});
test('fetchDetail keeps recommendations when the detail GET fails', () async {
final source = _source(
MockClient((request) async {
if (request.url.path == '/api/v1/movie/603') {
return jsonResponse({'message': 'detail unavailable'}, status: 500);
}
expect(request.url.path, '/api/v1/movie/603/recommendations');
return jsonResponse({
'page': 1,
@@ -187,9 +516,12 @@ void main() {
title: 'The Matrix',
ids: const CatalogItemIds(tmdb: 603),
);
final related = await source.fetchRelated(item);
expect(related.single.title, 'The Matrix Reloaded');
expect(related.single.kind, MediaKind.movie);
final detail = await source.fetchDetail(item);
expect(detail.item, same(item));
expect(detail.cast, isEmpty);
expect(detail.related.single.title, 'The Matrix Reloaded');
expect(detail.related.single.kind, MediaKind.movie);
});
test('canRequest honors the per-kind permission split', () {
@@ -205,5 +537,34 @@ void main() {
expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), isNull);
expect(() => source.addToWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), throwsUnsupportedError);
});
test('an untranslated locale degrades to the original title instead of blanking', () async {
// TMDB answers `language=` for a title it has no translation of with an
// empty string, not a missing field. Sending the app locale must not
// turn a populated row into a blank one.
final source = _source(
MockClient((request) async {
return jsonResponse({
'page': 1,
'totalPages': 1,
'totalResults': 1,
'results': [
{
'id': 603,
'mediaType': 'movie',
'title': '',
'originalTitle': 'The Matrix',
'overview': '',
'releaseDate': '1999-03-30',
},
],
});
}),
);
final item = (await source.fetchRow(CatalogRowId.trending)).items.single;
expect(item.title, 'The Matrix');
expect(item.overview, isNull, reason: 'a blank overview must not render as an empty block');
});
});
}
@@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/models/catalog/catalog_metadata.dart';
import 'package:plezy/models/simkl/simkl_trending_item.dart';
import 'package:plezy/services/catalog/catalog_source.dart';
import 'package:plezy/services/catalog/simkl_catalog_source.dart';
@@ -31,25 +32,41 @@ http.Response _json(Object? body, {int status = 200, Map<String, String>? header
Map<String, dynamic> _trending({required int simkl, String title = 'Inception', String? animeType}) => {
'title': title,
'title_romaji': animeType == null ? null : 'Inception no Yume',
'alt_titles': [
{'name': title},
{'name': 'Le rêve'},
],
'url': '/${animeType == null ? 'movies' : 'anime'}/$simkl/inception',
'poster': '12/posterhash',
'fanart': '34/fanarthash',
'release_date': '07/16/2010',
'rank': 7,
'drop_rate': '2.5%',
'watched': 321,
'plan_to_watch': 654,
'runtime': '2h 37m',
'status': 'ended',
'overview': 'Dreams within dreams.',
'genres': ['Action', 'Science Fiction'],
'trailer': 'YoHD9XEInc0',
'country': 'us',
'original_language': 'en',
'dvd_date': '12/03/2010',
'theater': '07/15/2010',
'total_episodes': animeType == null ? null : 12,
'anime_type': ?animeType,
'ids': {'simkl_id': simkl, 'slug': 'inception', 'imdb': 'tt1375666', 'tmdb': '27205', 'tvdb': '123'},
'ratings': {
'simkl': {'rating': 8.8, 'votes': 1234},
if (animeType == null) 'imdb': {'rating': 8.7, 'votes': 2345} else 'mal': {'rating': 8.6, 'votes': 3456},
},
};
Map<String, dynamic> _best(int simkl) => {
Map<String, dynamic> _best(int simkl, {int? watched}) => {
'title': 'Best $simkl',
'year': 2020,
'watched': ?watched,
'ids': {'simkl': simkl},
};
@@ -69,6 +86,9 @@ Map<String, dynamic> _allItemsBody() => {
'shows': [
{
'status': 'plantowatch',
'added_to_watchlist_at': '2025-04-03T02:01:00Z',
'user_rating': 9,
'not_aired_episodes_count': 4,
'total_episodes_count': 62,
'show': {
'title': 'Breaking Bad',
@@ -92,6 +112,51 @@ Map<String, dynamic> _allItemsBody() => {
],
};
Map<String, dynamic> _detailBody() => {
'title': 'One Piece',
'year': 1999,
'type': 'anime',
'anime_type': 'tv',
'poster': '15/detailposter',
'fanart': '87/detailfanart',
'first_aired': '1999-10-20T14:15:00Z',
'last_aired': null,
'airs': {'day': 'Sunday', 'time': '11:15 PM', 'timezone': 'Asia/Tokyo'},
'runtime': 25,
'certification': 'PG-13',
'overview': 'The full detail overview.',
'genres': ['Action', 'Adventure'],
'country': 'jp',
'total_episodes': 1176,
'status': 'airing',
'network': 'Fuji TV',
'season_name_year': 'Fall 1999',
'studios': [
{'id': 74, 'name': 'Toei Animation'},
],
'ratings': {
'simkl': {'rating': 8.9, 'votes': 8037},
'imdb': {'rating': 8.3, 'votes': 15720},
'mal': {'rating': 8.7, 'votes': 1548013},
},
'trailers': [
{'youtube': 'tnj5YOZpCyo', 'size': 1080},
],
'ids': {'simkl': 3, 'mal': 21, 'anilist': 21},
'users_recommendations': [
{
'title': 'Fairy Tail',
'year': 2009,
'poster': '64/related',
'type': 'anime',
'anime_type': 'tv',
'users_percent': '19%',
'users_count': 76,
'ids': {'simkl': 4},
},
],
};
void main() {
group('Simkl models', () {
test('parses runtime strings and rejects malformed values', () {
@@ -151,6 +216,24 @@ void main() {
expect(first.items.single.runtimeMinutes, 157);
expect(first.items.single.posterUrl, 'https://simkl.in/posters/12/posterhash_m.webp');
expect(first.items.single.backdropUrl, 'https://simkl.in/fanart/34/fanarthash_medium.webp');
final item = first.items.single;
expect(item.posterVariants, containsPair(170, 'https://simkl.in/posters/12/posterhash_c.webp'));
expect(item.posterVariants, containsPair(340, item.posterUrl));
expect(item.backdropVariants, containsPair(960, 'https://simkl.in/fanart/34/fanarthash_mobile.webp'));
expect(item.backdropVariants, containsPair(1920, item.backdropUrl));
expect(item.ratings?.map((rating) => rating.source), ['simkl', 'imdb']);
expect(item.ratings?.map((rating) => rating.votes), [1234, 2345]);
expect(item.audience?.viewers, 321);
expect(item.audience?.viewersPeriod, CatalogAudiencePeriod.week);
expect(item.audience?.planning, 654);
expect(item.audience?.dropRate, 0.025);
expect(item.ranks?.single.scope, CatalogRankScope.trending);
expect(item.ranks?.single.allTime, isFalse);
expect(item.releaseDate, DateTime(2010, 7, 15));
expect(item.physicalReleaseDate, DateTime(2010, 12, 3));
expect(item.countries, ['US']);
expect(item.languages, ['en']);
expect(item.links?.single.url, 'https://simkl.com/movies/1/inception');
expect(second.items.single.title, 'Second');
expect(second.hasMore, isFalse);
});
@@ -181,6 +264,15 @@ void main() {
expect(page.hasMore, isFalse);
});
test('best watched counts are monthly viewers', () async {
responder = (request) => _json([_best(1, watched: 987)]);
final page = await source.fetchRow(CatalogRowId.popularShows);
expect(page.items.single.audience?.viewers, 987);
expect(page.items.single.audience?.viewersPeriod, CatalogAudiencePeriod.month);
});
test('best rows expose all cached pages without refetching', () async {
responder = (request) {
expect(request.url.path, '/tv/best/watched');
@@ -219,9 +311,35 @@ void main() {
expect(movie.runtimeMinutes, 148);
expect(first.items.last.kind, MediaKind.movie);
expect(first.items.last.ids.anilist, 21519);
final show = first.items[1];
expect(show.addedAt, DateTime.parse('2025-04-03T02:01:00Z'));
expect(show.userRating, 9);
expect(show.unairedEpisodeCount, 4);
expect(second.items, hasLength(3));
});
test('absent optional row fields stay absent', () async {
responder = (request) => _json([
{
'title': 'Sparse',
'ids': {'simkl_id': 99},
},
]);
final page = await source.fetchRow(CatalogRowId.trendingMovies);
final item = page.items.single;
expect(item.ratings, isNull);
expect(item.audience, isNull);
expect(item.ranks, isNull);
expect(item.posterUrl, isNull);
expect(item.posterVariants, isNull);
expect(item.backdropVariants, isNull);
expect(item.originalTitle, isNull);
expect(item.altTitles, isEmpty);
expect(item.links, isNull);
});
test('watchlist row and membership snapshot share one full-library download', () async {
responder = (request) {
expect(request.url.queryParameters['extended'], 'full');
@@ -337,47 +455,59 @@ void main() {
expect(results.map((item) => item.kind), [MediaKind.movie, MediaKind.show, MediaKind.movie]);
});
test('fetchCast performs no requests', () async {
test('fetchDetail without a Simkl id returns the row unchanged without a request', () async {
const item = CatalogItem(
source: CatalogSourceId.simkl,
kind: MediaKind.movie,
title: 'Inception',
ids: CatalogItemIds(simkl: 1),
ids: CatalogItemIds(imdb: 'tt1375666'),
);
expect(await source.fetchCast(item), isEmpty);
final detail = await source.fetchDetail(item);
expect(detail.item, same(item));
expect(detail.cast, isEmpty);
expect(detail.related, isEmpty);
expect(detail.relations, isEmpty);
expect(requests, isEmpty);
});
test('related retries anime when the kind endpoint returns an empty array', () async {
test('fetchDetail uses one request for an enriched item and recommendations', () async {
responder = (request) {
expect(request.url.queryParameters, isNot(contains('extended')));
if (request.url.path == '/tv/3') return _json([]);
if (request.url.host == 'data.simkl.in') {
return _json([_trending(simkl: 3, animeType: 'tv')]);
}
expect(request.url.path, '/anime/3');
return _json({
'users_recommendations': [
{
'title': 'A Silent Voice',
'year': 2016,
'poster': '1/related',
'type': 'anime',
'ids': {'simkl': 4, 'mal': 28851},
},
],
});
expect(request.url.queryParameters, isNot(contains('extended')));
return _json(_detailBody());
};
const item = CatalogItem(
source: CatalogSourceId.simkl,
kind: MediaKind.show,
title: 'Anime',
ids: CatalogItemIds(simkl: 3),
);
final row = await source.fetchRow(CatalogRowId.trendingAnime);
final item = row.items.single;
requests.clear();
final related = await source.fetchRelated(item);
final detail = await source.fetchDetail(item);
expect(requests.map((request) => request.url.path), ['/tv/3', '/anime/3']);
expect(related.single.title, 'A Silent Voice');
expect(related.single.ids.mal, 28851);
expect(requests, hasLength(1));
expect(detail.cast, isEmpty);
expect(detail.relations, isEmpty);
expect(detail.item.title, 'One Piece');
expect(detail.item.overview, 'The full detail overview.');
expect(detail.item.certification, 'PG-13');
expect(detail.item.runtimeMinutes, 25);
expect(detail.item.ratings?.map((rating) => rating.source), ['simkl', 'imdb', 'mal']);
expect(detail.item.ranks, same(item.ranks));
expect(detail.item.posterVariants, containsPair(170, 'https://simkl.in/posters/15/detailposter_c.webp'));
expect(detail.item.backdropVariants, containsPair(960, 'https://simkl.in/fanart/87/detailfanart_mobile.webp'));
expect(detail.item.trailerUrl, 'https://www.youtube.com/watch?v=tnj5YOZpCyo');
expect(detail.item.broadcast?.weekday, DateTime.sunday);
expect(detail.item.broadcast?.time, '23:15');
expect(detail.item.countries, ['JP']);
expect(detail.item.studios, ['Toei Animation']);
expect(detail.related, hasLength(1));
expect(detail.related.single.title, 'Fairy Tail');
expect(detail.related.single.recommendationCount, 76);
expect(detail.related.single.recommendationPercent, 0.19);
expect(detail.related.single.posterVariants, isNotNull);
});
});
}
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
@@ -6,6 +7,7 @@ import 'package:http/testing.dart';
import 'package:plezy/media/catalog_item_ref.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/models/catalog/catalog_metadata.dart';
import 'package:plezy/services/catalog/catalog_source.dart';
import 'package:plezy/services/catalog/trakt_catalog_source.dart';
import 'package:plezy/services/trackers/tracker_session.dart';
@@ -27,11 +29,14 @@ Map<String, dynamic> _watchlistBody() => {
'entries': [
{
'rank': 1,
'listed_at': '2026-01-03T12:34:56.000Z',
'type': 'movie',
'movie': {
'title': 'The Matrix',
'year': 1999,
'ids': {'trakt': 1, 'imdb': 'tt0133093', 'tmdb': 603},
'trailer': 'https://youtube.com/watch?v=m8e-FF8MsqU',
'released': '1999-03-31T00:00:00.000Z',
},
},
{
@@ -46,6 +51,18 @@ Map<String, dynamic> _watchlistBody() => {
'aired_episodes': 19,
'votes': 7294,
'rating': 8.5,
'comment_count': 432,
'tagline': 'Your innie has a life of its own.',
'original_title': 'Severance',
'first_aired': '2022-02-18T00:00:00.000Z',
'language': 'en',
'languages': ['en'],
'available_translations': ['es', 'fr'],
'country': 'us',
'airs': {'day': 'Tuesday', 'time': '21:00', 'timezone': 'America/New_York'},
'images': {
'logo': ['walter-r2.trakt.tv/images/shows/logos/severance.webp'],
},
},
},
// Episode entries are not Explore rows and must be skipped.
@@ -88,10 +105,15 @@ void main() {
});
test('fetchRow(watchlist) maps mixed entries and skips non-movie/show types', () async {
handlers.add(
(request) =>
http.Response(json.encode(_watchlistBody()['entries']), 200, headers: {'x-pagination-item-count': '3'}),
);
final page = await source.fetchRow(CatalogRowId.watchlist);
expect(requests.single.url.path, '/sync/watchlist');
expect(page.items, hasLength(2));
expect(page.totalResults, 3);
expect(page.items[0].kind, MediaKind.movie);
expect(page.items[0].identityKey, 'movie/imdb:tt0133093');
expect(page.items[1].kind, MediaKind.show);
@@ -103,6 +125,22 @@ void main() {
expect(show.episodeCount, 19);
expect(show.votes, 7294);
expect(show.rating, 8.5);
expect(show.audience?.comments, 432);
expect(show.broadcast?.weekday, DateTime.tuesday);
expect(show.broadcast?.time, '21:00');
expect(show.broadcast?.timezone, 'America/New_York');
expect(show.tagline, 'Your innie has a life of its own.');
expect(show.originalTitle, 'Severance');
expect(show.releaseDate, DateTime.utc(2022, 2, 18));
expect(show.languages, ['en', 'es', 'fr']);
expect(show.logoUrl, 'https://walter-r2.trakt.tv/images/shows/logos/severance.webp');
expect(show.countries, ['US']);
expect(page.items[0].trailerUrl, 'https://youtube.com/watch?v=m8e-FF8MsqU');
expect(page.items[0].releaseDate, DateTime.utc(1999, 3, 31));
expect(page.items[1].addedAt, isNull);
expect(page.items[0].addedAt, DateTime.utc(2026, 1, 3, 12, 34, 56));
expect(page.items[0].audience, isNull);
expect(page.items[0].broadcast, isNull);
expect(page.items[0].airStatus, isNull);
final rendered = page.items[0].toMediaItem();
@@ -150,48 +188,179 @@ void main() {
expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(tmdb: 1396)), isTrue);
});
test('fetchCast maps people to cast members with https-prefixed headshots', () async {
handlers.add(
(request) => http.Response(
json.encode({
'cast': [
{
'characters': ['Walter White'],
'person': {
'name': 'Bryan Cranston',
'images': {
'headshot': ['media.trakt.tv/images/people/headshots/medium/25eb34a2d5.jpg.webp'],
test('fetchDetail appends bounded guest stars and maps cast metadata, crew, and related titles', () async {
http.Response detailResponse(http.Request request) {
if (request.url.path == '/shows/1388/people') {
return http.Response(
json.encode({
'cast': [
{
'characters': ['Walter White', 'Heisenberg'],
'episode_count': 62,
'person': {
'name': 'Bryan Cranston',
'images': {
'headshot': ['media.trakt.tv/images/people/headshots/medium/25eb34a2d5.jpg.webp'],
},
},
},
],
'guest_stars': [
{
'characters': ['Tuco Salamanca'],
'episode_count': 4,
'person': {'name': 'Raymond Cruz'},
},
{
'characters': ['Gale Boetticher'],
'episode_count': 7,
'person': {'name': 'David Costabile'},
},
],
'crew': {
'directing': [
{
'jobs': ['Director'],
'person': {'name': 'Vince Gilligan'},
},
],
'writing': [
{
'jobs': ['Writer', 'Screenplay'],
'person': {'name': 'Peter Gould'},
},
],
'production': [
{
'jobs': ['Executive Producer'],
'person': {'name': 'Mark Johnson'},
},
],
},
{
'characters': <String>[],
'person': {'name': 'Aaron Paul'},
}),
200,
);
}
expect(request.url.path, '/shows/1388/related');
return http.Response(
json.encode([
{
'title': 'Better Call Saul',
'year': 2015,
'ids': {'trakt': 5},
},
]),
200,
);
}
handlers
..add(detailResponse)
..add(detailResponse);
final item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.show,
title: 'Breaking Bad',
tagline: 'All bad things must come to an end.',
ids: const CatalogItemIds(trakt: 1388, slug: 'breaking-bad'),
);
final detail = await source.fetchDetail(item, castLimit: 2, relatedLimit: 7);
expect(requests.map((request) => request.url.path).toSet(), {'/shows/1388/people', '/shows/1388/related'});
final peopleRequest = requests.singleWhere((request) => request.url.path.endsWith('/people'));
expect(peopleRequest.url.queryParameters['extended'], 'full,images,guest_stars');
final relatedRequest = requests.singleWhere((request) => request.url.path.endsWith('/related'));
expect(relatedRequest.url.queryParameters['limit'], '7');
expect(detail.cast, hasLength(2));
expect(detail.cast[0].name, 'Bryan Cranston');
expect(detail.cast[0].secondary, 'Walter White, Heisenberg · 62 eps');
expect(detail.cast[0].imageUrl, 'https://media.trakt.tv/images/people/headshots/medium/25eb34a2d5.jpg.webp');
expect(detail.cast[1].name, 'Raymond Cruz');
expect(detail.cast[1].secondary, 'Tuco Salamanca · 4 eps');
expect(detail.item.tagline, item.tagline);
expect(detail.item.credits, [
isA<CatalogCredit>()
.having((credit) => credit.name, 'name', 'Vince Gilligan')
.having((credit) => credit.role, 'role', CatalogCreditRole.director),
isA<CatalogCredit>()
.having((credit) => credit.name, 'name', 'Peter Gould')
.having((credit) => credit.role, 'role', CatalogCreditRole.writer),
isA<CatalogCredit>()
.having((credit) => credit.name, 'name', 'Mark Johnson')
.having((credit) => credit.role, 'role', CatalogCreditRole.producer),
]);
expect(detail.related.single.title, 'Better Call Saul');
expect(detail.related.single.kind, MediaKind.show);
});
test('trending watchers reach audience and pagination count reaches totalResults', () async {
handlers.add(
(request) => http.Response(
json.encode([
{
'watchers': 120,
'movie': {
'title': 'The Matrix',
'ids': {'trakt': 1},
},
{'characters': <String>[]}, // no person — skipped
],
'crew': <String, dynamic>{},
}),
},
]),
200,
headers: {'x-pagination-item-count': '987'},
),
);
final page = await source.fetchRow(CatalogRowId.trendingMovies);
expect(page.items.single.audience?.watchingNow, 120);
expect(page.items.single.recommenders, isNull);
expect(page.totalResults, 987);
});
test('recommendation users and their notes reach row-only provenance', () async {
handlers.add(
(request) => http.Response(
json.encode([
{
'title': 'The Matrix',
'ids': {'trakt': 1},
'favorited_by': [
{'username': 'alice', 'name': 'Alice', 'notes': 'A forever favorite.'},
],
'recommended_by': [
{'username': 'bob', 'name': null, 'notes': 'The lobby scene.'},
],
},
]),
200,
),
);
final cast = await source.fetchCast(
const CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.show,
title: 'Breaking Bad',
ids: CatalogItemIds(trakt: 1388, slug: 'breaking-bad'),
),
);
final page = await source.fetchRow(CatalogRowId.recommendedMovies);
expect(requests.single.url.path, '/shows/1388/people');
expect(cast, hasLength(2));
expect(cast[0].name, 'Bryan Cranston');
expect(cast[0].secondary, 'Walter White');
expect(cast[0].imageUrl, 'https://media.trakt.tv/images/people/headshots/medium/25eb34a2d5.jpg.webp');
expect(cast[1].imageUrl, isNull);
expect(cast[1].secondary, isNull);
expect(page.items.single.recommendationCount, isNull);
expect(page.items.single.recommenders, [
isA<CatalogRecommender>()
.having((recommender) => recommender.username, 'username', 'alice')
.having((recommender) => recommender.name, 'name', 'Alice')
.having((recommender) => recommender.note, 'note', 'A forever favorite.')
.having((recommender) => recommender.reason, 'reason', CatalogRecommendationReason.favorited),
isA<CatalogRecommender>()
.having((recommender) => recommender.username, 'username', 'bob')
.having((recommender) => recommender.note, 'note', 'The lobby scene.')
.having((recommender) => recommender.reason, 'reason', CatalogRecommendationReason.recommended),
]);
});
test('weekday mapping covers every Trakt day name and rejects unknown values', () {
expect(TraktCatalogSource.weekdayFor('Monday'), DateTime.monday);
expect(TraktCatalogSource.weekdayFor('Tuesday'), DateTime.tuesday);
expect(TraktCatalogSource.weekdayFor('Wednesday'), DateTime.wednesday);
expect(TraktCatalogSource.weekdayFor('Thursday'), DateTime.thursday);
expect(TraktCatalogSource.weekdayFor('Friday'), DateTime.friday);
expect(TraktCatalogSource.weekdayFor('Saturday'), DateTime.saturday);
expect(TraktCatalogSource.weekdayFor('Sunday'), DateTime.sunday);
expect(TraktCatalogSource.weekdayFor('Someday'), isNull);
});
test('air status normalization covers the Trakt vocabulary', () {
@@ -287,30 +456,68 @@ void main() {
expect(requests, isEmpty);
});
test('fetchRelated hits /related and keeps the item kind', () async {
handlers.add((request) {
expect(request.url.path, '/shows/2/related');
return http.Response(
json.encode([
{
'title': 'Dark',
'year': 2017,
'ids': {'trakt': 5, 'tmdb': 70523},
},
]),
200,
);
test('fetchDetail starts people and related concurrently and isolates related failure', () async {
final peopleStarted = Completer<void>();
final relatedStarted = Completer<void>();
final peopleResponse = Completer<http.Response>();
final relatedResponse = Completer<http.Response>();
final concurrentClient = TraktClient(
_session(),
onSessionInvalidated: () => fail('should not invalidate'),
httpClient: MockClient((request) {
if (request.url.path.endsWith('/people')) {
peopleStarted.complete();
return peopleResponse.future;
}
if (request.url.path.endsWith('/related')) {
relatedStarted.complete();
return relatedResponse.future;
}
return Future.value(http.Response('not found', 404));
}),
);
final concurrentSource = TraktCatalogSource(concurrentClient);
addTearDown(() {
concurrentSource.dispose();
concurrentClient.dispose();
});
final item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.show,
title: 'Severance',
ids: const CatalogItemIds(trakt: 2),
);
final related = await source.fetchRelated(item);
expect(related.single.title, 'Dark');
expect(related.single.kind, MediaKind.show);
final detailFuture = concurrentSource.fetchDetail(item);
await Future.wait([peopleStarted.future, relatedStarted.future]).timeout(const Duration(seconds: 1));
peopleResponse.complete(
http.Response(
json.encode({
'cast': [
{
'characters': ['Mark Scout'],
'person': {'name': 'Adam Scott'},
},
],
'crew': {
'directing': [
{
'jobs': ['Director'],
'person': {'name': 'Ben Stiller'},
},
],
},
}),
200,
),
);
relatedResponse.complete(http.Response('upstream failure', 500));
final detail = await detailFuture;
expect(detail.cast.single.name, 'Adam Scott');
expect(detail.item.credits?.single.name, 'Ben Stiller');
expect(detail.item.credits?.single.role, CatalogCreditRole.director);
expect(detail.related, isEmpty);
});
});
}
+47 -1
View File
@@ -3,6 +3,8 @@ import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/i18n/app_locale_utils.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/models/seerr/seerr_page.dart';
import 'package:plezy/models/seerr/seerr_request.dart';
import 'package:plezy/models/seerr/seerr_session.dart';
@@ -281,6 +283,7 @@ void main() {
return _json({
'page': 1,
'totalPages': 1,
'totalResults': 37,
'results': [
{'id': 4, 'title': 'Dune', 'releaseDate': '2021-09-15'},
],
@@ -291,6 +294,48 @@ void main() {
final page = await client.getPopularMovies();
expect(page.items.single.isMovie, isTrue);
expect(page.hasMore, isFalse);
expect(page.totalResults, 37);
});
test('adds the current Plezy locale to every catalog GET', () async {
final urls = <Uri>[];
final client = clientWith(
MockClient((request) async {
urls.add(request.url);
if (request.url.path == '/api/v1/movie/4' || request.url.path == '/api/v1/tv/4') {
return _json({});
}
return _json({'page': 1, 'totalPages': 1, 'results': []});
}),
);
await client.getPopularMovies();
await client.getPopularTv();
await client.getUpcomingMovies();
await client.getUpcomingTv();
await client.getTrending();
await client.search('dune');
await client.getMovieRecommendations(4);
await client.getTvRecommendations(4);
await client.getMovie(4);
await client.getTv(4);
expect(urls.map((url) => url.path).toSet(), {
'/api/v1/discover/movies',
'/api/v1/discover/tv',
'/api/v1/discover/movies/upcoming',
'/api/v1/discover/tv/upcoming',
'/api/v1/discover/trending',
'/api/v1/search',
'/api/v1/movie/4/recommendations',
'/api/v1/tv/4/recommendations',
'/api/v1/movie/4',
'/api/v1/tv/4',
});
final expectedLanguage = LocaleSettings.currentLocale.plexLanguageCode;
for (final url in urls) {
expect(url.queryParameters['language'], expectedLanguage, reason: url.path);
}
});
test('createRequest posts the movie payload without seasons', () async {
@@ -341,7 +386,7 @@ void main() {
group('SeerrPage', () {
test('parses the pageInfo pagination shape', () {
final page = SeerrPage<int>.fromJson({
'pageInfo': {'page': 2, 'pages': 2},
'pageInfo': {'page': 2, 'pages': 2, 'totalResults': 55},
'results': [
{'id': 1},
],
@@ -349,6 +394,7 @@ void main() {
expect(page.hasMore, isFalse);
expect(page.items, [1]);
expect(page.totalResults, 55);
});
});
+102 -1
View File
@@ -24,21 +24,42 @@ TrackerSession _session() {
);
}
Map<String, dynamic> _movieJson({int trakt = 1, String? posterUrl = 'walter-r2.trakt.tv/images/movies/p.webp'}) {
Map<String, dynamic> _movieJson({
int trakt = 1,
String? posterUrl = 'walter-r2.trakt.tv/images/movies/p.webp',
bool withRecommenders = false,
}) {
return {
'title': 'The Matrix',
'year': 1999,
'ids': {'trakt': trakt, 'slug': 'the-matrix-1999', 'imdb': 'tt0133093', 'tmdb': 603},
'overview': 'A hacker learns the truth.',
'tagline': 'Welcome to the Real World.',
'original_title': 'The Matrix',
'released': '1999-03-31T00:00:00.000Z',
'runtime': 136,
'rating': 8.7,
'votes': 42000,
'genres': ['action', 'sci-fi'],
'certification': 'R',
'trailer': 'https://youtube.com/watch?v=m8e-FF8MsqU',
'comment_count': 1234,
'language': 'en',
'languages': ['en'],
'available_translations': ['es', 'fr'],
'country': 'us',
if (withRecommenders) ...{
'favorited_by': [
{'username': 'alice', 'name': 'Alice', 'notes': 'A forever favorite.'},
],
'recommended_by': [
{'username': 'bob', 'name': null, 'notes': 'The lobby scene.'},
],
},
'images': {
'poster': [?posterUrl],
'fanart': ['walter-r2.trakt.tv/images/movies/f.webp'],
'logo': ['walter-r2.trakt.tv/images/movies/logo.webp'],
},
};
}
@@ -51,6 +72,8 @@ Map<String, dynamic> _showJson() {
'overview': 'Work-life balance, surgically.',
'runtime': 50,
'rating': 8.9,
'first_aired': '2022-02-18T00:00:00.000Z',
'airs': {'day': 'Friday', 'time': '09:30', 'timezone': 'America/New_York'},
'images': <String, dynamic>{},
};
}
@@ -73,6 +96,7 @@ void main() {
'poster': ['walter-r2.trakt.tv/images/movies/p.webp'],
});
expect(images.primaryPoster, 'https://walter-r2.trakt.tv/images/movies/p.webp');
expect(images.primaryLogo, isNull);
});
test('keeps absolute URLs and falls back fanart -> thumb for backdrop', () {
@@ -82,6 +106,7 @@ void main() {
});
expect(images.primaryPoster, 'https://example.com/p.webp');
expect(images.primaryBackdrop, 'https://walter-r2.trakt.tv/t.webp');
expect(images.primaryLogo, isNull);
});
test('returns null for missing or empty image arrays', () {
@@ -137,6 +162,7 @@ void main() {
expect(page.page, 1);
expect(page.pageCount, 1);
expect(page.hasMore, isFalse);
expect(page.itemCount, isNull);
client.dispose();
});
@@ -159,6 +185,7 @@ void main() {
expect(requests.single.url.queryParameters['limit'], '10');
expect(page.items.single.watchers, 120);
expect(page.items.single.media?.title, 'The Matrix');
expect(page.itemCount, isNull);
client.dispose();
});
@@ -174,6 +201,11 @@ void main() {
expect(requests.single.url.path, '/shows/popular');
expect(page.items.single, isA<TraktCatalogMedia>());
expect(page.items.single.title, 'Severance');
expect(page.items.single.firstAired, '2022-02-18T00:00:00.000Z');
expect(page.items.single.airs?.day, 'Friday');
expect(page.items.single.airs?.time, '09:30');
expect(page.items.single.airs?.timezone, 'America/New_York');
expect(page.items.single.commentCount, isNull);
client.dispose();
});
@@ -196,6 +228,75 @@ void main() {
client.dispose();
});
test('getRecommended parses recommendation provenance without reducing it to a count', () async {
final client = _client((request) async {
return http.Response(json.encode([_movieJson(withRecommenders: true)]), 200);
});
final items = await client.getRecommended(TraktCatalogType.movies, limit: 15);
final item = items.single;
expect(item.favoritedBy?.single.username, 'alice');
expect(item.favoritedBy?.single.name, 'Alice');
expect(item.favoritedBy?.single.notes, 'A forever favorite.');
expect(item.recommendedBy?.single.username, 'bob');
expect(item.recommendedBy?.single.notes, 'The lobby scene.');
client.dispose();
});
test('getPeople widens show people for guest stars and parses cast and crew metadata', () async {
final requests = <http.Request>[];
final client = _client(requests: requests, (request) async {
return http.Response(
json.encode({
'cast': [
{
'characters': ['Mark Scout', 'Mark S.'],
'episode_count': 19,
'person': {'name': 'Adam Scott'},
},
],
'guest_stars': [
{
'characters': ['June'],
'episode_count': 1,
'person': {'name': 'Guest Actor'},
},
],
'crew': {
'directing': [
{
'jobs': ['Director'],
'person': {'name': 'Ben Stiller'},
},
],
'production': [
{
'job': 'Executive Producer',
'person': {'name': 'Jackie Cohn'},
},
],
},
}),
200,
);
});
final people = await client.getPeople(TraktCatalogType.shows, 'severance');
expect(requests.single.url.path, '/shows/severance/people');
expect(requests.single.url.queryParameters['extended'], 'full,images,guest_stars');
expect(people.cast.single.characters, ['Mark Scout', 'Mark S.']);
expect(people.cast.single.episodeCount, 19);
expect(people.guestStars.single.person?.name, 'Guest Actor');
expect(people.crew, hasLength(2));
expect(people.crew.first.jobs, ['Director']);
expect(people.crew.last.job, 'Executive Producer');
client.dispose();
});
test('addToWatchlist accepts 201 and posts the ids body untouched', () async {
final requests = <http.Request>[];
final client = _client(requests: requests, (request) async => http.Response('{"added":{"movies":1}}', 201));
+26
View File
@@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/focus/locked_hub_controller.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_hub.dart';
import 'package:plezy/media/media_item.dart';
@@ -21,6 +22,7 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
LocaleSettings.setLocaleSync(AppLocale.en);
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
@@ -133,6 +135,30 @@ void main() {
expect(outerPadding.padding.resolve(TextDirection.ltr).bottom, 0);
});
testWidgets('shows a provider result count in the existing hub header only when supplied', (tester) async {
final item = testMediaItem(
id: 'counted_item',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Counted Movie',
);
final hub = MediaHub(id: 'counted_hub', title: 'Popular', type: 'mixed', items: [item], size: 237, more: true);
await tester.pumpWidget(
_TestApp(
child: HubSection(hub: hub, focusMemory: HubFocusMemory(), icon: Symbols.movie_rounded),
),
);
expect(find.text(t.explore.totalResults(n: 237)), findsNothing);
await tester.pumpWidget(
_TestApp(
child: HubSection(hub: hub, focusMemory: HubFocusMemory(), icon: Symbols.movie_rounded, totalResults: 237),
),
);
expect(find.text(t.explore.totalResults(n: 237)), findsOneWidget);
});
testWidgets('restores within one owner but resets for a fresh owner', (tester) async {
final items = [
for (var index = 0; index < 3; index++)
+298
View File
@@ -1,3 +1,4 @@
import 'dart:collection';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
@@ -8,8 +9,11 @@ import 'package:plezy/focus/focus_theme.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/models/catalog/catalog_metadata.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/layout_constants.dart';
@@ -17,6 +21,7 @@ import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/focusable_media_card.dart';
import 'package:plezy/widgets/media_card.dart';
import 'package:plezy/widgets/media_grid_delegate.dart';
import 'package:plezy/widgets/optimized_media_image.dart';
import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
@@ -24,6 +29,10 @@ import '../test_helpers/media_items.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
LocaleSettings.setLocaleSync(AppLocale.en);
});
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
@@ -114,6 +123,219 @@ void main() {
expect(find.text('Visible Movie'), findsOneWidget);
});
testWidgets('catalog grid metadata leads with the rating and omits certification', (tester) async {
// Measured on a Pixel 7: at shelf width the full list composition renders
// `PG-13 • 2006 • 2h 10mi…` and ellipsizes the rating away — the one value
// this line exists to surface. The grid therefore leads with the rating
// and drops certification, votes and genres; the list keeps them.
final item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Catalog Movie',
year: 2024,
runtimeMinutes: 125,
rating: 8.6,
votes: 12345,
genres: const ['Drama', 'Mystery'],
certification: 'PG-13',
ids: const CatalogItemIds(tmdb: 1),
).toMediaItem();
await tester.pumpWidget(_catalogGridHarness(item));
final metadata = tester.widget<Text>(
find.byWidgetPredicate((widget) => widget is Text && (widget.data?.contains('8.6★') ?? false)),
);
expect(metadata.maxLines, 1);
expect(metadata.overflow, TextOverflow.ellipsis);
expect(metadata.data, '8.6★ • 2024 • 2h 5min');
expect(metadata.data, isNot(contains('PG-13')), reason: 'certification is detail/search only');
expect(metadata.data, isNot(contains('Drama')), reason: 'genres do not fit a shelf caption');
expect(metadata.data, isNot(contains('(')), reason: 'vote counts do not fit a shelf caption');
});
testWidgets('plain library grid metadata remains year only', (tester) async {
final item = testMediaItem(
id: 'library-movie',
kind: MediaKind.movie,
title: 'Library Movie',
year: 2024,
contentRating: 'PG-13',
durationMs: const Duration(minutes: 125).inMilliseconds,
rating: 8.6,
genres: const ['Drama'],
);
await tester.pumpWidget(_catalogGridHarness(item));
expect(find.text('2024'), findsOneWidget);
expect(find.textContaining('PG-13'), findsNothing);
expect(find.textContaining('2h 5m'), findsNothing);
expect(find.textContaining('8.6★'), findsNothing);
expect(find.textContaining('Drama'), findsNothing);
});
testWidgets('seasonal rank badge names its season instead of claiming all-time rank', (tester) async {
final item = CatalogItem(
source: CatalogSourceId.anilist,
kind: MediaKind.show,
title: 'Seasonal Show',
ids: const CatalogItemIds(anilist: 1),
ranks: const [
CatalogRank(
rank: 3,
scope: CatalogRankScope.popular,
allTime: false,
year: 2026,
season: CatalogSeasonName.summer,
),
],
).toMediaItem();
final season = t.explore.season.withYear(season: t.explore.season.summer, year: 2026);
await tester.pumpWidget(_catalogGridHarness(item));
expect(find.text(t.explore.badge.rankSeasonal(n: 3, season: season)), findsOneWidget);
expect(find.text(t.explore.badge.rankPopular(n: 3)), findsNothing);
});
testWidgets('windowed viewers render only when their period is present', (tester) async {
final withoutPeriod = CatalogItem(
source: CatalogSourceId.simkl,
kind: MediaKind.show,
title: 'No Viewer Window',
ids: const CatalogItemIds(simkl: 1),
audience: const CatalogAudience(viewers: 37),
).toMediaItem();
await tester.pumpWidget(_catalogGridHarness(withoutPeriod, key: const ValueKey('viewer-card')));
expect(find.textContaining('37'), findsNothing);
final withPeriod = CatalogItem(
source: CatalogSourceId.simkl,
kind: MediaKind.show,
title: 'Viewer Window',
ids: const CatalogItemIds(simkl: 1),
audience: const CatalogAudience(viewers: 37, viewersPeriod: CatalogAudiencePeriod.week),
).toMediaItem();
await tester.pumpWidget(_catalogGridHarness(withPeriod, key: const ValueKey('viewer-card')));
expect(find.text(t.explore.stats.viewersWeek(n: '37')), findsOneWidget);
});
testWidgets('HD availability and pending 4K request render as independent badges', (tester) async {
final item = CatalogItem(
source: CatalogSourceId.seerr,
kind: MediaKind.movie,
title: 'Server Movie',
ids: const CatalogItemIds(tmdb: 1),
serverState: const CatalogServerState(
availability: CatalogAvailability.available,
request4k: CatalogRequestState.pending,
),
).toMediaItem();
await tester.pumpWidget(_catalogGridHarness(item));
expect(find.text(t.explore.badge.available), findsOneWidget);
expect(find.text(t.explore.badge.pendingApproval), findsOneWidget);
});
testWidgets('catalog poster badges are capped at three by priority', (tester) async {
final item = CatalogItem(
source: CatalogSourceId.seerr,
kind: MediaKind.show,
title: 'Busy Show',
ids: const CatalogItemIds(tmdb: 1),
isAdult: true,
serverState: const CatalogServerState(
availability: CatalogAvailability.available,
request4k: CatalogRequestState.pending,
),
nextEpisode: CatalogNextEpisode(airsAt: DateTime.now().add(const Duration(days: 1)), episode: 4),
ranks: const [CatalogRank(rank: 2, scope: CatalogRankScope.popular)],
audience: const CatalogAudience(watchingNow: 200),
).toMediaItem();
await tester.pumpWidget(_catalogGridHarness(item));
final badgeTexts = find.descendant(of: find.byKey(const Key('catalog-badges')), matching: find.byType(Text));
expect(badgeTexts, findsNWidgets(3));
expect(find.text(t.explore.badge.available), findsOneWidget);
expect(find.text(t.explore.badge.pendingApproval), findsOneWidget);
expect(find.text(t.explore.badge.adult), findsOneWidget);
expect(find.text(t.explore.badge.rankPopular(n: 2)), findsNothing);
});
testWidgets('catalog item is rehydrated once across rebuilds of the same item identity', (tester) async {
final catalog = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Cached Catalog Movie',
ids: const CatalogItemIds(tmdb: 1),
ranks: const [CatalogRank(rank: 1, scope: CatalogRankScope.trending)],
);
final rawCatalog = _ReadCountingMap(catalog.toJson());
final mediaItem = testMediaItem(
id: 'catalog:cached',
kind: MediaKind.movie,
title: catalog.title,
raw: {CatalogItem.rawKey: rawCatalog},
);
const cardKey = ValueKey('cached-catalog-card');
await tester.pumpWidget(_catalogGridHarness(mediaItem, key: cardKey));
final readsAfterFirstBuild = rawCatalog.reads;
expect(readsAfterFirstBuild, greaterThan(0));
await tester.pumpWidget(_catalogGridHarness(mediaItem, key: cardKey));
expect(rawCatalog.reads, readsAfterFirstBuild);
});
testWidgets('catalog poster selection covers the card width at device pixel ratio', (tester) async {
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.resetDevicePixelRatio);
final item = CatalogItem(
source: CatalogSourceId.seerr,
kind: MediaKind.movie,
title: 'Sharp Poster',
ids: const CatalogItemIds(tmdb: 1),
posterUrl: 'https://example.com/default.jpg',
posterVariants: const {200: 'https://example.com/200.jpg', 500: 'https://example.com/500.jpg'},
).toMediaItem();
await tester.pumpWidget(_catalogGridHarness(item, width: 150));
final image = tester.widget<OptimizedMediaImage>(find.byType(OptimizedMediaImage));
expect(image.imagePath, 'https://example.com/500.jpg');
});
testWidgets('recommendation badge prefers a user count and falls back to viewer percentage', (tester) async {
final withCount = CatalogItem(
source: CatalogSourceId.simkl,
kind: MediaKind.movie,
title: 'Counted Recommendation',
ids: const CatalogItemIds(simkl: 1),
recommendationCount: 19,
recommendationPercent: 0.42,
).toMediaItem();
await tester.pumpWidget(_catalogGridHarness(withCount, key: const ValueKey('recommendation-card')));
expect(find.text(t.explore.detail.recommendedByUsers(n: 19)), findsOneWidget);
expect(find.text(t.explore.detail.recommendedByPercent(percent: '42%')), findsNothing);
final withPercent = CatalogItem(
source: CatalogSourceId.simkl,
kind: MediaKind.movie,
title: 'Percentage Recommendation',
ids: const CatalogItemIds(simkl: 2),
recommendationPercent: 0.42,
).toMediaItem();
await tester.pumpWidget(_catalogGridHarness(withPercent, key: const ValueKey('recommendation-card')));
expect(find.text(t.explore.detail.recommendedByPercent(percent: '42%')), findsOneWidget);
});
testWidgets('full bleed flag does not hide list media card text', (tester) async {
final item = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'List Movie');
@@ -130,6 +352,37 @@ void main() {
expect(find.text('List Movie'), findsOneWidget);
});
testWidgets('catalog list metadata keeps certification, votes and genres', (tester) async {
// The counterpart to the grid contract above: search uses the list card,
// which is wide enough for the full composition and must not be trimmed
// by the grid's compact mode.
final item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Catalog Movie',
year: 2024,
runtimeMinutes: 125,
rating: 8.6,
votes: 12345,
genres: const ['Drama', 'Mystery'],
certification: 'PG-13',
ids: const CatalogItemIds(tmdb: 1),
).toMediaItem();
await tester.pumpWidget(
_TestApp(
child: SizedBox(width: 420, height: 160, child: MediaCard(item: item, forceListMode: true, isOffline: true)),
),
);
final metadata = tester.widget<Text>(
find.byWidgetPredicate((widget) => widget is Text && (widget.data?.contains('8.6★') ?? false)),
);
expect(metadata.data, startsWith('PG-13 • 2024 • 2h 5m'));
expect(metadata.data, contains('8.6★ (12.3K)'));
expect(metadata.data, contains('Drama, Mystery'));
});
testWidgets('full bleed focusable media card lifts the glow into an overlay above siblings', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final focusNode = FocusNode(debugLabel: 'full_bleed_card');
@@ -529,6 +782,51 @@ Widget _fullCardHarness({required FocusNode focusNode, required bool fullBleed})
);
}
Widget _catalogGridHarness(MediaItem item, {Key? key, double width = 220}) {
return _TestApp(
child: SizedBox(
width: width,
height: 330,
child: MediaCard(
key: key,
item: item,
width: width,
height: 280,
forceGridMode: true,
isOffline: true,
onTap: () {},
),
),
);
}
class _ReadCountingMap extends MapBase<String, Object?> {
final Map<String, Object?> _values;
int reads = 0;
_ReadCountingMap(this._values);
@override
Object? operator [](Object? key) {
reads++;
return _values[key];
}
@override
void operator []=(String key, Object? value) {
_values[key] = value;
}
@override
void clear() => _values.clear();
@override
Iterable<String> get keys => _values.keys;
@override
Object? remove(Object? key) => _values.remove(key);
}
class _TestApp extends StatelessWidget {
final Widget child;