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
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:
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
# 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 tag vX.Y.Z → :vX.Y.Z and :latest
|
||||
# push to main → :main and :latest
|
||||
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest
|
||||
# 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:
|
||||
push:
|
||||
@@ -20,6 +26,13 @@ on:
|
||||
- '**/*.md'
|
||||
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:
|
||||
release:
|
||||
runs-on: go-ci
|
||||
@@ -55,9 +68,13 @@ jobs:
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Release build: ${VERSION} + latest"
|
||||
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 "::notice::Main-branch build: :main"
|
||||
echo "::notice::Main-branch build: :main + :latest"
|
||||
fi
|
||||
|
||||
- name: Registry login
|
||||
@@ -30,18 +30,13 @@ on:
|
||||
- 'internal/**'
|
||||
- 'cmd/**'
|
||||
- '.golangci.yml'
|
||||
- '.forgejo/workflows/test-go.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '**/*.go'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- 'sqlc.yaml'
|
||||
- 'internal/**'
|
||||
- 'cmd/**'
|
||||
- '.golangci.yml'
|
||||
- '.forgejo/workflows/test-go.yml'
|
||||
- '.gitea/workflows/test-go.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
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -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
@@ -38,11 +38,19 @@ go.work.sum
|
||||
# canonical record lives in commit messages and the running code).
|
||||
docs/superpowers/
|
||||
|
||||
# Per-machine Claude Code settings + remember-skill memory artifacts.
|
||||
# Both are operator-scoped; the canonical project memory lives under
|
||||
# ~/.claude/projects/ outside the repo.
|
||||
# Per-machine Claude Code settings + remember-skill memory artifacts +
|
||||
# project-level Claude/AI instruction files. All operator-scoped; the
|
||||
# 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/
|
||||
.remember/
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
GEMINI.md
|
||||
.cursorrules
|
||||
.windsurfrules
|
||||
.aider.conf.yml
|
||||
|
||||
# Flutter
|
||||
flutter_client/.dart_tool/
|
||||
@@ -57,3 +65,21 @@ flutter_client/android/app/build/
|
||||
flutter_client/android/local.properties
|
||||
flutter_client/android/key.properties
|
||||
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
|
||||
|
||||
@@ -91,7 +91,7 @@ Two concurrent dev processes:
|
||||
truncates your dev `minstrel` data (admin user, library, likes). It
|
||||
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
|
||||
job with its own ephemeral Postgres (`.forgejo/workflows/test-go.yml`).
|
||||
job with its own ephemeral Postgres (`.gitea/workflows/test-go.yml`).
|
||||
|
||||
### Production build
|
||||
|
||||
@@ -101,7 +101,7 @@ Two concurrent dev processes:
|
||||
|
||||
- Day-to-day work happens on `dev` (or feature branches merged into `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).
|
||||
|
||||
|
||||
Generated
+3
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
Generated
+1
@@ -0,0 +1 @@
|
||||
Minstrel
|
||||
Generated
+6
@@ -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>
|
||||
Generated
+1844
File diff suppressed because it is too large
Load Diff
Generated
+123
@@ -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
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
+11
@@ -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>
|
||||
Generated
+13
@@ -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>
|
||||
Generated
+19
@@ -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>
|
||||
Generated
+10
@@ -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>
|
||||
Generated
+10
@@ -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>
|
||||
Generated
+17
@@ -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>
|
||||
Generated
+6
@@ -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>
|
||||
@@ -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() }
|
||||
Vendored
+19
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
+38
@@ -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,
|
||||
)
|
||||
+57
@@ -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() },
|
||||
)
|
||||
+51
@@ -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") }
|
||||
}
|
||||
}
|
||||
+85
@@ -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
|
||||
+110
@@ -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)
|
||||
+29
@@ -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() }
|
||||
}
|
||||
}
|
||||
+23
@@ -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
|
||||
}
|
||||
}
|
||||
+37
@@ -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
|
||||
}
|
||||
+109
@@ -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"
|
||||
}
|
||||
+40
@@ -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) }
|
||||
}
|
||||
+68
@@ -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()
|
||||
}
|
||||
+45
@@ -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?)
|
||||
}
|
||||
+35
@@ -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>)
|
||||
}
|
||||
+29
@@ -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>)
|
||||
}
|
||||
Vendored
+18
@@ -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)
|
||||
}
|
||||
+33
@@ -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()
|
||||
}
|
||||
+39
@@ -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)
|
||||
}
|
||||
+34
@@ -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()
|
||||
}
|
||||
+70
@@ -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)
|
||||
}
|
||||
}
|
||||
+47
@@ -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>)
|
||||
}
|
||||
+52
@@ -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)
|
||||
}
|
||||
}
|
||||
+23
@@ -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()
|
||||
}
|
||||
+35
@@ -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>)
|
||||
}
|
||||
+20
@@ -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)
|
||||
}
|
||||
Vendored
+31
@@ -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,
|
||||
)
|
||||
+39
@@ -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,
|
||||
)
|
||||
+22
@@ -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(),
|
||||
)
|
||||
+26
@@ -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(),
|
||||
)
|
||||
Vendored
+26
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+34
@@ -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(),
|
||||
)
|
||||
+26
@@ -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
Reference in New Issue
Block a user