diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogDiscoveryRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogDiscoveryRepository.kt index 5746d09d..8d05d24a 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogDiscoveryRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogDiscoveryRepository.kt @@ -187,7 +187,7 @@ class CatalogDiscoveryRepository @Inject constructor( append(result.description.orEmpty().lowercase()) } val tokens = query.lowercase() - .split(NON_ALPHA_NUM_REGEX) + .split(CatalogDiscoveryRepoRegexes.NON_ALPHA_NUM_REGEX) .filter { it.length >= 3 } .distinct() if (tokens.isEmpty()) return 0 @@ -197,7 +197,9 @@ class CatalogDiscoveryRepository @Inject constructor( titleScore + bodyScore } } - private companion object { - private val NON_ALPHA_NUM_REGEX = Regex("[^a-z0-9]+") - } + +} + +private object CatalogDiscoveryRepoRegexes { + val NON_ALPHA_NUM_REGEX = Regex("[^a-z0-9]+") } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt index 250c8723..8e1beb32 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt @@ -973,9 +973,9 @@ class CatalogRepository @Inject constructor( } } - val titleFromMeta = TITLE_FROM_META_REGEX.find(html)?.groupValues?.getOrNull(1) + val titleFromMeta = CatalogRepoRegexes.TITLE_FROM_META_REGEX.find(html)?.groupValues?.getOrNull(1) - val titleFromTag = TITLE_FROM_TAG_REGEX.find(html)?.groupValues?.getOrNull(1) + val titleFromTag = CatalogRepoRegexes.TITLE_FROM_TAG_REGEX.find(html)?.groupValues?.getOrNull(1) ?.replace(" - MDBList", "", ignoreCase = true) val titleFromSlug = extractMdblistSlugTitle(url) @@ -999,7 +999,7 @@ class CatalogRepository @Inject constructor( } private fun extractTraktUrl(html: String): String? { - return TRAKT_URL_REGEX.find(html)?.value + return CatalogRepoRegexes.TRAKT_URL_REGEX.find(html)?.value } private suspend fun fetchUrl(url: String): String? { @@ -1291,18 +1291,6 @@ class CatalogRepository @Inject constructor( private companion object { private const val ADDON_SOURCE_REF_PREFIX = "addon_catalog|" - private val TITLE_FROM_META_REGEX = Regex( - """([^<]+)""", - RegexOption.IGNORE_CASE - ) - private val TRAKT_URL_REGEX = Regex( - """https?://(?:www\.)?trakt\.tv/users/[^"'\s<]+/lists/[^"'\s<]+""", - RegexOption.IGNORE_CASE - ) } } @@ -1315,3 +1303,18 @@ private fun String.toDisplayTitle(): String { } .ifBlank { "Custom Catalog" } } + +private object CatalogRepoRegexes { + val TITLE_FROM_META_REGEX = Regex( + """([^<]+)""", + RegexOption.IGNORE_CASE + ) + val TRAKT_URL_REGEX = Regex( + """https?://(?:www\.)?trakt\.tv/users/[^"'\s<]+/lists/[^"'\s<]+""", + RegexOption.IGNORE_CASE + ) +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncCoordinator.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncCoordinator.kt index 9c054c19..eb6c7cfe 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncCoordinator.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncCoordinator.kt @@ -30,7 +30,8 @@ class CloudSyncCoordinator @Inject constructor( if (!started.compareAndSet(false, true)) return collectorJob = scope.launch { invalidationBus.events.collectLatest { invalidation -> - if (authRepository.getCurrentUserId().isNullOrBlank()) return@collectLatest + val userId = runCatching { authRepository.getCurrentUserId() }.getOrNull() + if (userId.isNullOrBlank()) return@collectLatest cloudSyncRepository.markLocalStateDirtyNow() scheduleFlush(invalidation) } @@ -54,7 +55,8 @@ class CloudSyncCoordinator @Inject constructor( flushJob?.cancel() flushJob = scope.launch { delay(debounceMsFor(invalidation.scope)) - if (authRepository.getCurrentUserId().isNullOrBlank()) return@launch + val userId = runCatching { authRepository.getCurrentUserId() }.getOrNull() + if (userId.isNullOrBlank()) return@launch runCatching { cloudSyncRepository.pushToCloud() } .onFailure { error -> Log.w("CloudSyncCoordinator", "Cloud push failed after ${invalidation.scope}: ${error.message}") diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/HomeServerRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/HomeServerRepository.kt index 5e317d85..7c2190c1 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/HomeServerRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/HomeServerRepository.kt @@ -2166,9 +2166,28 @@ class HomeServerRepository @Inject constructor( private fun JsonElement.asStringOrNull(): String? = runCatching { takeUnless { it.isJsonNull }?.asString }.getOrNull() + private val xmlAttributeRegexCache = java.util.concurrent.ConcurrentHashMap() + private fun String.xmlAttribute(name: String): String { - val pattern = Regex("""\b${Regex.escape(name)}=["']([^"']*)["']""") - return pattern.find(this)?.groupValues?.getOrNull(1).orEmpty().xmlDecoded() + val target = "$name=" + var index = this.indexOf(target) + // Ensure word boundary check to avoid substring matches + while (index != -1) { + if (index == 0 || !(this[index - 1].isLetterOrDigit() || this[index - 1] == '_')) { + val valueStart = index + target.length + if (valueStart < this.length) { + val quote = this[valueStart] + if (quote == '"' || quote == '\'') { + val valueEnd = this.indexOf(quote, valueStart + 1) + if (valueEnd != -1) { + return this.substring(valueStart + 1, valueEnd).xmlDecoded() + } + } + } + } + index = this.indexOf(target, index + target.length) + } + return "" } private fun String.xmlBooleanAttribute(name: String): Boolean { diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt index 4c6da88b..64987c3c 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt @@ -978,7 +978,7 @@ class IptvRepository @Inject constructor( } private fun String.replaceDurationScalePlaceholders(durationSec: Long): String { - return Regex("""\$\{duration:(\d+)\}|\{duration:(\d+)\}""").replace(this) { match -> + return DURATION_SCALE_REGEX.replace(this) { match -> val divisor = (match.groupValues.getOrNull(1)?.takeIf { it.isNotBlank() } ?: match.groupValues.getOrNull(2)) ?.toLongOrNull() @@ -988,8 +988,10 @@ class IptvRepository @Inject constructor( } } + private val datePatternRegexCache = java.util.concurrent.ConcurrentHashMap() + private fun String.replaceDatePatternPlaceholders(key: String, dateTime: LocalDateTime): String { - val regex = Regex("""\$\{""" + key + """:([^}]+)\}|\{""" + key + """:([^}]+)\}""") + val regex = datePatternRegexCache.getOrPut(key) { Regex("""\$\{""" + key + """:([^}]+)\}|\{""" + key + """:([^}]+)\}""") } return regex.replace(this) { match -> val pattern = match.groupValues.getOrNull(1)?.takeIf { it.isNotBlank() } ?: match.groupValues.getOrNull(2) @@ -1066,11 +1068,9 @@ class IptvRepository @Inject constructor( } private fun redactIptvUrl(url: String): String { - val withoutQuerySecrets = Regex( - pattern = """(?i)([?&](?:username|user|uname|password|pass|pwd)=)[^&]+""" - ).replace(url) { match -> "${match.groupValues[1]}***" } + val withoutQuerySecrets = URL_QUERY_SECRETS_REGEX.replace(url) { match -> "${match.groupValues[1]}***" } - return Regex("""(?i)(/(?:live|movie|series|timeshift)/)([^/]+)/([^/]+)(/)""") + return URL_PATH_SECRETS_REGEX .replace(withoutQuerySecrets) { match -> "${match.groupValues[1]}***/***${match.groupValues[4]}" } @@ -6454,8 +6454,8 @@ class IptvRepository @Inject constructor( val base = epgId?.takeIf { it.isNotBlank() } ?: name val normalizedBase = normalizeLooseKey( base - .replace(Regex("""\b(4K|UHD|FHD|HD|SD|2160P?|1080P?|720P?|576P?|480P?)\b""", RegexOption.IGNORE_CASE), " ") - .replace(Regex("""\[[^\]]*]|\([^)]*\)"""), " ") + .replace(QUALITY_WORDS_REGEX, " ") + .replace(BRACKET_PAREN_REGEX, " ") ) val normalizedGroup = normalizeLooseKey(group) return listOf(normalizedGroup, normalizedBase).filter { it.isNotBlank() }.joinToString(":") @@ -7529,6 +7529,12 @@ class IptvRepository @Inject constructor( // ════════════════════════════════════════════════════════════════════════ private companion object { + private val DURATION_SCALE_REGEX = Regex("""\$\{duration:(\d+)\}|\{duration:(\d+)\}""") + private val URL_QUERY_SECRETS_REGEX = Regex("""(?i)([?&](?:username|user|uname|password|pass|pwd)=)[^&]+""") + private val URL_PATH_SECRETS_REGEX = Regex("""(?i)(/(?:live|movie|series|timeshift)/)([^/]+)/([^/]+)(/)""") + private val QUALITY_WORDS_REGEX = Regex("""\b(4K|UHD|FHD|HD|SD|2160P?|1080P?|720P?|576P?|480P?)\b""", RegexOption.IGNORE_CASE) + private val BRACKET_PAREN_REGEX = Regex("""\[[^\]]*]|\([^)]*\)""") + const val ENC_PREFIX = "encv1:" const val ANDROID_KEYSTORE = "AndroidKeyStore" const val CONFIG_KEY_ALIAS = "arvio_iptv_config_v1" diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt index 72662d33..ff7d9b73 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt @@ -566,7 +566,14 @@ class TraktRepository @Inject constructor( return try { val watched = traktApi.getWatchedMovies(auth, clientId) watched.mapNotNull { it.movie.ids.tmdb }.toSet() + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptySet() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptySet() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptySet() } } @@ -591,7 +598,14 @@ class TraktRepository @Inject constructor( } } episodes + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptySet() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptySet() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptySet() } } @@ -2957,10 +2971,10 @@ class TraktRepository @Inject constructor( private fun normalizeWatchlistTitle(title: String): String { return Normalizer.normalize(title, Normalizer.Form.NFD) - .replace(DIACRITICS_REGEX, "") + .replace(TraktRepoRegexes.DIACRITICS_REGEX, "") .lowercase(Locale.US) .replace("&", "and") - .replace(NON_ALPHA_NUM_REGEX, " ") + .replace(TraktRepoRegexes.NON_ALPHA_NUM_REGEX, " ") .trim() .removePrefix("the ") .removePrefix("a ") @@ -3071,7 +3085,14 @@ class TraktRepository @Inject constructor( val auth = getAuthHeader() ?: return emptyList() return try { traktApi.getCollectionMovies(auth, clientId) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3083,7 +3104,14 @@ class TraktRepository @Inject constructor( val auth = getAuthHeader() ?: return emptyList() return try { traktApi.getCollectionShows(auth, clientId) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3177,7 +3205,14 @@ class TraktRepository @Inject constructor( val auth = getAuthHeader() ?: return emptyList() return try { traktApi.getRatingsMovies(auth, clientId) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3189,7 +3224,14 @@ class TraktRepository @Inject constructor( val auth = getAuthHeader() ?: return emptyList() return try { traktApi.getRatingsShows(auth, clientId) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3201,7 +3243,14 @@ class TraktRepository @Inject constructor( val auth = getAuthHeader() ?: return emptyList() return try { traktApi.getRatingsEpisodes(auth, clientId) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3313,7 +3362,14 @@ class TraktRepository @Inject constructor( suspend fun getMovieComments(mediaId: String, page: Int = 1, limit: Int = 10, sort: String = "newest"): List { return try { traktApi.getMovieComments(clientId, "2", mediaId, sort, page, limit) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3328,7 +3384,14 @@ class TraktRepository @Inject constructor( suspend fun getShowComments(mediaId: String, page: Int = 1, limit: Int = 10, sort: String = "newest"): List { return try { traktApi.getShowComments(clientId, "2", mediaId, sort, page, limit) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3343,7 +3406,14 @@ class TraktRepository @Inject constructor( suspend fun getSeasonComments(showId: String, season: Int, page: Int = 1, limit: Int = 10, sort: String = "newest"): List { return try { traktApi.getSeasonComments(clientId, "2", showId, season, sort, page, limit) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3358,7 +3428,14 @@ class TraktRepository @Inject constructor( suspend fun getEpisodeComments(showId: String, season: Int, episode: Int, page: Int = 1, limit: Int = 10, sort: String = "newest"): List { return try { traktApi.getEpisodeComments(clientId, "2", showId, season, episode, sort, page, limit) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3516,7 +3593,14 @@ class TraktRepository @Inject constructor( val auth = getAuthHeader() ?: return emptyList() return try { traktApi.getHistoryMovies(auth, clientId, "2", page, limit) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3528,7 +3612,14 @@ class TraktRepository @Inject constructor( val auth = getAuthHeader() ?: return emptyList() return try { traktApi.getHistoryEpisodes(auth, clientId, "2", page, limit) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3920,10 +4011,10 @@ private fun parseRuntimeLabelSeconds(label: String): Long { if (normalized.isBlank()) return 0L var minutes = 0L - HOURS_REGEX.find(normalized)?.groupValues?.getOrNull(1)?.toLongOrNull()?.let { hours -> + TraktRepoRegexes.HOURS_REGEX.find(normalized)?.groupValues?.getOrNull(1)?.toLongOrNull()?.let { hours -> minutes += hours * 60L } - MINS_REGEX.find(normalized)?.groupValues?.getOrNull(1)?.toLongOrNull()?.let { mins -> + TraktRepoRegexes.MINS_REGEX.find(normalized)?.groupValues?.getOrNull(1)?.toLongOrNull()?.let { mins -> minutes += mins } @@ -3966,7 +4057,9 @@ private fun buildEpisodeKey( } -private val DIACRITICS_REGEX = Regex("\\p{Mn}+") -private val NON_ALPHA_NUM_REGEX = Regex("[^a-z0-9]+") -private val HOURS_REGEX = Regex("""(\d+)\s*h""") -private val MINS_REGEX = Regex("""(\d+)\s*m""") +private object TraktRepoRegexes { + val DIACRITICS_REGEX = Regex("\\p{Mn}+") + val NON_ALPHA_NUM_REGEX = Regex("[^a-z0-9]+") + val HOURS_REGEX = Regex("""(\d+)\s*h""") + val MINS_REGEX = Regex("""(\d+)\s*m""") +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt index e7b77b8c..c7a72685 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt @@ -179,6 +179,12 @@ import java.util.Locale import kotlin.math.abs import androidx.compose.ui.res.stringResource + +private object DetailsScreenRegexes { + val FOUR_K_REGEX = Regex("""\b4[kK]\b""") + val YEAR_REGEX = Regex("""\d{4}""") +} + /** * Details screen for movies and TV shows */ @@ -1020,7 +1026,7 @@ private fun qualityScoreForAutoPlay(quality: String): Int { private fun qualityScoreForStream(stream: com.arflix.tv.data.model.StreamSource): Int { val combined = listOfNotNull(stream.quality, stream.source, stream.addonName).joinToString(" ") return when { - combined.contains("2160p", ignoreCase = true) || Regex("\\b4[kK]\\b").containsMatchIn(combined) -> 4 + combined.contains("2160p", ignoreCase = true) || DetailsScreenRegexes.FOUR_K_REGEX.containsMatchIn(combined) -> 4 combined.contains("1080p", ignoreCase = true) -> 3 combined.contains("720p", ignoreCase = true) -> 2 combined.contains("480p", ignoreCase = true) -> 1 @@ -1200,7 +1206,7 @@ private fun DetailsContent( } val displayDate = item.year.takeIf { it.isNotBlank() } ?: item.releaseDate?.trim()?.takeIf { it.isNotEmpty() }?.let { date -> - Regex("\\d{4}").find(date)?.value ?: date + DetailsScreenRegexes.YEAR_REGEX.find(date)?.value ?: date } ?: "" val hasDuration = item.duration.isNotEmpty() && item.duration != "0m" diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt index 703deed9..3cbe3cbb 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt @@ -211,19 +211,6 @@ class DetailsViewModel @Inject constructor( return value.isBlank() || value == "0.0" || value == "0" } - private val reviewWhitespaceRegex = Regex("\\s+") - private val reviewMarkdownLinkRegex = Regex("\\[([^\\]]+)]\\([^)]*\\)") - private val reviewHtmlTagRegex = Regex("<[^>]*>") - private val reviewMarkdownNoiseRegex = Regex("[*_`>#]+") - private val reviewSpamRegex = Regex( - pattern = "\\b(?:https?://|www\\.|discord\\.gg|t\\.me/|telegram|whatsapp|onlyfans|casino|betting|viagra|loan|crypto|airdrop|promo\\s+code|coupon|download\\s+now|watch\\s+(?:free|online)|free\\s+stream|\\.xyz\\b|\\.top\\b|\\.click\\b|\\.link\\b|\\.site\\b)\\b", - option = RegexOption.IGNORE_CASE - ) - private val reviewDomainRegex = Regex( - pattern = "\\b[a-z0-9-]+\\.(?:com|net|org|xyz|top|click|link|site|online|shop|info)\\b", - option = RegexOption.IGNORE_CASE - ) - private fun normalizeAutoPlayMinQuality(raw: String?): String { return when (raw?.trim()?.lowercase()) { "any" -> "Any" @@ -2329,7 +2316,7 @@ class DetailsViewModel @Inject constructor( if (cleanedContent.length !in MIN_COMMUNITY_REVIEW_CHARS..MAX_COMMUNITY_REVIEW_CHARS) { return@mapNotNull null } - val wordCount = cleanedContent.split(reviewWhitespaceRegex).count { it.length > 1 } + val wordCount = cleanedContent.split(DetailsVMRegexes.reviewWhitespaceRegex).count { it.length > 1 } if (wordCount < MIN_COMMUNITY_REVIEW_WORDS) return@mapNotNull null review.copy(content = cleanedContent) } @@ -2349,7 +2336,7 @@ class DetailsViewModel @Inject constructor( if (cleanedContent.length !in MIN_COMMUNITY_REVIEW_CHARS..MAX_COMMUNITY_REVIEW_CHARS) { return null } - val wordCount = cleanedContent.split(reviewWhitespaceRegex).count { it.length > 1 } + val wordCount = cleanedContent.split(DetailsVMRegexes.reviewWhitespaceRegex).count { it.length > 1 } if (wordCount < MIN_COMMUNITY_REVIEW_WORDS) return null val username = user?.username?.trim().orEmpty() @@ -2372,17 +2359,17 @@ class DetailsViewModel @Inject constructor( private fun cleanCommunityReviewText(raw: String): String { return raw - .replace(reviewMarkdownLinkRegex, "\$1") - .replace(reviewHtmlTagRegex, " ") - .replace(reviewMarkdownNoiseRegex, " ") - .replace(reviewWhitespaceRegex, " ") + .replace(DetailsVMRegexes.reviewMarkdownLinkRegex, "\$1") + .replace(DetailsVMRegexes.reviewHtmlTagRegex, " ") + .replace(DetailsVMRegexes.reviewMarkdownNoiseRegex, " ") + .replace(DetailsVMRegexes.reviewWhitespaceRegex, " ") .trim() } private fun isSpammyReviewText(raw: String): Boolean { val text = raw.trim() if (text.isBlank()) return true - if (reviewSpamRegex.containsMatchIn(text) || reviewDomainRegex.containsMatchIn(text)) return true + if (DetailsVMRegexes.reviewSpamRegex.containsMatchIn(text) || DetailsVMRegexes.reviewDomainRegex.containsMatchIn(text)) return true if (text.count { it == '$' } > 2 || text.count { it == '!' } > 6) return true val visibleChars = text.count { !it.isWhitespace() }.coerceAtLeast(1) @@ -2509,3 +2496,18 @@ class DetailsViewModel @Inject constructor( prewarmVisibleStreams(mergedStreams) } } + +private object DetailsVMRegexes { + val reviewWhitespaceRegex = Regex("\\s+") + val reviewMarkdownLinkRegex = Regex("\\[([^\\]]+)]\\([^)]*\\)") + val reviewHtmlTagRegex = Regex("<[^>]*>") + val reviewMarkdownNoiseRegex = Regex("[*_`>#]+") + val reviewSpamRegex = Regex( + pattern = "\\b(?:https?://|www\\.|discord\\.gg|t\\.me/|telegram|whatsapp|onlyfans|casino|betting|viagra|loan|crypto|airdrop|promo\\s+code|coupon|download\\s+now|watch\\s+(?:free|online)|free\\s+stream|\\.xyz\\b|\\.top\\b|\\.click\\b|\\.link\\b|\\.site\\b)\\b", + option = RegexOption.IGNORE_CASE + ) + val reviewDomainRegex = Regex( + pattern = "\\b[a-z0-9-]+\\.(?:com|net|org|xyz|top|click|link|site|online|shop|info)\\b", + option = RegexOption.IGNORE_CASE + ) +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index eaf4b6e4..a2f71de9 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -114,9 +114,6 @@ enum class ToastType { SUCCESS, ERROR, INFO } -private val ALPHANUMERIC_REGEX = Regex("[^A-Za-z0-9_.-]") -private val FILE_NAME_REGEX = Regex("[^a-zA-Z0-9._-]") - @HiltViewModel @OptIn(ExperimentalCoroutinesApi::class) class HomeViewModel @Inject constructor( @@ -816,9 +813,9 @@ class HomeViewModel @Inject constructor( private fun categoriesCacheFile(): java.io.File { val profileId = profileManager.getProfileIdSync() .ifBlank { "default" } - .replace(ALPHANUMERIC_REGEX, "_") + .replace(HomeVMRegexes.ALPHANUMERIC_REGEX, "_") val language = (mediaRepository.contentLanguage ?: "en-US") - .replace(ALPHANUMERIC_REGEX, "_") + .replace(HomeVMRegexes.ALPHANUMERIC_REGEX, "_") return java.io.File(context.cacheDir, "home_categories_cache_${profileId}_$language.json") } @@ -1494,6 +1491,7 @@ class HomeViewModel @Inject constructor( try { iptvRepository.warmXtreamVodCachesIfPossible() } catch (e: Exception) { + AppLogger.e("HomeVM", "warmXtreamVodCachesIfPossible failed", e) } } } @@ -3084,6 +3082,7 @@ class HomeViewModel @Inject constructor( } } catch (e: Exception) { // Silently fail - don't clear existing data on error + AppLogger.e("HomeVM", "launchContinueWatchingFetch failed", e) } } } @@ -3527,6 +3526,7 @@ class HomeViewModel @Inject constructor( } } catch (e: Exception) { // Logo fetch failed + AppLogger.e("HomeVM", "Hero logo fetch failed", e) } } } @@ -4226,7 +4226,7 @@ class HomeViewModel @Inject constructor( downloadJob = viewModelScope.launch { updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.Downloading(0f, update)) - val safeName = update.assetName.replace(FILE_NAME_REGEX, "_") + val safeName = update.assetName.replace(HomeVMRegexes.FILE_NAME_REGEX, "_") val dest = java.io.File(java.io.File(context.cacheDir, "updates"), safeName) val result = kotlinx.coroutines.withContext(Dispatchers.IO) { @@ -4310,3 +4310,8 @@ class HomeViewModel @Inject constructor( updateStatusManager.reset() } } + +private object HomeVMRegexes { + val ALPHANUMERIC_REGEX = Regex("[^A-Za-z0-9_.-]") + val FILE_NAME_REGEX = Regex("[^a-zA-Z0-9._-]") +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/AiSubtitleRenderersFactory.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/AiSubtitleRenderersFactory.kt index 4c15dbda..c920c558 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/AiSubtitleRenderersFactory.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/AiSubtitleRenderersFactory.kt @@ -11,6 +11,12 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch + +private object AiSubtitleRegexes { + val BRACKET_REGEX = Regex("""\[.*?\]""") + val MUSIC_REGEX = Regex("[♪♫]+") +} + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) class AiSubtitleRenderersFactory( context: Context, @@ -145,8 +151,8 @@ private class TranslatingTextOutput( } private fun stripHearingImpaired(text: String): String = - text.replace(Regex("\\[.*?]"), "") - .replace(Regex("[♪♫]+"), "") + text.replace(AiSubtitleRegexes.BRACKET_REGEX, "") + .replace(AiSubtitleRegexes.MUSIC_REGEX, "") .trim() private fun buildTranslated(group: CueGroup, originalCues: List, translatedText: String): CueGroup = @@ -385,8 +391,8 @@ private class SubtitleOffsetRenderer( .let { if (removeHI) stripHI(it) else it } private fun stripHI(text: String): String = - text.replace(Regex("\\[.*?]"), "") - .replace(Regex("[♪♫]+"), "") + text.replace(AiSubtitleRegexes.BRACKET_REGEX, "") + .replace(AiSubtitleRegexes.MUSIC_REGEX, "") .trim() private fun findField(startClass: Class<*>, name: String): java.lang.reflect.Field? { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 2a2b369c..a40662d7 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -234,7 +234,6 @@ class SettingsViewModel @Inject constructor( } } - private val _uiState = MutableStateFlow(SettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -477,7 +476,7 @@ class SettingsViewModel @Inject constructor( } else { gson.fromJson>( json, - object : TypeToken>() {}.type + TypeToken.getParameterized(List::class.java, QualityFilterConfig::class.java).type ).orEmpty() } }.getOrDefault(emptyList()) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt index 54b53b44..92863e4f 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt @@ -138,6 +138,11 @@ import java.time.format.DateTimeFormatter import kotlinx.coroutines.delay import kotlin.math.abs + +private object TvScreenRegexes { + val NON_ALPHANUMERIC_REGEX = Regex("""[^a-z0-9]+""") +} + private enum class TvFocusZone { SIDEBAR, GROUPS, @@ -169,7 +174,7 @@ private fun preferredStartupGroup( private fun String.isPriorityGuideGroup(): Boolean { if (this == FAVORITES_GROUP_NAME) return true val tokens = lowercase() - .split(Regex("[^a-z0-9]+")) + .split(TvScreenRegexes.NON_ALPHANUMERIC_REGEX) .filter { it.isNotBlank() } .toSet() return "netherlands" in tokens || "nederland" in tokens || "nl" in tokens diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt index 37069e17..f358011d 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt @@ -24,6 +24,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject + +private object TvViewModelRegexes { + val NON_ALPHANUMERIC_REGEX = Regex("""[^a-z0-9]+""") +} + internal const val FAVORITES_GROUP_NAME = "My Favorites" private const val EpgLoadingStateLimit = 800 private const val EpgAttemptedStateLimit = 2_400 @@ -1532,7 +1537,7 @@ private fun buildPreparedChannelsByGroup( private fun String.isNetherlandsGroup(): Boolean { val tokens = lowercase() - .split(Regex("[^a-z0-9]+")) + .split(TvVMRegexes.NON_ALPHA_NUM) .filter { it.isNotBlank() } .toSet() return "netherlands" in tokens || "nederland" in tokens || "nl" in tokens @@ -1631,3 +1636,7 @@ private fun IptvConfig.syncSignature(): String { playlistsSignature ).joinToString("||") } + +private object TvVMRegexes { + val NON_ALPHA_NUM = Regex("[^a-z0-9]+") +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt index 4fcc7198..27e0f049 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt @@ -98,6 +98,11 @@ import okhttp3.ConnectionPool import okhttp3.OkHttpClient import java.util.concurrent.TimeUnit + +private object LiveTvScreenRegexes { + val IPTV_URL_REDACT_REGEX = Regex("""(?i)(/(?:live|movie|series|timeshift)/)([^/]+)/([^/]+)(/)""") +} + private enum class LiveTvFocusZone { TOPBAR, PROVIDER_SWITCHER, @@ -1909,7 +1914,7 @@ private fun redactPlaybackUrl(url: String): String { pattern = """(?i)([?&](?:username|user|uname|password|pass|pwd)=)[^&]+""" ).replace(url) { match -> "${match.groupValues[1]}***" } - return Regex("""(?i)(/(?:live|movie|series|timeshift)/)([^/]+)/([^/]+)(/)""") + return LiveTvScreenRegexes.IPTV_URL_REDACT_REGEX .replace(withoutQuerySecrets) { match -> "${match.groupValues[1]}***/***${match.groupValues[4]}" }