Compare commits
20 Commits
dev
..
v2026.05.15.1
| Author | SHA1 | Date | |
|---|---|---|---|
| ec0cc37bc9 | |||
| e772938a3b | |||
| 29fee5aa37 | |||
| e5ab471ce1 | |||
| 573aa4226d | |||
| 7339815ea9 | |||
| baa601765e | |||
| 0d009b34e2 | |||
| f1b4652c77 | |||
| e610948307 | |||
| fb811804d2 | |||
| 37134950a5 | |||
| fcded9294c | |||
| 42abb7adff | |||
| 04933f2d9f | |||
| 626fc7502c | |||
| 9bf3b8a2f2 | |||
| 492460cf4a | |||
| 1c775905d7 | |||
| cd2ff648d0 |
@@ -0,0 +1,149 @@
|
||||
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:
|
||||
# flutter-ci runner image (CI-Runner/CI-flutter/Dockerfile) bakes in
|
||||
# Flutter SDK + Android cmdline-tools + platform-34 + build-tools 34.0.0
|
||||
# + JDK 17 + a non-root `runner` user. No setup-action ceremony needed.
|
||||
runs-on: flutter-ci
|
||||
|
||||
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"
|
||||
@@ -0,0 +1,101 @@
|
||||
name: release
|
||||
|
||||
# Builds and pushes the minstrel container image to the Forgejo registry.
|
||||
#
|
||||
# push to main → :main
|
||||
# push tag vX.Y.Z → :vX.Y.Z and :latest
|
||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
# Tag pushes still build (tag releases are intentional, server + Flutter
|
||||
# share the version). Branch pushes skip when the diff is Flutter-only —
|
||||
# rebuilding the Go image for a Flutter-only merge wastes CI and churns
|
||||
# the registry.
|
||||
paths-ignore:
|
||||
- 'flutter_client/**'
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: go-ci
|
||||
|
||||
env:
|
||||
IMAGE: git.fabledsword.com/bvandeusen/minstrel
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect buildable project
|
||||
id: guard
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f Dockerfile ] && [ -f go.mod ]; then
|
||||
echo "ready=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ready=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::No Dockerfile + go.mod yet — release build skipped"
|
||||
fi
|
||||
|
||||
- name: Compute image tags
|
||||
id: tags
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Release build: ${VERSION} + latest"
|
||||
else
|
||||
echo "args=-t ${IMAGE}:main" >> "$GITHUB_OUTPUT"
|
||||
echo "version=main" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Main-branch build: :main"
|
||||
fi
|
||||
|
||||
- name: Registry login
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "${{ secrets.CI_TOKEN }}" \
|
||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
# In-app update flow (#397): on tag pushes only, fetch the APK
|
||||
# that flutter.yml is attaching to this same release. flutter.yml
|
||||
# runs in parallel; poll up to 15 min for the asset to appear.
|
||||
# On main pushes (or if the APK never lands), client/ stays empty
|
||||
# and the endpoints return 404 — graceful degradation.
|
||||
- name: Fetch APK for bundled in-app update channel
|
||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
APK_URL="https://git.fabledsword.com/${REPO}/releases/download/${TAG}/minstrel-${TAG}.apk"
|
||||
echo "Polling ${APK_URL} (up to 15 min for flutter.yml to attach)..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
|
||||
-o client/minstrel.apk "$APK_URL"; then
|
||||
echo "Got APK on attempt $i"
|
||||
echo "${TAG}" > client/minstrel.apk.version
|
||||
ls -lh client/
|
||||
exit 0
|
||||
fi
|
||||
echo "APK not ready yet (attempt $i/30), sleeping 30s..."
|
||||
sleep 30
|
||||
done
|
||||
echo "::warning::APK never appeared at ${APK_URL} after 15 min — image will ship without bundled update channel"
|
||||
|
||||
- name: Build and push
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
run: |
|
||||
docker buildx build \
|
||||
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
|
||||
--push ${{ steps.tags.outputs.args }} .
|
||||
@@ -0,0 +1,56 @@
|
||||
name: test-go
|
||||
|
||||
# Go server: vet + golangci-lint + short race tests. Runs on push to
|
||||
# dev/main and PRs to main, scoped to Go-side files only — web-only or
|
||||
# Flutter-only diffs don't trigger this workflow.
|
||||
#
|
||||
# Integration tests needing Postgres/ffmpeg run locally via docker-compose;
|
||||
# they should guard with testing.Short() so this short-mode run skips them.
|
||||
#
|
||||
# `web/build/` has a committed placeholder index.html so go:embed succeeds
|
||||
# without needing the SPA to be freshly built. Real builds happen in
|
||||
# release.yml (container) and locally during dev.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
paths:
|
||||
- '**/*.go'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- 'sqlc.yaml'
|
||||
- 'internal/**'
|
||||
- 'cmd/**'
|
||||
- '.forgejo/workflows/test-go.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '**/*.go'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- 'sqlc.yaml'
|
||||
- 'internal/**'
|
||||
- 'cmd/**'
|
||||
- '.forgejo/workflows/test-go.yml'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: go-ci
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Toolchain versions
|
||||
run: |
|
||||
go version
|
||||
golangci-lint --version
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: golangci-lint
|
||||
run: golangci-lint run ./...
|
||||
|
||||
- name: go test (short, race)
|
||||
run: go test -short -race ./...
|
||||
@@ -0,0 +1,50 @@
|
||||
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
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
# `cache: 'npm'` removed — the Forgejo Actions cache server at
|
||||
# 172.18.0.27:41161 isn't reachable from this runner's container,
|
||||
# so setup-node spent 4m41s burning the npm cache restore on
|
||||
# ETIMEDOUT before failing open. npm ci still works deterministically
|
||||
# from package-lock.json; just re-fetches from the registry on each
|
||||
# run. Restore the cache option once the runner-host network reaches
|
||||
# the cache server.
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Type-check + svelte-check
|
||||
run: npm run check
|
||||
|
||||
- name: Vitest
|
||||
run: npm test
|
||||
@@ -1,89 +0,0 @@
|
||||
name: android
|
||||
|
||||
# Native Android (Kotlin/Compose/Media3) — M8 rewrite, now the only client.
|
||||
# This workflow is testing only — lint + detekt + unit tests on every push
|
||||
# to dev/main, plus a debug APK artifact for main. The signed-release
|
||||
# build + asset attach + image-bundling lives in release.yml under a
|
||||
# `needs:` chain so the docker image cannot ship without the APK.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
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'
|
||||
# Gitea Actions runs in GHES-emulation mode; @actions/artifact v2+
|
||||
# (i.e. upload-artifact@v4+) errors with "GHESNotSupportedError".
|
||||
# Pin to @v3 until act_runner or the artifact backend catches up.
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: minstrel-android-debug-${{ github.sha }}
|
||||
path: android/app/build/outputs/apk/debug/app-debug.apk
|
||||
@@ -1,304 +0,0 @@
|
||||
name: release
|
||||
|
||||
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||
#
|
||||
# push to main → :main and :latest (latest-release APK bundled)
|
||||
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (freshly-built APK bundled)
|
||||
# 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.
|
||||
#
|
||||
# APK pipeline: on tag pushes the android-release job builds + signs the
|
||||
# Android APK and uploads it as a workflow artifact. The image-release
|
||||
# job declares `needs: android-release`, so the docker image cannot
|
||||
# start building until the APK is guaranteed-ready — no polling, no
|
||||
# race, no silent-failure mode. Asset attachment to the gitea Release
|
||||
# happens in the same android-release job, so the Release-page download
|
||||
# link and the in-image bundled APK are both populated atomically.
|
||||
#
|
||||
# :latest always carries an APK. Because every main push also moves
|
||||
# :latest (not just tags), a main build with no APK would silently strip
|
||||
# the in-app update channel off :latest until the next release. So on
|
||||
# non-tag builds image-release pulls the MOST RECENT release's signed APK
|
||||
# and reconstructs its exact versionName (tag + commit-count, the same
|
||||
# formula android-release bakes in) for the version sidecar — no rebuild,
|
||||
# just rebundle. Tag builds keep bundling their own freshly-built APK.
|
||||
#
|
||||
# Android testing (lint + detekt + unit tests, debug APK upload on main)
|
||||
# lives in android.yml and runs independently on every push.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
workflow_dispatch:
|
||||
|
||||
# Force-moving the per-day tag (or rapidly re-pushing to main) should
|
||||
# supersede the in-flight build — the operator explicitly wants the
|
||||
# later commit to win.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
android-release:
|
||||
name: Build signed APK (tag releases only)
|
||||
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:
|
||||
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
|
||||
# PKCS12 keystores collapse store + key password into a single
|
||||
# value; both env vars map to one secret. build.gradle 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 }}
|
||||
|
||||
# Job outputs propagate the computed release version to image-release
|
||||
# so the bundled sidecar file matches what's baked into the APK —
|
||||
# otherwise the server would report a different version string than
|
||||
# the installed client and the update banner could thrash.
|
||||
outputs:
|
||||
version_name: ${{ steps.ver.outputs.name }}
|
||||
version_code: ${{ steps.ver.outputs.code }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# fetch-depth: 0 retrieves full history; default shallow clone
|
||||
# would return 1 for `git rev-list --count HEAD`, breaking the
|
||||
# iteration suffix.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute release version
|
||||
id: ver
|
||||
shell: bash
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/v}"
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
VERSION_NAME="${TAG}.${COMMIT_COUNT}"
|
||||
echo "name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})"
|
||||
|
||||
- name: Cache Gradle dirs
|
||||
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: Decode signing keystore
|
||||
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 \
|
||||
-PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \
|
||||
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
||||
|
||||
- name: Upload APK as workflow artifact
|
||||
# @v3 because Gitea Actions emulates GHES and the v2 artifact
|
||||
# backend used by upload-artifact@v4 errors with GHESNotSupportedError.
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: minstrel-apk
|
||||
path: android/app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
- name: Attach APK to gitea Release
|
||||
shell: bash
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
APK_PATH="app/build/outputs/apk/release/app-release.apk"
|
||||
ls -lh "${APK_PATH}"
|
||||
|
||||
RELEASE_JSON="$(curl -fsSL \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}")"
|
||||
RELEASE_ID="$(printf '%s' "${RELEASE_JSON}" | grep -oP '"id":\s*\K[0-9]+' | head -1)"
|
||||
if [ -z "${RELEASE_ID}" ]; then
|
||||
echo "::error::release for ${TAG} not found"; exit 1
|
||||
fi
|
||||
echo "release_id=${RELEASE_ID}"
|
||||
|
||||
UPLOAD_HTTP=$(curl -sS -L -o /tmp/upload.out -w '%{http_code}' \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-F "attachment=@${APK_PATH}" \
|
||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk")
|
||||
echo "upload_http=${UPLOAD_HTTP}"
|
||||
cat /tmp/upload.out || true
|
||||
echo
|
||||
if [ "${UPLOAD_HTTP}" -lt 200 ] || [ "${UPLOAD_HTTP}" -ge 300 ]; then
|
||||
echo "::error::APK upload returned HTTP ${UPLOAD_HTTP}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
image-release:
|
||||
name: Build + push container image
|
||||
# `needs:` waits for android-release. For tag pushes android-release
|
||||
# runs and must succeed before this job starts — guaranteeing the
|
||||
# APK artifact is present. For main pushes android-release is
|
||||
# skipped; the `if: ...` below lets this job run anyway and the
|
||||
# download/copy steps gate themselves on the tag context.
|
||||
needs: [android-release]
|
||||
if: ${{ !failure() && !cancelled() }}
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
|
||||
env:
|
||||
IMAGE: git.fabledsword.com/bvandeusen/minstrel
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history + tags so non-tag :latest builds can resolve the
|
||||
# latest release tag's commit count and reconstruct the bundled
|
||||
# APK's exact versionName (see "Bundle latest release APK" below).
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Detect buildable project
|
||||
id: guard
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f Dockerfile ] && [ -f go.mod ]; then
|
||||
echo "ready=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ready=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::No Dockerfile + go.mod yet — release build skipped"
|
||||
fi
|
||||
|
||||
- name: Compute image tags
|
||||
id: tags
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Release build: ${VERSION} + latest"
|
||||
else
|
||||
# Main is the protected, post-PR-merge branch. Treat it as the
|
||||
# rolling stable channel — every main push moves :latest.
|
||||
# Pinned consumers can target :vYYYY.MM.DD; everyone else
|
||||
# gets the newest main.
|
||||
echo "args=-t ${IMAGE}:main -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "version=main" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Main-branch build: :main + :latest"
|
||||
fi
|
||||
|
||||
- name: Registry login
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "${{ secrets.CI_TOKEN }}" \
|
||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Download signed APK artifact
|
||||
# Tag pushes only — android-release just produced this. Non-tag
|
||||
# builds take the "Bundle latest release APK" path below instead.
|
||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: minstrel-apk
|
||||
path: client/
|
||||
|
||||
- name: Stage bundled APK + version sidecar
|
||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
# Pulled from android-release.outputs.version_name so the
|
||||
# sidecar string the server hands clients matches the
|
||||
# versionName baked into the APK they're comparing against.
|
||||
APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
# The artifact lands as `app-release.apk` (the original Gradle
|
||||
# output name). The Dockerfile COPYs client/* into /app/client/
|
||||
# and the server reads minstrel.apk + minstrel.apk.version.
|
||||
mv client/app-release.apk client/minstrel.apk
|
||||
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
|
||||
ls -lh client/
|
||||
|
||||
- name: Bundle latest release APK (non-tag :latest builds)
|
||||
# Main pushes don't build an APK, but they DO move :latest — so
|
||||
# without this the in-app update channel would vanish from :latest
|
||||
# until the next tag. Pull the most-recent release's signed APK and
|
||||
# reconstruct its exact versionName (${TAG#v}.$(git rev-list --count
|
||||
# TAG) — identical to android-release's formula) so the version
|
||||
# sidecar the server hands clients matches the installed build.
|
||||
# Degrades to an empty client/ (404 update channel) — never a wrong
|
||||
# version — if no release / APK asset / tag-count can be resolved.
|
||||
if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
REL_JSON="$(curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
|
||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/latest" || true)"
|
||||
if [ -z "${REL_JSON}" ]; then
|
||||
echo "::notice::no published release — image ships without bundled APK"; exit 0
|
||||
fi
|
||||
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)"
|
||||
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)"
|
||||
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
|
||||
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
|
||||
fi
|
||||
COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)"
|
||||
if [ -z "${COUNT}" ]; then
|
||||
echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
|
||||
fi
|
||||
VERSION_NAME="${TAG#v}.${COUNT}"
|
||||
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
|
||||
echo "${VERSION_NAME}" > client/minstrel.apk.version
|
||||
echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}"
|
||||
ls -lh client/
|
||||
|
||||
- name: Build and push
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
run: |
|
||||
docker buildx build \
|
||||
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
|
||||
--push ${{ steps.tags.outputs.args }} .
|
||||
@@ -1,146 +0,0 @@
|
||||
name: test-go
|
||||
|
||||
# Go server: vet + golangci-lint + short race tests. Runs on push to
|
||||
# dev/main and PRs to main, scoped to Go-side files only — web-only or
|
||||
# Flutter-only diffs don't trigger this workflow.
|
||||
#
|
||||
# Two jobs: `test` (fast — vet + lint + `go test -short -race`, no DB) and
|
||||
# `integration` (full `go test -race` against an ephemeral Postgres).
|
||||
#
|
||||
# Integration-job DB wiring follows the act_runner shared-daemon pattern:
|
||||
# the runner's Docker daemon also runs the operator's dev compose stack,
|
||||
# so service containers get NO published ports (collision) and no
|
||||
# service-name DNS. We discover the service container by the job-scoped
|
||||
# name filter via the mounted docker socket and reach it by bridge IP.
|
||||
# The exactly-one assertion is a hard guard — pointing tests at the dev
|
||||
# Postgres would truncate it (the disaster Fable #339 exists to prevent).
|
||||
#
|
||||
# `web/build/` has a committed placeholder index.html so go:embed succeeds
|
||||
# without needing the SPA to be freshly built. Real builds happen in
|
||||
# release.yml (container) and locally during dev.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
paths:
|
||||
- '**/*.go'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- 'sqlc.yaml'
|
||||
- 'internal/**'
|
||||
- 'cmd/**'
|
||||
- '.golangci.yml'
|
||||
- '.gitea/workflows/test-go.yml'
|
||||
|
||||
# pull_request trigger intentionally omitted — see test-web.yml for
|
||||
# the rationale (single-author repo, push covers PR-merge equivalent).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Toolchain versions
|
||||
run: |
|
||||
go version
|
||||
golangci-lint --version
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: golangci-lint
|
||||
run: golangci-lint run ./...
|
||||
|
||||
- name: go test (short, race)
|
||||
run: go test -short -race ./...
|
||||
|
||||
integration:
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: minstrel
|
||||
POSTGRES_PASSWORD: minstrel
|
||||
POSTGRES_DB: minstrel_test
|
||||
# No `ports:` — the runner shares the operator's dev compose
|
||||
# Docker daemon; publishing a fixed host port collides.
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Integration suite (discover service by bridge IP, migrate, test)
|
||||
run: |
|
||||
set -eux
|
||||
# Discover THIS job's Postgres service container via the
|
||||
# mounted docker socket. act_runner attaches the job
|
||||
# container and its service container(s) to a shared per-job
|
||||
# network, so scope discovery to a postgres that sits on a
|
||||
# network THIS job container is also on. The old
|
||||
# `--filter name=integration` matched EVERY concurrent
|
||||
# integration run's postgres (a dev push + the main-merge run
|
||||
# overlap → 2 candidates → false "expected exactly 1" abort).
|
||||
# The operator's dev compose `minstrel-postgres-*` is never on
|
||||
# this job's network; skip it explicitly as belt-and-suspenders
|
||||
# (a wrong target would truncate real data).
|
||||
SELF=$(cat /etc/hostname)
|
||||
SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF")
|
||||
test -n "$SELF_NETS"
|
||||
echo "self ($SELF) networks: $SELF_NETS"
|
||||
PG_ID=""
|
||||
PG_NAME=""
|
||||
for cid in $(docker ps --filter "ancestor=postgres:16-alpine" -q); do
|
||||
nm=$(docker inspect -f '{{.Name}}' "$cid" | sed 's#^/##')
|
||||
case "$nm" in *minstrel-postgres*|*_postgres_*) continue ;; esac
|
||||
for net in $(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$cid"); do
|
||||
case " $SELF_NETS " in *" $net "*) PG_ID="$cid"; PG_NAME="$nm"; break 2 ;; esac
|
||||
done
|
||||
done
|
||||
test -n "$PG_ID" || { echo "FATAL: no postgres service container on this job's network (self nets: $SELF_NETS)"; exit 1; }
|
||||
echo "selected postgres: $PG_ID $PG_NAME"
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_ID")
|
||||
test -n "$PG_IP"
|
||||
export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable"
|
||||
|
||||
# Wait for Postgres to accept TCP (no health-check dependency).
|
||||
for i in $(seq 1 60); do (echo > "/dev/tcp/${PG_IP}/5432") 2>/dev/null && break; sleep 2; done
|
||||
|
||||
# Relax durability on the throwaway CI Postgres. Our test pattern
|
||||
# is dbtest.ResetDB → TRUNCATE … RESTART IDENTITY CASCADE before
|
||||
# every test, and the per-TRUNCATE commit fsync is the dominant
|
||||
# cost of the integration suite. The CI DB is rebuilt every run so
|
||||
# fsync / full_page_writes / synchronous_commit buy nothing. Apply
|
||||
# via docker exec because:
|
||||
# - The act_runner `services:` block can't override the container
|
||||
# command, so `postgres -c fsync=off` at boot isn't an option.
|
||||
# - ALTER SYSTEM cannot run inside a transaction; psql -c
|
||||
# auto-commits each statement, which is what we need.
|
||||
# - fsync / full_page_writes are sighup GUCs and
|
||||
# synchronous_commit is user-context, so pg_reload_conf() picks
|
||||
# all three up with no restart.
|
||||
# Non-fatal: a perms surprise degrades to "slower", never red CI.
|
||||
docker exec "$PG_ID" psql -U minstrel -d minstrel_test \
|
||||
-c "ALTER SYSTEM SET fsync = off" \
|
||||
-c "ALTER SYSTEM SET synchronous_commit = off" \
|
||||
-c "ALTER SYSTEM SET full_page_writes = off" \
|
||||
-c "SELECT pg_reload_conf()" \
|
||||
|| echo "WARN: durability relax failed; continuing"
|
||||
|
||||
# Apply embedded migrations to the fresh test DB, then run the
|
||||
# full suite (no -short → integration tests execute). -p 1:
|
||||
# every integration package TRUNCATEs the one shared test DB;
|
||||
# concurrent package binaries → TRUNCATE deadlocks. Serialize
|
||||
# package execution (the documented local invocation too).
|
||||
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
|
||||
go test -p 1 -race ./...
|
||||
@@ -1,44 +0,0 @@
|
||||
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
|
||||
+3
-29
@@ -38,19 +38,11 @@ go.work.sum
|
||||
# canonical record lives in commit messages and the running code).
|
||||
docs/superpowers/
|
||||
|
||||
# Per-machine Claude Code settings + remember-skill memory artifacts +
|
||||
# project-level Claude/AI instruction files. All operator-scoped; the
|
||||
# canonical project memory lives under ~/.claude/projects/ outside the
|
||||
# repo. CLAUDE.md/AGENTS.md/GEMINI.md stay on the operator's machine
|
||||
# only — they're tool-specific working notes, not project artifacts.
|
||||
# Per-machine Claude Code settings + remember-skill memory artifacts.
|
||||
# Both are operator-scoped; the canonical project memory lives under
|
||||
# ~/.claude/projects/ outside the repo.
|
||||
.claude/
|
||||
.remember/
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
GEMINI.md
|
||||
.cursorrules
|
||||
.windsurfrules
|
||||
.aider.conf.yml
|
||||
|
||||
# Flutter
|
||||
flutter_client/.dart_tool/
|
||||
@@ -65,21 +57,3 @@ flutter_client/android/app/build/
|
||||
flutter_client/android/local.properties
|
||||
flutter_client/android/key.properties
|
||||
flutter_client/*.iml
|
||||
|
||||
# Native Android (Kotlin/Compose) — M8 rewrite
|
||||
android/.gradle/
|
||||
android/.kotlin/
|
||||
android/.idea/
|
||||
android/build/
|
||||
android/app/build/
|
||||
android/local.properties
|
||||
android/*.iml
|
||||
android/app/*.iml
|
||||
# Release signing material — keystore + extracted credentials live local
|
||||
# only; the canonical copy is the Gitea repo secret (ANDROID_KEYSTORE_B64
|
||||
# + the alias/password secrets). Losing the local file is fine; losing
|
||||
# the secret means rebuilding the keystore + reinstalling all clients.
|
||||
android/*.keystore
|
||||
android/*.keystore.*
|
||||
android/*.jks
|
||||
android/keystore.properties
|
||||
|
||||
+15
-16
@@ -1,29 +1,28 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: true
|
||||
|
||||
linters:
|
||||
default: none
|
||||
disable-all: true
|
||||
enable:
|
||||
- errcheck
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
- revive
|
||||
settings:
|
||||
revive:
|
||||
# Intentionally narrow: we skip `exported` (no doc-comment requirement) per
|
||||
# the project's no-boilerplate-comment policy. Re-enable if the public API
|
||||
# surface grows to the point where documentation lives alongside it.
|
||||
rules:
|
||||
- name: var-naming
|
||||
- name: unused-parameter
|
||||
- name: early-return
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
- revive
|
||||
|
||||
linters-settings:
|
||||
revive:
|
||||
# Intentionally narrow: we skip `exported` (no doc-comment requirement) per
|
||||
# the project's no-boilerplate-comment policy. Re-enable if the public API
|
||||
# surface grows to the point where documentation lives alongside it.
|
||||
rules:
|
||||
- name: var-naming
|
||||
- name: unused-parameter
|
||||
- name: early-return
|
||||
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ RUN npm ci
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM golang:1.25-bookworm AS builder
|
||||
FROM golang:1.23-bookworm AS builder
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: generate test test-short test-integration lint build
|
||||
.PHONY: generate test test-short lint build
|
||||
|
||||
SQLC_VERSION := 1.31.1
|
||||
|
||||
@@ -11,16 +11,6 @@ test:
|
||||
test-short:
|
||||
go test -short -race ./...
|
||||
|
||||
# Full suite incl. integration tests, against the dedicated minstrel_test
|
||||
# DB so a run never truncates the dev `minstrel` DB (Fable #339). Ensures
|
||||
# the test DB exists (idempotent — createdb errors if present, ignored).
|
||||
test-integration:
|
||||
docker compose up -d postgres
|
||||
-docker compose exec -T postgres createdb -U minstrel minstrel_test
|
||||
# -p 1: integration packages share one test DB and each TRUNCATEs it;
|
||||
# concurrent package binaries deadlock on TRUNCATE. Serialize packages.
|
||||
MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable go test -p 1 -race ./...
|
||||
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
||||
|
||||
> State and intelligence belong on the server, not the client.
|
||||
|
||||
<a href="docs/screenshots/home.png"><img src="docs/screenshots/home.png" width="820" alt="Minstrel home — your library at a glance"></a>
|
||||
<!-- TODO: screenshot of the home page -->
|
||||
|
||||
## Highlights
|
||||
|
||||
@@ -13,7 +13,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
||||
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
|
||||
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit.
|
||||
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
|
||||
- **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
|
||||
- **Flutter mobile client in flight.** Tracking issue [#356](https://git.fabledsword.com/bvandeusen/minstrel/issues/356).
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -24,18 +24,10 @@ services:
|
||||
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
||||
ports: ['4533:4533']
|
||||
volumes:
|
||||
# Your music library. Point ./music at wherever your audio files
|
||||
# live. Mounted read-only — Minstrel never writes to your library.
|
||||
- ./music:/music:ro
|
||||
# Generated data: playlist cover collages, artist art, caches.
|
||||
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image
|
||||
# sets to /app/data — keep this mount on /app/data or your cache
|
||||
# won't survive a container recreate.
|
||||
- minstrel-data:/app/data
|
||||
- minstrel-data:/data
|
||||
environment:
|
||||
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
|
||||
# Colon-separated library roots to scan; must match the container
|
||||
# path of the read-only music mount above (/music here).
|
||||
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
||||
depends_on: [db]
|
||||
|
||||
@@ -45,8 +37,6 @@ services:
|
||||
POSTGRES_USER: minstrel
|
||||
POSTGRES_PASSWORD: minstrel
|
||||
POSTGRES_DB: minstrel
|
||||
# Postgres data dir — users, likes, play history, sessions, settings.
|
||||
# The one volume you must never lose; back it up with pg_dump.
|
||||
volumes: [pgdata:/var/lib/postgresql/data]
|
||||
|
||||
volumes:
|
||||
@@ -58,29 +48,7 @@ volumes:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## First run
|
||||
|
||||
With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address (plain `http://` is fine — no TLS required).
|
||||
|
||||
**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance is automatically the administrator; later users join through the same form or an invite token (step 5).
|
||||
|
||||
<a href="docs/screenshots/register.png"><img src="docs/screenshots/register.png" width="320" alt="Creating the first (admin) account on a fresh instance"></a>
|
||||
|
||||
**2. Let the first library scan finish.** `scan_on_startup` is on by default, so Minstrel walks your mounted library on boot and imports artists, albums, and tracks — no button to press. Watch progress (and re-scan any time) on the **Admin** page (`/admin`); the scan runs in stages and is incremental, so later restarts only pick up what changed.
|
||||
|
||||
<a href="docs/screenshots/library-scan.png"><img src="docs/screenshots/library-scan.png" width="820" alt="The Admin page, where the library scan runs and reports progress"></a>
|
||||
|
||||
**3. (Optional) Name the instance and wire up integrations.** In admin **Settings → Integrations** (`/admin/integrations`), add a ListenBrainz token (scrobbling + similarity radio) and/or a Lidarr URL + API key (the request flow). These live in the UI and apply without a restart; the display name can also be set via `MINSTREL_BRANDING_APP_NAME`.
|
||||
|
||||
<a href="docs/screenshots/integrations.png"><img src="docs/screenshots/integrations.png" width="820" alt="ListenBrainz and Lidarr integration cards in admin Settings"></a>
|
||||
|
||||
**4. Install the Android app.** Open **Settings** (`/settings`) and use the *Install the Android app* card to download the APK that ships inside this server image, then sign in with the same account. From then on the app self-updates straight from your server.
|
||||
|
||||
<a href="docs/screenshots/android-download.png"><img src="docs/screenshots/android-download.png" width="820" alt="The "Install the Android app" download card in Settings"></a>
|
||||
|
||||
**5. Invite the rest of the household.** From admin **Users** (`/admin/users`), generate an invite token (or enable open registration). Each person gets their own account, so likes, play history, and recommendations stay per-user.
|
||||
|
||||
<a href="docs/screenshots/invite-users.png"><img src="docs/screenshots/invite-users.png" width="820" alt="Generating an invite token in admin Users"></a>
|
||||
After the stack is up, visit `http://localhost:4533/register` and create your admin account. The first user to register on a fresh instance is automatically marked as the administrator; subsequent users can register through the same form (or via invite tokens generated from the admin Users panel, depending on how you configure registration).
|
||||
|
||||
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
|
||||
|
||||
@@ -89,7 +57,7 @@ For the full configuration surface, see [`config.example.yaml`](./config.example
|
||||
Most operators only need the env vars in the quickstart above. A few extras worth knowing:
|
||||
|
||||
- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
|
||||
- `MINSTREL_STORAGE_DATA_DIR` — where generated artefacts (playlist cover collages, artist art, caches) are written. The container image sets this to `/app/data`, which is why the quickstart mounts the `minstrel-data` volume there.
|
||||
- `MINSTREL_STORAGE_DATA_DIR` — defaults to `./data`. Holds playlist cover collages and other generated artefacts.
|
||||
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
|
||||
|
||||
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
|
||||
@@ -98,13 +66,8 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
|
||||
|
||||
## Updating
|
||||
|
||||
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
||||
|
||||
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
|
||||
- `:vYYYY.MM.DD` — immutable per-day release tags. Pin one of these for a deployment you don't want moving under you. (Per-day CalVer — no trailing patch digit; a same-day re-cut moves the tag forward.)
|
||||
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
|
||||
|
||||
Every `:latest` and every `:vYYYY.MM.DD` bundles the current signed Android APK, so the in-app update channel is always live. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||
- `:main` — rolling, follows the dev branch's tested tip. Recommended only for the operator who's running an upstream-watching deployment.
|
||||
- `:v1.0.x` — pinned releases. Recommended default. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||
|
||||
## Specs
|
||||
|
||||
@@ -120,16 +83,6 @@ Two concurrent dev processes:
|
||||
1. **Backend:** `docker compose up` — Postgres + Minstrel on `:4533`.
|
||||
2. **Frontend:** `cd web && npm install && npm run dev` — Vite dev server on `:5173` with HMR. The Vite server proxies `/api/*` and `/rest/*` to `:4533` so session cookies work.
|
||||
|
||||
### Testing
|
||||
|
||||
- Unit + race (no DB): `make test-short`.
|
||||
- Full suite incl. integration tests: `make test-integration`. This runs
|
||||
against a dedicated `minstrel_test` database so a test run never
|
||||
truncates your dev `minstrel` data (admin user, library, likes). It
|
||||
brings up the compose Postgres and creates the test DB if missing.
|
||||
- CI runs both: a fast `go test -short -race` gate plus an integration
|
||||
job with its own ephemeral Postgres (`.gitea/workflows/test-go.yml`).
|
||||
|
||||
### Production build
|
||||
|
||||
`docker build -t minstrel .` runs the SvelteKit build inside a `node` stage, copies the output into the `golang` stage, and `//go:embed`s it into the final binary. The container serves the SPA from `/` alongside the API surfaces; no separate static-file server is required.
|
||||
@@ -138,7 +91,7 @@ Two concurrent dev processes:
|
||||
|
||||
- Day-to-day work happens on `dev` (or feature branches merged into `dev`).
|
||||
- `main` is **protected** — changes land via PR from `dev`.
|
||||
- Releases are cut by tagging `v*` off `main`; the release workflow builds and pushes the container image to the Gitea registry.
|
||||
- Releases are cut by tagging `v*` off `main`; the release workflow builds and pushes the container image to the Forgejo registry.
|
||||
|
||||
Task and milestone tracking: Fable (`Minstrel` project, id 12).
|
||||
|
||||
|
||||
Generated
-3
@@ -1,3 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
Generated
-1
@@ -1 +0,0 @@
|
||||
Minstrel
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?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
-1857
File diff suppressed because it is too large
Load Diff
Generated
-123
@@ -1,123 +0,0 @@
|
||||
<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
@@ -1,5 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
<?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
@@ -1,13 +0,0 @@
|
||||
<?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
@@ -1,19 +0,0 @@
|
||||
<?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
@@ -1,10 +0,0 @@
|
||||
<?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
-9
@@ -1,9 +0,0 @@
|
||||
<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
@@ -1,17 +0,0 @@
|
||||
<?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
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,213 +0,0 @@
|
||||
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
|
||||
// versionName / versionCode are released-build values injected by
|
||||
// CI from the git tag + commit count. Local / debug builds fall
|
||||
// back to "dev" so the About card reads honestly. Releases ship
|
||||
// versionName="YYYY.MM.DD.<commits>" (e.g. "2026.06.02.142") and
|
||||
// versionCode=<commits>, which is monotonic forever and lets the
|
||||
// shared isVersionNewer comparator distinguish two same-day
|
||||
// re-cuts (the iteration suffix differs).
|
||||
val versionNameOverride =
|
||||
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
|
||||
val versionCodeOverride =
|
||||
(project.findProperty("MINSTREL_VERSION_CODE") as String?)?.toIntOrNull()
|
||||
versionCode = versionCodeOverride ?: 1
|
||||
versionName = versionNameOverride ?: "dev"
|
||||
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.mediarouter)
|
||||
|
||||
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)
|
||||
// kxml2 — provides an org.xmlpull.v1 impl on the JVM unit-test
|
||||
// classpath. Android's stock XmlPullParserFactory resolves to the
|
||||
// android.jar Stub on JVM tests; kxml2 is picked up via service-
|
||||
// provider lookup and makes XmlPullParserFactory.newInstance() work
|
||||
// unconditionally so DeviceDescriptionTest runs in CI.
|
||||
testImplementation(libs.kxml2)
|
||||
// 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
@@ -1,19 +0,0 @@
|
||||
# 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.** { *; }
|
||||
@@ -1,645 +0,0 @@
|
||||
{
|
||||
"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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,650 +0,0 @@
|
||||
{
|
||||
"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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,660 +0,0 @@
|
||||
{
|
||||
"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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,690 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 6,
|
||||
"identityHash": "fb73ed8674efb1d82a586551baba5ef0",
|
||||
"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": "cached_history_snapshot",
|
||||
"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": "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, 'fb73ed8674efb1d82a586551baba5ef0')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
<?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" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
|
||||
<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">
|
||||
|
||||
<!-- Portrait-locked until a tablet/landscape layout exists.
|
||||
Current Compose screens are sized for phone-portrait;
|
||||
landscape just stretches the column awkwardly. Revisit
|
||||
this when a dedicated tablet layout lands. -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait"
|
||||
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>
|
||||
|
||||
<!-- On-demand WorkManager initialization: MinstrelApplication
|
||||
implements Configuration.Provider and supplies the
|
||||
HiltWorkerFactory. Remove the default startup-driven
|
||||
initializer so WorkManager picks up our config instead of
|
||||
auto-initializing with the wrong factory. lintVitalRelease
|
||||
flags this as a fatal error if left in place. -->
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
tools:node="merge">
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,153 +0,0 @@
|
||||
package com.fabledsword.minstrel
|
||||
|
||||
import android.content.Intent
|
||||
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.LaunchedEffect
|
||||
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.connectivity.LocalServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||
import com.fabledsword.minstrel.nav.NowPlaying
|
||||
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 kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
@Inject lateinit var seedCache: DetailSeedCache
|
||||
@Inject lateinit var cachedTrackIds: CachedTrackIds
|
||||
@Inject lateinit var serverHealth: NetworkStatusController
|
||||
|
||||
// Flipped to true when the user taps the media notification (or
|
||||
// any other entry point that asks for the full player). The App
|
||||
// composable observes this, navigates to NowPlaying once the
|
||||
// NavHost is ready, then calls back to reset the flag so the
|
||||
// navigation doesn't re-fire on the next recomposition.
|
||||
private val pendingOpenNowPlaying = MutableStateFlow(false)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
consumeOpenNowPlayingIntent(intent)
|
||||
setContent {
|
||||
App(
|
||||
seedCache = seedCache,
|
||||
cachedTrackIds = cachedTrackIds,
|
||||
serverHealth = serverHealth,
|
||||
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
|
||||
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
consumeOpenNowPlayingIntent(intent)
|
||||
}
|
||||
|
||||
private fun consumeOpenNowPlayingIntent(intent: Intent?) {
|
||||
if (intent?.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false) == true) {
|
||||
pendingOpenNowPlaying.value = true
|
||||
// Strip the extra so a subsequent config-change recreation
|
||||
// doesn't re-trigger the navigation.
|
||||
intent.removeExtra(EXTRA_OPEN_NOW_PLAYING)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** PendingIntent extra set by [com.fabledsword.minstrel.player.MinstrelPlayerService]
|
||||
* so a media-notification tap lands on the full NowPlaying screen
|
||||
* instead of whatever shell route MainActivity last rendered. */
|
||||
const val EXTRA_OPEN_NOW_PLAYING = "com.fabledsword.minstrel.action.OPEN_NOW_PLAYING"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun App(
|
||||
seedCache: DetailSeedCache,
|
||||
cachedTrackIds: CachedTrackIds,
|
||||
serverHealth: NetworkStatusController,
|
||||
pendingOpenNowPlaying: StateFlow<Boolean>,
|
||||
onOpenedNowPlaying: () -> Unit,
|
||||
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||
gate: AuthGateViewModel = hiltViewModel(),
|
||||
) {
|
||||
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
|
||||
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
|
||||
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
|
||||
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
|
||||
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
|
||||
CompositionLocalProvider(
|
||||
LocalDetailSeedCache provides seedCache,
|
||||
LocalCachedTrackIds provides cached,
|
||||
LocalServerHealth provides health,
|
||||
) {
|
||||
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()
|
||||
// Honour a pending notification-tap once the NavHost is
|
||||
// mounted. launchSingleTop avoids stacking copies of
|
||||
// NowPlaying if the user taps the notification while
|
||||
// already on it; the callback clears the flag so a later
|
||||
// recomposition (config change, theme switch) doesn't
|
||||
// re-navigate.
|
||||
LaunchedEffect(pending, navController) {
|
||||
if (pending) {
|
||||
navController.navigate(NowPlaying) { launchSingleTop = true }
|
||||
onOpenedNowPlaying()
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
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.diagnostics.DiagnosticsReporter
|
||||
import com.fabledsword.minstrel.diagnostics.DiagnosticsUploader
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
||||
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
||||
import com.fabledsword.minstrel.player.AudioPrefetcher
|
||||
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.connectivity.NetworkStatusController
|
||||
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 — AudioPrefetcher's init
|
||||
* block subscribes to PlayerController.uiState +
|
||||
* AuthStore.cacheSettings and pins the next-N tracks into
|
||||
* SimpleCache via CacheWriter. Without this @Inject the
|
||||
* prefetchWindow setting would be inert and skips would re-fetch.
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — NetworkStatusController owns the
|
||||
* /healthz poll loop + the device-link collector + the reachability state
|
||||
* machine, and is the single authority on the tri-state ServerHealth
|
||||
* signal (plus the VersionTooOld byproduct). It must exist from launch so
|
||||
* the poll loop runs and the StateFlow stays warm for every consumer.
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
// Device diagnostics (M9). The reporter gates itself on the account's
|
||||
// debug flag; the uploader drains its buffer on a tick / recovery.
|
||||
@Suppress("unused") @Inject lateinit var diagnosticsReporter: DiagnosticsReporter
|
||||
@Suppress("unused") @Inject lateinit var diagnosticsUploader: DiagnosticsUploader
|
||||
|
||||
@Inject @ApplicationScope lateinit var appScope: CoroutineScope
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Debug builds get the full DebugTree (verbose). Release builds
|
||||
// get a WARN+ tree so operator-driven diagnosis via `adb logcat`
|
||||
// still surfaces UPnP / cast failures, OkHttp errors, and our
|
||||
// own Timber.w / Timber.e calls — without the chatty DEBUG /
|
||||
// INFO traffic flooding the buffer in production.
|
||||
if (BuildConfig.DEBUG) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
} else {
|
||||
Timber.plant(ReleaseTree())
|
||||
}
|
||||
appScope.launch { resumeController.restore() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Release-build Timber tree: emits at WARN and above only.
|
||||
* `android.util.Log` with the canonical tag so `adb logcat` shows
|
||||
* the line under the standard tag column without falling through
|
||||
* to the package-stack-trace tag DebugTree produces.
|
||||
*/
|
||||
private class ReleaseTree : Timber.Tree() {
|
||||
override fun isLoggable(tag: String?, priority: Int): Boolean =
|
||||
priority >= android.util.Log.WARN
|
||||
|
||||
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
|
||||
val resolvedTag = tag ?: "Minstrel"
|
||||
if (t == null) {
|
||||
android.util.Log.println(priority, resolvedTag, message)
|
||||
} else {
|
||||
android.util.Log.println(
|
||||
priority,
|
||||
resolvedTag,
|
||||
message + '\n' + android.util.Log.getStackTraceString(t),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
@@ -1,38 +0,0 @@
|
||||
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
@@ -1,57 +0,0 @@
|
||||
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
@@ -1,51 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,46 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,96 +0,0 @@
|
||||
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.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
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,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : 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()
|
||||
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||
// when server health returns instead of waiting for a manual pull.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
if (internal.value.message != null) 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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
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.ErrorRetry
|
||||
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 -> ErrorRetry(
|
||||
title = "Couldn't load queue",
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
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") }
|
||||
}
|
||||
}
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
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.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
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,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : 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() }
|
||||
}
|
||||
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||
// when server health returns instead of waiting for a manual pull.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
if (internal.value is AdminQuarantineUiState.Error) 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
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.ErrorRetry
|
||||
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 -> ErrorRetry(
|
||||
title = "Couldn't load requests",
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
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
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
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.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
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,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : 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() }
|
||||
}
|
||||
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||
// when server health returns instead of waiting for a manual pull.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
if (internal.value is AdminRequestsUiState.Error) 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,539 +0,0 @@
|
||||
@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"
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
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.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
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,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
|
||||
val uiState: StateFlow<AdminUsersUiState> = internal.asStateFlow()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||
// when server health returns instead of waiting for a manual pull.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
if (internal.value is AdminUsersUiState.Error) 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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 Minstrel-server
|
||||
* requests, 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.
|
||||
*
|
||||
* Scoped to the [BaseUrlInterceptor.PLACEHOLDER_HOST] sentinel host
|
||||
* the same way BaseUrlInterceptor is. Drift #568 / #569 caught two
|
||||
* leaks here: (1) the session cookie was being attached to every
|
||||
* external request the shared OkHttpClient services — including
|
||||
* Coil image fetches to artwork.musicbrainz.org / coverartarchive.org
|
||||
* / Lidarr's /MediaCover endpoints — exposing the session
|
||||
* identifier to third-party logging; (2) a 401 from any of those
|
||||
* external hosts silently wiped the user's Minstrel session.
|
||||
* Restricting both attach + clear to placeholder-host requests
|
||||
* closes both gaps.
|
||||
*/
|
||||
@Singleton
|
||||
class AuthCookieInterceptor @Inject constructor(
|
||||
private val authStore: AuthStore,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val original = chain.request()
|
||||
if (original.url.host != BaseUrlInterceptor.PLACEHOLDER_HOST) {
|
||||
// External request — no Minstrel session cookie attached,
|
||||
// and a 401 from this host does NOT clear the user's
|
||||
// session. Pass through untouched.
|
||||
return chain.proceed(original)
|
||||
}
|
||||
val cookie = authStore.sessionCookie.value
|
||||
val request = original.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
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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 Minstrel-server requests' 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 Minstrel-bound request gets
|
||||
* retargeted at the live AuthStore value. Lets the user change
|
||||
* server URL in Settings without an app relaunch (Phase 11 wiring).
|
||||
*
|
||||
* Only requests whose host is the [PLACEHOLDER_HOST] sentinel are
|
||||
* rewritten. Absolute external URLs — Lidarr-surfaced artwork from
|
||||
* artwork.musicbrainz.org / coverartarchive.org, for example — must
|
||||
* reach their authored host unchanged; rewriting them to the
|
||||
* Minstrel host produced 404s the user saw as missing Discover
|
||||
* suggestion covers.
|
||||
*
|
||||
* 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()
|
||||
// Early-bail keeps the no-op path for external URLs cheap
|
||||
// (no AuthStore read, no URL parse).
|
||||
if (original.url.host != PLACEHOLDER_HOST) return chain.proceed(original)
|
||||
// Unparseable baseUrl folds into the same proceed path — keeps
|
||||
// detekt's ReturnCount happy by avoiding a second early return.
|
||||
val rewritten: HttpUrl = authStore.baseUrl.value.toHttpUrlOrNull()?.let { baseUrl ->
|
||||
original.url.newBuilder()
|
||||
.scheme(baseUrl.scheme)
|
||||
.host(baseUrl.host)
|
||||
.port(baseUrl.port)
|
||||
.build()
|
||||
} ?: original.url
|
||||
return chain.proceed(original.newBuilder().url(rewritten).build())
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Sentinel host used by Retrofit + every code path that builds
|
||||
* a Minstrel-server URL via the `http://placeholder.invalid/...`
|
||||
* form (see [com.fabledsword.minstrel.shared.resolveServerUrl],
|
||||
* CoverUrls.kt, AlbumRef/TrackRef cover getters). */
|
||||
const val PLACEHOLDER_HOST = "placeholder.invalid"
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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.",
|
||||
)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package com.fabledsword.minstrel.api
|
||||
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.connectivity.ReachabilityReportingInterceptor
|
||||
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,
|
||||
reachability: ReachabilityReportingInterceptor,
|
||||
logging: HttpLoggingInterceptor,
|
||||
): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
// ReachabilityReportingInterceptor MUST run first: it identifies
|
||||
// Minstrel-bound requests by the still-unrewritten PLACEHOLDER_HOST
|
||||
// (so external artwork fetches don't read as server reachability)
|
||||
// and observes the final transport outcome by wrapping the chain.
|
||||
.addInterceptor(reachability)
|
||||
// AuthCookieInterceptor MUST run before BaseUrlInterceptor.
|
||||
// Both scope on `host == PLACEHOLDER_HOST` to distinguish
|
||||
// Minstrel-server requests from external image fetches
|
||||
// (drift #568 / #569). If BaseUrlInterceptor runs first it
|
||||
// rewrites the host to the real server before auth sees the
|
||||
// request, auth's placeholder check fails, and the session
|
||||
// cookie is neither attached on outgoing requests nor
|
||||
// captured from Set-Cookie on login — fresh installs get
|
||||
// stuck at the Welcome screen. Auth first means it sees
|
||||
// placeholder.invalid, attaches/captures correctly, then
|
||||
// BaseUrlInterceptor retargets to the live AuthStore host
|
||||
// for transport.
|
||||
.addInterceptor(auth)
|
||||
.addInterceptor(baseUrl)
|
||||
.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
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
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)
|
||||
@@ -1,36 +0,0 @@
|
||||
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
@@ -1,29 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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)
|
||||
@@ -1,22 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package com.fabledsword.minstrel.api.endpoints
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Retrofit interface for the cast-token endpoint. Used by the UPnP
|
||||
* selection path in `OutputPickerController` to obtain a signed stream
|
||||
* URL that a network speaker can fetch without the user's session
|
||||
* cookie — those devices cannot carry the session, so the signed
|
||||
* query string is the only way they can fetch the bytes.
|
||||
*
|
||||
* Endpoint: `POST /api/cast/stream-token`
|
||||
* Auth: standard session cookie (`AuthCookieInterceptor` handles it).
|
||||
*
|
||||
* Server contract lives at `internal/api/cast_token.go`
|
||||
* (commit e774097f). Field names here mirror the server's `json:`
|
||||
* tags verbatim — `trackId` / `expSeconds` — so no `@SerialName` is
|
||||
* needed on the request, and `token` / `exp` / `url` map straight
|
||||
* through on the response.
|
||||
*/
|
||||
interface CastApi {
|
||||
@POST("api/cast/stream-token")
|
||||
suspend fun streamToken(@Body req: StreamTokenRequest): StreamTokenResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body. [expSeconds] is clamped server-side to [60, 86400];
|
||||
* the 21_600 default (6h) is long enough to play through any typical
|
||||
* track without re-minting mid-playback.
|
||||
*/
|
||||
@Serializable
|
||||
data class StreamTokenRequest(
|
||||
val trackId: String,
|
||||
val expSeconds: Int = 21_600,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response body. [url] is a fully-formed stream URL with [token] and
|
||||
* [exp] already embedded as query params — callers pass it verbatim
|
||||
* to `AVTransport.SetAVTransportURI`. [mime] + [title] are the bits
|
||||
* the client needs to build DIDL-Lite metadata for that call: Sonos
|
||||
* rejects empty DIDL with vendor error 1023, so the server hands back
|
||||
* the track's MIME (from `tracks.file_format`) and title so the
|
||||
* client can populate `<res protocolInfo>` and `<dc:title>` without
|
||||
* a follow-up round trip.
|
||||
*/
|
||||
@Serializable
|
||||
data class StreamTokenResponse(
|
||||
val token: String,
|
||||
val exp: Long,
|
||||
val url: String,
|
||||
val mime: String = "audio/mpeg",
|
||||
val title: String = "",
|
||||
)
|
||||
@@ -1,36 +0,0 @@
|
||||
package com.fabledsword.minstrel.api.endpoints
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Retrofit interface for device diagnostics ingest (M9). One call
|
||||
* uploads a batch of buffered events. The server stores them only when
|
||||
* the account's debug_mode_enabled flag is on (it returns 204 otherwise,
|
||||
* which the uploader treats as success so it can drop the batch).
|
||||
*/
|
||||
interface DiagnosticsApi {
|
||||
@POST("api/diagnostics")
|
||||
suspend fun report(@Body body: DiagnosticsReportRequest)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class DiagnosticsReportRequest(
|
||||
@SerialName("client_id") val clientId: String,
|
||||
@SerialName("app_version") val appVersion: String? = null,
|
||||
@SerialName("os_version") val osVersion: String? = null,
|
||||
val events: List<DiagnosticEventWire>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DiagnosticEventWire(
|
||||
val kind: String,
|
||||
// Device-clock epoch milliseconds. The server stamps its own
|
||||
// received_at; both are stored so a skewed device clock is visible.
|
||||
@SerialName("occurred_at") val occurredAt: Long,
|
||||
// Opaque structured payload carrying the event sub-type + fields.
|
||||
val payload: JsonElement,
|
||||
)
|
||||
@@ -1,33 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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.ArtistWire
|
||||
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/artists/{id}/similar")
|
||||
suspend fun getSimilarArtists(
|
||||
@Path("id") id: String,
|
||||
@Query("limit") limit: Int = SIMILAR_ARTISTS_LIMIT,
|
||||
): List<ArtistWire>
|
||||
|
||||
@GET("api/artists/{id}/top-tracks")
|
||||
suspend fun getArtistTopTracks(
|
||||
@Path("id") id: String,
|
||||
@Query("limit") limit: Int = TOP_TRACKS_LIMIT,
|
||||
): 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>
|
||||
|
||||
private companion object {
|
||||
const val SIMILAR_ARTISTS_LIMIT = 12
|
||||
const val TOP_TRACKS_LIMIT = 5
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,33 +0,0 @@
|
||||
package com.fabledsword.minstrel.api.endpoints
|
||||
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Retrofit interface for `POST /api/playback-errors`. Reports a
|
||||
* client-detected playback failure (zero-duration, decode error, ...)
|
||||
* so the admin inbox surfaces it.
|
||||
*
|
||||
* Failures during dispatch are reported via the MutationQueue path
|
||||
* for offline replay (see [com.fabledsword.minstrel.cache.mutations.MutationQueue.enqueuePlaybackErrorReport]).
|
||||
*/
|
||||
interface PlaybackErrorsApi {
|
||||
@POST("api/playback-errors")
|
||||
suspend fun report(@Body body: PlaybackErrorReportRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST body for `/api/playback-errors`. `kind` is one of
|
||||
* "zero_duration" / "load_failed" / "stalled"; server validates against
|
||||
* the CHECK constraint enum. `detail` is optional free-text (Media3
|
||||
* error message, etc.). `clientId` reuses the existing playback
|
||||
* client identifier the device already sends with `/api/plays/...`
|
||||
* so support can correlate reports across surfaces.
|
||||
*/
|
||||
@kotlinx.serialization.Serializable
|
||||
data class PlaybackErrorReportRequest(
|
||||
@kotlinx.serialization.SerialName("track_id") val trackId: String,
|
||||
val kind: String,
|
||||
val detail: String? = null,
|
||||
@kotlinx.serialization.SerialName("client_id") val clientId: String,
|
||||
)
|
||||
@@ -1,80 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,39 +0,0 @@
|
||||
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 = "",
|
||||
)
|
||||
@@ -1,19 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package com.fabledsword.minstrel.auth
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.AuthApi
|
||||
import com.fabledsword.minstrel.api.endpoints.MeApi
|
||||
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 meApi: MeApi = 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) }
|
||||
}
|
||||
}
|
||||
// Refresh from /api/me on startup so a remotely-changed account
|
||||
// flag (e.g. admin enabling debug mode, M9) reaches the device
|
||||
// without a re-login. Best-effort; failures keep the cached value.
|
||||
scope.launch { refreshProfile() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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))
|
||||
// Pull the fuller /me shape (carries debug_mode_enabled) right
|
||||
// after login so the diagnostics gate is correct without waiting
|
||||
// for the next startup refresh.
|
||||
scope.launch { refreshProfile() }
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch the caller's profile from /api/me and update currentUser
|
||||
* + persisted userJson. Carries account-level flags (debug mode)
|
||||
* that aren't in the login response. Best-effort: a network failure
|
||||
* leaves the cached identity untouched. No-op when signed out.
|
||||
*/
|
||||
suspend fun refreshProfile() {
|
||||
if (!isSignedIn) return
|
||||
val p = runCatching { meApi.getProfile() }.getOrNull() ?: return
|
||||
val user = UserRef(
|
||||
id = p.id,
|
||||
username = p.username,
|
||||
isAdmin = p.isAdmin,
|
||||
debugModeEnabled = p.debugModeEnabled,
|
||||
)
|
||||
currentUserState.value = user
|
||||
authStore.setUserJson(json.encodeToString(UserRef.serializer(), 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()
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
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 diagnosticsOptOutState = MutableStateFlow(false)
|
||||
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.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)
|
||||
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
|
||||
fun setDiagnosticsOptOut(value: Boolean) {
|
||||
diagnosticsOptOutState.value = value
|
||||
scope.launch { persistDiagnosticsOptOut(value) }
|
||||
}
|
||||
|
||||
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 suspend fun persistDiagnosticsOptOut(value: Boolean) {
|
||||
if (dao.get() == null) {
|
||||
dao.upsert(currentEntity().copy(diagnosticsOptOut = value))
|
||||
} else {
|
||||
dao.setDiagnosticsOptOut(value)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
diagnosticsOptOut = diagnosticsOptOutState.value,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_BASE_URL: String = "http://localhost:8080"
|
||||
private const val ROW_ID = 0
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
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") }
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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())
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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
@@ -1,23 +0,0 @@
|
||||
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
@@ -1,37 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
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.DiagnosticEventDao
|
||||
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.DiagnosticEventEntity
|
||||
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,
|
||||
DiagnosticEventEntity::class,
|
||||
],
|
||||
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
|
||||
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch.
|
||||
version = 7,
|
||||
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
|
||||
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
||||
}
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
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.DiagnosticEventDao
|
||||
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()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
|
||||
db.diagnosticEventDao()
|
||||
|
||||
private const val DATABASE_NAME = "minstrel.db"
|
||||
}
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
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
@@ -1,68 +0,0 @@
|
||||
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()
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
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?)
|
||||
|
||||
/** Partial update: change only the per-device diagnostics opt-out. */
|
||||
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
|
||||
suspend fun setDiagnosticsOptOut(optOut: Boolean)
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
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 * FROM cached_albums " +
|
||||
"WHERE title LIKE '%' || :q || '%' COLLATE NOCASE " +
|
||||
"ORDER BY sortTitle COLLATE NOCASE ASC " +
|
||||
"LIMIT :limit",
|
||||
)
|
||||
suspend fun searchByTitle(q: String, limit: Int): List<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>)
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
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 * FROM cached_artists " +
|
||||
"WHERE name LIKE '%' || :q || '%' COLLATE NOCASE " +
|
||||
"ORDER BY sortName COLLATE NOCASE ASC " +
|
||||
"LIMIT :limit",
|
||||
)
|
||||
suspend fun searchByName(q: String, limit: Int): List<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
@@ -1,18 +0,0 @@
|
||||
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
@@ -1,33 +0,0 @@
|
||||
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()
|
||||
}
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
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.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)
|
||||
|
||||
@Query("DELETE FROM cached_likes WHERE userId = :userId")
|
||||
suspend fun clearForUser(userId: String)
|
||||
|
||||
/**
|
||||
* Atomically replaces the user's entire cached_likes set with
|
||||
* [rows]. Used by [com.fabledsword.minstrel.likes.data.LikesRepository.refreshIds]
|
||||
* so cross-device unlikes (a row that the server no longer
|
||||
* surfaces) get removed from the local cache — drift #570
|
||||
* caught the missing delete pass that left stale Liked tab
|
||||
* entries pointing at tracks the user had unliked elsewhere.
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun replaceAllForUser(userId: String, rows: List<CachedLikeEntity>) {
|
||||
clearForUser(userId)
|
||||
upsertAll(rows)
|
||||
}
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
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()
|
||||
}
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
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?
|
||||
|
||||
/**
|
||||
* Per-playlist count of member tracks resident in the audio cache index.
|
||||
* LEFT JOINs so playlists with zero cached tracks still appear
|
||||
* (cachedCount = 0). Drives the offline "fully cached" greying.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT p.id AS playlistId, COUNT(a.trackId) AS cachedCount " +
|
||||
"FROM cached_playlists p " +
|
||||
"LEFT JOIN cached_playlist_tracks t ON t.playlistId = p.id " +
|
||||
"LEFT JOIN audio_cache_index a ON a.trackId = t.trackId " +
|
||||
"GROUP BY p.id",
|
||||
)
|
||||
fun observeCachedCounts(): Flow<List<PlaylistCachedCount>>
|
||||
|
||||
@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
@@ -1,47 +0,0 @@
|
||||
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
@@ -1,52 +0,0 @@
|
||||
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
@@ -1,23 +0,0 @@
|
||||
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()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user