diff --git a/lib/data/iso_3166_data.dart b/lib/data/iso_3166_data.dart new file mode 100644 index 00000000..f7534dc2 --- /dev/null +++ b/lib/data/iso_3166_data.dart @@ -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 = { + '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 = { + "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', +}; diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index 759228ea..3ce92a4b 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -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", diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index be13ed03..2fb907fc 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -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": "Телевизия на живо", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index ccec3676..fe3cb049 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -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", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 210b2451..9048acf3 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -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", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 9051ae96..0307252c 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 86ffa7bf..30cb4ddf 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -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", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 4de2d2a3..9b2dd53e 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -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", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index cd903f41..e86a0131 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -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", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 78d46791..2bbddb10 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -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", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 93ed8afa..6c409a90 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -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", diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index 684150b3..86809b42 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -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", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index e18d131d..f09d81ec 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -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", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index cd0485b9..49b234d3 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -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", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 0e54ec1d..7da2b63b 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -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", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index bced8259..0493bbf4 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -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", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index b68d26c5..82b8f630 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -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", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 9e4053d8..0925bf24 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -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": "ТВ в прямом эфире", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 703aec31..e4bf987b 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -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 diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 0a0b6b62..a14275d5 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 2170d4b6..3cf09fd0 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -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", diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index 89592960..e487304c 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -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", diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index 2c1284bb..b73e060c 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -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", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index ff7e0b49..894e68e1 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -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": "直播電視", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 017e43ab..5efed546 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -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": "直播电视", diff --git a/lib/models/anilist/anilist_media.dart b/lib/models/anilist/anilist_media.dart index ee681305..2229cc07 100644 --- a/lib/models/anilist/anilist_media.dart +++ b/lib/models/anilist/anilist_media.dart @@ -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? 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? genres; - final List? synonyms; final bool isAdult; + final String? source; + final String? countryOfOrigin; final String? coverImageExtraLarge; final String? coverImageLarge; + final String? coverImageColor; final String? bannerImage; final List? mainStudios; final String? trailerId; final String? trailerSite; + final AnilistNextAiringEpisode? nextAiringEpisode; + final List? rankings; + final List? tags; + final List? externalLinks; + final List? streamingEpisodes; + final List? staffCredits; + final List? 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 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? get alternateTitles { + final chosen = displayTitle.toLowerCase(); + final values = []; + 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? _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? _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 = []; + 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? _rankings(Object? value) { + if (value is! List) return null; + final rankings = []; + 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? _tags(Object? value) { + if (value is! List) return null; + final tags = []; + 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? _links(Object? value, {bool streaming = false}) { + if (value is! List) return null; + final links = []; + 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? _characters(Object? value) { + final edges = value is Map ? value['edges'] : null; + if (edges is! List) return null; + final characters = []; + 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? _staffCredits(Object? value) { + final edges = value is Map ? value['edges'] : null; + if (edges is! List) return null; + final credits = []; + 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 items, bool hasMore}); diff --git a/lib/models/catalog/catalog_cast_member.dart b/lib/models/catalog/catalog_cast_member.dart index 382b2ef1..dc2a8a18 100644 --- a/lib/models/catalog/catalog_cast_member.dart +++ b/lib/models/catalog/catalog_cast_member.dart @@ -10,4 +10,20 @@ class CatalogCastMember { final String? imageUrl; const CatalogCastMember({required this.name, this.secondary, this.imageUrl}); + + Map toJson() => { + 'name': name, + if (secondary != null) 'secondary': secondary, + if (imageUrl != null) 'imageUrl': imageUrl, + }; + + static CatalogCastMember? fromJson(Map 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?, + ); + } } diff --git a/lib/models/catalog/catalog_item.dart b/lib/models/catalog/catalog_item.dart index 57fc897c..18c06a4e 100644 --- a/lib/models/catalog/catalog_item.dart +++ b/lib/models/catalog/catalog_item.dart @@ -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 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? posterVariants; + final Map? 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? ratings; + + /// Leaderboard positions. A list because providers return several windows + /// at once (all-time and seasonal). + final List? 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? studios; + + /// ISO 3166-1 alpha-2 country codes. + final List? countries; + + /// ISO 639-1 language codes; the first is the original language. + final List? languages; + + /// Directors, writers and producers from a detail payload. + final List? credits; + + /// Ranked descriptive tags, spoilers included and flagged. + final List? tags; + + /// Provider and streaming links. + final List? 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? 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? 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? 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 json) => CatalogItem( @@ -278,5 +597,78 @@ class CatalogItem { ids: CatalogItemIds.fromJson((json['ids'] as Map?)?.cast() ?? 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(), + countries: (json['countries'] as List?)?.cast(), + languages: (json['languages'] as List?)?.cast(), + 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(), + background: json['background'] as String?, + cast: decodeCatalogList(json['cast'], CatalogCastMember.fromJson), ); } + +T? _decodeObject(Object? raw, T? Function(Map) decode) => + raw is Map ? decode(raw.cast()) : null; + +DateTime? _decodeDate(Object? raw) => raw is String ? DateTime.tryParse(raw) : null; + +Map? _decodeVariants(Object? raw) { + if (raw is! Map) return null; + final out = {}; + 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? 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]; +} diff --git a/lib/models/catalog/catalog_metadata.dart b/lib/models/catalog/catalog_metadata.dart new file mode 100644 index 00000000..92f050c7 --- /dev/null +++ b/lib/models/catalog/catalog_metadata.dart @@ -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 toJson() => {'source': source, 'value': value, if (votes != null) 'votes': votes}; + + static CatalogRatingSource? fromJson(Map 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 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 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 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 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 toJson() => { + if (weekday != null) 'weekday': weekday, + if (time != null) 'time': time, + if (timezone != null) 'timezone': timezone, + }; + + factory CatalogBroadcast.fromJson(Map 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 toJson() => { + 'airsAt': airsAt.toUtc().toIso8601String(), + if (episode != null) 'episode': episode, + }; + + static CatalogNextEpisode? fromJson(Map 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 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 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 toJson() => {'name': name.name, if (year != null) 'year': year}; + + static CatalogSeasonInfo? fromJson(Map 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 toJson() => {'name': name, 'role': role.name}; + + static CatalogCredit? fromJson(Map 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 toJson() => {'name': name, if (rank != null) 'rank': rank, if (isSpoiler) 'isSpoiler': true}; + + static CatalogTag? fromJson(Map 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 toJson() => {'label': label, 'url': url, if (isStreaming) 'isStreaming': true}; + + static CatalogLink? fromJson(Map 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 toJson() => { + if (viewCount != null) 'viewCount': viewCount, + if (viewOffsetMs != null) 'viewOffsetMs': viewOffsetMs, + if (viewedLeafCount != null) 'viewedLeafCount': viewedLeafCount, + }; + + factory CatalogPlayState.fromJson(Map 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? decodeCatalogList(Object? raw, T? Function(Map) decode) { + if (raw is! List) return null; + final out = []; + for (final entry in raw) { + if (entry is! Map) continue; + final decoded = decode(entry.cast()); + 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 toJson() => { + 'username': username, + 'reason': reason.name, + if (name != null) 'name': name, + if (note != null) 'note': note, + }; + + static CatalogRecommender? fromJson(Map 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?, + ); + } +} diff --git a/lib/models/mal/mal_anime.dart b/lib/models/mal/mal_anime.dart index 72b59238..6723fa6a 100644 --- a/lib/models/mal/mal_anime.dart +++ b/lib/models/mal/mal_anime.dart @@ -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 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 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 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 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()), + 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()), + 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 json) { + final status = json['status']; + final statuses = status is Map ? status : const {}; + 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 recommendations; + final List relations; + final MalStatistics? statistics; + final List 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 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()), + ], + background: background == null || background.isEmpty ? null : background, + ); + } +} diff --git a/lib/models/mal/mal_anime.g.dart b/lib/models/mal/mal_anime.g.dart index 864c3190..4d05f056 100644 --- a/lib/models/mal/mal_anime.g.dart +++ b/lib/models/mal/mal_anime.g.dart @@ -28,7 +28,10 @@ MalStudio _$MalStudioFromJson(Map json) => MalStudio(name: json['name'] as String?); MalStartSeason _$MalStartSeasonFromJson(Map json) => - MalStartSeason(year: flexibleInt(json['year'])); + MalStartSeason( + year: flexibleInt(json['year']), + season: json['season'] as String?, + ); MalAnime _$MalAnimeFromJson(Map json) => MalAnime( id: flexibleInt(json['id']), @@ -59,4 +62,13 @@ MalAnime _$MalAnimeFromJson(Map json) => MalAnime( ?.map((e) => MalStudio.fromJson(e as Map)) .toList(), numScoringUsers: flexibleInt(json['num_scoring_users']), + broadcast: json['broadcast'] == null + ? null + : MalBroadcast.fromJson(json['broadcast'] as Map), + 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?, ); diff --git a/lib/models/seerr/seerr_details.dart b/lib/models/seerr/seerr_details.dart index aa25ef1c..c54b3507 100644 --- a/lib/models/seerr/seerr_details.dart +++ b/lib/models/seerr/seerr_details.dart @@ -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? episodeRunTime; + final int? budget; + final int? revenue; + final double? voteAverage; + final int? voteCount; + final double? popularity; + final String? lastAirDate; + final List? languages; + final List? spokenLanguages; + final List? genres; + final List? networks; + final List? productionCompanies; + final List? productionCountries; + final List? originCountry; + final List? createdBy; + final List? keywords; + final int? numberOfEpisodes; + final String? status; + final String? tagline; + final SeerrReleaseInfo? releases; + final SeerrContentRatingInfo? contentRatings; + final SeerrExternalIds? externalIds; + final List? relatedVideos; final List? 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 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 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 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 json) => + SeerrSpokenLanguage(languageCode: json['iso_639_1'] as String?, name: json['name'] as String?); +} + +class SeerrReleaseInfo { + final List? results; + + const SeerrReleaseInfo({this.results}); + + factory SeerrReleaseInfo.fromJson(Map json) => + SeerrReleaseInfo(results: _decodeObjectList(json['results'], SeerrReleaseCountry.fromJson)); +} + +class SeerrReleaseCountry { + final String? countryCode; + final String? rating; + final List? releaseDates; + + const SeerrReleaseCountry({this.countryCode, this.rating, this.releaseDates}); + + factory SeerrReleaseCountry.fromJson(Map 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 json) => + SeerrReleaseDate(certification: json['certification'] as String?); +} + +class SeerrContentRatingInfo { + final List? results; + + const SeerrContentRatingInfo({this.results}); + + factory SeerrContentRatingInfo.fromJson(Map 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 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 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 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? cast; - const SeerrCredits({this.cast}); + final List? crew; + + const SeerrCredits({this.cast, this.crew}); + factory SeerrCredits.fromJson(Map json) => _$SeerrCreditsFromJson(json); } @@ -48,3 +249,25 @@ class SeerrCastMember { factory SeerrCastMember.fromJson(Map 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 json) => SeerrCrewMember( + name: json['name'] as String?, + job: json['job'] as String?, + department: json['department'] as String?, + ); +} + +List? _decodeObjectList(Object? raw, T Function(Map) decode) { + if (raw is! List) return null; + return [ + for (final item in raw) + if (item is Map) decode(item), + ]; +} diff --git a/lib/models/seerr/seerr_details.g.dart b/lib/models/seerr/seerr_details.g.dart index 487de020..41206d7c 100644 --- a/lib/models/seerr/seerr_details.g.dart +++ b/lib/models/seerr/seerr_details.g.dart @@ -7,6 +7,73 @@ part of 'seerr_details.dart'; // ************************************************************************** SeerrDetails _$SeerrDetailsFromJson(Map 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?) + ?.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?) + ?.map((e) => e as String) + .toList(), + spokenLanguages: (json['spokenLanguages'] as List?) + ?.map((e) => SeerrSpokenLanguage.fromJson(e as Map)) + .toList(), + genres: (json['genres'] as List?) + ?.map((e) => SeerrNamedValue.fromJson(e as Map)) + .toList(), + networks: (json['networks'] as List?) + ?.map((e) => SeerrNamedValue.fromJson(e as Map)) + .toList(), + productionCompanies: (json['productionCompanies'] as List?) + ?.map((e) => SeerrNamedValue.fromJson(e as Map)) + .toList(), + productionCountries: (json['productionCountries'] as List?) + ?.map((e) => SeerrProductionCountry.fromJson(e as Map)) + .toList(), + originCountry: (json['originCountry'] as List?) + ?.map((e) => e as String) + .toList(), + createdBy: (json['createdBy'] as List?) + ?.map((e) => SeerrNamedValue.fromJson(e as Map)) + .toList(), + keywords: (json['keywords'] as List?) + ?.map((e) => SeerrNamedValue.fromJson(e as Map)) + .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), + contentRatings: json['contentRatings'] == null + ? null + : SeerrContentRatingInfo.fromJson( + json['contentRatings'] as Map, + ), + externalIds: json['externalIds'] == null + ? null + : SeerrExternalIds.fromJson(json['externalIds'] as Map), + relatedVideos: (json['relatedVideos'] as List?) + ?.map((e) => SeerrRelatedVideo.fromJson(e as Map)) + .toList(), seasons: (json['seasons'] as List?) ?.map((e) => SeerrSeason.fromJson(e as Map)) .toList(), @@ -29,6 +96,9 @@ SeerrCredits _$SeerrCreditsFromJson(Map json) => SeerrCredits( cast: (json['cast'] as List?) ?.map((e) => SeerrCastMember.fromJson(e as Map)) .toList(), + crew: (json['crew'] as List?) + ?.map((e) => SeerrCrewMember.fromJson(e as Map)) + .toList(), ); SeerrCastMember _$SeerrCastMemberFromJson(Map json) => diff --git a/lib/models/seerr/seerr_media.dart b/lib/models/seerr/seerr_media.dart index 9007dfd7..56f6dc63 100644 --- a/lib/models/seerr/seerr_media.dart +++ b/lib/models/seerr/seerr_media.dart @@ -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? 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; diff --git a/lib/models/seerr/seerr_media.g.dart b/lib/models/seerr/seerr_media.g.dart index d4c1eb31..ead3a80a 100644 --- a/lib/models/seerr/seerr_media.g.dart +++ b/lib/models/seerr/seerr_media.g.dart @@ -21,6 +21,14 @@ SeerrMedia _$SeerrMediaFromJson(Map json) => SeerrMedia( mediaInfo: json['mediaInfo'] == null ? null : SeerrMediaInfo.fromJson(json['mediaInfo'] as Map), + 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?) + ?.map((e) => e as String) + .toList(), ); SeerrMediaInfo _$SeerrMediaInfoFromJson(Map json) => diff --git a/lib/models/seerr/seerr_page.dart b/lib/models/seerr/seerr_page.dart index f14bdbfc..c3e58f72 100644 --- a/lib/models/seerr/seerr_page.dart +++ b/lib/models/seerr/seerr_page.dart @@ -4,8 +4,9 @@ class SeerrPage { final List 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 { final info = json['pageInfo'] is Map ? json['pageInfo'] as Map : 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 { if (fromItem(item) case final T parsed) parsed, ], hasMore: page < totalPages, + totalResults: totalResults, ); } } diff --git a/lib/models/simkl/simkl_all_items_entry.dart b/lib/models/simkl/simkl_all_items_entry.dart index 97ef6d3d..f2aba516 100644 --- a/lib/models/simkl/simkl_all_items_entry.dart +++ b/lib/models/simkl/simkl_all_items_entry.dart @@ -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; diff --git a/lib/models/simkl/simkl_all_items_entry.g.dart b/lib/models/simkl/simkl_all_items_entry.g.dart index 97d46e91..c4ac04dc 100644 --- a/lib/models/simkl/simkl_all_items_entry.g.dart +++ b/lib/models/simkl/simkl_all_items_entry.g.dart @@ -25,6 +25,8 @@ SimklAllItemsMedia _$SimklAllItemsMediaFromJson(Map json) => SimklAllItemsEntry _$SimklAllItemsEntryFromJson(Map 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), @@ -33,4 +35,5 @@ SimklAllItemsEntry _$SimklAllItemsEntryFromJson(Map json) => : SimklAllItemsMedia.fromJson(json['movie'] as Map), animeType: json['anime_type'] as String?, totalEpisodes: flexibleInt(json['total_episodes_count']), + notAiredEpisodes: flexibleInt(json['not_aired_episodes_count']), ); diff --git a/lib/models/simkl/simkl_best_item.dart b/lib/models/simkl/simkl_best_item.dart index f7d29590..c70d6d17 100644 --- a/lib/models/simkl/simkl_best_item.dart +++ b/lib/models/simkl/simkl_best_item.dart @@ -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 json) => _$SimklBestItemFromJson(json); } diff --git a/lib/models/simkl/simkl_best_item.g.dart b/lib/models/simkl/simkl_best_item.g.dart index fb294e37..79a174fe 100644 --- a/lib/models/simkl/simkl_best_item.g.dart +++ b/lib/models/simkl/simkl_best_item.g.dart @@ -9,6 +9,8 @@ part of 'simkl_best_item.dart'; SimklBestItem _$SimklBestItemFromJson(Map 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), diff --git a/lib/models/simkl/simkl_detail.dart b/lib/models/simkl/simkl_detail.dart new file mode 100644 index 00000000..9008fa34 --- /dev/null +++ b/lib/models/simkl/simkl_detail.dart @@ -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? 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? genres; + final String? country; + final String? language; + final int? totalEpisodes; + final String? status; + final String? network; + final String? director; + final List? studios; + final String? seasonNameYear; + final SimklRatings? ratings; + final String? trailer; + final List 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 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 json) => + SimklAirs(day: json['day'] as String?, time: json['time'] as String?, timezone: json['timezone'] as String?); +} diff --git a/lib/models/simkl/simkl_images.dart b/lib/models/simkl/simkl_images.dart index a58f7cc7..3db250cd 100644 --- a/lib/models/simkl/simkl_images.dart +++ b/lib/models/simkl/simkl_images.dart @@ -3,7 +3,25 @@ String? simklPosterUrl(String? hash) { return 'https://simkl.in/posters/${hash}_m.webp'; } +Map? 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? 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'}; +} diff --git a/lib/models/simkl/simkl_recommendation.dart b/lib/models/simkl/simkl_recommendation.dart index 6d4a1bc3..25822cfc 100644 --- a/lib/models/simkl/simkl_recommendation.dart +++ b/lib/models/simkl/simkl_recommendation.dart @@ -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 json) => _$SimklRecommendationFromJson(json); } diff --git a/lib/models/simkl/simkl_recommendation.g.dart b/lib/models/simkl/simkl_recommendation.g.dart index bac2e01d..db83ba97 100644 --- a/lib/models/simkl/simkl_recommendation.g.dart +++ b/lib/models/simkl/simkl_recommendation.g.dart @@ -9,8 +9,12 @@ part of 'simkl_recommendation.dart'; SimklRecommendation _$SimklRecommendationFromJson(Map 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), ); diff --git a/lib/models/simkl/simkl_search_result.dart b/lib/models/simkl/simkl_search_result.dart index 268c93a6..03a45664 100644 --- a/lib/models/simkl/simkl_search_result.dart +++ b/lib/models/simkl/simkl_search_result.dart @@ -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? 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, diff --git a/lib/models/simkl/simkl_search_result.g.dart b/lib/models/simkl/simkl_search_result.g.dart index 63ce64e5..54074754 100644 --- a/lib/models/simkl/simkl_search_result.g.dart +++ b/lib/models/simkl/simkl_search_result.g.dart @@ -9,6 +9,10 @@ part of 'simkl_search_result.dart'; SimklSearchResult _$SimklSearchResultFromJson(Map 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?, diff --git a/lib/models/simkl/simkl_title.dart b/lib/models/simkl/simkl_title.dart new file mode 100644 index 00000000..ca454750 --- /dev/null +++ b/lib/models/simkl/simkl_title.dart @@ -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? simklTitleNames(Object? raw) => stringListFromRaw(raw, mapKey: 'name', nullIfEmpty: true); diff --git a/lib/models/simkl/simkl_trending_item.dart b/lib/models/simkl/simkl_trending_item.dart index cdc000ec..c7a975c1 100644 --- a/lib/models/simkl/simkl_trending_item.dart +++ b/lib/models/simkl/simkl_trending_item.dart @@ -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? 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? 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 json) => _$SimklTrendingItemFromJson(json); } diff --git a/lib/models/simkl/simkl_trending_item.g.dart b/lib/models/simkl/simkl_trending_item.g.dart index 8e439ace..810431cd 100644 --- a/lib/models/simkl/simkl_trending_item.g.dart +++ b/lib/models/simkl/simkl_trending_item.g.dart @@ -9,10 +9,16 @@ part of 'simkl_trending_item.dart'; SimklTrendingItem _$SimklTrendingItemFromJson(Map 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?) @@ -23,8 +29,12 @@ SimklTrendingItem _$SimklTrendingItemFromJson(Map json) => ratings: json['ratings'] == null ? null : SimklRatings.fromJson(json['ratings'] as Map), + 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), ); diff --git a/lib/models/trakt/trakt_cast_entry.dart b/lib/models/trakt/trakt_cast_entry.dart index 16261589..32f0086c 100644 --- a/lib/models/trakt/trakt_cast_entry.dart +++ b/lib/models/trakt/trakt_cast_entry.dart @@ -29,9 +29,24 @@ class TraktPerson { @JsonSerializable(createToJson: false) class TraktCastEntry { final List? characters; + @JsonKey(name: 'episode_count') + final int? episodeCount; + final List? 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 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 cast; + final List guestStars; + final List crew; + + const TraktPeople({this.cast = const [], this.guestStars = const [], this.crew = const []}); +} diff --git a/lib/models/trakt/trakt_cast_entry.g.dart b/lib/models/trakt/trakt_cast_entry.g.dart index 571f9f39..4f57e9d3 100644 --- a/lib/models/trakt/trakt_cast_entry.g.dart +++ b/lib/models/trakt/trakt_cast_entry.g.dart @@ -25,6 +25,9 @@ TraktCastEntry _$TraktCastEntryFromJson(Map json) => characters: (json['characters'] as List?) ?.map((e) => e as String) .toList(), + episodeCount: (json['episode_count'] as num?)?.toInt(), + jobs: (json['jobs'] as List?)?.map((e) => e as String).toList(), + job: json['job'] as String?, person: json['person'] == null ? null : TraktPerson.fromJson(json['person'] as Map), diff --git a/lib/models/trakt/trakt_catalog_media.dart b/lib/models/trakt/trakt_catalog_media.dart index 8868d10e..f31890cf 100644 --- a/lib/models/trakt/trakt_catalog_media.dart +++ b/lib/models/trakt/trakt_catalog_media.dart @@ -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 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 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? genres; final String? certification; final String? trailer; + @JsonKey(name: 'comment_count') + final int? commentCount; + final String? language; + final List? languages; + @JsonKey(name: 'available_translations') + final List? availableTranslations; + final String? country; + @JsonKey(name: 'favorited_by') + final List? favoritedBy; + @JsonKey(name: 'recommended_by') + final List? 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, }); diff --git a/lib/models/trakt/trakt_catalog_media.g.dart b/lib/models/trakt/trakt_catalog_media.g.dart index 16eda52a..079078af 100644 --- a/lib/models/trakt/trakt_catalog_media.g.dart +++ b/lib/models/trakt/trakt_catalog_media.g.dart @@ -6,24 +6,45 @@ part of 'trakt_catalog_media.dart'; // JsonSerializableGenerator // ************************************************************************** -TraktCatalogMedia _$TraktCatalogMediaFromJson(Map json) => - TraktCatalogMedia( - title: json['title'] as String?, - year: (json['year'] as num?)?.toInt(), - ids: TraktIds.fromJson(json['ids'] as Map), - 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?) - ?.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), - ); +TraktCatalogMedia _$TraktCatalogMediaFromJson( + Map json, +) => TraktCatalogMedia( + title: json['title'] as String?, + year: (json['year'] as num?)?.toInt(), + ids: TraktIds.fromJson(json['ids'] as Map), + 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?)?.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?) + ?.map((e) => e as String) + .toList(), + availableTranslations: (json['available_translations'] as List?) + ?.map((e) => e as String) + .toList(), + country: json['country'] as String?, + favoritedBy: (json['favorited_by'] as List?) + ?.map((e) => TraktRecommendationUser.fromJson(e as Map)) + .toList(), + recommendedBy: (json['recommended_by'] as List?) + ?.map((e) => TraktRecommendationUser.fromJson(e as Map)) + .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), + images: json['images'] == null + ? null + : TraktImages.fromJson(json['images'] as Map), +); diff --git a/lib/models/trakt/trakt_images.dart b/lib/models/trakt/trakt_images.dart index 53c3147e..6e81f27d 100644 --- a/lib/models/trakt/trakt_images.dart +++ b/lib/models/trakt/trakt_images.dart @@ -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? urls) { diff --git a/lib/providers/catalog_sources_provider.dart b/lib/providers/catalog_sources_provider.dart index 8549e1d7..a3dca70b 100644 --- a/lib/providers/catalog_sources_provider.dart +++ b/lib/providers/catalog_sources_provider.dart @@ -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 Function(); @@ -94,7 +95,11 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie final PlexDiscoverSessionSupplier? plexSessionSupplier; final _CatalogSourceBinding _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 _trakt = _CatalogSourceBinding(TraktCatalogSource.new); diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart index d725543f..06a965bb 100644 --- a/lib/providers/explore_provider.dart +++ b/lib/providers/explore_provider.dart @@ -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, diff --git a/lib/screens/catalog_item_detail_screen.dart b/lib/screens/catalog_item_detail_screen.dart index deca4f40..12a4935c 100644 --- a/lib/screens/catalog_item_detail_screen.dart +++ b/lib/screens/catalog_item_detail_screen.dart @@ -5,8 +5,10 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../focus/focusable_action_bar.dart'; +import '../focus/focusable_button.dart'; import '../focus/dpad_navigator.dart'; import '../focus/input_mode_tracker.dart'; import '../focus/key_event_utils.dart'; @@ -17,6 +19,7 @@ import '../media/media_hub.dart'; import '../media/media_item.dart'; import '../models/catalog/catalog_cast_member.dart'; import '../models/catalog/catalog_item.dart'; +import '../models/catalog/catalog_metadata.dart'; import '../providers/catalog_sources_provider.dart'; import '../services/catalog/catalog_library_matcher.dart'; import '../services/catalog/catalog_source.dart'; @@ -24,6 +27,8 @@ import '../services/catalog/seerr_catalog_source.dart'; import '../utils/app_logger.dart'; import '../utils/desktop_window_padding.dart'; import '../utils/formatters.dart'; +import '../utils/country_codes.dart'; +import '../utils/language_codes.dart'; import '../utils/media_navigation_helper.dart'; import '../utils/platform_detector.dart'; import '../utils/snackbar_helper.dart'; @@ -31,6 +36,7 @@ import '../widgets/app_bar_back_button.dart'; import '../widgets/app_icon.dart'; import '../widgets/backend_badge.dart'; import '../widgets/cast_member_strip.dart'; +import '../widgets/horizontal_scroll_with_arrows.dart'; import '../widgets/focusable_list_tile.dart'; import '../widgets/hub_section.dart'; import '../widgets/optimized_media_image.dart'; @@ -60,11 +66,20 @@ class _CatalogItemDetailScreenState extends State { final _relatedSectionKey = GlobalKey(); final _hubFocusMemory = HubFocusMemory(); final ScrollController _scrollController = ScrollController(); + final _spoilerTagFocusNode = FocusNode(debugLabel: 'catalog_spoiler_tags'); + List _linkFocusNodes = const []; + List> _relationSectionKeys = const []; + List _orderedTags = const []; + List _streamingLinks = const []; + List _otherLinks = const []; + bool _showSpoilerTags = false; List _libraryMatchFocusNodes = const []; CatalogSource? _watchlistSource; SeerrCatalogSource? _requestSource; bool _mutatingWatchlist = false; + CatalogItem? _detailItem; + /// Library items matching this catalog item; null while resolving. List? _matches; @@ -76,12 +91,15 @@ class _CatalogItemDetailScreenState extends State { /// row only renders once loaded non-empty). List? _related; + /// Labelled franchise edges, kept separate from taste-based recommendations. + List _relations = const []; + @override void initState() { super.initState(); + _syncDetailCollections(widget.item); unawaited(_resolveMatches()); - unawaited(_loadCast()); - unawaited(_loadRelated()); + unawaited(_loadDetail()); final sources = context.read(); _watchlistSource = sources.watchlistSourceFor(widget.item); // Request needs a connected Seerr, the permission for this kind, and a @@ -106,6 +124,10 @@ class _CatalogItemDetailScreenState extends State { @override void dispose() { _backButtonFocusNode.dispose(); + _spoilerTagFocusNode.dispose(); + for (final node in _linkFocusNodes) { + node.dispose(); + } _watchlistSource?.watchlistChanges.removeListener(_onWatchlistChanged); for (final node in _libraryMatchFocusNodes) { node.dispose(); @@ -146,36 +168,70 @@ class _CatalogItemDetailScreenState extends State { CatalogSource? get _ownSource => context.read().connectedSources.firstWhereOrNull((s) => s.id == widget.item.source); - /// One lazy request against the item's own source; failures just leave the - /// section hidden. - Future _loadCast() async { + CatalogItem get _item => _detailItem ?? widget.item; + + void _syncDetailCollections(CatalogItem item) { + final tags = [ + for (final tag in item.tags ?? const []) + if (tag.name.trim().isNotEmpty) tag, + ]..sort((a, b) => (b.rank ?? -1).compareTo(a.rank ?? -1)); + final streamingLinks = []; + final otherLinks = []; + for (final link in item.links ?? const []) { + if (link.label.trim().isEmpty || link.url.trim().isEmpty) continue; + (link.isStreaming ? streamingLinks : otherLinks).add(link); + } + for (final node in _linkFocusNodes) { + node.dispose(); + } + _orderedTags = tags; + _streamingLinks = streamingLinks; + _otherLinks = otherLinks; + _linkFocusNodes = [ + for (var index = 0; index < streamingLinks.length + otherLinks.length; index++) + FocusNode(debugLabel: 'catalog_external_link_$index'), + ]; + } + + /// One lazy detail load against the item's own source; failures keep the + /// opening item visible and leave provider-only sections hidden. + Future _loadDetail() async { final source = _ownSource; if (source == null) return; try { - final cast = await source.fetchCast(widget.item); - if (mounted) setState(() => _cast = cast); + final detail = await source.fetchDetail(widget.item); + if (!mounted) return; + final relations = [ + for (final relation in detail.relations) + if (relation.items.isNotEmpty) relation, + ]; + _syncDetailCollections(detail.item); + setState(() { + _detailItem = detail.item; + _cast = detail.cast; + _related = detail.related; + _relations = relations; + _relationSectionKeys = [for (var index = 0; index < relations.length; index++) GlobalKey()]; + }); } catch (e) { - appLogger.d('Catalog cast load failed for ${widget.item.identityKey}', error: e); + appLogger.d('Catalog detail load failed for ${widget.item.identityKey}', error: e); } } - /// One lazy request against the item's own source; failures just leave the - /// row hidden. - Future _loadRelated() async { - final source = _ownSource; - if (source == null) return; - try { - final related = await source.fetchRelated(widget.item); - if (mounted) setState(() => _related = related); - } catch (e) { - appLogger.d('Catalog related load failed for ${widget.item.identityKey}', error: e); - } - } + bool get _hasTrailer => _item.trailerUrl?.trim().isNotEmpty ?? false; - bool get _hasActions => _watchlistSource != null || _requestSource != null; + bool get _hasActions => _watchlistSource != null || _requestSource != null || _hasTrailer; bool get _hasLibraryMatches => _libraryMatchFocusNodes.isNotEmpty; + bool get _hasSpoilerTags => _orderedTags.any((tag) => tag.isSpoiler); + + bool get _hasSpoilerReveal => _hasSpoilerTags && !_showSpoilerTags; + + bool get _hasDetailActions => _hasSpoilerReveal || _linkFocusNodes.isNotEmpty; + + bool get _hasHubRows => _relations.isNotEmpty || (_related?.isNotEmpty ?? false); + void _revealFocusNode(FocusNode? node, {double alignment = 0.3}) { final focusContext = node?.context; if (focusContext == null) return; @@ -196,40 +252,29 @@ class _CatalogItemDetailScreenState extends State { _revealFocusNode(node); } - bool _focusSectionBelowLibraryMatches() { - if (_cast?.isNotEmpty ?? false) { - _requestCastFocus(); - return true; - } - if (_related?.isNotEmpty ?? false) { - _requestRelatedFocus(); - return true; - } - return false; + void _requestLinkFocus(int index) { + if (index < 0 || index >= _linkFocusNodes.length) return; + final node = _linkFocusNodes[index]; + node.requestFocus(); + _revealFocusNode(node); } - KeyEventResult _handleLibraryMatchKey(int index, KeyEvent event) { - if (!event.isActionable) return KeyEventResult.ignored; - final key = event.logicalKey; - if (key.isUpKey) { - if (index > 0) { - _requestLibraryMatchFocus(index - 1); - } else if (_hasActions) { - _requestActionBarFocus(); - } else { - return KeyEventResult.ignored; - } - return KeyEventResult.handled; + void _requestFirstDetailActionFocus() { + if (_hasSpoilerReveal) { + _spoilerTagFocusNode.requestFocus(); + _revealFocusNode(_spoilerTagFocusNode); + } else { + _requestLinkFocus(0); } - if (key.isDownKey) { - if (index + 1 < _libraryMatchFocusNodes.length) { - _requestLibraryMatchFocus(index + 1); - } else if (!_focusSectionBelowLibraryMatches()) { - return KeyEventResult.ignored; - } - return KeyEventResult.handled; + } + + void _requestLastDetailActionFocus() { + if (_linkFocusNodes.isNotEmpty) { + _requestLinkFocus(_linkFocusNodes.length - 1); + } else if (_hasSpoilerReveal) { + _spoilerTagFocusNode.requestFocus(); + _revealFocusNode(_spoilerTagFocusNode); } - return KeyEventResult.ignored; } void _requestCastFocus() { @@ -253,39 +298,143 @@ class _CatalogItemDetailScreenState extends State { unawaited(_scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut)); } + void _requestRelationFocus(int index) { + if (index < 0 || index >= _relationSectionKeys.length) return; + _relationSectionKeys[index].currentState?.requestFocusFromMemory(); + } + void _requestRelatedFocus() { _relatedSectionKey.currentState?.requestFocusFromMemory(); } - void _focusSectionBelowActions() { + void _requestFirstHubFocus() { + if (_relations.isNotEmpty) { + _requestRelationFocus(0); + } else { + _requestRelatedFocus(); + } + } + + bool _focusSectionBelowLibraryMatches() { + if (_cast?.isNotEmpty ?? false) { + _requestCastFocus(); + return true; + } + if (_hasHubRows) { + _requestFirstHubFocus(); + return true; + } + return false; + } + + void _focusSectionBelowDetailActions() { if (_hasLibraryMatches) { _requestLibraryMatchFocus(0); } else if (_cast?.isNotEmpty ?? false) { _requestCastFocus(); } else { - _requestRelatedFocus(); + _requestFirstHubFocus(); + } + } + + void _focusSectionBelowActions() { + if (_hasDetailActions) { + _requestFirstDetailActionFocus(); + } else { + _focusSectionBelowDetailActions(); + } + } + + void _focusSectionAboveLibraryMatches() { + if (_hasDetailActions) { + _requestLastDetailActionFocus(); + } else { + _requestActionBarFocus(); } } void _focusSectionAboveCast() { if (_hasLibraryMatches) { _requestLibraryMatchFocus(_libraryMatchFocusNodes.length - 1); + } else if (_hasDetailActions) { + _requestLastDetailActionFocus(); + } else { + _requestActionBarFocus(); + } + } + + void _focusSectionAboveFirstHub() { + if (_cast?.isNotEmpty ?? false) { + _requestCastFocus(); + } else if (_hasLibraryMatches) { + _requestLibraryMatchFocus(_libraryMatchFocusNodes.length - 1); + } else if (_hasDetailActions) { + _requestLastDetailActionFocus(); } else { _requestActionBarFocus(); } } void _focusSectionAboveRelated() { - if (_cast?.isNotEmpty ?? false) { - _requestCastFocus(); - } else if (_hasLibraryMatches) { - _requestLibraryMatchFocus(_libraryMatchFocusNodes.length - 1); + if (_relations.isNotEmpty) { + _requestRelationFocus(_relations.length - 1); } else { - _requestActionBarFocus(); + _focusSectionAboveFirstHub(); } } - bool? get _isOnWatchlist => _watchlistSource?.isOnWatchlist(widget.item.kind, widget.item.ids); + KeyEventResult _handleLibraryMatchKey(int index, KeyEvent event) { + if (!event.isActionable) return KeyEventResult.ignored; + final key = event.logicalKey; + if (key.isUpKey) { + if (index > 0) { + _requestLibraryMatchFocus(index - 1); + } else if (_hasDetailActions || _hasActions) { + _focusSectionAboveLibraryMatches(); + } else { + return KeyEventResult.ignored; + } + return KeyEventResult.handled; + } + if (key.isDownKey) { + if (index + 1 < _libraryMatchFocusNodes.length) { + _requestLibraryMatchFocus(index + 1); + } else if (!_focusSectionBelowLibraryMatches()) { + return KeyEventResult.ignored; + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + void _revealSpoilerTags() { + if (!_hasSpoilerReveal) return; + setState(() => _showSpoilerTags = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (_linkFocusNodes.isNotEmpty) { + _requestLinkFocus(0); + } else { + _focusSectionBelowDetailActions(); + } + }); + } + + Future _openExternalUrl(String value) async { + final uri = Uri.tryParse(value); + if (uri == null || !uri.hasScheme) { + appLogger.d('Catalog external URL is invalid: $value'); + return; + } + try { + final opened = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!opened) appLogger.d('Catalog external URL could not be opened: $value'); + } catch (e) { + appLogger.d('Catalog external URL failed to open: $value', error: e); + } + } + + bool? get _isOnWatchlist => _watchlistSource?.isOnWatchlist(_item.kind, _item.ids); Future _toggleWatchlist() async { final source = _watchlistSource; @@ -301,9 +450,9 @@ class _CatalogItemDetailScreenState extends State { _mutatingWatchlist = true; try { if (current) { - await source.removeFromWatchlist(widget.item.kind, widget.item.ids); + await source.removeFromWatchlist(_item.kind, _item.ids); } else { - await source.addToWatchlist(widget.item.kind, widget.item.ids); + await source.addToWatchlist(_item.kind, _item.ids); } } catch (_) { if (mounted) showErrorSnackBar(context, t.explore.watchlistUpdateFailed); @@ -371,11 +520,11 @@ class _CatalogItemDetailScreenState extends State { } String get _metaLine { - final item = widget.item; + final item = _item; final parts = [ if (item.year != null) '${item.year}', if (item.runtimeMinutes != null) formatDurationTextual(Duration(minutes: item.runtimeMinutes!).inMilliseconds), - if (item.certification != null && item.certification!.isNotEmpty) item.certification!, + if (item.certification?.trim().isNotEmpty ?? false) item.certification!.trim(), ]; return parts.join(' • '); } @@ -387,28 +536,507 @@ class _CatalogItemDetailScreenState extends State { CatalogAirStatus.upcoming => t.explore.status.upcoming, }; - /// Score (with a compact vote count), airing status, episode count, and - /// network/studio — all data that rode along on the row fetch. - Widget? _buildStatsChips(ThemeData theme) { - final item = widget.item; - String? score; - if (item.rating != null) { - score = item.rating!.toStringAsFixed(1); - if (item.votes != null && item.votes! > 0) { - final compactVotes = NumberFormat.compact(locale: LocaleSettings.currentLocale.intlLocaleName); - score = '$score (${compactVotes.format(item.votes)})'; + static String _seasonName(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, + }; + + static String _seasonLabel(CatalogSeasonInfo season) { + final name = _seasonName(season.name); + return season.year == null ? name : t.explore.season.withYear(season: name, year: season.year!); + } + + static String _formatLabel(CatalogFormat format) => switch (format) { + CatalogFormat.tv => t.explore.format.tv, + CatalogFormat.tvShort => t.explore.format.tvShort, + CatalogFormat.movie => t.explore.format.movie, + CatalogFormat.special => t.explore.format.special, + CatalogFormat.ova => t.explore.format.ova, + CatalogFormat.ona => t.explore.format.ona, + CatalogFormat.music => t.explore.format.music, + CatalogFormat.other => t.explore.format.other, + }; + + static String _sourceMaterialLabel(CatalogSourceMaterial source) => switch (source) { + CatalogSourceMaterial.original => t.explore.sourceMaterial.original, + CatalogSourceMaterial.manga => t.explore.sourceMaterial.manga, + CatalogSourceMaterial.lightNovel => t.explore.sourceMaterial.lightNovel, + CatalogSourceMaterial.novel => t.explore.sourceMaterial.novel, + CatalogSourceMaterial.visualNovel => t.explore.sourceMaterial.visualNovel, + CatalogSourceMaterial.game => t.explore.sourceMaterial.game, + CatalogSourceMaterial.webComic => t.explore.sourceMaterial.webComic, + CatalogSourceMaterial.musicRelease => t.explore.sourceMaterial.musicRelease, + CatalogSourceMaterial.otherMedia => t.explore.sourceMaterial.otherMedia, + }; + + static String _creditRoleLabel(CatalogCreditRole role) => switch (role) { + CatalogCreditRole.director => t.explore.creditRole.director, + CatalogCreditRole.writer => t.explore.creditRole.writer, + CatalogCreditRole.producer => t.explore.creditRole.producer, + CatalogCreditRole.creator => t.explore.creditRole.creator, + CatalogCreditRole.composer => t.explore.creditRole.composer, + }; + + static String? _ratingSourceLabel(String source) => switch (source) { + 'critic' => t.explore.ratingSource.critic, + 'audience' => t.explore.ratingSource.audience, + 'imdb' => t.explore.ratingSource.imdb, + 'tmdb' => t.explore.ratingSource.tmdb, + 'rottenTomatoes' => t.explore.ratingSource.rottenTomatoes, + // Plex splits Rotten Tomatoes into its two panels, so both the provenance + // and the critic/audience distinction survive. + 'rottenTomatoesCritic' => t.explore.ratingSource.rottenTomatoesCritic, + 'rottenTomatoesAudience' => t.explore.ratingSource.rottenTomatoesAudience, + 'simkl' => t.explore.ratingSource.simkl, + 'mal' => t.explore.ratingSource.mal, + 'anilist' => t.explore.ratingSource.anilist, + 'trakt' => t.explore.ratingSource.trakt, + _ => null, + }; + + static String _relationLabel(CatalogRelationType type) => switch (type) { + CatalogRelationType.prequel => t.explore.relation.prequel, + CatalogRelationType.sequel => t.explore.relation.sequel, + CatalogRelationType.sideStory => t.explore.relation.sideStory, + CatalogRelationType.spinOff => t.explore.relation.spinOff, + CatalogRelationType.alternativeVersion => t.explore.relation.alternativeVersion, + CatalogRelationType.summary => t.explore.relation.summary, + CatalogRelationType.parentStory => t.explore.relation.parentStory, + CatalogRelationType.adaptation => t.explore.relation.adaptation, + CatalogRelationType.other => t.explore.relation.other, + }; + + static String? _joinValues(Iterable? raw, {String Function(String value)? displayName}) { + if (raw == null) return null; + final values = []; + final seen = {}; + for (final rawValue in raw) { + final value = rawValue.trim(); + if (value.isEmpty) continue; + final displayed = displayName?.call(value) ?? value; + if (displayed.trim().isNotEmpty && seen.add(displayed)) values.add(displayed); + } + return values.isEmpty ? null : values.join(' • '); + } + + static String? _rankLabel(CatalogRank rank) { + if (!rank.allTime) { + final season = rank.season; + final window = switch ((season, rank.year)) { + (final CatalogSeasonName season, final int year) => t.explore.season.withYear( + season: _seasonName(season), + year: year, + ), + (final CatalogSeasonName season, null) => _seasonName(season), + (null, final int year) => '$year', + _ => null, + }; + return window == null ? null : t.explore.badge.rankSeasonal(n: rank.rank, season: window); + } + return 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, + }; + } + + static String? _availabilityLabel(CatalogAvailability availability, {required bool is4k}) { + if (is4k) { + return availability == CatalogAvailability.available ? t.explore.badge.availableIn4k : null; + } + return switch (availability) { + CatalogAvailability.available => t.explore.badge.available, + CatalogAvailability.partiallyAvailable => t.explore.badge.partiallyAvailable, + CatalogAvailability.unavailable => null, + }; + } + + static String _requestStateLabel(CatalogRequestState request, {required bool is4k}) { + if (is4k && + {CatalogRequestState.pending, CatalogRequestState.approved, CatalogRequestState.processing}.contains(request)) { + return t.explore.badge.requested4k; + } + return switch (request) { + CatalogRequestState.pending => t.explore.badge.pendingApproval, + CatalogRequestState.approved => t.explore.badge.requested, + CatalogRequestState.processing => t.explore.badge.processing, + CatalogRequestState.declined => t.explore.badge.declined, + CatalogRequestState.failed => t.explore.badge.requestFailed, + }; + } + + /// Headline score, leaderboard context, audience counts, availability and + /// release-shape facts that fit the established compact chip treatment. + Widget? _buildStatsChips() { + final item = _item; + final locale = LocaleSettings.currentLocale.intlLocaleName; + final compact = NumberFormat.compact(locale: locale); + final chips = []; + void add(String? label, {IconData? icon, Color? iconColor}) { + if (label?.trim().isNotEmpty ?? false) { + chips.add(StatChip(icon: icon, iconColor: iconColor, label: label!)); } } - final chips = [ - if (score != null) StatChip(icon: Symbols.star_rounded, iconColor: Colors.amber, label: score), - if (item.airStatus != null) StatChip(label: _statusLabel(item.airStatus!)), - if (item.episodeCount != null) StatChip(label: t.explore.episodeCount(n: item.episodeCount!)), - if (item.network != null) StatChip(label: item.network!), - ]; + + if (item.rating case final rating?) { + var score = rating.toStringAsFixed(1); + if (item.votes case final votes?) { + score = '$score (${t.explore.stats.votes(n: compact.format(votes))})'; + } + add(score, icon: Symbols.star_rounded, iconColor: Colors.amber); + } + if (item.airStatus case final status?) add(_statusLabel(status)); + if (item.episodeCount case final count?) add(t.explore.episodeCount(n: count)); + if (item.unairedEpisodeCount case final count?) add(t.explore.detail.unairedEpisodes(n: count)); + add(item.network); + if (item.broadcastSeason case final season?) add(_seasonLabel(season)); + if (item.format case final format?) add(_formatLabel(format)); + if (item.sourceMaterial case final source?) add(_sourceMaterialLabel(source)); + if (item.isAdult == true) add(t.explore.badge.adult); + if (item.addedAt case final addedAt?) { + add(t.explore.detail.addedOn(date: DateFormat.yMMMd(locale).format(addedAt.toLocal()))); + } + for (final rank in item.ranks ?? const []) { + add(_rankLabel(rank)); + } + + final audience = item.audience; + if (audience != null) { + if (audience.watchingNow case final count?) add(t.explore.badge.watchingNow(n: compact.format(count))); + if (audience.listed case final count?) add(t.explore.stats.listed(n: compact.format(count))); + if (audience.viewers case final count? when audience.viewersPeriod != null) { + final formatted = compact.format(count); + add(switch (audience.viewersPeriod!) { + CatalogAudiencePeriod.day => t.explore.stats.viewersDay(n: formatted), + CatalogAudiencePeriod.week => t.explore.stats.viewersWeek(n: formatted), + CatalogAudiencePeriod.month => t.explore.stats.viewersMonth(n: formatted), + CatalogAudiencePeriod.year => t.explore.stats.viewersYear(n: formatted), + CatalogAudiencePeriod.allTime => t.explore.stats.viewersAllTime(n: formatted), + }); + } + if (audience.planning case final count?) add(t.explore.stats.planning(n: compact.format(count))); + if (audience.watching case final count?) add(t.explore.stats.watching(n: compact.format(count))); + if (audience.completed case final count?) add(t.explore.stats.completed(n: compact.format(count))); + if (audience.onHold case final count?) add(t.explore.stats.onHold(n: compact.format(count))); + if (audience.dropped case final count?) add(t.explore.stats.dropped(n: compact.format(count))); + if (audience.favorited case final count?) add(t.explore.stats.favorited(n: compact.format(count))); + if (audience.dropRate case final rate?) { + add(t.explore.stats.dropRate(percent: NumberFormat.percentPattern(locale).format(rate))); + } + if (audience.comments case final count?) add(t.explore.stats.comments(n: count)); + } + + final server = item.serverState; + if (server != null) { + if (server.availability case final availability?) { + add(_availabilityLabel(availability, is4k: false)); + } + if (server.availability4k case final availability?) { + add(_availabilityLabel(availability, is4k: true)); + } + if (server.request case final request?) add(_requestStateLabel(request, is4k: false)); + if (server.request4k case final request?) add(_requestStateLabel(request, is4k: true)); + if (server.availableSeasons case final available? when server.totalSeasons != null) { + add(t.explore.badge.seasonsAvailable(available: available, total: server.totalSeasons!)); + } + } + if (chips.isEmpty) return null; return Wrap(spacing: 8, runSpacing: 8, children: chips); } + Widget? _buildRatingsSection(ThemeData theme) { + final compact = NumberFormat.compact(locale: LocaleSettings.currentLocale.intlLocaleName); + final chips = []; + for (final rating in _item.ratings ?? const []) { + final source = _ratingSourceLabel(rating.source); + if (source == null) continue; + var label = '$source ${rating.value.toStringAsFixed(1)}'; + if (rating.votes case final votes?) { + label = '$label (${t.explore.stats.votes(n: compact.format(votes))})'; + } + chips.add(StatChip(icon: Symbols.star_rounded, iconColor: Colors.amber, label: label)); + } + if (chips.isEmpty) return null; + return Column( + crossAxisAlignment: .start, + children: [ + Text(t.explore.detail.ratings, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Wrap(spacing: 8, runSpacing: 8, children: chips), + ], + ); + } + + Widget? _buildScheduleSection(ThemeData theme) { + final item = _item; + final chips = []; + final locale = LocaleSettings.currentLocale.intlLocaleName; + final broadcast = item.broadcast; + if (broadcast?.weekday case final weekday? when weekday >= DateTime.monday && weekday <= DateTime.sunday) { + final time = broadcast!.time?.trim(); + if (time?.isNotEmpty ?? false) { + final day = DateFormat.EEEE(locale).format(DateTime.utc(2024, 1, weekday)); + final timezone = broadcast.timezone?.trim(); + chips.add( + StatChip( + label: timezone?.isNotEmpty ?? false + ? t.explore.broadcastWithZone(day: day, time: time!, timezone: timezone!) + : t.explore.broadcast(day: day, time: time!), + ), + ); + } + } + if (item.nextEpisode case final next?) { + final duration = formatDurationTextual(next.timeUntil(DateTime.now()).inMilliseconds); + chips.add( + StatChip( + label: next.episode == null + ? t.explore.badge.nextAiringIn(duration: duration) + : t.explore.badge.nextEpisodeIn(episode: next.episode!, duration: duration), + ), + ); + } + if (chips.isEmpty) return null; + return Column( + crossAxisAlignment: .start, + children: [ + Text(t.explore.detail.schedule, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Wrap(spacing: 8, runSpacing: 8, children: chips), + ], + ); + } + + Widget _buildFactRow(ThemeData theme, String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: .start, + children: [ + SizedBox( + width: 140, + child: Text( + label, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.65)), + ), + ), + const SizedBox(width: 12), + Expanded(child: Text(value, style: theme.textTheme.bodyMedium)), + ], + ), + ); + } + + Widget? _buildFactsSection(ThemeData theme) { + final item = _item; + final locale = LocaleSettings.currentLocale.intlLocaleName; + final dateFormat = DateFormat.yMMMd(locale); + final currency = NumberFormat.simpleCurrency(locale: locale, name: 'USD', decimalDigits: 0); + final facts = <({String label, String value})>[]; + void add(String label, String? value) { + if (value?.trim().isNotEmpty ?? false) facts.add((label: label, value: value!)); + } + + add(t.explore.detail.originalTitle, item.originalTitle); + add(t.explore.detail.alsoKnownAs, _joinValues(item.altTitles)); + add(t.explore.detail.studios, _joinValues(item.studios)); + add(t.explore.detail.country, _joinValues(item.countries, displayName: CountryCodes.getDisplayName)); + add(t.explore.detail.language, _joinValues(item.languages, displayName: LanguageCodes.getDisplayName)); + if (item.releaseDate case final date?) add(t.explore.detail.released, dateFormat.format(date.toLocal())); + if (item.physicalReleaseDate case final date?) { + add(t.explore.detail.physicalRelease, dateFormat.format(date.toLocal())); + } + if (item.endDate case final date?) add(t.explore.detail.ended, dateFormat.format(date.toLocal())); + if (item.userRating case final rating?) add(t.explore.detail.yourRating, rating.toStringAsFixed(1)); + if (item.budget case final budget?) add(t.explore.detail.budget, currency.format(budget)); + if (item.revenue case final revenue?) add(t.explore.detail.revenue, currency.format(revenue)); + add(t.explore.detail.contentAdvisory, item.contentAdvisory); + if (facts.isEmpty) return null; + return Column( + crossAxisAlignment: .start, + children: [for (final fact in facts) _buildFactRow(theme, fact.label, fact.value)], + ); + } + + Widget? _buildRecommendersSection(ThemeData theme) { + final recommenders = _item.recommenders; + if (recommenders == null || recommenders.isEmpty) return null; + return Column( + crossAxisAlignment: .start, + children: [ + for (var index = 0; index < recommenders.length; index++) ...[ + if (index > 0) const SizedBox(height: 10), + Text(switch (recommenders[index].reason) { + CatalogRecommendationReason.favorited => t.explore.detail.favoritedBy(who: recommenders[index].displayName), + CatalogRecommendationReason.recommended => t.explore.detail.recommendedBy( + who: recommenders[index].displayName, + ), + }, style: theme.textTheme.titleSmall), + if (recommenders[index].note?.trim().isNotEmpty ?? false) ...[ + const SizedBox(height: 4), + Text(recommenders[index].note!.trim(), style: theme.textTheme.bodyMedium), + ], + ], + ], + ); + } + + Widget? _buildCrewSection(ThemeData theme) { + final credits = _item.credits; + if (credits == null || credits.isEmpty) return null; + final rows = []; + for (final role in CatalogCreditRole.values) { + final names = _joinValues(credits.where((credit) => credit.role == role).map((credit) => credit.name)); + if (names != null) rows.add(_buildFactRow(theme, _creditRoleLabel(role), names)); + } + if (rows.isEmpty) return null; + return Column( + crossAxisAlignment: .start, + children: [ + Text(t.explore.detail.crew, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + ...rows, + ], + ); + } + + Widget? _buildTagsSection(ThemeData theme) { + if (_orderedTags.isEmpty) return null; + final visibleTags = [ + for (final tag in _orderedTags) + if (!tag.isSpoiler || _showSpoilerTags) tag, + ]; + return Column( + crossAxisAlignment: .start, + children: [ + Text(t.explore.detail.tags, style: theme.textTheme.titleMedium), + if (visibleTags.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap(spacing: 8, runSpacing: 8, children: [for (final tag in visibleTags) StatChip(label: tag.name)]), + ], + if (_hasSpoilerReveal) ...[ + const SizedBox(height: 8), + FocusableButton( + focusNode: _spoilerTagFocusNode, + onPressed: _revealSpoilerTags, + onNavigateUp: _hasActions ? _requestActionBarFocus : null, + onNavigateDown: _linkFocusNodes.isNotEmpty ? () => _requestLinkFocus(0) : _focusSectionBelowDetailActions, + child: OutlinedButton.icon( + onPressed: _revealSpoilerTags, + icon: const AppIcon(Symbols.visibility_rounded, fill: 1), + label: Text(t.explore.detail.revealSpoilerTags), + ), + ), + ], + ], + ); + } + + void _focusAboveLinkGroup(int startIndex) { + if (startIndex > 0) { + _requestLinkFocus(startIndex - 1); + } else if (_hasSpoilerReveal) { + _spoilerTagFocusNode.requestFocus(); + _revealFocusNode(_spoilerTagFocusNode); + } else { + _requestActionBarFocus(); + } + } + + void _focusBelowLinkGroup(int endIndex) { + if (endIndex < _linkFocusNodes.length) { + _requestLinkFocus(endIndex); + } else { + _focusSectionBelowDetailActions(); + } + } + + Widget _buildLinksSection(ThemeData theme, String title, List links, int startIndex) { + final endIndex = startIndex + links.length; + return Column( + crossAxisAlignment: .start, + children: [ + Text(title, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (var localIndex = 0; localIndex < links.length; localIndex++) + FocusableButton( + focusNode: _linkFocusNodes[startIndex + localIndex], + onPressed: () => unawaited(_openExternalUrl(links[localIndex].url)), + onNavigateLeft: localIndex > 0 ? () => _requestLinkFocus(startIndex + localIndex - 1) : null, + onNavigateRight: localIndex + 1 < links.length + ? () => _requestLinkFocus(startIndex + localIndex + 1) + : null, + onNavigateUp: () => _focusAboveLinkGroup(startIndex), + onNavigateDown: () => _focusBelowLinkGroup(endIndex), + child: OutlinedButton.icon( + onPressed: () => unawaited(_openExternalUrl(links[localIndex].url)), + icon: const AppIcon(Symbols.open_in_new_rounded, fill: 1), + label: Text(t.explore.detail.openOn(site: links[localIndex].label)), + ), + ), + ], + ), + ], + ); + } + + Widget _buildBackgroundSection(ThemeData theme, String background) { + return Column( + crossAxisAlignment: .start, + children: [ + Text(t.explore.detail.background, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text(background, style: theme.textTheme.bodyLarge), + ], + ); + } + + Widget _buildGallerySection(ThemeData theme, List gallery) { + final cardWidth = CastMemberStrip.responsiveCardWidth(context); + final imageHeight = cardWidth * 1.5; + return Column( + crossAxisAlignment: .start, + children: [ + Text(t.explore.detail.gallery, style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + ExcludeFocus( + child: SizedBox( + height: imageHeight + 10, + child: HorizontalScrollWithArrows( + builder: (scrollController) => ListView.builder( + key: const Key('catalog_detail_gallery'), + addAutomaticKeepAlives: false, + addSemanticIndexes: false, + controller: scrollController, + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + padding: const EdgeInsets.symmetric(vertical: 5), + itemCount: gallery.length, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.only(right: 4), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: OptimizedMediaImage.poster(imagePath: gallery[index], width: cardWidth, height: imageHeight), + ), + ), + ), + ), + ), + ), + ], + ); + } + /// Horizontal cast strip — the same [CastMemberStrip] cards as the media /// detail screen. Trakt serves actors with their character; MAL serves /// characters with their role, so the section is titled accordingly. @@ -418,7 +1046,7 @@ class _CatalogItemDetailScreenState extends State { crossAxisAlignment: .start, children: [ Text( - const {CatalogSourceId.mal, CatalogSourceId.anilist}.contains(widget.item.source) + const {CatalogSourceId.mal, CatalogSourceId.anilist}.contains(_item.source) ? t.explore.characters : t.explore.cast, style: theme.textTheme.titleMedium, @@ -429,22 +1057,58 @@ class _CatalogItemDetailScreenState extends State { members: [ for (final member in cast) (name: member.name, secondary: member.secondary, imagePath: member.imageUrl), ], - onNavigateUp: _hasLibraryMatches || _hasActions ? _focusSectionAboveCast : null, - onNavigateDown: (_related?.isNotEmpty ?? false) ? _requestRelatedFocus : null, + onNavigateUp: _hasLibraryMatches || _hasDetailActions || _hasActions ? _focusSectionAboveCast : null, + onNavigateDown: _hasHubRows ? _requestFirstHubFocus : null, debugLabel: 'catalog_cast_row', ), ], ); } - /// "More like this" from the item's own source, rendered through the - /// standard shelf so cards, long-press menus, and taps behave exactly like - /// the Explore rows (tap opens another catalog detail screen). + Widget _buildRelationSection(CatalogRelation relation, int index) { + return HubSection( + key: _relationSectionKeys[index], + hub: MediaHub( + id: 'catalog-relation:${_item.source.name}:${_item.identityKey}:${relation.type.name}:$index', + identifier: 'explore.relation.${relation.type.name}', + title: _relationLabel(relation.type), + type: 'mixed', + items: [for (final item in relation.items) item.toMediaItem()], + size: relation.items.length, + ), + focusMemory: _hubFocusMemory, + icon: Symbols.link_rounded, + inset: true, + onNavigateUp: index == 0 ? _focusSectionAboveFirstHub : () => _requestRelationFocus(index - 1), + onVerticalNavigation: (isUp) { + if (isUp) { + if (index == 0) { + _focusSectionAboveFirstHub(); + } else { + _requestRelationFocus(index - 1); + } + return true; + } + if (index + 1 < _relations.length) { + _requestRelationFocus(index + 1); + return true; + } + if (_related?.isNotEmpty ?? false) { + _requestRelatedFocus(); + return true; + } + return false; + }, + cardSizing: HubCardSizing.grid, + ); + } + + /// Taste-based recommendations stay separate from labelled franchise facts. Widget _buildRelatedSection(List related) { return HubSection( key: _relatedSectionKey, hub: MediaHub( - id: 'catalog-related:${widget.item.source.name}:${widget.item.identityKey}', + id: 'catalog-related:${_item.source.name}:${_item.identityKey}', identifier: 'explore.related', title: t.discover.moreLikeThis, type: 'mixed', @@ -466,9 +1130,10 @@ class _CatalogItemDetailScreenState extends State { @override Widget build(BuildContext context) { - final item = widget.item; + final item = _item; final theme = Theme.of(context); final onWatchlist = _isOnWatchlist; + final tmdbId = item.ids.tmdb; final viewInsets = MediaQuery.paddingOf(context); final blockSystemBack = PlatformDetector.isTV() || InputModeTracker.shouldBlockSystemBack(context); @@ -533,6 +1198,16 @@ class _CatalogItemDetailScreenState extends State { child: Column( crossAxisAlignment: .start, children: [ + if (item.tagline?.trim() case final tagline? when tagline.isNotEmpty) ...[ + Text( + tagline, + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.75), + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: 6), + ], Text( item.title, style: theme.textTheme.headlineMedium, @@ -558,7 +1233,7 @@ class _CatalogItemDetailScreenState extends State { ), ], const SizedBox(height: 16), - if (_watchlistSource != null || _requestSource != null) + if (_hasActions) FocusableActionBar( key: _actionBarKey, onNavigateDown: _focusSectionBelowActions, @@ -573,7 +1248,7 @@ class _CatalogItemDetailScreenState extends State { : t.explore.addToWatchlist, onPressed: () => unawaited(_toggleWatchlist()), ), - if (_requestSource case final SeerrCatalogSource seerr) + if (_requestSource case final SeerrCatalogSource seerr when tmdbId != null) FocusableAction( icon: Symbols.download_rounded, tooltip: t.seerr.request, @@ -582,11 +1257,17 @@ class _CatalogItemDetailScreenState extends State { hostContext, source: seerr, kind: item.kind, - tmdbId: item.ids.tmdb!, + tmdbId: tmdbId, title: item.title, ), ), ), + if (item.trailerUrl?.trim() case final trailer? when trailer.isNotEmpty) + FocusableAction( + icon: Symbols.play_circle_rounded, + tooltip: t.explore.detail.watchTrailer, + onPressed: () => unawaited(_openExternalUrl(trailer)), + ), ], ), ], @@ -594,15 +1275,55 @@ class _CatalogItemDetailScreenState extends State { ), ], ), - if (_buildStatsChips(theme) case final Widget chips) ...[const SizedBox(height: 20), chips], - const SizedBox(height: 24), - if (item.overview != null) Text(item.overview!, style: theme.textTheme.bodyLarge), + if (_buildStatsChips() case final Widget chips) ...[const SizedBox(height: 20), chips], + if (item.overview?.trim() case final overview? when overview.isNotEmpty) ...[ + const SizedBox(height: 24), + Text(overview, style: theme.textTheme.bodyLarge), + ], + if (item.background?.trim() case final background? when background.isNotEmpty) ...[ + const SizedBox(height: 24), + _buildBackgroundSection(theme, background), + ], + if (item.gallery case final List gallery when gallery.isNotEmpty) ...[ + const SizedBox(height: 24), + _buildGallerySection(theme, gallery), + ], + if (_buildFactsSection(theme) case final Widget facts) ...[ + const SizedBox(height: 24), + facts, + ], + if (_buildRecommendersSection(theme) case final Widget recommenders) ...[ + const SizedBox(height: 24), + recommenders, + ], + if (_buildRatingsSection(theme) case final Widget ratings) ...[ + const SizedBox(height: 24), + ratings, + ], + if (_buildScheduleSection(theme) case final Widget schedule) ...[ + const SizedBox(height: 24), + schedule, + ], + if (_buildCrewSection(theme) case final Widget crew) ...[const SizedBox(height: 24), crew], + if (_buildTagsSection(theme) case final Widget tags) ...[const SizedBox(height: 24), tags], + if (_streamingLinks.isNotEmpty) ...[ + const SizedBox(height: 24), + _buildLinksSection(theme, t.explore.detail.watchOn, _streamingLinks, 0), + ], + if (_otherLinks.isNotEmpty) ...[ + const SizedBox(height: 24), + _buildLinksSection(theme, t.explore.detail.links, _otherLinks, _streamingLinks.length), + ], const SizedBox(height: 24), _buildLibrarySection(theme), if (_cast case final List cast when cast.isNotEmpty) ...[ const SizedBox(height: 28), _buildCastSection(theme, cast), ], + for (var index = 0; index < _relations.length; index++) ...[ + const SizedBox(height: 20), + _buildRelationSection(_relations[index], index), + ], if (_related case final List related when related.isNotEmpty) ...[ const SizedBox(height: 20), _buildRelatedSection(related), diff --git a/lib/screens/explore_screen.dart b/lib/screens/explore_screen.dart index 0a739c68..183f60a1 100644 --- a/lib/screens/explore_screen.dart +++ b/lib/screens/explore_screen.dart @@ -379,6 +379,7 @@ class ExploreScreenState extends State 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, diff --git a/lib/services/catalog/anilist_catalog_source.dart b/lib/services/catalog/anilist_catalog_source.dart index 48734ae9..e9c3ec56 100644 --- a/lib/services/catalog/anilist_catalog_source.dart +++ b/lib/services/catalog/anilist_catalog_source.dart @@ -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 _viewerIdLoad = FutureCoalescer(); @@ -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: { for (final title in [ 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> fetchCast(CatalogItem item, {int limit = 20}) async { + Future 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 = >{}; + 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> 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? _castFor(List? characters) => characters == null + ? null + : [ + for (final character in characters) + CatalogCastMember(name: character.name, secondary: character.role, imageUrl: character.imageUrl), + ]; + + static List? _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? _ranksFor(List? 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? _countriesFor(String? country) { + final code = country == null ? null : CountryCodes.normalizeCode(country); + return code == null || code.isEmpty ? null : [code]; + } + + static List? _creditsFor(List? staff) { + if (staff == null) return null; + final credits = []; + 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? _tagsFor(List? tags) => tags == null + ? null + : [for (final tag in tags) CatalogTag(name: tag.name, rank: tag.rank, isSpoiler: tag.isMediaSpoiler)]; + + static List? _linksFor(List? external, List? streaming) { + final links = []; + for (final link in external ?? const []) { + links.add(CatalogLink(label: link.label, url: link.url)); + } + for (final link in streaming ?? const []) { + 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 resolveItemIds(MediaKind kind, ExternalIds external) async { if (!external.hasAny) return null; diff --git a/lib/services/catalog/catalog_source.dart b/lib/services/catalog/catalog_source.dart index 98ec128b..6bfba47f 100644 --- a/lib/services/catalog/catalog_source.dart +++ b/lib/services/catalog/catalog_source.dart @@ -48,7 +48,28 @@ class CatalogPage { final List 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 cast; + + /// "More like this" — recommendations, which are a similarity judgement and + /// carry no relationship to the item. + final List 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 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 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> 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> 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> 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 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. diff --git a/lib/services/catalog/mal_catalog_source.dart b/lib/services/catalog/mal_catalog_source.dart index 7e6c45ac..0a906974 100644 --- a/lib/services/catalog/mal_catalog_source.dart +++ b/lib/services/catalog/mal_catalog_source.dart @@ -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> fetchRelated(CatalogItem item, {int limit = 20}) async { + Future 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([_loadAnimeDetail(malId, relatedLimit), _loadCast(malId, castLimit)]); + final detail = responses[0] as MalAnimeDetail?; + final cast = responses[1] as List; + 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 = >{}; + for (var i = 0; i < relationEntries.length; i++) { + groupedRelations + .putIfAbsent(_relationTypeFor(relationEntries[i].type), () => []) + .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> _toCatalogItems(List anime) async { - final withIds = [ - for (final entry in anime) - if (entry.id != null) entry, - ]; + Future> _toCatalogItems( + List anime, { + List rankingRanks = const [], + CatalogRankScope? rankingScope, + List recommendationCounts = const [], + }) async { + final withIds = []; + final originalIndexes = []; + 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 [ - 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> 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? 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: { + for (final candidate in [ + 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 _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> _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? _ranksFor(MalAnime anime, {int? rankingRank, CatalogRankScope? rankingScope}) { + final ranks = []; + 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? _galleryFor(List pictures, String? posterUrl) { + final urls = {}; + 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 diff --git a/lib/services/catalog/plex_catalog_source.dart b/lib/services/catalog/plex_catalog_source.dart index 65fda46b..7c89ebf4 100644 --- a/lib/services/catalog/plex_catalog_source.dart +++ b/lib/services/catalog/plex_catalog_source.dart @@ -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 _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 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> fetchHubs({int limit = 25}) async { - final fetched = await _client.getHomeHubs(limit: limit); + final fetched = await _client.getHomeHubs(limit: limit, includeImageVariants: includeImageVariants); final keys = {}; final result = []; 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 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> 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> fetchCast(CatalogItem item, {int limit = 20}) async { + Future 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> 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?> _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>> _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 metadata) { + List _fromSearchResults(List results) { + final items = []; + final seen = {}; + 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 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? _tagsFor(Object? value) { + final tags = []; + final seen = {}; + 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? _countriesFor(Object? value) { + final countries = []; + final seen = {}; + 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? _creditsFor(Map metadata) { + final credits = []; + final seen = {}; + + 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? _ratingsFor(Map metadata) { + final ratings = []; + 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 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 metadata) { + final advisories = []; + final seen = {}; + 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 metadata) { String? imdb; int? tmdb; diff --git a/lib/services/catalog/seerr_catalog_source.dart b/lib/services/catalog/seerr_catalog_source.dart index 3eaa32be..af66ef69 100644 --- a/lib/services/catalog/seerr_catalog_source.dart +++ b/lib/services/catalog/seerr_catalog_source.dart @@ -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> fetchRelated(CatalogItem item, {int limit = 20}) async { + Future 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 resolveItemIds(MediaKind kind, ExternalIds external) async => external.tmdb == null ? null : CatalogItemIds(tmdb: external.tmdb, imdb: external.imdb, tvdb: external.tvdb); - @override - Future> 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 []).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 _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> _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 _toCast(SeerrDetails details, int limit) => [ + for (final member in (details.credits?.cast ?? const []).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 []) { + 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( + List? 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? 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 []) { + if (_positive(season.episodeCount) case final int count) { + total += count; + found = true; + } + } + return found ? total : null; + } + + static List? _countryCodes(Iterable? values) { + if (values == null) return null; + final result = []; + final seen = {}; + 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? _detailCountries(SeerrDetails details) => _countryCodes([ + ...?details.originCountry, + for (final value in details.productionCountries ?? const []) + value.countryCode ?? value.name, + ]); + + static List? _detailLanguages(SeerrDetails details) => _languageCodes([ + details.originalLanguage, + ...?details.languages, + for (final language in details.spokenLanguages ?? const []) language.languageCode, + ]); + + static List? _languageCodes(Iterable values) { + final result = []; + final seen = {}; + 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? _credits(SeerrDetails details) { + final result = []; + final seen = {}; + + 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 []) { + add(creator.name, CatalogCreditRole.creator); + } + for (final member in details.credits?.crew ?? const []) { + 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? _tags(List? keywords) { + final names = _names(keywords); + return names == null ? null : [for (final name in names) CatalogTag(name: name)]; + } + + static List? _names(List? 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? 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? _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? 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? 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? 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) => diff --git a/lib/services/catalog/simkl_catalog_source.dart b/lib/services/catalog/simkl_catalog_source.dart index f312e9d8..13889164 100644 --- a/lib/services/catalog/simkl_catalog_source.dart +++ b/lib/services/catalog/simkl_catalog_source.dart @@ -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 _rowCacheLoadedAt = {}; final KeyedFutureCoalescer> _rowLoads = KeyedFutureCoalescer(); final FutureCoalescer _watchlistLoad = FutureCoalescer(); + final Set _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? _ratingsFor(SimklRatings? ratings) { + if (ratings == null) return null; + final result = []; + 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? _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? _alternateTitles(String title, List? raw, {String? originalTitle, String? extra}) { + final seen = {title.trim().toLowerCase(), if (originalTitle != null) originalTitle.toLowerCase()}; + final result = []; + 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? _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? _countryFor(String? raw) { + final value = raw?.trim(); + return value == null || value.isEmpty ? null : [CountryCodes.normalizeCode(value)]; + } + + static List? _languageFor(String? raw) { + final value = raw?.trim(); + return value == null || value.isEmpty ? null : [value.toLowerCase()]; + } + + static List? _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> fetchCast(CatalogItem item, {int limit = 20}) async => const []; - - @override - Future> fetchRelated(CatalogItem item, {int limit = 20}) async { + Future 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 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 = [ + 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(); } } diff --git a/lib/services/catalog/trakt_catalog_source.dart b/lib/services/catalog/trakt_catalog_source.dart index a74f546f..b261fd4f 100644 --- a/lib/services/catalog/trakt_catalog_source.dart +++ b/lib/services/catalog/trakt_catalog_source.dart @@ -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> fetchCast(CatalogItem item, {int limit = 20}) async { + Future 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> 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 _withoutFailure(Future 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 _castFrom(TraktPeople? people, int limit) { + if (people == null || limit <= 0) return const []; + final cast = []; + // `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 _creditsFrom(List? crew) { + if (crew == null || crew.isEmpty) return const []; + final credits = []; + final seen = {}; + 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 = [ + if (job != null) job.toLowerCase(), + for (final job in entry.jobs ?? const []) 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 _fromEntries(List 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 _fromMedia(List 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? _languagesFor(TraktCatalogMedia media) { + final languages = { + 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? _recommendersFor(TraktCatalogMedia media) { + final recommenders = [ + for (final user in media.favoritedBy ?? const []) + 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 []) + 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() { diff --git a/lib/services/plex_discover_client.dart b/lib/services/plex_discover_client.dart index 2675b8a6..7c6c8f0c 100644 --- a/lib/services/plex_discover_client.dart +++ b/lib/services/plex_discover_client.dart @@ -29,17 +29,34 @@ class PlexDiscoverSession { class PlexDiscoverPage { final List> 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 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> getHomeHubs({int limit = 25, int concurrency = 6}) async { + Future> 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 = {}; 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 getHub(String key, {int limit = 100}) async { + Future 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>> search(String query, {int limit = 30}) async { + Future> 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 metadata) metadata, + if (firstFlexibleMap(result['Metadata']) case final Map metadata) + PlexDiscoverSearchResult(metadata: metadata, score: flexibleDouble(result['score'])), ]; } diff --git a/lib/services/seerr/seerr_client.dart b/lib/services/seerr/seerr_client.dart index 60f87e34..cbf8def9 100644 --- a/lib/services/seerr/seerr_client.dart +++ b/lib/services/seerr/seerr_client.dart @@ -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> 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> _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 getTv(int tmdbId) => _details('/tv/$tmdbId'); Future _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); } @@ -171,6 +174,7 @@ class SeerrClient { } // ---------- Internals ---------- + String get _language => LocaleSettings.currentLocale.plexLanguageCode; Future _request( String method, diff --git a/lib/services/trackers/anilist/anilist_client.dart b/lib/services/trackers/anilist/anilist_client.dart index f928a941..cf8936d8 100644 --- a/lib/services/trackers/anilist/anilist_client.dart +++ b/lib/services/trackers/anilist/anilist_client.dart @@ -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 characters, + List recommendations, + List 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 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 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 getSeasonalAnime(String season, int seasonYear, {int page = 1, int limit = 25}) => - _getAnimePage(sort: 'POPULARITY_DESC', season: season, seasonYear: seasonYear, page: page, limit: limit); + Future 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 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 _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> 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> getAnimeRecommendations(int id, {int limit = 20}) async { - final recommendationQuery = + Future 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) { + return ( + item: null, + characters: const [], + recommendations: const [], + relations: const [], + ); + } + final item = AnilistMedia.fromJson(media); + return ( + item: item, + characters: item.characters ?? const [], + recommendations: _recommendationsFrom(media['recommendations']), + relations: _relationsFrom(media['relations']), + ); + } + + static List _recommendationsFrom(Object? value) { + final nodes = value is Map ? value['nodes'] : null; if (nodes is! List) return const []; final items = []; for (final node in nodes) { @@ -397,6 +566,22 @@ class AnilistClient implements DisposableTrackerClient { return items; } + static List _relationsFrom(Object? value) { + final edges = value is Map ? value['edges'] : null; + if (edges is! List) return const []; + final relations = []; + for (final edge in edges) { + if (edge is! Map) continue; + final node = edge['node']; + if (node is! Map) continue; + final item = AnilistMedia.fromJson(node); + if (!item.isAdult) { + relations.add((relationType: edge['relationType'] as String?, item: item)); + } + } + return relations; + } + Future> query(String query, {Map? variables}) async { final uri = Uri.parse(AnilistConstants.apiBase); final headers = AnilistConstants.headers(accessToken: _session.accessToken); diff --git a/lib/services/trackers/anilist/anilist_constants.dart b/lib/services/trackers/anilist/anilist_constants.dart index 5a64885d..62aff33d 100644 --- a/lib/services/trackers/anilist/anilist_constants.dart +++ b/lib/services/trackers/anilist/anilist_constants.dart @@ -7,6 +7,7 @@ class AnilistConstants { AnilistConstants._(); static const String apiBase = 'https://graphql.anilist.co'; + static const int catalogRowCastLimit = 6; static Map headers({String? accessToken}) => { 'Content-Type': 'application/json', diff --git a/lib/services/trackers/mal/mal_client.dart b/lib/services/trackers/mal/mal_client.dart index 595f3024..ea5afb66 100644 --- a/lib/services/trackers/mal/mal_client.dart +++ b/lib/services/trackers/mal/mal_client.dart @@ -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> 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> 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 && entry['node'] is Map) - MalAnime.fromJson(entry['node'] as Map), - ]; + /// The anime detail body, including its community recommendations, + /// franchise relations and status statistics. + Future 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(), relatedLimit: relatedLimit); } Future> _getAnimePage( diff --git a/lib/services/trackers/mal/mal_page.dart b/lib/services/trackers/mal/mal_page.dart index 8e5b5dc8..e4eebf16 100644 --- a/lib/services/trackers/mal/mal_page.dart +++ b/lib/services/trackers/mal/mal_page.dart @@ -6,17 +6,28 @@ class MalPage { final List items; final bool hasMore; - const MalPage({required this.items, this.hasMore = false}); + /// Entry-level `/anime/ranking` positions, aligned with [items]. + final List rankingRanks; + + const MalPage({required this.items, this.hasMore = false, this.rankingRanks = const []}); static MalPage fromJson(Object? json, T Function(Map node) fromNode) { if (json is! Map) return MalPage(items: List.empty()); final data = json['data']; - final items = [ + 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()), + 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())]; + 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 { 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 json) { final paging = json['paging']; final next = paging is Map ? paging['next'] : null; diff --git a/lib/services/trackers/simkl/simkl_client.dart b/lib/services/trackers/simkl/simkl_client.dart index 99e2cfe7..59ea3315 100644 --- a/lib/services/trackers/simkl/simkl_client.dart +++ b/lib/services/trackers/simkl/simkl_client.dart @@ -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> getRecommendations(SimklCatalogType urlType, int simklId) async { + Future 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) SimklRecommendation.fromJson(item), - ]; + return decoded is Map ? SimklDetail.fromJson(decoded) : null; } Future _request( diff --git a/lib/services/trakt/trakt_client.dart b/lib/services/trakt/trakt_client.dart index e59040b4..3fa2c030 100644 --- a/lib/services/trakt/trakt_client.dart +++ b/lib/services/trakt/trakt_client.dart @@ -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> 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 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>()) 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 removeFromWatchlist(Map body) => _request('POST', '/sync/watchlist/remove', body: body); + static List _decodePeopleEntries(Object? raw) { + if (raw is! List) return const []; + return [for (final entry in raw.whereType>()) TraktCastEntry.fromJson(entry)]; + } + + static List _decodeCrewEntries(Object? raw) { + if (raw is! Map) return const []; + return [ + for (final department in raw.values.whereType()) + for (final entry in department.whereType>()) TraktCastEntry.fromJson(entry), + ]; + } + static List _decodeEntries(String body) { final decoded = TrackerHttpClient.decodeJson(body); if (decoded is! List) return const []; diff --git a/lib/services/trakt/trakt_page.dart b/lib/services/trakt/trakt_page.dart index 5fc297f1..9e517481 100644 --- a/lib/services/trakt/trakt_page.dart +++ b/lib/services/trakt/trakt_page.dart @@ -6,7 +6,7 @@ class TraktPage { final List 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 { 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'] ?? ''), ); } diff --git a/lib/utils/country_codes.dart b/lib/utils/country_codes.dart new file mode 100644 index 00000000..9b32a155 --- /dev/null +++ b/lib/utils/country_codes.dart @@ -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(); +} diff --git a/lib/utils/json_utils.dart b/lib/utils/json_utils.dart index 31226784..e39f5ad8 100644 --- a/lib/utils/json_utils.dart +++ b/lib/utils/json_utils.dart @@ -146,3 +146,18 @@ List? stringListFromRaw(Object? raw, {String? mapKey, bool stringify = f } List? nullIfEmptyList(List 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 values) { + for (final value in values) { + final trimmed = value?.trim(); + if (trimmed != null && trimmed.isNotEmpty) return trimmed; + } + return null; +} diff --git a/lib/utils/trailer_urls.dart b/lib/utils/trailer_urls.dart new file mode 100644 index 00000000..2fcd55a7 --- /dev/null +++ b/lib/utils/trailer_urls.dart @@ -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'; +} diff --git a/lib/widgets/catalog_context_menu.dart b/lib/widgets/catalog_context_menu.dart index cade29d3..ef10bfb3 100644 --- a/lib/widgets/catalog_context_menu.dart +++ b/lib/widgets/catalog_context_menu.dart @@ -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 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 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 showCatalogItemMenu(BuildContext context, CatalogItem item, {Offset } finally { _watchlistMutationsInFlight.remove(mutationKey); } + case _CatalogMenuActionType.openUrl: + final url = action.url; + if (url != null) await _launchCatalogUrl(url); + } +} + +Future _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); } } diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 2acf8646..6d856e8e 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -55,6 +55,9 @@ class HubSection extends StatefulWidget { final bool showServerName; final Future> 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? 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 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( diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index d6d870f1..336efd65 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -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 = []; + + 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 with ContextMenuTapMixin _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 with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin badgeLabels, }) { return SizedBox( width: width, @@ -410,7 +525,8 @@ class MediaCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin 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 with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin density <= 2 ? 2 : density; // 2, 2, 3, 4, 5 String _buildMetadataLine() { - final parts = []; - - 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 = []; + 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? 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 []) { + 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 = [ + 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 _buildCatalogBadgeLabels(CatalogItem? item, DateTime? now) { + if (item == null) return const []; + final labels = []; + + 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 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, diff --git a/lib/widgets/tv_spotlight_background.dart b/lib/widgets/tv_spotlight_background.dart index c7124c5e..54bd01bb 100644 --- a/lib/widgets/tv_spotlight_background.dart +++ b/lib/widgets/tv_spotlight_background.dart @@ -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(); diff --git a/lib/widgets/tv_spotlight_scaffold.dart b/lib/widgets/tv_spotlight_scaffold.dart index b6da0798..e4e72657 100644 --- a/lib/widgets/tv_spotlight_scaffold.dart +++ b/lib/widgets/tv_spotlight_scaffold.dart @@ -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. /// diff --git a/test/models/catalog_item_test.dart b/test/models/catalog_item_test.dart index bd6a8938..bed3527a 100644 --- a/test/models/catalog_item_test.dart +++ b/test/models/catalog_item_test.dart @@ -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'); + }); }); } diff --git a/test/providers/explore_provider_test.dart b/test/providers/explore_provider_test.dart index 5c07e130..4107466e 100644 --- a/test/providers/explore_provider_test.dart +++ b/test/providers/explore_provider_test.dart @@ -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> search(String query, {int limit = 30}) async => const []; @override - Future> fetchCast(CatalogItem item, {int limit = 20}) async => const []; - @override - Future> fetchRelated(CatalogItem item, {int limit = 20}) async => const []; + Future fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async => + CatalogDetail(item: item); @override Future 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); diff --git a/test/screens/catalog_item_detail_screen_test.dart b/test/screens/catalog_item_detail_screen_test.dart index 01e89146..3101e5e2 100644 --- a/test/screens/catalog_item_detail_screen_test.dart +++ b/test/screens/catalog_item_detail_screen_test.dart @@ -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() : null; @@ -35,6 +38,10 @@ class _FakeCatalogSource implements CatalogSource { final Completer? _watchlistLoad; int addToWatchlistCalls = 0; + final CatalogDetail? detail; + final Object? detailError; + final Completer? detailCompleter; + int fetchDetailCalls = 0; @override CatalogSourceId get id => CatalogSourceId.trakt; @@ -48,20 +55,29 @@ class _FakeCatalogSource implements CatalogSource { Listenable get watchlistChanges => _watchlistChanges; @override - Future> fetchCast(CatalogItem item, {int limit = 20}) async => const [ - CatalogCastMember(name: 'First Actor', secondary: 'Lead'), - CatalogCastMember(name: 'Second Actor', secondary: 'Support'), - ]; - - @override - Future> fetchRelated(CatalogItem item, {int limit = 20}) async => const [ - CatalogItem( - source: CatalogSourceId.trakt, - kind: MediaKind.movie, - title: 'Related Movie', - ids: CatalogItemIds(tmdb: 2), - ), - ]; + Future 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 ensureWatchlistLoaded() async { @@ -123,6 +139,7 @@ Future _pumpDetail( _FakeCatalogSource source, { List matches = const [], bool pushedRoute = false, + CatalogItem item = _item, }) async { final sources = _FakeCatalogSourcesProvider(source); final serverManager = MultiServerManager(); @@ -148,12 +165,12 @@ Future _pumpDetail( body: TextButton( onPressed: () => Navigator.of( context, - ).push(MaterialPageRoute(builder: (_) => const CatalogItemDetailScreen(item: _item))), + ).push(MaterialPageRoute(builder: (_) => CatalogItemDetailScreen(item: item))), child: const Text('Open catalog'), ), ), ) - : const CatalogItemDetailScreen(item: _item), + : CatalogItemDetailScreen(item: item), ), ), ), @@ -168,8 +185,10 @@ Future _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(); + 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(); + 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(galleryFinder).scrollDirection, Axis.horizontal); + final images = tester + .widgetList(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); diff --git a/test/screens/catalog_search_screen_test.dart b/test/screens/catalog_search_screen_test.dart index cad6b380..4bd29261 100644 --- a/test/screens/catalog_search_screen_test.dart +++ b/test/screens/catalog_search_screen_test.dart @@ -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 = []; 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> fetchCast(CatalogItem item, {int limit = 20}) async => const []; - - @override - Future> fetchRelated(CatalogItem item, {int limit = 20}) async => const []; + Future fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async => + CatalogDetail(item: item); @override void dispose() {} @@ -73,8 +74,10 @@ Future _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(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>(find.byType(CatalogSearchScreen)); diff --git a/test/screens/explore_screen_test.dart b/test/screens/explore_screen_test.dart index aa171513..8db679dd 100644 --- a/test/screens/explore_screen_test.dart +++ b/test/screens/explore_screen_test.dart @@ -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 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(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(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(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); diff --git a/test/services/catalog/anilist_catalog_source_test.dart b/test/services/catalog/anilist_catalog_source_test.dart index 44fda094..9931ea1d 100644 --- a/test/services/catalog/anilist_catalog_source_test.dart +++ b/test/services/catalog/anilist_catalog_source_test.dart @@ -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 _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': 'Humanity
fights & 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 _characters() => { + 'edges': [ + { + 'role': 'MAIN', + 'node': { + 'name': {'full': 'Mikasa Ackerman'}, + 'image': {'large': 'https://img.anilist.co/mikasa.jpg'}, + }, + }, + ], }; http.Response _data(Map data, {int status = 200, Map? 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': >[], + }); + + 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; @@ -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': >[], + }, + }); + } + return _data({'Media': {}}); + }; + + 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; + 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())); }); @@ -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; + expect(results.single.cast, isNull); + final body = _requestBody(requests.single); + final variables = body['variables'] as Map; 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; + 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; + 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': >[], + 'streamingEpisodes': >[], + }, + }); + 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); }); }); diff --git a/test/services/catalog/mal_catalog_source_test.dart b/test/services/catalog/mal_catalog_source_test.dart index fdfb3f13..5749d5d9 100644 --- a/test/services/catalog/mal_catalog_source_test.dart +++ b/test/services/catalog/mal_catalog_source_test.dart @@ -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 _node({ List? synonyms, String mediaType = 'tv', String status = 'finished_airing', + Map extra = const {}, }) => { 'id': id, 'title': title, @@ -63,18 +65,43 @@ Map _node({ 'studios': [ {'id': 858, 'name': 'Wit Studio'}, ], + ...extra, }; -Map _pageBody(List> nodes, {bool hasMore = false}) => { +Map _pageBody( + List> nodes, { + bool hasMore = false, + List 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': {}, - }), + 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': {}, + }), + 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': [], 'paging': {}}), 200); + } + return http.Response( + json.encode({ + ..._node(id: 16498, title: 'Shingeki no Kyojin'), + 'pictures': [ + {}, + {'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); }); }); diff --git a/test/services/catalog/plex_catalog_source_test.dart b/test/services/catalog/plex_catalog_source_test.dart index 90cd2ca1..33a869d4 100644 --- a/test/services/catalog/plex_catalog_source_test.dart +++ b/test/services/catalog/plex_catalog_source_test.dart @@ -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 _metadata({ /// Discover answers `/hubs/sections/
` with placeholders only — the /// shelf identity, never its items. -Map _placeholderHub(String id, String title, {String type = 'mixed'}) => { +Map _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 = []; 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 = []; + 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 = []; 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 = []; + final metadataResponse = Completer(); + 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.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() + .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 { diff --git a/test/services/catalog/seerr_catalog_source_test.dart b/test/services/catalog/seerr_catalog_source_test.dart index 6945e650..899d8b39 100644 --- a/test/services/catalog/seerr_catalog_source_test.dart +++ b/test/services/catalog/seerr_catalog_source_test.dart @@ -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 = []; + 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 = {}; + final bothStarted = Completer(); 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'); + }); }); } diff --git a/test/services/catalog/simkl_catalog_source_test.dart b/test/services/catalog/simkl_catalog_source_test.dart index 9b4894e4..c41c17e5 100644 --- a/test/services/catalog/simkl_catalog_source_test.dart +++ b/test/services/catalog/simkl_catalog_source_test.dart @@ -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? header Map _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 _best(int simkl) => { +Map _best(int simkl, {int? watched}) => { 'title': 'Best $simkl', 'year': 2020, + 'watched': ?watched, 'ids': {'simkl': simkl}, }; @@ -69,6 +86,9 @@ Map _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 _allItemsBody() => { ], }; +Map _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); }); }); } diff --git a/test/services/catalog/trakt_catalog_source_test.dart b/test/services/catalog/trakt_catalog_source_test.dart index 4f82aece..13b0297b 100644 --- a/test/services/catalog/trakt_catalog_source_test.dart +++ b/test/services/catalog/trakt_catalog_source_test.dart @@ -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 _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 _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': [], - '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() + .having((credit) => credit.name, 'name', 'Vince Gilligan') + .having((credit) => credit.role, 'role', CatalogCreditRole.director), + isA() + .having((credit) => credit.name, 'name', 'Peter Gould') + .having((credit) => credit.role, 'role', CatalogCreditRole.writer), + isA() + .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': []}, // no person — skipped - ], - 'crew': {}, - }), + }, + ]), + 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() + .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() + .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(); + final relatedStarted = Completer(); + final peopleResponse = Completer(); + final relatedResponse = Completer(); + 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); }); }); } diff --git a/test/services/seerr/seerr_client_test.dart b/test/services/seerr/seerr_client_test.dart index c08ff757..f3f3c8c0 100644 --- a/test/services/seerr/seerr_client_test.dart +++ b/test/services/seerr/seerr_client_test.dart @@ -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 = []; + 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.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); }); }); diff --git a/test/services/trakt_catalog_test.dart b/test/services/trakt_catalog_test.dart index f1113b76..ecefb0eb 100644 --- a/test/services/trakt_catalog_test.dart +++ b/test/services/trakt_catalog_test.dart @@ -24,21 +24,42 @@ TrackerSession _session() { ); } -Map _movieJson({int trakt = 1, String? posterUrl = 'walter-r2.trakt.tv/images/movies/p.webp'}) { +Map _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 _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': {}, }; } @@ -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()); 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 = []; + 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 = []; final client = _client(requests: requests, (request) async => http.Response('{"added":{"movies":1}}', 201)); diff --git a/test/widgets/hub_section_test.dart b/test/widgets/hub_section_test.dart index 30769d74..a11e1040 100644 --- a/test/widgets/hub_section_test.dart +++ b/test/widgets/hub_section_test.dart @@ -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++) diff --git a/test/widgets/media_card_full_card_test.dart b/test/widgets/media_card_full_card_test.dart index 98760537..ee8aa519 100644 --- a/test/widgets/media_card_full_card_test.dart +++ b/test/widgets/media_card_full_card_test.dart @@ -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( + 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(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( + 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 { + final Map _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 get keys => _values.keys; + + @override + Object? remove(Object? key) => _values.remove(key); +} + class _TestApp extends StatelessWidget { final Widget child;