Compare commits
11 Commits
dev
..
v2026.05.11.2
| Author | SHA1 | Date | |
|---|---|---|---|
| e610948307 | |||
| fb811804d2 | |||
| 37134950a5 | |||
| fcded9294c | |||
| 42abb7adff | |||
| 04933f2d9f | |||
| 626fc7502c | |||
| 9bf3b8a2f2 | |||
| 492460cf4a | |||
| 1c775905d7 | |||
| cd2ff648d0 |
@@ -0,0 +1,143 @@
|
|||||||
|
name: flutter
|
||||||
|
|
||||||
|
# Analyze + test + build the Flutter mobile client. Only runs when the
|
||||||
|
# diff touches flutter_client/ or one of the shared web inputs the
|
||||||
|
# sync_shared.sh script copies. Other pushes skip — this workflow is
|
||||||
|
# independent from the Go/web test.yml and release.yml.
|
||||||
|
|
||||||
|
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
|
||||||
|
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
||||||
|
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' && !startsWith(github.ref, 'refs/tags/')
|
||||||
|
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).
|
# canonical record lives in commit messages and the running code).
|
||||||
docs/superpowers/
|
docs/superpowers/
|
||||||
|
|
||||||
# Per-machine Claude Code settings + remember-skill memory artifacts +
|
# Per-machine Claude Code settings + remember-skill memory artifacts.
|
||||||
# project-level Claude/AI instruction files. All operator-scoped; the
|
# Both are operator-scoped; the canonical project memory lives under
|
||||||
# canonical project memory lives under ~/.claude/projects/ outside the
|
# ~/.claude/projects/ outside the repo.
|
||||||
# repo. CLAUDE.md/AGENTS.md/GEMINI.md stay on the operator's machine
|
|
||||||
# only — they're tool-specific working notes, not project artifacts.
|
|
||||||
.claude/
|
.claude/
|
||||||
.remember/
|
.remember/
|
||||||
CLAUDE.md
|
|
||||||
AGENTS.md
|
|
||||||
GEMINI.md
|
|
||||||
.cursorrules
|
|
||||||
.windsurfrules
|
|
||||||
.aider.conf.yml
|
|
||||||
|
|
||||||
# Flutter
|
# Flutter
|
||||||
flutter_client/.dart_tool/
|
flutter_client/.dart_tool/
|
||||||
@@ -65,21 +57,3 @@ flutter_client/android/app/build/
|
|||||||
flutter_client/android/local.properties
|
flutter_client/android/local.properties
|
||||||
flutter_client/android/key.properties
|
flutter_client/android/key.properties
|
||||||
flutter_client/*.iml
|
flutter_client/*.iml
|
||||||
|
|
||||||
# Native Android (Kotlin/Compose) — M8 rewrite
|
|
||||||
android/.gradle/
|
|
||||||
android/.kotlin/
|
|
||||||
android/.idea/
|
|
||||||
android/build/
|
|
||||||
android/app/build/
|
|
||||||
android/local.properties
|
|
||||||
android/*.iml
|
|
||||||
android/app/*.iml
|
|
||||||
# Release signing material — keystore + extracted credentials live local
|
|
||||||
# only; the canonical copy is the Gitea repo secret (ANDROID_KEYSTORE_B64
|
|
||||||
# + the alias/password secrets). Losing the local file is fine; losing
|
|
||||||
# the secret means rebuilding the keystore + reinstalling all clients.
|
|
||||||
android/*.keystore
|
|
||||||
android/*.keystore.*
|
|
||||||
android/*.jks
|
|
||||||
android/keystore.properties
|
|
||||||
|
|||||||
+15
-16
@@ -1,29 +1,28 @@
|
|||||||
version: "2"
|
|
||||||
|
|
||||||
run:
|
run:
|
||||||
timeout: 5m
|
timeout: 5m
|
||||||
tests: true
|
tests: true
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
default: none
|
disable-all: true
|
||||||
enable:
|
enable:
|
||||||
- errcheck
|
- errcheck
|
||||||
- govet
|
- govet
|
||||||
- ineffassign
|
- ineffassign
|
||||||
- staticcheck
|
- staticcheck
|
||||||
- unused
|
- 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
|
- gofmt
|
||||||
- goimports
|
- 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/ ./
|
COPY web/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM golang:1.25-bookworm AS builder
|
FROM golang:1.23-bookworm AS builder
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
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
|
SQLC_VERSION := 1.31.1
|
||||||
|
|
||||||
@@ -11,16 +11,6 @@ test:
|
|||||||
test-short:
|
test-short:
|
||||||
go test -short -race ./...
|
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:
|
lint:
|
||||||
golangci-lint run ./...
|
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.
|
> 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
|
## 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.
|
- **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.
|
- **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.
|
- **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
|
## Quickstart
|
||||||
|
|
||||||
@@ -24,18 +24,10 @@ services:
|
|||||||
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
||||||
ports: ['4533:4533']
|
ports: ['4533:4533']
|
||||||
volumes:
|
volumes:
|
||||||
# Your music library. Point ./music at wherever your audio files
|
|
||||||
# live. Mounted read-only — Minstrel never writes to your library.
|
|
||||||
- ./music:/music:ro
|
- ./music:/music:ro
|
||||||
# Generated data: playlist cover collages, artist art, caches.
|
- minstrel-data:/data
|
||||||
# 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
|
|
||||||
environment:
|
environment:
|
||||||
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
|
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
|
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
||||||
depends_on: [db]
|
depends_on: [db]
|
||||||
|
|
||||||
@@ -45,8 +37,6 @@ services:
|
|||||||
POSTGRES_USER: minstrel
|
POSTGRES_USER: minstrel
|
||||||
POSTGRES_PASSWORD: minstrel
|
POSTGRES_PASSWORD: minstrel
|
||||||
POSTGRES_DB: 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: [pgdata:/var/lib/postgresql/data]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
@@ -58,29 +48,7 @@ volumes:
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
## First run
|
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).
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
|
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:
|
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_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`).
|
- `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.
|
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
|
## Updating
|
||||||
|
|
||||||
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
- `: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.
|
||||||
- `: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.
|
|
||||||
|
|
||||||
## Specs
|
## Specs
|
||||||
|
|
||||||
@@ -120,16 +83,6 @@ Two concurrent dev processes:
|
|||||||
1. **Backend:** `docker compose up` — Postgres + Minstrel on `:4533`.
|
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.
|
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
|
### 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.
|
`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`).
|
- Day-to-day work happens on `dev` (or feature branches merged into `dev`).
|
||||||
- `main` is **protected** — changes land via PR from `dev`.
|
- `main` is **protected** — changes land via PR from `dev`.
|
||||||
- Releases are cut by tagging `v*` off `main`; the release workflow builds and pushes the container image to the 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).
|
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