Merge pull request 'Release: web UX overhaul + Android native port + server polish' (#59) from dev into main
test-go / test (push) Successful in 33s
test-web / test (push) Successful in 44s
android / Build + lint + test (push) Successful in 4m49s
android / Build signed release APK (push) Failing after 3m32s
test-go / integration (push) Successful in 9m33s
release / release (push) Failing after 15m9s

This commit was merged in pull request #59.
This commit is contained in:
2026-06-01 16:11:21 -04:00
353 changed files with 29899 additions and 725 deletions
-152
View File
@@ -1,152 +0,0 @@
name: flutter
# Analyze + test the Flutter mobile client; build an APK only where
# it's actually consumed. dev pushes: analyze+test+codegen only
# (~3min) — the operator dev-tests via a local Android Studio build,
# not the CI artifact. main pushes: also build a debug APK as the
# native-build safety net (Gradle/manifest/plugin breakage that
# analyze+test can't catch) before any release tag. tags: signed
# release APK. Only runs when the diff touches flutter_client/ or a
# shared web input sync_shared.sh copies.
on:
push:
branches: [dev, main]
tags: ['v*']
paths:
- 'flutter_client/**'
- 'web/src/lib/styles/tokens.json'
- 'web/src/lib/styles/error-copy.json'
- 'web/static/placeholders/album-fallback.svg'
- '.forgejo/workflows/flutter.yml'
pull_request:
branches: [main]
paths:
- 'flutter_client/**'
- 'web/src/lib/styles/tokens.json'
- 'web/src/lib/styles/error-copy.json'
- 'web/static/placeholders/album-fallback.svg'
- '.forgejo/workflows/flutter.yml'
workflow_dispatch:
jobs:
analyze-test-build:
# Toolchain selected via container.image per ci-runners.md
# (label = scheduling handle, image = environment). The ci-flutter
# image ships Flutter SDK + Android cmdline-tools/platform/build-tools
# + Java 25. See ci-requirements.md.
runs-on: flutter-ci
container:
image: git.fabledsword.com/bvandeusen/ci-flutter:3.44
defaults:
run:
working-directory: flutter_client
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Sync shared assets from web/
run: ./tool/sync_shared.sh
- name: Verify generated tokens.dart matches JSON
shell: bash
run: |
dart run tool/gen_tokens.dart
if ! git diff --exit-code lib/theme/tokens.dart; then
echo "::error::lib/theme/tokens.dart is out of sync with shared/fabledsword.tokens.json"
echo "Run: cd flutter_client && dart run tool/gen_tokens.dart"
exit 1
fi
- name: Pub get
run: flutter pub get
- name: Codegen (drift, etc.)
# build_runner generates *.g.dart files (drift schemas, etc.)
# which are gitignored. Must run before analyze + test so the
# generated symbols exist.
run: dart run build_runner build --delete-conflicting-outputs
- name: Analyze
run: flutter analyze --fatal-infos
- name: Test
run: flutter test --reporter compact
- name: Build debug APK
# main-only: native-build safety net before release tags.
# dev skips this (~7min) — operator dev-tests locally.
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: flutter build apk --debug
- name: Decode signing keystore
if: startsWith(github.ref, 'refs/tags/v')
# Reconstructs the release keystore from a base64-encoded
# CI secret so every tagged build is signed with the same
# key. Without this, each CI runner would generate its own
# debug keystore and Android would refuse to upgrade an
# existing install (signature mismatch).
shell: bash
env:
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
run: |
if [ -z "${ANDROID_KEYSTORE_B64}" ]; then
echo "::error::ANDROID_KEYSTORE_B64 secret is missing — release builds need a consistent signing key"
exit 1
fi
KEYSTORE_PATH="${RUNNER_TEMP}/minstrel-release.keystore"
echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "${KEYSTORE_PATH}"
echo "ANDROID_KEYSTORE_PATH=${KEYSTORE_PATH}" >> "${GITHUB_ENV}"
- name: Build release APK
if: startsWith(github.ref, 'refs/tags/v')
# Inject the tag (sans leading 'v') as the build's --build-name
# so PackageInfo.version matches what /api/client/version
# reports — otherwise the in-app update banner would always
# think the user is behind because pubspec.yaml's static
# version drifts from the actual release tag.
#
# ANDROID_KEYSTORE_PATH is set by the previous step;
# build.gradle.kts picks it up and signs the release APK with
# the operator's persistent keystore.
shell: bash
env:
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
TAG="${GITHUB_REF#refs/tags/v}"
flutter build apk --release --build-name="${TAG}"
- name: Upload debug APK artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: forgejo/upload-artifact@v3
with:
name: minstrel-debug-${{ github.sha }}
path: flutter_client/build/app/outputs/flutter-apk/app-debug.apk
- name: Attach release APK to release
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
run: |
TAG="${GITHUB_REF#refs/tags/}"
REPO="${GITHUB_REPOSITORY}"
# Look up the release ID for this tag. The release object must
# exist already — operator creates it (or release.yml will, in
# a future enhancement) before pushing the tag.
RELEASE_ID=$(curl -fsSL \
-H "Authorization: token ${CI_TOKEN}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}" \
| grep -oP '"id":\s*\K[0-9]+' | head -1)
if [ -z "${RELEASE_ID}" ]; then
echo "::error::release for tag ${TAG} not found"
exit 1
fi
curl -fsSL \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@build/app/outputs/flutter-apk/app-release.apk" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk"
-40
View File
@@ -1,40 +0,0 @@
name: test-web
# Web SPA: vitest + svelte-check. Runs on push to dev/main and PRs to
# main, scoped to web/** changes only — Go-only or Flutter-only diffs
# don't trigger this workflow.
on:
push:
branches: [dev, main]
paths:
- 'web/**'
- '.forgejo/workflows/test-web.yml'
pull_request:
branches: [main]
paths:
- 'web/**'
- '.forgejo/workflows/test-web.yml'
jobs:
test:
runs-on: go-ci
container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26
defaults:
run:
working-directory: web
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install deps
run: npm ci
- name: Type-check + svelte-check
run: npm run check
- name: Vitest
run: npm test
+159
View File
@@ -0,0 +1,159 @@
name: android
# Native Android (Kotlin/Compose/Media3) — M8 rewrite, now the only client.
# At cutover (M8 phase 14.4) the asset name flipped from
# minstrel-android-<tag>.apk to minstrel-<tag>.apk so the server's bundled
# in-app-update channel (release.yml polls this name) resolves to the
# native APK with no further changes.
#
# Tag format: vYYYY.MM.DD (per-day CalVer, mutable within the day). If a
# tag is force-moved within the day, this workflow re-runs and overwrites
# the existing release asset — same name, new content.
on:
push:
branches: [main, dev]
tags: ['v*']
paths:
- 'android/**'
- '.gitea/workflows/android.yml'
# pull_request trigger intentionally omitted — see test-web.yml for
# the rationale (single-author repo, push covers PR-merge equivalent).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
# Silences the JDK 22+ "restricted method in java.lang.System has been
# called" warning that Gradle 9.1's bundled native-platform jar trips
# at launch (System.load for native primitives). Affects the LAUNCHER
# JVM, not the daemon — that's why org.gradle.jvmargs in
# gradle.properties isn't enough. Future-compat: required opt-in once
# JDK 25 promotes the warning to an error.
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
jobs:
build:
name: Build + lint + test
# Using flutter-ci runner label because it's the only proven-working
# label with docker that can pull our container.image. Switch to
# android-ci once the operator registers that runner label.
runs-on: flutter-ci
container:
image: git.fabledsword.com/bvandeusen/ci-android:36
defaults:
run:
working-directory: android
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache Gradle dirs
# Resolved deps + Gradle distribution + Kotlin daemon caches.
# Saves ~3 min per CI run after the first warm-up.
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.kotlin
key: gradle-${{ runner.os }}-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Make gradlew executable
run: chmod +x ./gradlew
- name: Gradle wrapper validation
run: ./gradlew --version
- name: ktlint
run: ./gradlew ktlintCheck
- name: detekt
run: ./gradlew detekt
- name: Unit tests
run: ./gradlew testDebugUnitTest
- name: Assemble debug
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: ./gradlew assembleDebug
- name: Upload debug APK
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
with:
name: minstrel-android-debug-${{ github.sha }}
path: android/app/build/outputs/apk/debug/app-debug.apk
release:
name: Build signed release APK
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: flutter-ci
container:
image: git.fabledsword.com/bvandeusen/ci-android:36
defaults:
run:
working-directory: android
env:
# PKCS12 keystores collapse store + key password into a single
# value, so both env vars map to one secret. build.gradle still
# reads them separately to stay format-agnostic.
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Make gradlew executable
run: chmod +x ./gradlew
- name: Decode signing keystore
# Reuses the same keystore env-var contract as flutter.yml so the
# native APK is signed with the same key — Android will accept
# the upgrade in place at cutover without uninstall.
env:
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
shell: bash
run: |
if [ -z "${ANDROID_KEYSTORE_B64}" ]; then
echo "::error::ANDROID_KEYSTORE_B64 missing"; exit 1
fi
KEYSTORE_PATH="${RUNNER_TEMP}/minstrel-release.keystore"
echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "${KEYSTORE_PATH}"
echo "ANDROID_KEYSTORE_PATH=${KEYSTORE_PATH}" >> "${GITHUB_ENV}"
- name: Build release APK
run: ./gradlew assembleRelease
- name: Attach APK to release
# M8 phase 14.4 cutover: the asset is now minstrel-<tag>.apk so
# release.yml's bundled in-app-update fetcher (which polls this
# exact filename) resolves to the native APK with no further
# workflow plumbing.
shell: bash
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
run: |
TAG="${GITHUB_REF#refs/tags/}"
REPO="${GITHUB_REPOSITORY}"
RELEASE_ID=$(curl -fsSL \
-H "Authorization: token ${CI_TOKEN}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}" \
| grep -oP '"id":\s*\K[0-9]+' | head -1)
if [ -z "${RELEASE_ID}" ]; then
echo "::error::release for ${TAG} not found"; exit 1
fi
curl -fsSL \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@app/build/outputs/apk/release/app-release.apk" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk"
@@ -1,10 +1,16 @@
name: release name: release
# Builds and pushes the minstrel container image to the Forgejo registry. # Builds and pushes the minstrel container image to the Gitea registry.
# #
# push to main → :main # push to main → :main and :latest
# push tag vX.Y.Z → :vX.Y.Z and :latest # push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest
# workflow_dispatch → manual trigger (same rules based on the ref) # workflow_dispatch → manual trigger (same rules based on the ref)
#
# Release model: per-day CalVer tags (no trailing patch digit). The day's
# tag is intentionally mutable — if a second release happens the same day,
# move the tag with `git push -f origin vYYYY.MM.DD` and the image tag of
# the same name gets overwritten. :latest is updated by every main push
# AND every tag push, so it always reflects the newest blessed image.
on: on:
push: push:
@@ -20,6 +26,13 @@ on:
- '**/*.md' - '**/*.md'
workflow_dispatch: workflow_dispatch:
# Force-moving the per-day tag (or rapidly re-pushing to main) should
# supersede the in-flight build — the operator explicitly wants the
# later commit to win.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
release: release:
runs-on: go-ci runs-on: go-ci
@@ -55,9 +68,13 @@ jobs:
echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "::notice::Release build: ${VERSION} + latest" echo "::notice::Release build: ${VERSION} + latest"
else else
echo "args=-t ${IMAGE}:main" >> "$GITHUB_OUTPUT" # Main is the protected, post-PR-merge branch. Treat it as the
# rolling stable channel — every main push moves :latest.
# Pinned consumers can target :vYYYY.MM.DD; everyone else
# gets the newest main.
echo "args=-t ${IMAGE}:main -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "version=main" >> "$GITHUB_OUTPUT" echo "version=main" >> "$GITHUB_OUTPUT"
echo "::notice::Main-branch build: :main" echo "::notice::Main-branch build: :main + :latest"
fi fi
- name: Registry login - name: Registry login
@@ -30,18 +30,13 @@ on:
- 'internal/**' - 'internal/**'
- 'cmd/**' - 'cmd/**'
- '.golangci.yml' - '.golangci.yml'
- '.forgejo/workflows/test-go.yml' - '.gitea/workflows/test-go.yml'
pull_request:
branches: [main] # pull_request trigger intentionally omitted — see test-web.yml for
paths: # the rationale (single-author repo, push covers PR-merge equivalent).
- '**/*.go' concurrency:
- 'go.mod' group: ${{ github.workflow }}-${{ github.ref }}
- 'go.sum' cancel-in-progress: true
- 'sqlc.yaml'
- 'internal/**'
- 'cmd/**'
- '.golangci.yml'
- '.forgejo/workflows/test-go.yml'
jobs: jobs:
test: test:
+44
View File
@@ -0,0 +1,44 @@
name: test-web
# Web SPA: vitest + svelte-check. Runs on push to dev/main only —
# the `pull_request` trigger is intentionally omitted because every
# branch on this repo is local-only (no fork PRs), so the dev push
# fully covers what a PR run would re-execute. Keeping both events
# doubled CI cost on every commit.
on:
push:
branches: [dev, main]
paths:
- 'web/**'
- '.gitea/workflows/test-web.yml'
# Cancel an earlier in-flight run for the same ref when a newer
# commit arrives. With cancel-in-progress, rapid re-pushes don't
# pile up zombie runs.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: go-ci
container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26
defaults:
run:
working-directory: web
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install deps
run: npm ci
- name: Type-check + svelte-check
run: npm run check
- name: Vitest
run: npm test
+29 -3
View File
@@ -38,11 +38,19 @@ go.work.sum
# canonical record lives in commit messages and the running code). # canonical record lives in commit messages and the running code).
docs/superpowers/ docs/superpowers/
# Per-machine Claude Code settings + remember-skill memory artifacts. # Per-machine Claude Code settings + remember-skill memory artifacts +
# Both are operator-scoped; the canonical project memory lives under # project-level Claude/AI instruction files. All operator-scoped; the
# ~/.claude/projects/ outside the repo. # canonical project memory lives under ~/.claude/projects/ outside the
# repo. CLAUDE.md/AGENTS.md/GEMINI.md stay on the operator's machine
# only — they're tool-specific working notes, not project artifacts.
.claude/ .claude/
.remember/ .remember/
CLAUDE.md
AGENTS.md
GEMINI.md
.cursorrules
.windsurfrules
.aider.conf.yml
# Flutter # Flutter
flutter_client/.dart_tool/ flutter_client/.dart_tool/
@@ -57,3 +65,21 @@ flutter_client/android/app/build/
flutter_client/android/local.properties flutter_client/android/local.properties
flutter_client/android/key.properties flutter_client/android/key.properties
flutter_client/*.iml flutter_client/*.iml
# Native Android (Kotlin/Compose) — M8 rewrite
android/.gradle/
android/.kotlin/
android/.idea/
android/build/
android/app/build/
android/local.properties
android/*.iml
android/app/*.iml
# Release signing material — keystore + extracted credentials live local
# only; the canonical copy is the Gitea repo secret (ANDROID_KEYSTORE_B64
# + the alias/password secrets). Losing the local file is fine; losing
# the secret means rebuilding the keystore + reinstalling all clients.
android/*.keystore
android/*.keystore.*
android/*.jks
android/keystore.properties
+2 -2
View File
@@ -91,7 +91,7 @@ Two concurrent dev processes:
truncates your dev `minstrel` data (admin user, library, likes). It truncates your dev `minstrel` data (admin user, library, likes). It
brings up the compose Postgres and creates the test DB if missing. brings up the compose Postgres and creates the test DB if missing.
- CI runs both: a fast `go test -short -race` gate plus an integration - CI runs both: a fast `go test -short -race` gate plus an integration
job with its own ephemeral Postgres (`.forgejo/workflows/test-go.yml`). job with its own ephemeral Postgres (`.gitea/workflows/test-go.yml`).
### Production build ### Production build
@@ -101,7 +101,7 @@ Two concurrent dev processes:
- Day-to-day work happens on `dev` (or feature branches merged into `dev`). - Day-to-day work happens on `dev` (or feature branches merged into `dev`).
- `main` is **protected** — changes land via PR from `dev`. - `main` is **protected** — changes land via PR from `dev`.
- Releases are cut by tagging `v*` off `main`; the release workflow builds and pushes the container image to the Forgejo registry. - Releases are cut by tagging `v*` off `main`; the release workflow builds and pushes the container image to the Gitea registry.
Task and milestone tracking: Fable (`Minstrel` project, id 12). Task and milestone tracking: Fable (`Minstrel` project, id 12).
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+1
View File
@@ -0,0 +1 @@
Minstrel
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
+195
View File
@@ -0,0 +1,195 @@
plugins {
alias(libs.plugins.android.application)
// kotlin-android NOT applied: AGP 9 enables built-in Kotlin by default,
// and KSP 2.3.x supports it (PR #2674, merged Oct 2025). serialization
// + compose-compiler are language-level Kotlin compiler plugins and
// still need explicit application.
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.ksp)
alias(libs.plugins.androidx.room)
alias(libs.plugins.hilt)
alias(libs.plugins.ktlint)
alias(libs.plugins.detekt)
}
android {
namespace = "com.fabledsword.minstrel"
compileSdk = 36
defaultConfig {
applicationId = "com.fabledsword.minstrel"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0-native"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true }
}
// Room schema export is handled by the androidx.room Gradle plugin via
// the room {} block below — replaces the legacy
// `ksp { arg("room.schemaLocation", ...) }` pattern in Room 2.7+.
signingConfigs {
create("release") {
val keystorePath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
if (!keystorePath.isNullOrEmpty()) {
storeFile = file(keystorePath)
storePassword = System.getenv("ANDROID_STORE_PASSWORD")
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
}
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig =
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
signingConfigs.getByName("debug")
} else {
signingConfigs.getByName("release")
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources.excludes +=
setOf(
"/META-INF/{AL2.0,LGPL2.1}",
"META-INF/LICENSE.md",
"META-INF/LICENSE-notice.md",
)
}
}
// Kotlin 2.x: `kotlinOptions { ... }` inside `android { }` is gone; the
// modern shape is the top-level `kotlin { compilerOptions { ... } }` block,
// which works whether Kotlin comes from AGP 9's built-in path or an
// explicit plugin alias.
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
// Opt into the future Kotlin behavior: annotations on
// constructor parameters apply to both the param AND the
// generated property/field. Without this flag, Kotlin 2.x
// emits a deprecation warning at every @Inject /
// @ApplicationContext / @ApplicationScope constructor-
// parameter use. Setting it now matches what becomes the
// default in Kotlin 2.3 (KT-73255).
freeCompilerArgs.add("-Xannotation-default-target=param-property")
}
}
// Room schema export — generated JSON dumps live under android/app/schemas/
// for migration-test fixtures. Replaces the legacy
// `ksp { arg("room.schemaLocation", ...) }` arg-passing.
room {
schemaDirectory("$projectDir/schemas")
}
detekt {
toolVersion = libs.versions.detekt.get()
config.setFrom(files("$rootDir/config/detekt.yml"))
buildUponDefaultConfig = true
parallel = true
// `autoCorrect` was dropped in detekt 2.0's DSL options list.
}
// detekt 2.0 moved its task types to the `dev.detekt.gradle` package and
// flipped `jvmTarget` to the Property API. Pin to 17 to match our actual
// bytecode target (compileOptions.targetCompatibility +
// kotlin.compilerOptions.jvmTarget).
tasks.withType<dev.detekt.gradle.Detekt>().configureEach {
jvmTarget.set("17")
}
tasks.withType<dev.detekt.gradle.DetektCreateBaselineTask>().configureEach {
jvmTarget.set("17")
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.nav.compose)
implementation(libs.androidx.hilt.nav.compose)
implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler)
implementation(libs.androidx.work.runtime.ktx)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.material3)
implementation(libs.compose.ui.text.google.fonts)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization.converter)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.okhttp.sse)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime)
implementation(libs.media3.exoplayer)
implementation(libs.media3.session)
implementation(libs.media3.datasource.okhttp)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.androidx.palette)
implementation(libs.icons.lucide)
implementation(libs.timber)
testImplementation(libs.junit.jupiter)
testImplementation(libs.turbine)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.okhttp.mockwebserver)
// kotlin.test for assertEquals/assertNull/etc. — version managed by
// the applied Kotlin plugin so no explicit version pin needed.
testImplementation(kotlin("test"))
// Gradle 9 no longer auto-injects the JUnit Platform launcher; must
// be declared explicitly on the runtime classpath for useJUnitPlatform()
// to discover tests.
testRuntimeOnly(libs.junit.platform.launcher)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.compose.ui.test)
// androidTest dep parity with the unit-test side; needed once we have
// instrumented tests that consume the same APIs.
androidTestImplementation(kotlin("test"))
androidTestImplementation(libs.kotlinx.coroutines.test)
debugImplementation(libs.compose.ui.test.manifest)
}
tasks.withType<Test> { useJUnitPlatform() }
+19
View File
@@ -0,0 +1,19 @@
# Keep the entry classes
-keep class com.fabledsword.minstrel.MainActivity { *; }
-keep class com.fabledsword.minstrel.MinstrelApplication { *; }
# kotlinx.serialization (from the kotlinx.serialization README)
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** {
*** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
kotlinx.serialization.KSerializer serializer(...);
}
# Hilt
-keep class * extends androidx.lifecycle.ViewModel { *; }
# Media3 keep player/exo classes
-keep class androidx.media3.** { *; }
@@ -0,0 +1,645 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "f78c0a5166450f1f31fce7c1d625f87d",
"entities": [
{
"tableName": "sync_metadata",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cursor` INTEGER NOT NULL, `lastSyncAt` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cursor",
"columnName": "cursor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSyncAt",
"columnName": "lastSyncAt",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_artists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortName` TEXT NOT NULL, `mbid` TEXT, `artistThumbPath` TEXT, `artistFanartPath` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortName",
"columnName": "sortName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mbid",
"columnName": "mbid",
"affinity": "TEXT"
},
{
"fieldPath": "artistThumbPath",
"columnName": "artistThumbPath",
"affinity": "TEXT"
},
{
"fieldPath": "artistFanartPath",
"columnName": "artistFanartPath",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_albums",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `sortTitle` TEXT NOT NULL, `releaseDate` TEXT, `coverPath` TEXT, `mbid` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortTitle",
"columnName": "sortTitle",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "releaseDate",
"columnName": "releaseDate",
"affinity": "TEXT"
},
{
"fieldPath": "coverPath",
"columnName": "coverPath",
"affinity": "TEXT"
},
{
"fieldPath": "mbid",
"columnName": "mbid",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_tracks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `trackNumber` INTEGER, `discNumber` INTEGER, `filePath` TEXT, `fileFormat` TEXT, `genre` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "durationMs",
"columnName": "durationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trackNumber",
"columnName": "trackNumber",
"affinity": "INTEGER"
},
{
"fieldPath": "discNumber",
"columnName": "discNumber",
"affinity": "INTEGER"
},
{
"fieldPath": "filePath",
"columnName": "filePath",
"affinity": "TEXT"
},
{
"fieldPath": "fileFormat",
"columnName": "fileFormat",
"affinity": "TEXT"
},
{
"fieldPath": "genre",
"columnName": "genre",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_likes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `likedAt` INTEGER NOT NULL, PRIMARY KEY(`userId`, `entityType`, `entityId`))",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityType",
"columnName": "entityType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entityId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "likedAt",
"columnName": "likedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"entityType",
"entityId"
]
}
},
{
"tableName": "cached_playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userId` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `isPublic` INTEGER NOT NULL, `coverPath` TEXT, `trackCount` INTEGER NOT NULL, `durationSec` INTEGER NOT NULL, `systemVariant` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isPublic",
"columnName": "isPublic",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "coverPath",
"columnName": "coverPath",
"affinity": "TEXT"
},
{
"fieldPath": "trackCount",
"columnName": "trackCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "durationSec",
"columnName": "durationSec",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "systemVariant",
"columnName": "systemVariant",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_playlist_tracks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `trackId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlistId`, `trackId`))",
"fields": [
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackId",
"columnName": "trackId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"playlistId",
"trackId"
]
}
},
{
"tableName": "cached_quarantine_mine",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `reason` TEXT NOT NULL, `notes` TEXT, `createdAt` TEXT NOT NULL, `trackTitle` TEXT NOT NULL, `trackDurationMs` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `albumTitle` TEXT NOT NULL, `albumCoverArtPath` TEXT, `artistId` TEXT NOT NULL, `artistName` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`trackId`))",
"fields": [
{
"fieldPath": "trackId",
"columnName": "trackId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "reason",
"columnName": "reason",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notes",
"columnName": "notes",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackTitle",
"columnName": "trackTitle",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackDurationMs",
"columnName": "trackDurationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumTitle",
"columnName": "albumTitle",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumCoverArtPath",
"columnName": "albumCoverArtPath",
"affinity": "TEXT"
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistName",
"columnName": "artistName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"trackId"
]
}
},
{
"tableName": "audio_cache_index",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `path` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, `lastPlayedAt` INTEGER, `source` TEXT NOT NULL, PRIMARY KEY(`trackId`))",
"fields": [
{
"fieldPath": "trackId",
"columnName": "trackId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cachedAt",
"columnName": "cachedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastPlayedAt",
"columnName": "lastPlayedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"trackId"
]
}
},
{
"tableName": "cached_mutations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAttemptAt` INTEGER, `attempts` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "kind",
"columnName": "kind",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "payload",
"columnName": "payload",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastAttemptAt",
"columnName": "lastAttemptAt",
"affinity": "INTEGER"
},
{
"fieldPath": "attempts",
"columnName": "attempts",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_resume_state",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "json",
"columnName": "json",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_home_index",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`section` TEXT NOT NULL, `position` INTEGER NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`section`, `position`))",
"fields": [
{
"fieldPath": "section",
"columnName": "section",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "entityType",
"columnName": "entityType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entityId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"section",
"position"
]
}
},
{
"tableName": "auth_session",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `sessionCookie` TEXT, `baseUrl` TEXT NOT NULL, `userJson` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sessionCookie",
"columnName": "sessionCookie",
"affinity": "TEXT"
},
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userJson",
"columnName": "userJson",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
}
],
"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, 'f78c0a5166450f1f31fce7c1d625f87d')"
]
}
}
@@ -0,0 +1,650 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "4c6180b4cb157afbc109f26f39719439",
"entities": [
{
"tableName": "sync_metadata",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cursor` INTEGER NOT NULL, `lastSyncAt` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cursor",
"columnName": "cursor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSyncAt",
"columnName": "lastSyncAt",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_artists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortName` TEXT NOT NULL, `mbid` TEXT, `artistThumbPath` TEXT, `artistFanartPath` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortName",
"columnName": "sortName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mbid",
"columnName": "mbid",
"affinity": "TEXT"
},
{
"fieldPath": "artistThumbPath",
"columnName": "artistThumbPath",
"affinity": "TEXT"
},
{
"fieldPath": "artistFanartPath",
"columnName": "artistFanartPath",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_albums",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `sortTitle` TEXT NOT NULL, `releaseDate` TEXT, `coverPath` TEXT, `mbid` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortTitle",
"columnName": "sortTitle",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "releaseDate",
"columnName": "releaseDate",
"affinity": "TEXT"
},
{
"fieldPath": "coverPath",
"columnName": "coverPath",
"affinity": "TEXT"
},
{
"fieldPath": "mbid",
"columnName": "mbid",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_tracks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `trackNumber` INTEGER, `discNumber` INTEGER, `filePath` TEXT, `fileFormat` TEXT, `genre` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "durationMs",
"columnName": "durationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trackNumber",
"columnName": "trackNumber",
"affinity": "INTEGER"
},
{
"fieldPath": "discNumber",
"columnName": "discNumber",
"affinity": "INTEGER"
},
{
"fieldPath": "filePath",
"columnName": "filePath",
"affinity": "TEXT"
},
{
"fieldPath": "fileFormat",
"columnName": "fileFormat",
"affinity": "TEXT"
},
{
"fieldPath": "genre",
"columnName": "genre",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_likes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `likedAt` INTEGER NOT NULL, PRIMARY KEY(`userId`, `entityType`, `entityId`))",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityType",
"columnName": "entityType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entityId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "likedAt",
"columnName": "likedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"entityType",
"entityId"
]
}
},
{
"tableName": "cached_playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userId` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `isPublic` INTEGER NOT NULL, `coverPath` TEXT, `trackCount` INTEGER NOT NULL, `durationSec` INTEGER NOT NULL, `systemVariant` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isPublic",
"columnName": "isPublic",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "coverPath",
"columnName": "coverPath",
"affinity": "TEXT"
},
{
"fieldPath": "trackCount",
"columnName": "trackCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "durationSec",
"columnName": "durationSec",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "systemVariant",
"columnName": "systemVariant",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_playlist_tracks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `trackId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlistId`, `trackId`))",
"fields": [
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackId",
"columnName": "trackId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"playlistId",
"trackId"
]
}
},
{
"tableName": "cached_quarantine_mine",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `reason` TEXT NOT NULL, `notes` TEXT, `createdAt` TEXT NOT NULL, `trackTitle` TEXT NOT NULL, `trackDurationMs` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `albumTitle` TEXT NOT NULL, `albumCoverArtPath` TEXT, `artistId` TEXT NOT NULL, `artistName` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`trackId`))",
"fields": [
{
"fieldPath": "trackId",
"columnName": "trackId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "reason",
"columnName": "reason",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notes",
"columnName": "notes",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackTitle",
"columnName": "trackTitle",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackDurationMs",
"columnName": "trackDurationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumTitle",
"columnName": "albumTitle",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumCoverArtPath",
"columnName": "albumCoverArtPath",
"affinity": "TEXT"
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistName",
"columnName": "artistName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"trackId"
]
}
},
{
"tableName": "audio_cache_index",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `path` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, `lastPlayedAt` INTEGER, `source` TEXT NOT NULL, PRIMARY KEY(`trackId`))",
"fields": [
{
"fieldPath": "trackId",
"columnName": "trackId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cachedAt",
"columnName": "cachedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastPlayedAt",
"columnName": "lastPlayedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"trackId"
]
}
},
{
"tableName": "cached_mutations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAttemptAt` INTEGER, `attempts` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "kind",
"columnName": "kind",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "payload",
"columnName": "payload",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastAttemptAt",
"columnName": "lastAttemptAt",
"affinity": "INTEGER"
},
{
"fieldPath": "attempts",
"columnName": "attempts",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_resume_state",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "json",
"columnName": "json",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_home_index",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`section` TEXT NOT NULL, `position` INTEGER NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`section`, `position`))",
"fields": [
{
"fieldPath": "section",
"columnName": "section",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "entityType",
"columnName": "entityType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entityId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"section",
"position"
]
}
},
{
"tableName": "auth_session",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `sessionCookie` TEXT, `baseUrl` TEXT NOT NULL, `userJson` TEXT, `themeMode` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sessionCookie",
"columnName": "sessionCookie",
"affinity": "TEXT"
},
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userJson",
"columnName": "userJson",
"affinity": "TEXT"
},
{
"fieldPath": "themeMode",
"columnName": "themeMode",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
}
],
"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, '4c6180b4cb157afbc109f26f39719439')"
]
}
}
@@ -0,0 +1,660 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "644920ed281564a77a383ecaea9b9fdc",
"entities": [
{
"tableName": "sync_metadata",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cursor` INTEGER NOT NULL, `lastSyncAt` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cursor",
"columnName": "cursor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSyncAt",
"columnName": "lastSyncAt",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_artists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortName` TEXT NOT NULL, `mbid` TEXT, `artistThumbPath` TEXT, `artistFanartPath` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortName",
"columnName": "sortName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mbid",
"columnName": "mbid",
"affinity": "TEXT"
},
{
"fieldPath": "artistThumbPath",
"columnName": "artistThumbPath",
"affinity": "TEXT"
},
{
"fieldPath": "artistFanartPath",
"columnName": "artistFanartPath",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_albums",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `sortTitle` TEXT NOT NULL, `releaseDate` TEXT, `coverPath` TEXT, `mbid` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortTitle",
"columnName": "sortTitle",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "releaseDate",
"columnName": "releaseDate",
"affinity": "TEXT"
},
{
"fieldPath": "coverPath",
"columnName": "coverPath",
"affinity": "TEXT"
},
{
"fieldPath": "mbid",
"columnName": "mbid",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_tracks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `trackNumber` INTEGER, `discNumber` INTEGER, `filePath` TEXT, `fileFormat` TEXT, `genre` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "durationMs",
"columnName": "durationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trackNumber",
"columnName": "trackNumber",
"affinity": "INTEGER"
},
{
"fieldPath": "discNumber",
"columnName": "discNumber",
"affinity": "INTEGER"
},
{
"fieldPath": "filePath",
"columnName": "filePath",
"affinity": "TEXT"
},
{
"fieldPath": "fileFormat",
"columnName": "fileFormat",
"affinity": "TEXT"
},
{
"fieldPath": "genre",
"columnName": "genre",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_likes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `likedAt` INTEGER NOT NULL, PRIMARY KEY(`userId`, `entityType`, `entityId`))",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityType",
"columnName": "entityType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entityId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "likedAt",
"columnName": "likedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"entityType",
"entityId"
]
}
},
{
"tableName": "cached_playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userId` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `isPublic` INTEGER NOT NULL, `coverPath` TEXT, `trackCount` INTEGER NOT NULL, `durationSec` INTEGER NOT NULL, `systemVariant` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isPublic",
"columnName": "isPublic",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "coverPath",
"columnName": "coverPath",
"affinity": "TEXT"
},
{
"fieldPath": "trackCount",
"columnName": "trackCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "durationSec",
"columnName": "durationSec",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "systemVariant",
"columnName": "systemVariant",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_playlist_tracks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `trackId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlistId`, `trackId`))",
"fields": [
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackId",
"columnName": "trackId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"playlistId",
"trackId"
]
}
},
{
"tableName": "cached_quarantine_mine",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `reason` TEXT NOT NULL, `notes` TEXT, `createdAt` TEXT NOT NULL, `trackTitle` TEXT NOT NULL, `trackDurationMs` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `albumTitle` TEXT NOT NULL, `albumCoverArtPath` TEXT, `artistId` TEXT NOT NULL, `artistName` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`trackId`))",
"fields": [
{
"fieldPath": "trackId",
"columnName": "trackId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "reason",
"columnName": "reason",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notes",
"columnName": "notes",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackTitle",
"columnName": "trackTitle",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackDurationMs",
"columnName": "trackDurationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumTitle",
"columnName": "albumTitle",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumCoverArtPath",
"columnName": "albumCoverArtPath",
"affinity": "TEXT"
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistName",
"columnName": "artistName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"trackId"
]
}
},
{
"tableName": "audio_cache_index",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `path` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, `lastPlayedAt` INTEGER, `source` TEXT NOT NULL, PRIMARY KEY(`trackId`))",
"fields": [
{
"fieldPath": "trackId",
"columnName": "trackId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cachedAt",
"columnName": "cachedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastPlayedAt",
"columnName": "lastPlayedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"trackId"
]
}
},
{
"tableName": "cached_mutations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAttemptAt` INTEGER, `attempts` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "kind",
"columnName": "kind",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "payload",
"columnName": "payload",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastAttemptAt",
"columnName": "lastAttemptAt",
"affinity": "INTEGER"
},
{
"fieldPath": "attempts",
"columnName": "attempts",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_resume_state",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "json",
"columnName": "json",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_home_index",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`section` TEXT NOT NULL, `position` INTEGER NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`section`, `position`))",
"fields": [
{
"fieldPath": "section",
"columnName": "section",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "entityType",
"columnName": "entityType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entityId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"section",
"position"
]
}
},
{
"tableName": "auth_session",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `sessionCookie` TEXT, `baseUrl` TEXT NOT NULL, `userJson` TEXT, `themeMode` TEXT, `clientId` TEXT, `cacheSettingsJson` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sessionCookie",
"columnName": "sessionCookie",
"affinity": "TEXT"
},
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userJson",
"columnName": "userJson",
"affinity": "TEXT"
},
{
"fieldPath": "themeMode",
"columnName": "themeMode",
"affinity": "TEXT"
},
{
"fieldPath": "clientId",
"columnName": "clientId",
"affinity": "TEXT"
},
{
"fieldPath": "cacheSettingsJson",
"columnName": "cacheSettingsJson",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
}
],
"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, '644920ed281564a77a383ecaea9b9fdc')"
]
}
}
+54
View File
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".MinstrelApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Minstrel"
android:usesCleartextTraffic="true"
tools:targetApi="34">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Minstrel">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".player.MinstrelPlayerService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,88 @@
package com.fabledsword.minstrel
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
import com.fabledsword.minstrel.cache.CachedTrackIds
import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.nav.MinstrelNavGraph
import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds
import com.fabledsword.minstrel.theme.MinstrelTheme
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var seedCache: DetailSeedCache
@Inject lateinit var cachedTrackIds: CachedTrackIds
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { App(seedCache = seedCache, cachedTrackIds = cachedTrackIds) }
}
}
@Composable
private fun App(
seedCache: DetailSeedCache,
cachedTrackIds: CachedTrackIds,
themeVm: ThemePreferenceViewModel = hiltViewModel(),
gate: AuthGateViewModel = hiltViewModel(),
) {
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
CompositionLocalProvider(
LocalDetailSeedCache provides seedCache,
LocalCachedTrackIds provides cached,
) {
val startDestination by gate.startDestination.collectAsStateWithLifecycle()
val resolved = startDestination
if (resolved == null) {
BootSplash()
} else {
// No bottom nav, no drawer — navigation is per-screen via
// the AppBar actions row + kebab (`MainAppBarActions`). The
// MiniPlayer is included by each in-shell route's
// `ShellScaffold` wrap; full-screen routes (NowPlaying /
// Queue / unauthenticated) bypass the shell entirely.
val navController = rememberNavController()
MinstrelNavGraph(
navController = navController,
startDestination = resolved,
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
@Composable
private fun BootSplash() {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
}
@@ -0,0 +1,172 @@
package com.fabledsword.minstrel
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.fabledsword.minstrel.cache.CacheIndexer
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.events.LiveEventsDispatcher
import com.fabledsword.minstrel.metadata.FreshnessSweeper
import com.fabledsword.minstrel.player.CoverPrefetcher
import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.PlaybackErrorReporter
import com.fabledsword.minstrel.player.ResumeController
import com.fabledsword.minstrel.update.data.UpdateBannerController
import com.fabledsword.minstrel.update.data.VersionCheckController
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
@HiltAndroidApp
class MinstrelApplication :
Application(),
Configuration.Provider,
SingletonImageLoader.Factory {
@Inject lateinit var workerFactory: HiltWorkerFactory
/**
* The single shared OkHttp client (with the auth-cookie interceptor)
* — also handed to Coil so cover-art requests carry the session
* cookie and reuse the same connection pool + TLS config.
*/
@Inject lateinit var okHttpClient: OkHttpClient
/**
* Injecting ResumeController forces Hilt to construct the singleton
* so its init block (observe + persist on track change) wires up at
* app launch. We also fire `restore()` from onCreate so a torn-down
* player resumes the last queue without requiring UI interaction.
*/
@Inject lateinit var resumeController: ResumeController
/**
* Same construct-the-singleton trick — SyncController's init block
* subscribes to AuthStore.sessionCookie and fires `/api/library/sync`
* whenever the cookie transitions to a value (fresh sign-in OR
* cold start with persisted cookie). Without this @Inject the
* singleton would never instantiate.
*/
@Suppress("unused") @Inject lateinit var syncController: SyncController
/**
* Same construct-the-singleton trick — MutationReplayer's init
* block subscribes to AuthStore.sessionCookie and drains the
* offline write queue on every signed-in transition (sign-in OR
* cold start with persisted cookie). Without this @Inject the
* queue would only enqueue, never replay.
*/
@Suppress("unused") @Inject lateinit var mutationReplayer: MutationReplayer
/**
* Same construct-the-singleton trick — PlayEventsReporter's init
* block subscribes to PlayerController.uiState and reports the
* play-event lifecycle (started/ended/skipped/offline) to the
* server. Without this @Inject the singleton would never
* instantiate and Android plays would never reach the server.
*/
@Suppress("unused") @Inject lateinit var playEventsReporter: PlayEventsReporter
/**
* Same construct-the-singleton trick — PlaybackErrorReporter
* subscribes to PlayerController.playbackErrorEvents and emits
* coalesced "Couldn't play X" / "Skipped N tracks" messages on
* its own Flow, which ShellScaffold surfaces in the shared
* snackbar. Without this construct ExoPlayer skip-on-error is
* invisible to the user.
*/
@Suppress("unused") @Inject lateinit var playbackErrorReporter: PlaybackErrorReporter
/**
* Same construct-the-singleton trick — CoverPrefetcher's init
* block subscribes to PlayerController.uiState and warms Coil's
* cache with the next track's cover so track changes don't flash
* a loading state.
*/
@Suppress("unused") @Inject lateinit var coverPrefetcher: CoverPrefetcher
/**
* Same construct-the-singleton trick — CacheIndexer's init block
* subscribes to PlayerController.uiState and records each played
* track into `audio_cache_index`, giving the offline shuffle pools
* and the cached indicator their data source. Without this @Inject
* the index would stay empty and the offline pools would be inert.
*/
@Suppress("unused") @Inject lateinit var cacheIndexer: CacheIndexer
/**
* Same construct-the-singleton trick — VersionCheckController's
* init block starts a 5-min poll loop against /healthz so the
* shell-level VersionTooOldBanner can surface min_client_version
* mismatches without waiting for the next user-driven request.
*/
@Suppress("unused") @Inject lateinit var versionCheckController: VersionCheckController
/**
* Same construct-the-singleton trick — UpdateBannerController polls
* /api/client/version at launch + every 24h and drives the shell's
* soft "update available" banner. Without this @Inject the poll
* loop would never start and the banner would never surface.
*/
@Suppress("unused") @Inject lateinit var updateBannerController: UpdateBannerController
/**
* Same construct-the-singleton trick — FreshnessSweeper's init
* block starts a 30-min sweep loop that re-fetches cached
* album/artist/track rows whose `fetchedAt` is older than 24h
* via MetadataProvider, so the cache stays warm without
* user-driven pull-to-refresh.
*/
@Suppress("unused") @Inject lateinit var freshnessSweeper: FreshnessSweeper
/**
* Same construct-the-singleton trick — EventsStream's init block
* subscribes to AuthStore.sessionCookie and opens / closes the
* `/api/events/stream` SSE subscription on auth transitions.
* Without this @Inject the events SharedFlow would have no
* upstream and consumers would never see anything.
*/
@Suppress("unused") @Inject lateinit var eventsStream: EventsStream
/**
* Same construct-the-singleton trick — LiveEventsDispatcher's
* init block subscribes to EventsStream.events + the process
* lifecycle and routes cross-screen state refreshes.
*/
@Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher
@Inject @ApplicationScope lateinit var appScope: CoroutineScope
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
appScope.launch { resumeController.restore() }
}
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
/**
* Builds the process-singleton Coil ImageLoader with our shared
* OkHttp client as the network fetcher. The `callFactory` lambda
* is invoked lazily so Hilt has time to inject `okHttpClient`
* before Coil makes its first request.
*/
override fun newImageLoader(context: android.content.Context): ImageLoader =
ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient }))
}
.build()
}
@@ -0,0 +1,38 @@
package com.fabledsword.minstrel.admin.data
import com.fabledsword.minstrel.api.endpoints.AdminInvitesApi
import com.fabledsword.minstrel.api.endpoints.CreateInviteBody
import com.fabledsword.minstrel.models.Invite
import com.fabledsword.minstrel.models.wire.InviteWire
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Thin facade over `/api/admin/invites`. Same shape as the other
* admin repos — direct REST, no caching.
*/
@Singleton
class AdminInvitesRepository @Inject constructor(retrofit: Retrofit) {
private val api: AdminInvitesApi = retrofit.create()
suspend fun list(): List<Invite> = api.list().invites.map { it.toDomain() }
suspend fun create(note: String?): Invite =
api.create(CreateInviteBody(note = note?.takeIf { it.isNotEmpty() })).toDomain()
suspend fun revoke(token: String) {
api.revoke(token)
}
}
private fun InviteWire.toDomain(): Invite = Invite(
token = token,
invitedBy = invitedBy,
note = note,
createdAt = createdAt,
expiresAt = expiresAt,
redeemedAt = redeemedAt,
redeemedBy = redeemedBy,
)
@@ -0,0 +1,57 @@
package com.fabledsword.minstrel.admin.data
import com.fabledsword.minstrel.api.endpoints.AdminQuarantineApi
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import com.fabledsword.minstrel.models.AdminQuarantineReportRef
import com.fabledsword.minstrel.models.wire.AdminQuarantineItemWire
import com.fabledsword.minstrel.models.wire.AdminQuarantineReportWire
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Read-through accessor for the admin quarantine queue. Mirrors the
* Flutter `AdminQuarantineController`. No Room cache — same rationale
* as AdminRequests. Three resolution actions all delete the row from
* the queue once the server accepts them.
*/
@Singleton
class AdminQuarantineRepository @Inject constructor(
retrofit: Retrofit,
) {
private val api: AdminQuarantineApi = retrofit.create()
suspend fun list(): List<AdminQuarantineItemRef> = api.list().map { it.toDomain() }
suspend fun resolve(trackId: String) = api.resolve(trackId)
suspend fun deleteFile(trackId: String) = api.deleteFile(trackId)
suspend fun deleteViaLidarr(trackId: String) = api.deleteViaLidarr(trackId)
}
// ── Mappers ──
private fun AdminQuarantineReportWire.toDomain(): AdminQuarantineReportRef =
AdminQuarantineReportRef(
userId = userId,
username = username,
reason = reason,
notes = notes,
createdAt = createdAt,
)
private fun AdminQuarantineItemWire.toDomain(): AdminQuarantineItemRef =
AdminQuarantineItemRef(
trackId = trackId,
trackTitle = trackTitle,
artistName = artistName,
albumTitle = albumTitle,
albumId = albumId,
lidarrAlbumMbid = lidarrAlbumMbid,
reportCount = reportCount,
latestAt = latestAt,
reasonCounts = reasonCounts,
reports = reports.map { it.toDomain() },
)
@@ -0,0 +1,51 @@
package com.fabledsword.minstrel.admin.data
import com.fabledsword.minstrel.api.endpoints.AdminRequestsApi
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.models.RequestStatus
import com.fabledsword.minstrel.models.wire.RequestWire
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Read-through accessor for the admin cross-user requests queue.
* Mirrors `flutter_client/lib/admin/admin_providers.dart`'s
* AdminRequestsController.
*
* No Room caching — admin actions are infrequent and don't benefit
* from offline scrollback. `approve` and `reject` fire direct REST
* (no mutation queue) because operator confirmation is point-and-shoot;
* if the request fails the UI surfaces the error and rolls back.
*/
@Singleton
class AdminRequestsRepository @Inject constructor(
retrofit: Retrofit,
) {
private val api: AdminRequestsApi = retrofit.create()
suspend fun list(): List<RequestRef> = api.list().map { it.toDomain() }
suspend fun approve(id: String) = api.approve(id)
suspend fun reject(id: String) = api.reject(id)
}
private fun RequestWire.toDomain(): RequestRef = RequestRef(
id = id,
userId = userId,
status = RequestStatus.fromWire(status),
kind = kind,
artistName = artistName,
albumTitle = albumTitle,
trackTitle = trackTitle,
requestedAt = requestedAt,
decidedAt = decidedAt,
notes = notes,
importedAlbumCount = importedAlbumCount,
importedTrackCount = importedTrackCount,
matchedTrackId = matchedTrackId,
matchedAlbumId = matchedAlbumId,
matchedArtistId = matchedArtistId,
)
@@ -0,0 +1,46 @@
package com.fabledsword.minstrel.admin.data
import com.fabledsword.minstrel.api.endpoints.AdminUsersApi
import com.fabledsword.minstrel.api.endpoints.ResetPasswordBody
import com.fabledsword.minstrel.api.endpoints.SetAdminBody
import com.fabledsword.minstrel.api.endpoints.SetAutoApproveBody
import com.fabledsword.minstrel.models.AdminUserRef
import com.fabledsword.minstrel.models.wire.AdminUserWire
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Read-through accessor for the admin users list + the four per-user
* mutations. Mirrors Flutter's `AdminUsersController`. Same
* no-Room-cache, no-MutationQueue pattern as the other admin slices.
*/
@Singleton
class AdminUsersRepository @Inject constructor(
retrofit: Retrofit,
) {
private val api: AdminUsersApi = retrofit.create()
suspend fun list(): List<AdminUserRef> = api.list().users.map { it.toDomain() }
suspend fun setAdmin(id: String, isAdmin: Boolean) =
api.setAdmin(id, SetAdminBody(isAdmin))
suspend fun setAutoApprove(id: String, autoApprove: Boolean) =
api.setAutoApprove(id, SetAutoApproveBody(autoApprove))
suspend fun resetPassword(id: String, newPassword: String) =
api.resetPassword(id, ResetPasswordBody(newPassword))
suspend fun delete(id: String) = api.delete(id)
}
private fun AdminUserWire.toDomain(): AdminUserRef = AdminUserRef(
id = id,
username = username,
displayName = displayName,
isAdmin = isAdmin,
autoApproveRequests = autoApproveRequests,
createdAt = createdAt,
)
@@ -0,0 +1,86 @@
package com.fabledsword.minstrel.admin.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.Invite
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class AdminInvitesUiState(
val isLoading: Boolean = true,
val invites: List<Invite> = emptyList(),
val message: String? = null,
)
@HiltViewModel
class AdminInvitesViewModel @Inject constructor(
private val repository: AdminInvitesRepository,
) : ViewModel() {
private val internal = MutableStateFlow(AdminInvitesUiState())
val state: StateFlow<AdminInvitesUiState> = internal.asStateFlow()
/** One-shot signal for screens to show the generated token + copy UI. */
private val createdChannel = Channel<Invite>(Channel.BUFFERED)
val created: Flow<Invite> = createdChannel.receiveAsFlow()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
internal.update { it.copy(isLoading = true, message = null) }
try {
val rows = repository.list()
internal.update { it.copy(isLoading = false, invites = rows) }
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.update {
it.copy(
isLoading = false,
message = ErrorCopy.fromThrowable(e),
)
}
}
}
}
fun generate(note: String?) {
viewModelScope.launch {
try {
val invite = repository.create(note)
createdChannel.trySend(invite)
// Optimistically prepend; next refresh reconciles.
internal.update { it.copy(invites = listOf(invite) + it.invites) }
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.update {
it.copy(message = ErrorCopy.fromThrowable(e))
}
}
}
}
fun revoke(token: String) {
val before = internal.value.invites
// Optimistic removal.
internal.update { it.copy(invites = before.filterNot { i -> i.token == token }) }
viewModelScope.launch {
runCatching { repository.revoke(token) }
.onFailure { refresh() }
}
}
}
@@ -0,0 +1,219 @@
package com.fabledsword.minstrel.admin.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.composables.icons.lucide.Inbox
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.TriangleAlert
import com.composables.icons.lucide.Users
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.nav.Admin
import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
// ─── State ───────────────────────────────────────────────────────────
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int)
sealed interface AdminLandingUiState {
data object Loading : AdminLandingUiState
data class Success(val counts: AdminCounts) : AdminLandingUiState
data class Error(val message: String) : AdminLandingUiState
}
// ─── ViewModel ───────────────────────────────────────────────────────
@HiltViewModel
class AdminLandingViewModel @Inject constructor(
private val requestsRepo: AdminRequestsRepository,
private val quarantineRepo: AdminQuarantineRepository,
private val usersRepo: AdminUsersRepository,
) : ViewModel() {
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
val uiState: StateFlow<AdminLandingUiState> = internal.asStateFlow()
init {
refresh()
}
fun refresh(): Job = viewModelScope.launch {
internal.value = AdminLandingUiState.Loading
try {
val counts = coroutineScope {
val req = async { requestsRepo.list().size }
val qua = async { quarantineRepo.list().size }
val usr = async { usersRepo.list().size }
AdminCounts(req.await(), qua.await(), usr.await())
}
internal.value = AdminLandingUiState.Success(counts)
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = AdminLandingUiState.Error(
ErrorCopy.fromThrowable(e),
)
}
}
}
// ─── Screen ──────────────────────────────────────────────────────────
@Composable
fun AdminLandingScreen(
navController: NavHostController,
viewModel: AdminLandingViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
title = "Admin",
navController = navController,
currentRouteName = Admin::class.qualifiedName,
onBack = { navController.popBackStack() },
)
},
) { inner ->
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
AdminLandingUiState.Loading -> LoadingCentered()
is AdminLandingUiState.Error -> EmptyState(
title = "Couldn't load admin counts",
body = s.message,
)
is AdminLandingUiState.Success -> SectionList(
counts = s.counts,
navController = navController,
)
}
}
}
}
@Composable
private fun SectionList(counts: AdminCounts, navController: NavHostController) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
SectionCard(
icon = Lucide.Inbox,
title = "Requests",
subtitle = "Approve or reject Lidarr requests",
count = counts.requests,
onClick = { navController.navigate(AdminRequests) },
)
}
item {
SectionCard(
icon = Lucide.TriangleAlert,
title = "Quarantine",
subtitle = "Resolve scan failures",
count = counts.quarantine,
onClick = { navController.navigate(AdminQuarantine) },
)
}
item {
SectionCard(
icon = Lucide.Users,
title = "Users",
subtitle = "Manage accounts and invites",
count = counts.users,
onClick = { navController.navigate(AdminUsers) },
)
}
}
}
@Composable
private fun SectionCard(
icon: ImageVector,
title: String,
subtitle: String,
count: Int,
onClick: () -> Unit,
) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = "$count",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
@@ -0,0 +1,173 @@
package com.fabledsword.minstrel.admin.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdminQuarantineScreen(
navController: NavHostController,
viewModel: AdminQuarantineViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
title = "Admin · Quarantine",
navController = navController,
currentRouteName = AdminQuarantine::class.qualifiedName,
onBack = { navController.popBackStack() },
)
},
) { inner ->
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
AdminQuarantineUiState.Loading -> LoadingCentered()
AdminQuarantineUiState.Empty -> EmptyState(
title = "Queue is empty",
body = "When users flag tracks as bad rips, wrong tags, or " +
"duplicates, their reports get aggregated and surfaced here.",
)
is AdminQuarantineUiState.Error -> EmptyState(
title = "Couldn't load queue",
body = s.message,
)
is AdminQuarantineUiState.Success -> QueueList(
rows = s.rows,
onResolve = viewModel::resolve,
onDeleteFile = viewModel::deleteFile,
onDeleteViaLidarr = viewModel::deleteViaLidarr,
)
}
}
}
}
@Composable
private fun QueueList(
rows: List<AdminQuarantineItemRef>,
onResolve: (String) -> Unit,
onDeleteFile: (String) -> Unit,
onDeleteViaLidarr: (String) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = rows, key = { it.trackId }) { row ->
QuarantineRow(
row = row,
onResolve = { onResolve(row.trackId) },
onDeleteFile = { onDeleteFile(row.trackId) },
onDeleteViaLidarr = { onDeleteViaLidarr(row.trackId) },
)
HorizontalDivider()
}
}
}
@Composable
private fun QuarantineRow(
row: AdminQuarantineItemRef,
onResolve: () -> Unit,
onDeleteFile: () -> Unit,
onDeleteViaLidarr: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = row.trackTitle,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${row.artistName} · ${row.albumTitle}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
AssistChip(
onClick = {},
enabled = false,
label = {
Text(
text = "${row.reportCount} reports",
style = MaterialTheme.typography.labelSmall,
)
},
)
if (row.topReasonSummary.isNotEmpty()) {
AssistChip(
onClick = {},
enabled = false,
label = {
Text(
text = row.topReasonSummary,
style = MaterialTheme.typography.labelSmall,
)
},
)
}
}
ActionButtons(
onResolve = onResolve,
onDeleteFile = onDeleteFile,
onDeleteViaLidarr = onDeleteViaLidarr,
)
}
}
@Composable
private fun ActionButtons(
onResolve: () -> Unit,
onDeleteFile: () -> Unit,
onDeleteViaLidarr: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
TextButton(onClick = onResolve) { Text("Resolve") }
OutlinedButton(onClick = onDeleteFile) { Text("Delete file") }
Button(onClick = onDeleteViaLidarr) { Text("Delete + Lidarr") }
}
}
@@ -0,0 +1,85 @@
package com.fabledsword.minstrel.admin.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface AdminQuarantineUiState {
data object Loading : AdminQuarantineUiState
data object Empty : AdminQuarantineUiState
data class Success(val rows: List<AdminQuarantineItemRef>) : AdminQuarantineUiState
data class Error(val message: String) : AdminQuarantineUiState
}
@HiltViewModel
class AdminQuarantineViewModel @Inject constructor(
private val repository: AdminQuarantineRepository,
private val eventsStream: EventsStream,
) : ViewModel() {
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
val uiState: StateFlow<AdminQuarantineUiState> = internal.asStateFlow()
init {
refresh()
viewModelScope.launch {
eventsStream.events
.filter { it.kind.startsWith("quarantine.") }
.collect { refresh() }
}
}
fun refresh(): Job = viewModelScope.launch {
internal.value = AdminQuarantineUiState.Loading
try {
val rows = repository.list()
internal.value = if (rows.isEmpty()) {
AdminQuarantineUiState.Empty
} else {
AdminQuarantineUiState.Success(rows)
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = AdminQuarantineUiState.Error(
ErrorCopy.fromThrowable(e),
)
}
}
fun resolve(trackId: String) = act(trackId) { repository.resolve(it) }
fun deleteFile(trackId: String) = act(trackId) { repository.deleteFile(it) }
fun deleteViaLidarr(trackId: String) = act(trackId) { repository.deleteViaLidarr(it) }
private fun act(trackId: String, action: suspend (String) -> Unit) {
val before = internal.value
if (before is AdminQuarantineUiState.Success) {
val filtered = before.rows.filterNot { it.trackId == trackId }
internal.value = if (filtered.isEmpty()) {
AdminQuarantineUiState.Empty
} else {
AdminQuarantineUiState.Success(filtered)
}
}
viewModelScope.launch {
try {
action(trackId)
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
refresh()
}
}
}
}
@@ -0,0 +1,161 @@
package com.fabledsword.minstrel.admin.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdminRequestsScreen(
navController: NavHostController,
viewModel: AdminRequestsViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
title = "Admin · Requests",
navController = navController,
currentRouteName = AdminRequests::class.qualifiedName,
onBack = { navController.popBackStack() },
)
},
) { inner ->
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
AdminRequestsUiState.Loading -> LoadingCentered()
AdminRequestsUiState.Empty -> EmptyState(
title = "No requests waiting",
body = "When users ask Lidarr for new music, their pending " +
"requests show up here for approval.",
)
is AdminRequestsUiState.Error -> EmptyState(
title = "Couldn't load requests",
body = s.message,
)
is AdminRequestsUiState.Success -> RequestList(
rows = s.rows,
usernames = s.usernames,
onApprove = viewModel::approve,
onReject = viewModel::reject,
)
}
}
}
}
@Composable
private fun RequestList(
rows: List<RequestRef>,
usernames: Map<String, String>,
onApprove: (String) -> Unit,
onReject: (String) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = rows, key = { it.id }) { req ->
AdminRequestRow(
req = req,
requester = usernames[req.userId]
?: req.userId.takeIf { it.isNotEmpty() }?.take(USER_ID_PREFIX_LEN)
?: "unknown",
onApprove = { onApprove(req.id) },
onReject = { onReject(req.id) },
)
HorizontalDivider()
}
}
}
@Composable
private fun AdminRequestRow(
req: RequestRef,
requester: String,
onApprove: () -> Unit,
onReject: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = req.displayName,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "Requested by $requester",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
AssistChip(
onClick = {},
enabled = false,
label = { Text(req.kind, style = MaterialTheme.typography.labelSmall) },
)
AssistChip(
onClick = {},
enabled = false,
label = { Text(req.status.wire, style = MaterialTheme.typography.labelSmall) },
)
}
if (req.artistName.isNotEmpty() && req.artistName != req.displayName) {
Text(
text = "Artist: ${req.artistName}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
OutlinedButton(onClick = onReject) { Text("Reject") }
Button(onClick = onApprove) { Text("Approve") }
}
}
}
// UUID prefix length used as a friendlier-than-raw-UUID fallback when
// the admin users list fetch fails and we can't resolve userId → name.
private const val USER_ID_PREFIX_LEN = 8
@@ -0,0 +1,110 @@
package com.fabledsword.minstrel.admin.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.RequestRef
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import javax.inject.Inject
private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
sealed interface AdminRequestsUiState {
data object Loading : AdminRequestsUiState
data object Empty : AdminRequestsUiState
data class Success(
val rows: List<RequestRef>,
val usernames: Map<String, String>,
) : AdminRequestsUiState
data class Error(val message: String) : AdminRequestsUiState
}
@HiltViewModel
class AdminRequestsViewModel @Inject constructor(
private val repository: AdminRequestsRepository,
private val usersRepository: AdminUsersRepository,
private val eventsStream: EventsStream,
) : ViewModel() {
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
val uiState: StateFlow<AdminRequestsUiState> = internal.asStateFlow()
init {
refresh()
viewModelScope.launch {
eventsStream.events
.filter { it.kind in RELEVANT_EVENT_KINDS }
.collect { refresh() }
}
}
fun refresh(): Job = viewModelScope.launch {
internal.value = AdminRequestsUiState.Loading
try {
val (rows, usernames) = coroutineScope {
val requestsJob = async { repository.list() }
val usernamesJob = async {
runCatching {
usersRepository.list().associate { it.id to it.username }
}.getOrDefault(emptyMap())
}
requestsJob.await() to usernamesJob.await()
}
internal.value = if (rows.isEmpty()) {
AdminRequestsUiState.Empty
} else {
AdminRequestsUiState.Success(rows = rows, usernames = usernames)
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = AdminRequestsUiState.Error(
ErrorCopy.fromThrowable(e),
)
}
}
fun approve(id: String) = decide(id) { repository.approve(it) }
fun reject(id: String) = decide(id) { repository.reject(it) }
/**
* Optimistically removes the decided row from the visible list and
* fires the REST call. On failure, refetches so the UI matches the
* server's view rather than leaving a phantom hole.
*/
private fun decide(id: String, action: suspend (String) -> Unit) {
val before = internal.value
if (before is AdminRequestsUiState.Success) {
val filtered = before.rows.filterNot { it.id == id }
internal.value = if (filtered.isEmpty()) {
AdminRequestsUiState.Empty
} else {
AdminRequestsUiState.Success(rows = filtered, usernames = before.usernames)
}
}
viewModelScope.launch {
try {
action(id)
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Refetch reconciles whatever the server now says about
// this request; the error is intentionally swallowed
// because the refresh result IS the user-facing signal.
refresh()
}
}
}
}
@@ -0,0 +1,539 @@
@file:Suppress("TooManyFunctions") // Compose screen + private helper composables and dialogs
package com.fabledsword.minstrel.admin.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Copy
import com.composables.icons.lucide.KeyRound
import com.composables.icons.lucide.Plus
import com.composables.icons.lucide.Trash2
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import android.content.ClipData
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import com.fabledsword.minstrel.models.Invite
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminUserRef
import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import kotlinx.coroutines.launch
@Composable
fun AdminUsersScreen(
navController: NavHostController,
viewModel: AdminUsersViewModel = hiltViewModel(),
invitesViewModel: AdminInvitesViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val invitesState by invitesViewModel.state.collectAsStateWithLifecycle()
var resetPasswordFor by remember { mutableStateOf<AdminUserRef?>(null) }
var deleteCandidate by remember { mutableStateOf<AdminUserRef?>(null) }
var showGenerateInvite by remember { mutableStateOf(false) }
var generatedInvite by remember { mutableStateOf<Invite?>(null) }
LaunchedEffect(Unit) {
invitesViewModel.created.collect { invite ->
generatedInvite = invite
}
}
AdminUsersScaffold(
navController = navController,
usersState = state,
invitesState = invitesState,
onRefresh = {
viewModel.refresh().join()
invitesViewModel.refresh()
},
onSetAdmin = viewModel::setAdmin,
onSetAutoApprove = viewModel::setAutoApprove,
onAskResetPassword = { resetPasswordFor = it },
onAskDelete = { deleteCandidate = it },
onAskGenerateInvite = { showGenerateInvite = true },
onRevokeInvite = invitesViewModel::revoke,
)
PendingDialogs(
resetPasswordFor = resetPasswordFor,
deleteCandidate = deleteCandidate,
onClearReset = { resetPasswordFor = null },
onClearDelete = { deleteCandidate = null },
onConfirmReset = viewModel::resetPassword,
onConfirmDelete = viewModel::delete,
)
if (showGenerateInvite) {
GenerateInviteDialog(
onDismiss = { showGenerateInvite = false },
onConfirm = { note ->
invitesViewModel.generate(note)
showGenerateInvite = false
},
)
}
generatedInvite?.let { invite ->
GeneratedInviteDialog(
invite = invite,
onDismiss = { generatedInvite = null },
)
}
}
@Composable
private fun AdminUsersScaffold(
navController: NavHostController,
usersState: AdminUsersUiState,
invitesState: AdminInvitesUiState,
onRefresh: suspend () -> Unit,
onSetAdmin: (String, Boolean) -> Unit,
onSetAutoApprove: (String, Boolean) -> Unit,
onAskResetPassword: (AdminUserRef) -> Unit,
onAskDelete: (AdminUserRef) -> Unit,
onAskGenerateInvite: () -> Unit,
onRevokeInvite: (String) -> Unit,
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
title = "Admin · Users",
navController = navController,
currentRouteName = AdminUsers::class.qualifiedName,
onBack = { navController.popBackStack() },
)
},
) { inner ->
PullToRefreshScaffold(
onRefresh = onRefresh,
modifier = Modifier.fillMaxSize().padding(inner),
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
item { SectionHeader("Users") }
usersSection(
state = usersState,
onSetAdmin = onSetAdmin,
onSetAutoApprove = onSetAutoApprove,
onAskResetPassword = onAskResetPassword,
onAskDelete = onAskDelete,
)
item { HorizontalDivider() }
item {
SectionHeader(
label = "Invites",
trailing = {
TextButton(onClick = onAskGenerateInvite) {
Icon(
Lucide.Plus,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Text("Generate")
}
},
)
}
invitesSection(state = invitesState, onRevoke = onRevokeInvite)
}
}
}
}
@Composable
private fun SectionHeader(label: String, trailing: (@Composable () -> Unit)? = null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
if (trailing != null) trailing()
}
}
private fun androidx.compose.foundation.lazy.LazyListScope.usersSection(
state: AdminUsersUiState,
onSetAdmin: (String, Boolean) -> Unit,
onSetAutoApprove: (String, Boolean) -> Unit,
onAskResetPassword: (AdminUserRef) -> Unit,
onAskDelete: (AdminUserRef) -> Unit,
) {
when (state) {
AdminUsersUiState.Loading -> item { CenteredHint("Loading users…") }
AdminUsersUiState.Empty -> item { CenteredHint("No registered users.") }
is AdminUsersUiState.Error -> item { CenteredHint(state.message) }
is AdminUsersUiState.Success -> items(items = state.users, key = { it.id }) { user ->
UserRow(
user = user,
onSetAdmin = { onSetAdmin(user.id, it) },
onSetAutoApprove = { onSetAutoApprove(user.id, it) },
onResetPassword = { onAskResetPassword(user) },
onDelete = { onAskDelete(user) },
)
HorizontalDivider()
}
}
}
private fun androidx.compose.foundation.lazy.LazyListScope.invitesSection(
state: AdminInvitesUiState,
onRevoke: (String) -> Unit,
) {
when {
state.isLoading -> item { CenteredHint("Loading invites…") }
state.invites.isEmpty() -> item {
CenteredHint("No active invites. Use Generate to create one.")
}
else -> items(items = state.invites, key = { it.token }) { invite ->
InviteRow(invite = invite, onRevoke = { onRevoke(invite.token) })
HorizontalDivider()
}
}
}
@Composable
private fun CenteredHint(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(16.dp),
)
}
@Composable
private fun InviteRow(invite: Invite, onRevoke: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = invite.token,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val subtitle = buildString {
if (!invite.note.isNullOrEmpty()) {
append(invite.note)
append(" · ")
}
append(if (invite.isRedeemed) "redeemed" else "expires ${invite.expiresAt}")
}
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (!invite.isRedeemed) {
IconButton(onClick = onRevoke) {
Icon(
Lucide.Trash2,
contentDescription = "Revoke invite",
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
@Composable
private fun GenerateInviteDialog(onDismiss: () -> Unit, onConfirm: (String?) -> Unit) {
var note by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Generate invite") },
text = {
Column {
Text(
text = "Token expires in 24 hours.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = note,
onValueChange = { note = it },
label = { Text("Note (optional)") },
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
singleLine = true,
)
}
},
confirmButton = {
Button(onClick = { onConfirm(note.takeIf { it.isNotBlank() }) }) {
Text("Generate")
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@Composable
private fun GeneratedInviteDialog(invite: Invite, onDismiss: () -> Unit) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Invite created") },
text = {
Column {
Text(
text = "Share this token. It expires in 24 hours.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = invite.token,
onValueChange = {},
readOnly = true,
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
)
}
},
confirmButton = {
Button(
onClick = {
val clip = ClipData.newPlainText("Minstrel invite", invite.token)
scope.launch { clipboard.setClipEntry(ClipEntry(clip)) }
onDismiss()
},
) {
Icon(
Lucide.Copy,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Text(" Copy", modifier = Modifier.padding(start = 4.dp))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Close") }
},
)
}
@Composable
private fun PendingDialogs(
resetPasswordFor: AdminUserRef?,
deleteCandidate: AdminUserRef?,
onClearReset: () -> Unit,
onClearDelete: () -> Unit,
onConfirmReset: (String, String) -> Unit,
onConfirmDelete: (String) -> Unit,
) {
resetPasswordFor?.let { user ->
ResetPasswordDialog(
user = user,
onDismiss = onClearReset,
onConfirm = { newPassword ->
onConfirmReset(user.id, newPassword)
onClearReset()
},
)
}
deleteCandidate?.let { user ->
DeleteConfirmDialog(
user = user,
onDismiss = onClearDelete,
onConfirm = {
onConfirmDelete(user.id)
onClearDelete()
},
)
}
}
@Composable
private fun UserRow(
user: AdminUserRef,
onSetAdmin: (Boolean) -> Unit,
onSetAutoApprove: (Boolean) -> Unit,
onResetPassword: () -> Unit,
onDelete: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = user.displayName?.takeIf { it.isNotEmpty() } ?: user.username,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "@${user.username}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
ToggleRow(label = "Admin", checked = user.isAdmin, onChange = onSetAdmin)
ToggleRow(
label = "Auto-approve requests",
checked = user.autoApproveRequests,
onChange = onSetAutoApprove,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onResetPassword) {
Icon(
Lucide.KeyRound,
contentDescription = null,
modifier = Modifier.padding(end = 4.dp),
)
Text("Reset password")
}
TextButton(onClick = onDelete) {
Icon(
Lucide.Trash2,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(end = 4.dp),
)
Text("Delete", color = MaterialTheme.colorScheme.error)
}
}
}
}
@Composable
private fun ToggleRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = label, style = MaterialTheme.typography.bodyMedium)
Switch(checked = checked, onCheckedChange = onChange)
}
}
@Composable
private fun ResetPasswordDialog(
user: AdminUserRef,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var newPassword by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Reset password") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Set a new password for @${user.username}.")
OutlinedTextField(
value = newPassword,
onValueChange = { newPassword = it },
label = { Text("New password") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
)
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(newPassword) },
enabled = newPassword.isNotEmpty(),
) { Text("Reset") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@Composable
private fun DeleteConfirmDialog(
user: AdminUserRef,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
var typed by remember { mutableStateOf("") }
val confirmed = typed.trim().equals(DELETE_CONFIRM_WORD, ignoreCase = true)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Delete user?") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Delete @${user.username}? This removes the account and all " +
"their data. Type $DELETE_CONFIRM_WORD to confirm.",
)
OutlinedTextField(
value = typed,
onValueChange = { typed = it },
label = { Text("Type $DELETE_CONFIRM_WORD") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(onClick = onConfirm, enabled = confirmed) {
Text(
"Delete",
color = if (confirmed) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
private const val DELETE_CONFIRM_WORD = "DELETE"
@@ -0,0 +1,110 @@
package com.fabledsword.minstrel.admin.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.AdminUserRef
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface AdminUsersUiState {
data object Loading : AdminUsersUiState
data object Empty : AdminUsersUiState
data class Success(val users: List<AdminUserRef>) : AdminUsersUiState
data class Error(val message: String) : AdminUsersUiState
}
@HiltViewModel
class AdminUsersViewModel @Inject constructor(
private val repository: AdminUsersRepository,
) : ViewModel() {
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
val uiState: StateFlow<AdminUsersUiState> = internal.asStateFlow()
init {
refresh()
}
fun refresh(): Job = viewModelScope.launch {
internal.value = AdminUsersUiState.Loading
try {
val users = repository.list()
internal.value = if (users.isEmpty()) {
AdminUsersUiState.Empty
} else {
AdminUsersUiState.Success(users)
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = AdminUsersUiState.Error(ErrorCopy.fromThrowable(e))
}
}
fun setAdmin(id: String, isAdmin: Boolean) = patch(id, { it.copy(isAdmin = isAdmin) }) {
repository.setAdmin(it, isAdmin)
}
fun setAutoApprove(id: String, autoApprove: Boolean) =
patch(id, { it.copy(autoApproveRequests = autoApprove) }) {
repository.setAutoApprove(it, autoApprove)
}
fun resetPassword(id: String, newPassword: String) {
viewModelScope.launch {
runCatching { repository.resetPassword(id, newPassword) }
}
}
fun delete(id: String) {
val before = internal.value
if (before is AdminUsersUiState.Success) {
val filtered = before.users.filterNot { it.id == id }
internal.value = if (filtered.isEmpty()) {
AdminUsersUiState.Empty
} else {
AdminUsersUiState.Success(filtered)
}
}
viewModelScope.launch {
try {
repository.delete(id)
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
refresh()
}
}
}
private fun patch(
id: String,
mutate: (AdminUserRef) -> AdminUserRef,
action: suspend (String) -> Unit,
) {
val before = internal.value
if (before is AdminUsersUiState.Success) {
internal.value = AdminUsersUiState.Success(
before.users.map { if (it.id == id) mutate(it) else it },
)
}
viewModelScope.launch {
try {
action(id)
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Server-side guard might have rejected the toggle (e.g.
// last-admin protection); refetch reconciles state.
refresh()
}
}
}
}
@@ -0,0 +1,45 @@
package com.fabledsword.minstrel.api
import com.fabledsword.minstrel.auth.AuthStore
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
/**
* Attaches the session cookie from [AuthStore] to every request,
* captures Set-Cookie from successful responses (login flow), and
* clears the store on 401 so downstream code can react to logout.
*
* Mirrors the Flutter Dio interceptor pattern.
*/
@Singleton
class AuthCookieInterceptor @Inject constructor(
private val authStore: AuthStore,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val cookie = authStore.sessionCookie.value
val request = chain.request().newBuilder().apply {
if (!cookie.isNullOrEmpty()) header("Cookie", cookie)
}.build()
val response = chain.proceed(request)
if (response.code == HTTP_UNAUTHORIZED) {
authStore.setSessionCookie(null)
} else if (response.isSuccessful) {
// Capture the first Set-Cookie pair on login; subsequent
// responses that include Set-Cookie keep the store fresh.
response.headers("Set-Cookie").firstOrNull()?.let { sc ->
val firstPair = sc.substringBefore(';')
if (firstPair.isNotBlank()) authStore.setSessionCookie(firstPair)
}
}
return response
}
private companion object {
const val HTTP_UNAUTHORIZED = 401
}
}
@@ -0,0 +1,43 @@
package com.fabledsword.minstrel.api
import com.fabledsword.minstrel.auth.AuthStore
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
/**
* Rewrites every outgoing request's scheme/host/port to match the
* current [AuthStore.baseUrl]. Retrofit's `.baseUrl(...)` is read
* once at Retrofit creation, but AuthStore.baseUrl loads from Room
* asynchronously — so at injection time the Retrofit instance is
* frozen pointing at the [AuthStore.DEFAULT_BASE_URL] placeholder.
*
* This interceptor closes the gap: Retrofit can stay built with the
* placeholder forever, and every actual request gets retargeted at
* the live AuthStore value. Lets the user change server URL in
* Settings without an app relaunch (Phase 11 wiring).
*
* No-op when the stored base URL is unparseable (falls through to
* whatever Retrofit had) — that case shows up as a transport-level
* error in the caller's normal error path.
*/
@Singleton
class BaseUrlInterceptor @Inject constructor(
private val authStore: AuthStore,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
val baseUrl = authStore.baseUrl.value.toHttpUrlOrNull()
?: return chain.proceed(original)
val rewritten: HttpUrl = original.url.newBuilder()
.scheme(baseUrl.scheme)
.host(baseUrl.host)
.port(baseUrl.port)
.build()
return chain.proceed(original.newBuilder().url(rewritten).build())
}
}
@@ -0,0 +1,109 @@
package com.fabledsword.minstrel.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import java.io.IOException
/**
* Maps server error codes (and common transport failures) to
* friendly, sentence-case copy. Mirrors
* `flutter_client/assets/error-copy.json` + `error_copy.dart`.
*
* Server errors are `{"error":{"code":"...","message":"..."}}`.
* [fromThrowable] pulls the code out of a Retrofit [HttpException]'s
* error body and maps it; network failures (no connection / DNS /
* refused) map to `connection_refused`; anything unrecognised falls
* back to `unknown`.
*
* Replaces the `e.message ?: "unknown error"` pattern scattered
* across ViewModels with copy a user can actually act on.
*/
object ErrorCopy {
private val json = Json { ignoreUnknownKeys = true }
@Serializable
private data class Envelope(val error: Body? = null)
@Serializable
private data class Body(val code: String = "", val message: String = "")
/** Friendly copy for a known error [code], or the `unknown` fallback. */
fun messageFor(code: String): String = TABLE[code] ?: TABLE.getValue("unknown")
/**
* Friendly copy for any caught throwable. Parses Retrofit
* HttpException bodies for the server code; treats IOExceptions
* as connection failures.
*/
fun fromThrowable(t: Throwable): String = when (t) {
is HttpException -> messageFor(codeFromHttp(t))
is IOException -> messageFor("connection_refused")
else -> TABLE.getValue("unknown")
}
private fun codeFromHttp(e: HttpException): String {
val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull()
?: return "unknown"
val code = runCatching { json.decodeFromString<Envelope>(raw).error?.code }
.getOrNull()
.orEmpty()
return code.ifEmpty { "unknown" }
}
private val TABLE: Map<String, String> = mapOf(
"unknown" to "Something went wrong.",
"unauthenticated" to "Your session has ended. Please sign in again.",
"auth_required" to "You need to sign in to do that.",
"forbidden" to "You don't have permission to do that.",
"not_authorized" to "You don't have permission to do that.",
"invalid_credentials" to "Wrong username or password.",
"wrong_password" to "Current password is incorrect.",
"password_too_short" to "Password must be at least 8 characters.",
"username_invalid" to "That username isn't valid.",
"username_taken" to "That username is already taken.",
"email_invalid" to "Enter a valid email address.",
"email_taken" to "That email is already in use.",
"invalid_token" to "That link has expired or already been used. Request a new one.",
"invite_invalid" to "That invite has expired or already been used.",
"invite_required" to "An invite is required to register on this server.",
"last_admin" to "Can't demote or delete the last admin.",
"no_email_on_file" to "No email is associated with that account.",
"not_configured" to "This integration isn't set up yet.",
"validation" to "Some fields aren't valid. Check and try again.",
"missing_fields" to "Required fields are missing.",
"missing_query" to "Search needs at least one keyword.",
"invalid_id" to "Invalid identifier.",
"invalid_body" to "Couldn't read the request.",
"bad_request" to "Invalid request.",
"bad_body" to "Couldn't read the request.",
"bad_kind" to "Invalid request type.",
"bad_paging" to "Invalid page size or offset.",
"bad_reason" to "Invalid quarantine reason.",
"mbid_required" to "An MBID is required for this lookup.",
"system_playlist_readonly" to "System playlists can't be edited directly.",
"connection_refused" to "Couldn't reach the server. Check the URL and try again.",
"lidarr_unreachable" to
"Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
"lidarr_disabled" to "Lidarr integration is not enabled.",
"lidarr_auth_failed" to "Lidarr authentication failed.",
"lidarr_defaults_incomplete" to
"Lidarr is missing a default quality profile or root folder. " +
"Set them in Admin → Integrations.",
"lidarr_server_error" to "Lidarr returned an error. Check Lidarr's logs for the cause.",
"lidarr_rejected" to
"Lidarr rejected the request. Check the server logs for the field-level reason.",
"lidarr_album_lookup_failed" to
"Lidarr doesn't recognize this album. Try Resolve or Delete file instead.",
"album_mbid_missing" to "This track has no Lidarr album to remove.",
"request_not_pending" to "This request is no longer pending.",
"request_not_found" to "That request no longer exists.",
"track_not_found" to "That track no longer exists.",
"album_not_found" to "That album no longer exists.",
"artist_not_found" to "That artist no longer exists.",
"playlist_not_found" to "That playlist no longer exists.",
"user_not_found" to "That user no longer exists.",
"not_found" to "That no longer exists.",
)
}
@@ -0,0 +1,94 @@
package com.fabledsword.minstrel.api
import com.fabledsword.minstrel.BuildConfig
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
/**
* Provides a single shared OkHttp client + Retrofit instance for the
* whole app. Per-endpoint Retrofit interfaces (LibraryApi, PlaylistsApi,
* etc.) are provided by feature modules using
* `retrofit.create(EndpointApi::class.java)` in the relevant @Provides.
*
* Streaming audio HTTP (ExoPlayer) reuses this same OkHttp via
* `OkHttpDataSource.Factory` so the auth interceptor, connection pool,
* and TLS config are shared.
*/
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideHttpLogging(): HttpLoggingInterceptor =
HttpLoggingInterceptor().apply {
level =
if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BASIC
} else {
HttpLoggingInterceptor.Level.NONE
}
}
@Provides
@Singleton
fun provideOkHttp(
baseUrl: BaseUrlInterceptor,
auth: AuthCookieInterceptor,
logging: HttpLoggingInterceptor,
): OkHttpClient =
OkHttpClient.Builder()
// BaseUrlInterceptor first — it rewrites scheme/host/port
// to match the live AuthStore.baseUrl on every request.
// Auth then sees the final URL.
.addInterceptor(baseUrl)
.addInterceptor(auth)
.addInterceptor(logging)
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
@Provides
@Singleton
fun provideRetrofit(
okHttp: OkHttpClient,
json: Json,
): Retrofit =
Retrofit.Builder()
// Placeholder base URL — BaseUrlInterceptor rewrites every
// outgoing request to the live AuthStore value, so this
// string only has to satisfy Retrofit's parser and is
// never reached as an actual host. This lets the user
// change server URL in Settings without an app relaunch.
.baseUrl("http://placeholder.invalid/")
.client(okHttp)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
// Per-endpoint Retrofit interface @Provides intentionally NOT here.
// Feature repositories construct their interface via the shared
// Retrofit (Hilt-injected), e.g.:
//
// class LibraryRepository @Inject constructor(retrofit: Retrofit) {
// private val api = retrofit.create<LibraryApi>()
// ...
// }
//
// Fewer Hilt bindings + no KSP2 type-resolution sharp edges from
// Hilt processing a `@Provides` return type that's a hand-written
// Kotlin interface (Hilt's ModuleProcessingStep "could not be
// resolved" failure mode under KSP2 — google/dagger#4303).
private const val CONNECT_TIMEOUT_SECONDS = 10L
private const val READ_TIMEOUT_SECONDS = 30L
}
@@ -0,0 +1,9 @@
package com.fabledsword.minstrel.api
/**
* Value-class wrapper around the server base URL string. Used for
* dependency-injection type safety so a raw String isn't ambiguous in
* the graph.
*/
@JvmInline
value class ServerBaseUrl(val value: String)
@@ -0,0 +1,36 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.AdminInvitesListWire
import com.fabledsword.minstrel.models.wire.InviteWire
import kotlinx.serialization.Serializable
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/invites`. Mirrors
* `flutter_client/lib/api/endpoints/admin_invites.dart`.
*
* Server TTL is hardcoded at 24h; the only configurable field is the
* optional `note` on create.
*/
interface AdminInvitesApi {
@GET("api/admin/invites")
suspend fun list(): AdminInvitesListWire
@POST("api/admin/invites")
suspend fun create(@Body body: CreateInviteBody): InviteWire
@DELETE("api/admin/invites/{token}")
suspend fun revoke(@Path("token") token: String)
}
/**
* Body for `POST /api/admin/invites`. The Flutter client omits the
* field when note is empty; Kotlin/serialization just serializes
* null which the server treats identically.
*/
@Serializable
data class CreateInviteBody(val note: String? = null)
@@ -0,0 +1,29 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.AdminQuarantineItemWire
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/quarantine`. Mirrors
* `flutter_client/lib/api/endpoints/admin_quarantine.dart`.
*
* Three resolution endpoints:
* - `resolve` → admin reviewed, no action taken (clears flags).
* - `delete-file` → drop the local file; Lidarr keeps the track.
* - `delete-via-lidarr` → ask Lidarr to remove the track upstream too.
*/
interface AdminQuarantineApi {
@GET("api/admin/quarantine")
suspend fun list(): List<AdminQuarantineItemWire>
@POST("api/admin/quarantine/{trackId}/resolve")
suspend fun resolve(@Path("trackId") trackId: String)
@POST("api/admin/quarantine/{trackId}/delete-file")
suspend fun deleteFile(@Path("trackId") trackId: String)
@POST("api/admin/quarantine/{trackId}/delete-via-lidarr")
suspend fun deleteViaLidarr(@Path("trackId") trackId: String)
}
@@ -0,0 +1,25 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.RequestWire
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/requests`. Mirrors
* `flutter_client/lib/api/endpoints/admin_requests.dart`.
*
* Server returns the same `requestView` shape as the user-side
* `/api/requests`, so RequestWire is reused. Different listing scope —
* admin sees all users' requests, not just the caller's.
*/
interface AdminRequestsApi {
@GET("api/admin/requests")
suspend fun list(): List<RequestWire>
@POST("api/admin/requests/{id}/approve")
suspend fun approve(@Path("id") id: String)
@POST("api/admin/requests/{id}/reject")
suspend fun reject(@Path("id") id: String)
}
@@ -0,0 +1,49 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.AdminUsersListWire
import kotlinx.serialization.Serializable
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/users`. Mirrors
* `flutter_client/lib/api/endpoints/admin_users.dart`.
*
* Note: the PUT-auto-approve body field is `auto_approve`, NOT
* `auto_approve_requests` — the request shape differs from the
* response field name. Verified against `internal/api/admin_users.go
* adminAutoApproveReq` (May 2026).
*/
interface AdminUsersApi {
@GET("api/admin/users")
suspend fun list(): AdminUsersListWire
@PUT("api/admin/users/{id}/admin")
suspend fun setAdmin(@Path("id") id: String, @Body body: SetAdminBody)
@PUT("api/admin/users/{id}/auto-approve")
suspend fun setAutoApprove(@Path("id") id: String, @Body body: SetAutoApproveBody)
@POST("api/admin/users/{id}/reset-password")
suspend fun resetPassword(@Path("id") id: String, @Body body: ResetPasswordBody)
@DELETE("api/admin/users/{id}")
suspend fun delete(@Path("id") id: String)
}
@Serializable
data class SetAdminBody(
@kotlinx.serialization.SerialName("is_admin") val isAdmin: Boolean,
)
@Serializable
data class SetAutoApproveBody(
@kotlinx.serialization.SerialName("auto_approve") val autoApprove: Boolean,
)
@Serializable
data class ResetPasswordBody(val password: String)
@@ -0,0 +1,22 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.LoginRequestBody
import com.fabledsword.minstrel.models.wire.LoginResponseWire
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Retrofit interface for `/api/auth`. Mirrors
* `flutter_client/lib/api/endpoints/auth.dart`.
*
* The actual session-cookie capture happens in
* [com.fabledsword.minstrel.api.AuthCookieInterceptor]; we don't
* pass the response `token` around manually.
*/
interface AuthApi {
@POST("api/auth/login")
suspend fun login(@Body body: LoginRequestBody): LoginResponseWire
@POST("api/auth/logout")
suspend fun logout()
}
@@ -0,0 +1,33 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
import com.fabledsword.minstrel.models.wire.CreateRequestBody
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
/**
* Retrofit interface for Discover / Lidarr search / request creation.
* Mirrors `flutter_client/lib/api/endpoints/discover.dart`.
*
* `/api/lidarr/search` has a 60s LRU on the server so quick re-types
* of the same query are cheap.
*
* Cancel + my-requests-list endpoints live on RequestsApi (Phase 10
* Commit B), not here.
*/
interface DiscoverApi {
@GET("api/discover/suggestions")
suspend fun listSuggestions(): List<ArtistSuggestionWire>
@GET("api/lidarr/search")
suspend fun search(
@Query("q") query: String,
@Query("kind") kind: String,
): List<LidarrSearchResultWire>
@POST("api/requests")
suspend fun createRequest(@Body body: CreateRequestBody)
}
@@ -0,0 +1,31 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
import com.fabledsword.minstrel.models.wire.PlaySkippedRequest
import com.fabledsword.minstrel.models.wire.PlayStartedRequest
import com.fabledsword.minstrel.models.wire.PlayStartedResponse
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Retrofit interface for `POST /api/events`. Mirrors the relevant
* slice of `flutter_client/lib/api/endpoints/events.dart`. All four
* variants share the same URL — the discriminator is in the request
* body's `type` field. Server contract is best-effort per spec;
* callers (the live path in PlayEventsReporter) swallow errors and
* fall back to the offline mutation queue on failure.
*/
interface EventsApi {
@POST("api/events")
suspend fun playStarted(@Body body: PlayStartedRequest): PlayStartedResponse
@POST("api/events")
suspend fun playEnded(@Body body: PlayEndedRequest)
@POST("api/events")
suspend fun playSkipped(@Body body: PlaySkippedRequest)
@POST("api/events")
suspend fun playOffline(@Body body: PlayOfflineRequest)
}
@@ -0,0 +1,23 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.HistoryPageWire
import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit interface for `/api/me/history`. Mirrors the relevant
* subset of `flutter_client/lib/api/endpoints/me.dart` (only
* `history()`; profile / timezone / quarantine endpoints land with
* their respective phases).
*/
interface HistoryApi {
@GET("api/me/history")
suspend fun getHistory(
@Query("limit") limit: Int = DEFAULT_LIMIT,
@Query("offset") offset: Int = 0,
): HistoryPageWire
companion object {
const val DEFAULT_LIMIT: Int = 50
}
}
@@ -0,0 +1,17 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.HomeIndexWire
import retrofit2.http.GET
/**
* Retrofit interface for the Home discovery endpoint. Mirrors
* `flutter_client/lib/api/endpoints/home.dart` — just the ID-only
* `/api/home/index` variant. The Flutter port has a heavier
* `/api/home` (full embedded payload) too; we don't use it because
* the per-item hydration path (sync controller → Room → Flow) is
* the only one the native client needs.
*/
interface HomeApi {
@GET("api/home/index")
suspend fun getHomeIndex(): HomeIndexWire
}
@@ -0,0 +1,43 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
import com.fabledsword.minstrel.models.wire.TrackWire
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
/**
* Retrofit interface for the server's native `/api/...` library surface.
* Mirrors `flutter_client/lib/api/endpoints/library.dart` 1:1.
*
* Notes on shapes:
* - `GET /api/artists/{id}` returns ArtistDetailWire (ArtistRef fields
* plus embedded "albums" array)
* - `GET /api/artists/{id}/tracks` returns a bare JSON array, not an
* enveloped object — Retrofit's `List<TrackWire>` return type
* handles that.
* - `GET /api/albums/{id}` returns AlbumDetailWire (AlbumRef fields
* plus embedded "tracks" array)
* - `GET /api/library/shuffle` returns a bare JSON array.
*
* Home endpoints (`/api/home`, `/api/home/index`) live in a separate
* `HomeApi` because they have their own wire types (HomePayload,
* HomeIndex) that are larger and only used by the Home screen.
*/
interface LibraryApi {
@GET("api/tracks/{id}")
suspend fun getTrack(@Path("id") id: String): TrackWire
@GET("api/artists/{id}")
suspend fun getArtistDetail(@Path("id") id: String): ArtistDetailWire
@GET("api/artists/{id}/tracks")
suspend fun getArtistTracks(@Path("id") id: String): List<TrackWire>
@GET("api/albums/{id}")
suspend fun getAlbumDetail(@Path("id") id: String): AlbumDetailWire
@GET("api/library/shuffle")
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
}
@@ -0,0 +1,31 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.LikedIdsWire
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/likes`. Mirrors
* `flutter_client/lib/api/endpoints/likes.dart`.
*
* Path segment `kind` is one of "artists" | "albums" | "tracks"
* (plural, matching the server route). The Repository hides that
* pluralization mapping behind a typed enum so callers pass
* "artist" / "album" / "track" everywhere else.
*
* Paged list endpoints (`/api/likes/{kind}?limit&offset`) are
* intentionally omitted — the Liked tab renders straight from
* `cached_likes`, hydrated against the per-entity caches.
*/
interface LikesApi {
@POST("api/likes/{kind}/{id}")
suspend fun like(@Path("kind") kind: String, @Path("id") id: String)
@DELETE("api/likes/{kind}/{id}")
suspend fun unlike(@Path("kind") kind: String, @Path("id") id: String)
@GET("api/likes/ids")
suspend fun ids(): LikedIdsWire
}
@@ -0,0 +1,95 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
import com.fabledsword.minstrel.models.wire.MyProfileWire
import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PUT
/**
* Retrofit interface for the `/api/me` endpoints — caller-scoped account endpoints.
* Mirrors the relevant slice of `flutter_client/lib/api/endpoints/settings.dart`.
*
* History + timezone + system-playlists-status live under /api/me too
* but are handled by their respective feature repositories; this
* interface only carries the profile + password slice.
*/
interface MeApi {
@GET("api/me")
suspend fun getProfile(): MyProfileWire
/**
* Pass only the fields you want to change; server merges. Returns
* the canonical post-update row so the caller can refresh local
* state without an extra GET.
*/
@PUT("api/me/profile")
suspend fun updateProfile(@Body body: UpdateProfileRequest): MyProfileWire
/**
* Change the caller's password. Server validates `current_password`
* before accepting `new_password`. No body on success (204).
*/
@PUT("api/me/password")
suspend fun changePassword(@Body body: ChangePasswordRequest)
/**
* Caller's ListenBrainz state — never returns the token itself,
* only whether one is stored, whether scrobbling is enabled, and
* the last successful scrobble timestamp (RFC3339 string or null).
*/
@GET("api/me/listenbrainz")
suspend fun getListenBrainz(): ListenBrainzStatusWire
/**
* Caller's most recent system-playlist build state. Drives the
* Home placeholder cards (building / failed / pending / seed-needed)
* for system playlists that haven't generated yet.
*/
@GET("api/me/system-playlists-status")
suspend fun getSystemPlaylistsStatus(): SystemPlaylistsStatusWire
/**
* PUT against the same `/api/me/listenbrainz` endpoint with one
* of two shapes — `{token: ...}` stashes a new token,
* `{enabled: ...}` flips the scrobble switch. Server returns the
* post-write status so callers refresh without a separate GET.
*/
@PUT("api/me/listenbrainz")
suspend fun setListenBrainz(@Body body: ListenBrainzPutBody): ListenBrainzStatusWire
}
/**
* Body for `PUT /api/me/profile`. Pass only the fields you want to
* change; the server merges — empty strings clear, nulls leave
* existing values alone.
*/
@Serializable
data class UpdateProfileRequest(
@SerialName("display_name") val displayName: String? = null,
val email: String? = null,
)
/**
* Body for `PUT /api/me/password`. Both fields required.
*/
@Serializable
data class ChangePasswordRequest(
@SerialName("current_password") val currentPassword: String,
@SerialName("new_password") val newPassword: String,
)
/**
* Body for `PUT /api/me/listenbrainz`. Either `token` or `enabled`
* is set; the server treats other fields as untouched. Mirrors
* Flutter's `setListenBrainzToken` / `setListenBrainzEnabled` split
* over a single endpoint shape.
*/
@Serializable
data class ListenBrainzPutBody(
val token: String? = null,
val enabled: Boolean? = null,
)
@@ -0,0 +1,80 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.PlaylistDetailWire
import com.fabledsword.minstrel.models.wire.PlaylistsListWire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
/**
* Retrofit interface for `/api/playlists`. Mirrors
* `flutter_client/lib/api/endpoints/playlists.dart`.
*/
interface PlaylistsApi {
/**
* `kind` = "user" | "system" | "all". Server returns
* `{"owned": [...], "public": [...]}` — `owned` is the caller's
* playlists filtered by kind, `public` is other users' shared
* playlists (not kind-filtered).
*/
@GET("api/playlists")
suspend fun list(@Query("kind") kind: String = "all"): PlaylistsListWire
@GET("api/playlists/{id}")
suspend fun get(@Path("id") id: String): PlaylistDetailWire
/**
* POST /api/playlists/{id}/tracks — append the given track IDs to
* the owner's playlist. Server is idempotent at the (playlist_id,
* track_id) pair level; re-firing the same payload yields no
* duplicates.
*/
@POST("api/playlists/{id}/tracks")
suspend fun appendTracks(
@Path("id") playlistId: String,
@Body body: AppendTracksRequest,
)
/**
* POST /api/playlists/system/{kind}/refresh — rebuild the caller's
* system playlist of the given variant ("for_you" / "discover" /
* etc.). Returns the new playlist UUID (the rebuild rotates the
* id); playlistId is null when the library is empty / build can't
* pick seed tracks. The kind is the raw system_variant.
*/
@POST("api/playlists/system/{kind}/refresh")
suspend fun refreshSystem(@Path("kind") variant: String): RefreshSystemResponse
/**
* GET /api/playlists/system/{kind}/shuffle (#415 / #411 R2). Returns
* the system playlist's tracks in rotation-aware order without
* rebuilding — used by the Home play-button overlay so taps on For
* You / Discover / Today's mix advance rotation rather than picking
* the stored order. Mirrors `playlists.dart.systemShuffle`.
*/
@GET("api/playlists/system/{kind}/shuffle")
suspend fun systemShuffle(@Path("kind") variant: String): PlaylistDetailWire
}
/**
* POST /api/playlists/{id}/tracks body. Server expects snake_case
* `track_ids` per the Flutter wire shape.
*/
@Serializable
data class AppendTracksRequest(
@SerialName("track_ids") val trackIds: List<String>,
)
/**
* Response body for the system-refresh endpoint. `playlistId` is the
* uuid of the rebuilt playlist, or null when the build had nothing
* to seed from.
*/
@Serializable
data class RefreshSystemResponse(
@SerialName("playlist_id") val playlistId: String? = null,
)
@@ -0,0 +1,39 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.QuarantineMineWire
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/quarantine`. Mirrors the relevant
* parts of `flutter_client/lib/api/endpoints/quarantine.dart` (flag
* and unflag) plus the `/api/quarantine/mine` endpoint from `me.dart`.
*
* Both flag and unflag are user-scoped — callers act on their own
* quarantine entries. The cross-user admin surface is a separate
* `/api/admin/quarantine` route (Phase 10 Commit D).
*/
interface QuarantineApi {
@GET("api/quarantine/mine")
suspend fun listMine(): List<QuarantineMineWire>
@POST("api/quarantine")
suspend fun flag(@Body body: FlagRequest)
@DELETE("api/quarantine/{trackId}")
suspend fun unflag(@Path("trackId") trackId: String)
}
/**
* POST /api/quarantine body. `reason` is one of bad_rip / wrong_file
* / wrong_tags / duplicate / other; `notes` is the free-text comment.
*/
@kotlinx.serialization.Serializable
data class FlagRequest(
@kotlinx.serialization.SerialName("track_id") val trackId: String,
val reason: String,
val notes: String = "",
)
@@ -0,0 +1,19 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.RadioResponseWire
import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit interface for `/api/radio`. Mirrors the relevant slice of
* `flutter_client/lib/api/endpoints/radio.dart` (a single GET that
* returns the seeded queue). The server picks a fresh shuffle each
* invocation — clients call this once per radio start.
*/
interface RadioApi {
@GET("api/radio")
suspend fun seedTrack(
@Query("seed_track") trackId: String,
@Query("limit") limit: Int? = null,
): RadioResponseWire
}
@@ -0,0 +1,25 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.RequestWire
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Path
/**
* Retrofit interface for the user-side `/api/requests`. Mirrors
* `flutter_client/lib/api/endpoints/requests.dart`.
*
* Server scopes results to the caller — admins see only their own
* requests through this endpoint. The cross-user admin view lives on
* a separate `/api/admin/requests` route (Phase 10 Commit D).
*
* The DELETE endpoint returns the cancelled row (not 204) so the UI
* can patch local state without a refetch.
*/
interface RequestsApi {
@GET("api/requests")
suspend fun listMine(): List<RequestWire>
@DELETE("api/requests/{id}")
suspend fun cancel(@Path("id") id: String): RequestWire
}
@@ -0,0 +1,24 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.SearchResponseWire
import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit interface for `GET /api/search`. Mirrors
* `flutter_client/lib/api/endpoints/search.dart`. Server returns 400
* on empty/whitespace-only `q` — the caller is responsible for
* guarding.
*/
interface SearchApi {
@GET("api/search")
suspend fun search(
@Query("q") query: String,
@Query("limit") limit: Int = DEFAULT_LIMIT,
@Query("offset") offset: Int = 0,
): SearchResponseWire
companion object {
const val DEFAULT_LIMIT: Int = 20
}
}
@@ -0,0 +1,23 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.SyncResponseWire
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit interface for `/api/library/sync` (#357). The endpoint
* returns three possible status codes that the SyncController
* branches on:
* - 200: parsed body with cursor + upserts + deletes
* - 204: no changes since the cursor; advance lastSyncAt only
* - 410: cursor is too old; wipe + retry with cursor=0
*
* The Retrofit return type is `Response<SyncResponseWire>` so the
* controller can read `code()` directly instead of having to wrap
* a successful no-content body or catch HttpException for 410.
*/
interface SyncApi {
@GET("api/library/sync")
suspend fun sync(@Query("since") since: Long): Response<SyncResponseWire>
}
@@ -0,0 +1,93 @@
package com.fabledsword.minstrel.auth
import com.fabledsword.minstrel.api.endpoints.AuthApi
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.UserRef
import com.fabledsword.minstrel.models.wire.LoginRequestBody
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Singleton facade over the auth state machine. Mirrors Flutter's
* `AuthController` from `auth_provider.dart`.
*
* Cookie persistence is handled by [AuthCookieInterceptor] capturing
* Set-Cookie on the login response; the user identity itself
* persists via AuthStore.userJson so the username + isAdmin survive
* cold restarts. On construction the controller subscribes to
* AuthStore.userJson and reflects any persisted value into
* `currentUser` — that's what makes the Settings screen show the
* username after a fresh app launch without re-prompting.
*/
@Singleton
class AuthController @Inject constructor(
private val authStore: AuthStore,
private val json: Json,
@ApplicationScope private val scope: CoroutineScope,
retrofit: Retrofit,
) {
private val api: AuthApi = retrofit.create()
private val currentUserState = MutableStateFlow<UserRef?>(null)
val currentUser: StateFlow<UserRef?> = currentUserState.asStateFlow()
val sessionCookie: StateFlow<String?> get() = authStore.sessionCookie
val isSignedIn: Boolean
get() = !authStore.sessionCookie.value.isNullOrEmpty()
init {
// Rehydrate currentUser whenever the persisted userJson emits
// (cold start with a saved cookie OR explicit sign-in/out
// writing through AuthStore). Decode failures collapse to null
// so a malformed row can't crash the app — worst case is a
// blank username until the next sign-in writes a valid value.
scope.launch {
authStore.userJson.collect { raw ->
currentUserState.value = raw?.let { decodeUser(it) }
}
}
}
/**
* Submit credentials. On success the AuthCookieInterceptor will
* have captured the Set-Cookie before this method returns; the
* caller can safely navigate to the in-shell graph. Throws on
* any non-2xx response (caller surfaces in UI).
*/
suspend fun signIn(username: String, password: String): UserRef {
val response = api.login(LoginRequestBody(username = username, password = password))
val user = UserRef(
id = response.user.id,
username = response.user.username,
isAdmin = response.user.isAdmin,
)
currentUserState.value = user
authStore.setUserJson(json.encodeToString(UserRef.serializer(), user))
return user
}
/**
* Clears the local session immediately and best-effort hits
* `/api/auth/logout` so the server can drop its session row.
* Network errors are swallowed — the user is already locally
* signed out and a stale server session times out on its own.
*/
suspend fun signOut() {
currentUserState.value = null
authStore.setSessionCookie(null)
authStore.setUserJson(null)
runCatching { api.logout() }
}
private fun decodeUser(raw: String): UserRef? =
runCatching { json.decodeFromString(UserRef.serializer(), raw) }.getOrNull()
}
@@ -0,0 +1,180 @@
package com.fabledsword.minstrel.auth
import com.fabledsword.minstrel.cache.audiocache.CacheSettings
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import com.fabledsword.minstrel.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import javax.inject.Inject
import javax.inject.Singleton
/**
* Holds the user's session cookie and configured server base URL.
*
* **Hybrid storage**: a `MutableStateFlow` is the primary read source so
* interceptor-thread reads stay synchronous, and writes are persisted
* write-through to a Room `auth_session` single-row table for
* process-death persistence.
*
* Reads on init() rehydrate the in-memory state from the DAO, then a
* collection on `dao.observe()` keeps the in-memory state in sync with
* any external writes (e.g. multi-process pokes, future). On mutate the
* in-memory state changes synchronously so the next interceptor read
* sees the new value immediately; the DAO write coroutine catches up
* shortly after.
*/
// AuthStore is the single-row facade over auth_session (de-facto
// app_preferences — see entity comment). It legitimately owns one
// getter + one setter + one persist helper per pref column; the
// function count scales with the pref count, not with feature
// complexity. Splitting into per-pref stores would scatter the
// shared dao + scope + json plumbing for no real gain. Suppress
// the cap rather than fragment.
@Suppress("TooManyFunctions")
@Singleton
class AuthStore @Inject constructor(
private val dao: AuthSessionDao,
@ApplicationScope private val scope: CoroutineScope,
) {
private val sessionCookieState = MutableStateFlow<String?>(null)
val sessionCookie: StateFlow<String?> = sessionCookieState.asStateFlow()
private val baseUrlState = MutableStateFlow(DEFAULT_BASE_URL)
val baseUrl: StateFlow<String> = baseUrlState.asStateFlow()
private val userJsonState = MutableStateFlow<String?>(null)
val userJson: StateFlow<String?> = userJsonState.asStateFlow()
private val themeModeState = MutableStateFlow<String?>(null)
val themeMode: StateFlow<String?> = themeModeState.asStateFlow()
private val clientIdState = MutableStateFlow<String?>(null)
val clientId: StateFlow<String?> = clientIdState.asStateFlow()
private val cacheSettingsState = MutableStateFlow(CacheSettings.DEFAULT)
val cacheSettings: StateFlow<CacheSettings> = cacheSettingsState.asStateFlow()
private val json = Json { ignoreUnknownKeys = true }
init {
scope.launch {
dao.observe().collect { row ->
sessionCookieState.value = row?.sessionCookie
baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL
userJsonState.value = row?.userJson
themeModeState.value = row?.themeMode
clientIdState.value = row?.clientId
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
}
}
}
private fun decodeCacheSettings(raw: String?): CacheSettings {
if (raw.isNullOrEmpty()) return CacheSettings.DEFAULT
return runCatching {
json.decodeFromString(CacheSettings.serializer(), raw)
}.getOrDefault(CacheSettings.DEFAULT)
}
fun setSessionCookie(value: String?) {
sessionCookieState.value = value
scope.launch { persistCookie(value) }
}
fun setBaseUrl(value: String) {
baseUrlState.value = value
scope.launch { persistBaseUrl(value) }
}
fun setUserJson(value: String?) {
userJsonState.value = value
scope.launch { persistUserJson(value) }
}
fun setThemeMode(value: String?) {
themeModeState.value = value
scope.launch { persistThemeMode(value) }
}
fun setClientId(value: String?) {
clientIdState.value = value
scope.launch { persistClientId(value) }
}
fun setCacheSettings(value: CacheSettings) {
cacheSettingsState.value = value
val encoded = json.encodeToString(CacheSettings.serializer(), value)
scope.launch { persistCacheSettings(encoded) }
}
private suspend fun persistCookie(value: String?) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(sessionCookie = value))
} else {
dao.setSessionCookie(value)
}
}
private suspend fun persistBaseUrl(value: String) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(baseUrl = value))
} else {
dao.setBaseUrl(value)
}
}
private suspend fun persistUserJson(value: String?) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(userJson = value))
} else {
dao.setUserJson(value)
}
}
private suspend fun persistThemeMode(value: String?) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(themeMode = value))
} else {
dao.setThemeMode(value)
}
}
private suspend fun persistClientId(value: String?) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(clientId = value))
} else {
dao.setClientId(value)
}
}
private suspend fun persistCacheSettings(json: String) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(cacheSettingsJson = json))
} else {
dao.setCacheSettingsJson(json)
}
}
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
id = ROW_ID,
sessionCookie = sessionCookieState.value,
baseUrl = baseUrlState.value,
userJson = userJsonState.value,
themeMode = themeModeState.value,
clientId = clientIdState.value,
cacheSettingsJson = json.encodeToString(
CacheSettings.serializer(),
cacheSettingsState.value,
),
)
companion object {
const val DEFAULT_BASE_URL: String = "http://localhost:8080"
private const val ROW_ID = 0
}
}
@@ -0,0 +1,51 @@
package com.fabledsword.minstrel.auth.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.nav.Home
import com.fabledsword.minstrel.nav.Login
import com.fabledsword.minstrel.nav.ServerUrl
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Computes the initial startDestination for the root NavHost based on
* persisted auth state. Sits on top of AuthSessionDao directly rather
* than AuthStore's StateFlow because the StateFlow defaults to null
* until Room's first async emission — we need a definitive answer
* before drawing any nav graph.
*
* - no row at all → ServerUrl (first launch)
* - row with baseUrl, no cookie → Login (URL configured, not yet signed in)
* - row with cookie → Home (signed in)
*
* `startDestination` emits null while resolving (UI shows a spinner)
* and then exactly one resolved value.
*/
@HiltViewModel
class AuthGateViewModel @Inject constructor(
private val dao: AuthSessionDao,
) : ViewModel() {
private val internal = MutableStateFlow<Any?>(null)
val startDestination: StateFlow<Any?> = internal.asStateFlow()
init {
viewModelScope.launch {
val row = dao.get()
internal.value = when {
row == null -> ServerUrl
row.baseUrl == AuthStore.DEFAULT_BASE_URL && row.sessionCookie.isNullOrEmpty() ->
ServerUrl
row.sessionCookie.isNullOrEmpty() -> Login
else -> Home
}
}
}
}
@@ -0,0 +1,147 @@
package com.fabledsword.minstrel.auth.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.nav.Home
import com.fabledsword.minstrel.nav.Login
import com.fabledsword.minstrel.nav.ServerUrl
@Composable
fun LoginScreen(
navController: NavHostController,
viewModel: LoginViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(state.signedIn) {
if (state.signedIn) {
navController.navigate(Home) {
popUpTo(Login) { inclusive = true }
}
viewModel.consumeSignedIn()
}
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
) {
LoginForm(
state = state,
onUsernameChange = viewModel::setUsername,
onPasswordChange = viewModel::setPassword,
onSubmit = viewModel::submit,
onChangeServerUrl = { navController.navigate(ServerUrl) },
)
}
}
@Composable
private fun LoginForm(
state: LoginFormState,
onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onSubmit: () -> Unit,
onChangeServerUrl: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Sign in",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
OutlinedTextField(
value = state.username,
onValueChange = onUsernameChange,
modifier = Modifier.fillMaxWidth(),
label = { Text("Username") },
singleLine = true,
enabled = !state.isSubmitting,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next,
),
)
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChange,
modifier = Modifier.fillMaxWidth(),
label = { Text("Password") },
singleLine = true,
enabled = !state.isSubmitting,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { onSubmit() }),
)
if (state.errorMessage != null) {
Text(
text = state.errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
SubmitButton(state = state, onSubmit = onSubmit)
TextButton(
onClick = onChangeServerUrl,
enabled = !state.isSubmitting,
) {
Text("Change server URL")
}
}
}
@Composable
private fun SubmitButton(state: LoginFormState, onSubmit: () -> Unit) {
Button(
onClick = onSubmit,
enabled = !state.isSubmitting &&
state.username.isNotBlank() &&
state.password.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
if (state.isSubmitting) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp,
)
} else {
Text("Sign in")
}
}
}
@@ -0,0 +1,64 @@
package com.fabledsword.minstrel.auth.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.auth.AuthController
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class LoginFormState(
val username: String = "",
val password: String = "",
val isSubmitting: Boolean = false,
val errorMessage: String? = null,
val signedIn: Boolean = false,
)
@HiltViewModel
class LoginViewModel @Inject constructor(
private val auth: AuthController,
) : ViewModel() {
private val internal = MutableStateFlow(LoginFormState())
val state: StateFlow<LoginFormState> = internal.asStateFlow()
fun setUsername(value: String) {
internal.update { it.copy(username = value, errorMessage = null) }
}
fun setPassword(value: String) {
internal.update { it.copy(password = value, errorMessage = null) }
}
fun submit() {
val s = internal.value
if (s.username.isBlank() || s.password.isBlank() || s.isSubmitting) return
viewModelScope.launch {
internal.update { it.copy(isSubmitting = true, errorMessage = null) }
try {
auth.signIn(s.username, s.password)
internal.update { it.copy(isSubmitting = false, signedIn = true) }
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.update {
it.copy(
isSubmitting = false,
errorMessage = ErrorCopy.fromThrowable(e),
)
}
}
}
}
/** Called after the navigation away from /login completes. */
fun consumeSignedIn() {
internal.update { it.copy(signedIn = false) }
}
}
@@ -0,0 +1,177 @@
package com.fabledsword.minstrel.auth.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.nav.Login
import com.fabledsword.minstrel.nav.ServerUrl
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
// ─── State + VM ───────────────────────────────────────────────────────
data class ServerUrlFormState(
val url: String = "",
val saving: Boolean = false,
val saved: Boolean = false,
val errorMessage: String? = null,
)
@HiltViewModel
class ServerUrlViewModel @Inject constructor(
private val authStore: AuthStore,
) : ViewModel() {
private val internal = MutableStateFlow(
// Pre-fill ONLY if the user has previously saved a real URL —
// the DEFAULT_BASE_URL placeholder (`http://localhost:8080`) is
// an internal HTTP-client fallback, not something a user
// typed. Leaving the field empty lets the OutlinedTextField's
// placeholder ("https://minstrel.example.com") show through
// so the user can just type without first having to clear.
ServerUrlFormState(
url = authStore.baseUrl.value
.takeIf { it != AuthStore.DEFAULT_BASE_URL }
.orEmpty(),
),
)
val state: StateFlow<ServerUrlFormState> = internal.asStateFlow()
fun setUrl(value: String) {
internal.update { it.copy(url = value, errorMessage = null) }
}
fun submit() {
val raw = internal.value.url.trim().trimEnd('/')
if (raw.isEmpty()) {
internal.update { it.copy(errorMessage = "Enter a server URL.") }
return
}
if (!raw.startsWith("http://") && !raw.startsWith("https://")) {
internal.update {
it.copy(errorMessage = "URL must start with http:// or https://")
}
return
}
viewModelScope.launch {
internal.update { it.copy(saving = true, errorMessage = null) }
authStore.setBaseUrl(raw)
internal.update { it.copy(saving = false, saved = true) }
}
}
fun consumeSaved() {
internal.update { it.copy(saved = false) }
}
}
// ─── Screen ───────────────────────────────────────────────────────────
@Composable
fun ServerUrlScreen(
navController: NavHostController,
viewModel: ServerUrlViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(state.saved) {
if (state.saved) {
navController.navigate(Login) {
popUpTo(ServerUrl) { inclusive = true }
}
viewModel.consumeSaved()
}
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
) {
ServerUrlForm(
state = state,
onUrlChange = viewModel::setUrl,
onSubmit = viewModel::submit,
)
}
}
@Composable
private fun ServerUrlForm(
state: ServerUrlFormState,
onUrlChange: (String) -> Unit,
onSubmit: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Connect to your Minstrel",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = "Enter the URL of your Minstrel server.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = state.url,
onValueChange = onUrlChange,
modifier = Modifier.fillMaxWidth(),
label = { Text("Server URL") },
placeholder = { Text("https://minstrel.example.com") },
singleLine = true,
enabled = !state.saving,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { onSubmit() }),
)
if (state.errorMessage != null) {
Text(
text = state.errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Button(
onClick = onSubmit,
enabled = !state.saving && state.url.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text("Continue") }
}
}
@@ -0,0 +1,66 @@
package com.fabledsword.minstrel.cache
import com.fabledsword.minstrel.cache.db.CacheSource
import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import javax.inject.Inject
import javax.inject.Singleton
/**
* Populates `audio_cache_index` from playback so the offline pools
* ([ShuffleSource]) and the cached indicator have a data source.
*
* Mirrors the user-visible behavior of Flutter's `AudioCacheManager`
* (`registerStreamCache` + `touch`): a track you play gets cached as a
* side effect of streaming, and playing it bumps its recency. The
* Android idiom differs intentionally — Media3's `SimpleCache` is the
* real byte store (span-based, keyed by trackId via the MediaItem's
* custom cache key), so this index is a lightweight *play-recency*
* record (source = INCIDENTAL) rather than Flutter's file-per-track
* table. SimpleCache stays the source of truth for "is it actually on
* disk"; this index orders "recently played" for the offline pools.
*
* State observed against [PlayerController.uiState], deduped by track
* id so each entry into the player records once. Eager-constructed via
* the construct-the-singleton trick in `MinstrelApplication`.
*/
@Singleton
class CacheIndexer @Inject constructor(
playerController: PlayerController,
private val dao: AudioCacheIndexDao,
@ApplicationScope private val scope: CoroutineScope,
) {
init {
scope.launch {
playerController.uiState
.map { it.currentTrack }
.distinctUntilChanged { a, b -> a?.id == b?.id }
.collect { track -> if (track != null) record(track) }
}
}
private suspend fun record(track: TrackRef) {
val now = Clock.System.now()
if (dao.get(track.id) != null) {
dao.touchLastPlayed(track.id, now)
} else {
dao.upsert(
AudioCacheIndexEntity(
trackId = track.id,
path = track.streamUrl,
sizeBytes = 0,
lastPlayedAt = now,
source = CacheSource.INCIDENTAL,
),
)
}
}
}
@@ -0,0 +1,60 @@
package com.fabledsword.minstrel.cache
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.PlayerFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Reactive set of track ids that have audio bytes in the Media3
* [SimpleCache][PlayerFactory.simpleCache], used to drive the cached
* indicator dot on track rows.
*
* The SimpleCache (keyed per track via the MediaItem custom cache key)
* is the source of truth for true on-disk residency — querying it
* directly, rather than trusting an index row, means an evicted track
* loses its dot. The key set is read on the IO dispatcher (it scans
* cache metadata) and re-read on every track change, since playback is
* what grows the incidental cache.
*
* The persisted SimpleCache survives process death, so the eager
* initial read paints dots for previously-cached tracks even when the
* library is opened offline with no active playback.
*/
@Singleton
class CachedTrackIds @Inject constructor(
playerController: PlayerController,
private val playerFactory: PlayerFactory,
@ApplicationScope scope: CoroutineScope,
) {
private val refresh = MutableStateFlow(0L)
val ids: StateFlow<Set<String>> =
refresh
.map { readKeys() }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptySet())
init {
scope.launch {
playerController.uiState
.map { it.currentTrack?.id }
.distinctUntilChanged()
.collect { refresh.value += 1 }
}
}
private fun readKeys(): Set<String> =
runCatching { playerFactory.simpleCache.keys.toSet() }.getOrDefault(emptySet())
}
@@ -0,0 +1,63 @@
package com.fabledsword.minstrel.cache
import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerFactory
import javax.inject.Inject
import javax.inject.Singleton
private const val POOL_LIMIT = 100
/**
* Offline play sources over the local audio-cache index. Mirrors
* `flutter_client/lib/cache/shuffle_source.dart`.
*
* Both pools are UNIONs over the cache regardless of storage bucket
* (liked AND recently-played both included). The two-bucket split is
* storage/eviction-only and never filters playback — exactly what
* this relies on.
*
* Divergence note: Flutter's `audioCacheIndex` only holds fully-cached
* tracks, so its pools are inherently resident. Android's index records
* plays (see `CacheIndexer`), so the recency list is intersected with
* true Media3 SimpleCache residency here — the pool never offers a
* played-then-evicted track that would fail to play offline.
*
* Surfaced on Home's Playlists row only when offline; tap shuffles +
* plays the pool from the cache.
*/
@Singleton
class ShuffleSource @Inject constructor(
private val cacheIndexDao: AudioCacheIndexDao,
private val trackDao: CachedTrackDao,
private val likes: LikesRepository,
private val playerFactory: PlayerFactory,
) {
/** Cached tracks, most-recently-played first (liked included). */
suspend fun recentlyPlayed(limit: Int = POOL_LIMIT): List<TrackRef> =
materialize(residentIdsByRecency()).take(limit)
/** Cached tracks that are in the user's liked set, recency-ordered. */
suspend fun liked(limit: Int = POOL_LIMIT): List<TrackRef> {
val likedSet = likes.likedTrackIds()
val ids = residentIdsByRecency().filter { it in likedSet }
return materialize(ids).take(limit)
}
/** Recency-ordered ids whose audio is actually resident in the cache. */
private suspend fun residentIdsByRecency(): List<String> {
val resident = runCatching { playerFactory.simpleCache.keys.toSet() }
.getOrDefault(emptySet())
return cacheIndexDao.trackIdsByRecency().filter { it in resident }
}
/** Materialize ordered ids into TrackRefs from cached metadata, preserving order. */
private suspend fun materialize(orderedIds: List<String>): List<TrackRef> {
if (orderedIds.isEmpty()) return emptyList()
val byId = trackDao.getByIds(orderedIds).associateBy { it.id }
return orderedIds.mapNotNull { byId[it]?.toDomain() }
}
}
@@ -0,0 +1,23 @@
package com.fabledsword.minstrel.cache.audiocache
/**
* Defaults for the 2-bucket audio cache. Matches the Flutter client.
*
* - `likedCapBytes`: cap for the protected bucket — cached files for
* tracks the user has liked. Evicted only after the rolling bucket
* is fully drained (almost never under normal use).
* - `rollingCapBytes`: cap for the unprotected bucket — incidental /
* auto-prefetch downloads. LRU within this bucket.
*
* Phase 11 (Settings) will allow user overrides; the values are kept
* generous so first-time installs work well without configuration.
*/
data class CacheConfig(
val likedCapBytes: Long = DEFAULT_LIKED_CAP_BYTES,
val rollingCapBytes: Long = DEFAULT_ROLLING_CAP_BYTES,
) {
companion object {
const val DEFAULT_LIKED_CAP_BYTES: Long = 200L * 1024 * 1024 // 200 MiB
const val DEFAULT_ROLLING_CAP_BYTES: Long = 150L * 1024 * 1024 // 150 MiB
}
}
@@ -0,0 +1,37 @@
package com.fabledsword.minstrel.cache.audiocache
import kotlinx.serialization.Serializable
private const val FIVE_GIB_BYTES = 5L * 1024 * 1024 * 1024
private const val DEFAULT_PREFETCH_WINDOW = 5
/**
* User-tunable audio cache settings. Mirrors Flutter's `CacheSettings`
* (cache_settings_provider.dart) field-for-field. Persisted as a JSON
* blob on the auth_session single-row table via [AuthStore].
*
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means
* unlimited. Effective at next app start (SimpleCache is built once
* per process).
* - [rollingCapBytes]: budget for incidental / auto-prefetch downloads.
* Same 0=unlimited / restart-to-apply caveat.
* - [prefetchWindow]: 1..10. Number of next-tracks the prefetcher
* pre-downloads. Has no effect until the prefetcher lands (separate
* audit follow-up) — persisted now so the user can pre-configure.
* - [cacheLikedTracks]: when true, liking a track should also pin its
* audio to the liked-cache bucket. Has no effect until pin-on-like
* lands (separate follow-up).
*
* Defaults match Flutter: 5 GiB per bucket, prefetch=5, cache-liked=true.
*/
@Serializable
data class CacheSettings(
val likedCapBytes: Long = FIVE_GIB_BYTES,
val rollingCapBytes: Long = FIVE_GIB_BYTES,
val prefetchWindow: Int = DEFAULT_PREFETCH_WINDOW,
val cacheLikedTracks: Boolean = true,
) {
companion object {
val DEFAULT: CacheSettings = CacheSettings()
}
}
@@ -0,0 +1,84 @@
package com.fabledsword.minstrel.cache.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import com.fabledsword.minstrel.cache.db.entities.CachedAlbumEntity
import com.fabledsword.minstrel.cache.db.entities.CachedArtistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity
import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
/**
* Single Room database for the whole app. Mirrors the Flutter client's
* Drift `AppDb` — same logical schema, one entity-family per @Entity.
*
* Native v1 starts at schema version 1 (the Flutter Drift schemaVersion 11
* is an internal client detail; the server is the source of truth and the
* sync controller refills on first launch). Future schema bumps land per
* change with explicit `Migration` entries; `fallbackToDestructiveMigration`
* in `DatabaseModule` is the safety net while we're pre-v1.
*
* Entity families land in Task 4.2 slices. The set will grow until the
* whole Drift schema is mirrored.
*/
@Database(
entities = [
SyncMetadataEntity::class,
CachedArtistEntity::class,
CachedAlbumEntity::class,
CachedTrackEntity::class,
CachedLikeEntity::class,
CachedPlaylistEntity::class,
CachedPlaylistTrackEntity::class,
CachedQuarantineEntity::class,
AudioCacheIndexEntity::class,
CachedMutationEntity::class,
CachedResumeStateEntity::class,
CachedHomeIndexEntity::class,
CachedHistorySnapshotEntity::class,
AuthSessionEntity::class,
],
version = 6,
exportSchema = true,
)
@TypeConverters(MinstrelTypeConverters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun syncMetadataDao(): SyncMetadataDao
abstract fun cachedArtistDao(): CachedArtistDao
abstract fun cachedAlbumDao(): CachedAlbumDao
abstract fun cachedTrackDao(): CachedTrackDao
abstract fun cachedLikeDao(): CachedLikeDao
abstract fun cachedPlaylistDao(): CachedPlaylistDao
abstract fun cachedPlaylistTrackDao(): CachedPlaylistTrackDao
abstract fun cachedQuarantineDao(): CachedQuarantineDao
abstract fun audioCacheIndexDao(): AudioCacheIndexDao
abstract fun cachedMutationDao(): CachedMutationDao
abstract fun cachedResumeStateDao(): CachedResumeStateDao
abstract fun cachedHomeIndexDao(): CachedHomeIndexDao
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
abstract fun authSessionDao(): AuthSessionDao
}
@@ -0,0 +1,109 @@
package com.fabledsword.minstrel.cache.db
import android.content.Context
import androidx.room.Room
import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
@Suppress("TooManyFunctions") // Hilt @Provides bridges per DAO; one per family.
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME)
// Pre-v1 safety net: rebuild on any schema mismatch. The sync
// controller refills the cache from the server on first
// launch, so users lose only the unsynced mutation queue
// (acceptable while we're iterating). Replace with explicit
// Migration entries before the first tagged release.
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
// DAO @Provides — Hilt can't inject AppDatabase abstract accessors
// directly. Each consumer-needed DAO gets a thin @Provides bridge.
@Provides
@Singleton
fun provideAuthSessionDao(db: AppDatabase): AuthSessionDao = db.authSessionDao()
@Provides
@Singleton
fun provideCachedArtistDao(db: AppDatabase): CachedArtistDao = db.cachedArtistDao()
@Provides
@Singleton
fun provideCachedAlbumDao(db: AppDatabase): CachedAlbumDao = db.cachedAlbumDao()
@Provides
@Singleton
fun provideCachedTrackDao(db: AppDatabase): CachedTrackDao = db.cachedTrackDao()
@Provides
@Singleton
fun provideCachedResumeStateDao(db: AppDatabase): CachedResumeStateDao =
db.cachedResumeStateDao()
@Provides
@Singleton
fun provideCachedHomeIndexDao(db: AppDatabase): CachedHomeIndexDao =
db.cachedHomeIndexDao()
@Provides
@Singleton
fun provideCachedHistorySnapshotDao(db: AppDatabase): CachedHistorySnapshotDao =
db.cachedHistorySnapshotDao()
@Provides
@Singleton
fun provideCachedQuarantineDao(db: AppDatabase): CachedQuarantineDao =
db.cachedQuarantineDao()
@Provides
@Singleton
fun provideCachedPlaylistDao(db: AppDatabase): CachedPlaylistDao =
db.cachedPlaylistDao()
@Provides
@Singleton
fun provideCachedPlaylistTrackDao(db: AppDatabase): CachedPlaylistTrackDao =
db.cachedPlaylistTrackDao()
@Provides
@Singleton
fun provideCachedLikeDao(db: AppDatabase): CachedLikeDao = db.cachedLikeDao()
@Provides
@Singleton
fun provideCachedMutationDao(db: AppDatabase): CachedMutationDao =
db.cachedMutationDao()
@Provides
@Singleton
fun provideSyncMetadataDao(db: AppDatabase): SyncMetadataDao = db.syncMetadataDao()
@Provides
@Singleton
fun provideAudioCacheIndexDao(db: AppDatabase): AudioCacheIndexDao =
db.audioCacheIndexDao()
private const val DATABASE_NAME = "minstrel.db"
}
@@ -0,0 +1,40 @@
package com.fabledsword.minstrel.cache.db
import androidx.room.TypeConverter
import kotlinx.datetime.Instant
/**
* Source category for a cached audio file. Mirrors the Drift `CacheSource`
* enum used by the Flutter client's `audio_cache_index` table 1:1.
*
* - `MANUAL`: user explicitly pinned (e.g. "save for offline")
* - `AUTO_LIKED`: auto-cached because the track is liked
* - `AUTO_PLAYLIST`: auto-cached because it's in a pinned playlist
* - `AUTO_PREFETCH`: pre-warmed by background prefetch heuristics
* - `INCIDENTAL`: cached as a side effect of playback streaming
*
* Eviction priority order (first to evict): INCIDENTAL → AUTO_PREFETCH
* → AUTO_PLAYLIST → AUTO_LIKED → MANUAL.
*/
enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCIDENTAL }
/**
* Cross-cutting Room type converters. Registered on `@Database` via
* `@TypeConverters(MinstrelTypeConverters::class)`.
*
* `kotlinx.datetime.Instant` ↔ `Long` epoch millis: the same convention the
* Flutter client uses (Drift `text()` column with ISO-8601 strings would
* also work but Long is leaner; we don't need human-readable rows in the
* SQLite file).
*/
class MinstrelTypeConverters {
@TypeConverter fun instantToLong(i: Instant?): Long? = i?.toEpochMilliseconds()
@TypeConverter fun longToInstant(l: Long?): Instant? =
l?.let { Instant.fromEpochMilliseconds(it) }
@TypeConverter fun sourceToName(s: CacheSource?): String? = s?.name
@TypeConverter fun nameToSource(n: String?): CacheSource? =
n?.let { CacheSource.valueOf(it) }
}
@@ -0,0 +1,68 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.CacheSource
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface AudioCacheIndexDao {
/** Used to render the "Downloaded" / "Offline" listing. */
@Query("SELECT * FROM audio_cache_index ORDER BY lastPlayedAt DESC NULLS LAST")
fun observeAll(): Flow<List<AudioCacheIndexEntity>>
@Query("SELECT * FROM audio_cache_index WHERE trackId = :trackId")
suspend fun get(trackId: String): AudioCacheIndexEntity?
@Query("SELECT * FROM audio_cache_index WHERE trackId IN (:trackIds)")
suspend fun getByTrackIds(trackIds: List<String>): List<AudioCacheIndexEntity>
/** All cached track IDs — bucket-membership lookup for eviction. */
@Query("SELECT trackId FROM audio_cache_index")
suspend fun allCachedTrackIds(): List<String>
/**
* Cached track IDs most-recently-played first (nulls last so
* never-played downloads sort after real plays). Backs the
* offline-pool shuffle sources.
*/
@Query("SELECT trackId FROM audio_cache_index ORDER BY lastPlayedAt DESC NULLS LAST")
suspend fun trackIdsByRecency(): List<String>
/** Eviction-candidate ordering — oldest lastPlayedAt first within a source. */
@Query(
"SELECT * FROM audio_cache_index " +
"WHERE source = :source " +
"ORDER BY lastPlayedAt ASC NULLS FIRST, cachedAt ASC",
)
suspend fun evictionCandidates(source: CacheSource): List<AudioCacheIndexEntity>
/** Sum of bytes across rows; the rolling/liked bucket caps compare against this. */
@Query("SELECT COALESCE(SUM(sizeBytes), 0) FROM audio_cache_index")
suspend fun totalBytes(): Long
@Query(
"SELECT COALESCE(SUM(sizeBytes), 0) FROM audio_cache_index WHERE source = :source",
)
suspend fun bytesBySource(source: CacheSource): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(row: AudioCacheIndexEntity)
@Query(
"UPDATE audio_cache_index SET lastPlayedAt = :at WHERE trackId = :trackId",
)
suspend fun touchLastPlayed(trackId: String, at: kotlinx.datetime.Instant)
@Query("DELETE FROM audio_cache_index WHERE trackId = :trackId")
suspend fun deleteByTrackId(trackId: String)
@Query("DELETE FROM audio_cache_index WHERE trackId IN (:trackIds)")
suspend fun deleteByTrackIds(trackIds: List<String>)
@Query("DELETE FROM audio_cache_index")
suspend fun clear()
}
@@ -0,0 +1,45 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface AuthSessionDao {
@Query("SELECT * FROM auth_session WHERE id = 0")
fun observe(): Flow<AuthSessionEntity?>
@Query("SELECT * FROM auth_session WHERE id = 0")
suspend fun get(): AuthSessionEntity?
/** Upsert with both fields at once — used for first-run init. */
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(row: AuthSessionEntity)
/** Partial update: change only the session cookie. */
@Query("UPDATE auth_session SET sessionCookie = :cookie WHERE id = 0")
suspend fun setSessionCookie(cookie: String?)
/** Partial update: change only the base URL. */
@Query("UPDATE auth_session SET baseUrl = :baseUrl WHERE id = 0")
suspend fun setBaseUrl(baseUrl: String)
/** Partial update: change only the serialized user identity JSON. */
@Query("UPDATE auth_session SET userJson = :userJson WHERE id = 0")
suspend fun setUserJson(userJson: String?)
/** Partial update: change only the theme override ("light"/"dark"/null=system). */
@Query("UPDATE auth_session SET themeMode = :themeMode WHERE id = 0")
suspend fun setThemeMode(themeMode: String?)
/** Partial update: change only the stable client install id. */
@Query("UPDATE auth_session SET clientId = :clientId WHERE id = 0")
suspend fun setClientId(clientId: String?)
/** Partial update: change only the serialized cache settings. */
@Query("UPDATE auth_session SET cacheSettingsJson = :json WHERE id = 0")
suspend fun setCacheSettingsJson(json: String?)
}
@@ -0,0 +1,35 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.CachedAlbumEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CachedAlbumDao {
@Query("SELECT * FROM cached_albums ORDER BY sortTitle COLLATE NOCASE ASC")
fun observeAll(): Flow<List<CachedAlbumEntity>>
@Query(
"SELECT * FROM cached_albums " +
"WHERE artistId = :artistId ORDER BY sortTitle COLLATE NOCASE ASC",
)
fun observeByArtist(artistId: String): Flow<List<CachedAlbumEntity>>
@Query("SELECT * FROM cached_albums WHERE id = :id")
suspend fun getById(id: String): CachedAlbumEntity?
@Query("SELECT * FROM cached_albums WHERE id = :id")
fun observeById(id: String): Flow<CachedAlbumEntity?>
@Query("SELECT id FROM cached_albums WHERE fetchedAt < :before LIMIT :limit")
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedAlbumEntity>)
@Query("DELETE FROM cached_albums WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>)
}
@@ -0,0 +1,29 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.CachedArtistEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CachedArtistDao {
@Query("SELECT * FROM cached_artists ORDER BY sortName COLLATE NOCASE ASC")
fun observeAll(): Flow<List<CachedArtistEntity>>
@Query("SELECT * FROM cached_artists WHERE id = :id")
suspend fun getById(id: String): CachedArtistEntity?
@Query("SELECT * FROM cached_artists WHERE id = :id")
fun observeById(id: String): Flow<CachedArtistEntity?>
@Query("SELECT id FROM cached_artists WHERE fetchedAt < :before LIMIT :limit")
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedArtistEntity>)
@Query("DELETE FROM cached_artists WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>)
}
@@ -0,0 +1,18 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CachedHistorySnapshotDao {
/** Observes the single snapshot row (null until the first fetch lands). */
@Query("SELECT * FROM cached_history_snapshot WHERE id = 1")
fun observe(): Flow<CachedHistorySnapshotEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(row: CachedHistorySnapshotEntity)
}
@@ -0,0 +1,33 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CachedHomeIndexDao {
@Query(
"SELECT * FROM cached_home_index " +
"WHERE section = :section ORDER BY position ASC",
)
fun observeBySection(section: String): Flow<List<CachedHomeIndexEntity>>
@Query(
"SELECT * FROM cached_home_index " +
"WHERE section = :section ORDER BY position ASC",
)
suspend fun getBySection(section: String): List<CachedHomeIndexEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedHomeIndexEntity>)
/** Replace-all pattern; sync wipes a section then re-inserts. */
@Query("DELETE FROM cached_home_index WHERE section = :section")
suspend fun deleteBySection(section: String)
@Query("DELETE FROM cached_home_index")
suspend fun clear()
}
@@ -0,0 +1,39 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CachedLikeDao {
/** All track IDs the user has liked. Audio-cache eviction uses this set. */
@Query(
"SELECT entityId FROM cached_likes " +
"WHERE userId = :userId AND entityType = 'track'",
)
fun observeLikedTrackIds(userId: String): Flow<List<String>>
@Query(
"SELECT entityId FROM cached_likes " +
"WHERE userId = :userId AND entityType = :entityType",
)
fun observeLikedIdsOfType(userId: String, entityType: String): Flow<List<String>>
@Query(
"SELECT EXISTS(SELECT 1 FROM cached_likes " +
"WHERE userId = :userId AND entityType = :entityType AND entityId = :entityId)",
)
fun observeIsLiked(userId: String, entityType: String, entityId: String): Flow<Boolean>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedLikeEntity>)
@Query(
"DELETE FROM cached_likes " +
"WHERE userId = :userId AND entityType = :entityType AND entityId = :entityId",
)
suspend fun delete(userId: String, entityType: String, entityId: String)
}
@@ -0,0 +1,34 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.Instant
@Dao
interface CachedMutationDao {
/** Pending count for the offline-indicator badge. */
@Query("SELECT COUNT(*) FROM cached_mutations")
fun observePendingCount(): Flow<Int>
/** FIFO drain order so the replayer processes oldest first. */
@Query("SELECT * FROM cached_mutations ORDER BY id ASC")
suspend fun getAll(): List<CachedMutationEntity>
@Insert
suspend fun insert(row: CachedMutationEntity): Long
@Query(
"UPDATE cached_mutations SET attempts = attempts + 1, lastAttemptAt = :at " +
"WHERE id = :id",
)
suspend fun recordAttempt(id: Long, at: Instant)
@Query("DELETE FROM cached_mutations WHERE id = :id")
suspend fun delete(id: Long)
@Query("DELETE FROM cached_mutations")
suspend fun clear()
}
@@ -0,0 +1,70 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CachedPlaylistDao {
/** All playlists, user + system, sorted by name. */
@Query("SELECT * FROM cached_playlists ORDER BY name COLLATE NOCASE ASC")
fun observeAll(): Flow<List<CachedPlaylistEntity>>
/** User playlists only (systemVariant IS NULL) — for the add-to-playlist sheet. */
@Query(
"SELECT * FROM cached_playlists " +
"WHERE systemVariant IS NULL ORDER BY name COLLATE NOCASE ASC",
)
fun observeUserPlaylists(): Flow<List<CachedPlaylistEntity>>
/** System playlists for the home screen. */
@Query(
"SELECT * FROM cached_playlists " +
"WHERE systemVariant IS NOT NULL ORDER BY name COLLATE NOCASE ASC",
)
fun observeSystemPlaylists(): Flow<List<CachedPlaylistEntity>>
@Query("SELECT * FROM cached_playlists WHERE id = :id")
suspend fun getById(id: String): CachedPlaylistEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedPlaylistEntity>)
@Query("DELETE FROM cached_playlists WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>)
@Query("DELETE FROM cached_playlists WHERE userId = :userId")
suspend fun deleteAllOwned(userId: String)
@Query("DELETE FROM cached_playlists WHERE userId = :userId AND id NOT IN (:keepIds)")
suspend fun deleteOwnedNotIn(userId: String, keepIds: List<String>)
/**
* Atomically reconciles the cache against the fresh list response.
* Mirrors `flutter_client/lib/playlists/playlists_provider.dart:54` —
* `BuildSystemPlaylists` rotates system-playlist UUIDs every
* rebuild, so upsert alone leaves stale rows whose detail fetch
* 404s. Delete any of the user's rows not in [freshOwnedIds] (this
* catches both deleted owned playlists AND old system-playlist
* UUIDs, since system playlists are user-scoped and appear in
* [all] under their new id rather than in [freshOwnedIds]), then
* upsert the fresh set.
*/
@Transaction
suspend fun replaceList(
userId: String,
freshOwnedIds: List<String>,
all: List<CachedPlaylistEntity>,
) {
if (freshOwnedIds.isEmpty()) {
deleteAllOwned(userId)
} else {
deleteOwnedNotIn(userId, freshOwnedIds)
}
upsertAll(all)
}
}
@@ -0,0 +1,47 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CachedPlaylistTrackDao {
@Query(
"SELECT * FROM cached_playlist_tracks " +
"WHERE playlistId = :playlistId ORDER BY position ASC",
)
fun observeByPlaylist(playlistId: String): Flow<List<CachedPlaylistTrackEntity>>
@Query(
"SELECT * FROM cached_playlist_tracks " +
"WHERE playlistId = :playlistId ORDER BY position ASC",
)
suspend fun getByPlaylist(playlistId: String): List<CachedPlaylistTrackEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedPlaylistTrackEntity>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnore(row: CachedPlaylistTrackEntity)
/**
* Highest existing position for [playlistId], or null if the
* playlist has no rows. Used by appendTrack to assign the next
* position before the optimistic Room write.
*/
@Query("SELECT MAX(position) FROM cached_playlist_tracks WHERE playlistId = :playlistId")
suspend fun maxPosition(playlistId: String): Int?
/** Replace-all pattern for a playlist; called after a sync delta lands. */
@Query("DELETE FROM cached_playlist_tracks WHERE playlistId = :playlistId")
suspend fun deleteByPlaylist(playlistId: String)
@Query(
"DELETE FROM cached_playlist_tracks " +
"WHERE playlistId = :playlistId AND trackId IN (:trackIds)",
)
suspend fun deleteTracksFromPlaylist(playlistId: String, trackIds: List<String>)
}
@@ -0,0 +1,52 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CachedQuarantineDao {
/** Newest-first listing for the Quarantine screen. */
@Query("SELECT * FROM cached_quarantine_mine ORDER BY createdAt DESC")
fun observeAll(): Flow<List<CachedQuarantineEntity>>
/** Just the IDs — feed-filtering code reads this to hide tracks. */
@Query("SELECT trackId FROM cached_quarantine_mine")
fun observeFlaggedTrackIds(): Flow<List<String>>
@Query(
"SELECT EXISTS(SELECT 1 FROM cached_quarantine_mine WHERE trackId = :trackId)",
)
fun observeIsHidden(trackId: String): Flow<Boolean>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(row: CachedQuarantineEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedQuarantineEntity>)
@Query("DELETE FROM cached_quarantine_mine WHERE trackId = :trackId")
suspend fun deleteByTrackId(trackId: String)
@Query("DELETE FROM cached_quarantine_mine WHERE trackId IN (:trackIds)")
suspend fun deleteByTrackIds(trackIds: List<String>)
@Query("DELETE FROM cached_quarantine_mine")
suspend fun clear()
/**
* Atomic full-replace from the server snapshot — one transaction so
* the `observeAll()` watcher sees a single merged emission, not a
* delete-then-insert flicker. Mirrors Flutter's MyQuarantineController
* `_refreshFromServer` transaction.
*/
@Transaction
suspend fun replaceAll(rows: List<CachedQuarantineEntity>) {
clear()
upsertAll(rows)
}
}
@@ -0,0 +1,23 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CachedResumeStateDao {
@Query("SELECT * FROM cached_resume_state WHERE id = 1")
suspend fun get(): CachedResumeStateEntity?
@Query("SELECT * FROM cached_resume_state WHERE id = 1")
fun observe(): Flow<CachedResumeStateEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(row: CachedResumeStateEntity)
@Query("DELETE FROM cached_resume_state")
suspend fun clear()
}
@@ -0,0 +1,35 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CachedTrackDao {
@Query(
"SELECT * FROM cached_tracks " +
"WHERE albumId = :albumId ORDER BY discNumber ASC, trackNumber ASC",
)
fun observeByAlbum(albumId: String): Flow<List<CachedTrackEntity>>
@Query("SELECT * FROM cached_tracks WHERE id = :id")
suspend fun getById(id: String): CachedTrackEntity?
@Query("SELECT * FROM cached_tracks WHERE id = :id")
fun observeById(id: String): Flow<CachedTrackEntity?>
@Query("SELECT * FROM cached_tracks WHERE id IN (:ids)")
suspend fun getByIds(ids: List<String>): List<CachedTrackEntity>
@Query("SELECT id FROM cached_tracks WHERE fetchedAt < :before LIMIT :limit")
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedTrackEntity>)
@Query("DELETE FROM cached_tracks WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>)
}
@@ -0,0 +1,20 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface SyncMetadataDao {
@Query("SELECT * FROM sync_metadata WHERE id = 0")
fun observe(): Flow<SyncMetadataEntity?>
@Query("SELECT * FROM sync_metadata WHERE id = 0")
suspend fun get(): SyncMetadataEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(row: SyncMetadataEntity)
}
@@ -0,0 +1,31 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.fabledsword.minstrel.cache.db.CacheSource
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* One row per fully-downloaded audio file. Mirrors
* `flutter_client/lib/cache/db.dart`'s `AudioCacheIndex` Drift table.
*
* Drives the 2-bucket LRU eviction (Phase 12 AudioCacheEvictionWorker):
* - `incidental` files (streamed-and-cached side effect) evict first
* - `autoPrefetch` second
* - `manual` (user explicitly pinned) evict last
*
* `cachedAt` = when we downloaded. `lastPlayedAt` = when the user last
* played it; drives the offline "recently played" ordering and is the
* tiebreaker for LRU eviction within a source bucket. Nullable on
* brand-new rows until the first post-cache play touches them.
*/
@Entity(tableName = "audio_cache_index")
data class AudioCacheIndexEntity(
@PrimaryKey val trackId: String,
val path: String,
val sizeBytes: Int,
val cachedAt: Instant = Clock.System.now(),
val lastPlayedAt: Instant? = null,
val source: CacheSource,
)
@@ -0,0 +1,39 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Single-row table holding the user's session cookie, configured
* server base URL, the serialized current user identity, and the
* theme-override preference.
*
* `id = 0` is the only valid row. `sessionCookie` is null when the
* user is logged out (or has never logged in). `baseUrl` always has
* a value — `AuthStore.DEFAULT_BASE_URL` is used until the user
* configures one in Settings. `userJson` is the JSON-encoded
* UserRef captured at sign-in so the username + isAdmin survive
* a cold restart (the cookie alone doesn't carry identity).
* `themeMode` is "system" / "light" / "dark" (or null = system).
*
* The table is the de-facto single-row app-prefs row at this point,
* not strictly auth-only. Kept under the auth_session name to avoid
* a schema-rename migration; a future cleanup could rename to
* `app_preferences`.
*/
@Entity(tableName = "auth_session")
data class AuthSessionEntity(
@PrimaryKey val id: Int = 0,
val sessionCookie: String? = null,
val baseUrl: String,
val userJson: String? = null,
val themeMode: String? = null,
val clientId: String? = null,
/**
* JSON-encoded CacheSettings (cache/audiocache/CacheSettings.kt).
* Null = use defaults. Stored as a JSON blob rather than four
* individual columns to keep schema changes cheap when the
* CacheSettings shape evolves.
*/
val cacheSettingsJson: String? = null,
)
@@ -0,0 +1,22 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one album. Mirrors `flutter_client/lib/cache/db.dart`'s
* `CachedAlbums` Drift table.
*/
@Entity(tableName = "cached_albums")
data class CachedAlbumEntity(
@PrimaryKey val id: String,
val artistId: String,
val title: String,
val sortTitle: String,
val releaseDate: String? = null,
val coverPath: String? = null,
val mbid: String? = null,
val fetchedAt: Instant = Clock.System.now(),
)
@@ -0,0 +1,26 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one artist. Mirrors `flutter_client/lib/cache/db.dart`'s
* `CachedArtists` Drift table.
*
* Column names follow Kotlin idiom (camelCase) rather than Drift's
* snake_case — the schema is purely internal to the native client; the
* sync controller exchanges snake_case JSON with the server and our
* mappers translate at the boundary.
*/
@Entity(tableName = "cached_artists")
data class CachedArtistEntity(
@PrimaryKey val id: String,
val name: String,
val sortName: String,
val mbid: String? = null,
val artistThumbPath: String? = null,
val artistFanartPath: String? = null,
val fetchedAt: Instant = Clock.System.now(),
)
@@ -0,0 +1,26 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Single-row snapshot of the `/api/me/history` response, stored as the
* raw wire JSON so the History tab paints instantly from disk on cold
* open and survives connectivity loss. Mirrors the Flutter Drift
* `CachedHistorySnapshot` table (id defaults to 1; one row total).
*
* Cache-first + SWR: the tab observes this row and a background refresh
* overwrites it, so the freshest plays land without a blocking spinner.
*/
@Entity(tableName = "cached_history_snapshot")
data class CachedHistorySnapshotEntity(
@PrimaryKey val id: Int = SINGLETON_ID,
val json: String,
val updatedAt: Instant = Clock.System.now(),
) {
companion object {
const val SINGLETON_ID = 1
}
}
@@ -0,0 +1,34 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Per-item row driving the Home screen sections. Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedHomeIndex` Drift table.
*
* `section` is one of (matching /api/home keys):
* - "recently_added_albums"
* - "rediscover_albums"
* - "rediscover_artists"
* - "most_played_tracks"
* - "last_played_artists"
*
* `entityType` is "album" | "artist" | "track" — dispatches hydration
* to the right per-entity endpoint when a tile is rendered.
*
* The composite PK (section, position) means there's exactly one row
* per slot per section; sync replaces in-place by upsert.
*/
@Entity(
tableName = "cached_home_index",
primaryKeys = ["section", "position"],
)
data class CachedHomeIndexEntity(
val section: String,
val position: Int,
val entityType: String,
val entityId: String,
val fetchedAt: Instant = Clock.System.now(),
)
@@ -0,0 +1,26 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Like membership row. Mirrors `flutter_client/lib/cache/db.dart`'s
* `CachedLikes` Drift table. Composite primary key — one user may
* independently like a track AND its album AND its artist; rows are
* disambiguated by the (userId, entityType, entityId) triple.
*
* `entityType` is one of "track" | "album" | "artist". Stored as a
* plain string for parity with the Drift schema and the server's wire
* format; an enum doesn't earn its keep here.
*/
@Entity(
tableName = "cached_likes",
primaryKeys = ["userId", "entityType", "entityId"],
)
data class CachedLikeEntity(
val userId: String,
val entityType: String,
val entityId: String,
val likedAt: Instant = Clock.System.now(),
)

Some files were not shown because too many files have changed in this diff Show More