Merge branch 'collectionstmdb' into cmp-rewrite

This commit is contained in:
tapframe 2026-04-26 00:51:02 +05:30
commit 7e716f7e79
16 changed files with 2455 additions and 253 deletions

View file

@ -90,6 +90,9 @@
<string name="collections_editor_select_catalogs_description">Elige los catálogos del complemento que debe agrupar esta carpeta.</string> <string name="collections_editor_select_catalogs_description">Elige los catálogos del complemento que debe agrupar esta carpeta.</string>
<string name="collections_editor_select_catalogs">Seleccionar catálogos</string> <string name="collections_editor_select_catalogs">Seleccionar catálogos</string>
<string name="collections_editor_select_genre">Seleccionar género</string> <string name="collections_editor_select_genre">Seleccionar género</string>
<string name="collections_editor_selected_count">%1$d seleccionados</string>
<string name="collections_editor_catalog_count">%1$d catálogos</string>
<string name="collections_editor_catalog_selected_count">%1$d seleccionados</string>
<string name="collections_editor_shape_poster">Póster</string> <string name="collections_editor_shape_poster">Póster</string>
<string name="collections_editor_shape_square">Cuadrado</string> <string name="collections_editor_shape_square">Cuadrado</string>
<string name="collections_editor_shape_wide">Panorámico</string> <string name="collections_editor_shape_wide">Panorámico</string>
@ -102,6 +105,121 @@
<string name="collections_editor_view_mode_rows">Filas</string> <string name="collections_editor_view_mode_rows">Filas</string>
<string name="collections_editor_view_mode_tabs">Pestañas</string> <string name="collections_editor_view_mode_tabs">Pestañas</string>
<string name="collections_editor_view_mode">Modo de vista</string> <string name="collections_editor_view_mode">Modo de vista</string>
<string name="collections_editor_tmdb_sources">Fuentes de TMDB</string>
<string name="collections_editor_tmdb_public_list_mode">Lista pública</string>
<string name="collections_editor_tmdb_production_mode">Producción</string>
<string name="collections_editor_tmdb_network_mode">Cadena</string>
<string name="collections_editor_tmdb_collection_mode">Colección</string>
<string name="collections_editor_tmdb_custom_mode">Personalizado</string>
<string name="collections_editor_tmdb_help_presets">Elige una fuente preparada. Puedes editarla o quitarla después de añadirla.</string>
<string name="collections_editor_tmdb_help_list">Pega una URL de lista pública de TMDB o solo el número de la URL.</string>
<string name="collections_editor_tmdb_help_production">Busca por nombre de estudio, o pega un ID/URL de compañía de TMDB y añádelo directamente.</string>
<string name="collections_editor_tmdb_help_network">Introduce un ID de cadena. Las cadenas comunes están disponibles en ajustes predefinidos y filtros rápidos.</string>
<string name="collections_editor_tmdb_help_collection">Busca el nombre de una colección de películas o pega el ID de colección de TMDB.</string>
<string name="collections_editor_tmdb_help_discover">Crea una fila dinámica de TMDB con filtros opcionales. Deja los campos vacíos cuando no necesites ese filtro.</string>
<string name="collections_editor_tmdb_public_list">Lista pública de TMDB</string>
<string name="collections_editor_tmdb_network_id">ID de cadena</string>
<string name="collections_editor_tmdb_collection_id">ID de colección</string>
<string name="collections_editor_tmdb_company_search">Nombre, ID o URL de compañía de producción</string>
<string name="collections_editor_tmdb_id_or_url">ID o URL de TMDB</string>
<string name="collections_editor_tmdb_list_placeholder">https://www.themoviedb.org/list/8504994 o 8504994</string>
<string name="collections_editor_tmdb_network_placeholder">213 para Netflix, 49 para HBO, 2739 para Disney+</string>
<string name="collections_editor_tmdb_collection_placeholder">10 para Star Wars Collection</string>
<string name="collections_editor_tmdb_company_placeholder">Marvel Studios, 420 o URL de compañía</string>
<string name="collections_editor_tmdb_search_helper">Ejemplos: Marvel Studios, 420 o https://www.themoviedb.org/company/420.</string>
<string name="collections_editor_tmdb_collection_helper">Ejemplo: Star Wars Collection, Harry Potter Collection o una URL de colección.</string>
<string name="collections_editor_tmdb_network_helper">IDs de ejemplo: Netflix 213, HBO 49, Disney+ 2739.</string>
<string name="collections_editor_tmdb_list_helper">Ejemplo: https://www.themoviedb.org/list/8504994 o 8504994.</string>
<string name="collections_editor_tmdb_display_title">Título visible</string>
<string name="collections_editor_tmdb_title_helper">Se muestra como nombre de fila/pestaña. Si queda vacío, Nuvio crea uno desde la fuente.</string>
<string name="collections_editor_tmdb_title_placeholder">Películas de Marvel, Originales de Netflix, Pixar</string>
<string name="collections_editor_tmdb_discover_title_placeholder">Mejores películas de acción, dramas coreanos, animación 2024</string>
<string name="collections_editor_tmdb_search_results">Resultados de búsqueda</string>
<string name="collections_editor_tmdb_collection">Colección de TMDB</string>
<string name="collections_editor_tmdb_company_fallback">Compañía de TMDB %1$d</string>
<string name="collections_editor_tmdb_collection_fallback">Colección de TMDB %1$d</string>
<string name="collections_editor_tmdb_type">Tipo</string>
<string name="collections_editor_tmdb_movies">Películas</string>
<string name="collections_editor_tmdb_series">Series</string>
<string name="collections_editor_tmdb_both">Ambos</string>
<string name="collections_editor_tmdb_sort">Orden</string>
<string name="collections_editor_tmdb_filters">Filtros</string>
<string name="collections_editor_tmdb_filters_helper">Deja los campos vacíos cuando no necesites ese filtro.</string>
<string name="collections_editor_tmdb_quick_genres">Géneros rápidos</string>
<string name="collections_editor_tmdb_quick_languages">Idiomas rápidos</string>
<string name="collections_editor_tmdb_quick_countries">Países rápidos</string>
<string name="collections_editor_tmdb_quick_keywords">Palabras clave rápidas</string>
<string name="collections_editor_tmdb_quick_studios">Estudios rápidos</string>
<string name="collections_editor_tmdb_quick_networks">Cadenas rápidas</string>
<string name="collections_editor_tmdb_genres">IDs de género</string>
<string name="collections_editor_tmdb_genres_helper">Usa números de género de TMDB. Separa varios con comas para AND, o barras verticales para OR.</string>
<string name="collections_editor_tmdb_date_from">Fecha de estreno o emisión desde</string>
<string name="collections_editor_tmdb_date_to">Fecha de estreno o emisión hasta</string>
<string name="collections_editor_tmdb_date_helper">Usa YYYY-MM-DD, por ejemplo 2024-01-01.</string>
<string name="collections_editor_tmdb_rating_min">Calificación mínima</string>
<string name="collections_editor_tmdb_rating_max">Calificación máxima</string>
<string name="collections_editor_tmdb_rating_helper">Calificación de TMDB de 0 a 10. Ejemplo: 7.0.</string>
<string name="collections_editor_tmdb_votes_min">Votos mínimos</string>
<string name="collections_editor_tmdb_votes_helper">Úsalo para evitar títulos poco conocidos con pocos votos. Ejemplo: 100.</string>
<string name="collections_editor_tmdb_language">Idioma original</string>
<string name="collections_editor_tmdb_language_helper">Usa códigos de idioma de dos letras, por ejemplo en, ko, ja, hi.</string>
<string name="collections_editor_tmdb_country">País de origen</string>
<string name="collections_editor_tmdb_country_helper">Usa códigos de país de dos letras, por ejemplo US, KR, JP, IN.</string>
<string name="collections_editor_tmdb_keywords">IDs de palabra clave</string>
<string name="collections_editor_tmdb_keywords_helper">Usa números de palabra clave de TMDB. Los chips rápidos rellenan ejemplos comunes.</string>
<string name="collections_editor_tmdb_keywords_placeholder">9715 para superhéroes</string>
<string name="collections_editor_tmdb_companies">IDs de compañía</string>
<string name="collections_editor_tmdb_companies_helper">Usa IDs de estudio/compañía. Los chips rápidos rellenan ejemplos comunes.</string>
<string name="collections_editor_tmdb_companies_placeholder">420 para Marvel Studios</string>
<string name="collections_editor_tmdb_networks">IDs de cadena</string>
<string name="collections_editor_tmdb_networks_helper">Solo para series. Usa IDs de cadena como Netflix 213 o HBO 49.</string>
<string name="collections_editor_tmdb_networks_placeholder">213 para Netflix</string>
<string name="collections_editor_tmdb_year">Año</string>
<string name="collections_editor_tmdb_year_helper">Usa un año de cuatro dígitos, por ejemplo 2024.</string>
<string name="collections_editor_tmdb_presets">Predefinidos</string>
<string name="collections_editor_tmdb_search">Buscar</string>
<string name="collections_editor_add_source">Añadir fuente</string>
<string name="collections_editor_tmdb_genre_action">Acción</string>
<string name="collections_editor_tmdb_genre_adventure">Aventura</string>
<string name="collections_editor_tmdb_genre_animation">Animación</string>
<string name="collections_editor_tmdb_genre_comedy">Comedia</string>
<string name="collections_editor_tmdb_genre_horror">Terror</string>
<string name="collections_editor_tmdb_genre_scifi">Ciencia ficción</string>
<string name="collections_editor_tmdb_genre_drama">Drama</string>
<string name="collections_editor_tmdb_genre_crime">Crimen</string>
<string name="collections_editor_tmdb_genre_reality">Reality</string>
<string name="collections_editor_tmdb_language_english">Inglés</string>
<string name="collections_editor_tmdb_language_korean">Coreano</string>
<string name="collections_editor_tmdb_language_japanese">Japonés</string>
<string name="collections_editor_tmdb_language_hindi">Hindi</string>
<string name="collections_editor_tmdb_language_spanish">Español</string>
<string name="collections_editor_tmdb_country_us">Estados Unidos</string>
<string name="collections_editor_tmdb_country_korea">Corea</string>
<string name="collections_editor_tmdb_country_japan">Japón</string>
<string name="collections_editor_tmdb_country_india">India</string>
<string name="collections_editor_tmdb_country_uk">Reino Unido</string>
<string name="collections_editor_tmdb_keyword_superhero">Superhéroes</string>
<string name="collections_editor_tmdb_keyword_based_on_novel">Basado en novela</string>
<string name="collections_editor_tmdb_keyword_time_travel">Viaje en el tiempo</string>
<string name="collections_editor_tmdb_keyword_space">Espacio</string>
<string name="collections_editor_tmdb_studio_marvel">Marvel</string>
<string name="collections_editor_tmdb_studio_disney">Disney</string>
<string name="collections_editor_tmdb_studio_pixar">Pixar</string>
<string name="collections_editor_tmdb_studio_lucasfilm">Lucasfilm</string>
<string name="collections_editor_tmdb_studio_warner">Warner Bros.</string>
<string name="collections_editor_tmdb_network_netflix">Netflix</string>
<string name="collections_editor_tmdb_network_hbo">HBO</string>
<string name="collections_editor_tmdb_network_disney_plus">Disney+</string>
<string name="collections_editor_tmdb_network_prime_video">Prime Video</string>
<string name="collections_editor_tmdb_network_hulu">Hulu</string>
<string name="collections_editor_tmdb_sort_popular">Popular</string>
<string name="collections_editor_tmdb_sort_top_rated">Mejor valoradas</string>
<string name="collections_editor_tmdb_sort_recent">Reciente</string>
<string name="collections_editor_tmdb_subtitle_list">Lista de TMDB</string>
<string name="collections_editor_tmdb_subtitle_movie_collection">Colección de películas de TMDB</string>
<string name="collections_editor_tmdb_subtitle_production">Producción</string>
<string name="collections_editor_tmdb_subtitle_network">Cadena</string>
<string name="collections_editor_tmdb_subtitle_discover">Discover de TMDB</string>
<string name="collections_empty_subtitle">Crea una para organizar tus catálogos.</string> <string name="collections_empty_subtitle">Crea una para organizar tus catálogos.</string>
<string name="collections_empty_title">Aún no hay colecciones</string> <string name="collections_empty_title">Aún no hay colecciones</string>
<string name="collections_folder_count">%1$d carpeta(s)</string> <string name="collections_folder_count">%1$d carpeta(s)</string>

View file

@ -90,6 +90,9 @@
<string name="collections_editor_select_catalogs_description">Choose the addon catalogs this folder should aggregate.</string> <string name="collections_editor_select_catalogs_description">Choose the addon catalogs this folder should aggregate.</string>
<string name="collections_editor_select_catalogs">Select Catalogs</string> <string name="collections_editor_select_catalogs">Select Catalogs</string>
<string name="collections_editor_select_genre">Select genre</string> <string name="collections_editor_select_genre">Select genre</string>
<string name="collections_editor_selected_count">%1$d selected</string>
<string name="collections_editor_catalog_count">%1$d catalogs</string>
<string name="collections_editor_catalog_selected_count">%1$d selected</string>
<string name="collections_editor_shape_poster">Poster</string> <string name="collections_editor_shape_poster">Poster</string>
<string name="collections_editor_shape_square">Square</string> <string name="collections_editor_shape_square">Square</string>
<string name="collections_editor_shape_wide">Wide</string> <string name="collections_editor_shape_wide">Wide</string>
@ -102,6 +105,121 @@
<string name="collections_editor_view_mode_rows">Rows</string> <string name="collections_editor_view_mode_rows">Rows</string>
<string name="collections_editor_view_mode_tabs">Tabs</string> <string name="collections_editor_view_mode_tabs">Tabs</string>
<string name="collections_editor_view_mode">View Mode</string> <string name="collections_editor_view_mode">View Mode</string>
<string name="collections_editor_tmdb_sources">TMDB Sources</string>
<string name="collections_editor_tmdb_public_list_mode">Public List</string>
<string name="collections_editor_tmdb_production_mode">Production</string>
<string name="collections_editor_tmdb_network_mode">Network</string>
<string name="collections_editor_tmdb_collection_mode">Collection</string>
<string name="collections_editor_tmdb_custom_mode">Custom</string>
<string name="collections_editor_tmdb_help_presets">Pick a ready-made source. You can edit or remove it after adding.</string>
<string name="collections_editor_tmdb_help_list">Paste a public TMDB list URL or only the number from the URL.</string>
<string name="collections_editor_tmdb_help_production">Search by studio name, or paste a TMDB company ID/URL and add it directly.</string>
<string name="collections_editor_tmdb_help_network">Enter a network ID. Common networks are available in Presets and quick filters.</string>
<string name="collections_editor_tmdb_help_collection">Search a movie collection name or paste the collection ID from TMDB.</string>
<string name="collections_editor_tmdb_help_discover">Build a live TMDB row using optional filters. Leave fields empty when you do not need that filter.</string>
<string name="collections_editor_tmdb_public_list">Public TMDB list</string>
<string name="collections_editor_tmdb_network_id">Network ID</string>
<string name="collections_editor_tmdb_collection_id">Collection ID</string>
<string name="collections_editor_tmdb_company_search">Production company name, ID, or URL</string>
<string name="collections_editor_tmdb_id_or_url">TMDB ID or URL</string>
<string name="collections_editor_tmdb_list_placeholder">https://www.themoviedb.org/list/8504994 or 8504994</string>
<string name="collections_editor_tmdb_network_placeholder">213 for Netflix, 49 for HBO, 2739 for Disney+</string>
<string name="collections_editor_tmdb_collection_placeholder">10 for Star Wars Collection</string>
<string name="collections_editor_tmdb_company_placeholder">Marvel Studios, 420, or company URL</string>
<string name="collections_editor_tmdb_search_helper">Examples: Marvel Studios, 420, or https://www.themoviedb.org/company/420.</string>
<string name="collections_editor_tmdb_collection_helper">Example: Star Wars Collection, Harry Potter Collection, or a collection URL.</string>
<string name="collections_editor_tmdb_network_helper">Example IDs: Netflix 213, HBO 49, Disney+ 2739.</string>
<string name="collections_editor_tmdb_list_helper">Example: https://www.themoviedb.org/list/8504994 or 8504994.</string>
<string name="collections_editor_tmdb_display_title">Display title</string>
<string name="collections_editor_tmdb_title_helper">Shown as the row/tab name. If blank, Nuvio creates one from the source.</string>
<string name="collections_editor_tmdb_title_placeholder">Marvel Movies, Netflix Originals, Pixar</string>
<string name="collections_editor_tmdb_discover_title_placeholder">Best Action Movies, Korean Dramas, 2024 Animation</string>
<string name="collections_editor_tmdb_search_results">Search Results</string>
<string name="collections_editor_tmdb_collection">TMDB Collection</string>
<string name="collections_editor_tmdb_company_fallback">TMDB Company %1$d</string>
<string name="collections_editor_tmdb_collection_fallback">TMDB Collection %1$d</string>
<string name="collections_editor_tmdb_type">Type</string>
<string name="collections_editor_tmdb_movies">Movies</string>
<string name="collections_editor_tmdb_series">Series</string>
<string name="collections_editor_tmdb_both">Both</string>
<string name="collections_editor_tmdb_sort">Sort</string>
<string name="collections_editor_tmdb_filters">Filters</string>
<string name="collections_editor_tmdb_filters_helper">Leave fields empty when you do not need that filter.</string>
<string name="collections_editor_tmdb_quick_genres">Quick genres</string>
<string name="collections_editor_tmdb_quick_languages">Quick languages</string>
<string name="collections_editor_tmdb_quick_countries">Quick countries</string>
<string name="collections_editor_tmdb_quick_keywords">Quick keywords</string>
<string name="collections_editor_tmdb_quick_studios">Quick studios</string>
<string name="collections_editor_tmdb_quick_networks">Quick networks</string>
<string name="collections_editor_tmdb_genres">Genre IDs</string>
<string name="collections_editor_tmdb_genres_helper">Use TMDB genre numbers. Separate multiple with commas for AND, or pipes for OR.</string>
<string name="collections_editor_tmdb_date_from">Release or air date from</string>
<string name="collections_editor_tmdb_date_to">Release or air date to</string>
<string name="collections_editor_tmdb_date_helper">Use YYYY-MM-DD, for example 2024-01-01.</string>
<string name="collections_editor_tmdb_rating_min">Minimum rating</string>
<string name="collections_editor_tmdb_rating_max">Maximum rating</string>
<string name="collections_editor_tmdb_rating_helper">TMDB rating from 0 to 10. Example: 7.0.</string>
<string name="collections_editor_tmdb_votes_min">Minimum votes</string>
<string name="collections_editor_tmdb_votes_helper">Use this to avoid obscure low-vote titles. Example: 100.</string>
<string name="collections_editor_tmdb_language">Original language</string>
<string name="collections_editor_tmdb_language_helper">Use two-letter language codes, for example en, ko, ja, hi.</string>
<string name="collections_editor_tmdb_country">Origin country</string>
<string name="collections_editor_tmdb_country_helper">Use two-letter country codes, for example US, KR, JP, IN.</string>
<string name="collections_editor_tmdb_keywords">Keyword IDs</string>
<string name="collections_editor_tmdb_keywords_helper">Use TMDB keyword numbers. Quick chips fill common examples.</string>
<string name="collections_editor_tmdb_keywords_placeholder">9715 for superhero</string>
<string name="collections_editor_tmdb_companies">Company IDs</string>
<string name="collections_editor_tmdb_companies_helper">Use studio/company IDs. Quick chips fill common examples.</string>
<string name="collections_editor_tmdb_companies_placeholder">420 for Marvel Studios</string>
<string name="collections_editor_tmdb_networks">Network IDs</string>
<string name="collections_editor_tmdb_networks_helper">For series only. Use network IDs like Netflix 213 or HBO 49.</string>
<string name="collections_editor_tmdb_networks_placeholder">213 for Netflix</string>
<string name="collections_editor_tmdb_year">Year</string>
<string name="collections_editor_tmdb_year_helper">Use a four-digit year, for example 2024.</string>
<string name="collections_editor_tmdb_presets">Presets</string>
<string name="collections_editor_tmdb_search">Search</string>
<string name="collections_editor_add_source">Add Source</string>
<string name="collections_editor_tmdb_genre_action">Action</string>
<string name="collections_editor_tmdb_genre_adventure">Adventure</string>
<string name="collections_editor_tmdb_genre_animation">Animation</string>
<string name="collections_editor_tmdb_genre_comedy">Comedy</string>
<string name="collections_editor_tmdb_genre_horror">Horror</string>
<string name="collections_editor_tmdb_genre_scifi">Sci-Fi</string>
<string name="collections_editor_tmdb_genre_drama">Drama</string>
<string name="collections_editor_tmdb_genre_crime">Crime</string>
<string name="collections_editor_tmdb_genre_reality">Reality</string>
<string name="collections_editor_tmdb_language_english">English</string>
<string name="collections_editor_tmdb_language_korean">Korean</string>
<string name="collections_editor_tmdb_language_japanese">Japanese</string>
<string name="collections_editor_tmdb_language_hindi">Hindi</string>
<string name="collections_editor_tmdb_language_spanish">Spanish</string>
<string name="collections_editor_tmdb_country_us">United States</string>
<string name="collections_editor_tmdb_country_korea">Korea</string>
<string name="collections_editor_tmdb_country_japan">Japan</string>
<string name="collections_editor_tmdb_country_india">India</string>
<string name="collections_editor_tmdb_country_uk">United Kingdom</string>
<string name="collections_editor_tmdb_keyword_superhero">Superhero</string>
<string name="collections_editor_tmdb_keyword_based_on_novel">Based on Novel</string>
<string name="collections_editor_tmdb_keyword_time_travel">Time Travel</string>
<string name="collections_editor_tmdb_keyword_space">Space</string>
<string name="collections_editor_tmdb_studio_marvel">Marvel</string>
<string name="collections_editor_tmdb_studio_disney">Disney</string>
<string name="collections_editor_tmdb_studio_pixar">Pixar</string>
<string name="collections_editor_tmdb_studio_lucasfilm">Lucasfilm</string>
<string name="collections_editor_tmdb_studio_warner">Warner Bros.</string>
<string name="collections_editor_tmdb_network_netflix">Netflix</string>
<string name="collections_editor_tmdb_network_hbo">HBO</string>
<string name="collections_editor_tmdb_network_disney_plus">Disney+</string>
<string name="collections_editor_tmdb_network_prime_video">Prime Video</string>
<string name="collections_editor_tmdb_network_hulu">Hulu</string>
<string name="collections_editor_tmdb_sort_popular">Popular</string>
<string name="collections_editor_tmdb_sort_top_rated">Top Rated</string>
<string name="collections_editor_tmdb_sort_recent">Recent</string>
<string name="collections_editor_tmdb_subtitle_list">TMDB List</string>
<string name="collections_editor_tmdb_subtitle_movie_collection">TMDB Movie Collection</string>
<string name="collections_editor_tmdb_subtitle_production">Production</string>
<string name="collections_editor_tmdb_subtitle_network">Network</string>
<string name="collections_editor_tmdb_subtitle_discover">TMDB Discover</string>
<string name="collections_empty_subtitle">Create one to organize your catalogs.</string> <string name="collections_empty_subtitle">Create one to organize your catalogs.</string>
<string name="collections_empty_title">No collections yet</string> <string name="collections_empty_title">No collections yet</string>
<string name="collections_folder_count">%1$d folder(s)</string> <string name="collections_folder_count">%1$d folder(s)</string>

View file

@ -46,96 +46,105 @@ import nuvio.composeapp.generated.resources.unit_bytes_kb
import nuvio.composeapp.generated.resources.unit_bytes_mb import nuvio.composeapp.generated.resources.unit_bytes_mb
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
fun localizedMediaTypeLabel(type: String): String = runBlocking { fun localizedMediaTypeLabel(type: String): String {
when (type.trim().lowercase()) { val fallback = type.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
"movie" -> getString(Res.string.media_movies) return when (type.trim().lowercase()) {
"series" -> getString(Res.string.media_series) "movie" -> resourceString("Movies") { getString(Res.string.media_movies) }
"anime" -> getString(Res.string.media_anime) "series" -> resourceString("Series") { getString(Res.string.media_series) }
"channel" -> getString(Res.string.media_channels) "anime" -> resourceString("Anime") { getString(Res.string.media_anime) }
"tv" -> getString(Res.string.media_tv) "channel" -> resourceString("Channels") { getString(Res.string.media_channels) }
else -> type.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } "tv" -> resourceString("TV") { getString(Res.string.media_tv) }
else -> fallback
} }
} }
fun localizedMovieTypeLabel(): String = runBlocking { getString(Res.string.media_movie) } fun localizedMovieTypeLabel(): String = resourceString("Movie") { getString(Res.string.media_movie) }
fun localizedSeasonEpisodeCode(seasonNumber: Int?, episodeNumber: Int?): String? = runBlocking { fun localizedSeasonEpisodeCode(seasonNumber: Int?, episodeNumber: Int?): String? =
when { when {
seasonNumber != null && episodeNumber != null -> seasonNumber != null && episodeNumber != null ->
resourceString("S${seasonNumber}E${episodeNumber}") {
getString(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber) getString(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber)
}
episodeNumber != null -> episodeNumber != null ->
resourceString("E${episodeNumber}") {
getString(Res.string.compose_player_episode_code_episode_only, episodeNumber) getString(Res.string.compose_player_episode_code_episode_only, episodeNumber)
}
else -> null else -> null
} }
}
fun localizedPlayLabel(seasonNumber: Int?, episodeNumber: Int?): String = runBlocking { fun localizedPlayLabel(seasonNumber: Int?, episodeNumber: Int?): String {
val episodeCode = localizedSeasonEpisodeCode(seasonNumber, episodeNumber) val episodeCode = localizedSeasonEpisodeCode(seasonNumber, episodeNumber)
if (episodeCode != null) { return if (episodeCode != null) {
getString(Res.string.action_play_episode, episodeCode) resourceString("Play $episodeCode") { getString(Res.string.action_play_episode, episodeCode) }
} else { } else {
getString(Res.string.action_play) resourceString("Play") { getString(Res.string.action_play) }
} }
} }
fun localizedResumeLabel(seasonNumber: Int?, episodeNumber: Int?): String = runBlocking { fun localizedResumeLabel(seasonNumber: Int?, episodeNumber: Int?): String {
val episodeCode = localizedSeasonEpisodeCode(seasonNumber, episodeNumber) val episodeCode = localizedSeasonEpisodeCode(seasonNumber, episodeNumber)
if (episodeCode != null) { return if (episodeCode != null) {
getString(Res.string.action_resume_episode, episodeCode) resourceString("Resume $episodeCode") { getString(Res.string.action_resume_episode, episodeCode) }
} else { } else {
getString(Res.string.action_resume) resourceString("Resume") { getString(Res.string.action_resume) }
} }
} }
fun localizedUpNextLabel(seasonNumber: Int?, episodeNumber: Int?): String = runBlocking { fun localizedUpNextLabel(seasonNumber: Int?, episodeNumber: Int?): String =
if (seasonNumber != null && episodeNumber != null) { if (seasonNumber != null && episodeNumber != null) {
resourceString("Up Next • S${seasonNumber}E${episodeNumber}") {
getString(Res.string.continue_watching_up_next_episode, seasonNumber, episodeNumber) getString(Res.string.continue_watching_up_next_episode, seasonNumber, episodeNumber)
}
} else { } else {
getString(Res.string.continue_watching_up_next) resourceString("Up Next") { getString(Res.string.continue_watching_up_next) }
}
} }
fun localizedMonthName(month: Int): String = runBlocking { fun localizedMonthName(month: Int): String =
when (month) { when (month) {
1 -> getString(Res.string.date_month_january) 1 -> resourceString("January") { getString(Res.string.date_month_january) }
2 -> getString(Res.string.date_month_february) 2 -> resourceString("February") { getString(Res.string.date_month_february) }
3 -> getString(Res.string.date_month_march) 3 -> resourceString("March") { getString(Res.string.date_month_march) }
4 -> getString(Res.string.date_month_april) 4 -> resourceString("April") { getString(Res.string.date_month_april) }
5 -> getString(Res.string.date_month_may) 5 -> resourceString("May") { getString(Res.string.date_month_may) }
6 -> getString(Res.string.date_month_june) 6 -> resourceString("June") { getString(Res.string.date_month_june) }
7 -> getString(Res.string.date_month_july) 7 -> resourceString("July") { getString(Res.string.date_month_july) }
8 -> getString(Res.string.date_month_august) 8 -> resourceString("August") { getString(Res.string.date_month_august) }
9 -> getString(Res.string.date_month_september) 9 -> resourceString("September") { getString(Res.string.date_month_september) }
10 -> getString(Res.string.date_month_october) 10 -> resourceString("October") { getString(Res.string.date_month_october) }
11 -> getString(Res.string.date_month_november) 11 -> resourceString("November") { getString(Res.string.date_month_november) }
12 -> getString(Res.string.date_month_december) 12 -> resourceString("December") { getString(Res.string.date_month_december) }
else -> month.toString() else -> month.toString()
} }
}
fun localizedShortMonthName(month: Int): String = runBlocking { fun localizedShortMonthName(month: Int): String =
when (month) { when (month) {
1 -> getString(Res.string.date_month_short_jan) 1 -> resourceString("Jan") { getString(Res.string.date_month_short_jan) }
2 -> getString(Res.string.date_month_short_feb) 2 -> resourceString("Feb") { getString(Res.string.date_month_short_feb) }
3 -> getString(Res.string.date_month_short_mar) 3 -> resourceString("Mar") { getString(Res.string.date_month_short_mar) }
4 -> getString(Res.string.date_month_short_apr) 4 -> resourceString("Apr") { getString(Res.string.date_month_short_apr) }
5 -> getString(Res.string.date_month_short_may) 5 -> resourceString("May") { getString(Res.string.date_month_short_may) }
6 -> getString(Res.string.date_month_short_jun) 6 -> resourceString("Jun") { getString(Res.string.date_month_short_jun) }
7 -> getString(Res.string.date_month_short_jul) 7 -> resourceString("Jul") { getString(Res.string.date_month_short_jul) }
8 -> getString(Res.string.date_month_short_aug) 8 -> resourceString("Aug") { getString(Res.string.date_month_short_aug) }
9 -> getString(Res.string.date_month_short_sep) 9 -> resourceString("Sep") { getString(Res.string.date_month_short_sep) }
10 -> getString(Res.string.date_month_short_oct) 10 -> resourceString("Oct") { getString(Res.string.date_month_short_oct) }
11 -> getString(Res.string.date_month_short_nov) 11 -> resourceString("Nov") { getString(Res.string.date_month_short_nov) }
12 -> getString(Res.string.date_month_short_dec) 12 -> resourceString("Dec") { getString(Res.string.date_month_short_dec) }
else -> month.toString() else -> month.toString()
} }
}
fun localizedByteUnit(unit: String): String = runBlocking { fun localizedByteUnit(unit: String): String =
when (unit) { when (unit) {
"GB" -> getString(Res.string.unit_bytes_gb) "GB" -> resourceString("GB") { getString(Res.string.unit_bytes_gb) }
"MB" -> getString(Res.string.unit_bytes_mb) "MB" -> resourceString("MB") { getString(Res.string.unit_bytes_mb) }
"KB" -> getString(Res.string.unit_bytes_kb) "KB" -> resourceString("KB") { getString(Res.string.unit_bytes_kb) }
else -> getString(Res.string.unit_bytes_b) else -> resourceString("B") { getString(Res.string.unit_bytes_b) }
}
} }
private fun resourceString(
fallback: String,
provider: suspend () -> String,
): String = runCatching {
runBlocking { provider() }
}.getOrDefault(fallback)

View file

@ -2,9 +2,13 @@ package com.nuvio.app.features.collection
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.PosterShape
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@ -22,11 +26,32 @@ data class CollectionEditorUiState(
val editingFolder: CollectionFolder? = null, val editingFolder: CollectionFolder? = null,
val showFolderEditor: Boolean = false, val showFolderEditor: Boolean = false,
val showCatalogPicker: Boolean = false, val showCatalogPicker: Boolean = false,
val showTmdbSourcePicker: Boolean = false,
val genrePickerSourceIndex: Int? = null, val genrePickerSourceIndex: Int? = null,
val tmdbBuilderMode: TmdbBuilderMode = TmdbBuilderMode.PRESETS,
val tmdbInput: String = "",
val tmdbTitleInput: String = "",
val tmdbMediaType: TmdbCollectionMediaType = TmdbCollectionMediaType.MOVIE,
val tmdbMediaBoth: Boolean = false,
val tmdbSortBy: String = TmdbCollectionSort.POPULAR_DESC.value,
val tmdbFilters: TmdbCollectionFilters = TmdbCollectionFilters(),
val tmdbCompanyResults: List<TmdbCompanySearchResult> = emptyList(),
val tmdbCollectionResults: List<TmdbCollectionSearchResult> = emptyList(),
val tmdbSearchError: String? = null,
) )
enum class TmdbBuilderMode {
PRESETS,
LIST,
PRODUCTION,
NETWORK,
COLLECTION,
DISCOVER,
}
object CollectionEditorRepository { object CollectionEditorRepository {
private val log = Logger.withTag("CollectionEditorRepository") private val log = Logger.withTag("CollectionEditorRepository")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(CollectionEditorUiState()) private val _uiState = MutableStateFlow(CollectionEditorUiState())
val uiState: StateFlow<CollectionEditorUiState> = _uiState.asStateFlow() val uiState: StateFlow<CollectionEditorUiState> = _uiState.asStateFlow()
@ -198,39 +223,40 @@ object CollectionEditorRepository {
catalogId = catalog.catalogId, catalogId = catalog.catalogId,
genre = defaultGenre, genre = defaultGenre,
) )
if (folder.catalogSources.any { if (folder.resolvedCatalogSources.any {
it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId
}) return }) return
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
editingFolder = folder.copy(catalogSources = folder.catalogSources + source), editingFolder = folder.withSources(folder.resolvedSources + source.toCollectionSource()),
) )
} }
fun removeCatalogSource(index: Int) { fun removeCatalogSource(index: Int) {
val folder = _uiState.value.editingFolder ?: return val folder = _uiState.value.editingFolder ?: return
if (index !in folder.catalogSources.indices) return val sources = folder.resolvedSources
if (index !in sources.indices) return
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
editingFolder = folder.copy( editingFolder = folder.withSources(sources.toMutableList().apply { removeAt(index) }),
catalogSources = folder.catalogSources.toMutableList().apply { removeAt(index) },
),
genrePickerSourceIndex = null, genrePickerSourceIndex = null,
) )
} }
fun updateCatalogSourceGenre(index: Int, genre: String?) { fun updateCatalogSourceGenre(index: Int, genre: String?) {
val folder = _uiState.value.editingFolder ?: return val folder = _uiState.value.editingFolder ?: return
if (index !in folder.catalogSources.indices) return val sources = folder.resolvedSources
val updated = folder.catalogSources.toMutableList() if (index !in sources.indices || sources[index].isTmdb) return
val updated = sources.toMutableList()
updated[index] = updated[index].copy(genre = genre) updated[index] = updated[index].copy(genre = genre)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
editingFolder = folder.copy(catalogSources = updated), editingFolder = folder.withSources(updated),
) )
} }
fun toggleCatalogSource(catalog: AvailableCatalog) { fun toggleCatalogSource(catalog: AvailableCatalog) {
val folder = _uiState.value.editingFolder ?: return val folder = _uiState.value.editingFolder ?: return
val existingIndex = folder.catalogSources.indexOfFirst { val sources = folder.resolvedSources
it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId val existingIndex = sources.indexOfFirst {
!it.isTmdb && it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId
} }
if (existingIndex >= 0) { if (existingIndex >= 0) {
removeCatalogSource(existingIndex) removeCatalogSource(existingIndex)
@ -242,6 +268,7 @@ object CollectionEditorRepository {
fun showCatalogPicker() { fun showCatalogPicker() {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
showCatalogPicker = true, showCatalogPicker = true,
showTmdbSourcePicker = false,
genrePickerSourceIndex = null, genrePickerSourceIndex = null,
) )
} }
@ -250,12 +277,27 @@ object CollectionEditorRepository {
_uiState.value = _uiState.value.copy(showCatalogPicker = false) _uiState.value = _uiState.value.copy(showCatalogPicker = false)
} }
fun showTmdbSourcePicker() {
_uiState.value = _uiState.value.copy(
showTmdbSourcePicker = true,
showCatalogPicker = false,
genrePickerSourceIndex = null,
tmdbSearchError = null,
)
}
fun hideTmdbSourcePicker() {
_uiState.value = _uiState.value.copy(showTmdbSourcePicker = false, tmdbSearchError = null)
}
fun showGenrePicker(index: Int) { fun showGenrePicker(index: Int) {
val folder = _uiState.value.editingFolder ?: return val folder = _uiState.value.editingFolder ?: return
if (index !in folder.catalogSources.indices) return val sources = folder.resolvedSources
if (index !in sources.indices || sources[index].isTmdb) return
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
genrePickerSourceIndex = index, genrePickerSourceIndex = index,
showCatalogPicker = false, showCatalogPicker = false,
showTmdbSourcePicker = false,
) )
} }
@ -265,17 +307,19 @@ object CollectionEditorRepository {
fun saveFolderEdit() { fun saveFolderEdit() {
val folder = _uiState.value.editingFolder ?: return val folder = _uiState.value.editingFolder ?: return
val normalizedFolder = folder.withSources(folder.resolvedSources)
val existing = _uiState.value.folders val existing = _uiState.value.folders
val updated = if (existing.any { it.id == folder.id }) { val updated = if (existing.any { it.id == normalizedFolder.id }) {
existing.map { if (it.id == folder.id) folder else it } existing.map { if (it.id == normalizedFolder.id) normalizedFolder else it }
} else { } else {
existing + folder existing + normalizedFolder
} }
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
folders = updated, folders = updated,
editingFolder = null, editingFolder = null,
showFolderEditor = false, showFolderEditor = false,
showCatalogPicker = false, showCatalogPicker = false,
showTmdbSourcePicker = false,
genrePickerSourceIndex = null, genrePickerSourceIndex = null,
) )
} }
@ -285,10 +329,211 @@ object CollectionEditorRepository {
editingFolder = null, editingFolder = null,
showFolderEditor = false, showFolderEditor = false,
showCatalogPicker = false, showCatalogPicker = false,
showTmdbSourcePicker = false,
genrePickerSourceIndex = null, genrePickerSourceIndex = null,
) )
} }
fun setTmdbBuilderMode(mode: TmdbBuilderMode) {
val mediaType = if (mode == TmdbBuilderMode.NETWORK) {
TmdbCollectionMediaType.TV
} else {
_uiState.value.tmdbMediaType
}
_uiState.value = _uiState.value.copy(
tmdbBuilderMode = mode,
tmdbMediaType = mediaType,
tmdbMediaBoth = if (
mode == TmdbBuilderMode.NETWORK ||
mode == TmdbBuilderMode.LIST ||
mode == TmdbBuilderMode.COLLECTION
) {
false
} else {
_uiState.value.tmdbMediaBoth
},
tmdbCompanyResults = emptyList(),
tmdbCollectionResults = emptyList(),
tmdbSearchError = null,
)
}
fun setTmdbInput(value: String) {
_uiState.value = _uiState.value.copy(tmdbInput = value, tmdbSearchError = null)
}
fun setTmdbTitleInput(value: String) {
_uiState.value = _uiState.value.copy(tmdbTitleInput = value)
}
fun setTmdbMediaType(value: TmdbCollectionMediaType) {
_uiState.value = _uiState.value.copy(tmdbMediaType = value, tmdbMediaBoth = false)
}
fun setTmdbMediaBoth(value: Boolean) {
_uiState.value = _uiState.value.copy(
tmdbMediaBoth = value,
tmdbMediaType = if (value) TmdbCollectionMediaType.MOVIE else _uiState.value.tmdbMediaType,
)
}
fun setTmdbSortBy(value: String) {
_uiState.value = _uiState.value.copy(tmdbSortBy = value)
}
fun updateTmdbFilters(transform: (TmdbCollectionFilters) -> TmdbCollectionFilters) {
_uiState.value = _uiState.value.copy(tmdbFilters = transform(_uiState.value.tmdbFilters))
}
fun addTmdbPreset(source: CollectionSource) {
addTmdbSource(source)
}
fun searchTmdbCompanies() {
val query = _uiState.value.tmdbInput.trim()
if (query.isBlank()) return
scope.launch {
val results = runCatching { TmdbCollectionSourceResolver.searchCompanies(query) }
_uiState.value = _uiState.value.copy(
tmdbCompanyResults = results.getOrDefault(emptyList()),
tmdbSearchError = results.exceptionOrNull()?.message,
)
}
}
fun searchTmdbCollections() {
val query = _uiState.value.tmdbInput.trim()
if (query.isBlank()) return
scope.launch {
val results = runCatching { TmdbCollectionSourceResolver.searchCollections(query) }
_uiState.value = _uiState.value.copy(
tmdbCollectionResults = results.getOrDefault(emptyList()),
tmdbSearchError = results.exceptionOrNull()?.message,
)
}
}
fun addTmdbSource(source: CollectionSource) {
val sourceType = source.tmdbType()
if (source.tmdbId != null && sourceType in coverMetadataSourceTypes) {
scope.launch {
val metadata = runCatching { TmdbCollectionSourceResolver.importMetadata(sourceType, source.tmdbId) }
val resolved = metadata.getOrNull()
addTmdbSources(
sources = listOf(
if (source.title.isNullOrBlank()) {
source.copy(title = resolved?.title)
} else {
source
},
),
coverImageUrl = resolved?.coverImageUrl,
)
}
return
}
addTmdbSources(listOf(source))
}
fun addTmdbSourcesFromPicker(sources: List<CollectionSource>) {
val metadataSource = sources.firstOrNull {
it.tmdbId != null && it.tmdbType() in coverMetadataSourceTypes
}
if (metadataSource != null) {
scope.launch {
val sourceType = metadataSource.tmdbType()
val metadata = runCatching { TmdbCollectionSourceResolver.importMetadata(sourceType, metadataSource.tmdbId!!) }
addTmdbSources(sources, metadata.getOrNull()?.coverImageUrl)
}
return
}
addTmdbSources(sources)
}
fun addTmdbSourceFromInput() {
val state = _uiState.value
val mode = state.tmdbBuilderMode
val sourceType = when (mode) {
TmdbBuilderMode.PRESETS -> TmdbCollectionSourceType.DISCOVER
TmdbBuilderMode.LIST -> TmdbCollectionSourceType.LIST
TmdbBuilderMode.COLLECTION -> TmdbCollectionSourceType.COLLECTION
TmdbBuilderMode.PRODUCTION -> TmdbCollectionSourceType.COMPANY
TmdbBuilderMode.NETWORK -> TmdbCollectionSourceType.NETWORK
TmdbBuilderMode.DISCOVER -> TmdbCollectionSourceType.DISCOVER
}
val id = TmdbCollectionSourceResolver.parseTmdbId(state.tmdbInput)
if (sourceType != TmdbCollectionSourceType.DISCOVER && id == null) {
_uiState.value = state.copy(tmdbSearchError = "Enter a valid TMDB ID or URL.")
return
}
val mediaTypes = selectedMediaTypes(state, sourceType)
val baseTitle = state.tmdbTitleInput.ifBlank {
when (sourceType) {
TmdbCollectionSourceType.LIST -> "TMDB List ${id ?: ""}".trim()
TmdbCollectionSourceType.COLLECTION -> "TMDB Collection ${id ?: ""}".trim()
TmdbCollectionSourceType.COMPANY -> "TMDB Production ${id ?: ""}".trim()
TmdbCollectionSourceType.NETWORK -> "TMDB Network ${id ?: ""}".trim()
TmdbCollectionSourceType.DISCOVER -> "TMDB Discover"
}
}
val sources = mediaTypes.map { mediaType ->
CollectionSource(
provider = "tmdb",
tmdbSourceType = sourceType.name,
title = titleForMedia(baseTitle, mediaType, mediaTypes.size > 1),
tmdbId = id,
mediaType = mediaType.name,
sortBy = state.tmdbSortBy,
filters = state.tmdbFilters,
)
}
if (sourceType == TmdbCollectionSourceType.LIST || sourceType == TmdbCollectionSourceType.COLLECTION) {
scope.launch {
val metadata = runCatching { TmdbCollectionSourceResolver.importMetadata(sourceType, id!!) }
val resolved = metadata.getOrNull()
if (metadata.isFailure) {
_uiState.value = _uiState.value.copy(
tmdbSearchError = metadata.exceptionOrNull()?.message ?: "Could not load TMDB source",
)
return@launch
}
addTmdbSources(
sources.map { source ->
source.copy(title = state.tmdbTitleInput.ifBlank { resolved?.title ?: baseTitle })
},
coverImageUrl = resolved?.coverImageUrl,
)
}
return
}
addTmdbSourcesFromPicker(sources)
}
private fun addTmdbSources(sources: List<CollectionSource>, coverImageUrl: String? = null) {
val folder = _uiState.value.editingFolder ?: return
val existingKeys = folder.resolvedSources.mapTo(mutableSetOf(), ::collectionSourceKey)
val newSources = sources.filter { existingKeys.add(collectionSourceKey(it)) }
if (newSources.isEmpty()) return
val shouldApplyCover = newSources.any { it.tmdbType() in coverMetadataSourceTypes } &&
!coverImageUrl.isNullOrBlank() &&
folder.coverImageUrl.isNullOrBlank()
val updatedFolder = if (shouldApplyCover) {
folder.withSources(folder.resolvedSources + newSources)
.copy(coverImageUrl = coverImageUrl, coverEmoji = null)
} else {
folder.withSources(folder.resolvedSources + newSources)
}
_uiState.value = _uiState.value.copy(
editingFolder = updatedFolder,
showTmdbSourcePicker = false,
tmdbInput = "",
tmdbTitleInput = "",
tmdbCompanyResults = emptyList(),
tmdbCollectionResults = emptyList(),
tmdbSearchError = null,
)
}
fun save(): Boolean { fun save(): Boolean {
val state = _uiState.value val state = _uiState.value
if (state.title.isBlank()) return false if (state.title.isBlank()) return false
@ -311,3 +556,65 @@ object CollectionEditorRepository {
return true return true
} }
} }
private val coverMetadataSourceTypes = setOf(
TmdbCollectionSourceType.COLLECTION,
TmdbCollectionSourceType.COMPANY,
TmdbCollectionSourceType.NETWORK,
)
private fun CollectionCatalogSource.toCollectionSource(): CollectionSource =
CollectionSource(
provider = "addon",
addonId = addonId,
type = type,
catalogId = catalogId,
genre = genre,
)
private fun CollectionFolder.withSources(nextSources: List<CollectionSource>): CollectionFolder =
copy(
sources = nextSources,
catalogSources = nextSources.mapNotNull { it.addonCatalogSource() },
)
private fun collectionSourceKey(source: CollectionSource): String =
if (source.isTmdb) {
"tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}"
} else {
"addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}"
}
private fun selectedMediaTypes(
state: CollectionEditorUiState,
sourceType: TmdbCollectionSourceType,
): List<TmdbCollectionMediaType> =
when (sourceType) {
TmdbCollectionSourceType.COMPANY,
TmdbCollectionSourceType.DISCOVER -> if (state.tmdbMediaBoth) {
listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV)
} else {
listOf(state.tmdbMediaType)
}
TmdbCollectionSourceType.NETWORK -> listOf(TmdbCollectionMediaType.TV)
TmdbCollectionSourceType.COLLECTION,
TmdbCollectionSourceType.LIST -> listOf(TmdbCollectionMediaType.MOVIE)
}
private fun titleForMedia(
title: String,
mediaType: TmdbCollectionMediaType,
addSuffix: Boolean,
): String {
if (!addSuffix) return title
val suffix = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> "Movies"
TmdbCollectionMediaType.TV -> "Series"
}
return "$title $suffix"
}
private fun CollectionSource.tmdbType(): TmdbCollectionSourceType =
tmdbSourceType
?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() }
?: TmdbCollectionSourceType.DISCOVER

View file

@ -57,9 +57,22 @@ internal object CollectionJsonPreserver {
folder: CollectionFolder, folder: CollectionFolder,
): JsonObject { ): JsonObject {
val encoded = json.encodeToJsonElement(CollectionFolder.serializer(), folder).jsonObject val encoded = json.encodeToJsonElement(CollectionFolder.serializer(), folder).jsonObject
val rawUnifiedSourcesByKey = raw?.get("sources").asObjectArrayByKey(::unifiedSourceKey)
val mergedUnifiedSources = buildJsonArray {
folder.resolvedSources.forEach { source ->
val sourceElement = json.encodeToJsonElement(CollectionSource.serializer(), source)
add(
mergeUnifiedSource(
json = json,
raw = rawUnifiedSourcesByKey[unifiedSourceKey(sourceElement)],
source = source,
),
)
}
}
val rawSourcesByKey = raw?.get("catalogSources").asObjectArrayByKey(::sourceKey) val rawSourcesByKey = raw?.get("catalogSources").asObjectArrayByKey(::sourceKey)
val mergedSources = buildJsonArray { val mergedSources = buildJsonArray {
folder.catalogSources.forEach { source -> folder.resolvedCatalogSources.forEach { source ->
val sourceElement = val sourceElement =
json.encodeToJsonElement(CollectionCatalogSource.serializer(), source) json.encodeToJsonElement(CollectionCatalogSource.serializer(), source)
add( add(
@ -71,7 +84,23 @@ internal object CollectionJsonPreserver {
) )
} }
} }
return mergeObjects(raw, encoded, mapOf("catalogSources" to mergedSources)) return mergeObjects(
raw,
encoded,
mapOf(
"sources" to mergedUnifiedSources,
"catalogSources" to mergedSources,
),
)
}
private fun mergeUnifiedSource(
json: Json,
raw: JsonObject?,
source: CollectionSource,
): JsonObject {
val encoded = json.encodeToJsonElement(CollectionSource.serializer(), source).jsonObject
return mergeObjects(raw, encoded)
} }
private fun mergeSource( private fun mergeSource(
@ -111,4 +140,21 @@ internal object CollectionJsonPreserver {
val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null
return "$addonId|$type|$catalogId" return "$addonId|$type|$catalogId"
} }
private fun unifiedSourceKey(element: JsonElement): String? {
val obj = element as? JsonObject ?: return null
val provider = obj["provider"]?.jsonPrimitive?.contentOrNull ?: "addon"
return if (provider.equals("tmdb", ignoreCase = true)) {
val sourceType = obj["tmdbSourceType"]?.jsonPrimitive?.contentOrNull ?: return null
val tmdbId = obj["tmdbId"]?.jsonPrimitive?.contentOrNull.orEmpty()
val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty()
val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty()
"$provider|$sourceType|$tmdbId|$mediaType|$sortBy"
} else {
val addonId = obj["addonId"]?.jsonPrimitive?.contentOrNull ?: return null
val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null
val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null
"$provider|$addonId|$type|$catalogId"
}
}
} }

View file

@ -30,6 +30,95 @@ data class CollectionCatalogSource(
val genre: String? = null, val genre: String? = null,
) )
@Immutable
@Serializable
data class CollectionSource(
val provider: String = "addon",
val addonId: String? = null,
val type: String? = null,
val catalogId: String? = null,
val genre: String? = null,
val tmdbSourceType: String? = null,
val title: String? = null,
val tmdbId: Int? = null,
val mediaType: String? = null,
val sortBy: String? = null,
val filters: TmdbCollectionFilters? = null,
) {
val isTmdb: Boolean
get() = provider.equals("tmdb", ignoreCase = true)
fun addonCatalogSource(): CollectionCatalogSource? {
if (isTmdb) return null
val sourceAddonId = addonId?.takeIf { it.isNotBlank() } ?: return null
val sourceType = type?.takeIf { it.isNotBlank() } ?: return null
val sourceCatalogId = catalogId?.takeIf { it.isNotBlank() } ?: return null
return CollectionCatalogSource(
addonId = sourceAddonId,
type = sourceType,
catalogId = sourceCatalogId,
genre = genre,
)
}
}
@Serializable
enum class TmdbCollectionSourceType {
LIST,
COLLECTION,
COMPANY,
NETWORK,
DISCOVER,
}
@Serializable
enum class TmdbCollectionMediaType(val value: String) {
MOVIE("movie"),
TV("tv");
companion object {
fun fromString(value: String?): TmdbCollectionMediaType =
when (value?.trim()?.lowercase()) {
"tv", "series" -> TV
else -> MOVIE
}
}
}
enum class TmdbCollectionSort(val value: String) {
POPULAR_DESC("popularity.desc"),
VOTE_AVERAGE_DESC("vote_average.desc"),
RELEASE_DATE_DESC("primary_release_date.desc"),
FIRST_AIR_DATE_DESC("first_air_date.desc"),
}
@Immutable
@Serializable
data class TmdbCollectionFilters(
val withGenres: String? = null,
val releaseDateGte: String? = null,
val releaseDateLte: String? = null,
val voteAverageGte: Double? = null,
val voteAverageLte: Double? = null,
val voteCountGte: Int? = null,
val withOriginalLanguage: String? = null,
val withOriginCountry: String? = null,
val withKeywords: String? = null,
val withCompanies: String? = null,
val withNetworks: String? = null,
val year: Int? = null,
)
data class TmdbSourceImportMetadata(
val title: String? = null,
val coverImageUrl: String? = null,
)
data class TmdbPresetSource(
val label: String,
val source: CollectionSource,
)
@Immutable @Immutable
@Serializable @Serializable
data class CollectionFolder( data class CollectionFolder(
@ -41,7 +130,10 @@ data class CollectionFolder(
val coverEmoji: String? = null, val coverEmoji: String? = null,
val tileShape: String = "poster", val tileShape: String = "poster",
val hideTitle: Boolean = false, val hideTitle: Boolean = false,
val sources: List<CollectionSource> = emptyList(),
val catalogSources: List<CollectionCatalogSource> = emptyList(), val catalogSources: List<CollectionCatalogSource> = emptyList(),
val heroBackdropUrl: String? = null,
val titleLogoUrl: String? = null,
) { ) {
val posterShape: PosterShape val posterShape: PosterShape
get() = when (tileShape.lowercase()) { get() = when (tileShape.lowercase()) {
@ -50,6 +142,22 @@ data class CollectionFolder(
"square" -> PosterShape.Square "square" -> PosterShape.Square
else -> PosterShape.Poster else -> PosterShape.Poster
} }
val resolvedSources: List<CollectionSource>
get() = sources.ifEmpty {
catalogSources.map { source ->
CollectionSource(
provider = "addon",
addonId = source.addonId,
type = source.type,
catalogId = source.catalogId,
genre = source.genre,
)
}
}
val resolvedCatalogSources: List<CollectionCatalogSource>
get() = resolvedSources.mapNotNull { it.addonCatalogSource() }
} }
@Immutable @Immutable

View file

@ -179,8 +179,12 @@ object CollectionRepository {
}, },
) )
} }
f.catalogSources.forEachIndexed { si, s -> f.resolvedSources.forEachIndexed { si, s ->
if (s.addonId.isBlank() || s.type.isBlank() || s.catalogId.isBlank()) { val invalidAddon = !s.isTmdb &&
(s.addonId.isNullOrBlank() || s.type.isNullOrBlank() || s.catalogId.isNullOrBlank())
val invalidTmdb = s.isTmdb &&
s.tmdbSourceType.isNullOrBlank()
if (invalidAddon || invalidTmdb) {
return ValidationResult( return ValidationResult(
valid = false, valid = false,
error = runBlocking { error = runBlocking {

View file

@ -27,6 +27,7 @@ import org.jetbrains.compose.resources.getString
data class FolderTab( data class FolderTab(
val label: String, val label: String,
val typeLabel: String = "", val typeLabel: String = "",
val source: CollectionSource? = null,
val manifestUrl: String? = null, val manifestUrl: String? = null,
val type: String = "", val type: String = "",
val catalogId: String = "", val catalogId: String = "",
@ -114,7 +115,8 @@ object FolderDetailRepository {
return return
} }
val showAll = collection.showAllTab && folder.catalogSources.size > 1 val sources = folder.resolvedSources
val showAll = collection.showAllTab && sources.size > 1
val addons = AddonRepository.uiState.value.addons val addons = AddonRepository.uiState.value.addons
val tabs = buildList { val tabs = buildList {
@ -127,28 +129,46 @@ object FolderDetailRepository {
), ),
) )
} }
folder.catalogSources.forEach { source -> sources.forEach { source ->
val addon = addons.find { it.manifest?.id == source.addonId } if (source.isTmdb) {
val mediaType = TmdbCollectionMediaType.fromString(source.mediaType)
val type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie"
add(
FolderTab(
label = source.title?.takeIf { it.isNotBlank() } ?: "TMDB",
typeLabel = "TMDB",
source = source,
type = type,
catalogId = tmdbCatalogId(source),
supportsPagination = source.tmdbSourceType != TmdbCollectionSourceType.COLLECTION.name,
isLoading = true,
),
)
} else {
val catalogSource = source.addonCatalogSource() ?: return@forEach
val addon = addons.find { it.manifest?.id == catalogSource.addonId }
val catalog = addon?.manifest?.catalogs?.find { val catalog = addon?.manifest?.catalogs?.find {
it.id == source.catalogId && it.type == source.type it.id == catalogSource.catalogId && it.type == catalogSource.type
} }
val label = catalog?.name ?: source.catalogId val label = catalog?.name ?: catalogSource.catalogId
val typeLabel = localizedMediaTypeLabel(source.type) val typeLabel = localizedMediaTypeLabel(catalogSource.type)
val genreSuffix = if (source.genre != null) " · ${source.genre}" else "" val genreSuffix = if (catalogSource.genre != null) " · ${catalogSource.genre}" else ""
add( add(
FolderTab( FolderTab(
label = "$label ($typeLabel)$genreSuffix", label = "$label ($typeLabel)$genreSuffix",
typeLabel = typeLabel, typeLabel = typeLabel,
source = source,
manifestUrl = addon?.manifestUrl, manifestUrl = addon?.manifestUrl,
type = source.type, type = catalogSource.type,
catalogId = source.catalogId, catalogId = catalogSource.catalogId,
genre = source.genre, genre = catalogSource.genre,
supportsPagination = catalog?.supportsPagination() == true, supportsPagination = catalog?.supportsPagination() == true,
isLoading = true, isLoading = true,
), ),
) )
} }
} }
}
_uiState.value = FolderDetailUiState( _uiState.value = FolderDetailUiState(
folder = folder, folder = folder,
@ -161,15 +181,16 @@ object FolderDetailRepository {
) )
// Load catalog data for each source // Load catalog data for each source
folder.catalogSources.forEachIndexed { sourceIndex, source -> sources.forEachIndexed { sourceIndex, source ->
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
val addon = addons.find { it.manifest?.id == source.addonId } val catalogSource = source.addonCatalogSource()
if (addon == null) { val addon = catalogSource?.let { value -> addons.find { it.manifest?.id == value.addonId } }
if (!source.isTmdb && addon == null) {
updateTab(tabIndex) { updateTab(tabIndex) {
it.copy( it.copy(
isLoading = false, isLoading = false,
error = runBlocking { error = runBlocking {
getString(Res.string.collections_folder_addon_not_found, source.addonId) getString(Res.string.collections_folder_addon_not_found, catalogSource?.addonId.orEmpty())
}, },
) )
} }
@ -180,7 +201,7 @@ object FolderDetailRepository {
} }
// If no sources, mark as done // If no sources, mark as done
if (folder.catalogSources.isEmpty()) { if (sources.isEmpty()) {
_uiState.value = _uiState.value.copy(isLoading = false) _uiState.value = _uiState.value.copy(isLoading = false)
} }
} }
@ -229,8 +250,8 @@ object FolderDetailRepository {
private fun loadTabPage(index: Int, reset: Boolean) { private fun loadTabPage(index: Int, reset: Boolean) {
val currentTab = _uiState.value.tabs.getOrNull(index) ?: return val currentTab = _uiState.value.tabs.getOrNull(index) ?: return
val manifestUrl = currentTab.manifestUrl ?: return
val requestedSkip = if (reset) 0 else currentTab.nextSkip ?: return val requestedSkip = if (reset) 0 else currentTab.nextSkip ?: return
if (!currentTab.source?.isTmdb.orFalse() && currentTab.manifestUrl == null) return
updateTab(index) { tab -> updateTab(index) { tab ->
if (reset) { if (reset) {
@ -252,13 +273,21 @@ object FolderDetailRepository {
loadJobs.remove(index)?.cancel() loadJobs.remove(index)?.cancel()
val job = scope.launch { val job = scope.launch {
runCatching { runCatching {
val source = currentTab.source
if (source?.isTmdb == true) {
TmdbCollectionSourceResolver.resolve(
source = source,
page = if (reset) 1 else requestedSkip,
)
} else {
fetchCatalogPage( fetchCatalogPage(
manifestUrl = manifestUrl, manifestUrl = requireNotNull(currentTab.manifestUrl),
type = currentTab.type, type = currentTab.type,
catalogId = currentTab.catalogId, catalogId = currentTab.catalogId,
genre = currentTab.genre, genre = currentTab.genre,
skip = requestedSkip.takeIf { it > 0 }, skip = requestedSkip.takeIf { it > 0 },
) )
}
}.onSuccess { page -> }.onSuccess { page ->
updateTab(index) { tab -> updateTab(index) { tab ->
val mergedItems = if (reset) { val mergedItems = if (reset) {
@ -279,7 +308,7 @@ object FolderDetailRepository {
} }
rebuildAllTab() rebuildAllTab()
}.onFailure { error -> }.onFailure { error ->
log.e(error) { "Failed to load catalog ${currentTab.catalogId} from $manifestUrl" } log.e(error) { "Failed to load source ${currentTab.catalogId}" }
updateTab(index) { tab -> updateTab(index) { tab ->
tab.copy( tab.copy(
isLoading = false, isLoading = false,
@ -353,3 +382,17 @@ object FolderDetailRepository {
} }
} }
} }
private fun Boolean?.orFalse(): Boolean = this == true
private fun tmdbCatalogId(source: CollectionSource): String =
buildString {
append("tmdb_")
append(source.tmdbSourceType?.lowercase().orEmpty())
source.tmdbId?.let {
append("_")
append(it)
}
append("_")
append(source.mediaType?.lowercase().orEmpty())
}

View file

@ -0,0 +1,526 @@
package com.nuvio.app.features.collection
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.catalog.CatalogPage
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.tmdb.buildTmdbUrl
import com.nuvio.app.features.tmdb.normalizeTmdbLanguage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.math.roundToInt
object TmdbCollectionSourceResolver {
private val log = Logger.withTag("TmdbCollectionSource")
private val json = Json { ignoreUnknownKeys = true }
suspend fun resolve(source: CollectionSource, page: Int = 1): CatalogPage = withContext(Dispatchers.Default) {
val settings = TmdbSettingsRepository.snapshot()
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
?: error("Add a TMDB API key in Settings to use TMDB sources.")
val language = normalizeTmdbLanguage(settings.language)
val sourceType = source.tmdbType()
when (sourceType) {
TmdbCollectionSourceType.LIST -> resolveList(source, apiKey, language, page)
TmdbCollectionSourceType.COLLECTION -> resolveCollection(source, apiKey, language)
TmdbCollectionSourceType.COMPANY,
TmdbCollectionSourceType.NETWORK,
TmdbCollectionSourceType.DISCOVER -> resolveDiscover(source, apiKey, language, page)
}
}
suspend fun importMetadata(sourceType: TmdbCollectionSourceType, id: Int): TmdbSourceImportMetadata =
withContext(Dispatchers.Default) {
val settings = TmdbSettingsRepository.snapshot()
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
?: error("Add a TMDB API key in Settings to use TMDB sources.")
val language = normalizeTmdbLanguage(settings.language)
when (sourceType) {
TmdbCollectionSourceType.LIST -> {
val body = fetch<TmdbListResponse>(
endpoint = "list/$id",
apiKey = apiKey,
query = mapOf("language" to language, "page" to "1"),
) ?: error("TMDB list not found")
TmdbSourceImportMetadata(title = body.name?.takeIf { it.isNotBlank() })
}
TmdbCollectionSourceType.COLLECTION -> {
val body = fetch<TmdbCollectionResponse>(
endpoint = "collection/$id",
apiKey = apiKey,
query = mapOf("language" to language),
) ?: error("TMDB collection not found")
TmdbSourceImportMetadata(
title = body.name?.takeIf { it.isNotBlank() },
coverImageUrl = imageUrl(body.posterPath, "w500") ?: imageUrl(body.backdropPath, "w1280"),
)
}
TmdbCollectionSourceType.COMPANY -> {
val body = fetch<TmdbCompanyResponse>(
endpoint = "company/$id",
apiKey = apiKey,
) ?: error("TMDB company not found")
TmdbSourceImportMetadata(
title = body.name?.takeIf { it.isNotBlank() },
coverImageUrl = imageUrl(body.logoPath, "w500"),
)
}
TmdbCollectionSourceType.NETWORK -> {
val body = fetch<TmdbNetworkResponse>(
endpoint = "network/$id",
apiKey = apiKey,
) ?: error("TMDB network not found")
TmdbSourceImportMetadata(
title = body.name?.takeIf { it.isNotBlank() },
coverImageUrl = imageUrl(body.logoPath, "w500"),
)
}
TmdbCollectionSourceType.DISCOVER -> TmdbSourceImportMetadata(title = "TMDB Discover")
}
}
suspend fun searchCompanies(query: String): List<TmdbCompanySearchResult> = withContext(Dispatchers.Default) {
val trimmed = query.trim()
if (trimmed.isBlank()) return@withContext emptyList()
val settings = TmdbSettingsRepository.snapshot()
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
?: error("Add a TMDB API key in Settings to use TMDB sources.")
fetch<TmdbCompanySearchResponse>(
endpoint = "search/company",
apiKey = apiKey,
query = mapOf("query" to trimmed),
)?.results.orEmpty()
}
suspend fun searchCollections(query: String): List<TmdbCollectionSearchResult> = withContext(Dispatchers.Default) {
val trimmed = query.trim()
if (trimmed.isBlank()) return@withContext emptyList()
val settings = TmdbSettingsRepository.snapshot()
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
?: error("Add a TMDB API key in Settings to use TMDB sources.")
val language = normalizeTmdbLanguage(settings.language)
fetch<TmdbCollectionSearchResponse>(
endpoint = "search/collection",
apiKey = apiKey,
query = mapOf("query" to trimmed, "language" to language),
)?.results.orEmpty()
}
suspend fun searchKeywords(query: String): Map<Int, String> = withContext(Dispatchers.Default) {
val trimmed = query.trim()
if (trimmed.isBlank()) return@withContext emptyMap()
val settings = TmdbSettingsRepository.snapshot()
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
?: error("Add a TMDB API key in Settings to use TMDB sources.")
fetch<TmdbKeywordSearchResponse>(
endpoint = "search/keyword",
apiKey = apiKey,
query = mapOf("query" to trimmed),
)?.results.orEmpty()
.mapNotNull { result ->
val name = result.name?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
result.id to name
}
.toMap()
}
suspend fun genres(mediaType: TmdbCollectionMediaType): Map<Int, String> = withContext(Dispatchers.Default) {
val settings = TmdbSettingsRepository.snapshot()
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
?: error("Add a TMDB API key in Settings to use TMDB sources.")
val language = normalizeTmdbLanguage(settings.language)
val endpoint = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> "genre/movie/list"
TmdbCollectionMediaType.TV -> "genre/tv/list"
}
fetch<TmdbGenreResponse>(
endpoint = endpoint,
apiKey = apiKey,
query = mapOf("language" to language),
)?.genres.orEmpty().associate { it.id to it.name }
}
fun parseTmdbId(input: String): Int? {
val trimmed = input.trim()
trimmed.toIntOrNull()?.let { return it }
return Regex("""(?:list|collection|company|network)/(\d+)""")
.find(trimmed)
?.groupValues
?.getOrNull(1)
?.toIntOrNull()
?: Regex("""[?&]id=(\d+)""")
.find(trimmed)
?.groupValues
?.getOrNull(1)
?.toIntOrNull()
}
fun presets(): List<TmdbPresetSource> = listOf(
TmdbPresetSource("Marvel Studios", company("Marvel Studios", 420)),
TmdbPresetSource("Walt Disney Pictures", company("Walt Disney Pictures", 2)),
TmdbPresetSource("Pixar", company("Pixar", 3)),
TmdbPresetSource("Lucasfilm", company("Lucasfilm", 1)),
TmdbPresetSource("Warner Bros.", company("Warner Bros.", 174)),
TmdbPresetSource("Netflix", network("Netflix", 213)),
TmdbPresetSource("HBO", network("HBO", 49)),
TmdbPresetSource("Disney+", network("Disney+", 2739)),
TmdbPresetSource("Prime Video", network("Prime Video", 1024)),
TmdbPresetSource("Hulu", network("Hulu", 453)),
TmdbPresetSource("Apple TV+", network("Apple TV+", 2552)),
)
private suspend fun resolveList(
source: CollectionSource,
apiKey: String,
language: String,
page: Int,
): CatalogPage {
val id = source.tmdbId ?: error("Missing TMDB list ID")
val body = fetch<TmdbListResponse>(
endpoint = "list/$id",
apiKey = apiKey,
query = mapOf("language" to language, "page" to page.toString()),
) ?: error("TMDB list not found")
val items = body.items.orEmpty()
.mapNotNull { it.toPreview() }
.distinctBy { "${it.type}:${it.id}" }
return CatalogPage(
items = items,
rawItemCount = items.size,
nextSkip = if ((body.page ?: page) < (body.totalPages ?: page) && items.isNotEmpty()) page + 1 else null,
)
}
private suspend fun resolveCollection(
source: CollectionSource,
apiKey: String,
language: String,
): CatalogPage {
val id = source.tmdbId ?: error("Missing TMDB collection ID")
val body = fetch<TmdbCollectionResponse>(
endpoint = "collection/$id",
apiKey = apiKey,
query = mapOf("language" to language),
) ?: error("TMDB collection not found")
val items = body.parts.orEmpty()
.sortedBy { it.releaseDate ?: "9999" }
.mapNotNull { it.toPreview(TmdbCollectionMediaType.MOVIE) }
.distinctBy { it.id }
return CatalogPage(items = items, rawItemCount = items.size, nextSkip = null)
}
private suspend fun resolveDiscover(
source: CollectionSource,
apiKey: String,
language: String,
page: Int,
): CatalogPage {
val sourceType = source.tmdbType()
val mediaType = if (sourceType == TmdbCollectionSourceType.NETWORK) {
TmdbCollectionMediaType.TV
} else {
source.tmdbMediaType()
}
val filters = source.filters ?: TmdbCollectionFilters()
val query = buildDiscoverQuery(
source = source,
sourceType = sourceType,
mediaType = mediaType,
language = language,
page = page,
filters = filters,
)
val endpoint = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> "discover/movie"
TmdbCollectionMediaType.TV -> "discover/tv"
}
val body = fetch<TmdbDiscoverResponse>(
endpoint = endpoint,
apiKey = apiKey,
query = query,
) ?: error("TMDB discover returned no data")
val items = body.results.orEmpty()
.mapNotNull { it.toPreview(mediaType) }
.distinctBy { it.id }
return CatalogPage(
items = items,
rawItemCount = items.size,
nextSkip = if ((body.page ?: page) < (body.totalPages ?: page) && items.isNotEmpty()) page + 1 else null,
)
}
private fun buildDiscoverQuery(
source: CollectionSource,
sourceType: TmdbCollectionSourceType,
mediaType: TmdbCollectionMediaType,
language: String,
page: Int,
filters: TmdbCollectionFilters,
): Map<String, String> {
val sortBy = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> movieSort(source.sortBy)
TmdbCollectionMediaType.TV -> tvSort(source.sortBy)
}
return buildMap {
put("language", language)
put("page", page.toString())
put("sort_by", sortBy)
val companyId = source.tmdbId?.toString().takeIf { sourceType == TmdbCollectionSourceType.COMPANY }
val networkId = source.tmdbId?.toString().takeIf { sourceType == TmdbCollectionSourceType.NETWORK }
putIfNotBlank("with_companies", companyId ?: filters.withCompanies)
putIfNotBlank("with_networks", networkId ?: filters.withNetworks)
putIfNotBlank("with_genres", filters.withGenres)
putIfNotBlank("vote_count.gte", filters.voteCountGte?.toString())
putIfNotBlank("vote_average.gte", filters.voteAverageGte?.toString())
putIfNotBlank("vote_average.lte", filters.voteAverageLte?.toString())
putIfNotBlank("with_original_language", filters.withOriginalLanguage)
putIfNotBlank("with_origin_country", filters.withOriginCountry)
putIfNotBlank("with_keywords", filters.withKeywords)
putIfNotBlank("year", filters.year?.takeIf { mediaType == TmdbCollectionMediaType.MOVIE }?.toString())
putIfNotBlank("first_air_date_year", filters.year?.takeIf { mediaType == TmdbCollectionMediaType.TV }?.toString())
putIfNotBlank(
if (mediaType == TmdbCollectionMediaType.MOVIE) "primary_release_date.gte" else "first_air_date.gte",
filters.releaseDateGte,
)
putIfNotBlank(
if (mediaType == TmdbCollectionMediaType.MOVIE) "primary_release_date.lte" else "first_air_date.lte",
filters.releaseDateLte,
)
}
}
private suspend inline fun <reified T> fetch(
endpoint: String,
apiKey: String,
query: Map<String, String> = emptyMap(),
): T? {
val url = buildTmdbUrl(endpoint = endpoint, apiKey = apiKey, query = query)
return runCatching {
json.decodeFromString<T>(httpGetText(url))
}.onFailure { error ->
log.w(error) { "TMDB source request failed for $endpoint" }
}.getOrNull()
}
private fun TmdbListItem.toPreview(): MetaPreview? {
val media = mediaType?.lowercase()
val contentType = if (media == "tv") TmdbCollectionMediaType.TV else TmdbCollectionMediaType.MOVIE
return toPreview(contentType)
}
private fun TmdbListItem.toPreview(mediaType: TmdbCollectionMediaType): MetaPreview? {
val title = title?.takeIf { it.isNotBlank() }
?: name?.takeIf { it.isNotBlank() }
?: originalTitle?.takeIf { it.isNotBlank() }
?: originalName?.takeIf { it.isNotBlank() }
?: return null
return MetaPreview(
id = "tmdb:$id",
type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie",
name = title,
poster = imageUrl(posterPath, "w500") ?: imageUrl(backdropPath, "w780"),
banner = imageUrl(backdropPath, "w1280"),
posterShape = PosterShape.Poster,
description = overview?.takeIf { it.isNotBlank() },
releaseInfo = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> releaseDate?.take(4)
TmdbCollectionMediaType.TV -> firstAirDate?.take(4)
},
rawReleaseDate = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> releaseDate
TmdbCollectionMediaType.TV -> firstAirDate
},
popularity = popularity,
imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
)
}
private fun TmdbCollectionPart.toPreview(mediaType: TmdbCollectionMediaType): MetaPreview? {
val title = title?.takeIf { it.isNotBlank() } ?: return null
return MetaPreview(
id = "tmdb:$id",
type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie",
name = title,
poster = imageUrl(posterPath, "w500") ?: imageUrl(backdropPath, "w780"),
banner = imageUrl(backdropPath, "w1280"),
posterShape = PosterShape.Poster,
description = overview?.takeIf { it.isNotBlank() },
releaseInfo = releaseDate?.take(4),
rawReleaseDate = releaseDate,
popularity = popularity,
imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
)
}
private fun CollectionSource.tmdbType(): TmdbCollectionSourceType =
tmdbSourceType
?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() }
?: TmdbCollectionSourceType.DISCOVER
private fun CollectionSource.tmdbMediaType(): TmdbCollectionMediaType =
TmdbCollectionMediaType.fromString(mediaType)
private fun company(title: String, id: Int) = CollectionSource(
provider = "tmdb",
tmdbSourceType = TmdbCollectionSourceType.COMPANY.name,
title = title,
tmdbId = id,
mediaType = TmdbCollectionMediaType.MOVIE.name,
sortBy = TmdbCollectionSort.POPULAR_DESC.value,
)
private fun network(title: String, id: Int) = CollectionSource(
provider = "tmdb",
tmdbSourceType = TmdbCollectionSourceType.NETWORK.name,
title = title,
tmdbId = id,
mediaType = TmdbCollectionMediaType.TV.name,
sortBy = TmdbCollectionSort.POPULAR_DESC.value,
)
private fun movieSort(sortBy: String?): String =
when (sortBy) {
TmdbCollectionSort.FIRST_AIR_DATE_DESC.value -> TmdbCollectionSort.RELEASE_DATE_DESC.value
null, "" -> TmdbCollectionSort.POPULAR_DESC.value
else -> sortBy
}
private fun tvSort(sortBy: String?): String =
when (sortBy) {
TmdbCollectionSort.RELEASE_DATE_DESC.value -> TmdbCollectionSort.FIRST_AIR_DATE_DESC.value
null, "" -> TmdbCollectionSort.POPULAR_DESC.value
else -> sortBy
}
}
private fun MutableMap<String, String>.putIfNotBlank(key: String, value: String?) {
if (!value.isNullOrBlank()) {
put(key, value)
}
}
private fun imageUrl(path: String?, size: String): String? {
val clean = path?.takeIf { it.isNotBlank() } ?: return null
return "https://image.tmdb.org/t/p/$size$clean"
}
@Serializable
private data class TmdbListResponse(
val name: String? = null,
val page: Int? = null,
@SerialName("total_pages") val totalPages: Int? = null,
val items: List<TmdbListItem>? = null,
)
@Serializable
private data class TmdbCollectionResponse(
val name: String? = null,
@SerialName("poster_path") val posterPath: String? = null,
@SerialName("backdrop_path") val backdropPath: String? = null,
val parts: List<TmdbCollectionPart>? = null,
)
@Serializable
private data class TmdbDiscoverResponse(
val page: Int? = null,
@SerialName("total_pages") val totalPages: Int? = null,
val results: List<TmdbListItem>? = null,
)
@Serializable
private data class TmdbCompanyResponse(
val name: String? = null,
@SerialName("logo_path") val logoPath: String? = null,
)
@Serializable
private data class TmdbNetworkResponse(
val name: String? = null,
@SerialName("logo_path") val logoPath: String? = null,
)
@Serializable
data class TmdbCompanySearchResult(
val id: Int,
val name: String? = null,
@SerialName("origin_country") val originCountry: String? = null,
)
@Serializable
private data class TmdbCompanySearchResponse(
val results: List<TmdbCompanySearchResult>? = null,
)
@Serializable
data class TmdbCollectionSearchResult(
val id: Int,
val name: String? = null,
@SerialName("poster_path") val posterPath: String? = null,
@SerialName("backdrop_path") val backdropPath: String? = null,
)
@Serializable
private data class TmdbCollectionSearchResponse(
val results: List<TmdbCollectionSearchResult>? = null,
)
@Serializable
private data class TmdbKeywordSearchResponse(
val results: List<TmdbKeywordSearchResult>? = null,
)
@Serializable
private data class TmdbKeywordSearchResult(
val id: Int,
val name: String? = null,
)
@Serializable
private data class TmdbGenreResponse(
val genres: List<TmdbGenreItem>? = null,
)
@Serializable
private data class TmdbGenreItem(
val id: Int,
val name: String,
)
@Serializable
private data class TmdbListItem(
val id: Int,
@SerialName("media_type") val mediaType: String? = null,
val title: String? = null,
val name: String? = null,
@SerialName("original_title") val originalTitle: String? = null,
@SerialName("original_name") val originalName: String? = null,
val overview: String? = null,
@SerialName("poster_path") val posterPath: String? = null,
@SerialName("backdrop_path") val backdropPath: String? = null,
@SerialName("release_date") val releaseDate: String? = null,
@SerialName("first_air_date") val firstAirDate: String? = null,
@SerialName("vote_average") val voteAverage: Double? = null,
val popularity: Double? = null,
)
@Serializable
private data class TmdbCollectionPart(
val id: Int,
val title: String? = null,
val overview: String? = null,
@SerialName("poster_path") val posterPath: String? = null,
@SerialName("backdrop_path") val backdropPath: String? = null,
@SerialName("release_date") val releaseDate: String? = null,
@SerialName("vote_average") val voteAverage: Double? = null,
val popularity: Double? = null,
)

View file

@ -40,6 +40,7 @@ import com.nuvio.app.features.watchprogress.nextUpDismissKey
import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressClock
import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle
import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watchprogress.toContinueWatchingItem
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watching.application.WatchingState
@ -617,7 +618,11 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
parentMetaType = contentType, parentMetaType = contentType,
videoId = videoId, videoId = videoId,
title = name, title = name,
subtitle = episodeTitle.orEmpty(), subtitle = buildContinueWatchingEpisodeSubtitle(
seasonNumber = season,
episodeNumber = episode,
episodeTitle = episodeTitle,
),
imageUrl = episodeThumbnail ?: backdrop ?: poster, imageUrl = episodeThumbnail ?: backdrop ?: poster,
logo = logo, logo = logo,
poster = poster, poster = poster,
@ -654,7 +659,11 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem
parentMetaType = contentType, parentMetaType = contentType,
videoId = videoId, videoId = videoId,
title = name, title = name,
subtitle = episodeTitle.orEmpty(), subtitle = buildContinueWatchingEpisodeSubtitle(
seasonNumber = season,
episodeNumber = episode,
episodeTitle = episodeTitle,
),
imageUrl = episodeThumbnail ?: backdrop ?: poster, imageUrl = episodeThumbnail ?: backdrop ?: poster,
logo = logo, logo = logo,
poster = poster, poster = poster,

View file

@ -65,7 +65,7 @@ private const val HERO_SCROLL_UP_SCALE_MULTIPLIER = 0.002f
private const val HERO_SCROLL_MAX_SCALE = 1.3f private const val HERO_SCROLL_MAX_SCALE = 1.3f
private const val HERO_SWIPE_THRESHOLD_FRACTION = 0.16f private const val HERO_SWIPE_THRESHOLD_FRACTION = 0.16f
private const val HERO_SWIPE_VELOCITY_THRESHOLD = 300f private const val HERO_SWIPE_VELOCITY_THRESHOLD = 300f
private const val MOBILE_HERO_VIEWPORT_RATIO = 0.78f private const val MOBILE_HERO_VIEWPORT_RATIO = 0.82f
private const val MOBILE_HERO_MIN_HEIGHT_DP = 360f private const val MOBILE_HERO_MIN_HEIGHT_DP = 360f
private const val MOBILE_HERO_MAX_HEIGHT_DP = 760f private const val MOBILE_HERO_MAX_HEIGHT_DP = 760f

View file

@ -11,7 +11,6 @@ import io.github.jan.supabase.postgrest.rpc
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -25,9 +24,6 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.library_other
import org.jetbrains.compose.resources.getString
@Serializable @Serializable
private data class StoredLibraryPayload( private data class StoredLibraryPayload(
@ -370,7 +366,7 @@ private fun LibraryItem.toSyncItem(): LibrarySyncItem = LibrarySyncItem(
internal fun String.toLibraryDisplayTitle(): String { internal fun String.toLibraryDisplayTitle(): String {
val normalized = trim() val normalized = trim()
if (normalized.isBlank()) return runBlocking { getString(Res.string.library_other) } if (normalized.isBlank()) return "Other"
return normalized return normalized
.split('-', '_', ' ') .split('-', '_', ' ')
@ -378,5 +374,5 @@ internal fun String.toLibraryDisplayTitle(): String {
.joinToString(" ") { token -> .joinToString(" ") { token ->
token.lowercase().replaceFirstChar { char -> char.uppercase() } token.lowercase().replaceFirstChar { char -> char.uppercase() }
} }
.ifBlank { runBlocking { getString(Res.string.library_other) } } .ifBlank { "Other" }
} }

View file

@ -190,7 +190,11 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
parentMetaType = normalizedEntry.parentMetaType, parentMetaType = normalizedEntry.parentMetaType,
videoId = normalizedEntry.videoId, videoId = normalizedEntry.videoId,
title = normalizedEntry.title, title = normalizedEntry.title,
subtitle = normalizedEntry.episodeTitle.orEmpty(), subtitle = buildContinueWatchingEpisodeSubtitle(
seasonNumber = normalizedEntry.seasonNumber,
episodeNumber = normalizedEntry.episodeNumber,
episodeTitle = normalizedEntry.episodeTitle,
),
imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster, imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster,
logo = normalizedEntry.logo, logo = normalizedEntry.logo,
poster = normalizedEntry.poster, poster = normalizedEntry.poster,
@ -223,7 +227,11 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem(
fallbackVideoId = nextEpisode.id, fallbackVideoId = nextEpisode.id,
), ),
title = title, title = title,
subtitle = nextEpisode.title, subtitle = buildContinueWatchingEpisodeSubtitle(
seasonNumber = nextEpisode.season,
episodeNumber = nextEpisode.episode,
episodeTitle = nextEpisode.title,
),
imageUrl = nextEpisode.thumbnail ?: episodeThumbnail ?: background ?: poster, imageUrl = nextEpisode.thumbnail ?: episodeThumbnail ?: background ?: poster,
logo = logo, logo = logo,
poster = poster, poster = poster,
@ -243,6 +251,20 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem(
) )
} }
internal fun buildContinueWatchingEpisodeSubtitle(
seasonNumber: Int?,
episodeNumber: Int?,
episodeTitle: String?,
): String {
val episodeCode = when {
seasonNumber != null && episodeNumber != null -> "S${seasonNumber}E${episodeNumber}"
episodeNumber != null -> "E${episodeNumber}"
else -> null
}
val title = episodeTitle.orEmpty()
return listOfNotNull(episodeCode, title.takeIf { it.isNotBlank() }).joinToString("")
}
fun buildPlaybackVideoId( fun buildPlaybackVideoId(
parentMetaId: String, parentMetaId: String,
seasonNumber: Int?, seasonNumber: Int?,

View file

@ -36,7 +36,7 @@ class SeriesPlaybackResolverTest {
) )
assertNotNull(action) assertNotNull(action)
assertEquals("Up Next S1E3", action.label) assertEquals("Up Next S1E3", action.label)
assertEquals("show:1:3", action.videoId) assertEquals("show:1:3", action.videoId)
assertEquals(1, action.seasonNumber) assertEquals(1, action.seasonNumber)
assertEquals(3, action.episodeNumber) assertEquals(3, action.episodeNumber)
@ -85,7 +85,7 @@ class SeriesPlaybackResolverTest {
) )
assertNotNull(action) assertNotNull(action)
assertEquals("Up Next S1E3", action.label) assertEquals("Up Next S1E3", action.label)
assertEquals("show:1:3", action.videoId) assertEquals("show:1:3", action.videoId)
} }
} }

View file

@ -39,7 +39,7 @@ class SeriesContinuityTest {
) )
assertNotNull(action) assertNotNull(action)
assertEquals("Up Next S1E3", action.label) assertEquals("Up Next S1E3", action.label)
assertEquals("show:1:3", action.videoId) assertEquals("show:1:3", action.videoId)
assertEquals(3, action.episodeNumber) assertEquals(3, action.episodeNumber)
} }
@ -142,7 +142,7 @@ class SeriesContinuityTest {
) )
assertNotNull(action) assertNotNull(action)
assertEquals("Up Next S2E2", action.label) assertEquals("Up Next S2E2", action.label)
assertEquals("show:2:2", action.videoId) assertEquals("show:2:2", action.videoId)
} }
} }