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
|
name: release
|
||||||
|
|
||||||
# Builds and pushes the minstrel container image to the Forgejo registry.
|
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||||
#
|
#
|
||||||
# push to main → :main
|
# push to main → :main and :latest
|
||||||
# push tag vX.Y.Z → :vX.Y.Z and :latest
|
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest
|
||||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||||
|
#
|
||||||
|
# Release model: per-day CalVer tags (no trailing patch digit). The day's
|
||||||
|
# tag is intentionally mutable — if a second release happens the same day,
|
||||||
|
# move the tag with `git push -f origin vYYYY.MM.DD` and the image tag of
|
||||||
|
# the same name gets overwritten. :latest is updated by every main push
|
||||||
|
# AND every tag push, so it always reflects the newest blessed image.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -20,6 +26,13 @@ on:
|
|||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Force-moving the per-day tag (or rapidly re-pushing to main) should
|
||||||
|
# supersede the in-flight build — the operator explicitly wants the
|
||||||
|
# later commit to win.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: go-ci
|
runs-on: go-ci
|
||||||
@@ -55,9 +68,13 @@ jobs:
|
|||||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::Release build: ${VERSION} + latest"
|
echo "::notice::Release build: ${VERSION} + latest"
|
||||||
else
|
else
|
||||||
echo "args=-t ${IMAGE}:main" >> "$GITHUB_OUTPUT"
|
# Main is the protected, post-PR-merge branch. Treat it as the
|
||||||
|
# rolling stable channel — every main push moves :latest.
|
||||||
|
# Pinned consumers can target :vYYYY.MM.DD; everyone else
|
||||||
|
# gets the newest main.
|
||||||
|
echo "args=-t ${IMAGE}:main -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||||
echo "version=main" >> "$GITHUB_OUTPUT"
|
echo "version=main" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::Main-branch build: :main"
|
echo "::notice::Main-branch build: :main + :latest"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Registry login
|
- name: Registry login
|
||||||
@@ -30,18 +30,13 @@ on:
|
|||||||
- 'internal/**'
|
- 'internal/**'
|
||||||
- 'cmd/**'
|
- 'cmd/**'
|
||||||
- '.golangci.yml'
|
- '.golangci.yml'
|
||||||
- '.forgejo/workflows/test-go.yml'
|
- '.gitea/workflows/test-go.yml'
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
# pull_request trigger intentionally omitted — see test-web.yml for
|
||||||
paths:
|
# the rationale (single-author repo, push covers PR-merge equivalent).
|
||||||
- '**/*.go'
|
concurrency:
|
||||||
- 'go.mod'
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
- 'go.sum'
|
cancel-in-progress: true
|
||||||
- 'sqlc.yaml'
|
|
||||||
- 'internal/**'
|
|
||||||
- 'cmd/**'
|
|
||||||
- '.golangci.yml'
|
|
||||||
- '.forgejo/workflows/test-go.yml'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
@@ -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).
|
# canonical record lives in commit messages and the running code).
|
||||||
docs/superpowers/
|
docs/superpowers/
|
||||||
|
|
||||||
# Per-machine Claude Code settings + remember-skill memory artifacts.
|
# Per-machine Claude Code settings + remember-skill memory artifacts +
|
||||||
# Both are operator-scoped; the canonical project memory lives under
|
# project-level Claude/AI instruction files. All operator-scoped; the
|
||||||
# ~/.claude/projects/ outside the repo.
|
# canonical project memory lives under ~/.claude/projects/ outside the
|
||||||
|
# repo. CLAUDE.md/AGENTS.md/GEMINI.md stay on the operator's machine
|
||||||
|
# only — they're tool-specific working notes, not project artifacts.
|
||||||
.claude/
|
.claude/
|
||||||
.remember/
|
.remember/
|
||||||
|
CLAUDE.md
|
||||||
|
AGENTS.md
|
||||||
|
GEMINI.md
|
||||||
|
.cursorrules
|
||||||
|
.windsurfrules
|
||||||
|
.aider.conf.yml
|
||||||
|
|
||||||
# Flutter
|
# Flutter
|
||||||
flutter_client/.dart_tool/
|
flutter_client/.dart_tool/
|
||||||
@@ -57,3 +65,21 @@ flutter_client/android/app/build/
|
|||||||
flutter_client/android/local.properties
|
flutter_client/android/local.properties
|
||||||
flutter_client/android/key.properties
|
flutter_client/android/key.properties
|
||||||
flutter_client/*.iml
|
flutter_client/*.iml
|
||||||
|
|
||||||
|
# Native Android (Kotlin/Compose) — M8 rewrite
|
||||||
|
android/.gradle/
|
||||||
|
android/.kotlin/
|
||||||
|
android/.idea/
|
||||||
|
android/build/
|
||||||
|
android/app/build/
|
||||||
|
android/local.properties
|
||||||
|
android/*.iml
|
||||||
|
android/app/*.iml
|
||||||
|
# Release signing material — keystore + extracted credentials live local
|
||||||
|
# only; the canonical copy is the Gitea repo secret (ANDROID_KEYSTORE_B64
|
||||||
|
# + the alias/password secrets). Losing the local file is fine; losing
|
||||||
|
# the secret means rebuilding the keystore + reinstalling all clients.
|
||||||
|
android/*.keystore
|
||||||
|
android/*.keystore.*
|
||||||
|
android/*.jks
|
||||||
|
android/keystore.properties
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ Two concurrent dev processes:
|
|||||||
truncates your dev `minstrel` data (admin user, library, likes). It
|
truncates your dev `minstrel` data (admin user, library, likes). It
|
||||||
brings up the compose Postgres and creates the test DB if missing.
|
brings up the compose Postgres and creates the test DB if missing.
|
||||||
- CI runs both: a fast `go test -short -race` gate plus an integration
|
- CI runs both: a fast `go test -short -race` gate plus an integration
|
||||||
job with its own ephemeral Postgres (`.forgejo/workflows/test-go.yml`).
|
job with its own ephemeral Postgres (`.gitea/workflows/test-go.yml`).
|
||||||
|
|
||||||
### Production build
|
### Production build
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ Two concurrent dev processes:
|
|||||||
|
|
||||||
- Day-to-day work happens on `dev` (or feature branches merged into `dev`).
|
- Day-to-day work happens on `dev` (or feature branches merged into `dev`).
|
||||||
- `main` is **protected** — changes land via PR from `dev`.
|
- `main` is **protected** — changes land via PR from `dev`.
|
||||||
- Releases are cut by tagging `v*` off `main`; the release workflow builds and pushes the container image to the Forgejo registry.
|
- Releases are cut by tagging `v*` off `main`; the release workflow builds and pushes the container image to the Gitea registry.
|
||||||
|
|
||||||
Task and milestone tracking: Fable (`Minstrel` project, id 12).
|
Task and milestone tracking: Fable (`Minstrel` project, id 12).
|
||||||
|
|
||||||
|
|||||||
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