diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 000000000..7bf87bb5b
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,4 @@
+root = true
+
+[*.{kt,kts}]
+ij_kotlin_imports_layout = *
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 000000000..9c8ad9842
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,46 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ check:
+ name: Format + compile
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v4
+
+ - name: Make gradlew executable
+ run: chmod +x gradlew
+
+ - name: Check formatting
+ run: ./gradlew ktfmtCheck
+
+ - name: Check cancellation rethrows
+ if: always()
+ run: ./gradlew checkCancellationRethrow
+
+ - name: Compile
+ if: always()
+ run: ./gradlew compileDebugKotlin
+
+ - name: Unit tests
+ if: always()
+ run: ./gradlew testDebugUnitTest
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 83ee5060f..492babbdc 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -67,12 +67,29 @@ jobs:
DATE=$(TZ=Asia/Kolkata date +%Y-%m-%d)
echo "date=${DATE}" >> $GITHUB_OUTPUT
- if gh release view nightly > /dev/null 2>&1; then
- echo "Comparing commits since last nightly"
- COMMITS=$(git log nightly..HEAD --oneline --no-merges -- \
+ BRANCH="${GITHUB_REF_NAME}"
+ if [ "$BRANCH" = "master" ]; then
+ TAG="nightly"
+ TITLE="Nightly ${DATE}"
+ else
+ SLUG=$(echo "$BRANCH" | sed 's#[^a-zA-Z0-9._-]#-#g')
+ TAG="nightly-${SLUG}"
+ TITLE="Nightly ${DATE} (${BRANCH})"
+ fi
+ echo "Branch ${BRANCH} -> tag ${TAG}"
+ echo "tag=${TAG}" >> $GITHUB_OUTPUT
+ echo "title=${TITLE}" >> $GITHUB_OUTPUT
+
+ if gh release view "$TAG" > /dev/null 2>&1; then
+ echo "Comparing commits since last ${TAG}"
+ COMMITS=$(git log "${TAG}..HEAD" --oneline --no-merges -- \
+ app/src/ app/build.gradle.kts gradle/ build.gradle.kts settings.gradle.kts)
+ elif [ "$BRANCH" != "master" ] && git rev-parse --verify origin/master > /dev/null 2>&1; then
+ echo "First ${TAG} — using every commit since ${BRANCH} diverged from master"
+ COMMITS=$(git log origin/master..HEAD --oneline --no-merges -- \
app/src/ app/build.gradle.kts gradle/ build.gradle.kts settings.gradle.kts)
else
- echo "No previous nightly found, using last 15 commits"
+ echo "No previous ${TAG} found, using last 15 commits"
COMMITS=$(git log -15 --oneline --no-merges -- \
app/src/ app/build.gradle.kts gradle/ build.gradle.kts settings.gradle.kts)
fi
@@ -82,6 +99,16 @@ jobs:
echo "$COMMITS" >> $GITHUB_OUTPUT
echo "$DELIMITER" >> $GITHUB_OUTPUT
+ NOTICE_DELIMITER="EOF_NOTICE_$(date +%s)"
+ echo "notice<<$NOTICE_DELIMITER" >> $GITHUB_OUTPUT
+ if [ "$BRANCH" != "master" ]; then
+ echo "> [!CAUTION]" >> $GITHUB_OUTPUT
+ echo "> Built from the \`${BRANCH}\` branch, not \`master\`." >> $GITHUB_OUTPUT
+ echo "> **This build targets Jellyfin 12 and breaks compatibility with Jellyfin 10 servers.**" >> $GITHUB_OUTPUT
+ echo "> Future releases will drop v10 support entirely. Do not install this against a v10 server." >> $GITHUB_OUTPUT
+ fi
+ echo "$NOTICE_DELIMITER" >> $GITHUB_OUTPUT
+
- name: Decode keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > keystore.jks
@@ -104,27 +131,41 @@ jobs:
- name: Rename APKs
run: |
DATE="${{ steps.info.outputs.date }}"
+ TAG="${{ steps.info.outputs.tag }}"
for APK in app/build/outputs/apk/nightly/*.apk; do
ABI=$(basename "$APK" | grep -oP '(arm64-v8a|armeabi-v7a)')
- cp "$APK" "AFinity-nightly-${DATE}-${ABI}.apk"
+ cp "$APK" "AFinity-${TAG}-${DATE}-${ABI}.apk"
done
+ - name: Package R8 mapping
+ run: |
+ DATE="${{ steps.info.outputs.date }}"
+ TAG="${{ steps.info.outputs.tag }}"
+ MAPPING="app/build/outputs/mapping/nightly/mapping.txt"
+ if [ ! -f "$MAPPING" ]; then
+ echo "::warning::No R8 mapping at ${MAPPING}. Publishing without one; library frames in crashes from this build will not be retraceable."
+ exit 0
+ fi
+ gzip -9 -c "$MAPPING" > "AFinity-${TAG}-${DATE}-mapping.txt.gz"
+ ls -lh "AFinity-${TAG}-${DATE}-mapping.txt.gz"
+
- name: Delete existing nightly release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
- if gh release view nightly > /dev/null 2>&1; then
- echo "Deleting existing nightly release..."
- gh release delete nightly --yes --cleanup-tag
+ TAG="${{ steps.info.outputs.tag }}"
+ if gh release view "$TAG" > /dev/null 2>&1; then
+ echo "Deleting existing ${TAG} release..."
+ gh release delete "$TAG" --yes --cleanup-tag
else
- echo "No existing nightly release found"
+ echo "No existing ${TAG} release found"
fi
- name: Publish pre-release
uses: softprops/action-gh-release@v2
with:
- tag_name: nightly
- name: Nightly ${{ steps.info.outputs.date }}
+ tag_name: ${{ steps.info.outputs.tag }}
+ name: ${{ steps.info.outputs.title }}
prerelease: true
body: |
**Automated nightly build** — ${{ steps.info.outputs.date }} (IST)
@@ -135,7 +176,26 @@ jobs:
```
> [!CAUTION]
> ⚠️ Nightly builds may be unstable. Expect Bugs. Installs alongside the beta release.
- files: AFinity-nightly-${{ steps.info.outputs.date }}-*.apk
+
+ ${{ steps.info.outputs.notice }}
+
+ Deobfuscating a crash from this build
+
+ Download `AFinity-${{ steps.info.outputs.tag }}-${{ steps.info.outputs.date }}-mapping.txt.gz`,
+ then:
+
+ ```
+ gunzip AFinity-*-mapping.txt.gz
+ retrace mapping.txt stacktrace.txt
+ ```
+
+ The mapping only matches **this** nightly — each nightly replaces the previous one.
+
+
+ fail_on_unmatched_files: false
+ files: |
+ AFinity-${{ steps.info.outputs.tag }}-${{ steps.info.outputs.date }}-*.apk
+ AFinity-${{ steps.info.outputs.tag }}-${{ steps.info.outputs.date }}-mapping.txt.gz
- name: Notify Discord
env:
@@ -143,4 +203,4 @@ jobs:
run: |
gh workflow run discord-release-notification.yml \
--ref master \
- -f release_tag=nightly
\ No newline at end of file
+ -f release_tag=${{ steps.info.outputs.tag }}
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 02c4aa5e0..1a450ae9e 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -10,6 +10,7 @@
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 993d7a030..6e27e05c8 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,6 +1,9 @@
+import com.android.build.api.artifact.SingleArtifact
import com.android.build.api.dsl.ApplicationExtension
+import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import java.util.Properties
import java.util.regex.Pattern
+import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension
plugins {
alias(libs.plugins.android.application)
@@ -9,6 +12,8 @@ plugins {
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.hilt.android)
alias(libs.plugins.ksp)
+ alias(libs.plugins.room)
+ alias(libs.plugins.androidx.baselineprofile)
id("kotlin-parcelize")
}
@@ -30,6 +35,15 @@ aboutLibraries {
}
}
+configure {
+ stabilityConfigurationFiles.add(layout.projectDirectory.file("compose_compiler_config.conf"))
+
+ if (providers.gradleProperty("composeMetrics").orNull == "true") {
+ metricsDestination.set(layout.buildDirectory.dir("compose-metrics"))
+ reportsDestination.set(layout.buildDirectory.dir("compose-reports"))
+ }
+}
+
configure {
namespace = "com.makd.afinity"
compileSdk = 37
@@ -129,6 +143,8 @@ configure {
}
}
+room { schemaDirectory("$projectDir/schemas") }
+
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
@@ -136,12 +152,37 @@ kotlin {
"-opt-in=kotlin.RequiresOptIn",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
- "-Xjvm-default=all",
- "-Xcontext-parameters",
+ "-jvm-default=no-compatibility",
)
}
}
+configure {
+ onVariants { variant ->
+ val buildType = variant.buildType ?: return@onVariants
+ if (buildType != "release" && buildType != "nightly") return@onVariants
+
+ val mappingFile = variant.artifacts.get(SingleArtifact.OBFUSCATION_MAPPING_FILE)
+ val variantName = variant.name.replaceFirstChar { it.uppercase() }
+
+ val archiveMapping =
+ tasks.register("archive${variantName}Mapping") {
+ group = "reporting"
+ description = "Archives the R8 mapping for $buildType builds."
+ from(mappingFile)
+ archiveFileName.set(
+ "afinity-v$appVersionName-$appVersionCode-${variant.name}-mapping.zip"
+ )
+ destinationDirectory.set(rootProject.layout.projectDirectory.dir("mapping-archive"))
+ onlyIf { mappingFile.orNull?.asFile?.exists() == true }
+ }
+
+ tasks
+ .matching { it.name == "assemble$variantName" }
+ .configureEach { finalizedBy(archiveMapping) }
+ }
+}
+
dependencies {
coreLibraryDesugaring(libs.android.desugar.jdk)
debugImplementation(libs.leakcanary.android)
@@ -195,12 +236,14 @@ dependencies {
implementation(libs.compose.pager.indicator)
implementation(libs.hilt.android)
implementation(libs.jellyfin.core)
+ implementation(libs.kotlin.logging)
implementation(libs.kotlinx.serialization.json)
implementation(libs.libmpv)
implementation(libs.lottie.compose)
implementation(libs.media3.ffmpeg.decoder)
implementation(libs.okhttp)
implementation(libs.okhttp.logging.interceptor)
+ implementation(libs.androidx.profileinstaller)
implementation(libs.play.services.cast.framework)
implementation(libs.reorderable)
implementation(libs.retrofit)
@@ -208,6 +251,7 @@ dependencies {
implementation(libs.richtext.commonmark)
implementation(libs.richtext.ui)
implementation(libs.richtext.ui.material3)
+ implementation(libs.slf4j.api)
implementation(libs.socketio) { exclude(group = "org.json", module = "json") }
implementation(libs.timber)
implementation(libs.tink.android)
@@ -216,4 +260,91 @@ dependencies {
ksp(libs.androidx.room.compiler)
ksp(libs.hilt.android.compiler)
ksp(libs.kotlin.metadata.jvm)
+
+ androidTestImplementation(libs.androidx.room.testing)
+ androidTestImplementation(libs.androidx.test.ext.junit)
+ androidTestImplementation(libs.androidx.test.runner)
+ androidTestImplementation(libs.junit)
+
+ testImplementation(libs.junit)
+
+ baselineProfile(project(":benchmark"))
+}
+
+val cancellationRethrowAllowlist =
+ setOf(
+ "data/database/AfinityTypeConverters.kt",
+ "data/manager/DownloadNotificationManager.kt",
+ "data/models/jellyseerr/JellyseerrRequest.kt",
+ "data/models/jellyseerr/SearchResultItem.kt",
+ "data/repository/audiobookshelf/AbsProgressSyncScheduler.kt",
+ "data/repository/audiobookshelf/AudiobookshelfEpisodeMappers.kt",
+ "data/storage/StorageLocationProvider.kt",
+ "data/sync/UserDataSyncScheduler.kt",
+ "di/NetworkModule.kt",
+ "player/common/AudioEqualizerManager.kt",
+ "player/mpv/MPVPlayer.kt",
+ "ui/audiobookshelf/libraries/AudiobookshelfLibrariesViewModel.kt",
+ "ui/components/AsyncImage.kt",
+ "ui/components/HeroCarousel.kt",
+ "ui/person/PersonViewModel.kt",
+ "ui/player/Extensions.kt",
+ "ui/player/PlayerActivity.kt",
+ "ui/player/utils/VolmeManager.kt",
+ "ui/requests/SeerrMediaDetailScreen.kt",
+ "ui/settings/player/PlayerOptionsScreen.kt",
+ "ui/settings/servers/utils/Formatters.kt",
+ "ui/utils/IntentUtils.kt",
+ )
+
+tasks.register("checkCancellationRethrow") {
+ group = "verification"
+ description =
+ "Fails if a catch (e: Exception) is missing a CancellationException rethrow above it."
+
+ val sourceRoot = layout.projectDirectory.dir("src/main/java/com/makd/afinity").asFile
+ val allowlist = cancellationRethrowAllowlist
+ inputs.dir(sourceRoot)
+
+ doLast {
+ val violations = mutableListOf()
+ sourceRoot
+ .walkTopDown()
+ .filter { it.isFile && it.extension == "kt" }
+ .forEach { file ->
+ val relative = file.relativeTo(sourceRoot).invariantSeparatorsPath
+ if (relative in allowlist) return@forEach
+ val lines = file.readLines()
+ lines.forEachIndexed { index, line ->
+ if (!line.contains("catch (e: Exception)")) return@forEachIndexed
+ val guarded =
+ index >= 2 &&
+ lines[index - 2].contains("catch (e: CancellationException)") &&
+ lines[index - 1].trim() == "throw e"
+ if (!guarded) violations += "$relative:${index + 1}"
+ }
+ }
+
+ if (violations.isNotEmpty()) {
+ throw GradleException(
+ buildString {
+ appendLine(
+ "Cancellation rethrow missing at ${violations.size} site(s). " +
+ "Catching Exception swallows coroutine cancellation, so the coroutine " +
+ "keeps running until its next suspension point."
+ )
+ violations.forEach { appendLine(" $it") }
+ appendLine()
+ appendLine("Add directly above each catch:")
+ appendLine(" } catch (e: CancellationException) {")
+ appendLine(" throw e")
+ appendLine()
+ appendLine(
+ "If the file contains no coroutines, add it to " +
+ "cancellationRethrowAllowlist in app/build.gradle.kts instead."
+ )
+ }
+ )
+ }
+ }
}
diff --git a/app/compose_compiler_config.conf b/app/compose_compiler_config.conf
new file mode 100644
index 000000000..195547825
--- /dev/null
+++ b/app/compose_compiler_config.conf
@@ -0,0 +1,16 @@
+// Types the Compose compiler cannot prove stable because they live outside this module,
+// but which are immutable value types in practice. Declaring them stable lets the AFinity
+// domain models that hold them (AfinityMovie, AfinityEpisode, AfinityShow, ...) be inferred
+// stable, so skipping compares them with data-class equals() instead of falling back to
+// instance equality under strong skipping.
+//
+// Only add a type here if it is genuinely immutable - this is a promise to the compiler,
+// not a hint. A mutable type listed here causes missed recompositions (stale UI).
+
+java.util.UUID
+java.time.LocalDateTime
+java.time.LocalDate
+java.time.LocalTime
+java.time.Instant
+java.time.Duration
+android.net.Uri
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 166aca14f..4b936f643 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -12,13 +12,10 @@
# public *;
#}
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
-
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile
+# Crash readability
+-keepattributes SourceFile,LineNumberTable
+-renamesourcefileattribute SourceFile
+-keepnames class com.makd.afinity.** { *; }
# Google Cast SDK
-keep class com.google.android.gms.cast.** { *; }
diff --git a/app/schemas/com.makd.afinity.data.database.AfinityDatabase/78.json b/app/schemas/com.makd.afinity.data.database.AfinityDatabase/78.json
new file mode 100644
index 000000000..faa622c3e
--- /dev/null
+++ b/app/schemas/com.makd.afinity.data.database.AfinityDatabase/78.json
@@ -0,0 +1,3462 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 78,
+ "identityHash": "73039f9e281bd9105f1a3db3e80c0fe0",
+ "entities": [
+ {
+ "tableName": "servers",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `version` TEXT, `address` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "address",
+ "columnName": "address",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "serverAddresses",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `address` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "address",
+ "columnName": "address",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_serverAddresses_serverId",
+ "unique": false,
+ "columnNames": [
+ "serverId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_serverAddresses_serverId` ON `${TABLE_NAME}` (`serverId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "servers",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "users",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `serverId` TEXT NOT NULL, `accessToken` TEXT, `primaryImageTag` TEXT, `isAdmin` INTEGER NOT NULL, `canAccessLiveTv` INTEGER, `canDownload` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accessToken",
+ "columnName": "accessToken",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "primaryImageTag",
+ "columnName": "primaryImageTag",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "isAdmin",
+ "columnName": "isAdmin",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "canAccessLiveTv",
+ "columnName": "canAccessLiveTv",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "canDownload",
+ "columnName": "canDownload",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_users_serverId",
+ "unique": false,
+ "columnNames": [
+ "serverId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "servers",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "genre_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`genreName` TEXT NOT NULL, `serverId` TEXT NOT NULL, `userId` TEXT NOT NULL, `lastFetchedTimestamp` INTEGER NOT NULL, `movieCount` INTEGER NOT NULL, PRIMARY KEY(`genreName`, `serverId`, `userId`))",
+ "fields": [
+ {
+ "fieldPath": "genreName",
+ "columnName": "genreName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastFetchedTimestamp",
+ "columnName": "lastFetchedTimestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "movieCount",
+ "columnName": "movieCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "genreName",
+ "serverId",
+ "userId"
+ ]
+ }
+ },
+ {
+ "tableName": "genre_movie_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`genreName` TEXT NOT NULL, `movieId` TEXT NOT NULL, `serverId` TEXT NOT NULL, `userId` TEXT NOT NULL, `movieData` TEXT NOT NULL, `position` INTEGER NOT NULL, `cachedTimestamp` INTEGER NOT NULL, PRIMARY KEY(`genreName`, `movieId`, `serverId`, `userId`))",
+ "fields": [
+ {
+ "fieldPath": "genreName",
+ "columnName": "genreName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "movieId",
+ "columnName": "movieId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "movieData",
+ "columnName": "movieData",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "cachedTimestamp",
+ "columnName": "cachedTimestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "genreName",
+ "movieId",
+ "serverId",
+ "userId"
+ ]
+ }
+ },
+ {
+ "tableName": "show_genre_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`genreName` TEXT NOT NULL, `serverId` TEXT NOT NULL, `userId` TEXT NOT NULL, `lastFetchedTimestamp` INTEGER NOT NULL, `showCount` INTEGER NOT NULL, PRIMARY KEY(`genreName`, `serverId`, `userId`))",
+ "fields": [
+ {
+ "fieldPath": "genreName",
+ "columnName": "genreName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastFetchedTimestamp",
+ "columnName": "lastFetchedTimestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "showCount",
+ "columnName": "showCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "genreName",
+ "serverId",
+ "userId"
+ ]
+ }
+ },
+ {
+ "tableName": "genre_show_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`genreName` TEXT NOT NULL, `showId` TEXT NOT NULL, `serverId` TEXT NOT NULL, `userId` TEXT NOT NULL, `showData` TEXT NOT NULL, `position` INTEGER NOT NULL, `cachedTimestamp` INTEGER NOT NULL, PRIMARY KEY(`genreName`, `showId`, `serverId`, `userId`))",
+ "fields": [
+ {
+ "fieldPath": "genreName",
+ "columnName": "genreName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "showId",
+ "columnName": "showId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "showData",
+ "columnName": "showData",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "cachedTimestamp",
+ "columnName": "cachedTimestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "genreName",
+ "showId",
+ "serverId",
+ "userId"
+ ]
+ }
+ },
+ {
+ "tableName": "top_people_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`personType` TEXT NOT NULL, `serverId` TEXT NOT NULL, `userId` TEXT NOT NULL, `peopleData` TEXT NOT NULL, `cachedTimestamp` INTEGER NOT NULL, PRIMARY KEY(`personType`, `serverId`, `userId`))",
+ "fields": [
+ {
+ "fieldPath": "personType",
+ "columnName": "personType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "peopleData",
+ "columnName": "peopleData",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "cachedTimestamp",
+ "columnName": "cachedTimestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "personType",
+ "serverId",
+ "userId"
+ ]
+ }
+ },
+ {
+ "tableName": "person_section_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cacheKey` TEXT NOT NULL, `serverId` TEXT NOT NULL, `userId` TEXT NOT NULL, `personData` TEXT NOT NULL, `itemsData` TEXT NOT NULL, `sectionType` TEXT NOT NULL, `cachedTimestamp` INTEGER NOT NULL, PRIMARY KEY(`cacheKey`, `serverId`, `userId`))",
+ "fields": [
+ {
+ "fieldPath": "cacheKey",
+ "columnName": "cacheKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "personData",
+ "columnName": "personData",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemsData",
+ "columnName": "itemsData",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sectionType",
+ "columnName": "sectionType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "cachedTimestamp",
+ "columnName": "cachedTimestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "cacheKey",
+ "serverId",
+ "userId"
+ ]
+ }
+ },
+ {
+ "tableName": "movies",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `name` TEXT NOT NULL, `originalTitle` TEXT, `overview` TEXT NOT NULL, `runtimeTicks` INTEGER NOT NULL, `premiereDate` TEXT, `dateCreated` TEXT, `communityRating` REAL, `officialRating` TEXT, `criticRating` REAL, `status` TEXT NOT NULL, `productionYear` INTEGER, `endDate` TEXT, `chapters` TEXT, `images` TEXT, `genres` TEXT, `tagline` TEXT, `people` TEXT, `tmdbReviews` TEXT, `mdbRatings` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "originalTitle",
+ "columnName": "originalTitle",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "overview",
+ "columnName": "overview",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "runtimeTicks",
+ "columnName": "runtimeTicks",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "premiereDate",
+ "columnName": "premiereDate",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "dateCreated",
+ "columnName": "dateCreated",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "communityRating",
+ "columnName": "communityRating",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "officialRating",
+ "columnName": "officialRating",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "criticRating",
+ "columnName": "criticRating",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "productionYear",
+ "columnName": "productionYear",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "endDate",
+ "columnName": "endDate",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "chapters",
+ "columnName": "chapters",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "images",
+ "columnName": "images",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "genres",
+ "columnName": "genres",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "tagline",
+ "columnName": "tagline",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "people",
+ "columnName": "people",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "tmdbReviews",
+ "columnName": "tmdbReviews",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mdbRatings",
+ "columnName": "mdbRatings",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "shows",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `name` TEXT NOT NULL, `originalTitle` TEXT, `overview` TEXT NOT NULL, `runtimeTicks` INTEGER NOT NULL, `communityRating` REAL, `officialRating` TEXT, `status` TEXT NOT NULL, `productionYear` INTEGER, `premiereDate` TEXT, `dateCreated` TEXT, `dateLastContentAdded` TEXT, `endDate` TEXT, `images` TEXT, `genres` TEXT, `people` TEXT, `tmdbReviews` TEXT, `mdbRatings` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "originalTitle",
+ "columnName": "originalTitle",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "overview",
+ "columnName": "overview",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "runtimeTicks",
+ "columnName": "runtimeTicks",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "communityRating",
+ "columnName": "communityRating",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "officialRating",
+ "columnName": "officialRating",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "productionYear",
+ "columnName": "productionYear",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "premiereDate",
+ "columnName": "premiereDate",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "dateCreated",
+ "columnName": "dateCreated",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "dateLastContentAdded",
+ "columnName": "dateLastContentAdded",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "endDate",
+ "columnName": "endDate",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "images",
+ "columnName": "images",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "genres",
+ "columnName": "genres",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "people",
+ "columnName": "people",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "tmdbReviews",
+ "columnName": "tmdbReviews",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mdbRatings",
+ "columnName": "mdbRatings",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "seasons",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `seriesId` TEXT NOT NULL, `name` TEXT NOT NULL, `seriesName` TEXT NOT NULL, `overview` TEXT NOT NULL, `indexNumber` INTEGER NOT NULL, `images` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`seriesId`) REFERENCES `shows`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "seriesId",
+ "columnName": "seriesId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "seriesName",
+ "columnName": "seriesName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "overview",
+ "columnName": "overview",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "indexNumber",
+ "columnName": "indexNumber",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "images",
+ "columnName": "images",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_seasons_seriesId",
+ "unique": false,
+ "columnNames": [
+ "seriesId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_seasons_seriesId` ON `${TABLE_NAME}` (`seriesId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "shows",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "seriesId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "episodes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `seasonId` TEXT NOT NULL, `seriesId` TEXT NOT NULL, `name` TEXT NOT NULL, `seriesName` TEXT NOT NULL, `overview` TEXT NOT NULL, `indexNumber` INTEGER NOT NULL, `indexNumberEnd` INTEGER, `parentIndexNumber` INTEGER NOT NULL, `runtimeTicks` INTEGER NOT NULL, `premiereDate` TEXT, `communityRating` REAL, `chapters` TEXT, `images` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`seasonId`) REFERENCES `seasons`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`seriesId`) REFERENCES `shows`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "seasonId",
+ "columnName": "seasonId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "seriesId",
+ "columnName": "seriesId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "seriesName",
+ "columnName": "seriesName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "overview",
+ "columnName": "overview",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "indexNumber",
+ "columnName": "indexNumber",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "indexNumberEnd",
+ "columnName": "indexNumberEnd",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "parentIndexNumber",
+ "columnName": "parentIndexNumber",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "runtimeTicks",
+ "columnName": "runtimeTicks",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "premiereDate",
+ "columnName": "premiereDate",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "communityRating",
+ "columnName": "communityRating",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "chapters",
+ "columnName": "chapters",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "images",
+ "columnName": "images",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_episodes_seasonId",
+ "unique": false,
+ "columnNames": [
+ "seasonId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_seasonId` ON `${TABLE_NAME}` (`seasonId`)"
+ },
+ {
+ "name": "index_episodes_seriesId",
+ "unique": false,
+ "columnNames": [
+ "seriesId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_seriesId` ON `${TABLE_NAME}` (`seriesId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "seasons",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "seasonId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "shows",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "seriesId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "sources",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itemId` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `path` TEXT NOT NULL, `downloadId` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "path",
+ "columnName": "path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "downloadId",
+ "columnName": "downloadId",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "mediastreams",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sourceId` TEXT NOT NULL, `title` TEXT NOT NULL, `displayTitle` TEXT, `language` TEXT NOT NULL, `type` TEXT NOT NULL, `codec` TEXT NOT NULL, `isExternal` INTEGER NOT NULL, `path` TEXT NOT NULL, `channelLayout` TEXT, `videoRangeType` TEXT, `height` INTEGER, `width` INTEGER, `videoDoViTitle` TEXT, `hdr10PlusPresentFlag` INTEGER NOT NULL, `index` INTEGER NOT NULL, `channels` INTEGER, `isDefault` INTEGER NOT NULL, `isForced` INTEGER NOT NULL, `isHearingImpaired` INTEGER NOT NULL, `isOriginal` INTEGER NOT NULL, `profile` TEXT, `localizedLanguage` TEXT, `localizedForced` TEXT, `localizedExternal` TEXT, `localizedHearingImpaired` TEXT, `localizedOriginal` TEXT, `downloadId` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceId",
+ "columnName": "sourceId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayTitle",
+ "columnName": "displayTitle",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "language",
+ "columnName": "language",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "codec",
+ "columnName": "codec",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isExternal",
+ "columnName": "isExternal",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "path",
+ "columnName": "path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channelLayout",
+ "columnName": "channelLayout",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "videoRangeType",
+ "columnName": "videoRangeType",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "height",
+ "columnName": "height",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "width",
+ "columnName": "width",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "videoDoViTitle",
+ "columnName": "videoDoViTitle",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "hdr10PlusPresentFlag",
+ "columnName": "hdr10PlusPresentFlag",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "index",
+ "columnName": "index",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channels",
+ "columnName": "channels",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "isDefault",
+ "columnName": "isDefault",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isForced",
+ "columnName": "isForced",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isHearingImpaired",
+ "columnName": "isHearingImpaired",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isOriginal",
+ "columnName": "isOriginal",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "profile",
+ "columnName": "profile",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "localizedLanguage",
+ "columnName": "localizedLanguage",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "localizedForced",
+ "columnName": "localizedForced",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "localizedExternal",
+ "columnName": "localizedExternal",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "localizedHearingImpaired",
+ "columnName": "localizedHearingImpaired",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "localizedOriginal",
+ "columnName": "localizedOriginal",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "downloadId",
+ "columnName": "downloadId",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "trickplayInfos",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` TEXT NOT NULL, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `tileWidth` INTEGER NOT NULL, `tileHeight` INTEGER NOT NULL, `thumbnailCount` INTEGER NOT NULL, `interval` INTEGER NOT NULL, `bandwidth` INTEGER NOT NULL, PRIMARY KEY(`sourceId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "sourceId",
+ "columnName": "sourceId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "width",
+ "columnName": "width",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "height",
+ "columnName": "height",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tileWidth",
+ "columnName": "tileWidth",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tileHeight",
+ "columnName": "tileHeight",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thumbnailCount",
+ "columnName": "thumbnailCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "interval",
+ "columnName": "interval",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bandwidth",
+ "columnName": "bandwidth",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "sourceId"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "sources",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "sourceId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "segments",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `startTicks` INTEGER NOT NULL, `endTicks` INTEGER NOT NULL, PRIMARY KEY(`itemId`, `type`))",
+ "fields": [
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "startTicks",
+ "columnName": "startTicks",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "endTicks",
+ "columnName": "endTicks",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "itemId",
+ "type"
+ ]
+ }
+ },
+ {
+ "tableName": "userdata",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `itemId` TEXT NOT NULL, `serverId` TEXT NOT NULL, `played` INTEGER NOT NULL, `favorite` INTEGER NOT NULL, `likes` INTEGER NOT NULL, `playbackPositionTicks` INTEGER NOT NULL, `toBeSynced` INTEGER NOT NULL, `audioStreamIndex` INTEGER, `subtitleStreamIndex` INTEGER, `lastPlayedAt` INTEGER, `unplayedItemCount` INTEGER, `playCount` INTEGER, PRIMARY KEY(`userId`, `itemId`, `serverId`))",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "played",
+ "columnName": "played",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "favorite",
+ "columnName": "favorite",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "likes",
+ "columnName": "likes",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playbackPositionTicks",
+ "columnName": "playbackPositionTicks",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "toBeSynced",
+ "columnName": "toBeSynced",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "audioStreamIndex",
+ "columnName": "audioStreamIndex",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "subtitleStreamIndex",
+ "columnName": "subtitleStreamIndex",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastPlayedAt",
+ "columnName": "lastPlayedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "unplayedItemCount",
+ "columnName": "unplayedItemCount",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "playCount",
+ "columnName": "playCount",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "itemId",
+ "serverId"
+ ]
+ }
+ },
+ {
+ "tableName": "downloads",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itemId` TEXT NOT NULL, `itemName` TEXT NOT NULL, `itemType` TEXT NOT NULL, `sourceId` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `status` TEXT NOT NULL, `progress` REAL NOT NULL, `bytesDownloaded` INTEGER NOT NULL, `totalBytes` INTEGER NOT NULL, `filePath` TEXT, `error` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `userId` TEXT NOT NULL, `imageUrl` TEXT, `seriesImageUrl` TEXT, `seriesName` TEXT, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `releaseYear` TEXT, `runtimeTicks` INTEGER, `folderPath` TEXT, `seriesId` TEXT, `storageVolumeId` TEXT NOT NULL, `playlistId` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemName",
+ "columnName": "itemName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemType",
+ "columnName": "itemType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceId",
+ "columnName": "sourceId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceName",
+ "columnName": "sourceName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "progress",
+ "columnName": "progress",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bytesDownloaded",
+ "columnName": "bytesDownloaded",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totalBytes",
+ "columnName": "totalBytes",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "filePath",
+ "columnName": "filePath",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "error",
+ "columnName": "error",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "imageUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "seriesImageUrl",
+ "columnName": "seriesImageUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "seriesName",
+ "columnName": "seriesName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "seasonNumber",
+ "columnName": "seasonNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "episodeNumber",
+ "columnName": "episodeNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "releaseYear",
+ "columnName": "releaseYear",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "runtimeTicks",
+ "columnName": "runtimeTicks",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "folderPath",
+ "columnName": "folderPath",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "seriesId",
+ "columnName": "seriesId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "storageVolumeId",
+ "columnName": "storageVolumeId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlistId",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "jellyseerr_requests",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `jellyfinServerId` TEXT NOT NULL, `jellyfinUserId` TEXT NOT NULL, `status` INTEGER NOT NULL, `mediaType` TEXT NOT NULL, `tmdbId` INTEGER, `tvdbId` INTEGER, `title` TEXT NOT NULL, `posterPath` TEXT, `requestedAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `requestedByName` TEXT, `requestedByAvatar` TEXT, `cachedAt` INTEGER NOT NULL, `mediaTitle` TEXT, `mediaName` TEXT, `mediaBackdropPath` TEXT, `mediaReleaseDate` TEXT, `mediaFirstAirDate` TEXT, `mediaStatus` INTEGER, `mediaStatus4k` INTEGER, `is4k` INTEGER NOT NULL, `serverId` INTEGER, `profileId` INTEGER, `rootFolder` TEXT, `seasonsJson` TEXT, `mediaAddedAt` INTEGER, `jellyfinMediaId` TEXT, PRIMARY KEY(`id`, `jellyfinServerId`, `jellyfinUserId`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinServerId",
+ "columnName": "jellyfinServerId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinUserId",
+ "columnName": "jellyfinUserId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mediaType",
+ "columnName": "mediaType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tmdbId",
+ "columnName": "tmdbId",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "tvdbId",
+ "columnName": "tvdbId",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "posterPath",
+ "columnName": "posterPath",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "requestedAt",
+ "columnName": "requestedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requestedByName",
+ "columnName": "requestedByName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "requestedByAvatar",
+ "columnName": "requestedByAvatar",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "cachedAt",
+ "columnName": "cachedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mediaTitle",
+ "columnName": "mediaTitle",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mediaName",
+ "columnName": "mediaName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mediaBackdropPath",
+ "columnName": "mediaBackdropPath",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mediaReleaseDate",
+ "columnName": "mediaReleaseDate",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mediaFirstAirDate",
+ "columnName": "mediaFirstAirDate",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mediaStatus",
+ "columnName": "mediaStatus",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "mediaStatus4k",
+ "columnName": "mediaStatus4k",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "is4k",
+ "columnName": "is4k",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "profileId",
+ "columnName": "profileId",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "rootFolder",
+ "columnName": "rootFolder",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "seasonsJson",
+ "columnName": "seasonsJson",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mediaAddedAt",
+ "columnName": "mediaAddedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "jellyfinMediaId",
+ "columnName": "jellyfinMediaId",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id",
+ "jellyfinServerId",
+ "jellyfinUserId"
+ ]
+ }
+ },
+ {
+ "tableName": "jellyseerr_config",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinServerId` TEXT NOT NULL, `jellyfinUserId` TEXT NOT NULL, `serverUrl` TEXT NOT NULL, `isLoggedIn` INTEGER NOT NULL, `username` TEXT, `userId` INTEGER, `permissions` INTEGER, PRIMARY KEY(`jellyfinServerId`, `jellyfinUserId`))",
+ "fields": [
+ {
+ "fieldPath": "jellyfinServerId",
+ "columnName": "jellyfinServerId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinUserId",
+ "columnName": "jellyfinUserId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverUrl",
+ "columnName": "serverUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isLoggedIn",
+ "columnName": "isLoggedIn",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "username",
+ "columnName": "username",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "permissions",
+ "columnName": "permissions",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "jellyfinServerId",
+ "jellyfinUserId"
+ ]
+ }
+ },
+ {
+ "tableName": "audiobookshelf_config",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinServerId` TEXT NOT NULL, `jellyfinUserId` TEXT NOT NULL, `serverUrl` TEXT NOT NULL, `absUserId` TEXT NOT NULL, `username` TEXT NOT NULL, `isLoggedIn` INTEGER NOT NULL, `lastSync` INTEGER NOT NULL, PRIMARY KEY(`jellyfinServerId`, `jellyfinUserId`))",
+ "fields": [
+ {
+ "fieldPath": "jellyfinServerId",
+ "columnName": "jellyfinServerId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinUserId",
+ "columnName": "jellyfinUserId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverUrl",
+ "columnName": "serverUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "absUserId",
+ "columnName": "absUserId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "username",
+ "columnName": "username",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isLoggedIn",
+ "columnName": "isLoggedIn",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSync",
+ "columnName": "lastSync",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "jellyfinServerId",
+ "jellyfinUserId"
+ ]
+ }
+ },
+ {
+ "tableName": "audiobookshelf_libraries",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `jellyfinServerId` TEXT NOT NULL, `jellyfinUserId` TEXT NOT NULL, `name` TEXT NOT NULL, `mediaType` TEXT NOT NULL, `icon` TEXT, `displayOrder` INTEGER NOT NULL, `totalItems` INTEGER NOT NULL, `totalDuration` REAL, `lastUpdated` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, PRIMARY KEY(`id`, `jellyfinServerId`, `jellyfinUserId`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinServerId",
+ "columnName": "jellyfinServerId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinUserId",
+ "columnName": "jellyfinUserId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mediaType",
+ "columnName": "mediaType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "icon",
+ "columnName": "icon",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "displayOrder",
+ "columnName": "displayOrder",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totalItems",
+ "columnName": "totalItems",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totalDuration",
+ "columnName": "totalDuration",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "lastUpdated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "cachedAt",
+ "columnName": "cachedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id",
+ "jellyfinServerId",
+ "jellyfinUserId"
+ ]
+ }
+ },
+ {
+ "tableName": "audiobookshelf_items",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `jellyfinServerId` TEXT NOT NULL, `jellyfinUserId` TEXT NOT NULL, `libraryId` TEXT NOT NULL, `title` TEXT NOT NULL, `authorName` TEXT, `narratorName` TEXT, `seriesName` TEXT, `seriesSequence` TEXT, `mediaType` TEXT NOT NULL, `duration` REAL, `coverUrl` TEXT, `description` TEXT, `publishedYear` TEXT, `genres` TEXT, `numTracks` INTEGER, `numChapters` INTEGER, `addedAt` INTEGER, `updatedAt` INTEGER, `cachedAt` INTEGER NOT NULL, `numEpisodesIncomplete` INTEGER, PRIMARY KEY(`id`, `jellyfinServerId`, `jellyfinUserId`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinServerId",
+ "columnName": "jellyfinServerId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinUserId",
+ "columnName": "jellyfinUserId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "libraryId",
+ "columnName": "libraryId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "authorName",
+ "columnName": "authorName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "narratorName",
+ "columnName": "narratorName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "seriesName",
+ "columnName": "seriesName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "seriesSequence",
+ "columnName": "seriesSequence",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mediaType",
+ "columnName": "mediaType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "coverUrl",
+ "columnName": "coverUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "publishedYear",
+ "columnName": "publishedYear",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "genres",
+ "columnName": "genres",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "numTracks",
+ "columnName": "numTracks",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "numChapters",
+ "columnName": "numChapters",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "addedAt",
+ "columnName": "addedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "cachedAt",
+ "columnName": "cachedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "numEpisodesIncomplete",
+ "columnName": "numEpisodesIncomplete",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id",
+ "jellyfinServerId",
+ "jellyfinUserId"
+ ]
+ }
+ },
+ {
+ "tableName": "audiobookshelf_episodes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `libraryItemId` TEXT NOT NULL, `jellyfinServerId` TEXT NOT NULL, `jellyfinUserId` TEXT NOT NULL, `oldEpisodeId` TEXT, `episodeIndex` INTEGER, `season` TEXT, `episode` TEXT, `episodeType` TEXT, `title` TEXT NOT NULL, `subtitle` TEXT, `description` TEXT, `enclosureUrl` TEXT, `enclosureType` TEXT, `enclosureLength` TEXT, `guid` TEXT, `pubDate` TEXT, `serializedChapters` TEXT, `serializedAudioFile` TEXT, `serializedAudioTrack` TEXT, `publishedAt` INTEGER, `addedAt` INTEGER, `updatedAt` INTEGER, `duration` REAL, `size` INTEGER, PRIMARY KEY(`id`, `libraryItemId`, `jellyfinServerId`, `jellyfinUserId`), FOREIGN KEY(`libraryItemId`, `jellyfinServerId`, `jellyfinUserId`) REFERENCES `audiobookshelf_items`(`id`, `jellyfinServerId`, `jellyfinUserId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "libraryItemId",
+ "columnName": "libraryItemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinServerId",
+ "columnName": "jellyfinServerId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinUserId",
+ "columnName": "jellyfinUserId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "oldEpisodeId",
+ "columnName": "oldEpisodeId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "episodeIndex",
+ "columnName": "episodeIndex",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "season",
+ "columnName": "season",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "episode",
+ "columnName": "episode",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "episodeType",
+ "columnName": "episodeType",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subtitle",
+ "columnName": "subtitle",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "enclosureUrl",
+ "columnName": "enclosureUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "enclosureType",
+ "columnName": "enclosureType",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "enclosureLength",
+ "columnName": "enclosureLength",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "guid",
+ "columnName": "guid",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pubDate",
+ "columnName": "pubDate",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "serializedChapters",
+ "columnName": "serializedChapters",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "serializedAudioFile",
+ "columnName": "serializedAudioFile",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "serializedAudioTrack",
+ "columnName": "serializedAudioTrack",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "publishedAt",
+ "columnName": "publishedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "addedAt",
+ "columnName": "addedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "size",
+ "columnName": "size",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id",
+ "libraryItemId",
+ "jellyfinServerId",
+ "jellyfinUserId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_audiobookshelf_episodes_libraryItemId_jellyfinServerId_jellyfinUserId",
+ "unique": false,
+ "columnNames": [
+ "libraryItemId",
+ "jellyfinServerId",
+ "jellyfinUserId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_audiobookshelf_episodes_libraryItemId_jellyfinServerId_jellyfinUserId` ON `${TABLE_NAME}` (`libraryItemId`, `jellyfinServerId`, `jellyfinUserId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "audiobookshelf_items",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "libraryItemId",
+ "jellyfinServerId",
+ "jellyfinUserId"
+ ],
+ "referencedColumns": [
+ "id",
+ "jellyfinServerId",
+ "jellyfinUserId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "audiobookshelf_progress",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `jellyfinServerId` TEXT NOT NULL, `jellyfinUserId` TEXT NOT NULL, `libraryItemId` TEXT NOT NULL, `episodeId` TEXT, `currentTime` REAL NOT NULL, `duration` REAL NOT NULL, `progress` REAL NOT NULL, `isFinished` INTEGER NOT NULL, `lastUpdate` INTEGER NOT NULL, `startedAt` INTEGER NOT NULL, `finishedAt` INTEGER, `pendingSync` INTEGER NOT NULL, PRIMARY KEY(`id`, `jellyfinServerId`, `jellyfinUserId`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinServerId",
+ "columnName": "jellyfinServerId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinUserId",
+ "columnName": "jellyfinUserId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "libraryItemId",
+ "columnName": "libraryItemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "episodeId",
+ "columnName": "episodeId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "currentTime",
+ "columnName": "currentTime",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "progress",
+ "columnName": "progress",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFinished",
+ "columnName": "isFinished",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdate",
+ "columnName": "lastUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "startedAt",
+ "columnName": "startedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "finishedAt",
+ "columnName": "finishedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "pendingSync",
+ "columnName": "pendingSync",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id",
+ "jellyfinServerId",
+ "jellyfinUserId"
+ ]
+ }
+ },
+ {
+ "tableName": "audiobookshelf_bookmarks",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinServerId` TEXT NOT NULL, `jellyfinUserId` TEXT NOT NULL, `libraryItemId` TEXT NOT NULL, `time` INTEGER NOT NULL, `serverTime` REAL NOT NULL, `title` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `pendingSync` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`jellyfinServerId`, `jellyfinUserId`, `libraryItemId`, `time`))",
+ "fields": [
+ {
+ "fieldPath": "jellyfinServerId",
+ "columnName": "jellyfinServerId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinUserId",
+ "columnName": "jellyfinUserId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "libraryItemId",
+ "columnName": "libraryItemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "time",
+ "columnName": "time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverTime",
+ "columnName": "serverTime",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pendingSync",
+ "columnName": "pendingSync",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deleted",
+ "columnName": "deleted",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "jellyfinServerId",
+ "jellyfinUserId",
+ "libraryItemId",
+ "time"
+ ]
+ }
+ },
+ {
+ "tableName": "item_metadata_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`itemId` TEXT NOT NULL, `serverId` TEXT NOT NULL, `userId` TEXT NOT NULL, `tmdbReviews` TEXT NOT NULL, `mdbRatings` TEXT NOT NULL, `mdbRatingBadges` TEXT NOT NULL, `omdbAwards` TEXT, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`itemId`, `serverId`, `userId`))",
+ "fields": [
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tmdbReviews",
+ "columnName": "tmdbReviews",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mdbRatings",
+ "columnName": "mdbRatings",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mdbRatingBadges",
+ "columnName": "mdbRatingBadges",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "omdbAwards",
+ "columnName": "omdbAwards",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "lastUpdated",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "itemId",
+ "serverId",
+ "userId"
+ ]
+ }
+ },
+ {
+ "tableName": "jellyseerr_addresses",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `jellyfinServerId` TEXT NOT NULL, `jellyfinUserId` TEXT NOT NULL, `address` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinServerId",
+ "columnName": "jellyfinServerId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinUserId",
+ "columnName": "jellyfinUserId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "address",
+ "columnName": "address",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "audiobookshelf_addresses",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `jellyfinServerId` TEXT NOT NULL, `jellyfinUserId` TEXT NOT NULL, `address` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinServerId",
+ "columnName": "jellyfinServerId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinUserId",
+ "columnName": "jellyfinUserId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "address",
+ "columnName": "address",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "jellyfin_stats_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `movieCount` INTEGER NOT NULL, `seriesCount` INTEGER NOT NULL, `episodeCount` INTEGER NOT NULL, `boxsetCount` INTEGER NOT NULL, `albumCount` INTEGER NOT NULL, `songCount` INTEGER NOT NULL, `artistCount` INTEGER NOT NULL, `musicVideoCount` INTEGER NOT NULL, `bookCount` INTEGER NOT NULL, `trailerCount` INTEGER NOT NULL, `programCount` INTEGER NOT NULL, `itemCount` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`serverId`))",
+ "fields": [
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "movieCount",
+ "columnName": "movieCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "seriesCount",
+ "columnName": "seriesCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "episodeCount",
+ "columnName": "episodeCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "boxsetCount",
+ "columnName": "boxsetCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumCount",
+ "columnName": "albumCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "songCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistCount",
+ "columnName": "artistCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "musicVideoCount",
+ "columnName": "musicVideoCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bookCount",
+ "columnName": "bookCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trailerCount",
+ "columnName": "trailerCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "programCount",
+ "columnName": "programCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemCount",
+ "columnName": "itemCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "lastUpdated",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "serverId"
+ ]
+ }
+ },
+ {
+ "tableName": "abs_downloads",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `libraryItemId` TEXT NOT NULL, `episodeId` TEXT, `jellyfinServerId` TEXT NOT NULL, `jellyfinUserId` TEXT NOT NULL, `title` TEXT NOT NULL, `authorName` TEXT, `mediaType` TEXT NOT NULL, `coverUrl` TEXT, `duration` REAL NOT NULL, `status` TEXT NOT NULL, `progress` REAL NOT NULL, `bytesDownloaded` INTEGER NOT NULL, `totalBytes` INTEGER NOT NULL, `tracksTotal` INTEGER NOT NULL, `tracksDownloaded` INTEGER NOT NULL, `error` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `localDirPath` TEXT, `serializedSession` TEXT, `episodeDescription` TEXT, `publishedAt` INTEGER, `storageVolumeId` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "libraryItemId",
+ "columnName": "libraryItemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "episodeId",
+ "columnName": "episodeId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "jellyfinServerId",
+ "columnName": "jellyfinServerId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinUserId",
+ "columnName": "jellyfinUserId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "authorName",
+ "columnName": "authorName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mediaType",
+ "columnName": "mediaType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "coverUrl",
+ "columnName": "coverUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "progress",
+ "columnName": "progress",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bytesDownloaded",
+ "columnName": "bytesDownloaded",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totalBytes",
+ "columnName": "totalBytes",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tracksTotal",
+ "columnName": "tracksTotal",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tracksDownloaded",
+ "columnName": "tracksDownloaded",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "error",
+ "columnName": "error",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "localDirPath",
+ "columnName": "localDirPath",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "serializedSession",
+ "columnName": "serializedSession",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "episodeDescription",
+ "columnName": "episodeDescription",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "publishedAt",
+ "columnName": "publishedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "storageVolumeId",
+ "columnName": "storageVolumeId",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_abs_downloads_libraryItemId_episodeId_jellyfinServerId_jellyfinUserId",
+ "unique": true,
+ "columnNames": [
+ "libraryItemId",
+ "episodeId",
+ "jellyfinServerId",
+ "jellyfinUserId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_abs_downloads_libraryItemId_episodeId_jellyfinServerId_jellyfinUserId` ON `${TABLE_NAME}` (`libraryItemId`, `episodeId`, `jellyfinServerId`, `jellyfinUserId`)"
+ }
+ ]
+ },
+ {
+ "tableName": "audible_ratings",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`itemId` TEXT NOT NULL, `jellyfinServerId` TEXT NOT NULL, `jellyfinUserId` TEXT NOT NULL, `asin` TEXT NOT NULL, `rating` REAL NOT NULL, `numRatings` INTEGER, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`itemId`, `jellyfinServerId`, `jellyfinUserId`))",
+ "fields": [
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinServerId",
+ "columnName": "jellyfinServerId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinUserId",
+ "columnName": "jellyfinUserId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "asin",
+ "columnName": "asin",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rating",
+ "columnName": "rating",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "numRatings",
+ "columnName": "numRatings",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "fetchedAt",
+ "columnName": "fetchedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "itemId",
+ "jellyfinServerId",
+ "jellyfinUserId"
+ ]
+ }
+ },
+ {
+ "tableName": "music_queue",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position` INTEGER NOT NULL, `trackId` TEXT NOT NULL, `name` TEXT NOT NULL, `artist` TEXT, `albumId` TEXT, `album` TEXT, `durationMs` INTEGER NOT NULL, `imageUrl` TEXT, `normalizationGain` REAL, `albumNormalizationGain` REAL, `indexNumber` INTEGER, `discNumber` INTEGER, `serverId` TEXT NOT NULL, PRIMARY KEY(`position`))",
+ "fields": [
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackId",
+ "columnName": "trackId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "albumId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "album",
+ "columnName": "album",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "durationMs",
+ "columnName": "durationMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "imageUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "normalizationGain",
+ "columnName": "normalizationGain",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "albumNormalizationGain",
+ "columnName": "albumNormalizationGain",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "indexNumber",
+ "columnName": "indexNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "discNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "position"
+ ]
+ }
+ },
+ {
+ "tableName": "music_tracks",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `userId` TEXT NOT NULL, `name` TEXT NOT NULL, `artist` TEXT, `artistId` TEXT, `albumId` TEXT, `albumName` TEXT, `indexNumber` INTEGER, `discNumber` INTEGER, `productionYear` INTEGER, `runtimeTicks` INTEGER NOT NULL, `images` TEXT, `normalizationGain` REAL, `albumNormalizationGain` REAL, `localFilePath` TEXT, `localImagePath` TEXT, PRIMARY KEY(`id`, `serverId`, `userId`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artistId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "albumId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "albumName",
+ "columnName": "albumName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "indexNumber",
+ "columnName": "indexNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "discNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "productionYear",
+ "columnName": "productionYear",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "runtimeTicks",
+ "columnName": "runtimeTicks",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "images",
+ "columnName": "images",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "normalizationGain",
+ "columnName": "normalizationGain",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "albumNormalizationGain",
+ "columnName": "albumNormalizationGain",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "localFilePath",
+ "columnName": "localFilePath",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "localImagePath",
+ "columnName": "localImagePath",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id",
+ "serverId",
+ "userId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_music_tracks_serverId_userId",
+ "unique": false,
+ "columnNames": [
+ "serverId",
+ "userId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_music_tracks_serverId_userId` ON `${TABLE_NAME}` (`serverId`, `userId`)"
+ },
+ {
+ "name": "index_music_tracks_albumId_serverId_userId",
+ "unique": false,
+ "columnNames": [
+ "albumId",
+ "serverId",
+ "userId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_music_tracks_albumId_serverId_userId` ON `${TABLE_NAME}` (`albumId`, `serverId`, `userId`)"
+ }
+ ]
+ },
+ {
+ "tableName": "music_albums",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `userId` TEXT NOT NULL, `name` TEXT NOT NULL, `artist` TEXT, `artistId` TEXT, `productionYear` INTEGER, `songCount` INTEGER, `runtimeTicks` INTEGER NOT NULL, `images` TEXT, `localImagePath` TEXT, PRIMARY KEY(`id`, `serverId`, `userId`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artistId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "productionYear",
+ "columnName": "productionYear",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "songCount",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "runtimeTicks",
+ "columnName": "runtimeTicks",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "images",
+ "columnName": "images",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "localImagePath",
+ "columnName": "localImagePath",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id",
+ "serverId",
+ "userId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_music_albums_serverId_userId",
+ "unique": false,
+ "columnNames": [
+ "serverId",
+ "userId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_music_albums_serverId_userId` ON `${TABLE_NAME}` (`serverId`, `userId`)"
+ }
+ ]
+ },
+ {
+ "tableName": "music_lyrics",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `serverId` TEXT NOT NULL, `userId` TEXT NOT NULL, `lyricsJson` TEXT NOT NULL, `cachedAt` INTEGER NOT NULL, PRIMARY KEY(`trackId`, `serverId`, `userId`))",
+ "fields": [
+ {
+ "fieldPath": "trackId",
+ "columnName": "trackId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lyricsJson",
+ "columnName": "lyricsJson",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "cachedAt",
+ "columnName": "cachedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "trackId",
+ "serverId",
+ "userId"
+ ]
+ }
+ },
+ {
+ "tableName": "home_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`key`))",
+ "fields": [
+ {
+ "fieldPath": "key",
+ "columnName": "key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "json",
+ "columnName": "json",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "key"
+ ]
+ }
+ },
+ {
+ "tableName": "jellyseerr_discover_filters",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinServerId` TEXT NOT NULL, `jellyfinUserId` TEXT NOT NULL, `filterContextKey` TEXT NOT NULL, `sortBy` TEXT NOT NULL, `filterOptionsJson` TEXT NOT NULL, PRIMARY KEY(`jellyfinServerId`, `jellyfinUserId`, `filterContextKey`))",
+ "fields": [
+ {
+ "fieldPath": "jellyfinServerId",
+ "columnName": "jellyfinServerId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinUserId",
+ "columnName": "jellyfinUserId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "filterContextKey",
+ "columnName": "filterContextKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortBy",
+ "columnName": "sortBy",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "filterOptionsJson",
+ "columnName": "filterOptionsJson",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "jellyfinServerId",
+ "jellyfinUserId",
+ "filterContextKey"
+ ]
+ }
+ },
+ {
+ "tableName": "custom_home_sections",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `position` INTEGER NOT NULL, `title` TEXT NOT NULL, `sourceType` TEXT NOT NULL, `sourceValue` TEXT NOT NULL, `includeItemTypes` TEXT NOT NULL, `itemLimit` INTEGER NOT NULL, `sortBy` TEXT NOT NULL, `sortDescending` INTEGER NOT NULL, `randomOrder` INTEGER NOT NULL, `cardStyle` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `seasonStart` TEXT, `seasonEnd` TEXT, `filtersJson` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sessionKey",
+ "columnName": "sessionKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceType",
+ "columnName": "sourceType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceValue",
+ "columnName": "sourceValue",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "includeItemTypes",
+ "columnName": "includeItemTypes",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemLimit",
+ "columnName": "itemLimit",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortBy",
+ "columnName": "sortBy",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortDescending",
+ "columnName": "sortDescending",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "randomOrder",
+ "columnName": "randomOrder",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "cardStyle",
+ "columnName": "cardStyle",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "enabled",
+ "columnName": "enabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "seasonStart",
+ "columnName": "seasonStart",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "seasonEnd",
+ "columnName": "seasonEnd",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "filtersJson",
+ "columnName": "filtersJson",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_custom_home_sections_sessionKey",
+ "unique": false,
+ "columnNames": [
+ "sessionKey"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_custom_home_sections_sessionKey` ON `${TABLE_NAME}` (`sessionKey`)"
+ }
+ ]
+ },
+ {
+ "tableName": "home_layout_preferences",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionKey` TEXT NOT NULL, `sectionKey` TEXT NOT NULL, `position` INTEGER NOT NULL, `visible` INTEGER NOT NULL, `maxCount` INTEGER, PRIMARY KEY(`sessionKey`, `sectionKey`))",
+ "fields": [
+ {
+ "fieldPath": "sessionKey",
+ "columnName": "sessionKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sectionKey",
+ "columnName": "sectionKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "visible",
+ "columnName": "visible",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "maxCount",
+ "columnName": "maxCount",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "sessionKey",
+ "sectionKey"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_home_layout_preferences_sessionKey",
+ "unique": false,
+ "columnNames": [
+ "sessionKey"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_home_layout_preferences_sessionKey` ON `${TABLE_NAME}` (`sessionKey`)"
+ }
+ ]
+ },
+ {
+ "tableName": "deleted_items",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`itemId` TEXT NOT NULL, `serverId` TEXT NOT NULL, `deletedAt` INTEGER NOT NULL, PRIMARY KEY(`itemId`))",
+ "fields": [
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "itemId"
+ ]
+ }
+ },
+ {
+ "tableName": "server_storage_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `payload` TEXT NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`serverId`))",
+ "fields": [
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "payload",
+ "columnName": "payload",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "lastUpdated",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "serverId"
+ ]
+ }
+ },
+ {
+ "tableName": "wikidata_awards_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subjectType` TEXT NOT NULL, `tmdbId` TEXT NOT NULL, `awards` TEXT NOT NULL, `confirmed` INTEGER NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`subjectType`, `tmdbId`))",
+ "fields": [
+ {
+ "fieldPath": "subjectType",
+ "columnName": "subjectType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tmdbId",
+ "columnName": "tmdbId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "awards",
+ "columnName": "awards",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "confirmed",
+ "columnName": "confirmed",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fetchedAt",
+ "columnName": "fetchedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "subjectType",
+ "tmdbId"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '73039f9e281bd9105f1a3db3e80c0fe0')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/makd/afinity/data/database/AfinityMigrationTest.kt b/app/src/androidTest/java/com/makd/afinity/data/database/AfinityMigrationTest.kt
new file mode 100644
index 000000000..c9a24f5f1
--- /dev/null
+++ b/app/src/androidTest/java/com/makd/afinity/data/database/AfinityMigrationTest.kt
@@ -0,0 +1,41 @@
+package com.makd.afinity.data.database
+
+import androidx.room.Room
+import androidx.room.testing.MigrationTestHelper
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import java.io.IOException
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class AfinityMigrationTest {
+
+ @get:Rule
+ val helper: MigrationTestHelper =
+ MigrationTestHelper(
+ InstrumentationRegistry.getInstrumentation(),
+ AfinityDatabase::class.java,
+ )
+
+ @Test
+ @Throws(IOException::class)
+ fun currentSchemaOpensWithAllMigrationsRegistered() {
+ helper.createDatabase(TEST_DB, AFINITY_DB_VERSION).close()
+
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val db =
+ Room.databaseBuilder(context, AfinityDatabase::class.java, TEST_DB)
+ .addMigrations(*DatabaseMigrations.ALL_MIGRATIONS)
+ .build()
+
+ assertTrue(db.openHelper.writableDatabase.isOpen)
+ db.close()
+ }
+
+ private companion object {
+ const val TEST_DB = "afinity-migration-test"
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5a1677995..9564de8c6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,8 @@
+
+
diff --git a/app/src/main/java/com/makd/afinity/AfinityApplication.kt b/app/src/main/java/com/makd/afinity/AfinityApplication.kt
index a24421e8c..9df238de5 100644
--- a/app/src/main/java/com/makd/afinity/AfinityApplication.kt
+++ b/app/src/main/java/com/makd/afinity/AfinityApplication.kt
@@ -24,7 +24,11 @@ import com.makd.afinity.di.ImageClient
import com.makd.afinity.ui.components.IMAGE_CROSSFADE_MILLIS
import com.makd.afinity.util.logging.CrashFileExporter
import com.makd.afinity.util.logging.RingBufferTree
+import com.makd.afinity.util.logging.SdkLogBridge
import dagger.hilt.android.HiltAndroidApp
+import io.github.oshai.kotlinlogging.Level
+import javax.inject.Inject
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -33,7 +37,6 @@ import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import okio.Path.Companion.toOkioPath
import timber.log.Timber
-import javax.inject.Inject
@HiltAndroidApp
class AfinityApplication : Application(), Configuration.Provider, SingletonImageLoader.Factory {
@@ -69,6 +72,7 @@ class AfinityApplication : Application(), Configuration.Provider, SingletonImage
Timber.plant(Timber.DebugTree())
Timber.d("Afinity Application started")
}
+ SdkLogBridge.install(if (BuildConfig.DEBUG) Level.DEBUG else Level.WARN)
Thread.setDefaultUncaughtExceptionHandler(
CrashFileExporter(this, ringBufferTree, Thread.getDefaultUncaughtExceptionHandler())
@@ -82,6 +86,8 @@ class AfinityApplication : Application(), Configuration.Provider, SingletonImage
Timber.d(
"ImageLoader prefs: cacheEnabled=$imageCacheEnabled, cacheSizeMb=$imageCacheSizeMb"
)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to read ImageLoader prefs, using defaults")
}
diff --git a/app/src/main/java/com/makd/afinity/AuthViewModel.kt b/app/src/main/java/com/makd/afinity/AuthViewModel.kt
index cee347d0a..a49bac75d 100644
--- a/app/src/main/java/com/makd/afinity/AuthViewModel.kt
+++ b/app/src/main/java/com/makd/afinity/AuthViewModel.kt
@@ -4,13 +4,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.makd.afinity.data.repository.auth.AuthRepository
import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import timber.log.Timber
-import javax.inject.Inject
sealed class AuthenticationState {
object Loading : AuthenticationState()
@@ -21,9 +22,7 @@ sealed class AuthenticationState {
}
@HiltViewModel
-class AuthViewModel
-@Inject
-constructor(private val authRepository: AuthRepository) : ViewModel() {
+class AuthViewModel @Inject constructor(private val authRepository: AuthRepository) : ViewModel() {
private val _authenticationState =
MutableStateFlow(AuthenticationState.Loading)
@@ -57,6 +56,8 @@ constructor(private val authRepository: AuthRepository) : ViewModel() {
_authenticationState.value = AuthenticationState.NotAuthenticated
}
}
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Error checking authentication state")
_authenticationState.value = AuthenticationState.NotAuthenticated
diff --git a/app/src/main/java/com/makd/afinity/MainActivity.kt b/app/src/main/java/com/makd/afinity/MainActivity.kt
index 3d6fef4c9..5d49d3a8f 100644
--- a/app/src/main/java/com/makd/afinity/MainActivity.kt
+++ b/app/src/main/java/com/makd/afinity/MainActivity.kt
@@ -10,7 +10,10 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -22,8 +25,10 @@ import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
@@ -32,6 +37,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.makd.afinity.data.manager.OfflineModeManager
import com.makd.afinity.data.manager.PendingNavigationManager
+import com.makd.afinity.data.manager.RemoteMessageManager
import com.makd.afinity.data.repository.PreferencesRepository
import com.makd.afinity.data.updater.UpdateManager
import com.makd.afinity.data.updater.UpdateScheduler
@@ -40,12 +46,13 @@ import com.makd.afinity.data.updater.notification.UpdateNotificationManager
import com.makd.afinity.data.websocket.AudiobookshelfSocketManager
import com.makd.afinity.navigation.Destination
import com.makd.afinity.navigation.MainNavigation
+import com.makd.afinity.ui.components.RemoteMessagePill
import com.makd.afinity.ui.theme.AFinityTheme
import com.makd.afinity.ui.theme.ThemeMode
import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
-import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@@ -61,6 +68,8 @@ class MainActivity : AppCompatActivity() {
@Inject lateinit var pendingNavigationManager: PendingNavigationManager
+ @Inject lateinit var remoteMessageManager: RemoteMessageManager
+
private val authViewModel: AuthViewModel by viewModels()
private val showNotificationRationale = MutableStateFlow(false)
@@ -118,13 +127,26 @@ class MainActivity : AppCompatActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
- MainContent(
- modifier = Modifier.fillMaxSize(),
- viewModel = authViewModel,
- updateManager = updateManager,
- offlineModeManager = offlineModeManager,
- widthSizeClass = windowSize.widthSizeClass,
- )
+ Box(modifier = Modifier.fillMaxSize()) {
+ MainContent(
+ modifier = Modifier.fillMaxSize(),
+ viewModel = authViewModel,
+ updateManager = updateManager,
+ offlineModeManager = offlineModeManager,
+ widthSizeClass = windowSize.widthSizeClass,
+ )
+
+ val remoteMessage by
+ remoteMessageManager.message.collectAsStateWithLifecycle()
+ RemoteMessagePill(
+ message = remoteMessage,
+ onDismiss = remoteMessageManager::dismiss,
+ modifier =
+ Modifier.align(Alignment.TopCenter)
+ .statusBarsPadding()
+ .padding(top = 8.dp),
+ )
+ }
}
val showRationale by showNotificationRationale.collectAsStateWithLifecycle()
diff --git a/app/src/main/java/com/makd/afinity/cast/CastDeviceProfileFactory.kt b/app/src/main/java/com/makd/afinity/cast/CastDeviceProfileFactory.kt
index 8832f7f10..025ff54b5 100644
--- a/app/src/main/java/com/makd/afinity/cast/CastDeviceProfileFactory.kt
+++ b/app/src/main/java/com/makd/afinity/cast/CastDeviceProfileFactory.kt
@@ -1,5 +1,7 @@
package com.makd.afinity.cast
+import javax.inject.Inject
+import javax.inject.Singleton
import org.jellyfin.sdk.model.api.CodecProfile
import org.jellyfin.sdk.model.api.CodecType
import org.jellyfin.sdk.model.api.DeviceProfile
@@ -13,8 +15,6 @@ import org.jellyfin.sdk.model.api.ProfileConditionValue
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.api.SubtitleProfile
import org.jellyfin.sdk.model.api.TranscodingProfile
-import javax.inject.Inject
-import javax.inject.Singleton
@Singleton
class CastDeviceProfileFactory @Inject constructor() {
diff --git a/app/src/main/java/com/makd/afinity/cast/CastManager.kt b/app/src/main/java/com/makd/afinity/cast/CastManager.kt
index 507c8c055..0804f2318 100644
--- a/app/src/main/java/com/makd/afinity/cast/CastManager.kt
+++ b/app/src/main/java/com/makd/afinity/cast/CastManager.kt
@@ -23,6 +23,9 @@ import com.makd.afinity.data.models.media.AfinityItem
import com.makd.afinity.data.models.music.AfinityTrack
import com.makd.afinity.data.repository.SecurePreferencesRepository
import com.makd.afinity.data.repository.playback.PlaybackRepository
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -39,8 +42,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.MediaStreamType
import timber.log.Timber
-import javax.inject.Inject
-import javax.inject.Singleton
private const val DEFAULT_MAX_BITRATE = 16_000_000
@@ -89,6 +90,8 @@ constructor(
.setOutputSwitcherEnabled(true)
.build()
Timber.d("CastManager initialized")
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to initialize CastManager")
}
@@ -306,6 +309,8 @@ constructor(
_castEvents.emit(CastEvent.PlaybackStarted(item.id))
Timber.d("Cast media loaded: ${item.name}")
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to load media on cast device")
_castEvents.emit(CastEvent.PlaybackError("Failed to load media: ${e.message}"))
@@ -373,6 +378,8 @@ constructor(
Timber.d(
"Music queue loaded to Cast: ${tracks.size} tracks, starting at $startIndex"
)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to load music queue on cast device")
_castEvents.emit(CastEvent.PlaybackError("Failed to load music: ${e.message}"))
@@ -385,6 +392,8 @@ constructor(
try {
if (forward) remoteMediaClient?.queueNext(null)
else remoteMediaClient?.queuePrev(null)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to skip music track on cast device")
}
@@ -459,6 +468,8 @@ constructor(
Timber.d(
"ABS loaded to Cast: ${tracks.size} tracks, starting at index $startTrackIndex, pos ${startPositionInTrackMs}ms"
)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to load ABS session on cast device")
_castEvents.emit(CastEvent.PlaybackError("Failed to load audiobook: ${e.message}"))
@@ -483,6 +494,8 @@ constructor(
client.queueJumpToItem(targetEntry.key, positionWithinTrackMs, null)
}
}
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to seek ABS position on cast device")
}
@@ -543,6 +556,8 @@ constructor(
stopProgressReporting()
stopPositionPolling()
resetPlaybackState()
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Error stopping cast playback")
}
@@ -556,6 +571,8 @@ constructor(
try {
castSession?.volume = volume.coerceIn(0.0, 1.0)
_castState.update { it.copy(volume = volume.coerceIn(0.0, 1.0)) }
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to set cast volume")
}
@@ -677,6 +694,8 @@ constructor(
scope.launch {
try {
castContext?.sessionManager?.endCurrentSession(true)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Error disconnecting cast session")
}
@@ -763,6 +782,8 @@ constructor(
_castState.value =
_castState.value.copy(volume = session.volume, isMuted = session.isMute)
}
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Error polling cast position")
}
@@ -890,6 +911,8 @@ constructor(
positionTicks = finalState.currentPosition * 10000,
mediaSourceId = mediaSourceId,
)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to report cast playback stop")
}
diff --git a/app/src/main/java/com/makd/afinity/data/database/AfinityDatabase.kt b/app/src/main/java/com/makd/afinity/data/database/AfinityDatabase.kt
index 8f57846ad..2bcca7b4d 100644
--- a/app/src/main/java/com/makd/afinity/data/database/AfinityDatabase.kt
+++ b/app/src/main/java/com/makd/afinity/data/database/AfinityDatabase.kt
@@ -6,7 +6,6 @@ import androidx.room.TypeConverters
import com.makd.afinity.data.database.dao.AbsDownloadDao
import com.makd.afinity.data.database.dao.AudibleRatingDao
import com.makd.afinity.data.database.dao.AudiobookshelfDao
-import com.makd.afinity.data.database.dao.BoxSetCacheDao
import com.makd.afinity.data.database.dao.CustomHomeSectionDao
import com.makd.afinity.data.database.dao.DeletedItemDao
import com.makd.afinity.data.database.dao.EpisodeDao
@@ -27,6 +26,7 @@ import com.makd.afinity.data.database.dao.SeasonDao
import com.makd.afinity.data.database.dao.ServerAddressDao
import com.makd.afinity.data.database.dao.ServerDao
import com.makd.afinity.data.database.dao.ServerDatabaseDao
+import com.makd.afinity.data.database.dao.ServerStorageDao
import com.makd.afinity.data.database.dao.ShowDao
import com.makd.afinity.data.database.dao.SourceDao
import com.makd.afinity.data.database.dao.TopPeopleDao
@@ -44,13 +44,12 @@ import com.makd.afinity.data.database.entities.AfinitySourceDto
import com.makd.afinity.data.database.entities.AfinityTrickplayInfoDto
import com.makd.afinity.data.database.entities.AudibleRatingEntity
import com.makd.afinity.data.database.entities.AudiobookshelfAddressEntity
+import com.makd.afinity.data.database.entities.AudiobookshelfBookmarkEntity
import com.makd.afinity.data.database.entities.AudiobookshelfConfigEntity
import com.makd.afinity.data.database.entities.AudiobookshelfEpisodeEntity
import com.makd.afinity.data.database.entities.AudiobookshelfItemEntity
import com.makd.afinity.data.database.entities.AudiobookshelfLibraryEntity
import com.makd.afinity.data.database.entities.AudiobookshelfProgressEntity
-import com.makd.afinity.data.database.entities.BoxSetCacheEntity
-import com.makd.afinity.data.database.entities.BoxSetCacheMetadata
import com.makd.afinity.data.database.entities.CustomHomeSectionEntity
import com.makd.afinity.data.database.entities.DeletedItemEntity
import com.makd.afinity.data.database.entities.DownloadDto
@@ -70,6 +69,7 @@ import com.makd.afinity.data.database.entities.MusicLyricsEntity
import com.makd.afinity.data.database.entities.MusicQueueEntity
import com.makd.afinity.data.database.entities.MusicTrackEntity
import com.makd.afinity.data.database.entities.PersonSectionCacheEntity
+import com.makd.afinity.data.database.entities.ServerStorageCacheEntity
import com.makd.afinity.data.database.entities.ShowGenreCacheEntity
import com.makd.afinity.data.database.entities.TopPeopleCacheEntity
import com.makd.afinity.data.database.entities.WikidataAwardsCacheEntity
@@ -78,14 +78,14 @@ import com.makd.afinity.data.models.server.ServerAddress
import com.makd.afinity.data.models.user.AfinityUserDataDto
import com.makd.afinity.data.models.user.User
+const val AFINITY_DB_VERSION = 78
+
@Database(
entities =
[
Server::class,
ServerAddress::class,
User::class,
- BoxSetCacheEntity::class,
- BoxSetCacheMetadata::class,
GenreCacheEntity::class,
GenreMovieCacheEntity::class,
ShowGenreCacheEntity::class,
@@ -109,6 +109,7 @@ import com.makd.afinity.data.models.user.User
AudiobookshelfItemEntity::class,
AudiobookshelfEpisodeEntity::class,
AudiobookshelfProgressEntity::class,
+ AudiobookshelfBookmarkEntity::class,
ItemMetadataCacheEntity::class,
JellyseerrAddressEntity::class,
AudiobookshelfAddressEntity::class,
@@ -124,10 +125,11 @@ import com.makd.afinity.data.models.user.User
CustomHomeSectionEntity::class,
HomeLayoutPreferenceEntity::class,
DeletedItemEntity::class,
+ ServerStorageCacheEntity::class,
WikidataAwardsCacheEntity::class,
],
- version = 71,
- exportSchema = false,
+ version = AFINITY_DB_VERSION,
+ exportSchema = true,
)
@TypeConverters(AfinityTypeConverters::class)
abstract class AfinityDatabase : RoomDatabase() {
@@ -154,8 +156,6 @@ abstract class AfinityDatabase : RoomDatabase() {
abstract fun serverDatabaseDao(): ServerDatabaseDao
- abstract fun boxSetCacheDao(): BoxSetCacheDao
-
abstract fun genreCacheDao(): GenreCacheDao
abstract fun topPeopleDao(): TopPeopleDao
@@ -170,6 +170,8 @@ abstract class AfinityDatabase : RoomDatabase() {
abstract fun jellyfinStatsDao(): JellyfinStatsDao
+ abstract fun serverStorageDao(): ServerStorageDao
+
abstract fun absDownloadDao(): AbsDownloadDao
abstract fun audibleRatingDao(): AudibleRatingDao
diff --git a/app/src/main/java/com/makd/afinity/data/database/AfinityTypeConverters.kt b/app/src/main/java/com/makd/afinity/data/database/AfinityTypeConverters.kt
index 09284e5ff..2dc58787d 100644
--- a/app/src/main/java/com/makd/afinity/data/database/AfinityTypeConverters.kt
+++ b/app/src/main/java/com/makd/afinity/data/database/AfinityTypeConverters.kt
@@ -16,13 +16,13 @@ import com.makd.afinity.data.models.media.AfinitySourceType
import com.makd.afinity.data.models.media.AfinityStudio
import com.makd.afinity.data.models.tmdb.TmdbReview
import com.makd.afinity.data.models.wikidata.WikidataAward
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+import java.util.UUID
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PersonKind
-import java.time.LocalDateTime
-import java.time.format.DateTimeFormatter
-import java.util.UUID
class AfinityTypeConverters {
diff --git a/app/src/main/java/com/makd/afinity/data/database/DatabaseMigrations.kt b/app/src/main/java/com/makd/afinity/data/database/DatabaseMigrations.kt
index c20aa5715..3573a803e 100644
--- a/app/src/main/java/com/makd/afinity/data/database/DatabaseMigrations.kt
+++ b/app/src/main/java/com/makd/afinity/data/database/DatabaseMigrations.kt
@@ -1501,6 +1501,150 @@ object DatabaseMigrations {
}
}
+ val MIGRATION_71_72 =
+ object : Migration(71, 72) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ "ALTER TABLE `mediastreams` ADD COLUMN `isOriginal` INTEGER NOT NULL DEFAULT 0"
+ )
+ db.execSQL(
+ "ALTER TABLE `mediastreams` ADD COLUMN `localizedLanguage` TEXT DEFAULT NULL"
+ )
+ db.execSQL(
+ "ALTER TABLE `mediastreams` ADD COLUMN `localizedForced` TEXT DEFAULT NULL"
+ )
+ db.execSQL(
+ "ALTER TABLE `mediastreams` ADD COLUMN `localizedExternal` TEXT DEFAULT NULL"
+ )
+ db.execSQL(
+ "ALTER TABLE `mediastreams` ADD COLUMN `localizedHearingImpaired` TEXT DEFAULT NULL"
+ )
+ db.execSQL(
+ "ALTER TABLE `mediastreams` ADD COLUMN `localizedOriginal` TEXT DEFAULT NULL"
+ )
+ }
+ }
+
+ val MIGRATION_72_73 =
+ object : Migration(72, 73) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ "ALTER TABLE `music_tracks` ADD COLUMN `normalizationGain` REAL DEFAULT NULL"
+ )
+ db.execSQL(
+ "ALTER TABLE `music_tracks` ADD COLUMN `albumNormalizationGain` REAL DEFAULT NULL"
+ )
+ db.execSQL(
+ "ALTER TABLE `music_queue` ADD COLUMN `albumNormalizationGain` REAL DEFAULT NULL"
+ )
+ }
+ }
+
+ val MIGRATION_73_74 =
+ object : Migration(73, 74) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("DROP TABLE IF EXISTS `boxset_cache`")
+ db.execSQL("DROP TABLE IF EXISTS `boxset_cache_metadata`")
+ }
+ }
+
+ val MIGRATION_74_75 =
+ object : Migration(74, 75) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ listOf(
+ "albumCount",
+ "songCount",
+ "artistCount",
+ "musicVideoCount",
+ "bookCount",
+ "trailerCount",
+ "programCount",
+ "itemCount",
+ )
+ .forEach { column ->
+ db.execSQL(
+ "ALTER TABLE `jellyfin_stats_cache` ADD COLUMN `$column` INTEGER NOT NULL DEFAULT 0"
+ )
+ }
+ }
+ }
+
+ val MIGRATION_75_76 =
+ object : Migration(75, 76) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS server_storage_cache (
+ serverId TEXT NOT NULL PRIMARY KEY,
+ payload TEXT NOT NULL,
+ lastUpdated INTEGER NOT NULL
+ )
+ """
+ .trimIndent()
+ )
+ }
+ }
+
+ val MIGRATION_76_77 =
+ object : Migration(76, 77) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ "ALTER TABLE `mediastreams` ADD COLUMN `hdr10PlusPresentFlag` INTEGER NOT NULL DEFAULT 0"
+ )
+ db.execSQL(
+ "UPDATE `mediastreams` SET `videoRangeType` = 'HDR10Plus' WHERE `videoRangeType` = 'HDR10_PLUS'"
+ )
+ db.execSQL(
+ "UPDATE `mediastreams` SET `videoRangeType` = 'DOVIWithHDR10' WHERE `videoRangeType` = 'DOVI_WITH_HDR10'"
+ )
+ db.execSQL(
+ "UPDATE `mediastreams` SET `videoRangeType` = 'DOVIWithHLG' WHERE `videoRangeType` = 'DOVI_WITH_HLG'"
+ )
+ db.execSQL(
+ "UPDATE `mediastreams` SET `videoRangeType` = 'DOVIWithSDR' WHERE `videoRangeType` = 'DOVI_WITH_SDR'"
+ )
+ db.execSQL(
+ "UPDATE `mediastreams` SET `videoRangeType` = 'DOVIWithEL' WHERE `videoRangeType` = 'DOVI_WITH_EL'"
+ )
+ db.execSQL(
+ "UPDATE `mediastreams` SET `videoRangeType` = 'DOVIWithHDR10Plus' WHERE `videoRangeType` = 'DOVI_WITH_HDR10_PLUS'"
+ )
+ db.execSQL(
+ "UPDATE `mediastreams` SET `videoRangeType` = 'DOVIWithELHDR10Plus' WHERE `videoRangeType` = 'DOVI_WITH_ELHDR10_PLUS'"
+ )
+ db.execSQL(
+ "UPDATE `mediastreams` SET `videoRangeType` = 'DOVIInvalid' WHERE `videoRangeType` = 'DOVI_INVALID'"
+ )
+ db.execSQL(
+ "UPDATE `mediastreams` SET `videoRangeType` = 'Unknown' WHERE `videoRangeType` = 'UNKNOWN'"
+ )
+ }
+ }
+
+ val MIGRATION_77_78 =
+ object : Migration(77, 78) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `audiobookshelf_bookmarks` (
+ `jellyfinServerId` TEXT NOT NULL,
+ `jellyfinUserId` TEXT NOT NULL,
+ `libraryItemId` TEXT NOT NULL,
+ `time` INTEGER NOT NULL,
+ `serverTime` REAL NOT NULL,
+ `title` TEXT NOT NULL,
+ `createdAt` INTEGER NOT NULL,
+ `pendingSync` INTEGER NOT NULL DEFAULT 0,
+ `deleted` INTEGER NOT NULL DEFAULT 0,
+ `updatedAt` INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY(`jellyfinServerId`, `jellyfinUserId`, `libraryItemId`, `time`)
+ )
+ """
+ .trimIndent()
+ )
+ }
+ }
+
val ALL_MIGRATIONS =
arrayOf(
MIGRATION_1_2,
@@ -1573,5 +1717,12 @@ object DatabaseMigrations {
MIGRATION_68_69,
MIGRATION_69_70,
MIGRATION_70_71,
+ MIGRATION_71_72,
+ MIGRATION_72_73,
+ MIGRATION_73_74,
+ MIGRATION_74_75,
+ MIGRATION_75_76,
+ MIGRATION_76_77,
+ MIGRATION_77_78,
)
}
diff --git a/app/src/main/java/com/makd/afinity/data/database/dao/AbsDownloadDao.kt b/app/src/main/java/com/makd/afinity/data/database/dao/AbsDownloadDao.kt
index 45bdfbceb..8ef856227 100644
--- a/app/src/main/java/com/makd/afinity/data/database/dao/AbsDownloadDao.kt
+++ b/app/src/main/java/com/makd/afinity/data/database/dao/AbsDownloadDao.kt
@@ -6,8 +6,8 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.makd.afinity.data.database.entities.AbsDownloadEntity
import com.makd.afinity.data.models.audiobookshelf.AbsDownloadStatus
-import kotlinx.coroutines.flow.Flow
import java.util.UUID
+import kotlinx.coroutines.flow.Flow
@Dao
interface AbsDownloadDao {
diff --git a/app/src/main/java/com/makd/afinity/data/database/dao/AudiobookshelfDao.kt b/app/src/main/java/com/makd/afinity/data/database/dao/AudiobookshelfDao.kt
index c9c61b441..90c32cc36 100644
--- a/app/src/main/java/com/makd/afinity/data/database/dao/AudiobookshelfDao.kt
+++ b/app/src/main/java/com/makd/afinity/data/database/dao/AudiobookshelfDao.kt
@@ -6,13 +6,14 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.makd.afinity.data.database.entities.AudiobookshelfAddressEntity
+import com.makd.afinity.data.database.entities.AudiobookshelfBookmarkEntity
import com.makd.afinity.data.database.entities.AudiobookshelfConfigEntity
import com.makd.afinity.data.database.entities.AudiobookshelfEpisodeEntity
import com.makd.afinity.data.database.entities.AudiobookshelfItemEntity
import com.makd.afinity.data.database.entities.AudiobookshelfLibraryEntity
import com.makd.afinity.data.database.entities.AudiobookshelfProgressEntity
-import kotlinx.coroutines.flow.Flow
import java.util.UUID
+import kotlinx.coroutines.flow.Flow
@Dao
interface AudiobookshelfDao {
@@ -296,6 +297,53 @@ interface AudiobookshelfDao {
)
suspend fun deleteAllProgress(serverId: String, userId: String)
+ @Query(
+ "SELECT * FROM audiobookshelf_bookmarks WHERE libraryItemId = :itemId AND deleted = 0 " +
+ "AND jellyfinServerId = :serverId AND jellyfinUserId = :userId ORDER BY time ASC"
+ )
+ fun getBookmarksForItemFlow(
+ itemId: String,
+ serverId: String,
+ userId: String,
+ ): Flow>
+
+ @Query(
+ "SELECT * FROM audiobookshelf_bookmarks WHERE pendingSync = 1 " +
+ "AND jellyfinServerId = :serverId AND jellyfinUserId = :userId ORDER BY updatedAt ASC"
+ )
+ suspend fun getPendingSyncBookmarks(
+ serverId: String,
+ userId: String,
+ ): List
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertBookmark(bookmark: AudiobookshelfBookmarkEntity)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertBookmarks(bookmarks: List)
+
+ @Query(
+ "DELETE FROM audiobookshelf_bookmarks WHERE libraryItemId = :itemId AND time = :time " +
+ "AND jellyfinServerId = :serverId AND jellyfinUserId = :userId"
+ )
+ suspend fun deleteBookmarkRow(
+ itemId: String,
+ time: Long,
+ serverId: String,
+ userId: String,
+ )
+
+ @Query(
+ "DELETE FROM audiobookshelf_bookmarks WHERE pendingSync = 0 " +
+ "AND jellyfinServerId = :serverId AND jellyfinUserId = :userId"
+ )
+ suspend fun deleteSyncedBookmarks(serverId: String, userId: String)
+
+ @Query(
+ "DELETE FROM audiobookshelf_bookmarks WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId"
+ )
+ suspend fun deleteAllBookmarks(serverId: String, userId: String)
+
@Query(
"DELETE FROM audiobookshelf_items WHERE cachedAt < :expiryTime AND jellyfinServerId = :serverId AND jellyfinUserId = :userId"
)
diff --git a/app/src/main/java/com/makd/afinity/data/database/dao/BoxSetCacheDao.kt b/app/src/main/java/com/makd/afinity/data/database/dao/BoxSetCacheDao.kt
deleted file mode 100644
index 1de2d24f4..000000000
--- a/app/src/main/java/com/makd/afinity/data/database/dao/BoxSetCacheDao.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-package com.makd.afinity.data.database.dao
-
-import androidx.room.Dao
-import androidx.room.Insert
-import androidx.room.OnConflictStrategy
-import androidx.room.Query
-import com.makd.afinity.data.database.entities.BoxSetCacheEntity
-import com.makd.afinity.data.database.entities.BoxSetCacheMetadata
-
-@Dao
-interface BoxSetCacheDao {
-
- @Query(
- "SELECT * FROM boxset_cache WHERE itemId = :itemId AND serverId = :serverId AND userId = :userId"
- )
- suspend fun getCacheEntry(itemId: String, serverId: String, userId: String): BoxSetCacheEntity?
-
- @Query("SELECT * FROM boxset_cache WHERE serverId = :serverId AND userId = :userId")
- suspend fun getAllCacheEntries(serverId: String, userId: String): List
-
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insertCacheEntries(entries: List)
-
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insertCacheEntry(entry: BoxSetCacheEntity)
-
- @Query("DELETE FROM boxset_cache WHERE serverId = :serverId AND userId = :userId")
- suspend fun clearAllCacheEntries(serverId: String, userId: String)
-
- @Query(
- "DELETE FROM boxset_cache WHERE itemId = :itemId AND serverId = :serverId AND userId = :userId"
- )
- suspend fun deleteCacheEntry(itemId: String, serverId: String, userId: String)
-
- @Query("SELECT COUNT(*) FROM boxset_cache WHERE serverId = :serverId AND userId = :userId")
- suspend fun getCacheSize(serverId: String, userId: String): Int
-
- @Query("SELECT * FROM boxset_cache_metadata WHERE serverId = :serverId AND userId = :userId")
- suspend fun getMetadata(serverId: String, userId: String): BoxSetCacheMetadata?
-
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insertMetadata(metadata: BoxSetCacheMetadata)
-
- @Query("DELETE FROM boxset_cache_metadata WHERE serverId = :serverId AND userId = :userId")
- suspend fun clearMetadata(serverId: String, userId: String)
-
- @Query("DELETE FROM boxset_cache") suspend fun clearAllCache()
-
- @androidx.room.Transaction
- suspend fun clearAllCache(serverId: String, userId: String) {
- clearAllCacheEntries(serverId, userId)
- clearMetadata(serverId, userId)
- }
-
- @Query("SELECT (SELECT COUNT(*) FROM boxset_cache)") suspend fun cachedEntryCount(): Int
-}
diff --git a/app/src/main/java/com/makd/afinity/data/database/dao/ServerDatabaseDao.kt b/app/src/main/java/com/makd/afinity/data/database/dao/ServerDatabaseDao.kt
index fbd1374af..e6dcc879e 100644
--- a/app/src/main/java/com/makd/afinity/data/database/dao/ServerDatabaseDao.kt
+++ b/app/src/main/java/com/makd/afinity/data/database/dao/ServerDatabaseDao.kt
@@ -16,8 +16,8 @@ import com.makd.afinity.data.database.entities.AfinityTrickplayInfoDto
import com.makd.afinity.data.database.entities.DownloadDto
import com.makd.afinity.data.models.download.DownloadStatus
import com.makd.afinity.data.models.user.AfinityUserDataDto
-import kotlinx.coroutines.flow.Flow
import java.util.UUID
+import kotlinx.coroutines.flow.Flow
@Dao
abstract class ServerDatabaseDao {
@@ -251,12 +251,6 @@ abstract class ServerDatabaseDao {
@Query("DELETE FROM genre_show_cache WHERE serverId = :serverId")
abstract suspend fun deleteGenreShowCacheByServerId(serverId: String)
- @Query("DELETE FROM boxset_cache WHERE serverId = :serverId")
- abstract suspend fun deleteBoxSetCacheByServerId(serverId: String)
-
- @Query("DELETE FROM boxset_cache_metadata WHERE serverId = :serverId")
- abstract suspend fun deleteBoxSetCacheMetadataByServerId(serverId: String)
-
@Query("DELETE FROM top_people_cache WHERE serverId = :serverId")
abstract suspend fun deleteTopPeopleCacheByServerId(serverId: String)
@@ -269,12 +263,93 @@ abstract class ServerDatabaseDao {
@Query("DELETE FROM jellyfin_stats_cache WHERE serverId = :serverId")
abstract suspend fun deleteJellyfinStatsCacheByServerId(serverId: String)
+ @Query("DELETE FROM server_storage_cache WHERE serverId = :serverId")
+ abstract suspend fun deleteServerStorageCacheByServerId(serverId: String)
+
@Query("DELETE FROM jellyseerr_requests WHERE jellyfinServerId = :serverId")
abstract suspend fun deleteJellyseerrRequestsByServerId(serverId: String)
@Query("DELETE FROM jellyseerr_config WHERE jellyfinServerId = :serverId")
abstract suspend fun deleteJellyseerrConfigByServerId(serverId: String)
+ @Query("SELECT * FROM downloads WHERE serverId = :serverId AND userId = :userId")
+ abstract suspend fun getDownloadsForUser(serverId: String, userId: UUID): List
+
+ @Query(
+ "SELECT id FROM abs_downloads WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId"
+ )
+ abstract suspend fun getAbsDownloadIdsForUser(serverId: String, userId: String): List
+
+ @Query("DELETE FROM userdata WHERE userId = :userId AND serverId = :serverId")
+ abstract suspend fun deleteUserDataForUser(userId: UUID, serverId: String)
+
+ @Query("DELETE FROM downloads WHERE serverId = :serverId AND userId = :userId")
+ abstract suspend fun deleteDownloadsForUser(serverId: String, userId: UUID)
+
+ @Query(
+ "DELETE FROM abs_downloads WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId"
+ )
+ abstract suspend fun deleteAbsDownloadsForUser(serverId: String, userId: String)
+
+ @Query(
+ "DELETE FROM jellyseerr_config WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId"
+ )
+ abstract suspend fun deleteJellyseerrConfigForUser(serverId: String, userId: String)
+
+ @Query(
+ "DELETE FROM audiobookshelf_config WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId"
+ )
+ abstract suspend fun deleteAudiobookshelfConfigForUser(serverId: String, userId: String)
+
+ @Query(
+ "DELETE FROM audiobookshelf_libraries WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId"
+ )
+ abstract suspend fun deleteAudiobookshelfLibrariesForUser(serverId: String, userId: String)
+
+ @Query(
+ "DELETE FROM audiobookshelf_items WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId"
+ )
+ abstract suspend fun deleteAudiobookshelfItemsForUser(serverId: String, userId: String)
+
+ @Query(
+ "DELETE FROM audiobookshelf_progress WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId"
+ )
+ abstract suspend fun deleteAudiobookshelfProgressForUser(serverId: String, userId: String)
+
+ @Query("DELETE FROM music_tracks WHERE serverId = :serverId AND userId = :userId")
+ abstract suspend fun deleteMusicTracksForUser(serverId: String, userId: String)
+
+ @Query("DELETE FROM music_albums WHERE serverId = :serverId AND userId = :userId")
+ abstract suspend fun deleteMusicAlbumsForUser(serverId: String, userId: String)
+
+ @Query("DELETE FROM music_lyrics WHERE serverId = :serverId AND userId = :userId")
+ abstract suspend fun deleteMusicLyricsForUser(serverId: String, userId: String)
+
+ @Query("DELETE FROM item_metadata_cache WHERE serverId = :serverId AND userId = :userId")
+ abstract suspend fun deleteItemMetadataCacheForUser(serverId: String, userId: String)
+
+ @Query("DELETE FROM users WHERE id = :userId") abstract suspend fun deleteUserRow(userId: UUID)
+
+ @Transaction
+ open suspend fun clearAllDataForUser(serverId: String, userId: UUID) {
+ val userIdText = userId.toString()
+ deleteUserDataForUser(userId, serverId)
+ deleteDownloadsForUser(serverId, userId)
+ deleteAbsDownloadsForUser(serverId, userIdText)
+ deleteJellyseerrConfigForUser(serverId, userIdText)
+ deleteAudiobookshelfConfigForUser(serverId, userIdText)
+ deleteAudiobookshelfLibrariesForUser(serverId, userIdText)
+ deleteAudiobookshelfItemsForUser(serverId, userIdText)
+ deleteAudiobookshelfProgressForUser(serverId, userIdText)
+ deleteMusicTracksForUser(serverId, userIdText)
+ deleteMusicAlbumsForUser(serverId, userIdText)
+ deleteMusicLyricsForUser(serverId, userIdText)
+ deleteItemMetadataCacheForUser(serverId, userIdText)
+ deleteOrphanedSources()
+ deleteOrphanedMediaStreams()
+ deleteUserRow(userId)
+ }
+
@Transaction
open suspend fun clearAllDataForServer(serverId: String) {
deleteMoviesByServerId(serverId)
@@ -289,12 +364,11 @@ abstract class ServerDatabaseDao {
deleteGenreMovieCacheByServerId(serverId)
deleteShowGenreCacheByServerId(serverId)
deleteGenreShowCacheByServerId(serverId)
- deleteBoxSetCacheByServerId(serverId)
- deleteBoxSetCacheMetadataByServerId(serverId)
deleteTopPeopleCacheByServerId(serverId)
deletePersonSectionCacheByServerId(serverId)
deleteItemMetadataCacheByServerId(serverId)
deleteJellyfinStatsCacheByServerId(serverId)
+ deleteServerStorageCacheByServerId(serverId)
deleteJellyseerrRequestsByServerId(serverId)
deleteJellyseerrConfigByServerId(serverId)
}
diff --git a/app/src/main/java/com/makd/afinity/data/database/dao/ServerStorageDao.kt b/app/src/main/java/com/makd/afinity/data/database/dao/ServerStorageDao.kt
new file mode 100644
index 000000000..7ad91ea52
--- /dev/null
+++ b/app/src/main/java/com/makd/afinity/data/database/dao/ServerStorageDao.kt
@@ -0,0 +1,18 @@
+package com.makd.afinity.data.database.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import com.makd.afinity.data.database.entities.ServerStorageCacheEntity
+
+@Dao
+interface ServerStorageDao {
+ @Query("SELECT * FROM server_storage_cache WHERE serverId = :serverId")
+ suspend fun getStorage(serverId: String): ServerStorageCacheEntity?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertStorage(storage: ServerStorageCacheEntity)
+
+ @Query("DELETE FROM server_storage_cache") suspend fun clearAll()
+}
diff --git a/app/src/main/java/com/makd/afinity/data/database/dao/UserDataDao.kt b/app/src/main/java/com/makd/afinity/data/database/dao/UserDataDao.kt
index 404ebeb38..2bf862e05 100644
--- a/app/src/main/java/com/makd/afinity/data/database/dao/UserDataDao.kt
+++ b/app/src/main/java/com/makd/afinity/data/database/dao/UserDataDao.kt
@@ -8,8 +8,8 @@ import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import com.makd.afinity.data.models.user.AfinityUserDataDto
-import kotlinx.coroutines.flow.Flow
import java.util.UUID
+import kotlinx.coroutines.flow.Flow
@Dao
interface UserDataDao {
diff --git a/app/src/main/java/com/makd/afinity/data/database/dao/WikidataAwardsDao.kt b/app/src/main/java/com/makd/afinity/data/database/dao/WikidataAwardsDao.kt
index 3f3883686..fa1a7fab1 100644
--- a/app/src/main/java/com/makd/afinity/data/database/dao/WikidataAwardsDao.kt
+++ b/app/src/main/java/com/makd/afinity/data/database/dao/WikidataAwardsDao.kt
@@ -18,4 +18,4 @@ interface WikidataAwardsDao {
suspend fun insertOrUpdateAwards(awards: WikidataAwardsCacheEntity)
@Query("DELETE FROM wikidata_awards_cache") suspend fun clearAll()
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/makd/afinity/data/database/entities/AfinityMediaStreamDto.kt b/app/src/main/java/com/makd/afinity/data/database/entities/AfinityMediaStreamDto.kt
index b480fc7ea..37419bb7f 100644
--- a/app/src/main/java/com/makd/afinity/data/database/entities/AfinityMediaStreamDto.kt
+++ b/app/src/main/java/com/makd/afinity/data/database/entities/AfinityMediaStreamDto.kt
@@ -3,8 +3,8 @@ package com.makd.afinity.data.database.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.makd.afinity.data.models.media.AfinityMediaStream
-import org.jellyfin.sdk.model.api.MediaStreamType
import java.util.UUID
+import org.jellyfin.sdk.model.api.MediaStreamType
@Entity(tableName = "mediastreams")
data class AfinityMediaStreamDto(
@@ -22,12 +22,19 @@ data class AfinityMediaStreamDto(
val height: Int?,
val width: Int?,
val videoDoViTitle: String?,
+ val hdr10PlusPresentFlag: Boolean = false,
val index: Int,
val channels: Int?,
val isDefault: Boolean,
val isForced: Boolean = false,
val isHearingImpaired: Boolean = false,
+ val isOriginal: Boolean = false,
val profile: String? = null,
+ val localizedLanguage: String? = null,
+ val localizedForced: String? = null,
+ val localizedExternal: String? = null,
+ val localizedHearingImpaired: String? = null,
+ val localizedOriginal: String? = null,
val downloadId: Long? = null,
)
@@ -47,15 +54,22 @@ fun AfinityMediaStream.toAfinityMediaStreamDto(
isExternal = isExternal,
path = path,
channelLayout = channelLayout,
- videoRangeType = videoRangeType?.name,
+ videoRangeType = videoRangeType?.serialName,
height = height,
width = width,
videoDoViTitle = videoDoViTitle,
+ hdr10PlusPresentFlag = hdr10PlusPresentFlag,
index = index,
channels = channels,
isDefault = isDefault,
isForced = isForced,
isHearingImpaired = isHearingImpaired,
+ isOriginal = isOriginal,
profile = profile,
+ localizedLanguage = localizedLanguage,
+ localizedForced = localizedForced,
+ localizedExternal = localizedExternal,
+ localizedHearingImpaired = localizedHearingImpaired,
+ localizedOriginal = localizedOriginal,
)
}
diff --git a/app/src/main/java/com/makd/afinity/data/database/entities/AudiobookshelfBookmarkEntity.kt b/app/src/main/java/com/makd/afinity/data/database/entities/AudiobookshelfBookmarkEntity.kt
new file mode 100644
index 000000000..bc8affce8
--- /dev/null
+++ b/app/src/main/java/com/makd/afinity/data/database/entities/AudiobookshelfBookmarkEntity.kt
@@ -0,0 +1,20 @@
+package com.makd.afinity.data.database.entities
+
+import androidx.room.Entity
+
+@Entity(
+ tableName = "audiobookshelf_bookmarks",
+ primaryKeys = ["jellyfinServerId", "jellyfinUserId", "libraryItemId", "time"],
+)
+data class AudiobookshelfBookmarkEntity(
+ val jellyfinServerId: String,
+ val jellyfinUserId: String,
+ val libraryItemId: String,
+ val time: Long,
+ val serverTime: Double,
+ val title: String,
+ val createdAt: Long,
+ val pendingSync: Boolean = false,
+ val deleted: Boolean = false,
+ val updatedAt: Long = 0L,
+)
diff --git a/app/src/main/java/com/makd/afinity/data/database/entities/BoxSetCacheEntity.kt b/app/src/main/java/com/makd/afinity/data/database/entities/BoxSetCacheEntity.kt
deleted file mode 100644
index b57478036..000000000
--- a/app/src/main/java/com/makd/afinity/data/database/entities/BoxSetCacheEntity.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package com.makd.afinity.data.database.entities
-
-import androidx.room.Entity
-
-@Entity(tableName = "boxset_cache", primaryKeys = ["itemId", "serverId", "userId"])
-data class BoxSetCacheEntity(
- val itemId: String,
- val serverId: String,
- val userId: String,
- val boxSetIds: String,
- val lastUpdated: Long,
-)
diff --git a/app/src/main/java/com/makd/afinity/data/database/entities/BoxSetCacheMetadata.kt b/app/src/main/java/com/makd/afinity/data/database/entities/BoxSetCacheMetadata.kt
deleted file mode 100644
index ade0eb551..000000000
--- a/app/src/main/java/com/makd/afinity/data/database/entities/BoxSetCacheMetadata.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.makd.afinity.data.database.entities
-
-import androidx.room.Entity
-
-@Entity(tableName = "boxset_cache_metadata", primaryKeys = ["serverId", "userId"])
-data class BoxSetCacheMetadata(
- val serverId: String,
- val userId: String,
- val lastFullBuild: Long,
- val cacheVersion: Int,
-)
diff --git a/app/src/main/java/com/makd/afinity/data/database/entities/JellyfinStatsCacheEntity.kt b/app/src/main/java/com/makd/afinity/data/database/entities/JellyfinStatsCacheEntity.kt
index 6f8147345..74f223c43 100644
--- a/app/src/main/java/com/makd/afinity/data/database/entities/JellyfinStatsCacheEntity.kt
+++ b/app/src/main/java/com/makd/afinity/data/database/entities/JellyfinStatsCacheEntity.kt
@@ -2,6 +2,7 @@ package com.makd.afinity.data.database.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
+import com.makd.afinity.ui.settings.servers.JellyfinStats
@Entity(tableName = "jellyfin_stats_cache")
data class JellyfinStatsCacheEntity(
@@ -10,5 +11,29 @@ data class JellyfinStatsCacheEntity(
val seriesCount: Int,
val episodeCount: Int,
val boxsetCount: Int,
+ val albumCount: Int = 0,
+ val songCount: Int = 0,
+ val artistCount: Int = 0,
+ val musicVideoCount: Int = 0,
+ val bookCount: Int = 0,
+ val trailerCount: Int = 0,
+ val programCount: Int = 0,
+ val itemCount: Int = 0,
val lastUpdated: Long = System.currentTimeMillis(),
)
+
+fun JellyfinStatsCacheEntity.toJellyfinStats() =
+ JellyfinStats(
+ movieCount = movieCount,
+ seriesCount = seriesCount,
+ episodeCount = episodeCount,
+ boxsetCount = boxsetCount,
+ albumCount = albumCount,
+ songCount = songCount,
+ artistCount = artistCount,
+ musicVideoCount = musicVideoCount,
+ bookCount = bookCount,
+ trailerCount = trailerCount,
+ programCount = programCount,
+ itemCount = itemCount,
+ )
diff --git a/app/src/main/java/com/makd/afinity/data/database/entities/MusicQueueEntity.kt b/app/src/main/java/com/makd/afinity/data/database/entities/MusicQueueEntity.kt
index e64277f91..77a5c7107 100644
--- a/app/src/main/java/com/makd/afinity/data/database/entities/MusicQueueEntity.kt
+++ b/app/src/main/java/com/makd/afinity/data/database/entities/MusicQueueEntity.kt
@@ -14,6 +14,7 @@ data class MusicQueueEntity(
val durationMs: Long,
val imageUrl: String?,
val normalizationGain: Float?,
+ val albumNormalizationGain: Float? = null,
val indexNumber: Int?,
val discNumber: Int?,
val serverId: String,
diff --git a/app/src/main/java/com/makd/afinity/data/database/entities/MusicTrackEntity.kt b/app/src/main/java/com/makd/afinity/data/database/entities/MusicTrackEntity.kt
index 0cfee8716..df7edf7e2 100644
--- a/app/src/main/java/com/makd/afinity/data/database/entities/MusicTrackEntity.kt
+++ b/app/src/main/java/com/makd/afinity/data/database/entities/MusicTrackEntity.kt
@@ -30,6 +30,8 @@ data class MusicTrackEntity(
val productionYear: Int?,
val runtimeTicks: Long,
val images: AfinityImages?,
+ val normalizationGain: Float? = null,
+ val albumNormalizationGain: Float? = null,
val localFilePath: String? = null,
val localImagePath: String? = null,
)
@@ -49,6 +51,8 @@ fun AfinityTrack.toMusicTrackEntity(serverId: String, userId: String) =
productionYear = productionYear,
runtimeTicks = runtimeTicks,
images = images,
+ normalizationGain = normalizationGain,
+ albumNormalizationGain = albumNormalizationGain,
)
fun MusicTrackEntity.toAfinityTrack(): AfinityTrack {
@@ -74,7 +78,8 @@ fun MusicTrackEntity.toAfinityTrack(): AfinityTrack {
played = false,
favorite = false,
playCount = null,
- normalizationGain = null,
+ normalizationGain = normalizationGain,
+ albumNormalizationGain = albumNormalizationGain,
images = resolvedImages,
localFilePath = localFilePath,
localImagePath = localImagePath,
diff --git a/app/src/main/java/com/makd/afinity/data/database/entities/ServerStorageCacheEntity.kt b/app/src/main/java/com/makd/afinity/data/database/entities/ServerStorageCacheEntity.kt
new file mode 100644
index 000000000..f423105f3
--- /dev/null
+++ b/app/src/main/java/com/makd/afinity/data/database/entities/ServerStorageCacheEntity.kt
@@ -0,0 +1,11 @@
+package com.makd.afinity.data.database.entities
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "server_storage_cache")
+data class ServerStorageCacheEntity(
+ @PrimaryKey val serverId: String,
+ val payload: String,
+ val lastUpdated: Long = System.currentTimeMillis(),
+)
diff --git a/app/src/main/java/com/makd/afinity/data/database/entities/WikidataAwardsCacheEntity.kt b/app/src/main/java/com/makd/afinity/data/database/entities/WikidataAwardsCacheEntity.kt
index b4b9a2e33..76730e8d2 100644
--- a/app/src/main/java/com/makd/afinity/data/database/entities/WikidataAwardsCacheEntity.kt
+++ b/app/src/main/java/com/makd/afinity/data/database/entities/WikidataAwardsCacheEntity.kt
@@ -10,4 +10,4 @@ data class WikidataAwardsCacheEntity(
val awards: List = emptyList(),
val confirmed: Boolean = false,
val fetchedAt: Long = System.currentTimeMillis(),
-)
\ No newline at end of file
+)
diff --git a/app/src/main/java/com/makd/afinity/data/discovery/LocalServiceDiscovery.kt b/app/src/main/java/com/makd/afinity/data/discovery/LocalServiceDiscovery.kt
new file mode 100644
index 000000000..0b11c6760
--- /dev/null
+++ b/app/src/main/java/com/makd/afinity/data/discovery/LocalServiceDiscovery.kt
@@ -0,0 +1,237 @@
+package com.makd.afinity.data.discovery
+
+import android.content.Context
+import android.net.nsd.NsdManager
+import android.net.nsd.NsdServiceInfo
+import android.net.wifi.WifiManager
+import com.makd.afinity.util.LocalNetworkPermission
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.net.Inet4Address
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.Executor
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+object AfinityServiceTypes {
+ const val JELLYFIN = "_jellyfin._tcp"
+ const val JELLYSEERR = "_jellyseerr._tcp"
+ const val AUDIOBOOKSHELF = "_audiobookshelf._tcp"
+}
+
+data class DiscoveredService(
+ val name: String,
+ val host: String,
+ val port: Int,
+ val scheme: String,
+ val path: String,
+) {
+ val url: String = "$scheme://$host:$port$path".trimEnd('/')
+}
+
+sealed interface DiscoveryResult {
+ data class Services(val services: List) : DiscoveryResult
+
+ data object PermissionRequired : DiscoveryResult
+
+ data object Unavailable : DiscoveryResult
+}
+
+@Singleton
+class LocalServiceDiscovery
+@Inject
+constructor(
+ @param:ApplicationContext private val context: Context,
+ private val localNetworkPermission: LocalNetworkPermission,
+) {
+
+ private val nsdManager: NsdManager?
+ get() = context.getSystemService(Context.NSD_SERVICE) as? NsdManager
+
+ private val wifiManager: WifiManager?
+ get() = context.getSystemService(Context.WIFI_SERVICE) as? WifiManager
+
+ private val callbackExecutor = Executor { it.run() }
+
+ fun discover(
+ serviceType: String,
+ timeoutMs: Long = DEFAULT_TIMEOUT_MS,
+ ): Flow> =
+ discoverResult(serviceType, timeoutMs).map { result ->
+ (result as? DiscoveryResult.Services)?.services.orEmpty()
+ }
+
+ fun discoverResult(
+ serviceType: String,
+ timeoutMs: Long = DEFAULT_TIMEOUT_MS,
+ ): Flow {
+ val manager =
+ nsdManager
+ ?: run {
+ Timber.w("mDNS: NsdManager unavailable, skipping $serviceType")
+ return flowOf(DiscoveryResult.Unavailable)
+ }
+
+ return callbackFlow {
+ val found = ConcurrentHashMap()
+ val infoCallbacks = ConcurrentHashMap()
+
+ val multicastLock = runCatching {
+ wifiManager?.createMulticastLock(MULTICAST_LOCK_TAG)?.apply {
+ setReferenceCounted(true)
+ acquire()
+ }
+ }
+ .getOrNull()
+
+ fun publish() {
+ trySend(DiscoveryResult.Services(found.values.sortedBy { it.name.lowercase() }))
+ }
+
+ fun closeDiagnosed() {
+ if (found.isEmpty() && !localNetworkPermission.isSatisfied()) {
+ Timber.w(
+ "mDNS: no services for $serviceType and local network permission not granted"
+ )
+ trySend(DiscoveryResult.PermissionRequired)
+ }
+ close()
+ }
+
+ val discoveryListener =
+ object : NsdManager.DiscoveryListener {
+ override fun onDiscoveryStarted(type: String) {
+ Timber.d("mDNS: discovery started for $type")
+ }
+
+ override fun onDiscoveryStopped(type: String) {
+ Timber.d("mDNS: discovery stopped for $type")
+ }
+
+ override fun onStartDiscoveryFailed(type: String, errorCode: Int) {
+ Timber.w("mDNS: start discovery failed for $type (error $errorCode)")
+ closeDiagnosed()
+ }
+
+ override fun onStopDiscoveryFailed(type: String, errorCode: Int) {
+ Timber.w("mDNS: stop discovery failed for $type (error $errorCode)")
+ }
+
+ override fun onServiceFound(serviceInfo: NsdServiceInfo) {
+ val key = serviceInfo.serviceName ?: return
+ if (infoCallbacks.containsKey(key)) return
+
+ val callback =
+ object : NsdManager.ServiceInfoCallback {
+ override fun onServiceInfoCallbackRegistrationFailed(
+ errorCode: Int
+ ) {
+ Timber.w("mDNS: resolve failed for $key (error $errorCode)")
+ }
+
+ override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
+ val service = serviceInfo.toDiscoveredService(key) ?: return
+ found[key] = service
+ publish()
+ }
+
+ override fun onServiceLost() {
+ found.remove(key)
+ publish()
+ }
+
+ override fun onServiceInfoCallbackUnregistered() = Unit
+ }
+
+ infoCallbacks[key] = callback
+ runCatching {
+ manager.registerServiceInfoCallback(
+ serviceInfo,
+ callbackExecutor,
+ callback,
+ )
+ }
+ .onFailure {
+ infoCallbacks.remove(key)
+ Timber.w(it, "mDNS: could not resolve $key")
+ }
+ }
+
+ override fun onServiceLost(serviceInfo: NsdServiceInfo) {
+ val key = serviceInfo.serviceName ?: return
+ found.remove(key)
+ publish()
+ }
+ }
+
+ runCatching {
+ manager.discoverServices(
+ serviceType,
+ NsdManager.PROTOCOL_DNS_SD,
+ discoveryListener,
+ )
+ }
+ .onFailure {
+ Timber.w(it, "mDNS: could not start discovery for $serviceType")
+ closeDiagnosed()
+ }
+
+ launch {
+ delay(timeoutMs)
+ closeDiagnosed()
+ }
+
+ awaitClose {
+ infoCallbacks.values.forEach { callback ->
+ runCatching { manager.unregisterServiceInfoCallback(callback) }
+ }
+ infoCallbacks.clear()
+ runCatching { manager.stopServiceDiscovery(discoveryListener) }
+ runCatching { multicastLock?.takeIf { it.isHeld }?.release() }
+ }
+ }
+ }
+
+ private fun NsdServiceInfo.toDiscoveredService(fallbackName: String): DiscoveredService? {
+ if (port <= 0) return null
+
+ val address =
+ (hostAddresses.firstOrNull { it is Inet4Address } ?: hostAddresses.firstOrNull())
+ ?.hostAddress
+ ?.substringBefore('%')
+ ?.takeIf { it.isNotBlank() } ?: return null
+
+ val attrs = attributes.orEmpty()
+
+ fun attribute(key: String): String = attrs[key]?.toString(Charsets.UTF_8)?.trim().orEmpty()
+
+ val scheme = attribute("scheme").lowercase().takeIf { it == "https" } ?: "http"
+ val rawPath = attribute("path")
+ val path =
+ when {
+ rawPath.isBlank() || rawPath == "/" -> ""
+ rawPath.startsWith("/") -> rawPath
+ else -> "/$rawPath"
+ }
+
+ return DiscoveredService(
+ name = serviceName?.takeIf { it.isNotBlank() } ?: fallbackName,
+ host = address,
+ port = port,
+ scheme = scheme,
+ path = path,
+ )
+ }
+
+ private companion object {
+ const val DEFAULT_TIMEOUT_MS = 4000L
+ const val MULTICAST_LOCK_TAG = "afinity-mdns"
+ }
+}
diff --git a/app/src/main/java/com/makd/afinity/data/manager/AdminChangeBroadcaster.kt b/app/src/main/java/com/makd/afinity/data/manager/AdminChangeBroadcaster.kt
index db88c4200..178cbf38c 100644
--- a/app/src/main/java/com/makd/afinity/data/manager/AdminChangeBroadcaster.kt
+++ b/app/src/main/java/com/makd/afinity/data/manager/AdminChangeBroadcaster.kt
@@ -1,12 +1,12 @@
package com.makd.afinity.data.manager
+import javax.inject.Inject
+import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.map
-import javax.inject.Inject
-import javax.inject.Singleton
enum class AdminChangeKind {
IMAGES,
diff --git a/app/src/main/java/com/makd/afinity/data/manager/DownloadNotificationManager.kt b/app/src/main/java/com/makd/afinity/data/manager/DownloadNotificationManager.kt
index 49780ebe0..3bfc241f0 100644
--- a/app/src/main/java/com/makd/afinity/data/manager/DownloadNotificationManager.kt
+++ b/app/src/main/java/com/makd/afinity/data/manager/DownloadNotificationManager.kt
@@ -13,10 +13,10 @@ import com.makd.afinity.R
import com.makd.afinity.data.workers.DownloadActionReceiver
import com.makd.afinity.util.formatFileSize
import dagger.hilt.android.qualifiers.ApplicationContext
-import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
+import timber.log.Timber
@Singleton
class DownloadNotificationManager
@@ -151,9 +151,10 @@ constructor(@param:ApplicationContext private val context: Context) {
fun postFailed(notificationId: Int, title: String, subText: String?, error: String?) {
ensureChannel()
val failedText =
- error?.takeIf { it.isNotBlank() }?.let {
- context.getString(R.string.notif_download_failed_reason_fmt, it)
- } ?: context.getString(R.string.notif_download_failed)
+ error
+ ?.takeIf { it.isNotBlank() }
+ ?.let { context.getString(R.string.notif_download_failed_reason_fmt, it) }
+ ?: context.getString(R.string.notif_download_failed)
val notification =
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_download)
diff --git a/app/src/main/java/com/makd/afinity/data/manager/DownloadPermissions.kt b/app/src/main/java/com/makd/afinity/data/manager/DownloadPermissions.kt
index a1b51b453..351ab0692 100644
--- a/app/src/main/java/com/makd/afinity/data/manager/DownloadPermissions.kt
+++ b/app/src/main/java/com/makd/afinity/data/manager/DownloadPermissions.kt
@@ -3,6 +3,8 @@ package com.makd.afinity.data.manager
import com.makd.afinity.data.repository.PreferencesRepository
import com.makd.afinity.di.ApplicationScope
import com.makd.afinity.util.NetworkConnectivityMonitor
+import javax.inject.Inject
+import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -10,8 +12,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
-import javax.inject.Inject
-import javax.inject.Singleton
@Singleton
class DownloadPermissions
@@ -37,4 +37,4 @@ constructor(
}
.distinctUntilChanged()
.stateIn(scope, SharingStarted.WhileSubscribed(5000), true)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/makd/afinity/data/manager/DownloadSemaphoreManager.kt b/app/src/main/java/com/makd/afinity/data/manager/DownloadSemaphoreManager.kt
index 81a99e842..40248f444 100644
--- a/app/src/main/java/com/makd/afinity/data/manager/DownloadSemaphoreManager.kt
+++ b/app/src/main/java/com/makd/afinity/data/manager/DownloadSemaphoreManager.kt
@@ -1,8 +1,8 @@
package com.makd.afinity.data.manager
-import kotlinx.coroutines.sync.Semaphore
import javax.inject.Inject
import javax.inject.Singleton
+import kotlinx.coroutines.sync.Semaphore
@Singleton
class DownloadSemaphoreManager @Inject constructor() {
diff --git a/app/src/main/java/com/makd/afinity/data/manager/ForgetUserUseCase.kt b/app/src/main/java/com/makd/afinity/data/manager/ForgetUserUseCase.kt
new file mode 100644
index 000000000..9f652d2a0
--- /dev/null
+++ b/app/src/main/java/com/makd/afinity/data/manager/ForgetUserUseCase.kt
@@ -0,0 +1,64 @@
+package com.makd.afinity.data.manager
+
+import com.makd.afinity.data.database.dao.ServerDatabaseDao
+import com.makd.afinity.data.repository.SecurePreferencesRepository
+import com.makd.afinity.data.repository.audiobookshelf.AbsDownloadRepository
+import com.makd.afinity.data.repository.download.DownloadRepository
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+
+@Singleton
+class ForgetUserUseCase
+@Inject
+constructor(
+ private val serverDatabaseDao: ServerDatabaseDao,
+ private val downloadRepository: DownloadRepository,
+ private val absDownloadRepository: AbsDownloadRepository,
+ private val securePreferencesRepository: SecurePreferencesRepository,
+ private val sessionManager: SessionManager,
+) {
+
+ suspend operator fun invoke(serverId: String, userId: UUID): Result =
+ withContext(Dispatchers.IO + NonCancellable) {
+ val current = sessionManager.currentSession.value
+ if (current?.serverId == serverId && current.userId == userId) {
+ return@withContext Result.failure(
+ IllegalStateException("Cannot forget the account that is currently signed in")
+ )
+ }
+
+ try {
+ serverDatabaseDao.getDownloadsForUser(serverId, userId).forEach { download ->
+ downloadRepository.deleteDownload(download.id).onFailure {
+ Timber.w(it, "Failed to delete download ${download.id}")
+ }
+ }
+
+ serverDatabaseDao.getAbsDownloadIdsForUser(serverId, userId.toString()).forEach { id
+ ->
+ absDownloadRepository.deleteDownload(id).onFailure {
+ Timber.w(it, "Failed to delete Audiobookshelf download $id")
+ }
+ }
+
+ securePreferencesRepository.clearServerUserToken(serverId, userId)
+ securePreferencesRepository.clearJellyseerrAuthForUser(serverId, userId)
+ securePreferencesRepository.clearAudiobookshelfAuthForUser(serverId, userId)
+
+ serverDatabaseDao.clearAllDataForUser(serverId, userId)
+ Timber.i("Forgot account $userId on server $serverId")
+ Result.success(Unit)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to forget account $userId on server $serverId")
+ Result.failure(e)
+ }
+ }
+}
diff --git a/app/src/main/java/com/makd/afinity/data/manager/MediaChangeEventResolution.kt b/app/src/main/java/com/makd/afinity/data/manager/MediaChangeEventResolution.kt
index ae7d67677..fbb925f40 100644
--- a/app/src/main/java/com/makd/afinity/data/manager/MediaChangeEventResolution.kt
+++ b/app/src/main/java/com/makd/afinity/data/manager/MediaChangeEventResolution.kt
@@ -6,8 +6,9 @@ import com.makd.afinity.data.models.media.AfinitySeason
import com.makd.afinity.data.models.media.withUserData
import com.makd.afinity.data.models.media.withUserDataPatch
import com.makd.afinity.data.repository.media.MediaRepository
-import timber.log.Timber
import java.util.UUID
+import kotlinx.coroutines.CancellationException
+import timber.log.Timber
suspend fun MediaChangeEvent.resolveChangedItems(
mediaRepository: MediaRepository,
@@ -35,6 +36,8 @@ suspend fun MediaChangeEvent.resolveChangedItems(
if (!fetchAllowed) return@mapNotNull null
try {
mediaRepository.getItemById(id)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to resolve item $id for media change patch")
null
@@ -61,6 +64,8 @@ suspend fun MediaChangeEvent.resolveTargetItem(
}
return try {
mediaRepository.getItemById(itemId)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to resolve target item for media change patch: $itemId")
null
@@ -80,4 +85,4 @@ private fun MediaChangeEvent.patchedFromHeld(
val partial = patch ?: return null
val held = heldItem?.invoke(id) ?: return null
return held.withUserDataPatch(partial)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/makd/afinity/data/manager/MediaChangeManager.kt b/app/src/main/java/com/makd/afinity/data/manager/MediaChangeManager.kt
index e668dd2d3..520faa2e6 100644
--- a/app/src/main/java/com/makd/afinity/data/manager/MediaChangeManager.kt
+++ b/app/src/main/java/com/makd/afinity/data/manager/MediaChangeManager.kt
@@ -9,6 +9,10 @@ import com.makd.afinity.data.repository.FieldSets
import com.makd.afinity.data.repository.media.MediaRepository
import com.makd.afinity.data.store.ItemStore
import com.makd.afinity.di.ApplicationScope
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -19,9 +23,6 @@ import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.UserItemDataDto
import timber.log.Timber
-import java.util.UUID
-import javax.inject.Inject
-import javax.inject.Singleton
@OptIn(ExperimentalCoroutinesApi::class)
@Singleton
@@ -97,9 +98,7 @@ constructor(
private suspend fun emitBatch(batch: MediaChangeBatch) {
itemStore.put(
- batch.changes.flatMap {
- listOfNotNull(it.updatedItem, it.parentItem, it.seasonItem)
- }
+ batch.changes.flatMap { listOfNotNull(it.updatedItem, it.parentItem, it.seasonItem) }
)
_batches.emit(batch)
}
@@ -114,23 +113,24 @@ constructor(
val items =
try {
mediaRepository.getItemsByIds(ids, FieldSets.ITEM_DETAIL)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to resolve content changes for ${ids.size} items")
return
}
if (items.isEmpty()) return
- val changes =
- items.map { item ->
- MediaChangeEvent(
- itemId = item.id,
- updatedItem = item,
- seriesId =
- (item as? AfinityEpisode)?.seriesId ?: (item as? AfinitySeason)?.seriesId,
- seasonId = (item as? AfinityEpisode)?.seasonId,
- source = MediaChangeSource.WEBSOCKET,
- )
- }
+ val changes = items.map { item ->
+ MediaChangeEvent(
+ itemId = item.id,
+ updatedItem = item,
+ seriesId =
+ (item as? AfinityEpisode)?.seriesId ?: (item as? AfinitySeason)?.seriesId,
+ seasonId = (item as? AfinityEpisode)?.seasonId,
+ source = MediaChangeSource.WEBSOCKET,
+ )
+ }
emitBatch(MediaChangeBatch(changes, MediaChangeSource.WEBSOCKET))
}
@@ -151,6 +151,8 @@ constructor(
userDataByItemId[itemId] = userData
try {
databaseRepository.patchUserDataLocally(itemId, userId, serverId, userData)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to patch local DB for $itemId")
}
@@ -170,6 +172,8 @@ constructor(
mediaRepository.getItemsByIds(itemIds, FieldSets.MEDIA_ITEM_CARDS)
}
items.associateBy { it.id }
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to resolve items for user data changes")
emptyMap()
@@ -245,7 +249,6 @@ constructor(
val seasonItem = resolveSeasonItem(updatedItem, knownSeasonId)
mediaRefreshBus.emit(RefreshTrigger.USER_DATA_CHANGED)
- updatedItem?.let { refreshDerivedState(it) }
val resolvedSeriesId =
when (updatedItem) {
@@ -275,6 +278,8 @@ constructor(
)
updatedItem
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to publish media change for $itemId")
emitSingle(
@@ -327,6 +332,8 @@ constructor(
userData = userData,
)
)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to publish known media change for ${updatedItem.id}")
}
@@ -365,6 +372,8 @@ constructor(
if (seasonId == null || seasonId == updatedItem?.id) return null
return try {
mediaRepository.getItemById(seasonId)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.w(e, "Could not resolve season item for $seasonId")
null
@@ -376,14 +385,13 @@ data class MediaChangeBatch(
val changes: List,
val source: MediaChangeSource,
) {
- val itemIds: Set =
- buildSet {
- changes.forEach { change ->
- add(change.itemId)
- change.seriesId?.let { add(it) }
- change.seasonId?.let { add(it) }
- }
+ val itemIds: Set = buildSet {
+ changes.forEach { change ->
+ add(change.itemId)
+ change.seriesId?.let { add(it) }
+ change.seasonId?.let { add(it) }
}
+ }
fun affects(vararg ids: UUID?): Boolean = ids.any { it != null && it in itemIds }
diff --git a/app/src/main/java/com/makd/afinity/data/manager/MediaRefreshBus.kt b/app/src/main/java/com/makd/afinity/data/manager/MediaRefreshBus.kt
index 2e2eb8377..649e672e2 100644
--- a/app/src/main/java/com/makd/afinity/data/manager/MediaRefreshBus.kt
+++ b/app/src/main/java/com/makd/afinity/data/manager/MediaRefreshBus.kt
@@ -1,10 +1,10 @@
package com.makd.afinity.data.manager
+import javax.inject.Inject
+import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
-import javax.inject.Inject
-import javax.inject.Singleton
enum class RefreshTrigger {
USER_DATA_CHANGED,
diff --git a/app/src/main/java/com/makd/afinity/data/manager/OfflineModeManager.kt b/app/src/main/java/com/makd/afinity/data/manager/OfflineModeManager.kt
index 89b95e79c..23b8da36f 100644
--- a/app/src/main/java/com/makd/afinity/data/manager/OfflineModeManager.kt
+++ b/app/src/main/java/com/makd/afinity/data/manager/OfflineModeManager.kt
@@ -8,6 +8,8 @@ import com.makd.afinity.util.NetworkConnectivityMonitor
import com.makd.afinity.util.NetworkLocality
import com.makd.afinity.util.isLocalAddress
import com.makd.afinity.util.isTailscaleAddress
+import javax.inject.Inject
+import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
@@ -18,8 +20,6 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import timber.log.Timber
-import javax.inject.Inject
-import javax.inject.Singleton
@OptIn(ExperimentalCoroutinesApi::class)
@Singleton
diff --git a/app/src/main/java/com/makd/afinity/data/manager/PendingNavigationManager.kt b/app/src/main/java/com/makd/afinity/data/manager/PendingNavigationManager.kt
index c4e2d575c..21e5700a4 100644
--- a/app/src/main/java/com/makd/afinity/data/manager/PendingNavigationManager.kt
+++ b/app/src/main/java/com/makd/afinity/data/manager/PendingNavigationManager.kt
@@ -1,10 +1,10 @@
package com.makd.afinity.data.manager
+import javax.inject.Inject
+import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import javax.inject.Inject
-import javax.inject.Singleton
@Singleton
class PendingNavigationManager @Inject constructor() {
diff --git a/app/src/main/java/com/makd/afinity/data/manager/PlaybackStateManager.kt b/app/src/main/java/com/makd/afinity/data/manager/PlaybackStateManager.kt
index 63181c3a1..22d5a61d0 100644
--- a/app/src/main/java/com/makd/afinity/data/manager/PlaybackStateManager.kt
+++ b/app/src/main/java/com/makd/afinity/data/manager/PlaybackStateManager.kt
@@ -7,6 +7,10 @@ import com.makd.afinity.data.repository.media.MediaRepository
import com.makd.afinity.data.repository.playback.PlaybackRepository
import com.makd.afinity.data.sync.UserDataSyncScheduler
import com.makd.afinity.di.ApplicationScope
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -14,9 +18,6 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
-import java.util.UUID
-import javax.inject.Inject
-import javax.inject.Singleton
@Singleton
class PlaybackStateManager
@@ -86,6 +87,8 @@ constructor(
isEnded,
)
handlePlaybackStopped(itemId)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Error in notifyPlaybackStopped")
}
@@ -137,6 +140,8 @@ constructor(
)
syncScheduler.triggerSync()
}
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to report playback stop to Jellyfin")
syncScheduler.triggerSync()
@@ -160,6 +165,8 @@ constructor(
)
mediaRefreshBus.emit(RefreshTrigger.USER_DATA_CHANGED)
}
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to handle playback stopped")
}
diff --git a/app/src/main/java/com/makd/afinity/data/manager/RemoteMessageManager.kt b/app/src/main/java/com/makd/afinity/data/manager/RemoteMessageManager.kt
new file mode 100644
index 000000000..761795d88
--- /dev/null
+++ b/app/src/main/java/com/makd/afinity/data/manager/RemoteMessageManager.kt
@@ -0,0 +1,70 @@
+package com.makd.afinity.data.manager
+
+import com.makd.afinity.data.websocket.JellyfinWebSocketManager
+import com.makd.afinity.di.ApplicationScope
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import org.jellyfin.sdk.model.api.GeneralCommandType
+import timber.log.Timber
+
+data class RemoteMessage(val header: String?, val text: String)
+
+private const val DEFAULT_TIMEOUT_MS = 6_000L
+private const val MIN_TIMEOUT_MS = 1_000L
+private const val MAX_TIMEOUT_MS = 30_000L
+
+@Singleton
+class RemoteMessageManager
+@Inject
+constructor(
+ jellyfinWebSocketManager: JellyfinWebSocketManager,
+ @ApplicationScope private val scope: CoroutineScope,
+) {
+
+ private val _message = MutableStateFlow(null)
+ val message: StateFlow = _message.asStateFlow()
+
+ private var hideJob: Job? = null
+
+ init {
+ scope.launch {
+ jellyfinWebSocketManager.remoteGeneralCommands.collect { command ->
+ if (command.name != GeneralCommandType.DISPLAY_MESSAGE) return@collect
+ val text = command.arguments["Text"]
+ if (text.isNullOrBlank()) {
+ Timber.w("DisplayMessage command received with no Text argument")
+ return@collect
+ }
+ val timeoutMs = command.arguments["TimeoutMs"]?.toLongOrNull() ?: DEFAULT_TIMEOUT_MS
+ show(
+ RemoteMessage(
+ header = command.arguments["Header"]?.takeIf { it.isNotBlank() },
+ text = text,
+ ),
+ timeoutMs,
+ )
+ }
+ }
+ }
+
+ private fun show(message: RemoteMessage, timeoutMs: Long) {
+ _message.value = message
+ hideJob?.cancel()
+ hideJob = scope.launch {
+ delay(timeoutMs.coerceIn(MIN_TIMEOUT_MS, MAX_TIMEOUT_MS))
+ _message.value = null
+ }
+ }
+
+ fun dismiss() {
+ hideJob?.cancel()
+ _message.value = null
+ }
+}
diff --git a/app/src/main/java/com/makd/afinity/data/manager/SessionManager.kt b/app/src/main/java/com/makd/afinity/data/manager/SessionManager.kt
index fd70f7f2f..6f2f6d5af 100644
--- a/app/src/main/java/com/makd/afinity/data/manager/SessionManager.kt
+++ b/app/src/main/java/com/makd/afinity/data/manager/SessionManager.kt
@@ -1,6 +1,5 @@
package com.makd.afinity.data.manager
-import android.content.Context
import com.makd.afinity.data.models.server.Server
import com.makd.afinity.data.models.user.User
import com.makd.afinity.data.repository.AudiobookshelfRepository
@@ -14,7 +13,14 @@ import com.makd.afinity.di.ApplicationScope
import com.makd.afinity.di.NetworkModule
import com.makd.afinity.di.ProberClient
import com.makd.afinity.util.forUser
-import dagger.hilt.android.qualifiers.ApplicationContext
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.atomic.AtomicReference
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -29,20 +35,12 @@ import kotlinx.coroutines.withTimeoutOrNull
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
-import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.api.operations.UserApi
import org.jellyfin.sdk.api.sockets.DefaultSocketApi
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.api.UserConfiguration
import org.jellyfin.sdk.model.api.UserDto
import timber.log.Timber
-import java.util.UUID
-import java.util.concurrent.ConcurrentHashMap
-import java.util.concurrent.atomic.AtomicBoolean
-import java.util.concurrent.atomic.AtomicInteger
-import java.util.concurrent.atomic.AtomicReference
-import javax.inject.Inject
-import javax.inject.Singleton
data class Session(
val serverId: String,
@@ -66,11 +64,9 @@ constructor(
private val jellyseerrRepository: JellyseerrRepository,
private val audiobookshelfRepository: AudiobookshelfRepository,
private val serverAddressResolver: ServerAddressResolver,
- private val okHttpFactory: OkHttpFactory,
private val jellyfin: Jellyfin,
private val deviceInfo: DeviceInfo,
@param:ProberClient private val proberJellyfin: Jellyfin,
- @param:ApplicationContext private val context: Context,
@ApplicationScope private val sessionScope: CoroutineScope,
) {
private val _currentSession = MutableStateFlow(null)
@@ -79,6 +75,13 @@ constructor(
private val _isServerReachable = MutableStateFlow(true)
val isServerReachable: StateFlow = _isServerReachable.asStateFlow()
+ private val _needsLocalNetworkPermission = MutableStateFlow(false)
+ val needsLocalNetworkPermission: StateFlow = _needsLocalNetworkPermission.asStateFlow()
+
+ fun clearLocalNetworkPermissionPrompt() {
+ _needsLocalNetworkPermission.value = false
+ }
+
/**
* True while switchUser() is running. currentSession is overwritten in place during a switch
* (never nulled), so isAuthenticated never toggles false->true on its own; UI layers that need
@@ -142,12 +145,21 @@ constructor(
Timber.e("Token rejected by server during address resolution (401)")
return@withContext Result.failure(InvalidStatusException(401, null))
} else {
- Timber.w(
- "Address resolution failed, starting in offline mode. Saved URL: $serverUrl"
- )
+ if (result is AddressResolutionResult.PermissionRequired) {
+ Timber.w(
+ "Local network permission missing, cannot reach ${result.attemptedAddresses}"
+ )
+ _needsLocalNetworkPermission.value = true
+ } else {
+ Timber.w(
+ "Address resolution failed, starting in offline mode. Saved URL: $serverUrl"
+ )
+ }
_isServerReachable.value = false
serverUrl
}
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.w(
e,
@@ -197,11 +209,15 @@ constructor(
try {
jellyseerrRepository.setActiveJellyfinSession(serverId, userId)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to link Jellyseerr session")
}
try {
audiobookshelfRepository.setActiveJellyfinSession(serverId, userId)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to link Audiobookshelf session")
}
@@ -246,12 +262,16 @@ constructor(
Timber.d(
"Admin status refreshed from policy: isAdmin=$isAdmin, canDownload=$canDownload"
)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.w(e, "Failed to refresh user policy; using cached isAdmin")
}
}
Result.success(Unit)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.e(e, "Failed to start session")
Result.failure(e)
@@ -295,6 +315,10 @@ constructor(
val address =
when (val result = serverAddressResolver.resolveAddress(serverId)) {
is AddressResolutionResult.Success -> result.address
+ is AddressResolutionResult.PermissionRequired -> {
+ _needsLocalNetworkPermission.value = true
+ tokenInfo.serverUrl
+ }
is AddressResolutionResult.AllFailed -> tokenInfo.serverUrl
}
val client = getOrCreateApiClient(serverId, tokenInfo.userId, address)
@@ -320,6 +344,10 @@ constructor(
val address =
when (val result = serverAddressResolver.resolveAddress(serverId)) {
is AddressResolutionResult.Success -> result.address
+ is AddressResolutionResult.PermissionRequired -> {
+ _needsLocalNetworkPermission.value = true
+ tokenInfo.serverUrl
+ }
is AddressResolutionResult.AllFailed -> tokenInfo.serverUrl
}
diff --git a/app/src/main/java/com/makd/afinity/data/manager/UserImageStore.kt b/app/src/main/java/com/makd/afinity/data/manager/UserImageStore.kt
index e9be05e44..eeec6bb3c 100644
--- a/app/src/main/java/com/makd/afinity/data/manager/UserImageStore.kt
+++ b/app/src/main/java/com/makd/afinity/data/manager/UserImageStore.kt
@@ -3,16 +3,17 @@ package com.makd.afinity.data.manager
import android.content.Context
import com.makd.afinity.di.ImageClient
import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import okhttp3.OkHttpClient
-import okhttp3.Request
-import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import timber.log.Timber
@Singleton
class UserImageStore
@@ -52,6 +53,8 @@ constructor(
tmp.delete()
}
}
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.w(e, "Failed to cache avatar for $userId")
}
diff --git a/app/src/main/java/com/makd/afinity/data/models/HomeScreenSections.kt b/app/src/main/java/com/makd/afinity/data/models/HomeScreenSections.kt
index bd1b719c5..d15ad7c91 100644
--- a/app/src/main/java/com/makd/afinity/data/models/HomeScreenSections.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/HomeScreenSections.kt
@@ -5,9 +5,9 @@ import com.makd.afinity.data.models.media.AfinityItem
import com.makd.afinity.data.models.media.AfinityMovie
import com.makd.afinity.data.models.media.AfinityPerson
import com.makd.afinity.data.models.media.AfinityPersonImage
+import java.util.UUID
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.api.PersonKind
-import java.util.UUID
@Serializable
data class CachedPersonWithCount(
diff --git a/app/src/main/java/com/makd/afinity/data/models/admin/IdentifyModels.kt b/app/src/main/java/com/makd/afinity/data/models/admin/IdentifyModels.kt
index b264c3a23..8d6ec057a 100644
--- a/app/src/main/java/com/makd/afinity/data/models/admin/IdentifyModels.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/admin/IdentifyModels.kt
@@ -10,8 +10,4 @@ data class IdentifyResult(
val premiereDate: String?,
)
-data class ExternalIdProvider(
- val name: String,
- val key: String,
- val urlFormatString: String?,
-)
+data class ExternalIdProvider(val name: String, val key: String)
diff --git a/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfItem.kt b/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfItem.kt
index 318e297fc..e99a7a370 100644
--- a/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfItem.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfItem.kt
@@ -340,5 +340,3 @@ data class SeriesResult(
@Serializable
data class ItemsInProgressResponse(@SerialName("libraryItems") val libraryItems: List)
-
-@Serializable data class GenresResponse(@SerialName("genres") val genres: List)
diff --git a/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfPlayback.kt b/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfPlayback.kt
index 6fec2e386..17fdd7783 100644
--- a/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfPlayback.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfPlayback.kt
@@ -10,6 +10,8 @@ import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonPrimitive
@@ -29,6 +31,23 @@ private object NullableInt : KSerializer {
}
}
+private object NullableStringFromAny : KSerializer {
+ override val descriptor: SerialDescriptor =
+ PrimitiveSerialDescriptor("NullableStringFromAny", PrimitiveKind.STRING)
+
+ override fun deserialize(decoder: Decoder): String? {
+ val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeString()
+ val element = jsonDecoder.decodeJsonElement()
+ val primitive = element as? JsonPrimitive ?: return null
+ return primitive.contentOrNull
+ }
+
+ @OptIn(ExperimentalSerializationApi::class)
+ override fun serialize(encoder: Encoder, value: String?) {
+ if (value == null) encoder.encodeNull() else encoder.encodeString(value)
+ }
+}
+
@Serializable
data class AudioTrack(
@SerialName("index") val index: Int,
@@ -52,20 +71,32 @@ data class BookChapter(
fun mediaProgressKey(libraryItemId: String, episodeId: String? = null): String =
if (episodeId.isNullOrEmpty()) libraryItemId else "$libraryItemId|$episodeId"
+@Serializable
+enum class AbsMediaItemType {
+ @SerialName("book") BOOK,
+ @SerialName("podcastEpisode") PODCAST_EPISODE,
+ @SerialName("unknown") UNKNOWN,
+}
+
@Serializable
data class MediaProgress(
@SerialName("id") val id: String,
+ @SerialName("userId") val userId: String? = null,
@SerialName("libraryItemId") val libraryItemId: String,
@SerialName("episodeId") val episodeId: String? = null,
- @SerialName("duration") val duration: Double,
- @SerialName("progress") val progress: Double,
- @SerialName("currentTime") val currentTime: Double,
- @SerialName("isFinished") val isFinished: Boolean,
+ @SerialName("mediaItemId") val mediaItemId: String? = null,
+ @SerialName("mediaItemType") val mediaItemType: AbsMediaItemType = AbsMediaItemType.UNKNOWN,
+ @SerialName("duration") val duration: Double = 0.0,
+ @SerialName("progress") val progress: Double = 0.0,
+ @SerialName("currentTime") val currentTime: Double = 0.0,
+ @SerialName("isFinished") val isFinished: Boolean = false,
@SerialName("hideFromContinueListening") val hideFromContinueListening: Boolean? = null,
- @SerialName("ebookLocation") val ebookLocation: String? = null,
+ @Serializable(with = NullableStringFromAny::class)
+ @SerialName("ebookLocation")
+ val ebookLocation: String? = null,
@SerialName("ebookProgress") val ebookProgress: Double? = null,
- @SerialName("lastUpdate") val lastUpdate: Long,
- @SerialName("startedAt") val startedAt: Long,
+ @SerialName("lastUpdate") val lastUpdate: Long = 0L,
+ @SerialName("startedAt") val startedAt: Long = 0L,
@SerialName("finishedAt") val finishedAt: Long? = null,
)
@@ -145,12 +176,6 @@ data class MediaProgressSyncData(
@SerialName("isFinished") val isFinished: Boolean? = null,
)
-@Serializable
-data class SyncResponse(
- @SerialName("id") val id: String? = null,
- @SerialName("success") val success: Boolean? = null,
-)
-
@Serializable
data class ProgressUpdateRequest(
@SerialName("currentTime") val currentTime: Double? = null,
diff --git a/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfUser.kt b/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfUser.kt
index 6724af8dd..d4e7567b4 100644
--- a/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfUser.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfUser.kt
@@ -7,6 +7,7 @@ import kotlinx.serialization.Serializable
data class AudiobookshelfUser(
@SerialName("id") val id: String,
@SerialName("username") val username: String,
+ @SerialName("email") val email: String? = null,
@SerialName("type") val type: String? = null,
@SerialName("token") val token: String? = null,
@SerialName("accessToken") val accessToken: String? = null,
@@ -20,6 +21,9 @@ data class AudiobookshelfUser(
@SerialName("permissions") val permissions: UserPermissions? = null,
@SerialName("librariesAccessible") val librariesAccessible: List? = null,
@SerialName("itemTagsSelected") val itemTagsSelected: List? = null,
+ @SerialName("hasOpenIDLink") val hasOpenIDLink: Boolean? = null,
+ @SerialName("seriesHideFromContinueListening")
+ val seriesHideFromContinueListening: List? = null,
)
@Serializable
@@ -41,6 +45,12 @@ data class Bookmark(
@SerialName("createdAt") val createdAt: Long,
)
+@Serializable
+data class BookmarkRequest(
+ @SerialName("time") val time: Long,
+ @SerialName("title") val title: String,
+)
+
@Serializable
data class LoginRequest(
@SerialName("username") val username: String,
diff --git a/app/src/main/java/com/makd/afinity/data/models/auth/QuickConnectState.kt b/app/src/main/java/com/makd/afinity/data/models/auth/QuickConnectState.kt
index fecd002bf..d56a7ce3f 100644
--- a/app/src/main/java/com/makd/afinity/data/models/auth/QuickConnectState.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/auth/QuickConnectState.kt
@@ -5,3 +5,10 @@ data class QuickConnectState(
val secret: String,
val authenticated: Boolean = false,
)
+
+enum class QuickConnectAuthorization {
+ APPROVED,
+ REFUSED,
+ UNKNOWN_CODE,
+ FAILED,
+}
diff --git a/app/src/main/java/com/makd/afinity/data/models/download/PlaylistDownloadFilter.kt b/app/src/main/java/com/makd/afinity/data/models/download/PlaylistDownloadFilter.kt
index a5933fe99..5e26aa9da 100644
--- a/app/src/main/java/com/makd/afinity/data/models/download/PlaylistDownloadFilter.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/download/PlaylistDownloadFilter.kt
@@ -4,4 +4,4 @@ enum class PlaylistDownloadFilter {
AUDIO,
VIDEO,
ALL,
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/makd/afinity/data/models/extensions/JellyfinModelExtensions.kt b/app/src/main/java/com/makd/afinity/data/models/extensions/JellyfinModelExtensions.kt
index e18936dd9..3756e75f2 100644
--- a/app/src/main/java/com/makd/afinity/data/models/extensions/JellyfinModelExtensions.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/extensions/JellyfinModelExtensions.kt
@@ -12,7 +12,6 @@ import com.makd.afinity.data.models.media.AfinityExternalUrl
import com.makd.afinity.data.models.media.AfinityFolder
import com.makd.afinity.data.models.media.AfinityImages
import com.makd.afinity.data.models.media.AfinityItem
-import com.makd.afinity.data.models.media.AfinityMediaStream
import com.makd.afinity.data.models.media.AfinityMovie
import com.makd.afinity.data.models.media.AfinityPerson
import com.makd.afinity.data.models.media.AfinityPersonDetail
@@ -24,6 +23,7 @@ import com.makd.afinity.data.models.media.AfinitySourceType
import com.makd.afinity.data.models.media.AfinityVideo
import com.makd.afinity.data.models.media.AfinityVideoPlaylist
import com.makd.afinity.data.models.media.toAfinityExternalUrl
+import com.makd.afinity.data.models.media.toAfinityMediaStream
import com.makd.afinity.data.models.media.toAfinityTrickplayInfo
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
@@ -62,33 +62,7 @@ private fun BaseItemDto.toAfinitySources(baseUrl: String): List =
container = mediaSource.container,
mediaStreams =
mediaSource.mediaStreams?.map { mediaStream ->
- AfinityMediaStream(
- title = mediaStream.title.orEmpty(),
- displayTitle = mediaStream.displayTitle,
- language = mediaStream.language.orEmpty(),
- type = mediaStream.type,
- codec = mediaStream.codec.orEmpty(),
- isExternal = mediaStream.isExternal,
- path =
- if (
- mediaStream.isExternal && !mediaStream.deliveryUrl.isNullOrBlank()
- ) {
- baseUrl + mediaStream.deliveryUrl
- } else {
- null
- },
- channelLayout = mediaStream.channelLayout,
- videoRangeType = mediaStream.videoRangeType,
- height = mediaStream.height,
- width = mediaStream.width,
- videoDoViTitle = mediaStream.videoDoViTitle,
- index = mediaStream.index,
- channels = mediaStream.channels,
- isDefault = mediaStream.isDefault,
- isForced = mediaStream.isForced,
- isHearingImpaired = mediaStream.isHearingImpaired,
- profile = mediaStream.profile,
- )
+ mediaStream.toAfinityMediaStream(baseUrl)
} ?: emptyList(),
)
} ?: emptyList()
@@ -123,7 +97,9 @@ fun BaseItemDto.toAfinityMovie(baseUrl: String): AfinityMovie {
trickplayInfo =
trickplay
?.flatMap { (_, widthMap) ->
- widthMap.map { (width, info) -> width to info.toAfinityTrickplayInfo() }
+ widthMap.orEmpty().map { (width, info) ->
+ width to info.toAfinityTrickplayInfo()
+ }
}
?.toMap(),
providerIds = providerIds?.mapNotNull { (key, value) -> value?.let { key to it } }?.toMap(),
@@ -215,34 +191,7 @@ fun BaseItemDto.toAfinityEpisode(baseUrl: String): AfinityEpisode? {
container = mediaSource.container,
mediaStreams =
mediaSource.mediaStreams?.map { mediaStream ->
- AfinityMediaStream(
- title = mediaStream.title.orEmpty(),
- displayTitle = mediaStream.displayTitle,
- language = mediaStream.language.orEmpty(),
- type = mediaStream.type,
- codec = mediaStream.codec.orEmpty(),
- isExternal = mediaStream.isExternal,
- path =
- if (
- mediaStream.isExternal &&
- !mediaStream.deliveryUrl.isNullOrBlank()
- ) {
- baseUrl + mediaStream.deliveryUrl
- } else {
- null
- },
- channelLayout = mediaStream.channelLayout,
- videoRangeType = mediaStream.videoRangeType,
- height = mediaStream.height,
- width = mediaStream.width,
- videoDoViTitle = mediaStream.videoDoViTitle,
- index = mediaStream.index,
- channels = mediaStream.channels,
- isDefault = mediaStream.isDefault,
- isForced = mediaStream.isForced,
- isHearingImpaired = mediaStream.isHearingImpaired,
- profile = mediaStream.profile,
- )
+ mediaStream.toAfinityMediaStream(baseUrl)
} ?: emptyList(),
)
} ?: emptyList(),
@@ -266,7 +215,9 @@ fun BaseItemDto.toAfinityEpisode(baseUrl: String): AfinityEpisode? {
trickplayInfo =
trickplay
?.flatMap { (_, widthMap) ->
- widthMap.map { (width, info) -> width to info.toAfinityTrickplayInfo() }
+ widthMap.orEmpty().map { (width, info) ->
+ width to info.toAfinityTrickplayInfo()
+ }
}
?.toMap(),
providerIds =
diff --git a/app/src/main/java/com/makd/afinity/data/models/extensions/MusicExtensions.kt b/app/src/main/java/com/makd/afinity/data/models/extensions/MusicExtensions.kt
index cd8a453ab..5448c7ddb 100644
--- a/app/src/main/java/com/makd/afinity/data/models/extensions/MusicExtensions.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/extensions/MusicExtensions.kt
@@ -6,10 +6,10 @@ import com.makd.afinity.data.models.music.AfinityAlbum
import com.makd.afinity.data.models.music.AfinityArtist
import com.makd.afinity.data.models.music.AfinityPlaylist
import com.makd.afinity.data.models.music.AfinityTrack
+import java.util.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageFormat
import org.jellyfin.sdk.model.api.ImageType
-import java.util.UUID
fun List.toRecentlyPlayedAlbums(limit: Int): List {
val seen = mutableSetOf()
@@ -75,6 +75,7 @@ fun BaseItemDto.toAfinityTrack(baseUrl: String): AfinityTrack {
liked = userData?.likes == true,
playCount = userData?.playCount,
normalizationGain = normalizationGain,
+ albumNormalizationGain = albumNormalizationGain,
images =
AfinityImages(
primary = primary,
@@ -96,6 +97,7 @@ fun BaseItemDto.toAfinityAlbum(baseUrl: String): AfinityAlbum {
runtimeTicks = runTimeTicks ?: 0L,
genres = genres ?: emptyList(),
overview = overview,
+ externalUrls = toAfinityExternalUrls(),
favorite = userData?.isFavorite == true,
played = userData?.played == true,
liked = userData?.likes == true,
@@ -110,6 +112,7 @@ fun BaseItemDto.toAfinityArtist(baseUrl: String): AfinityArtist {
id = id,
name = name.orEmpty(),
overview = overview,
+ externalUrls = toAfinityExternalUrls(),
albumCount = childCount,
genres = genres ?: emptyList(),
favorite = userData?.isFavorite == true,
diff --git a/app/src/main/java/com/makd/afinity/data/models/jellyseerr/DiscoverCategory.kt b/app/src/main/java/com/makd/afinity/data/models/jellyseerr/DiscoverCategory.kt
deleted file mode 100644
index 14d99feed..000000000
--- a/app/src/main/java/com/makd/afinity/data/models/jellyseerr/DiscoverCategory.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package com.makd.afinity.data.models.jellyseerr
-
-enum class DiscoverCategory {
- TRENDING,
- POPULAR_MOVIES,
- POPULAR_TV,
- UPCOMING_MOVIES,
- UPCOMING_TV;
-
- fun getDisplayName(): String =
- when (this) {
- TRENDING -> "Trending Now"
- POPULAR_MOVIES -> "Popular Movies"
- POPULAR_TV -> "Popular TV Shows"
- UPCOMING_MOVIES -> "Upcoming Movies"
- UPCOMING_TV -> "Upcoming TV Shows"
- }
-}
diff --git a/app/src/main/java/com/makd/afinity/data/models/jellyseerr/Genre.kt b/app/src/main/java/com/makd/afinity/data/models/jellyseerr/Genre.kt
index da2ba0a96..de71689a1 100644
--- a/app/src/main/java/com/makd/afinity/data/models/jellyseerr/Genre.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/jellyseerr/Genre.kt
@@ -34,8 +34,3 @@ data class GenreSliderItem(
}
}
}
-
-@Serializable data class GenreListResponse(@SerialName("genres") val genres: List)
-
-@Serializable
-data class GenreSliderResponse(@SerialName("genres") val genres: List)
diff --git a/app/src/main/java/com/makd/afinity/data/models/jellyseerr/JellyseerrQuickConnect.kt b/app/src/main/java/com/makd/afinity/data/models/jellyseerr/JellyseerrQuickConnect.kt
new file mode 100644
index 000000000..1bcde84de
--- /dev/null
+++ b/app/src/main/java/com/makd/afinity/data/models/jellyseerr/JellyseerrQuickConnect.kt
@@ -0,0 +1,12 @@
+package com.makd.afinity.data.models.jellyseerr
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class QuickConnectInitiateResponse(
+ @SerialName("code") val code: String,
+ @SerialName("secret") val secret: String,
+)
+
+@Serializable data class QuickConnectAuthenticateRequest(@SerialName("secret") val secret: String)
diff --git a/app/src/main/java/com/makd/afinity/data/models/jellyseerr/MediaDetails.kt b/app/src/main/java/com/makd/afinity/data/models/jellyseerr/MediaDetails.kt
index a0315b451..fcdaeb0e3 100644
--- a/app/src/main/java/com/makd/afinity/data/models/jellyseerr/MediaDetails.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/jellyseerr/MediaDetails.kt
@@ -1,5 +1,6 @@
package com.makd.afinity.data.models.jellyseerr
+import java.util.Locale
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -56,7 +57,9 @@ data class MediaDetails(
}
fun getRating(): String? {
- return voteAverage?.let { if (it > 0) String.format("%.1f", it) else null }
+ return voteAverage?.let {
+ if (it > 0) String.format(Locale.getDefault(), "%.1f", it) else null
+ }
}
fun getYear(): String? {
diff --git a/app/src/main/java/com/makd/afinity/data/models/jellyseerr/MediaStatus.kt b/app/src/main/java/com/makd/afinity/data/models/jellyseerr/MediaStatus.kt
index 61d6d05c4..76fedf16b 100644
--- a/app/src/main/java/com/makd/afinity/data/models/jellyseerr/MediaStatus.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/jellyseerr/MediaStatus.kt
@@ -10,7 +10,7 @@ enum class MediaStatus(val value: Int) {
DELETED(7);
companion object {
- fun fromValue(value: Int): MediaStatus = values().find { it.value == value } ?: UNKNOWN
+ fun fromValue(value: Int): MediaStatus = entries.find { it.value == value } ?: UNKNOWN
fun getDisplayName(status: MediaStatus): String =
when (status) {
diff --git a/app/src/main/java/com/makd/afinity/data/models/jellyseerr/RequestStatus.kt b/app/src/main/java/com/makd/afinity/data/models/jellyseerr/RequestStatus.kt
index 74e5f3a0e..929d9728b 100644
--- a/app/src/main/java/com/makd/afinity/data/models/jellyseerr/RequestStatus.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/jellyseerr/RequestStatus.kt
@@ -8,7 +8,7 @@ enum class RequestStatus(val value: Int) {
COMPLETED(5);
companion object {
- fun fromValue(value: Int): RequestStatus = values().find { it.value == value } ?: PENDING
+ fun fromValue(value: Int): RequestStatus = entries.find { it.value == value } ?: PENDING
fun getDisplayName(status: RequestStatus): String =
when (status) {
diff --git a/app/src/main/java/com/makd/afinity/data/models/mdblist/MdbListUser.kt b/app/src/main/java/com/makd/afinity/data/models/mdblist/MdbListUser.kt
index fe696afbc..b5006c3ad 100644
--- a/app/src/main/java/com/makd/afinity/data/models/mdblist/MdbListUser.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/mdblist/MdbListUser.kt
@@ -13,4 +13,4 @@ data class MdbListUser(
data class MdbListUsage(val used: Int, val limit: Int) {
val remaining: Int
get() = (limit - used).coerceAtLeast(0)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/makd/afinity/data/models/media/AfinityChapter.kt b/app/src/main/java/com/makd/afinity/data/models/media/AfinityChapter.kt
index f3b59b7e6..e94ada104 100644
--- a/app/src/main/java/com/makd/afinity/data/models/media/AfinityChapter.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/media/AfinityChapter.kt
@@ -1,8 +1,8 @@
package com.makd.afinity.data.models.media
+import java.util.UUID
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.api.BaseItemDto
-import java.util.UUID
@Serializable
data class AfinityChapter(
diff --git a/app/src/main/java/com/makd/afinity/data/models/media/AfinityEpisode.kt b/app/src/main/java/com/makd/afinity/data/models/media/AfinityEpisode.kt
index 08fc1998a..c876dd518 100644
--- a/app/src/main/java/com/makd/afinity/data/models/media/AfinityEpisode.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/media/AfinityEpisode.kt
@@ -90,7 +90,9 @@ suspend fun BaseItemDto.toAfinityEpisode(
images = toAfinityImages(baseUrl),
chapters = toAfinityChapters(),
trickplayInfo =
- trickplay?.mapValues { it.value[it.value.keys.max()]!!.toAfinityTrickplayInfo() },
+ trickplay?.mapValues { (_, widthMap) ->
+ widthMap!![widthMap.keys.max()]!!.toAfinityTrickplayInfo()
+ },
providerIds =
providerIds?.mapNotNull { (key, value) -> value?.let { key to it } }?.toMap(),
externalUrls = externalUrls?.map { it.toAfinityExternalUrl() },
diff --git a/app/src/main/java/com/makd/afinity/data/models/media/AfinityItem.kt b/app/src/main/java/com/makd/afinity/data/models/media/AfinityItem.kt
index a7269ba69..03fdce48e 100644
--- a/app/src/main/java/com/makd/afinity/data/models/media/AfinityItem.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/media/AfinityItem.kt
@@ -6,9 +6,9 @@ import com.makd.afinity.data.models.extensions.toAfinityChannel
import com.makd.afinity.data.models.extensions.toAfinityFolder
import com.makd.afinity.data.models.extensions.toAfinityVideo
import com.makd.afinity.data.models.extensions.toAfinityVideoPlaylist
+import java.util.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
-import java.util.UUID
interface AfinityItem : AfinityUserDataOwner {
override val id: UUID
diff --git a/app/src/main/java/com/makd/afinity/data/models/media/AfinityItemUserData.kt b/app/src/main/java/com/makd/afinity/data/models/media/AfinityItemUserData.kt
index 733700038..b48e8e92c 100644
--- a/app/src/main/java/com/makd/afinity/data/models/media/AfinityItemUserData.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/media/AfinityItemUserData.kt
@@ -4,8 +4,8 @@ import com.makd.afinity.data.models.music.AfinityAlbum
import com.makd.afinity.data.models.music.AfinityArtist
import com.makd.afinity.data.models.music.AfinityTrack
import com.makd.afinity.data.models.user.AfinityUserDataDto
-import org.jellyfin.sdk.model.api.UserItemDataDto
import java.util.UUID
+import org.jellyfin.sdk.model.api.UserItemDataDto
data class UserDataPatch(
val played: Boolean? = null,
@@ -209,8 +209,7 @@ fun AfinityUserDataOwner.applying(values: UserDataValues): AfinityUserDataOwner
else -> owner
}
-fun AfinityItem.withUserData(data: UserItemDataDto): AfinityItem =
- applying(data.toUserDataValues())
+fun AfinityItem.withUserData(data: UserItemDataDto): AfinityItem = applying(data.toUserDataValues())
fun AfinityItem.withUserData(data: AfinityUserDataDto): AfinityItem =
applying(data.toUserDataValues())
@@ -239,15 +238,14 @@ fun AfinityUserDataOwner.patchedWith(patch: UserDataPatch): AfinityUserDataOwner
fun List.withUserData(itemId: UUID, data: UserItemDataDto): List {
if (none { it.id == itemId }) return this
var changed = false
- val patched =
- map { item ->
- if (item.id != itemId) {
- item
- } else {
- @Suppress("UNCHECKED_CAST") val next = item.withUserData(data) as T
- if (next !== item) changed = true
- next
- }
+ val patched = map { item ->
+ if (item.id != itemId) {
+ item
+ } else {
+ @Suppress("UNCHECKED_CAST") val next = item.withUserData(data) as T
+ if (next !== item) changed = true
+ next
}
+ }
return if (changed) patched else this
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/makd/afinity/data/models/media/AfinityMediaStream.kt b/app/src/main/java/com/makd/afinity/data/models/media/AfinityMediaStream.kt
index 19420b4e4..30403d922 100644
--- a/app/src/main/java/com/makd/afinity/data/models/media/AfinityMediaStream.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/media/AfinityMediaStream.kt
@@ -18,14 +18,49 @@ data class AfinityMediaStream(
val height: Int?,
val width: Int?,
val videoDoViTitle: String?,
+ val hdr10PlusPresentFlag: Boolean = false,
val index: Int,
val channels: Int?,
val isDefault: Boolean,
val isForced: Boolean = false,
val isHearingImpaired: Boolean = false,
+ val isOriginal: Boolean = false,
val profile: String? = null,
+ val localizedLanguage: String? = null,
+ val localizedForced: String? = null,
+ val localizedExternal: String? = null,
+ val localizedHearingImpaired: String? = null,
+ val localizedOriginal: String? = null,
)
+private val DOLBY_VISION_RANGES =
+ setOf(
+ VideoRangeType.DOVI,
+ VideoRangeType.DOVI_WITH_HDR10,
+ VideoRangeType.DOVI_WITH_HLG,
+ VideoRangeType.DOVI_WITH_SDR,
+ VideoRangeType.DOVI_WITH_EL,
+ VideoRangeType.DOVI_WITH_HDR10_PLUS,
+ VideoRangeType.DOVI_WITH_ELHDR10_PLUS,
+ )
+
+fun AfinityMediaStream.isDolbyVision(): Boolean =
+ videoDoViTitle != null || videoRangeType in DOLBY_VISION_RANGES
+
+fun AfinityMediaStream.hdrLabel(): String? =
+ when (videoRangeType) {
+ VideoRangeType.HDR10_PLUS -> "HDR10+"
+ VideoRangeType.HDR10 -> if (hdr10PlusPresentFlag) "HDR10+" else "HDR10"
+ VideoRangeType.HLG -> "HLG"
+ else -> null
+ }
+
+fun parseVideoRangeType(stored: String?): VideoRangeType? {
+ if (stored.isNullOrBlank()) return null
+ return VideoRangeType.fromNameOrNull(stored)
+ ?: VideoRangeType.entries.firstOrNull { it.name == stored }
+}
+
fun MediaStream.toAfinityMediaStream(baseUrl: String): AfinityMediaStream {
return AfinityMediaStream(
title = title.orEmpty(),
@@ -45,12 +80,19 @@ fun MediaStream.toAfinityMediaStream(baseUrl: String): AfinityMediaStream {
height = height,
width = width,
videoDoViTitle = videoDoViTitle,
+ hdr10PlusPresentFlag = hdr10PlusPresentFlag == true,
index = index,
channels = channels,
isDefault = isDefault,
isForced = isForced,
isHearingImpaired = isHearingImpaired,
+ isOriginal = isOriginal,
profile = profile,
+ localizedLanguage = localizedLanguage,
+ localizedForced = localizedForced,
+ localizedExternal = localizedExternal,
+ localizedHearingImpaired = localizedHearingImpaired,
+ localizedOriginal = localizedOriginal,
)
}
@@ -64,15 +106,22 @@ fun AfinityMediaStreamDto.toAfinityMediaStream(): AfinityMediaStream {
isExternal = isExternal,
path = path,
channelLayout = channelLayout,
- videoRangeType = VideoRangeType.fromNameOrNull(videoRangeType ?: ""),
+ videoRangeType = parseVideoRangeType(videoRangeType),
height = height,
width = width,
videoDoViTitle = videoDoViTitle,
+ hdr10PlusPresentFlag = hdr10PlusPresentFlag,
index = index,
channels = channels,
isDefault = isDefault,
isForced = isForced,
isHearingImpaired = isHearingImpaired,
+ isOriginal = isOriginal,
profile = profile,
+ localizedLanguage = localizedLanguage,
+ localizedForced = localizedForced,
+ localizedExternal = localizedExternal,
+ localizedHearingImpaired = localizedHearingImpaired,
+ localizedOriginal = localizedOriginal,
)
}
diff --git a/app/src/main/java/com/makd/afinity/data/models/media/AfinityMovie.kt b/app/src/main/java/com/makd/afinity/data/models/media/AfinityMovie.kt
index 9f096ff96..d9458fde5 100644
--- a/app/src/main/java/com/makd/afinity/data/models/media/AfinityMovie.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/media/AfinityMovie.kt
@@ -5,10 +5,10 @@ import com.makd.afinity.data.models.extensions.toAfinityImages
import com.makd.afinity.data.models.extensions.toAfinityPerson
import com.makd.afinity.data.models.mdblist.MdbListRating
import com.makd.afinity.data.models.tmdb.TmdbReview
-import org.jellyfin.sdk.model.api.BaseItemDto
-import org.jellyfin.sdk.model.api.PlayAccess
import java.time.LocalDateTime
import java.util.UUID
+import org.jellyfin.sdk.model.api.BaseItemDto
+import org.jellyfin.sdk.model.api.PlayAccess
data class AfinityMovie(
override val id: UUID,
@@ -84,7 +84,9 @@ suspend fun BaseItemDto.toAfinityMovie(
images = toAfinityImages(baseUrl),
chapters = toAfinityChapters(),
trickplayInfo =
- trickplay?.mapValues { it.value[it.value.keys.max()]!!.toAfinityTrickplayInfo() },
+ trickplay?.mapValues { (_, widthMap) ->
+ widthMap!![widthMap.keys.max()]!!.toAfinityTrickplayInfo()
+ },
providerIds = providerIds?.mapNotNull { (key, value) -> value?.let { key to it } }?.toMap(),
externalUrls = externalUrls?.map { it.toAfinityExternalUrl() },
tmdbReviews = emptyList(),
diff --git a/app/src/main/java/com/makd/afinity/data/models/media/AfinityPerson.kt b/app/src/main/java/com/makd/afinity/data/models/media/AfinityPerson.kt
index bc7d225ae..d8996bb1b 100644
--- a/app/src/main/java/com/makd/afinity/data/models/media/AfinityPerson.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/media/AfinityPerson.kt
@@ -1,8 +1,8 @@
package com.makd.afinity.data.models.media
import android.net.Uri
-import org.jellyfin.sdk.model.api.PersonKind
import java.util.UUID
+import org.jellyfin.sdk.model.api.PersonKind
data class AfinityPersonImage(val uri: Uri?, val blurHash: String?)
diff --git a/app/src/main/java/com/makd/afinity/data/models/media/AfinityRecommendation.kt b/app/src/main/java/com/makd/afinity/data/models/media/AfinityRecommendation.kt
deleted file mode 100644
index c5aba25ea..000000000
--- a/app/src/main/java/com/makd/afinity/data/models/media/AfinityRecommendation.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package com.makd.afinity.data.models.media
-
-data class AfinityRecommendationCategory(
- val title: String,
- val items: List,
- val recommendationType: RecommendationType,
- val baselineItemName: String?,
-)
-
-enum class RecommendationType {
- SIMILAR_TO_RECENTLY_PLAYED,
- SIMILAR_TO_LIKED_ITEM,
- HAS_DIRECTOR_FROM_RECENTLY_PLAYED,
- HAS_ACTOR_FROM_RECENTLY_PLAYED,
- HAS_LIKED_DIRECTOR,
- HAS_LIKED_ACTOR,
- RECOMMENDED_FOR_YOU,
-}
-
-fun org.jellyfin.sdk.model.api.RecommendationType.toAfinityRecommendationType():
- RecommendationType {
- return when (this) {
- org.jellyfin.sdk.model.api.RecommendationType.SIMILAR_TO_RECENTLY_PLAYED ->
- RecommendationType.SIMILAR_TO_RECENTLY_PLAYED
- org.jellyfin.sdk.model.api.RecommendationType.SIMILAR_TO_LIKED_ITEM ->
- RecommendationType.SIMILAR_TO_LIKED_ITEM
- org.jellyfin.sdk.model.api.RecommendationType.HAS_DIRECTOR_FROM_RECENTLY_PLAYED ->
- RecommendationType.HAS_DIRECTOR_FROM_RECENTLY_PLAYED
- org.jellyfin.sdk.model.api.RecommendationType.HAS_ACTOR_FROM_RECENTLY_PLAYED ->
- RecommendationType.HAS_ACTOR_FROM_RECENTLY_PLAYED
- org.jellyfin.sdk.model.api.RecommendationType.HAS_LIKED_DIRECTOR ->
- RecommendationType.HAS_LIKED_DIRECTOR
- org.jellyfin.sdk.model.api.RecommendationType.HAS_LIKED_ACTOR ->
- RecommendationType.HAS_LIKED_ACTOR
- }
-}
-
-fun RecommendationType.buildTitle(baselineItemName: String?): String {
- return when (this) {
- RecommendationType.SIMILAR_TO_RECENTLY_PLAYED ->
- baselineItemName?.let { "Because you watched $it" } ?: "Similar to Recently Watched"
- RecommendationType.SIMILAR_TO_LIKED_ITEM ->
- baselineItemName?.let { "Because you liked $it" } ?: "Similar to Liked Items"
- RecommendationType.HAS_ACTOR_FROM_RECENTLY_PLAYED,
- RecommendationType.HAS_LIKED_ACTOR ->
- baselineItemName?.let { "Starring $it" } ?: "Featuring Actors You Like"
- RecommendationType.HAS_DIRECTOR_FROM_RECENTLY_PLAYED,
- RecommendationType.HAS_LIKED_DIRECTOR ->
- baselineItemName?.let { "Directed by $it" } ?: "From Directors You Like"
- RecommendationType.RECOMMENDED_FOR_YOU -> "Recommended for You"
- }
-}
diff --git a/app/src/main/java/com/makd/afinity/data/models/media/AfinityShow.kt b/app/src/main/java/com/makd/afinity/data/models/media/AfinityShow.kt
index e688ac9c9..f25cd6b24 100644
--- a/app/src/main/java/com/makd/afinity/data/models/media/AfinityShow.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/media/AfinityShow.kt
@@ -4,11 +4,11 @@ import com.makd.afinity.data.models.extensions.toAfinityImages
import com.makd.afinity.data.models.extensions.toAfinityPerson
import com.makd.afinity.data.models.mdblist.MdbListRating
import com.makd.afinity.data.models.tmdb.TmdbReview
+import java.time.LocalDateTime
+import java.util.UUID
import org.jellyfin.sdk.model.DateTime
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.PlayAccess
-import java.time.LocalDateTime
-import java.util.UUID
data class AfinityShow(
override val id: UUID,
diff --git a/app/src/main/java/com/makd/afinity/data/models/media/AfinitySource.kt b/app/src/main/java/com/makd/afinity/data/models/media/AfinitySource.kt
index fab8372e1..bd54ab946 100644
--- a/app/src/main/java/com/makd/afinity/data/models/media/AfinitySource.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/media/AfinitySource.kt
@@ -2,11 +2,11 @@ package com.makd.afinity.data.models.media
import com.makd.afinity.data.database.dao.ServerDatabaseDao
import com.makd.afinity.data.database.entities.AfinitySourceDto
+import java.io.File
+import java.util.UUID
import org.jellyfin.sdk.model.api.MediaProtocol
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStreamType
-import java.io.File
-import java.util.UUID
data class AfinitySource(
val id: String,
@@ -24,7 +24,7 @@ data class AfinitySource(
val height: Int? = null,
)
-suspend fun MediaSourceInfo.toAfinitySource(
+fun MediaSourceInfo.toAfinitySource(
baseUrl: String,
itemId: UUID,
): AfinitySource {
diff --git a/app/src/main/java/com/makd/afinity/data/models/media/AfinityTrickplayInfo.kt b/app/src/main/java/com/makd/afinity/data/models/media/AfinityTrickplayInfo.kt
index 58e24da16..b4242af60 100644
--- a/app/src/main/java/com/makd/afinity/data/models/media/AfinityTrickplayInfo.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/media/AfinityTrickplayInfo.kt
@@ -1,7 +1,7 @@
package com.makd.afinity.data.models.media
import com.makd.afinity.data.database.entities.AfinityTrickplayInfoDto
-import org.jellyfin.sdk.model.api.TrickplayInfo
+import org.jellyfin.sdk.model.api.TrickplayInfoDto
data class AfinityTrickplayInfo(
val width: Int,
@@ -13,7 +13,7 @@ data class AfinityTrickplayInfo(
val bandwidth: Int,
)
-fun TrickplayInfo.toAfinityTrickplayInfo(): AfinityTrickplayInfo {
+fun TrickplayInfoDto.toAfinityTrickplayInfo(): AfinityTrickplayInfo {
return AfinityTrickplayInfo(
width = width,
height = height,
diff --git a/app/src/main/java/com/makd/afinity/data/models/media/AfinityUserDataOwner.kt b/app/src/main/java/com/makd/afinity/data/models/media/AfinityUserDataOwner.kt
index 529062b6e..7a92bd91c 100644
--- a/app/src/main/java/com/makd/afinity/data/models/media/AfinityUserDataOwner.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/media/AfinityUserDataOwner.kt
@@ -8,4 +8,4 @@ interface AfinityUserDataOwner {
val favorite: Boolean
val liked: Boolean
val playbackPositionTicks: Long
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/makd/afinity/data/models/media/ItemFilterCriteria.kt b/app/src/main/java/com/makd/afinity/data/models/media/ItemFilterCriteria.kt
index 209fd5733..82b00f61f 100644
--- a/app/src/main/java/com/makd/afinity/data/models/media/ItemFilterCriteria.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/media/ItemFilterCriteria.kt
@@ -21,6 +21,8 @@ data class ItemFilterCriteria(
val isHd: Boolean? = null,
val is4k: Boolean? = null,
val is3d: Boolean? = null,
+ val audioLanguages: List = emptyList(),
+ val subtitleLanguages: List = emptyList(),
)
fun LibraryFilters.toItemFilterCriteria(studios: List = emptyList()): ItemFilterCriteria =
@@ -39,6 +41,8 @@ fun LibraryFilters.toItemFilterCriteria(studios: List = emptyList()): It
studios = studios,
officialRatings = officialRatings.toList(),
tags = tags.toList(),
+ audioLanguages = audioLanguages.toList(),
+ subtitleLanguages = subtitleLanguages.toList(),
videoTypes =
buildList {
if (VideoTypeFilter.BLU_RAY in videoTypes) add("BluRay")
diff --git a/app/src/main/java/com/makd/afinity/data/models/media/LibraryFilters.kt b/app/src/main/java/com/makd/afinity/data/models/media/LibraryFilters.kt
index 3b8adb7c4..b29232fbb 100644
--- a/app/src/main/java/com/makd/afinity/data/models/media/LibraryFilters.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/media/LibraryFilters.kt
@@ -39,6 +39,8 @@ data class LibraryFilters(
val videoTypes: Set = emptySet(),
val seriesStatuses: Set = emptySet(),
val years: Set = emptySet(),
+ val audioLanguages: Set = emptySet(),
+ val subtitleLanguages: Set = emptySet(),
) {
val activeCount: Int
get() =
@@ -49,18 +51,30 @@ data class LibraryFilters(
tags.size +
videoTypes.size +
seriesStatuses.size +
- years.size
+ years.size +
+ audioLanguages.size +
+ subtitleLanguages.size
val isEmpty: Boolean
get() = activeCount == 0
}
+data class LibraryLanguageOption(val name: String, val code: String)
+
data class LibraryFilterOptions(
val genres: List = emptyList(),
val tags: List = emptyList(),
val officialRatings: List = emptyList(),
val years: List = emptyList(),
+ val audioLanguages: List = emptyList(),
+ val subtitleLanguages: List = emptyList(),
) {
val isEmpty: Boolean
- get() = genres.isEmpty() && tags.isEmpty() && officialRatings.isEmpty() && years.isEmpty()
+ get() =
+ genres.isEmpty() &&
+ tags.isEmpty() &&
+ officialRatings.isEmpty() &&
+ years.isEmpty() &&
+ audioLanguages.isEmpty() &&
+ subtitleLanguages.isEmpty()
}
diff --git a/app/src/main/java/com/makd/afinity/data/models/media/VideoMetadata.kt b/app/src/main/java/com/makd/afinity/data/models/media/VideoMetadata.kt
deleted file mode 100644
index 21fc7c90b..000000000
--- a/app/src/main/java/com/makd/afinity/data/models/media/VideoMetadata.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-@file:Suppress("Unused")
-
-package com.makd.afinity.data.models.media
-
-data class VideoMetadata(
- val resolution: List,
- val videoCodecs: List,
- val displayProfiles: List,
- val audioChannels: List,
- val audioCodecs: List,
- val isAtmos: List,
-)
-
-enum class Resolution(val raw: String) {
- SD("SD"),
- HD("HD"),
- UHD("4K"),
-}
-
-enum class VideoCodec(val raw: String) {
- H264("H.264"),
- HEVC("HEVC"),
- VVC("VVC"),
- AV1("AV1");
-
- override fun toString() = super.toString().lowercase()
-}
-
-enum class DisplayProfile(val raw: String) {
- SDR("SDR"),
- HDR10("HDR10"),
- HDR10_PLUS("HDR10+"),
- DOLBY_VISION("Vision"),
- HLG("HLG"),
-}
-
-enum class AudioChannel(val raw: String) {
- CH_2_0("2.0"),
- CH_2_1("2.1"),
- CH_5_1("5.1"),
- CH_7_1("7.1"),
-}
-
-enum class AudioCodec(val raw: String) {
- FLAC("FLAC"),
- MP3("MP3"),
- AAC("AAC"),
- AC3("Digital"),
- EAC3("Digital+"),
- VORBIS("VORBIS"),
- DTS("DTS"),
- TRUEHD("TrueHD"),
- OPUS("OPUS");
-
- override fun toString() = super.toString().lowercase()
-}
diff --git a/app/src/main/java/com/makd/afinity/data/models/music/AfinityAlbum.kt b/app/src/main/java/com/makd/afinity/data/models/music/AfinityAlbum.kt
index 9aeb2245d..1b2813782 100644
--- a/app/src/main/java/com/makd/afinity/data/models/music/AfinityAlbum.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/music/AfinityAlbum.kt
@@ -1,5 +1,6 @@
package com.makd.afinity.data.models.music
+import com.makd.afinity.data.models.media.AfinityExternalUrl
import com.makd.afinity.data.models.media.AfinityImages
import com.makd.afinity.data.models.media.AfinityUserDataOwner
import java.util.UUID
@@ -15,10 +16,11 @@ data class AfinityAlbum(
val runtimeTicks: Long,
val genres: List,
val overview: String?,
+ val externalUrls: List? = null,
override val favorite: Boolean,
override val played: Boolean,
val playCount: Int?,
val images: AfinityImages,
override val liked: Boolean = false,
override val playbackPositionTicks: Long = 0L,
-) : AfinityUserDataOwner
\ No newline at end of file
+) : AfinityUserDataOwner
diff --git a/app/src/main/java/com/makd/afinity/data/models/music/AfinityArtist.kt b/app/src/main/java/com/makd/afinity/data/models/music/AfinityArtist.kt
index b8948f9e2..c867d0f0a 100644
--- a/app/src/main/java/com/makd/afinity/data/models/music/AfinityArtist.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/music/AfinityArtist.kt
@@ -1,5 +1,6 @@
package com.makd.afinity.data.models.music
+import com.makd.afinity.data.models.media.AfinityExternalUrl
import com.makd.afinity.data.models.media.AfinityImages
import com.makd.afinity.data.models.media.AfinityUserDataOwner
import java.util.UUID
@@ -8,6 +9,7 @@ data class AfinityArtist(
override val id: UUID,
val name: String,
val overview: String?,
+ val externalUrls: List? = null,
val albumCount: Int?,
val genres: List,
override val favorite: Boolean,
@@ -15,4 +17,4 @@ data class AfinityArtist(
override val liked: Boolean = false,
override val played: Boolean = false,
override val playbackPositionTicks: Long = 0L,
-) : AfinityUserDataOwner
\ No newline at end of file
+) : AfinityUserDataOwner
diff --git a/app/src/main/java/com/makd/afinity/data/models/music/AfinityLyricLine.kt b/app/src/main/java/com/makd/afinity/data/models/music/AfinityLyricLine.kt
index b3fe4cb9e..e43ef183e 100644
--- a/app/src/main/java/com/makd/afinity/data/models/music/AfinityLyricLine.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/music/AfinityLyricLine.kt
@@ -1,6 +1,79 @@
package com.makd.afinity.data.models.music
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.decodeFromJsonElement
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonPrimitive
+import org.jellyfin.sdk.model.api.LyricLine
+import org.jellyfin.sdk.model.api.LyricLineCue
+
+private const val TICKS_PER_SECOND = 10_000_000.0
+
+private val lyricJson = Json { ignoreUnknownKeys = true }
+
+@Serializable
+data class AfinityLyricCue(
+ val startSeconds: Double,
+ val endSeconds: Double?,
+ val position: Int,
+ val endPosition: Int,
+)
+
+@Serializable
data class AfinityLyricLine(
val text: String,
val startSeconds: Double,
+ val cues: List = emptyList(),
)
+
+fun LyricLine.toAfinityLyricLine(): AfinityLyricLine? {
+ val start = start ?: return null
+ return AfinityLyricLine(
+ text = text,
+ startSeconds = start / TICKS_PER_SECOND,
+ cues =
+ cues
+ .orEmpty()
+ .mapNotNull { it.toAfinityLyricCue(text.length) }
+ .sortedBy { it.startSeconds },
+ )
+}
+
+private fun LyricLineCue.toAfinityLyricCue(textLength: Int): AfinityLyricCue? {
+ val from = position.coerceIn(0, textLength)
+ val to = endPosition.coerceIn(from, textLength)
+ if (to <= from) return null
+ return AfinityLyricCue(
+ startSeconds = start / TICKS_PER_SECOND,
+ endSeconds = end?.let { it / TICKS_PER_SECOND },
+ position = from,
+ endPosition = to,
+ )
+}
+
+data class CachedLyrics(val lines: List, val cueAware: Boolean)
+
+fun encodeLyricsJson(lines: List): String = lyricJson.encodeToString(lines)
+
+fun decodeLyricsJson(json: String): CachedLyrics? = runCatching {
+ val root = lyricJson.parseToJsonElement(json).jsonArray
+ if (root.firstOrNull() is JsonArray) {
+ val lines = root.mapNotNull { element ->
+ val pair = element.jsonArray
+ val text = pair.getOrNull(0)?.jsonPrimitive?.content ?: return@mapNotNull null
+ val seconds =
+ pair.getOrNull(1)?.jsonPrimitive?.content?.toDoubleOrNull()
+ ?: return@mapNotNull null
+ AfinityLyricLine(text = text, startSeconds = seconds)
+ }
+ CachedLyrics(lines, cueAware = false)
+ } else {
+ CachedLyrics(
+ lyricJson.decodeFromJsonElement>(root),
+ cueAware = true,
+ )
+ }
+}
+ .getOrNull()
diff --git a/app/src/main/java/com/makd/afinity/data/models/music/AfinityTrack.kt b/app/src/main/java/com/makd/afinity/data/models/music/AfinityTrack.kt
index 86cbbeec0..75489cab9 100644
--- a/app/src/main/java/com/makd/afinity/data/models/music/AfinityTrack.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/music/AfinityTrack.kt
@@ -21,9 +21,10 @@ data class AfinityTrack(
override val favorite: Boolean,
val playCount: Int?,
val normalizationGain: Float?,
+ val albumNormalizationGain: Float? = null,
val images: AfinityImages,
override val liked: Boolean = false,
val playlistItemId: String? = null,
val localFilePath: String? = null,
val localImagePath: String? = null,
-) : AfinityUserDataOwner
\ No newline at end of file
+) : AfinityUserDataOwner
diff --git a/app/src/main/java/com/makd/afinity/data/models/music/MusicBrowsePrefs.kt b/app/src/main/java/com/makd/afinity/data/models/music/MusicBrowsePrefs.kt
index 6894a41d3..b855615a6 100644
--- a/app/src/main/java/com/makd/afinity/data/models/music/MusicBrowsePrefs.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/music/MusicBrowsePrefs.kt
@@ -8,6 +8,7 @@ data class MusicBrowsePrefs(
val albumSortDescending: Boolean = false,
val albumFilters: MusicFilters = MusicFilters(),
val artistFilters: MusicFilters = MusicFilters(),
+ val allArtistFilters: MusicFilters = MusicFilters(),
val trackSortField: String = "Name",
val trackSortDescending: Boolean = false,
val trackFilters: MusicFilters = MusicFilters(),
diff --git a/app/src/main/java/com/makd/afinity/data/models/music/MusicPlaybackState.kt b/app/src/main/java/com/makd/afinity/data/models/music/MusicPlaybackState.kt
index caa7c7e4a..38321bb41 100644
--- a/app/src/main/java/com/makd/afinity/data/models/music/MusicPlaybackState.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/music/MusicPlaybackState.kt
@@ -10,4 +10,6 @@ data class MusicPlaybackState(
val repeatMode: RepeatMode = RepeatMode.OFF,
val shuffled: Boolean = false,
val sleepTimerEndMs: Long? = null,
+ val audioCodec: String? = null,
+ val isServerTranscode: Boolean = false,
)
diff --git a/app/src/main/java/com/makd/afinity/data/models/player/AssRenderMode.kt b/app/src/main/java/com/makd/afinity/data/models/player/AssRenderMode.kt
index 3632d9f46..23c5c660a 100644
--- a/app/src/main/java/com/makd/afinity/data/models/player/AssRenderMode.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/player/AssRenderMode.kt
@@ -15,4 +15,4 @@ enum class AssRenderMode(val value: String, @param:StringRes val labelRes: Int)
return entries.find { it.value == value } ?: default
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/makd/afinity/data/models/player/ExternalSubtitle.kt b/app/src/main/java/com/makd/afinity/data/models/player/ExternalSubtitle.kt
deleted file mode 100644
index 5cd7fce9b..000000000
--- a/app/src/main/java/com/makd/afinity/data/models/player/ExternalSubtitle.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.makd.afinity.data.models.player
-
-import android.net.Uri
-import android.os.Parcelable
-import kotlinx.parcelize.Parcelize
-
-@Parcelize
-data class ExternalSubtitle(
- val title: String?,
- val language: String?,
- val uri: Uri,
- val mimeType: String,
-) : Parcelable
diff --git a/app/src/main/java/com/makd/afinity/data/models/player/MpvOptions.kt b/app/src/main/java/com/makd/afinity/data/models/player/MpvOptions.kt
index 6184e6b62..bdb33c226 100644
--- a/app/src/main/java/com/makd/afinity/data/models/player/MpvOptions.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/player/MpvOptions.kt
@@ -85,4 +85,4 @@ enum class MpvAudioOutput(val value: String, @param:StringRes val labelRes: Int)
return entries.find { it.value == value } ?: default
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/makd/afinity/data/models/player/MusicQuality.kt b/app/src/main/java/com/makd/afinity/data/models/player/MusicQuality.kt
new file mode 100644
index 000000000..681871fdb
--- /dev/null
+++ b/app/src/main/java/com/makd/afinity/data/models/player/MusicQuality.kt
@@ -0,0 +1,46 @@
+package com.makd.afinity.data.models.player
+
+data class MusicQuality(val maxBitrate: Int) {
+
+ val isOriginal: Boolean
+ get() = maxBitrate == ORIGINAL_BITRATE
+
+ val streamingBitrate: Int
+ get() =
+ if (isOriginal) maxBitrate
+ else (maxBitrate * (1f + CONTAINER_OVERHEAD_HEADROOM)).toInt()
+
+ companion object {
+ const val ORIGINAL_BITRATE = -1
+
+ const val CONTAINER_OVERHEAD_HEADROOM = 0.05f
+
+ val ORIGINAL = MusicQuality(ORIGINAL_BITRATE)
+
+ const val AUDIO_CODECS = "opus,aac,mp3"
+ const val CONTAINERS = "opus,ogg,aac,m4a,m4b,mp4,mp3,flac,alac,wav"
+ const val TRANSCODING_CONTAINER = "ogg"
+ const val TRANSCODING_PROTOCOL = "http"
+
+ private val LADDER =
+ listOf(
+ MusicQuality(320_000),
+ MusicQuality(256_000),
+ MusicQuality(192_000),
+ MusicQuality(128_000),
+ MusicQuality(96_000),
+ MusicQuality(64_000),
+ )
+
+ val CELLULAR_DEFAULT_BITRATE = 128_000
+
+ fun options(): List = listOf(ORIGINAL) + LADDER
+
+ fun fromBitrate(bitrate: Int?): MusicQuality =
+ when (bitrate) {
+ null,
+ ORIGINAL_BITRATE -> ORIGINAL
+ else -> LADDER.firstOrNull { it.maxBitrate == bitrate } ?: MusicQuality(bitrate)
+ }
+ }
+}
diff --git a/app/src/main/java/com/makd/afinity/data/models/player/PlayerState.kt b/app/src/main/java/com/makd/afinity/data/models/player/PlayerState.kt
index fc3bbbcc6..b5dca4e0d 100644
--- a/app/src/main/java/com/makd/afinity/data/models/player/PlayerState.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/player/PlayerState.kt
@@ -5,30 +5,6 @@ import com.makd.afinity.data.models.media.AfinityItem
import com.makd.afinity.data.models.media.AfinitySegment
import java.util.UUID
-data class PlayerState(
- val isPlaying: Boolean = false,
- val isPaused: Boolean = false,
- val isLoading: Boolean = false,
- val isBuffering: Boolean = false,
- val currentPosition: Long = 0L,
- val duration: Long = 0L,
- val currentItem: AfinityItem? = null,
- val mediaSourceId: String? = null,
- val audioStreamIndex: Int? = null,
- val subtitleStreamIndex: Int? = null,
- val volume: Int = 100,
- val brightness: Float = 0.5f,
- val playbackSpeed: Float = 1.0f,
- val isControlsVisible: Boolean = true,
- val error: PlayerError? = null,
- val isFullscreen: Boolean = true,
- val sessionId: String? = null,
- val isControlsLocked: Boolean = false,
- val isInPictureInPictureMode: Boolean = false,
-)
-
-data class PlayerError(val code: Int, val message: String, val cause: Throwable? = null)
-
sealed class PlayerEvent {
object Play : PlayerEvent()
@@ -94,6 +70,23 @@ sealed class PlayerEvent {
data object TogglePlaybackStats : PlayerEvent()
data object ToggleVersionPicker : PlayerEvent()
+
+ data class SelectVideoQuality(val quality: VideoQuality) : PlayerEvent()
+
+ data object PlayAnywayWithTranscoding : PlayerEvent()
+
+ data class RenegotiateTracks(
+ val audioStreamIndex: Int? = null,
+ val subtitleStreamIndex: Int? = null,
+ ) : PlayerEvent()
+
+ data class SetSleepTimer(val mode: SleepTimerMode) : PlayerEvent()
+
+ data object CancelSleepTimer : PlayerEvent()
+
+ data object ExtendSleepTimer : PlayerEvent()
+
+ data object ResumeFromSleepTimer : PlayerEvent()
}
data class PlaybackStats(
@@ -120,7 +113,19 @@ data class PlaybackStats(
val hwDec: String = "Unknown",
val bufferHealth: String = "Unknown",
val videoBitrate: String = "Unknown",
+ val transcodeOutput: String = "",
+ val transcodeVideo: String = "",
+ val transcodeAudio: String = "",
+ val transcodeSpeed: String = "",
+ val transcodeHardware: String = "",
+ val transcodeReasons: List = emptyList(),
) {
+ val isTranscoding: Boolean
+ get() =
+ transcodeOutput.isNotBlank() ||
+ transcodeVideo.isNotBlank() ||
+ transcodeAudio.isNotBlank()
+
val hasVideo: Boolean
get() = !videoResolution.startsWith("0x0") && videoResolution != "Unknown"
diff --git a/app/src/main/java/com/makd/afinity/data/models/player/SleepTimerMode.kt b/app/src/main/java/com/makd/afinity/data/models/player/SleepTimerMode.kt
new file mode 100644
index 000000000..820feffac
--- /dev/null
+++ b/app/src/main/java/com/makd/afinity/data/models/player/SleepTimerMode.kt
@@ -0,0 +1,10 @@
+package com.makd.afinity.data.models.player
+
+const val SLEEP_TIMER_EXTEND_MINUTES = 15
+const val SLEEP_TIMER_PROMPT_THRESHOLD_MS = 30_000L
+
+sealed class SleepTimerMode {
+ data class Duration(val minutes: Int) : SleepTimerMode()
+
+ data object EndOfItem : SleepTimerMode()
+}
diff --git a/app/src/main/java/com/makd/afinity/data/models/player/StreamDecision.kt b/app/src/main/java/com/makd/afinity/data/models/player/StreamDecision.kt
new file mode 100644
index 000000000..c9e3fb93e
--- /dev/null
+++ b/app/src/main/java/com/makd/afinity/data/models/player/StreamDecision.kt
@@ -0,0 +1,44 @@
+package com.makd.afinity.data.models.player
+
+import org.jellyfin.sdk.model.api.MediaStreamProtocol
+import org.jellyfin.sdk.model.api.PlayMethod
+import org.jellyfin.sdk.model.api.TranscodeReason
+
+sealed interface StreamDecision {
+
+ val url: String
+ val playMethod: PlayMethod
+
+ val protocol: MediaStreamProtocol
+ get() = MediaStreamProtocol.HTTP
+
+ val transcodeReasons: List
+ get() = emptyList()
+
+ val burnedInSubtitleIndex: Int?
+ get() = null
+
+ val isServerSideSubtitle: Boolean
+ get() = burnedInSubtitleIndex != null
+
+ data class DirectPlay(override val url: String) : StreamDecision {
+ override val playMethod = PlayMethod.DIRECT_PLAY
+ }
+
+ data class DirectStream(
+ override val url: String,
+ override val protocol: MediaStreamProtocol,
+ override val transcodeReasons: List,
+ ) : StreamDecision {
+ override val playMethod = PlayMethod.DIRECT_STREAM
+ }
+
+ data class Transcode(
+ override val url: String,
+ override val protocol: MediaStreamProtocol,
+ override val transcodeReasons: List,
+ override val burnedInSubtitleIndex: Int?,
+ ) : StreamDecision {
+ override val playMethod = PlayMethod.TRANSCODE
+ }
+}
diff --git a/app/src/main/java/com/makd/afinity/data/models/player/VideoQuality.kt b/app/src/main/java/com/makd/afinity/data/models/player/VideoQuality.kt
new file mode 100644
index 000000000..b0e62047b
--- /dev/null
+++ b/app/src/main/java/com/makd/afinity/data/models/player/VideoQuality.kt
@@ -0,0 +1,76 @@
+package com.makd.afinity.data.models.player
+
+data class VideoQuality(val maxBitrate: Int, val maxWidth: Int?) {
+
+ val isAuto: Boolean
+ get() = maxBitrate == AUTO_BITRATE
+
+ val isOriginal: Boolean
+ get() = maxBitrate == ORIGINAL_BITRATE
+
+ val maxHeight: Int?
+ get() =
+ when (maxWidth) {
+ 3840 -> 2160
+ 2560 -> 1440
+ 1920 -> 1080
+ 1280 -> 720
+ 854 -> 480
+ 640 -> 360
+ else -> null
+ }
+
+ companion object {
+ const val AUTO_BITRATE = 0
+ const val ORIGINAL_BITRATE = -1
+
+ val AUTO = VideoQuality(AUTO_BITRATE, null)
+ val ORIGINAL = VideoQuality(ORIGINAL_BITRATE, null)
+
+ private val LADDER =
+ listOf(
+ VideoQuality(120_000_000, 3840),
+ VideoQuality(80_000_000, 3840),
+ VideoQuality(60_000_000, 3840),
+ VideoQuality(40_000_000, 3840),
+ VideoQuality(20_000_000, 3840),
+ VideoQuality(15_000_000, 2560),
+ VideoQuality(10_000_000, 2560),
+ VideoQuality(8_000_000, 1920),
+ VideoQuality(6_000_000, 1920),
+ VideoQuality(4_000_000, 1920),
+ VideoQuality(3_000_000, 1920),
+ VideoQuality(2_000_000, 1280),
+ VideoQuality(1_500_000, 1280),
+ VideoQuality(720_000, 854),
+ VideoQuality(420_000, 640),
+ )
+
+ fun settingsLadder(): List = listOf(AUTO, ORIGINAL) + LADDER
+
+ fun fromBitrate(bitrate: Int?): VideoQuality =
+ when (bitrate) {
+ null,
+ ORIGINAL_BITRATE -> ORIGINAL
+ AUTO_BITRATE -> AUTO
+ else ->
+ LADDER.firstOrNull { it.maxBitrate == bitrate } ?: VideoQuality(bitrate, null)
+ }
+
+ fun optionsFor(sourceBitrate: Int?, sourceWidth: Int?): List {
+ val knownBitrate = sourceBitrate?.takeIf { it > 0 }
+ val rungs = LADDER.filter { rung ->
+ val width = rung.maxWidth ?: Int.MAX_VALUE
+ val neverUpscale = sourceWidth == null || width <= sourceWidth
+ val downscales = sourceWidth != null && width < sourceWidth
+ val lowersBitrate = knownBitrate == null || rung.maxBitrate < knownBitrate
+ neverUpscale && (downscales || lowersBitrate)
+ }
+ return buildList {
+ add(AUTO)
+ add(ORIGINAL)
+ addAll(rungs)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/makd/afinity/data/models/server/ServerStorage.kt b/app/src/main/java/com/makd/afinity/data/models/server/ServerStorage.kt
new file mode 100644
index 000000000..4dd38f173
--- /dev/null
+++ b/app/src/main/java/com/makd/afinity/data/models/server/ServerStorage.kt
@@ -0,0 +1,38 @@
+package com.makd.afinity.data.models.server
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+enum class StorageFolderKind {
+ PROGRAM_DATA,
+ METADATA,
+ TRANSCODING_TEMP,
+ CACHE,
+ IMAGE_CACHE,
+ LOGS,
+ WEB,
+}
+
+@Serializable data class StorageFolder(val kind: StorageFolderKind?, val path: String)
+
+@Serializable
+data class StorageDevice(
+ val label: String,
+ val storageType: String?,
+ val freeSpace: Long,
+ val usedSpace: Long,
+ val folders: List,
+ val libraries: List,
+) {
+ val totalSpace: Long
+ get() = freeSpace + usedSpace
+
+ val usedFraction: Float
+ get() = if (totalSpace > 0) usedSpace.toFloat() / totalSpace.toFloat() else 0f
+}
+
+@Serializable
+data class ServerStorage(val devices: List = emptyList()) {
+ val isEmpty: Boolean
+ get() = devices.isEmpty()
+}
diff --git a/app/src/main/java/com/makd/afinity/data/models/wikidata/SparqlResponse.kt b/app/src/main/java/com/makd/afinity/data/models/wikidata/SparqlResponse.kt
index b788469d9..01ad271c7 100644
--- a/app/src/main/java/com/makd/afinity/data/models/wikidata/SparqlResponse.kt
+++ b/app/src/main/java/com/makd/afinity/data/models/wikidata/SparqlResponse.kt
@@ -2,10 +2,8 @@ package com.makd.afinity.data.models.wikidata
import kotlinx.serialization.Serializable
-@Serializable
-data class SparqlResponse(val results: SparqlResults = SparqlResults())
+@Serializable data class SparqlResponse(val results: SparqlResults = SparqlResults())
-@Serializable
-data class SparqlResults(val bindings: List