diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index cf65f99a6..ea54248de 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -1,8 +1,7 @@ name: Android Builds -# CI gate: unit tests + Kover line-coverage verify on :shared commonMain, plus -# lint. Floor is configured in shared/build.gradle.kts. Release artifacts -# (Play Store bundles, sideload APKs, GitHub releases) are owned by +# CI gate: tests, lint, and release readiness on main pushes and pull requests. +# Release artifacts (Play Store bundles, sideload APKs, GitHub releases) are owned by # release.yml — triggered by v* tags or its workflow_dispatch. on: @@ -94,6 +93,53 @@ jobs: path: android-shared/libs/media3-decoder-ffmpeg-1.11.0.aar if-no-files-found: error + release-readiness: + name: Release readiness + if: github.event_name != 'workflow_dispatch' || !inputs.build_ffmpeg_aar + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Check out sources + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + # Match release.yml: the pinned Ruby and frozen lockfile must work + # before a release reaches the Google Play job. + - name: Set up Ruby and install release dependencies + uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 + with: + bundler-cache: true + + - name: Load the release lane and Play client + run: | + bundle exec fastlane lanes + bundle exec ruby -e "require 'supply'" + env: + FASTLANE_SKIP_UPDATE_CHECK: "true" + FASTLANE_OPT_OUT_USAGE: "true" + + - name: Set up JDK 21 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 + with: + distribution: temurin + java-version: "21" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + + # Exercise both Play artifacts, including R8 and bundle packaging. + # Signing and publishing remain in release.yml. + - name: Build phone and TV release bundles + run: | + ./gradlew \ + -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ + :androidApp:bundleRelease :androidTvApp:bundleRelease \ + --max-workers=2 + test -s androidApp/build/outputs/bundle/release/androidApp-release.aab + test -s androidTvApp/build/outputs/bundle/release/androidTvApp-release.aab + unit-tests: name: Unit tests if: github.event_name != 'workflow_dispatch' || !inputs.build_ffmpeg_aar diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2e3479cd..c6fd5eb38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -250,7 +250,7 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 - - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 + - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 with: bundler-cache: true diff --git a/.ruby-version b/.ruby-version index e650c01d9..0ce07b72f 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2.9 +3.4.11 diff --git a/Gemfile b/Gemfile index 9f3e0ef5f..d3afcfcbf 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,5 @@ source "https://rubygems.org" -ruby "~> 3.2" +ruby "~> 3.4.0" gem "fastlane" diff --git a/Gemfile.lock b/Gemfile.lock index 1ca8bb94c..c4ddb77c1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -30,6 +30,7 @@ GEM base64 (0.3.0) benchmark (0.5.0) bigdecimal (4.1.2) + cgi (0.5.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) @@ -42,38 +43,26 @@ GEM domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) + erb (6.0.7) excon (1.5.0) logger - faraday (1.10.6) - faraday-em_http (~> 1.0) - faraday-em_synchrony (~> 1.0) - faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0) - faraday-multipart (~> 1.0) - faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.0) - faraday-patron (~> 1.0) - faraday-rack (~> 1.0) - faraday-retry (~> 1.0) - ruby2_keywords (>= 0.0.4) + faraday (2.14.4) + faraday-net_http (>= 2.0, < 3.5) + json + logger faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) http-cookie (>= 1.0.0) - faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.1) - faraday-excon (1.1.0) - faraday-httpclient (1.0.1) + faraday-follow_redirects (0.5.0) + faraday (>= 1, < 3) faraday-multipart (1.2.0) multipart-post (~> 2.0) - faraday-net_http (1.0.2) - faraday-net_http_persistent (1.2.0) - faraday-patron (1.0.0) - faraday-rack (1.0.0) - faraday-retry (1.0.4) - faraday_middleware (1.2.1) - faraday (~> 1.0) + faraday-net_http (3.4.4) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) fastimage (2.4.1) - fastlane (2.237.0) + fastlane (2.240.1) CFPropertyList (>= 2.3, < 5.0.0) abbrev (~> 0.1) addressable (>= 2.9.0, < 3.0.0) @@ -83,24 +72,27 @@ GEM base64 (~> 0.2) benchmark (>= 0.1.0) bundler (>= 2.4.0, < 5.0.0) + cgi (~> 0.4) colored (~> 1.2) commander (~> 4.6) csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) excon (>= 0.71.0, < 2.0.0) - faraday (~> 1.0) - faraday-cookie_jar (~> 0.0.6) - faraday_middleware (~> 1.0) + faraday (~> 2.7) + faraday-cookie_jar (~> 0.0.8) + faraday-follow_redirects (~> 0.3) + faraday-multipart (~> 1.0) + faraday-retry (~> 2.0) fastimage (>= 2.1.0, < 3.0.0) fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) - google-apis-androidpublisher_v3 (~> 0.3) + google-apis-androidpublisher_v3 (~> 0.99) google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-env (>= 1.6.0, < 2.3.0) + google-cloud-env (>= 1.6.0, < 2.4.0) google-cloud-storage (~> 1.31) highline (~> 2.0) - http-cookie (~> 1.0.5) + irb (>= 1.8) json (< 3.0.0) jwt (>= 2.10.3, < 4) logger (>= 1.6, < 2.0) @@ -113,11 +105,11 @@ GEM optparse (>= 0.1.1, < 1.0.0) ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) - rubyzip (>= 2.0.0, < 3.0.0) - security (= 0.1.5) + rubyzip (>= 3.4.0, < 4.0.0) + security (~> 0.3) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (~> 3) + terminal-table (~> 4) tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) @@ -172,6 +164,12 @@ GEM domain_name (~> 0.5) httpclient (2.9.0) mutex_m + io-console (0.9.4) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) jmespath (1.6.2) json (2.21.2) jwt (3.2.0) @@ -184,14 +182,31 @@ GEM mutex_m (0.3.0) nanaimo (0.4.0) naturally (2.3.0) + net-http (0.9.1) + uri (>= 0.11.1) nkf (0.3.0) optparse (0.8.1) os (1.1.4) ostruct (0.6.3) plist (3.7.2) + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) pstore (0.2.1) public_suffix (7.0.5) rake (13.4.2) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + reline (0.7.0) + io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) @@ -199,9 +214,8 @@ GEM retriable (3.8.0) rexml (3.4.4) rouge (3.28.0) - ruby2_keywords (0.0.5) - rubyzip (2.4.1) - security (0.1.5) + rubyzip (3.4.0) + security (0.3.0) signet (0.22.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) @@ -210,15 +224,17 @@ GEM CFPropertyList naturally terminal-notifier (2.0.0) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) trailblazer-option (0.1.2) + tsort (0.2.0) tty-cursor (0.7.1) tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) unicode-display_width (2.6.0) + uri (1.1.1) word_wrap (1.0.0) xcodeproj (1.28.1) CFPropertyList (>= 2.3.3, < 4.0) @@ -257,6 +273,8 @@ CHECKSUMS base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bundler (4.0.15) sha256=a4ceb882fe94a0e0ac63cd0813932bbfd631a14e5ac0b7975189b19a4d28d9e7 + cgi (0.5.2) sha256=61ca30298171190fd4fa0d8018e57ada456eae9b7a2b78526debf7f0a0e6f8bb claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a @@ -267,22 +285,16 @@ CHECKSUMS domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933 dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8 emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b + erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 excon (1.5.0) sha256=c503ad1d0123bc8ab2a062ff3789dc891ec368cb9e13765ab88a9c58c8bb6d50 - faraday (1.10.6) sha256=7ff4802a6b312876a2241b3e641ce0d5045e168dd871b422c35b505e5261ad4d + faraday (2.14.4) sha256=9bb4408c44621b0dbaaaa39e5dbfc4dbe58cc6751b6eb94d16ae9e1f2f2fab6e faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb - faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689 - faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750 - faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940 - faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b + faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757 - faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682 - faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335 - faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7 - faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0 - faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa - faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9 + faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588 + faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20 - fastlane (2.237.0) sha256=bb1e867bc070fb328741b5e6e7606d5ba596941a9ecca0478f60ef22d1009db9 + fastlane (2.240.1) sha256=cf792bc99ec6af6574ea416cda6530f519c8b4b3a46b1a3b52488ebf1696aa5c fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847 gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939 google-apis-androidpublisher_v3 (0.104.0) sha256=3bf7f0dee23ae71e070e279848374834853204e304831cf6aa76fa435a4d6eff @@ -299,6 +311,8 @@ CHECKSUMS highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479 http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6 httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8 + io-console (0.9.4) sha256=a403304f32928ee85e56d457b4612ca43f2703b561a3258844992bd24085fbb1 + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7 @@ -310,38 +324,46 @@ CHECKSUMS mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723 naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01 + net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895 optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 + rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace retriable (3.8.0) sha256=9f2f1b0207594c7817f17f671587b8ec7587387ac6cebda6c941a802bb98a8e5 rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20 - ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef - rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615 - security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7 + rubyzip (3.4.0) sha256=6de39bc9eba302b635a476d16c9e16b0872ad24517c2f98f2b3a7ea23caff57b + security (0.3.0) sha256=bc9d06718258a96c8552ce4044bfe69144491b10d3403d74c4753a03a720ecf0 signet (0.22.0) sha256=b76d495ccb07ad35dbc89f3e920665a9d8ed717141955034005d7843dcfe4780 simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea - terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91 + terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2 trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3 + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48 tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50 tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542 uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7 xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892 xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93 RUBY VERSION - ruby 3.2.9 + ruby 3.4.11 BUNDLED WITH 4.0.15 diff --git a/android-shared/schemas/org.prairieserver.prairie.common.data.db.PrairieDatabase/12.json b/android-shared/schemas/org.prairieserver.prairie.common.data.db.PrairieDatabase/12.json new file mode 100644 index 000000000..ca15542f8 --- /dev/null +++ b/android-shared/schemas/org.prairieserver.prairie.common.data.db.PrairieDatabase/12.json @@ -0,0 +1,984 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "8a348d04acd6cd7fe41b6ee065fb65cb", + "entities": [ + { + "tableName": "user_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `fileId` INTEGER NOT NULL, `positionSeconds` REAL NOT NULL, `durationSeconds` REAL, `audioFingerprint` TEXT, `subtitleFingerprint` TEXT, `cfi` TEXT, `readProgress` REAL, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`, `fileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "positionSeconds", + "columnName": "positionSeconds", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "audioFingerprint", + "columnName": "audioFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleFingerprint", + "columnName": "subtitleFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "cfi", + "columnName": "cfi", + "affinity": "TEXT" + }, + { + "fieldPath": "readProgress", + "columnName": "readProgress", + "affinity": "REAL" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId", + "fileId" + ] + }, + "indices": [ + { + "name": "index_user_item_state_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_user_item_state_serverId_profileId_clientUpdatedAtMs", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "clientUpdatedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_clientUpdatedAtMs` ON `${TABLE_NAME}` (`serverId`, `profileId`, `clientUpdatedAtMs`)" + } + ] + }, + { + "tableName": "content_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `watched` INTEGER, `ratingValue` INTEGER, `favorite` INTEGER, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watched", + "columnName": "watched", + "affinity": "INTEGER" + }, + { + "fieldPath": "ratingValue", + "columnName": "ratingValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ] + } + }, + { + "tableName": "dirty_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `opKind` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetContentId` TEXT NOT NULL, `targetFileId` INTEGER, `coalesceKey` TEXT NOT NULL, `idempotencyKey` TEXT NOT NULL, `opVersion` INTEGER NOT NULL, `payloadJson` TEXT NOT NULL, `state` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, `attemptCount` INTEGER NOT NULL, `lastAttemptAtMs` INTEGER, `nextAttemptAtMs` INTEGER NOT NULL, `lastError` TEXT, `membershipAuthority` TEXT, `membershipClaim` TEXT, `membershipOwner` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opKind", + "columnName": "opKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetContentId", + "columnName": "targetContentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetFileId", + "columnName": "targetFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coalesceKey", + "columnName": "coalesceKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "idempotencyKey", + "columnName": "idempotencyKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "opVersion", + "columnName": "opVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attemptCount", + "columnName": "attemptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAtMs", + "columnName": "lastAttemptAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "nextAttemptAtMs", + "columnName": "nextAttemptAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipAuthority", + "columnName": "membershipAuthority", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipClaim", + "columnName": "membershipClaim", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipOwner", + "columnName": "membershipOwner", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_dirty_operations_idempotencyKey", + "unique": true, + "columnNames": [ + "idempotencyKey" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_dirty_operations_idempotencyKey` ON `${TABLE_NAME}` (`idempotencyKey`)" + }, + { + "name": "index_dirty_operations_coalesceKey", + "unique": false, + "columnNames": [ + "coalesceKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_coalesceKey` ON `${TABLE_NAME}` (`coalesceKey`)" + }, + { + "name": "index_dirty_operations_nextAttemptAtMs_id", + "unique": false, + "columnNames": [ + "nextAttemptAtMs", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_nextAttemptAtMs_id` ON `${TABLE_NAME}` (`nextAttemptAtMs`, `id`)" + } + ] + }, + { + "tableName": "downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `mediaFileId` INTEGER NOT NULL, `recordId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `title` TEXT NOT NULL, `subtitle` TEXT, `posterUrl` TEXT, `posterThumbhash` TEXT, `year` INTEGER, `seriesTitle` TEXT, `seriesContentId` TEXT, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `fileName` TEXT, `container` TEXT, `localUri` TEXT, `mediaType` TEXT NOT NULL, `overview` TEXT, `author` TEXT, `narrator` TEXT, `durationSeconds` REAL, `chaptersJson` TEXT, `status` TEXT NOT NULL, `kind` TEXT NOT NULL, `fileSize` INTEGER NOT NULL, `bytesSent` INTEGER NOT NULL, `createdAt` TEXT NOT NULL, `completedAt` TEXT, `updatedAtMs` INTEGER NOT NULL, `resumeValidator` TEXT, `quality` TEXT, `effectiveQuality` TEXT, `revision` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `mediaFileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "posterUrl", + "columnName": "posterUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "posterThumbhash", + "columnName": "posterThumbhash", + "affinity": "TEXT" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "seriesTitle", + "columnName": "seriesTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesContentId", + "columnName": "seriesContentId", + "affinity": "TEXT" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "episodeNumber", + "columnName": "episodeNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "container", + "columnName": "container", + "affinity": "TEXT" + }, + { + "fieldPath": "localUri", + "columnName": "localUri", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT" + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "narrator", + "columnName": "narrator", + "affinity": "TEXT" + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "chaptersJson", + "columnName": "chaptersJson", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bytesSent", + "columnName": "bytesSent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resumeValidator", + "columnName": "resumeValidator", + "affinity": "TEXT" + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT" + }, + { + "fieldPath": "effectiveQuality", + "columnName": "effectiveQuality", + "affinity": "TEXT" + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "mediaFileId" + ] + }, + "indices": [ + { + "name": "index_downloads_serverId_profileId_status", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_status` ON `${TABLE_NAME}` (`serverId`, `profileId`, `status`)" + }, + { + "name": "index_downloads_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_downloads_recordId", + "unique": true, + "columnNames": [ + "recordId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloads_recordId` ON `${TABLE_NAME}` (`recordId`)" + } + ] + }, + { + "tableName": "legacy_imports", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sourceKind` TEXT NOT NULL, `sourcePath` TEXT NOT NULL, `sourceHash` TEXT NOT NULL, `sourceMtimeMs` INTEGER NOT NULL, `importedAtMs` INTEGER NOT NULL, `importVersion` INTEGER NOT NULL, `status` TEXT NOT NULL, `error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceKind", + "columnName": "sourceKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcePath", + "columnName": "sourcePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceHash", + "columnName": "sourceHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceMtimeMs", + "columnName": "sourceMtimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importedAtMs", + "columnName": "importedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importVersion", + "columnName": "importVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_legacy_imports_sourceKind_sourcePath", + "unique": true, + "columnNames": [ + "sourceKind", + "sourcePath" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_legacy_imports_sourceKind_sourcePath` ON `${TABLE_NAME}` (`sourceKind`, `sourcePath`)" + } + ] + }, + { + "tableName": "home_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `sectionsJson` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sectionsJson", + "columnName": "sectionsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId" + ] + } + }, + { + "tableName": "catalog_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `cacheKey` TEXT NOT NULL, `json` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`, `cacheKey`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cacheKey", + "columnName": "cacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "cacheKey" + ] + } + }, + { + "tableName": "download_deletions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `recordId` TEXT NOT NULL, `mediaFileId` INTEGER, `enqueuedAtMs` INTEGER NOT NULL, `loginId` TEXT, `origin` TEXT, `deviceId` TEXT, PRIMARY KEY(`serverId`, `profileId`, `recordId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "enqueuedAtMs", + "columnName": "enqueuedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loginId", + "columnName": "loginId", + "affinity": "TEXT" + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT" + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "recordId" + ] + } + }, + { + "tableName": "download_subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetType` TEXT NOT NULL, `targetId` TEXT NOT NULL, `displayTitle` TEXT NOT NULL, `mediaKind` TEXT NOT NULL, `quality` TEXT NOT NULL, `wifiOnly` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `includeExisting` INTEGER NOT NULL, `keepUnwatchedLimit` INTEGER NOT NULL, `deleteWatchedAfterDays` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastEvaluatedAt` INTEGER, `lastError` TEXT, PRIMARY KEY(`serverId`, `profileId`, `id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetType", + "columnName": "targetType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetId", + "columnName": "targetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaKind", + "columnName": "mediaKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifiOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeExisting", + "columnName": "includeExisting", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepUnwatchedLimit", + "columnName": "keepUnwatchedLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleteWatchedAfterDays", + "columnName": "deleteWatchedAfterDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEvaluatedAt", + "columnName": "lastEvaluatedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "id" + ] + }, + "indices": [ + { + "name": "index_download_subscriptions_serverId_profileId_enabled", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "enabled" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_enabled` ON `${TABLE_NAME}` (`serverId`, `profileId`, `enabled`)" + }, + { + "name": "index_download_subscriptions_serverId_profileId_targetType_targetId", + "unique": true, + "columnNames": [ + "serverId", + "profileId", + "targetType", + "targetId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_targetType_targetId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `targetType`, `targetId`)" + } + ] + }, + { + "tableName": "membership_projection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authority` TEXT NOT NULL, `itemId` TEXT NOT NULL, `kind` TEXT NOT NULL, `commandId` INTEGER NOT NULL, `present` INTEGER NOT NULL, `disposition` TEXT, PRIMARY KEY(`authority`, `itemId`, `kind`))", + "fields": [ + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "present", + "columnName": "present", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "authority", + "itemId", + "kind" + ] + } + }, + { + "tableName": "legacy_membership_quarantine", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`commandId` INTEGER NOT NULL, `originalState` TEXT NOT NULL, PRIMARY KEY(`commandId`))", + "fields": [ + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalState", + "columnName": "originalState", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "commandId" + ] + } + } + ], + "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, '8a348d04acd6cd7fe41b6ee065fb65cb')" + ] + } +} \ No newline at end of file diff --git a/android-shared/schemas/org.prairieserver.prairie.common.data.db.PrairieDatabase/13.json b/android-shared/schemas/org.prairieserver.prairie.common.data.db.PrairieDatabase/13.json new file mode 100644 index 000000000..5d3dc4bad --- /dev/null +++ b/android-shared/schemas/org.prairieserver.prairie.common.data.db.PrairieDatabase/13.json @@ -0,0 +1,989 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "86a5233fe50f7b265ded23af8525415e", + "entities": [ + { + "tableName": "user_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `fileId` INTEGER NOT NULL, `positionSeconds` REAL NOT NULL, `durationSeconds` REAL, `audioFingerprint` TEXT, `subtitleFingerprint` TEXT, `cfi` TEXT, `readProgress` REAL, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`, `fileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "positionSeconds", + "columnName": "positionSeconds", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "audioFingerprint", + "columnName": "audioFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleFingerprint", + "columnName": "subtitleFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "cfi", + "columnName": "cfi", + "affinity": "TEXT" + }, + { + "fieldPath": "readProgress", + "columnName": "readProgress", + "affinity": "REAL" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId", + "fileId" + ] + }, + "indices": [ + { + "name": "index_user_item_state_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_user_item_state_serverId_profileId_clientUpdatedAtMs", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "clientUpdatedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_clientUpdatedAtMs` ON `${TABLE_NAME}` (`serverId`, `profileId`, `clientUpdatedAtMs`)" + } + ] + }, + { + "tableName": "content_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `watched` INTEGER, `ratingValue` INTEGER, `favorite` INTEGER, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watched", + "columnName": "watched", + "affinity": "INTEGER" + }, + { + "fieldPath": "ratingValue", + "columnName": "ratingValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ] + } + }, + { + "tableName": "dirty_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `opKind` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetContentId` TEXT NOT NULL, `targetFileId` INTEGER, `coalesceKey` TEXT NOT NULL, `idempotencyKey` TEXT NOT NULL, `opVersion` INTEGER NOT NULL, `payloadJson` TEXT NOT NULL, `state` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, `attemptCount` INTEGER NOT NULL, `lastAttemptAtMs` INTEGER, `nextAttemptAtMs` INTEGER NOT NULL, `lastError` TEXT, `membershipAuthority` TEXT, `membershipClaim` TEXT, `membershipOwner` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opKind", + "columnName": "opKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetContentId", + "columnName": "targetContentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetFileId", + "columnName": "targetFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coalesceKey", + "columnName": "coalesceKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "idempotencyKey", + "columnName": "idempotencyKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "opVersion", + "columnName": "opVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attemptCount", + "columnName": "attemptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAtMs", + "columnName": "lastAttemptAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "nextAttemptAtMs", + "columnName": "nextAttemptAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipAuthority", + "columnName": "membershipAuthority", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipClaim", + "columnName": "membershipClaim", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipOwner", + "columnName": "membershipOwner", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_dirty_operations_idempotencyKey", + "unique": true, + "columnNames": [ + "idempotencyKey" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_dirty_operations_idempotencyKey` ON `${TABLE_NAME}` (`idempotencyKey`)" + }, + { + "name": "index_dirty_operations_coalesceKey", + "unique": false, + "columnNames": [ + "coalesceKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_coalesceKey` ON `${TABLE_NAME}` (`coalesceKey`)" + }, + { + "name": "index_dirty_operations_nextAttemptAtMs_id", + "unique": false, + "columnNames": [ + "nextAttemptAtMs", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_nextAttemptAtMs_id` ON `${TABLE_NAME}` (`nextAttemptAtMs`, `id`)" + } + ] + }, + { + "tableName": "downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `mediaFileId` INTEGER NOT NULL, `recordId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `title` TEXT NOT NULL, `subtitle` TEXT, `posterUrl` TEXT, `posterThumbhash` TEXT, `year` INTEGER, `seriesTitle` TEXT, `seriesContentId` TEXT, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `fileName` TEXT, `container` TEXT, `localUri` TEXT, `mediaType` TEXT NOT NULL, `overview` TEXT, `author` TEXT, `narrator` TEXT, `durationSeconds` REAL, `chaptersJson` TEXT, `status` TEXT NOT NULL, `kind` TEXT NOT NULL, `fileSize` INTEGER NOT NULL, `bytesSent` INTEGER NOT NULL, `createdAt` TEXT NOT NULL, `completedAt` TEXT, `updatedAtMs` INTEGER NOT NULL, `resumeValidator` TEXT, `quality` TEXT, `effectiveQuality` TEXT, `revision` INTEGER, `offlineTracksJson` TEXT, PRIMARY KEY(`serverId`, `profileId`, `mediaFileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "posterUrl", + "columnName": "posterUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "posterThumbhash", + "columnName": "posterThumbhash", + "affinity": "TEXT" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "seriesTitle", + "columnName": "seriesTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesContentId", + "columnName": "seriesContentId", + "affinity": "TEXT" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "episodeNumber", + "columnName": "episodeNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "container", + "columnName": "container", + "affinity": "TEXT" + }, + { + "fieldPath": "localUri", + "columnName": "localUri", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT" + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "narrator", + "columnName": "narrator", + "affinity": "TEXT" + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "chaptersJson", + "columnName": "chaptersJson", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bytesSent", + "columnName": "bytesSent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resumeValidator", + "columnName": "resumeValidator", + "affinity": "TEXT" + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT" + }, + { + "fieldPath": "effectiveQuality", + "columnName": "effectiveQuality", + "affinity": "TEXT" + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER" + }, + { + "fieldPath": "offlineTracksJson", + "columnName": "offlineTracksJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "mediaFileId" + ] + }, + "indices": [ + { + "name": "index_downloads_serverId_profileId_status", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_status` ON `${TABLE_NAME}` (`serverId`, `profileId`, `status`)" + }, + { + "name": "index_downloads_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_downloads_recordId", + "unique": true, + "columnNames": [ + "recordId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloads_recordId` ON `${TABLE_NAME}` (`recordId`)" + } + ] + }, + { + "tableName": "legacy_imports", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sourceKind` TEXT NOT NULL, `sourcePath` TEXT NOT NULL, `sourceHash` TEXT NOT NULL, `sourceMtimeMs` INTEGER NOT NULL, `importedAtMs` INTEGER NOT NULL, `importVersion` INTEGER NOT NULL, `status` TEXT NOT NULL, `error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceKind", + "columnName": "sourceKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcePath", + "columnName": "sourcePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceHash", + "columnName": "sourceHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceMtimeMs", + "columnName": "sourceMtimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importedAtMs", + "columnName": "importedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importVersion", + "columnName": "importVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_legacy_imports_sourceKind_sourcePath", + "unique": true, + "columnNames": [ + "sourceKind", + "sourcePath" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_legacy_imports_sourceKind_sourcePath` ON `${TABLE_NAME}` (`sourceKind`, `sourcePath`)" + } + ] + }, + { + "tableName": "home_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `sectionsJson` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sectionsJson", + "columnName": "sectionsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId" + ] + } + }, + { + "tableName": "catalog_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `cacheKey` TEXT NOT NULL, `json` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`, `cacheKey`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cacheKey", + "columnName": "cacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "cacheKey" + ] + } + }, + { + "tableName": "download_deletions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `recordId` TEXT NOT NULL, `mediaFileId` INTEGER, `enqueuedAtMs` INTEGER NOT NULL, `loginId` TEXT, `origin` TEXT, `deviceId` TEXT, PRIMARY KEY(`serverId`, `profileId`, `recordId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "enqueuedAtMs", + "columnName": "enqueuedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loginId", + "columnName": "loginId", + "affinity": "TEXT" + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT" + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "recordId" + ] + } + }, + { + "tableName": "download_subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetType` TEXT NOT NULL, `targetId` TEXT NOT NULL, `displayTitle` TEXT NOT NULL, `mediaKind` TEXT NOT NULL, `quality` TEXT NOT NULL, `wifiOnly` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `includeExisting` INTEGER NOT NULL, `keepUnwatchedLimit` INTEGER NOT NULL, `deleteWatchedAfterDays` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastEvaluatedAt` INTEGER, `lastError` TEXT, PRIMARY KEY(`serverId`, `profileId`, `id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetType", + "columnName": "targetType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetId", + "columnName": "targetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaKind", + "columnName": "mediaKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifiOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeExisting", + "columnName": "includeExisting", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepUnwatchedLimit", + "columnName": "keepUnwatchedLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleteWatchedAfterDays", + "columnName": "deleteWatchedAfterDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEvaluatedAt", + "columnName": "lastEvaluatedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "id" + ] + }, + "indices": [ + { + "name": "index_download_subscriptions_serverId_profileId_enabled", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "enabled" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_enabled` ON `${TABLE_NAME}` (`serverId`, `profileId`, `enabled`)" + }, + { + "name": "index_download_subscriptions_serverId_profileId_targetType_targetId", + "unique": true, + "columnNames": [ + "serverId", + "profileId", + "targetType", + "targetId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_targetType_targetId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `targetType`, `targetId`)" + } + ] + }, + { + "tableName": "membership_projection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authority` TEXT NOT NULL, `itemId` TEXT NOT NULL, `kind` TEXT NOT NULL, `commandId` INTEGER NOT NULL, `present` INTEGER NOT NULL, `disposition` TEXT, PRIMARY KEY(`authority`, `itemId`, `kind`))", + "fields": [ + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "present", + "columnName": "present", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "authority", + "itemId", + "kind" + ] + } + }, + { + "tableName": "legacy_membership_quarantine", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`commandId` INTEGER NOT NULL, `originalState` TEXT NOT NULL, PRIMARY KEY(`commandId`))", + "fields": [ + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalState", + "columnName": "originalState", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "commandId" + ] + } + } + ], + "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, '86a5233fe50f7b265ded23af8525415e')" + ] + } +} \ No newline at end of file diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabase.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabase.kt index 6c900731d..beac58280 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabase.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabase.kt @@ -56,7 +56,7 @@ import org.prairieserver.prairie.common.data.db.entity.UserItemStateEntity MembershipProjectionEntity::class, LegacyMembershipQuarantineEntity::class, ], - version = 11, + version = 13, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -68,6 +68,8 @@ import org.prairieserver.prairie.common.data.db.entity.UserItemStateEntity AutoMigration(from = 7, to = 8), AutoMigration(from = 8, to = 9), AutoMigration(from = 10, to = 11), + AutoMigration(from = 11, to = 12), + AutoMigration(from = 12, to = 13), ], ) abstract class PrairieDatabase : RoomDatabase() { diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/entity/DownloadEntity.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/entity/DownloadEntity.kt index feb29d4e7..6086005da 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/entity/DownloadEntity.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/entity/DownloadEntity.kt @@ -79,4 +79,11 @@ data class DownloadEntity( /** Quality the server actually delivered after any compatibility fallback; * may differ from [quality]. Same offline-fallback reason as above. */ val effectiveQuality: String? = null, + /** Registry revision whose bytes this row describes. A local completion + * only speaks for this revision; null on rows written before v12. */ + val revision: Int? = null, + /** Serialized [org.prairieserver.prairie.model.download.OfflineTrackInfo]: the + * downloaded file's audio tracks and the local subtitle sidecars. Null for + * downloads completed before offline track data was captured. */ + val offlineTracksJson: String? = null, ) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadEnqueuer.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadEnqueuer.kt index f7044f9c6..ff67efbff 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadEnqueuer.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadEnqueuer.kt @@ -3,8 +3,11 @@ package org.prairieserver.prairie.common.downloads import android.content.Context import android.util.Log import androidx.work.WorkManager +import org.prairieserver.prairie.common.player.PlaybackCapabilityDetector import org.prairieserver.prairie.common.settings.PlayerSettingsStore +import org.prairieserver.prairie.common.settings.dolbyVisionPolicySnapshot import org.prairieserver.prairie.model.download.DownloadQuality +import org.prairieserver.prairie.model.download.effectiveDefault import org.prairieserver.prairie.model.download.DownloadMediaType import org.prairieserver.prairie.model.download.DownloadRequest import org.prairieserver.prairie.model.download.DownloadSidecar @@ -46,6 +49,8 @@ class DownloadEnqueuer( private val authorities: org.prairieserver.prairie.network.DurableLoginAuthorityProvider? = null, private val transitions: org.prairieserver.prairie.network.IdentityTransitionBarrier? = null, private val devices: org.prairieserver.prairie.network.DeviceMetadataProvider? = null, + /** Supplies the decode caps sent with each create; null sends none. */ + private val capabilityDetector: PlaybackCapabilityDetector? = null, ) { /** @@ -86,8 +91,9 @@ class DownloadEnqueuer( /** * Single-episode download. Resolves the episode's catalog row for rich - * sidecar metadata (series title, S/E numbers, still image). The - * `seriesContentId` is what gets associated on the server side; the + * sidecar metadata (series title, S/E numbers, still image). The server + * registers the entry under `seriesContentId` with `episodeContentId` as + * its episode, and rejects an episode sent as the content id; the * `fileId` chosen here is the user-preferred quality. */ suspend fun startEpisode( @@ -95,8 +101,8 @@ class DownloadEnqueuer( episodeContentId: String, fileId: Int, seriesTitle: String, - seasonNumber: Int, - episodeNumber: Int, + seasonNumber: Int?, + episodeNumber: Int?, episodeTitle: String?, posterUrl: String? = null, downloadQualityOverride: DownloadQuality? = null, @@ -108,11 +114,15 @@ class DownloadEnqueuer( Log.i(TAG, "startEpisode: fileId=$fileId already queued/downloading — skipping duplicate") return alreadyActive() } - val displayTitle = "$seriesTitle S${seasonNumber}E${episodeNumber}" + - (episodeTitle?.takeIf { it.isNotBlank() }?.let { " · $it" } ?: "") + // Unknown numbers leave the S/E label out rather than print "S0E0". + val episodeCode = if (seasonNumber != null && episodeNumber != null) "S${seasonNumber}E${episodeNumber}" else null + val shownEpisodeTitle = episodeTitle?.takeIf { it.isNotBlank() } + val displayTitle = listOfNotNull(seriesTitle, episodeCode).joinToString(" ") + + (shownEpisodeTitle?.let { " · $it" } ?: "") val record = when (val r = repository.create( downloadRequest( - contentId = episodeContentId, + contentId = seriesContentId, + episodeId = episodeContentId, fileId = fileId, downloadQualityOverride = downloadQualityOverride, ), @@ -127,7 +137,7 @@ class DownloadEnqueuer( val sidecar = DownloadSidecar( record = record, title = seriesTitle, - subtitle = "S${seasonNumber}E${episodeNumber}" + (episodeTitle?.takeIf { it.isNotBlank() }?.let { " · $it" } ?: ""), + subtitle = listOfNotNull(episodeCode, shownEpisodeTitle).joinToString(" · ").ifEmpty { null }, posterUrl = posterUrl, seriesTitle = seriesTitle, seriesContentId = seriesContentId, @@ -373,7 +383,10 @@ class DownloadEnqueuer( series: Boolean = false, downloadQualityOverride: DownloadQuality? = null, ): DownloadRequest { - val quality = downloadQualityOverride ?: DownloadQuality.fromWire(playerSettingsStore.defaultDownloadQualityFlow.first()) + val quality = downloadQualityOverride + ?: repository.capability.value.effectiveDefault( + DownloadQuality.fromWire(playerSettingsStore.defaultDownloadQualityFlow.first()), + ) return DownloadRequest( contentId = contentId, episodeId = episodeId, @@ -381,6 +394,15 @@ class DownloadEnqueuer( series = series, quality = quality.wire, targetBitrateKbps = quality.targetBitrateKbps, + // A failed probe must not block the download; the server then + // treats the device as able to play the original. + caps = capabilityDetector?.let { detector -> + withContext(Dispatchers.Default) { + runCatching { detector.downloadCaps(dolbyVision = playerSettingsStore.dolbyVisionPolicySnapshot()) } + .onFailure { Log.w(TAG, "download caps probe failed", it) } + .getOrNull() + } + }, ) } @@ -422,6 +444,7 @@ class DownloadEnqueuer( deviceId = deviceId, loginId = authority?.loginId, origin = authority?.scope?.serverUrl, + revision = record.revision, ) } if (authority == null) persistAndEnqueue() diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarMapping.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarMapping.kt index fe26f5969..e314f755a 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarMapping.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarMapping.kt @@ -4,15 +4,22 @@ import org.prairieserver.prairie.common.data.db.entity.DownloadEntity import org.prairieserver.prairie.model.catalog.VersionChapter import org.prairieserver.prairie.model.download.DownloadRecord import org.prairieserver.prairie.model.download.DownloadSidecar +import org.prairieserver.prairie.model.download.OfflineTrackInfo import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json /** * Maps between the on-the-wire-ish [DownloadSidecar] (the download's full local * picture) and the Room [DownloadEntity] projection. Chapters serialize to a JSON - * column (no TypeConverter). `episodeId`/`batchId`/`deliveryFormat`/ - * `targetBitrateKbps` on [DownloadRecord] have no entity column and aren't read - * anywhere in the download/playback paths, so they round-trip as null. + * column (no TypeConverter). `batchId`/`deliveryFormat`/`targetBitrateKbps` on + * [DownloadRecord] have no entity column and aren't read anywhere in the + * download/playback paths, so they round-trip as null. + * + * The server keys an episode's entry by its series (`contentId`) plus + * `episodeId`, while every local reader (offline playback, the Downloads list, + * watched state) looks a download up by the item it plays. The entity's + * `contentId` is therefore the episode for episodes; the series stays in + * `seriesContentId`. */ private val mappingJson = Json { ignoreUnknownKeys = true } @@ -22,7 +29,7 @@ fun DownloadSidecar.toEntity(serverId: String, profileId: String): DownloadEntit profileId = profileId, mediaFileId = record.mediaFileId, recordId = record.id, - contentId = record.contentId, + contentId = record.episodeId?.takeIf { it.isNotBlank() } ?: record.contentId, title = title, subtitle = subtitle, posterUrl = posterUrl, @@ -51,6 +58,8 @@ fun DownloadSidecar.toEntity(serverId: String, profileId: String): DownloadEntit updatedAtMs = updatedAtMs, quality = record.quality, effectiveQuality = record.effectiveQuality, + revision = record.revision, + offlineTracksJson = offlineTracks?.let { mappingJson.encodeToString(it) }, ) fun DownloadEntity.toSidecar(): DownloadSidecar = @@ -69,6 +78,7 @@ fun DownloadEntity.toSidecar(): DownloadSidecar = completedAt = completedAt, quality = quality, effectiveQuality = effectiveQuality, + revision = revision, ), title = title, subtitle = subtitle, @@ -89,5 +99,8 @@ fun DownloadEntity.toSidecar(): DownloadSidecar = durationSeconds = durationSeconds, chapters = chaptersJson?.let { runCatching { mappingJson.decodeFromString>(it) }.getOrNull() }, resumeValidator = resumeValidator, + offlineTracks = offlineTracksJson?.let { + runCatching { mappingJson.decodeFromString(it) }.getOrNull() + }, updatedAtMs = updatedAtMs, ) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadStatusWorker.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadStatusWorker.kt new file mode 100644 index 000000000..a8fc32d31 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadStatusWorker.kt @@ -0,0 +1,169 @@ +package org.prairieserver.prairie.common.downloads + +import android.content.Context +import android.util.Log +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import org.prairieserver.prairie.model.download.DownloadRecord +import org.prairieserver.prairie.model.download.DownloadStatusEvent +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.DeviceMetadataProvider +import org.prairieserver.prairie.network.DurableLoginAuthorityProvider +import org.prairieserver.prairie.repository.DownloadsRepository +import java.util.concurrent.TimeUnit + +/** + * Tells the server's managed registry what this device holds for one download + * (`PATCH /api/v2/downloads/{id}`: `downloading` when bytes start, `completed` + * once the file is published). The file route never marks completion itself, + * so without this report the server row stays `ready`. + * + * Runs as its own network-constrained unique job per download, separate from + * the transfer, so an event recorded offline or cut short by process death is + * sent once the device is back online. A newer event replaces a pending older + * one; a retry resends the same event, which the server acknowledges without + * changing the entry. + */ +class DownloadStatusWorker( + appContext: Context, + params: WorkerParameters, + private val repository: DownloadsRepository, + private val authorities: DurableLoginAuthorityProvider, + private val devices: DeviceMetadataProvider, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + val downloadId = inputData.getString(KEY_DOWNLOAD_ID) ?: return Result.failure() + val event = DownloadStatusEvent( + status = inputData.getString(KEY_STATUS) ?: return Result.failure(), + updatedAt = inputData.getString(KEY_UPDATED_AT) ?: return Result.failure(), + revision = inputData.getInt(KEY_REVISION, 0), + ) + val serverId = inputData.getString(KEY_SERVER_ID) ?: return Result.failure() + val profileId = inputData.getString(KEY_PROFILE_ID) ?: return Result.failure() + if (runAttemptCount >= MAX_ATTEMPTS) { + Log.w(TAG, "status report gave up id=$downloadId status=${event.status} after $runAttemptCount attempts") + return Result.failure() + } + // Only the owner the download ran for can report it. Another login or + // profile may be active for now; wait for it to come back. + val authority = authorities.snapshotDurableLoginAuthority() ?: return Result.retry() + if (!downloadWorkMatchesOwner(inputData.getString(KEY_LOGIN_ID), inputData.getString(KEY_ORIGIN), + inputData.getString(KEY_DEVICE_ID), serverId, profileId, authority, devices.current()?.id)) return Result.retry() + + val result = repository.reportStatus(downloadId, event, authority) + return when (downloadStatusReportOutcome(result)) { + DownloadStatusReportOutcome.Settled -> { + Log.i(TAG, "status report settled id=$downloadId status=${event.status} answer=${result.describe()}") + Result.success() + } + DownloadStatusReportOutcome.Reconcile -> { + // The entry's revision moved on, so this event describes replaced + // bytes. Drop it and read the registry instead. + Log.i(TAG, "status report superseded id=$downloadId revision=${event.revision}") + repository.refresh() + Result.success() + } + DownloadStatusReportOutcome.RetryLater -> { + Log.i(TAG, "status report retry id=$downloadId status=${event.status} answer=${result.describe()}") + Result.retry() + } + } + } + + private fun ApiResult<*>.describe(): String = when (this) { + is ApiResult.Success -> "ok" + is ApiResult.Error -> "$code ${error.ifBlank { message }}" + is ApiResult.NetworkError -> "network" + } + + companion object { + private const val TAG = "DownloadStatusWorker" + const val KEY_DOWNLOAD_ID = "download_id" + const val KEY_STATUS = "status" + const val KEY_UPDATED_AT = "updated_at" + const val KEY_REVISION = "revision" + const val KEY_SERVER_ID = "server_id" + const val KEY_PROFILE_ID = "profile_id" + const val KEY_DEVICE_ID = "device_id" + const val KEY_LOGIN_ID = "login_id" + const val KEY_ORIGIN = "origin" + + /** Exponential from [BACKOFF_SECONDS] (WorkManager caps each wait at + * 5 hours), so this spans a few days before the report is dropped. */ + private const val MAX_ATTEMPTS = 20 + private const val BACKOFF_SECONDS = 30L + + fun uniqueName(downloadId: String): String = "download_status_$downloadId" + + /** Queue [event] for [downloadId], replacing a report still waiting to go out. */ + fun enqueue( + context: Context, + downloadId: String, + event: DownloadStatusEvent, + serverId: String, + profileId: String, + deviceId: String?, + loginId: String?, + origin: String?, + ) { + val request = OneTimeWorkRequestBuilder() + .setInputData( + workDataOf( + KEY_DOWNLOAD_ID to downloadId, + KEY_STATUS to event.status, + KEY_UPDATED_AT to event.updatedAt, + KEY_REVISION to event.revision, + KEY_SERVER_ID to serverId, + KEY_PROFILE_ID to profileId, + KEY_DEVICE_ID to deviceId, + KEY_LOGIN_ID to loginId, + KEY_ORIGIN to origin, + ), + ) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS) + .build() + Log.i(TAG, "enqueue id=$downloadId status=${event.status} revision=${event.revision}") + WorkManager.getInstance(context) + .enqueueUniqueWork(uniqueName(downloadId), ExistingWorkPolicy.REPLACE, request) + } + + /** Drop a report still waiting to go out: the transfer it describes was + * cancelled or failed, so the server row must not move to `downloading`. */ + fun cancel(context: Context, downloadId: String) { + WorkManager.getInstance(context).cancelUniqueWork(uniqueName(downloadId)) + } + } +} + +internal enum class DownloadStatusReportOutcome { Settled, Reconcile, RetryLater } + +/** + * What the server's answer means for a queued status event. + * + * - `409`: the revision changed; the event describes replaced bytes. + * - `400`/`422`: most often the event time is ahead of the server's clock + * (future times are rejected), which a later retry of the same event fixes. + * - `404`/`403` and other client errors: the entry is gone, not reportable + * (preparing, failed, revoked), or downloads are off; resending cannot help. + * - Profile PIN verification, auth, throttling, server errors, network loss and identity changes are transient. + */ +internal fun downloadStatusReportOutcome(result: ApiResult): DownloadStatusReportOutcome = when (result) { + is ApiResult.Success -> DownloadStatusReportOutcome.Settled + is ApiResult.NetworkError -> DownloadStatusReportOutcome.RetryLater + is ApiResult.Error -> when (result.code) { + 409 -> DownloadStatusReportOutcome.Reconcile + 403 -> if (result.error == "profile_verification_required") DownloadStatusReportOutcome.RetryLater else DownloadStatusReportOutcome.Settled + 0, 400, 401, 408, 422, 429 -> DownloadStatusReportOutcome.RetryLater + in 500..599 -> DownloadStatusReportOutcome.RetryLater + else -> DownloadStatusReportOutcome.Settled + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadStorage.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadStorage.kt index 1555d7697..6e0cf940f 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadStorage.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadStorage.kt @@ -106,8 +106,10 @@ class DownloadStorage( * directories are left in place; cleanup at higher granularity goes * through [deleteAllForProfile] or [deleteAllForServer]. */ - fun delete(serverId: String, profileId: String, fileId: Int): Boolean = - publicStore.delete(serverId, profileId, fileId, uriString = null) + fun delete(serverId: String, profileId: String, fileId: Int): Boolean { + containedSafeChild(offlineAssetsRoot, serverId, profileId, fileId.toString())?.deleteRecursively() + return publicStore.delete(serverId, profileId, fileId, uriString = null) + } fun completeWrite(uriString: String): String = publicStore.complete(uriString) @@ -133,12 +135,27 @@ class DownloadStorage( /** Wipes every downloaded byte under (serverId, profileId). Used on sign-out * and profile switch. Metadata rows are cleared via [DownloadMetadataStore]. */ - fun deleteAllForProfile(serverId: String, profileId: String): Boolean = - publicStore.deleteAllForProfile(serverId, profileId) + fun deleteAllForProfile(serverId: String, profileId: String): Boolean { + containedSafeChild(offlineAssetsRoot, serverId, profileId)?.deleteRecursively() + return publicStore.deleteAllForProfile(serverId, profileId) + } /** Wipes every downloaded byte under (serverId). Used on server delete / re-bind. */ - fun deleteAllForServer(serverId: String): Boolean = - publicStore.deleteAllForServer(serverId) + fun deleteAllForServer(serverId: String): Boolean { + containedSafeChild(offlineAssetsRoot, serverId)?.deleteRecursively() + return publicStore.deleteAllForServer(serverId) + } + + /** + * Private directory for the offline subtitle sidecars of one download. + * Kept in app-internal storage rather than beside the (public, possibly + * MediaStore-owned) media file: they are only meaningful to this app, and + * a scoped directory is removed with the download by [delete]. + */ + fun offlineSubtitleDirectory(serverId: String, profileId: String, fileId: Int): File? = + containedSafeChild(offlineAssetsRoot, serverId, profileId, fileId.toString(), SUBTITLES_DIR) + + private val offlineAssetsRoot: File get() = File(baseDir, OFFLINE_ASSETS_DIR) /** Sum of bytes across every downloaded file under this storage. */ fun totalBytesUsed(): Long = publicStore.totalBytesUsed() @@ -170,6 +187,10 @@ class DownloadStorage( private fun sanitizeBasename(value: String): String = value.replace(Regex("[\\\\/:*?\"<>|]"), "_") + private companion object { + const val OFFLINE_ASSETS_DIR = "download-assets" + const val SUBTITLES_DIR = "subtitles" + } } data class DownloadTarget( diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt index ead0e5dcb..f27eb4327 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt @@ -21,6 +21,8 @@ import androidx.work.WorkManager import androidx.work.workDataOf import org.prairieserver.prairie.model.download.DownloadStatus import org.prairieserver.prairie.model.download.DownloadRecord +import org.prairieserver.prairie.model.download.DownloadStatusEvent +import org.prairieserver.prairie.model.download.statusEnum import org.prairieserver.prairie.repository.DownloadsRepository import org.prairieserver.prairie.network.* import org.prairieserver.prairie.network.apiv2.managedDownloadAuth @@ -43,6 +45,7 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import java.io.IOException import java.net.URLDecoder +import java.time.Instant import java.util.concurrent.TimeUnit internal fun DownloadRecord.withWorkerStatus( @@ -89,6 +92,7 @@ class DownloadWorker( ) : CoroutineWorker(appContext, params) { private var transferAuthority: DurableLoginAuthority? = null + private val offlineTrackFetcher = OfflineTrackAssetFetcher(httpClient, storage) private suspend fun requireOwner() { if (authorities != null && (transferAuthority == null || transferAuthority != authorities.snapshotDurableLoginAuthority() || inputData.getString(KEY_DEVICE_ID) != devices?.current()?.id)) throw DownloadOwnerChanged() } @@ -141,20 +145,75 @@ class DownloadWorker( }.onFailure { Log.w(TAG, "setForeground initial failed", it) } var activeUri: String? = null - - // Resume state (survives process death + WorkManager retries): the partial's - // uri + the validator captured at download start. Resume offset is the REAL - // on-disk size (fd stat), never the metadata SIZE column (stale while pending). - val existing = runCatching { metadataStore.readSidecar(serverId, profileId, fileId) }.getOrNull() - val resumeUri = existing?.localUri - val resumeFrom = resumeUri?.let { storage.partialSize(it) } ?: 0L - val resumeValidator = existing?.resumeValidator - // Resume ONLY with a validator: an unvalidated Range append would silently - // corrupt the file if the source changed (the server can't tell us). No - // validator → behave as a fresh download (no Range header). - val canResume = resumeFrom > 0 && resumeUri != null && !resumeValidator.isNullOrBlank() + // Capture can outlive media publication. A stop then preserves the + // completed file and its queued completion report. + var mediaPublished = false try { + var existing = runCatching { metadataStore.readSidecar(serverId, profileId, fileId) }.getOrNull() + // A stopped capture resumes from the completed local file, including + // legacy rows whose original transfer revision is unknown. + if (existing != null && existing.record.id == downloadId && + existing.record.statusEnum() == DownloadStatus.Completed && + storage.locateLocalMedia(serverId, profileId, fileId) != null + ) { + mediaPublished = true + requireOwner() + // Publication and status enqueue are separate durable writes. + // Recover a stop between them using the revision of these bytes. + reportStatus( + downloadId, DownloadStatus.Completed, + maxOf(System.currentTimeMillis(), existing.updatedAtMs + 1), + existing.record.revision?.takeIf { it > 0 }, serverId, profileId, + ) + if (existing.offlineTracks == null) { + captureOfflineTracks(downloadId, serverId, profileId, fileId, mediaType) + } + return@withContext Result.success() + } + // Old WorkManager jobs have no revision input. Resolve it as their owner + // and save it in Room so another attempt does not depend on a UI cache. + val revision = inputData.getInt(KEY_REVISION, 0).takeIf { it > 0 } + ?: existing?.record?.takeIf { it.id == downloadId }?.revision?.takeIf { it > 0 } + ?: when (val result = repository.resolveTransferRecord(downloadId, fileId, transferAuthority)) { + is ApiResult.Success -> { + val row = result.data + val sidecar = existing?.takeIf { it.record.id == downloadId } + ?: return@withContext Result.failure() + // Unversioned partial bytes cannot prove which registry target + // they came from. Start fresh before adopting this revision. + val restarted = sidecar.copy( + record = row.withWorkerStatus( + if (row.statusEnum() == DownloadStatus.Preparing) row.status else DownloadStatus.Ready.wire, + bytesSent = 0, + ), + localUri = null, + resumeValidator = null, + ) + ownedWrite { + storage.delete(serverId, profileId, fileId) + metadataStore.writeSidecar(serverId, profileId, restarted) + true + } + existing = restarted + checkNotNull(row.revision) + } + is ApiResult.NetworkError -> return@withContext Result.retry() + is ApiResult.Error -> return@withContext if (downloadStatusReportOutcome(result) == DownloadStatusReportOutcome.RetryLater) Result.retry() else Result.failure() + } + var transferStartedAtMs = 0L + // Resume state (survives process death + WorkManager retries): the partial's + // uri + the validator captured at download start. Resume offset is the REAL + // on-disk size (fd stat), never the metadata SIZE column (stale while pending). + val resumeUri = existing?.localUri + val resumeFrom = resumeUri?.let { storage.partialSize(it) } ?: 0L + val resumeValidator = existing?.resumeValidator + // Resume ONLY with a validator: an unvalidated Range append would silently + // corrupt the file if the source changed (the server can't tell us). No + // validator → behave as a fresh download (no Range header). + val canResume = existing?.record?.let { it.id == downloadId && it.revision == revision } == true && + resumeFrom > 0 && resumeUri != null && !resumeValidator.isNullOrBlank() + requireOwner() httpClient.prepareGet("/api/v2/downloads/${downloadId.encodeURLPathPart()}/file") { transferAuthority?.let { managedDownloadAuth(it.scope) } @@ -226,6 +285,9 @@ class DownloadWorker( out = fresh.openOutputStream() } + transferStartedAtMs = System.currentTimeMillis() + reportStatus(downloadId, DownloadStatus.Downloading, transferStartedAtMs, revision, serverId, profileId) + val channel = response.bodyAsChannel() val throttle = DownloadProgressThrottle() @@ -277,9 +339,6 @@ class DownloadWorker( } } - // Server flips status → completed when its serve handler returns; - // a refresh here ensures the cache reflects that before the - // worker exits and the UI re-renders. val pendingUri = activeUri ?: error("download target was not created") val finalBytes = storage.partialSize(pendingUri) // Publish the file and flip the sidecar to completed under one @@ -297,14 +356,32 @@ class DownloadWorker( fileSize = finalBytes, localUri = finalUri, resumeValidator = "", + revision = revision, ) true } + mediaPublished = true Log.i(TAG, "doWork success id=$downloadId bytes=$finalBytes") DiagnosticsDownloadLogger.event("download completed") - // Server flips status → completed when its serve handler returns; - // refresh so the cache reflects that before the UI re-renders. - repository.refresh() + // Strictly after the `downloading` event: the server ignores an event + // that is not newer than the one it holds. + val completedAtMs = maxOf(System.currentTimeMillis(), transferStartedAtMs + 1) + // Only while the cached row still describes the bytes just fetched: a + // refresh may already hold a replacement revision, which stays as-is. + if (uiPushAllowed(serverId, profileId)) { + repository.recordForFile(fileId) + ?.takeIf { it.id == downloadId && (it.revision == null || it.revision == revision) } + ?.let { existing -> + repository.upsertLocal( + existing.withWorkerStatus(DownloadStatus.Completed.wire, bytesSent = finalBytes, fileSize = finalBytes) + .copy(completedAt = existing.completedAt ?: Instant.ofEpochMilli(completedAtMs).toString(), + revision = revision), + ) + } + } + // The v2 file route never marks the entry completed; this report does. + reportStatus(downloadId, DownloadStatus.Completed, completedAtMs, revision, serverId, profileId) + captureOfflineTracks(downloadId, serverId, profileId, fileId, mediaType) Result.success(workDataOf(KEY_BYTES_WRITTEN to finalBytes, KEY_TOTAL_BYTES to finalBytes)) } catch (e: DownloadOwnerChanged) { // Preserve the original owner's partial; a new login cannot resume it. @@ -321,7 +398,10 @@ class DownloadWorker( // downloads with a red badge and delete-then-fail them. Log.i(TAG, "doWork cancelled id=$downloadId") DiagnosticsDownloadLogger.event("download cancelled") - withContext(NonCancellable) { + if (!mediaPublished) withContext(NonCancellable) { + // WorkManager's notification action cancels this transfer directly. + // Its separate status job must stop before we remove the bytes. + runCatching { DownloadStatusWorker.cancel(appContext, downloadId) } // Delete by scope+fileId (not just activeUri): a cancel before the // response is classified leaves activeUri null but a prior attempt's // partial may still be on disk. @@ -373,6 +453,67 @@ class DownloadWorker( } } + /** + * Queue a status event for the server registry ([DownloadStatusWorker]). + * Skipped without a captured owner or a known revision: the server would + * refuse the event, and there is no owner to send it as. + */ + private fun reportStatus(downloadId: String, status: DownloadStatus, atMs: Long, revision: Int?, serverId: String, profileId: String) { + if (transferAuthority == null || revision == null) { + Log.w(TAG, "status report skipped id=$downloadId status=${status.wire} revision=$revision") + return + } + runCatching { + DownloadStatusWorker.enqueue( + context = appContext, + downloadId = downloadId, + event = DownloadStatusEvent(status.wire, Instant.ofEpochMilli(atMs).toString(), revision), + serverId = serverId, + profileId = profileId, + deviceId = inputData.getString(KEY_DEVICE_ID), + loginId = inputData.getString(KEY_LOGIN_ID), + origin = inputData.getString(KEY_ORIGIN), + ) + }.onFailure { Log.w(TAG, "status report enqueue failed id=$downloadId status=${status.wire}", it) } + } + + /** + * Fetches the offline manifest's audio tracks and subtitle sidecars for a + * published video download and stores them on its metadata row. Best + * effort: every failure leaves the download playable the legacy way. + */ + private suspend fun captureOfflineTracks( + downloadId: String, + serverId: String, + profileId: String, + fileId: Int, + mediaType: String?, + ) { + if (!OfflineTrackAssetFetcher.appliesTo(mediaType)) return + try { + requireOwner() + val tracks = offlineTrackFetcher.fetch(downloadId, serverId, profileId, fileId) { + transferAuthority?.let { managedDownloadAuth(it.scope) } + } ?: return + ownedWrite { + val existing = metadataStore.readSidecar(serverId, profileId, fileId) + ?.takeIf { it.record.id == downloadId } + if (existing != null) { + metadataStore.writeSidecar( + serverId, profileId, + existing.copy(offlineTracks = tracks, updatedAtMs = System.currentTimeMillis()), + ) + } + true + } + Log.i(TAG, "offline tracks captured id=$downloadId audio=${tracks.audioTracks.size} subtitles=${tracks.subtitles.size}") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "offline track capture skipped id=$downloadId", e) + } + } + /** Permanent failure — clean up local file and let the user retry manually. */ private suspend fun failPermanently( e: Throwable, @@ -387,6 +528,7 @@ class DownloadWorker( // Delete by scope+fileId so a partial from any attempt is cleaned up // even if this attempt failed before activeUri was assigned. runCatching { ownedWrite { storage.delete(serverId, profileId, fileId); true } } + runCatching { DownloadStatusWorker.cancel(appContext, downloadId) } // Best-effort: publish failed state into the repo + sidecar. val record = if (uiPushAllowed(serverId, profileId)) repository.recordForFile(fileId) else null if (record != null) { @@ -440,6 +582,7 @@ class DownloadWorker( localUri: String? = null, fileName: String? = null, resumeValidator: String? = null, + revision: Int? = null, ) { val existing = metadataStore.readSidecar(serverId, profileId, fileId) ?: return metadataStore.writeSidecar( @@ -449,7 +592,7 @@ class DownloadWorker( status = status, bytesSent = bytesSent, fileSize = fileSize, - ), + ).copy(revision = revision ?: existing.record.revision), localUri = localUri ?: existing.localUri, fileName = fileName?.takeIf { it.isNotBlank() } ?: existing.fileName, resumeValidator = when { @@ -522,6 +665,7 @@ class DownloadWorker( const val KEY_CONTAINER = "container" const val KEY_MEDIA_TYPE = "media_type" const val KEY_DISPLAY_TITLE = "display_title" + const val KEY_REVISION = "revision" const val KEY_BYTES_WRITTEN = "bytes" const val KEY_TOTAL_BYTES = "total" @@ -567,8 +711,10 @@ class DownloadWorker( deviceId: String? = null, loginId: String? = null, origin: String? = null, + revision: Int? = null, ) { val data = workDataOf( + KEY_REVISION to (revision ?: 0), KEY_DEVICE_ID to deviceId, KEY_LOGIN_ID to loginId, KEY_ORIGIN to origin, @@ -602,6 +748,7 @@ class DownloadWorker( fun cancel(context: Context, downloadId: String) { DownloadWorkerLifetime.beginCancellation(downloadId) WorkManager.getInstance(context).cancelAllWorkByTag(tagFor(downloadId)) + DownloadStatusWorker.cancel(context, downloadId) } /** @@ -611,6 +758,7 @@ class DownloadWorker( */ suspend fun cancelAndAwait(context: Context, downloadId: String) { DownloadWorkerLifetime.beginCancellation(downloadId) + DownloadStatusWorker.cancel(context, downloadId) withContext(Dispatchers.IO) { WorkManager.getInstance(context) .cancelUniqueWork(tagFor(downloadId)) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/OfflineTrackAssetFetcher.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/OfflineTrackAssetFetcher.kt new file mode 100644 index 000000000..5d14742d5 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/OfflineTrackAssetFetcher.kt @@ -0,0 +1,176 @@ +package org.prairieserver.prairie.common.downloads + +import android.util.Log +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpTimeoutConfig +import io.ktor.client.plugins.timeout +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.get +import io.ktor.client.request.prepareGet +import io.ktor.client.statement.bodyAsChannel +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.http.encodeURLPathPart +import io.ktor.utils.io.jvm.javaio.toInputStream +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import org.prairieserver.prairie.model.download.DownloadMediaType +import org.prairieserver.prairie.model.download.OfflineManifestTracks +import org.prairieserver.prairie.model.download.OfflineManifestSubtitle +import org.prairieserver.prairie.model.download.OfflineSubtitleFile +import org.prairieserver.prairie.model.download.OfflineTrackInfo +import org.prairieserver.prairie.model.download.decodeOfflineManifestTracks +import org.prairieserver.prairie.model.download.isOfflineSubtitleFetchUrl +import org.prairieserver.prairie.model.download.offlineSubtitleExtension +import org.prairieserver.prairie.model.download.offlineSubtitleFormat +import org.prairieserver.prairie.model.download.toOfflineTrackInfo +import org.prairieserver.prairie.playback.orNullIfBlank +import java.io.File +import java.io.IOException + +/** + * Captures what offline video playback needs once the media bytes are down: + * the offline manifest's audio tracks (positions inside the delivered file) and + * the subtitle sidecars it lists, each fetched once into private storage. + * + * Everything here is best effort. A missing manifest, an older server, or a + * sidecar that fails to fetch never fails the video download; the download + * then plays with whatever was captured (or the legacy offline behaviour). + */ +internal class OfflineTrackAssetFetcher( + private val httpClient: HttpClient, + private val storage: DownloadStorage, + private val manifestRetryDelayMs: Long = 2_000, +) { + suspend fun fetch( + downloadId: String, + serverId: String, + profileId: String, + fileId: Int, + configure: HttpRequestBuilder.() -> Unit, + ): OfflineTrackInfo? { + val manifest = fetchManifest(downloadId, configure) ?: return null + + val directory = storage.offlineSubtitleDirectory(serverId, profileId, fileId) + // A replaced download (new revision) must not keep the previous + // revision's sidecars around under the same file slot. + directory?.deleteRecursively() + val saved = if (directory == null || manifest.subtitles.isEmpty()) { + emptyList() + } else { + manifest.subtitles.mapIndexedNotNull { ordinal, subtitle -> + fetchSubtitle(downloadId, ordinal, subtitle, directory, configure) + } + } + return manifest.toOfflineTrackInfo(saved) + } + + /** + * The capture runs once, right after the download completes, so a network + * blip or a transient server error is retried briefly here; a missing + * manifest (older server) is not. + */ + private suspend fun fetchManifest( + downloadId: String, + configure: HttpRequestBuilder.() -> Unit, + ): OfflineManifestTracks? { + repeat(MANIFEST_ATTEMPTS) { attempt -> + if (attempt > 0) delay(manifestRetryDelayMs * attempt) + try { + val response = httpClient.get("/api/v2/downloads/${downloadId.encodeURLPathPart()}/manifest") { + configure() + } + val status = response.status + if (status == HttpStatusCode.OK) return decodeOfflineManifestTracks(response.bodyAsText()) + Log.i(TAG, "manifest unavailable id=$downloadId status=${status.value}") + if (status.value < 500 && status != HttpStatusCode.TooManyRequests) return null + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "manifest fetch failed id=$downloadId attempt=${attempt + 1}", e) + } + } + return null + } + + private suspend fun fetchSubtitle( + downloadId: String, + ordinal: Int, + subtitle: OfflineManifestSubtitle, + directory: File, + configure: HttpRequestBuilder.() -> Unit, + ): OfflineSubtitleFile? { + val format = offlineSubtitleFormat(subtitle.format) ?: return null + if (!isOfflineSubtitleFetchUrl(subtitle.fetchUrl)) { + Log.w(TAG, "skipping subtitle with unexpected reference id=$downloadId ordinal=$ordinal") + return null + } + val target = File(directory, "$ordinal.${offlineSubtitleExtension(format)}") + val partial = File(directory, "$ordinal.part") + return try { + if (!directory.isDirectory && !directory.mkdirs()) throw IOException("could not create $directory") + httpClient.prepareGet(subtitle.fetchUrl.trim()) { + configure() + // Embedded ASS/PGS sidecars are extracted from the source on + // demand, so the first byte can take a while; keep only an idle + // timeout, like the media transfer itself. + timeout { + requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS + socketTimeoutMillis = SUBTITLE_IDLE_TIMEOUT_MS + } + }.execute { response -> + if (response.status != HttpStatusCode.OK) { + throw IOException("HTTP ${response.status.value}") + } + var written = 0L + response.bodyAsChannel().toInputStream().use { input -> + partial.outputStream().use { output -> + val buffer = ByteArray(BUFFER_BYTES) + while (true) { + val read = input.read(buffer) + if (read < 0) break + written += read + if (written > MAX_SUBTITLE_BYTES) throw IOException("subtitle exceeds $MAX_SUBTITLE_BYTES bytes") + output.write(buffer, 0, read) + } + } + } + if (written == 0L) throw IOException("empty subtitle") + } + if (!partial.renameTo(target)) throw IOException("could not publish $target") + OfflineSubtitleFile( + path = target.absolutePath, + format = format, + language = subtitle.language.orNullIfBlank(), + title = subtitle.title.orNullIfBlank(), + forced = subtitle.forced, + hearingImpaired = subtitle.hearingImpaired, + ) + } catch (e: CancellationException) { + partial.delete() + throw e + } catch (e: Exception) { + Log.w(TAG, "subtitle fetch failed id=$downloadId ordinal=$ordinal format=$format", e) + partial.delete() + target.delete() + null + } + } + + companion object { + private const val TAG = "OfflineTrackAssets" + private const val MANIFEST_ATTEMPTS = 3 + private const val BUFFER_BYTES = 64 * 1024 + private const val SUBTITLE_IDLE_TIMEOUT_MS = 120_000L + + /** PGS tracks for a feature run to tens of MB; text is far smaller. */ + private const val MAX_SUBTITLE_BYTES = 256L * 1024 * 1024 + + /** Only video downloads have tracks to capture. */ + fun appliesTo(mediaType: String?): Boolean = + when (DownloadMediaType.fromWire(mediaType)) { + DownloadMediaType.Movie, DownloadMediaType.TvShow, DownloadMediaType.Unknown -> true + DownloadMediaType.Audiobook, DownloadMediaType.Ebook -> false + } + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetector.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetector.kt index 5eaa6d491..bb793a5f9 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetector.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetector.kt @@ -15,6 +15,7 @@ import androidx.media3.common.util.UnstableApi import org.prairieserver.prairie.common.network.PrairieClientBuildIdentity import org.prairieserver.prairie.player.DolbyVisionPolicy import org.prairieserver.prairie.common.player.video.media3OriginalPlaybackContainers +import org.prairieserver.prairie.model.download.DownloadCaps import org.prairieserver.prairie.model.playback.ClientPlaybackContext import org.prairieserver.prairie.model.playback.ClientCodecCapabilities import org.prairieserver.prairie.model.playback.CAPABILITY_EVIDENCE_EXACT @@ -404,6 +405,33 @@ class PlaybackCapabilityDetector( return detected } + /** + * Decode-only capability for `POST /api/v2/downloads`. A download plays + * later on whatever output is attached then, so this omits the display + * and audio-route facts [detect] intersects in: the codec, container and + * resolution facts come from the same probes, and HDR is the decoder's own + * support after the same Dolby Vision setting [detect] applies. + */ + fun downloadCaps( + ffmpegAvailable: Boolean = FfmpegAudioSupport.isAvailable(), + dolbyVision: DolbyVisionPolicy.Snapshot = DolbyVisionPolicy.Snapshot(), + ): DownloadCaps { + val codecProbe = MediaCodecCapabilitiesProbe.probe() + val decoderHdr = codecProbe.hdr.withDolbyVisionPolicy(dolbyVision) + return DownloadCaps( + videoEvidence = CAPABILITY_EVIDENCE_EXACT, + codecsVideo = codecProbe.videoCodecs.toList(), + codecsAudio = advertisedAudioDecodeCodecs( + platformCodecs = detectPlatformSoftwareAudioCodecs().codecs, + ffmpegCodecs = if (ffmpegAvailable) FfmpegAudioSupport.supportedCodecShortCodes() else emptyList(), + ), + containers = media3OriginalPlaybackContainers, + maxResolution = codecProbe.maxResolution, + hdr = decoderHdr.hdr10 || decoderHdr.hdr10Plus || decoderHdr.hlg || decoderHdr.dolbyVisionProfiles.isNotEmpty(), + videoDecode = codecProbe.videoDecodeCapabilities, + ) + } + /** * The form factor implied by the current UI mode, for callers that live in * `android-shared` and so cannot see either app's `BuildConfig`. The app diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt index 051dac743..6a47168a1 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt @@ -1712,9 +1712,17 @@ fun isSubtitleSelected(tracks: Tracks, identity: SubtitleIdentity): Boolean { ?.isTrackSelected(selection.trackIndex) == true } +/** + * Every text track in a snapshot that this device can render, in snapshot + * order, described the way subtitle resolution sees it. + */ +fun renderableMountedTextTracks(tracks: Tracks): List = + textTrackCandidates(tracks).filter(TextTrackCandidate::supported).map(TextTrackCandidate::track) + private data class TextTrackCandidate( val selection: SubtitleSelection, val track: MountedSubtitleTrack, + val supported: Boolean, ) private fun textTrackCandidates(tracks: Tracks): List { @@ -1735,6 +1743,7 @@ private fun textTrackCandidates(tracks: Tracks): List { forced = format.selectionFlags and C.SELECTION_FLAG_FORCED != 0, hearingImpaired = format.isHearingImpairedSubtitle(), ), + supported = group.isTrackSupported(trackIndex), ) flatIndex++ } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleMountResolver.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleMountResolver.kt index 5e896f72f..e9d8d696c 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleMountResolver.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleMountResolver.kt @@ -20,6 +20,14 @@ private const val SUBTITLE_ARTIFACT_TRACK_ID_PREFIX = "prairie-subtitle:" fun subtitleArtifactTrackId(serverIndex: Int): String = "$SUBTITLE_ARTIFACT_TRACK_ID_PREFIX$serverIndex" +/** + * True for a Media3 track id Silo authored for a subtitle artifact (a + * [subtitleArtifactTrackId] or a downloaded-subtitle id), including one Media3 + * reports with a merged-source child prefix (`1:prairie-subtitle:0`). + */ +fun isSubtitleArtifactTrackId(trackId: String?): Boolean = + trackId?.withoutMergedSourcePrefix().isReservedArtifactTrackId() + /** * True when a mounted Media3 `Format.id` denotes [expected]. * @@ -33,13 +41,14 @@ fun subtitleArtifactTrackId(serverIndex: Int): String = * Only a purely numeric prefix is accepted, so this can never collide with an * authored id that happens to contain a colon. */ -fun trackIdDenotes(actual: String?, expected: String): Boolean { - if (actual == null) return false - if (actual == expected) return true - val separator = actual.indexOf(':') - if (separator <= 0) return false - if (!actual.substring(0, separator).all(Char::isDigit)) return false - return actual.substring(separator + 1) == expected +fun trackIdDenotes(actual: String?, expected: String): Boolean = + actual != null && (actual == expected || actual.withoutMergedSourcePrefix() == expected) + +/** [trackIdDenotes]'s merged-source form: drops a purely numeric `:` prefix. */ +private fun String.withoutMergedSourcePrefix(): String { + val separator = indexOf(':') + if (separator <= 0 || !substring(0, separator).all(Char::isDigit)) return this + return substring(separator + 1) } /** diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/AudioReconcile.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/AudioReconcile.kt index ef8445e7e..cfd4c0fc0 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/AudioReconcile.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/AudioReconcile.kt @@ -77,6 +77,11 @@ sealed interface AudioReconcileAction { * @param planAudioOrdinal the catalog ordinal the server says it delivered. * @param requiresMountedIdentity whether a byte-for-byte original-file plan * must prove the selected catalog row against Media3's mounted inventory. + * @param positionalCatalog whether [catalog] lists the mounted file's own audio + * tracks in file order (a downloaded file described by its offline manifest). + * The catalog ordinal then IS the Media3 audio-group ordinal, so selection + * goes by position: a re-encoded track (TrueHD source delivered as AAC) can + * never identity-match the source description, but it sits at the same place. */ fun reconcileDesiredAudioAction( desired: DesiredAudio?, @@ -86,6 +91,7 @@ fun reconcileDesiredAudioAction( selectedOrdinal: Int?, planAudioOrdinal: Int?, requiresMountedIdentity: Boolean = false, + positionalCatalog: Boolean = false, ): AudioReconcileAction { if (desired == null) return AudioReconcileAction.None // An empty or partial snapshot is not evidence of anything. The intent must @@ -102,6 +108,18 @@ fun reconcileDesiredAudioAction( val wanted = catalog.getOrNull(desired.catalogOrdinal) ?: return AudioReconcileAction.None + // Only trusted while the inventories agree in size: a snapshot that shows a + // different number of audio groups is not the file the catalog describes, + // and falls back to identity matching below. + if (positionalCatalog && mounted.size == catalog.size) { + val target = mounted[desired.catalogOrdinal] + return if (selectedOrdinal == target.ordinal) { + AudioReconcileAction.Confirm + } else { + AudioReconcileAction.Apply(target.ordinal) + } + } + // Resolved ONCE against the whole snapshot. Matching a one-element list // asks a different question: the matcher stops as soon as one candidate // remains, so a main mix and its commentary — same language, same codec — diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabaseMigrationTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabaseMigrationTest.kt index f355eee5f..b560d918f 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabaseMigrationTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabaseMigrationTest.kt @@ -100,6 +100,50 @@ class PrairieDatabaseMigrationTest { } } + @Test + fun migration11To12KeepsDownloadsWithAnUnknownRevision() { + val name = "migration-11-to-12" + migrationHelper.createDatabase(name, 11).use { database -> + database.execSQL( + "INSERT INTO downloads (serverId, profileId, mediaFileId, recordId, contentId, title, mediaType, status, kind, " + + "fileSize, bytesSent, createdAt, updatedAtMs) VALUES ('s', 'p', 42, 'row', 'movie', 'Movie', 'movie', " + + "'completed', 'queued', 1024, 1024, '2026-09-29T00:00:00Z', 123)", + ) + } + migrationHelper.runMigrationsAndValidate(name, 12, true).use { database -> + database.query("SELECT recordId, status, revision FROM downloads").use { cursor -> + assertEquals(true, cursor.moveToFirst()) + assertEquals("row", cursor.getString(0)); assertEquals("completed", cursor.getString(1)) + assertEquals(true, cursor.isNull(2)) + assertEquals(false, cursor.moveToNext()) + } + } + } + + @Test + fun migration12To13KeepsDownloadRevisionsAndAddsNullableOfflineTracks() { + val name = "migration-12-to-13" + migrationHelper.createDatabase(name, 12).use { database -> + database.execSQL( + "INSERT INTO downloads (serverId, profileId, mediaFileId, recordId, contentId, title, mediaType, " + + "status, kind, fileSize, bytesSent, createdAt, updatedAtMs, revision) VALUES " + + "('s', 'p', 42, 'dl_1', 'mv_1', 'Example', 'movie', 'completed', 'queued', 1024, 1024, " + + "'2026-09-29T00:00:00Z', 123, 7)", + ) + } + migrationHelper.runMigrationsAndValidate(name, 13, true).use { database -> + database.query("SELECT recordId, status, bytesSent, revision, offlineTracksJson FROM downloads").use { cursor -> + assertEquals(true, cursor.moveToFirst()) + assertEquals("dl_1", cursor.getString(0)) + assertEquals("completed", cursor.getString(1)) + assertEquals(1024L, cursor.getLong(2)) + assertEquals(7, cursor.getInt(3)) + assertNull(cursor.getString(4)) + assertEquals(false, cursor.moveToNext()) + } + } + } + private companion object { const val DATABASE_NAME = "migration-7-to-8" } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarMappingTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarMappingTest.kt new file mode 100644 index 000000000..eeb35336a --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarMappingTest.kt @@ -0,0 +1,40 @@ +package org.prairieserver.prairie.common.downloads + +import org.prairieserver.prairie.model.download.DownloadRecord +import org.prairieserver.prairie.model.download.DownloadSidecar +import kotlin.test.Test +import kotlin.test.assertEquals + +class DownloadSidecarMappingTest { + private fun sidecar(contentId: String, episodeId: String?) = DownloadSidecar( + record = DownloadRecord( + id = "dl-1", + contentId = contentId, + episodeId = episodeId, + mediaFileId = 7, + kind = "queued", + status = "ready", + createdAt = "2026-09-28T10:00:00Z", + ), + title = "Show", + seriesContentId = "series-1", + updatedAtMs = 0L, + ) + + @Test + fun `an episode is stored under the episode it plays, not its series`() { + // The server keys the entry by series + episode; offline playback and + // the Downloads list look it up by the episode. + val entity = sidecar(contentId = "series-1", episodeId = "episode-2").toEntity("server", "profile") + + assertEquals("episode-2", entity.contentId) + assertEquals("series-1", entity.seriesContentId) + } + + @Test + fun `a movie keeps its own content id`() { + val entity = sidecar(contentId = "movie-1", episodeId = null).toEntity("server", "profile") + + assertEquals("movie-1", entity.contentId) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarOfflineTracksMappingTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarOfflineTracksMappingTest.kt new file mode 100644 index 000000000..a67874076 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarOfflineTracksMappingTest.kt @@ -0,0 +1,53 @@ +package org.prairieserver.prairie.common.downloads + +import org.prairieserver.prairie.model.catalog.AudioTrack +import org.prairieserver.prairie.model.download.DownloadRecord +import org.prairieserver.prairie.model.download.DownloadSidecar +import org.prairieserver.prairie.model.download.OfflineSubtitleFile +import org.prairieserver.prairie.model.download.OfflineTrackInfo +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DownloadSidecarOfflineTracksMappingTest { + + private val sidecar = DownloadSidecar( + record = DownloadRecord( + id = "dl_1", + contentId = "mv_1", + mediaFileId = 42, + kind = "queued", + status = "completed", + createdAt = "2026-09-29T00:00:00Z", + ), + title = "Example", + updatedAtMs = 1L, + ) + + @Test + fun offlineTracksSurviveTheRoomRow() { + val tracks = OfflineTrackInfo( + audioTracks = listOf( + AudioTrack(index = 0, language = "eng", codec = "aac", channels = 2, isDefault = true), + AudioTrack(index = 1, language = "jpn", codec = "aac", channels = 2), + ), + selectedAudioTrackIndex = 1, + subtitles = listOf( + OfflineSubtitleFile(path = "/data/subs/0.ass", format = "ass", language = "eng", forced = true), + ), + ) + + val restored = sidecar.copy(offlineTracks = tracks).toEntity("srv", "prof").toSidecar() + + assertEquals(tracks, restored.offlineTracks) + } + + @Test + fun rowsWithoutOfflineTracksStillLoad() { + val entity = sidecar.toEntity("srv", "prof") + + assertNull(entity.offlineTracksJson) + assertNull(entity.toSidecar().offlineTracks) + assertNull(entity.copy(offlineTracksJson = "{not json").toSidecar().offlineTracks) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadStatusReportOutcomeTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadStatusReportOutcomeTest.kt new file mode 100644 index 000000000..80772a7c7 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadStatusReportOutcomeTest.kt @@ -0,0 +1,49 @@ +package org.prairieserver.prairie.common.downloads + +import java.io.IOException +import kotlin.test.Test +import kotlin.test.assertEquals +import org.prairieserver.prairie.model.download.DownloadRecord +import org.prairieserver.prairie.network.ApiResult + +class DownloadStatusReportOutcomeTest { + private val row = DownloadRecord(id = "dl", contentId = "movie", mediaFileId = 42, kind = "queued", + status = "completed", createdAt = "2026-09-29T00:00:00Z", revision = 1) + + private fun error(code: Int) = ApiResult.Error(code, "", "") + + @Test + fun `an accepted event is settled`() { + assertEquals(DownloadStatusReportOutcome.Settled, downloadStatusReportOutcome(ApiResult.Success(row))) + } + + @Test + fun `a moved revision reconciles instead of resending`() { + assertEquals(DownloadStatusReportOutcome.Reconcile, downloadStatusReportOutcome(error(409))) + } + + @Test + fun `offline and transient answers keep the event for later`() { + assertEquals(DownloadStatusReportOutcome.RetryLater, downloadStatusReportOutcome(ApiResult.NetworkError(IOException("offline")))) + // A device clock ahead of the server's makes the event "future" until time catches up. + for (code in listOf(0, 400, 401, 408, 422, 429, 500, 502, 503)) { + assertEquals(DownloadStatusReportOutcome.RetryLater, downloadStatusReportOutcome(error(code)), "HTTP $code") + } + } + + @Test + fun `answers a resend cannot change are settled`() { + for (code in listOf(403, 404, 410)) { + assertEquals(DownloadStatusReportOutcome.Settled, downloadStatusReportOutcome(error(code)), "HTTP $code") + } + } + + @Test + fun `a locked profile keeps its report until PIN verification succeeds`() { + val locked = ApiResult.Error(403, "profile_verification_required", "Verify the active profile.") + assertEquals(DownloadStatusReportOutcome.RetryLater, downloadStatusReportOutcome(locked)) + assertEquals(DownloadStatusReportOutcome.Settled, downloadStatusReportOutcome( + ApiResult.Error(403, "permission_denied", "Downloads are disabled."), + )) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OfflineTrackAssetFetcherTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OfflineTrackAssetFetcherTest.kt new file mode 100644 index 000000000..a562eb4a1 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OfflineTrackAssetFetcherTest.kt @@ -0,0 +1,133 @@ +package org.prairieserver.prairie.common.downloads + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.defaultRequest +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OfflineTrackAssetFetcherTest { + + @get:Rule + val tmp = TemporaryFolder() + + private val manifest = """ + { + "download_id": "dl_1", + "selected_audio_track_index": 1, + "audio_tracks": [ + {"index": 0, "language": "eng", "codec": "aac", "channels": 2, "default": true}, + {"index": 1, "language": "jpn", "codec": "aac", "channels": 2} + ], + "subtitles": [ + {"language": "eng", "format": "ass", "fetch_url": "/api/v2/downloads/dl_1/subtitles/embedded:2"}, + {"language": "fre", "format": "srt", "external": true, "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:0"}, + {"language": "eng", "format": "sup", "forced": true, "fetch_url": "/api/v2/downloads/dl_1/subtitles/embedded:4"}, + {"language": "ger", "format": "sub", "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:1"}, + {"language": "spa", "format": "srt", "fetch_url": "https://elsewhere.example/sub.srt"} + ] + } + """.trimIndent() + + private val requested = mutableListOf() + + private fun client( + manifestStatus: HttpStatusCode = HttpStatusCode.OK, + transientManifestFailures: Int = 0, + ) = HttpClient( + MockEngine { request -> + val path = request.url.encodedPath + requested += path + when { + path.endsWith("/manifest") && requested.count { it.endsWith("/manifest") } <= transientManifestFailures -> + respond("", HttpStatusCode.ServiceUnavailable) + path.endsWith("/manifest") -> respond(manifest, manifestStatus) + path.endsWith("/subtitles/embedded:2") -> respond("[Script Info]\nTitle: x\n") + // The server failed to read the external sidecar. + path.endsWith("/subtitles/external:0") -> respond("", HttpStatusCode.NotFound) + path.endsWith("/subtitles/embedded:4") -> respond(byteArrayOf(0x50, 0x47, 0x00, 0x01)) + else -> respond("", HttpStatusCode.InternalServerError) + } + }, + ) { + install(HttpTimeout) + defaultRequest { url("https://silo.example/") } + } + + @Test + fun capturesAudioTracksAndSavesEveryFetchableSidecar() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + + val info = assertNotNull( + OfflineTrackAssetFetcher(client(), storage).fetch("dl_1", "srv", "prof", 42) {}, + ) + + assertEquals(listOf("eng", "jpn"), info.audioTracks.map { it.language }) + assertEquals(1, info.defaultAudioPosition()) + // The failed SRT, the unmountable .sub and the foreign URL are skipped + // without failing the rest. + assertEquals(listOf("ass", "pgs"), info.subtitles.map { it.format }) + assertEquals(listOf(false, true), info.subtitles.map { it.forced }) + val ass = File(info.subtitles[0].path) + val pgs = File(info.subtitles[1].path) + assertTrue(ass.isFile && ass.name.endsWith(".ass")) + assertTrue(pgs.isFile && pgs.name.endsWith(".sup")) + assertEquals(storage.offlineSubtitleDirectory("srv", "prof", 42), ass.parentFile) + assertFalse(requested.any { it.contains("sub.srt") }) + assertTrue(ass.parentFile!!.listFiles()!!.none { it.name.endsWith(".part") }) + } + + @Test + fun deletingTheDownloadRemovesItsSidecars() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + val info = assertNotNull( + OfflineTrackAssetFetcher(client(), storage).fetch("dl_1", "srv", "prof", 42) {}, + ) + + storage.delete("srv", "prof", 42) + + assertTrue(info.subtitles.none { File(it.path).exists() }) + } + + @Test + fun aMissingManifestCapturesNothing() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + + assertNull( + OfflineTrackAssetFetcher(client(HttpStatusCode.NotFound), storage).fetch("dl_1", "srv", "prof", 42) {}, + ) + assertEquals(listOf("/api/v2/downloads/dl_1/manifest"), requested) + } + + @Test + fun aTransientManifestFailureIsRetried() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + + val info = assertNotNull( + OfflineTrackAssetFetcher(client(transientManifestFailures = 2), storage, manifestRetryDelayMs = 0) + .fetch("dl_1", "srv", "prof", 42) {}, + ) + + assertEquals(3, requested.count { it.endsWith("/manifest") }) + assertEquals(2, info.audioTracks.size) + } + + @Test + fun onlyVideoDownloadsCaptureTracks() { + assertTrue(OfflineTrackAssetFetcher.appliesTo("movie")) + assertTrue(OfflineTrackAssetFetcher.appliesTo("tv")) + assertFalse(OfflineTrackAssetFetcher.appliesTo("audiobook")) + assertFalse(OfflineTrackAssetFetcher.appliesTo("ebook")) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerTrackSelectionTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerTrackSelectionTest.kt index cdfffa477..275d07c96 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerTrackSelectionTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerTrackSelectionTest.kt @@ -609,6 +609,27 @@ class SubtitleManagerTrackSelectionTest { } } + @Test + fun renderableTextTracksDropUnsupportedTracksAndReportTheCueSourceFormat() { + fun group(format: Format, support: Int) = + Tracks.Group(TrackGroup(format), false, intArrayOf(support), booleanArrayOf(false)) + val tracks = Tracks( + listOf( + group( + subtitle("English", "en", MimeTypes.APPLICATION_MEDIA3_CUES, MimeTypes.TEXT_SSA, id = "0:3", forced = true), + C.FORMAT_HANDLED, + ), + group(subtitle(null, "it", MimeTypes.APPLICATION_TX3G, id = "0:4"), C.FORMAT_UNSUPPORTED_TYPE), + ), + ) + + val mounted = renderableMountedTextTracks(tracks).single() + + assertEquals("0:3", mounted.trackId) + assertEquals(MimeTypes.TEXT_SSA, mounted.codec) + assertEquals(true, mounted.forced) + } + private fun subtitle( label: String?, language: String?, diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/AudioReconcilePositionalTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/AudioReconcilePositionalTest.kt new file mode 100644 index 000000000..fb16a07b6 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/AudioReconcilePositionalTest.kt @@ -0,0 +1,94 @@ +package org.prairieserver.prairie.common.player.video + +import org.prairieserver.prairie.model.catalog.AudioTrack +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Downloaded files described by their offline manifest: the catalog lists the + * file's own audio tracks in file order, so selection goes by position. + */ +class AudioReconcilePositionalTest { + + /** Two English stereo AAC tracks that only a title could tell apart. */ + private val catalog = listOf( + AudioTrack(index = 0, codec = "aac", channels = 2, language = "eng", title = "Main"), + AudioTrack(index = 1, codec = "aac", channels = 2, language = "jpn"), + AudioTrack(index = 2, codec = "aac", channels = 2, language = "eng", title = "Commentary"), + ) + + /** Media3 reports no labels for the MP4's tracks. */ + private val mounted = listOf( + MountedAudioTrack(0, "eng", "audio/mp4a-latm", 2), + MountedAudioTrack(1, "jpn", "audio/mp4a-latm", 2), + MountedAudioTrack(2, "eng", "audio/mp4a-latm", 2), + ) + + private fun desire(ordinal: Int) = DesiredAudio( + generation = 1L, + catalogOrdinal = ordinal, + explicit = true, + fileId = 7, + ) + + private fun reconcile( + ordinal: Int, + selectedOrdinal: Int?, + mountedTracks: List = mounted, + catalogTracks: List = catalog, + positional: Boolean = true, + ) = reconcileDesiredAudioAction( + desired = desire(ordinal), + activeFileId = 7, + catalog = catalogTracks, + mounted = mountedTracks, + selectedOrdinal = selectedOrdinal, + planAudioOrdinal = null, + positionalCatalog = positional, + ) + + @Test + fun selectsTheMountedGroupAtTheSamePosition() { + assertEquals(AudioReconcileAction.Apply(2), reconcile(ordinal = 2, selectedOrdinal = 0)) + assertEquals(AudioReconcileAction.Confirm, reconcile(ordinal = 2, selectedOrdinal = 2)) + } + + @Test + fun identityMatchingCannotTellTheSameLanguageTracksApart() { + // Without the positional mode the untitled mount is ambiguous, so the + // pick silently goes nowhere: the failure this mode exists for. + assertEquals( + AudioReconcileAction.None, + reconcile(ordinal = 2, selectedOrdinal = 0, positional = false), + ) + } + + @Test + fun reEncodedTracksAreSelectedEvenWhenTheCatalogNamesTheSourceCodec() { + val sourceDescribed = listOf( + AudioTrack(index = 0, codec = "truehd", channels = 8, language = "eng"), + AudioTrack(index = 1, codec = "ac3", channels = 6, language = "fre"), + ) + val remux = listOf( + MountedAudioTrack(0, "eng", "audio/mp4a-latm", 2), + MountedAudioTrack(1, "fre", "audio/ac3", 6), + ) + + assertEquals( + AudioReconcileAction.Apply(0), + reconcile(ordinal = 0, selectedOrdinal = 1, mountedTracks = remux, catalogTracks = sourceDescribed), + ) + } + + @Test + fun aSnapshotOfADifferentSizeFallsBackToIdentityMatching() { + val legacySingleTrack = listOf(MountedAudioTrack(0, "jpn", "audio/mp4a-latm", 2)) + + // Identity matching finds the Japanese track even though the file does + // not have the three tracks the catalog lists. + assertEquals( + AudioReconcileAction.Confirm, + reconcile(ordinal = 1, selectedOrdinal = 0, mountedTracks = legacySingleTrack), + ) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt index 6368e7cc6..f47d81010 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt @@ -317,7 +317,7 @@ val androidModule = module { // One-time import of the legacy .record.json sidecar tree into Room. single { org.prairieserver.prairie.common.downloads.LegacyDownloadImporter(androidContext().filesDir, get()) } single { OfflineMediaResolver(get(), get(), get()) } - single { DownloadEnqueuer(androidContext(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } + single { DownloadEnqueuer(androidContext(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } single { DownloadSubscriptionEvaluatorFactory(get(), get(), get()) } // CoroutineWorker constructed by Koin's WorkerFactory — see // PrairieApplication.onCreate `workManagerFactory()` call. @@ -335,6 +335,15 @@ val androidModule = module { gate = get(), ) } + worker { + org.prairieserver.prairie.common.downloads.DownloadStatusWorker( + appContext = androidContext(), + params = get(), + repository = get(), + authorities = get(), + devices = get(), + ) + } worker { DownloadSubscriptionWorker( appContext = androidContext(), @@ -380,6 +389,7 @@ val androidModule = module { castPlaybackPreparer = get(), qualityLadderClient = get(), seekIntervalStore = get(), + activeProfileStore = get(), ) } viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull(), get(), get()) } @@ -456,7 +466,7 @@ val androidModule = module { tmdbId = args.second, ) } - viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } + viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { DiagnosticsViewModel(get()) } viewModel { DownloadsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { org.prairieserver.prairie.android.ui.screens.pairing.CompanionPairingViewModel(get(), get()) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/downloads/AppWorkerFactory.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/downloads/AppWorkerFactory.kt index 514fadb56..05c2f9e7c 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/downloads/AppWorkerFactory.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/downloads/AppWorkerFactory.kt @@ -10,6 +10,7 @@ import org.prairieserver.prairie.common.data.sync.SyncWorker import org.prairieserver.prairie.common.downloads.DownloadStorage import org.prairieserver.prairie.common.downloads.DownloadSubscriptionEvaluatorFactory import org.prairieserver.prairie.common.downloads.DownloadSubscriptionWorker +import org.prairieserver.prairie.common.downloads.DownloadStatusWorker import org.prairieserver.prairie.common.downloads.DownloadWorker import org.prairieserver.prairie.common.diagnostics.DiagnosticsCoordinator import org.prairieserver.prairie.common.diagnostics.DiagnosticsUploadWorker @@ -65,6 +66,16 @@ class AppWorkerFactory : WorkerFactory() { }, ) } + DownloadStatusWorker::class.java.name -> { + Log.i(TAG, "Building DownloadStatusWorker via Koin") + DownloadStatusWorker( + appContext = appContext, + params = workerParameters, + repository = koin.get(), + authorities = koin.get(), + devices = koin.get(), + ) + } DownloadSubscriptionWorker::class.java.name -> { Log.i(TAG, "Building DownloadSubscriptionWorker via Koin") DownloadSubscriptionWorker( diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/calendar/CalendarScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/calendar/CalendarScreen.kt index b4896bcce..893aa4a1b 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/calendar/CalendarScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/calendar/CalendarScreen.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -23,6 +25,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -218,7 +221,7 @@ fun CalendarScreen( !state.hasAnyItems -> item(key = "empty") { EmptyState( filter = state.filter, - onShowEverything = { viewModel.setFilter(CalendarFilter.Everything) }, + onSelectFilter = viewModel::setFilter, ) } else -> items(state.weekDates, key = { "day-$it" }) { date -> @@ -800,6 +803,9 @@ private fun BadgePill(label: String) { fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp, // iOS tracking 0.8 maxLines = 1, + // Wrapping drew only the first word in a full-width pill. + softWrap = false, + overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.background, ) } @@ -807,14 +813,27 @@ private fun BadgePill(label: String) { // MARK: - Empty state +/** + * The views an empty week links to: always the other two, never the one on + * screen (silo-server #1494, silo-apple #513). "all" is the legacy spelling of + * Everything; any other legacy filter links to all three views. + */ +private fun emptyStateLinks(filter: String): List> { + val view = if (filter == CalendarFilter.All) CalendarFilter.Everything else filter + return listOf( + CalendarFilter.Following to "Following", + CalendarFilter.Trending to "Trending", + CalendarFilter.Everything to "All", + ).filter { (value, _) -> value != view } +} + /** * iOS empty state: 44pt calendar glyph at `onSurface 0.3`, subheadline title, - * caption body, and a 220pt "Show Everything" primary button whenever the - * filter is narrower than Everything. + * caption body, and primary buttons linking to the other two views. */ +@OptIn(ExperimentalLayoutApi::class) @Composable -private fun EmptyState(filter: String, onShowEverything: () -> Unit) { - val isEverything = filter == CalendarFilter.Everything || filter == CalendarFilter.All +private fun EmptyState(filter: String, onSelectFilter: (String) -> Unit) { Column( modifier = Modifier .fillMaxWidth() @@ -830,11 +849,7 @@ private fun EmptyState(filter: String, onShowEverything: () -> Unit) { modifier = Modifier.size(44.dp), ) Text( - text = if (filter == CalendarFilter.Following) { - "Nothing from shows you follow" - } else { - "Nothing scheduled this week" - }, + text = emptyTitle(filter), fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, @@ -846,25 +861,35 @@ private fun EmptyState(filter: String, onShowEverything: () -> Unit) { modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, ) - if (!isEverything) { - Button( - onClick = onShowEverything, - modifier = Modifier.width(220.dp), - shape = CircleShape, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.onSurface, - contentColor = MaterialTheme.colorScheme.background, - ), - ) { - Text("Show Everything", fontWeight = FontWeight.SemiBold) + // Wraps so a legacy filter's three links, or a large font scale, + // never push a button off a narrow phone. + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + emptyStateLinks(filter).forEach { (value, label) -> + Button( + onClick = { onSelectFilter(value) }, + modifier = Modifier.widthIn(min = 140.dp), + shape = CircleShape, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.onSurface, + contentColor = MaterialTheme.colorScheme.background, + ), + ) { + Text(label, fontWeight = FontWeight.SemiBold, maxLines = 1) + } } } } } private fun badgeLabel(badge: String): String? = when (badge) { - // iOS CalendarBadge labels (uppercased editorial). - CalendarBadge.SeriesPremiere -> "SERIES PREMIERE" + // iOS CalendarBadge labels (uppercased editorial). iOS says "SERIES + // PREMIERE", which does not fit a poster at the 11sp badge floor; + // NEW SEASON already marks season premieres. + CalendarBadge.SeriesPremiere -> "PREMIERE" CalendarBadge.SeasonPremiere -> "NEW SEASON" CalendarBadge.Finale -> "FINALE" else -> null @@ -912,8 +937,15 @@ private fun cardSubtitle(item: CalendarItem): String? { return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ") } +private fun emptyTitle(filter: String): String = when (filter) { + CalendarFilter.Following -> "Nothing from shows you follow" + CalendarFilter.Trending -> "Nothing trending this week" + else -> "Nothing scheduled this week" +} + private fun emptySubtitle(filter: String): String = when (filter) { CalendarFilter.Following -> "No upcoming releases this week from shows you watch, favorite, or watchlist." + CalendarFilter.Trending -> "No trending releases this week." else -> "No movie releases or episode airings in this week." } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DownloadQualityPickerSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DownloadQualityPickerSheet.kt index 2b0f8668a..04be54451 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DownloadQualityPickerSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DownloadQualityPickerSheet.kt @@ -39,6 +39,9 @@ fun DownloadQualityPickerSheet( // Presets to offer, already gated by server capability + media type (issue // #20 GAP 4). Empty falls back to Original so the sheet is never blank. allowedQualities: List = DownloadQuality.entries, + // Row title per preset; the caller passes the server capability's label, + // which adds the preset's resolution ceiling ("10 Mbps · up to 1080p"). + qualityLabel: (DownloadQuality) -> String = { it.label }, ) { val qualities = allowedQualities.ifEmpty { listOf(DownloadQuality.Original) } ModalBottomSheet( @@ -89,7 +92,7 @@ fun DownloadQualityPickerSheet( ListItem( headlineContent = { Text( - text = quality.label, + text = qualityLabel(quality), style = MaterialTheme.typography.bodyLarge, ) }, @@ -165,6 +168,6 @@ internal fun downloadEstimateWarning( private fun downloadQualityDescription(quality: DownloadQuality): String = when (quality) { - DownloadQuality.Original -> "Original file, best quality, largest download." - else -> "Smaller copy capped around ${quality.label}." + DownloadQuality.Original -> "Source quality, with compatibility fallback if needed." + else -> "Prepared on the server when the original is larger." } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt index d0b6b4827..7cbee2008 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt @@ -79,6 +79,7 @@ import org.prairieserver.prairie.model.ebook.chooseEbookVersion import org.prairieserver.prairie.model.ebook.isInAppReadableEbookVersion import org.prairieserver.prairie.model.ebook.isSupportedEbookVersion import org.prairieserver.prairie.model.download.DownloadQuality +import org.prairieserver.prairie.model.download.labelFor import org.prairieserver.prairie.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED import org.prairieserver.prairie.common.settings.PlayerSettingsStore import org.prairieserver.prairie.network.ServerRegistry @@ -621,8 +622,18 @@ fun ItemDetailScreen( it.contentId == state.selectedEpisodeContentId } val selectedEpisodeDetail = state.selectedEpisodeDetail - val selectedEpisodeVersionIndex = state.selectedVersionIndex - .coerceIn(0, (selectedEpisodeDetail?.versions?.lastIndex ?: 0).coerceAtLeast(0)) + // Auto resolves like the movie page and playback, so + // the version shown is the one Play and Download use. + val selectedEpisodeVersions = selectedEpisodeDetail?.versions.orEmpty() + val selectedEpisodeVersionIndex = detailDisplayVersionIndex( + versions = selectedEpisodeVersions, + explicitIndex = state.selectedVersionIndex + .coerceIn(0, selectedEpisodeVersions.lastIndex.coerceAtLeast(0)) + .takeIf { state.hasExplicitVersionSelection }, + lastFileId = selectedEpisodeDetail?.userData?.lastFileId, + preferredQuality = preferredQuality, + fallbackIndex = 0, + ) val selectedEpisodeFileId = selectedEpisodeDetail?.versions ?.getOrNull(selectedEpisodeVersionIndex) ?.fileId @@ -748,7 +759,7 @@ fun ItemDetailScreen( viewModel.onDownloadTapped( version, episode.title, forceRedownloadMissingLocal = episodeDownloadState.needsLocalRecovery, - downloadContentId = episode.contentId, + episode = episode, ) }, qualityAction = { quality -> @@ -756,7 +767,7 @@ fun ItemDetailScreen( version, episode.title, forceRedownloadMissingLocal = episodeDownloadState.needsLocalRecovery, downloadQuality = quality, - downloadContentId = episode.contentId, + episode = episode, ) }, estimate = org.prairieserver.prairie.model.download.DownloadSizeEstimate @@ -821,41 +832,18 @@ fun ItemDetailScreen( // flow through to the DownloadButton. val downloadRecords by viewModel.downloads.collectAsState() // Auto preview must resolve through the SAME shared - // selector as playback (lastFileId → preferred-quality - // rank → bestAvailable), not just lastFileId-else-[0] — + // selector as playback, not just lastFileId-else-[0] — // otherwise the previewed version (and the audio/subtitle // lists derived from it) can describe a file playback - // won't use. An explicit user pick still wins. TV parity: - // selectTvDetailDisplayVersion does the same. - val videoDisplayVersionIndex = if ( - state.hasExplicitVersionSelection || detail.versions.isEmpty() - ) { - effectiveSelectedVersionIndex - } else if (preferredQuality == null) { - // The quality pref hasn't emitted from DataStore yet - // (a frame or two): don't auto-resolve against a - // missing pref — it would name a version the arriving - // pref immediately contradicts (first-frame flash). - // lastFileId is pref-independent and always wins in - // selectPlaybackVersion, so it can be shown at once; - // otherwise hold the bare "Auto" placeholder (-1 → - // no resolved version) until the pref lands. - detail.userData?.lastFileId - ?.let { lastFileId -> - detail.versions.indexOfFirst { it.fileId == lastFileId } - .takeIf { it >= 0 } - } - ?: -1 - } else { - val resolvedFileId = selectPlaybackVersion( - detail.versions, - detail.userData?.lastFileId, - preferredQuality, - ).fileId - detail.versions.indexOfFirst { it.fileId == resolvedFileId } - .takeIf { it >= 0 } - ?: effectiveSelectedVersionIndex - } + // won't use. TV parity: selectTvDetailDisplayVersion + // does the same. + val videoDisplayVersionIndex = detailDisplayVersionIndex( + versions = detail.versions, + explicitIndex = effectiveSelectedVersionIndex.takeIf { state.hasExplicitVersionSelection }, + lastFileId = detail.userData?.lastFileId, + preferredQuality = preferredQuality, + fallbackIndex = effectiveSelectedVersionIndex, + ) val selectedVersion = detail.versions.getOrNull(videoDisplayVersionIndex) val selectedLocalDownload = selectedVersion?.let { version -> localDownloadFor(version.fileId) @@ -1002,6 +990,7 @@ fun ItemDetailScreen( estimate = pendingDownloadEstimate, availableBytes = remember { downloadStorage.usableSpaceBytes() }, allowedQualities = pendingDownloadAllowedQualities, + qualityLabel = { quality -> downloadCapability.labelFor(quality) }, ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt index e1eff84a5..d8b7b87e0 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt @@ -62,6 +62,8 @@ data class ItemDetailUiState( val isLoadingSelectedEpisodeDetail: Boolean = false, /** Parent-series portrait art used when an episode's own artwork is a wide still. */ val episodeSeriesPosterUrl: String? = null, + /** Parent series title on a standalone episode page, for download grouping. */ + val episodeSeriesTitle: String? = null, val episodeSeriesPosterThumbhash: String? = null, /** * Route-scoped episode lists keyed by season. Unlike the repository's @@ -235,7 +237,7 @@ class ItemDetailViewModel( displayTitle: String, forceRedownloadMissingLocal: Boolean = false, downloadQuality: DownloadQuality? = null, - downloadContentId: String = contentId, + episode: ItemDetail? = null, ) { val existing = downloadRecordFor(version) when ( @@ -257,11 +259,11 @@ class ItemDetailViewModel( DetailDownloadTapAction.ReplaceAndStart -> viewModelScope.launch { val staleRecord = existing if (staleRecord == null || downloadsRepository.delete(staleRecord.id) is ApiResult.Success) { - startDownload(version, displayTitle, downloadQuality, downloadContentId) + startDownload(version, displayTitle, downloadQuality, episode) } } DetailDownloadTapAction.Start -> viewModelScope.launch { - startDownload(version, displayTitle, downloadQuality, downloadContentId) + startDownload(version, displayTitle, downloadQuality, episode) } } } @@ -275,16 +277,58 @@ class ItemDetailViewModel( version: FileVersion, displayTitle: String, downloadQuality: DownloadQuality?, - downloadContentId: String, + episode: ItemDetail?, ) { + // The series page names its selected episode; an episode page is + // itself the episode. Episodes register under their series, which the + // server requires, so they go through startEpisode. + val pageDetail = _uiState.value.detail + val item = episode ?: pageDetail + val seriesPage = pageDetail?.takeIf { it.type == "series" } + // On the series page the page itself is the parent when the episode + // row omits its series id. + val seriesId = item?.seriesId?.takeIf { it.isNotBlank() } + ?: seriesPage?.takeIf { episode != null }?.contentId // wifiOnly read from per-profile PlayerSettingsStore inside - // DownloadEnqueuer.start; default true. - val result = downloadEnqueuer.start( - contentId = downloadContentId, - fileId = version.fileId, - displayTitle = displayTitle, - downloadQualityOverride = downloadQuality, - ) + // DownloadEnqueuer; default true. + val result = when { + item?.type == "episode" && seriesId != null -> { + // Never the episode's own title or art: the Downloads tab + // groups episodes under the series' name and poster. + val knownTitle = item.seriesTitle?.takeIf { it.isNotBlank() } + ?: seriesPage?.title + ?: _uiState.value.episodeSeriesTitle?.takeIf { it.isNotBlank() } + val knownPoster = seriesPage?.posterUrl ?: _uiState.value.episodeSeriesPosterUrl + // On an episode page the parent load may not have finished (or + // may not run at all); the title and poster are stored with the + // download, so fetch the parent now when either is missing. A + // series page already is the parent, so it never refetches. + val parent = if (seriesPage == null && (knownTitle == null || knownPoster == null)) { + (catalogRepository.getItemDetailForPrefetch(seriesId, libraryId = libraryId) as? ApiResult.Success)?.data + } else { + null + } + downloadEnqueuer.startEpisode( + seriesContentId = seriesId, + episodeContentId = item.contentId, + fileId = version.fileId, + seriesTitle = knownTitle ?: parent?.title?.takeIf { it.isNotBlank() } ?: "Series", + seasonNumber = item.seasonNumber, + episodeNumber = item.episodeNumber, + episodeTitle = item.title, + posterUrl = knownPoster ?: parent?.posterUrl ?: pageDetail?.posterUrl, + downloadQualityOverride = downloadQuality, + ) + } + // The server rejects an episode sent without its series. + item?.type == "episode" -> ApiResult.Error(0, "missing_series", "This episode isn't linked to a series.") + else -> downloadEnqueuer.start( + contentId = item?.contentId ?: contentId, + fileId = version.fileId, + displayTitle = displayTitle, + downloadQualityOverride = downloadQuality, + ) + } _downloadStartEvents.emit(result is ApiResult.Success) } @@ -625,6 +669,7 @@ class ItemDetailViewModel( it.copy( episodeSeriesPosterUrl = result.data.posterUrl, episodeSeriesPosterThumbhash = result.data.posterThumbhash, + episodeSeriesTitle = result.data.title, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MediaSelectors.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MediaSelectors.kt index 7c50b82f8..4a867d0db 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MediaSelectors.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MediaSelectors.kt @@ -61,6 +61,7 @@ import org.prairieserver.prairie.model.catalog.hasEditionChoices import org.prairieserver.prairie.model.catalog.FileVersion import org.prairieserver.prairie.model.catalog.SubtitleTrack import org.prairieserver.prairie.player.DolbyVisionDetection +import org.prairieserver.prairie.playback.selectPlaybackVersion /** * One iOS-style row inside [PlaybackSelectorCard]. Icon + group label lead, @@ -520,6 +521,38 @@ private fun BadgePill(text: String) { ) } +// ── Display version ─────────────────────────────────────────── + +/** + * Index of the version a video detail page shows for [versions], which is + * also the version its Download button saves. An explicit pick wins. Auto + * resolves through the same shared selector as playback (lastFileId → + * preferred-quality rank → bestAvailable), so what Auto previews is the file + * playback and downloads use. While the quality pref is still loading, only + * the pref-independent lastFileId is shown, otherwise -1 (the bare "Auto" + * placeholder), so the page never names a version the arriving pref + * contradicts. [fallbackIndex] covers an empty list or an unmatched version. + */ +internal fun detailDisplayVersionIndex( + versions: List, + explicitIndex: Int?, + lastFileId: Int?, + preferredQuality: String?, + fallbackIndex: Int, +): Int { + if (versions.isEmpty()) return fallbackIndex + // A stale pick (the list shrank after a refresh) names no version rather + // than a different one, since playback would not use a clamped file. + if (explicitIndex != null) return explicitIndex.takeIf { it in versions.indices } ?: -1 + if (preferredQuality == null) { + return lastFileId + ?.let { id -> versions.indexOfFirst { it.fileId == id }.takeIf { it >= 0 } } + ?: -1 + } + val resolvedFileId = selectPlaybackVersion(versions, lastFileId, preferredQuality).fileId + return versions.indexOfFirst { it.fileId == resolvedFileId }.takeIf { it >= 0 } ?: fallbackIndex +} + // ── Label formatting ────────────────────────────────────────── /** diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/downloads/DownloadsViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/downloads/DownloadsViewModel.kt index 031d389c5..11b0b4ab4 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/downloads/DownloadsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/downloads/DownloadsViewModel.kt @@ -328,10 +328,20 @@ class DownloadsViewModel( // contentId + MOVIES + "missing file" whenever the join missed). // Live byte-progress/status is overlaid from the in-memory server // records for in-flight items. Reload sidecars when a record the - // map doesn't know about appears (newly enqueued). + // map doesn't know about appears (newly enqueued), or when a + // download finished: the worker writes the completed sidecar + // (status + final file URI) before publishing the record, and + // the local completion is what the row's Ready/Play state reads. val (sections, bytesUsed) = withContext(Dispatchers.IO) { - if (records.any { it.id !in metadataByRecordId }) { + val unseenCompletions = records.filter { sidecarMissesCompletion(it) }.map { it.id } + if (unseenCompletions.isNotEmpty() || records.any { it.id !in metadataByRecordId }) { reloadSidecarMetadata() + // Reload once per disagreement: a row the server holds as + // completed while it downloads again locally must not + // re-read Room on every progress tick. + completionReloadIds = (completionReloadIds + unseenCompletions).filterTo(mutableSetOf()) { + metadataByRecordId[it]?.record?.statusEnum() != DownloadStatus.Completed + } } val sects = buildSections(records.associateBy { it.id }) sects to sects.sumOf { it.totalBytesUsed } @@ -516,10 +526,9 @@ class DownloadsViewModel( // write a DURABLE tombstone (so the record can't resurrect as a ghost on // the next online refresh — written BEFORE byte deletion so a crash can't // lose the server-delete intent), then drop the bytes + metadata. - val status = record?.statusEnum() ?: sidecar?.record?.statusEnum() - if (status == DownloadStatus.Queued || status == DownloadStatus.Downloading) { - downloadEnqueuer.cancel(id) - } + // Completed media can still be capturing subtitle sidecars. Wait + // for that worker too, before removing files or its metadata row. + downloadEnqueuer.cancel(id) // Drop the in-memory sidecar maps BEFORE the tombstone: enqueueDurableDelete // emits on repository.records and the collector rebuilds sections from // metadataByRecordId — if the entry were still present the deleted row would @@ -635,6 +644,13 @@ class DownloadsViewModel( } } + /** Completed records whose sidecar still disagreed after a reload. */ + private var completionReloadIds: Set = emptySet() + + private fun sidecarMissesCompletion(record: DownloadRecord): Boolean = + record.statusEnum() == DownloadStatus.Completed && record.id !in completionReloadIds && + metadataByRecordId[record.id]?.record?.statusEnum()?.let { it != DownloadStatus.Completed } == true + private suspend fun activeDownloadScope(): Pair { val serverId = serverRegistry.activeServerId.value ?: DownloadEnqueuer.DEFAULT_SERVER_ID val profileId = withContext(Dispatchers.IO) { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/OfflinePlaybackTracks.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/OfflinePlaybackTracks.kt new file mode 100644 index 000000000..2275f58b4 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/OfflinePlaybackTracks.kt @@ -0,0 +1,93 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import org.prairieserver.prairie.common.player.MountedSubtitleTrack +import org.prairieserver.prairie.common.player.isSubtitleArtifactTrackId +import org.prairieserver.prairie.common.player.subtitleArtifactTrackId +import org.prairieserver.prairie.model.download.OfflineSubtitleFile +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.playback.canonicalSubtitleCodecFamily +import org.prairieserver.prairie.playback.isBitmapSubtitleCodecFamily +import org.prairieserver.prairie.playback.isClientMountableBitmapCodecFamily + +/** + * Menu indexes for text tracks found inside a downloaded file. Kept clear of + * the sidecar rows' indexes (0 until sidecar count), which also key the + * sidecars' Media3 track ids (`prairie-subtitle:`). + */ +internal const val OFFLINE_EMBEDDED_SUBTITLE_INDEX_BASE = 10_000 + +/** Subtitle preferences applied to offline playback, as online playback resolves them. */ +internal data class OfflineSubtitlePreferences( + val preferredLanguage: String?, + val mode: String?, + val showForced: Boolean, +) + +/** + * Menu rows for the subtitle sidecars saved with a download. Each row's + * [PlayerSubtitleInfo.index] is its position here, which the mount turns into + * the sidecar's Media3 track id, so selection resolves by that exact id. + * Files that have gone missing are left out. + */ +internal fun offlineSidecarSubtitleRows( + files: List, + fileExists: (String) -> Boolean = { java.io.File(it).isFile }, +): List = + files.filter { fileExists(it.path) }.mapIndexed { index, file -> + PlayerSubtitleInfo( + index = index, + language = file.language, + codec = file.format, + label = file.title ?: if (file.hearingImpaired) "SDH" else null, + source = "external", + forced = file.forced, + url = "file://" + java.io.File(file.path).toURI().rawPath, + // The id the mount gives this sidecar; also keeps two otherwise + // identical sidecars distinct when the menu resolves a selection. + mediaTrackId = subtitleArtifactTrackId(index), + ) + } + +/** + * Menu rows for the text tracks inside the downloaded file itself (MP4 timed + * text in a prepared download; any embedded track of an original download). + * + * The file's own default flag is deliberately not carried: a prepared MP4 + * marks its first subtitle track default whether or not it should show, so + * only the forced flag and the viewer's preferences may turn one on. + * + * [tracks] are the renderable ones (renderableMountedTextTracks). Merged + * sidecars and bitmap families the player cannot mount are left out. + */ +internal fun localEmbeddedSubtitleRows(tracks: List): List = + tracks + .filter { track -> + val family = canonicalSubtitleCodecFamily(track.codec) + track.trackId != null && + !isSubtitleArtifactTrackId(track.trackId) && + (!isBitmapSubtitleCodecFamily(family) || isClientMountableBitmapCodecFamily(family)) + } + .mapIndexed { ordinal, track -> + PlayerSubtitleInfo( + index = OFFLINE_EMBEDDED_SUBTITLE_INDEX_BASE + ordinal, + language = track.language, + codec = track.codec, + label = track.label, + source = "embedded", + forced = track.forced, + url = "", + mediaTrackId = track.trackId, + ) + } + +/** A row [localEmbeddedSubtitleRows] produced. */ +internal fun PlayerSubtitleInfo.isLocalEmbeddedSubtitleRow(): Boolean = + source == "embedded" && url.isBlank() && index >= OFFLINE_EMBEDDED_SUBTITLE_INDEX_BASE + +/** + * A row that exists only in offline playback: a track inside the downloaded + * file or a sidecar saved on the device. Its index is a local menu position, + * never a server subtitle index. + */ +internal fun PlayerSubtitleInfo.isOfflineLocalSubtitleRow(): Boolean = + isLocalEmbeddedSubtitleRow() || url.startsWith("file://") diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt index 506f8cc46..a1bfe6288 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt @@ -98,6 +98,7 @@ import org.prairieserver.prairie.common.player.video.PlaybackStartupStallDetecto import org.prairieserver.prairie.common.player.video.PlaybackRuntimeCorrectionMetrics import org.prairieserver.prairie.common.player.video.PostResumeVideoStallDetector import org.prairieserver.prairie.common.player.isSubtitleSelected +import org.prairieserver.prairie.common.player.renderableMountedTextTracks import org.prairieserver.prairie.common.player.video.VideoPlayerTrackEntry import org.prairieserver.prairie.model.playback.PlaybackExecutionPlan import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo @@ -1039,6 +1040,14 @@ fun PlayerScreen( val backend = videoBackend ?: return if (!viewModel.isCurrentSubtitleMount(mount)) return val currentTracks = backend.player.currentTracks + // A downloaded file's own text tracks are only known now; + // this completes its subtitle menu and runs auto-selection + // before the selection below reads the live state. + viewModel.onLocalMediaTracksChanged( + mediaId = backend.player.currentMediaItem?.mediaId, + textTracks = renderableMountedTextTracks(currentTracks), + hasTracks = !currentTracks.isEmpty, + ) val liveState = viewModel.uiState.value val pendingIdentity = liveState.localSubtitleMountIdentity val targetIdentity = pendingIdentity ?: liveState.committedSubtitleIdentity diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt index a58ccedad..8b0bdb829 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt @@ -85,6 +85,7 @@ import org.prairieserver.prairie.model.subtitles.SubtitleSearchRequest import org.prairieserver.prairie.model.subtitles.SubtitleTranslateRequest import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.common.player.AutoPlayGuard +import org.prairieserver.prairie.common.player.MountedSubtitleTrack import org.prairieserver.prairie.network.ServerRegistry import org.prairieserver.prairie.network.errorMessage import org.prairieserver.prairie.playback.audioTrackFingerprint @@ -135,6 +136,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.prairieserver.prairie.playback.orNullIfBlank import java.util.concurrent.atomic.AtomicBoolean /** @@ -157,7 +159,11 @@ internal fun selectedServerSubtitleTrackIndex( subtitleTracks: List, ): Int? = when (selectedOrdinal) { -1 -> -1 - else -> subtitleTracks.getOrNull(selectedOrdinal)?.index + // A downloaded file's own tracks and saved sidecars carry local menu + // indexes that name no server subtitle; let the server apply preferences. + else -> subtitleTracks.getOrNull(selectedOrdinal) + ?.takeUnless { it.isOfflineLocalSubtitleRow() } + ?.index } data class PlaybackClock( @@ -226,8 +232,12 @@ internal fun PlayerViewModel.PlayerUiState.withPlaybackClock(clock: PlaybackCloc internal fun mobileAudioTrackPersistenceUpdate( committedAudioTrackIndex: Int?, audioTracks: List, + originalDownload: Boolean = false, ): TrackSelectionFingerprintUpdate = committedAudioTrackIndex ?.let(audioTracks::getOrNull) + // Offline manifests number audio rows by position. The online catalog + // omits this field, so its source-track fingerprints use the default 0. + ?.let { if (originalDownload) it.copy(index = 0) else it } ?.let(::audioTrackFingerprint) ?.let(TrackSelectionFingerprintUpdate::Set) ?: TrackSelectionFingerprintUpdate.Preserve @@ -285,6 +295,9 @@ class PlayerViewModel( // Profile-wide seek intervals (settings revision 9). Optional so unit // tests that construct the VM directly keep the legacy fixed intervals. private val seekIntervalStore: org.prairieserver.prairie.common.settings.SeekIntervalStore? = null, + // Cached active profile; offline playback reads subtitle preferences from + // it instead of waiting on the server. Optional for the same reason. + private val activeProfileStore: org.prairieserver.prairie.model.profile.ActiveProfileStore? = null, ) : ViewModel() { // Last load request, replayed by the "Can't reach server" Retry / Try Anyway. @@ -468,6 +481,12 @@ class PlayerViewModel( * current position. */ val subtitleRefreshNonce: Int = 0, + /** + * True when [audioTracks] come from a download's offline manifest and + * list the local file's own audio tracks in file order, so an audio + * choice selects the Media3 audio group at the same position. + */ + val offlineAudioByPosition: Boolean = false, // Live player statistics for phone diagnostics. Populates field-by-field // as PlaybackAnalyticsListener emits decoder, format, bandwidth, and // dropped-frame events. @@ -985,8 +1004,10 @@ class PlayerViewModel( upNextVideoEnded = it.upNextVideoEnded && preservesNextUp, upNextCountdownSeconds = null, stats = PlayerStatsSnapshot(), + offlineAudioByPosition = false, ) } + localSubtitleState = null } private var browseLibraryId: Int? = null @@ -1463,6 +1484,7 @@ class PlayerViewModel( subtitleRefreshNonce = 0, preferredAudioLanguage = playbackState.preferredAudioLanguage, preferredTextLanguage = playbackState.preferredTextLanguage, + offlineAudioByPosition = false, ) } @@ -3160,7 +3182,10 @@ class PlayerViewModel( qualityPreference = currentMobileQualityPreference(), subtitleTracks = state.subtitleTracks, audioTracks = state.audioTracks, - writeScope = finalPositionScope, + // A download's subtitle rows describe the local file, not the + // server's inventory. Saved as the item's preference they would not + // resolve online and would suppress auto-selection there. + writeScope = finalPositionScope.takeUnless { state.isLocalFilePlayback() }, ) private fun currentMobileQualityPreference(): String? = @@ -3339,6 +3364,8 @@ class PlayerViewModel( fun onSelectSubtitle(index: Int) { val state = _uiState.value if (index != -1 && index !in state.subtitleTracks.indices) return + // The viewer chose: a later local track discovery must not auto-select over it. + localSubtitleState = localSubtitleState?.copy(autoSelectionPending = false) val identity = state.subtitleTracks .getOrNull(index) ?.let(::mobileSubtitleIdentity) @@ -3390,6 +3417,10 @@ class PlayerViewModel( ) } setDesiredAudio(serverIndex, explicit = userInitiated) + // A downloaded file is switched on the player only: there is no + // server session to replan, and the desired-audio reconcile above + // already applies the track when the file carries it. + if (state.isLocalFilePlayback()) return // Already in the mounted stream: switch it on the player instead of // rebuilding the session to deliver audio already being received. A // replan is only needed when the track is genuinely absent. @@ -3462,6 +3493,7 @@ class PlayerViewModel( selectedOrdinal = selectedOrdinal, planAudioOrdinal = state.playbackPlan?.selectedTracks?.audioIndex, requiresMountedIdentity = requiresMountedIdentity, + positionalCatalog = state.offlineAudioByPosition, ) ) { AudioReconcileAction.None -> armOriginalAudioSelectionVerification( @@ -3503,7 +3535,9 @@ class PlayerViewModel( localAudioAttemptsFor(desired.generation) >= MAX_LOCAL_AUDIO_ATTEMPTS ) { _pendingLocalAudioSelection.value = null - replanForDesiredAudio(desired) + // A downloaded file has no server plan to change; the + // player's own tracks are all there is. + if (!state.isLocalFilePlayback()) replanForDesiredAudio(desired) return } localAudioAttempt += 1 @@ -3619,8 +3653,11 @@ class PlayerViewModel( private fun persistDesiredAudio(catalogOrdinal: Int) { val state = _uiState.value + // A prepared download's rows describe its own re-encoded tracks, whose + // fingerprints would not match the source's tracks online. + if (state.offlineAudioByPosition) return val context = mobileSubtitleContext(state) - val scope = context.writeScope ?: return + val scope = finalPositionScope ?: return viewModelScope.launch { runCatching { userItemStatePort.recordTrackSelection( @@ -3630,6 +3667,7 @@ class PlayerViewModel( audioUpdate = mobileAudioTrackPersistenceUpdate( committedAudioTrackIndex = catalogOrdinal, audioTracks = context.audioTracks, + originalDownload = state.isLocalFilePlayback(), ), // Untouched: this path changed audio only. subtitleUpdate = TrackSelectionFingerprintUpdate.Preserve, @@ -4698,6 +4736,30 @@ class PlayerViewModel( ?: watchDetail?.posterUrl?.takeIf { url -> url.isNotBlank() } ?: sidecar.posterUrl?.takeIf { url -> url.isNotBlank() } + // Downloads that captured their offline manifest describe the local + // file's own audio tracks (positions in the file) and carry saved + // subtitle sidecars. Older downloads keep the catalog audio rows and + // have no sidecars. + val offlineTracks = sidecar.offlineTracks + val offlineAudioTracks = offlineTracks?.audioTracks.orEmpty() + val offlineAudioByPosition = offlineTracks?.audioByPosition == true && offlineAudioTracks.isNotEmpty() + val localAudioTracks = offlineAudioTracks.ifEmpty { versions[selectedIndex].audioTracks.orEmpty() } + // The selected version carries the same rows: cast, replans and the + // subtitle context read audio indexes from it. + val localVersions = if (offlineAudioTracks.isNotEmpty()) { + versions.mapIndexed { index, version -> + if (index == selectedIndex) version.copy(audioTracks = offlineAudioTracks) else version + } + } else { + versions + } + val localSelectedAudio = offlineTracks?.defaultAudioPosition() ?: 0 + val sidecarSubtitles = withContext(Dispatchers.IO) { + offlineSidecarSubtitleRows(offlineTracks?.subtitles.orEmpty()) + } + val subtitlePreferences = offlineSubtitlePreferences(watchDetail) + if (!ownsLoad(loadOwner)) return false + val published = loadOwners.runIfOwned(loadOwner) { val mountGeneration = expectNextMediaMount() val preservesNextUp = nextUpTransitionGate.expectMount( @@ -4735,12 +4797,20 @@ class PlayerViewModel( isPlaying = true, isPaused = false, isBuffering = false, - versions = versions, + versions = localVersions, selectedVersionIndex = selectedIndex, - audioTracks = versions[selectedIndex].audioTracks ?: emptyList(), - subtitleTracks = emptyList(), // sidecars are remote in v1 - selectedAudioIndex = 0, + audioTracks = localAudioTracks, + // Saved sidecars mount with the file. Text tracks inside the + // file join the list once Media3 reports them + // (onLocalMediaTracksChanged). + subtitleTracks = sidecarSubtitles, + selectedAudioIndex = localSelectedAudio, selectedSubtitleIndex = -1, + committedSubtitleIdentity = SubtitleIdentity.Off, + pendingSubtitleIdentity = null, + localSubtitleMountIdentity = null, + subtitleApplying = false, + offlineAudioByPosition = offlineAudioByPosition, intro = watchDetail?.intro, credits = watchDetail?.credits, recap = watchDetail?.recap, @@ -4759,17 +4829,28 @@ class PlayerViewModel( subtitleRefreshNonce = 0, ) } + localSubtitleState = LocalSubtitleState( + contentId = contentId, + mountGeneration = mountGeneration, + preferences = subtitlePreferences, + ) + mobileSubtitleTransactions.resetContent( + context = mobileSubtitleContext(_uiState.value), + committedIdentity = SubtitleIdentity.Off, + ) Log.i( TAG, - "tryLocalPlayback: serving ${media.displayName} (${media.sizeBytes}B) for content=$contentId (sidecar id=${sidecar.record.id})", + "tryLocalPlayback: serving ${media.displayName} (${media.sizeBytes}B) for content=$contentId " + + "(sidecar id=${sidecar.record.id}) offlineAudio=${offlineAudioTracks.size} " + + "sidecarSubtitles=${sidecarSubtitles.size}", ) } - // Downloaded playback publishes the catalog and hardcodes ordinal 0, but - // Media3 still picks its own default from the file's tracks -- so the - // intent has to exist here too or a multi-audio download cannot be - // corrected. - if (_uiState.value.audioTracks.isNotEmpty()) setDesiredAudio(0, explicit = false) + // Media3 picks its own default from the file's tracks, so the intent + // has to exist here too or a multi-audio download cannot be corrected. + // With manifest data the default is the manifest's pick (the viewer's + // preferred language when the file has it); legacy downloads keep 0. + if (_uiState.value.audioTracks.isNotEmpty()) setDesiredAudio(localSelectedAudio, explicit = false) return published } @@ -4806,6 +4887,111 @@ class PlayerViewModel( super.onCleared() } + // ---- Local (downloaded) subtitle discovery --------------------------------- + + /** + * Subtitle state of the downloaded file being played: the preferences to + * auto-select with, the file's own text tracks last listed, and whether auto-selection still + * has to run. Text tracks inside the file are only known once Media3 has + * parsed it, so the menu is completed, and auto-selection runs, from the + * first track snapshot of this mount. + */ + private data class LocalSubtitleState( + val contentId: String, + val mountGeneration: Long, + val preferences: OfflineSubtitlePreferences, + val embeddedTracks: List? = null, + val autoSelectionPending: Boolean = true, + ) + + private var localSubtitleState: LocalSubtitleState? = null + + /** + * Called by PlayerScreen with every Media3 track snapshot of the current + * mount. Only acts for a downloaded file this ViewModel mounted: adds the + * file's own text tracks to the subtitle menu and runs the one-time + * preference-based subtitle auto-selection. + */ + internal fun onLocalMediaTracksChanged( + mediaId: String?, + textTracks: List, + hasTracks: Boolean, + ) { + val local = localSubtitleState ?: return + val state = _uiState.value + if (!state.isLocalFilePlayback()) return + if (state.contentId != local.contentId || state.mediaMountGeneration != local.mountGeneration) return + if (mediaId != null && mediaId != local.contentId) return + // An empty snapshot is the gap between mounts, not a file without tracks. + if (!hasTracks) return + + var current = local + if (current.embeddedTracks != textTracks) { + val embedded = localEmbeddedSubtitleRows(textTracks) + _uiState.update { + // Replace only the rows for the file's own tracks; the saved + // sidecars (and anything else already listed) stay as they are. + val rows = embedded + it.subtitleTracks.filterNot(PlayerSubtitleInfo::isLocalEmbeddedSubtitleRow) + it.copy( + subtitleTracks = rows, + selectedSubtitleIndex = resolveMobileSubtitleOrdinal(it.committedSubtitleIdentity, rows) ?: -1, + ) + } + current = current.copy(embeddedTracks = textTracks) + localSubtitleState = current + mobileSubtitleTransactions.updatePlaybackContext(mobileSubtitleContext(_uiState.value)) + } + if (!current.autoSelectionPending) return + localSubtitleState = current.copy(autoSelectionPending = false) + + val published = _uiState.value + val selection = resolveMobileAutoSubtitleSelection( + audioTracks = published.audioTracks, + selectedAudioIndex = published.selectedAudioIndex, + subtitles = published.subtitleTracks, + preferredLanguage = current.preferences.preferredLanguage, + subtitleMode = current.preferences.mode, + showForcedSubtitles = current.preferences.showForced, + ) + val identity = when (selection) { + is MobileSubtitleAutoSelection.Select -> + published.subtitleTracks.getOrNull(selection.ordinal)?.let(::mobileSubtitleIdentity) + MobileSubtitleAutoSelection.Disable, + MobileSubtitleAutoSelection.NoChange, + -> null + } ?: return + if (identity == published.committedSubtitleIdentity) return + Log.i(TAG, "local subtitle auto-selection applied") + mobileSubtitleTransactions.updatePlaybackContext(mobileSubtitleContext(published)) + mobileSubtitleTransactions.select(identity) + } + + /** + * Subtitle preferences for offline playback, resolved the way the online + * starter does: the server's effective values for this item when the + * detail could be read, then the cached active profile, then the + * defaults. Nothing here waits on the server, so an unreachable one cannot + * delay the local file. + */ + private suspend fun offlineSubtitlePreferences( + watchDetail: org.prairieserver.prairie.model.catalog.WatchDetail?, + ): OfflineSubtitlePreferences { + val language = watchDetail?.effectiveSubtitleLanguage.orNullIfBlank() + val mode = watchDetail?.effectiveSubtitleMode.orNullIfBlank() + val forced = watchDetail?.effectiveShowForcedSubtitles + val profile = if (language == null || mode == null || forced == null) { + val activeId = profileRepository.getActiveProfileId() + activeProfileStore?.activeProfile?.value?.takeIf { it.id == activeId } + } else { + null + } + return OfflineSubtitlePreferences( + preferredLanguage = language ?: profile?.subtitleLanguage.orNullIfBlank(), + mode = mode ?: profile?.subtitleMode.orNullIfBlank(), + showForced = forced ?: profile?.showForcedSubtitles ?: true, + ) + } + private suspend fun resolveDownloadScope(): Pair { val serverId = serverRegistry.activeServerId.value ?: DownloadEnqueuer.DEFAULT_SERVER_ID val profileId = profileRepository.getActiveProfileId() ?: DownloadEnqueuer.DEFAULT_PROFILE_ID @@ -4823,6 +5009,16 @@ private const val PLAYBACK_PAUSE_GRACE_MS = 1_500L /** Snapshots to let a local audio switch take before asking the server. */ private const val MAX_LOCAL_AUDIO_ATTEMPTS = 3 +/** + * A downloaded file mounted by the offline-first path: a local URI with no + * server session or playback plan behind it. + */ +internal fun PlayerViewModel.PlayerUiState.isLocalFilePlayback(): Boolean { + val url = streamUrl ?: return false + return sessionId == null && playbackPlan == null && + (url.startsWith("file://") || url.startsWith("content://")) +} + internal fun authoritativePlaybackSubtitleOrdinal( serverIndex: Int?, playbackTracks: List, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestDetailScreen.kt index e65a0a109..99abe345a 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/requests/RequestDetailScreen.kt @@ -42,6 +42,7 @@ import org.prairieserver.prairie.common.ui.components.DeferImagePresentationWhil import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.request.RequestMediaDetail import org.prairieserver.prairie.model.request.RequestMediaResult +import org.prairieserver.prairie.model.request.reasonMessage import org.prairieserver.prairie.model.request.requestBackdropUrl import org.prairieserver.prairie.model.request.requestDisplayLabel import org.prairieserver.prairie.model.request.requestPosterUrl @@ -344,9 +345,11 @@ private fun RequestActions( @Composable private fun RequestDetailStatus(detail: RequestMediaDetail) { - val status = detail.request.reason - .takeIf { it.isNotBlank() } - ?: detail.availability.requestDisplayLabel() + val status = if (detail.request.reason.isBlank()) { + detail.availability.requestDisplayLabel() + } else { + detail.request.reasonMessage() + } ?: return Text( text = status, style = MaterialTheme.typography.bodySmall, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt index 1bbf3946a..02666ed91 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt @@ -370,7 +370,7 @@ fun SettingsScreen( label = "Download quality", description = "Quality preset used for new downloads.", value = state.defaultDownloadQuality, - options = DownloadQuality.entries.map { it.label }, + options = state.downloadQualityOptions, onOptionSelected = viewModel::setDefaultDownloadQuality, ) SettingsSwitchRow( diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModel.kt index 845f82ac8..1fdf9b0e1 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModel.kt @@ -16,6 +16,9 @@ import org.prairieserver.prairie.domain.player.IntroSkipMode import org.prairieserver.prairie.domain.settings.ProfileSettingsController import org.prairieserver.prairie.model.auth.User import org.prairieserver.prairie.model.download.DownloadQuality +import org.prairieserver.prairie.model.download.effectiveDefault +import org.prairieserver.prairie.model.download.labelFor +import org.prairieserver.prairie.repository.DownloadsRepository import org.prairieserver.prairie.model.notifications.NotificationPreferencesUpdate import org.prairieserver.prairie.model.settings.CardCaption import org.prairieserver.prairie.model.settings.CardPosterSize @@ -94,6 +97,9 @@ data class SettingsUiState( val downloadsWifiOnly: Boolean = true, val keepWatchedDownloads: Boolean = false, val defaultDownloadQuality: String = DownloadQuality.Original.label, + // The presets this account may request, labelled with the server's + // resolution ceiling once the download capability has loaded. + val downloadQualityOptions: List = DownloadQuality.entries.map { it.label }, // Subtitles // BCP 47 tag, "" = off. The picker converts to and from labels. @@ -131,11 +137,19 @@ class SettingsViewModel( private val cardPresentationStore: CardPresentationStore, private val seekIntervalStore: SeekIntervalStore, audiobookSettingsStore: AudiobookSettingsStore, + private val downloadsRepository: DownloadsRepository? = null, ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var subtitleLanguageEditGeneration = 0L + private var subtitleModeEditGeneration = 0L + private var forcedSubtitlesEditGeneration = 0L + private var subtitleLanguageConfirmedGeneration = 0L + private var subtitleModeConfirmedGeneration = 0L + private var forcedSubtitlesConfirmedGeneration = 0L + /** Profile-wide video and audiobook skip intervals (settings revision 9). */ val seekIntervals = SeekIntervalSettingsModel(seekIntervalStore, audiobookSettingsStore, viewModelScope) @@ -233,9 +247,20 @@ class SettingsViewModel( playerSettingsStore.keepWatchedDownloadsFlow.onEach { keepWatched -> _uiState.update { it.copy(keepWatchedDownloads = keepWatched) } }.launchIn(viewModelScope) - playerSettingsStore.defaultDownloadQualityFlow.onEach { quality -> - _uiState.update { it.copy(defaultDownloadQuality = downloadQualityLabel(quality)) } + val downloadCapability = downloadsRepository?.capability ?: MutableStateFlow(null) + combine(playerSettingsStore.defaultDownloadQualityFlow, downloadCapability) { quality, capability -> + val offered = capability?.allowedQualities() ?: DownloadQuality.entries + _uiState.update { + it.copy( + // The preset new downloads will actually use. + defaultDownloadQuality = capability.labelFor(capability.effectiveDefault(DownloadQuality.fromWire(quality))), + downloadQualityOptions = offered.map { preset -> capability.labelFor(preset) }, + ) + } }.launchIn(viewModelScope) + // Opening Settings refreshes the capability, as the detail screen does, + // so the labels reflect the server's current download settings. + downloadsRepository?.let { repository -> viewModelScope.launch { repository.refreshCapability() } } playerSettingsStore.pictureInPictureEnabledFlow.onEach { enabled -> _uiState.update { it.copy(pictureInPictureEnabled = enabled) } }.launchIn(viewModelScope) @@ -539,8 +564,14 @@ class SettingsViewModel( } } + // The subtitle setters below also store each write's confirmed value in + // the cached profile, which offline playback reads for its subtitle + // preferences. Only a newer successful write to the same field supersedes + // a confirmed value; pending or failed edits leave that value available. + /** [language] is a BCP 47 tag, or "" for off. */ fun setSubtitleLanguage(language: String) { + val editGeneration = ++subtitleLanguageEditGeneration val previous = _uiState.value.subtitleLanguage _uiState.update { it.copy(subtitleLanguage = language) } viewModelScope.launch { @@ -550,12 +581,18 @@ class SettingsViewModel( if (it.subtitleLanguage == language) it.copy(subtitleLanguage = previous) else it } } else { - applyResolved(result.snapshot, edited = language) { it.subtitleLanguage } + if (editGeneration > subtitleLanguageConfirmedGeneration) { + applyResolved(result.snapshot, edited = language) { it.subtitleLanguage } + subtitleLanguageConfirmedGeneration = editGeneration + val confirmed = result.snapshot?.subtitleLanguage ?: language + activeProfileStore.update { it.copy(subtitleLanguage = confirmed) } + } } } } fun setSubtitleMode(mode: SubtitleMode) { + val editGeneration = ++subtitleModeEditGeneration val previous = _uiState.value.subtitleMode _uiState.update { it.copy(subtitleMode = mode) } viewModelScope.launch { @@ -565,12 +602,18 @@ class SettingsViewModel( if (it.subtitleMode == mode) it.copy(subtitleMode = previous) else it } } else { - applyResolved(result.snapshot, edited = mode.wire) { it.subtitleMode } + if (editGeneration > subtitleModeConfirmedGeneration) { + applyResolved(result.snapshot, edited = mode.wire) { it.subtitleMode } + subtitleModeConfirmedGeneration = editGeneration + val confirmed = result.snapshot?.subtitleMode ?: mode.wire + activeProfileStore.update { it.copy(subtitleMode = confirmed) } + } } } } fun setShowForcedSubtitles(enabled: Boolean) { + val editGeneration = ++forcedSubtitlesEditGeneration val previous = _uiState.value.showForcedSubtitles _uiState.update { it.copy(showForcedSubtitles = enabled) } viewModelScope.launch { @@ -580,8 +623,13 @@ class SettingsViewModel( if (it.showForcedSubtitles == enabled) it.copy(showForcedSubtitles = previous) else it } } else { - applyResolved(result.snapshot, edited = enabled.toString()) { - it.showForcedSubtitles.toString() + if (editGeneration > forcedSubtitlesConfirmedGeneration) { + applyResolved(result.snapshot, edited = enabled.toString()) { + it.showForcedSubtitles.toString() + } + forcedSubtitlesConfirmedGeneration = editGeneration + val confirmed = result.snapshot?.showForcedSubtitles ?: enabled + activeProfileStore.update { it.copy(showForcedSubtitles = confirmed) } } } } @@ -627,9 +675,10 @@ class SettingsViewModel( } } - private fun downloadQualityLabel(value: String): String = - DownloadQuality.fromWire(value).label - + // A shown label is the preset's bitrate label plus an optional + // " · up to …" suffix, so the bitrate part alone identifies the preset + // even if the capability refreshed after the list was drawn. private fun downloadQualityWireValue(value: String): String = - DownloadQuality.entries.firstOrNull { it.label == value }?.wire ?: DownloadQuality.Original.wire + DownloadQuality.entries.firstOrNull { it.label == value.substringBefore(" · ") }?.wire + ?: DownloadQuality.Original.wire } diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailDisplayVersionTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailDisplayVersionTest.kt new file mode 100644 index 000000000..d589002db --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailDisplayVersionTest.kt @@ -0,0 +1,79 @@ +package org.prairieserver.prairie.android.ui.screens.detail + +import org.prairieserver.prairie.model.catalog.FileVersion +import kotlin.test.Test +import kotlin.test.assertEquals + +class DetailDisplayVersionTest { + // The 1080p file is listed first; the 720p file is the one with subtitles + // the profile last played. + private val versions = listOf( + FileVersion(fileId = 1080, resolution = "1080p"), + FileVersion(fileId = 720, resolution = "720p"), + ) + + @Test + fun `auto shows and downloads the last played version, not the first listed`() { + val index = detailDisplayVersionIndex( + versions = versions, + explicitIndex = null, + lastFileId = 720, + preferredQuality = "auto", + fallbackIndex = 0, + ) + + assertEquals(720, versions[index].fileId) + } + + @Test + fun `an explicit pick wins over the last played version`() { + val index = detailDisplayVersionIndex( + versions = versions, + explicitIndex = 0, + lastFileId = 720, + preferredQuality = "auto", + fallbackIndex = 0, + ) + + assertEquals(1080, versions[index].fileId) + } + + @Test + fun `auto names no version until the quality preference loads`() { + val index = detailDisplayVersionIndex( + versions = versions, + explicitIndex = null, + lastFileId = null, + preferredQuality = null, + fallbackIndex = 0, + ) + + assertEquals(-1, index) + } + + @Test + fun `an explicit pick with no versions loaded yields the fallback`() { + val index = detailDisplayVersionIndex( + versions = emptyList(), + explicitIndex = 1, + lastFileId = null, + preferredQuality = "auto", + fallbackIndex = 0, + ) + + assertEquals(0, index) + } + + @Test + fun `a stale explicit pick names no version instead of another one`() { + val index = detailDisplayVersionIndex( + versions = versions, + explicitIndex = 5, + lastFileId = 720, + preferredQuality = "auto", + fallbackIndex = 0, + ) + + assertEquals(-1, index) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileAudioTrackSelectionTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileAudioTrackSelectionTest.kt index 7002e5824..650a38ed8 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileAudioTrackSelectionTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileAudioTrackSelectionTest.kt @@ -1,10 +1,13 @@ package org.prairieserver.prairie.android.ui.screens.player +import kotlinx.serialization.json.Json import org.prairieserver.prairie.model.catalog.AudioTrack import org.prairieserver.prairie.playback.audioTrackFingerprint +import org.prairieserver.prairie.playback.resolveAudioTrackOrdinal import org.prairieserver.prairie.repository.port.TrackSelectionFingerprintUpdate import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertNull /** @@ -62,6 +65,22 @@ class MobileAudioTrackSelectionTest { ) } + @Test + fun `an original download choice restores against the online catalog`() { + val catalog = Json.decodeFromString>( + """[{"language":"eng","codec":"aac","title":"English"},{"language":"deu","codec":"aac","title":"German"}]""", + ) + val manifestTracks = catalog.mapIndexed { ordinal, track -> track.copy(index = ordinal) } + val update = assertIs(mobileAudioTrackPersistenceUpdate( + committedAudioTrackIndex = 1, + audioTracks = manifestTracks, + originalDownload = true, + )) + + assertEquals(1, resolveAudioTrackOrdinal(catalog, update.fingerprint)) + assertEquals(1, selectedServerAudioTrackIndex(1, manifestTracks)) + } + @Test fun `no committed audio preserves whatever was stored`() { assertEquals( diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/OfflinePlaybackTracksTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/OfflinePlaybackTracksTest.kt new file mode 100644 index 000000000..8f2333a24 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/OfflinePlaybackTracksTest.kt @@ -0,0 +1,117 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import org.prairieserver.prairie.common.player.MountedSubtitleTrack +import org.prairieserver.prairie.common.player.resolveMountedSubtitle +import org.prairieserver.prairie.model.catalog.AudioTrack +import org.prairieserver.prairie.model.download.OfflineSubtitleFile +import org.prairieserver.prairie.model.playback.SubtitleIdentity +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OfflinePlaybackTracksTest { + + private val files = listOf( + OfflineSubtitleFile(path = "/data/app/subs/0.ass", format = "ass", language = "eng", title = "Signs & Songs"), + OfflineSubtitleFile(path = "/data/app/subs/1.sup", format = "pgs", language = "eng", forced = true), + OfflineSubtitleFile(path = "/data/app/subs/2.srt", format = "srt", language = "fre", hearingImpaired = true), + ) + + /** A prepared MP4's timed text, the two merged sidecars, and a bitmap track the menu drops. */ + private val mediaTracks = listOf( + textTrack(0, "0:3", null, "eng", "application/x-quicktime-tx3g"), + textTrack(1, "0:4", null, "spa", "application/x-quicktime-tx3g"), + textTrack(2, "1:prairie-subtitle:0", "English", "eng", "text/x-ssa"), + textTrack(3, "2:prairie-subtitle:1", "English", "eng", "application/pgs", forced = true), + textTrack(4, "0:5", null, "ger", "application/vobsub"), + ) + + @Test + fun sidecarRowsSkipMissingFilesAndPointAtTheLocalFile() { + val rows = offlineSidecarSubtitleRows(files) { !it.endsWith("1.sup") } + + assertEquals(listOf(0, 1), rows.map { it.index }) + assertEquals(listOf("eng", "fre"), rows.map { it.language }) + assertEquals("file:///data/app/subs/0.ass", rows[0].url) + assertEquals("Signs & Songs", rows[0].label) + assertEquals("SDH", rows[1].label) + } + + @Test + fun offlineLocalRowsNeverBecomeServerSubtitleIndexes() { + val rows = offlineSidecarSubtitleRows(files) { true } + localEmbeddedSubtitleRows(mediaTracks) + + // Cast and replans send this index to the server; a local menu position + // names no server subtitle, so the server applies preferences instead. + rows.indices.forEach { ordinal -> assertNull(selectedServerSubtitleTrackIndex(ordinal, rows)) } + assertEquals(-1, selectedServerSubtitleTrackIndex(-1, rows)) + } + + @Test + fun sidecarSelectionResolvesToItsMergedMedia3Track() { + val rows = offlineSidecarSubtitleRows(files) { true } + + val identity = assertIs(mobileSubtitleIdentity(rows[1])) + assertEquals(1, identity.serverIndex) + val match = assertNotNull(resolveMountedSubtitle(identity, mediaTracks)) + assertEquals("2:prairie-subtitle:1", match.track.trackId) + } + + @Test + fun embeddedRowsListOnlyTheFilesOwnRenderableTracks() { + val rows = localEmbeddedSubtitleRows(mediaTracks) + + assertEquals(listOf("0:3", "0:4"), rows.map { it.mediaTrackId }) + assertTrue(rows.all { it.url.isBlank() && it.source == "embedded" }) + assertTrue(rows.all { it.index >= OFFLINE_EMBEDDED_SUBTITLE_INDEX_BASE }) + val identity = assertIs(mobileSubtitleIdentity(rows[1])) + val match = assertNotNull(resolveMountedSubtitle(identity, mediaTracks)) + assertEquals("0:4", match.track.trackId) + } + + @Test + fun menuSelectionResolvesBackToTheSameRow() { + val rows = localEmbeddedSubtitleRows(mediaTracks) + offlineSidecarSubtitleRows(files) { true } + + rows.forEachIndexed { ordinal, row -> + assertEquals(ordinal, resolveMobileSubtitleOrdinal(mobileSubtitleIdentity(row), rows)) + } + } + + @Test + fun preferencesPickASubtitleWhileTheFileDefaultFlagIsIgnored() { + val rows = localEmbeddedSubtitleRows(mediaTracks) + offlineSidecarSubtitleRows(files) { true } + val audio = listOf( + AudioTrack(index = 0, codec = "aac", channels = 2, language = "jpn"), + AudioTrack(index = 1, codec = "aac", channels = 2, language = "eng"), + ) + + // Japanese audio with an English subtitle preference: the first + // full English text track. + assertEquals( + MobileSubtitleAutoSelection.Select(0), + resolveMobileAutoSubtitleSelection(audio, 0, rows, "eng", null, showForcedSubtitles = true), + ) + // English audio: only a forced English track may turn on. + val forced = resolveMobileAutoSubtitleSelection(audio, 1, rows, "eng", null, showForcedSubtitles = true) + val forcedRow = rows[(forced as MobileSubtitleAutoSelection.Select).ordinal] + assertEquals(true, forcedRow.forced) + // No preference: nothing turns on, whatever the file marks default. + assertEquals( + MobileSubtitleAutoSelection.NoChange, + resolveMobileAutoSubtitleSelection(audio, 0, rows, null, null, showForcedSubtitles = true), + ) + } + + private fun textTrack( + index: Int, + trackId: String, + label: String?, + language: String, + codec: String, + forced: Boolean = false, + ) = MountedSubtitleTrack(index, trackId, label, language, codec, forced, hearingImpaired = false) +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt new file mode 100644 index 000000000..2d2703717 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt @@ -0,0 +1,190 @@ +package org.prairieserver.prairie.android.ui.screens.settings + +import android.app.Application +import androidx.lifecycle.viewModelScope +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.prairieserver.prairie.common.player.AudiobookSettingsStore +import org.prairieserver.prairie.common.settings.CardPresentationUiState +import org.prairieserver.prairie.domain.settings.ProfileSettingsController +import org.prairieserver.prairie.model.profile.ActiveProfileStore +import org.prairieserver.prairie.model.profile.Profile +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.EffectiveSettingValuesResponse +import org.prairieserver.prairie.model.settings.SeekIntervalState +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingScopeIdentity +import org.prairieserver.prairie.model.settings.SettingsContractCapabilities +import org.prairieserver.prairie.model.settings.StoredSettingValue +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.api.AuthApi +import org.prairieserver.prairie.network.api.ProfileApi +import org.prairieserver.prairie.network.api.SettingsApi +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.network.apiv2.NotificationsV2Api +import org.prairieserver.prairie.network.apiv2.SettingsV2Api +import org.prairieserver.prairie.repository.AuthRepository +import org.prairieserver.prairie.repository.NotificationsRepository +import org.prairieserver.prairie.repository.ProfileRepository +import org.prairieserver.prairie.repository.SettingsRepository +import java.lang.reflect.Proxy +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [28]) +class SettingsViewModelOfflinePreferencesTest { + @Test + fun unrelatedResolvedOverrideDoesNotDiscardAConfirmedSubtitleMode() = scenario { vm, api, profiles -> + val modeReply = api.queueReply() + vm.setSubtitleMode(SubtitleMode.ALWAYS) + runCurrent() + val metadataReply = api.queueReply() + vm.setMetadataLanguage("fr") + runCurrent() + + // The device overrides both keys. The unrelated response replaces the + // optimistic mode before its own successful response reaches the screen. + metadataReply.complete(snapshot(mode = "off", metadataLanguage = "en")) + runCurrent() + assertEquals(SubtitleMode.OFF, vm.uiState.value.subtitleMode) + modeReply.complete(snapshot(mode = "off")) + runCurrent() + + assertEquals("off", profiles.activeProfile.value?.subtitleMode) + } + + @Test + fun anOlderResponseCannotWinWhenANewerEditReturnsToTheSameValue() = scenario { vm, api, profiles -> + val oldReply = api.queueReply() + vm.setSubtitleMode(SubtitleMode.OFF) + runCurrent() + val middleReply = api.queueReply() + vm.setSubtitleMode(SubtitleMode.ALWAYS) + runCurrent() + val newestReply = api.queueReply() + vm.setSubtitleMode(SubtitleMode.OFF) + runCurrent() + + newestReply.complete(snapshot(mode = "off")) + runCurrent() + middleReply.complete(snapshot(mode = "always")) + runCurrent() + // This older write was narrowed by the effective settings response. + oldReply.complete(snapshot(mode = "auto")) + runCurrent() + + assertEquals(SubtitleMode.OFF, vm.uiState.value.subtitleMode) + assertEquals("off", profiles.activeProfile.value?.subtitleMode) + } + + @Test + fun aFailedNewerEditDoesNotDiscardAnEarlierConfirmedWrite() = scenario { vm, api, profiles -> + val earlierReply = api.queueReply() + vm.setSubtitleMode(SubtitleMode.ALWAYS) + runCurrent() + api.failNextWrite = true + vm.setSubtitleMode(SubtitleMode.OFF) + runCurrent() + assertEquals(SubtitleMode.ALWAYS, vm.uiState.value.subtitleMode) + + earlierReply.complete(snapshot(mode = "always")) + runCurrent() + + assertEquals("always", profiles.activeProfile.value?.subtitleMode) + } + + private fun scenario( + block: suspend TestScope.(SettingsViewModel, PendingSettingsApi, ActiveProfileStore) -> Unit, + ) = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val client = HttpClient(MockEngine { respond("", HttpStatusCode.ServiceUnavailable) }) + val tokens = TokenManagerImpl() + val api = PendingSettingsApi(client, tokens) + val profiles = ActiveProfileStore(object : ProfileRepository(ProfileApi(client, ApiV2Gate.Unrestricted), tokens) { + override suspend fun getActiveProfileId() = "p1" + override suspend fun listProfiles() = ApiResult.Success(listOf(Profile(id = "p1", name = "Test", subtitleMode = "auto"))) + }) + profiles.refresh() + val vm = SettingsViewModel( + authRepository = AuthRepository(AuthApi(client, ApiV2Gate.Unrestricted), tokens), + playerSettingsStore = idleStore(), + libraryPlaybackPrefsStore = idleStore(), + overlayPrefsStore = idleStore(), + activeProfileStore = profiles, + notificationsRepository = NotificationsRepository(NotificationsV2Api(client, tokens, ApiV2Gate.Unrestricted)), + profileSettings = ProfileSettingsController(SettingsRepository(api)), + cardPresentationStore = idleStore(mapOf("getState" to MutableStateFlow(CardPresentationUiState()))), + seekIntervalStore = idleStore(mapOf( + "getState" to MutableStateFlow(SeekIntervalState()), + "getLastError" to MutableStateFlow(null), + )), + audiobookSettingsStore = AudiobookSettingsStore(RuntimeEnvironment.getApplication(), { null }), + ) + try { + runCurrent() + block(vm, api, profiles) + } finally { + vm.viewModelScope.cancel() + client.close() + Dispatchers.resetMain() + } + } + + /** Unrelated settings stores do not emit or start network work in these tests. */ + private inline fun idleStore(values: Map = emptyMap()): T = + Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, method, _ -> + values[method.name] ?: if (Flow::class.java.isAssignableFrom(method.returnType)) emptyFlow() else Unit + } as T + + private class PendingSettingsApi(client: HttpClient, tokens: TokenManagerImpl) : + SettingsApi(SettingsV2Api(client, tokens, ApiV2Gate.Unrestricted)) { + private val replies = ArrayDeque>>() + var failNextWrite = false + fun queueReply() = CompletableDeferred>().also(replies::addLast) + + override suspend fun getContractCapabilities() = ApiResult.Success(SettingsContractCapabilities(manifestRevision = 1)) + override suspend fun getEffectiveValues(keys: List, libraryIds: List, seriesIds: List, authority: AuthScopeSnapshot?) = + if (replies.isEmpty()) snapshot() else replies.removeFirst().await() + override suspend fun putValue(key: String, scope: SettingScopeIdentity, value: JsonElement, profileId: String?, authority: AuthScopeSnapshot?): ApiResult = + if (failNextWrite) { + failNextWrite = false + ApiResult.Error(503, "unavailable", "Unavailable") + } else { + ApiResult.Success(StoredSettingValue(key = key, scope = "profile")) + } + } + + companion object { + private fun snapshot(mode: String = "auto", metadataLanguage: String = "") = ApiResult.Success( + EffectiveSettingValuesResponse(settings = listOf( + EffectiveSettingValue(key = SettingKeys.PLAYBACK_SUBTITLE_MODE, value = JsonPrimitive(mode)), + EffectiveSettingValue(key = SettingKeys.CATALOG_METADATA_LANGUAGE, value = JsonPrimitive(metadataLanguage)), + )), + ) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt index 2ac235417..fc64c1f5d 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt @@ -135,8 +135,8 @@ import org.prairieserver.prairie.viewmodel.CalendarViewModel * "Monday, June 9") over a HORIZONTAL row (LazyRow) of portrait poster * cards. Event-less days render a "Nothing scheduled" stub so the week keeps * its shape and every day is a scroll target. - * - Whole-screen empty state with a focusable action ("Show Everything" when - * filtered, else "Refresh") and a filter-aware title. + * - Whole-screen empty state with focusable links to the other two views and + * a view-specific title. * * Mirrors [org.prairieserver.prairie.tv.ui.screens.recommendations.TvRecommendationsScreen] * for the koinViewModel + initial-focus-once pattern. @@ -488,7 +488,16 @@ fun TvCalendarScreen( } }, onRefresh = viewModel::refresh, - onShowEverything = { viewModel.setFilter(CalendarFilter.All) }, + onSelectFilter = { filter -> + viewModel.setFilter(filter) + // The pressed link leaves with the old empty state; move + // focus to the new view's filter chip so it doesn't fall + // through to the top menu. + filterFocusRequesters[filter]?.claimFocusOrReport( + target = "calendar_filter", + action = "empty_state_link", + ) + }, selectedDayFocusRequester = selectedDayFocusRequester, shelfFocusDay = shelfFocusDay, shelfFocusRequest = shelfFocusRequest, @@ -1009,7 +1018,7 @@ private fun CalendarList( onControlFocused: (CalendarControlFocusZone?) -> Unit, onFocusRequestAcknowledged: () -> Unit, onRefresh: () -> Unit, - onShowEverything: () -> Unit, + onSelectFilter: (String) -> Unit, selectedDayFocusRequester: FocusRequester, shelfFocusDay: String?, shelfFocusRequest: Int, @@ -1167,7 +1176,7 @@ private fun CalendarList( CalendarMessage( title = state.error ?: "Failed to load calendar", subtitle = "Press the week arrows to try another week.", - action = CalendarAction("Refresh", onRefresh), + actions = listOf(CalendarAction("Refresh", onRefresh)), onActionFocused = clearControlFocusZone, ) } @@ -1177,10 +1186,8 @@ private fun CalendarList( CalendarMessage( title = emptyTitle(state.filter), subtitle = emptyCopy(state.filter), - action = if (state.filter != CalendarFilter.All) { - CalendarAction("Show Everything", onShowEverything) - } else { - CalendarAction("Refresh", onRefresh) + actions = emptyStateLinks(state.filter).map { (value, label) -> + CalendarAction(label) { onSelectFilter(value) } }, topAligned = true, onActionFocused = clearControlFocusZone, @@ -1561,6 +1568,9 @@ private fun BadgePill(text: String) { fontWeight = FontWeight.Bold, color = Color.Black, maxLines = 1, + // Wrapping drew only the first word in a full-width pill. + softWrap = false, + overflow = TextOverflow.Ellipsis, ) } } @@ -1574,7 +1584,7 @@ private data class CalendarAction(val label: String, val onClick: () -> Unit) private fun CalendarMessage( title: String, subtitle: String, - action: CalendarAction, + actions: List, topAligned: Boolean = false, onActionFocused: () -> Unit, ) { @@ -1613,34 +1623,38 @@ private fun CalendarMessage( color = Color.White.copy(alpha = 0.62f), ) Spacer(modifier = Modifier.height(2.dp)) - Surface( - onClick = action.onClick, - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(100.dp)), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color.White.copy(alpha = 0.10f), - contentColor = Color.White, - focusedContainerColor = FocusedContainer, - focusedContentColor = FocusedContent, - pressedContainerColor = FocusedContainer, - pressedContentColor = FocusedContent, - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), - modifier = Modifier.onFocusChanged { if (it.isFocused) onActionFocused() }, - ) { - Box( - modifier = Modifier - .width(140.dp) - .padding(vertical = 7.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = action.label, - style = MaterialTheme.typography.titleMedium.copy( - fontSize = 15.5.sp, - lineHeight = 18.5.sp, + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + actions.forEach { action -> + Surface( + onClick = action.onClick, + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(100.dp)), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.White.copy(alpha = 0.10f), + contentColor = Color.White, + focusedContainerColor = FocusedContainer, + focusedContentColor = FocusedContent, + pressedContainerColor = FocusedContainer, + pressedContentColor = FocusedContent, ), - fontWeight = FontWeight.SemiBold, - ) + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), + modifier = Modifier.onFocusChanged { if (it.isFocused) onActionFocused() }, + ) { + Box( + modifier = Modifier + .width(140.dp) + .padding(vertical = 7.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = action.label, + style = MaterialTheme.typography.titleMedium.copy( + fontSize = 15.5.sp, + lineHeight = 18.5.sp, + ), + fontWeight = FontWeight.SemiBold, + ) + } + } } } } @@ -1650,7 +1664,9 @@ private fun CalendarMessage( // MARK: - Formatting helpers private fun badgeLabel(badge: String): String? = when (badge) { - CalendarBadge.SeriesPremiere -> "SERIES PREMIERE" + // tvOS says "SERIES PREMIERE", which does not fit a poster at 14sp; + // NEW SEASON already marks season premieres. + CalendarBadge.SeriesPremiere -> "PREMIERE" CalendarBadge.SeasonPremiere -> "NEW SEASON" CalendarBadge.Finale -> "FINALE" else -> null @@ -1673,8 +1689,23 @@ private fun cardSubtitle(item: CalendarItem): String? { return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ") } +/** + * The views an empty week links to: always the other two, never the one on + * screen (silo-server #1494, silo-apple #513). "everything" is the phone's + * spelling of All; any other legacy filter links to all three views. + */ +private fun emptyStateLinks(filter: String): List> { + val view = if (filter == CalendarFilter.Everything) CalendarFilter.All else filter + return listOf( + CalendarFilter.Following to "Following", + CalendarFilter.Trending to "Trending", + CalendarFilter.All to "All", + ).filter { (value, _) -> value != view } +} + private fun emptyTitle(filter: String): String = when (filter) { CalendarFilter.Following -> "Nothing from shows you follow" + CalendarFilter.Trending -> "Nothing trending this week" else -> "Nothing scheduled this week" } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestDetailScreen.kt index 0baf91f05..4b04fa3c4 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestDetailScreen.kt @@ -2,6 +2,7 @@ package org.prairieserver.prairie.tv.ui.screens.requests import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -15,9 +16,19 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale @@ -32,12 +43,18 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.request.RequestMediaDetail +import org.prairieserver.prairie.model.request.RequestState +import org.prairieserver.prairie.model.request.reasonMessage import org.prairieserver.prairie.model.request.requestBackdropUrl +import org.prairieserver.prairie.model.request.requestDisplayLabel import org.prairieserver.prairie.model.request.requestPosterUrl import org.prairieserver.prairie.tv.ui.components.TvErrorScreen import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen -import org.prairieserver.prairie.tv.ui.theme.PrairieBlue +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved import org.prairieserver.prairie.tv.ui.theme.RowDimens +import org.prairieserver.prairie.tv.ui.theme.PrairieBlue import org.prairieserver.prairie.tv.ui.theme.cardScaled import org.prairieserver.prairie.tv.ui.theme.sectionEyebrow import org.prairieserver.prairie.viewmodel.RequestDetailViewModel @@ -47,8 +64,8 @@ import org.koin.core.parameter.parametersOf /** * TV request detail — the 10-foot counterpart to the phone RequestDetailScreen. * Reuses the shared [RequestDetailViewModel] (load + submitRequest) keyed by - * (mediaType, tmdbId). Shows title/metadata/genres/overview and a Request - * action when the title is requestable, plus the current request status. + * (mediaType, tmdbId). Shows title/metadata/genres/overview and one primary + * action: Request when the title is requestable, otherwise the request status. */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -56,19 +73,71 @@ fun TvRequestDetailScreen( mediaType: String, tmdbId: Int, onBack: () -> Unit, + /** + * False while the shell has an overlay (a cascade panel or the profile + * menu) that Back should close first. This handler registers after the + * shell's, so on Android 16 it would otherwise take that press and pop + * the page from under the open overlay. + */ + backEnabled: Boolean = true, + onInitialContentFocus: () -> Unit = {}, viewModel: RequestDetailViewModel = koinViewModel { parametersOf(mediaType, tmdbId) }, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val loadingFocusRequester = remember { FocusRequester() } + val primaryActionFocusRequester = remember { FocusRequester() } + var pageHasFocus by remember { mutableStateOf(false) } + var pageHadFocus by remember { mutableStateOf(false) } + val showsLoading = state.isLoading && state.detail == null + // The title, not the loaded detail: the refresh after a submit replaces the + // detail and must not re-run the claim below. + val detailKey = state.detail?.let { "${it.mediaType}:${it.tmdbId}" } + val detailArrived by rememberUpdatedState(detailKey != null) - BackHandler(enabled = true) { onBack() } + // Nothing here is focusable until the detail loads, and the row that opened + // the page is about to be disposed, so Compose would re-home focus onto the + // top bar's Search button. Hold it on the loading indicator instead: focus + // stays in the page, and a move to the bar while this loads is the viewer's. + LaunchedEffect(showsLoading) { + if (!showsLoading) return@LaunchedEffect + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = loadingFocusRequester::requestFocus, + isFocused = { pageHasFocus || detailArrived }, + ) + } + + // Hand focus to the primary action when the detail arrives, but only if it + // is still in the page (or never got here). A viewer who went up to the bar + // or into the profile menu while this loaded keeps their place. Read in the + // composition that swaps the indicator for the content, so it is the focus + // from before the swap. + val claimOnArrival = remember(detailKey) { pageHasFocus || !pageHadFocus } + LaunchedEffect(detailKey) { + if (detailKey == null || !claimOnArrival) return@LaunchedEffect + val result = requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = primaryActionFocusRequester::requestFocus, + isFocused = { pageHasFocus }, + ) + if (result == TvObservedFocusResult.Focused) onInitialContentFocus() + } + + BackHandler(enabled = backEnabled) { onBack() } Column( modifier = Modifier .fillMaxSize() + .onFocusChanged { + pageHasFocus = it.hasFocus + if (it.hasFocus) pageHadFocus = true + } .background(MaterialTheme.colorScheme.background), ) { when { - state.isLoading && state.detail == null -> TvLoadingScreen() + showsLoading -> RequestDetailLoading(focusRequester = loadingFocusRequester) state.error != null && state.detail == null -> TvErrorScreen( message = state.error ?: "Failed to load this title.", onRetry = viewModel::load, @@ -79,11 +148,30 @@ fun TvRequestDetailScreen( notice = state.notice, error = state.error, onRequest = viewModel::submitRequest, + primaryActionFocusRequester = primaryActionFocusRequester, ) } } } +/** + * The loading indicator plus a spinner-sized focus target over it, so focus + * can wait inside the page. Kept small and centred: D-pad Up from it has to + * find the top bar by geometry, which a full-screen target would not. + */ +@Composable +private fun RequestDetailLoading(focusRequester: FocusRequester) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + TvLoadingScreen() + Box( + modifier = Modifier + .size(64.dp) + .focusRequester(focusRequester) + .focusable(), + ) + } +} + /** * Content starts below the shell's top navigation, which is a `TopStart` * overlay drawn over every screen rather than something that reserves space. @@ -111,6 +199,7 @@ private fun RequestDetailContent( notice: String?, error: String?, onRequest: () -> Unit, + primaryActionFocusRequester: FocusRequester, ) { Box(modifier = Modifier.fillMaxSize()) { // Backdrop, scrimmed hard enough that body copy stays legible over the @@ -229,34 +318,25 @@ private fun RequestDetailContent( ) } + // One pill in every state, never swapped for another node. The + // status used to be plain text, which left the page with nothing + // to focus, and replacing Request with it after a submit dropped + // the focused node the same way. Only Request is enabled; the + // status or reason renders disabled so it reads as not + // actionable. A disabled TV Surface still takes focus (its + // clickable is focusable regardless of enabled), so the page + // keeps a focus target in every state, like tvOS + // RequestDetailView's single primary action. val request = detail.request - when { - request.requestable -> { - TvRequestActionPill( - label = if (isSubmitting) "Requesting…" else "Request", - icon = Icons.Filled.Add, - onClick = onRequest, - enabled = !isSubmitting, - modifier = Modifier.padding(top = 12.dp), - ) - } - !request.status.isNullOrBlank() -> { - Text( - text = "Request status: ${request.status}", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 12.dp), - ) - } - request.reason.isNotBlank() -> { - Text( - text = request.reason, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 12.dp), - ) - } - } + TvRequestActionPill( + label = request.primaryActionLabel(isSubmitting), + icon = Icons.Filled.Add.takeIf { request.requestable }, + onClick = onRequest, + enabled = request.requestable && !isSubmitting, + modifier = Modifier + .padding(top = 12.dp) + .focusRequester(primaryActionFocusRequester), + ) notice?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) @@ -268,3 +348,18 @@ private fun RequestDetailContent( } } } + +/** + * Label for the detail's primary action: Request while the title is + * requestable, otherwise the existing request's status, otherwise the reason + * it cannot be requested. Status tokens use [requestDisplayLabel]; reasons use + * the shared [reasonMessage] policy for readable sentences and unknown codes. + */ +private fun RequestState.primaryActionLabel(isSubmitting: Boolean): String { + if (requestable) return if (isSubmitting) "Requesting…" else "Request" + val status = status?.takeIf { it.isNotBlank() } + return when { + status != null -> "Request status: ${status.requestDisplayLabel()}" + else -> reasonMessage() ?: "Unavailable" + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestsScreen.kt index e124db37e..03e02b0e2 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestsScreen.kt @@ -57,6 +57,7 @@ import org.prairieserver.prairie.model.request.RequestAvailability import org.prairieserver.prairie.model.request.RequestDiscoverySection import org.prairieserver.prairie.model.request.RequestMediaResult import org.prairieserver.prairie.model.request.RequestMediaType +import org.prairieserver.prairie.model.request.reasonMessage import org.prairieserver.prairie.tv.ui.components.TvErrorScreen import org.prairieserver.prairie.tv.ui.components.TvFilterChip import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen @@ -620,8 +621,7 @@ private fun RequestMediaResult.requestKey(): String = "$mediaType-$tmdbId" private fun RequestMediaResult.nonActionableMessage(): String = when { availability == RequestAvailability.Available -> "This title is already in your library." request.status?.isNotBlank() == true -> "$title is already ${request.status}." - request.reason.isNotBlank() -> request.reason - else -> "This title cannot be requested right now." + else -> request.reasonMessage() ?: "This title cannot be requested right now." } private fun searchEmptyMessage( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt index 14b56592b..82e6dbb1b 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt @@ -976,7 +976,8 @@ fun TvMainShell( } } - // Android 16 no longer dispatches KEYCODE_BACK to apps targeting API 36. + // Android 16 routes KEYCODE_BACK for apps targeting API 36 to back + // callbacks first (the key reaches views only as a canceled KeyUp). // Register the shell's stateful routing through the supported callback and // enable it only when this layer can consume the press, so child callbacks // and the activity fallback retain their existing priority. @@ -1036,7 +1037,14 @@ fun TvMainShell( if (ev.type == KeyEventType.KeyUp && (ev.key == Key.Back || ev.key == Key.Escape) ) { - handleShellBack() + // Android 16 still delivers the key: after invoking the + // top back callback it forwards the same KeyUp marked + // canceled. Acting on it ran the shell's Back a second + // time for one press, so Back from a pushed screen + // (a request detail, My Requests) skipped its parent. + // A canceled up must not perform the key's action, so + // swallow it rather than let it reach anything below. + if (ev.nativeKeyEvent.isCanceled) true else handleShellBack() } else { false } @@ -1339,6 +1347,10 @@ fun TvMainShell( mediaType = entry.arguments?.getString(TvMainRoute.RequestDetail.ARG_MEDIA_TYPE).orEmpty(), tmdbId = entry.arguments?.getInt(TvMainRoute.RequestDetail.ARG_TMDB_ID) ?: 0, onBack = { if (nestedNav.previousBackStackEntry != null) nestedNav.popBackStack() }, + // Back closes an open panel or profile menu first, as + // on every other screen; the shell's handler does that. + backEnabled = focusState.openPanel == null && !focusState.profileMenuOpen, + onInitialContentFocus = { focusState.closeProfileMenuForContent() }, ) } shellComposable(TvMainRoute.Collections.route) { diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/DownloadModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/DownloadModels.kt index 331f5791c..60971d4d7 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/DownloadModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/DownloadModels.kt @@ -73,12 +73,53 @@ enum class DownloadQuality( Mbps2("2mbps", "2 Mbps", 2_000), Mbps1("1mbps", "1 Mbps", 1_000); + /** + * [label] plus the resolution ceiling the server reports for this preset, + * e.g. "20 Mbps · up to 4K". A missing ceiling (Original, or an older + * server) leaves the bitrate-only label. + */ + fun label(maxHeight: Int?): String { + if (maxHeight == null || maxHeight <= 0) return label + val resolution = if (maxHeight >= 2160) "4K" else "${maxHeight}p" + return "$label · up to $resolution" + } + companion object { fun fromWire(value: String?): DownloadQuality = entries.firstOrNull { it.wire == value?.lowercase()?.trim() } ?: Original } } +/** + * One entry of the capability's `quality_options`: a preset's video bitrate + * cap and the tallest output it can produce on this server. Both are absent + * for `original`. + */ +@Serializable +data class DownloadQualityOption( + val preset: String, + @SerialName("bitrate_kbps") val bitrateKbps: Int? = null, + @SerialName("max_height") val maxHeight: Int? = null, +) + +/** + * Decode-only device facts sent as `caps` on `POST /api/v2/downloads`. The + * server uses them to serve `original` as-is only when this device plays it, + * and to keep a bitrate preset's resolution within what the decoder takes. + * A download plays later on whatever output is attached then, so nothing here + * describes the current display or audio route. + */ +@Serializable +data class DownloadCaps( + @SerialName("video_evidence") val videoEvidence: String, + @SerialName("codecs_video") val codecsVideo: List, + @SerialName("codecs_audio") val codecsAudio: List, + val containers: List, + @SerialName("max_resolution") val maxResolution: String? = null, + val hdr: Boolean = false, + @SerialName("video_decode") val videoDecode: List = emptyList(), +) + /** * POST /api/v2/downloads body. Either `episodeId` or `fileId` is set on * top of the always-required `contentId`. `series = true` requests batch @@ -96,6 +137,7 @@ data class DownloadRequest( @SerialName("device_id") val deviceId: String? = null, val revision: Int? = null, @SerialName("status_event_at") val statusEventAt: String? = null, + val caps: DownloadCaps? = null, ) /** @@ -125,6 +167,17 @@ enum class DownloadStatus(val wire: String) { } } +/** + * One local status report for one registry revision (`PATCH /api/v2/downloads/{id}`). + * [status] is `downloading` or `completed`; [updatedAt] is the RFC 3339 time the + * local state changed, not the send time. A retry must resend all three values. + */ +data class DownloadStatusEvent( + val status: String, + val updatedAt: String, + val revision: Int, +) + /** * `direct` = browser one-shot serve (no persistent server record). * `queued` = tracked record visible in the user's downloads list. @@ -158,12 +211,18 @@ data class DownloadCapability( val enabled: Boolean = false, @SerialName("download_allowed") val downloadAllowed: Boolean = false, @SerialName("quality_presets") val qualityPresets: List = emptyList(), + /** One entry per [qualityPresets] value: its bitrate cap and resolution ceiling. */ + @SerialName("quality_options") val qualityOptions: List = emptyList(), @SerialName("transcode_enabled") val transcodeEnabled: Boolean = false, @SerialName("transcode_user_allowed") val transcodeUserAllowed: Boolean = false, @SerialName("season_download") val seasonDownload: Boolean = false, @SerialName("series_monitoring") val seriesMonitoring: Boolean = false, @SerialName("monitoring_modes") val monitoringModes: List = emptyList(), ) { + /** The label for [quality], with the server's resolution ceiling: "10 Mbps · up to 1080p". */ + fun label(quality: DownloadQuality): String = + quality.label(qualityOptions.firstOrNull { it.preset == quality.wire }?.maxHeight) + /** Downloads are usable only when the feature is on AND this user may download. */ val isUsable: Boolean get() = enabled && downloadAllowed && (revision == null || (revision.isNotBlank() && state == "available")) @@ -186,6 +245,26 @@ data class DownloadCapability( } } +/** [DownloadCapability.label], or the bitrate-only label before a capability has loaded. */ +fun DownloadCapability?.labelFor(quality: DownloadQuality): String = this?.label(quality) ?: quality.label + +/** + * The preset new downloads use for a saved default: the saved one when this + * capability offers it, since the server refuses a preset the account can no + * longer request (transcoding turned off, say); else Original, or the first + * offered preset if the server does not list Original. An unloaded capability + * keeps the saved value. + */ +fun DownloadCapability?.effectiveDefault(saved: DownloadQuality): DownloadQuality { + if (this == null) return saved + val offered = allowedQualities() + return when { + saved in offered -> saved + DownloadQuality.Original in offered -> DownloadQuality.Original + else -> offered.first() + } +} + /** Convenience: type-safe accessor. */ fun DownloadRecord.statusEnum(): DownloadStatus = DownloadStatus.fromWire(status) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/DownloadSidecar.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/DownloadSidecar.kt index 1d2354686..fa26cdced 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/DownloadSidecar.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/DownloadSidecar.kt @@ -68,6 +68,10 @@ data class DownloadSidecar( * used to send `If-Range` when resuming an interrupted transfer so a changed * source file restarts cleanly instead of corrupting. Null/absent = none. */ val resumeValidator: String? = null, + /** Audio tracks of the downloaded file and the subtitle sidecars saved beside + * it, captured from the offline manifest when the download completed. Null + * for downloads completed before this existed (legacy offline playback). */ + val offlineTracks: OfflineTrackInfo? = null, /** Wall-clock millis when the sidecar was last written. Diagnostic only; * helps debug stale-file scenarios via `ls -la`. */ val updatedAtMs: Long, diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/OfflineTracks.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/OfflineTracks.kt new file mode 100644 index 000000000..292a84aea --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/download/OfflineTracks.kt @@ -0,0 +1,134 @@ +package org.prairieserver.prairie.model.download + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.prairieserver.prairie.model.catalog.AudioTrack + +/** + * The part of `GET /api/v2/downloads/{id}/manifest` the offline player needs: + * the audio tracks of the DELIVERED file and the subtitle sidecars to fetch. + * + * `audio_tracks[].index` is the track's position inside the downloaded file, + * not a source-file ordinal: a prepared (remux/transcode) MP4 carries every + * source audio track in source order, a legacy prepared file carries one, and + * an original download is the source file itself. + */ +@Serializable +data class OfflineManifestTracks( + @SerialName("delivery_format") val deliveryFormat: String? = null, + @SerialName("selected_audio_track_index") val selectedAudioTrackIndex: Int? = null, + @SerialName("audio_tracks") val audioTracks: List = emptyList(), + val subtitles: List = emptyList(), +) + +@Serializable +data class OfflineManifestSubtitle( + val language: String? = null, + val title: String? = null, + val format: String? = null, + val forced: Boolean = false, + @SerialName("hearing_impaired") val hearingImpaired: Boolean = false, + @SerialName("fetch_url") val fetchUrl: String = "", +) + +/** + * Track data persisted with a [DownloadSidecar] so a download can offer its + * audio and subtitle choices with no network. Absent on downloads completed + * before this existed; those keep the legacy offline behaviour. + */ +@Serializable +data class OfflineTrackInfo( + /** + * The downloaded file's audio tracks in file order. The server numbers + * them 0..n-1 in list order, so [AudioTrack.index] and the list position + * are both the track's position among the file's Media3 audio groups. + */ + val audioTracks: List = emptyList(), + /** Position (into [audioTracks]) to select by default. */ + val selectedAudioTrackIndex: Int? = null, + val subtitles: List = emptyList(), + /** + * True for server-prepared files, whose re-encoded tracks are selected by + * position. An original download is the source file with its source + * codecs; Media3 does not report every container's audio groups in file + * order (Matroska can differ), so originals select by catalog identity. + */ + val audioByPosition: Boolean = false, +) { + /** The default audio position: the manifest's pick, else the default flag, else the first. */ + fun defaultAudioPosition(): Int { + if (audioTracks.isEmpty()) return 0 + selectedAudioTrackIndex?.takeIf { it in audioTracks.indices }?.let { return it } + return audioTracks.indexOfFirst { it.isDefault }.takeIf { it >= 0 } ?: 0 + } +} + +/** A subtitle sidecar fetched once at download time and kept beside the download. */ +@Serializable +data class OfflineSubtitleFile( + /** Absolute path of the local file. Its extension matches [format]. */ + val path: String, + /** Canonical format: `srt`, `vtt`, `ass`, `ssa`, `pgs`, or `ttml`. */ + val format: String, + val language: String? = null, + val title: String? = null, + val forced: Boolean = false, + val hearingImpaired: Boolean = false, +) + +private val offlineManifestJson = Json { + ignoreUnknownKeys = true + coerceInputValues = true + isLenient = true +} + +/** Decodes the manifest subset; null for a body that is not a manifest object. */ +fun decodeOfflineManifestTracks(body: String): OfflineManifestTracks? = + runCatching { offlineManifestJson.decodeFromString(OfflineManifestTracks.serializer(), body) }.getOrNull() + +/** Builds the persisted track data from a manifest and the sidecars saved from it. */ +fun OfflineManifestTracks.toOfflineTrackInfo(savedSubtitles: List): OfflineTrackInfo = + OfflineTrackInfo( + audioTracks = audioTracks, + selectedAudioTrackIndex = selectedAudioTrackIndex?.takeIf { it in audioTracks.indices }, + subtitles = savedSubtitles, + audioByPosition = !deliveryFormat.equals(DELIVERY_FORMAT_ORIGINAL, ignoreCase = true), + ) + +private const val DELIVERY_FORMAT_ORIGINAL = "original" + +/** + * Canonical local format for a manifest subtitle `format`, or null when the + * player cannot mount it as a sidecar (so it is not downloaded at all). + */ +fun offlineSubtitleFormat(format: String?): String? = + when (format?.trim()?.lowercase()?.removePrefix(".")) { + "srt", "subrip" -> "srt" + "vtt", "webvtt" -> "vtt" + "ass" -> "ass" + "ssa" -> "ssa" + "sup", "pgs", "hdmv_pgs_subtitle" -> "pgs" + "ttml", "dfxp" -> "ttml" + else -> null + } + +/** File extension for a canonical [offlineSubtitleFormat] value. */ +fun offlineSubtitleExtension(format: String): String = when (format) { + "pgs" -> "sup" + else -> format +} + +/** + * Only same-server managed-download subtitle proxy paths are fetched. The URL + * is authenticated with the download owner's credentials, so an absolute or + * foreign reference in a manifest must never be followed. + */ +fun isOfflineSubtitleFetchUrl(url: String): Boolean { + val trimmed = url.trim() + if (!trimmed.startsWith(OFFLINE_DOWNLOADS_PREFIX)) return false + if (trimmed.contains("://") || trimmed.contains("..") || trimmed.contains('\\')) return false + return trimmed.substring(OFFLINE_DOWNLOADS_PREFIX.length).contains("/subtitles/") +} + +private const val OFFLINE_DOWNLOADS_PREFIX = "/api/v2/downloads/" diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/profile/ActiveProfileStore.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/profile/ActiveProfileStore.kt index c2299e58d..6cc0e4a98 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/profile/ActiveProfileStore.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/profile/ActiveProfileStore.kt @@ -3,6 +3,7 @@ package org.prairieserver.prairie.model.profile import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.repository.ProfileRepository @@ -71,6 +72,11 @@ class ActiveProfileStore( } } + /** Apply a local edit to the cached profile, if one is cached. */ + fun update(transform: (Profile) -> Profile) { + _activeProfile.update { it?.let(transform) } + } + /** Clear on sign-out or a server switch, so no profile leaks across accounts. */ fun reset() { loadedForProfileId = null diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestModels.kt index 094b01c61..6a87b46e6 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestModels.kt @@ -27,6 +27,18 @@ object RequestOutcome { const val Failed = "failed" } +/** + * Values of [RequestState.reason]: why a title cannot be requested. They are + * codes for deciding what to offer, not text; show [reasonMessage] instead. + */ +object RequestReason { + const val AlreadyRequested = "already_requested" + const val AlreadyAvailable = "already_available" + const val RequestsDisabled = "requests_disabled" + const val Blocked = "blocked" + const val QuotaExceeded = "quota_exceeded" +} + object RequestAvailability { const val Missing = "missing" const val Available = "available" diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestPresentation.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestPresentation.kt index 2d6375566..4e452bda6 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestPresentation.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/request/RequestPresentation.kt @@ -48,6 +48,25 @@ fun String.requestDisplayLabel(): String = when (lowercase()) { .ifBlank { this } } +/** + * A sentence saying why this title cannot be requested, or null when there is + * nothing worth showing. A title that already has a request gets null: its + * request status already says where it stands. A code this client does not + * know also gets null, so a raw code never reaches the screen, while reason + * text that is not a code is shown as the server wrote it. + */ +fun RequestState.reasonMessage(): String? = when (reason) { + RequestReason.AlreadyRequested -> null + RequestReason.AlreadyAvailable -> "This title is already in your library." + RequestReason.RequestsDisabled -> "Requests are disabled on this server." + RequestReason.Blocked -> "Your account is blocked from making requests." + RequestReason.QuotaExceeded -> "You've reached your request limit." + else -> reason.takeUnless { it.isBlank() || ReasonCodePattern.matches(it) } +} + +/** What a server reason code looks like, as opposed to readable text. */ +private val ReasonCodePattern = Regex("[a-z0-9_]+") + fun MediaRequest.canCancel(): Boolean = outcome == RequestOutcome.Active && status == RequestStatus.Pending diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/DownloadsApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/DownloadsApi.kt index 7a0884bb1..64002f143 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/DownloadsApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/DownloadsApi.kt @@ -3,6 +3,7 @@ package org.prairieserver.prairie.network.api import org.prairieserver.prairie.model.download.DownloadCapability import org.prairieserver.prairie.model.download.DownloadRecord import org.prairieserver.prairie.model.download.DownloadRequest +import org.prairieserver.prairie.model.download.DownloadStatusEvent import org.prairieserver.prairie.model.download.DownloadsListResponse import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.AuthScopeSnapshot @@ -34,6 +35,9 @@ open class DownloadsApi( open suspend fun delete(id: String, scope: AuthScopeSnapshot?): ApiResult = if (scope == null) changed() else registry.delete(id, scope) + open suspend fun reportStatus(id: String, event: DownloadStatusEvent, scope: AuthScopeSnapshot?): ApiResult = + if (scope == null) changed() else registry.reportStatus(id, event, scope) + /** * Feature detection (issue #20 §3). Call at detail load / profile switch; * the picker offers only `quality_presets` and hides bitrate presets when diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/DownloadCreationV2Api.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/DownloadCreationV2Api.kt index f69ca8c5f..8d407b3ff 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/DownloadCreationV2Api.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/DownloadCreationV2Api.kt @@ -34,6 +34,7 @@ class DownloadCreationV2Api( request.episodeId?.let { put("episode_id",it) } request.fileId?.let { put("media_file_id",it.toString()) } put("quality", request.quality ?: "original") + request.caps?.let { put("caps", PrairieJson.encodeToJsonElement(DownloadCaps.serializer(), it)) } }.toMutableMap() private suspend fun send(scope: AuthScopeSnapshot, device: String, body: JsonObject, cursor: String? = null): ApiResult { @@ -56,8 +57,12 @@ class DownloadCreationV2Api( val device = devices.current()?.id?.takeIf { it.isNotBlank() } ?: return identityChanged() if (!current(scope,device)) return identityChanged() if (request.series || request.fileId == null || request.fileId <= 0) return invalid() - // Reconcile first. A confirmed usable entry with the requested quality - // can be downloaded without resetting its bytes/status/batch on the server. + // Reconcile first. A usable entry with the requested quality whose + // bytes are already on or coming to this device is downloaded without + // resetting its bytes/status/batch on the server. One no bytes have + // reached yet is posted again with the current caps, so an entry an + // earlier build created without them is decided for this device; the + // server keeps the entry unchanged when the decision is the same. val listed = registry.list(scope) if (listed !is ApiResult.Success) return when(listed) { is ApiResult.Error -> listed @@ -74,7 +79,8 @@ class DownloadCreationV2Api( if (matches.size > 1) return invalid() val existing = matches.singleOrNull() if (existing != null && existing.mediaFileId == request.fileId && existing.quality == (request.quality ?: "original") && - existing.status in setOf("ready","preparing","queued","downloading","completed")) return ApiResult.Success(existing) + existing.status in setOf("ready","preparing","queued","downloading","completed") && + (request.caps == null || existing.status in setOf("downloading","completed") || existing.bytesSent > 0)) return ApiResult.Success(existing) val fields = body(request) fields["expected_revision"] = JsonPrimitive(existing?.revision ?: 0) existing?.let { fields["expected_download_id"] = JsonPrimitive(it.id) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/DownloadRegistryV2Api.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/DownloadRegistryV2Api.kt index dd1cbf6f0..be9ac7ac6 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/DownloadRegistryV2Api.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/DownloadRegistryV2Api.kt @@ -5,6 +5,8 @@ import io.ktor.client.request.* import io.ktor.http.* import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import org.prairieserver.prairie.model.download.* import org.prairieserver.prairie.network.* @@ -48,7 +50,9 @@ class DownloadRegistryV2Api(private val client: HttpClient, private val tokens: client.request(path) { this.method = method; authScope(owner!!); requirePrairieAuth() // The existing auth plugin attaches the installation's device metadata. - if (method != HttpMethod.Get) singleAttempt() + // A status event retains its revision and timestamp, so replaying + // PATCH after an auth refresh is safe. DELETE remains one attempt. + if (method == HttpMethod.Delete) singleAttempt() configure() } }) { it } @@ -93,4 +97,29 @@ class DownloadRegistryV2Api(private val client: HttpClient, private val tokens: val device = devices.current()?.id ?: return identityChanged() return exchange(scope, device, HttpMethod.Delete, "/api/v2/downloads/${id.encodeURLPathPart()}") } + + /** + * `PATCH /downloads/{id}`: one revision-bound local status event. [updatedAt] is when the + * local state changed, not when this is sent; a retry resends the same event, which the + * server acknowledges without changing the entry. A moved revision answers `409`. + */ + suspend fun reportStatus(id: String, event: DownloadStatusEvent, scope: AuthScopeSnapshot): ApiResult { + val device = devices.current()?.id?.takeIf { it.isNotBlank() } ?: return identityChanged() + if (id.isBlank() || event.revision < 1 || event.updatedAt.isBlank() || + event.status != DownloadStatus.Downloading.wire && event.status != DownloadStatus.Completed.wire) return invalid() + val result = exchange(scope, device, HttpMethod.Patch, "/api/v2/downloads/${id.encodeURLPathPart()}") { + contentType(ContentType.Application.Json) + setBody(buildJsonObject { + put("status", event.status); put("updated_at", event.updatedAt); put("revision", event.revision) + }) + } + return when (result) { + is ApiResult.Success -> { + val row = try { result.data.project(device) } catch (_: IllegalArgumentException) { return invalid() } + if (row.id == id) ApiResult.Success(row) else invalid() + } + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/DownloadsRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/DownloadsRepository.kt index 8d5083c1a..f7249699b 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/DownloadsRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/DownloadsRepository.kt @@ -3,10 +3,14 @@ package org.prairieserver.prairie.repository import org.prairieserver.prairie.model.download.DownloadCapability import org.prairieserver.prairie.model.download.DownloadRecord import org.prairieserver.prairie.model.download.DownloadRequest +import org.prairieserver.prairie.model.download.DownloadStatus +import org.prairieserver.prairie.model.download.DownloadStatusEvent +import org.prairieserver.prairie.model.download.statusEnum import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.api.DownloadsApi import org.prairieserver.prairie.repository.port.DownloadDeletionPort import org.prairieserver.prairie.repository.port.NoOpDownloadDeletionPort +import org.prairieserver.prairie.util.parseRfc3339ToEpochMillis import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -21,9 +25,12 @@ import kotlinx.coroutines.flow.update * **Disk-as-truth (v1.1).** The Android side seeds this cache from the * on-disk sidecar files at startup via [seedFromSidecars]. [refresh] then * *merges* the server view into that cache instead of overwriting it — - * server-known records win on conflict (fresher status), but records that - * only exist on disk (server cleaned them up while we were offline, or we - * marked them [pendingDelete] but haven't synced yet) stay visible. + * server-known records win on conflict (fresher status), except that a + * locally completed download is never downgraded to the server's `ready` / + * `downloading` for the same revision before its completion report lands + * ([keepLocalCompletion]). + * Records that only exist on disk (server cleaned them up while we were + * offline, or we marked them [pendingDelete] but haven't synced yet) stay visible. * * **Two-phase delete.** The server has a quirky DELETE: for active records * (queued / downloading) it only flips status to cancelled rather than @@ -137,7 +144,11 @@ class DownloadsRepository( if (!localWrite(authority) { val hidden = pendingDelete + durable val serverList = result.data.downloads.filterNot { it.id in hidden } - _records.update { current -> serverList + current.filter { it.id !in serverIds && it.id !in hidden && it.id in keepIdsAbsentFromServer } } + _records.update { current -> + val local = current.associateBy { it.id } + serverList.map { keepLocalCompletion(it, local[it.id]) } + + current.filter { it.id !in serverIds && it.id !in hidden && it.id in keepIdsAbsentFromServer } + } }) return changed() if (serverId != null && profileId != null) { val deviceId = devices?.current()?.id @@ -174,6 +185,60 @@ class DownloadsRepository( } + /** Read the revision for an older queued transfer without relying on the UI cache. */ + suspend fun resolveTransferRecord( + id: String, + fileId: Int, + expectedAuthority: org.prairieserver.prairie.network.DurableLoginAuthority? = null, + ): ApiResult { + val authority = expectedAuthority ?: authorities?.snapshotDurableLoginAuthority() + if (!current(authority)) return changed() + val result = api.list(authority?.scope) + if (!current(authority)) return changed() + return when (result) { + is ApiResult.Success -> { + val row = result.data.downloads.singleOrNull { it.id == id } + ?: return ApiResult.Error(404, "download_not_found", "The queued download no longer exists.") + if (row.mediaFileId != fileId) return ApiResult.Error(409, "download_target_changed", "The queued download now targets another file.") + if (row.revision == null || row.revision < 1) return ApiResult.Error(0, "invalid_download_registry", "The download revision is unavailable.") + ApiResult.Success(row) + } + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + } + + /** + * Reports one local status event under [expectedAuthority] (the owner the + * download ran for) and records the server's acknowledgement in the cache. + * The result is returned as-is so the caller can tell a moved revision (409) + * from a transient failure. + */ + suspend fun reportStatus( + id: String, + event: DownloadStatusEvent, + expectedAuthority: org.prairieserver.prairie.network.DurableLoginAuthority? = null, + ): ApiResult { + val authority = expectedAuthority ?: authorities?.snapshotDurableLoginAuthority() + if (!current(authority)) return changed() + val result = api.reportStatus(id, event, authority?.scope) + if (result is ApiResult.Success && !localWrite(authority) { + // The worker publishes local status itself, and an answer can land + // after the transfer moved on (completed, or failed and cleaned up), + // or after a refresh installed a replacement revision. Only the + // acknowledgement is taken from it, and only for the same revision. + val row = result.data + _records.update { list -> + list.map { + if (it.id != id || (it.revision != null && it.revision != row.revision)) it + else it.copy(completedAt = row.completedAt ?: it.completedAt, revision = row.revision, + statusEventAt = latestInstant(it.statusEventAt, row.statusEventAt)) + } + } + }) return changed() + return result + } + /** * Series-batch creation (one POST → N records sharing a batchId). All * returned records are upserted so the Downloads tab + per-row UI @@ -297,6 +362,32 @@ class DownloadsRepository( } } + /** + * A download this device finished stays completed while the server still + * reports it as `ready`/`downloading`: the completion report may not have + * reached it yet (offline, or still queued). The completion only speaks for + * the bytes it fetched, so the server row must be the same revision. A local + * row without a stored revision (saved before it was kept) proves nothing, + * and a replaced revision makes the server row win. + */ + internal fun keepLocalCompletion(server: DownloadRecord, local: DownloadRecord?): DownloadRecord { + if (local == null || local.statusEnum() != DownloadStatus.Completed) return server + if (server.statusEnum() != DownloadStatus.Ready && server.statusEnum() != DownloadStatus.Downloading) return server + if (local.revision == null || local.revision != server.revision) return server + return server.copy(status = local.status, bytesSent = local.bytesSent, completedAt = server.completedAt ?: local.completedAt) + } + + /** Compare event times at the client's millisecond precision, regardless of timestamp shape. */ + private fun latestInstant(a: String?, b: String?): String? { + val aMillis = a?.let(::parseRfc3339ToEpochMillis) + val bMillis = b?.let(::parseRfc3339ToEpochMillis) + return when { + bMillis == null -> a + aMillis == null || bMillis > aMillis -> b + else -> a + } + } + /** Lookup helper for callers (item detail) that key off `FileVersion.fileId`. */ fun recordForFile(fileId: Int): DownloadRecord? = _records.value.firstOrNull { it.mediaFileId == fileId } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/download/DownloadCapabilityTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/download/DownloadCapabilityTest.kt index ab80f4293..e946ab8ba 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/download/DownloadCapabilityTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/download/DownloadCapabilityTest.kt @@ -31,6 +31,30 @@ class DownloadCapabilityTest { assertTrue(cap.transcodeEnabled) } + @Test + fun `labels presets with the server resolution ceiling`() { + val source = """ + { + "enabled": true, + "download_allowed": true, + "quality_presets": ["original", "20mbps", "1mbps"], + "quality_options": [ + {"preset": "original"}, + {"preset": "20mbps", "bitrate_kbps": 20000, "max_height": 2160}, + {"preset": "1mbps", "bitrate_kbps": 1000, "max_height": 480} + ] + } + """.trimIndent() + + val cap = json.decodeFromString(source) + assertEquals("Original", cap.labelFor(DownloadQuality.Original)) + assertEquals("20 Mbps · up to 4K", cap.labelFor(DownloadQuality.Mbps20)) + assertEquals("1 Mbps · up to 480p", cap.labelFor(DownloadQuality.Mbps1)) + // A preset the server does not describe, or no capability yet, keeps the bitrate label. + assertEquals("10 Mbps", cap.labelFor(DownloadQuality.Mbps10)) + assertEquals("5 Mbps", (null as DownloadCapability?).labelFor(DownloadQuality.Mbps5)) + } + @Test fun `allowedQualities offers every preset when transcode is enabled and allowed`() { val cap = DownloadCapability( @@ -43,6 +67,25 @@ class DownloadCapabilityTest { assertEquals(DownloadQuality.entries.toList(), cap.allowedQualities()) } + @Test + fun `a saved default the account can no longer request falls back to Original`() { + val transcodeOff = DownloadCapability( + enabled = true, + downloadAllowed = true, + qualityPresets = listOf("original"), + transcodeEnabled = false, + transcodeUserAllowed = true, + ) + assertEquals(DownloadQuality.Original, transcodeOff.effectiveDefault(DownloadQuality.Mbps10)) + val transcodeOn = transcodeOff.copy(qualityPresets = listOf("original", "10mbps"), transcodeEnabled = true) + assertEquals(DownloadQuality.Mbps10, transcodeOn.effectiveDefault(DownloadQuality.Mbps10)) + // Before the capability loads, the saved value stands. + assertEquals(DownloadQuality.Mbps10, (null as DownloadCapability?).effectiveDefault(DownloadQuality.Mbps10)) + // Without Original on offer, the first offered preset is used. + val bitrateOnly = transcodeOn.copy(qualityPresets = listOf("5mbps", "2mbps")) + assertEquals(DownloadQuality.Mbps5, bitrateOnly.effectiveDefault(DownloadQuality.Mbps10)) + } + @Test fun `allowedQualities collapses to Original when transcode is disabled`() { val cap = DownloadCapability( diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/download/OfflineTracksTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/download/OfflineTracksTest.kt new file mode 100644 index 000000000..71f8add94 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/download/OfflineTracksTest.kt @@ -0,0 +1,146 @@ +package org.prairieserver.prairie.model.download + +import org.prairieserver.prairie.model.catalog.AudioTrack +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OfflineTracksTest { + + /** A multi-track prepared download, shaped like the server's manifest. */ + private val preparedManifest = """ + { + "download_id": "dl_1", + "media_file_id": "4567", + "delivery_format": "transcode", + "container": "mp4", + "selected_audio_track_index": 1, + "audio_tracks": [ + {"index": 0, "language": "eng", "title": "Main", "codec": "aac", "layout": "stereo", "channels": 2, "default": true}, + {"index": 1, "language": "jpn", "codec": "aac", "channels": 2, "default": false}, + {"index": 2, "language": "eng", "title": "Commentary", "codec": "aac", "channels": 2, "default": false} + ], + "subtitles": [ + {"language": "eng", "format": "ass", "forced": false, "hearing_impaired": false, + "fetch_url": "/api/v2/downloads/dl_1/subtitles/embedded:2"}, + {"language": "eng", "format": "sup", "forced": true, "hearing_impaired": false, + "fetch_url": "/api/v2/downloads/dl_1/subtitles/embedded:3"} + ], + "manifest_version": 3 + } + """.trimIndent() + + @Test + fun decodesTheServerManifestIgnoringFieldsItDoesNotUse() { + val manifest = assertNotNull(decodeOfflineManifestTracks(preparedManifest)) + + assertEquals("transcode", manifest.deliveryFormat) + assertEquals(1, manifest.selectedAudioTrackIndex) + assertEquals(3, manifest.audioTracks.size) + assertEquals(listOf("ass", "sup"), manifest.subtitles.map { it.format }) + assertTrue(manifest.subtitles[1].forced) + } + + @Test + fun audioTracksKeepFilePositionsAndTheManifestSelection() { + val info = assertNotNull(decodeOfflineManifestTracks(preparedManifest)).toOfflineTrackInfo(emptyList()) + assertTrue(info.audioByPosition) + + assertEquals(listOf(0, 1, 2), info.audioTracks.map { it.index }) + assertEquals(listOf("Main", null, "Commentary"), info.audioTracks.map { it.title }) + assertEquals(1, info.defaultAudioPosition()) + assertEquals("jpn", info.audioTracks[1].language) + } + + @Test + fun originalDownloadsKeepAudioRowsWithoutPositionalSelection() { + val manifest = assertNotNull( + decodeOfflineManifestTracks(preparedManifest.replace("\"transcode\"", "\"original\"")), + ) + val sidecar = OfflineSubtitleFile(path = "subtitles/0.srt", format = "srt", language = "eng") + + val info = manifest.toOfflineTrackInfo(listOf(sidecar)) + + // Media3 may not report a source container's audio groups in file order, + // so an original file keeps its menu rows but is not selected by position. + assertFalse(info.audioByPosition) + assertEquals(3, info.audioTracks.size) + assertEquals(1, info.defaultAudioPosition()) + assertEquals(listOf(sidecar), info.subtitles) + } + + @Test + fun anOutOfRangeManifestSelectionIsDropped() { + val manifest = OfflineManifestTracks( + selectedAudioTrackIndex = 2, + audioTracks = listOf(AudioTrack(index = 0, language = "eng"), AudioTrack(index = 1, language = "fre")), + ) + + assertNull(manifest.toOfflineTrackInfo(emptyList()).selectedAudioTrackIndex) + } + + @Test + fun defaultAudioFallsBackToTheDefaultFlagThenTheFirstTrack() { + val flagged = OfflineTrackInfo( + audioTracks = listOf( + AudioTrack(index = 0, language = "eng"), + AudioTrack(index = 1, language = "fre", isDefault = true), + ), + selectedAudioTrackIndex = 7, + ) + assertEquals(1, flagged.defaultAudioPosition()) + assertEquals(0, flagged.copy(audioTracks = flagged.audioTracks.map { it.copy(isDefault = false) }).defaultAudioPosition()) + assertEquals(0, OfflineTrackInfo().defaultAudioPosition()) + } + + @Test + fun legacyPreparedManifestDescribesItsSingleTrack() { + val manifest = assertNotNull( + decodeOfflineManifestTracks( + """{"selected_audio_track_index":0,"audio_tracks":[{"index":0,"language":"en","codec":"aac","default":true}],"subtitles":[]}""", + ), + ) + val info = manifest.toOfflineTrackInfo(emptyList()) + + assertEquals(1, info.audioTracks.size) + assertEquals(0, info.defaultAudioPosition()) + assertTrue(info.subtitles.isEmpty()) + } + + @Test + fun manifestWithoutTrackFieldsDecodesEmpty() { + val manifest = assertNotNull(decodeOfflineManifestTracks("""{"download_id":"dl_1","subtitles":null}""")) + + assertTrue(manifest.audioTracks.isEmpty()) + assertTrue(manifest.subtitles.isEmpty()) + assertNull(decodeOfflineManifestTracks("not json")) + } + + @Test + fun subtitleFormatsMapToMountableLocalFormats() { + assertEquals("srt", offlineSubtitleFormat("srt")) + assertEquals("srt", offlineSubtitleFormat("SubRip")) + assertEquals("vtt", offlineSubtitleFormat("webvtt")) + assertEquals("ass", offlineSubtitleFormat("ass")) + assertEquals("ssa", offlineSubtitleFormat("ssa")) + assertEquals("pgs", offlineSubtitleFormat("sup")) + assertEquals("ttml", offlineSubtitleFormat("ttml")) + assertNull(offlineSubtitleFormat("sub")) + assertNull(offlineSubtitleFormat(null)) + assertEquals("sup", offlineSubtitleExtension("pgs")) + assertEquals("ass", offlineSubtitleExtension("ass")) + } + + @Test + fun onlyManagedDownloadSubtitleProxyPathsAreFetched() { + assertTrue(isOfflineSubtitleFetchUrl("/api/v2/downloads/dl_1/subtitles/embedded:2")) + assertTrue(isOfflineSubtitleFetchUrl("/api/v2/downloads/dl_1/subtitles/downloaded:17")) + assertFalse(isOfflineSubtitleFetchUrl("https://evil.example/api/v2/downloads/dl_1/subtitles/external:0")) + assertFalse(isOfflineSubtitleFetchUrl("/api/v2/downloads/dl_1/../../admin/subtitles/x")) + assertFalse(isOfflineSubtitleFetchUrl("/api/v2/downloads/dl_1/artwork/poster")) + assertFalse(isOfflineSubtitleFetchUrl("/api/v1/downloads/dl_1/subtitles/external:0")) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/profile/ActiveProfileStoreTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/profile/ActiveProfileStoreTest.kt index 25c3d6953..4a0523652 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/profile/ActiveProfileStoreTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/profile/ActiveProfileStoreTest.kt @@ -68,6 +68,20 @@ class ActiveProfileStoreTest { assertEquals(2, repo.listCalls) } + @Test + fun aLocalEditUpdatesOnlyACachedProfile() = runTest { + val repo = FakeProfileRepository() + val store = ActiveProfileStore(repo) + + store.update { it.copy(subtitleLanguage = "fr") } + assertEquals(null, store.activeProfile.value) + + store.refresh() + store.update { it.copy(subtitleLanguage = "fr") } + assertEquals("fr", store.activeProfile.value?.subtitleLanguage) + assertEquals(1, repo.listCalls) + } + @Test fun aFailedRefreshKeepsTheCachedProfile() = runTest { val repo = FakeProfileRepository() diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/request/RequestPresentationTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/request/RequestPresentationTest.kt index 2d3be3aae..052ee18f9 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/request/RequestPresentationTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/request/RequestPresentationTest.kt @@ -48,6 +48,40 @@ class RequestPresentationTest { assertEquals("Partially Available", "partially_available".requestDisplayLabel()) } + @Test + fun `reason message reads each server reason as a sentence`() { + assertEquals( + "This title is already in your library.", + RequestState(reason = RequestReason.AlreadyAvailable).reasonMessage(), + ) + assertEquals( + "Requests are disabled on this server.", + RequestState(reason = RequestReason.RequestsDisabled).reasonMessage(), + ) + assertEquals( + "Your account is blocked from making requests.", + RequestState(reason = RequestReason.Blocked).reasonMessage(), + ) + assertEquals( + "You've reached your request limit.", + RequestState(reason = RequestReason.QuotaExceeded).reasonMessage(), + ) + } + + @Test + fun `reason message is empty for an existing request, an unknown code, or no reason`() { + assertNull( + RequestState(status = RequestStatus.Downloading, reason = RequestReason.AlreadyRequested).reasonMessage(), + ) + assertNull(RequestState(reason = "some_future_reason").reasonMessage()) + assertNull(RequestState(requestable = true).reasonMessage()) + } + + @Test + fun `reason message keeps readable reason text from the server`() { + assertEquals("Already requested", RequestState(reason = "Already requested").reasonMessage()) + } + @Test fun `can cancel only while active and pending`() { assertTrue(request(status = RequestStatus.Pending, outcome = RequestOutcome.Active).canCancel()) diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/DownloadCreationV2Test.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/DownloadCreationV2Test.kt index 0bc972f14..2dd81d8c3 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/DownloadCreationV2Test.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/DownloadCreationV2Test.kt @@ -68,6 +68,29 @@ class DownloadCreationV2Test { } finally { c.close() } } + @Test fun createSendsDecodeCapsForTheServerToFitTheDownload() = runTest { + var caps: JsonObject? = null + val c = client { + if (it.method == HttpMethod.Get) json("""{"items":[],"page":{"has_more":false}}""") else { + caps = PrairieJson.parseToJsonElement(it.body.toByteArray().decodeToString()).jsonObject["caps"]?.jsonObject + json("""{"items":[$row],"skipped":[],"page":{"has_more":false}}""",HttpStatusCode.Accepted) + } + } + try { + val decode = org.prairieserver.prairie.model.playback.VideoDecodeCapability(codec = "h264", bitDepths = listOf(8), maxWidth = 1920, maxHeight = 1080, hardware = true) + val request = DownloadRequest("movie", fileId = 42, caps = DownloadCaps("exact", listOf("h264"), listOf("aac"), listOf("mp4", "mkv"), "1080p", false, listOf(decode))) + assertIs>(api(c).create(request, scope)) + val sent = assertNotNull(caps) + assertEquals(JsonPrimitive("exact"), sent["video_evidence"]) + assertEquals(JsonPrimitive("1080p"), sent["max_resolution"]) + val entry = sent["video_decode"]!!.jsonArray.single().jsonObject + assertEquals(JsonPrimitive(1920), entry["max_width"]) + // The server rejects fields outside its caps schema, and null for an integer. + assertEquals(setOf("video_evidence", "codecs_video", "codecs_audio", "containers", "max_resolution", "hdr", "video_decode"), sent.keys) + assertFalse(entry.values.any { value -> value is JsonNull }) + } finally { c.close() } + } + @Test fun matchingEntryReconcilesWithoutResetAnd401HasNoReplay() = runTest { var posts = 0 val c = client { @@ -84,6 +107,36 @@ class DownloadCreationV2Test { } finally { c.close() } } + @Test fun reusableEntryIsPostedWithCapsUntilBytesReachTheDevice() = runTest { + var listed = row + var posts = 0 + val c = client { + if (it.method == HttpMethod.Get) json("""{"items":[$listed],"page":{"has_more":false}}""") else { + posts++ + val body = PrairieJson.parseToJsonElement(it.body.toByteArray().decodeToString()).jsonObject + assertNotNull(body["caps"]) + assertEquals(JsonPrimitive(7), body["expected_revision"]); assertEquals(JsonPrimitive("entry"), body["expected_download_id"]) + json("""{"items":[$listed],"skipped":[],"page":{"has_more":false}}""",HttpStatusCode.Accepted) + } + } + try { + val api = api(c) + val caps = DownloadCaps("exact", listOf("h264"), listOf("aac"), listOf("mp4"), "1080p", false, emptyList()) + // A ready entry no bytes have reached is re-decided with the caps. + assertIs>(api.create(DownloadRequest("movie",fileId=42,caps=caps),scope)) + assertEquals(1,posts) + // Without caps there is nothing new to send. + assertIs>(api.create(DownloadRequest("movie",fileId=42),scope)) + assertEquals(1,posts) + // Bytes already sent, or a completed entry, are never re-decided. + listed = row.replace("\"bytes_sent\":0", "\"bytes_sent\":50") + assertIs>(api.create(DownloadRequest("movie",fileId=42,caps=caps),scope)) + listed = row.replace("\"status\":\"ready\"", "\"status\":\"completed\"") + assertIs>(api.create(DownloadRequest("movie",fileId=42,caps=caps),scope)) + assertEquals(1,posts) + } finally { c.close() } + } + @Test fun batchContinuesEmptyPageAndPreservesSkippedAndOldBatch() = runTest { var firstBody: JsonObject? = null var calls = 0 diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/DownloadRegistryV2Test.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/DownloadRegistryV2Test.kt index 3cd2c523c..222e002da 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/DownloadRegistryV2Test.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/apiv2/DownloadRegistryV2Test.kt @@ -81,6 +81,92 @@ class DownloadRegistryV2Test { } finally { c.close() } } + @Test fun reportStatusPatchesTheRevisionBoundEvent() = runTest { + val event = DownloadStatusEvent("completed", "2026-01-02T03:04:05.000Z", 1) + var answer: MockRequestHandleScope.() -> io.ktor.client.request.HttpResponseData = { + reply(row.replace("\"ready\"", "\"completed\"").replace("\"revision\":1", "\"revision\":1,\"status_event_at\":\"2026-01-02T03:04:05.000Z\"")) + } + var sent = 0 + val c = client { + sent++ + assertEquals(HttpMethod.Patch, it.method) + assertEquals("/api/v2/downloads/one", it.url.encodedPath) + assertEquals(scope, it.attributes[AuthScopeAttributeKey]) + val body = PrairieJson.parseToJsonElement((it.body as io.ktor.http.content.TextContent).text) + assertEquals(PrairieJson.parseToJsonElement("""{"status":"completed","updated_at":"2026-01-02T03:04:05.000Z","revision":1}"""), body) + answer() + } + try { + val api = DownloadRegistryV2Api(c,tokens,devices, ApiV2Gate.Unrestricted) + val answered = assertIs>(api.reportStatus("one", event, scope)).data + assertEquals("completed", answered.status); assertEquals("2026-01-02T03:04:05.000Z", answered.statusEventAt) + + answer = { respond("""{"code":"conflict","detail":"The download revision changed."}""", HttpStatusCode.Conflict, headersOf(HttpHeaders.ContentType, "application/problem+json")) } + assertEquals(409, assertIs(api.reportStatus("one", event, scope)).code) + + // An answer for another entry is not this event's receipt. + answer = { reply(row.replace("\"one\"", "\"two\"")) } + assertIs(api.reportStatus("one", event, scope)) + + // Events the server would refuse are never sent. + val before = sent + assertIs(api.reportStatus("one", event.copy(revision = 0), scope)) + assertIs(api.reportStatus("one", event.copy(status = "ready"), scope)) + assertEquals(before, sent) + } finally { c.close() } + } + + @Test fun delayedStatusRefreshesItsOwnerAndResendsTheRetainedEvent() = runTest { + var access = "expired-access" + var refreshedOwner: AuthScopeSnapshot? = null + val authenticated = object : TokenManager by TokenManagerImpl() { + override suspend fun snapshotCurrentScope() = scope + override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot) = access + override suspend fun getRefreshTokenForScope(scope: AuthScopeSnapshot) = "owner-refresh" + override suspend fun saveTokensForScope(scope: AuthScopeSnapshot, accessToken: String, refreshToken: String, expiresIn: Long) { + refreshedOwner = scope + access = accessToken + } + override suspend fun invalidateSession() = fail("A background report must retain its owner's session.") + } + val bodies = mutableListOf() + val bearers = mutableListOf() + var refreshes = 0 + val c = HttpClient(MockEngine { request -> + assertEquals("example.invalid", request.url.host) + when (request.url.encodedPath) { + "/api/v2/auth/refresh" -> { + refreshes++ + reply("""{"access_token":"rotated-access","refresh_token":"rotated-refresh","expires_in":900}""") + } + "/api/v2/downloads/one" -> { + assertEquals(HttpMethod.Patch, request.method) + assertEquals(scope, request.attributes[AuthScopeAttributeKey]) + bodies += request.body.toByteArray().decodeToString() + bearers += request.headers[HttpHeaders.Authorization] + if (bearers.size == 1) respond("""{"code":"invalid_token","detail":"Expired."}""", + HttpStatusCode.Unauthorized, headersOf(HttpHeaders.ContentType, "application/problem+json")) + else reply(row.replace("\"ready\"", "\"completed\"")) + } + else -> fail("Unexpected request ${request.url}") + } + }) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { tokenManager = authenticated; deviceMetadataProvider = devices } + } + try { + val event = DownloadStatusEvent("completed", "2026-01-02T03:04:05.000Z", 1) + assertIs>( + DownloadRegistryV2Api(c, authenticated, devices, ApiV2Gate.Unrestricted).reportStatus("one", event, scope), + ) + assertEquals(1, refreshes) + assertEquals(scope, refreshedOwner) + assertEquals>(listOf("Bearer expired-access", "Bearer rotated-access"), bearers) + assertEquals(2, bodies.size) + assertEquals(bodies[0], bodies[1]) + } finally { c.close() } + } + @Test fun capabilityRequiresVersionedStateAndFailsClosed() = runTest { var body = """{"revision":"rev","state":"future","enabled":true,"download_allowed":true}""" val c = client { reply(body) } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/DownloadsRepositoryCompletionTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/DownloadsRepositoryCompletionTest.kt new file mode 100644 index 000000000..234e4604b --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/DownloadsRepositoryCompletionTest.kt @@ -0,0 +1,287 @@ +package org.prairieserver.prairie.repository + +import io.ktor.client.HttpClient +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.download.DownloadRecord +import org.prairieserver.prairie.model.download.DownloadStatusEvent +import org.prairieserver.prairie.model.download.DownloadsListResponse +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DeviceMetadataProvider +import org.prairieserver.prairie.network.DurableLoginAuthority +import org.prairieserver.prairie.network.DurableLoginAuthorityProvider +import org.prairieserver.prairie.network.PrairieDeviceMetadata +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.api.DownloadsApi +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.network.apiv2.DownloadCreationV2Api +import org.prairieserver.prairie.network.apiv2.DownloadRegistryV2Api +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +private object CompletionTestNoDevices : DeviceMetadataProvider { + override suspend fun current(): PrairieDeviceMetadata? = null +} + +private fun registryStub() = DownloadRegistryV2Api(HttpClient(), TokenManagerImpl(), CompletionTestNoDevices, ApiV2Gate.Unrestricted) + +private class RegistryFake : DownloadsApi( + registry = registryStub(), + tokens = TokenManagerImpl(), + creation = DownloadCreationV2Api(HttpClient(), TokenManagerImpl(), CompletionTestNoDevices, registryStub(), ApiV2Gate.Unrestricted), +) { + var server: List = emptyList() + var onList: (() -> Unit)? = null + val listedScopes = mutableListOf() + var reportAnswer: (DownloadStatusEvent) -> ApiResult = { ApiResult.NetworkError(IllegalStateException("offline")) } + val reports = mutableListOf>() + + override suspend fun list(scope: AuthScopeSnapshot?): ApiResult { + listedScopes += scope + onList?.invoke() + return ApiResult.Success(DownloadsListResponse(server)) + } + + override suspend fun reportStatus(id: String, event: DownloadStatusEvent, scope: AuthScopeSnapshot?): ApiResult { + reports += id to event + return reportAnswer(event) + } +} + +private fun entry(status: String, revision: Int? = 1, bytes: Long = 0, completedAt: String? = null, quality: String? = "original") = DownloadRecord( + id = "dl", contentId = "movie", mediaFileId = 42, fileSize = 1000, bytesSent = bytes, kind = "queued", + status = status, createdAt = "2026-09-29T00:00:00Z", completedAt = completedAt, revision = revision, + quality = quality, effectiveQuality = quality, +) + +class DownloadsRepositoryCompletionTest { + + @Test + fun `an old queued job resolves its revision with an empty UI cache`() = runTest { + val owner = DurableLoginAuthority("login", AuthScopeSnapshot("server", "profile", "https://example.invalid", "proof")) + val authorities = object : DurableLoginAuthorityProvider { override suspend fun snapshotDurableLoginAuthority() = owner } + val api = RegistryFake().apply { server = listOf(entry("ready", revision = 7)) } + val repo = DownloadsRepository(api, authorities = authorities) + + val row = assertIs>(repo.resolveTransferRecord("dl", 42, owner)).data + + assertEquals(7, row.revision) + assertEquals(listOf(owner.scope), api.listedScopes) + assertEquals(emptyList(), repo.records.value) + } + + @Test + fun `an old job cannot resolve a replacement targeting another file`() = runTest { + val api = RegistryFake().apply { server = listOf(entry("ready", revision = 2).copy(mediaFileId = 99)) } + assertEquals(409, assertIs(DownloadsRepository(api).resolveTransferRecord("dl", 42)).code) + assertEquals(404, assertIs(DownloadsRepository(api).resolveTransferRecord("gone", 42)).code) + } + + @Test + fun `an old job cannot use a revision returned after its owner changed`() = runTest { + val owner = DurableLoginAuthority("login", AuthScopeSnapshot("server", "profile", "https://example.invalid", null)) + var active = owner + val authorities = object : DurableLoginAuthorityProvider { override suspend fun snapshotDurableLoginAuthority() = active } + val api = RegistryFake().apply { + server = listOf(entry("ready")) + onList = { active = owner.copy(loginId = "other-login") } + } + val repo = DownloadsRepository(api, authorities = authorities) + + assertEquals("identity_changed", assertIs(repo.resolveTransferRecord("dl", 42, owner)).error) + assertEquals(emptyList(), repo.records.value) + } + + @Test + fun `refresh keeps a local completion while the server still says ready`() = runTest { + val api = RegistryFake().apply { server = listOf(entry("ready")) } + val repo = DownloadsRepository(api) + repo.refresh() + // The worker publishes the finished file before the server hears about it. + repo.upsertLocal(entry("completed", bytes = 1000, completedAt = "2026-09-29T00:01:00Z")) + + repo.refresh() + + val row = repo.records.value.single() + assertEquals("completed", row.status) + assertEquals(1000, row.bytesSent) + assertEquals("2026-09-29T00:01:00Z", row.completedAt) + } + + @Test + fun `refresh keeps a completion seeded from local metadata with the same revision`() = runTest { + val api = RegistryFake().apply { server = listOf(entry("downloading")) } + val repo = DownloadsRepository(api) + repo.seedFromSidecars(listOf(entry("completed", bytes = 1000))) + + repo.refresh() + + assertEquals("completed", repo.records.value.single().status) + } + + @Test + fun `a replaced server revision wins over the old local completion`() = runTest { + val api = RegistryFake().apply { server = listOf(entry("ready", revision = 2)) } + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", revision = 1, bytes = 1000)) + + repo.refresh() + + val row = repo.records.value.single() + assertEquals("ready", row.status) + assertEquals(2, row.revision) + } + + @Test + fun `a completion saved without a revision does not cover any server revision`() = runTest { + // Same file and quality: a failed row re-created at revision 2 keeps its target. + val api = RegistryFake().apply { server = listOf(entry("ready", revision = 2)) } + val repo = DownloadsRepository(api) + repo.seedFromSidecars(listOf(entry("completed", revision = null, bytes = 1000))) + + repo.refresh() + + assertEquals("ready", repo.records.value.single().status) + } + + @Test + fun `server failure states are not masked by a local completion`() = runTest { + val api = RegistryFake().apply { server = listOf(entry("failed")) } + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000)) + + repo.refresh() + + assertEquals("failed", repo.records.value.single().status) + } + + @Test + fun `a completion report records the server acknowledgement and keeps local state`() = runTest { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000)) + val event = DownloadStatusEvent("completed", "2026-09-29T00:01:00.000Z", 1) + api.reportAnswer = { entry("completed", completedAt = "2026-09-29T00:01:00Z").copy(statusEventAt = it.updatedAt).let { ApiResult.Success(it) } } + + assertIs>(repo.reportStatus("dl", event)) + + assertEquals(listOf("dl" to event), api.reports) + val row = repo.records.value.single() + assertEquals("completed", row.status) + assertEquals(1000, row.bytesSent) + assertEquals("2026-09-29T00:01:00Z", row.completedAt) + assertEquals("2026-09-29T00:01:00.000Z", row.statusEventAt) + } + + @Test + fun `a late downloading answer does not undo a local completion`() = runTest { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000)) + api.reportAnswer = { ApiResult.Success(entry("downloading")) } + + repo.reportStatus("dl", DownloadStatusEvent("downloading", "2026-09-29T00:00:30.000Z", 1)) + + assertEquals("completed", repo.records.value.single().status) + } + + @Test + fun `a late downloading answer does not revive a failed transfer`() = runTest { + val api = RegistryFake() + val repo = DownloadsRepository(api) + // The worker failed permanently and cleaned up after the report was sent. + repo.upsertLocal(entry("failed")) + api.reportAnswer = { ApiResult.Success(entry("downloading").copy(statusEventAt = it.updatedAt)) } + + repo.reportStatus("dl", DownloadStatusEvent("downloading", "2026-09-29T00:00:30.000Z", 1)) + + val row = repo.records.value.single() + assertEquals("failed", row.status) + assertEquals("2026-09-29T00:00:30.000Z", row.statusEventAt) + } + + @Test + fun `an acknowledgement for a superseded revision leaves the replacement alone`() = runTest { + val api = RegistryFake().apply { server = listOf(entry("ready", revision = 2, quality = "2mbps")) } + val repo = DownloadsRepository(api) + repo.refresh() + api.reportAnswer = { ApiResult.Success(entry("completed", completedAt = "2026-09-29T00:01:00Z").copy(statusEventAt = it.updatedAt)) } + + repo.reportStatus("dl", DownloadStatusEvent("completed", "2026-09-29T00:01:00.000Z", 1)) + + assertEquals(entry("ready", revision = 2, quality = "2mbps"), repo.records.value.single()) + } + + @Test + fun `an older acknowledgement keeps the newer status event time`() = runTest { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000).copy(statusEventAt = "2026-09-29T00:01:00.000Z")) + api.reportAnswer = { ApiResult.Success(entry("downloading").copy(statusEventAt = it.updatedAt)) } + + repo.reportStatus("dl", DownloadStatusEvent("downloading", "2026-09-29T00:00:30.000Z", 1)) + + assertEquals("2026-09-29T00:01:00.000Z", repo.records.value.single().statusEventAt) + } + + @Test + fun `acknowledgements compare whole and fractional seconds chronologically`() = runTest { + val whole = "2026-09-29T00:01:00Z" + val fractional = "2026-09-29T00:01:00.500Z" + for ((cached, acknowledged) in listOf(whole to fractional, fractional to whole)) { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000).copy(statusEventAt = cached)) + api.reportAnswer = { ApiResult.Success(entry("completed").copy(statusEventAt = acknowledged)) } + + repo.reportStatus("dl", DownloadStatusEvent("completed", acknowledged, 1)) + + assertEquals(fractional, repo.records.value.single().statusEventAt) + } + } + + @Test + fun `acknowledgements compare instants across numeric offsets`() = runTest { + val older = "2026-09-29T01:01:00+01:00" + val newer = "2026-09-29T00:01:00.500Z" + for ((cached, acknowledged) in listOf(older to newer, newer to older)) { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000).copy(statusEventAt = cached)) + api.reportAnswer = { ApiResult.Success(entry("completed").copy(statusEventAt = acknowledged)) } + + repo.reportStatus("dl", DownloadStatusEvent("completed", acknowledged, 1)) + + assertEquals(newer, repo.records.value.single().statusEventAt) + } + } + + @Test + fun `an invalid timestamp cannot replace a valid acknowledgement`() = runTest { + val valid = "2026-09-29T00:01:00.500Z" + for ((cached, acknowledged) in listOf("invalid" to valid, valid to "invalid")) { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000).copy(statusEventAt = cached)) + api.reportAnswer = { ApiResult.Success(entry("completed").copy(statusEventAt = acknowledged)) } + + repo.reportStatus("dl", DownloadStatusEvent("completed", valid, 1)) + + assertEquals(valid, repo.records.value.single().statusEventAt) + } + } + + @Test + fun `a failed report leaves the cache untouched`() = runTest { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000)) + api.reportAnswer = { ApiResult.Error(409, "conflict", "revision changed") } + + val result = repo.reportStatus("dl", DownloadStatusEvent("completed", "2026-09-29T00:01:00.000Z", 1)) + + assertEquals(409, assertIs(result).code) + assertEquals(entry("completed", bytes = 1000), repo.records.value.single()) + } +}