From f68b84e8da25852eb50667232524475ff65bd5db Mon Sep 17 00:00:00 2001 From: Uwe Date: Wed, 8 Jul 2026 13:20:36 +0200 Subject: [PATCH 01/10] TmdbTools4: allow mocking by changing from object to class --- .../battlelancer/seriesguide/movies/tools/MovieDownloader.kt | 4 ++-- .../seriesguide/movies/tools/MoviePosterDownloader.kt | 2 +- .../java/com/battlelancer/seriesguide/tmdbapi/TmdbTools4.kt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/movies/tools/MovieDownloader.kt b/app/src/main/java/com/battlelancer/seriesguide/movies/tools/MovieDownloader.kt index a419bf0569..531c0d2092 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/movies/tools/MovieDownloader.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/movies/tools/MovieDownloader.kt @@ -115,7 +115,7 @@ class MovieDownloader( movieTmdbId: Int ): EnhancedTmdbMovieResult { // Try to get movie details for desired language - val movieResult = TmdbTools4.getMovieSummary( + val movieResult = TmdbTools4().getMovieSummary( tmdbMovies, movieTmdbId, languageCode, @@ -152,7 +152,7 @@ class MovieDownloader( movieTmdbId: Int ): String { // Try with default language if TMDb has no localized overview - val fallbackResult = TmdbTools4.getMovieSummary( + val fallbackResult = TmdbTools4().getMovieSummary( tmdbMovies, movieTmdbId, language = null, diff --git a/app/src/main/java/com/battlelancer/seriesguide/movies/tools/MoviePosterDownloader.kt b/app/src/main/java/com/battlelancer/seriesguide/movies/tools/MoviePosterDownloader.kt index 0a331a5149..0c0adfe269 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/movies/tools/MoviePosterDownloader.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/movies/tools/MoviePosterDownloader.kt @@ -24,7 +24,7 @@ class MoviePosterDownloader( */ suspend fun getMoviePosterPath(movieTmdbId: Int): String? { val languageCode = MoviesSettings.getMoviesLanguage(context) - val result = TmdbTools4.getMovieSummary( + val result = TmdbTools4().getMovieSummary( tmdbMovies, movieTmdbId, languageCode, diff --git a/app/src/main/java/com/battlelancer/seriesguide/tmdbapi/TmdbTools4.kt b/app/src/main/java/com/battlelancer/seriesguide/tmdbapi/TmdbTools4.kt index 970bd69470..dfc7067562 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/tmdbapi/TmdbTools4.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/tmdbapi/TmdbTools4.kt @@ -17,7 +17,7 @@ import retrofit2.awaitResponse * Removes any Android specific classes and no longer relies on a third-party library to handle * results. */ -object TmdbTools4 { +class TmdbTools4 { sealed interface TmdbNonNullResponse { data class Success(val data: T) : TmdbNonNullResponse From c703c9b639e6259b81fb86221dd164cce235406c Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 9 Jul 2026 08:08:12 +0200 Subject: [PATCH 02/10] Import: restore original data if importing fails (use db transaction) --- CHANGELOG.md | 2 + .../dataliberation/JsonImportTask.kt | 180 +++++++++--------- .../dataliberation/JsonImportTaskTest.kt | 4 +- 3 files changed, 91 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e338ef3855..dd453581c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Releases marked with ๐Ÿงช (or previously with the "beta" suffix) were released o ### Next release +* ๐Ÿ”ง Import: restore original data on failure. + ## Version 2026.2 * ๐ŸŒŸ Lists: movies can be added to lists, currently only from the details view. diff --git a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt index 94a8ecd06f..b92df68bd5 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt @@ -139,7 +139,7 @@ class JsonImportTask( var result: Int if (isImportShows) { - result = importData(Export.Shows) + result = openFilesAndImport(Export.Shows) if (result != SUCCESS) { return result } @@ -149,7 +149,7 @@ class JsonImportTask( } if (isImportLists) { - result = importData(Export.Lists) + result = openFilesAndImport(Export.Lists) if (result != SUCCESS) { return result } @@ -159,7 +159,7 @@ class JsonImportTask( } if (isImportMovies) { - result = importData(Export.Movies) + result = openFilesAndImport(Export.Movies) if (result != SUCCESS) { return result } @@ -207,7 +207,7 @@ class JsonImportTask( ) } - private fun importData(export: Export): Int { + private fun openFilesAndImport(export: Export): Int { if (!isImportingAutoBackup) { val testBackupFile = testBackupFile var pfd: ParcelFileDescriptor? = null @@ -237,36 +237,21 @@ class JsonImportTask( } } - if (!clearExistingData(export)) { - return ERROR - } - // Access JSON from backup file and try to import data val inputStream = if (testBackupFile == null) { FileInputStream(pfd!!.fileDescriptor) } else FileInputStream(testBackupFile) + try { - importFromJson(export, inputStream) - // Let the document provider know this is done. - pfd?.close() - } catch (e: JsonParseException) { - // The given Json might not be valid or unreadable - Timber.e(e, "Import failed") - errorCause = e.message - return ERROR - } catch (e: IOException) { - Timber.e(e, "Import failed") - errorCause = e.message - return ERROR - } catch (e: IllegalStateException) { - Timber.e(e, "Import failed") - errorCause = e.message - return ERROR - } catch (e: Exception) { - // Only report unexpected errors. - Errors.logAndReport("Import failed", e) - errorCause = e.message - return ERROR + return clearDataAndImportInTransaction(export, inputStream) + } finally { + try { + // Let the document provider know this is done. + pfd?.close() + } catch (e: Exception) { + // Import is already done, don't fail if closing the descriptor fails, but report it + Errors.logAndReport("Import failed", e) + } } } else { // Restoring latest auto backup. @@ -285,67 +270,74 @@ class JsonImportTask( return ERROR_FILE_ACCESS } - // Only clear data after backup file could be opened. - if (!clearExistingData(export)) { - return ERROR - } - - // Access JSON from backup file and try to import data - try { - importFromJson(export, inputStream) - } catch (e: JsonParseException) { - // The given Json might not be valid or unreadable - Timber.e(e, "Import failed") - errorCause = e.message - return ERROR - } catch (e: IOException) { - Timber.e(e, "Import failed") - errorCause = e.message - return ERROR - } catch (e: IllegalStateException) { - Timber.e(e, "Import failed") - errorCause = e.message - return ERROR - } catch (e: Exception) { - // Only report unexpected errors. - Errors.logAndReport("Import failed", e) - errorCause = e.message - return ERROR - } + return clearDataAndImportInTransaction(export, inputStream) } - return SUCCESS } private fun getDataBackupFile(export: Export): Uri? { return BackupSettings.getImportFileUriOrExportFileUri(context, export) } - private fun clearExistingData(export: Export): Boolean { + /** + * [inputStream] will be closed when this returns. + */ + private fun clearDataAndImportInTransaction(export: Export, inputStream: FileInputStream): Int { + try { + // Wrap in transaction, so if anything goes wrong existing data is restored + database.runInTransaction { + clearExistingData(export) + // Note: takes care of closing input stream + importFromJson(export, inputStream) + } + return SUCCESS + } catch (e: JsonParseException) { + // The given Json might not be valid or unreadable + Timber.e(e, "Import failed") + errorCause = e.message + return ERROR + } catch (e: IOException) { + Timber.e(e, "Import failed") + errorCause = e.message + return ERROR + } catch (e: IllegalStateException) { + Timber.e(e, "Import failed") + errorCause = e.message + return ERROR + } catch (e: Exception) { + // Only report unexpected errors. + Errors.logAndReport("Import failed", e) + errorCause = e.message + return ERROR + } + } + + /** + * Assumes this is run within a database transaction. + */ + private fun clearExistingData(export: Export) { when (export) { Export.Shows -> { - database.runInTransaction { - // delete episodes and seasons first to prevent violating foreign key constraints - sgEpisode2Helper.deleteAllEpisodes() - sgSeason2Helper.deleteAllSeasons() - sgShow2Helper.deleteAllShows() - } + // delete episodes and seasons first to prevent violating foreign key constraints + sgEpisode2Helper.deleteAllEpisodes() + sgSeason2Helper.deleteAllSeasons() + sgShow2Helper.deleteAllShows() } Export.Lists -> { - database.runInTransaction { - // Delete list items before lists to prevent violating foreign key constraints - sgListHelper.deleteAllListItems() - sgListHelper.deleteAllLists() - } + // Delete list items before lists to prevent violating foreign key constraints + sgListHelper.deleteAllListItems() + sgListHelper.deleteAllLists() } Export.Movies -> { sgMovieHelper.deleteAllMovies() } } - return true } + /** + * Takes care of closing the given [inputStream]. + */ @Throws(JsonParseException::class, IOException::class, IllegalArgumentException::class) private fun importFromJson(export: Export, inputStream: FileInputStream) { if (inputStream.channel.size() == 0L) { @@ -355,32 +347,34 @@ class JsonImportTask( } val gson = Gson() - val reader = JsonReader(InputStreamReader(inputStream, "UTF-8")) - reader.beginArray() - when (export) { - Export.Shows -> { - while (reader.hasNext()) { - val show = gson.fromJson(reader, Show::class.java) - addShowToDatabase(show) - } - } - Export.Lists -> { - while (reader.hasNext()) { - val list = gson.fromJson(reader, List::class.java) - addListToDatabase(list) + JsonReader(InputStreamReader(inputStream, "UTF-8")) + .use { reader -> + reader.beginArray() + when (export) { + Export.Shows -> { + while (reader.hasNext()) { + val show = gson.fromJson(reader, Show::class.java) + addShowToDatabase(show) + } + } + + Export.Lists -> { + while (reader.hasNext()) { + val list = gson.fromJson(reader, List::class.java) + addListToDatabase(list) + } + } + + Export.Movies -> { + while (reader.hasNext()) { + val movie = gson.fromJson(reader, Movie::class.java) + addMovieToDatabase(movie) + } + } } + reader.endArray() } - - Export.Movies -> { - while (reader.hasNext()) { - val movie = gson.fromJson(reader, Movie::class.java) - addMovieToDatabase(movie) - } - } - } - reader.endArray() - reader.close() } private fun addMovieToDatabase(movie: Movie) { diff --git a/app/src/test/java/com/battlelancer/seriesguide/dataliberation/JsonImportTaskTest.kt b/app/src/test/java/com/battlelancer/seriesguide/dataliberation/JsonImportTaskTest.kt index be74727cc5..a210b25387 100644 --- a/app/src/test/java/com/battlelancer/seriesguide/dataliberation/JsonImportTaskTest.kt +++ b/app/src/test/java/com/battlelancer/seriesguide/dataliberation/JsonImportTaskTest.kt @@ -68,7 +68,7 @@ class JsonImportTaskTest { importShows = true, importLists = false, importMovies = false, - mock(SgRoomDatabase::class.java), + testDb, sgShow2Helper, sgSeason2Helper, sgEpisode2Helper, @@ -144,7 +144,7 @@ class JsonImportTaskTest { importShows = false, importLists = true, importMovies = false, - mock(SgRoomDatabase::class.java), + testDb, mock(SgShow2Helper::class.java), mock(SgSeason2Helper::class.java), mock(SgEpisode2Helper::class.java), From 0d9a81b68aa80cda20feffb6ea161af8c0ea70eb Mon Sep 17 00:00:00 2001 From: Uwe Date: Wed, 8 Jul 2026 13:22:25 +0200 Subject: [PATCH 03/10] Import: for movies with only IMDB ID try to look up TMDB movie --- CHANGELOG.md | 1 + .../dataliberation/JsonImportTask.kt | 62 +++++++++++++- .../seriesguide/tmdbapi/TmdbFindTools.kt | 43 ++++++++++ .../seriesguide/tmdbapi/TmdbTools4.kt | 8 +- .../dataliberation/JsonImportTaskTest.kt | 81 ++++++++++++++++--- docs/backup-json-schema.md | 19 +++++ 6 files changed, 194 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/battlelancer/seriesguide/tmdbapi/TmdbFindTools.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index dd453581c8..252ec71575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Releases marked with ๐Ÿงช (or previously with the "beta" suffix) were released o ### Next release +* ๐Ÿ”ง Import: support importing movies with only IMDB IDs. * ๐Ÿ”ง Import: restore original data on failure. ## Version 2026.2 diff --git a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt index b92df68bd5..dc262cca87 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt @@ -8,6 +8,7 @@ import android.net.Uri import android.os.ParcelFileDescriptor import androidx.annotation.VisibleForTesting import com.battlelancer.seriesguide.R +import com.battlelancer.seriesguide.SgApp import com.battlelancer.seriesguide.dataliberation.DataLiberationFragment.LiberationResultEvent import com.battlelancer.seriesguide.dataliberation.ImportTools.toSgEpisodeForImport import com.battlelancer.seriesguide.dataliberation.ImportTools.toSgListForImport @@ -23,6 +24,7 @@ import com.battlelancer.seriesguide.dataliberation.model.Show import com.battlelancer.seriesguide.lists.ListsTools import com.battlelancer.seriesguide.lists.database.SgListHelper import com.battlelancer.seriesguide.lists.database.SgListItem +import com.battlelancer.seriesguide.movies.MoviesSettings import com.battlelancer.seriesguide.movies.database.MovieHelper import com.battlelancer.seriesguide.provider.SeriesGuideDatabase import com.battlelancer.seriesguide.provider.SgRoomDatabase @@ -31,6 +33,9 @@ import com.battlelancer.seriesguide.shows.database.SgEpisode2Helper import com.battlelancer.seriesguide.shows.database.SgSeason2Helper import com.battlelancer.seriesguide.shows.database.SgShow2Helper import com.battlelancer.seriesguide.sync.SgSyncAdapter +import com.battlelancer.seriesguide.tmdbapi.TmdbFindTools +import com.battlelancer.seriesguide.tmdbapi.TmdbTools4.TmdbErrorResponse +import com.battlelancer.seriesguide.tmdbapi.TmdbTools4.TmdbNonNullResponse import com.battlelancer.seriesguide.util.Errors import com.battlelancer.seriesguide.util.LanguageTools import com.battlelancer.seriesguide.util.TaskManager @@ -38,9 +43,11 @@ import com.battlelancer.seriesguide.util.TextTools import com.google.gson.Gson import com.google.gson.JsonParseException import com.google.gson.stream.JsonReader +import com.uwetrottmann.tmdb2.entities.BaseMovie import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.greenrobot.eventbus.EventBus @@ -64,7 +71,8 @@ class JsonImportTask( private val sgSeason2Helper: SgSeason2Helper, private val sgEpisode2Helper: SgEpisode2Helper, private val sgListHelper: SgListHelper, - private val sgMovieHelper: MovieHelper + private val sgMovieHelper: MovieHelper, + private val tmdbFindTools: TmdbFindTools ) { private val context: Context = context.applicationContext @@ -108,7 +116,11 @@ class JsonImportTask( SgRoomDatabase.getInstance(context).sgSeason2Helper(), SgRoomDatabase.getInstance(context).sgEpisode2Helper(), SgRoomDatabase.getInstance(context).sgListHelper(), - SgRoomDatabase.getInstance(context).movieHelper() + SgRoomDatabase.getInstance(context).movieHelper(), + TmdbFindTools( + SgApp.getServicesComponent(context).tmdb().findService(), + MoviesSettings.getMoviesLanguage(context) + ) ) constructor(context: Context) : this(context, true, true, true) { @@ -377,13 +389,57 @@ class JsonImportTask( } } + /** + * Throws [IOException] if looking up via IMDB ID fails due to an API or network error. + */ + @Throws(IOException::class) private fun addMovieToDatabase(movie: Movie) { if (movie.tmdb_id <= 0) { - return // Valid TMDB ID required + // Try to look up via IMDB ID + if (movie.imdb_id.isNullOrBlank()) { + return // Skip, needs IMDB ID for look-up + } + val tmdbIdResponse = runBlocking { + tmdbFindTools.findMovieByImdbId(movie.imdb_id) + } + if (tmdbIdResponse is TmdbErrorResponse.Other) { + // If the API or network has issues, stop importing to avoid skipping movies + throw IOException("Failed to look up movie: TMDB API or network error (title = ${movie.title}, imdb_id = ${movie.imdb_id})") + } + if (tmdbIdResponse is TmdbNonNullResponse.Success) { + val tmdbMovie = tmdbIdResponse.data + val tmdbId = tmdbMovie.id + if (tmdbId != null) { + movie.tmdb_id = tmdbId + // Already made an API call to TMDB, so fill in data and prevent from updating + // right away. + tmdbMovie.enrichAndSetUpdated(movie) + } + } + if (movie.tmdb_id <= 0) { + Timber.i( + "Failed to look up movie (title = %s, imdb_id = %s)", + movie.imdb_id, + movie.title + ) + return // Skip, TMDB ID required + } } sgMovieHelper.insertMovie(movie.toSgMovieForImport()) } + /** + * Sets last updated to avoid hitting TMDB again soon after importing. + */ + private fun BaseMovie.enrichAndSetUpdated(toImport: Movie) { + title?.let { toImport.title = it } + release_date?.time?.let { toImport.released_utc_ms = it } + poster_path?.let { toImport.poster = it } + overview?.let { toImport.overview = it } + + toImport.last_updated_ms = System.currentTimeMillis() + } + private fun addShowToDatabase(show: Show) { if ((show.tmdb_id == null || show.tmdb_id!! <= 0) && (show.tvdb_id == null || show.tvdb_id!! <= 0)) { diff --git a/app/src/main/java/com/battlelancer/seriesguide/tmdbapi/TmdbFindTools.kt b/app/src/main/java/com/battlelancer/seriesguide/tmdbapi/TmdbFindTools.kt new file mode 100644 index 0000000000..cb38779ba1 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/tmdbapi/TmdbFindTools.kt @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright ยฉ 2026 Uwe Trottmann + +package com.battlelancer.seriesguide.tmdbapi + +import com.uwetrottmann.tmdb2.entities.BaseMovie +import com.uwetrottmann.tmdb2.entities.FindResults +import com.uwetrottmann.tmdb2.enumerations.ExternalSource +import com.uwetrottmann.tmdb2.services.FindService + +class TmdbFindTools( + private val findService: FindService, + private val languageCode: String? +) : TmdbTools4() { + + /** + * Tries to look up a movie using an IMDB ID. + */ + suspend fun findMovieByImdbId( + imdbId: String, + ): TmdbNonNullResponse { + val response: TmdbNonNullResponse = awaitTmdbCall( + findService.find(imdbId, ExternalSource.IMDB_ID, languageCode), + "find movie via imdb id" + ) + + return when (response) { + is TmdbNonNullResponse.Success -> { + val movie = response.data.movie_results?.firstOrNull() + if (movie == null) { + TmdbErrorResponse.IsNotFound() + } else { + TmdbNonNullResponse.Success(movie) + } + } + + // This should never return 404 Not Found, but map it in case it ever does + is TmdbErrorResponse.IsNotFound -> TmdbErrorResponse.IsNotFound() + is TmdbErrorResponse.Other -> TmdbErrorResponse.Other() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/tmdbapi/TmdbTools4.kt b/app/src/main/java/com/battlelancer/seriesguide/tmdbapi/TmdbTools4.kt index dfc7067562..a2cdb2bb49 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/tmdbapi/TmdbTools4.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/tmdbapi/TmdbTools4.kt @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -// Copyright 2025 Uwe Trottmann +// SPDX-FileCopyrightText: Copyright ยฉ 2025 Uwe Trottmann package com.battlelancer.seriesguide.tmdbapi @@ -14,10 +14,10 @@ import retrofit2.awaitResponse /** * Uses response classes inheriting from a Kotlin sealed interface. * - * Removes any Android specific classes and no longer relies on a third-party library to handle + * Removes any Android-specific classes and no longer relies on a third-party library to handle * results. */ -class TmdbTools4 { +open class TmdbTools4 { sealed interface TmdbNonNullResponse { data class Success(val data: T) : TmdbNonNullResponse @@ -61,7 +61,7 @@ class TmdbTools4 { * * If there is an error, logs and reports it. */ - private suspend fun awaitTmdbCall( + suspend fun awaitTmdbCall( call: Call, action: String ): TmdbNonNullResponse { diff --git a/app/src/test/java/com/battlelancer/seriesguide/dataliberation/JsonImportTaskTest.kt b/app/src/test/java/com/battlelancer/seriesguide/dataliberation/JsonImportTaskTest.kt index a210b25387..3e14de0c82 100644 --- a/app/src/test/java/com/battlelancer/seriesguide/dataliberation/JsonImportTaskTest.kt +++ b/app/src/test/java/com/battlelancer/seriesguide/dataliberation/JsonImportTaskTest.kt @@ -16,7 +16,10 @@ import com.battlelancer.seriesguide.shows.database.SgSeason2 import com.battlelancer.seriesguide.shows.database.SgSeason2Helper import com.battlelancer.seriesguide.shows.database.SgShow2 import com.battlelancer.seriesguide.shows.database.SgShow2Helper +import com.battlelancer.seriesguide.tmdbapi.TmdbFindTools +import com.battlelancer.seriesguide.tmdbapi.TmdbTools4.TmdbNonNullResponse.Success import com.google.common.truth.Truth.assertThat +import com.uwetrottmann.tmdb2.entities.BaseMovie import kotlinx.coroutines.test.runTest import org.intellij.lang.annotations.Language import org.junit.After @@ -27,6 +30,8 @@ import org.mockito.Mockito import org.mockito.Mockito.mock import org.mockito.Mockito.verify import org.mockito.Mockito.`when` +import org.mockito.kotlin.eq +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import java.nio.file.Files @@ -73,7 +78,8 @@ class JsonImportTaskTest { sgSeason2Helper, sgEpisode2Helper, mock(SgListHelper::class.java), - mock(MovieHelper::class.java) + mock(MovieHelper::class.java), + mock() ) // Test data from export task test: single show, two seasons, each with two episodes. @@ -149,7 +155,8 @@ class JsonImportTaskTest { mock(SgSeason2Helper::class.java), mock(SgEpisode2Helper::class.java), sgListHelper, - mock(MovieHelper::class.java) + mock(MovieHelper::class.java), + mock() ) // Test data from export task test: two lists, the first with one item of each type. @@ -174,14 +181,7 @@ class JsonImportTaskTest { ) } - private suspend fun importMovies(json: String) { - val importTask = JsonImportTask( - context, - importShows = false, - importLists = false, - importMovies = true - ) - + private suspend fun importMovies(importTask: JsonImportTask, json: String) { @Suppress("BlockingMethodInNonBlockingContext") val testBackupFile = Files.createTempFile("seriesguide-movies-json", null) testBackupFile.writeText(json) @@ -194,7 +194,14 @@ class JsonImportTaskTest { @Test fun importMovie_modelAsExpected() = runTest { - importMovies(JsonExportTaskTest.expectedJsonMovies) + val importTask = JsonImportTask( + context, + importShows = false, + importLists = false, + importMovies = true + ) + + importMovies(importTask, JsonExportTaskTest.expectedJsonMovies) // Two movies, second one with only TMDB ID and title val insertedMovies = testDb.movieHelper().getAllMovies() @@ -229,17 +236,65 @@ class JsonImportTaskTest { ) } + @Test + fun importMovie_imdbId_mappedToTmdbId() = runTest { + val testImdbId = "test-imdbid" + + @Language("json") + val jsonMovieMissingRequiredValues = + """ + [ + {"imdb_id":"test-imdbid","title":"First Movie"} + ] + """.trimIndent() + + val tmdbTools: TmdbFindTools = mock() + val resolvedTmdbId = 42 + whenever { + tmdbTools.findMovieByImdbId(eq(testImdbId)) + }.thenReturn(Success(BaseMovie().apply { id = resolvedTmdbId })) + + val movieHelper = testDb.movieHelper() + + val importTask = JsonImportTask( + context, + importShows = false, + importLists = false, + importMovies = true, + testDb, + mock(), + mock(), + mock(), + mock(), + movieHelper, + tmdbTools + ) + + importMovies(importTask, jsonMovieMissingRequiredValues) + + val importedMovies = movieHelper.getAllMovies() + assertThat(importedMovies).hasSize(1) + assertThat(importedMovies[0].tmdbId).isEqualTo(resolvedTmdbId) + } + @Test fun importMovie_missingRequired_notImported() = runTest { @Language("json") val jsonMovieMissingRequiredValues = """ [ - {"imdb_id":"imdbidvalue","title":"First Movie"} + {"title":"First Movie"} ] """.trimIndent() - importMovies(jsonMovieMissingRequiredValues) + val importTask = JsonImportTask( + context, + importShows = false, + importLists = false, + importMovies = true + ) + + importMovies(importTask, jsonMovieMissingRequiredValues) // Nothing is imported assertThat(testDb.movieHelper().getAllMovies()).hasSize(0) diff --git a/docs/backup-json-schema.md b/docs/backup-json-schema.md index 335cd82647..53acd26840 100644 --- a/docs/backup-json-schema.md +++ b/docs/backup-json-schema.md @@ -215,3 +215,22 @@ The JSON is an array of [Movie](/app/src/main/java/com/battlelancer/seriesguide/ } ] ``` + +### Import movies using IMDB IDs + +Movies can be imported using an IMDB ID instead of a TMDB ID if the movie has it set as an external +ID on TMDB: + +```json +[ + { + "imdb_id": "tt1022603", + "title": "(500) Days of Summer", + + "in_collection": false, + "in_watchlist": false, + "watched": true, + "plays": 1 + } +] +``` From 88adbdffc15940f2b31490cb384398fcede79de5 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 9 Jul 2026 10:21:20 +0200 Subject: [PATCH 04/10] Import: display summary, notably list of skipped movies --- .../dataliberation/AutoBackupFragment.kt | 38 ++++--- .../dataliberation/AutoBackupViewModel.kt | 24 +---- .../BaseDataLiberationViewModel.kt | 101 ++++++++++++++++++ .../dataliberation/DataLiberationFragment.kt | 40 +++---- .../dataliberation/DataLiberationViewModel.kt | 36 +++---- .../dataliberation/JsonExportTask.kt | 3 +- .../dataliberation/JsonImportTask.kt | 92 ++++++++++------ .../main/res/layout/fragment_auto_backup.xml | 12 +++ .../res/layout/fragment_data_liberation.xml | 12 +++ .../dataliberation/JsonImportTaskTest.kt | 90 ++++++++-------- 10 files changed, 300 insertions(+), 148 deletions(-) create mode 100644 app/src/main/java/com/battlelancer/seriesguide/dataliberation/BaseDataLiberationViewModel.kt diff --git a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/AutoBackupFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/AutoBackupFragment.kt index 85dd41ebdb..78e92d40e7 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/AutoBackupFragment.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/AutoBackupFragment.kt @@ -17,7 +17,6 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import com.battlelancer.seriesguide.R -import com.battlelancer.seriesguide.SgApp import com.battlelancer.seriesguide.databinding.FragmentAutoBackupBinding import com.battlelancer.seriesguide.dataliberation.DataLiberationFragment.LiberationResultEvent import com.battlelancer.seriesguide.dataliberation.JsonExportTask.Export @@ -68,10 +67,10 @@ class AutoBackupFragment : Fragment() { binding.buttonAutoBackupNow.setOnClickListener { if (TaskManager.tryBackupTask(requireContext())) { - setProgressLock(true) + viewModel.setInProgress(true) } } - binding.buttonAutoBackupImport.setOnClickListener { runAutoBackupImport() } + binding.buttonAutoBackupImport.setOnClickListener { viewModel.runImportTask() } binding.checkBoxAutoBackupCreateCopy.isChecked = BackupSettings.isCreateCopyOfAutoBackup(requireContext()) @@ -115,12 +114,13 @@ class AutoBackupFragment : Fragment() { binding.groupState.visibility = View.GONE viewModel.updateCopiesFileNames() - setProgressLock(false) // Also disables import button if backup availability unknown. - // restore UI state - if (viewModel.isImportTaskNotCompleted) { - setProgressLock(true) + viewLifecycleOwner.lifecycleScope.launch { + viewModel.isInProgress.collect { + setProgressLock(it) + } } + viewModel.availableBackupLiveData .observe(viewLifecycleOwner, { availableBackupTimeString: String? -> val lastBackupTimeString = @@ -157,6 +157,16 @@ class AutoBackupFragment : Fragment() { } } }) + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.importSummaryState.collect { state -> + state.applyTo( + viewModel, + binding.textViewAutoBackupImportSummary, + binding.scrollViewAutoBackup + ) + } + } } override fun onStart() { @@ -183,6 +193,7 @@ class AutoBackupFragment : Fragment() { binding = null } + // This is currently only posted by the export task @Subscribe(threadMode = ThreadMode.MAIN) fun onEvent(event: LiberationResultEvent) { event.handle(view) @@ -193,14 +204,7 @@ class AutoBackupFragment : Fragment() { viewModel.updateAvailableBackupData() // Note: backup may remove a copy file URI viewModel.updateCopiesFileNames() - setProgressLock(false) - } - - private fun runAutoBackupImport() { - setProgressLock(true) - - val importTask = JsonImportTask(requireContext()) - viewModel.importTask = SgApp.coroutineScope.launch { importTask.run() } + viewModel.setInProgress(false) } private val createShowExportFileResult = @@ -235,6 +239,10 @@ class AutoBackupFragment : Fragment() { binding.buttonAutoBackupImport.isEnabled = isBackupAvailableForImport } + /** + * Also disables import button if backup availability unknown, so make sure to also call after + * view setup. + */ private fun setProgressLock(isLocked: Boolean) { val binding = binding ?: return binding.buttonAutoBackupNow.isEnabled = !isLocked diff --git a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/AutoBackupViewModel.kt b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/AutoBackupViewModel.kt index b5a25a3259..4a2504ab70 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/AutoBackupViewModel.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/AutoBackupViewModel.kt @@ -4,22 +4,21 @@ package com.battlelancer.seriesguide.dataliberation import android.app.Application +import android.content.Context import android.text.format.DateUtils -import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.battlelancer.seriesguide.R import com.battlelancer.seriesguide.dataliberation.DataLiberationTools.getFileNameFromUriOrLastPathSegment import com.battlelancer.seriesguide.dataliberation.JsonExportTask.Export import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch /** * View model that checks for available backup files. */ -class AutoBackupViewModel(application: Application) : AndroidViewModel(application) { +class AutoBackupViewModel(application: Application) : BaseDataLiberationViewModel(application) { data class CopiesFiles( val fileNameShows: String?, @@ -31,17 +30,6 @@ class AutoBackupViewModel(application: Application) : AndroidViewModel(applicati val copiesFiles = MutableStateFlow(CopiesFiles("", "", "", "", visible = false)) - /** - * Try to keep the import task around on config changes - * so it does not have to be finished. - */ - var importTask: Job? = null - val isImportTaskNotCompleted: Boolean - get() { - val importTask = importTask - return importTask != null && !importTask.isCompleted - } - /** Time string of the available backup, or null if no backup is available. */ val availableBackupLiveData = MutableLiveData() @@ -115,11 +103,9 @@ class AutoBackupViewModel(application: Application) : AndroidViewModel(applicati } } - override fun onCleared() { - if (isImportTaskNotCompleted) { - importTask?.cancel(null) - } - importTask = null + fun runImportTask() { + val context: Context = getApplication() + runImportTask { JsonImportTask(context) } } } \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/BaseDataLiberationViewModel.kt b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/BaseDataLiberationViewModel.kt new file mode 100644 index 0000000000..d317f37ed0 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/BaseDataLiberationViewModel.kt @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright ยฉ 2026 Uwe Trottmann + +package com.battlelancer.seriesguide.dataliberation + +import android.app.Application +import android.widget.TextView +import androidx.core.widget.NestedScrollView +import androidx.lifecycle.AndroidViewModel +import com.battlelancer.seriesguide.R +import com.battlelancer.seriesguide.SgApp +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Supports running an import task, provides state flows for progress and import summary. + */ +open class BaseDataLiberationViewModel(application: Application) : AndroidViewModel(application) { + + data class ImportSummaryUiState( + val message: String?, + val isError: Boolean, + val handled: Boolean = false + ) { + // This introduces Android UI classes into imports, but is fine as the model is currently + // not used in unit tests. + fun applyTo( + model: BaseDataLiberationViewModel, + textViewSummary: TextView, + scrollView: NestedScrollView + ) { + textViewSummary.apply { + setTextAppearance( + if (isError) { + R.style.TextAppearance_SeriesGuide_Body2_Error + } else { + R.style.TextAppearance_SeriesGuide_Body2 + } + ) + text = message + } + if (!handled) { + model.importSummaryState.update { it.copy(handled = true) } + + // Scroll the top of the summary into view + scrollView.post { + scrollView.scrollTo(0, textViewSummary.top) + } + } + } + } + + val isInProgress = MutableStateFlow(false) + val importSummaryState = MutableStateFlow( + ImportSummaryUiState( + message = null, + isError = false, + handled = true + ) + ) + + var dataLibJob: Job? = null + + private val isDataLibTaskNotCompleted: Boolean + get() { + val dataLibJob = dataLibJob + return (dataLibJob != null && !dataLibJob.isCompleted) + } + + override fun onCleared() { + if (isDataLibTaskNotCompleted) { + dataLibJob?.cancel(null) + } + dataLibJob = null + } + + fun setInProgress(value: Boolean) { + isInProgress.value = value + } + + fun runImportTask( + jsonImportTask: () -> JsonImportTask + ) { + setInProgress(true) + + // Use global coroutine scope: rather keep this view model alive for a bit than cancel an + // in-progress import. + dataLibJob = SgApp.coroutineScope.launch { + val result = jsonImportTask().run() + + setInProgress(false) + importSummaryState.value = ImportSummaryUiState( + result.message, + result.isError + ) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/DataLiberationFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/DataLiberationFragment.kt index 9591b02d41..157b560015 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/DataLiberationFragment.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/DataLiberationFragment.kt @@ -3,7 +3,6 @@ package com.battlelancer.seriesguide.dataliberation -import android.content.Context import android.net.Uri import android.os.Bundle import android.view.LayoutInflater @@ -14,13 +13,11 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import com.battlelancer.seriesguide.R -import com.battlelancer.seriesguide.SgApp import com.battlelancer.seriesguide.databinding.FragmentDataLiberationBinding import com.battlelancer.seriesguide.dataliberation.DataLiberationTools.CreateExportFileContract import com.battlelancer.seriesguide.dataliberation.DataLiberationTools.SelectImportFileContract import com.battlelancer.seriesguide.dataliberation.JsonExportTask.Export import com.battlelancer.seriesguide.dataliberation.JsonExportTask.OnTaskProgressListener -import com.battlelancer.seriesguide.util.TextTools import com.battlelancer.seriesguide.util.ThemeUtils import com.battlelancer.seriesguide.util.ViewTools.openUriOnClick import com.battlelancer.seriesguide.util.tryLaunch @@ -109,9 +106,20 @@ class DataLiberationFragment : Fragment(), OnTaskProgressListener { model.updateImportFileNames() } - // restore UI state - if (model.isDataLibTaskNotCompleted) { - setProgressLock(true) + viewLifecycleOwner.lifecycleScope.launch { + model.importSummaryState.collect { state -> + state.applyTo( + model, + binding.textViewDataLibImportSummary, + binding.scrollViewDataLiberation + ) + } + } + + viewLifecycleOwner.lifecycleScope.launch { + model.isInProgress.collect { + setProgressLock(it) + } } } @@ -144,6 +152,7 @@ class DataLiberationFragment : Fragment(), OnTaskProgressListener { binding.progressBarDataLib.progress = completed } + // This is currently only posted by the export task @Subscribe(threadMode = ThreadMode.MAIN) fun onEvent(event: LiberationResultEvent) { event.handle(view) @@ -153,7 +162,7 @@ class DataLiberationFragment : Fragment(), OnTaskProgressListener { } // Note: export may remove an export file URI which may be used as a default import file model.updateImportFileNames() - setProgressLock(false) + model.setInProgress(false) } private fun setProgressLock(isLocked: Boolean) { @@ -178,14 +187,11 @@ class DataLiberationFragment : Fragment(), OnTaskProgressListener { private fun doDataImport() { val binding = binding ?: return - setProgressLock(true) - - val dataLibTask = JsonImportTask( - requireContext(), - binding.checkBoxDataLibShows.isChecked, binding.checkBoxDataLibLists.isChecked, - binding.checkBoxDataLibMovies.isChecked + model.runImportTask( + importShows = binding.checkBoxDataLibShows.isChecked, + importLists = binding.checkBoxDataLibLists.isChecked, + importMovies = binding.checkBoxDataLibMovies.isChecked ) - model.dataLibJob = SgApp.coroutineScope.launch { dataLibTask.run() } } private fun doDataExport(export: Export, uri: Uri?) { @@ -196,8 +202,8 @@ class DataLiberationFragment : Fragment(), OnTaskProgressListener { model.updateImportFileNames() val binding = binding ?: return - setProgressLock(true) + model.setInProgress(true) val exportTask = JsonExportTask( requireContext(), this@DataLiberationFragment, @@ -265,12 +271,10 @@ class DataLiberationFragment : Fragment(), OnTaskProgressListener { } constructor( - context: Context, message: String?, - errorCause: String?, showIndefinite: Boolean ) { - this.message = TextTools.dotSeparate(context, message, errorCause) + this.message = message this.showIndefinite = showIndefinite } diff --git a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/DataLiberationViewModel.kt b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/DataLiberationViewModel.kt index 0556b9f495..cf289518ff 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/DataLiberationViewModel.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/DataLiberationViewModel.kt @@ -4,20 +4,19 @@ package com.battlelancer.seriesguide.dataliberation import android.app.Application -import androidx.lifecycle.AndroidViewModel +import android.content.Context import androidx.lifecycle.viewModelScope import com.battlelancer.seriesguide.R import com.battlelancer.seriesguide.dataliberation.DataLiberationTools.getFileNameFromUriOrLastPathSegment import com.battlelancer.seriesguide.dataliberation.JsonExportTask.Export import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch /** * Try to keep the backup tasks on config changes so they do not have to be finished. */ -class DataLiberationViewModel(application: Application) : AndroidViewModel(application) { +class DataLiberationViewModel(application: Application) : BaseDataLiberationViewModel(application) { data class ImportFiles( val fileNameShows: String?, @@ -28,21 +27,6 @@ class DataLiberationViewModel(application: Application) : AndroidViewModel(appli val importFiles = MutableStateFlow(ImportFiles("", "", "", "")) - var dataLibJob: Job? = null - - val isDataLibTaskNotCompleted: Boolean - get() { - val dataLibJob = dataLibJob - return (dataLibJob != null && !dataLibJob.isCompleted) - } - - override fun onCleared() { - if (isDataLibTaskNotCompleted) { - dataLibJob?.cancel(null) - } - dataLibJob = null - } - fun updateImportFileNames() { viewModelScope.launch(Dispatchers.IO) { val context = getApplication() @@ -68,4 +52,20 @@ class DataLiberationViewModel(application: Application) : AndroidViewModel(appli } } + fun runImportTask( + importShows: Boolean, + importLists: Boolean, + importMovies: Boolean + ) { + val context: Context = getApplication() + runImportTask { + JsonImportTask( + context, + importShows, + importLists, + importMovies + ) + } + } + } \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonExportTask.kt b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonExportTask.kt index dc6658aeb0..02eabfbf31 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonExportTask.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonExportTask.kt @@ -140,7 +140,8 @@ open class JsonExportTask( EventBus.getDefault() .post( LiberationResultEvent( - context, message, errorCause, showIndefinite + TextTools.dotSeparate(context, message, errorCause), + showIndefinite ) ) } diff --git a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt index dc262cca87..52f8e75817 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt @@ -9,7 +9,6 @@ import android.os.ParcelFileDescriptor import androidx.annotation.VisibleForTesting import com.battlelancer.seriesguide.R import com.battlelancer.seriesguide.SgApp -import com.battlelancer.seriesguide.dataliberation.DataLiberationFragment.LiberationResultEvent import com.battlelancer.seriesguide.dataliberation.ImportTools.toSgEpisodeForImport import com.battlelancer.seriesguide.dataliberation.ImportTools.toSgListForImport import com.battlelancer.seriesguide.dataliberation.ImportTools.toSgListItemForImport @@ -50,7 +49,6 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext -import org.greenrobot.eventbus.EventBus import timber.log.Timber import java.io.File import java.io.FileInputStream @@ -83,6 +81,9 @@ class JsonImportTask( private val isImportLists: Boolean private val isImportMovies: Boolean + private var importedMovies: Int = 0 + private val skippedMovies: MutableList = mutableListOf() + @VisibleForTesting var errorCause: String? = null private set @@ -127,9 +128,14 @@ class JsonImportTask( isImportingAutoBackup = true } - suspend fun run(): Int { + data class Result( + val message: String, + val isError: Boolean = false + ) + + suspend fun run(): Result { return withContext(Dispatchers.IO) { - val result = if (SgSyncAdapter.isSyncActive(context, false)) { + val resultCode = if (SgSyncAdapter.isSyncActive(context, false)) { // Do not import if an update task is running ERROR_LARGE_DB_OP } else { @@ -138,8 +144,7 @@ class JsonImportTask( doInBackground(this) } } - onPostExecute(result) - return@withContext result + return@withContext buildResult(resultCode) } } @@ -186,37 +191,57 @@ class JsonImportTask( return SUCCESS } - private fun onPostExecute(result: Int) { - val message: String - val showIndefinite: Boolean - when (result) { + private fun buildResult(resultCode: Int): Result { + return when (resultCode) { SUCCESS -> { - message = context.getString(R.string.status_successful) - showIndefinite = false - } - - ERROR_FILE_ACCESS -> { - message = TextTools.dotSeparate( - context, - R.string.status_failure, - R.string.status_failed_file_access - ) - showIndefinite = true - } + val skippedMoviesSummary = skippedMovies.joinToString(separator = "\n") { + "tmdb_id = ${it.tmdb_id}, imdb_id = ${it.imdb_id}, title = ${it.title}" + } - ERROR_LARGE_DB_OP -> { - message = context.getString(R.string.update_inprogress) - showIndefinite = false + // TODO Add other types, localize + val summary = """ + ${context.getString(R.string.status_successful)} + + Movies: $importedMovies imported, ${skippedMovies.size} skipped + + Skipped movies: + $skippedMoviesSummary + """.trimIndent() + + Result(summary) } - else -> { - message = context.getString(R.string.status_failure) - showIndefinite = true - } + ERROR_FILE_ACCESS -> Result( + TextTools.dotSeparate( + context, + TextTools.dotSeparate( + context, + R.string.status_failure, + R.string.status_failed_file_access + ), + errorCause + ), + isError = true + ) + + ERROR_LARGE_DB_OP -> Result( + TextTools.dotSeparate( + context, + context.getString(R.string.update_inprogress), + errorCause + ), + isError = true + ) + + else -> Result( + TextTools.dotSeparate( + context, + context.getString(R.string.status_failure), + errorCause + ), + isError = true + ) } - EventBus.getDefault().post( - LiberationResultEvent(context, message, errorCause, showIndefinite) - ) } private fun openFilesAndImport(export: Export): Int { @@ -397,6 +422,7 @@ class JsonImportTask( if (movie.tmdb_id <= 0) { // Try to look up via IMDB ID if (movie.imdb_id.isNullOrBlank()) { + skippedMovies.add(movie) return // Skip, needs IMDB ID for look-up } val tmdbIdResponse = runBlocking { @@ -422,10 +448,12 @@ class JsonImportTask( movie.imdb_id, movie.title ) + skippedMovies.add(movie) return // Skip, TMDB ID required } } sgMovieHelper.insertMovie(movie.toSgMovieForImport()) + importedMovies++ } /** diff --git a/app/src/main/res/layout/fragment_auto_backup.xml b/app/src/main/res/layout/fragment_auto_backup.xml index c917da9458..4667a84f42 100644 --- a/app/src/main/res/layout/fragment_auto_backup.xml +++ b/app/src/main/res/layout/fragment_auto_backup.xml @@ -294,6 +294,18 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/textViewAutoBackupImportCloudWarningDetails" /> + + diff --git a/app/src/main/res/layout/fragment_data_liberation.xml b/app/src/main/res/layout/fragment_data_liberation.xml index d803f1f096..09dfaa708d 100644 --- a/app/src/main/res/layout/fragment_data_liberation.xml +++ b/app/src/main/res/layout/fragment_data_liberation.xml @@ -260,6 +260,18 @@ app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf="@id/textViewDataLibImportCloudWarningDetails" /> + + diff --git a/app/src/test/java/com/battlelancer/seriesguide/dataliberation/JsonImportTaskTest.kt b/app/src/test/java/com/battlelancer/seriesguide/dataliberation/JsonImportTaskTest.kt index 3e14de0c82..57c9174a3e 100644 --- a/app/src/test/java/com/battlelancer/seriesguide/dataliberation/JsonImportTaskTest.kt +++ b/app/src/test/java/com/battlelancer/seriesguide/dataliberation/JsonImportTaskTest.kt @@ -34,6 +34,7 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import java.io.File import java.nio.file.Files import kotlin.io.path.writeText @@ -62,6 +63,17 @@ class JsonImportTaskTest { */ private fun anyNotNull(type: Class): T = Mockito.any(type) + private fun createTempJsonFile(name: String, contents: String): File = + Files.createTempFile(name, null) + .also { it.writeText(contents) } + .toFile() + + private suspend fun JsonImportTask.runAndAssertSuccess() { + val result = run() + assertThat(errorCause).isNull() + assertThat(result.isError).isFalse() + } + @Test fun importShow_modelAsExpected() = runTest { val sgShow2Helper = mock(SgShow2Helper::class.java) @@ -80,12 +92,13 @@ class JsonImportTaskTest { mock(SgListHelper::class.java), mock(MovieHelper::class.java), mock() - ) - - // Test data from export task test: single show, two seasons, each with two episodes. - val testBackupFile = Files.createTempFile("seriesguide-shows-json", null) - testBackupFile.writeText(JsonExportTaskTest.expectedJsonShows) - importTask.testBackupFile = testBackupFile.toFile() + ).apply { + // Test data from export task test: single show, two seasons, each with two episodes. + testBackupFile = createTempJsonFile( + "seriesguide-shows-json", + JsonExportTaskTest.expectedJsonShows + ) + } // Return row ids like it would be an insert on an empty database, so start at 1. `when`(sgShow2Helper.insertShow(anyNotNull(SgShow2::class.java))).thenReturn(1) @@ -95,9 +108,7 @@ class JsonImportTaskTest { return@then seasonRowId } - val result = importTask.run() - assertThat(importTask.errorCause).isNull() - assertThat(result).isEqualTo(JsonImportTask.SUCCESS) + importTask.runAndAssertSuccess() val expectedShow = JsonExportTaskTest.listOfTestShows[0].copy( id = 0, // insert @@ -145,7 +156,7 @@ class JsonImportTaskTest { fun importList_modelAsExpected() = runTest { val sgListHelper = mock(SgListHelper::class.java) - val importTask = JsonImportTask( + JsonImportTask( context, importShows = false, importLists = true, @@ -157,16 +168,13 @@ class JsonImportTaskTest { sgListHelper, mock(MovieHelper::class.java), mock() - ) - - // Test data from export task test: two lists, the first with one item of each type. - val testBackupFile = Files.createTempFile("seriesguide-lists-json", null) - testBackupFile.writeText(JsonExportTaskTest.expectedJsonLists) - importTask.testBackupFile = testBackupFile.toFile() - - val result = importTask.run() - assertThat(importTask.errorCause).isNull() - assertThat(result).isEqualTo(JsonImportTask.SUCCESS) + ).apply { + // Test data from export task test: two lists, the first with one item of each type. + testBackupFile = createTempJsonFile( + "seriesguide-lists-json", + JsonExportTaskTest.expectedJsonLists + ) + }.runAndAssertSuccess() // List 1 with items verify(sgListHelper).insertList( @@ -181,27 +189,19 @@ class JsonImportTaskTest { ) } - private suspend fun importMovies(importTask: JsonImportTask, json: String) { - @Suppress("BlockingMethodInNonBlockingContext") - val testBackupFile = Files.createTempFile("seriesguide-movies-json", null) - testBackupFile.writeText(json) - importTask.testBackupFile = testBackupFile.toFile() - - val result = importTask.run() - assertThat(importTask.errorCause).isNull() - assertThat(result).isEqualTo(JsonImportTask.SUCCESS) - } - @Test fun importMovie_modelAsExpected() = runTest { - val importTask = JsonImportTask( + JsonImportTask( context, importShows = false, importLists = false, importMovies = true - ) - - importMovies(importTask, JsonExportTaskTest.expectedJsonMovies) + ).apply { + testBackupFile = createTempJsonFile( + "seriesguide-movies-json", + JsonExportTaskTest.expectedJsonMovies + ) + }.runAndAssertSuccess() // Two movies, second one with only TMDB ID and title val insertedMovies = testDb.movieHelper().getAllMovies() @@ -241,7 +241,7 @@ class JsonImportTaskTest { val testImdbId = "test-imdbid" @Language("json") - val jsonMovieMissingRequiredValues = + val jsonToImport = """ [ {"imdb_id":"test-imdbid","title":"First Movie"} @@ -256,7 +256,7 @@ class JsonImportTaskTest { val movieHelper = testDb.movieHelper() - val importTask = JsonImportTask( + JsonImportTask( context, importShows = false, importLists = false, @@ -268,9 +268,9 @@ class JsonImportTaskTest { mock(), movieHelper, tmdbTools - ) - - importMovies(importTask, jsonMovieMissingRequiredValues) + ).apply { + testBackupFile = createTempJsonFile("seriesguide-movies-json", jsonToImport) + }.runAndAssertSuccess() val importedMovies = movieHelper.getAllMovies() assertThat(importedMovies).hasSize(1) @@ -280,21 +280,21 @@ class JsonImportTaskTest { @Test fun importMovie_missingRequired_notImported() = runTest { @Language("json") - val jsonMovieMissingRequiredValues = + val jsonToImport = """ [ {"title":"First Movie"} ] """.trimIndent() - val importTask = JsonImportTask( + JsonImportTask( context, importShows = false, importLists = false, importMovies = true - ) - - importMovies(importTask, jsonMovieMissingRequiredValues) + ).apply { + testBackupFile = createTempJsonFile("seriesguide-movies-json", jsonToImport) + }.runAndAssertSuccess() // Nothing is imported assertThat(testDb.movieHelper().getAllMovies()).hasSize(0) From 17ce44abf54f9ff81d01d5b9c5330babee81eefb Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 9 Jul 2026 14:11:33 +0200 Subject: [PATCH 05/10] JsonImportTask: support setting tests files for each type --- .../dataliberation/JsonImportTask.kt | 36 +++++++++++++++---- .../dataliberation/JsonImportTaskTest.kt | 32 +++++++++++------ 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt index 52f8e75817..bb95d27858 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt @@ -88,12 +88,10 @@ class JsonImportTask( var errorCause: String? = null private set - /** - * If set will use this file instead of opening via URI, which seems broken with Robolectric - * (openFileDescriptor throws FileNotFoundException). - */ - @VisibleForTesting - var testBackupFile: File? = null + // Note: Path or NIO APIs require Android 8 (API 26) or coreLibraryDesugaring + private var testBackupFileShows: File? = null + private var testBackupFileMovies: File? = null + private var testBackupFileLists: File? = null init { isImportingAutoBackup = false @@ -244,9 +242,33 @@ class JsonImportTask( } } + /** + * If set, will use these files instead of opening via URI, which seems broken with Robolectric + * (openFileDescriptor throws FileNotFoundException). + */ + fun setTestBackupFiles( + fileShows: File? = null, + fileMovies: File? = null, + fileLists: File? = null + ) { + this.testBackupFileShows = fileShows + this.testBackupFileMovies = fileMovies + this.testBackupFileLists = fileLists + } + + private fun getTestBackupFile(export: Export): File? { + return when (export) { + Export.Shows -> testBackupFileShows + Export.Lists -> testBackupFileLists + Export.Movies -> testBackupFileMovies + } + } + private fun openFilesAndImport(export: Export): Int { if (!isImportingAutoBackup) { - val testBackupFile = testBackupFile + + val testBackupFile = getTestBackupFile(export) + var pfd: ParcelFileDescriptor? = null if (testBackupFile == null) { // Make sure a file is configured... diff --git a/app/src/test/java/com/battlelancer/seriesguide/dataliberation/JsonImportTaskTest.kt b/app/src/test/java/com/battlelancer/seriesguide/dataliberation/JsonImportTaskTest.kt index 57c9174a3e..e60a50b898 100644 --- a/app/src/test/java/com/battlelancer/seriesguide/dataliberation/JsonImportTaskTest.kt +++ b/app/src/test/java/com/battlelancer/seriesguide/dataliberation/JsonImportTaskTest.kt @@ -94,9 +94,11 @@ class JsonImportTaskTest { mock() ).apply { // Test data from export task test: single show, two seasons, each with two episodes. - testBackupFile = createTempJsonFile( - "seriesguide-shows-json", - JsonExportTaskTest.expectedJsonShows + setTestBackupFiles( + fileShows = createTempJsonFile( + "seriesguide-shows-json", + JsonExportTaskTest.expectedJsonShows + ) ) } @@ -170,9 +172,11 @@ class JsonImportTaskTest { mock() ).apply { // Test data from export task test: two lists, the first with one item of each type. - testBackupFile = createTempJsonFile( - "seriesguide-lists-json", - JsonExportTaskTest.expectedJsonLists + setTestBackupFiles( + fileLists = createTempJsonFile( + "seriesguide-lists-json", + JsonExportTaskTest.expectedJsonLists + ) ) }.runAndAssertSuccess() @@ -197,9 +201,11 @@ class JsonImportTaskTest { importLists = false, importMovies = true ).apply { - testBackupFile = createTempJsonFile( - "seriesguide-movies-json", - JsonExportTaskTest.expectedJsonMovies + setTestBackupFiles( + fileMovies = createTempJsonFile( + "seriesguide-movies-json", + JsonExportTaskTest.expectedJsonMovies + ) ) }.runAndAssertSuccess() @@ -269,7 +275,9 @@ class JsonImportTaskTest { movieHelper, tmdbTools ).apply { - testBackupFile = createTempJsonFile("seriesguide-movies-json", jsonToImport) + setTestBackupFiles( + fileMovies = createTempJsonFile("seriesguide-movies-json", jsonToImport) + ) }.runAndAssertSuccess() val importedMovies = movieHelper.getAllMovies() @@ -293,7 +301,9 @@ class JsonImportTaskTest { importLists = false, importMovies = true ).apply { - testBackupFile = createTempJsonFile("seriesguide-movies-json", jsonToImport) + setTestBackupFiles( + fileMovies = createTempJsonFile("seriesguide-movies-json", jsonToImport) + ) }.runAndAssertSuccess() // Nothing is imported From 39e46ce544a991b042ac9fb6ac70894b011bc547 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 9 Jul 2026 14:41:43 +0200 Subject: [PATCH 06/10] Import: support new imdb-movie list item, mapped to TMDB ID on insert --- CHANGELOG.md | 1 + .../dataliberation/JsonExportTask.kt | 6 ++ .../dataliberation/JsonImportTask.kt | 22 +++++- .../movies/database/MovieHelper.kt | 3 + .../dataliberation/JsonImportTaskTest.kt | 73 +++++++++++++++++++ docs/backup-json-schema.md | 19 +++++ 6 files changed, 120 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 252ec71575..754e30728f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Releases marked with ๐Ÿงช (or previously with the "beta" suffix) were released o ### Next release * ๐Ÿ”ง Import: support importing movies with only IMDB IDs. +* ๐Ÿ”ง Import: support new "imdb-movie" list item type, maps to added movie using "externalId" as IMDB ID on import, otherwise skips. * ๐Ÿ”ง Import: restore original data on failure. ## Version 2026.2 diff --git a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonExportTask.kt b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonExportTask.kt index 02eabfbf31..4b5ceff421 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonExportTask.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonExportTask.kt @@ -466,6 +466,12 @@ open class JsonExportTask( const val TMDB_SHOW = "tmdb-show" const val SEASON = "season" const val EPISODE = "episode" + + /** + * Special type only supported when importing and movie with that IMDB ID is in the + * database already. + */ + const val IMDB_MOVIE = "imdb-movie" } } \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt index bb95d27858..008d37f750 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt @@ -16,6 +16,7 @@ import com.battlelancer.seriesguide.dataliberation.ImportTools.toSgMovieForImpor import com.battlelancer.seriesguide.dataliberation.ImportTools.toSgSeasonForImport import com.battlelancer.seriesguide.dataliberation.ImportTools.toSgShowForImport import com.battlelancer.seriesguide.dataliberation.JsonExportTask.Export +import com.battlelancer.seriesguide.dataliberation.JsonExportTask.ListItemTypesExport import com.battlelancer.seriesguide.dataliberation.model.List import com.battlelancer.seriesguide.dataliberation.model.Movie import com.battlelancer.seriesguide.dataliberation.model.Season @@ -163,8 +164,8 @@ class JsonImportTask( } } - if (isImportLists) { - result = openFilesAndImport(Export.Lists) + if (isImportMovies) { + result = openFilesAndImport(Export.Movies) if (result != SUCCESS) { return result } @@ -173,8 +174,9 @@ class JsonImportTask( } } - if (isImportMovies) { - result = openFilesAndImport(Export.Movies) + // Import lists last so imdb-movie list items can be mapped + if (isImportLists) { + result = openFilesAndImport(Export.Lists) if (result != SUCCESS) { return result } @@ -584,6 +586,18 @@ class JsonImportTask( // Insert the list items val items = ArrayList() for (item in list.items) { + + // Special type for movies: map to TMDB ID if movie with that IMDB ID is in the database + if (ListItemTypesExport.IMDB_MOVIE == item.type) { + val tmdbIdOrNull = sgMovieHelper.getTmdbIdByImdbId(item.externalId) + if (tmdbIdOrNull == null) { + Timber.i("Skipping imdb-movie list item: no movie in database with IMDB ID ${item.externalId}") + continue + } + item.externalId = tmdbIdOrNull.toString() + item.type = ListItemTypesExport.MOVIE + } + item.toSgListItemForImport(sgList.listId) ?.let { items.add(it) } } diff --git a/app/src/main/java/com/battlelancer/seriesguide/movies/database/MovieHelper.kt b/app/src/main/java/com/battlelancer/seriesguide/movies/database/MovieHelper.kt index 5ddf9b999d..288a8bc525 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/movies/database/MovieHelper.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/movies/database/MovieHelper.kt @@ -69,6 +69,9 @@ interface MovieHelper { ) fun getTmdbIdsOfMoviesNotOnAnyList(): List + @Query("SELECT movies_tmdbid FROM movies WHERE movies_imdbid=:imdbId") + fun getTmdbIdByImdbId(imdbId: String): Int? + @Query("SELECT movies_tmdbid, movies_incollection, movies_inwatchlist, movies_watched, movies_plays FROM movies") fun getMovieFlags(): List diff --git a/app/src/test/java/com/battlelancer/seriesguide/dataliberation/JsonImportTaskTest.kt b/app/src/test/java/com/battlelancer/seriesguide/dataliberation/JsonImportTaskTest.kt index e60a50b898..6deb8c8e8e 100644 --- a/app/src/test/java/com/battlelancer/seriesguide/dataliberation/JsonImportTaskTest.kt +++ b/app/src/test/java/com/battlelancer/seriesguide/dataliberation/JsonImportTaskTest.kt @@ -10,6 +10,8 @@ import com.battlelancer.seriesguide.lists.database.SgList import com.battlelancer.seriesguide.lists.database.SgListHelper import com.battlelancer.seriesguide.movies.database.MovieHelper import com.battlelancer.seriesguide.movies.database.SgMovie +import com.battlelancer.seriesguide.provider.SeriesGuideContract +import com.battlelancer.seriesguide.provider.SeriesGuideContract.ListItemTypes import com.battlelancer.seriesguide.provider.SgRoomDatabase import com.battlelancer.seriesguide.shows.database.SgEpisode2Helper import com.battlelancer.seriesguide.shows.database.SgSeason2 @@ -193,6 +195,77 @@ class JsonImportTaskTest { ) } + @Language("json") + val jsonListMovieImdbId = + """ + [ + {"name":"Movie with IMDB ID","items":[{"externalId":"test-imdbid","type":"imdb-movie"}]} + ] + """.trimIndent() + + @Test + fun importList_movieWithKnownImdbId_mappedToTmdbId() = runTest { + @Language("json") + val moviesJson = + """ + [ + {"tmdb_id":42,"imdb_id":"test-imdbid","title":"First Movie"} + ] + """.trimIndent() + + JsonImportTask( + context, + importShows = false, + importLists = true, + importMovies = true + ).apply { + setTestBackupFiles( + fileMovies = createTempJsonFile( + "seriesguide-movies-json", + moviesJson + ), + fileLists = createTempJsonFile( + "seriesguide-lists-json", + jsonListMovieImdbId + ) + ) + }.runAndAssertSuccess() + + // The movie should be inserted (first) + assertThat(testDb.movieHelper().getAllMovies()).hasSize(1) + + // The movie list item should be imported and use the TMDB ID of the movie + val lists = testDb.sgListHelper().getListsForExport() + assertThat(lists).hasSize(1) + val listItems = testDb.sgListHelper().getListItemsForExport(lists[0].listId) + assertThat(listItems).hasSize(1) + assertThat(listItems[0].itemRefId).isEqualTo("42") + assertThat(listItems[0].type).isEqualTo(ListItemTypes.TMDB_MOVIE) + } + + @Test + fun importList_movieWithUnknownImdbId_skipped() = runTest { + JsonImportTask( + context, + importShows = false, + importLists = true, + importMovies = false + ).apply { + setTestBackupFiles( + fileLists = createTempJsonFile( + "seriesguide-lists-json", + jsonListMovieImdbId + ) + ) + }.runAndAssertSuccess() + + // The list should be imported, but not the item + val lists = testDb.sgListHelper().getListsForExport() + assertThat(lists).hasSize(1) + val listItems = testDb.sgListHelper().getListItemsForExport(lists[0].listId) + assertThat(listItems).hasSize(0) + } + @Test fun importMovie_modelAsExpected() = runTest { JsonImportTask( diff --git a/docs/backup-json-schema.md b/docs/backup-json-schema.md index 53acd26840..ed935bdc99 100644 --- a/docs/backup-json-schema.md +++ b/docs/backup-json-schema.md @@ -189,6 +189,25 @@ to the library. ] ``` +### Import movie list items using IMDB IDs + +Movie list items using an IMDB ID as `externalId` can be imported if a movie with a matching IMDB ID +is already in the database or imported at the same time. Use the special `imdb-movie` type: + +```json +[ + { + "name": "List with movie with IMDB ID", + "items": [ + { + "externalId": "tt1022603", + "type": "imdb-movie" + } + ] + } +] +``` + ## Movies The JSON is an array of [Movie](/app/src/main/java/com/battlelancer/seriesguide/dataliberation/model/Movie.java) From a3b30224c048dda42eaec3bb6693bd3a0ffccba0 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 9 Jul 2026 15:26:15 +0200 Subject: [PATCH 07/10] Docs: add example for importing shows using TVDB IDs --- docs/backup-json-schema.md | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/docs/backup-json-schema.md b/docs/backup-json-schema.md index ed935bdc99..c94f3994b6 100644 --- a/docs/backup-json-schema.md +++ b/docs/backup-json-schema.md @@ -114,6 +114,8 @@ two-letter language code (like `en`) that can be mapped to one of the supported ] ``` +### Import shows using minimal values + When importing from another app or data source a minimal amount of values can be enough, here is an example: @@ -142,6 +144,46 @@ example: While the `language` is technically not required, it's easier to set it in the JSON than changing it for each show after importing. +### Import shows using TVDB IDs + +Shows can be imported using a TVDB ID instead of a TMDB ID. However, only if the show has the TVDB +ID set as an external ID on TMDB will it be migrated to TMDB data and support all functionality. + +Note that only the `tvdb_id` of the show needs to be valid. You can use any `0` or greater value for +seasons and episodes. + +```json +[ + { + "tvdb_id": 332331, + "title": "Altered Carbon", + "language": "pt-BR", + "favorite": false, + "seasons": [ + { + "tvdb_id": 1, + "season": 1, + "episodes": [ + { + "tvdb_id": 1, + "episode": 1, + "title": "Out of the Past", + "watched": true, + "plays": 1 + } + ] + } + ] + } +] +``` + +Note: seasons and episodes are mapped to their TMDB equivalent based on their number (`season` and +`episode` values). Depending on how episodes are organized or available on TMDB, not all episodes +can be migrated. For example, for anime on TMDB all episodes might be part of the first season. Or +later seasons might be split into a separate show entry. If affected episodes are watched or added +to your collection, you would have to manually mark the replacements. + ## Lists The JSON is structured like this (see the classes, they document each property): From 7b782a8c827fe3eb85faa58797acc446ac79bc57 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 9 Jul 2026 16:18:14 +0200 Subject: [PATCH 08/10] JsonImportTask: include shows and list items, localize summary --- CHANGELOG.md | 1 + .../dataliberation/JsonImportTask.kt | 88 ++++++++++++++++--- 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 754e30728f..0f607f5b33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Releases marked with ๐Ÿงช (or previously with the "beta" suffix) were released o * ๐Ÿ”ง Import: support importing movies with only IMDB IDs. * ๐Ÿ”ง Import: support new "imdb-movie" list item type, maps to added movie using "externalId" as IMDB ID on import, otherwise skips. + ๐Ÿ”ง Import: display a simple summary of imported and skipped items. * ๐Ÿ”ง Import: restore original data on failure. ## Version 2026.2 diff --git a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt index 008d37f750..234c402999 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt @@ -18,6 +18,7 @@ import com.battlelancer.seriesguide.dataliberation.ImportTools.toSgShowForImport import com.battlelancer.seriesguide.dataliberation.JsonExportTask.Export import com.battlelancer.seriesguide.dataliberation.JsonExportTask.ListItemTypesExport import com.battlelancer.seriesguide.dataliberation.model.List +import com.battlelancer.seriesguide.dataliberation.model.ListItem import com.battlelancer.seriesguide.dataliberation.model.Movie import com.battlelancer.seriesguide.dataliberation.model.Season import com.battlelancer.seriesguide.dataliberation.model.Show @@ -82,7 +83,11 @@ class JsonImportTask( private val isImportLists: Boolean private val isImportMovies: Boolean + private var importedShows: Int = 0 + private var importedListItems: Int = 0 private var importedMovies: Int = 0 + private var skippedShows: MutableList = mutableListOf() + private var skippedListItems: MutableList = mutableListOf() private val skippedMovies: MutableList = mutableListOf() @VisibleForTesting @@ -191,22 +196,67 @@ class JsonImportTask( return SUCCESS } + private fun MutableList.skippedItemsString( + transform: ((T) -> CharSequence)? = null + ): String { + return if (isEmpty()) { + "" + } else { + joinToString( + prefix = "\n", + separator = "\n", + limit = SUMMARY_ITEM_LIMIT, + truncated = "+ ${skippedMovies.size - SUMMARY_ITEM_LIMIT}", + transform = transform + ) + } + } + private fun buildResult(resultCode: Int): Result { return when (resultCode) { SUCCESS -> { - val skippedMoviesSummary = skippedMovies.joinToString(separator = "\n") { - "tmdb_id = ${it.tmdb_id}, imdb_id = ${it.imdb_id}, title = ${it.title}" + // As this is displayed in a TextView, try to stay below 10.000 characters + val skippedShowsSummary = skippedShows.skippedItemsString { + "title = ${it.title}, tmdb_id = ${it.tmdb_id}, tvdb_id = ${it.tvdb_id}" } - // TODO Add other types, localize - val summary = """ - ${context.getString(R.string.status_successful)} - - Movies: $importedMovies imported, ${skippedMovies.size} skipped - - Skipped movies: - $skippedMoviesSummary - """.trimIndent() + val skippedMoviesSummary = skippedMovies.skippedItemsString { + "title = ${it.title}, tmdb_id = ${it.tmdb_id}, imdb_id = ${it.imdb_id}" + } + + val skippedListItemsSummary = skippedListItems.skippedItemsString { + "type = ${it.type}, externalId = ${it.externalId}" + } + + val stringSuccess = context.getString(R.string.status_successful) + val stringShows = context.getString(R.string.shows) + val stringMovies = context.getString(R.string.movies) + val stringLists = context.getString(R.string.lists) + + val summary = buildString { + appendLine(stringSuccess) + + appendLine() + + appendLine(stringShows) + append("โœ”๏ธ ").appendLine(importedShows) + append("โŒ ").append(skippedShows.size) + appendLine(skippedShowsSummary) + + appendLine() + + appendLine(stringLists) + append("โœ”๏ธ ").appendLine(importedListItems) + append("โŒ ").append(skippedListItems.size) + appendLine(skippedListItemsSummary) + + appendLine() + + appendLine(stringMovies) + append("โœ”๏ธ ").appendLine(importedMovies) + append("โŒ ").append(skippedMovies.size) + appendLine(skippedMoviesSummary) + } Result(summary) } @@ -496,6 +546,7 @@ class JsonImportTask( if ((show.tmdb_id == null || show.tmdb_id!! <= 0) && (show.tvdb_id == null || show.tvdb_id!! <= 0)) { // valid id required + skippedShows.add(show) return } @@ -522,6 +573,8 @@ class JsonImportTask( // Parse and insert seasons and episodes. insertSeasonsAndEpisodes(show, showId) + + importedShows++ } private fun insertSeasonsAndEpisodes(show: Show, showId: Long) { @@ -592,17 +645,24 @@ class JsonImportTask( val tmdbIdOrNull = sgMovieHelper.getTmdbIdByImdbId(item.externalId) if (tmdbIdOrNull == null) { Timber.i("Skipping imdb-movie list item: no movie in database with IMDB ID ${item.externalId}") + skippedListItems.add(item) continue } item.externalId = tmdbIdOrNull.toString() item.type = ListItemTypesExport.MOVIE } - item.toSgListItemForImport(sgList.listId) - ?.let { items.add(it) } + val listItemForImport = item.toSgListItemForImport(sgList.listId) + if (listItemForImport != null) { + items.add(listItemForImport) + } else { + Timber.i("Skipping list item (type = ${item.type}, externalId = ${item.externalId})") + skippedListItems.add(item) + } } sgListHelper.insertListItems(items) + importedListItems += items.size } companion object { @@ -610,5 +670,7 @@ class JsonImportTask( private const val ERROR = -1 private const val ERROR_LARGE_DB_OP = -2 private const val ERROR_FILE_ACCESS = -3 + + private const val SUMMARY_ITEM_LIMIT = 50 } } \ No newline at end of file From 2a567bb2b2eb6b3519c152602793c9a9c63ae8f2 Mon Sep 17 00:00:00 2001 From: Uwe Date: Fri, 10 Jul 2026 09:41:55 +0200 Subject: [PATCH 09/10] Import/export: use view model for coroutine scope and export progress --- .../BaseDataLiberationViewModel.kt | 23 +++------------- .../dataliberation/DataLiberationFragment.kt | 19 +++++++------ .../dataliberation/DataLiberationViewModel.kt | 27 +++++++++++++++++-- 3 files changed, 37 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/BaseDataLiberationViewModel.kt b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/BaseDataLiberationViewModel.kt index d317f37ed0..7f29e9111f 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/BaseDataLiberationViewModel.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/BaseDataLiberationViewModel.kt @@ -7,9 +7,9 @@ import android.app.Application import android.widget.TextView import androidx.core.widget.NestedScrollView import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope import com.battlelancer.seriesguide.R -import com.battlelancer.seriesguide.SgApp -import kotlinx.coroutines.Job +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -61,21 +61,6 @@ open class BaseDataLiberationViewModel(application: Application) : AndroidViewMo ) ) - var dataLibJob: Job? = null - - private val isDataLibTaskNotCompleted: Boolean - get() { - val dataLibJob = dataLibJob - return (dataLibJob != null && !dataLibJob.isCompleted) - } - - override fun onCleared() { - if (isDataLibTaskNotCompleted) { - dataLibJob?.cancel(null) - } - dataLibJob = null - } - fun setInProgress(value: Boolean) { isInProgress.value = value } @@ -85,9 +70,7 @@ open class BaseDataLiberationViewModel(application: Application) : AndroidViewMo ) { setInProgress(true) - // Use global coroutine scope: rather keep this view model alive for a bit than cancel an - // in-progress import. - dataLibJob = SgApp.coroutineScope.launch { + viewModelScope.launch(Dispatchers.Default) { val result = jsonImportTask().run() setInProgress(false) diff --git a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/DataLiberationFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/DataLiberationFragment.kt index 157b560015..e79d094eea 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/DataLiberationFragment.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/DataLiberationFragment.kt @@ -17,7 +17,6 @@ import com.battlelancer.seriesguide.databinding.FragmentDataLiberationBinding import com.battlelancer.seriesguide.dataliberation.DataLiberationTools.CreateExportFileContract import com.battlelancer.seriesguide.dataliberation.DataLiberationTools.SelectImportFileContract import com.battlelancer.seriesguide.dataliberation.JsonExportTask.Export -import com.battlelancer.seriesguide.dataliberation.JsonExportTask.OnTaskProgressListener import com.battlelancer.seriesguide.util.ThemeUtils import com.battlelancer.seriesguide.util.ViewTools.openUriOnClick import com.battlelancer.seriesguide.util.tryLaunch @@ -31,7 +30,7 @@ import org.greenrobot.eventbus.ThreadMode * One button export or import of shows, lists and movies to or from a JSON file. * Uses Storage Access Framework so no permissions are required. */ -class DataLiberationFragment : Fragment(), OnTaskProgressListener { +class DataLiberationFragment : Fragment() { private var binding: FragmentDataLiberationBinding? = null private val model: DataLiberationViewModel by viewModels() @@ -121,6 +120,12 @@ class DataLiberationFragment : Fragment(), OnTaskProgressListener { setProgressLock(it) } } + + viewLifecycleOwner.lifecycleScope.launch { + model.exportProgressState.collect { + updateProgressBar(it.total, it.completed) + } + } } private fun updateImportButtonEnabledState() { @@ -145,7 +150,7 @@ class DataLiberationFragment : Fragment(), OnTaskProgressListener { binding = null } - override fun onProgressUpdate(total: Int, completed: Int) { + private fun updateProgressBar(total: Int, completed: Int) { val binding = binding ?: return binding.progressBarDataLib.isIndeterminate = total == completed binding.progressBarDataLib.max = total @@ -203,13 +208,7 @@ class DataLiberationFragment : Fragment(), OnTaskProgressListener { val binding = binding ?: return - model.setInProgress(true) - val exportTask = JsonExportTask( - requireContext(), - this@DataLiberationFragment, - binding.checkBoxDataLibFullDump.isChecked - ) - model.dataLibJob = exportTask.launch(export) + model.runExportTask(export, binding.checkBoxDataLibFullDump.isChecked) } private val createShowExportFileResult = diff --git a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/DataLiberationViewModel.kt b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/DataLiberationViewModel.kt index cf289518ff..2cbfb4cf64 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/DataLiberationViewModel.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/DataLiberationViewModel.kt @@ -14,9 +14,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch /** - * Try to keep the backup tasks on config changes so they do not have to be finished. + * A [BaseDataLiberationViewModel] that also can run and report progress for an export task and + * keeps track of import files. */ -class DataLiberationViewModel(application: Application) : BaseDataLiberationViewModel(application) { +class DataLiberationViewModel(application: Application) : BaseDataLiberationViewModel(application), + JsonExportTask.OnTaskProgressListener { data class ImportFiles( val fileNameShows: String?, @@ -25,6 +27,9 @@ class DataLiberationViewModel(application: Application) : BaseDataLiberationView val placeholderText: String ) + data class ExportProgressUiState(val total: Int, val completed: Int) + + val exportProgressState = MutableStateFlow(ExportProgressUiState(0, 0)) val importFiles = MutableStateFlow(ImportFiles("", "", "", "")) fun updateImportFileNames() { @@ -52,6 +57,24 @@ class DataLiberationViewModel(application: Application) : BaseDataLiberationView } } + fun runExportTask(export: Export, isFullDump: Boolean) { + setInProgress(true) + + val context: Context = getApplication() + val exportTask = JsonExportTask( + context, + this, + isFullDump + ) + viewModelScope.launch(Dispatchers.Default) { + exportTask.run(export) + } + } + + override fun onProgressUpdate(total: Int, completed: Int) { + exportProgressState.value = ExportProgressUiState(total, completed) + } + fun runImportTask( importShows: Boolean, importLists: Boolean, From 2740e66c3c49ba867c36af71abf2a4984c70d771 Mon Sep 17 00:00:00 2001 From: Uwe Date: Fri, 10 Jul 2026 09:56:47 +0200 Subject: [PATCH 10/10] JsonImportTask: use local format for summary numbers --- .../dataliberation/JsonImportTask.kt | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt index 234c402999..bf35e9ee33 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt @@ -57,6 +57,7 @@ import java.io.FileInputStream import java.io.FileNotFoundException import java.io.IOException import java.io.InputStreamReader +import java.text.NumberFormat /** * Imports shows, lists or movies from a human-readable JSON file replacing existing data. @@ -197,6 +198,7 @@ class JsonImportTask( } private fun MutableList.skippedItemsString( + numberFormat: NumberFormat, transform: ((T) -> CharSequence)? = null ): String { return if (isEmpty()) { @@ -206,7 +208,7 @@ class JsonImportTask( prefix = "\n", separator = "\n", limit = SUMMARY_ITEM_LIMIT, - truncated = "+ ${skippedMovies.size - SUMMARY_ITEM_LIMIT}", + truncated = "+ ${numberFormat.format(skippedMovies.size - SUMMARY_ITEM_LIMIT)}", transform = transform ) } @@ -215,16 +217,18 @@ class JsonImportTask( private fun buildResult(resultCode: Int): Result { return when (resultCode) { SUCCESS -> { + val numberFormat = NumberFormat.getIntegerInstance() + // As this is displayed in a TextView, try to stay below 10.000 characters - val skippedShowsSummary = skippedShows.skippedItemsString { + val skippedShowsSummary = skippedShows.skippedItemsString(numberFormat) { "title = ${it.title}, tmdb_id = ${it.tmdb_id}, tvdb_id = ${it.tvdb_id}" } - val skippedMoviesSummary = skippedMovies.skippedItemsString { + val skippedMoviesSummary = skippedMovies.skippedItemsString(numberFormat) { "title = ${it.title}, tmdb_id = ${it.tmdb_id}, imdb_id = ${it.imdb_id}" } - val skippedListItemsSummary = skippedListItems.skippedItemsString { + val skippedListItemsSummary = skippedListItems.skippedItemsString(numberFormat) { "type = ${it.type}, externalId = ${it.externalId}" } @@ -239,22 +243,22 @@ class JsonImportTask( appendLine() appendLine(stringShows) - append("โœ”๏ธ ").appendLine(importedShows) - append("โŒ ").append(skippedShows.size) + append("โœ”๏ธ ").appendLine(numberFormat.format(importedShows)) + append("โŒ ").append(numberFormat.format(skippedShows.size)) appendLine(skippedShowsSummary) appendLine() appendLine(stringLists) - append("โœ”๏ธ ").appendLine(importedListItems) - append("โŒ ").append(skippedListItems.size) + append("โœ”๏ธ ").appendLine(numberFormat.format(importedListItems)) + append("โŒ ").append(numberFormat.format(skippedListItems.size)) appendLine(skippedListItemsSummary) appendLine() appendLine(stringMovies) - append("โœ”๏ธ ").appendLine(importedMovies) - append("โŒ ").append(skippedMovies.size) + append("โœ”๏ธ ").appendLine(numberFormat.format(importedMovies)) + append("โŒ ").append(numberFormat.format(skippedMovies.size)) appendLine(skippedMoviesSummary) }