This commit is contained in:
foXaCe 2026-05-16 06:08:00 +02:00 committed by GitHub
commit 60977fe1d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 364 additions and 107 deletions

View file

@ -7,9 +7,15 @@ import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.updates_download_failed_http
import nuvio.composeapp.generated.resources.updates_downloaded_file_missing
import nuvio.composeapp.generated.resources.updates_empty_download_body
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.jetbrains.compose.resources.getString
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -63,10 +69,10 @@ object AndroidAppUpdaterPlatform {
httpClient.newCall(request).execute().use { response -> httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) { if (!response.isSuccessful) {
error("Download failed with HTTP ${response.code}") error(runBlocking { getString(Res.string.updates_download_failed_http, response.code) })
} }
val body = response.body ?: error("Empty download body") val body = response.body ?: error(runBlocking { getString(Res.string.updates_empty_download_body) })
val totalBytes = body.contentLength().takeIf { it > 0L } val totalBytes = body.contentLength().takeIf { it > 0L }
body.byteStream().use { input -> body.byteStream().use { input ->
FileOutputStream(destination).use { output -> FileOutputStream(destination).use { output ->
@ -115,7 +121,7 @@ object AndroidAppUpdaterPlatform {
fun installDownloadedApk(path: String): Result<Unit> = runCatching { fun installDownloadedApk(path: String): Result<Unit> = runCatching {
val context = requireContext() val context = requireContext()
val apkFile = File(path) val apkFile = File(path)
check(apkFile.exists()) { "Downloaded update file is missing." } check(apkFile.exists()) { runBlocking { getString(Res.string.updates_downloaded_file_missing) } }
val apkUri = FileProvider.getUriForFile( val apkUri = FileProvider.getUriForFile(
context, context,

View file

@ -4,7 +4,12 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import com.nuvio.app.core.network.IPv4FirstDns import com.nuvio.app.core.network.IPv4FirstDns
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.network_empty_response_body
import nuvio.composeapp.generated.resources.network_request_failed_http
import org.jetbrains.compose.resources.getString
import okhttp3.ResponseBody import okhttp3.ResponseBody
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -153,10 +158,10 @@ private suspend fun executeTextRequest(
addonHttpClient.newCall(request).execute().use { response -> addonHttpClient.newCall(request).execute().use { response ->
val payload = readResponseBody(response.body) val payload = readResponseBody(response.body)
if (!response.isSuccessful) { if (!response.isSuccessful) {
error("Request failed with HTTP ${response.code}") error(runBlocking { getString(Res.string.network_request_failed_http, response.code) })
} }
if (payload.isBlank()) { if (payload.isBlank()) {
throw IllegalStateException("Empty response body") throw IllegalStateException(runBlocking { getString(Res.string.network_empty_response_body) })
} }
payload payload
} }

View file

@ -111,7 +111,7 @@ internal actual object DownloadsLiveStatusPlatform {
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_LOW)
.addAction( .addAction(
0, 0,
"Pause", runBlocking { getString(Res.string.compose_action_pause) },
buildActionPendingIntent( buildActionPendingIntent(
context = context, context = context,
action = DownloadsNotificationActionReceiver.actionPause, action = DownloadsNotificationActionReceiver.actionPause,
@ -178,7 +178,15 @@ internal actual object DownloadsLiveStatusPlatform {
private fun formatBytes(bytes: Long): String { private fun formatBytes(bytes: Long): String {
val safe = bytes.coerceAtLeast(0L).toDouble() val safe = bytes.coerceAtLeast(0L).toDouble()
val units = arrayOf("B", "KB", "MB", "GB", "TB") val units = runBlocking {
arrayOf(
getString(Res.string.unit_bytes_b),
getString(Res.string.unit_bytes_kb),
getString(Res.string.unit_bytes_mb),
getString(Res.string.unit_bytes_gb),
getString(Res.string.unit_bytes_tb),
)
}
var value = safe var value = safe
var unitIndex = 0 var unitIndex = 0
while (value >= 1024.0 && unitIndex < units.lastIndex) { while (value >= 1024.0 && unitIndex < units.lastIndex) {

View file

@ -1192,4 +1192,72 @@
<string name="unit_bytes_kb">Ko</string> <string name="unit_bytes_kb">Ko</string>
<string name="unit_bytes_mb">Mo</string> <string name="unit_bytes_mb">Mo</string>
<string name="unit_bytes_gb">Go</string> <string name="unit_bytes_gb">Go</string>
<string name="unit_bytes_tb">To</string>
<string name="collections_editor_tmdb_genres_movie_placeholder">28,12</string>
<string name="collections_editor_tmdb_genres_series_placeholder">18,35</string>
<string name="collections_editor_tmdb_date_from_placeholder">2020-01-01</string>
<string name="collections_editor_tmdb_date_to_placeholder">2024-12-31</string>
<string name="collections_editor_tmdb_rating_min_placeholder">7.0</string>
<string name="collections_editor_tmdb_rating_max_placeholder">10</string>
<string name="collections_editor_tmdb_votes_min_placeholder">100</string>
<string name="collections_editor_tmdb_language_placeholder">en, ko, ja, hi</string>
<string name="collections_editor_tmdb_country_placeholder">US, KR, JP, IN</string>
<string name="collections_editor_tmdb_year_placeholder">2024</string>
<string name="collections_editor_trakt_list_id_format">ID %1$s</string>
<string name="player_addon_subtitle_display_format">%1$s (%2$s)</string>
<string name="trakt_connected_status">Connecté à Trakt</string>
<string name="trakt_disconnected_status">Déconnecté de Trakt</string>
<string name="collections_editor_trakt_enter_name_url_or_id">Saisis un nom, une URL ou un ID de liste Trakt</string>
<string name="collections_editor_trakt_enter_id_or_url">Saisis un ID ou une URL de liste Trakt</string>
<string name="collections_editor_trakt_load_failed">Impossible de charger la liste Trakt</string>
<string name="collections_editor_trakt_fallback_title">Liste Trakt %1$d</string>
<string name="collections_editor_trakt_resolved_subtitle">Liste Trakt résolue</string>
<string name="collections_editor_trakt_no_lists_found">Aucune liste Trakt trouvée</string>
<string name="collections_editor_tmdb_invalid_id_or_url">Saisis un ID ou une URL TMDB valide.</string>
<string name="collections_editor_tmdb_list_title_format">Liste TMDB %1$s</string>
<string name="collections_editor_tmdb_collection_title_format">Collection TMDB %1$s</string>
<string name="collections_editor_tmdb_production_title_format">Production TMDB %1$s</string>
<string name="collections_editor_tmdb_network_title_format">Chaîne TMDB %1$s</string>
<string name="collections_editor_tmdb_person_title_format">Personne TMDB %1$s</string>
<string name="collections_editor_tmdb_director_title_format">Réalisateur TMDB %1$s</string>
<string name="collections_editor_tmdb_discover_title">Découverte TMDB</string>
<string name="collections_editor_tmdb_source_load_failed">Impossible de charger la source TMDB</string>
<string name="collections_folder_trakt_series_list">Liste Trakt de séries</string>
<string name="collections_folder_trakt_movie_list">Liste Trakt de films</string>
<string name="collections_tmdb_api_key_required">Ajoute une clé API TMDB dans les Paramètres pour utiliser les sources TMDB.</string>
<string name="collections_tmdb_list_not_found">Liste TMDB introuvable</string>
<string name="collections_tmdb_collection_not_found">Collection TMDB introuvable</string>
<string name="collections_tmdb_company_not_found">Société TMDB introuvable</string>
<string name="collections_tmdb_network_not_found">Chaîne TMDB introuvable</string>
<string name="collections_tmdb_person_not_found">Personne TMDB introuvable</string>
<string name="collections_tmdb_missing_list_id">ID de liste TMDB manquant</string>
<string name="collections_tmdb_missing_collection_id">ID de collection TMDB manquant</string>
<string name="collections_tmdb_missing_person_id">ID de personne TMDB manquant</string>
<string name="collections_tmdb_person_credits_not_found">Crédits TMDB de la personne introuvables</string>
<string name="collections_tmdb_discover_no_data">Découverte TMDB na retourné aucune donnée</string>
<string name="collections_trakt_missing_list_id">ID de liste Trakt manquant</string>
<string name="collections_trakt_invalid_list_id_or_url">Saisis un ID ou une URL de liste Trakt valide</string>
<string name="collections_trakt_missing_numeric_id">La liste Trakt ne contient pas dID numérique</string>
<string name="collections_trakt_request_failed">Échec de la requête Trakt</string>
<string name="collections_trakt_credentials_missing">Identifiants Trakt manquants.</string>
<string name="collections_trakt_list_items_count">%1$d éléments</string>
<string name="collections_trakt_list_likes_count">%1$d jaime</string>
<string name="collections_trakt_public_list">Liste Trakt publique</string>
<string name="collections_trakt_list_not_found_or_private">Liste Trakt introuvable ou non publique</string>
<string name="collections_trakt_rate_limit_reached">Limite de requêtes Trakt atteinte</string>
<string name="collections_trakt_error_with_code">%1$s (%2$d)</string>
<string name="details_comments_trakt_load_failed_with_code">Échec du chargement des commentaires Trakt (%1$d)</string>
<string name="updates_no_channel_release">Aucune mise à jour na encore été publiée.</string>
<string name="updates_github_api_error">Erreur API releases GitHub : %1$d</string>
<string name="updates_release_missing_title">La release na pas de tag ni de nom</string>
<string name="updates_apk_asset_missing">Aucun fichier APK trouvé dans la release</string>
<string name="updates_download_failed_http">Échec du téléchargement (HTTP %1$d)</string>
<string name="updates_empty_download_body">Corps de téléchargement vide</string>
<string name="updates_downloaded_file_missing">Le fichier de mise à jour téléchargé est introuvable.</string>
<string name="network_request_failed_http">Échec de la requête (HTTP %1$d)</string>
<string name="network_empty_response_body">Corps de réponse vide</string>
<string name="downloads_error_finalize_file_failed">Impossible de finaliser le fichier de téléchargement</string>
<string name="downloads_error_open_partial_file_failed">Impossible douvrir le fichier de téléchargement partiel</string>
<string name="downloads_error_partial_file_not_open">Le fichier de téléchargement partiel nest pas ouvert</string>
<string name="downloads_error_write_partial_file_failed">Impossible décrire dans le fichier de téléchargement partiel</string>
</resources> </resources>

View file

@ -1322,4 +1322,72 @@
<string name="unit_bytes_kb">KB</string> <string name="unit_bytes_kb">KB</string>
<string name="unit_bytes_mb">MB</string> <string name="unit_bytes_mb">MB</string>
<string name="unit_bytes_gb">GB</string> <string name="unit_bytes_gb">GB</string>
<string name="unit_bytes_tb">TB</string>
<string name="collections_editor_tmdb_genres_movie_placeholder">28,12</string>
<string name="collections_editor_tmdb_genres_series_placeholder">18,35</string>
<string name="collections_editor_tmdb_date_from_placeholder">2020-01-01</string>
<string name="collections_editor_tmdb_date_to_placeholder">2024-12-31</string>
<string name="collections_editor_tmdb_rating_min_placeholder">7.0</string>
<string name="collections_editor_tmdb_rating_max_placeholder">10</string>
<string name="collections_editor_tmdb_votes_min_placeholder">100</string>
<string name="collections_editor_tmdb_language_placeholder">en, ko, ja, hi</string>
<string name="collections_editor_tmdb_country_placeholder">US, KR, JP, IN</string>
<string name="collections_editor_tmdb_year_placeholder">2024</string>
<string name="collections_editor_trakt_list_id_format">ID %1$s</string>
<string name="player_addon_subtitle_display_format">%1$s (%2$s)</string>
<string name="trakt_connected_status">Connected to Trakt</string>
<string name="trakt_disconnected_status">Disconnected from Trakt</string>
<string name="collections_editor_trakt_enter_name_url_or_id">Enter a Trakt list name, URL, or ID</string>
<string name="collections_editor_trakt_enter_id_or_url">Enter a Trakt list ID or URL</string>
<string name="collections_editor_trakt_load_failed">Could not load Trakt list</string>
<string name="collections_editor_trakt_fallback_title">Trakt List %1$d</string>
<string name="collections_editor_trakt_resolved_subtitle">Resolved Trakt list</string>
<string name="collections_editor_trakt_no_lists_found">No Trakt lists found</string>
<string name="collections_editor_tmdb_invalid_id_or_url">Enter a valid TMDB ID or URL.</string>
<string name="collections_editor_tmdb_list_title_format">TMDB List %1$s</string>
<string name="collections_editor_tmdb_collection_title_format">TMDB Collection %1$s</string>
<string name="collections_editor_tmdb_production_title_format">TMDB Production %1$s</string>
<string name="collections_editor_tmdb_network_title_format">TMDB Network %1$s</string>
<string name="collections_editor_tmdb_person_title_format">TMDB Person %1$s</string>
<string name="collections_editor_tmdb_director_title_format">TMDB Director %1$s</string>
<string name="collections_editor_tmdb_discover_title">TMDB Discover</string>
<string name="collections_editor_tmdb_source_load_failed">Could not load TMDB source</string>
<string name="collections_folder_trakt_series_list">Trakt Series List</string>
<string name="collections_folder_trakt_movie_list">Trakt Movie List</string>
<string name="collections_tmdb_api_key_required">Add a TMDB API key in Settings to use TMDB sources.</string>
<string name="collections_tmdb_list_not_found">TMDB list not found</string>
<string name="collections_tmdb_collection_not_found">TMDB collection not found</string>
<string name="collections_tmdb_company_not_found">TMDB company not found</string>
<string name="collections_tmdb_network_not_found">TMDB network not found</string>
<string name="collections_tmdb_person_not_found">TMDB person not found</string>
<string name="collections_tmdb_missing_list_id">Missing TMDB list ID</string>
<string name="collections_tmdb_missing_collection_id">Missing TMDB collection ID</string>
<string name="collections_tmdb_missing_person_id">Missing TMDB person ID</string>
<string name="collections_tmdb_person_credits_not_found">TMDB person credits not found</string>
<string name="collections_tmdb_discover_no_data">TMDB discover returned no data</string>
<string name="collections_trakt_missing_list_id">Missing Trakt list ID</string>
<string name="collections_trakt_invalid_list_id_or_url">Enter a valid Trakt list ID or URL</string>
<string name="collections_trakt_missing_numeric_id">Trakt list did not include a numeric ID</string>
<string name="collections_trakt_request_failed">Trakt request failed</string>
<string name="collections_trakt_credentials_missing">Missing Trakt credentials.</string>
<string name="collections_trakt_list_items_count">%1$d items</string>
<string name="collections_trakt_list_likes_count">%1$d likes</string>
<string name="collections_trakt_public_list">Trakt public list</string>
<string name="collections_trakt_list_not_found_or_private">Trakt list not found or not public</string>
<string name="collections_trakt_rate_limit_reached">Trakt rate limit reached</string>
<string name="collections_trakt_error_with_code">%1$s (%2$d)</string>
<string name="details_comments_trakt_load_failed_with_code">Failed to load Trakt comments (%1$d)</string>
<string name="updates_no_channel_release">No update has been published yet.</string>
<string name="updates_github_api_error">GitHub releases API error: %1$d</string>
<string name="updates_release_missing_title">Release has no tag or name</string>
<string name="updates_apk_asset_missing">No APK asset found in the release</string>
<string name="updates_download_failed_http">Download failed with HTTP %1$d</string>
<string name="updates_empty_download_body">Empty download body</string>
<string name="updates_downloaded_file_missing">Downloaded update file is missing.</string>
<string name="network_request_failed_http">Request failed with HTTP %1$d</string>
<string name="network_empty_response_body">Empty response body</string>
<string name="downloads_error_finalize_file_failed">Failed to finalize download file</string>
<string name="downloads_error_open_partial_file_failed">Failed to open partial download file</string>
<string name="downloads_error_partial_file_not_open">Partial download file is not open</string>
<string name="downloads_error_write_partial_file_failed">Failed to write partial download file</string>
</resources> </resources>

View file

@ -11,6 +11,26 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.collections_editor_tmdb_collection_title_format
import nuvio.composeapp.generated.resources.collections_editor_tmdb_director_title_format
import nuvio.composeapp.generated.resources.collections_editor_tmdb_discover_title
import nuvio.composeapp.generated.resources.collections_editor_tmdb_invalid_id_or_url
import nuvio.composeapp.generated.resources.collections_editor_tmdb_list_title_format
import nuvio.composeapp.generated.resources.collections_editor_tmdb_network_title_format
import nuvio.composeapp.generated.resources.collections_editor_tmdb_person_title_format
import nuvio.composeapp.generated.resources.collections_editor_tmdb_production_title_format
import nuvio.composeapp.generated.resources.collections_editor_tmdb_source_load_failed
import nuvio.composeapp.generated.resources.collections_editor_trakt_enter_id_or_url
import nuvio.composeapp.generated.resources.collections_editor_trakt_enter_name_url_or_id
import nuvio.composeapp.generated.resources.collections_editor_trakt_fallback_title
import nuvio.composeapp.generated.resources.collections_editor_trakt_load_failed
import nuvio.composeapp.generated.resources.collections_editor_trakt_no_lists_found
import nuvio.composeapp.generated.resources.collections_editor_trakt_resolved_subtitle
import nuvio.composeapp.generated.resources.media_movies
import nuvio.composeapp.generated.resources.media_series
import org.jetbrains.compose.resources.getString
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@ -394,7 +414,9 @@ object CollectionEditorRepository {
val state = _uiState.value val state = _uiState.value
val query = state.traktInput.trim() val query = state.traktInput.trim()
if (query.isBlank()) { if (query.isBlank()) {
_uiState.value = state.copy(traktSearchError = "Enter a Trakt list name, URL, or ID") _uiState.value = state.copy(
traktSearchError = runBlocking { getString(Res.string.collections_editor_trakt_enter_name_url_or_id) },
)
return return
} }
@ -402,12 +424,13 @@ object CollectionEditorRepository {
val results = if (query.isTraktListIdentifierInput()) { val results = if (query.isTraktListIdentifierInput()) {
runCatching { runCatching {
val metadata = TraktPublicListSourceResolver.listImportMetadata(query) val metadata = TraktPublicListSourceResolver.listImportMetadata(query)
val id = metadata.traktListId ?: error("Could not load Trakt list") val id = metadata.traktListId
?: error(getString(Res.string.collections_editor_trakt_load_failed))
listOf( listOf(
TraktPublicListSearchResult( TraktPublicListSearchResult(
traktListId = id, traktListId = id,
title = metadata.title ?: "Trakt List $id", title = metadata.title ?: getString(Res.string.collections_editor_trakt_fallback_title, id),
subtitle = "Resolved Trakt list", subtitle = getString(Res.string.collections_editor_trakt_resolved_subtitle),
coverImageUrl = metadata.coverImageUrl, coverImageUrl = metadata.coverImageUrl,
), ),
) )
@ -419,7 +442,7 @@ object CollectionEditorRepository {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
traktSearchResults = mapped, traktSearchResults = mapped,
traktSearchError = results.exceptionOrNull()?.message traktSearchError = results.exceptionOrNull()?.message
?: if (mapped.isEmpty()) "No Trakt lists found" else null, ?: if (mapped.isEmpty()) getString(Res.string.collections_editor_trakt_no_lists_found) else null,
) )
} }
} }
@ -624,19 +647,24 @@ object CollectionEditorRepository {
} }
val id = TmdbCollectionSourceResolver.parseTmdbId(state.tmdbInput) val id = TmdbCollectionSourceResolver.parseTmdbId(state.tmdbInput)
if (sourceType != TmdbCollectionSourceType.DISCOVER && id == null) { if (sourceType != TmdbCollectionSourceType.DISCOVER && id == null) {
_uiState.value = state.copy(tmdbSearchError = "Enter a valid TMDB ID or URL.") _uiState.value = state.copy(
tmdbSearchError = runBlocking { getString(Res.string.collections_editor_tmdb_invalid_id_or_url) },
)
return return
} }
val mediaTypes = selectedMediaTypes(state, sourceType) val mediaTypes = selectedMediaTypes(state, sourceType)
val baseTitle = state.tmdbTitleInput.ifBlank { val baseTitle = state.tmdbTitleInput.ifBlank {
when (sourceType) { runBlocking {
TmdbCollectionSourceType.LIST -> "TMDB List ${id ?: ""}".trim() val idArg = id?.toString().orEmpty()
TmdbCollectionSourceType.COLLECTION -> "TMDB Collection ${id ?: ""}".trim() when (sourceType) {
TmdbCollectionSourceType.COMPANY -> "TMDB Production ${id ?: ""}".trim() TmdbCollectionSourceType.LIST -> getString(Res.string.collections_editor_tmdb_list_title_format, idArg).trim()
TmdbCollectionSourceType.NETWORK -> "TMDB Network ${id ?: ""}".trim() TmdbCollectionSourceType.COLLECTION -> getString(Res.string.collections_editor_tmdb_collection_title_format, idArg).trim()
TmdbCollectionSourceType.PERSON -> "TMDB Person ${id ?: ""}".trim() TmdbCollectionSourceType.COMPANY -> getString(Res.string.collections_editor_tmdb_production_title_format, idArg).trim()
TmdbCollectionSourceType.DIRECTOR -> "TMDB Director ${id ?: ""}".trim() TmdbCollectionSourceType.NETWORK -> getString(Res.string.collections_editor_tmdb_network_title_format, idArg).trim()
TmdbCollectionSourceType.DISCOVER -> "TMDB Discover" TmdbCollectionSourceType.PERSON -> getString(Res.string.collections_editor_tmdb_person_title_format, idArg).trim()
TmdbCollectionSourceType.DIRECTOR -> getString(Res.string.collections_editor_tmdb_director_title_format, idArg).trim()
TmdbCollectionSourceType.DISCOVER -> getString(Res.string.collections_editor_tmdb_discover_title)
}
} }
} }
val sources = mediaTypes.map { mediaType -> val sources = mediaTypes.map { mediaType ->
@ -656,7 +684,8 @@ object CollectionEditorRepository {
val resolved = metadata.getOrNull() val resolved = metadata.getOrNull()
if (metadata.isFailure) { if (metadata.isFailure) {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
tmdbSearchError = metadata.exceptionOrNull()?.message ?: "Could not load TMDB source", tmdbSearchError = metadata.exceptionOrNull()?.message
?: getString(Res.string.collections_editor_tmdb_source_load_failed),
) )
return@launch return@launch
} }
@ -701,7 +730,9 @@ object CollectionEditorRepository {
val state = _uiState.value val state = _uiState.value
val input = state.traktInput.trim() val input = state.traktInput.trim()
if (input.isBlank()) { if (input.isBlank()) {
_uiState.value = state.copy(traktSearchError = "Enter a Trakt list ID or URL") _uiState.value = state.copy(
traktSearchError = runBlocking { getString(Res.string.collections_editor_trakt_enter_id_or_url) },
)
return return
} }
@ -711,12 +742,15 @@ object CollectionEditorRepository {
val listId = resolved?.traktListId val listId = resolved?.traktListId
if (metadata.isFailure || listId == null) { if (metadata.isFailure || listId == null) {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
traktSearchError = metadata.exceptionOrNull()?.message ?: "Could not load Trakt list", traktSearchError = metadata.exceptionOrNull()?.message
?: getString(Res.string.collections_editor_trakt_load_failed),
) )
return@launch return@launch
} }
val title = state.traktTitleInput.ifBlank { resolved.title ?: "Trakt List $listId" } val title = state.traktTitleInput.ifBlank {
resolved.title ?: getString(Res.string.collections_editor_trakt_fallback_title, listId)
}
addTraktSourcesToFolder( addTraktSourcesToFolder(
sources = selectedTraktMediaTypes(state).map { mediaType -> sources = selectedTraktMediaTypes(state).map { mediaType ->
CollectionSource( CollectionSource(
@ -881,9 +915,13 @@ private fun titleForMedia(
addSuffix: Boolean, addSuffix: Boolean,
): String { ): String {
if (!addSuffix) return title if (!addSuffix) return title
val suffix = when (mediaType) { val suffix = runBlocking {
TmdbCollectionMediaType.MOVIE -> "Movies" getString(
TmdbCollectionMediaType.TV -> "Series" when (mediaType) {
TmdbCollectionMediaType.MOVIE -> Res.string.media_movies
TmdbCollectionMediaType.TV -> Res.string.media_series
},
)
} }
return "$title $suffix" return "$title $suffix"
} }

View file

@ -1162,7 +1162,11 @@ private fun TmdbSourcePickerScreen(
label = stringResource(Res.string.collections_editor_tmdb_genres), label = stringResource(Res.string.collections_editor_tmdb_genres),
helper = stringResource(Res.string.collections_editor_tmdb_genres_helper), helper = stringResource(Res.string.collections_editor_tmdb_genres_helper),
value = state.tmdbFilters.withGenres.orEmpty(), value = state.tmdbFilters.withGenres.orEmpty(),
placeholder = if (state.tmdbMediaType == TmdbCollectionMediaType.MOVIE) "28,12" else "18,35", placeholder = if (state.tmdbMediaType == TmdbCollectionMediaType.MOVIE) {
stringResource(Res.string.collections_editor_tmdb_genres_movie_placeholder)
} else {
stringResource(Res.string.collections_editor_tmdb_genres_series_placeholder)
},
onValueChange = { value -> onValueChange = { value ->
CollectionEditorRepository.updateTmdbFilters { CollectionEditorRepository.updateTmdbFilters {
it.copy(withGenres = value.ifBlank { null }) it.copy(withGenres = value.ifBlank { null })
@ -1173,7 +1177,7 @@ private fun TmdbSourcePickerScreen(
label = stringResource(Res.string.collections_editor_tmdb_date_from), label = stringResource(Res.string.collections_editor_tmdb_date_from),
helper = stringResource(Res.string.collections_editor_tmdb_date_helper), helper = stringResource(Res.string.collections_editor_tmdb_date_helper),
value = state.tmdbFilters.releaseDateGte.orEmpty(), value = state.tmdbFilters.releaseDateGte.orEmpty(),
placeholder = "2020-01-01", placeholder = stringResource(Res.string.collections_editor_tmdb_date_from_placeholder),
onValueChange = { value -> onValueChange = { value ->
CollectionEditorRepository.updateTmdbFilters { CollectionEditorRepository.updateTmdbFilters {
it.copy(releaseDateGte = value.ifBlank { null }) it.copy(releaseDateGte = value.ifBlank { null })
@ -1184,7 +1188,7 @@ private fun TmdbSourcePickerScreen(
label = stringResource(Res.string.collections_editor_tmdb_date_to), label = stringResource(Res.string.collections_editor_tmdb_date_to),
helper = stringResource(Res.string.collections_editor_tmdb_date_helper), helper = stringResource(Res.string.collections_editor_tmdb_date_helper),
value = state.tmdbFilters.releaseDateLte.orEmpty(), value = state.tmdbFilters.releaseDateLte.orEmpty(),
placeholder = "2024-12-31", placeholder = stringResource(Res.string.collections_editor_tmdb_date_to_placeholder),
onValueChange = { value -> onValueChange = { value ->
CollectionEditorRepository.updateTmdbFilters { CollectionEditorRepository.updateTmdbFilters {
it.copy(releaseDateLte = value.ifBlank { null }) it.copy(releaseDateLte = value.ifBlank { null })
@ -1195,7 +1199,7 @@ private fun TmdbSourcePickerScreen(
label = stringResource(Res.string.collections_editor_tmdb_rating_min), label = stringResource(Res.string.collections_editor_tmdb_rating_min),
helper = stringResource(Res.string.collections_editor_tmdb_rating_helper), helper = stringResource(Res.string.collections_editor_tmdb_rating_helper),
value = state.tmdbFilters.voteAverageGte?.toString().orEmpty(), value = state.tmdbFilters.voteAverageGte?.toString().orEmpty(),
placeholder = "7.0", placeholder = stringResource(Res.string.collections_editor_tmdb_rating_min_placeholder),
onValueChange = { value -> onValueChange = { value ->
CollectionEditorRepository.updateTmdbFilters { CollectionEditorRepository.updateTmdbFilters {
it.copy(voteAverageGte = value.toDoubleOrNull()) it.copy(voteAverageGte = value.toDoubleOrNull())
@ -1206,7 +1210,7 @@ private fun TmdbSourcePickerScreen(
label = stringResource(Res.string.collections_editor_tmdb_rating_max), label = stringResource(Res.string.collections_editor_tmdb_rating_max),
helper = stringResource(Res.string.collections_editor_tmdb_rating_helper), helper = stringResource(Res.string.collections_editor_tmdb_rating_helper),
value = state.tmdbFilters.voteAverageLte?.toString().orEmpty(), value = state.tmdbFilters.voteAverageLte?.toString().orEmpty(),
placeholder = "10", placeholder = stringResource(Res.string.collections_editor_tmdb_rating_max_placeholder),
onValueChange = { value -> onValueChange = { value ->
CollectionEditorRepository.updateTmdbFilters { CollectionEditorRepository.updateTmdbFilters {
it.copy(voteAverageLte = value.toDoubleOrNull()) it.copy(voteAverageLte = value.toDoubleOrNull())
@ -1217,7 +1221,7 @@ private fun TmdbSourcePickerScreen(
label = stringResource(Res.string.collections_editor_tmdb_votes_min), label = stringResource(Res.string.collections_editor_tmdb_votes_min),
helper = stringResource(Res.string.collections_editor_tmdb_votes_helper), helper = stringResource(Res.string.collections_editor_tmdb_votes_helper),
value = state.tmdbFilters.voteCountGte?.toString().orEmpty(), value = state.tmdbFilters.voteCountGte?.toString().orEmpty(),
placeholder = "100", placeholder = stringResource(Res.string.collections_editor_tmdb_votes_min_placeholder),
onValueChange = { value -> onValueChange = { value ->
CollectionEditorRepository.updateTmdbFilters { CollectionEditorRepository.updateTmdbFilters {
it.copy(voteCountGte = value.toIntOrNull()) it.copy(voteCountGte = value.toIntOrNull())
@ -1241,7 +1245,7 @@ private fun TmdbSourcePickerScreen(
label = stringResource(Res.string.collections_editor_tmdb_language), label = stringResource(Res.string.collections_editor_tmdb_language),
helper = stringResource(Res.string.collections_editor_tmdb_language_helper), helper = stringResource(Res.string.collections_editor_tmdb_language_helper),
value = state.tmdbFilters.withOriginalLanguage.orEmpty(), value = state.tmdbFilters.withOriginalLanguage.orEmpty(),
placeholder = "en, ko, ja, hi", placeholder = stringResource(Res.string.collections_editor_tmdb_language_placeholder),
onValueChange = { value -> onValueChange = { value ->
CollectionEditorRepository.updateTmdbFilters { CollectionEditorRepository.updateTmdbFilters {
it.copy(withOriginalLanguage = value.ifBlank { null }) it.copy(withOriginalLanguage = value.ifBlank { null })
@ -1265,7 +1269,7 @@ private fun TmdbSourcePickerScreen(
label = stringResource(Res.string.collections_editor_tmdb_country), label = stringResource(Res.string.collections_editor_tmdb_country),
helper = stringResource(Res.string.collections_editor_tmdb_country_helper), helper = stringResource(Res.string.collections_editor_tmdb_country_helper),
value = state.tmdbFilters.withOriginCountry.orEmpty(), value = state.tmdbFilters.withOriginCountry.orEmpty(),
placeholder = "US, KR, JP, IN", placeholder = stringResource(Res.string.collections_editor_tmdb_country_placeholder),
onValueChange = { value -> onValueChange = { value ->
CollectionEditorRepository.updateTmdbFilters { CollectionEditorRepository.updateTmdbFilters {
it.copy(withOriginCountry = value.ifBlank { null }) it.copy(withOriginCountry = value.ifBlank { null })
@ -1347,7 +1351,7 @@ private fun TmdbSourcePickerScreen(
label = stringResource(Res.string.collections_editor_tmdb_year), label = stringResource(Res.string.collections_editor_tmdb_year),
helper = stringResource(Res.string.collections_editor_tmdb_year_helper), helper = stringResource(Res.string.collections_editor_tmdb_year_helper),
value = state.tmdbFilters.year?.toString().orEmpty(), value = state.tmdbFilters.year?.toString().orEmpty(),
placeholder = "2024", placeholder = stringResource(Res.string.collections_editor_tmdb_year_placeholder),
onValueChange = { value -> onValueChange = { value ->
CollectionEditorRepository.updateTmdbFilters { CollectionEditorRepository.updateTmdbFilters {
it.copy(year = value.toIntOrNull()) it.copy(year = value.toIntOrNull())
@ -2352,7 +2356,7 @@ private fun traktSourceSubtitle(source: CollectionSource): String {
media, media,
traktSortLabel(source.sortBy), traktSortLabel(source.sortBy),
traktDirectionLabel(source.sortHow), traktDirectionLabel(source.sortHow),
"ID ${source.traktListId ?: ""}".trim(), stringResource(Res.string.collections_editor_trakt_list_id_format, source.traktListId ?: ""),
).joinToString("") ).joinToString("")
} }

View file

@ -26,6 +26,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.collections_folder_addon_not_found import nuvio.composeapp.generated.resources.collections_folder_addon_not_found
import nuvio.composeapp.generated.resources.collections_folder_trakt_movie_list
import nuvio.composeapp.generated.resources.collections_folder_trakt_series_list
import nuvio.composeapp.generated.resources.collections_tab_all import nuvio.composeapp.generated.resources.collections_tab_all
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
@ -156,10 +158,14 @@ object FolderDetailRepository {
} else if (source.isTrakt) { } else if (source.isTrakt) {
val mediaType = TmdbCollectionMediaType.fromString(source.mediaType) val mediaType = TmdbCollectionMediaType.fromString(source.mediaType)
val type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie" val type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie"
val typeLabel = if (mediaType == TmdbCollectionMediaType.TV) { val typeLabel = runBlocking {
"Trakt Series List" getString(
} else { if (mediaType == TmdbCollectionMediaType.TV) {
"Trakt Movie List" Res.string.collections_folder_trakt_series_list
} else {
Res.string.collections_folder_trakt_movie_list
},
)
} }
add( add(
FolderTab( FolderTab(

View file

@ -13,6 +13,19 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.collections_tmdb_api_key_required
import nuvio.composeapp.generated.resources.collections_tmdb_collection_not_found
import nuvio.composeapp.generated.resources.collections_tmdb_company_not_found
import nuvio.composeapp.generated.resources.collections_tmdb_discover_no_data
import nuvio.composeapp.generated.resources.collections_tmdb_list_not_found
import nuvio.composeapp.generated.resources.collections_tmdb_missing_collection_id
import nuvio.composeapp.generated.resources.collections_tmdb_missing_list_id
import nuvio.composeapp.generated.resources.collections_tmdb_missing_person_id
import nuvio.composeapp.generated.resources.collections_tmdb_network_not_found
import nuvio.composeapp.generated.resources.collections_tmdb_person_credits_not_found
import nuvio.composeapp.generated.resources.collections_tmdb_person_not_found
import org.jetbrains.compose.resources.getString
import kotlin.math.roundToInt import kotlin.math.roundToInt
object TmdbCollectionSourceResolver { object TmdbCollectionSourceResolver {
@ -22,7 +35,7 @@ object TmdbCollectionSourceResolver {
suspend fun resolve(source: CollectionSource, page: Int = 1): CatalogPage = withContext(Dispatchers.Default) { suspend fun resolve(source: CollectionSource, page: Int = 1): CatalogPage = withContext(Dispatchers.Default) {
val settings = TmdbSettingsRepository.snapshot() val settings = TmdbSettingsRepository.snapshot()
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() } val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
?: error("Add a TMDB API key in Settings to use TMDB sources.") ?: error(getString(Res.string.collections_tmdb_api_key_required))
val language = normalizeTmdbLanguage(settings.language) val language = normalizeTmdbLanguage(settings.language)
val sourceType = source.tmdbType() val sourceType = source.tmdbType()
@ -41,7 +54,7 @@ object TmdbCollectionSourceResolver {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val settings = TmdbSettingsRepository.snapshot() val settings = TmdbSettingsRepository.snapshot()
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() } val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
?: error("Add a TMDB API key in Settings to use TMDB sources.") ?: error(getString(Res.string.collections_tmdb_api_key_required))
val language = normalizeTmdbLanguage(settings.language) val language = normalizeTmdbLanguage(settings.language)
when (sourceType) { when (sourceType) {
TmdbCollectionSourceType.LIST -> { TmdbCollectionSourceType.LIST -> {
@ -49,7 +62,7 @@ object TmdbCollectionSourceResolver {
endpoint = "list/$id", endpoint = "list/$id",
apiKey = apiKey, apiKey = apiKey,
query = mapOf("language" to language, "page" to "1"), query = mapOf("language" to language, "page" to "1"),
) ?: error("TMDB list not found") ) ?: error(getString(Res.string.collections_tmdb_list_not_found))
TmdbSourceImportMetadata(title = body.name?.takeIf { it.isNotBlank() }) TmdbSourceImportMetadata(title = body.name?.takeIf { it.isNotBlank() })
} }
@ -58,7 +71,7 @@ object TmdbCollectionSourceResolver {
endpoint = "collection/$id", endpoint = "collection/$id",
apiKey = apiKey, apiKey = apiKey,
query = mapOf("language" to language), query = mapOf("language" to language),
) ?: error("TMDB collection not found") ) ?: error(getString(Res.string.collections_tmdb_collection_not_found))
TmdbSourceImportMetadata( TmdbSourceImportMetadata(
title = body.name?.takeIf { it.isNotBlank() }, title = body.name?.takeIf { it.isNotBlank() },
coverImageUrl = imageUrl(body.posterPath, "w500") ?: imageUrl(body.backdropPath, "w1280"), coverImageUrl = imageUrl(body.posterPath, "w500") ?: imageUrl(body.backdropPath, "w1280"),
@ -69,7 +82,7 @@ object TmdbCollectionSourceResolver {
val body = fetch<TmdbCompanyResponse>( val body = fetch<TmdbCompanyResponse>(
endpoint = "company/$id", endpoint = "company/$id",
apiKey = apiKey, apiKey = apiKey,
) ?: error("TMDB company not found") ) ?: error(getString(Res.string.collections_tmdb_company_not_found))
TmdbSourceImportMetadata( TmdbSourceImportMetadata(
title = body.name?.takeIf { it.isNotBlank() }, title = body.name?.takeIf { it.isNotBlank() },
coverImageUrl = imageUrl(body.logoPath, "w500"), coverImageUrl = imageUrl(body.logoPath, "w500"),
@ -80,7 +93,7 @@ object TmdbCollectionSourceResolver {
val body = fetch<TmdbNetworkResponse>( val body = fetch<TmdbNetworkResponse>(
endpoint = "network/$id", endpoint = "network/$id",
apiKey = apiKey, apiKey = apiKey,
) ?: error("TMDB network not found") ) ?: error(getString(Res.string.collections_tmdb_network_not_found))
TmdbSourceImportMetadata( TmdbSourceImportMetadata(
title = body.name?.takeIf { it.isNotBlank() }, title = body.name?.takeIf { it.isNotBlank() },
coverImageUrl = imageUrl(body.logoPath, "w500"), coverImageUrl = imageUrl(body.logoPath, "w500"),
@ -93,7 +106,7 @@ object TmdbCollectionSourceResolver {
endpoint = "person/$id", endpoint = "person/$id",
apiKey = apiKey, apiKey = apiKey,
query = mapOf("language" to language), query = mapOf("language" to language),
) ?: error("TMDB person not found") ) ?: error(getString(Res.string.collections_tmdb_person_not_found))
TmdbSourceImportMetadata( TmdbSourceImportMetadata(
title = body.name?.takeIf { it.isNotBlank() }, title = body.name?.takeIf { it.isNotBlank() },
coverImageUrl = imageUrl(body.profilePath, "w500"), coverImageUrl = imageUrl(body.profilePath, "w500"),
@ -109,7 +122,7 @@ object TmdbCollectionSourceResolver {
if (trimmed.isBlank()) return@withContext emptyList() if (trimmed.isBlank()) return@withContext emptyList()
val settings = TmdbSettingsRepository.snapshot() val settings = TmdbSettingsRepository.snapshot()
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() } val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
?: error("Add a TMDB API key in Settings to use TMDB sources.") ?: error(getString(Res.string.collections_tmdb_api_key_required))
fetch<TmdbCompanySearchResponse>( fetch<TmdbCompanySearchResponse>(
endpoint = "search/company", endpoint = "search/company",
apiKey = apiKey, apiKey = apiKey,
@ -122,7 +135,7 @@ object TmdbCollectionSourceResolver {
if (trimmed.isBlank()) return@withContext emptyList() if (trimmed.isBlank()) return@withContext emptyList()
val settings = TmdbSettingsRepository.snapshot() val settings = TmdbSettingsRepository.snapshot()
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() } val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
?: error("Add a TMDB API key in Settings to use TMDB sources.") ?: error(getString(Res.string.collections_tmdb_api_key_required))
val language = normalizeTmdbLanguage(settings.language) val language = normalizeTmdbLanguage(settings.language)
fetch<TmdbCollectionSearchResponse>( fetch<TmdbCollectionSearchResponse>(
endpoint = "search/collection", endpoint = "search/collection",
@ -136,7 +149,7 @@ object TmdbCollectionSourceResolver {
if (trimmed.isBlank()) return@withContext emptyMap() if (trimmed.isBlank()) return@withContext emptyMap()
val settings = TmdbSettingsRepository.snapshot() val settings = TmdbSettingsRepository.snapshot()
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() } val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
?: error("Add a TMDB API key in Settings to use TMDB sources.") ?: error(getString(Res.string.collections_tmdb_api_key_required))
fetch<TmdbKeywordSearchResponse>( fetch<TmdbKeywordSearchResponse>(
endpoint = "search/keyword", endpoint = "search/keyword",
apiKey = apiKey, apiKey = apiKey,
@ -152,7 +165,7 @@ object TmdbCollectionSourceResolver {
suspend fun genres(mediaType: TmdbCollectionMediaType): Map<Int, String> = withContext(Dispatchers.Default) { suspend fun genres(mediaType: TmdbCollectionMediaType): Map<Int, String> = withContext(Dispatchers.Default) {
val settings = TmdbSettingsRepository.snapshot() val settings = TmdbSettingsRepository.snapshot()
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() } val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
?: error("Add a TMDB API key in Settings to use TMDB sources.") ?: error(getString(Res.string.collections_tmdb_api_key_required))
val language = normalizeTmdbLanguage(settings.language) val language = normalizeTmdbLanguage(settings.language)
val endpoint = when (mediaType) { val endpoint = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> "genre/movie/list" TmdbCollectionMediaType.MOVIE -> "genre/movie/list"
@ -200,12 +213,12 @@ object TmdbCollectionSourceResolver {
language: String, language: String,
page: Int, page: Int,
): CatalogPage { ): CatalogPage {
val id = source.tmdbId ?: error("Missing TMDB list ID") val id = source.tmdbId ?: error(getString(Res.string.collections_tmdb_missing_list_id))
val body = fetch<TmdbListResponse>( val body = fetch<TmdbListResponse>(
endpoint = "list/$id", endpoint = "list/$id",
apiKey = apiKey, apiKey = apiKey,
query = mapOf("language" to language, "page" to page.toString()), query = mapOf("language" to language, "page" to page.toString()),
) ?: error("TMDB list not found") ) ?: error(getString(Res.string.collections_tmdb_list_not_found))
val items = body.items.orEmpty() val items = body.items.orEmpty()
.mapNotNull { it.toPreview() } .mapNotNull { it.toPreview() }
.sortedFor(source.sortBy) .sortedFor(source.sortBy)
@ -222,12 +235,12 @@ object TmdbCollectionSourceResolver {
apiKey: String, apiKey: String,
language: String, language: String,
): CatalogPage { ): CatalogPage {
val id = source.tmdbId ?: error("Missing TMDB collection ID") val id = source.tmdbId ?: error(getString(Res.string.collections_tmdb_missing_collection_id))
val body = fetch<TmdbCollectionResponse>( val body = fetch<TmdbCollectionResponse>(
endpoint = "collection/$id", endpoint = "collection/$id",
apiKey = apiKey, apiKey = apiKey,
query = mapOf("language" to language), query = mapOf("language" to language),
) ?: error("TMDB collection not found") ) ?: error(getString(Res.string.collections_tmdb_collection_not_found))
val items = body.parts.orEmpty() val items = body.parts.orEmpty()
.mapNotNull { it.toPreview(TmdbCollectionMediaType.MOVIE) } .mapNotNull { it.toPreview(TmdbCollectionMediaType.MOVIE) }
.sortedFor(source.sortBy) .sortedFor(source.sortBy)
@ -240,13 +253,13 @@ object TmdbCollectionSourceResolver {
apiKey: String, apiKey: String,
language: String, language: String,
): CatalogPage { ): CatalogPage {
val id = source.tmdbId ?: error("Missing TMDB person ID") val id = source.tmdbId ?: error(getString(Res.string.collections_tmdb_missing_person_id))
val mediaType = source.tmdbMediaType() val mediaType = source.tmdbMediaType()
val body = fetch<TmdbPersonCreditsResponse>( val body = fetch<TmdbPersonCreditsResponse>(
endpoint = "person/$id/combined_credits", endpoint = "person/$id/combined_credits",
apiKey = apiKey, apiKey = apiKey,
query = mapOf("language" to language), query = mapOf("language" to language),
) ?: error("TMDB person credits not found") ) ?: error(getString(Res.string.collections_tmdb_person_credits_not_found))
val items = when (source.tmdbType()) { val items = when (source.tmdbType()) {
TmdbCollectionSourceType.DIRECTOR -> body.crew.orEmpty() TmdbCollectionSourceType.DIRECTOR -> body.crew.orEmpty()
.filter { it.job.equals("Director", ignoreCase = true) } .filter { it.job.equals("Director", ignoreCase = true) }
@ -287,7 +300,7 @@ object TmdbCollectionSourceResolver {
endpoint = endpoint, endpoint = endpoint,
apiKey = apiKey, apiKey = apiKey,
query = query, query = query,
) ?: error("TMDB discover returned no data") ) ?: error(getString(Res.string.collections_tmdb_discover_no_data))
val items = body.results.orEmpty() val items = body.results.orEmpty()
.mapNotNull { it.toPreview(mediaType) } .mapNotNull { it.toPreview(mediaType) }
.distinctBy { it.id } .distinctBy { it.id }

View file

@ -84,7 +84,11 @@ object SubtitleRepository {
id = id, id = id,
url = url, url = url,
language = normalizedLang, language = normalizedLang,
display = "${getLanguageLabelForCode(rawLang)} (${addon.displayTitle})", display = getString(
Res.string.player_addon_subtitle_display_format,
getLanguageLabelForCode(rawLang),
addon.displayTitle,
),
) )
) )
} }

View file

@ -283,7 +283,7 @@ object TraktAuthRepository {
refreshUserSettings() refreshUserSettings()
publish( publish(
isLoading = false, isLoading = false,
statusMessage = "Connected to Trakt", statusMessage = localizedString(Res.string.trakt_connected_status),
errorMessage = null, errorMessage = null,
) )
} }
@ -316,7 +316,7 @@ object TraktAuthRepository {
persist() persist()
publish( publish(
isLoading = false, isLoading = false,
statusMessage = "Disconnected from Trakt", statusMessage = localizedString(Res.string.trakt_disconnected_status),
errorMessage = null, errorMessage = null,
) )
} }

View file

@ -90,7 +90,9 @@ object TraktCommentsRepository {
return TraktCommentsPage(emptyList(), page, 0, 0) return TraktCommentsPage(emptyList(), page, 0, 0)
} }
if (response.status !in 200..299) { if (response.status !in 200..299) {
throw IllegalStateException("Failed to load Trakt comments (${response.status})") throw IllegalStateException(
getString(Res.string.details_comments_trakt_load_failed_with_code, response.status),
)
} }
val dtos = commentsJson.decodeFromString<List<TraktCommentDto>>(response.body) val dtos = commentsJson.decodeFromString<List<TraktCommentDto>>(response.body)

View file

@ -12,11 +12,27 @@ import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.PosterShape
import io.ktor.http.encodeURLParameter import io.ktor.http.encodeURLParameter
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.collections_editor_trakt_fallback_title
import nuvio.composeapp.generated.resources.collections_trakt_credentials_missing
import nuvio.composeapp.generated.resources.collections_trakt_error_with_code
import nuvio.composeapp.generated.resources.collections_trakt_invalid_list_id_or_url
import nuvio.composeapp.generated.resources.collections_trakt_list_items_count
import nuvio.composeapp.generated.resources.collections_trakt_list_likes_count
import nuvio.composeapp.generated.resources.collections_trakt_list_not_found_or_private
import nuvio.composeapp.generated.resources.collections_editor_trakt_load_failed
import nuvio.composeapp.generated.resources.collections_trakt_missing_list_id
import nuvio.composeapp.generated.resources.collections_trakt_missing_numeric_id
import nuvio.composeapp.generated.resources.collections_trakt_public_list
import nuvio.composeapp.generated.resources.collections_trakt_rate_limit_reached
import nuvio.composeapp.generated.resources.collections_trakt_request_failed
import org.jetbrains.compose.resources.getString
import kotlin.math.roundToInt import kotlin.math.roundToInt
data class TraktPublicListImportMetadata( data class TraktPublicListImportMetadata(
@ -44,7 +60,7 @@ object TraktPublicListSourceResolver {
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
suspend fun resolve(source: CollectionSource, page: Int = 1): CatalogPage = withContext(Dispatchers.Default) { suspend fun resolve(source: CollectionSource, page: Int = 1): CatalogPage = withContext(Dispatchers.Default) {
val listId = source.traktListId?.takeIf { it > 0L } ?: error("Missing Trakt list ID") val listId = source.traktListId?.takeIf { it > 0L } ?: error(getString(Res.string.collections_trakt_missing_list_id))
val mediaType = TmdbCollectionMediaType.fromString(source.mediaType) val mediaType = TmdbCollectionMediaType.fromString(source.mediaType)
val type = mediaType.toTraktType() val type = mediaType.toTraktType()
val sortBy = TraktListSort.normalize(source.sortBy) val sortBy = TraktListSort.normalize(source.sortBy)
@ -60,7 +76,7 @@ object TraktPublicListSourceResolver {
), ),
) )
if (response.status !in 200..299) { if (response.status !in 200..299) {
error(errorMessageFor(response.status, "Could not load Trakt list")) error(errorMessageFor(response.status, getString(Res.string.collections_editor_trakt_load_failed)))
} }
val rawItems = json.decodeFromString<List<PublicTraktListItemDto>>(response.body) val rawItems = json.decodeFromString<List<PublicTraktListItemDto>>(response.body)
@ -76,12 +92,12 @@ object TraktPublicListSourceResolver {
} }
suspend fun listImportMetadata(input: String): TraktPublicListImportMetadata = withContext(Dispatchers.Default) { suspend fun listImportMetadata(input: String): TraktPublicListImportMetadata = withContext(Dispatchers.Default) {
val idPath = parseTraktListPath(input) ?: error("Enter a valid Trakt list ID or URL") val idPath = parseTraktListPath(input) ?: error(getString(Res.string.collections_trakt_invalid_list_id_or_url))
val list = requestJson<PublicTraktListSummaryDto>( val list = requestJson<PublicTraktListSummaryDto>(
endpoint = "lists/$idPath", endpoint = "lists/$idPath",
query = mapOf("extended" to "full,images"), query = mapOf("extended" to "full,images"),
) )
val id = list.ids?.trakt ?: idPath.toLongOrNull() ?: error("Trakt list did not include a numeric ID") val id = list.ids?.trakt ?: idPath.toLongOrNull() ?: error(getString(Res.string.collections_trakt_missing_numeric_id))
TraktPublicListImportMetadata( TraktPublicListImportMetadata(
title = list.name?.takeIf { it.isNotBlank() }, title = list.name?.takeIf { it.isNotBlank() },
coverImageUrl = list.images?.posters.firstTraktImageUrl(), coverImageUrl = list.images?.posters.firstTraktImageUrl(),
@ -132,7 +148,7 @@ object TraktPublicListSourceResolver {
): T { ): T {
val response = requestRaw(endpoint = endpoint, query = query) val response = requestRaw(endpoint = endpoint, query = query)
if (response.status !in 200..299) { if (response.status !in 200..299) {
error(errorMessageFor(response.status, "Trakt request failed")) error(errorMessageFor(response.status, getString(Res.string.collections_trakt_request_failed)))
} }
return runCatching { json.decodeFromString<T>(response.body) } return runCatching { json.decodeFromString<T>(response.body) }
.onFailure { error -> log.w(error) { "Failed to parse Trakt response for $endpoint" } } .onFailure { error -> log.w(error) { "Failed to parse Trakt response for $endpoint" } }
@ -144,7 +160,7 @@ object TraktPublicListSourceResolver {
query: Map<String, String> = emptyMap(), query: Map<String, String> = emptyMap(),
): RawHttpResponse { ): RawHttpResponse {
if (TraktConfig.CLIENT_ID.isBlank()) { if (TraktConfig.CLIENT_ID.isBlank()) {
error("Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID).") error(getString(Res.string.collections_trakt_credentials_missing))
} }
val url = buildTraktUrl(endpoint, query) val url = buildTraktUrl(endpoint, query)
return httpRequestRaw( return httpRequestRaw(
@ -237,21 +253,25 @@ object TraktPublicListSourceResolver {
private fun PublicTraktListSummaryDto.toPublicListResult(likeCount: Int? = null): TraktPublicListSearchResult? { private fun PublicTraktListSummaryDto.toPublicListResult(likeCount: Int? = null): TraktPublicListSearchResult? {
val id = ids?.trakt ?: return null val id = ids?.trakt ?: return null
val listTitle = name?.takeIf { it.isNotBlank() } ?: "Trakt List $id" return runBlocking {
val owner = user?.username?.takeIf { it.isNotBlank() } val listTitle = name?.takeIf { it.isNotBlank() }
val stats = buildList { ?: getString(Res.string.collections_editor_trakt_fallback_title, id)
itemCount?.let { add("$it items") } val owner = user?.username?.takeIf { it.isNotBlank() }
(likeCount ?: likes)?.let { add("$it likes") } val stats = buildList {
itemCount?.let { add(getString(Res.string.collections_trakt_list_items_count, it)) }
(likeCount ?: likes)?.let { add(getString(Res.string.collections_trakt_list_likes_count, it)) }
}
val subtitle = (listOfNotNull(owner) + stats).joinToString("")
.ifBlank { getString(Res.string.collections_trakt_public_list) }
TraktPublicListSearchResult(
traktListId = id,
title = listTitle,
subtitle = subtitle,
coverImageUrl = images?.posters.firstTraktImageUrl(),
sortBy = sortBy,
sortHow = sortHow,
)
} }
val subtitle = (listOfNotNull(owner) + stats).joinToString("").ifBlank { "Trakt public list" }
return TraktPublicListSearchResult(
traktListId = id,
title = listTitle,
subtitle = subtitle,
coverImageUrl = images?.posters.firstTraktImageUrl(),
sortBy = sortBy,
sortHow = sortHow,
)
} }
private fun parseTraktListPath(input: String): String? { private fun parseTraktListPath(input: String): String? {
@ -292,11 +312,11 @@ object TraktPublicListSourceResolver {
?.trim() ?.trim()
?.toIntOrNull() ?.toIntOrNull()
private fun errorMessageFor(code: Int, fallback: String): String { private fun errorMessageFor(code: Int, fallback: String): String = runBlocking {
return when (code) { when (code) {
401, 403, 404 -> "Trakt list not found or not public" 401, 403, 404 -> getString(Res.string.collections_trakt_list_not_found_or_private)
429 -> "Trakt rate limit reached" 429 -> getString(Res.string.collections_trakt_rate_limit_reached)
else -> "$fallback ($code)" else -> getString(Res.string.collections_trakt_error_with_code, fallback, code)
} }
} }
} }

View file

@ -49,6 +49,7 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@ -106,7 +107,7 @@ private val appUpdaterJson = Json {
} }
private class NoChannelReleaseException : IllegalStateException( private class NoChannelReleaseException : IllegalStateException(
"No cmp-rewrite release has been published yet.", runBlocking { getString(Res.string.updates_no_channel_release) },
) )
private object VersionUtils { private object VersionUtils {
@ -158,7 +159,7 @@ private object AppUpdaterRepository {
body = "", body = "",
) )
if (response.status !in 200..299) { if (response.status !in 200..299) {
error("GitHub releases API error: ${response.status}") error(getString(Res.string.updates_github_api_error, response.status))
} }
val releases = appUpdaterJson.decodeFromString<List<GitHubReleaseDto>>(response.body) val releases = appUpdaterJson.decodeFromString<List<GitHubReleaseDto>>(response.body)
@ -167,10 +168,10 @@ private object AppUpdaterRepository {
val tag = release.tagName?.takeIf { it.isNotBlank() } val tag = release.tagName?.takeIf { it.isNotBlank() }
?: release.name?.takeIf { it.isNotBlank() } ?: release.name?.takeIf { it.isNotBlank() }
?: error("Release has no tag or name") ?: error(getString(Res.string.updates_release_missing_title))
val asset = chooseBestApkAsset(release.assets) val asset = chooseBestApkAsset(release.assets)
?: error("No APK asset found in the cmp-rewrite release") ?: error(getString(Res.string.updates_apk_asset_missing))
AppUpdate( AppUpdate(
tag = tag, tag = tag,

View file

@ -15,6 +15,11 @@ import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod import io.ktor.http.HttpMethod
import io.ktor.http.isSuccess import io.ktor.http.isSuccess
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.network_empty_response_body
import nuvio.composeapp.generated.resources.network_request_failed_http
import org.jetbrains.compose.resources.getString
import platform.Foundation.NSUserDefaults import platform.Foundation.NSUserDefaults
actual object AddonStorage { actual object AddonStorage {
@ -54,10 +59,10 @@ actual suspend fun httpGetText(url: String): String =
.let { response -> .let { response ->
val payload = response.bodyAsText() val payload = response.bodyAsText()
if (!response.status.isSuccess()) { if (!response.status.isSuccess()) {
error("Request failed with HTTP ${response.status.value}") error(runBlocking { getString(Res.string.network_request_failed_http, response.status.value) })
} }
if (payload.isBlank()) { if (payload.isBlank()) {
throw IllegalStateException("Empty response body") throw IllegalStateException(runBlocking { getString(Res.string.network_empty_response_body) })
} }
payload payload
} }
@ -72,10 +77,10 @@ actual suspend fun httpPostJson(url: String, body: String): String =
.let { response -> .let { response ->
val payload = response.bodyAsText() val payload = response.bodyAsText()
if (!response.status.isSuccess()) { if (!response.status.isSuccess()) {
error("Request failed with HTTP ${response.status.value}") error(runBlocking { getString(Res.string.network_request_failed_http, response.status.value) })
} }
if (payload.isBlank()) { if (payload.isBlank()) {
throw IllegalStateException("Empty response body") throw IllegalStateException(runBlocking { getString(Res.string.network_empty_response_body) })
} }
payload payload
} }
@ -94,10 +99,10 @@ actual suspend fun httpGetTextWithHeaders(
.let { response -> .let { response ->
val payload = response.bodyAsText() val payload = response.bodyAsText()
if (!response.status.isSuccess()) { if (!response.status.isSuccess()) {
error("Request failed with HTTP ${response.status.value}") error(runBlocking { getString(Res.string.network_request_failed_http, response.status.value) })
} }
if (payload.isBlank()) { if (payload.isBlank()) {
throw IllegalStateException("Empty response body") throw IllegalStateException(runBlocking { getString(Res.string.network_empty_response_body) })
} }
payload payload
} }
@ -119,10 +124,10 @@ actual suspend fun httpPostJsonWithHeaders(
.let { response -> .let { response ->
val payload = response.bodyAsText() val payload = response.bodyAsText()
if (!response.status.isSuccess()) { if (!response.status.isSuccess()) {
error("Request failed with HTTP ${response.status.value}") error(runBlocking { getString(Res.string.network_request_failed_http, response.status.value) })
} }
if (payload.isBlank()) { if (payload.isBlank()) {
throw IllegalStateException("Empty response body") throw IllegalStateException(runBlocking { getString(Res.string.network_empty_response_body) })
} }
payload payload
} }

View file

@ -10,6 +10,15 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.download_failed
import nuvio.composeapp.generated.resources.downloads_error_finalize_file_failed
import nuvio.composeapp.generated.resources.downloads_error_open_partial_file_failed
import nuvio.composeapp.generated.resources.downloads_error_partial_file_not_open
import nuvio.composeapp.generated.resources.downloads_error_write_partial_file_failed
import nuvio.composeapp.generated.resources.network_request_failed_http
import org.jetbrains.compose.resources.getString
import platform.Foundation.NSError import platform.Foundation.NSError
import platform.Foundation.NSDate import platform.Foundation.NSDate
import platform.Foundation.NSData import platform.Foundation.NSData
@ -99,7 +108,7 @@ internal actual object DownloadsPlatformDownloader {
} }
if (result.statusCode !in 200..299) { if (result.statusCode !in 200..299) {
error("Request failed with HTTP ${result.statusCode}") error(runBlocking { getString(Res.string.network_request_failed_http, result.statusCode) })
} }
val isPartialResume = attemptedRangeRequest && result.statusCode == 206 && resumeFromBytes > 0L val isPartialResume = attemptedRangeRequest && result.statusCode == 206 && resumeFromBytes > 0L
@ -118,7 +127,7 @@ internal actual object DownloadsPlatformDownloader {
error = null, error = null,
) )
if (!moved) { if (!moved) {
error("Failed to finalize download file") error(runBlocking { getString(Res.string.downloads_error_finalize_file_failed) })
} }
val localFileUri = NSURL.fileURLWithPath(destinationPath).absoluteString ?: "file://$destinationPath" val localFileUri = NSURL.fileURLWithPath(destinationPath).absoluteString ?: "file://$destinationPath"
@ -127,7 +136,7 @@ internal actual object DownloadsPlatformDownloader {
} catch (_: CancellationException) { } catch (_: CancellationException) {
handle.cancelNativeTask() handle.cancelNativeTask()
} catch (error: Throwable) { } catch (error: Throwable) {
onFailure(error.message ?: "Download failed") onFailure(error.message ?: runBlocking { getString(Res.string.download_failed) })
} }
} }
@ -248,7 +257,7 @@ private class IosDownloadDelegate(
) )
outputFile = fopen(tempPath, if (isPartialResume) "ab" else "wb") ?: run { outputFile = fopen(tempPath, if (isPartialResume) "ab" else "wb") ?: run {
fileError = IllegalStateException("Failed to open partial download file") fileError = IllegalStateException(runBlocking { getString(Res.string.downloads_error_open_partial_file_failed) })
null null
} }
@ -266,7 +275,7 @@ private class IosDownloadDelegate(
if (fileError != null) return if (fileError != null) return
val file = outputFile ?: run { val file = outputFile ?: run {
fileError = IllegalStateException("Partial download file is not open") fileError = IllegalStateException(runBlocking { getString(Res.string.downloads_error_partial_file_not_open) })
return return
} }
@ -278,7 +287,7 @@ private class IosDownloadDelegate(
file, file,
).toLong() ).toLong()
if (wrote != bytesToWrite) { if (wrote != bytesToWrite) {
fileError = IllegalStateException("Failed to write partial download file") fileError = IllegalStateException(runBlocking { getString(Res.string.downloads_error_write_partial_file_failed) })
return return
} }
fflush(file) fflush(file)