diff --git a/CHANGELOG.md b/CHANGELOG.md index e338ef3855..0f607f5b33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ 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: display a simple summary of imported and skipped items. +* ๐Ÿ”ง 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/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..7f29e9111f --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/BaseDataLiberationViewModel.kt @@ -0,0 +1,84 @@ +// 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 androidx.lifecycle.viewModelScope +import com.battlelancer.seriesguide.R +import kotlinx.coroutines.Dispatchers +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 + ) + ) + + fun setInProgress(value: Boolean) { + isInProgress.value = value + } + + fun runImportTask( + jsonImportTask: () -> JsonImportTask + ) { + setInProgress(true) + + viewModelScope.launch(Dispatchers.Default) { + 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..e79d094eea 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,10 @@ 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 @@ -34,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() @@ -109,9 +105,26 @@ 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) + } + } + + viewLifecycleOwner.lifecycleScope.launch { + model.exportProgressState.collect { + updateProgressBar(it.total, it.completed) + } } } @@ -137,13 +150,14 @@ 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 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 +167,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 +192,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,14 +207,8 @@ class DataLiberationFragment : Fragment(), OnTaskProgressListener { model.updateImportFileNames() val binding = binding ?: return - setProgressLock(true) - val exportTask = JsonExportTask( - requireContext(), - this@DataLiberationFragment, - binding.checkBoxDataLibFullDump.isChecked - ) - model.dataLibJob = exportTask.launch(export) + model.runExportTask(export, binding.checkBoxDataLibFullDump.isChecked) } private val createShowExportFileResult = @@ -265,12 +270,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..2cbfb4cf64 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,21 @@ 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. + * A [BaseDataLiberationViewModel] that also can run and report progress for an export task and + * keeps track of import files. */ -class DataLiberationViewModel(application: Application) : AndroidViewModel(application) { +class DataLiberationViewModel(application: Application) : BaseDataLiberationViewModel(application), + JsonExportTask.OnTaskProgressListener { data class ImportFiles( val fileNameShows: String?, @@ -26,22 +27,10 @@ class DataLiberationViewModel(application: Application) : AndroidViewModel(appli val placeholderText: String ) - val importFiles = MutableStateFlow(ImportFiles("", "", "", "")) - - var dataLibJob: Job? = null + data class ExportProgressUiState(val total: Int, val completed: Int) - val isDataLibTaskNotCompleted: Boolean - get() { - val dataLibJob = dataLibJob - return (dataLibJob != null && !dataLibJob.isCompleted) - } - - override fun onCleared() { - if (isDataLibTaskNotCompleted) { - dataLibJob?.cancel(null) - } - dataLibJob = null - } + val exportProgressState = MutableStateFlow(ExportProgressUiState(0, 0)) + val importFiles = MutableStateFlow(ImportFiles("", "", "", "")) fun updateImportFileNames() { viewModelScope.launch(Dispatchers.IO) { @@ -68,4 +57,38 @@ class DataLiberationViewModel(application: Application) : AndroidViewModel(appli } } + 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, + 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..4b5ceff421 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 ) ) } @@ -465,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 94a8ecd06f..bf35e9ee33 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/dataliberation/JsonImportTask.kt @@ -8,7 +8,7 @@ import android.net.Uri import android.os.ParcelFileDescriptor import androidx.annotation.VisibleForTesting import com.battlelancer.seriesguide.R -import com.battlelancer.seriesguide.dataliberation.DataLiberationFragment.LiberationResultEvent +import com.battlelancer.seriesguide.SgApp import com.battlelancer.seriesguide.dataliberation.ImportTools.toSgEpisodeForImport import com.battlelancer.seriesguide.dataliberation.ImportTools.toSgListForImport import com.battlelancer.seriesguide.dataliberation.ImportTools.toSgListItemForImport @@ -16,13 +16,16 @@ 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.ListItem import com.battlelancer.seriesguide.dataliberation.model.Movie import com.battlelancer.seriesguide.dataliberation.model.Season 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 +34,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,18 +44,20 @@ 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 import timber.log.Timber import java.io.File 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. @@ -64,7 +72,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 @@ -75,16 +84,21 @@ 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 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 @@ -108,16 +122,25 @@ 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) { 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 { @@ -126,8 +149,7 @@ class JsonImportTask( doInBackground(this) } } - onPostExecute(result) - return@withContext result + return@withContext buildResult(resultCode) } } @@ -139,7 +161,7 @@ class JsonImportTask( var result: Int if (isImportShows) { - result = importData(Export.Shows) + result = openFilesAndImport(Export.Shows) if (result != SUCCESS) { return result } @@ -148,8 +170,8 @@ class JsonImportTask( } } - if (isImportLists) { - result = importData(Export.Lists) + if (isImportMovies) { + result = openFilesAndImport(Export.Movies) if (result != SUCCESS) { return result } @@ -158,8 +180,9 @@ class JsonImportTask( } } - if (isImportMovies) { - result = importData(Export.Movies) + // Import lists last so imdb-movie list items can be mapped + if (isImportLists) { + result = openFilesAndImport(Export.Lists) if (result != SUCCESS) { return result } @@ -174,42 +197,134 @@ class JsonImportTask( return SUCCESS } - private fun onPostExecute(result: Int) { - val message: String - val showIndefinite: Boolean - when (result) { + private fun MutableList.skippedItemsString( + numberFormat: NumberFormat, + transform: ((T) -> CharSequence)? = null + ): String { + return if (isEmpty()) { + "" + } else { + joinToString( + prefix = "\n", + separator = "\n", + limit = SUMMARY_ITEM_LIMIT, + truncated = "+ ${numberFormat.format(skippedMovies.size - SUMMARY_ITEM_LIMIT)}", + transform = transform + ) + } + } + + private fun buildResult(resultCode: Int): Result { + return when (resultCode) { SUCCESS -> { - message = context.getString(R.string.status_successful) - showIndefinite = false + val numberFormat = NumberFormat.getIntegerInstance() + + // As this is displayed in a TextView, try to stay below 10.000 characters + val skippedShowsSummary = skippedShows.skippedItemsString(numberFormat) { + "title = ${it.title}, tmdb_id = ${it.tmdb_id}, tvdb_id = ${it.tvdb_id}" + } + + val skippedMoviesSummary = skippedMovies.skippedItemsString(numberFormat) { + "title = ${it.title}, tmdb_id = ${it.tmdb_id}, imdb_id = ${it.imdb_id}" + } + + val skippedListItemsSummary = skippedListItems.skippedItemsString(numberFormat) { + "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(numberFormat.format(importedShows)) + append("โŒ ").append(numberFormat.format(skippedShows.size)) + appendLine(skippedShowsSummary) + + appendLine() + + appendLine(stringLists) + append("โœ”๏ธ ").appendLine(numberFormat.format(importedListItems)) + append("โŒ ").append(numberFormat.format(skippedListItems.size)) + appendLine(skippedListItemsSummary) + + appendLine() + + appendLine(stringMovies) + append("โœ”๏ธ ").appendLine(numberFormat.format(importedMovies)) + append("โŒ ").append(numberFormat.format(skippedMovies.size)) + appendLine(skippedMoviesSummary) + } + + Result(summary) } - ERROR_FILE_ACCESS -> { - message = TextTools.dotSeparate( + ERROR_FILE_ACCESS -> Result( + TextTools.dotSeparate( context, - R.string.status_failure, - R.string.status_failed_file_access - ) - showIndefinite = true - } + 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 + ) + } + } - ERROR_LARGE_DB_OP -> { - message = context.getString(R.string.update_inprogress) - showIndefinite = false - } + /** + * 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 + } - else -> { - message = context.getString(R.string.status_failure) - showIndefinite = true - } + private fun getTestBackupFile(export: Export): File? { + return when (export) { + Export.Shows -> testBackupFileShows + Export.Lists -> testBackupFileLists + Export.Movies -> testBackupFileMovies } - EventBus.getDefault().post( - LiberationResultEvent(context, message, errorCause, showIndefinite) - ) } - private fun importData(export: Export): Int { + 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... @@ -237,36 +352,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 +385,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,45 +462,95 @@ 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) - } - } - Export.Movies -> { - while (reader.hasNext()) { - val movie = gson.fromJson(reader, Movie::class.java) - addMovieToDatabase(movie) + 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() } - } - reader.endArray() - reader.close() } + /** + * 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()) { + skippedMovies.add(movie) + 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 + ) + skippedMovies.add(movie) + return // Skip, TMDB ID required + } } sgMovieHelper.insertMovie(movie.toSgMovieForImport()) + importedMovies++ + } + + /** + * 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)) { // valid id required + skippedShows.add(show) return } @@ -420,6 +577,8 @@ class JsonImportTask( // Parse and insert seasons and episodes. insertSeasonsAndEpisodes(show, showId) + + importedShows++ } private fun insertSeasonsAndEpisodes(show: Show, showId: Long) { @@ -484,11 +643,30 @@ class JsonImportTask( // Insert the list items val items = ArrayList() for (item in list.items) { - item.toSgListItemForImport(sgList.listId) - ?.let { items.add(it) } + + // 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}") + skippedListItems.add(item) + continue + } + item.externalId = tmdbIdOrNull.toString() + item.type = ListItemTypesExport.MOVIE + } + + 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 { @@ -496,5 +674,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 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/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/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 970bd69470..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. */ -object TmdbTools4 { +open class TmdbTools4 { sealed interface TmdbNonNullResponse { data class Success(val data: T) : TmdbNonNullResponse @@ -61,7 +61,7 @@ object 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/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 be74727cc5..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,13 +10,18 @@ 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 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,8 +32,11 @@ 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.io.File import java.nio.file.Files import kotlin.io.path.writeText @@ -57,6 +65,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) @@ -68,18 +87,22 @@ class JsonImportTaskTest { importShows = true, importLists = false, importMovies = false, - mock(SgRoomDatabase::class.java), + testDb, sgShow2Helper, sgSeason2Helper, sgEpisode2Helper, mock(SgListHelper::class.java), - mock(MovieHelper::class.java) - ) - - // 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() + mock(MovieHelper::class.java), + mock() + ).apply { + // Test data from export task test: single show, two seasons, each with two episodes. + setTestBackupFiles( + fileShows = 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) @@ -89,9 +112,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 @@ -139,27 +160,27 @@ class JsonImportTaskTest { fun importList_modelAsExpected() = runTest { val sgListHelper = mock(SgListHelper::class.java) - val importTask = JsonImportTask( + JsonImportTask( context, importShows = false, importLists = true, importMovies = false, - mock(SgRoomDatabase::class.java), + testDb, mock(SgShow2Helper::class.java), mock(SgSeason2Helper::class.java), mock(SgEpisode2Helper::class.java), sgListHelper, - mock(MovieHelper::class.java) - ) - - // 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) + mock(MovieHelper::class.java), + mock() + ).apply { + // Test data from export task test: two lists, the first with one item of each type. + setTestBackupFiles( + fileLists = createTempJsonFile( + "seriesguide-lists-json", + JsonExportTaskTest.expectedJsonLists + ) + ) + }.runAndAssertSuccess() // List 1 with items verify(sgListHelper).insertList( @@ -174,27 +195,92 @@ class JsonImportTaskTest { ) } - private suspend fun importMovies(json: String) { - val importTask = JsonImportTask( + @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 = 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) + } - @Suppress("BlockingMethodInNonBlockingContext") - val testBackupFile = Files.createTempFile("seriesguide-movies-json", null) - testBackupFile.writeText(json) - importTask.testBackupFile = testBackupFile.toFile() + @Test + fun importList_movieWithUnknownImdbId_skipped() = runTest { + JsonImportTask( + context, + importShows = false, + importLists = true, + importMovies = false + ).apply { + setTestBackupFiles( + fileLists = createTempJsonFile( + "seriesguide-lists-json", + jsonListMovieImdbId + ) + ) + }.runAndAssertSuccess() - val result = importTask.run() - assertThat(importTask.errorCause).isNull() - assertThat(result).isEqualTo(JsonImportTask.SUCCESS) + // 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 { - importMovies(JsonExportTaskTest.expectedJsonMovies) + JsonImportTask( + context, + importShows = false, + importLists = false, + importMovies = true + ).apply { + setTestBackupFiles( + fileMovies = createTempJsonFile( + "seriesguide-movies-json", + JsonExportTaskTest.expectedJsonMovies + ) + ) + }.runAndAssertSuccess() // Two movies, second one with only TMDB ID and title val insertedMovies = testDb.movieHelper().getAllMovies() @@ -229,17 +315,69 @@ class JsonImportTaskTest { ) } + @Test + fun importMovie_imdbId_mappedToTmdbId() = runTest { + val testImdbId = "test-imdbid" + + @Language("json") + val jsonToImport = + """ + [ + {"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() + + JsonImportTask( + context, + importShows = false, + importLists = false, + importMovies = true, + testDb, + mock(), + mock(), + mock(), + mock(), + movieHelper, + tmdbTools + ).apply { + setTestBackupFiles( + fileMovies = createTempJsonFile("seriesguide-movies-json", jsonToImport) + ) + }.runAndAssertSuccess() + + val importedMovies = movieHelper.getAllMovies() + assertThat(importedMovies).hasSize(1) + assertThat(importedMovies[0].tmdbId).isEqualTo(resolvedTmdbId) + } + @Test fun importMovie_missingRequired_notImported() = runTest { @Language("json") - val jsonMovieMissingRequiredValues = + val jsonToImport = """ [ - {"imdb_id":"imdbidvalue","title":"First Movie"} + {"title":"First Movie"} ] """.trimIndent() - importMovies(jsonMovieMissingRequiredValues) + JsonImportTask( + context, + importShows = false, + importLists = false, + importMovies = true + ).apply { + setTestBackupFiles( + fileMovies = createTempJsonFile("seriesguide-movies-json", jsonToImport) + ) + }.runAndAssertSuccess() // 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..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): @@ -189,6 +231,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) @@ -215,3 +276,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 + } +] +```