mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
Merge branch 'collectionstmdb' into cmp-rewrite
This commit is contained in:
commit
7e716f7e79
16 changed files with 2455 additions and 253 deletions
|
|
@ -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">Seleccionar catálogos</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_square">Cuadrado</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_tabs">Pestañas</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_title">Aún no hay colecciones</string>
|
||||
<string name="collections_folder_count">%1$d carpeta(s)</string>
|
||||
|
|
|
|||
|
|
@ -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">Select Catalogs</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_square">Square</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_tabs">Tabs</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_title">No collections yet</string>
|
||||
<string name="collections_folder_count">%1$d folder(s)</string>
|
||||
|
|
|
|||
|
|
@ -46,96 +46,105 @@ import nuvio.composeapp.generated.resources.unit_bytes_kb
|
|||
import nuvio.composeapp.generated.resources.unit_bytes_mb
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
fun localizedMediaTypeLabel(type: String): String = runBlocking {
|
||||
when (type.trim().lowercase()) {
|
||||
"movie" -> getString(Res.string.media_movies)
|
||||
"series" -> getString(Res.string.media_series)
|
||||
"anime" -> getString(Res.string.media_anime)
|
||||
"channel" -> getString(Res.string.media_channels)
|
||||
"tv" -> getString(Res.string.media_tv)
|
||||
else -> type.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
fun localizedMediaTypeLabel(type: String): String {
|
||||
val fallback = type.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
return when (type.trim().lowercase()) {
|
||||
"movie" -> resourceString("Movies") { getString(Res.string.media_movies) }
|
||||
"series" -> resourceString("Series") { getString(Res.string.media_series) }
|
||||
"anime" -> resourceString("Anime") { getString(Res.string.media_anime) }
|
||||
"channel" -> resourceString("Channels") { getString(Res.string.media_channels) }
|
||||
"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 {
|
||||
seasonNumber != null && episodeNumber != null ->
|
||||
getString(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber)
|
||||
resourceString("S${seasonNumber}E${episodeNumber}") {
|
||||
getString(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber)
|
||||
}
|
||||
episodeNumber != null ->
|
||||
getString(Res.string.compose_player_episode_code_episode_only, episodeNumber)
|
||||
resourceString("E${episodeNumber}") {
|
||||
getString(Res.string.compose_player_episode_code_episode_only, episodeNumber)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun localizedPlayLabel(seasonNumber: Int?, episodeNumber: Int?): String = runBlocking {
|
||||
fun localizedPlayLabel(seasonNumber: Int?, episodeNumber: Int?): String {
|
||||
val episodeCode = localizedSeasonEpisodeCode(seasonNumber, episodeNumber)
|
||||
if (episodeCode != null) {
|
||||
getString(Res.string.action_play_episode, episodeCode)
|
||||
return if (episodeCode != null) {
|
||||
resourceString("Play $episodeCode") { getString(Res.string.action_play_episode, episodeCode) }
|
||||
} 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)
|
||||
if (episodeCode != null) {
|
||||
getString(Res.string.action_resume_episode, episodeCode)
|
||||
return if (episodeCode != null) {
|
||||
resourceString("Resume $episodeCode") { getString(Res.string.action_resume_episode, episodeCode) }
|
||||
} 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) {
|
||||
getString(Res.string.continue_watching_up_next_episode, seasonNumber, episodeNumber)
|
||||
resourceString("Up Next • S${seasonNumber}E${episodeNumber}") {
|
||||
getString(Res.string.continue_watching_up_next_episode, seasonNumber, episodeNumber)
|
||||
}
|
||||
} 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) {
|
||||
1 -> getString(Res.string.date_month_january)
|
||||
2 -> getString(Res.string.date_month_february)
|
||||
3 -> getString(Res.string.date_month_march)
|
||||
4 -> getString(Res.string.date_month_april)
|
||||
5 -> getString(Res.string.date_month_may)
|
||||
6 -> getString(Res.string.date_month_june)
|
||||
7 -> getString(Res.string.date_month_july)
|
||||
8 -> getString(Res.string.date_month_august)
|
||||
9 -> getString(Res.string.date_month_september)
|
||||
10 -> getString(Res.string.date_month_october)
|
||||
11 -> getString(Res.string.date_month_november)
|
||||
12 -> getString(Res.string.date_month_december)
|
||||
1 -> resourceString("January") { getString(Res.string.date_month_january) }
|
||||
2 -> resourceString("February") { getString(Res.string.date_month_february) }
|
||||
3 -> resourceString("March") { getString(Res.string.date_month_march) }
|
||||
4 -> resourceString("April") { getString(Res.string.date_month_april) }
|
||||
5 -> resourceString("May") { getString(Res.string.date_month_may) }
|
||||
6 -> resourceString("June") { getString(Res.string.date_month_june) }
|
||||
7 -> resourceString("July") { getString(Res.string.date_month_july) }
|
||||
8 -> resourceString("August") { getString(Res.string.date_month_august) }
|
||||
9 -> resourceString("September") { getString(Res.string.date_month_september) }
|
||||
10 -> resourceString("October") { getString(Res.string.date_month_october) }
|
||||
11 -> resourceString("November") { getString(Res.string.date_month_november) }
|
||||
12 -> resourceString("December") { getString(Res.string.date_month_december) }
|
||||
else -> month.toString()
|
||||
}
|
||||
}
|
||||
|
||||
fun localizedShortMonthName(month: Int): String = runBlocking {
|
||||
fun localizedShortMonthName(month: Int): String =
|
||||
when (month) {
|
||||
1 -> getString(Res.string.date_month_short_jan)
|
||||
2 -> getString(Res.string.date_month_short_feb)
|
||||
3 -> getString(Res.string.date_month_short_mar)
|
||||
4 -> getString(Res.string.date_month_short_apr)
|
||||
5 -> getString(Res.string.date_month_short_may)
|
||||
6 -> getString(Res.string.date_month_short_jun)
|
||||
7 -> getString(Res.string.date_month_short_jul)
|
||||
8 -> getString(Res.string.date_month_short_aug)
|
||||
9 -> getString(Res.string.date_month_short_sep)
|
||||
10 -> getString(Res.string.date_month_short_oct)
|
||||
11 -> getString(Res.string.date_month_short_nov)
|
||||
12 -> getString(Res.string.date_month_short_dec)
|
||||
1 -> resourceString("Jan") { getString(Res.string.date_month_short_jan) }
|
||||
2 -> resourceString("Feb") { getString(Res.string.date_month_short_feb) }
|
||||
3 -> resourceString("Mar") { getString(Res.string.date_month_short_mar) }
|
||||
4 -> resourceString("Apr") { getString(Res.string.date_month_short_apr) }
|
||||
5 -> resourceString("May") { getString(Res.string.date_month_short_may) }
|
||||
6 -> resourceString("Jun") { getString(Res.string.date_month_short_jun) }
|
||||
7 -> resourceString("Jul") { getString(Res.string.date_month_short_jul) }
|
||||
8 -> resourceString("Aug") { getString(Res.string.date_month_short_aug) }
|
||||
9 -> resourceString("Sep") { getString(Res.string.date_month_short_sep) }
|
||||
10 -> resourceString("Oct") { getString(Res.string.date_month_short_oct) }
|
||||
11 -> resourceString("Nov") { getString(Res.string.date_month_short_nov) }
|
||||
12 -> resourceString("Dec") { getString(Res.string.date_month_short_dec) }
|
||||
else -> month.toString()
|
||||
}
|
||||
}
|
||||
|
||||
fun localizedByteUnit(unit: String): String = runBlocking {
|
||||
fun localizedByteUnit(unit: String): String =
|
||||
when (unit) {
|
||||
"GB" -> getString(Res.string.unit_bytes_gb)
|
||||
"MB" -> getString(Res.string.unit_bytes_mb)
|
||||
"KB" -> getString(Res.string.unit_bytes_kb)
|
||||
else -> getString(Res.string.unit_bytes_b)
|
||||
"GB" -> resourceString("GB") { getString(Res.string.unit_bytes_gb) }
|
||||
"MB" -> resourceString("MB") { getString(Res.string.unit_bytes_mb) }
|
||||
"KB" -> resourceString("KB") { getString(Res.string.unit_bytes_kb) }
|
||||
else -> resourceString("B") { getString(Res.string.unit_bytes_b) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun resourceString(
|
||||
fallback: String,
|
||||
provider: suspend () -> String,
|
||||
): String = runCatching {
|
||||
runBlocking { provider() }
|
||||
}.getOrDefault(fallback)
|
||||
|
|
|
|||
|
|
@ -2,9 +2,13 @@ package com.nuvio.app.features.collection
|
|||
|
||||
import co.touchlab.kermit.Logger
|
||||
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.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
|
|
@ -22,11 +26,32 @@ data class CollectionEditorUiState(
|
|||
val editingFolder: CollectionFolder? = null,
|
||||
val showFolderEditor: Boolean = false,
|
||||
val showCatalogPicker: Boolean = false,
|
||||
val showTmdbSourcePicker: Boolean = false,
|
||||
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 {
|
||||
private val log = Logger.withTag("CollectionEditorRepository")
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
private val _uiState = MutableStateFlow(CollectionEditorUiState())
|
||||
val uiState: StateFlow<CollectionEditorUiState> = _uiState.asStateFlow()
|
||||
|
|
@ -198,39 +223,40 @@ object CollectionEditorRepository {
|
|||
catalogId = catalog.catalogId,
|
||||
genre = defaultGenre,
|
||||
)
|
||||
if (folder.catalogSources.any {
|
||||
if (folder.resolvedCatalogSources.any {
|
||||
it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId
|
||||
}) return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingFolder = folder.copy(catalogSources = folder.catalogSources + source),
|
||||
editingFolder = folder.withSources(folder.resolvedSources + source.toCollectionSource()),
|
||||
)
|
||||
}
|
||||
|
||||
fun removeCatalogSource(index: Int) {
|
||||
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(
|
||||
editingFolder = folder.copy(
|
||||
catalogSources = folder.catalogSources.toMutableList().apply { removeAt(index) },
|
||||
),
|
||||
editingFolder = folder.withSources(sources.toMutableList().apply { removeAt(index) }),
|
||||
genrePickerSourceIndex = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun updateCatalogSourceGenre(index: Int, genre: String?) {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
if (index !in folder.catalogSources.indices) return
|
||||
val updated = folder.catalogSources.toMutableList()
|
||||
val sources = folder.resolvedSources
|
||||
if (index !in sources.indices || sources[index].isTmdb) return
|
||||
val updated = sources.toMutableList()
|
||||
updated[index] = updated[index].copy(genre = genre)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingFolder = folder.copy(catalogSources = updated),
|
||||
editingFolder = folder.withSources(updated),
|
||||
)
|
||||
}
|
||||
|
||||
fun toggleCatalogSource(catalog: AvailableCatalog) {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
val existingIndex = folder.catalogSources.indexOfFirst {
|
||||
it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId
|
||||
val sources = folder.resolvedSources
|
||||
val existingIndex = sources.indexOfFirst {
|
||||
!it.isTmdb && it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId
|
||||
}
|
||||
if (existingIndex >= 0) {
|
||||
removeCatalogSource(existingIndex)
|
||||
|
|
@ -242,6 +268,7 @@ object CollectionEditorRepository {
|
|||
fun showCatalogPicker() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
showCatalogPicker = true,
|
||||
showTmdbSourcePicker = false,
|
||||
genrePickerSourceIndex = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -250,12 +277,27 @@ object CollectionEditorRepository {
|
|||
_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) {
|
||||
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(
|
||||
genrePickerSourceIndex = index,
|
||||
showCatalogPicker = false,
|
||||
showTmdbSourcePicker = false,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -265,17 +307,19 @@ object CollectionEditorRepository {
|
|||
|
||||
fun saveFolderEdit() {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
val normalizedFolder = folder.withSources(folder.resolvedSources)
|
||||
val existing = _uiState.value.folders
|
||||
val updated = if (existing.any { it.id == folder.id }) {
|
||||
existing.map { if (it.id == folder.id) folder else it }
|
||||
val updated = if (existing.any { it.id == normalizedFolder.id }) {
|
||||
existing.map { if (it.id == normalizedFolder.id) normalizedFolder else it }
|
||||
} else {
|
||||
existing + folder
|
||||
existing + normalizedFolder
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
folders = updated,
|
||||
editingFolder = null,
|
||||
showFolderEditor = false,
|
||||
showCatalogPicker = false,
|
||||
showTmdbSourcePicker = false,
|
||||
genrePickerSourceIndex = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -285,10 +329,211 @@ object CollectionEditorRepository {
|
|||
editingFolder = null,
|
||||
showFolderEditor = false,
|
||||
showCatalogPicker = false,
|
||||
showTmdbSourcePicker = false,
|
||||
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 {
|
||||
val state = _uiState.value
|
||||
if (state.title.isBlank()) return false
|
||||
|
|
@ -311,3 +556,65 @@ object CollectionEditorRepository {
|
|||
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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -57,9 +57,22 @@ internal object CollectionJsonPreserver {
|
|||
folder: CollectionFolder,
|
||||
): 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 mergedSources = buildJsonArray {
|
||||
folder.catalogSources.forEach { source ->
|
||||
folder.resolvedCatalogSources.forEach { source ->
|
||||
val sourceElement =
|
||||
json.encodeToJsonElement(CollectionCatalogSource.serializer(), source)
|
||||
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(
|
||||
|
|
@ -111,4 +140,21 @@ internal object CollectionJsonPreserver {
|
|||
val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,95 @@ data class CollectionCatalogSource(
|
|||
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
|
||||
@Serializable
|
||||
data class CollectionFolder(
|
||||
|
|
@ -41,7 +130,10 @@ data class CollectionFolder(
|
|||
val coverEmoji: String? = null,
|
||||
val tileShape: String = "poster",
|
||||
val hideTitle: Boolean = false,
|
||||
val sources: List<CollectionSource> = emptyList(),
|
||||
val catalogSources: List<CollectionCatalogSource> = emptyList(),
|
||||
val heroBackdropUrl: String? = null,
|
||||
val titleLogoUrl: String? = null,
|
||||
) {
|
||||
val posterShape: PosterShape
|
||||
get() = when (tileShape.lowercase()) {
|
||||
|
|
@ -50,6 +142,22 @@ data class CollectionFolder(
|
|||
"square" -> PosterShape.Square
|
||||
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
|
||||
|
|
|
|||
|
|
@ -179,8 +179,12 @@ object CollectionRepository {
|
|||
},
|
||||
)
|
||||
}
|
||||
f.catalogSources.forEachIndexed { si, s ->
|
||||
if (s.addonId.isBlank() || s.type.isBlank() || s.catalogId.isBlank()) {
|
||||
f.resolvedSources.forEachIndexed { si, s ->
|
||||
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(
|
||||
valid = false,
|
||||
error = runBlocking {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import org.jetbrains.compose.resources.getString
|
|||
data class FolderTab(
|
||||
val label: String,
|
||||
val typeLabel: String = "",
|
||||
val source: CollectionSource? = null,
|
||||
val manifestUrl: String? = null,
|
||||
val type: String = "",
|
||||
val catalogId: String = "",
|
||||
|
|
@ -114,7 +115,8 @@ object FolderDetailRepository {
|
|||
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 tabs = buildList {
|
||||
|
|
@ -127,26 +129,44 @@ object FolderDetailRepository {
|
|||
),
|
||||
)
|
||||
}
|
||||
folder.catalogSources.forEach { source ->
|
||||
val addon = addons.find { it.manifest?.id == source.addonId }
|
||||
val catalog = addon?.manifest?.catalogs?.find {
|
||||
it.id == source.catalogId && it.type == source.type
|
||||
sources.forEach { source ->
|
||||
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 {
|
||||
it.id == catalogSource.catalogId && it.type == catalogSource.type
|
||||
}
|
||||
val label = catalog?.name ?: catalogSource.catalogId
|
||||
val typeLabel = localizedMediaTypeLabel(catalogSource.type)
|
||||
val genreSuffix = if (catalogSource.genre != null) " · ${catalogSource.genre}" else ""
|
||||
add(
|
||||
FolderTab(
|
||||
label = "$label ($typeLabel)$genreSuffix",
|
||||
typeLabel = typeLabel,
|
||||
source = source,
|
||||
manifestUrl = addon?.manifestUrl,
|
||||
type = catalogSource.type,
|
||||
catalogId = catalogSource.catalogId,
|
||||
genre = catalogSource.genre,
|
||||
supportsPagination = catalog?.supportsPagination() == true,
|
||||
isLoading = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
val label = catalog?.name ?: source.catalogId
|
||||
val typeLabel = localizedMediaTypeLabel(source.type)
|
||||
val genreSuffix = if (source.genre != null) " · ${source.genre}" else ""
|
||||
add(
|
||||
FolderTab(
|
||||
label = "$label ($typeLabel)$genreSuffix",
|
||||
typeLabel = typeLabel,
|
||||
manifestUrl = addon?.manifestUrl,
|
||||
type = source.type,
|
||||
catalogId = source.catalogId,
|
||||
genre = source.genre,
|
||||
supportsPagination = catalog?.supportsPagination() == true,
|
||||
isLoading = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -161,15 +181,16 @@ object FolderDetailRepository {
|
|||
)
|
||||
|
||||
// Load catalog data for each source
|
||||
folder.catalogSources.forEachIndexed { sourceIndex, source ->
|
||||
sources.forEachIndexed { sourceIndex, source ->
|
||||
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
|
||||
val addon = addons.find { it.manifest?.id == source.addonId }
|
||||
if (addon == null) {
|
||||
val catalogSource = source.addonCatalogSource()
|
||||
val addon = catalogSource?.let { value -> addons.find { it.manifest?.id == value.addonId } }
|
||||
if (!source.isTmdb && addon == null) {
|
||||
updateTab(tabIndex) {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
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 (folder.catalogSources.isEmpty()) {
|
||||
if (sources.isEmpty()) {
|
||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||
}
|
||||
}
|
||||
|
|
@ -229,8 +250,8 @@ object FolderDetailRepository {
|
|||
|
||||
private fun loadTabPage(index: Int, reset: Boolean) {
|
||||
val currentTab = _uiState.value.tabs.getOrNull(index) ?: return
|
||||
val manifestUrl = currentTab.manifestUrl ?: return
|
||||
val requestedSkip = if (reset) 0 else currentTab.nextSkip ?: return
|
||||
if (!currentTab.source?.isTmdb.orFalse() && currentTab.manifestUrl == null) return
|
||||
|
||||
updateTab(index) { tab ->
|
||||
if (reset) {
|
||||
|
|
@ -252,13 +273,21 @@ object FolderDetailRepository {
|
|||
loadJobs.remove(index)?.cancel()
|
||||
val job = scope.launch {
|
||||
runCatching {
|
||||
fetchCatalogPage(
|
||||
manifestUrl = manifestUrl,
|
||||
type = currentTab.type,
|
||||
catalogId = currentTab.catalogId,
|
||||
genre = currentTab.genre,
|
||||
skip = requestedSkip.takeIf { it > 0 },
|
||||
)
|
||||
val source = currentTab.source
|
||||
if (source?.isTmdb == true) {
|
||||
TmdbCollectionSourceResolver.resolve(
|
||||
source = source,
|
||||
page = if (reset) 1 else requestedSkip,
|
||||
)
|
||||
} else {
|
||||
fetchCatalogPage(
|
||||
manifestUrl = requireNotNull(currentTab.manifestUrl),
|
||||
type = currentTab.type,
|
||||
catalogId = currentTab.catalogId,
|
||||
genre = currentTab.genre,
|
||||
skip = requestedSkip.takeIf { it > 0 },
|
||||
)
|
||||
}
|
||||
}.onSuccess { page ->
|
||||
updateTab(index) { tab ->
|
||||
val mergedItems = if (reset) {
|
||||
|
|
@ -279,7 +308,7 @@ object FolderDetailRepository {
|
|||
}
|
||||
rebuildAllTab()
|
||||
}.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 ->
|
||||
tab.copy(
|
||||
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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -40,6 +40,7 @@ import com.nuvio.app.features.watchprogress.nextUpDismissKey
|
|||
import com.nuvio.app.features.watchprogress.WatchProgressClock
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
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.toUpNextContinueWatchingItem
|
||||
import com.nuvio.app.features.watching.application.WatchingState
|
||||
|
|
@ -617,7 +618,11 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
|
|||
parentMetaType = contentType,
|
||||
videoId = videoId,
|
||||
title = name,
|
||||
subtitle = episodeTitle.orEmpty(),
|
||||
subtitle = buildContinueWatchingEpisodeSubtitle(
|
||||
seasonNumber = season,
|
||||
episodeNumber = episode,
|
||||
episodeTitle = episodeTitle,
|
||||
),
|
||||
imageUrl = episodeThumbnail ?: backdrop ?: poster,
|
||||
logo = logo,
|
||||
poster = poster,
|
||||
|
|
@ -654,7 +659,11 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem
|
|||
parentMetaType = contentType,
|
||||
videoId = videoId,
|
||||
title = name,
|
||||
subtitle = episodeTitle.orEmpty(),
|
||||
subtitle = buildContinueWatchingEpisodeSubtitle(
|
||||
seasonNumber = season,
|
||||
episodeNumber = episode,
|
||||
episodeTitle = episodeTitle,
|
||||
),
|
||||
imageUrl = episodeThumbnail ?: backdrop ?: poster,
|
||||
logo = logo,
|
||||
poster = poster,
|
||||
|
|
|
|||
|
|
@ -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_SWIPE_THRESHOLD_FRACTION = 0.16f
|
||||
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_MAX_HEIGHT_DP = 760f
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import io.github.jan.supabase.postgrest.rpc
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
|
@ -25,9 +24,6 @@ import kotlinx.serialization.json.Json
|
|||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
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
|
||||
private data class StoredLibraryPayload(
|
||||
|
|
@ -370,7 +366,7 @@ private fun LibraryItem.toSyncItem(): LibrarySyncItem = LibrarySyncItem(
|
|||
|
||||
internal fun String.toLibraryDisplayTitle(): String {
|
||||
val normalized = trim()
|
||||
if (normalized.isBlank()) return runBlocking { getString(Res.string.library_other) }
|
||||
if (normalized.isBlank()) return "Other"
|
||||
|
||||
return normalized
|
||||
.split('-', '_', ' ')
|
||||
|
|
@ -378,5 +374,5 @@ internal fun String.toLibraryDisplayTitle(): String {
|
|||
.joinToString(" ") { token ->
|
||||
token.lowercase().replaceFirstChar { char -> char.uppercase() }
|
||||
}
|
||||
.ifBlank { runBlocking { getString(Res.string.library_other) } }
|
||||
.ifBlank { "Other" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,7 +190,11 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
|
|||
parentMetaType = normalizedEntry.parentMetaType,
|
||||
videoId = normalizedEntry.videoId,
|
||||
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,
|
||||
logo = normalizedEntry.logo,
|
||||
poster = normalizedEntry.poster,
|
||||
|
|
@ -223,7 +227,11 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem(
|
|||
fallbackVideoId = nextEpisode.id,
|
||||
),
|
||||
title = title,
|
||||
subtitle = nextEpisode.title,
|
||||
subtitle = buildContinueWatchingEpisodeSubtitle(
|
||||
seasonNumber = nextEpisode.season,
|
||||
episodeNumber = nextEpisode.episode,
|
||||
episodeTitle = nextEpisode.title,
|
||||
),
|
||||
imageUrl = nextEpisode.thumbnail ?: episodeThumbnail ?: background ?: poster,
|
||||
logo = logo,
|
||||
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(
|
||||
parentMetaId: String,
|
||||
seasonNumber: Int?,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class SeriesPlaybackResolverTest {
|
|||
)
|
||||
|
||||
assertNotNull(action)
|
||||
assertEquals("Up Next S1E3", action.label)
|
||||
assertEquals("Up Next • S1E3", action.label)
|
||||
assertEquals("show:1:3", action.videoId)
|
||||
assertEquals(1, action.seasonNumber)
|
||||
assertEquals(3, action.episodeNumber)
|
||||
|
|
@ -85,7 +85,7 @@ class SeriesPlaybackResolverTest {
|
|||
)
|
||||
|
||||
assertNotNull(action)
|
||||
assertEquals("Up Next S1E3", action.label)
|
||||
assertEquals("Up Next • S1E3", action.label)
|
||||
assertEquals("show:1:3", action.videoId)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class SeriesContinuityTest {
|
|||
)
|
||||
|
||||
assertNotNull(action)
|
||||
assertEquals("Up Next S1E3", action.label)
|
||||
assertEquals("Up Next • S1E3", action.label)
|
||||
assertEquals("show:1:3", action.videoId)
|
||||
assertEquals(3, action.episodeNumber)
|
||||
}
|
||||
|
|
@ -142,7 +142,7 @@ class SeriesContinuityTest {
|
|||
)
|
||||
|
||||
assertNotNull(action)
|
||||
assertEquals("Up Next S2E2", action.label)
|
||||
assertEquals("Up Next • S2E2", action.label)
|
||||
assertEquals("show:2:2", action.videoId)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue