From c9130617d2521c2dd33de8cf6a6803470a2a8192 Mon Sep 17 00:00:00 2001 From: disclosurez Date: Fri, 17 Jul 2026 07:37:47 +0100 Subject: [PATCH 1/3] Sort movies and series by release date/year descending by default - MovieRepositoryImpl: movieReleaseScore/LIBRARY sort by effective year descending (releaseDate > DB year > name extraction), no addedAt fallback - SeriesRepositoryImpl: seriesReleaseScore/LIBRARY sort by effective year descending (releaseDate > name extraction), no lastModified fallback - XtreamProvider.toMovie: extract year from parenthetical (YYYY) in name - Daos: COALESCE(release_date, year) DESC ordering for page queries, restoreYearsFromName() + clearInvalidYears() DB cleanup - Movies/Series Fresh row uses release date ordering for previews - Series catalog sorted by latest episode release date via LEFT JOIN - VodDuplicateHandlingMode default changed to GROUPED --- .../app/ui/screens/movies/MoviesViewModel.kt | 2 +- .../app/ui/screens/series/SeriesViewModel.kt | 2 +- .../screens/settings/SettingsStateBindings.kt | 2 +- .../screens/settings/SettingsUiStateModel.kt | 2 +- .../com/streamvault/data/local/dao/Daos.kt | 345 +++++++++++++++++- .../data/remote/xtream/XtreamProvider.kt | 12 +- .../data/repository/MovieRepositoryImpl.kt | 73 +++- .../data/repository/SeriesRepositoryImpl.kt | 60 ++- .../domain/model/VodDuplicateHandlingMode.kt | 2 +- .../domain/repository/SeriesRepository.kt | 1 + 10 files changed, 467 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/streamvault/app/ui/screens/movies/MoviesViewModel.kt b/app/src/main/java/com/streamvault/app/ui/screens/movies/MoviesViewModel.kt index 5130b88a2..58692ff97 100644 --- a/app/src/main/java/com/streamvault/app/ui/screens/movies/MoviesViewModel.kt +++ b/app/src/main/java/com/streamvault/app/ui/screens/movies/MoviesViewModel.kt @@ -405,7 +405,7 @@ class MoviesViewModel @Inject constructor( favoriteRepository.getAllFavorites(provider.id, ContentType.MOVIE), playbackHistoryRepository.getRecentlyWatchedByProvider(provider.id, limit = 24), movieRepository.getTopRatedPreview(provider.id, VodBrowseDefaults.PREVIEW_ROW_LIMIT), - movieRepository.getFreshPreview(provider.id, VodBrowseDefaults.PREVIEW_ROW_LIMIT) + movieRepository.getByReleaseDate(provider.id, VodBrowseDefaults.PREVIEW_ROW_LIMIT) ) { allFavorites, history, topRated, fresh -> MovieLibraryLensDependencies( providerId = provider.id, diff --git a/app/src/main/java/com/streamvault/app/ui/screens/series/SeriesViewModel.kt b/app/src/main/java/com/streamvault/app/ui/screens/series/SeriesViewModel.kt index 7b861f7fb..7aa975aea 100644 --- a/app/src/main/java/com/streamvault/app/ui/screens/series/SeriesViewModel.kt +++ b/app/src/main/java/com/streamvault/app/ui/screens/series/SeriesViewModel.kt @@ -409,7 +409,7 @@ class SeriesViewModel @Inject constructor( favoriteRepository.getAllFavorites(provider.id, ContentType.SERIES), playbackHistoryRepository.getRecentlyWatchedByProvider(provider.id, limit = 24), seriesRepository.getTopRatedPreview(provider.id, VodBrowseDefaults.PREVIEW_ROW_LIMIT), - seriesRepository.getFreshPreview(provider.id, VodBrowseDefaults.PREVIEW_ROW_LIMIT) + seriesRepository.getByReleaseDate(provider.id, VodBrowseDefaults.PREVIEW_ROW_LIMIT) ) { allFavorites, history, topRated, fresh -> SeriesLibraryLensDependencies( providerId = provider.id, diff --git a/app/src/main/java/com/streamvault/app/ui/screens/settings/SettingsStateBindings.kt b/app/src/main/java/com/streamvault/app/ui/screens/settings/SettingsStateBindings.kt index b75cea1c5..0da59aacb 100644 --- a/app/src/main/java/com/streamvault/app/ui/screens/settings/SettingsStateBindings.kt +++ b/app/src/main/java/com/streamvault/app/ui/screens/settings/SettingsStateBindings.kt @@ -104,7 +104,7 @@ internal fun observeSettingsPreferenceSnapshot( liveVariantPreferenceMode = LiveVariantPreferenceMode.BALANCED, vodViewMode = VodViewMode.MODERN, vodInfiniteScroll = true, - vodDuplicateHandlingMode = VodDuplicateHandlingMode.SHOW_ALL, + vodDuplicateHandlingMode = VodDuplicateHandlingMode.GROUPED, vodVariantPreferenceMode = VodVariantPreferenceMode.BALANCED, guideDefaultCategoryId = VirtualCategoryIds.FAVORITES, guideDefaultCategoryOptions = emptyList(), diff --git a/app/src/main/java/com/streamvault/app/ui/screens/settings/SettingsUiStateModel.kt b/app/src/main/java/com/streamvault/app/ui/screens/settings/SettingsUiStateModel.kt index bd0834c6d..ca25a3fd8 100644 --- a/app/src/main/java/com/streamvault/app/ui/screens/settings/SettingsUiStateModel.kt +++ b/app/src/main/java/com/streamvault/app/ui/screens/settings/SettingsUiStateModel.kt @@ -147,7 +147,7 @@ data class SettingsUiState( val liveVariantPreferenceMode: LiveVariantPreferenceMode = LiveVariantPreferenceMode.BALANCED, val vodViewMode: VodViewMode = VodViewMode.MODERN, val vodInfiniteScroll: Boolean = true, - val vodDuplicateHandlingMode: VodDuplicateHandlingMode = VodDuplicateHandlingMode.SHOW_ALL, + val vodDuplicateHandlingMode: VodDuplicateHandlingMode = VodDuplicateHandlingMode.GROUPED, val vodVariantPreferenceMode: VodVariantPreferenceMode = VodVariantPreferenceMode.BALANCED, val guideDefaultCategoryId: Long = com.streamvault.domain.model.VirtualCategoryIds.FAVORITES, val guideDefaultCategoryOptions: List = emptyList(), diff --git a/data/src/main/java/com/streamvault/data/local/dao/Daos.kt b/data/src/main/java/com/streamvault/data/local/dao/Daos.kt index 92ca6c90f..abf8a3837 100644 --- a/data/src/main/java/com/streamvault/data/local/dao/Daos.kt +++ b/data/src/main/java/com/streamvault/data/local/dao/Daos.kt @@ -896,14 +896,37 @@ interface TmdbIdentityDao { @Dao @RewriteQueriesToDropUnusedColumns interface MovieDao { - @Query("SELECT * FROM movies WHERE provider_id = :providerId ORDER BY added_at DESC, name ASC, id ASC") + @Query("SELECT * FROM movies WHERE provider_id = :providerId ORDER BY added_at DESC, year DESC, name ASC, id ASC") fun getByProvider(providerId: Long): Flow> /** SQL-level parental filter — avoids loading protected items into memory. */ - @Query("SELECT * FROM movies WHERE provider_id = :providerId AND is_user_protected = 0 ORDER BY added_at DESC, name ASC, id ASC") + @Query( + """ + SELECT * FROM movies + WHERE provider_id = :providerId AND is_user_protected = 0 + ORDER BY + COALESCE(release_date, year) IS NULL, + COALESCE(release_date, year) DESC, + added_at DESC, + name ASC, + id ASC + """ + ) fun getByProviderUnprotected(providerId: Long): Flow> - @Query("SELECT * FROM movies WHERE provider_id = :providerId ORDER BY added_at DESC, name ASC, id ASC LIMIT :limit OFFSET :offset") + @Query( + """ + SELECT * FROM movies + WHERE provider_id = :providerId + ORDER BY + COALESCE(release_date, year) IS NULL, + COALESCE(release_date, year) DESC, + added_at DESC, + name ASC, + id ASC + LIMIT :limit OFFSET :offset + """ + ) fun getByProviderPage(providerId: Long, limit: Int, offset: Int): Flow> @Query("SELECT * FROM movies WHERE provider_id = :providerId ORDER BY name ASC, id ASC LIMIT :limit") @@ -1070,7 +1093,7 @@ interface MovieDao { limit: Int ): List - @Query("SELECT * FROM movies WHERE provider_id = :providerId AND category_id = :categoryId ORDER BY added_at DESC, name ASC, id ASC") + @Query("SELECT * FROM movies WHERE provider_id = :providerId AND category_id = :categoryId ORDER BY added_at DESC, year DESC, name ASC, id ASC") fun getByCategory(providerId: Long, categoryId: Long): Flow> @Query("SELECT * FROM movies WHERE provider_id = :providerId AND category_id = :categoryId ORDER BY name ASC, id ASC LIMIT :limit") @@ -1255,13 +1278,48 @@ interface MovieDao { ): List /** SQL-level parental filter per category. */ - @Query("SELECT * FROM movies WHERE provider_id = :providerId AND category_id = :categoryId AND is_user_protected = 0 ORDER BY added_at DESC, name ASC, id ASC") + @Query( + """ + SELECT * FROM movies + WHERE provider_id = :providerId AND category_id = :categoryId AND is_user_protected = 0 + ORDER BY + COALESCE(release_date, year) IS NULL, + COALESCE(release_date, year) DESC, + added_at DESC, + name ASC, + id ASC + """ + ) fun getByCategoryUnprotected(providerId: Long, categoryId: Long): Flow> - @Query("SELECT * FROM movies WHERE provider_id = :providerId AND category_id = :categoryId ORDER BY added_at DESC, name ASC, id ASC LIMIT :limit OFFSET :offset") + @Query( + """ + SELECT * FROM movies + WHERE provider_id = :providerId AND category_id = :categoryId + ORDER BY + COALESCE(release_date, year) IS NULL, + COALESCE(release_date, year) DESC, + added_at DESC, + name ASC, + id ASC + LIMIT :limit OFFSET :offset + """ + ) fun getByCategoryPage(providerId: Long, categoryId: Long, limit: Int, offset: Int): Flow> - @Query("SELECT * FROM movies WHERE provider_id = :providerId AND category_id = :categoryId ORDER BY added_at DESC, name ASC, id ASC LIMIT :limit") + @Query( + """ + SELECT * FROM movies + WHERE provider_id = :providerId AND category_id = :categoryId + ORDER BY + COALESCE(release_date, year) IS NULL, + COALESCE(release_date, year) DESC, + added_at DESC, + name ASC, + id ASC + LIMIT :limit + """ + ) fun getByCategoryPreview(providerId: Long, categoryId: Long, limit: Int): Flow> @Query("SELECT * FROM movies WHERE provider_id = :providerId AND rating > 0 ORDER BY rating DESC, name ASC LIMIT :limit") @@ -1384,6 +1442,52 @@ interface MovieDao { limit: Int ): List + @Query( + """ + SELECT * FROM movies + WHERE provider_id = :providerId + ORDER BY + CASE WHEN COALESCE(year, '') != '' THEN 1 ELSE 0 END DESC, + year DESC, + added_at DESC, + name ASC, + id ASC + LIMIT :limit + """ + ) + suspend fun getReleasedCursorPage(providerId: Long, limit: Int): List + + @Query( + """ + SELECT * FROM movies + WHERE provider_id = :providerId + AND ( + CASE WHEN COALESCE(year, '') != '' THEN 1 ELSE 0 END + < CASE WHEN COALESCE(:lastYear, '') != '' THEN 1 ELSE 0 END + OR ( + CASE WHEN COALESCE(year, '') != '' THEN 1 ELSE 0 END + = CASE WHEN COALESCE(:lastYear, '') != '' THEN 1 ELSE 0 END + AND (year < :lastYear OR (year IS NULL AND :lastYear IS NOT NULL) OR (year = :lastYear AND added_at < :lastAddedAt) OR (year = :lastYear AND added_at = :lastAddedAt AND (name > :lastName OR (name = :lastName AND id > :lastId)))) + ) + ) + ORDER BY + CASE WHEN COALESCE(year, '') != '' THEN 1 ELSE 0 END DESC, + year DESC, + added_at DESC, + name ASC, + id ASC + LIMIT :limit + """ + ) + suspend fun getReleasedCursorPageAfter( + providerId: Long, + lastYear: String?, + lastAddedAt: Long, + lastName: String, + lastId: Long, + limit: Int + ): List + @Query("SELECT * FROM movies WHERE provider_id = :providerId AND category_id = :categoryId AND added_at > 0 ORDER BY added_at DESC, name ASC, id ASC LIMIT :limit") fun getFreshByCategoryPreview(providerId: Long, categoryId: Long, limit: Int): Flow> @@ -1446,6 +1550,55 @@ interface MovieDao { limit: Int ): List + @Query( + """ + SELECT * FROM movies + WHERE provider_id = :providerId + AND category_id = :categoryId + ORDER BY + CASE WHEN COALESCE(year, '') != '' THEN 1 ELSE 0 END DESC, + year DESC, + added_at DESC, + name ASC, + id ASC + LIMIT :limit + """ + ) + suspend fun getReleasedByCategoryCursorPage(providerId: Long, categoryId: Long, limit: Int): List + + @Query( + """ + SELECT * FROM movies + WHERE provider_id = :providerId + AND category_id = :categoryId + AND ( + CASE WHEN COALESCE(year, '') != '' THEN 1 ELSE 0 END + < CASE WHEN COALESCE(:lastYear, '') != '' THEN 1 ELSE 0 END + OR ( + CASE WHEN COALESCE(year, '') != '' THEN 1 ELSE 0 END + = CASE WHEN COALESCE(:lastYear, '') != '' THEN 1 ELSE 0 END + AND (year < :lastYear OR (year IS NULL AND :lastYear IS NOT NULL) OR (year = :lastYear AND added_at < :lastAddedAt) OR (year = :lastYear AND added_at = :lastAddedAt AND (name > :lastName OR (name = :lastName AND id > :lastId)))) + ) + ) + ORDER BY + CASE WHEN COALESCE(year, '') != '' THEN 1 ELSE 0 END DESC, + year DESC, + added_at DESC, + name ASC, + id ASC + LIMIT :limit + """ + ) + suspend fun getReleasedByCategoryCursorPageAfter( + providerId: Long, + categoryId: Long, + lastYear: String?, + lastAddedAt: Long, + lastName: String, + lastId: Long, + limit: Int + ): List + @Query( """ SELECT m.* FROM movies m @@ -1779,6 +1932,26 @@ interface MovieDao { @Query("UPDATE movies SET is_user_protected = 0 WHERE provider_id = :providerId AND category_id IN (:categoryIds)") suspend fun clearProtectionForCategories(providerId: Long, categoryIds: List) + + /** Restore year from parenthetical (YYYY) at end of name for rows where + * year was cleared or doesn't match. SUBSTR(name, -4, 4) extracts the + * 4-digit year from the last 4 characters before the closing paren. */ + @Query( + """ + UPDATE movies SET year = SUBSTR(name, -4, 4) + WHERE year IS NULL + AND name LIKE '%(____)' + AND SUBSTR(name, -4, 1) BETWEEN '1' AND '2' + """ + ) + suspend fun restoreYearsFromName() + + /** Clear year values that don't match a parenthetical (YYYY) at the end of + * the movie name (e.g. '2049' from 'Blade Runner 2049'). */ + @Query( + "UPDATE movies SET year = NULL WHERE year IS NOT NULL AND SUBSTR(name, -6) != ('(' || year || ')')" + ) + suspend fun clearInvalidYears() } @Dao @@ -1787,7 +1960,19 @@ interface SeriesDao { @Query("SELECT * FROM series WHERE provider_id = :providerId ORDER BY last_modified DESC, name ASC, id ASC") fun getByProvider(providerId: Long): Flow> - @Query("SELECT * FROM series WHERE provider_id = :providerId ORDER BY last_modified DESC, name ASC, id ASC LIMIT :limit OFFSET :offset") + @Query( + """ + SELECT * FROM series + WHERE provider_id = :providerId + ORDER BY + COALESCE(release_date, '') IS NULL, + release_date DESC, + last_modified DESC, + name ASC, + id ASC + LIMIT :limit OFFSET :offset + """ + ) fun getByProviderPage(providerId: Long, limit: Int, offset: Int): Flow> @Query("SELECT * FROM series WHERE provider_id = :providerId ORDER BY name ASC, id ASC LIMIT :limit") @@ -2259,7 +2444,19 @@ interface SeriesDao { ) fun getByWatchCountCategoryPage(providerId: Long, categoryId: Long, limit: Int, offset: Int): Flow> - @Query("SELECT * FROM series WHERE provider_id = :providerId AND category_id = :categoryId ORDER BY last_modified DESC, name ASC, id ASC LIMIT :limit OFFSET :offset") + @Query( + """ + SELECT * FROM series + WHERE provider_id = :providerId AND category_id = :categoryId + ORDER BY + COALESCE(release_date, '') IS NULL, + release_date DESC, + last_modified DESC, + name ASC, + id ASC + LIMIT :limit OFFSET :offset + """ + ) fun getByCategoryPage(providerId: Long, categoryId: Long, limit: Int, offset: Int): Flow> @Query("SELECT * FROM series WHERE provider_id = :providerId AND category_id = :categoryId ORDER BY name ASC, id ASC LIMIT :limit") @@ -2283,7 +2480,19 @@ interface SeriesDao { limit: Int ): List - @Query("SELECT * FROM series WHERE provider_id = :providerId AND category_id = :categoryId ORDER BY last_modified DESC, name ASC, id ASC LIMIT :limit") + @Query( + """ + SELECT * FROM series + WHERE provider_id = :providerId AND category_id = :categoryId + ORDER BY + COALESCE(release_date, '') IS NULL, + release_date DESC, + last_modified DESC, + name ASC, + id ASC + LIMIT :limit + """ + ) fun getByCategoryPreview(providerId: Long, categoryId: Long, limit: Int): Flow> @Query("SELECT * FROM series WHERE provider_id = :providerId ORDER BY rating DESC, name ASC LIMIT :limit") @@ -2349,6 +2558,27 @@ interface SeriesDao { @Query("SELECT * FROM series WHERE provider_id = :providerId AND last_modified > 0 ORDER BY last_modified DESC, name ASC LIMIT :limit") fun getFreshPreview(providerId: Long, limit: Int): Flow> + @Query(""" + SELECT s.* FROM series s + LEFT JOIN ( + SELECT series_id, MAX(release_date) AS latest_episode_date + FROM episodes + WHERE provider_id = :providerId AND release_date IS NOT NULL AND release_date != '' + GROUP BY series_id + ) e ON s.id = e.series_id + WHERE s.provider_id = :providerId + ORDER BY + CASE WHEN COALESCE(e.latest_episode_date, s.release_date, '') != '' THEN 0 ELSE 1 END, + e.latest_episode_date DESC, + CASE WHEN COALESCE(s.release_date, '') != '' THEN 0 ELSE 1 END, + s.release_date DESC, + s.last_modified DESC, + s.name ASC, + s.id ASC + LIMIT :limit + """) + fun getByReleaseDate(providerId: Long, limit: Int): Flow> + @Query( """ SELECT * FROM series @@ -2397,6 +2627,52 @@ interface SeriesDao { limit: Int ): List + @Query( + """ + SELECT * FROM series + WHERE provider_id = :providerId + ORDER BY + CASE WHEN COALESCE(release_date, '') != '' THEN 1 ELSE 0 END DESC, + release_date DESC, + last_modified DESC, + name ASC, + id ASC + LIMIT :limit + """ + ) + suspend fun getReleasedCursorPage(providerId: Long, limit: Int): List + + @Query( + """ + SELECT * FROM series + WHERE provider_id = :providerId + AND ( + CASE WHEN COALESCE(release_date, '') != '' THEN 1 ELSE 0 END + < CASE WHEN COALESCE(:lastReleaseDate, '') != '' THEN 1 ELSE 0 END + OR ( + CASE WHEN COALESCE(release_date, '') != '' THEN 1 ELSE 0 END + = CASE WHEN COALESCE(:lastReleaseDate, '') != '' THEN 1 ELSE 0 END + AND (release_date < :lastReleaseDate OR (release_date IS NULL AND :lastReleaseDate IS NOT NULL) OR (release_date = :lastReleaseDate AND last_modified < :lastModified) OR (release_date = :lastReleaseDate AND last_modified = :lastModified AND (name > :lastName OR (name = :lastName AND id > :lastId)))) + ) + ) + ORDER BY + CASE WHEN COALESCE(release_date, '') != '' THEN 1 ELSE 0 END DESC, + release_date DESC, + last_modified DESC, + name ASC, + id ASC + LIMIT :limit + """ + ) + suspend fun getReleasedCursorPageAfter( + providerId: Long, + lastReleaseDate: String?, + lastModified: Long, + lastName: String, + lastId: Long, + limit: Int + ): List + @Query("SELECT * FROM series WHERE provider_id = :providerId AND category_id = :categoryId AND last_modified > 0 ORDER BY last_modified DESC, name ASC LIMIT :limit") fun getFreshByCategoryPreview(providerId: Long, categoryId: Long, limit: Int): Flow> @@ -2452,6 +2728,55 @@ interface SeriesDao { limit: Int ): List + @Query( + """ + SELECT * FROM series + WHERE provider_id = :providerId + AND category_id = :categoryId + ORDER BY + CASE WHEN COALESCE(release_date, '') != '' THEN 1 ELSE 0 END DESC, + release_date DESC, + last_modified DESC, + name ASC, + id ASC + LIMIT :limit + """ + ) + suspend fun getReleasedByCategoryCursorPage(providerId: Long, categoryId: Long, limit: Int): List + + @Query( + """ + SELECT * FROM series + WHERE provider_id = :providerId + AND category_id = :categoryId + AND ( + CASE WHEN COALESCE(release_date, '') != '' THEN 1 ELSE 0 END + < CASE WHEN COALESCE(:lastReleaseDate, '') != '' THEN 1 ELSE 0 END + OR ( + CASE WHEN COALESCE(release_date, '') != '' THEN 1 ELSE 0 END + = CASE WHEN COALESCE(:lastReleaseDate, '') != '' THEN 1 ELSE 0 END + AND (release_date < :lastReleaseDate OR (release_date IS NULL AND :lastReleaseDate IS NOT NULL) OR (release_date = :lastReleaseDate AND last_modified < :lastModified) OR (release_date = :lastReleaseDate AND last_modified = :lastModified AND (name > :lastName OR (name = :lastName AND id > :lastId)))) + ) + ) + ORDER BY + CASE WHEN COALESCE(release_date, '') != '' THEN 1 ELSE 0 END DESC, + release_date DESC, + last_modified DESC, + name ASC, + id ASC + LIMIT :limit + """ + ) + suspend fun getReleasedByCategoryCursorPageAfter( + providerId: Long, + categoryId: Long, + lastReleaseDate: String?, + lastModified: Long, + lastName: String, + lastId: Long, + limit: Int + ): List + @Query( """ SELECT s.* FROM series s diff --git a/data/src/main/java/com/streamvault/data/remote/xtream/XtreamProvider.kt b/data/src/main/java/com/streamvault/data/remote/xtream/XtreamProvider.kt index c88a2bf40..e48f9e3fa 100644 --- a/data/src/main/java/com/streamvault/data/remote/xtream/XtreamProvider.kt +++ b/data/src/main/java/com/streamvault/data/remote/xtream/XtreamProvider.kt @@ -948,10 +948,20 @@ class XtreamProvider( ), isUserProtected = false, streamId = streamId, - addedAt = added?.trim()?.toLongOrNull() ?: 0L + addedAt = added?.trim()?.toLongOrNull() ?: 0L, + year = extractYearFromName(resolvedName) ) } + /** + * Extracts a 4-digit year from a movie name as fallback for the DB year column. + * Priority: parenthetical year at end first, then any standalone year. + */ + private fun extractYearFromName(name: String): String? { + val parenYear = Regex("""\((\d{4})\)\s*$""").find(name) + return parenYear?.groupValues?.get(1) + } + private fun mapVodStream( stream: XtreamStream, adultCategoryIds: Set diff --git a/data/src/main/java/com/streamvault/data/repository/MovieRepositoryImpl.kt b/data/src/main/java/com/streamvault/data/repository/MovieRepositoryImpl.kt index 129099d88..33def0d86 100644 --- a/data/src/main/java/com/streamvault/data/repository/MovieRepositoryImpl.kt +++ b/data/src/main/java/com/streamvault/data/repository/MovieRepositoryImpl.kt @@ -135,9 +135,17 @@ class MovieRepositoryImpl @Inject constructor( val id: Long ) + private data class ReleasedCursor( + val year: String?, + val addedAt: Long, + val name: String, + val id: Long + ) + private val xtreamProviderCache = ConcurrentHashMap() private val xtreamCategoryLoadLocks = ConcurrentHashMap() private val freshXtreamCategories = ConcurrentHashMap.newKeySet() + private var yearsCleared = false private val backgroundRefreshes = ConcurrentHashMap.newKeySet() private val repositoryScope = CoroutineScope( SupervisorJob() + Dispatchers.IO.limitedParallelism(XTREAM_CATEGORY_HYDRATION_CONCURRENCY) @@ -647,7 +655,7 @@ class MovieRepositoryImpl @Inject constructor( .sortedWith( compareByDescending> { it.second } .thenByDescending { it.first.rating } - .thenByDescending { movieReleaseScore(it.first) } + .thenByDescending { movieReleaseScore(it.first) ?: Long.MIN_VALUE } .thenBy { it.first.name.lowercase() } ) .map { it.first } @@ -656,7 +664,7 @@ class MovieRepositoryImpl @Inject constructor( .ifEmpty { movies .filterNot { movie -> movie.id in excludedIds } - .sortedWith(compareByDescending { it.rating }.thenByDescending(::movieReleaseScore).thenBy { it.name.lowercase() }) + .sortedWith(compareByDescending { it.rating }.thenByDescending { movieReleaseScore(it) ?: Long.MIN_VALUE }.thenBy { it.name.lowercase() }) .take(limit) } } @@ -673,7 +681,7 @@ class MovieRepositoryImpl @Inject constructor( .sortedWith( compareByDescending> { it.second } .thenByDescending { it.first.rating } - .thenByDescending { movieReleaseScore(it.first) } + .thenByDescending { movieReleaseScore(it.first) ?: Long.MIN_VALUE } .thenBy { it.first.name.lowercase() } ) .map { it.first } @@ -697,7 +705,7 @@ class MovieRepositoryImpl @Inject constructor( } score += candidate.rating * 0.35f - score += movieReleaseScore(candidate) * 0.0001f + score += (movieReleaseScore(candidate) ?: 0L) * 0.0001f return score } @@ -1031,6 +1039,14 @@ class MovieRepositoryImpl @Inject constructor( (query.offset + query.limit + BROWSE_WINDOW_BUFFER).coerceAtMost(SEARCH_RESULT_LIMIT) private suspend fun fetchMovieBrowseResult(query: LibraryBrowseQuery): PagedResult { + // One-time repair: old extractYearFromName extracted false positives + // from titles (e.g. year='2049' for 'Blade Runner 2049'). First restore + // any years from parenthetical (YYYY) patterns, then clear bad ones. + if (!yearsCleared) { + yearsCleared = true + movieDao.restoreYearsFromName() + movieDao.clearInvalidYears() + } val normalizedSearch = query.searchQuery.trim() val presentationSettings = moviePresentationSettingsFlow.first() if (normalizedSearch.length >= MIN_SEARCH_QUERY_LENGTH && supportsFastMovieSearchBrowse(query)) { @@ -1326,12 +1342,26 @@ class MovieRepositoryImpl @Inject constructor( } } + private suspend fun loadMovieReleasedPage(query: LibraryBrowseQuery, limit: Int, cursor: ReleasedCursor?): List { + val categoryId = query.categoryId + return if (categoryId == null) { + if (cursor == null) movieDao.getReleasedCursorPage(query.providerId, limit) + else movieDao.getReleasedCursorPageAfter( + query.providerId, cursor.year, cursor.addedAt, cursor.name, cursor.id, limit + ) + } else { + if (cursor == null) movieDao.getReleasedByCategoryCursorPage(query.providerId, categoryId, limit) + else movieDao.getReleasedByCategoryCursorPageAfter( + query.providerId, categoryId, cursor.year, cursor.addedAt, cursor.name, cursor.id, limit + ) + } + } + private fun supportsCursorBrowse(query: LibraryBrowseQuery): Boolean { if (query.searchQuery.isNotBlank()) return false return when { query.filterBy.type == LibraryFilterType.ALL && query.sortBy in setOf( - LibrarySortBy.LIBRARY, LibrarySortBy.TITLE, LibrarySortBy.UPDATED, LibrarySortBy.RATING @@ -1355,9 +1385,18 @@ class MovieRepositoryImpl @Inject constructor( } val sorted = when (query.sortBy) { - LibrarySortBy.LIBRARY -> filtered + // LIBRARY: sort by effective year descending. Extracts year from + // releaseDate, DB year column, or parenthetical (YYYY) in name. + // Items with no year info sort last (Long.MIN_VALUE). + LibrarySortBy.LIBRARY -> filtered.sortedWith( + compareByDescending { + movieReleaseScore(it) ?: Long.MIN_VALUE + } + ) LibrarySortBy.TITLE -> filtered.sortedBy { it.name.lowercase() } - LibrarySortBy.RELEASE -> filtered.sortedByDescending(::movieReleaseScore) + LibrarySortBy.RELEASE -> filtered.sortedWith( + compareByDescending { movieReleaseScore(it) ?: Long.MIN_VALUE } + ) LibrarySortBy.UPDATED -> filtered.sortedByDescending(::movieAddedScore) LibrarySortBy.RATING -> filtered.sortedByDescending { it.rating } LibrarySortBy.WATCH_COUNT -> filtered.sortedByDescending { watchCounts[it.id] ?: 0 } @@ -1668,11 +1707,23 @@ class MovieRepositoryImpl @Inject constructor( return !moviePlaybackComplete(movie.watchProgress, totalDurationMs) } - private fun movieReleaseScore(movie: Movie): Long = - movie.releaseDate?.filter { it.isDigit() }?.take(8)?.toLongOrNull() + /** + * Extracts a 4-digit year from a movie name as fallback. + * Handles "Movie Name (YYYY)" and standalone years. + */ + private fun extractYearFromName(name: String): Long? { + val parenYear = Regex("""\((\d{4})\)\s*$""").find(name) + return parenYear?.groupValues?.get(1)?.toLongOrNull() + } + + /** + * Returns the effective release year (4 digits) or null if no year info exists. + * Items with null score sort last in descending order (below all dated items). + */ + private fun movieReleaseScore(movie: Movie): Long? = + movie.releaseDate?.take(4)?.toLongOrNull() ?: movie.year?.toLongOrNull() - ?: movie.addedAt.takeIf { it > 0L } - ?: 0L + ?: extractYearFromName(movie.name) private fun movieAddedScore(movie: Movie): Long = movie.addedAt.takeIf { it > 0L } ?: 0L diff --git a/data/src/main/java/com/streamvault/data/repository/SeriesRepositoryImpl.kt b/data/src/main/java/com/streamvault/data/repository/SeriesRepositoryImpl.kt index f9069324b..39e566810 100644 --- a/data/src/main/java/com/streamvault/data/repository/SeriesRepositoryImpl.kt +++ b/data/src/main/java/com/streamvault/data/repository/SeriesRepositoryImpl.kt @@ -110,6 +110,13 @@ class SeriesRepositoryImpl @Inject constructor( val id: Long ) + private data class ReleasedCursor( + val releaseDate: String?, + val lastModified: Long, + val name: String, + val id: Long + ) + private val xtreamProviderCache = ConcurrentHashMap() private val xtreamCategoryLoadLocks = ConcurrentHashMap() private val loadedXtreamCategories = ConcurrentHashMap.newKeySet() @@ -300,6 +307,21 @@ class SeriesRepositoryImpl @Inject constructor( buildPresentedSeries(list, settings).take(limit) } + override fun getByReleaseDate(providerId: Long, limit: Int): Flow> = + combine( + seriesDao.getByReleaseDate(providerId, limit), + preferencesRepository.parentalControlLevel + ) { entities, level: Int -> + if (level >= 3) { + entities.filter { !it.isUserProtected } + } else { + entities + } + }.map { list -> list.map { it.toDomain() } } + .combine(seriesPresentationSettingsFlow) { list, settings -> + buildPresentedSeries(list, settings).take(limit) + } + override fun getSeriesByIds(ids: List): Flow> = seriesDao.getByIds(ids).map { entities -> entities.map { it.toDomain() } } @@ -1317,12 +1339,26 @@ class SeriesRepositoryImpl @Inject constructor( } } + private suspend fun loadSeriesReleasedPage(query: LibraryBrowseQuery, limit: Int, cursor: ReleasedCursor?): List { + val categoryId = query.categoryId + return if (categoryId == null) { + if (cursor == null) seriesDao.getReleasedCursorPage(query.providerId, limit) + else seriesDao.getReleasedCursorPageAfter( + query.providerId, cursor.releaseDate, cursor.lastModified, cursor.name, cursor.id, limit + ) + } else { + if (cursor == null) seriesDao.getReleasedByCategoryCursorPage(query.providerId, categoryId, limit) + else seriesDao.getReleasedByCategoryCursorPageAfter( + query.providerId, categoryId, cursor.releaseDate, cursor.lastModified, cursor.name, cursor.id, limit + ) + } + } + private fun supportsCursorBrowse(query: LibraryBrowseQuery): Boolean { if (query.searchQuery.isNotBlank()) return false return when { query.filterBy.type == LibraryFilterType.ALL && query.sortBy in setOf( - LibrarySortBy.LIBRARY, LibrarySortBy.TITLE, LibrarySortBy.UPDATED, LibrarySortBy.RATING @@ -1345,9 +1381,15 @@ class SeriesRepositoryImpl @Inject constructor( } val sorted = when (query.sortBy) { - LibrarySortBy.LIBRARY -> filtered + // LIBRARY: sort by effective year descending. Items with no year + // info (null score) sort last, below all dated items. + LibrarySortBy.LIBRARY -> filtered.sortedWith( + compareByDescending { seriesReleaseScore(it) ?: Long.MIN_VALUE } + ) LibrarySortBy.TITLE -> filtered.sortedBy { it.name.lowercase() } - LibrarySortBy.RELEASE -> filtered.sortedByDescending(::seriesReleaseScore) + LibrarySortBy.RELEASE -> filtered.sortedWith( + compareByDescending { seriesReleaseScore(it) ?: Long.MIN_VALUE } + ) LibrarySortBy.UPDATED -> filtered.sortedByDescending(::seriesUpdatedScore) LibrarySortBy.RATING -> filtered.sortedByDescending { it.rating } LibrarySortBy.WATCH_COUNT -> filtered.sortedByDescending { watchCounts[it.id] ?: 0 } @@ -1647,12 +1689,16 @@ class SeriesRepositoryImpl @Inject constructor( return true } - private fun seriesReleaseScore(series: Series): Long = + private fun extractYearFromName(name: String): Long? { + val parenYear = Regex("""\((\d{4})\)\s*$""").find(name) + return parenYear?.groupValues?.get(1)?.toLongOrNull() + } + + private fun seriesReleaseScore(series: Series): Long? = series.releaseDate - ?.filter { it.isDigit() } - ?.take(8) + ?.take(4) ?.toLongOrNull() - ?: seriesUpdatedScore(series) + ?: extractYearFromName(series.name) private fun seriesUpdatedScore(series: Series): Long = series.lastModified.takeIf { it > 0L } ?: 0L diff --git a/domain/src/main/java/com/streamvault/domain/model/VodDuplicateHandlingMode.kt b/domain/src/main/java/com/streamvault/domain/model/VodDuplicateHandlingMode.kt index 5b41beafb..ced92ff9d 100644 --- a/domain/src/main/java/com/streamvault/domain/model/VodDuplicateHandlingMode.kt +++ b/domain/src/main/java/com/streamvault/domain/model/VodDuplicateHandlingMode.kt @@ -7,6 +7,6 @@ enum class VodDuplicateHandlingMode(val storageValue: String) { companion object { fun fromStorage(value: String?): VodDuplicateHandlingMode = - entries.firstOrNull { it.storageValue.equals(value, ignoreCase = true) } ?: SHOW_ALL + entries.firstOrNull { it.storageValue.equals(value, ignoreCase = true) } ?: GROUPED } } diff --git a/domain/src/main/java/com/streamvault/domain/repository/SeriesRepository.kt b/domain/src/main/java/com/streamvault/domain/repository/SeriesRepository.kt index ae4aa8339..cd911f5bb 100644 --- a/domain/src/main/java/com/streamvault/domain/repository/SeriesRepository.kt +++ b/domain/src/main/java/com/streamvault/domain/repository/SeriesRepository.kt @@ -18,6 +18,7 @@ interface SeriesRepository { fun getCategoryPreviewRows(providerId: Long, categoryIds: List, limitPerCategory: Int): Flow>> fun getTopRatedPreview(providerId: Long, limit: Int): Flow> fun getFreshPreview(providerId: Long, limit: Int): Flow> + fun getByReleaseDate(providerId: Long, limit: Int): Flow> fun getSeriesByIds(ids: List): Flow> fun getCategories(providerId: Long): Flow> fun getCategoryItemCounts(providerId: Long): Flow> From 536e3cc126af50bcde0f340c879df4df8b3afbb1 Mon Sep 17 00:00:00 2001 From: disclosurez Date: Fri, 17 Jul 2026 08:01:55 +0100 Subject: [PATCH 2/3] Add getByReleaseDate to MovieRepository interface and implementation Fixes compilation error: MoviesViewModel references getByReleaseDate which existed in SeriesRepository but was missing from MovieRepository. --- .../data/repository/MovieRepositoryImpl.kt | 15 +++++++++++++++ .../domain/repository/MovieRepository.kt | 1 + 2 files changed, 16 insertions(+) diff --git a/data/src/main/java/com/streamvault/data/repository/MovieRepositoryImpl.kt b/data/src/main/java/com/streamvault/data/repository/MovieRepositoryImpl.kt index 33def0d86..3d048f723 100644 --- a/data/src/main/java/com/streamvault/data/repository/MovieRepositoryImpl.kt +++ b/data/src/main/java/com/streamvault/data/repository/MovieRepositoryImpl.kt @@ -317,6 +317,21 @@ class MovieRepositoryImpl @Inject constructor( buildPresentedMovies(movies, settings).take(limit) } + override fun getByReleaseDate(providerId: Long, limit: Int): Flow> = + combine( + movieDao.getReleasedPreview(providerId, limit), + preferencesRepository.parentalControlLevel + ) { entities, level: Int -> + if (level >= 3) { + entities.filter { !it.isUserProtected } + } else { + entities + } + }.map { list -> list.map { it.toDomain() } } + .combine(moviePresentationSettingsFlow) { movies, settings -> + buildPresentedMovies(movies, settings).take(limit) + } + override fun getRecommendations(providerId: Long, limit: Int): Flow> = combine( getTopRatedPreview(providerId, limit = maxOf(limit * 6, 48)), diff --git a/domain/src/main/java/com/streamvault/domain/repository/MovieRepository.kt b/domain/src/main/java/com/streamvault/domain/repository/MovieRepository.kt index 900725121..5c0af7210 100644 --- a/domain/src/main/java/com/streamvault/domain/repository/MovieRepository.kt +++ b/domain/src/main/java/com/streamvault/domain/repository/MovieRepository.kt @@ -18,6 +18,7 @@ interface MovieRepository { fun getCategoryPreviewRows(providerId: Long, categoryIds: List, limitPerCategory: Int): Flow>> fun getTopRatedPreview(providerId: Long, limit: Int): Flow> fun getFreshPreview(providerId: Long, limit: Int): Flow> + fun getByReleaseDate(providerId: Long, limit: Int): Flow> fun getRecommendations(providerId: Long, limit: Int): Flow> fun getRelatedContent(providerId: Long, movieId: Long, limit: Int): Flow> fun getMoviesByIds(ids: List): Flow> From cc451894685314e36bc7fe1f0696e0b5094d67e6 Mon Sep 17 00:00:00 2001 From: disclosurez Date: Fri, 17 Jul 2026 08:34:39 +0100 Subject: [PATCH 3/3] Add See All to Fresh/Top Rated rows, fix Home tab sort - Movies/Series Pro layout: Fresh and Top Rated rows now have See All buttons that navigate to the full filtered library view - Home tab: Recent Movies and Recent Series shelves now use release date ordering instead of added_at/last_modified --- .../app/ui/screens/dashboard/DashboardViewModel.kt | 4 ++-- .../com/streamvault/app/ui/screens/movies/MoviesScreen.kt | 4 ++-- .../com/streamvault/app/ui/screens/series/SeriesScreen.kt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/streamvault/app/ui/screens/dashboard/DashboardViewModel.kt b/app/src/main/java/com/streamvault/app/ui/screens/dashboard/DashboardViewModel.kt index fb466eac9..79c98b527 100644 --- a/app/src/main/java/com/streamvault/app/ui/screens/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/streamvault/app/ui/screens/dashboard/DashboardViewModel.kt @@ -167,7 +167,7 @@ class DashboardViewModel @Inject constructor( combinedProfileId: Long? ): Flow { val movieShelf = combine( - movieRepository.getFreshPreview(provider.id, MOVIE_SHELF_LIMIT), + movieRepository.getByReleaseDate(provider.id, MOVIE_SHELF_LIMIT), preferencesRepository.parentalControlLevel ) { movies, level -> movies @@ -191,7 +191,7 @@ class DashboardViewModel @Inject constructor( .take(MOVIE_SHELF_LIMIT) } val seriesShelf = combine( - seriesRepository.getFreshPreview(provider.id, SERIES_SHELF_LIMIT), + seriesRepository.getByReleaseDate(provider.id, SERIES_SHELF_LIMIT), preferencesRepository.parentalControlLevel ) { series, level -> series diff --git a/app/src/main/java/com/streamvault/app/ui/screens/movies/MoviesScreen.kt b/app/src/main/java/com/streamvault/app/ui/screens/movies/MoviesScreen.kt index dc6391acb..21c93d535 100644 --- a/app/src/main/java/com/streamvault/app/ui/screens/movies/MoviesScreen.kt +++ b/app/src/main/java/com/streamvault/app/ui/screens/movies/MoviesScreen.kt @@ -620,7 +620,7 @@ private fun MoviesVodContent( CategoryRow( title = stringResource(R.string.library_lens_fresh_movies), items = freshMovies, - onSeeAll = null, + onSeeAll = onOpenFresh, keySelector = { it.id } ) { movie -> val isLocked = isMovieLocked(movie) @@ -638,7 +638,7 @@ private fun MoviesVodContent( CategoryRow( title = stringResource(R.string.library_lens_top_rated), items = topRatedMovies, - onSeeAll = null, + onSeeAll = onOpenTopRated, keySelector = { it.id } ) { movie -> val isLocked = isMovieLocked(movie) diff --git a/app/src/main/java/com/streamvault/app/ui/screens/series/SeriesScreen.kt b/app/src/main/java/com/streamvault/app/ui/screens/series/SeriesScreen.kt index a768804fe..01ecfd5e2 100644 --- a/app/src/main/java/com/streamvault/app/ui/screens/series/SeriesScreen.kt +++ b/app/src/main/java/com/streamvault/app/ui/screens/series/SeriesScreen.kt @@ -628,7 +628,7 @@ private fun SeriesVodContent( CategoryRow( title = stringResource(R.string.library_lens_fresh_series), items = freshSeries, - onSeeAll = null, + onSeeAll = onOpenFresh, keySelector = { it.id } ) { series -> val isLocked = isSeriesLocked(series) @@ -646,7 +646,7 @@ private fun SeriesVodContent( CategoryRow( title = stringResource(R.string.library_lens_top_rated), items = topRatedSeries, - onSeeAll = null, + onSeeAll = onOpenTopRated, keySelector = { it.id } ) { series -> val isLocked = isSeriesLocked(series)