Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e90b33f6 | ||
|
|
1ab53e3898 | ||
|
|
0b32d7e813 | ||
|
|
5402d9595d | ||
|
|
9d9edcfec8 | ||
|
|
943191a0ff | ||
|
|
2b254f417f | ||
|
|
69883af8bd | ||
|
|
6f337ee626 | ||
|
|
77cbb62d84 | ||
|
|
05d68724f3 | ||
|
|
d71845509d | ||
|
|
4efcfdc539 | ||
|
|
d61d7a6bac | ||
|
|
cad8b2280f | ||
|
|
3a75c7d848 | ||
|
|
dc1fac1a94 | ||
|
|
122214a896 | ||
|
|
134f89fc9d | ||
|
|
232c171aac | ||
|
|
845df4e031 | ||
|
|
b0d8929b4c | ||
|
|
291265be7d | ||
|
|
624e75839d | ||
|
|
fd766cda24 | ||
|
|
49000f716c | ||
|
|
ee4990e2f1 | ||
|
|
e64b61c167 | ||
|
|
b737ced3fc | ||
|
|
c2bdd62a51 | ||
|
|
d1370c6a28 | ||
|
|
2fc5fd7960 | ||
|
|
63506e3af2 | ||
|
|
5b339aa921 | ||
|
|
42c772f735 | ||
|
|
e738086573 | ||
|
|
4b7756c36e | ||
|
|
4381750c6e | ||
|
|
3485f643f4 | ||
|
|
bca0f5e2f0 | ||
|
|
53b2ff29f8 | ||
|
|
c349512fb0 | ||
|
|
0615356785 | ||
|
|
184371afca | ||
|
|
716da908c9 | ||
|
|
0c120c0868 | ||
|
|
d9d0fbb7d1 | ||
|
|
71d6556ba8 | ||
|
|
0ac59eb0ca | ||
|
|
652c6a470d | ||
|
|
1c54782dd7 | ||
|
|
8fa06038fe | ||
|
|
6b69cc0fc0 | ||
|
|
624cadab64 | ||
|
|
a8756c95de | ||
|
|
952d11e6f5 | ||
|
|
df16713aa2 | ||
|
|
3a9240b82c |
@@ -2,3 +2,6 @@
|
|||||||
/nul
|
/nul
|
||||||
/.claude
|
/.claude
|
||||||
/media
|
/media
|
||||||
|
/federation
|
||||||
|
/similarity-models
|
||||||
|
/federation-cache
|
||||||
|
|||||||
Generated
+3138
-624
File diff suppressed because it is too large
Load Diff
+20
-4
@@ -1,18 +1,26 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.3.0"
|
version = "0.10.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
cot = { version = "0.6.0", features = ["postgres", "json", "openapi", "swagger-ui"] }
|
# default-features off: cot's defaults include the sqlite backend, whose old
|
||||||
|
# libsqlite3-sys collides with music-dht's rusqlite (one native sqlite3 per
|
||||||
|
# binary). This server only ever talks PostgreSQL.
|
||||||
|
cot = { version = "0.6.0", default-features = false, features = ["postgres", "json", "openapi", "swagger-ui"] }
|
||||||
schemars = { version = "0.9", features = ["derive"] }
|
schemars = { version = "0.9", features = ["derive"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
openidconnect = "4.0"
|
openidconnect = "4.0"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream"] }
|
||||||
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
|
tokio = { version = "1", features = ["sync", "fs", "io-util", "process"] }
|
||||||
|
tokio-util = "0.7"
|
||||||
|
libc = "0.2"
|
||||||
|
async-stream = "0.3"
|
||||||
|
bytes = "1"
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
blake3 = "1"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
@@ -25,8 +33,16 @@ md-5 = "0.10"
|
|||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] }
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
futures-util = "0.3"
|
||||||
|
rodio = { version = "0.22.2", default-features = false, features = ["mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac", "symphonia-adpcm", "symphonia-aiff", "symphonia-mkv", "symphonia-pcm"] }
|
||||||
|
rustfft = "6.4.1"
|
||||||
|
tract-onnx = "0.23.4"
|
||||||
tokio-cron-scheduler = "0.15"
|
tokio-cron-scheduler = "0.15"
|
||||||
croner = "3"
|
croner = "3"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
postcard = { version = "1", features = ["alloc"] }
|
||||||
uuid = "1"
|
uuid = "1"
|
||||||
librqbit = { version = "8.1.1", features = ["disable-upload"] }
|
librqbit = { version = "8.1.1", features = ["disable-upload"] }
|
||||||
|
# P2P federation: publishes the library into a shared DHT and serves audio /
|
||||||
|
# catalogs to furumi peers (TUI clients) over the frid stack.
|
||||||
|
music-dht = "0.4.0"
|
||||||
|
|||||||
+13
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM rust:1-slim AS builder
|
FROM rust:1-bookworm AS builder
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates \
|
&& apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates \
|
||||||
@@ -14,14 +14,25 @@ COPY templates ./templates
|
|||||||
|
|
||||||
RUN cargo build --release
|
RUN cargo build --release
|
||||||
|
|
||||||
|
FROM denoland/deno:bin-2.8.3 AS deno
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
ARG YT_DLP_VERSION=2026.07.04
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
ffmpeg \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
&& pip3 install --break-system-packages --no-cache-dir --disable-pip-version-check \
|
||||||
|
"yt-dlp[default]==${YT_DLP_VERSION}" \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /data
|
WORKDIR /data
|
||||||
COPY --from=builder /app/target/release/furumusic /usr/local/bin/furumusic
|
COPY --from=builder /app/target/release/furumusic /usr/local/bin/furumusic
|
||||||
|
COPY --from=deno /deno /usr/local/bin/deno
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["furumusic", "-l", "0.0.0.0:8000"]
|
CMD ["furumusic", "-l", "0.0.0.0:8000"]
|
||||||
|
|||||||
@@ -1,201 +1,143 @@
|
|||||||
# furumusic
|
# Furumusic
|
||||||
|
|
||||||
Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL.
|
**Your library. Your users. Your network.**
|
||||||
|
|
||||||
Built with Rust ([cot](https://cot.rs) framework).
|
Furumusic is a self-hosted, multi-user music server with a full web player and
|
||||||
|
support for the Furumi federated network. It turns a collection of music files
|
||||||
|
into a shared library that is available from any modern browser, while every
|
||||||
|
user keeps their own playlists, likes, listening history, and playback state.
|
||||||
|
|
||||||
## Quick start
|
Music can be uploaded directly, imported from a `.torrent` file, or downloaded
|
||||||
|
from a magnet link. An optional AI-assisted import pipeline reads the available
|
||||||
|
tags and path information, reconstructs inconsistent metadata, finds artwork,
|
||||||
|
and organizes the result into artists, releases, and tracks. Uncertain matches
|
||||||
|
are kept for review instead of silently entering the library with bad data.
|
||||||
|
|
||||||
|
## Why Furumusic?
|
||||||
|
|
||||||
|
Furumusic is for a household, a small community, or anyone who wants one music
|
||||||
|
library without handing it to a subscription service. The server owns the
|
||||||
|
catalog and media files; the browser is only the player.
|
||||||
|
|
||||||
|
- one shared library with separate user accounts;
|
||||||
|
- a responsive web player with artists, releases, search, queue, and playlists;
|
||||||
|
- direct file uploads and imports from YouTube, torrent files, or magnet links;
|
||||||
|
- optional AI-assisted recognition and normalization of metadata;
|
||||||
|
- password login or OIDC/SSO with group-based access control;
|
||||||
|
- optional federation without a central catalog or search service;
|
||||||
|
- trusted-device pairing, playback handoff, and synchronization between Furumi
|
||||||
|
players;
|
||||||
|
- Last.fm scrobbling and similarity-based discovery when configured.
|
||||||
|
|
||||||
|
Furumusic does not include music. Import only media you are allowed to store and
|
||||||
|
share.
|
||||||
|
|
||||||
|
## Importing music
|
||||||
|
|
||||||
|
Users can add audio files from the web interface or ask the server to download
|
||||||
|
selected files from a torrent. Both routes feed the same import pipeline, so a
|
||||||
|
library remains consistent regardless of where its files came from.
|
||||||
|
|
||||||
|
The importer combines embedded tags, filenames, folder structure, and existing
|
||||||
|
catalog context. With an OpenAI-compatible language model configured, it can
|
||||||
|
normalize artist and release names, separate featured artists, recover track
|
||||||
|
numbers and release types, and flag ambiguous results for a user to approve.
|
||||||
|
Cover art is taken from nearby image files or embedded artwork when available.
|
||||||
|
|
||||||
|
The inbox and permanent library are separate directories. New files are first
|
||||||
|
processed in the inbox and are moved into the organized library only after
|
||||||
|
their metadata has been accepted.
|
||||||
|
|
||||||
|
## Federation
|
||||||
|
|
||||||
|
Federation is optional. Independent Furumusic and Furumi players that use the
|
||||||
|
same Network ID discover one another as a logical network. Each peer keeps its
|
||||||
|
own library and can continue working alone.
|
||||||
|
|
||||||
|
When federation is enabled, local and remote artists, releases, and tracks can
|
||||||
|
appear in the same search and library views. Missing audio is streamed from an
|
||||||
|
available peer and can optionally be retained locally. Similarity search also
|
||||||
|
stays decentralized: embeddings and exact ranking remain on the instance that
|
||||||
|
owns the music.
|
||||||
|
|
||||||
|
Trusted-device pairing is separate from public catalog discovery. It connects
|
||||||
|
a user's own Furumi players so likes, playlists, playback state, and control can
|
||||||
|
move between approved devices.
|
||||||
|
|
||||||
|
## Build and run
|
||||||
|
|
||||||
|
Furumusic requires PostgreSQL and a Rust toolchain with Rust 2024 edition
|
||||||
|
support. Create an empty database, provide its connection URL, and start the
|
||||||
|
server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export FURU_DATABASE_URL=postgresql://user:pass@localhost/furumusic
|
export FURU_DATABASE_URL='postgresql://furumusic:password@127.0.0.1/furumusic'
|
||||||
cargo run
|
cargo run --release --locked
|
||||||
# Open http://localhost:8000/admin/setup to create the first admin account
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project structure
|
Open <http://127.0.0.1:8000/admin/setup> to create the first administrator.
|
||||||
|
After setup, configure the inbox and library directories under **Admin →
|
||||||
|
Settings** before importing music.
|
||||||
|
|
||||||
|
To listen on another address or port:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --release --locked -- -l 0.0.0.0:8000
|
||||||
```
|
```
|
||||||
Cargo.toml Project manifest and dependencies
|
|
||||||
build.rs Captures rustc version + target at compile time
|
A Nix development shell is included for Linux and macOS:
|
||||||
src/
|
|
||||||
main.rs Entrypoint; HTTP router, login/logout handlers, tracing init
|
```bash
|
||||||
config.rs 3-tier config system (default → DB → env); FURU_* env vars
|
nix develop
|
||||||
auth.rs Session auth, Role enum (Admin/User), login/logout/guards
|
cargo run --locked
|
||||||
user.rs User + OidcLink DB models, CRUD, password hashing, migrations
|
|
||||||
oidc.rs OIDC/SSO flow: discovery, PKCE, token exchange, user provisioning
|
|
||||||
i18n/
|
|
||||||
mod.rs Language resolution (cookie → Accept-Language → default), extractor
|
|
||||||
phrases.rs All UI strings in English and Russian (translations! macro)
|
|
||||||
api/
|
|
||||||
mod.rs JSON API endpoints (mounted at /api), session-based auth
|
|
||||||
admin/
|
|
||||||
mod.rs Admin sub-app router: dashboard, settings, users, debug, setup
|
|
||||||
views.rs Admin page handlers and templates
|
|
||||||
templates/
|
|
||||||
base.html Root HTML layout with lang/title blocks
|
|
||||||
login.html Login page (password + optional SSO button)
|
|
||||||
admin/
|
|
||||||
layout.html Admin sidebar/nav wrapper
|
|
||||||
index.html Admin dashboard
|
|
||||||
debug.html Build info + config table (with secret redaction)
|
|
||||||
settings.html OIDC and auth settings form
|
|
||||||
setup.html First-run admin account creation
|
|
||||||
users.html User list
|
|
||||||
user_form.html User create/edit form
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The repository also contains a multi-stage `Dockerfile` for building a small
|
||||||
|
runtime image. A deployment must provide PostgreSQL plus persistent, writable
|
||||||
|
volumes for the inbox and music library.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Most settings can be changed from the administration interface. Every setting
|
||||||
|
also has a `FURU_`-prefixed environment variable; environment values take
|
||||||
|
priority over values stored in PostgreSQL.
|
||||||
|
|
||||||
|
The settings needed for a useful first installation are:
|
||||||
|
|
||||||
|
| Setting | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `FURU_DATABASE_URL` | PostgreSQL connection URL; required to run the service |
|
||||||
|
| `FURU_AGENT_INBOX_DIR` | Temporary inbox for uploads and downloaded files |
|
||||||
|
| `FURU_AGENT_STORAGE_DIR` | Permanent, organized music library |
|
||||||
|
| `FURU_AGENT_ENABLED` | Enables the background metadata import pipeline |
|
||||||
|
| `FURU_AGENT_LLM_URL` | Base URL of an OpenAI-compatible model server |
|
||||||
|
| `FURU_AGENT_LLM_MODEL` | Model used to recognize and normalize metadata |
|
||||||
|
| `FURU_FEDERATION_ENABLED` | Publishes the library and enables peer discovery |
|
||||||
|
| `FURU_FEDERATION_NETWORK_ID` | Joins peers with the same value into one logical network |
|
||||||
|
|
||||||
|
AI recognition, federation, similarity search, Last.fm, and OIDC are optional.
|
||||||
|
A local password-authenticated server can be used without any of them.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Config system (`src/config.rs`)
|
Furumusic is written in Rust on the
|
||||||
|
[Cot](https://cot.rs) web framework. PostgreSQL stores the catalog, accounts,
|
||||||
|
playlists, configuration, and background-job state. Audio inspection uses
|
||||||
|
Symphonia, torrent downloads use librqbit, and the shared `music-dht`/Frid
|
||||||
|
protocol stack provides decentralized catalog search, media transfer, and
|
||||||
|
connected-device synchronization.
|
||||||
|
|
||||||
Every setting lives in `AppConfig` and is resolved in three layers:
|
The browser interface and JSON API are served by the same application. Import,
|
||||||
|
artwork, metadata enrichment, similarity indexing, and maintenance run as
|
||||||
|
durable background jobs rather than blocking playback requests.
|
||||||
|
|
||||||
1. **Compiled default** — `AppConfig::default()`
|
## Contributing
|
||||||
2. **Database override** — rows in the `furumusic__config_entry` table
|
|
||||||
3. **Environment variable** — `FURU_<FIELD_NAME>` (highest priority)
|
|
||||||
|
|
||||||
`ConfigSources` tracks where each field's effective value came from (shown in the admin debug page).
|
Bug reports, design discussions, and patches are welcome. Before submitting a
|
||||||
|
change, run:
|
||||||
|
|
||||||
**To add a new config field:**
|
```bash
|
||||||
|
cargo fmt --all -- --check
|
||||||
1. Add the field to `AppConfig` struct
|
cargo clippy --all-targets -- -D warnings
|
||||||
2. Set its default in `AppConfig::default()`
|
cargo test --all-targets
|
||||||
3. Add the field to `ConfigSources` struct and its `Default` impl
|
|
||||||
4. Add it to the `impl_env_overrides!(…)` invocation
|
|
||||||
5. Add an `apply_db_field!()` call in `apply_db_overrides`
|
|
||||||
6. Add an `entry!()` line in `admin/views.rs → config_display_entries()`
|
|
||||||
|
|
||||||
### Auth (`src/auth.rs`)
|
|
||||||
|
|
||||||
Session-based authentication with two roles:
|
|
||||||
|
|
||||||
- **`Role::Admin`** — full access to admin panel
|
|
||||||
- **`Role::User`** — standard user
|
|
||||||
|
|
||||||
Key functions:
|
|
||||||
- `login(session, user_id)` — sets session, cycles session ID
|
|
||||||
- `logout(session)` — flushes session
|
|
||||||
- `get_session_user(session, db)` — returns `AuthenticatedUser` if active
|
|
||||||
- `require_admin_or_redirect(session, db)` — guard that returns 403 or redirects to `/login`
|
|
||||||
|
|
||||||
### OIDC/SSO (`src/oidc.rs`)
|
|
||||||
|
|
||||||
Full OpenID Connect authorization code flow with PKCE:
|
|
||||||
|
|
||||||
1. `GET /auth/oidc/start` — discovers provider, builds auth URL, stores CSRF/nonce/PKCE in session, redirects to IdP
|
|
||||||
2. `GET /auth/oidc/callback` — validates CSRF, exchanges code for tokens, verifies ID token, provisions user
|
|
||||||
|
|
||||||
Provider metadata is cached for 1 hour and invalidated when OIDC config changes.
|
|
||||||
|
|
||||||
**Group access and role mapping:** The `oidc_user_groups` config field lists OIDC group names (comma-separated) allowed to access the service. When it is set, users outside both `oidc_user_groups` and `oidc_admin_groups` are denied before provisioning/login. The `oidc_admin_groups` config field lists OIDC group names that grant the admin role. Groups are extracted from the `groups` claim in the ID token JWT payload.
|
|
||||||
|
|
||||||
**User provisioning order:**
|
|
||||||
1. Find existing `OidcLink` by issuer+sub → update claims, update role
|
|
||||||
2. Find existing `User` by email → create OidcLink, update role
|
|
||||||
3. Create new user (no password) + OidcLink
|
|
||||||
|
|
||||||
Stale links (pointing to deleted users) are cleaned up automatically.
|
|
||||||
|
|
||||||
### User model (`src/user.rs`)
|
|
||||||
|
|
||||||
Two database models:
|
|
||||||
|
|
||||||
- **`User`** — id, username (unique), password (optional for OIDC-only), email, display_name, avatar_url, role, is_active
|
|
||||||
- **`OidcLink`** — id, user_id, issuer, sub, email, name, avatar_url; unique index on (issuer, sub)
|
|
||||||
|
|
||||||
Migrations: M0003 (User table), M0004 (OidcLink table), M0005 (OidcLink indexes).
|
|
||||||
|
|
||||||
### i18n (`src/i18n/`)
|
|
||||||
|
|
||||||
Compile-time bilingual UI (English + Russian).
|
|
||||||
|
|
||||||
- `translations!` macro in `phrases.rs` generates a `Translations` struct with static `EN` and `RU` instances
|
|
||||||
- Language resolution: `furu_lang` cookie → `Accept-Language` header → English default
|
|
||||||
- `I18n` is a cot request extractor — handlers receive it automatically
|
|
||||||
- `set_lang` endpoint (`/set-lang?lang=ru&next=/`) sets the cookie
|
|
||||||
|
|
||||||
### API (`src/api/`)
|
|
||||||
|
|
||||||
JSON API mounted at `/api`. Uses the same session cookie as HTML pages — works automatically for same-origin frontend requests (no CORS, no tokens needed).
|
|
||||||
|
|
||||||
Helpers in `api/mod.rs`:
|
|
||||||
- `json_ok(value)` — 200 with `application/json`
|
|
||||||
- `json_error(status, message)` — error response as `{"error": "..."}`
|
|
||||||
|
|
||||||
| Route | Method | Description |
|
|
||||||
|-------|--------|-------------|
|
|
||||||
| `/api/me` | GET | Current user (id, name, role) or 401 |
|
|
||||||
|
|
||||||
**Swagger UI** is available at `/swagger/` when `FURU_SWAGGER_ENABLED=true`. The OpenAPI spec is auto-generated from handler types.
|
|
||||||
|
|
||||||
To add a new API endpoint:
|
|
||||||
1. Define request/response structs with `#[derive(Serialize, JsonSchema)]`
|
|
||||||
2. Write an async handler, return `Json(response).into_response()`
|
|
||||||
3. Add a `Route::with_api_handler_and_name(…, api_get(handler), …)` in `ApiApp::router()`
|
|
||||||
4. The endpoint appears automatically in Swagger UI
|
|
||||||
|
|
||||||
### Admin panel (`src/admin/`)
|
|
||||||
|
|
||||||
Mounted at `/admin`. All routes (except `/admin/setup`) require `Role::Admin`.
|
|
||||||
|
|
||||||
| Route | Purpose |
|
|
||||||
|-------|---------|
|
|
||||||
| `/admin/setup` | First-run: create initial admin (only works when zero users exist) |
|
|
||||||
| `/admin/` | Dashboard |
|
|
||||||
| `/admin/debug` | Build info, config values with sources, DB connectivity |
|
|
||||||
| `/admin/settings` | OIDC config, auth toggles (saved to DB config table) |
|
|
||||||
| `/admin/users` | User list |
|
|
||||||
| `/admin/users/new` | Create user |
|
|
||||||
| `/admin/users/{id}/edit` | Edit user |
|
|
||||||
| `/admin/users/{id}/delete` | Delete user (POST) |
|
|
||||||
|
|
||||||
## How to extend
|
|
||||||
|
|
||||||
### 1. Add a config field
|
|
||||||
|
|
||||||
See [Config system](#config-system-srcconfigrs) above — 6 locations to update.
|
|
||||||
|
|
||||||
### 2. Add a database model
|
|
||||||
|
|
||||||
1. Define a struct with `#[cot::db::model]` in a new or existing file
|
|
||||||
2. Write a migration struct implementing `cot::db::migrations::Migration`
|
|
||||||
3. Register the migration in the `AdminApp::migrations()` method in `src/admin/mod.rs`
|
|
||||||
|
|
||||||
### 3. Add a page
|
|
||||||
|
|
||||||
1. Create a template in `templates/`
|
|
||||||
2. Write a handler function that returns `Html`
|
|
||||||
3. Add a `Route::with_handler_and_name(…)` in the appropriate `router()` method
|
|
||||||
4. If admin-only, wrap with `require_admin_or_redirect`
|
|
||||||
|
|
||||||
### 4. Add a translation
|
|
||||||
|
|
||||||
Add a line to the `translations!` macro in `src/i18n/phrases.rs`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
my_key: "English text", "Русский текст";
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Access it in handlers/templates as `i18n.t.my_key` (or `t.my_key` in templates).
|
|
||||||
|
|
||||||
### 5. Add an API endpoint
|
|
||||||
|
|
||||||
Same as adding a page, but return a JSON response instead of `Html`. The `json` feature is enabled in Cargo.toml.
|
|
||||||
|
|
||||||
## Environment variables
|
|
||||||
|
|
||||||
All prefixed with `FURU_`. Priority: env var > DB override > compiled default.
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| `FURU_DATABASE_URL` | PostgreSQL connection URL | *(empty — required)* |
|
|
||||||
| `FURU_LOG_LEVEL` | Tracing filter (e.g. `info`, `debug`, `warn,furumusic=trace`) | `info` |
|
|
||||||
| `FURU_AUTH_PASSWORD_ENABLED` | Enable password login | `true` |
|
|
||||||
| `FURU_AUTH_SSO_ENABLED` | Enable SSO/OIDC login | `false` |
|
|
||||||
| `FURU_OIDC_ISSUER` | OIDC issuer URL | *(empty)* |
|
|
||||||
| `FURU_OIDC_CLIENT_ID` | OIDC client ID | *(empty)* |
|
|
||||||
| `FURU_OIDC_CLIENT_SECRET` | OIDC client secret | *(empty)* |
|
|
||||||
| `FURU_OIDC_BUTTON_TEXT` | SSO button label | `Sign in with SSO` |
|
|
||||||
| `FURU_OIDC_ADMIN_GROUPS` | Comma-separated OIDC groups that grant admin | *(empty)* |
|
|
||||||
| `FURU_OIDC_USER_GROUPS` | Comma-separated OIDC groups allowed to access the service. Empty means any authenticated SSO user is allowed. | *(empty)* |
|
|
||||||
| `FURU_SWAGGER_ENABLED` | Serve Swagger UI at `/swagger/` | `false` |
|
|
||||||
|
|||||||
Generated
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1786106723,
|
||||||
|
"narHash": "sha256-zDSUbpoeo/9ZmD2+wXnzxoo1+uhL8vxc0b8yuYMKYq0=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "f13ff45afd1bb73e640eaa08a7066dbed07e3238",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
description = "Furumusic development environment";
|
||||||
|
|
||||||
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
|
outputs = { nixpkgs, ... }:
|
||||||
|
let
|
||||||
|
supportedSystems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
"x86_64-darwin"
|
||||||
|
"aarch64-darwin"
|
||||||
|
];
|
||||||
|
forEachSystem = function:
|
||||||
|
nixpkgs.lib.genAttrs supportedSystems (system:
|
||||||
|
function (import nixpkgs { inherit system; }));
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells = forEachSystem (pkgs: {
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
cargo
|
||||||
|
clippy
|
||||||
|
pkg-config
|
||||||
|
rustc
|
||||||
|
rustfmt
|
||||||
|
];
|
||||||
|
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
cacert
|
||||||
|
deno
|
||||||
|
ffmpeg-headless
|
||||||
|
openssl
|
||||||
|
yt-dlp
|
||||||
|
] ++ lib.optionals stdenv.isDarwin [ libiconv ];
|
||||||
|
|
||||||
|
RUST_SRC_PATH = "${pkgs.rustPlatform.rustLibSrc}";
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
+131
-13
@@ -415,6 +415,52 @@ impl App for AdminApp {
|
|||||||
}),
|
}),
|
||||||
"admin_v2_settings_probe",
|
"admin_v2_settings_probe",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/federation",
|
||||||
|
get(move |session: Session, db: Database| async move {
|
||||||
|
v2::federation_status(session, db).await
|
||||||
|
}),
|
||||||
|
"admin_v2_federation_status",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/similarity",
|
||||||
|
get(move |session: Session, db: Database| async move {
|
||||||
|
v2::similarity_status(session, db).await
|
||||||
|
}),
|
||||||
|
"admin_v2_similarity_status",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/similarity/clear",
|
||||||
|
cot::router::method::post(move |session: Session, db: Database| async move {
|
||||||
|
v2::similarity_clear(session, db).await
|
||||||
|
}),
|
||||||
|
"admin_v2_similarity_clear",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/federation/sync",
|
||||||
|
cot::router::method::post(move |session: Session, db: Database| async move {
|
||||||
|
v2::federation_sync(session, db).await
|
||||||
|
}),
|
||||||
|
"admin_v2_federation_sync",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/federation/ticket",
|
||||||
|
get(move |session: Session, db: Database| async move {
|
||||||
|
v2::federation_ticket(session, db).await
|
||||||
|
}),
|
||||||
|
"admin_v2_federation_ticket",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/federation/connect",
|
||||||
|
cot::router::method::post(
|
||||||
|
move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
json: Json<v2::FederationConnectRequest>| async move {
|
||||||
|
v2::federation_connect(session, db, json).await
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"admin_v2_federation_connect",
|
||||||
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/v2/api/jobs/{name}/toggle",
|
"/v2/api/jobs/{name}/toggle",
|
||||||
cot::router::method::post({
|
cot::router::method::post({
|
||||||
@@ -555,6 +601,32 @@ impl App for AdminApp {
|
|||||||
},
|
},
|
||||||
"admin_v2_library_item_detail",
|
"admin_v2_library_item_detail",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/library/tracks/search",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
get(move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
query: UrlQuery<v2::TrackSearchQuery>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
async move {
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("admin pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
v2::track_search(session, db, pg_pool, query.0).await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
"admin_v2_library_tracks_search",
|
||||||
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/v2/api/library/item/image",
|
"/v2/api/library/item/image",
|
||||||
{
|
{
|
||||||
@@ -639,6 +711,34 @@ impl App for AdminApp {
|
|||||||
},
|
},
|
||||||
"admin_v2_library_bulk",
|
"admin_v2_library_bulk",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/library/releases/merge",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
cot::router::method::post(
|
||||||
|
move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
json: Json<v2::MergeReleasesRequest>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
async move {
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("admin pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
v2::merge_releases(session, db, pg_pool, json).await
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"admin_v2_library_releases_merge",
|
||||||
|
),
|
||||||
// -- Dashboard ----------------------------------------------------
|
// -- Dashboard ----------------------------------------------------
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/",
|
"/",
|
||||||
@@ -997,19 +1097,34 @@ impl App for AdminApp {
|
|||||||
),
|
),
|
||||||
"admin_releases_edit",
|
"admin_releases_edit",
|
||||||
),
|
),
|
||||||
Route::with_handler_and_name(
|
{
|
||||||
"/releases/{id}/delete",
|
let pool = Arc::clone(&pool);
|
||||||
cot::router::method::post(
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|session: Session, db: Database, path: Path<PathId>| async move {
|
Route::with_handler_and_name(
|
||||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
"/releases/{id}/delete",
|
||||||
Ok(u) => u,
|
cot::router::method::post(move |session: Session, db: Database, path: Path<PathId>| {
|
||||||
Err(resp) => return Ok(resp),
|
let pool = Arc::clone(&pool);
|
||||||
};
|
let pool_config = Arc::clone(&pool_config);
|
||||||
views::releases_delete(admin, &db, path.0.id).await
|
async move {
|
||||||
},
|
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||||
),
|
Ok(u) => u,
|
||||||
"admin_releases_delete",
|
Err(resp) => return Ok(resp),
|
||||||
),
|
};
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("admin pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
views::releases_delete(admin, &db, pg_pool, path.0.id).await
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"admin_releases_delete",
|
||||||
|
)
|
||||||
|
},
|
||||||
// -- Media Files --------------------------------------------------
|
// -- Media Files --------------------------------------------------
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/media-files",
|
"/media-files",
|
||||||
@@ -1324,6 +1439,9 @@ impl App for AdminApp {
|
|||||||
all.extend(cot::db::migrations::wrap_migrations(
|
all.extend(cot::db::migrations::wrap_migrations(
|
||||||
crate::scheduler::db_migrations::MIGRATIONS,
|
crate::scheduler::db_migrations::MIGRATIONS,
|
||||||
));
|
));
|
||||||
|
all.extend(cot::db::migrations::wrap_migrations(
|
||||||
|
crate::auth::db_migrations::MIGRATIONS,
|
||||||
|
));
|
||||||
all
|
all
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+928
-115
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -1262,9 +1262,11 @@ pub async fn releases_update(
|
|||||||
pub async fn releases_delete(
|
pub async fn releases_delete(
|
||||||
_admin: AuthenticatedUser,
|
_admin: AuthenticatedUser,
|
||||||
db: &Database,
|
db: &Database,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
release_id: i64,
|
release_id: i64,
|
||||||
) -> cot::Result<cot::http::Response<Body>> {
|
) -> cot::Result<cot::http::Response<Body>> {
|
||||||
Release::delete_by_id(db, release_id)
|
let (config, _) = AppConfig::load_with_db(db).await;
|
||||||
|
crate::library_cleanup::delete_releases(pool, &[release_id], &config.agent_storage_dir)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(format!("failed to delete release: {e}")))?;
|
.map_err(|e| cot::Error::internal(format!("failed to delete release: {e}")))?;
|
||||||
Ok(auth::redirect("/admin/releases"))
|
Ok(auth::redirect("/admin/releases"))
|
||||||
|
|||||||
@@ -96,17 +96,11 @@ fn generate_missing_variants_sync(
|
|||||||
image::ExtendedColorType::Rgb8,
|
image::ExtendedColorType::Rgb8,
|
||||||
);
|
);
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => crate::metrics::record_agent_cover_variant(
|
Ok(()) => {
|
||||||
variant.name,
|
crate::metrics::record_agent_cover_variant(variant.name, "ok", start.elapsed())
|
||||||
"ok",
|
}
|
||||||
start.elapsed(),
|
|
||||||
),
|
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
crate::metrics::record_agent_cover_variant(
|
crate::metrics::record_agent_cover_variant(variant.name, "error", start.elapsed());
|
||||||
variant.name,
|
|
||||||
"error",
|
|
||||||
start.elapsed(),
|
|
||||||
);
|
|
||||||
return Err(err.into());
|
return Err(err.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+525
-11
@@ -1,14 +1,27 @@
|
|||||||
|
use std::marker::PhantomData;
|
||||||
|
|
||||||
|
use cot::aide::openapi::{
|
||||||
|
MediaType, Operation, ReferenceOr, RequestBody, Response as OpenApiResponse, SchemaObject,
|
||||||
|
StatusCode as OpenApiStatusCode,
|
||||||
|
};
|
||||||
|
use cot::auth::PasswordVerificationResult;
|
||||||
|
use cot::common_types::Password;
|
||||||
use cot::db::Database;
|
use cot::db::Database;
|
||||||
|
use cot::http::StatusCode;
|
||||||
|
use cot::http::header::CONTENT_TYPE;
|
||||||
use cot::json::Json;
|
use cot::json::Json;
|
||||||
|
use cot::openapi::{AsApiOperation, RouteContext};
|
||||||
use cot::response::IntoResponse;
|
use cot::response::IntoResponse;
|
||||||
use cot::router::method::openapi::api_get;
|
use cot::router::method::openapi::{api_get, api_post};
|
||||||
use cot::router::{Route, Router};
|
use cot::router::{Route, Router};
|
||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
use cot::{App, Body};
|
use cot::{App, Body, RequestHandler};
|
||||||
use schemars::JsonSchema;
|
use schemars::{JsonSchema, SchemaGenerator};
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::auth;
|
use crate::auth;
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use crate::user::User;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// JSON error helper
|
// JSON error helper
|
||||||
@@ -23,6 +36,199 @@ fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Re
|
|||||||
.expect("valid response")
|
.expect("valid response")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct DocumentedJsonHandler<H, Req, Res> {
|
||||||
|
handler: H,
|
||||||
|
summary: &'static str,
|
||||||
|
_marker: PhantomData<fn(Req) -> Res>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct DocumentedResponseHandler<H, Res> {
|
||||||
|
handler: H,
|
||||||
|
summary: &'static str,
|
||||||
|
_marker: PhantomData<fn() -> Res>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn documented_json_handler<Req, Res, H>(
|
||||||
|
handler: H,
|
||||||
|
summary: &'static str,
|
||||||
|
) -> DocumentedJsonHandler<H, Req, Res> {
|
||||||
|
DocumentedJsonHandler {
|
||||||
|
handler,
|
||||||
|
summary,
|
||||||
|
_marker: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn documented_response_handler<Res, H>(
|
||||||
|
handler: H,
|
||||||
|
summary: &'static str,
|
||||||
|
) -> DocumentedResponseHandler<H, Res> {
|
||||||
|
DocumentedResponseHandler {
|
||||||
|
handler,
|
||||||
|
summary,
|
||||||
|
_marker: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<HandlerParams, H, Req, Res> RequestHandler<HandlerParams>
|
||||||
|
for DocumentedJsonHandler<H, Req, Res>
|
||||||
|
where
|
||||||
|
H: RequestHandler<HandlerParams> + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
async fn handle(&self, request: cot::request::Request) -> cot::Result<cot::response::Response> {
|
||||||
|
self.handler.handle(request).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<HandlerParams, H, Res> RequestHandler<HandlerParams> for DocumentedResponseHandler<H, Res>
|
||||||
|
where
|
||||||
|
H: RequestHandler<HandlerParams> + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
async fn handle(&self, request: cot::request::Request) -> cot::Result<cot::response::Response> {
|
||||||
|
self.handler.handle(request).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<H, Req, Res> AsApiOperation for DocumentedJsonHandler<H, Req, Res>
|
||||||
|
where
|
||||||
|
Req: JsonSchema,
|
||||||
|
Res: JsonSchema,
|
||||||
|
{
|
||||||
|
fn as_api_operation(
|
||||||
|
&self,
|
||||||
|
_route_context: &RouteContext<'_>,
|
||||||
|
schema_generator: &mut SchemaGenerator,
|
||||||
|
) -> Option<Operation> {
|
||||||
|
let mut operation = Operation {
|
||||||
|
summary: Some(self.summary.to_owned()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut request_body = RequestBody {
|
||||||
|
required: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
request_body.content.insert(
|
||||||
|
"application/json".to_owned(),
|
||||||
|
MediaType {
|
||||||
|
schema: Some(SchemaObject {
|
||||||
|
json_schema: Req::json_schema(schema_generator),
|
||||||
|
external_docs: None,
|
||||||
|
example: None,
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
operation.request_body = Some(ReferenceOr::Item(request_body));
|
||||||
|
|
||||||
|
let responses = operation.responses.get_or_insert_default();
|
||||||
|
let mut ok = OpenApiResponse {
|
||||||
|
description: "OK".to_owned(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
ok.content.insert(
|
||||||
|
"application/json".to_owned(),
|
||||||
|
MediaType {
|
||||||
|
schema: Some(SchemaObject {
|
||||||
|
json_schema: Res::json_schema(schema_generator),
|
||||||
|
external_docs: None,
|
||||||
|
example: None,
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
responses
|
||||||
|
.responses
|
||||||
|
.insert(OpenApiStatusCode::Code(200), ReferenceOr::Item(ok));
|
||||||
|
|
||||||
|
Some(operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<H, Res> AsApiOperation for DocumentedResponseHandler<H, Res>
|
||||||
|
where
|
||||||
|
Res: JsonSchema,
|
||||||
|
{
|
||||||
|
fn as_api_operation(
|
||||||
|
&self,
|
||||||
|
_route_context: &RouteContext<'_>,
|
||||||
|
schema_generator: &mut SchemaGenerator,
|
||||||
|
) -> Option<Operation> {
|
||||||
|
let mut operation = Operation {
|
||||||
|
summary: Some(self.summary.to_owned()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
add_json_response::<Res>(&mut operation, schema_generator);
|
||||||
|
Some(operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_json_response<Res: JsonSchema>(
|
||||||
|
operation: &mut Operation,
|
||||||
|
schema_generator: &mut SchemaGenerator,
|
||||||
|
) {
|
||||||
|
let responses = operation.responses.get_or_insert_default();
|
||||||
|
let mut ok = OpenApiResponse {
|
||||||
|
description: "OK".to_owned(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
ok.content.insert(
|
||||||
|
"application/json".to_owned(),
|
||||||
|
MediaType {
|
||||||
|
schema: Some(SchemaObject {
|
||||||
|
json_schema: Res::json_schema(schema_generator),
|
||||||
|
external_docs: None,
|
||||||
|
example: None,
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
responses
|
||||||
|
.responses
|
||||||
|
.insert(OpenApiStatusCode::Code(200), ReferenceOr::Item(ok));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_json_content_type(value: &str) -> bool {
|
||||||
|
value
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.map(str::trim)
|
||||||
|
.is_some_and(|media_type| media_type.eq_ignore_ascii_case("application/json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_json_request<T>(
|
||||||
|
request: cot::request::Request,
|
||||||
|
) -> cot::Result<Result<T, cot::response::Response>>
|
||||||
|
where
|
||||||
|
T: for<'de> Deserialize<'de>,
|
||||||
|
{
|
||||||
|
let content_type = request
|
||||||
|
.headers()
|
||||||
|
.get(CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !is_json_content_type(content_type) {
|
||||||
|
return Ok(Err(json_error(
|
||||||
|
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||||
|
"expected application/json",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = request.into_body().into_bytes().await?;
|
||||||
|
let body = match serde_json::from_slice::<T>(&bytes) {
|
||||||
|
Ok(body) => body,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(Err(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid JSON body",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(Ok(body))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /api/me
|
// GET /api/me
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -34,8 +240,85 @@ struct MeResponse {
|
|||||||
role: String,
|
role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response::Response> {
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
let Some(user) = auth::get_session_user(&session, &db).await else {
|
struct AuthUserResponse {
|
||||||
|
id: i64,
|
||||||
|
name: String,
|
||||||
|
role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct AuthTokenResponse {
|
||||||
|
access_token: String,
|
||||||
|
refresh_token: String,
|
||||||
|
token_type: String,
|
||||||
|
expires_in_seconds: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct AuthLoginResponse {
|
||||||
|
user: AuthUserResponse,
|
||||||
|
tokens: AuthTokenResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct PasswordLoginRequest {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
device_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct RefreshRequest {
|
||||||
|
refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct SsoExchangeRequest {
|
||||||
|
code: String,
|
||||||
|
device_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct LogoutRequest {
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct LogoutResponse {
|
||||||
|
revoked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_response(user: auth::AuthenticatedUser) -> AuthUserResponse {
|
||||||
|
AuthUserResponse {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role.code().to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_response(tokens: auth::ApiTokenPair) -> AuthTokenResponse {
|
||||||
|
AuthTokenResponse {
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
token_type: tokens.token_type.to_owned(),
|
||||||
|
expires_in_seconds: tokens.expires_in_seconds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login_response(user: auth::AuthenticatedUser, tokens: auth::ApiTokenPair) -> AuthLoginResponse {
|
||||||
|
AuthLoginResponse {
|
||||||
|
user: user_response(user),
|
||||||
|
tokens: token_response(tokens),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn me_handler(
|
||||||
|
auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||||
return Ok(json_error(
|
return Ok(json_error(
|
||||||
cot::http::StatusCode::UNAUTHORIZED,
|
cot::http::StatusCode::UNAUTHORIZED,
|
||||||
"not authenticated",
|
"not authenticated",
|
||||||
@@ -50,6 +333,146 @@ async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn password_login_handler(
|
||||||
|
db: Database,
|
||||||
|
raw_request: cot::request::Request,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let request = match parse_json_request::<PasswordLoginRequest>(raw_request).await? {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
if !config.auth_password_enabled {
|
||||||
|
crate::metrics::record_auth_attempt("api_password", "failure", "disabled");
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"password login is disabled",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = match User::get_by_username(&db, request.username.trim()).await {
|
||||||
|
Ok(Some(user)) if user.is_active() => user,
|
||||||
|
_ => {
|
||||||
|
crate::metrics::record_auth_attempt("api_password", "failure", "bad_credentials");
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"invalid username or password",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(hash) = user.password_ref() else {
|
||||||
|
crate::metrics::record_auth_attempt("api_password", "failure", "bad_credentials");
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"invalid username or password",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
match hash.verify(&Password::new(&request.password)) {
|
||||||
|
PasswordVerificationResult::Ok | PasswordVerificationResult::OkObsolete(_) => {
|
||||||
|
let auth_user = auth::AuthenticatedUser {
|
||||||
|
id: user.id_val(),
|
||||||
|
name: {
|
||||||
|
let display = user.display_name_str();
|
||||||
|
if display.is_empty() {
|
||||||
|
user.username_str().to_owned()
|
||||||
|
} else {
|
||||||
|
display
|
||||||
|
}
|
||||||
|
},
|
||||||
|
role: user.role(),
|
||||||
|
};
|
||||||
|
let tokens =
|
||||||
|
auth::create_api_session(&db, user.id_val(), request.device_name.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
crate::metrics::record_auth_attempt("api_password", "success", "ok");
|
||||||
|
crate::metrics::record_session_created("api_password");
|
||||||
|
Json(login_response(auth_user, tokens)).into_response()
|
||||||
|
}
|
||||||
|
PasswordVerificationResult::Invalid => {
|
||||||
|
crate::metrics::record_auth_attempt("api_password", "failure", "bad_credentials");
|
||||||
|
Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"invalid username or password",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_handler(
|
||||||
|
db: Database,
|
||||||
|
raw_request: cot::request::Request,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let request = match parse_json_request::<RefreshRequest>(raw_request).await? {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
|
||||||
|
match auth::refresh_api_session(&db, request.refresh_token.trim()).await {
|
||||||
|
Ok(Some(tokens)) => Json(token_response(tokens)).into_response(),
|
||||||
|
Ok(None) => Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"invalid refresh token",
|
||||||
|
)),
|
||||||
|
Err(err) => Err(cot::Error::internal(err.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sso_exchange_handler(
|
||||||
|
db: Database,
|
||||||
|
raw_request: cot::request::Request,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let request = match parse_json_request::<SsoExchangeRequest>(raw_request).await? {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
|
||||||
|
match auth::exchange_mobile_code_for_api_session(
|
||||||
|
&db,
|
||||||
|
request.code.trim(),
|
||||||
|
request.device_name.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some((user, tokens))) => {
|
||||||
|
crate::metrics::record_auth_attempt("api_sso_exchange", "success", "ok");
|
||||||
|
crate::metrics::record_session_created("api_sso_exchange");
|
||||||
|
Json(login_response(user, tokens)).into_response()
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
crate::metrics::record_auth_attempt("api_sso_exchange", "failure", "bad_code");
|
||||||
|
Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"invalid SSO exchange code",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(err) => Err(cot::Error::internal(err.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn logout_handler(
|
||||||
|
auth_ctx: auth::AuthContext,
|
||||||
|
db: Database,
|
||||||
|
raw_request: cot::request::Request,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let request = match parse_json_request::<LogoutRequest>(raw_request).await? {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
|
||||||
|
let revoked = auth::revoke_api_session(
|
||||||
|
&db,
|
||||||
|
auth_ctx.bearer_token(),
|
||||||
|
request.refresh_token.as_deref().map(str::trim),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Json(LogoutResponse { revoked }).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// App
|
// App
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -62,10 +485,101 @@ impl App for ApiApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn router(&self) -> Router {
|
fn router(&self) -> Router {
|
||||||
Router::with_urls([Route::with_api_handler_and_name(
|
Router::with_urls([
|
||||||
"/me",
|
Route::with_api_handler_and_name(
|
||||||
api_get(me_handler),
|
"/me",
|
||||||
"api_me",
|
api_get(documented_response_handler::<MeResponse, _>(
|
||||||
)])
|
me_handler,
|
||||||
|
"Get the current authenticated user",
|
||||||
|
)),
|
||||||
|
"api_me",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/auth/password",
|
||||||
|
api_post(documented_json_handler::<
|
||||||
|
PasswordLoginRequest,
|
||||||
|
AuthLoginResponse,
|
||||||
|
_,
|
||||||
|
>(
|
||||||
|
password_login_handler,
|
||||||
|
"Log in with username and password",
|
||||||
|
)),
|
||||||
|
"api_auth_password",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/auth/refresh",
|
||||||
|
api_post(documented_json_handler::<
|
||||||
|
RefreshRequest,
|
||||||
|
AuthTokenResponse,
|
||||||
|
_,
|
||||||
|
>(
|
||||||
|
refresh_handler, "Refresh an API token pair"
|
||||||
|
)),
|
||||||
|
"api_auth_refresh",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/auth/sso/exchange",
|
||||||
|
api_post(documented_json_handler::<
|
||||||
|
SsoExchangeRequest,
|
||||||
|
AuthLoginResponse,
|
||||||
|
_,
|
||||||
|
>(
|
||||||
|
sso_exchange_handler,
|
||||||
|
"Exchange a mobile SSO code for API tokens",
|
||||||
|
)),
|
||||||
|
"api_auth_sso_exchange",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/auth/logout",
|
||||||
|
api_post(documented_json_handler::<LogoutRequest, LogoutResponse, _>(
|
||||||
|
logout_handler,
|
||||||
|
"Revoke an API session",
|
||||||
|
)),
|
||||||
|
"api_auth_logout",
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use cot::aide::openapi::{PathItem, ReferenceOr};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn assert_get_path(paths: &cot::aide::openapi::Paths, path: &str) {
|
||||||
|
assert!(matches!(
|
||||||
|
paths.paths.get(path),
|
||||||
|
Some(ReferenceOr::Item(PathItem { get: Some(_), .. }))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_post_path(paths: &cot::aide::openapi::Paths, path: &str) {
|
||||||
|
assert!(matches!(
|
||||||
|
paths.paths.get(path),
|
||||||
|
Some(ReferenceOr::Item(PathItem { post: Some(_), .. }))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openapi_includes_auth_routes() {
|
||||||
|
let openapi = ApiApp.router().as_api();
|
||||||
|
let paths = openapi.paths.expect("OpenAPI paths");
|
||||||
|
|
||||||
|
assert_get_path(&paths, "/me");
|
||||||
|
assert_post_path(&paths, "/auth/password");
|
||||||
|
assert_post_path(&paths, "/auth/refresh");
|
||||||
|
assert_post_path(&paths, "/auth/sso/exchange");
|
||||||
|
assert_post_path(&paths, "/auth/logout");
|
||||||
|
|
||||||
|
let Some(ReferenceOr::Item(PathItem {
|
||||||
|
post: Some(operation),
|
||||||
|
..
|
||||||
|
})) = paths.paths.get("/auth/password")
|
||||||
|
else {
|
||||||
|
panic!("password auth path should be documented as POST");
|
||||||
|
};
|
||||||
|
assert!(operation.request_body.is_some());
|
||||||
|
assert!(operation.responses.is_some());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+588
-6
@@ -1,7 +1,13 @@
|
|||||||
|
use chrono::{Duration, Utc};
|
||||||
use cot::Body;
|
use cot::Body;
|
||||||
use cot::db::Database;
|
use cot::db::{Auto, Database, LimitedString, Model};
|
||||||
|
use cot::http::header::AUTHORIZATION;
|
||||||
|
use cot::request::RequestHead;
|
||||||
|
use cot::request::extractors::FromRequestHead;
|
||||||
use cot::response::IntoResponse;
|
use cot::response::IntoResponse;
|
||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
|
use serde::Serialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::user::User;
|
use crate::user::User;
|
||||||
|
|
||||||
@@ -37,6 +43,7 @@ impl Role {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const SESSION_USER_ID: &str = "user_id";
|
const SESSION_USER_ID: &str = "user_id";
|
||||||
|
const SESSION_POST_LOGIN_REDIRECT: &str = "post_login_redirect";
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AuthenticatedUser {
|
pub struct AuthenticatedUser {
|
||||||
@@ -45,11 +52,7 @@ pub struct AuthenticatedUser {
|
|||||||
pub role: Role,
|
pub role: Role,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read `user_id` from the session, fetch the `User` from DB, return
|
fn authenticated_user_from_user(user: User) -> Option<AuthenticatedUser> {
|
||||||
/// `AuthenticatedUser` if the user exists and is active.
|
|
||||||
pub async fn get_session_user(session: &Session, db: &Database) -> Option<AuthenticatedUser> {
|
|
||||||
let user_id: i64 = session.get(SESSION_USER_ID).await.ok()??;
|
|
||||||
let user = User::get_by_id(db, user_id).await.ok()??;
|
|
||||||
if !user.is_active() {
|
if !user.is_active() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -69,6 +72,362 @@ pub async fn get_session_user(session: &Session, db: &Database) -> Option<Authen
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read `user_id` from the session, fetch the `User` from DB, return
|
||||||
|
/// `AuthenticatedUser` if the user exists and is active.
|
||||||
|
pub async fn get_session_user(session: &Session, db: &Database) -> Option<AuthenticatedUser> {
|
||||||
|
let user_id: i64 = session.get(SESSION_USER_ID).await.ok()??;
|
||||||
|
let user = User::get_by_id(db, user_id).await.ok()??;
|
||||||
|
authenticated_user_from_user(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API bearer-token auth
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_PREFIX: &str = "furu_at_";
|
||||||
|
const REFRESH_TOKEN_PREFIX: &str = "furu_rt_";
|
||||||
|
const MOBILE_EXCHANGE_CODE_PREFIX: &str = "furu_mx_";
|
||||||
|
const ACCESS_TOKEN_TTL_MINUTES: i64 = 15;
|
||||||
|
const REFRESH_TOKEN_TTL_DAYS: i64 = 60;
|
||||||
|
const MOBILE_EXCHANGE_CODE_TTL_MINUTES: i64 = 3;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct AuthContext {
|
||||||
|
bearer_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthContext {
|
||||||
|
pub fn bearer_token(&self) -> Option<&str> {
|
||||||
|
self.bearer_token.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequestHead for AuthContext {
|
||||||
|
async fn from_request_head(head: &RequestHead) -> cot::Result<Self> {
|
||||||
|
let bearer_token = head
|
||||||
|
.headers
|
||||||
|
.get(AUTHORIZATION)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(parse_bearer_token)
|
||||||
|
.map(str::to_owned);
|
||||||
|
Ok(Self { bearer_token })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bearer_token(header: &str) -> Option<&str> {
|
||||||
|
let header = header.trim();
|
||||||
|
let (scheme, token) = header.split_once(' ')?;
|
||||||
|
if !scheme.eq_ignore_ascii_case("Bearer") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let token = token.trim();
|
||||||
|
if token.is_empty() || token.len() > 512 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ApiTokenPair {
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub token_type: &'static str,
|
||||||
|
pub expires_in_seconds: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[cot::db::model]
|
||||||
|
pub struct ApiSession {
|
||||||
|
#[model(primary_key)]
|
||||||
|
id: Auto<i64>,
|
||||||
|
user_id: i64,
|
||||||
|
device_name: Option<String>,
|
||||||
|
access_token_hash: LimitedString<128>,
|
||||||
|
refresh_token_hash: LimitedString<128>,
|
||||||
|
access_expires_at: String,
|
||||||
|
refresh_expires_at: String,
|
||||||
|
created_at: String,
|
||||||
|
last_used_at: Option<String>,
|
||||||
|
revoked_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[cot::db::model]
|
||||||
|
pub struct MobileExchangeCode {
|
||||||
|
#[model(primary_key)]
|
||||||
|
id: Auto<i64>,
|
||||||
|
code_hash: LimitedString<128>,
|
||||||
|
user_id: i64,
|
||||||
|
created_at: String,
|
||||||
|
expires_at: String,
|
||||||
|
consumed_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiSession {
|
||||||
|
pub async fn create_for_user(
|
||||||
|
db: &Database,
|
||||||
|
user_id: i64,
|
||||||
|
device_name: Option<&str>,
|
||||||
|
) -> cot::db::Result<ApiTokenPair> {
|
||||||
|
let tokens = fresh_token_pair();
|
||||||
|
let now = now_iso();
|
||||||
|
let mut session = Self {
|
||||||
|
id: Auto::auto(),
|
||||||
|
user_id,
|
||||||
|
device_name: device_name.and_then(normalize_device_name),
|
||||||
|
access_token_hash: LimitedString::new(&token_hash(&tokens.access_token)).unwrap(),
|
||||||
|
refresh_token_hash: LimitedString::new(&token_hash(&tokens.refresh_token)).unwrap(),
|
||||||
|
access_expires_at: access_expires_at(),
|
||||||
|
refresh_expires_at: refresh_expires_at(),
|
||||||
|
created_at: now.clone(),
|
||||||
|
last_used_at: Some(now),
|
||||||
|
revoked_at: None,
|
||||||
|
};
|
||||||
|
session.insert(db).await?;
|
||||||
|
Ok(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_access_token(db: &Database, token: &str) -> cot::db::Result<Option<Self>> {
|
||||||
|
let Ok(hash) = LimitedString::<128>::new(&token_hash(token)) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
cot::db::query!(ApiSession, $access_token_hash == hash)
|
||||||
|
.get(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_refresh_token(db: &Database, token: &str) -> cot::db::Result<Option<Self>> {
|
||||||
|
let Ok(hash) = LimitedString::<128>::new(&token_hash(token)) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
cot::db::query!(ApiSession, $refresh_token_hash == hash)
|
||||||
|
.get(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_revoked(&self) -> bool {
|
||||||
|
self.revoked_at.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn access_token_valid(&self) -> bool {
|
||||||
|
!self.is_revoked() && self.access_expires_at > now_iso()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_token_valid(&self) -> bool {
|
||||||
|
!self.is_revoked() && self.refresh_expires_at > now_iso()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rotate(&mut self, db: &Database) -> cot::db::Result<ApiTokenPair> {
|
||||||
|
let tokens = fresh_token_pair();
|
||||||
|
self.access_token_hash = LimitedString::new(&token_hash(&tokens.access_token)).unwrap();
|
||||||
|
self.refresh_token_hash = LimitedString::new(&token_hash(&tokens.refresh_token)).unwrap();
|
||||||
|
self.access_expires_at = access_expires_at();
|
||||||
|
self.refresh_expires_at = refresh_expires_at();
|
||||||
|
self.last_used_at = Some(now_iso());
|
||||||
|
self.save(db).await?;
|
||||||
|
Ok(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke(&mut self, db: &Database) -> cot::db::Result<()> {
|
||||||
|
if self.revoked_at.is_none() {
|
||||||
|
self.revoked_at = Some(now_iso());
|
||||||
|
self.save(db).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_api_session(
|
||||||
|
db: &Database,
|
||||||
|
user_id: i64,
|
||||||
|
device_name: Option<&str>,
|
||||||
|
) -> cot::db::Result<ApiTokenPair> {
|
||||||
|
ApiSession::create_for_user(db, user_id, device_name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_bearer_user(db: &Database, token: &str) -> Option<AuthenticatedUser> {
|
||||||
|
let session = ApiSession::find_by_access_token(db, token).await.ok()??;
|
||||||
|
if !session.access_token_valid() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let user = User::get_by_id(db, session.user_id).await.ok()??;
|
||||||
|
authenticated_user_from_user(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_request_user(
|
||||||
|
auth: &AuthContext,
|
||||||
|
session: &Session,
|
||||||
|
db: &Database,
|
||||||
|
) -> Option<AuthenticatedUser> {
|
||||||
|
if let Some(token) = auth.bearer_token() {
|
||||||
|
return get_bearer_user(db, token).await;
|
||||||
|
}
|
||||||
|
get_session_user(session, db).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresh_api_session(
|
||||||
|
db: &Database,
|
||||||
|
refresh_token: &str,
|
||||||
|
) -> cot::db::Result<Option<ApiTokenPair>> {
|
||||||
|
let Some(mut session) = ApiSession::find_by_refresh_token(db, refresh_token).await? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if !session.refresh_token_valid() {
|
||||||
|
session.revoke(db).await?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let Some(user) = User::get_by_id(db, session.user_id).await? else {
|
||||||
|
session.revoke(db).await?;
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if !user.is_active() {
|
||||||
|
session.revoke(db).await?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(session.rotate(db).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_api_session(
|
||||||
|
db: &Database,
|
||||||
|
access_token: Option<&str>,
|
||||||
|
refresh_token: Option<&str>,
|
||||||
|
) -> cot::db::Result<bool> {
|
||||||
|
let mut session = if let Some(token) = access_token {
|
||||||
|
ApiSession::find_by_access_token(db, token).await?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if session.is_none() {
|
||||||
|
if let Some(token) = refresh_token {
|
||||||
|
session = ApiSession::find_by_refresh_token(db, token).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(mut session) = session else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
session.revoke(db).await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileExchangeCode {
|
||||||
|
pub async fn create_for_user(db: &Database, user_id: i64) -> cot::db::Result<String> {
|
||||||
|
let code = random_token(MOBILE_EXCHANGE_CODE_PREFIX);
|
||||||
|
let now = now_iso();
|
||||||
|
let mut row = Self {
|
||||||
|
id: Auto::auto(),
|
||||||
|
code_hash: LimitedString::new(&token_hash(&code)).unwrap(),
|
||||||
|
user_id,
|
||||||
|
created_at: now,
|
||||||
|
expires_at: mobile_exchange_code_expires_at(),
|
||||||
|
consumed_at: None,
|
||||||
|
};
|
||||||
|
row.insert(db).await?;
|
||||||
|
Ok(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_code(db: &Database, code: &str) -> cot::db::Result<Option<Self>> {
|
||||||
|
let Ok(hash) = LimitedString::<128>::new(&token_hash(code)) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
cot::db::query!(MobileExchangeCode, $code_hash == hash)
|
||||||
|
.get(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_valid(&self) -> bool {
|
||||||
|
self.consumed_at.is_none() && self.expires_at > now_iso()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn consume(&mut self, db: &Database) -> cot::db::Result<()> {
|
||||||
|
self.consumed_at = Some(now_iso());
|
||||||
|
self.save(db).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_mobile_exchange_code(db: &Database, user_id: i64) -> cot::db::Result<String> {
|
||||||
|
MobileExchangeCode::create_for_user(db, user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn exchange_mobile_code_for_api_session(
|
||||||
|
db: &Database,
|
||||||
|
code: &str,
|
||||||
|
device_name: Option<&str>,
|
||||||
|
) -> cot::db::Result<Option<(AuthenticatedUser, ApiTokenPair)>> {
|
||||||
|
let Some(mut exchange_code) = MobileExchangeCode::find_by_code(db, code).await? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if !exchange_code.is_valid() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let Some(user) = User::get_by_id(db, exchange_code.user_id).await? else {
|
||||||
|
exchange_code.consume(db).await?;
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(auth_user) = authenticated_user_from_user(user) else {
|
||||||
|
exchange_code.consume(db).await?;
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
exchange_code.consume(db).await?;
|
||||||
|
let tokens = ApiSession::create_for_user(db, auth_user.id, device_name).await?;
|
||||||
|
Ok(Some((auth_user, tokens)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fresh_token_pair() -> ApiTokenPair {
|
||||||
|
ApiTokenPair {
|
||||||
|
access_token: random_token(ACCESS_TOKEN_PREFIX),
|
||||||
|
refresh_token: random_token(REFRESH_TOKEN_PREFIX),
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in_seconds: ACCESS_TOKEN_TTL_MINUTES * 60,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_token(prefix: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{prefix}{}{}",
|
||||||
|
uuid::Uuid::new_v4().simple(),
|
||||||
|
uuid::Uuid::new_v4().simple()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_hash(token: &str) -> String {
|
||||||
|
let digest = Sha256::digest(token.as_bytes());
|
||||||
|
let mut out = String::with_capacity(digest.len() * 2);
|
||||||
|
for byte in digest {
|
||||||
|
out.push_str(&format!("{byte:02x}"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_device_name(name: &str) -> Option<String> {
|
||||||
|
let trimmed = name.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(trimmed.chars().take(255).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_iso() -> String {
|
||||||
|
Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn access_expires_at() -> String {
|
||||||
|
(Utc::now() + Duration::minutes(ACCESS_TOKEN_TTL_MINUTES))
|
||||||
|
.format("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_expires_at() -> String {
|
||||||
|
(Utc::now() + Duration::days(REFRESH_TOKEN_TTL_DAYS))
|
||||||
|
.format("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mobile_exchange_code_expires_at() -> String {
|
||||||
|
(Utc::now() + Duration::minutes(MOBILE_EXCHANGE_CODE_TTL_MINUTES))
|
||||||
|
.format("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Return `Ok(user)` if the session belongs to an active admin, otherwise
|
/// Return `Ok(user)` if the session belongs to an active admin, otherwise
|
||||||
/// `Err(response)` — a redirect to `/login` or a 403.
|
/// `Err(response)` — a redirect to `/login` or a 403.
|
||||||
pub async fn require_admin_or_redirect(
|
pub async fn require_admin_or_redirect(
|
||||||
@@ -103,6 +462,43 @@ pub async fn login(session: &Session, user_id: i64) -> cot::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn remember_post_login_redirect(session: &Session, location: &str) -> cot::Result<()> {
|
||||||
|
if let Some(location) = safe_internal_redirect(location) {
|
||||||
|
session
|
||||||
|
.insert(SESSION_POST_LOGIN_REDIRECT, location)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_post_login_redirect(session: &Session) -> cot::Result<Option<String>> {
|
||||||
|
let location: Option<String> = session
|
||||||
|
.get(SESSION_POST_LOGIN_REDIRECT)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Ok(location.and_then(|value| safe_internal_redirect(&value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn clear_post_login_redirect(session: &Session) -> cot::Result<()> {
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_POST_LOGIN_REDIRECT)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_internal_redirect(location: &str) -> Option<String> {
|
||||||
|
let location = location.trim();
|
||||||
|
if !location.starts_with('/') || location.starts_with("//") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if location.bytes().any(|b| matches!(b, b'\r' | b'\n')) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(location.chars().take(2048).collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Flush (destroy) the session.
|
/// Flush (destroy) the session.
|
||||||
pub async fn logout(session: &Session) -> cot::Result<()> {
|
pub async fn logout(session: &Session) -> cot::Result<()> {
|
||||||
session
|
session
|
||||||
@@ -121,6 +517,192 @@ pub fn redirect(location: &str) -> cot::response::Response {
|
|||||||
.expect("valid response")
|
.expect("valid response")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Migrations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub mod db_migrations {
|
||||||
|
use cot::db::migrations::{self, Field, Operation, SyncDynMigration};
|
||||||
|
use cot::db::{DatabaseField, Identifier, LimitedString};
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0038CreateApiSession;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0038CreateApiSession {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0038_create_api_session";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0003_create_user",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||||
|
.table_name(Identifier::new("furumusic__api_session"))
|
||||||
|
.fields(&[
|
||||||
|
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||||
|
.primary_key()
|
||||||
|
.auto(),
|
||||||
|
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("device_name"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
)
|
||||||
|
.set_null(true),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("access_token_hash"),
|
||||||
|
<LimitedString<128> as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("refresh_token_hash"),
|
||||||
|
<LimitedString<128> as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("access_expires_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("refresh_expires_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("created_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("last_used_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
)
|
||||||
|
.set_null(true),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("revoked_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
)
|
||||||
|
.set_null(true),
|
||||||
|
])
|
||||||
|
.build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_api_session_indexes(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX idx_api_session_access_token_hash \
|
||||||
|
ON furumusic__api_session (access_token_hash)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX idx_api_session_refresh_token_hash \
|
||||||
|
ON furumusic__api_session (refresh_token_hash)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX idx_api_session_user_id \
|
||||||
|
ON furumusic__api_session (user_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0039CreateApiSessionIndexes;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0039CreateApiSessionIndexes {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0039_create_api_session_indexes";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0038_create_api_session",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_api_session_indexes).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0040CreateMobileExchangeCode;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0040CreateMobileExchangeCode {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0040_create_mobile_exchange_code";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0039_create_api_session_indexes",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||||
|
.table_name(Identifier::new("furumusic__mobile_exchange_code"))
|
||||||
|
.fields(&[
|
||||||
|
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||||
|
.primary_key()
|
||||||
|
.auto(),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("code_hash"),
|
||||||
|
<LimitedString<128> as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("created_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("expires_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("consumed_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
)
|
||||||
|
.set_null(true),
|
||||||
|
])
|
||||||
|
.build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_mobile_exchange_code_indexes(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX idx_mobile_exchange_code_hash \
|
||||||
|
ON furumusic__mobile_exchange_code (code_hash)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX idx_mobile_exchange_code_user_id \
|
||||||
|
ON furumusic__mobile_exchange_code (user_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0041CreateMobileExchangeCodeIndexes;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0041CreateMobileExchangeCodeIndexes {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0041_create_mobile_exchange_code_indexes";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0040_create_mobile_exchange_code",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_mobile_exchange_code_indexes).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||||
|
&M0038CreateApiSession,
|
||||||
|
&M0039CreateApiSessionIndexes,
|
||||||
|
&M0040CreateMobileExchangeCode,
|
||||||
|
&M0041CreateMobileExchangeCodeIndexes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+298
@@ -135,6 +135,19 @@ pub struct ConfigSources {
|
|||||||
pub agent_concurrency: ConfigSource,
|
pub agent_concurrency: ConfigSource,
|
||||||
pub lastfm_api_key: ConfigSource,
|
pub lastfm_api_key: ConfigSource,
|
||||||
pub lastfm_shared_secret: ConfigSource,
|
pub lastfm_shared_secret: ConfigSource,
|
||||||
|
pub federation_enabled: ConfigSource,
|
||||||
|
pub federation_network_id: ConfigSource,
|
||||||
|
pub federation_save_on_listen: ConfigSource,
|
||||||
|
pub similarity_enabled: ConfigSource,
|
||||||
|
pub similarity_model: ConfigSource,
|
||||||
|
pub similarity_profile: ConfigSource,
|
||||||
|
pub similarity_workers: ConfigSource,
|
||||||
|
pub downloads_enabled: ConfigSource,
|
||||||
|
pub torrent_downloads_enabled: ConfigSource,
|
||||||
|
pub youtube_downloads_enabled: ConfigSource,
|
||||||
|
pub download_proxies: ConfigSource,
|
||||||
|
pub torrent_proxy_id: ConfigSource,
|
||||||
|
pub youtube_proxy_id: ConfigSource,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ConfigSources {
|
impl Default for ConfigSources {
|
||||||
@@ -162,6 +175,19 @@ impl Default for ConfigSources {
|
|||||||
agent_concurrency: ConfigSource::Default,
|
agent_concurrency: ConfigSource::Default,
|
||||||
lastfm_api_key: ConfigSource::Default,
|
lastfm_api_key: ConfigSource::Default,
|
||||||
lastfm_shared_secret: ConfigSource::Default,
|
lastfm_shared_secret: ConfigSource::Default,
|
||||||
|
federation_enabled: ConfigSource::Default,
|
||||||
|
federation_network_id: ConfigSource::Default,
|
||||||
|
federation_save_on_listen: ConfigSource::Default,
|
||||||
|
similarity_enabled: ConfigSource::Default,
|
||||||
|
similarity_model: ConfigSource::Default,
|
||||||
|
similarity_profile: ConfigSource::Default,
|
||||||
|
similarity_workers: ConfigSource::Default,
|
||||||
|
downloads_enabled: ConfigSource::Default,
|
||||||
|
torrent_downloads_enabled: ConfigSource::Default,
|
||||||
|
youtube_downloads_enabled: ConfigSource::Default,
|
||||||
|
download_proxies: ConfigSource::Default,
|
||||||
|
torrent_proxy_id: ConfigSource::Default,
|
||||||
|
youtube_proxy_id: ConfigSource::Default,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,6 +250,84 @@ macro_rules! impl_env_overrides {
|
|||||||
// AppConfig
|
// AppConfig
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Saved SOCKS5 proxy used by user-facing download methods.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct DownloadProxy {
|
||||||
|
pub id: String,
|
||||||
|
pub address: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub username: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DownloadProxy {
|
||||||
|
/// Validate and normalize a proxy without performing network or DNS I/O.
|
||||||
|
pub fn normalized(mut self) -> anyhow::Result<Self> {
|
||||||
|
self.id = self.id.trim().to_string();
|
||||||
|
self.address = self.address.trim().to_string();
|
||||||
|
|
||||||
|
if self.id.is_empty() || self.id.len() > 64 {
|
||||||
|
anyhow::bail!("proxy id must contain from 1 to 64 characters");
|
||||||
|
}
|
||||||
|
if !self
|
||||||
|
.id
|
||||||
|
.chars()
|
||||||
|
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
|
||||||
|
{
|
||||||
|
anyhow::bail!("proxy id may contain only letters, digits, '-' and '_'");
|
||||||
|
}
|
||||||
|
if self.address.is_empty() || self.address.len() > 512 {
|
||||||
|
anyhow::bail!("proxy address must contain a host and port");
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.address
|
||||||
|
.chars()
|
||||||
|
.any(|character| matches!(character, '/' | '?' | '#' | '@'))
|
||||||
|
{
|
||||||
|
anyhow::bail!("proxy address must be in host:port format");
|
||||||
|
}
|
||||||
|
if self.username.len() > 256 || self.password.len() > 256 {
|
||||||
|
anyhow::bail!("proxy credentials are too long");
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = reqwest::Url::parse(&format!("socks5://{}", self.address))
|
||||||
|
.map_err(|_| anyhow::anyhow!("proxy address must be in host:port format"))?;
|
||||||
|
let host = parsed
|
||||||
|
.host_str()
|
||||||
|
.filter(|host| !host.trim().is_empty())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("proxy address has no host"))?;
|
||||||
|
let port = parsed
|
||||||
|
.port()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("proxy address has no port"))?;
|
||||||
|
if port == 0 {
|
||||||
|
anyhow::bail!("proxy port must be between 1 and 65535");
|
||||||
|
}
|
||||||
|
self.address = if host.starts_with('[') && host.ends_with(']') {
|
||||||
|
format!("{host}:{port}")
|
||||||
|
} else if host.contains(':') {
|
||||||
|
format!("[{host}]:{port}")
|
||||||
|
} else {
|
||||||
|
format!("{host}:{port}")
|
||||||
|
};
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the URL accepted by librqbit and yt-dlp. Credentials are included
|
||||||
|
/// only when both fields are non-empty.
|
||||||
|
pub fn socks_url(&self) -> anyhow::Result<String> {
|
||||||
|
let proxy = self.clone().normalized()?;
|
||||||
|
let mut url = reqwest::Url::parse(&format!("socks5://{}", proxy.address))?;
|
||||||
|
if !proxy.username.is_empty() && !proxy.password.is_empty() {
|
||||||
|
url.set_username(&proxy.username)
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid proxy username"))?;
|
||||||
|
url.set_password(Some(&proxy.password))
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid proxy password"))?;
|
||||||
|
}
|
||||||
|
Ok(url.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
/// PostgreSQL connection URL.
|
/// PostgreSQL connection URL.
|
||||||
@@ -270,6 +374,35 @@ pub struct AppConfig {
|
|||||||
pub lastfm_api_key: String,
|
pub lastfm_api_key: String,
|
||||||
/// Last.fm shared secret for authenticated scrobbling calls.
|
/// Last.fm shared secret for authenticated scrobbling calls.
|
||||||
pub lastfm_shared_secret: String,
|
pub lastfm_shared_secret: String,
|
||||||
|
/// Whether this server participates in the furumi federation (publishes
|
||||||
|
/// its library into the shared DHT and serves audio to peers).
|
||||||
|
pub federation_enabled: bool,
|
||||||
|
/// Federation network id — the shared secret every peer of the network
|
||||||
|
/// uses to find the others.
|
||||||
|
pub federation_network_id: String,
|
||||||
|
/// Whether a federated track requested for playback is imported into the
|
||||||
|
/// shared local library. This is a server-wide administrator policy.
|
||||||
|
pub federation_save_on_listen: bool,
|
||||||
|
/// Whether local embedding calculation and similarity search are enabled.
|
||||||
|
pub similarity_enabled: bool,
|
||||||
|
/// Embedding model selected by the administrator.
|
||||||
|
pub similarity_model: String,
|
||||||
|
/// Audio preprocessing profile selected by the administrator.
|
||||||
|
pub similarity_profile: String,
|
||||||
|
/// Maximum number of concurrent CPU embedding workers.
|
||||||
|
pub similarity_workers: u64,
|
||||||
|
/// Whether the download manager and local-file uploads are available.
|
||||||
|
pub downloads_enabled: bool,
|
||||||
|
/// Whether torrent imports are available when the download manager is enabled.
|
||||||
|
pub torrent_downloads_enabled: bool,
|
||||||
|
/// Whether YouTube imports are available when the download manager is enabled.
|
||||||
|
pub youtube_downloads_enabled: bool,
|
||||||
|
/// JSON-encoded list of [`DownloadProxy`] entries.
|
||||||
|
pub download_proxies: String,
|
||||||
|
/// Saved proxy id used for torrent downloads; empty means a direct connection.
|
||||||
|
pub torrent_proxy_id: String,
|
||||||
|
/// Saved proxy id used for YouTube downloads; empty means a direct connection.
|
||||||
|
pub youtube_proxy_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
@@ -297,6 +430,22 @@ impl Default for AppConfig {
|
|||||||
agent_concurrency: 2,
|
agent_concurrency: 2,
|
||||||
lastfm_api_key: String::new(),
|
lastfm_api_key: String::new(),
|
||||||
lastfm_shared_secret: String::new(),
|
lastfm_shared_secret: String::new(),
|
||||||
|
federation_enabled: false,
|
||||||
|
federation_network_id: String::new(),
|
||||||
|
federation_save_on_listen: false,
|
||||||
|
similarity_enabled: false,
|
||||||
|
similarity_model: "discogs-effnet-bsdynamic-1".into(),
|
||||||
|
similarity_profile: "furumi-full-track-v1".into(),
|
||||||
|
similarity_workers: std::thread::available_parallelism()
|
||||||
|
.map(|count| (count.get() / 2).clamp(1, 4) as u64)
|
||||||
|
.unwrap_or(1),
|
||||||
|
// Preserve the behavior from before these controls were added.
|
||||||
|
downloads_enabled: true,
|
||||||
|
torrent_downloads_enabled: true,
|
||||||
|
youtube_downloads_enabled: true,
|
||||||
|
download_proxies: "[]".into(),
|
||||||
|
torrent_proxy_id: String::new(),
|
||||||
|
youtube_proxy_id: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,6 +474,19 @@ impl_env_overrides!(
|
|||||||
agent_concurrency,
|
agent_concurrency,
|
||||||
lastfm_api_key,
|
lastfm_api_key,
|
||||||
lastfm_shared_secret,
|
lastfm_shared_secret,
|
||||||
|
federation_enabled,
|
||||||
|
federation_network_id,
|
||||||
|
federation_save_on_listen,
|
||||||
|
similarity_enabled,
|
||||||
|
similarity_model,
|
||||||
|
similarity_profile,
|
||||||
|
similarity_workers,
|
||||||
|
downloads_enabled,
|
||||||
|
torrent_downloads_enabled,
|
||||||
|
youtube_downloads_enabled,
|
||||||
|
download_proxies,
|
||||||
|
torrent_proxy_id,
|
||||||
|
youtube_proxy_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
@@ -338,10 +500,52 @@ impl AppConfig {
|
|||||||
pub fn load() -> Self {
|
pub fn load() -> Self {
|
||||||
let mut cfg = Self::default();
|
let mut cfg = Self::default();
|
||||||
cfg.apply_env_overrides();
|
cfg.apply_env_overrides();
|
||||||
|
cfg.apply_startup_db_overrides();
|
||||||
|
cfg.apply_env_overrides();
|
||||||
cfg.normalize_host_paths();
|
cfg.normalize_host_paths();
|
||||||
cfg
|
cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_startup_db_overrides(&mut self) {
|
||||||
|
if self.database_url.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if tokio::runtime::Handle::try_current().is_ok() {
|
||||||
|
tracing::warn!("skipping startup DB config load from inside an existing Tokio runtime");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let database_url = self.database_url.clone();
|
||||||
|
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
else {
|
||||||
|
tracing::warn!("failed to create runtime for startup DB config load");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = runtime.block_on(async move {
|
||||||
|
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect(&database_url)
|
||||||
|
.await?;
|
||||||
|
sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT value FROM furumusic__config_entry WHERE key = 'swagger_enabled'",
|
||||||
|
)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Some(value)) => match value.parse::<bool>() {
|
||||||
|
Ok(value) => self.swagger_enabled = value,
|
||||||
|
Err(_) => tracing::warn!("ignoring invalid DB config value for swagger_enabled"),
|
||||||
|
},
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(err) => tracing::warn!("failed to read startup DB config overrides: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build config with full 3-layer resolution (default → DB → env) and
|
/// Build config with full 3-layer resolution (default → DB → env) and
|
||||||
/// track the source of each field.
|
/// track the source of each field.
|
||||||
pub async fn load_with_db(db: &Database) -> (Self, ConfigSources) {
|
pub async fn load_with_db(db: &Database) -> (Self, ConfigSources) {
|
||||||
@@ -410,6 +614,46 @@ impl AppConfig {
|
|||||||
apply_db_field!(agent_concurrency);
|
apply_db_field!(agent_concurrency);
|
||||||
apply_db_field!(lastfm_api_key);
|
apply_db_field!(lastfm_api_key);
|
||||||
apply_db_field!(lastfm_shared_secret);
|
apply_db_field!(lastfm_shared_secret);
|
||||||
|
apply_db_field!(federation_enabled);
|
||||||
|
apply_db_field!(federation_network_id);
|
||||||
|
apply_db_field!(federation_save_on_listen);
|
||||||
|
apply_db_field!(similarity_enabled);
|
||||||
|
apply_db_field!(similarity_model);
|
||||||
|
apply_db_field!(similarity_profile);
|
||||||
|
apply_db_field!(similarity_workers);
|
||||||
|
apply_db_field!(downloads_enabled);
|
||||||
|
apply_db_field!(torrent_downloads_enabled);
|
||||||
|
apply_db_field!(youtube_downloads_enabled);
|
||||||
|
apply_db_field!(download_proxies);
|
||||||
|
apply_db_field!(torrent_proxy_id);
|
||||||
|
apply_db_field!(youtube_proxy_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parsed_download_proxies(&self) -> anyhow::Result<Vec<DownloadProxy>> {
|
||||||
|
let proxies: Vec<DownloadProxy> = serde_json::from_str(&self.download_proxies)
|
||||||
|
.map_err(|_| anyhow::anyhow!("saved download proxy list is invalid"))?;
|
||||||
|
proxies.into_iter().map(DownloadProxy::normalized).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_proxy_url(&self, proxy_id: &str) -> anyhow::Result<Option<String>> {
|
||||||
|
let proxy_id = proxy_id.trim();
|
||||||
|
if proxy_id.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let proxy = self
|
||||||
|
.parsed_download_proxies()?
|
||||||
|
.into_iter()
|
||||||
|
.find(|proxy| proxy.id == proxy_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("selected download proxy is not configured"))?;
|
||||||
|
proxy.socks_url().map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn torrent_proxy_url(&self) -> anyhow::Result<Option<String>> {
|
||||||
|
self.selected_proxy_url(&self.torrent_proxy_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn youtube_proxy_url(&self) -> anyhow::Result<Option<String>> {
|
||||||
|
self.selected_proxy_url(&self.youtube_proxy_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +673,60 @@ mod tests {
|
|||||||
let cfg = AppConfig::default();
|
let cfg = AppConfig::default();
|
||||||
assert!(cfg.database_url.is_empty());
|
assert!(cfg.database_url.is_empty());
|
||||||
assert_eq!(cfg.log_level, "info");
|
assert_eq!(cfg.log_level, "info");
|
||||||
|
assert!(!cfg.similarity_enabled);
|
||||||
|
assert_eq!(cfg.similarity_model, crate::similarity::DEFAULT_MODEL_ID);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.similarity_profile,
|
||||||
|
crate::similarity::DEFAULT_PROFILE_ID
|
||||||
|
);
|
||||||
|
assert!((1..=4).contains(&cfg.similarity_workers));
|
||||||
|
assert!(cfg.downloads_enabled);
|
||||||
|
assert!(cfg.torrent_downloads_enabled);
|
||||||
|
assert!(cfg.youtube_downloads_enabled);
|
||||||
|
assert!(cfg.parsed_download_proxies().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_proxy_url_encodes_complete_credentials() {
|
||||||
|
let proxy = DownloadProxy {
|
||||||
|
id: "proxy-1".into(),
|
||||||
|
address: "proxy.example:1080".into(),
|
||||||
|
username: "user name".into(),
|
||||||
|
password: "p@ss:word".into(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
proxy.socks_url().unwrap(),
|
||||||
|
"socks5://user%20name:p%40ss%3Aword@proxy.example:1080"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_proxy_url_omits_partial_credentials() {
|
||||||
|
let proxy = DownloadProxy {
|
||||||
|
id: "proxy-1".into(),
|
||||||
|
address: "127.0.0.1:1080".into(),
|
||||||
|
username: "user".into(),
|
||||||
|
password: String::new(),
|
||||||
|
};
|
||||||
|
assert_eq!(proxy.socks_url().unwrap(), "socks5://127.0.0.1:1080");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selected_download_proxy_is_resolved_by_id() {
|
||||||
|
let mut cfg = AppConfig::default();
|
||||||
|
cfg.download_proxies = serde_json::to_string(&[DownloadProxy {
|
||||||
|
id: "youtube".into(),
|
||||||
|
address: "[::1]:9050".into(),
|
||||||
|
username: String::new(),
|
||||||
|
password: String::new(),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
cfg.youtube_proxy_id = "youtube".into();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.youtube_proxy_url().unwrap().as_deref(),
|
||||||
|
Some("socks5://[::1]:9050")
|
||||||
|
);
|
||||||
|
assert_eq!(cfg.torrent_proxy_url().unwrap(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
//! Informational publication of the protocol versions exposed by this peer.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use music_dht::StreamAcceptor;
|
||||||
|
use music_dht::capabilities::{
|
||||||
|
CAPABILITIES_PROTOCOL_VERSION, CapabilityManifest, CapabilityMessage, JAM_ID, SIMILARITY_ID,
|
||||||
|
read_message, write_message,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::serve::AUDIO_PROTOCOL_VERSION;
|
||||||
|
|
||||||
|
fn local_manifest() -> CapabilityManifest {
|
||||||
|
CapabilityManifest::frid("furumusic", env!("CARGO_PKG_VERSION"))
|
||||||
|
// The web server does not expose federation Jam yet.
|
||||||
|
.without_protocol(JAM_ID)
|
||||||
|
.with_protocol("audio", AUDIO_PROTOCOL_VERSION)
|
||||||
|
.with_protocol(
|
||||||
|
SIMILARITY_ID,
|
||||||
|
music_dht::similarity::SIMILARITY_PROTOCOL_VERSION,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve(mut acceptor: StreamAcceptor) {
|
||||||
|
while let Some(stream) = acceptor.accept().await {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(error) = serve_one(stream).await {
|
||||||
|
tracing::debug!("capability stream failed: {error:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_one(mut stream: music_dht::ByteStream) -> Result<()> {
|
||||||
|
let response = match read_message(&mut stream).await? {
|
||||||
|
CapabilityMessage::Get {
|
||||||
|
version: CAPABILITIES_PROTOCOL_VERSION,
|
||||||
|
} => CapabilityMessage::Manifest {
|
||||||
|
manifest: local_manifest(),
|
||||||
|
},
|
||||||
|
CapabilityMessage::Get { version } => CapabilityMessage::Error {
|
||||||
|
message: format!("unsupported capability protocol {version}"),
|
||||||
|
},
|
||||||
|
_ => CapabilityMessage::Error {
|
||||||
|
message: "expected capability request".to_string(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
write_message(&mut stream, &response).await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
let _ = tokio::time::timeout(Duration::from_secs(2), stream.send.stopped()).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_describes_only_supported_player_protocols() {
|
||||||
|
let manifest = local_manifest();
|
||||||
|
assert_eq!(manifest.application, "furumusic");
|
||||||
|
assert_eq!(
|
||||||
|
manifest.protocols.get("audio"),
|
||||||
|
Some(&AUDIO_PROTOCOL_VERSION)
|
||||||
|
);
|
||||||
|
assert!(!manifest.protocols.contains_key(JAM_ID));
|
||||||
|
assert_eq!(
|
||||||
|
manifest.protocols.get(SIMILARITY_ID),
|
||||||
|
Some(&music_dht::similarity::SIMILARITY_PROTOCOL_VERSION)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest
|
||||||
|
.protocols
|
||||||
|
.get(music_dht::capabilities::SIMILARITY_DHT_ID),
|
||||||
|
Some(&music_dht::similarity_lsh::SIMILARITY_DHT_PROTOCOL_VERSION)
|
||||||
|
);
|
||||||
|
manifest.validate().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,600 @@
|
|||||||
|
//! Receiving side of music federation.
|
||||||
|
//!
|
||||||
|
//! User-facing identity is content-addressed. An `(owner, item_id)` pair is
|
||||||
|
//! only a source locator and several locators may resolve the same track.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use music_dht::{ItemKind, LibraryItem, normalize_content_id};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use sqlx::Row as _;
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
|
use super::{Federation, now_iso};
|
||||||
|
|
||||||
|
const MAX_CATALOG_BYTES: u64 = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TrackKeyDto {
|
||||||
|
pub content_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ArtistKeyDto {
|
||||||
|
pub normalized_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ArtistRefDto {
|
||||||
|
pub key: ArtistKeyDto,
|
||||||
|
pub name: String,
|
||||||
|
pub local_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ReleaseKeyDto {
|
||||||
|
pub normalized_title: String,
|
||||||
|
pub primary_artists: Vec<String>,
|
||||||
|
pub release_type: Option<String>,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ReleaseRefDto {
|
||||||
|
pub key: ReleaseKeyDto,
|
||||||
|
pub local_id: Option<i64>,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct FederationSourceDto {
|
||||||
|
pub owner: String,
|
||||||
|
pub item_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct LocalAvailabilityDto {
|
||||||
|
pub track_id: i64,
|
||||||
|
pub stream_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TrackMetadataDto {
|
||||||
|
pub title: String,
|
||||||
|
pub artists: Vec<ArtistRefDto>,
|
||||||
|
pub featured_artists: Vec<ArtistRefDto>,
|
||||||
|
pub release: Option<ReleaseRefDto>,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub duration_seconds: Option<f64>,
|
||||||
|
pub track_number: Option<i32>,
|
||||||
|
pub disc_number: Option<i32>,
|
||||||
|
pub cover_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TrackAvailabilityDto {
|
||||||
|
pub state: &'static str,
|
||||||
|
pub local: Option<LocalAvailabilityDto>,
|
||||||
|
pub federation: Vec<FederationSourceDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TrackDto {
|
||||||
|
pub key: TrackKeyDto,
|
||||||
|
pub metadata: TrackMetadataDto,
|
||||||
|
pub availability: TrackAvailabilityDto,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub similarity_score: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SearchEvent {
|
||||||
|
pub search_id: String,
|
||||||
|
pub sequence: u64,
|
||||||
|
pub kind: &'static str,
|
||||||
|
pub peer: Option<String>,
|
||||||
|
pub entity_key: Value,
|
||||||
|
pub entity: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Federation {
|
||||||
|
pub async fn prepare_similarity_tracks(
|
||||||
|
&self,
|
||||||
|
tracks: Vec<super::similarity::RemoteSimilarityTrack>,
|
||||||
|
) -> Result<Vec<TrackDto>> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
let mut prepared = Vec::new();
|
||||||
|
for track in tracks {
|
||||||
|
let Some(content_id) = track.content_id.as_deref().and_then(normalize_content_id)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let local = local_availability(&pool, &content_id).await?;
|
||||||
|
// A local result is already present in the first result section.
|
||||||
|
if local.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let owner = track.owner;
|
||||||
|
let item_id = track.item_id;
|
||||||
|
let dto = TrackDto {
|
||||||
|
key: TrackKeyDto {
|
||||||
|
content_id: content_id.clone(),
|
||||||
|
},
|
||||||
|
metadata: TrackMetadataDto {
|
||||||
|
title: track.title,
|
||||||
|
artists: artist_refs(&track.artist_names),
|
||||||
|
featured_artists: artist_refs(&track.featured_artist_names),
|
||||||
|
release: track.release_title.map(|title| ReleaseRefDto {
|
||||||
|
key: ReleaseKeyDto {
|
||||||
|
normalized_title: music_dht::normalize_name(&title),
|
||||||
|
primary_artists: track
|
||||||
|
.artist_names
|
||||||
|
.iter()
|
||||||
|
.map(|artist| music_dht::normalize_name(artist))
|
||||||
|
.collect(),
|
||||||
|
release_type: None,
|
||||||
|
year: track.year,
|
||||||
|
},
|
||||||
|
local_id: None,
|
||||||
|
title,
|
||||||
|
}),
|
||||||
|
year: track.year,
|
||||||
|
duration_seconds: track.duration_seconds.map(|value| value as f64),
|
||||||
|
track_number: track.track_number,
|
||||||
|
disc_number: track.disc_number,
|
||||||
|
cover_url: Some(format!(
|
||||||
|
"/api/player/federation/tracks/artwork?owner={owner}&item_id={item_id}"
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
availability: TrackAvailabilityDto {
|
||||||
|
state: "federated",
|
||||||
|
local: None,
|
||||||
|
federation: vec![FederationSourceDto { owner, item_id }],
|
||||||
|
},
|
||||||
|
similarity_score: Some(track.similarity_score),
|
||||||
|
};
|
||||||
|
persist_track_ref(&pool, &dto).await?;
|
||||||
|
prepared.push(dto);
|
||||||
|
}
|
||||||
|
Ok(prepared)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stream_artist_catalogs(
|
||||||
|
self: &std::sync::Arc<Self>,
|
||||||
|
name: String,
|
||||||
|
) -> tokio::sync::mpsc::UnboundedReceiver<Result<(String, music_dht::catalog::CatalogArtist)>>
|
||||||
|
{
|
||||||
|
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let federation = std::sync::Arc::clone(self);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = async {
|
||||||
|
let service = federation.service().await?;
|
||||||
|
let normalized = music_dht::normalize_name(&name);
|
||||||
|
let outcome = service
|
||||||
|
.search_network(&name)
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("federated artist search failed: {err}"))?;
|
||||||
|
let owners: std::collections::HashSet<_> = outcome
|
||||||
|
.network_results
|
||||||
|
.iter()
|
||||||
|
.filter(|item| {
|
||||||
|
(item.kind == ItemKind::Artist && item.normalized_name == normalized)
|
||||||
|
|| item
|
||||||
|
.artist_names
|
||||||
|
.iter()
|
||||||
|
.chain(&item.featured_artist_names)
|
||||||
|
.any(|artist| music_dht::normalize_name(artist) == normalized)
|
||||||
|
})
|
||||||
|
.map(|item| item.owner)
|
||||||
|
.collect();
|
||||||
|
for owner in owners {
|
||||||
|
let service = std::sync::Arc::clone(&service);
|
||||||
|
let sender = sender.clone();
|
||||||
|
let name = name.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(8),
|
||||||
|
fetch_artist_catalog(&service, owner, &name),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("catalog request timed out"))
|
||||||
|
.and_then(|result| result)
|
||||||
|
.map(|artist| (owner.to_string(), artist));
|
||||||
|
let _ = sender.send(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok::<(), anyhow::Error>(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
if let Err(err) = result {
|
||||||
|
let _ = sender.send(Err(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
receiver
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs one bounded DHT search and returns entity upserts. The HTTP
|
||||||
|
/// layer streams each upsert independently; catalog fan-out can append
|
||||||
|
/// events to the same contract without changing the browser model.
|
||||||
|
pub async fn search_events(&self, search_id: &str, query: &str) -> Result<Vec<SearchEvent>> {
|
||||||
|
let query = query.trim();
|
||||||
|
anyhow::ensure!(!query.is_empty(), "search query is empty");
|
||||||
|
anyhow::ensure!(query.chars().count() <= 200, "search query is too long");
|
||||||
|
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let service = self.service().await?;
|
||||||
|
tracing::info!(
|
||||||
|
search_id,
|
||||||
|
query,
|
||||||
|
connected_peers = service.connected_peers().len(),
|
||||||
|
known_contacts = service.known_peers().len(),
|
||||||
|
"federated search started"
|
||||||
|
);
|
||||||
|
let own = service.endpoint_id();
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(20),
|
||||||
|
service.search_network(query),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("federated search timed out after 20 seconds"))?
|
||||||
|
.map_err(|err| anyhow::anyhow!("federated search failed: {err}"))?;
|
||||||
|
tracing::info!(
|
||||||
|
search_id,
|
||||||
|
query,
|
||||||
|
local_results = result.local_results.len(),
|
||||||
|
network_results = result.network_results.len(),
|
||||||
|
queried_nodes = result.queried_nodes,
|
||||||
|
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||||
|
"federated DHT search finished"
|
||||||
|
);
|
||||||
|
let all_items: Vec<LibraryItem> = result
|
||||||
|
.local_results
|
||||||
|
.into_iter()
|
||||||
|
.chain(result.network_results)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
let mut by_content: HashMap<String, TrackDto> = HashMap::new();
|
||||||
|
for item in all_items.iter().filter(|item| item.kind == ItemKind::Track) {
|
||||||
|
let Some(content_id) = item.content_id.as_deref().and_then(normalize_content_id) else {
|
||||||
|
// A globally usable track reference must be verifiable.
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let local = local_availability(&pool, &content_id).await?;
|
||||||
|
let source = FederationSourceDto {
|
||||||
|
owner: item.owner.to_string(),
|
||||||
|
item_id: hex(item.id.as_bytes()),
|
||||||
|
};
|
||||||
|
let entry = by_content.entry(content_id.clone()).or_insert_with(|| {
|
||||||
|
track_from_item(content_id.clone(), item, local, item.owner == own)
|
||||||
|
});
|
||||||
|
if !entry.availability.federation.iter().any(|candidate| {
|
||||||
|
candidate.owner == source.owner && candidate.item_id == source.item_id
|
||||||
|
}) {
|
||||||
|
entry.availability.federation.push(source);
|
||||||
|
}
|
||||||
|
if entry.availability.local.is_some() {
|
||||||
|
entry.availability.state = "local";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tracks: Vec<_> = by_content.into_values().collect();
|
||||||
|
tracks.sort_by(|left, right| {
|
||||||
|
left.metadata
|
||||||
|
.title
|
||||||
|
.to_lowercase()
|
||||||
|
.cmp(&right.metadata.title.to_lowercase())
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut events = Vec::with_capacity(all_items.len());
|
||||||
|
for (index, track) in tracks.into_iter().enumerate() {
|
||||||
|
persist_track_ref(&pool, &track).await?;
|
||||||
|
let peer = track
|
||||||
|
.availability
|
||||||
|
.federation
|
||||||
|
.first()
|
||||||
|
.map(|source| source.owner.clone());
|
||||||
|
events.push(SearchEvent {
|
||||||
|
search_id: search_id.to_owned(),
|
||||||
|
sequence: index as u64 + 1,
|
||||||
|
kind: "federation.track",
|
||||||
|
peer,
|
||||||
|
entity_key: serde_json::to_value(&track.key)?,
|
||||||
|
entity: serde_json::to_value(track)?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut artist_peers: HashMap<String, (String, Vec<String>)> = HashMap::new();
|
||||||
|
let mut releases: HashMap<String, Value> = HashMap::new();
|
||||||
|
for item in &all_items {
|
||||||
|
match item.kind {
|
||||||
|
ItemKind::Artist => {
|
||||||
|
let key = music_dht::normalize_name(&item.name);
|
||||||
|
let entry = artist_peers
|
||||||
|
.entry(key)
|
||||||
|
.or_insert_with(|| (item.name.clone(), Vec::new()));
|
||||||
|
let owner = item.owner.to_string();
|
||||||
|
if !entry.1.contains(&owner) {
|
||||||
|
entry.1.push(owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ItemKind::Release => {
|
||||||
|
let artist_keys: Vec<String> = item
|
||||||
|
.artist_names
|
||||||
|
.iter()
|
||||||
|
.map(|name| music_dht::normalize_name(name))
|
||||||
|
.collect();
|
||||||
|
let normalized_title = music_dht::normalize_name(&item.name);
|
||||||
|
let cover_url = all_items
|
||||||
|
.iter()
|
||||||
|
.find(|track| {
|
||||||
|
track.kind == ItemKind::Track
|
||||||
|
&& track.release_title.as_deref().is_some_and(|title| {
|
||||||
|
music_dht::normalize_name(title) == normalized_title
|
||||||
|
})
|
||||||
|
&& track.year == item.year
|
||||||
|
})
|
||||||
|
.map(|track| {
|
||||||
|
format!(
|
||||||
|
"/api/player/federation/tracks/artwork?owner={}&item_id={}",
|
||||||
|
track.owner,
|
||||||
|
hex(track.id.as_bytes())
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let key = format!(
|
||||||
|
"{}|{}|{}",
|
||||||
|
normalized_title,
|
||||||
|
artist_keys.join(","),
|
||||||
|
item.year.map_or_else(String::new, |year| year.to_string())
|
||||||
|
);
|
||||||
|
releases.entry(key.clone()).or_insert_with(|| {
|
||||||
|
json!({
|
||||||
|
"key": {
|
||||||
|
"normalized_title": music_dht::normalize_name(&item.name),
|
||||||
|
"primary_artists": artist_keys,
|
||||||
|
"release_type": null,
|
||||||
|
"year": item.year,
|
||||||
|
},
|
||||||
|
"title": item.name,
|
||||||
|
"artists": item.artist_names,
|
||||||
|
"year": item.year,
|
||||||
|
"cover_url": cover_url,
|
||||||
|
"sources": [{
|
||||||
|
"owner": item.owner.to_string(),
|
||||||
|
"item_id": hex(item.id.as_bytes()),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ItemKind::Track => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (key, (name, peers)) in artist_peers {
|
||||||
|
let sequence = events.len() as u64 + 1;
|
||||||
|
events.push(SearchEvent {
|
||||||
|
search_id: search_id.to_owned(),
|
||||||
|
sequence,
|
||||||
|
kind: "federation.artist",
|
||||||
|
peer: peers.first().cloned(),
|
||||||
|
entity_key: json!({ "normalized_name": key }),
|
||||||
|
entity: json!({
|
||||||
|
"key": { "normalized_name": key },
|
||||||
|
"name": name,
|
||||||
|
"image_url": null,
|
||||||
|
"peers": peers,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (key, release) in releases {
|
||||||
|
let sequence = events.len() as u64 + 1;
|
||||||
|
events.push(SearchEvent {
|
||||||
|
search_id: search_id.to_owned(),
|
||||||
|
sequence,
|
||||||
|
kind: "federation.release",
|
||||||
|
peer: None,
|
||||||
|
entity_key: json!({ "composite": key }),
|
||||||
|
entity: release,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
search_id,
|
||||||
|
query,
|
||||||
|
events = events.len(),
|
||||||
|
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||||
|
"federated search response ready"
|
||||||
|
);
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_artist_catalog(
|
||||||
|
service: &music_dht::MusicDhtService,
|
||||||
|
owner: music_dht::EndpointId,
|
||||||
|
artist: &str,
|
||||||
|
) -> Result<music_dht::catalog::CatalogArtist> {
|
||||||
|
let mut stream = service
|
||||||
|
.open_stream(owner, super::CATALOG_ALPN)
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("cannot reach catalog peer: {err}"))?;
|
||||||
|
let mut request = serde_json::to_vec(&music_dht::catalog::CatalogRequest {
|
||||||
|
artist: artist.to_owned(),
|
||||||
|
want: Some("catalog".to_owned()),
|
||||||
|
..Default::default()
|
||||||
|
})?;
|
||||||
|
request.push(b'\n');
|
||||||
|
stream.send.write_all(&request).await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
let mut payload = Vec::new();
|
||||||
|
stream
|
||||||
|
.recv
|
||||||
|
.take(MAX_CATALOG_BYTES + 1)
|
||||||
|
.read_to_end(&mut payload)
|
||||||
|
.await?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
payload.len() as u64 <= MAX_CATALOG_BYTES,
|
||||||
|
"catalog response is too large"
|
||||||
|
);
|
||||||
|
let response: music_dht::catalog::CatalogResponse =
|
||||||
|
serde_json::from_slice(&payload).context("invalid catalog response")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
response.ok,
|
||||||
|
"peer refused catalog: {}",
|
||||||
|
response.error.unwrap_or_else(|| "unknown error".to_owned())
|
||||||
|
);
|
||||||
|
response.artist.context("peer returned no artist catalog")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_from_item(
|
||||||
|
content_id: String,
|
||||||
|
item: &LibraryItem,
|
||||||
|
local: Option<LocalAvailabilityDto>,
|
||||||
|
own: bool,
|
||||||
|
) -> TrackDto {
|
||||||
|
let owner = item.owner.to_string();
|
||||||
|
let item_id = hex(item.id.as_bytes());
|
||||||
|
let artists = artist_refs(&item.artist_names);
|
||||||
|
let featured_artists = artist_refs(&item.featured_artist_names);
|
||||||
|
let release = item.release_title.as_ref().map(|title| ReleaseRefDto {
|
||||||
|
key: ReleaseKeyDto {
|
||||||
|
normalized_title: music_dht::normalize_name(title),
|
||||||
|
primary_artists: item
|
||||||
|
.artist_names
|
||||||
|
.iter()
|
||||||
|
.map(|artist| music_dht::normalize_name(artist))
|
||||||
|
.collect(),
|
||||||
|
release_type: None,
|
||||||
|
year: item.year,
|
||||||
|
},
|
||||||
|
local_id: None,
|
||||||
|
title: title.clone(),
|
||||||
|
});
|
||||||
|
let state = if local.is_some() || own {
|
||||||
|
"local"
|
||||||
|
} else {
|
||||||
|
"federated"
|
||||||
|
};
|
||||||
|
TrackDto {
|
||||||
|
key: TrackKeyDto { content_id },
|
||||||
|
metadata: TrackMetadataDto {
|
||||||
|
title: item.name.clone(),
|
||||||
|
artists,
|
||||||
|
featured_artists,
|
||||||
|
release,
|
||||||
|
year: item.year,
|
||||||
|
duration_seconds: item.duration_seconds,
|
||||||
|
track_number: item.track_number,
|
||||||
|
disc_number: item.disc_number,
|
||||||
|
cover_url: Some(format!(
|
||||||
|
"/api/player/federation/tracks/artwork?owner={owner}&item_id={item_id}"
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
availability: TrackAvailabilityDto {
|
||||||
|
state,
|
||||||
|
local,
|
||||||
|
federation: vec![FederationSourceDto { owner, item_id }],
|
||||||
|
},
|
||||||
|
similarity_score: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn artist_refs(names: &[String]) -> Vec<ArtistRefDto> {
|
||||||
|
names
|
||||||
|
.iter()
|
||||||
|
.map(|name| ArtistRefDto {
|
||||||
|
key: ArtistKeyDto {
|
||||||
|
normalized_name: music_dht::normalize_name(name),
|
||||||
|
},
|
||||||
|
name: name.clone(),
|
||||||
|
local_id: None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn local_availability(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
content_id: &str,
|
||||||
|
) -> Result<Option<LocalAvailabilityDto>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT t.id
|
||||||
|
FROM furumusic__federation_content_id_cache c
|
||||||
|
JOIN furumusic__track t ON t.audio_file_id = c.media_file_id
|
||||||
|
WHERE c.content_id = $1 AND t.is_hidden = false
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(content_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|row| {
|
||||||
|
let track_id: i64 = row.get(0);
|
||||||
|
LocalAvailabilityDto {
|
||||||
|
track_id,
|
||||||
|
stream_url: format!("/api/player/stream/{track_id}"),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_track_ref(pool: &sqlx::PgPool, track: &TrackDto) -> Result<()> {
|
||||||
|
let metadata = serde_json::to_value(&track.metadata)?;
|
||||||
|
let local_id = track
|
||||||
|
.availability
|
||||||
|
.local
|
||||||
|
.as_ref()
|
||||||
|
.map(|local| local.track_id);
|
||||||
|
let row = sqlx::query(
|
||||||
|
"INSERT INTO furumusic__track_ref
|
||||||
|
(content_id, local_track_id, title, release_title, year,
|
||||||
|
duration_seconds, metadata_json, metadata_authority, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, 'federation', $8, $8)
|
||||||
|
ON CONFLICT (content_id) DO UPDATE SET
|
||||||
|
local_track_id = COALESCE(furumusic__track_ref.local_track_id, EXCLUDED.local_track_id),
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
release_title = EXCLUDED.release_title,
|
||||||
|
year = EXCLUDED.year,
|
||||||
|
duration_seconds = EXCLUDED.duration_seconds,
|
||||||
|
metadata_json = EXCLUDED.metadata_json,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(&track.key.content_id)
|
||||||
|
.bind(local_id)
|
||||||
|
.bind(&track.metadata.title)
|
||||||
|
.bind(
|
||||||
|
track
|
||||||
|
.metadata
|
||||||
|
.release
|
||||||
|
.as_ref()
|
||||||
|
.map(|release| &release.title),
|
||||||
|
)
|
||||||
|
.bind(track.metadata.year)
|
||||||
|
.bind(track.metadata.duration_seconds)
|
||||||
|
.bind(metadata)
|
||||||
|
.bind(now_iso())
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.context("persisting content-addressed track reference failed")?;
|
||||||
|
let track_ref_id: i64 = row.get(0);
|
||||||
|
for source in &track.availability.federation {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO furumusic__federation_track_source
|
||||||
|
(track_ref_id, owner_peer_id, item_id, last_seen_ms, metadata_json)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (owner_peer_id, item_id) DO UPDATE SET
|
||||||
|
track_ref_id = EXCLUDED.track_ref_id,
|
||||||
|
last_seen_ms = EXCLUDED.last_seen_ms,
|
||||||
|
metadata_json = EXCLUDED.metadata_json",
|
||||||
|
)
|
||||||
|
.bind(track_ref_id)
|
||||||
|
.bind(&source.owner)
|
||||||
|
.bind(&source.item_id)
|
||||||
|
.bind(chrono::Utc::now().timestamp_millis())
|
||||||
|
.bind(json!({ "track": track.metadata }))
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,775 @@
|
|||||||
|
//! Serve side of the federation wire protocols (audio + catalog), backed by
|
||||||
|
//! the PostgreSQL library and the media storage directory. Wire compatible
|
||||||
|
//! with the furumi TUI client and any other furumi peer.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
pub use music_dht::catalog::CATALOG_ALPN;
|
||||||
|
use music_dht::catalog::{
|
||||||
|
CatalogArtist, CatalogArtistPreview, CatalogImageHeader as ImageHeader, CatalogRelease,
|
||||||
|
CatalogRequest, CatalogResponse, CatalogTrack,
|
||||||
|
};
|
||||||
|
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, StreamAcceptor, normalize_name};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use sqlx::Row as _;
|
||||||
|
use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
use super::{TransportStats, record_stream_transport};
|
||||||
|
|
||||||
|
/// ALPN of the peer-to-peer audio streaming protocol.
|
||||||
|
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
||||||
|
/// Version of the peer-to-peer audio streaming protocol.
|
||||||
|
pub const AUDIO_PROTOCOL_VERSION: u16 = 1;
|
||||||
|
|
||||||
|
/// Maximum size of a JSON protocol line (request or response header).
|
||||||
|
const MAX_PROTOCOL_LINE: usize = 4096;
|
||||||
|
/// Images above this size are skipped rather than transferred.
|
||||||
|
const MAX_IMAGE_BYTES: u64 = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wire shapes (shared with the furumi TUI client)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AudioRequest {
|
||||||
|
item_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
offset: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
want_cover: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize)]
|
||||||
|
struct AudioResponseHeader {
|
||||||
|
ok: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
error: Option<String>,
|
||||||
|
mime_type: String,
|
||||||
|
total_size: u64,
|
||||||
|
offset: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
metadata: Option<TrackMetadata>,
|
||||||
|
cover_size: u64,
|
||||||
|
cover_mime: String,
|
||||||
|
artist_image_size: u64,
|
||||||
|
artist_image_mime: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
|
struct TrackMetadata {
|
||||||
|
title: String,
|
||||||
|
artists: Vec<String>,
|
||||||
|
featured_artists: Vec<String>,
|
||||||
|
album_artists: Vec<String>,
|
||||||
|
release_title: String,
|
||||||
|
release_type: Option<String>,
|
||||||
|
year: Option<i32>,
|
||||||
|
track_number: Option<i32>,
|
||||||
|
disc_number: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Framing helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn hex_encode(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_decode_item_id(value: &str) -> Option<ItemId> {
|
||||||
|
if value.len() != 64 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
for (i, byte) in bytes.iter_mut().enumerate() {
|
||||||
|
*byte = u8::from_str_radix(&value[i * 2..i * 2 + 2], 16).ok()?;
|
||||||
|
}
|
||||||
|
Some(ItemId::from_bytes(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_line<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Vec<u8>> {
|
||||||
|
let mut line = Vec::new();
|
||||||
|
let mut byte = [0u8; 1];
|
||||||
|
loop {
|
||||||
|
let n = reader.read(&mut byte).await?;
|
||||||
|
if n == 0 {
|
||||||
|
anyhow::bail!("stream ended before the protocol line was complete");
|
||||||
|
}
|
||||||
|
if byte[0] == b'\n' {
|
||||||
|
return Ok(line);
|
||||||
|
}
|
||||||
|
line.push(byte[0]);
|
||||||
|
if line.len() > MAX_PROTOCOL_LINE {
|
||||||
|
anyhow::bail!("protocol line exceeds {MAX_PROTOCOL_LINE} bytes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_line<W: AsyncWriteExt + Unpin>(
|
||||||
|
writer: &mut W,
|
||||||
|
value: &impl Serialize,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut line = serde_json::to_vec(value)?;
|
||||||
|
line.push(b'\n');
|
||||||
|
writer.write_all(&line).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn item_id_of(own: &EndpointId, track_id: i64) -> String {
|
||||||
|
hex_encode(ItemId::derive(own, ItemKind::Track, &format!("track:{track_id}")).as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_media_path(storage_dir: &str, file_path: &str) -> PathBuf {
|
||||||
|
crate::media_paths::resolve_media_file_path(storage_dir, file_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn guess_mime(path: &Path) -> &'static str {
|
||||||
|
match path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"mp3" => "audio/mpeg",
|
||||||
|
"flac" => "audio/flac",
|
||||||
|
"ogg" | "oga" => "audio/ogg",
|
||||||
|
"opus" => "audio/opus",
|
||||||
|
"wav" => "audio/wav",
|
||||||
|
"m4a" | "mp4" | "alac" => "audio/mp4",
|
||||||
|
"aac" => "audio/aac",
|
||||||
|
"aiff" | "aif" => "audio/aiff",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads an image media file from disk, bounded by [`MAX_IMAGE_BYTES`].
|
||||||
|
async fn read_image(
|
||||||
|
storage_dir: &str,
|
||||||
|
media: Option<(String, String)>,
|
||||||
|
) -> Option<(Vec<u8>, String)> {
|
||||||
|
let (file_path, mime) = media?;
|
||||||
|
let path = resolve_media_path(storage_dir, &file_path);
|
||||||
|
let size = tokio::fs::metadata(&path).await.ok()?.len();
|
||||||
|
if size == 0 || size > MAX_IMAGE_BYTES {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bytes = tokio::fs::read(&path).await.ok()?;
|
||||||
|
let mime = if mime.trim().is_empty() {
|
||||||
|
"image/jpeg".to_string()
|
||||||
|
} else {
|
||||||
|
mime
|
||||||
|
};
|
||||||
|
Some((bytes, mime))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Library lookups (PostgreSQL)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Finds the visible track whose derived DHT item id matches `item_id`.
|
||||||
|
async fn resolve_track_id(pool: &PgPool, own: &EndpointId, item_id: ItemId) -> Result<Option<i64>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT t.id FROM furumusic__track t
|
||||||
|
JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
WHERE t.is_hidden = false AND r.is_hidden = false",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
for row in rows {
|
||||||
|
let track_id: i64 = row.get(0);
|
||||||
|
if ItemId::derive(own, ItemKind::Track, &format!("track:{track_id}")) == item_id {
|
||||||
|
return Ok(Some(track_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (file_path, mime_type) of the track's audio media file.
|
||||||
|
async fn track_audio_file(pool: &PgPool, track_id: i64) -> Result<Option<(String, String)>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT m.file_path, m.mime_type FROM furumusic__track t
|
||||||
|
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||||
|
WHERE t.id = $1",
|
||||||
|
)
|
||||||
|
.bind(track_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track cover (falling back to the release cover) as (file_path, mime).
|
||||||
|
async fn track_cover_file(pool: &PgPool, track_id: i64) -> Result<Option<(String, String)>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT m.file_path, m.mime_type FROM furumusic__track t
|
||||||
|
JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
JOIN furumusic__media_file m ON m.id = COALESCE(t.cover_file_id, r.cover_file_id)
|
||||||
|
WHERE t.id = $1",
|
||||||
|
)
|
||||||
|
.bind(track_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The main artist's image of a track as (file_path, mime).
|
||||||
|
async fn track_artist_image_file(pool: &PgPool, track_id: i64) -> Result<Option<(String, String)>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT m.file_path, m.mime_type FROM furumusic__track_artist ta
|
||||||
|
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||||
|
JOIN furumusic__media_file m ON m.id = a.image_file_id
|
||||||
|
WHERE ta.track_id = $1 AND ta.role = 'main'
|
||||||
|
ORDER BY ta.position LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(track_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn track_catalog_artist_names(
|
||||||
|
pool: &PgPool,
|
||||||
|
track_id: i64,
|
||||||
|
) -> Result<(Vec<String>, Vec<String>)> {
|
||||||
|
let mut artists = Vec::new();
|
||||||
|
let mut featured = Vec::new();
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT a.name, ta.role FROM furumusic__track_artist ta
|
||||||
|
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||||
|
WHERE ta.track_id = $1 ORDER BY ta.position",
|
||||||
|
)
|
||||||
|
.bind(track_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
for row in rows {
|
||||||
|
let name: String = row.get(0);
|
||||||
|
match row.get::<String, _>(1).as_str() {
|
||||||
|
"featuring" => featured.push(name),
|
||||||
|
"main" => artists.push(name),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((artists, featured))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn track_metadata(pool: &PgPool, track_id: i64) -> Result<Option<TrackMetadata>> {
|
||||||
|
let Some(track) = sqlx::query(
|
||||||
|
"SELECT t.title, t.track_number, t.disc_number, COALESCE(t.year, r.year),
|
||||||
|
t.release_id, r.title, r.release_type
|
||||||
|
FROM furumusic__track t
|
||||||
|
JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
WHERE t.id = $1",
|
||||||
|
)
|
||||||
|
.bind(track_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let release_id: i64 = track.get(4);
|
||||||
|
|
||||||
|
let mut artists = Vec::new();
|
||||||
|
let mut featured = Vec::new();
|
||||||
|
let artist_rows = sqlx::query(
|
||||||
|
"SELECT a.name, ta.role FROM furumusic__track_artist ta
|
||||||
|
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||||
|
WHERE ta.track_id = $1 ORDER BY ta.position",
|
||||||
|
)
|
||||||
|
.bind(track_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
for row in artist_rows {
|
||||||
|
let name: String = row.get(0);
|
||||||
|
match row.get::<String, _>(1).as_str() {
|
||||||
|
"featuring" => featured.push(name),
|
||||||
|
"main" => artists.push(name),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let album_artists: Vec<String> = sqlx::query(
|
||||||
|
"SELECT a.name FROM furumusic__release_artist ra
|
||||||
|
JOIN furumusic__artist a ON a.id = ra.artist_id
|
||||||
|
WHERE ra.release_id = $1 ORDER BY ra.position",
|
||||||
|
)
|
||||||
|
.bind(release_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| row.get(0))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Some(TrackMetadata {
|
||||||
|
title: track.get(0),
|
||||||
|
artists,
|
||||||
|
featured_artists: featured,
|
||||||
|
album_artists,
|
||||||
|
release_title: track.get(5),
|
||||||
|
release_type: Some(track.get(6)),
|
||||||
|
year: track.get(3),
|
||||||
|
track_number: track.get(1),
|
||||||
|
disc_number: track.get(2),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Audio protocol
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Runs the audio accept loop until the acceptor closes. Every visible
|
||||||
|
/// track of the library is streamable by every peer of the network.
|
||||||
|
pub async fn serve_audio(
|
||||||
|
mut acceptor: StreamAcceptor,
|
||||||
|
pool: PgPool,
|
||||||
|
storage_dir: String,
|
||||||
|
own: EndpointId,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
|
) {
|
||||||
|
while let Some(stream) = acceptor.accept().await {
|
||||||
|
let pool = pool.clone();
|
||||||
|
let storage_dir = storage_dir.clone();
|
||||||
|
let transport_stats = Arc::clone(&transport_stats);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let peer = stream.peer_id;
|
||||||
|
if let Err(err) = serve_audio_one(stream, pool, storage_dir, own, transport_stats).await
|
||||||
|
{
|
||||||
|
tracing::warn!(peer = %peer, "federation audio stream failed: {err:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_audio_one(
|
||||||
|
mut stream: ByteStream,
|
||||||
|
pool: PgPool,
|
||||||
|
storage_dir: String,
|
||||||
|
own: EndpointId,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
|
) -> Result<()> {
|
||||||
|
record_stream_transport(&transport_stats, "audio", "inbound", "open", &stream);
|
||||||
|
let request: AudioRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
||||||
|
tracing::info!(
|
||||||
|
peer = %stream.peer_id,
|
||||||
|
item = %request.item_id,
|
||||||
|
offset = request.offset,
|
||||||
|
"federation peer requested audio"
|
||||||
|
);
|
||||||
|
|
||||||
|
let track_id = match hex_decode_item_id(&request.item_id) {
|
||||||
|
Some(item_id) => match resolve_track_id(&pool, &own, item_id).await {
|
||||||
|
Ok(Some(track_id)) => track_id,
|
||||||
|
Ok(None) => return refuse_audio(stream, "track not found in the library").await,
|
||||||
|
Err(err) => {
|
||||||
|
return refuse_audio(stream, &format!("library lookup failed: {err:#}")).await;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => return refuse_audio(stream, "malformed item_id").await,
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some((file_path, mime_type)) = track_audio_file(&pool, track_id).await? else {
|
||||||
|
return refuse_audio(stream, "audio file record is missing").await;
|
||||||
|
};
|
||||||
|
let path = resolve_media_path(&storage_dir, &file_path);
|
||||||
|
let mut file = match tokio::fs::File::open(&path).await {
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(err) => {
|
||||||
|
return refuse_audio(stream, &format!("audio file is not readable: {err}")).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let total_size = file.metadata().await?.len();
|
||||||
|
let offset = request.offset.min(total_size);
|
||||||
|
if offset > 0 {
|
||||||
|
file.seek(std::io::SeekFrom::Start(offset)).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = match track_metadata(&pool, track_id).await {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(track_id, "federation metadata lookup failed: {err:#}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (cover, artist_image) = if request.want_cover {
|
||||||
|
(
|
||||||
|
read_image(
|
||||||
|
&storage_dir,
|
||||||
|
track_cover_file(&pool, track_id).await.ok().flatten(),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
read_image(
|
||||||
|
&storage_dir,
|
||||||
|
track_artist_image_file(&pool, track_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten(),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mime_type = if mime_type.trim().is_empty() {
|
||||||
|
guess_mime(&path).to_string()
|
||||||
|
} else {
|
||||||
|
mime_type
|
||||||
|
};
|
||||||
|
write_line(
|
||||||
|
&mut stream.send,
|
||||||
|
&AudioResponseHeader {
|
||||||
|
ok: true,
|
||||||
|
error: None,
|
||||||
|
mime_type,
|
||||||
|
total_size,
|
||||||
|
offset,
|
||||||
|
metadata,
|
||||||
|
cover_size: cover.as_ref().map_or(0, |(bytes, _)| bytes.len() as u64),
|
||||||
|
cover_mime: cover
|
||||||
|
.as_ref()
|
||||||
|
.map(|(_, mime)| mime.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
artist_image_size: artist_image
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |(bytes, _)| bytes.len() as u64),
|
||||||
|
artist_image_mime: artist_image
|
||||||
|
.as_ref()
|
||||||
|
.map(|(_, mime)| mime.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if let Some((bytes, _)) = &cover {
|
||||||
|
stream.send.write_all(bytes).await?;
|
||||||
|
}
|
||||||
|
if let Some((bytes, _)) = &artist_image {
|
||||||
|
stream.send.write_all(bytes).await?;
|
||||||
|
}
|
||||||
|
tokio::io::copy(&mut file, &mut stream.send).await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
// Wait until the peer read everything before dropping the stream,
|
||||||
|
// otherwise the tail of the file is lost.
|
||||||
|
let _ = stream.send.stopped().await;
|
||||||
|
record_stream_transport(&transport_stats, "audio", "inbound", "done", &stream);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refuse_audio(mut stream: ByteStream, message: &str) -> Result<()> {
|
||||||
|
write_line(
|
||||||
|
&mut stream.send,
|
||||||
|
&AudioResponseHeader {
|
||||||
|
ok: false,
|
||||||
|
error: Some(message.to_string()),
|
||||||
|
..AudioResponseHeader::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
let _ = stream.send.stopped().await;
|
||||||
|
anyhow::bail!("refused audio request: {message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Catalog protocol
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Runs the catalog accept loop until the acceptor closes.
|
||||||
|
pub async fn serve_catalog(
|
||||||
|
mut acceptor: StreamAcceptor,
|
||||||
|
pool: PgPool,
|
||||||
|
storage_dir: String,
|
||||||
|
own: EndpointId,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
|
) {
|
||||||
|
while let Some(stream) = acceptor.accept().await {
|
||||||
|
let pool = pool.clone();
|
||||||
|
let storage_dir = storage_dir.clone();
|
||||||
|
let transport_stats = Arc::clone(&transport_stats);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let peer = stream.peer_id;
|
||||||
|
if let Err(err) =
|
||||||
|
serve_catalog_one(stream, pool, storage_dir, own, transport_stats).await
|
||||||
|
{
|
||||||
|
tracing::warn!(peer = %peer, "federation catalog request failed: {err:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_catalog_one(
|
||||||
|
mut stream: ByteStream,
|
||||||
|
pool: PgPool,
|
||||||
|
storage_dir: String,
|
||||||
|
own: EndpointId,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
|
) -> Result<()> {
|
||||||
|
record_stream_transport(&transport_stats, "catalog", "inbound", "open", &stream);
|
||||||
|
let request: CatalogRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
||||||
|
tracing::info!(
|
||||||
|
peer = %stream.peer_id,
|
||||||
|
artist = %request.artist,
|
||||||
|
want = request.want.as_deref().unwrap_or("catalog"),
|
||||||
|
"federation peer requested a catalog"
|
||||||
|
);
|
||||||
|
|
||||||
|
match request.want.as_deref() {
|
||||||
|
None | Some("catalog") => {
|
||||||
|
let response = match build_catalog(&pool, &own, &request.artist).await {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(err) => CatalogResponse {
|
||||||
|
ok: false,
|
||||||
|
error: Some(format!("catalog lookup failed: {err:#}")),
|
||||||
|
..CatalogResponse::default()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
stream
|
||||||
|
.send
|
||||||
|
.write_all(&serde_json::to_vec(&response)?)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Some("artists") => {
|
||||||
|
let cursor = request.cursor.clone();
|
||||||
|
let limit = request.limit.unwrap_or(64).clamp(1, 200);
|
||||||
|
let response = match build_artist_slice(&pool, cursor, limit).await {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(err) => CatalogResponse {
|
||||||
|
ok: false,
|
||||||
|
error: Some(format!("artist slice lookup failed: {err:#}")),
|
||||||
|
..CatalogResponse::default()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
stream
|
||||||
|
.send
|
||||||
|
.write_all(&serde_json::to_vec(&response)?)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Some(want @ ("artist_image" | "release_cover")) => {
|
||||||
|
let media = if want == "release_cover" {
|
||||||
|
release_cover_by_names(
|
||||||
|
&pool,
|
||||||
|
&request.artist,
|
||||||
|
request.release.as_deref().unwrap_or_default(),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
artist_image_by_name(&pool, &request.artist).await?
|
||||||
|
};
|
||||||
|
let image = read_image(&storage_dir, media).await;
|
||||||
|
let header = match &image {
|
||||||
|
Some((bytes, mime)) => ImageHeader {
|
||||||
|
ok: true,
|
||||||
|
error: None,
|
||||||
|
mime_type: mime.clone(),
|
||||||
|
size: bytes.len() as u64,
|
||||||
|
},
|
||||||
|
None => ImageHeader {
|
||||||
|
ok: false,
|
||||||
|
error: Some("no image".to_string()),
|
||||||
|
..ImageHeader::default()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
write_line(&mut stream.send, &header).await?;
|
||||||
|
if let Some((bytes, _)) = &image {
|
||||||
|
stream.send.write_all(bytes).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(other) => {
|
||||||
|
let response = CatalogResponse {
|
||||||
|
ok: false,
|
||||||
|
error: Some(format!("unknown request kind '{other}'")),
|
||||||
|
..CatalogResponse::default()
|
||||||
|
};
|
||||||
|
stream
|
||||||
|
.send
|
||||||
|
.write_all(&serde_json::to_vec(&response)?)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stream.send.finish()?;
|
||||||
|
let _ = stream.send.stopped().await;
|
||||||
|
record_stream_transport(&transport_stats, "catalog", "inbound", "done", &stream);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_catalog(pool: &PgPool, own: &EndpointId, artist: &str) -> Result<CatalogResponse> {
|
||||||
|
let Some(artist_row) = sqlx::query(
|
||||||
|
"SELECT id, name FROM furumusic__artist
|
||||||
|
WHERE LOWER(name) = LOWER($1) AND is_hidden = false
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(artist)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(CatalogResponse {
|
||||||
|
ok: false,
|
||||||
|
error: Some("artist not found in the library".to_string()),
|
||||||
|
..CatalogResponse::default()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let artist_id: i64 = artist_row.get(0);
|
||||||
|
|
||||||
|
let release_rows = sqlx::query(
|
||||||
|
"SELECT r.id, r.title, r.release_type, r.year
|
||||||
|
FROM furumusic__release r
|
||||||
|
JOIN furumusic__release_artist ra ON ra.release_id = r.id
|
||||||
|
WHERE ra.artist_id = $1 AND r.is_hidden = false
|
||||||
|
ORDER BY r.year NULLS LAST, r.title",
|
||||||
|
)
|
||||||
|
.bind(artist_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
let mut releases = Vec::new();
|
||||||
|
for release_row in release_rows {
|
||||||
|
let release_id: i64 = release_row.get(0);
|
||||||
|
let track_rows = sqlx::query(
|
||||||
|
"SELECT t.id, t.title, t.track_number, t.disc_number, t.duration_seconds,
|
||||||
|
c.content_id
|
||||||
|
FROM furumusic__track t
|
||||||
|
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||||
|
LEFT JOIN furumusic__federation_content_id_cache c
|
||||||
|
ON c.media_file_id = m.id AND c.sha256_hash = m.sha256_hash
|
||||||
|
WHERE t.release_id = $1 AND t.is_hidden = false
|
||||||
|
ORDER BY t.disc_number NULLS FIRST, t.track_number NULLS LAST, t.title",
|
||||||
|
)
|
||||||
|
.bind(release_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
let mut tracks = Vec::with_capacity(track_rows.len());
|
||||||
|
for row in track_rows {
|
||||||
|
let track_id: i64 = row.get(0);
|
||||||
|
let duration: f64 = row.get(4);
|
||||||
|
let (artists, featured_artists) = track_catalog_artist_names(pool, track_id).await?;
|
||||||
|
tracks.push(CatalogTrack {
|
||||||
|
title: row.get(1),
|
||||||
|
artists,
|
||||||
|
featured_artists,
|
||||||
|
track_number: row.get(2),
|
||||||
|
disc_number: row.get(3),
|
||||||
|
duration_seconds: (duration > 0.0).then_some(duration),
|
||||||
|
content_id: row.get(5),
|
||||||
|
item_id: item_id_of(own, track_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
releases.push(CatalogRelease {
|
||||||
|
title: release_row.get(1),
|
||||||
|
release_type: release_row.get(2),
|
||||||
|
year: release_row.get(3),
|
||||||
|
tracks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CatalogResponse {
|
||||||
|
ok: true,
|
||||||
|
artist: Some(CatalogArtist {
|
||||||
|
name: artist_row.get(1),
|
||||||
|
releases,
|
||||||
|
appears_on: Vec::new(),
|
||||||
|
}),
|
||||||
|
..CatalogResponse::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_artist_slice(
|
||||||
|
pool: &PgPool,
|
||||||
|
cursor: Option<String>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<CatalogResponse> {
|
||||||
|
let offset = cursor
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|value| value.parse::<i64>().ok())
|
||||||
|
.unwrap_or(0)
|
||||||
|
.max(0);
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"SELECT a.name::text AS name,
|
||||||
|
mf.file_path::text AS image_path,
|
||||||
|
COALESCE(s.release_count, 0)::bigint AS release_count,
|
||||||
|
COALESCE(s.track_count, 0)::bigint AS track_count
|
||||||
|
FROM furumusic__artist a
|
||||||
|
LEFT JOIN furumusic__media_file mf ON mf.id = a.image_file_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT appearance.artist_id,
|
||||||
|
COUNT(DISTINCT appearance.release_id) FILTER (WHERE appearance.is_primary_release_artist) AS release_count,
|
||||||
|
COUNT(DISTINCT appearance.track_id) AS track_count
|
||||||
|
FROM (
|
||||||
|
SELECT ta.artist_id,
|
||||||
|
t.id AS track_id,
|
||||||
|
r.id AS release_id,
|
||||||
|
primary_release.artist_id IS NOT NULL AS is_primary_release_artist
|
||||||
|
FROM furumusic__track_artist ta
|
||||||
|
JOIN furumusic__track t ON t.id = ta.track_id AND t.is_hidden = false
|
||||||
|
JOIN furumusic__release r ON r.id = t.release_id AND r.is_hidden = false
|
||||||
|
LEFT JOIN furumusic__release_artist primary_release
|
||||||
|
ON primary_release.release_id = r.id
|
||||||
|
AND primary_release.artist_id = ta.artist_id
|
||||||
|
AND primary_release.position = 0
|
||||||
|
) appearance
|
||||||
|
GROUP BY appearance.artist_id
|
||||||
|
) s ON s.artist_id = a.id
|
||||||
|
WHERE a.is_hidden = false
|
||||||
|
AND COALESCE(s.track_count, 0) > 0
|
||||||
|
ORDER BY (COALESCE(s.release_count, 0) > 0) DESC,
|
||||||
|
COALESCE(s.release_count, 0) DESC,
|
||||||
|
COALESCE(s.track_count, 0) DESC,
|
||||||
|
a.name_sort
|
||||||
|
LIMIT $1 OFFSET $2"#,
|
||||||
|
)
|
||||||
|
.bind(limit as i64 + 1)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut artists = Vec::with_capacity(rows.len().min(limit));
|
||||||
|
let has_more = rows.len() > limit;
|
||||||
|
for row in rows.into_iter().take(limit) {
|
||||||
|
let name: String = row.get(0);
|
||||||
|
artists.push(CatalogArtistPreview {
|
||||||
|
artist_key: normalize_name(&name),
|
||||||
|
name,
|
||||||
|
image_path: row.get(1),
|
||||||
|
release_count: row.get(2),
|
||||||
|
track_count: row.get(3),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let next_cursor = has_more.then(|| (offset + artists.len() as i64).to_string());
|
||||||
|
Ok(CatalogResponse {
|
||||||
|
ok: true,
|
||||||
|
artists,
|
||||||
|
next_cursor,
|
||||||
|
..CatalogResponse::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn artist_image_by_name(pool: &PgPool, artist: &str) -> Result<Option<(String, String)>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT m.file_path, m.mime_type FROM furumusic__artist a
|
||||||
|
JOIN furumusic__media_file m ON m.id = a.image_file_id
|
||||||
|
WHERE LOWER(a.name) = LOWER($1) AND a.is_hidden = false
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(artist)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release_cover_by_names(
|
||||||
|
pool: &PgPool,
|
||||||
|
artist: &str,
|
||||||
|
release: &str,
|
||||||
|
) -> Result<Option<(String, String)>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT m.file_path, m.mime_type FROM furumusic__release r
|
||||||
|
JOIN furumusic__release_artist ra ON ra.release_id = r.id
|
||||||
|
JOIN furumusic__artist a ON a.id = ra.artist_id
|
||||||
|
JOIN furumusic__media_file m ON m.id = r.cover_file_id
|
||||||
|
WHERE LOWER(a.name) = LOWER($1) AND LOWER(r.title) = LOWER($2)
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(artist)
|
||||||
|
.bind(release)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
//! Furumusic policy and PostgreSQL adapter for the shared similarity protocol.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context as _, Result};
|
||||||
|
use futures_util::stream::{self, StreamExt as _};
|
||||||
|
use music_dht::similarity::{self as wire, SimilarityHit, SimilarityRequest, SimilarityResponse};
|
||||||
|
use music_dht::similarity_dht::SimilarityDht;
|
||||||
|
use music_dht::{
|
||||||
|
ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, PeerTicket, StreamAcceptor,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::similarity::{Manager, QueryVector};
|
||||||
|
|
||||||
|
use super::TransportStats;
|
||||||
|
|
||||||
|
pub use music_dht::similarity::SIMILARITY_ALPN;
|
||||||
|
|
||||||
|
const INITIAL_QUERY_PEERS: usize = 16;
|
||||||
|
const MAX_QUERY_PEERS: usize = 48;
|
||||||
|
const QUERY_CONCURRENCY: usize = 8;
|
||||||
|
const QUERY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const ROUTING_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const MAX_PER_ARTIST: usize = 3;
|
||||||
|
const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RemoteSimilarityTrack {
|
||||||
|
pub owner: String,
|
||||||
|
pub item_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub artist_names: Vec<String>,
|
||||||
|
pub featured_artist_names: Vec<String>,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub duration_seconds: Option<i64>,
|
||||||
|
pub content_id: Option<String>,
|
||||||
|
pub release_title: Option<String>,
|
||||||
|
pub track_number: Option<i32>,
|
||||||
|
pub disc_number: Option<i32>,
|
||||||
|
pub similarity_score: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SimilaritySearchOutcome {
|
||||||
|
pub tracks: Vec<RemoteSimilarityTrack>,
|
||||||
|
pub queried_peers: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve_peers(
|
||||||
|
mut acceptor: StreamAcceptor,
|
||||||
|
manager: Arc<Manager>,
|
||||||
|
own: EndpointId,
|
||||||
|
transport: Arc<TransportStats>,
|
||||||
|
) {
|
||||||
|
while let Some(stream) = acceptor.accept().await {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
let transport = Arc::clone(&transport);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let peer = stream.peer_id;
|
||||||
|
if let Err(error) = serve_one(stream, manager, own, transport).await {
|
||||||
|
tracing::warn!(peer = %peer, "similarity request failed: {error:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_one(
|
||||||
|
mut stream: ByteStream,
|
||||||
|
manager: Arc<Manager>,
|
||||||
|
own: EndpointId,
|
||||||
|
transport: Arc<TransportStats>,
|
||||||
|
) -> Result<()> {
|
||||||
|
super::record_stream_transport(&transport, "similarity", "inbound", "open", &stream);
|
||||||
|
let request = wire::read_request(&mut stream).await?;
|
||||||
|
let response = if !manager.enabled() {
|
||||||
|
SimilarityResponse::refused("similarity search is disabled on this instance")?
|
||||||
|
} else {
|
||||||
|
let profile_id = request.profile_id;
|
||||||
|
let vector = request.vector;
|
||||||
|
let limit = request.limit;
|
||||||
|
let rank_manager = Arc::clone(&manager);
|
||||||
|
let ranked = tokio::task::spawn_blocking(move || {
|
||||||
|
rank_manager.rank_vector(&profile_id, &vector, None, None, limit)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("local similarity task failed")
|
||||||
|
.and_then(|result| result);
|
||||||
|
match ranked {
|
||||||
|
Ok(ranked) => {
|
||||||
|
let ids = ranked
|
||||||
|
.iter()
|
||||||
|
.map(|track| track.track_id)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
match manager.metadata_for_tracks(&ids).await {
|
||||||
|
Ok(metadata) => {
|
||||||
|
let by_id = ranked
|
||||||
|
.into_iter()
|
||||||
|
.map(|track| (track.track_id, track))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
let hits = metadata
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|track| {
|
||||||
|
let ranked = by_id.get(&track.track_id)?;
|
||||||
|
let hit = SimilarityHit {
|
||||||
|
score: ranked.score,
|
||||||
|
item_id: hex(
|
||||||
|
ItemId::derive(
|
||||||
|
&own,
|
||||||
|
ItemKind::Track,
|
||||||
|
&format!("track:{}", track.track_id),
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
),
|
||||||
|
title: track.title,
|
||||||
|
artist_names: track.artist_names,
|
||||||
|
featured_artist_names: track.featured_artist_names,
|
||||||
|
year: track.year,
|
||||||
|
duration_seconds: Some(track.duration_seconds.round() as i64),
|
||||||
|
content_id: track.content_id,
|
||||||
|
release_title: Some(track.release_title),
|
||||||
|
track_number: track.track_number,
|
||||||
|
disc_number: track.disc_number,
|
||||||
|
embedding_signature: Some(ranked.embedding_signature),
|
||||||
|
};
|
||||||
|
match hit.validate() {
|
||||||
|
Ok(()) => Some(hit),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::debug!(%error, "invalid local similarity metadata skipped");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
SimilarityResponse::success(hits)?
|
||||||
|
}
|
||||||
|
Err(error) => SimilarityResponse::refused(format!(
|
||||||
|
"similarity metadata is unavailable: {error:#}"
|
||||||
|
))?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
SimilarityResponse::refused(format!("similarity query is unavailable: {error:#}"))?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
wire::write_response(&mut stream, &response).await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
let _ = stream.send.stopped().await;
|
||||||
|
super::record_stream_transport(&transport, "similarity", "inbound", "done", &stream);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search(
|
||||||
|
service: Arc<MusicDhtService>,
|
||||||
|
routing: Arc<SimilarityDht>,
|
||||||
|
query: QueryVector,
|
||||||
|
limit: usize,
|
||||||
|
transport: Arc<TransportStats>,
|
||||||
|
) -> Result<SimilaritySearchOutcome> {
|
||||||
|
let own = service.endpoint_id();
|
||||||
|
let routed = match tokio::time::timeout(
|
||||||
|
ROUTING_TIMEOUT,
|
||||||
|
routing.find_peers(&query.profile_id, &query.vector, MAX_QUERY_PEERS),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(peers)) => peers,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::debug!("similarity DHT lookup timed out; using known peers");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
tracing::debug!(%error, "similarity DHT lookup unavailable; using known peers");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let mut peers: Vec<QueryPeer> = routed
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|ticket| {
|
||||||
|
let owner = ticket.endpoint_id();
|
||||||
|
(owner != own && seen.insert(owner)).then_some(QueryPeer {
|
||||||
|
owner,
|
||||||
|
ticket: Some(ticket),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for peer in service
|
||||||
|
.connected_peers()
|
||||||
|
.into_iter()
|
||||||
|
.chain(service.known_peers().into_iter().map(|peer| peer.peer_id))
|
||||||
|
{
|
||||||
|
if peer != own && seen.insert(peer) {
|
||||||
|
peers.push(QueryPeer {
|
||||||
|
owner: peer,
|
||||||
|
ticket: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if peers.len() >= MAX_QUERY_PEERS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let query_signature = wire::embedding_signature(&query.vector)?;
|
||||||
|
let request = Arc::new(SimilarityRequest::new(
|
||||||
|
query.profile_id,
|
||||||
|
query.vector,
|
||||||
|
limit.clamp(1, wire::MAX_SIMILARITY_RESULTS),
|
||||||
|
)?);
|
||||||
|
|
||||||
|
let mut hits = Vec::new();
|
||||||
|
let initial = peers.len().min(INITIAL_QUERY_PEERS);
|
||||||
|
let mut queried_peers = initial;
|
||||||
|
let responses = query_peers(
|
||||||
|
Arc::clone(&service),
|
||||||
|
&peers[..initial],
|
||||||
|
Arc::clone(&request),
|
||||||
|
Arc::clone(&transport),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut successful = 0usize;
|
||||||
|
for response in responses {
|
||||||
|
match response {
|
||||||
|
Ok(peer_hits) => {
|
||||||
|
successful += 1;
|
||||||
|
hits.extend(peer_hits);
|
||||||
|
}
|
||||||
|
Err(error) => tracing::debug!(%error, "similarity peer query skipped"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if initial < peers.len() && (hits.len() < limit || successful < initial.min(4)) {
|
||||||
|
queried_peers += peers.len() - initial;
|
||||||
|
for response in query_peers(
|
||||||
|
Arc::clone(&service),
|
||||||
|
&peers[initial..],
|
||||||
|
Arc::clone(&request),
|
||||||
|
Arc::clone(&transport),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
match response {
|
||||||
|
Ok(peer_hits) => hits.extend(peer_hits),
|
||||||
|
Err(error) => tracing::debug!(%error, "fallback similarity peer query skipped"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hits.sort_by(|left, right| right.1.total_cmp(&left.1));
|
||||||
|
let mut dedup = HashSet::new();
|
||||||
|
let mut signatures = vec![query_signature];
|
||||||
|
let mut artist_counts: HashMap<String, usize> = HashMap::new();
|
||||||
|
let mut tracks = Vec::new();
|
||||||
|
for (track, _, signature) in hits {
|
||||||
|
if query
|
||||||
|
.source_content_id
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|source| track.content_id.as_deref() == Some(source))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let key = track
|
||||||
|
.content_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{}:{}", track.owner, track.item_id));
|
||||||
|
if !dedup.insert(key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if signature.is_some_and(|candidate| {
|
||||||
|
signatures.iter().any(|existing| {
|
||||||
|
wire::signature_distance(&candidate, existing)
|
||||||
|
<= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE
|
||||||
|
})
|
||||||
|
}) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let artist = track
|
||||||
|
.artist_names
|
||||||
|
.first()
|
||||||
|
.map(|name| music_dht::normalize_name(name))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let count = artist_counts.entry(artist.clone()).or_default();
|
||||||
|
if !artist.is_empty() && *count >= MAX_PER_ARTIST {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
*count += 1;
|
||||||
|
if let Some(signature) = signature {
|
||||||
|
signatures.push(signature);
|
||||||
|
}
|
||||||
|
tracks.push(track);
|
||||||
|
if tracks.len() >= limit.min(wire::MAX_SIMILARITY_RESULTS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(SimilaritySearchOutcome {
|
||||||
|
tracks,
|
||||||
|
queried_peers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type PeerHits = Vec<(
|
||||||
|
RemoteSimilarityTrack,
|
||||||
|
f32,
|
||||||
|
Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>,
|
||||||
|
)>;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct QueryPeer {
|
||||||
|
owner: EndpointId,
|
||||||
|
ticket: Option<PeerTicket>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn query_peers(
|
||||||
|
service: Arc<MusicDhtService>,
|
||||||
|
peers: &[QueryPeer],
|
||||||
|
request: Arc<SimilarityRequest>,
|
||||||
|
transport: Arc<TransportStats>,
|
||||||
|
) -> Vec<Result<PeerHits>> {
|
||||||
|
stream::iter(peers.iter().cloned().map(|peer| {
|
||||||
|
let service = Arc::clone(&service);
|
||||||
|
let request = Arc::clone(&request);
|
||||||
|
let transport = Arc::clone(&transport);
|
||||||
|
async move {
|
||||||
|
tokio::time::timeout(
|
||||||
|
QUERY_TIMEOUT,
|
||||||
|
query_peer(service, peer, &request, transport),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("similarity peer timed out"))?
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.buffer_unordered(QUERY_CONCURRENCY)
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn query_peer(
|
||||||
|
service: Arc<MusicDhtService>,
|
||||||
|
peer: QueryPeer,
|
||||||
|
request: &SimilarityRequest,
|
||||||
|
transport: Arc<TransportStats>,
|
||||||
|
) -> Result<PeerHits> {
|
||||||
|
let owner = peer.owner;
|
||||||
|
let mut stream = match peer.ticket {
|
||||||
|
Some(ticket) => service.open_stream_to(&ticket, SIMILARITY_ALPN).await,
|
||||||
|
None => service.open_stream(owner, SIMILARITY_ALPN).await,
|
||||||
|
}
|
||||||
|
.map_err(|error| anyhow::anyhow!("cannot reach similarity peer: {error}"))?;
|
||||||
|
super::record_stream_transport(&transport, "similarity", "outbound", "open", &stream);
|
||||||
|
let response = wire::exchange(&mut stream, request).await?;
|
||||||
|
super::record_stream_transport(&transport, "similarity", "outbound", "done", &stream);
|
||||||
|
anyhow::ensure!(
|
||||||
|
response.ok,
|
||||||
|
"peer refused similarity query: {}",
|
||||||
|
response.error.unwrap_or_default()
|
||||||
|
);
|
||||||
|
Ok(response
|
||||||
|
.hits
|
||||||
|
.into_iter()
|
||||||
|
.map(|hit| {
|
||||||
|
let score = hit.score;
|
||||||
|
let signature = hit.embedding_signature;
|
||||||
|
(
|
||||||
|
RemoteSimilarityTrack {
|
||||||
|
owner: owner.to_string(),
|
||||||
|
item_id: hit.item_id,
|
||||||
|
title: hit.title,
|
||||||
|
artist_names: hit.artist_names,
|
||||||
|
featured_artist_names: hit.featured_artist_names,
|
||||||
|
year: hit.year,
|
||||||
|
duration_seconds: hit.duration_seconds,
|
||||||
|
content_id: hit.content_id,
|
||||||
|
release_title: hit.release_title,
|
||||||
|
track_number: hit.track_number,
|
||||||
|
disc_number: hit.disc_number,
|
||||||
|
similarity_score: score,
|
||||||
|
},
|
||||||
|
score,
|
||||||
|
signature,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hamming_threshold_keeps_exact_and_near_duplicates_out() {
|
||||||
|
let query = [0u8; wire::SIMILARITY_SIGNATURE_BYTES];
|
||||||
|
let mut near = query;
|
||||||
|
near[0] = 0b0000_0111;
|
||||||
|
assert!(wire::signature_distance(&query, &near) <= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use music_dht::{
|
||||||
|
DhtKey, EndpointId, LibraryItem, MAX_RECORDS_PER_RESPONSE, MusicDhtError, MusicDhtStorage,
|
||||||
|
NodeContact, NodeId, SecretKey, StoreDecision, StoredRecord, decide_store,
|
||||||
|
};
|
||||||
|
use sqlx::{PgPool, Row as _};
|
||||||
|
|
||||||
|
const IDENTITY_NAME: &str = "default";
|
||||||
|
|
||||||
|
const SCHEMA: &[&str] = &[
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_identity (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
secret_key BYTEA NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_local_item (
|
||||||
|
id BYTEA PRIMARY KEY,
|
||||||
|
normalized_name TEXT NOT NULL,
|
||||||
|
revision BIGINT NOT NULL,
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
payload BYTEA NOT NULL
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_local_item_normalized_name
|
||||||
|
ON furumusic__federation_local_item(normalized_name)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_dht_record (
|
||||||
|
dht_key BYTEA NOT NULL,
|
||||||
|
item_id BYTEA NOT NULL,
|
||||||
|
owner_peer_id TEXT NOT NULL,
|
||||||
|
payload BYTEA NOT NULL,
|
||||||
|
revision BIGINT NOT NULL,
|
||||||
|
deleted BOOLEAN NOT NULL,
|
||||||
|
expires_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (dht_key, item_id, owner_peer_id)
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_dht_record_expires_at
|
||||||
|
ON furumusic__federation_dht_record(expires_at_ms)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_known_peer (
|
||||||
|
peer_id TEXT PRIMARY KEY,
|
||||||
|
node_id BYTEA NOT NULL,
|
||||||
|
ticket TEXT NOT NULL,
|
||||||
|
last_seen_ms BIGINT NOT NULL
|
||||||
|
)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_content_id_cache (
|
||||||
|
media_file_id BIGINT PRIMARY KEY,
|
||||||
|
sha256_hash TEXT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
|
||||||
|
ON furumusic__federation_content_id_cache(content_id)",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PostgresFederationStorage {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresFederationStorage {
|
||||||
|
pub async fn new(pool: PgPool) -> music_dht::Result<Self> {
|
||||||
|
let storage = Self { pool };
|
||||||
|
storage.ensure_schema().await?;
|
||||||
|
Ok(storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_or_create_secret_key(&self) -> music_dht::Result<SecretKey> {
|
||||||
|
if let Some(bytes) = sqlx::query_scalar::<_, Vec<u8>>(
|
||||||
|
"SELECT secret_key FROM furumusic__federation_identity WHERE name = $1",
|
||||||
|
)
|
||||||
|
.bind(IDENTITY_NAME)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?
|
||||||
|
{
|
||||||
|
return secret_from_bytes(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = SecretKey::generate();
|
||||||
|
let key_bytes = key.to_bytes();
|
||||||
|
let now = now_iso();
|
||||||
|
let inserted = sqlx::query(
|
||||||
|
"INSERT INTO furumusic__federation_identity
|
||||||
|
(name, secret_key, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (name) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(IDENTITY_NAME)
|
||||||
|
.bind(key_bytes.as_slice())
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?
|
||||||
|
.rows_affected();
|
||||||
|
if inserted == 1 {
|
||||||
|
return Ok(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = sqlx::query_scalar::<_, Vec<u8>>(
|
||||||
|
"SELECT secret_key FROM furumusic__federation_identity WHERE name = $1",
|
||||||
|
)
|
||||||
|
.bind(IDENTITY_NAME)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
secret_from_bytes(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_schema(&self) -> music_dht::Result<()> {
|
||||||
|
for sql in SCHEMA {
|
||||||
|
sqlx::query(sql)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl MusicDhtStorage for PostgresFederationStorage {
|
||||||
|
async fn upsert_local_item(&self, item: &LibraryItem) -> music_dht::Result<()> {
|
||||||
|
let payload = postcard::to_stdvec(item).map_err(db_error)?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO furumusic__federation_local_item
|
||||||
|
(id, normalized_name, revision, deleted, updated_at_ms, payload)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
normalized_name = EXCLUDED.normalized_name,
|
||||||
|
revision = EXCLUDED.revision,
|
||||||
|
deleted = EXCLUDED.deleted,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms,
|
||||||
|
payload = EXCLUDED.payload",
|
||||||
|
)
|
||||||
|
.bind(item.id.as_bytes().as_slice())
|
||||||
|
.bind(&item.normalized_name)
|
||||||
|
.bind(item.revision as i64)
|
||||||
|
.bind(item.deleted)
|
||||||
|
.bind(item.updated_at_ms as i64)
|
||||||
|
.bind(payload)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_local_items(&self, include_deleted: bool) -> music_dht::Result<Vec<LibraryItem>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT payload
|
||||||
|
FROM furumusic__federation_local_item
|
||||||
|
WHERE $1 OR deleted = false
|
||||||
|
ORDER BY normalized_name",
|
||||||
|
)
|
||||||
|
.bind(include_deleted)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|row| postcard::from_bytes::<LibraryItem>(&row.get::<Vec<u8>, _>(0)).ok())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_dht_record(&self, key: DhtKey, record: StoredRecord) -> music_dht::Result<bool> {
|
||||||
|
let mut conn = self.pool.acquire().await.map_err(db_error)?;
|
||||||
|
store_record_in_conn(&mut conn, &key, &record).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_dht_records(
|
||||||
|
&self,
|
||||||
|
entries: Vec<(DhtKey, StoredRecord)>,
|
||||||
|
) -> music_dht::Result<Vec<bool>> {
|
||||||
|
let mut tx = self.pool.begin().await.map_err(db_error)?;
|
||||||
|
let mut stored = Vec::with_capacity(entries.len());
|
||||||
|
for (key, record) in &entries {
|
||||||
|
stored.push(store_record_in_conn(&mut tx, key, record).await?);
|
||||||
|
}
|
||||||
|
tx.commit().await.map_err(db_error)?;
|
||||||
|
Ok(stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dht_records_by_key(
|
||||||
|
&self,
|
||||||
|
key: DhtKey,
|
||||||
|
now_ms: u64,
|
||||||
|
) -> music_dht::Result<Vec<StoredRecord>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT payload
|
||||||
|
FROM furumusic__federation_dht_record
|
||||||
|
WHERE dht_key = $1 AND expires_at_ms > $2
|
||||||
|
ORDER BY expires_at_ms DESC, item_id
|
||||||
|
LIMIT $3",
|
||||||
|
)
|
||||||
|
.bind(key.as_bytes().as_slice())
|
||||||
|
.bind(now_ms as i64)
|
||||||
|
.bind(MAX_RECORDS_PER_RESPONSE as i64)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|row| postcard::from_bytes::<StoredRecord>(&row.get::<Vec<u8>, _>(0)).ok())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_expired_records(&self, now_ms: u64) -> music_dht::Result<usize> {
|
||||||
|
let result =
|
||||||
|
sqlx::query("DELETE FROM furumusic__federation_dht_record WHERE expires_at_ms <= $1")
|
||||||
|
.bind(now_ms as i64)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(result.rows_affected() as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_known_peer(&self, contact: &NodeContact) -> music_dht::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO furumusic__federation_known_peer
|
||||||
|
(peer_id, node_id, ticket, last_seen_ms)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (peer_id) DO UPDATE SET
|
||||||
|
node_id = EXCLUDED.node_id,
|
||||||
|
ticket = EXCLUDED.ticket,
|
||||||
|
last_seen_ms = EXCLUDED.last_seen_ms",
|
||||||
|
)
|
||||||
|
.bind(contact.peer_id.to_string())
|
||||||
|
.bind(contact.node_id.as_bytes().as_slice())
|
||||||
|
.bind(&contact.ticket)
|
||||||
|
.bind(contact.last_seen_ms as i64)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_known_peer(&self, peer_id: EndpointId) -> music_dht::Result<()> {
|
||||||
|
sqlx::query("DELETE FROM furumusic__federation_known_peer WHERE peer_id = $1")
|
||||||
|
.bind(peer_id.to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_known_peers(&self) -> music_dht::Result<Vec<NodeContact>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT peer_id, node_id, ticket, last_seen_ms
|
||||||
|
FROM furumusic__federation_known_peer",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
let mut contacts = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
let peer_id: String = row.get(0);
|
||||||
|
let node_id: Vec<u8> = row.get(1);
|
||||||
|
let ticket: String = row.get(2);
|
||||||
|
let last_seen_ms: i64 = row.get(3);
|
||||||
|
let Ok(peer_id) = EndpointId::from_str(&peer_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(node_id) = <[u8; 32]>::try_from(node_id.as_slice()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
contacts.push(NodeContact {
|
||||||
|
node_id: NodeId::from_bytes(node_id),
|
||||||
|
peer_id,
|
||||||
|
ticket,
|
||||||
|
last_seen_ms: last_seen_ms as u64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(contacts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_iso() -> String {
|
||||||
|
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn secret_from_bytes(bytes: Vec<u8>) -> music_dht::Result<SecretKey> {
|
||||||
|
let bytes: [u8; 32] = bytes
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| MusicDhtError::Database("stored federation identity is corrupted".into()))?;
|
||||||
|
Ok(SecretKey::from_bytes(&bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies one validated record following the revision/tombstone rules.
|
||||||
|
/// Returns `true` if the record was written or refreshed. Runs against a
|
||||||
|
/// pooled connection or an open transaction.
|
||||||
|
async fn store_record_in_conn(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
key: &DhtKey,
|
||||||
|
record: &StoredRecord,
|
||||||
|
) -> music_dht::Result<bool> {
|
||||||
|
let existing = sqlx::query(
|
||||||
|
"SELECT revision, deleted, expires_at_ms
|
||||||
|
FROM furumusic__federation_dht_record
|
||||||
|
WHERE dht_key = $1 AND item_id = $2 AND owner_peer_id = $3",
|
||||||
|
)
|
||||||
|
.bind(key.as_bytes().as_slice())
|
||||||
|
.bind(record.item.id.as_bytes().as_slice())
|
||||||
|
.bind(record.item.owner.to_string())
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?
|
||||||
|
.map(|row| {
|
||||||
|
(
|
||||||
|
row.get::<i64, _>(0) as u64,
|
||||||
|
row.get::<bool, _>(1),
|
||||||
|
row.get::<i64, _>(2) as u64,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
match decide_store(existing, record) {
|
||||||
|
StoreDecision::Ignore => return Ok(false),
|
||||||
|
StoreDecision::Write | StoreDecision::RefreshExpiry(_) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = postcard::to_stdvec(record).map_err(db_error)?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO furumusic__federation_dht_record
|
||||||
|
(dht_key, item_id, owner_peer_id, payload, revision, deleted, expires_at_ms)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (dht_key, item_id, owner_peer_id) DO UPDATE SET
|
||||||
|
payload = EXCLUDED.payload,
|
||||||
|
revision = EXCLUDED.revision,
|
||||||
|
deleted = EXCLUDED.deleted,
|
||||||
|
expires_at_ms = EXCLUDED.expires_at_ms",
|
||||||
|
)
|
||||||
|
.bind(key.as_bytes().as_slice())
|
||||||
|
.bind(record.item.id.as_bytes().as_slice())
|
||||||
|
.bind(record.item.owner.to_string())
|
||||||
|
.bind(payload)
|
||||||
|
.bind(record.item.revision as i64)
|
||||||
|
.bind(record.item.deleted)
|
||||||
|
.bind(record.expires_at_ms as i64)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn db_error(err: impl std::fmt::Display) -> MusicDhtError {
|
||||||
|
MusicDhtError::Database(err.to_string())
|
||||||
|
}
|
||||||
+82
-11
@@ -96,9 +96,9 @@ translations! {
|
|||||||
settings_swagger: "Swagger UI" , "Swagger UI";
|
settings_swagger: "Swagger UI" , "Swagger UI";
|
||||||
settings_swagger_help: "Serves interactive API docs at /swagger/ (requires restart)" , "Интерактивная документация API на /swagger/ (требуется перезапуск)";
|
settings_swagger_help: "Serves interactive API docs at /swagger/ (requires restart)" , "Интерактивная документация API на /swagger/ (требуется перезапуск)";
|
||||||
settings_lastfm_api_key: "Last.fm API key" , "API ключ Last.fm";
|
settings_lastfm_api_key: "Last.fm API key" , "API ключ Last.fm";
|
||||||
settings_lastfm_api_key_help: "Used for Last.fm popularity and account connection" , "Используется для популярности Last.fm и подключения аккаунта";
|
settings_lastfm_api_key_help: "Identifies this application to Last.fm and enables metadata, popularity data, and user account connection" , "Идентифицирует приложение в Last.fm и включает метаданные, данные о популярности и подключение аккаунтов";
|
||||||
settings_lastfm_shared_secret: "Last.fm shared secret" , "Shared secret Last.fm";
|
settings_lastfm_shared_secret: "Last.fm shared secret" , "Shared secret Last.fm";
|
||||||
settings_lastfm_shared_secret_help: "Required for signed Last.fm scrobbling requests" , "Нужен для подписанных запросов скробблинга Last.fm";
|
settings_lastfm_shared_secret_help: "Authenticates signed Last.fm requests, including scrobbling. Keep this value private" , "Подтверждает подписанные запросы Last.fm, включая скробблинг. Не раскрывайте это значение";
|
||||||
|
|
||||||
// OIDC login errors
|
// OIDC login errors
|
||||||
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
|
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
|
||||||
@@ -298,6 +298,9 @@ translations! {
|
|||||||
player_likes_playlist: "Likes" , "Лайки";
|
player_likes_playlist: "Likes" , "Лайки";
|
||||||
player_listened: "listened" , "прослушано";
|
player_listened: "listened" , "прослушано";
|
||||||
player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков...";
|
player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков...";
|
||||||
|
player_search_similar_to: "Search similar to:" , "Поиск похожих на:";
|
||||||
|
player_find_similar: "Find similar tracks" , "Найти похожие треки";
|
||||||
|
player_similarity_failed: "Similarity search failed" , "Не удалось найти похожие треки";
|
||||||
player_connection_lost: "Server connection lost" , "Нет соединения с сервером";
|
player_connection_lost: "Server connection lost" , "Нет соединения с сервером";
|
||||||
player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю...";
|
player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю...";
|
||||||
player_active_device: "Active device" , "Активный девайс";
|
player_active_device: "Active device" , "Активный девайс";
|
||||||
@@ -309,10 +312,14 @@ translations! {
|
|||||||
player_cancel: "Cancel" , "Отмена";
|
player_cancel: "Cancel" , "Отмена";
|
||||||
player_create: "Create" , "Создать";
|
player_create: "Create" , "Создать";
|
||||||
player_save: "Save" , "Сохранить";
|
player_save: "Save" , "Сохранить";
|
||||||
|
player_done: "Done" , "Готово";
|
||||||
player_delete: "Delete" , "Удалить";
|
player_delete: "Delete" , "Удалить";
|
||||||
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
|
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
|
||||||
player_rename: "Rename" , "Переименовать";
|
player_rename: "Rename" , "Переименовать";
|
||||||
player_close: "Close" , "Закрыть";
|
player_close: "Close" , "Закрыть";
|
||||||
|
player_interface_language: "Interface language" , "Язык интерфейса";
|
||||||
|
player_language_description: "Choose the language used by the web player." , "Выберите язык интерфейса веб-плеера.";
|
||||||
|
player_switch_language: "Русский" , "English";
|
||||||
player_log_out: "Log out" , "Выйти";
|
player_log_out: "Log out" , "Выйти";
|
||||||
player_admin_panel: "Admin Panel" , "Админка";
|
player_admin_panel: "Admin Panel" , "Админка";
|
||||||
player_info: "Info" , "Информация";
|
player_info: "Info" , "Информация";
|
||||||
@@ -358,6 +365,11 @@ translations! {
|
|||||||
player_add_to_queue: "Add to queue" , "Добавить в очередь";
|
player_add_to_queue: "Add to queue" , "Добавить в очередь";
|
||||||
player_add_to_end_queue: "Add to end of queue" , "Добавить в конец очереди";
|
player_add_to_end_queue: "Add to end of queue" , "Добавить в конец очереди";
|
||||||
player_play_next: "Play next" , "Играть следующим";
|
player_play_next: "Play next" , "Играть следующим";
|
||||||
|
player_share: "Share" , "Поделиться";
|
||||||
|
player_share_track: "Share track" , "Поделиться треком";
|
||||||
|
player_share_queue: "Share queue" , "Поделиться очередью";
|
||||||
|
player_shared_playlist: "Shared playlist" , "Общий плейлист";
|
||||||
|
player_jam_play_on_this_device: "Play on this device" , "Играть на этом устройстве";
|
||||||
player_queue: "Queue" , "Очередь";
|
player_queue: "Queue" , "Очередь";
|
||||||
player_next: "Next" , "Далее";
|
player_next: "Next" , "Далее";
|
||||||
player_previous: "Previous" , "Назад";
|
player_previous: "Previous" , "Назад";
|
||||||
@@ -368,6 +380,7 @@ translations! {
|
|||||||
player_repeat: "Repeat" , "Повтор";
|
player_repeat: "Repeat" , "Повтор";
|
||||||
player_volume: "Volume" , "Громкость";
|
player_volume: "Volume" , "Громкость";
|
||||||
player_appears_on: "Appears on" , "Участвует в";
|
player_appears_on: "Appears on" , "Участвует в";
|
||||||
|
player_top_tracks: "Popular tracks" , "Популярные треки";
|
||||||
player_albums: "Albums" , "Альбомы";
|
player_albums: "Albums" , "Альбомы";
|
||||||
player_eps: "EPs" , "EP";
|
player_eps: "EPs" , "EP";
|
||||||
player_singles: "Singles" , "Синглы";
|
player_singles: "Singles" , "Синглы";
|
||||||
@@ -376,9 +389,54 @@ translations! {
|
|||||||
player_live_releases: "Live releases" , "Концертные релизы";
|
player_live_releases: "Live releases" , "Концертные релизы";
|
||||||
player_soundtracks: "Soundtracks" , "Саундтреки";
|
player_soundtracks: "Soundtracks" , "Саундтреки";
|
||||||
|
|
||||||
// Player torrent/history UI
|
// Player download/history UI
|
||||||
player_torrent_manager: "Torrent manager" , "Торрент-менеджер";
|
player_torrent_manager: "Download Manager" , "Менеджер загрузок";
|
||||||
player_import_torrent: "Import torrent" , "Импортировать торрент";
|
player_import_torrent: "Open Download Manager" , "Открыть менеджер загрузок";
|
||||||
|
player_youtube: "YouTube" , "YouTube";
|
||||||
|
player_torrents: "Torrents" , "Торренты";
|
||||||
|
player_files: "Files" , "Файлы";
|
||||||
|
player_youtube_url: "Public video or playlist URL" , "Ссылка на открытое видео или плейлист";
|
||||||
|
player_youtube_url_hint: "Public youtube.com, music.youtube.com and youtu.be links are supported. Each video is imported as one AI batch." , "Поддерживаются открытые ссылки youtube.com, music.youtube.com и youtu.be. Каждое видео импортируется одним ИИ-батчем.";
|
||||||
|
player_youtube_downloads: "YouTube downloads" , "Загрузки YouTube";
|
||||||
|
player_no_youtube_downloads: "No YouTube downloads yet" , "Загрузок YouTube пока нет";
|
||||||
|
player_youtube_parse: "Check link" , "Проверить ссылку";
|
||||||
|
player_youtube_parsing: "Reading YouTube link..." , "Читаю ссылку YouTube...";
|
||||||
|
player_youtube_preview_failed: "Could not read YouTube link" , "Не удалось прочитать ссылку YouTube";
|
||||||
|
player_youtube_preview_title: "Choose videos to import" , "Выберите видео для импорта";
|
||||||
|
player_youtube_select_all: "Select all" , "Отметить все";
|
||||||
|
player_youtube_clear_selection: "Clear selection" , "Снять все";
|
||||||
|
player_youtube_selected_count: "selected" , "выбрано";
|
||||||
|
player_youtube_start_import: "Start import" , "Начать импорт";
|
||||||
|
player_start_download: "Start download" , "Начать загрузку";
|
||||||
|
player_retry_failed: "Retry failed" , "Повторить ошибки";
|
||||||
|
player_download_steps: "Processing steps" , "Этапы обработки";
|
||||||
|
player_chapters: "chapters" , "глав";
|
||||||
|
player_youtube_video: "video" , "видео";
|
||||||
|
player_youtube_playlist: "playlist" , "плейлист";
|
||||||
|
player_youtube_items: "videos" , "видео";
|
||||||
|
player_youtube_errors: "errors" , "ошибок";
|
||||||
|
player_youtube_queued: "Queued" , "В очереди";
|
||||||
|
player_youtube_resolving: "Reading link" , "Чтение ссылки";
|
||||||
|
player_youtube_postprocessing: "FFmpeg processing" , "Обработка FFmpeg";
|
||||||
|
player_youtube_awaiting_ai: "Waiting for AI" , "Ожидание ИИ";
|
||||||
|
player_youtube_ai_processing: "AI processing" , "Обработка ИИ";
|
||||||
|
player_youtube_needs_review: "Needs review" , "Требует проверки";
|
||||||
|
player_youtube_complete_with_errors: "Completed with errors" , "Завершено с ошибками";
|
||||||
|
player_youtube_skipped: "Already imported" , "Уже импортировано";
|
||||||
|
player_youtube_cancelled: "Stopped" , "Остановлено";
|
||||||
|
player_youtube_stop: "Stop import" , "Остановить импорт";
|
||||||
|
player_youtube_stop_confirm: "Stop this YouTube import? Completed and already published audio will remain." , "Остановить этот импорт из YouTube? Готовое и уже переданное на обработку аудио останется.";
|
||||||
|
player_youtube_stopping: "Stopping YouTube import..." , "Останавливаю импорт из YouTube...";
|
||||||
|
player_youtube_stopped: "YouTube import stopped." , "Импорт из YouTube остановлен.";
|
||||||
|
player_youtube_stop_failed: "Could not stop YouTube import" , "Не удалось остановить импорт из YouTube";
|
||||||
|
player_youtube_starting: "Adding YouTube download..." , "Добавляю загрузку YouTube...";
|
||||||
|
player_youtube_started: "YouTube download added." , "Загрузка YouTube добавлена.";
|
||||||
|
player_youtube_load_failed: "Could not load YouTube downloads" , "Не удалось загрузить список YouTube";
|
||||||
|
player_youtube_start_failed: "Could not start YouTube download" , "Не удалось начать загрузку YouTube";
|
||||||
|
player_youtube_retry_failed: "Could not retry YouTube download" , "Не удалось повторить загрузку YouTube";
|
||||||
|
player_youtube_delete_failed: "Could not remove YouTube download" , "Не удалось удалить загрузку YouTube";
|
||||||
|
player_youtube_delete_confirm: "Remove this YouTube download from history? Imported audio will remain." , "Удалить эту загрузку YouTube из истории? Импортированное аудио останется.";
|
||||||
|
player_remove_from_history: "Remove from history" , "Удалить из истории";
|
||||||
player_client_idle: "Client idle" , "Клиент простаивает";
|
player_client_idle: "Client idle" , "Клиент простаивает";
|
||||||
player_active: "active" , "активно";
|
player_active: "active" , "активно";
|
||||||
player_ai_idle: "AI idle" , "ИИ простаивает";
|
player_ai_idle: "AI idle" , "ИИ простаивает";
|
||||||
@@ -454,11 +512,24 @@ translations! {
|
|||||||
player_track_approved_imported: "Track approved and imported" , "Трек подтверждён и импортирован";
|
player_track_approved_imported: "Track approved and imported" , "Трек подтверждён и импортирован";
|
||||||
player_failed_update_selected_tracks: "Failed to update selected tracks" , "Не удалось обновить выбранные треки";
|
player_failed_update_selected_tracks: "Failed to update selected tracks" , "Не удалось обновить выбранные треки";
|
||||||
player_selected_tracks_updated: "Selected tracks updated" , "Выбранные треки обновлены";
|
player_selected_tracks_updated: "Selected tracks updated" , "Выбранные треки обновлены";
|
||||||
player_choose_saved_or_add_torrent: "Choose a saved item or upload new files." , "Выберите сохранённый элемент или загрузите новые файлы.";
|
player_choose_saved_or_add_torrent: "Choose a saved torrent or add a new one." , "Выберите сохранённый торрент или добавьте новый.";
|
||||||
player_local_files: "Local audio files" , "Локальные аудиофайлы";
|
player_local_files: "Local audio files" , "Локальные аудиофайлы";
|
||||||
|
player_file_uploads: "File uploads" , "Загрузки файлов";
|
||||||
|
player_drop_audio_title: "Drop audio files here" , "Перетащите аудиофайлы сюда";
|
||||||
|
player_drop_audio_hint: "or click to choose files" , "или нажмите, чтобы выбрать файлы";
|
||||||
|
player_drop_audio_formats: "MP3, FLAC, WAV, M4A, OGG, Opus and AAC" , "MP3, FLAC, WAV, M4A, OGG, Opus и AAC";
|
||||||
|
player_upload_selected_files: "Upload selected files" , "Загрузить выбранные файлы";
|
||||||
|
player_upload_history: "Upload history" , "История загрузок";
|
||||||
|
player_no_file_uploads: "No file uploads yet" , "Загрузок файлов пока нет";
|
||||||
|
player_file_upload_load_failed: "Could not load file upload history" , "Не удалось загрузить историю файлов";
|
||||||
|
player_remove_file_upload_confirm: "Remove this file upload from history? Imported audio will remain." , "Удалить эту загрузку файла из истории? Импортированное аудио останется.";
|
||||||
|
player_file_upload_history_removed: "File upload removed from history." , "Загрузка файла удалена из истории.";
|
||||||
|
player_file_upload_history_remove_failed: "Could not remove file upload from history" , "Не удалось удалить загрузку файла из истории";
|
||||||
|
player_no_supported_audio_files: "Choose at least one supported audio file." , "Выберите хотя бы один поддерживаемый аудиофайл.";
|
||||||
player_torrent_file: "Torrent file" , "Torrent-файл";
|
player_torrent_file: "Torrent file" , "Torrent-файл";
|
||||||
player_magnet_link: "Magnet link" , "Magnet-ссылка";
|
player_magnet_link: "Magnet link" , "Magnet-ссылка";
|
||||||
player_upload_content: "Upload" , "Загрузить";
|
player_upload_content: "Preview torrent" , "Проверить торрент";
|
||||||
|
player_add_torrent: "Add torrent" , "Добавить торрент";
|
||||||
player_download_selected: "Download selected" , "Скачать выбранное";
|
player_download_selected: "Download selected" , "Скачать выбранное";
|
||||||
player_pause_download: "Pause download" , "Поставить на паузу";
|
player_pause_download: "Pause download" , "Поставить на паузу";
|
||||||
player_expand_all: "Expand all" , "Развернуть всё";
|
player_expand_all: "Expand all" , "Развернуть всё";
|
||||||
@@ -488,7 +559,7 @@ translations! {
|
|||||||
player_no_plays_yet: "No plays yet" , "Прослушиваний пока нет";
|
player_no_plays_yet: "No plays yet" , "Прослушиваний пока нет";
|
||||||
player_page: "Page" , "Страница";
|
player_page: "Page" , "Страница";
|
||||||
player_of: "of" , "из";
|
player_of: "of" , "из";
|
||||||
player_choose_torrent: "Choose local files, paste a magnet link, or choose a .torrent file." , "Выберите локальные файлы, вставьте magnet-ссылку или выберите .torrent файл.";
|
player_choose_torrent: "Paste a magnet link or choose a .torrent file." , "Вставьте magnet-ссылку или выберите .torrent файл.";
|
||||||
player_uploading_files: "Uploading files..." , "Загружаю файлы...";
|
player_uploading_files: "Uploading files..." , "Загружаю файлы...";
|
||||||
player_upload_complete: "Upload complete. Files are queued for processing." , "Загрузка завершена. Файлы поставлены в обработку.";
|
player_upload_complete: "Upload complete. Files are queued for processing." , "Загрузка завершена. Файлы поставлены в обработку.";
|
||||||
player_upload_failed: "Upload failed" , "Загрузка не удалась";
|
player_upload_failed: "Upload failed" , "Загрузка не удалась";
|
||||||
@@ -498,8 +569,8 @@ translations! {
|
|||||||
player_all_files_selected: "All files are selected by default. Clear or adjust the tree before download." , "Все файлы выбраны по умолчанию. Перед скачиванием можно очистить или изменить выбор.";
|
player_all_files_selected: "All files are selected by default. Clear or adjust the tree before download." , "Все файлы выбраны по умолчанию. Перед скачиванием можно очистить или изменить выбор.";
|
||||||
player_opening_saved_torrent: "Opening saved torrent..." , "Открываю сохранённый торрент...";
|
player_opening_saved_torrent: "Opening saved torrent..." , "Открываю сохранённый торрент...";
|
||||||
player_saved_torrent_opened: "Saved torrent opened. Adjust files or resume download." , "Сохранённый торрент открыт. Можно изменить файлы или продолжить скачивание.";
|
player_saved_torrent_opened: "Saved torrent opened. Adjust files or resume download." , "Сохранённый торрент открыт. Можно изменить файлы или продолжить скачивание.";
|
||||||
player_remove_torrent_confirm: "Remove this torrent from the client list? Downloaded files will stay on disk." , "Удалить этот торрент из списка клиента? Скачанные файлы останутся на диске.";
|
player_remove_torrent_confirm: "Remove this torrent from history? Downloaded files will stay on disk." , "Удалить этот торрент из истории? Скачанные файлы останутся на диске.";
|
||||||
player_torrent_removed: "Torrent removed from the client list." , "Торрент удалён из списка клиента.";
|
player_torrent_removed: "Torrent removed from history." , "Торрент удалён из истории.";
|
||||||
player_select_one_file: "Select at least one file." , "Выберите хотя бы один файл.";
|
player_select_one_file: "Select at least one file." , "Выберите хотя бы один файл.";
|
||||||
player_starting_download: "Starting download..." , "Запускаю скачивание...";
|
player_starting_download: "Starting download..." , "Запускаю скачивание...";
|
||||||
player_download_started: "Download started. Files will move to inbox when complete." , "Скачивание началось. После завершения файлы будут перенесены во входящие.";
|
player_download_started: "Download started. Files will move to inbox when complete." , "Скачивание началось. После завершения файлы будут перенесены во входящие.";
|
||||||
@@ -510,6 +581,6 @@ translations! {
|
|||||||
player_pause_failed: "Pause failed" , "Не удалось поставить на паузу";
|
player_pause_failed: "Pause failed" , "Не удалось поставить на паузу";
|
||||||
player_load_torrents_failed: "Could not load torrents" , "Не удалось загрузить торренты";
|
player_load_torrents_failed: "Could not load torrents" , "Не удалось загрузить торренты";
|
||||||
player_open_torrent_failed: "Could not open torrent" , "Не удалось открыть торрент";
|
player_open_torrent_failed: "Could not open torrent" , "Не удалось открыть торрент";
|
||||||
player_delete_torrent_failed: "Could not delete torrent" , "Не удалось удалить торрент";
|
player_delete_torrent_failed: "Could not remove torrent from history" , "Не удалось удалить торрент из истории";
|
||||||
player_load_ai_queue_failed: "Could not load AI queue" , "Не удалось загрузить очередь ИИ";
|
player_load_ai_queue_failed: "Could not load AI queue" , "Не удалось загрузить очередь ИИ";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::scheduler::{Job, JobContext, JobLog};
|
||||||
|
|
||||||
|
const SAMPLE_LOG_LIMIT: usize = 50;
|
||||||
|
|
||||||
|
pub struct ArchiveCleanupJob;
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct TrackFileRow {
|
||||||
|
track_id: i64,
|
||||||
|
track_title: String,
|
||||||
|
release_id: i64,
|
||||||
|
release_title: Option<String>,
|
||||||
|
media_file_id: Option<i64>,
|
||||||
|
file_type: Option<String>,
|
||||||
|
file_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct MissingTrack {
|
||||||
|
track_id: i64,
|
||||||
|
track_title: String,
|
||||||
|
release_id: i64,
|
||||||
|
release_title: Option<String>,
|
||||||
|
media_file_id: Option<i64>,
|
||||||
|
file_path: Option<String>,
|
||||||
|
reason: MissingReason,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum MissingReason {
|
||||||
|
MissingMediaRow,
|
||||||
|
InvalidMediaType(String),
|
||||||
|
EmptyPath,
|
||||||
|
MissingFile,
|
||||||
|
NotRegularFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct DeleteStats {
|
||||||
|
playback_states_cleared: u64,
|
||||||
|
playlist_entries_deleted: u64,
|
||||||
|
likes_deleted: u64,
|
||||||
|
play_history_deleted: u64,
|
||||||
|
popularity_history_deleted: u64,
|
||||||
|
scrobble_outbox_deleted: u64,
|
||||||
|
track_genres_deleted: u64,
|
||||||
|
entity_tags_deleted: u64,
|
||||||
|
external_ids_deleted: u64,
|
||||||
|
track_artists_deleted: u64,
|
||||||
|
tracks_deleted: u64,
|
||||||
|
media_files_deleted: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Job for ArchiveCleanupJob {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"archive_cleanup"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &'static str {
|
||||||
|
"Clean stale archive records, starting with tracks whose audio files are missing"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_cron(&self) -> &'static str {
|
||||||
|
// Daily at 04:45.
|
||||||
|
"0 45 4 * * *"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
|
||||||
|
run_missing_audio_cleanup(ctx, log).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_missing_audio_cleanup(ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
|
||||||
|
let storage_dir = ctx.config.agent_storage_dir.trim();
|
||||||
|
if storage_dir.is_empty() {
|
||||||
|
log.warn("Archive cleanup: agent_storage_dir is not configured, skipping file checks");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, TrackFileRow>(
|
||||||
|
r#"SELECT t.id AS track_id,
|
||||||
|
t.title::text AS track_title,
|
||||||
|
t.release_id,
|
||||||
|
r.title::text AS release_title,
|
||||||
|
mf.id AS media_file_id,
|
||||||
|
mf.file_type::text AS file_type,
|
||||||
|
mf.file_path::text AS file_path
|
||||||
|
FROM furumusic__track t
|
||||||
|
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||||
|
ORDER BY t.id"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&ctx.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if rows.is_empty() {
|
||||||
|
log.info("Archive cleanup: no tracks found");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(&format!(
|
||||||
|
"Archive cleanup: checking {} track audio reference(s)",
|
||||||
|
rows.len()
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut missing_tracks = Vec::new();
|
||||||
|
let mut skipped_io_errors = 0u64;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let Some(media_file_id) = row.media_file_id else {
|
||||||
|
missing_tracks.push(MissingTrack::from_row(row, MissingReason::MissingMediaRow));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_type = row.file_type.clone();
|
||||||
|
match file_type.as_deref() {
|
||||||
|
Some("audio") => {}
|
||||||
|
Some(file_type) => {
|
||||||
|
missing_tracks.push(MissingTrack::from_row(
|
||||||
|
row,
|
||||||
|
MissingReason::InvalidMediaType(file_type.to_owned()),
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
missing_tracks.push(MissingTrack::from_row(row, MissingReason::MissingMediaRow));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(file_path) = row
|
||||||
|
.file_path
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|path| !path.is_empty())
|
||||||
|
else {
|
||||||
|
missing_tracks.push(MissingTrack::from_row(row, MissingReason::EmptyPath));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let absolute_path = crate::media_paths::resolve_media_file_path(storage_dir, file_path);
|
||||||
|
match tokio::fs::metadata(&absolute_path).await {
|
||||||
|
Ok(meta) if meta.is_file() => {}
|
||||||
|
Ok(_) => {
|
||||||
|
missing_tracks.push(MissingTrack::from_row(row, MissingReason::NotRegularFile));
|
||||||
|
}
|
||||||
|
Err(err) if err.kind() == ErrorKind::NotFound => {
|
||||||
|
missing_tracks.push(MissingTrack::from_row(row, MissingReason::MissingFile));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
skipped_io_errors += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Archive cleanup: skipping track {} media_file_id={media_file_id}; cannot inspect {}: {err}",
|
||||||
|
row.track_id,
|
||||||
|
absolute_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if missing_tracks.is_empty() {
|
||||||
|
log.info(&format!(
|
||||||
|
"Archive cleanup: all checked tracks have readable audio files; skipped_io_errors={skipped_io_errors}"
|
||||||
|
));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (index, track) in missing_tracks.iter().take(SAMPLE_LOG_LIMIT).enumerate() {
|
||||||
|
log.warn(&format!(
|
||||||
|
"Archive cleanup: deleting stale track {} \"{}\"{}{} ({})",
|
||||||
|
track.track_id,
|
||||||
|
track.track_title,
|
||||||
|
track
|
||||||
|
.release_title
|
||||||
|
.as_deref()
|
||||||
|
.map(|title| format!(" from \"{title}\""))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
track
|
||||||
|
.file_path
|
||||||
|
.as_deref()
|
||||||
|
.map(|path| format!(", path={path}"))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
track.reason
|
||||||
|
));
|
||||||
|
if index + 1 == SAMPLE_LOG_LIMIT && missing_tracks.len() > SAMPLE_LOG_LIMIT {
|
||||||
|
log.warn(&format!(
|
||||||
|
"Archive cleanup: suppressing per-track logs for remaining {} stale track(s)",
|
||||||
|
missing_tracks.len() - SAMPLE_LOG_LIMIT
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let track_ids = unique_sorted(
|
||||||
|
missing_tracks
|
||||||
|
.iter()
|
||||||
|
.map(|track| track.track_id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
let media_file_ids = unique_sorted(
|
||||||
|
missing_tracks
|
||||||
|
.iter()
|
||||||
|
.filter_map(|track| track.media_file_id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
let release_ids = unique_sorted(
|
||||||
|
missing_tracks
|
||||||
|
.iter()
|
||||||
|
.map(|track| track.release_id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let stats =
|
||||||
|
delete_tracks_and_unreferenced_audio_media(&ctx.pool, &track_ids, &media_file_ids).await?;
|
||||||
|
let empty_release_count = count_empty_releases(&ctx.pool, &release_ids).await?;
|
||||||
|
|
||||||
|
log.info(&format!(
|
||||||
|
"Archive cleanup: deleted {} track(s), {} unreferenced audio media_file row(s); cleared playback_states={}, playlist_entries={}, likes={}, play_history={}, popularity_history={}, scrobble_outbox={}, track_genres={}, entity_tags={}, external_ids={}, track_artists={}; skipped_io_errors={skipped_io_errors}; empty_releases_left={empty_release_count}",
|
||||||
|
stats.tracks_deleted,
|
||||||
|
stats.media_files_deleted,
|
||||||
|
stats.playback_states_cleared,
|
||||||
|
stats.playlist_entries_deleted,
|
||||||
|
stats.likes_deleted,
|
||||||
|
stats.play_history_deleted,
|
||||||
|
stats.popularity_history_deleted,
|
||||||
|
stats.scrobble_outbox_deleted,
|
||||||
|
stats.track_genres_deleted,
|
||||||
|
stats.entity_tags_deleted,
|
||||||
|
stats.external_ids_deleted,
|
||||||
|
stats.track_artists_deleted,
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MissingTrack {
|
||||||
|
fn from_row(row: TrackFileRow, reason: MissingReason) -> Self {
|
||||||
|
Self {
|
||||||
|
track_id: row.track_id,
|
||||||
|
track_title: row.track_title,
|
||||||
|
release_id: row.release_id,
|
||||||
|
release_title: row.release_title,
|
||||||
|
media_file_id: row.media_file_id,
|
||||||
|
file_path: row.file_path,
|
||||||
|
reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for MissingReason {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::MissingMediaRow => f.write_str("missing media_file row"),
|
||||||
|
Self::InvalidMediaType(file_type) => write!(f, "invalid media_file type {file_type:?}"),
|
||||||
|
Self::EmptyPath => f.write_str("empty media file path"),
|
||||||
|
Self::MissingFile => f.write_str("audio file not found on disk"),
|
||||||
|
Self::NotRegularFile => f.write_str("audio path is not a regular file"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_sorted(values: Vec<i64>) -> Vec<i64> {
|
||||||
|
values
|
||||||
|
.into_iter()
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_tracks_and_unreferenced_audio_media(
|
||||||
|
pool: &PgPool,
|
||||||
|
track_ids: &[i64],
|
||||||
|
media_file_ids: &[i64],
|
||||||
|
) -> anyhow::Result<DeleteStats> {
|
||||||
|
if track_ids.is_empty() {
|
||||||
|
return Ok(DeleteStats::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
let mut stats = DeleteStats::default();
|
||||||
|
|
||||||
|
stats.playback_states_cleared = sqlx::query(
|
||||||
|
r#"UPDATE furumusic__playback_state
|
||||||
|
SET current_track_id = NULL
|
||||||
|
WHERE current_track_id = ANY($1)"#,
|
||||||
|
)
|
||||||
|
.bind(track_ids)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
stats.playlist_entries_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__playlist_track", track_ids).await?;
|
||||||
|
stats.likes_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__user_liked_track", track_ids).await?;
|
||||||
|
stats.play_history_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__play_history", track_ids).await?;
|
||||||
|
stats.popularity_history_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__track_popularity_history", track_ids).await?;
|
||||||
|
stats.scrobble_outbox_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__lastfm_scrobble_outbox", track_ids).await?;
|
||||||
|
stats.track_genres_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__track_genre", track_ids).await?;
|
||||||
|
|
||||||
|
stats.entity_tags_deleted = sqlx::query(
|
||||||
|
r#"DELETE FROM furumusic__entity_genre_tag
|
||||||
|
WHERE entity_kind = 'track'
|
||||||
|
AND entity_id = ANY($1)"#,
|
||||||
|
)
|
||||||
|
.bind(track_ids)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
stats.external_ids_deleted = sqlx::query(
|
||||||
|
r#"DELETE FROM furumusic__external_metadata_id
|
||||||
|
WHERE entity_kind = 'track'
|
||||||
|
AND entity_id = ANY($1)"#,
|
||||||
|
)
|
||||||
|
.bind(track_ids)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
stats.track_artists_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__track_artist", track_ids).await?;
|
||||||
|
|
||||||
|
stats.tracks_deleted = sqlx::query("DELETE FROM furumusic__track WHERE id = ANY($1)")
|
||||||
|
.bind(track_ids)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if !media_file_ids.is_empty() {
|
||||||
|
stats.media_files_deleted = sqlx::query(
|
||||||
|
r#"DELETE FROM furumusic__media_file mf
|
||||||
|
WHERE mf.id = ANY($1)
|
||||||
|
AND mf.file_type = 'audio'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM furumusic__track t
|
||||||
|
WHERE t.audio_file_id = mf.id
|
||||||
|
OR t.cover_file_id = mf.id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM furumusic__release r
|
||||||
|
WHERE r.cover_file_id = mf.id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM furumusic__artist a
|
||||||
|
WHERE a.image_file_id = mf.id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM furumusic__playlist p
|
||||||
|
WHERE p.cover_file_id = mf.id
|
||||||
|
)"#,
|
||||||
|
)
|
||||||
|
.bind(media_file_ids)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_track_rows(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
table: &str,
|
||||||
|
track_ids: &[i64],
|
||||||
|
) -> anyhow::Result<u64> {
|
||||||
|
let sql = format!("DELETE FROM {table} WHERE track_id = ANY($1)");
|
||||||
|
Ok(sqlx::query(&sql)
|
||||||
|
.bind(track_ids)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_empty_releases(pool: &PgPool, release_ids: &[i64]) -> anyhow::Result<i64> {
|
||||||
|
if release_ids.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = sqlx::query_scalar::<_, i64>(
|
||||||
|
r#"SELECT COUNT(*)
|
||||||
|
FROM furumusic__release r
|
||||||
|
WHERE r.id = ANY($1)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM furumusic__track t
|
||||||
|
WHERE t.release_id = r.id
|
||||||
|
)"#,
|
||||||
|
)
|
||||||
|
.bind(release_ids)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unique_sorted_deduplicates_ids() {
|
||||||
|
assert_eq!(unique_sorted(vec![3, 1, 3, 2, 1]), vec![1, 2, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_reason_display_is_stable() {
|
||||||
|
assert_eq!(
|
||||||
|
MissingReason::InvalidMediaType("cover_art".to_owned()).to_string(),
|
||||||
|
"invalid media_file type \"cover_art\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
MissingReason::MissingFile.to_string(),
|
||||||
|
"audio file not found on disk"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+126
-9
@@ -12,6 +12,21 @@ const AUDIO_EXTENSIONS: &[&str] = &[
|
|||||||
"mp3", "flac", "ogg", "opus", "aac", "m4a", "wav", "ape", "wv", "wma", "tta", "aiff", "aif",
|
"mp3", "flac", "ogg", "opus", "aac", "m4a", "wav", "ape", "wv", "wma", "tta", "aiff", "aif",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// How long a `failed` review must stay untouched before discover
|
||||||
|
/// automatically requeues it (instead of creating a new row per attempt).
|
||||||
|
const FAILED_RETRY_COOLDOWN_SECS: i64 = 3600;
|
||||||
|
|
||||||
|
/// Leftover files that are safe to purge from inbox folders that no longer
|
||||||
|
/// contain any audio (covers, playlists, rip logs and similar sidecar files).
|
||||||
|
const JUNK_EXTENSIONS: &[&str] = &[
|
||||||
|
"jpg", "jpeg", "png", "gif", "webp", "bmp", "m3u", "m3u8", "cue", "log", "txt", "nfo", "sfv",
|
||||||
|
"md5", "accurip", "url", "ini", "pdf",
|
||||||
|
];
|
||||||
|
const JUNK_FILENAMES: &[&str] = &[".ds_store", "thumbs.db", "desktop.ini"];
|
||||||
|
|
||||||
|
/// Junk younger than this is kept — an upload might still be in progress.
|
||||||
|
const JUNK_MIN_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
|
||||||
|
|
||||||
pub struct InboxDiscoverJob;
|
pub struct InboxDiscoverJob;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -76,6 +91,10 @@ impl Job for InboxDiscoverJob {
|
|||||||
let mut audio_files = Vec::new();
|
let mut audio_files = Vec::new();
|
||||||
collect_audio_files(inbox, &mut audio_files).await?;
|
collect_audio_files(inbox, &mut audio_files).await?;
|
||||||
|
|
||||||
|
// Purge leftover junk (covers, playlists, logs) from subtrees that no
|
||||||
|
// longer contain audio, so processed uploads don't linger forever.
|
||||||
|
cleanup_inbox_junk(inbox, JUNK_MIN_AGE).await;
|
||||||
|
|
||||||
log.info(&format!("Found {} audio files in inbox", audio_files.len()));
|
log.info(&format!("Found {} audio files in inbox", audio_files.len()));
|
||||||
if audio_files.is_empty() {
|
if audio_files.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -87,6 +106,7 @@ impl Job for InboxDiscoverJob {
|
|||||||
let mut discovered = 0u64;
|
let mut discovered = 0u64;
|
||||||
let mut skipped_hash = 0u64;
|
let mut skipped_hash = 0u64;
|
||||||
let mut skipped_existing = 0u64;
|
let mut skipped_existing = 0u64;
|
||||||
|
let mut requeued = 0u64;
|
||||||
|
|
||||||
for (_folder, files) in &groups {
|
for (_folder, files) in &groups {
|
||||||
for file_path in files {
|
for file_path in files {
|
||||||
@@ -94,13 +114,34 @@ impl Job for InboxDiscoverJob {
|
|||||||
crate::media_paths::path_for_root(&config.agent_inbox_dir, file_path)
|
crate::media_paths::path_for_root(&config.agent_inbox_dir, file_path)
|
||||||
.unwrap_or_else(|| file_path.to_string_lossy().to_string());
|
.unwrap_or_else(|| file_path.to_string_lossy().to_string());
|
||||||
|
|
||||||
// Skip if a PendingReview already exists for this path
|
// One review row per path: any existing row blocks creating a
|
||||||
match PendingReview::exists_for_path(&ctx.db, &input_path_str).await {
|
// new one. A stale "failed" row is requeued in place instead,
|
||||||
Ok(true) => {
|
// so retries don't multiply rows. "rejected" stays rejected.
|
||||||
skipped_existing += 1;
|
match PendingReview::latest_for_path(&ctx.pool, &input_path_str).await {
|
||||||
|
Ok(None) => {}
|
||||||
|
Ok(Some((id, status, updated_at))) => {
|
||||||
|
if status == "failed" {
|
||||||
|
let stale = chrono::DateTime::parse_from_rfc3339(&updated_at)
|
||||||
|
.map(|t| {
|
||||||
|
chrono::Utc::now().signed_duration_since(t).num_seconds()
|
||||||
|
>= FAILED_RETRY_COOLDOWN_SECS
|
||||||
|
})
|
||||||
|
.unwrap_or(true);
|
||||||
|
if stale {
|
||||||
|
match PendingReview::requeue_by_ids(&ctx.db, &[id]).await {
|
||||||
|
Ok(()) => requeued += 1,
|
||||||
|
Err(e) => log.warn(&format!(
|
||||||
|
"Failed to requeue review {id} for {input_path_str}: {e}"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
skipped_existing += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
skipped_existing += 1;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Ok(false) => {}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log.warn(&format!(
|
log.warn(&format!(
|
||||||
"Error checking existing review for {}: {e}",
|
"Error checking existing review for {}: {e}",
|
||||||
@@ -128,7 +169,11 @@ impl Job for InboxDiscoverJob {
|
|||||||
v
|
v
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
crate::metrics::record_agent_file_hash(hash_start.elapsed(), 0, "error");
|
crate::metrics::record_agent_file_hash(
|
||||||
|
hash_start.elapsed(),
|
||||||
|
0,
|
||||||
|
"error",
|
||||||
|
);
|
||||||
log.warn(&format!("Failed to hash {}: {e}", file_path.display()));
|
log.warn(&format!("Failed to hash {}: {e}", file_path.display()));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -211,8 +256,8 @@ impl Job for InboxDiscoverJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.info(&format!(
|
log.info(&format!(
|
||||||
"Discovered {} new files, skipped {} (hash known), skipped {} (already queued)",
|
"Discovered {} new files, requeued {} failed, skipped {} (hash known), skipped {} (already tracked)",
|
||||||
discovered, skipped_hash, skipped_existing
|
discovered, requeued, skipped_hash, skipped_existing
|
||||||
));
|
));
|
||||||
crate::metrics::record_agent_discover_files(
|
crate::metrics::record_agent_discover_files(
|
||||||
audio_files.len() as u64,
|
audio_files.len() as u64,
|
||||||
@@ -223,7 +268,7 @@ impl Job for InboxDiscoverJob {
|
|||||||
|
|
||||||
// Trigger inbox_process in background if new files were discovered
|
// Trigger inbox_process in background if new files were discovered
|
||||||
// and no orchestrator is already running
|
// and no orchestrator is already running
|
||||||
if discovered > 0 {
|
if discovered + requeued > 0 {
|
||||||
if crate::jobs::inbox_process::is_orchestrator_running() {
|
if crate::jobs::inbox_process::is_orchestrator_running() {
|
||||||
log.info(
|
log.info(
|
||||||
"New files discovered but inbox_process already running, it will pick them up",
|
"New files discovered but inbox_process already running, it will pick them up",
|
||||||
@@ -295,3 +340,75 @@ pub fn is_audio_file(name: &str) -> bool {
|
|||||||
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
|
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||||
AUDIO_EXTENSIONS.contains(&ext.as_str())
|
AUDIO_EXTENSIONS.contains(&ext.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_junk_file(name: &str) -> bool {
|
||||||
|
let lower = name.to_lowercase();
|
||||||
|
// macOS AppleDouble sidecars ("._track.mp3") and well-known junk names
|
||||||
|
if lower.starts_with("._") || JUNK_FILENAMES.contains(&lower.as_str()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let ext = lower.rsplit('.').next().unwrap_or("");
|
||||||
|
JUNK_EXTENSIONS.contains(&ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove leftover sidecar files (covers, playlists, rip logs) from inbox
|
||||||
|
/// subtrees that no longer contain any audio, then prune emptied directories.
|
||||||
|
///
|
||||||
|
/// Junk younger than `min_age` is kept in case an upload is still in
|
||||||
|
/// progress, and unknown file types are never touched. Returns `true` when
|
||||||
|
/// `dir` still contains something worth keeping (so the caller must not
|
||||||
|
/// remove it).
|
||||||
|
async fn cleanup_inbox_junk(dir: &Path, min_age: std::time::Duration) -> bool {
|
||||||
|
let mut entries = match tokio::fs::read_dir(dir).await {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut has_audio = false;
|
||||||
|
let mut keep_other = false;
|
||||||
|
let mut junk: Vec<PathBuf> = Vec::new();
|
||||||
|
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
let ft = match entry.file_type().await {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => {
|
||||||
|
keep_other = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if ft.is_dir() {
|
||||||
|
if Box::pin(cleanup_inbox_junk(&entry.path(), min_age)).await {
|
||||||
|
keep_other = true;
|
||||||
|
} else {
|
||||||
|
let _ = tokio::fs::remove_dir(&entry.path()).await;
|
||||||
|
}
|
||||||
|
} else if !name.starts_with('.') && is_audio_file(&name) {
|
||||||
|
// dotfiles are invisible to discovery, so they don't count as audio
|
||||||
|
has_audio = true;
|
||||||
|
} else if is_junk_file(&name) {
|
||||||
|
junk.push(entry.path());
|
||||||
|
} else {
|
||||||
|
keep_other = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_audio {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut junk_left = false;
|
||||||
|
for path in junk {
|
||||||
|
let old_enough = tokio::fs::metadata(&path)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.modified().ok())
|
||||||
|
.and_then(|t| t.elapsed().ok())
|
||||||
|
.is_some_and(|age| age >= min_age);
|
||||||
|
if !old_enough || tokio::fs::remove_file(&path).await.is_err() {
|
||||||
|
junk_left = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keep_other || junk_left
|
||||||
|
}
|
||||||
|
|||||||
+159
-32
@@ -12,6 +12,11 @@ static ORCHESTRATOR_RUNNING: AtomicBool = AtomicBool::new(false);
|
|||||||
/// PostgreSQL advisory locks use a 64-bit key; this is an arbitrary unique value.
|
/// PostgreSQL advisory locks use a 64-bit key; this is an arbitrary unique value.
|
||||||
const ORCHESTRATOR_ADVISORY_LOCK_ID: i64 = 0x4655_5255_4D55_5349; // "FURUMUSI" in hex
|
const ORCHESTRATOR_ADVISORY_LOCK_ID: i64 = 0x4655_5255_4D55_5349; // "FURUMUSI" in hex
|
||||||
|
|
||||||
|
/// Maximum number of files sent to the LLM in a single batch call.
|
||||||
|
/// Folders with more files are processed in chunks of this size, otherwise
|
||||||
|
/// the model's completion window overflows and the JSON response is cut off.
|
||||||
|
const MAX_LLM_BATCH_FILES: usize = 20;
|
||||||
|
|
||||||
/// Check if an orchestrator is currently running (used by inbox_discover to avoid redundant triggers).
|
/// Check if an orchestrator is currently running (used by inbox_discover to avoid redundant triggers).
|
||||||
pub fn is_orchestrator_running() -> bool {
|
pub fn is_orchestrator_running() -> bool {
|
||||||
ORCHESTRATOR_RUNNING.load(Ordering::SeqCst)
|
ORCHESTRATOR_RUNNING.load(Ordering::SeqCst)
|
||||||
@@ -214,14 +219,25 @@ impl Job for InboxProcessJob {
|
|||||||
folder_rel, file_count,
|
folder_rel, file_count,
|
||||||
));
|
));
|
||||||
|
|
||||||
let (ok, fail) =
|
// Large folders are split into chunks: a single LLM call for
|
||||||
process_folder_batch(&ctx.db, &config, &ctx.pool, &folder_rel, reviews, log)
|
// 100+ files overflows the completion window and the whole
|
||||||
.await;
|
// batch fails with a truncated-JSON parse error.
|
||||||
|
for chunk in reviews.chunks(MAX_LLM_BATCH_FILES) {
|
||||||
|
let (ok, fail) = process_folder_batch(
|
||||||
|
&ctx.db,
|
||||||
|
&config,
|
||||||
|
&ctx.pool,
|
||||||
|
&folder_rel,
|
||||||
|
chunk.to_vec(),
|
||||||
|
log,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
total_ok += ok;
|
total_ok += ok;
|
||||||
total_fail += fail;
|
total_fail += fail;
|
||||||
|
}
|
||||||
log.info(&format!(
|
log.info(&format!(
|
||||||
"Folder done: {ok} ok, {fail} err. Total so far: {total_ok} ok, {total_fail} err"
|
"Folder done. Total so far: {total_ok} ok, {total_fail} err"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,6 +360,7 @@ async fn process_folder_batch(
|
|||||||
log.info("Phase 1: extracting metadata...");
|
log.info("Phase 1: extracting metadata...");
|
||||||
let mut prepared: Vec<PreparedFile> = Vec::with_capacity(file_count);
|
let mut prepared: Vec<PreparedFile> = Vec::with_capacity(file_count);
|
||||||
let mut failed_reviews: Vec<PendingReview> = Vec::new();
|
let mut failed_reviews: Vec<PendingReview> = Vec::new();
|
||||||
|
let mut merged_count = 0u64;
|
||||||
|
|
||||||
for mut review in reviews {
|
for mut review in reviews {
|
||||||
let stored_input_path = review.input_path_str().to_owned();
|
let stored_input_path = review.input_path_str().to_owned();
|
||||||
@@ -355,9 +372,6 @@ async fn process_folder_batch(
|
|||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
.to_owned();
|
.to_owned();
|
||||||
|
|
||||||
// Set status → processing
|
|
||||||
let _ = review.set_processing(db).await;
|
|
||||||
|
|
||||||
// Parse context_json
|
// Parse context_json
|
||||||
let mut context: serde_json::Value = review
|
let mut context: serde_json::Value = review
|
||||||
.context_json
|
.context_json
|
||||||
@@ -365,6 +379,42 @@ async fn process_folder_batch(
|
|||||||
.and_then(|s| serde_json::from_str(s).ok())
|
.and_then(|s| serde_json::from_str(s).ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Resolve duplicates and missing sources before any expensive work.
|
||||||
|
let sha = context
|
||||||
|
.get("sha256")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_owned();
|
||||||
|
let file_exists = file_path.exists();
|
||||||
|
if !sha.is_empty()
|
||||||
|
&& crate::agent::rag::file_hash_exists(pool, &sha)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
// Identical content is already in the library — drop the inbox
|
||||||
|
// copy (same as mover::Merged) and close the review.
|
||||||
|
if file_exists {
|
||||||
|
let _ = tokio::fs::remove_file(&file_path).await;
|
||||||
|
}
|
||||||
|
let _ = PendingReview::delete_by_ids(db, &[review.id_val()]).await;
|
||||||
|
log.info(&format!(
|
||||||
|
"{filename}: content already in library (sha256 match) — merged duplicate"
|
||||||
|
));
|
||||||
|
crate::metrics::record_agent_file_processed("ok", "merged_duplicate");
|
||||||
|
merged_count += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !file_exists {
|
||||||
|
let msg = format!("{filename}: source file missing: {stored_input_path}");
|
||||||
|
log.error(&msg);
|
||||||
|
let _ = review.set_failed(db, &msg).await;
|
||||||
|
failed_reviews.push(review);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set status → processing
|
||||||
|
let _ = review.set_processing(db).await;
|
||||||
|
|
||||||
// Extract metadata (with 60s timeout)
|
// Extract metadata (with 60s timeout)
|
||||||
let path_for_meta = file_path.to_path_buf();
|
let path_for_meta = file_path.to_path_buf();
|
||||||
let metadata_start = std::time::Instant::now();
|
let metadata_start = std::time::Instant::now();
|
||||||
@@ -444,15 +494,16 @@ async fn process_folder_batch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.info(&format!(
|
log.info(&format!(
|
||||||
"Phase 1 done: {} prepared, {} failed metadata",
|
"Phase 1 done: {} prepared, {} merged duplicates, {} failed",
|
||||||
prepared.len(),
|
prepared.len(),
|
||||||
|
merged_count,
|
||||||
failed_reviews.len(),
|
failed_reviews.len(),
|
||||||
));
|
));
|
||||||
|
|
||||||
if prepared.is_empty() {
|
if prepared.is_empty() {
|
||||||
let duration_ms = batch_start.elapsed().as_millis() as i64;
|
let duration_ms = batch_start.elapsed().as_millis() as i64;
|
||||||
let _ = run.set_completed(db, duration_ms, &log.output()).await;
|
let _ = run.set_completed(db, duration_ms, &log.output()).await;
|
||||||
return (0, failed_reviews.len() as u64);
|
return (merged_count, failed_reviews.len() as u64);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: RAG lookup (collect unique artist/album queries from all files)
|
// Phase 2: RAG lookup (collect unique artist/album queries from all files)
|
||||||
@@ -494,7 +545,12 @@ async fn process_folder_batch(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(Ok(results)) => {
|
Ok(Ok(results)) => {
|
||||||
crate::metrics::record_agent_rag("artist", "ok", rag_start.elapsed(), results.len());
|
crate::metrics::record_agent_rag(
|
||||||
|
"artist",
|
||||||
|
"ok",
|
||||||
|
rag_start.elapsed(),
|
||||||
|
results.len(),
|
||||||
|
);
|
||||||
for a in results {
|
for a in results {
|
||||||
if !all_similar_artists
|
if !all_similar_artists
|
||||||
.iter()
|
.iter()
|
||||||
@@ -525,7 +581,12 @@ async fn process_folder_batch(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(Ok(results)) => {
|
Ok(Ok(results)) => {
|
||||||
crate::metrics::record_agent_rag("release", "ok", rag_start.elapsed(), results.len());
|
crate::metrics::record_agent_rag(
|
||||||
|
"release",
|
||||||
|
"ok",
|
||||||
|
rag_start.elapsed(),
|
||||||
|
results.len(),
|
||||||
|
);
|
||||||
for r in results {
|
for r in results {
|
||||||
if !all_similar_releases
|
if !all_similar_releases
|
||||||
.iter()
|
.iter()
|
||||||
@@ -638,16 +699,17 @@ async fn process_folder_batch(
|
|||||||
let err_msg = format!("Batch LLM call failed: {e}");
|
let err_msg = format!("Batch LLM call failed: {e}");
|
||||||
log.error(&err_msg);
|
log.error(&err_msg);
|
||||||
// Mark all files as failed
|
// Mark all files as failed
|
||||||
|
let prepared_count = prepared.len() as u64;
|
||||||
for mut p in prepared {
|
for mut p in prepared {
|
||||||
let _ = p.review.set_failed(db, &err_msg).await;
|
let _ = p.review.set_failed(db, &err_msg).await;
|
||||||
crate::metrics::record_agent_file_processed("failed", "failed");
|
crate::metrics::record_agent_file_processed("failed", "failed");
|
||||||
}
|
}
|
||||||
let total_fail_count = failed_reviews.len() as u64 + file_count as u64;
|
let total_fail_count = failed_reviews.len() as u64 + prepared_count;
|
||||||
let duration_ms = batch_start.elapsed().as_millis() as i64;
|
let duration_ms = batch_start.elapsed().as_millis() as i64;
|
||||||
let _ = run
|
let _ = run
|
||||||
.set_failed(db, duration_ms, &log.output(), &err_msg)
|
.set_failed(db, duration_ms, &log.output(), &err_msg)
|
||||||
.await;
|
.await;
|
||||||
return (0, total_fail_count);
|
return (merged_count, total_fail_count);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -671,7 +733,7 @@ async fn process_folder_batch(
|
|||||||
let completion_per_file = batch_result.completion_tokens / prepared.len().max(1) as u64;
|
let completion_per_file = batch_result.completion_tokens / prepared.len().max(1) as u64;
|
||||||
let duration_per_file = batch_result.duration_ms as i64 / prepared.len().max(1) as i64;
|
let duration_per_file = batch_result.duration_ms as i64 / prepared.len().max(1) as i64;
|
||||||
|
|
||||||
let mut ok_count = 0u64;
|
let mut ok_count = merged_count;
|
||||||
let mut fail_count = failed_reviews.len() as u64;
|
let mut fail_count = failed_reviews.len() as u64;
|
||||||
|
|
||||||
for mut p in prepared {
|
for mut p in prepared {
|
||||||
@@ -926,23 +988,59 @@ pub async fn finalize_approved(
|
|||||||
})?
|
})?
|
||||||
};
|
};
|
||||||
|
|
||||||
let media_file = MediaFile::create(
|
let reusable_media_id: Option<i64> = sqlx::query_scalar(
|
||||||
db,
|
r#"SELECT media.id
|
||||||
"audio",
|
FROM furumusic__media_file media
|
||||||
&storage_path,
|
WHERE media.file_type = 'audio'
|
||||||
original_filename,
|
AND media.file_path = $1
|
||||||
mime_type,
|
AND media.sha256_hash = $2
|
||||||
file_size,
|
AND NOT EXISTS (
|
||||||
sha256,
|
SELECT 1 FROM furumusic__track track
|
||||||
Some(ext),
|
WHERE track.audio_file_id = media.id OR track.cover_file_id = media.id
|
||||||
audio_bitrate,
|
)
|
||||||
audio_sample_rate,
|
AND NOT EXISTS (
|
||||||
audio_bit_depth,
|
SELECT 1 FROM furumusic__release release
|
||||||
uploaded_by_user_id,
|
WHERE release.cover_file_id = media.id
|
||||||
Some(uploader_name),
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM furumusic__artist artist
|
||||||
|
WHERE artist.image_file_id = media.id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM furumusic__playlist playlist
|
||||||
|
WHERE playlist.cover_file_id = media.id
|
||||||
|
)
|
||||||
|
ORDER BY media.id
|
||||||
|
LIMIT 1"#,
|
||||||
)
|
)
|
||||||
.await
|
.bind(&storage_path)
|
||||||
.map_err(|e| anyhow::anyhow!("failed to create media file: {e}"))?;
|
.bind(sha256)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
let media_file = if let Some(media_file_id) = reusable_media_id {
|
||||||
|
MediaFile::get_by_id(db, media_file_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("failed to load reusable media file: {error}"))?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("reusable media file disappeared"))?
|
||||||
|
} else {
|
||||||
|
MediaFile::create(
|
||||||
|
db,
|
||||||
|
"audio",
|
||||||
|
&storage_path,
|
||||||
|
original_filename,
|
||||||
|
mime_type,
|
||||||
|
file_size,
|
||||||
|
sha256,
|
||||||
|
Some(ext),
|
||||||
|
audio_bitrate,
|
||||||
|
audio_sample_rate,
|
||||||
|
audio_bit_depth,
|
||||||
|
uploaded_by_user_id,
|
||||||
|
Some(uploader_name),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to create media file: {e}"))?
|
||||||
|
};
|
||||||
|
|
||||||
let track = Track::create(
|
let track = Track::create(
|
||||||
db,
|
db,
|
||||||
@@ -958,6 +1056,35 @@ pub async fn finalize_approved(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("failed to create track: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("failed to create track: {e}"))?;
|
||||||
|
|
||||||
|
if let Err(error) = sqlx::query(
|
||||||
|
r#"INSERT INTO furumusic__youtube_import_media (item_id, media_file_id)
|
||||||
|
SELECT DISTINCT item.id, $1
|
||||||
|
FROM furumusic__youtube_download_item item
|
||||||
|
JOIN furumusic__pending_review review
|
||||||
|
ON item.inbox_path IS NOT NULL
|
||||||
|
AND (review.input_path = item.inbox_path
|
||||||
|
OR left(review.input_path, length(item.inbox_path) + 1)
|
||||||
|
= item.inbox_path || '/')
|
||||||
|
WHERE review.context_json IS NOT NULL
|
||||||
|
AND substring(
|
||||||
|
review.context_json
|
||||||
|
from '"sha256"[[:space:]]*:[[:space:]]*"([0-9a-fA-F]{64})"'
|
||||||
|
) = $2
|
||||||
|
ON CONFLICT (item_id, media_file_id) DO NOTHING"#,
|
||||||
|
)
|
||||||
|
.bind(media_file.id_val())
|
||||||
|
.bind(sha256)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
track_id = track.id_val(),
|
||||||
|
media_file_id = media_file.id_val(),
|
||||||
|
error = %error,
|
||||||
|
"failed to link imported media to its YouTube download item"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
TrackArtist::create(db, track.id_val(), artist.id_val(), "main", 0)
|
TrackArtist::create(db, track.id_val(), artist.id_val(), "main", 0)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("failed to link track-artist: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("failed to link track-artist: {e}"))?;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod archive_cleanup;
|
||||||
pub mod artwork_backfill;
|
pub mod artwork_backfill;
|
||||||
pub mod inbox_discover;
|
pub mod inbox_discover;
|
||||||
pub mod inbox_process;
|
pub mod inbox_process;
|
||||||
|
|||||||
+5
-5
@@ -447,17 +447,17 @@ async fn fetch_pending_scrobbles(
|
|||||||
o.duration_seconds,
|
o.duration_seconds,
|
||||||
o.attempt_count,
|
o.attempt_count,
|
||||||
a.session_key::text AS session_key,
|
a.session_key::text AS session_key,
|
||||||
t.title::text AS title,
|
COALESCE(o.track_title, t.title::text) AS title,
|
||||||
r.title::text AS album_title,
|
COALESCE(o.album_title, r.title::text) AS album_title,
|
||||||
t.track_number,
|
t.track_number,
|
||||||
(
|
COALESCE(o.artist_name, (
|
||||||
SELECT ar.name::text
|
SELECT ar.name::text
|
||||||
FROM furumusic__track_artist ta
|
FROM furumusic__track_artist ta
|
||||||
JOIN furumusic__artist ar ON ar.id = ta.artist_id
|
JOIN furumusic__artist ar ON ar.id = ta.artist_id
|
||||||
WHERE ta.track_id = t.id AND ta.role <> 'featuring'
|
WHERE ta.track_id = t.id AND ta.role <> 'featuring'
|
||||||
ORDER BY ta.position
|
ORDER BY ta.position
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) AS artist_name,
|
)) AS artist_name,
|
||||||
(
|
(
|
||||||
SELECT ar.name::text
|
SELECT ar.name::text
|
||||||
FROM furumusic__release_artist ra
|
FROM furumusic__release_artist ra
|
||||||
@@ -468,7 +468,7 @@ async fn fetch_pending_scrobbles(
|
|||||||
) AS album_artist_name
|
) AS album_artist_name
|
||||||
FROM furumusic__lastfm_scrobble_outbox o
|
FROM furumusic__lastfm_scrobble_outbox o
|
||||||
JOIN furumusic__lastfm_account a ON a.user_id = o.user_id
|
JOIN furumusic__lastfm_account a ON a.user_id = o.user_id
|
||||||
JOIN furumusic__track t ON t.id = o.track_id
|
LEFT JOIN furumusic__track t ON t.id = o.track_id
|
||||||
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
||||||
WHERE o.user_id = $1
|
WHERE o.user_id = $1
|
||||||
AND o.status IN ('pending', 'retry')
|
AND o.status IN ('pending', 'retry')
|
||||||
|
|||||||
@@ -0,0 +1,813 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{Context, bail};
|
||||||
|
use sqlx::{FromRow, PgPool, Postgres, Transaction};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, FromRow)]
|
||||||
|
struct MediaFileRow {
|
||||||
|
id: i64,
|
||||||
|
file_type: String,
|
||||||
|
file_path: String,
|
||||||
|
sha256_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, FromRow)]
|
||||||
|
struct PlaybackStateRow {
|
||||||
|
id: i64,
|
||||||
|
current_track_id: Option<i64>,
|
||||||
|
position_ms: i32,
|
||||||
|
queue_json: String,
|
||||||
|
queue_position: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct QuarantinedFile {
|
||||||
|
original: PathBuf,
|
||||||
|
quarantined: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct Quarantine {
|
||||||
|
root: Option<PathBuf>,
|
||||||
|
files: Vec<QuarantinedFile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_tracks(
|
||||||
|
pool: &PgPool,
|
||||||
|
requested_track_ids: &[i64],
|
||||||
|
storage_dir: &str,
|
||||||
|
) -> anyhow::Result<u64> {
|
||||||
|
delete_scope(pool, requested_track_ids, &[], storage_dir).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_releases(
|
||||||
|
pool: &PgPool,
|
||||||
|
requested_release_ids: &[i64],
|
||||||
|
storage_dir: &str,
|
||||||
|
) -> anyhow::Result<u64> {
|
||||||
|
let mut transaction = pool.begin().await?;
|
||||||
|
let release_ids: Vec<i64> = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM furumusic__release WHERE id = ANY($1) ORDER BY id FOR UPDATE",
|
||||||
|
)
|
||||||
|
.bind(requested_release_ids)
|
||||||
|
.fetch_all(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
if release_ids.is_empty() {
|
||||||
|
transaction.rollback().await?;
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let track_ids: Vec<i64> = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM furumusic__track WHERE release_id = ANY($1) ORDER BY id FOR UPDATE",
|
||||||
|
)
|
||||||
|
.bind(&release_ids)
|
||||||
|
.fetch_all(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
delete_locked_scope(transaction, track_ids, release_ids, storage_dir, true).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ReleaseMergeTrack {
|
||||||
|
pub id: i64,
|
||||||
|
pub track_number: Option<i32>,
|
||||||
|
pub disc_number: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ReleaseMergeSpec {
|
||||||
|
pub release_ids: Vec<i64>,
|
||||||
|
pub target_release_id: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub title_sort: String,
|
||||||
|
pub release_type: String,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub hidden: bool,
|
||||||
|
pub cover_file_id: Option<i64>,
|
||||||
|
pub artist_ids: Vec<i64>,
|
||||||
|
pub tracks: Vec<ReleaseMergeTrack>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ReleaseMergeResult {
|
||||||
|
pub merged_releases: u64,
|
||||||
|
pub moved_tracks: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge several releases into one while preserving their tracks and media.
|
||||||
|
///
|
||||||
|
/// Source cover files are quarantined before the database transaction commits,
|
||||||
|
/// just like normal library deletion. The cover selected for the destination
|
||||||
|
/// and any media still referenced elsewhere are retained.
|
||||||
|
pub async fn merge_releases(
|
||||||
|
pool: &PgPool,
|
||||||
|
mut spec: ReleaseMergeSpec,
|
||||||
|
storage_dir: &str,
|
||||||
|
) -> anyhow::Result<ReleaseMergeResult> {
|
||||||
|
spec.release_ids.retain(|id| *id > 0);
|
||||||
|
spec.release_ids.sort_unstable();
|
||||||
|
spec.release_ids.dedup();
|
||||||
|
if spec.release_ids.len() < 2 {
|
||||||
|
bail!("select at least two releases to merge");
|
||||||
|
}
|
||||||
|
if !spec.release_ids.contains(&spec.target_release_id) {
|
||||||
|
bail!("destination release must be part of the selection");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut transaction = pool.begin().await?;
|
||||||
|
let locked_release_ids: Vec<i64> = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM furumusic__release WHERE id = ANY($1) ORDER BY id FOR UPDATE",
|
||||||
|
)
|
||||||
|
.bind(&spec.release_ids)
|
||||||
|
.fetch_all(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
if locked_release_ids != spec.release_ids {
|
||||||
|
bail!("one or more selected releases no longer exist; reopen the merge wizard");
|
||||||
|
}
|
||||||
|
let original_target_cover: Option<i64> =
|
||||||
|
sqlx::query_scalar("SELECT cover_file_id FROM furumusic__release WHERE id = $1")
|
||||||
|
.bind(spec.target_release_id)
|
||||||
|
.fetch_one(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let source_release_ids = spec
|
||||||
|
.release_ids
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|id| *id != spec.target_release_id)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let locked_track_ids: Vec<i64> = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM furumusic__track WHERE release_id = ANY($1) ORDER BY id FOR UPDATE",
|
||||||
|
)
|
||||||
|
.bind(&spec.release_ids)
|
||||||
|
.fetch_all(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
let mut requested_track_ids = spec.tracks.iter().map(|track| track.id).collect::<Vec<_>>();
|
||||||
|
requested_track_ids.sort_unstable();
|
||||||
|
if requested_track_ids.windows(2).any(|ids| ids[0] == ids[1]) {
|
||||||
|
bail!("the merge track list contains duplicates");
|
||||||
|
}
|
||||||
|
if requested_track_ids != locked_track_ids {
|
||||||
|
bail!("the selected releases changed; reopen the merge wizard before merging");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(cover_file_id) = spec.cover_file_id {
|
||||||
|
let valid_cover: Option<i64> = sqlx::query_scalar(
|
||||||
|
r#"SELECT r.cover_file_id
|
||||||
|
FROM furumusic__release r
|
||||||
|
JOIN furumusic__media_file mf ON mf.id = r.cover_file_id
|
||||||
|
WHERE r.id = ANY($1)
|
||||||
|
AND r.cover_file_id = $2
|
||||||
|
AND mf.file_type = 'cover_art'
|
||||||
|
LIMIT 1"#,
|
||||||
|
)
|
||||||
|
.bind(&spec.release_ids)
|
||||||
|
.bind(cover_file_id)
|
||||||
|
.fetch_optional(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
if valid_cover.is_none() {
|
||||||
|
bail!("selected cover does not belong to one of the merged releases");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut seen_artist_ids = HashSet::new();
|
||||||
|
spec.artist_ids
|
||||||
|
.retain(|id| *id > 0 && seen_artist_ids.insert(*id));
|
||||||
|
if !spec.artist_ids.is_empty() {
|
||||||
|
let existing_artist_ids: Vec<i64> =
|
||||||
|
sqlx::query_scalar("SELECT id FROM furumusic__artist WHERE id = ANY($1) ORDER BY id")
|
||||||
|
.bind(&spec.artist_ids)
|
||||||
|
.fetch_all(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
let mut requested_artist_ids = spec.artist_ids.clone();
|
||||||
|
requested_artist_ids.sort_unstable();
|
||||||
|
if existing_artist_ids != requested_artist_ids {
|
||||||
|
bail!("one or more selected artists no longer exist");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
let total_discs = spec
|
||||||
|
.tracks
|
||||||
|
.iter()
|
||||||
|
.filter_map(|track| track.disc_number)
|
||||||
|
.max();
|
||||||
|
sqlx::query(
|
||||||
|
r#"UPDATE furumusic__release
|
||||||
|
SET title = $2, title_sort = $3, release_type = $4, year = $5,
|
||||||
|
cover_file_id = $6, total_tracks = $7, total_discs = $8,
|
||||||
|
is_hidden = $9, model_name = NULL, updated_at = $10
|
||||||
|
WHERE id = $1"#,
|
||||||
|
)
|
||||||
|
.bind(spec.target_release_id)
|
||||||
|
.bind(&spec.title)
|
||||||
|
.bind(&spec.title_sort)
|
||||||
|
.bind(&spec.release_type)
|
||||||
|
.bind(spec.year)
|
||||||
|
.bind(spec.cover_file_id)
|
||||||
|
.bind(i32::try_from(spec.tracks.len()).unwrap_or(i32::MAX))
|
||||||
|
.bind(total_discs)
|
||||||
|
.bind(spec.hidden)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = $1")
|
||||||
|
.bind(spec.target_release_id)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
for (position, artist_id) in spec.artist_ids.iter().enumerate() {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO furumusic__release_artist (release_id, artist_id, position) VALUES ($1, $2, $3)",
|
||||||
|
)
|
||||||
|
.bind(spec.target_release_id)
|
||||||
|
.bind(*artist_id)
|
||||||
|
.bind(i32::try_from(position).unwrap_or(i32::MAX))
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for track in &spec.tracks {
|
||||||
|
sqlx::query(
|
||||||
|
r#"UPDATE furumusic__track
|
||||||
|
SET release_id = $1, track_number = $2, disc_number = $3,
|
||||||
|
updated_at = $4
|
||||||
|
WHERE id = $5"#,
|
||||||
|
)
|
||||||
|
.bind(spec.target_release_id)
|
||||||
|
.bind(track.track_number)
|
||||||
|
.bind(track.disc_number)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(track.id)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"INSERT INTO furumusic__entity_genre_tag
|
||||||
|
(entity_kind, entity_id, genre_id, source, weight, updated_at)
|
||||||
|
SELECT 'release', $1, genre_id, source, weight, $3
|
||||||
|
FROM furumusic__entity_genre_tag
|
||||||
|
WHERE entity_kind = 'release' AND entity_id = ANY($2)
|
||||||
|
ON CONFLICT (entity_kind, entity_id, genre_id, source) DO UPDATE
|
||||||
|
SET weight = GREATEST(furumusic__entity_genre_tag.weight, EXCLUDED.weight),
|
||||||
|
updated_at = EXCLUDED.updated_at"#,
|
||||||
|
)
|
||||||
|
.bind(spec.target_release_id)
|
||||||
|
.bind(&spec.release_ids)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let extra_media_ids = original_target_cover.into_iter().collect::<Vec<_>>();
|
||||||
|
let media_files = deletable_media_files_with_extra(
|
||||||
|
&mut transaction,
|
||||||
|
&[],
|
||||||
|
&source_release_ids,
|
||||||
|
&extra_media_ids,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let quarantine = match quarantine_media_files(storage_dir, &media_files).await {
|
||||||
|
Ok(quarantine) => quarantine,
|
||||||
|
Err(error) => {
|
||||||
|
transaction.rollback().await?;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let deletion = delete_database_rows(
|
||||||
|
&mut transaction,
|
||||||
|
&[],
|
||||||
|
&source_release_ids,
|
||||||
|
&media_files,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if let Err(error) = deletion {
|
||||||
|
transaction.rollback().await?;
|
||||||
|
restore_quarantine(&quarantine).await;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
if let Err(error) = transaction.commit().await {
|
||||||
|
restore_quarantine(&quarantine).await;
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
purge_quarantine(&quarantine).await;
|
||||||
|
remove_empty_storage_parents(storage_dir, &quarantine.files).await;
|
||||||
|
|
||||||
|
Ok(ReleaseMergeResult {
|
||||||
|
merged_releases: u64::try_from(spec.release_ids.len()).unwrap_or(u64::MAX),
|
||||||
|
moved_tracks: u64::try_from(spec.tracks.len()).unwrap_or(u64::MAX),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_scope(
|
||||||
|
pool: &PgPool,
|
||||||
|
requested_track_ids: &[i64],
|
||||||
|
release_ids: &[i64],
|
||||||
|
storage_dir: &str,
|
||||||
|
) -> anyhow::Result<u64> {
|
||||||
|
let mut transaction = pool.begin().await?;
|
||||||
|
let track_ids: Vec<i64> = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM furumusic__track WHERE id = ANY($1) ORDER BY id FOR UPDATE",
|
||||||
|
)
|
||||||
|
.bind(requested_track_ids)
|
||||||
|
.fetch_all(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
if track_ids.is_empty() {
|
||||||
|
transaction.rollback().await?;
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
delete_locked_scope(
|
||||||
|
transaction,
|
||||||
|
track_ids,
|
||||||
|
release_ids.to_vec(),
|
||||||
|
storage_dir,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_locked_scope(
|
||||||
|
mut transaction: Transaction<'_, Postgres>,
|
||||||
|
track_ids: Vec<i64>,
|
||||||
|
release_ids: Vec<i64>,
|
||||||
|
storage_dir: &str,
|
||||||
|
delete_release_rows: bool,
|
||||||
|
) -> anyhow::Result<u64> {
|
||||||
|
let media_files = deletable_media_files(&mut transaction, &track_ids, &release_ids).await?;
|
||||||
|
let quarantine = match quarantine_media_files(storage_dir, &media_files).await {
|
||||||
|
Ok(quarantine) => quarantine,
|
||||||
|
Err(error) => {
|
||||||
|
transaction.rollback().await?;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let deletion = delete_database_rows(
|
||||||
|
&mut transaction,
|
||||||
|
&track_ids,
|
||||||
|
&release_ids,
|
||||||
|
&media_files,
|
||||||
|
delete_release_rows,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let affected = match deletion {
|
||||||
|
Ok(affected) => affected,
|
||||||
|
Err(error) => {
|
||||||
|
transaction.rollback().await?;
|
||||||
|
restore_quarantine(&quarantine).await;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = transaction.commit().await {
|
||||||
|
restore_quarantine(&quarantine).await;
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
purge_quarantine(&quarantine).await;
|
||||||
|
remove_empty_storage_parents(storage_dir, &quarantine.files).await;
|
||||||
|
Ok(affected)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn deletable_media_files(
|
||||||
|
transaction: &mut Transaction<'_, Postgres>,
|
||||||
|
track_ids: &[i64],
|
||||||
|
release_ids: &[i64],
|
||||||
|
) -> anyhow::Result<Vec<MediaFileRow>> {
|
||||||
|
deletable_media_files_with_extra(transaction, track_ids, release_ids, &[]).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn deletable_media_files_with_extra(
|
||||||
|
transaction: &mut Transaction<'_, Postgres>,
|
||||||
|
track_ids: &[i64],
|
||||||
|
release_ids: &[i64],
|
||||||
|
extra_media_ids: &[i64],
|
||||||
|
) -> anyhow::Result<Vec<MediaFileRow>> {
|
||||||
|
Ok(sqlx::query_as(
|
||||||
|
r#"WITH seed_media(id) AS (
|
||||||
|
SELECT audio_file_id FROM furumusic__track WHERE id = ANY($1)
|
||||||
|
UNION
|
||||||
|
SELECT cover_file_id FROM furumusic__track
|
||||||
|
WHERE id = ANY($1) AND cover_file_id IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT cover_file_id FROM furumusic__release
|
||||||
|
WHERE id = ANY($2) AND cover_file_id IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT UNNEST($3::bigint[])
|
||||||
|
), candidate_media(id) AS (
|
||||||
|
SELECT id FROM seed_media
|
||||||
|
UNION
|
||||||
|
SELECT duplicate.id
|
||||||
|
FROM furumusic__media_file duplicate
|
||||||
|
JOIN furumusic__media_file seed
|
||||||
|
ON duplicate.file_path = seed.file_path
|
||||||
|
AND duplicate.sha256_hash = seed.sha256_hash
|
||||||
|
JOIN seed_media ON seed_media.id = seed.id
|
||||||
|
)
|
||||||
|
SELECT mf.id, mf.file_type::text AS file_type, mf.file_path,
|
||||||
|
mf.sha256_hash::text AS sha256_hash
|
||||||
|
FROM furumusic__media_file mf
|
||||||
|
JOIN candidate_media candidate ON candidate.id = mf.id
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM furumusic__track track
|
||||||
|
JOIN furumusic__media_file linked
|
||||||
|
ON linked.id = track.audio_file_id
|
||||||
|
OR linked.id = track.cover_file_id
|
||||||
|
WHERE linked.file_path = mf.file_path
|
||||||
|
AND linked.sha256_hash = mf.sha256_hash
|
||||||
|
AND NOT (track.id = ANY($1))
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM furumusic__release release
|
||||||
|
JOIN furumusic__media_file linked
|
||||||
|
ON linked.id = release.cover_file_id
|
||||||
|
WHERE linked.file_path = mf.file_path
|
||||||
|
AND linked.sha256_hash = mf.sha256_hash
|
||||||
|
AND NOT (release.id = ANY($2))
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM furumusic__artist artist
|
||||||
|
JOIN furumusic__media_file linked
|
||||||
|
ON linked.id = artist.image_file_id
|
||||||
|
WHERE linked.file_path = mf.file_path
|
||||||
|
AND linked.sha256_hash = mf.sha256_hash
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM furumusic__playlist playlist
|
||||||
|
JOIN furumusic__media_file linked
|
||||||
|
ON linked.id = playlist.cover_file_id
|
||||||
|
WHERE linked.file_path = mf.file_path
|
||||||
|
AND linked.sha256_hash = mf.sha256_hash
|
||||||
|
)
|
||||||
|
ORDER BY mf.id
|
||||||
|
FOR UPDATE OF mf"#,
|
||||||
|
)
|
||||||
|
.bind(track_ids)
|
||||||
|
.bind(release_ids)
|
||||||
|
.bind(extra_media_ids)
|
||||||
|
.fetch_all(&mut **transaction)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn quarantine_media_files(
|
||||||
|
storage_dir: &str,
|
||||||
|
media_files: &[MediaFileRow],
|
||||||
|
) -> anyhow::Result<Quarantine> {
|
||||||
|
if media_files.is_empty() {
|
||||||
|
return Ok(Quarantine::default());
|
||||||
|
}
|
||||||
|
if storage_dir.trim().is_empty() {
|
||||||
|
bail!("agent_storage_dir is not configured; refusing to leave deleted tracks on disk");
|
||||||
|
}
|
||||||
|
|
||||||
|
let storage_root = crate::media_paths::resolve_config_path_buf(storage_dir);
|
||||||
|
if storage_root.parent().is_none() {
|
||||||
|
bail!("agent_storage_dir must not be a filesystem root");
|
||||||
|
}
|
||||||
|
let quarantine_root = storage_root
|
||||||
|
.join(".furumusic-trash")
|
||||||
|
.join(Uuid::new_v4().to_string());
|
||||||
|
let mut quarantine = Quarantine {
|
||||||
|
root: Some(quarantine_root.clone()),
|
||||||
|
files: Vec::new(),
|
||||||
|
};
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let result: anyhow::Result<()> =
|
||||||
|
async {
|
||||||
|
for media in media_files {
|
||||||
|
let original = checked_storage_path(storage_dir, &media.file_path)?;
|
||||||
|
let mut paths = vec![original.clone()];
|
||||||
|
if media.file_type == "cover_art" {
|
||||||
|
paths.extend(crate::agent::cover_variants::COVER_VARIANTS.iter().map(
|
||||||
|
|variant| crate::agent::cover_variants::variant_path(&original, *variant),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (index, path) in paths.into_iter().enumerate() {
|
||||||
|
if !seen.insert(path.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match tokio::fs::symlink_metadata(&path).await {
|
||||||
|
Ok(metadata) if metadata.is_file() || metadata.file_type().is_symlink() => {
|
||||||
|
}
|
||||||
|
Ok(_) => bail!("media path is not a regular file: {}", path.display()),
|
||||||
|
Err(error) if error.kind() == ErrorKind::NotFound => continue,
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
tokio::fs::create_dir_all(&quarantine_root).await?;
|
||||||
|
let extension = path
|
||||||
|
.extension()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or("bin");
|
||||||
|
let quarantined = quarantine_root.join(format!(
|
||||||
|
"{}-{index}-{}.{}",
|
||||||
|
media.id,
|
||||||
|
Uuid::new_v4(),
|
||||||
|
extension
|
||||||
|
));
|
||||||
|
tokio::fs::rename(&path, &quarantined)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to remove {}", path.display()))?;
|
||||||
|
quarantine.files.push(QuarantinedFile {
|
||||||
|
original: path,
|
||||||
|
quarantined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(()) => Ok(quarantine),
|
||||||
|
Err(error) => {
|
||||||
|
restore_quarantine(&quarantine).await;
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checked_storage_path(storage_dir: &str, stored_path: &str) -> anyhow::Result<PathBuf> {
|
||||||
|
let resolved = crate::media_paths::resolve_media_file_path(storage_dir, stored_path);
|
||||||
|
crate::media_paths::path_for_root(storage_dir, &resolved).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"refusing to delete media outside agent_storage_dir: {}",
|
||||||
|
resolved.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn restore_quarantine(quarantine: &Quarantine) {
|
||||||
|
for file in quarantine.files.iter().rev() {
|
||||||
|
if let Some(parent) = file.original.parent() {
|
||||||
|
let _ = tokio::fs::create_dir_all(parent).await;
|
||||||
|
}
|
||||||
|
let _ = tokio::fs::rename(&file.quarantined, &file.original).await;
|
||||||
|
}
|
||||||
|
if let Some(root) = &quarantine.root {
|
||||||
|
let _ = tokio::fs::remove_dir_all(root).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn purge_quarantine(quarantine: &Quarantine) {
|
||||||
|
if let Some(root) = &quarantine.root
|
||||||
|
&& let Err(error) = tokio::fs::remove_dir_all(root).await
|
||||||
|
&& error.kind() != ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
tracing::warn!(path = %root.display(), error = %error, "failed to purge deleted media quarantine");
|
||||||
|
}
|
||||||
|
if let Some(parent) = quarantine.root.as_deref().and_then(|root| root.parent()) {
|
||||||
|
let _ = tokio::fs::remove_dir(parent).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_empty_storage_parents(storage_dir: &str, files: &[QuarantinedFile]) {
|
||||||
|
let storage_root = crate::media_paths::resolve_config_path_buf(storage_dir);
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for file in files {
|
||||||
|
let mut current = file.original.parent();
|
||||||
|
while let Some(directory) = current {
|
||||||
|
if directory == storage_root || !directory.starts_with(&storage_root) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !seen.insert(directory.to_path_buf()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match tokio::fs::remove_dir(directory).await {
|
||||||
|
Ok(()) => current = directory.parent(),
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_database_rows(
|
||||||
|
transaction: &mut Transaction<'_, Postgres>,
|
||||||
|
track_ids: &[i64],
|
||||||
|
release_ids: &[i64],
|
||||||
|
media_files: &[MediaFileRow],
|
||||||
|
delete_release_rows: bool,
|
||||||
|
) -> anyhow::Result<u64> {
|
||||||
|
cleanup_playback_states(transaction, track_ids).await?;
|
||||||
|
|
||||||
|
for table in [
|
||||||
|
"furumusic__playlist_track",
|
||||||
|
"furumusic__user_liked_track",
|
||||||
|
"furumusic__play_history",
|
||||||
|
"furumusic__track_popularity_history",
|
||||||
|
"furumusic__lastfm_scrobble_outbox",
|
||||||
|
"furumusic__track_genre",
|
||||||
|
"furumusic__track_artist",
|
||||||
|
"furumusic__track_embedding",
|
||||||
|
] {
|
||||||
|
let query = format!("DELETE FROM {table} WHERE track_id = ANY($1)");
|
||||||
|
sqlx::query(&query)
|
||||||
|
.bind(track_ids)
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
for table in [
|
||||||
|
"furumusic__entity_genre_tag",
|
||||||
|
"furumusic__external_metadata_id",
|
||||||
|
"furumusic__artwork_lookup_state",
|
||||||
|
] {
|
||||||
|
let query =
|
||||||
|
format!("DELETE FROM {table} WHERE entity_kind = 'track' AND entity_id = ANY($1)");
|
||||||
|
sqlx::query(&query)
|
||||||
|
.bind(track_ids)
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
for table in [
|
||||||
|
"furumusic__fed_state_like",
|
||||||
|
"furumusic__fed_state_playlist_item",
|
||||||
|
"furumusic__track_ref",
|
||||||
|
"furumusic__listen_event",
|
||||||
|
] {
|
||||||
|
let query =
|
||||||
|
format!("UPDATE {table} SET local_track_id = NULL WHERE local_track_id = ANY($1)");
|
||||||
|
sqlx::query(&query)
|
||||||
|
.bind(track_ids)
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tracks_deleted = sqlx::query("DELETE FROM furumusic__track WHERE id = ANY($1)")
|
||||||
|
.bind(track_ids)
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if delete_release_rows {
|
||||||
|
for table in [
|
||||||
|
"furumusic__entity_genre_tag",
|
||||||
|
"furumusic__external_metadata_id",
|
||||||
|
"furumusic__artwork_lookup_state",
|
||||||
|
] {
|
||||||
|
let query = format!(
|
||||||
|
"DELETE FROM {table} WHERE entity_kind = 'release' AND entity_id = ANY($1)"
|
||||||
|
);
|
||||||
|
sqlx::query(&query)
|
||||||
|
.bind(release_ids)
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = ANY($1)")
|
||||||
|
.bind(release_ids)
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM furumusic__release WHERE id = ANY($1)")
|
||||||
|
.bind(release_ids)
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let media_ids: Vec<i64> = media_files.iter().map(|media| media.id).collect();
|
||||||
|
if !media_ids.is_empty() {
|
||||||
|
let media_hashes: Vec<String> = media_files
|
||||||
|
.iter()
|
||||||
|
.map(|media| media.sha256_hash.clone())
|
||||||
|
.collect();
|
||||||
|
sqlx::query(
|
||||||
|
r#"UPDATE furumusic__youtube_download_item
|
||||||
|
SET status = 'failed', progress_percent = 0,
|
||||||
|
downloaded_bytes = 0, total_bytes = NULL,
|
||||||
|
speed_bytes_per_sec = NULL, eta_seconds = NULL,
|
||||||
|
error = 'Imported library files were deleted; this source can be imported again',
|
||||||
|
completed_at = NULL, updated_at = $2
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT item_id
|
||||||
|
FROM furumusic__youtube_import_media
|
||||||
|
WHERE media_file_id = ANY($1)
|
||||||
|
)"#,
|
||||||
|
)
|
||||||
|
.bind(&media_ids)
|
||||||
|
.bind(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let review_ids: Vec<i64> = sqlx::query_scalar(
|
||||||
|
r#"SELECT id FROM furumusic__pending_review
|
||||||
|
WHERE context_json IS NOT NULL
|
||||||
|
AND substring(
|
||||||
|
context_json
|
||||||
|
from '"sha256"[[:space:]]*:[[:space:]]*"([0-9a-fA-F]{64})"'
|
||||||
|
) = ANY($1)"#,
|
||||||
|
)
|
||||||
|
.bind(&media_hashes)
|
||||||
|
.fetch_all(&mut **transaction)
|
||||||
|
.await?;
|
||||||
|
if !review_ids.is_empty() {
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM furumusic__processing_stats WHERE pending_review_id = ANY($1)",
|
||||||
|
)
|
||||||
|
.bind(&review_ids)
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM furumusic__pending_review WHERE id = ANY($1)")
|
||||||
|
.bind(&review_ids)
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM furumusic__federation_content_id_cache WHERE media_file_id = ANY($1)",
|
||||||
|
)
|
||||||
|
.bind(&media_ids)
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM furumusic__media_file WHERE id = ANY($1)")
|
||||||
|
.bind(&media_ids)
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(if delete_release_rows {
|
||||||
|
u64::try_from(release_ids.len()).unwrap_or(u64::MAX)
|
||||||
|
} else {
|
||||||
|
tracks_deleted
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cleanup_playback_states(
|
||||||
|
transaction: &mut Transaction<'_, Postgres>,
|
||||||
|
track_ids: &[i64],
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let deleted: HashSet<i64> = track_ids.iter().copied().collect();
|
||||||
|
let states: Vec<PlaybackStateRow> = sqlx::query_as(
|
||||||
|
"SELECT id, current_track_id, position_ms, queue_json, queue_position FROM furumusic__playback_state FOR UPDATE",
|
||||||
|
)
|
||||||
|
.fetch_all(&mut **transaction)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for state in states {
|
||||||
|
let mut queue: Vec<i64> = serde_json::from_str(&state.queue_json).unwrap_or_default();
|
||||||
|
let original_queue = queue.clone();
|
||||||
|
queue.retain(|track_id| !deleted.contains(track_id));
|
||||||
|
let current_track_id = state.current_track_id.filter(|id| !deleted.contains(id));
|
||||||
|
if queue == original_queue && current_track_id == state.current_track_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let queue_position = current_track_id
|
||||||
|
.and_then(|current| queue.iter().position(|id| *id == current))
|
||||||
|
.map(|position| i32::try_from(position).unwrap_or(i32::MAX))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
if queue.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
state
|
||||||
|
.queue_position
|
||||||
|
.clamp(0, i32::try_from(queue.len() - 1).unwrap_or(i32::MAX))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let position_ms = if current_track_id.is_some() {
|
||||||
|
state.position_ms
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let queue_json = serde_json::to_string(&queue)?;
|
||||||
|
sqlx::query(
|
||||||
|
r#"UPDATE furumusic__playback_state
|
||||||
|
SET current_track_id = $2, position_ms = $3,
|
||||||
|
queue_json = $4, queue_position = $5
|
||||||
|
WHERE id = $1"#,
|
||||||
|
)
|
||||||
|
.bind(state.id)
|
||||||
|
.bind(current_track_id)
|
||||||
|
.bind(position_ms)
|
||||||
|
.bind(queue_json)
|
||||||
|
.bind(queue_position)
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_media_paths_outside_storage() {
|
||||||
|
assert!(checked_storage_path("/srv/music", "/etc/passwd").is_err());
|
||||||
|
assert!(checked_storage_path("/srv/music", "../outside.flac").is_err());
|
||||||
|
assert_eq!(
|
||||||
|
checked_storage_path("/srv/music", "Artist/Album/01.flac").unwrap(),
|
||||||
|
PathBuf::from("/srv/music/Artist/Album/01.flac")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleted_track_ids_are_removed_from_saved_queue() {
|
||||||
|
let deleted = HashSet::from([2_i64, 4]);
|
||||||
|
let mut queue = vec![1_i64, 2, 3, 4, 5];
|
||||||
|
queue.retain(|track_id| !deleted.contains(track_id));
|
||||||
|
assert_eq!(queue, vec![1, 3, 5]);
|
||||||
|
let serialized: serde_json::Value =
|
||||||
|
serde_json::from_str(&serde_json::to_string(&queue).unwrap()).unwrap();
|
||||||
|
assert_eq!(serialized, serde_json::json!([1, 3, 5]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use anyhow::{Context, bail};
|
||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::{FromRow, PgPool};
|
||||||
|
|
||||||
|
const LOCAL_UPLOAD_LIST_LIMIT: i64 = 100;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct LocalUploadDto {
|
||||||
|
pub id: String,
|
||||||
|
pub filename: String,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
pub status: String,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
pub completed_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow)]
|
||||||
|
struct LocalUploadRow {
|
||||||
|
id: String,
|
||||||
|
user_id: i64,
|
||||||
|
filename: String,
|
||||||
|
size_bytes: i64,
|
||||||
|
status: String,
|
||||||
|
inbox_path: String,
|
||||||
|
error: Option<String>,
|
||||||
|
created_at: String,
|
||||||
|
updated_at: String,
|
||||||
|
completed_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalUploadRow {
|
||||||
|
fn dto(&self) -> LocalUploadDto {
|
||||||
|
LocalUploadDto {
|
||||||
|
id: self.id.clone(),
|
||||||
|
filename: self.filename.clone(),
|
||||||
|
size_bytes: u64::try_from(self.size_bytes).unwrap_or(0),
|
||||||
|
status: self.status.clone(),
|
||||||
|
error: self.error.clone(),
|
||||||
|
created_at: self.created_at.clone(),
|
||||||
|
updated_at: self.updated_at.clone(),
|
||||||
|
completed_at: self.completed_at.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: &str,
|
||||||
|
user_id: i64,
|
||||||
|
filename: &str,
|
||||||
|
size_bytes: u64,
|
||||||
|
inbox_path: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let now = now_string();
|
||||||
|
sqlx::query(
|
||||||
|
r#"INSERT INTO furumusic__local_upload
|
||||||
|
(id, user_id, filename, size_bytes, status, inbox_path, error,
|
||||||
|
created_at, updated_at, completed_at)
|
||||||
|
VALUES ($1, $2, $3, $4, 'uploading', $5, NULL, $6, $6, NULL)"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(filename)
|
||||||
|
.bind(i64::try_from(size_bytes).unwrap_or(i64::MAX))
|
||||||
|
.bind(inbox_path)
|
||||||
|
.bind(now)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_queued(pool: &PgPool, id: &str, user_id: i64) -> anyhow::Result<LocalUploadDto> {
|
||||||
|
update_status(pool, id, user_id, "queued", None).await?;
|
||||||
|
load(pool, user_id, id).await.map(|row| row.dto())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_failed(pool: &PgPool, id: &str, user_id: i64, error: &str) -> anyhow::Result<()> {
|
||||||
|
update_status(pool, id, user_id, "failed", Some(error)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: i64,
|
||||||
|
inbox_dir: &str,
|
||||||
|
) -> anyhow::Result<Vec<LocalUploadDto>> {
|
||||||
|
sync_statuses(pool, user_id, inbox_dir).await?;
|
||||||
|
let rows: Vec<LocalUploadRow> = sqlx::query_as(
|
||||||
|
r#"SELECT id, user_id, filename, size_bytes, status, inbox_path, error,
|
||||||
|
created_at, updated_at, completed_at
|
||||||
|
FROM furumusic__local_upload
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT $2"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(LOCAL_UPLOAD_LIST_LIMIT)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.iter().map(LocalUploadRow::dto).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove(pool: &PgPool, user_id: i64, id: &str) -> anyhow::Result<()> {
|
||||||
|
let result = sqlx::query("DELETE FROM furumusic__local_upload WHERE id = $1 AND user_id = $2")
|
||||||
|
.bind(id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
bail!("file upload history entry not found");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_statuses(pool: &PgPool, user_id: i64, inbox_dir: &str) -> anyhow::Result<()> {
|
||||||
|
let inbox_dir = inbox_dir.trim();
|
||||||
|
if inbox_dir.is_empty() {
|
||||||
|
bail!("agent_inbox_dir is not configured");
|
||||||
|
}
|
||||||
|
let inbox_root = crate::media_paths::resolve_config_path_buf(inbox_dir);
|
||||||
|
if !inbox_root.is_absolute() {
|
||||||
|
bail!("agent_inbox_dir must be an absolute path");
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows: Vec<LocalUploadRow> = sqlx::query_as(
|
||||||
|
r#"SELECT id, user_id, filename, size_bytes, status, inbox_path, error,
|
||||||
|
created_at, updated_at, completed_at
|
||||||
|
FROM furumusic__local_upload
|
||||||
|
WHERE user_id = $1 AND status <> 'complete'"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let inbox_paths: Vec<String> = rows.iter().map(|row| row.inbox_path.clone()).collect();
|
||||||
|
let state_rows: Vec<(String, String, i64)> = sqlx::query_as(
|
||||||
|
r#"SELECT input_path, status::text, COUNT(*)
|
||||||
|
FROM furumusic__pending_review
|
||||||
|
WHERE input_path = ANY($1)
|
||||||
|
GROUP BY input_path, status"#,
|
||||||
|
)
|
||||||
|
.bind(&inbox_paths)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
let mut states_by_path: HashMap<String, HashMap<String, i64>> = HashMap::new();
|
||||||
|
for (input_path, status, total) in state_rows {
|
||||||
|
states_by_path
|
||||||
|
.entry(input_path)
|
||||||
|
.or_default()
|
||||||
|
.insert(status, total);
|
||||||
|
}
|
||||||
|
let error_rows: Vec<(String, String)> = sqlx::query_as(
|
||||||
|
r#"SELECT DISTINCT ON (input_path) input_path, error_message
|
||||||
|
FROM furumusic__pending_review
|
||||||
|
WHERE input_path = ANY($1) AND status = 'failed'
|
||||||
|
AND error_message IS NOT NULL
|
||||||
|
ORDER BY input_path, id DESC"#,
|
||||||
|
)
|
||||||
|
.bind(&inbox_paths)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
let errors_by_path: HashMap<String, String> = error_rows.into_iter().collect();
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let counts = states_by_path
|
||||||
|
.get(&row.inbox_path)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let total: i64 = counts.values().sum();
|
||||||
|
|
||||||
|
let mut terminal_error = None;
|
||||||
|
let next = if total == 0 {
|
||||||
|
if matches!(row.status.as_str(), "uploading" | "failed" | "needs_review") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let full_path = crate::media_paths::resolve_path_from_root(inbox_dir, &row.inbox_path);
|
||||||
|
if tokio::fs::try_exists(full_path).await.unwrap_or(false) {
|
||||||
|
"queued"
|
||||||
|
} else {
|
||||||
|
"complete"
|
||||||
|
}
|
||||||
|
} else if count(&counts, "processing") > 0 {
|
||||||
|
"ai_processing"
|
||||||
|
} else if count(&counts, "queued") > 0 {
|
||||||
|
"queued"
|
||||||
|
} else if count(&counts, "failed") > 0 {
|
||||||
|
terminal_error = errors_by_path.get(&row.inbox_path).cloned();
|
||||||
|
"failed"
|
||||||
|
} else if count(&counts, "pending") > 0 || count(&counts, "rejected") > 0 {
|
||||||
|
"needs_review"
|
||||||
|
} else if count(&counts, "approved") > 0 || count(&counts, "auto_approved") > 0 {
|
||||||
|
"complete"
|
||||||
|
} else {
|
||||||
|
"queued"
|
||||||
|
};
|
||||||
|
|
||||||
|
if row.status != next || row.error != terminal_error {
|
||||||
|
update_status(pool, &row.id, row.user_id, next, terminal_error.as_deref()).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_status(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: &str,
|
||||||
|
user_id: i64,
|
||||||
|
status: &str,
|
||||||
|
error: Option<&str>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let now = now_string();
|
||||||
|
let completed_at =
|
||||||
|
matches!(status, "complete" | "failed" | "needs_review").then(|| now.clone());
|
||||||
|
sqlx::query(
|
||||||
|
r#"UPDATE furumusic__local_upload
|
||||||
|
SET status = $3, error = $4, updated_at = $5, completed_at = $6
|
||||||
|
WHERE id = $1 AND user_id = $2"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(status)
|
||||||
|
.bind(error.map(trim_error))
|
||||||
|
.bind(&now)
|
||||||
|
.bind(completed_at)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load(pool: &PgPool, user_id: i64, id: &str) -> anyhow::Result<LocalUploadRow> {
|
||||||
|
sqlx::query_as(
|
||||||
|
r#"SELECT id, user_id, filename, size_bytes, status, inbox_path, error,
|
||||||
|
created_at, updated_at, completed_at
|
||||||
|
FROM furumusic__local_upload WHERE id = $1 AND user_id = $2"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.context("file upload history entry not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count(counts: &HashMap<String, i64>, status: &str) -> i64 {
|
||||||
|
counts.get(status).copied().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_error(value: &str) -> String {
|
||||||
|
value.chars().take(4_000).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_string() -> String {
|
||||||
|
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||||
|
}
|
||||||
+183
-14
@@ -3,17 +3,22 @@ mod agent;
|
|||||||
mod api;
|
mod api;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod federation;
|
||||||
mod i18n;
|
mod i18n;
|
||||||
mod jobs;
|
mod jobs;
|
||||||
mod lastfm;
|
mod lastfm;
|
||||||
|
mod library_cleanup;
|
||||||
|
mod local_uploads;
|
||||||
mod media_paths;
|
mod media_paths;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
mod music;
|
mod music;
|
||||||
mod oidc;
|
mod oidc;
|
||||||
mod player;
|
mod player;
|
||||||
mod scheduler;
|
mod scheduler;
|
||||||
|
mod similarity;
|
||||||
mod torrents;
|
mod torrents;
|
||||||
mod user;
|
mod user;
|
||||||
|
mod youtube;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -29,7 +34,7 @@ use cot::form::{Form, FormResult};
|
|||||||
use cot::html::Html;
|
use cot::html::Html;
|
||||||
use cot::middleware::SessionMiddleware;
|
use cot::middleware::SessionMiddleware;
|
||||||
use cot::project::RegisterAppsContext;
|
use cot::project::RegisterAppsContext;
|
||||||
use cot::request::extractors::{RequestForm, UrlQuery};
|
use cot::request::extractors::{Path, RequestForm, UrlQuery};
|
||||||
use cot::response::IntoResponse;
|
use cot::response::IntoResponse;
|
||||||
use cot::router::method::get;
|
use cot::router::method::get;
|
||||||
use cot::router::{Route, Router};
|
use cot::router::{Route, Router};
|
||||||
@@ -52,6 +57,7 @@ fn build_registry() -> Arc<JobRegistry> {
|
|||||||
registry.register(jobs::inbox_discover::InboxDiscoverJob);
|
registry.register(jobs::inbox_discover::InboxDiscoverJob);
|
||||||
registry.register(jobs::inbox_process::InboxProcessJob);
|
registry.register(jobs::inbox_process::InboxProcessJob);
|
||||||
registry.register(jobs::inbox_process::FileProcessJob);
|
registry.register(jobs::inbox_process::FileProcessJob);
|
||||||
|
registry.register(jobs::archive_cleanup::ArchiveCleanupJob);
|
||||||
registry.register(jobs::artwork_backfill::ArtworkBackfillJob);
|
registry.register(jobs::artwork_backfill::ArtworkBackfillJob);
|
||||||
registry.register(jobs::metadata_backfill::MetadataBackfillJob);
|
registry.register(jobs::metadata_backfill::MetadataBackfillJob);
|
||||||
registry.register(jobs::lastfm_popularity::LastfmPopularityJob);
|
registry.register(jobs::lastfm_popularity::LastfmPopularityJob);
|
||||||
@@ -63,15 +69,61 @@ fn build_registry() -> Arc<JobRegistry> {
|
|||||||
// Handlers
|
// Handlers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async fn index(session: Session, db: Database, i18n: I18n) -> cot::Result<cot::response::Response> {
|
#[derive(Deserialize)]
|
||||||
|
struct IndexQuery {
|
||||||
|
track: Option<i64>,
|
||||||
|
release: Option<i64>,
|
||||||
|
playlist_share: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
i18n: I18n,
|
||||||
|
UrlQuery(query): UrlQuery<IndexQuery>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
let _user = match auth::get_session_user(&session, &db).await {
|
let _user = match auth::get_session_user(&session, &db).await {
|
||||||
Some(u) => u,
|
Some(u) => u,
|
||||||
None => return Ok(auth::redirect("/login")),
|
None => {
|
||||||
|
if let Some(location) = share_query_redirect(&query) {
|
||||||
|
auth::remember_post_login_redirect(&session, &location).await?;
|
||||||
|
}
|
||||||
|
return Ok(auth::redirect("/login"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let template = player::PlayerPageTemplate {
|
||||||
|
t: i18n.t,
|
||||||
|
downloads_enabled: config.downloads_enabled,
|
||||||
|
torrent_downloads_enabled: config.downloads_enabled && config.torrent_downloads_enabled,
|
||||||
|
youtube_downloads_enabled: config.downloads_enabled && config.youtube_downloads_enabled,
|
||||||
};
|
};
|
||||||
let template = player::PlayerPageTemplate { t: i18n.t };
|
|
||||||
Html::new(template.render()?).into_response()
|
Html::new(template.render()?).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn share_query_redirect(query: &IndexQuery) -> Option<String> {
|
||||||
|
if let Some(track_id) = query.track.filter(|id| *id > 0) {
|
||||||
|
return Some(format!("/?track={track_id}"));
|
||||||
|
}
|
||||||
|
if let Some(release_id) = query.release.filter(|id| *id > 0) {
|
||||||
|
return Some(format!("/?release={release_id}"));
|
||||||
|
}
|
||||||
|
let token = query.playlist_share.as_deref()?.trim();
|
||||||
|
if is_share_token(token) {
|
||||||
|
Some(format!("/?playlist_share={token}"))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_share_token(token: &str) -> bool {
|
||||||
|
!token.is_empty()
|
||||||
|
&& token.len() <= 64
|
||||||
|
&& token
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct SetLangQuery {
|
struct SetLangQuery {
|
||||||
lang: String,
|
lang: String,
|
||||||
@@ -131,6 +183,21 @@ struct LoginForm {
|
|||||||
password: String,
|
password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LoginQuery {
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SharePathId {
|
||||||
|
id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SharePathToken {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Logout
|
// Logout
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -168,6 +235,58 @@ async fn metrics_handler(
|
|||||||
.expect("valid response"))
|
.expect("valid response"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn share_track_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
Path(path): Path<SharePathId>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let location = if path.id > 0 {
|
||||||
|
format!("/?track={}", path.id)
|
||||||
|
} else {
|
||||||
|
"/".to_string()
|
||||||
|
};
|
||||||
|
if auth::get_session_user(&session, &db).await.is_none() {
|
||||||
|
auth::remember_post_login_redirect(&session, &location).await?;
|
||||||
|
return Ok(auth::redirect("/login"));
|
||||||
|
}
|
||||||
|
Ok(auth::redirect(&location))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn share_release_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
Path(path): Path<SharePathId>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let location = if path.id > 0 {
|
||||||
|
format!("/?release={}", path.id)
|
||||||
|
} else {
|
||||||
|
"/".to_string()
|
||||||
|
};
|
||||||
|
if auth::get_session_user(&session, &db).await.is_none() {
|
||||||
|
auth::remember_post_login_redirect(&session, &location).await?;
|
||||||
|
return Ok(auth::redirect("/login"));
|
||||||
|
}
|
||||||
|
Ok(auth::redirect(&location))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn share_playlist_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
Path(path): Path<SharePathToken>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let token = path.token.trim();
|
||||||
|
let location = if is_share_token(token) {
|
||||||
|
format!("/?playlist_share={token}")
|
||||||
|
} else {
|
||||||
|
"/".to_string()
|
||||||
|
};
|
||||||
|
if auth::get_session_user(&session, &db).await.is_none() {
|
||||||
|
auth::remember_post_login_redirect(&session, &location).await?;
|
||||||
|
return Ok(auth::redirect("/login"));
|
||||||
|
}
|
||||||
|
Ok(auth::redirect(&location))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// App
|
// App
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -196,11 +315,26 @@ impl App for FuruApp {
|
|||||||
),
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/",
|
"/",
|
||||||
|session: Session, db: Database, i18n: I18n| async move {
|
|session: Session, db: Database, i18n: I18n, query: UrlQuery<IndexQuery>| async move {
|
||||||
index(session, db, i18n).await
|
index(session, db, i18n, query).await
|
||||||
},
|
},
|
||||||
"index",
|
"index",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/share/track/{id}",
|
||||||
|
get(share_track_handler),
|
||||||
|
"share_track",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/share/release/{id}",
|
||||||
|
get(share_release_handler),
|
||||||
|
"share_release",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/share/playlist/{token}",
|
||||||
|
get(share_playlist_handler),
|
||||||
|
"share_playlist",
|
||||||
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/metrics",
|
"/metrics",
|
||||||
get({
|
get({
|
||||||
@@ -218,14 +352,15 @@ impl App for FuruApp {
|
|||||||
"/login",
|
"/login",
|
||||||
get({
|
get({
|
||||||
let config = Arc::clone(&self.config);
|
let config = Arc::clone(&self.config);
|
||||||
move |i18n: I18n, db: Database| {
|
move |i18n: I18n, db: Database, query: UrlQuery<LoginQuery>| {
|
||||||
let config = Arc::clone(&config);
|
let config = Arc::clone(&config);
|
||||||
async move {
|
async move {
|
||||||
// No users at all → redirect to first-run setup
|
// No users at all → redirect to first-run setup
|
||||||
if User::count_all(&db).await.unwrap_or(0) == 0 {
|
if User::count_all(&db).await.unwrap_or(0) == 0 {
|
||||||
return Ok(auth::redirect("/admin/setup"));
|
return Ok(auth::redirect("/admin/setup"));
|
||||||
}
|
}
|
||||||
login_page_handler(i18n, &config, db, String::new())
|
let message = query.0.error.unwrap_or_default();
|
||||||
|
login_page_handler(i18n, &config, db, message)
|
||||||
.await?
|
.await?
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
@@ -255,6 +390,15 @@ impl App for FuruApp {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
if !live_config.auth_password_enabled {
|
||||||
|
metrics::record_auth_attempt("password", "failure", "disabled");
|
||||||
|
let msg = i18n.t.login_disabled.to_owned();
|
||||||
|
return login_page_handler(i18n, &config, db, msg)
|
||||||
|
.await?
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
// Try to authenticate
|
// Try to authenticate
|
||||||
if let Ok(Some(user)) = User::get_by_username(&db, &data.username).await
|
if let Ok(Some(user)) = User::get_by_username(&db, &data.username).await
|
||||||
{
|
{
|
||||||
@@ -263,23 +407,24 @@ impl App for FuruApp {
|
|||||||
match hash.verify(&password) {
|
match hash.verify(&password) {
|
||||||
PasswordVerificationResult::Ok
|
PasswordVerificationResult::Ok
|
||||||
| PasswordVerificationResult::OkObsolete(_) => {
|
| PasswordVerificationResult::OkObsolete(_) => {
|
||||||
|
let redirect_to =
|
||||||
|
auth::get_post_login_redirect(&session)
|
||||||
|
.await?
|
||||||
|
.unwrap_or_else(|| "/".to_string());
|
||||||
auth::login(&session, user.id_val()).await?;
|
auth::login(&session, user.id_val()).await?;
|
||||||
|
auth::clear_post_login_redirect(&session).await?;
|
||||||
metrics::record_auth_attempt(
|
metrics::record_auth_attempt(
|
||||||
"password", "success", "ok",
|
"password", "success", "ok",
|
||||||
);
|
);
|
||||||
metrics::record_session_created("password");
|
metrics::record_session_created("password");
|
||||||
return Ok(auth::redirect("/"));
|
return Ok(auth::redirect(&redirect_to));
|
||||||
}
|
}
|
||||||
PasswordVerificationResult::Invalid => {}
|
PasswordVerificationResult::Invalid => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics::record_auth_attempt(
|
metrics::record_auth_attempt("password", "failure", "bad_credentials");
|
||||||
"password",
|
|
||||||
"failure",
|
|
||||||
"bad_credentials",
|
|
||||||
);
|
|
||||||
let msg = i18n.t.login_invalid.to_owned();
|
let msg = i18n.t.login_invalid.to_owned();
|
||||||
login_page_handler(i18n, &config, db, msg)
|
login_page_handler(i18n, &config, db, msg)
|
||||||
.await?
|
.await?
|
||||||
@@ -301,6 +446,16 @@ impl App for FuruApp {
|
|||||||
get(oidc::oidc_callback_handler),
|
get(oidc::oidc_callback_handler),
|
||||||
"oidc_callback",
|
"oidc_callback",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/auth/mobile/oidc/start",
|
||||||
|
get(oidc::oidc_mobile_start_handler),
|
||||||
|
"mobile_oidc_start",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/auth/mobile/oidc/callback",
|
||||||
|
get(oidc::oidc_mobile_callback_handler),
|
||||||
|
"mobile_oidc_callback",
|
||||||
|
),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -415,6 +570,20 @@ impl Project for FuruProject {
|
|||||||
.await;
|
.await;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Join the federation at boot when it was left enabled (the settings
|
||||||
|
// live in the config KV table; changes apply live from the admin).
|
||||||
|
let fed_config = Arc::clone(&self.app_config);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
federation::handle().boot(&fed_config).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Embedding calculation is an independent, server-wide background
|
||||||
|
// service. It remains useful locally when federation is disabled.
|
||||||
|
let similarity_config = Arc::clone(&self.app_config);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
similarity::handle().boot(&similarity_config).await;
|
||||||
|
});
|
||||||
|
|
||||||
apps.register(cot::session::db::SessionApp::new());
|
apps.register(cot::session::db::SessionApp::new());
|
||||||
apps.register_with_views(
|
apps.register_with_views(
|
||||||
FuruApp {
|
FuruApp {
|
||||||
|
|||||||
+345
-60
@@ -6,11 +6,11 @@ use std::sync::{LazyLock, Mutex};
|
|||||||
use std::task::{Context, Poll};
|
use std::task::{Context, Poll};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use cot::http::header::CONTENT_LENGTH;
|
use cot::Error;
|
||||||
use cot::http::Method;
|
use cot::http::Method;
|
||||||
|
use cot::http::header::CONTENT_LENGTH;
|
||||||
use cot::request::Request;
|
use cot::request::Request;
|
||||||
use cot::response::Response;
|
use cot::response::Response;
|
||||||
use cot::Error;
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tower::{Layer, Service};
|
use tower::{Layer, Service};
|
||||||
|
|
||||||
@@ -80,28 +80,33 @@ where
|
|||||||
|
|
||||||
fn call(&mut self, request: Request) -> Self::Future {
|
fn call(&mut self, request: Request) -> Self::Future {
|
||||||
let method = request.method().clone();
|
let method = request.method().clone();
|
||||||
let route = normalize_route(request.uri().path());
|
let route = known_http_route(request.uri().path()).map(str::to_owned);
|
||||||
let request_bytes = request
|
let request_bytes = request
|
||||||
.headers()
|
.headers()
|
||||||
.get(CONTENT_LENGTH)
|
.get(CONTENT_LENGTH)
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.and_then(|value| value.parse::<f64>().ok())
|
.and_then(|value| value.parse::<f64>().ok())
|
||||||
.unwrap_or(0.0);
|
.unwrap_or(0.0);
|
||||||
let labels = http_labels(&method, &route, "in_flight");
|
if let Some(route) = &route {
|
||||||
REGISTRY.inc_gauge("furumusic_http_in_flight_requests", labels, 1.0);
|
let labels = http_labels(&method, route, "in_flight");
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_gauge("furumusic_http_in_flight_requests", labels, 1.0);
|
||||||
"furumusic_http_request_body_bytes_total",
|
REGISTRY.inc_counter(
|
||||||
vec![
|
"furumusic_http_request_body_bytes_total",
|
||||||
("method", method.as_str().to_owned()),
|
vec![
|
||||||
("route", route.clone()),
|
("method", method.as_str().to_owned()),
|
||||||
],
|
("route", route.clone()),
|
||||||
request_bytes,
|
],
|
||||||
);
|
request_bytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let fut = self.inner.call(request);
|
let fut = self.inner.call(request);
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let result = fut.await;
|
let result = fut.await;
|
||||||
|
let Some(route) = route else {
|
||||||
|
return result;
|
||||||
|
};
|
||||||
let elapsed = start.elapsed().as_secs_f64();
|
let elapsed = start.elapsed().as_secs_f64();
|
||||||
REGISTRY.inc_gauge(
|
REGISTRY.inc_gauge(
|
||||||
"furumusic_http_in_flight_requests",
|
"furumusic_http_in_flight_requests",
|
||||||
@@ -236,8 +241,17 @@ pub fn record_agent_discover_run(outcome: &'static str, duration: Duration) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_agent_discover_files(seen: u64, queued: u64, skipped_hash: u64, skipped_existing: u64) {
|
pub fn record_agent_discover_files(
|
||||||
REGISTRY.inc_counter("furumusic_agent_discover_files_seen_total", Vec::new(), seen as f64);
|
seen: u64,
|
||||||
|
queued: u64,
|
||||||
|
skipped_hash: u64,
|
||||||
|
skipped_existing: u64,
|
||||||
|
) {
|
||||||
|
REGISTRY.inc_counter(
|
||||||
|
"furumusic_agent_discover_files_seen_total",
|
||||||
|
Vec::new(),
|
||||||
|
seen as f64,
|
||||||
|
);
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_agent_discover_files_queued_total",
|
"furumusic_agent_discover_files_queued_total",
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
@@ -340,18 +354,12 @@ pub fn record_agent_llm(
|
|||||||
let model = normalize_model_label(model);
|
let model = normalize_model_label(model);
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_agent_llm_requests_total",
|
"furumusic_agent_llm_requests_total",
|
||||||
vec![
|
vec![("model", model.clone()), ("outcome", outcome.to_owned())],
|
||||||
("model", model.clone()),
|
|
||||||
("outcome", outcome.to_owned()),
|
|
||||||
],
|
|
||||||
1.0,
|
1.0,
|
||||||
);
|
);
|
||||||
REGISTRY.observe_histogram(
|
REGISTRY.observe_histogram(
|
||||||
"furumusic_agent_llm_duration_seconds",
|
"furumusic_agent_llm_duration_seconds",
|
||||||
vec![
|
vec![("model", model.clone()), ("outcome", outcome.to_owned())],
|
||||||
("model", model.clone()),
|
|
||||||
("outcome", outcome.to_owned()),
|
|
||||||
],
|
|
||||||
duration.as_secs_f64(),
|
duration.as_secs_f64(),
|
||||||
JOB_BUCKETS,
|
JOB_BUCKETS,
|
||||||
);
|
);
|
||||||
@@ -362,10 +370,7 @@ pub fn record_agent_llm(
|
|||||||
);
|
);
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_agent_llm_tokens_total",
|
"furumusic_agent_llm_tokens_total",
|
||||||
vec![
|
vec![("model", model.clone()), ("type", "completion".to_owned())],
|
||||||
("model", model.clone()),
|
|
||||||
("type", "completion".to_owned()),
|
|
||||||
],
|
|
||||||
completion_tokens as f64,
|
completion_tokens as f64,
|
||||||
);
|
);
|
||||||
REGISTRY.observe_histogram(
|
REGISTRY.observe_histogram(
|
||||||
@@ -400,7 +405,12 @@ pub fn record_agent_llm_parse_failure(model: &str) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_agent_rag(kind: &'static str, outcome: &'static str, duration: Duration, results: usize) {
|
pub fn record_agent_rag(
|
||||||
|
kind: &'static str,
|
||||||
|
outcome: &'static str,
|
||||||
|
duration: Duration,
|
||||||
|
results: usize,
|
||||||
|
) {
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_agent_rag_queries_total",
|
"furumusic_agent_rag_queries_total",
|
||||||
vec![("kind", kind.to_owned()), ("outcome", outcome.to_owned())],
|
vec![("kind", kind.to_owned()), ("outcome", outcome.to_owned())],
|
||||||
@@ -423,7 +433,10 @@ pub fn record_agent_rag(kind: &'static str, outcome: &'static str, duration: Dur
|
|||||||
pub fn record_agent_cover_lookup(source: &'static str, outcome: &'static str, bytes: usize) {
|
pub fn record_agent_cover_lookup(source: &'static str, outcome: &'static str, bytes: usize) {
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_agent_cover_lookup_total",
|
"furumusic_agent_cover_lookup_total",
|
||||||
vec![("source", source.to_owned()), ("outcome", outcome.to_owned())],
|
vec![
|
||||||
|
("source", source.to_owned()),
|
||||||
|
("outcome", outcome.to_owned()),
|
||||||
|
],
|
||||||
1.0,
|
1.0,
|
||||||
);
|
);
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
@@ -433,7 +446,11 @@ pub fn record_agent_cover_lookup(source: &'static str, outcome: &'static str, by
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_agent_cover_variant(variant: &'static str, outcome: &'static str, duration: Duration) {
|
pub fn record_agent_cover_variant(
|
||||||
|
variant: &'static str,
|
||||||
|
outcome: &'static str,
|
||||||
|
duration: Duration,
|
||||||
|
) {
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_agent_cover_variant_generation_total",
|
"furumusic_agent_cover_variant_generation_total",
|
||||||
vec![
|
vec![
|
||||||
@@ -489,7 +506,12 @@ pub fn record_torrent_download(outcome: &'static str, selected_bytes: u64, durat
|
|||||||
|
|
||||||
pub async fn render(pool: &PgPool, config: &AppConfig) -> String {
|
pub async fn render(pool: &PgPool, config: &AppConfig) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
emit_static_gauge(&mut out, "furumusic_build_info", &[("version", env!("CARGO_PKG_VERSION"))], 1.0);
|
emit_static_gauge(
|
||||||
|
&mut out,
|
||||||
|
"furumusic_build_info",
|
||||||
|
&[("version", env!("CARGO_PKG_VERSION"))],
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
render_active_users(&mut out);
|
render_active_users(&mut out);
|
||||||
render_storage(&mut out, config);
|
render_storage(&mut out, config);
|
||||||
render_db_metrics(&mut out, pool).await;
|
render_db_metrics(&mut out, pool).await;
|
||||||
@@ -538,11 +560,42 @@ fn render_storage(out: &mut String, config: &AppConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn render_db_metrics(out: &mut String, pool: &PgPool) {
|
async fn render_db_metrics(out: &mut String, pool: &PgPool) {
|
||||||
render_group_counts(out, pool, "furumusic_users_total", "SELECT role::text AS label, COUNT(*) AS count FROM furumusic__user GROUP BY role", "role").await;
|
render_group_counts(
|
||||||
render_single_count(out, pool, "furumusic_library_tracks_total", "SELECT COUNT(*) FROM furumusic__track").await;
|
out,
|
||||||
render_single_count(out, pool, "furumusic_library_releases_total", "SELECT COUNT(*) FROM furumusic__release").await;
|
pool,
|
||||||
render_single_count(out, pool, "furumusic_library_artists_total", "SELECT COUNT(*) FROM furumusic__artist").await;
|
"furumusic_users_total",
|
||||||
render_single_count(out, pool, "furumusic_library_playlists_total", "SELECT COUNT(*) FROM furumusic__playlist").await;
|
"SELECT role::text AS label, COUNT(*) AS count FROM furumusic__user GROUP BY role",
|
||||||
|
"role",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
render_single_count(
|
||||||
|
out,
|
||||||
|
pool,
|
||||||
|
"furumusic_library_tracks_total",
|
||||||
|
"SELECT COUNT(*) FROM furumusic__track",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
render_single_count(
|
||||||
|
out,
|
||||||
|
pool,
|
||||||
|
"furumusic_library_releases_total",
|
||||||
|
"SELECT COUNT(*) FROM furumusic__release",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
render_single_count(
|
||||||
|
out,
|
||||||
|
pool,
|
||||||
|
"furumusic_library_artists_total",
|
||||||
|
"SELECT COUNT(*) FROM furumusic__artist",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
render_single_count(
|
||||||
|
out,
|
||||||
|
pool,
|
||||||
|
"furumusic_library_playlists_total",
|
||||||
|
"SELECT COUNT(*) FROM furumusic__playlist",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
render_group_counts(out, pool, "furumusic_media_files_total", "SELECT file_type::text AS label, COUNT(*) AS count FROM furumusic__media_file GROUP BY file_type", "type").await;
|
render_group_counts(out, pool, "furumusic_media_files_total", "SELECT file_type::text AS label, COUNT(*) AS count FROM furumusic__media_file GROUP BY file_type", "type").await;
|
||||||
render_group_sums(out, pool, "furumusic_media_file_bytes_total", "SELECT file_type::text AS label, COALESCE(SUM(file_size_bytes), 0)::bigint AS value FROM furumusic__media_file GROUP BY file_type", "type").await;
|
render_group_sums(out, pool, "furumusic_media_file_bytes_total", "SELECT file_type::text AS label, COALESCE(SUM(file_size_bytes), 0)::bigint AS value FROM furumusic__media_file GROUP BY file_type", "type").await;
|
||||||
render_group_counts(out, pool, "furumusic_agent_reviews_total", "SELECT status::text AS label, COUNT(*) AS count FROM furumusic__pending_review GROUP BY status", "status").await;
|
render_group_counts(out, pool, "furumusic_agent_reviews_total", "SELECT status::text AS label, COUNT(*) AS count FROM furumusic__pending_review GROUP BY status", "status").await;
|
||||||
@@ -550,7 +603,13 @@ async fn render_db_metrics(out: &mut String, pool: &PgPool) {
|
|||||||
render_group_counts(out, pool, "furumusic_scheduler_job_running", "SELECT job_name::text AS label, COUNT(*) AS count FROM furumusic__job_run WHERE status = 'running' GROUP BY job_name", "job").await;
|
render_group_counts(out, pool, "furumusic_scheduler_job_running", "SELECT job_name::text AS label, COUNT(*) AS count FROM furumusic__job_run WHERE status = 'running' GROUP BY job_name", "job").await;
|
||||||
render_group_sums(out, pool, "furumusic_scheduler_job_enabled", "SELECT name::text AS label, (CASE WHEN enabled THEN 1 ELSE 0 END)::bigint AS value FROM furumusic__scheduled_job", "job").await;
|
render_group_sums(out, pool, "furumusic_scheduler_job_enabled", "SELECT name::text AS label, (CASE WHEN enabled THEN 1 ELSE 0 END)::bigint AS value FROM furumusic__scheduled_job", "job").await;
|
||||||
render_group_counts(out, pool, "furumusic_torrent_sessions_total", "SELECT status::text AS label, COUNT(*) AS count FROM furumusic__torrent_session GROUP BY status", "status").await;
|
render_group_counts(out, pool, "furumusic_torrent_sessions_total", "SELECT status::text AS label, COUNT(*) AS count FROM furumusic__torrent_session GROUP BY status", "status").await;
|
||||||
render_single_count(out, pool, "furumusic_play_history_total", "SELECT COUNT(*) FROM furumusic__play_history").await;
|
render_single_count(
|
||||||
|
out,
|
||||||
|
pool,
|
||||||
|
"furumusic_play_history_total",
|
||||||
|
"SELECT COUNT(*) FROM furumusic__play_history",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn render_single_count(out: &mut String, pool: &PgPool, metric: &'static str, sql: &str) {
|
async fn render_single_count(out: &mut String, pool: &PgPool, metric: &'static str, sql: &str) {
|
||||||
@@ -566,7 +625,10 @@ async fn render_group_counts(
|
|||||||
sql: &str,
|
sql: &str,
|
||||||
label_name: &'static str,
|
label_name: &'static str,
|
||||||
) {
|
) {
|
||||||
if let Ok(rows) = sqlx::query_as::<_, (String, i64)>(sql).fetch_all(pool).await {
|
if let Ok(rows) = sqlx::query_as::<_, (String, i64)>(sql)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
for (label, count) in rows {
|
for (label, count) in rows {
|
||||||
emit_static_gauge(out, metric, &[(label_name, label.as_str())], count as f64);
|
emit_static_gauge(out, metric, &[(label_name, label.as_str())], count as f64);
|
||||||
}
|
}
|
||||||
@@ -580,7 +642,10 @@ async fn render_group_sums(
|
|||||||
sql: &str,
|
sql: &str,
|
||||||
label_name: &'static str,
|
label_name: &'static str,
|
||||||
) {
|
) {
|
||||||
if let Ok(rows) = sqlx::query_as::<_, (String, i64)>(sql).fetch_all(pool).await {
|
if let Ok(rows) = sqlx::query_as::<_, (String, i64)>(sql)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
for (label, value) in rows {
|
for (label, value) in rows {
|
||||||
emit_static_gauge(out, metric, &[(label_name, label.as_str())], value as f64);
|
emit_static_gauge(out, metric, &[(label_name, label.as_str())], value as f64);
|
||||||
}
|
}
|
||||||
@@ -641,7 +706,12 @@ impl Registry {
|
|||||||
for (bucket, count) in state.buckets.iter().zip(state.counts.iter()) {
|
for (bucket, count) in state.buckets.iter().zip(state.counts.iter()) {
|
||||||
let mut labels = key.labels.clone();
|
let mut labels = key.labels.clone();
|
||||||
labels.push(("le", bucket.to_string()));
|
labels.push(("le", bucket.to_string()));
|
||||||
emit_metric(&mut out, &format!("{}_bucket", key.name), &labels, *count as f64);
|
emit_metric(
|
||||||
|
&mut out,
|
||||||
|
&format!("{}_bucket", key.name),
|
||||||
|
&labels,
|
||||||
|
*count as f64,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let mut inf_labels = key.labels.clone();
|
let mut inf_labels = key.labels.clone();
|
||||||
inf_labels.push(("le", "+Inf".to_owned()));
|
inf_labels.push(("le", "+Inf".to_owned()));
|
||||||
@@ -683,31 +753,246 @@ fn http_labels(method: &Method, route: &str, status: &str) -> Vec<(&'static str,
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_route(path: &str) -> String {
|
fn known_http_route(path: &str) -> Option<&'static str> {
|
||||||
let mut route = String::with_capacity(path.len());
|
let path = canonicalize_http_path(path);
|
||||||
for segment in path.split('/') {
|
KNOWN_HTTP_ROUTES
|
||||||
if segment.is_empty() {
|
.iter()
|
||||||
continue;
|
.copied()
|
||||||
}
|
.find(|pattern| route_pattern_matches(pattern, &path))
|
||||||
route.push('/');
|
}
|
||||||
if segment.parse::<i64>().is_ok() || looks_like_uuid(segment) {
|
|
||||||
route.push_str("{id}");
|
fn canonicalize_http_path(path: &str) -> String {
|
||||||
} else {
|
let without_trailing = path.trim_end_matches('/');
|
||||||
route.push_str(segment);
|
if without_trailing.is_empty() {
|
||||||
}
|
|
||||||
}
|
|
||||||
if route.is_empty() {
|
|
||||||
"/".to_owned()
|
"/".to_owned()
|
||||||
} else {
|
} else {
|
||||||
route
|
without_trailing.to_owned()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn looks_like_uuid(value: &str) -> bool {
|
fn route_pattern_matches(pattern: &str, path: &str) -> bool {
|
||||||
value.len() == 36
|
if pattern == "/" {
|
||||||
&& value
|
return path == "/";
|
||||||
.chars()
|
}
|
||||||
.all(|ch| ch.is_ascii_hexdigit() || ch == '-')
|
|
||||||
|
let mut pattern_segments = pattern.trim_start_matches('/').split('/');
|
||||||
|
let mut path_segments = path.trim_start_matches('/').split('/');
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match (pattern_segments.next(), path_segments.next()) {
|
||||||
|
(None, None) => return true,
|
||||||
|
(Some(pattern_segment), Some(path_segment)) => {
|
||||||
|
if path_segment.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if is_route_param(pattern_segment) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if pattern_segment != path_segment {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_route_param(segment: &str) -> bool {
|
||||||
|
segment.starts_with('{') && segment.ends_with('}')
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWN_HTTP_ROUTES: &[&str] = &[
|
||||||
|
// Keep this allowlist in sync with Cot route declarations. Unknown paths are
|
||||||
|
// intentionally skipped so bot traffic cannot create high-cardinality labels.
|
||||||
|
"/",
|
||||||
|
"/admin",
|
||||||
|
"/swagger",
|
||||||
|
"/swagger/openapi.json",
|
||||||
|
"/share/track/{id}",
|
||||||
|
"/share/release/{id}",
|
||||||
|
"/share/playlist/{token}",
|
||||||
|
"/metrics",
|
||||||
|
"/login",
|
||||||
|
"/logout",
|
||||||
|
"/set-lang",
|
||||||
|
"/auth/oidc/start",
|
||||||
|
"/auth/oidc/callback",
|
||||||
|
"/api/me",
|
||||||
|
"/admin/setup",
|
||||||
|
"/admin/v2",
|
||||||
|
"/admin/v2/api/dashboard",
|
||||||
|
"/admin/v2/api/reviews",
|
||||||
|
"/admin/v2/api/reviews/bulk",
|
||||||
|
"/admin/v2/api/users",
|
||||||
|
"/admin/v2/api/users/{id}",
|
||||||
|
"/admin/v2/api/reviews/{id}/approve",
|
||||||
|
"/admin/v2/api/jobs",
|
||||||
|
"/admin/v2/api/jobs/metadata_backfill/run-options",
|
||||||
|
"/admin/v2/api/jobs/artwork_backfill/run-options",
|
||||||
|
"/admin/v2/api/jobs/{name}/run",
|
||||||
|
"/admin/v2/api/settings",
|
||||||
|
"/admin/v2/api/settings/probe",
|
||||||
|
"/admin/v2/api/jobs/{name}/toggle",
|
||||||
|
"/admin/v2/api/jobs/{name}/runs",
|
||||||
|
"/admin/v2/api/jobs/{name}/runs/{run_id}",
|
||||||
|
"/admin/v2/api/library",
|
||||||
|
"/admin/v2/api/library/item",
|
||||||
|
"/admin/v2/api/library/item/detail",
|
||||||
|
"/admin/v2/api/library/item/image",
|
||||||
|
"/admin/v2/api/library/item/upload-image",
|
||||||
|
"/admin/v2/api/library/bulk",
|
||||||
|
"/admin/v2/api/library/releases/merge",
|
||||||
|
"/admin/debug",
|
||||||
|
"/admin/settings",
|
||||||
|
"/admin/settings/probe",
|
||||||
|
"/admin/users",
|
||||||
|
"/admin/users/new",
|
||||||
|
"/admin/users/{id}/edit",
|
||||||
|
"/admin/users/{id}/delete",
|
||||||
|
"/admin/artists",
|
||||||
|
"/admin/artists/new",
|
||||||
|
"/admin/artists/{id}/edit",
|
||||||
|
"/admin/artists/{id}/delete",
|
||||||
|
"/admin/artists/{id}/available-covers",
|
||||||
|
"/admin/artists/{id}/set-image",
|
||||||
|
"/admin/artists/{id}/upload-image",
|
||||||
|
"/admin/releases",
|
||||||
|
"/admin/releases/new",
|
||||||
|
"/admin/releases/{id}/edit",
|
||||||
|
"/admin/releases/{id}/delete",
|
||||||
|
"/admin/media-files",
|
||||||
|
"/admin/media-files/{id}/delete",
|
||||||
|
"/admin/jobs",
|
||||||
|
"/admin/jobs/metadata_backfill/run-options",
|
||||||
|
"/admin/jobs/{name}/run",
|
||||||
|
"/admin/jobs/{name}/toggle",
|
||||||
|
"/admin/jobs/{name}/cron",
|
||||||
|
"/admin/jobs/{name}/runs/{run_id}",
|
||||||
|
"/admin/jobs/{name}",
|
||||||
|
"/admin/reviews/clear",
|
||||||
|
"/admin/reviews/bulk",
|
||||||
|
"/admin/reviews",
|
||||||
|
"/admin/reviews/{id}",
|
||||||
|
"/admin/reviews/{id}/approve",
|
||||||
|
"/admin/reviews/{id}/reject",
|
||||||
|
"/admin/reviews/{id}/requeue",
|
||||||
|
"/api/player/me",
|
||||||
|
"/api/player/lastfm/status",
|
||||||
|
"/api/player/lastfm/connect",
|
||||||
|
"/api/player/lastfm/callback",
|
||||||
|
"/api/player/lastfm/disconnect",
|
||||||
|
"/api/player/lastfm/now-playing",
|
||||||
|
"/api/player/lastfm/scrobble",
|
||||||
|
"/api/player/agent-queue",
|
||||||
|
"/api/player/offline/manifest",
|
||||||
|
"/api/player/youtube",
|
||||||
|
"/api/player/youtube/preview",
|
||||||
|
"/api/player/youtube/start",
|
||||||
|
"/api/player/youtube/{id}/retry",
|
||||||
|
"/api/player/youtube/{id}/cancel",
|
||||||
|
"/api/player/youtube/{id}",
|
||||||
|
"/api/player/uploads/local",
|
||||||
|
"/api/player/uploads/local/history",
|
||||||
|
"/api/player/uploads/local/history/{id}",
|
||||||
|
"/api/player/torrents",
|
||||||
|
"/api/player/torrents/session/{id}",
|
||||||
|
"/api/player/torrents/preview",
|
||||||
|
"/api/player/uploads/tracks",
|
||||||
|
"/api/player/uploads/tracks/{track_id}",
|
||||||
|
"/api/player/uploads/bulk-tracks",
|
||||||
|
"/api/player/uploads/releases/{id}",
|
||||||
|
"/api/player/uploads/reviews/{id}",
|
||||||
|
"/api/player/uploads/reviews/{id}/approve",
|
||||||
|
"/api/player/torrents/{id}/start",
|
||||||
|
"/api/player/torrents/{id}/pause",
|
||||||
|
"/api/player/torrents/{id}/status",
|
||||||
|
"/api/player/artists",
|
||||||
|
"/api/player/artists/{id}",
|
||||||
|
"/api/player/releases/{id}",
|
||||||
|
"/api/player/radio/{kind}/{id}",
|
||||||
|
"/api/player/playlists",
|
||||||
|
"/api/player/share-playlist",
|
||||||
|
"/api/player/share-playlist/{id}",
|
||||||
|
"/api/player/playlists/{id}",
|
||||||
|
"/api/player/playlists/{id}/tracks",
|
||||||
|
"/api/player/likes",
|
||||||
|
"/api/player/likes/toggle/{track_id}",
|
||||||
|
"/api/player/likes/release/{id}",
|
||||||
|
"/api/player/follows",
|
||||||
|
"/api/player/follows/toggle/{id}",
|
||||||
|
"/api/player/stream/{track_id}",
|
||||||
|
"/api/player/cover/{media_file_id}/{variant}",
|
||||||
|
"/api/player/cover/{media_file_id}",
|
||||||
|
"/api/player/devices/heartbeat",
|
||||||
|
"/api/player/devices/poll",
|
||||||
|
"/api/player/devices/active",
|
||||||
|
"/api/player/devices/command",
|
||||||
|
"/api/player/jams/users",
|
||||||
|
"/api/player/jams",
|
||||||
|
"/api/player/jams/join",
|
||||||
|
"/api/player/jams/invite",
|
||||||
|
"/api/player/jams/leave",
|
||||||
|
"/api/player/state",
|
||||||
|
"/api/player/history",
|
||||||
|
"/api/player/search",
|
||||||
|
"/api/player/tracks-by-ids",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::known_http_route;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_http_route_matches_declared_dynamic_routes() {
|
||||||
|
assert_eq!(
|
||||||
|
known_http_route("/api/player/stream/42"),
|
||||||
|
Some("/api/player/stream/{track_id}")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
known_http_route("/admin/jobs/metadata_backfill/runs/123"),
|
||||||
|
Some("/admin/jobs/{name}/runs/{run_id}")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
known_http_route("/share/playlist/abcDEF123"),
|
||||||
|
Some("/share/playlist/{token}")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
known_http_route("/share/release/42"),
|
||||||
|
Some("/share/release/{id}")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
known_http_route("/api/player/youtube/start"),
|
||||||
|
Some("/api/player/youtube/start")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
known_http_route("/api/player/youtube/job-42/retry"),
|
||||||
|
Some("/api/player/youtube/{id}/retry")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
known_http_route("/api/player/youtube/job-42/cancel"),
|
||||||
|
Some("/api/player/youtube/{id}/cancel")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
known_http_route("/api/player/uploads/local/history/upload-42"),
|
||||||
|
Some("/api/player/uploads/local/history/{id}")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_http_route_skips_unknown_bot_paths() {
|
||||||
|
assert_eq!(known_http_route("/wp-login.php"), None);
|
||||||
|
assert_eq!(
|
||||||
|
known_http_route("/api/player/not-a-real-endpoint/123"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(known_http_route("/static/random-bot-path.js"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_http_route_uses_stable_canonical_labels() {
|
||||||
|
assert_eq!(known_http_route("/admin/"), Some("/admin"));
|
||||||
|
assert_eq!(known_http_route("/login/"), Some("/login"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_model_label(value: &str) -> String {
|
fn normalize_model_label(value: &str) -> String {
|
||||||
|
|||||||
@@ -1910,6 +1910,878 @@ pub mod db_migrations {
|
|||||||
&[Operation::custom(create_external_metadata_ids).build()];
|
&[Operation::custom(create_external_metadata_ids).build()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- M0037: Shared playlist snapshots ------------------------------------
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_playlist_share_links(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__playlist_share_link (
|
||||||
|
token VARCHAR(64) PRIMARY KEY,
|
||||||
|
creator_user_id BIGINT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
track_ids_json TEXT NOT NULL,
|
||||||
|
created_at VARCHAR(32) NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_playlist_share_link_creator
|
||||||
|
ON furumusic__playlist_share_link (creator_user_id, created_at DESC)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0037CreatePlaylistShareLinks;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0037CreatePlaylistShareLinks {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0037_create_playlist_share_links";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0036_create_external_metadata_ids",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_playlist_share_links).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_fed_device_sync(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_content_id_cache (
|
||||||
|
media_file_id BIGINT PRIMARY KEY,
|
||||||
|
sha256_hash TEXT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
|
||||||
|
ON furumusic__federation_content_id_cache (content_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_device_identity (
|
||||||
|
user_id BIGINT PRIMARY KEY,
|
||||||
|
device_id TEXT NOT NULL UNIQUE,
|
||||||
|
group_id TEXT NOT NULL,
|
||||||
|
device_name TEXT NOT NULL,
|
||||||
|
local_seq BIGINT NOT NULL DEFAULT 0,
|
||||||
|
last_hlc_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
local_seeded_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
last_sync TEXT,
|
||||||
|
last_error TEXT
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_device (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
client_version TEXT NOT NULL DEFAULT '',
|
||||||
|
protocol_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
endpoint_id TEXT NOT NULL DEFAULT '',
|
||||||
|
endpoint_ticket TEXT NOT NULL DEFAULT '',
|
||||||
|
trusted_at_ms BIGINT,
|
||||||
|
last_seen_ms BIGINT,
|
||||||
|
revoked_at_ms BIGINT,
|
||||||
|
revoked_by TEXT,
|
||||||
|
revoke_cutoff_seq BIGINT,
|
||||||
|
PRIMARY KEY (user_id, device_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_fed_device_single_user
|
||||||
|
ON furumusic__fed_device (device_id)
|
||||||
|
WHERE trusted_at_ms IS NOT NULL",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_device_invite (
|
||||||
|
invite_id TEXT PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
secret_hash TEXT NOT NULL,
|
||||||
|
expires_at_ms BIGINT NOT NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
used_at_ms BIGINT
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_pending_pairing (
|
||||||
|
request_id TEXT PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
client_version TEXT NOT NULL,
|
||||||
|
endpoint_id TEXT NOT NULL,
|
||||||
|
endpoint_ticket TEXT NOT NULL,
|
||||||
|
invite_id TEXT NOT NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
answered_at_ms BIGINT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
requester_group_id TEXT,
|
||||||
|
requester_group_active_devices BIGINT NOT NULL DEFAULT 1,
|
||||||
|
requester_group_devices_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
use_requester_group BOOLEAN NOT NULL DEFAULT false
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_fed_pending_pairing_user_status
|
||||||
|
ON furumusic__fed_pending_pairing (user_id, status, created_at_ms DESC)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_sync_ops (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
op_id TEXT NOT NULL,
|
||||||
|
origin_device_id TEXT NOT NULL,
|
||||||
|
seq BIGINT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
payload_json JSONB NOT NULL,
|
||||||
|
hlc_ms BIGINT NOT NULL,
|
||||||
|
received_at_ms BIGINT NOT NULL,
|
||||||
|
tombstone BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
PRIMARY KEY (user_id, op_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_fed_sync_ops_origin_seq
|
||||||
|
ON furumusic__fed_sync_ops (user_id, origin_device_id, seq)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_fed_sync_ops_tombstone
|
||||||
|
ON furumusic__fed_sync_ops (user_id, tombstone)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_sync_vector (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
max_seq BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, device_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_peer_ack (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
peer_device_id TEXT NOT NULL,
|
||||||
|
origin_device_id TEXT NOT NULL,
|
||||||
|
max_seq BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, peer_device_id, origin_device_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_like (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
liked BOOLEAN NOT NULL,
|
||||||
|
hlc_ms BIGINT NOT NULL,
|
||||||
|
op_id TEXT NOT NULL,
|
||||||
|
local_track_id BIGINT,
|
||||||
|
fed_json JSONB,
|
||||||
|
PRIMARY KEY (user_id, content_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_playlist (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
playlist_id TEXT NOT NULL,
|
||||||
|
local_playlist_id BIGINT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
hlc_ms BIGINT NOT NULL,
|
||||||
|
op_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, playlist_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_fed_state_playlist_local
|
||||||
|
ON furumusic__fed_state_playlist (user_id, local_playlist_id)
|
||||||
|
WHERE local_playlist_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_playlist_item (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
playlist_id TEXT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
present BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
position BIGINT NOT NULL DEFAULT 0,
|
||||||
|
hlc_ms BIGINT NOT NULL,
|
||||||
|
op_id TEXT NOT NULL,
|
||||||
|
local_track_id BIGINT,
|
||||||
|
fed_json JSONB,
|
||||||
|
PRIMARY KEY (user_id, playlist_id, content_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_fed_state_playlist_item_playlist
|
||||||
|
ON furumusic__fed_state_playlist_item
|
||||||
|
(user_id, playlist_id, present, position)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_playback_applied (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
op_id TEXT NOT NULL,
|
||||||
|
applied_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, op_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0038CreateFedDeviceSync;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0038CreateFedDeviceSync {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0038_create_fed_device_sync";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0037_create_playlist_share_links",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_fed_device_sync).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn ensure_federation_content_id_cache(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_content_id_cache (
|
||||||
|
media_file_id BIGINT PRIMARY KEY,
|
||||||
|
sha256_hash TEXT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
|
||||||
|
ON furumusic__federation_content_id_cache (content_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0039EnsureFederationContentIdCache;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0039EnsureFederationContentIdCache {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0039_ensure_federation_content_id_cache";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0038_create_fed_device_sync",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(ensure_federation_content_id_cache).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_content_addressed_music_refs(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
// A track reference is durable user-facing identity. `local_track_id`
|
||||||
|
// is availability, not identity: it may become non-NULL after a
|
||||||
|
// federated track is materialized without changing likes/playlists.
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__track_ref (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
content_id TEXT NOT NULL UNIQUE,
|
||||||
|
local_track_id BIGINT UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
release_title TEXT,
|
||||||
|
year INTEGER,
|
||||||
|
duration_seconds DOUBLE PRECISION,
|
||||||
|
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
metadata_authority TEXT NOT NULL DEFAULT 'local',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_track_ref_local_track
|
||||||
|
ON furumusic__track_ref (local_track_id)
|
||||||
|
WHERE local_track_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_track_source (
|
||||||
|
track_ref_id BIGINT NOT NULL REFERENCES furumusic__track_ref(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
owner_peer_id TEXT NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
last_seen_ms BIGINT NOT NULL,
|
||||||
|
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
PRIMARY KEY (owner_peer_id, item_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_federation_track_source_ref
|
||||||
|
ON furumusic__federation_track_source (track_ref_id, last_seen_ms DESC)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"ALTER TABLE furumusic__user_liked_track
|
||||||
|
ADD COLUMN IF NOT EXISTS track_ref_id BIGINT
|
||||||
|
REFERENCES furumusic__track_ref(id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_liked_track_ref_uniq
|
||||||
|
ON furumusic__user_liked_track (user_id, track_ref_id)
|
||||||
|
WHERE track_ref_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"ALTER TABLE furumusic__playlist_track
|
||||||
|
ADD COLUMN IF NOT EXISTS track_ref_id BIGINT
|
||||||
|
REFERENCES furumusic__track_ref(id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_playlist_track_ref
|
||||||
|
ON furumusic__playlist_track (track_ref_id)
|
||||||
|
WHERE track_ref_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// History deliberately remains local-track based. Only the web
|
||||||
|
// player's existing playback report records history and triggers
|
||||||
|
// Last.fm scrobbling.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0040CreateContentAddressedMusicRefs;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0040CreateContentAddressedMusicRefs {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0040_create_content_addressed_music_refs";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0039_ensure_federation_content_id_cache",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_content_addressed_music_refs).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_synced_listen_history(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__listen_event (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
listen_id TEXT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
local_track_id BIGINT,
|
||||||
|
origin_device_id TEXT NOT NULL,
|
||||||
|
started_at_ms BIGINT NOT NULL,
|
||||||
|
listened_ms BIGINT NOT NULL,
|
||||||
|
track_duration_ms BIGINT,
|
||||||
|
ended_reason TEXT NOT NULL,
|
||||||
|
qualified BOOLEAN NOT NULL,
|
||||||
|
metadata_json JSONB NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, listen_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_listen_event_user_time
|
||||||
|
ON furumusic__listen_event (user_id, started_at_ms DESC, listen_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_listen_event_content
|
||||||
|
ON furumusic__listen_event (user_id, content_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"INSERT INTO furumusic__listen_event
|
||||||
|
(user_id, listen_id, content_id, local_track_id,
|
||||||
|
origin_device_id, started_at_ms, listened_ms,
|
||||||
|
track_duration_ms, ended_reason, qualified,
|
||||||
|
metadata_json, created_at)
|
||||||
|
SELECT ph.user_id,
|
||||||
|
'legacy-web:' || ph.id::text,
|
||||||
|
tr.content_id,
|
||||||
|
ph.track_id,
|
||||||
|
ident.device_id,
|
||||||
|
(EXTRACT(EPOCH FROM ph.played_at::timestamptz) * 1000)::bigint,
|
||||||
|
COALESCE(ph.duration_listened, 0)::bigint * 1000,
|
||||||
|
(t.duration_seconds * 1000)::bigint,
|
||||||
|
CASE WHEN ph.completed THEN '\"finished\"' ELSE '\"unknown\"' END,
|
||||||
|
ph.completed,
|
||||||
|
jsonb_build_object(
|
||||||
|
'title', t.title::text,
|
||||||
|
'artist_names', COALESCE((
|
||||||
|
SELECT jsonb_agg(a.name::text ORDER BY ta.position)
|
||||||
|
FROM furumusic__track_artist ta
|
||||||
|
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||||
|
WHERE ta.track_id = t.id
|
||||||
|
AND ta.role <> 'featuring'
|
||||||
|
), '[]'::jsonb),
|
||||||
|
'featured_artist_names', COALESCE((
|
||||||
|
SELECT jsonb_agg(a.name::text ORDER BY ta.position)
|
||||||
|
FROM furumusic__track_artist ta
|
||||||
|
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||||
|
WHERE ta.track_id = t.id
|
||||||
|
AND ta.role = 'featuring'
|
||||||
|
), '[]'::jsonb),
|
||||||
|
'release_title', r.title::text
|
||||||
|
),
|
||||||
|
ph.played_at::text
|
||||||
|
FROM furumusic__play_history ph
|
||||||
|
JOIN furumusic__track t ON t.id = ph.track_id
|
||||||
|
JOIN furumusic__track_ref tr ON tr.local_track_id = ph.track_id
|
||||||
|
JOIN furumusic__fed_device_identity ident
|
||||||
|
ON ident.user_id = ph.user_id
|
||||||
|
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
ON CONFLICT (user_id, listen_id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"ALTER TABLE furumusic__lastfm_scrobble_outbox
|
||||||
|
ALTER COLUMN track_id DROP NOT NULL",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"ALTER TABLE furumusic__lastfm_scrobble_outbox
|
||||||
|
ADD COLUMN IF NOT EXISTS track_title TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS artist_name TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS album_title TEXT",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0041CreateSyncedListenHistory;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0041CreateSyncedListenHistory {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0041_create_synced_listen_history";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0040_create_content_addressed_music_refs",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_synced_listen_history).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn repair_legacy_listen_qualification(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"UPDATE furumusic__listen_event le
|
||||||
|
SET qualified = (
|
||||||
|
ph.completed
|
||||||
|
OR (
|
||||||
|
COALESCE(ph.duration_listened, 0) >= 5
|
||||||
|
AND COALESCE(t.duration_seconds, 0) > 0
|
||||||
|
AND COALESCE(ph.duration_listened, 0) >= LEAST(
|
||||||
|
COALESCE(t.duration_seconds, 0) / 2.0,
|
||||||
|
240.0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM furumusic__play_history ph
|
||||||
|
JOIN furumusic__track t ON t.id = ph.track_id
|
||||||
|
WHERE le.user_id = ph.user_id
|
||||||
|
AND le.listen_id = 'legacy-web:' || ph.id::text",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0042RepairLegacyListenQualification;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0042RepairLegacyListenQualification {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0042_repair_legacy_listen_qualification";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0041_create_synced_listen_history",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(repair_legacy_listen_qualification).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_similarity_embeddings(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__similarity_profile (
|
||||||
|
profile_id TEXT PRIMARY KEY,
|
||||||
|
model_id TEXT NOT NULL,
|
||||||
|
model_version TEXT NOT NULL,
|
||||||
|
model_sha256 TEXT NOT NULL,
|
||||||
|
preprocessing TEXT NOT NULL,
|
||||||
|
dimensions INTEGER NOT NULL,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_similarity_profile_active
|
||||||
|
ON furumusic__similarity_profile (active)
|
||||||
|
WHERE active = TRUE",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__track_embedding (
|
||||||
|
track_id BIGINT NOT NULL REFERENCES furumusic__track(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
profile_id TEXT NOT NULL REFERENCES furumusic__similarity_profile(profile_id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
dimensions INTEGER NOT NULL,
|
||||||
|
vector BYTEA NOT NULL,
|
||||||
|
source_sha256 TEXT NOT NULL,
|
||||||
|
source_content_id TEXT,
|
||||||
|
computed_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (track_id, profile_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_track_embedding_profile
|
||||||
|
ON furumusic__track_embedding (profile_id, track_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0043CreateSimilarityEmbeddings;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0043CreateSimilarityEmbeddings {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0043_create_similarity_embeddings";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0042_repair_legacy_listen_qualification",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_similarity_embeddings).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn add_similarity_routing_signature(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"ALTER TABLE furumusic__track_embedding
|
||||||
|
ADD COLUMN IF NOT EXISTS routing_signature BYTEA",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0044AddSimilarityRoutingSignature;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0044AddSimilarityRoutingSignature {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0044_add_similarity_routing_signature";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0043_create_similarity_embeddings",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(add_similarity_routing_signature).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- M0045: persistent YouTube download jobs ----------------------------
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_youtube_downloads(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__youtube_download (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
source_url TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
source_kind VARCHAR(32) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
total_items INTEGER NOT NULL DEFAULT 0,
|
||||||
|
completed_items INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_items INTEGER NOT NULL DEFAULT 0,
|
||||||
|
review_items INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error TEXT,
|
||||||
|
created_at VARCHAR(32) NOT NULL,
|
||||||
|
updated_at VARCHAR(32) NOT NULL,
|
||||||
|
completed_at VARCHAR(32)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__youtube_download_item (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
job_id VARCHAR(36) NOT NULL REFERENCES furumusic__youtube_download(id) ON DELETE CASCADE,
|
||||||
|
source_id VARCHAR(128) NOT NULL,
|
||||||
|
source_url TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
playlist_index INTEGER NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
progress_percent DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
downloaded_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
total_bytes BIGINT,
|
||||||
|
speed_bytes_per_sec BIGINT,
|
||||||
|
eta_seconds BIGINT,
|
||||||
|
chapter_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
audio_file_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
inbox_path TEXT,
|
||||||
|
error TEXT,
|
||||||
|
created_at VARCHAR(32) NOT NULL,
|
||||||
|
updated_at VARCHAR(32) NOT NULL,
|
||||||
|
completed_at VARCHAR(32),
|
||||||
|
UNIQUE(job_id, source_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_youtube_download_user_updated
|
||||||
|
ON furumusic__youtube_download (user_id, updated_at DESC)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_youtube_download_user_status
|
||||||
|
ON furumusic__youtube_download (user_id, status)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_youtube_download_item_job_status
|
||||||
|
ON furumusic__youtube_download_item (job_id, status, playlist_index)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_youtube_download_item_source
|
||||||
|
ON furumusic__youtube_download_item (source_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0045CreateYouTubeDownloads;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0045CreateYouTubeDownloads {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0045_create_youtube_downloads";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0044_add_similarity_routing_signature",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_youtube_downloads).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- M0046: persistent direct-file upload history -----------------------
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_local_upload_history(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__local_upload (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
inbox_path TEXT NOT NULL,
|
||||||
|
error TEXT,
|
||||||
|
created_at VARCHAR(32) NOT NULL,
|
||||||
|
updated_at VARCHAR(32) NOT NULL,
|
||||||
|
completed_at VARCHAR(32)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_local_upload_user_updated
|
||||||
|
ON furumusic__local_upload (user_id, updated_at DESC)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_local_upload_user_status
|
||||||
|
ON furumusic__local_upload (user_id, status)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0046CreateLocalUploadHistory;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0046CreateLocalUploadHistory {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0046_create_local_upload_history";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0045_create_youtube_downloads",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_local_upload_history).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- M0047: durable YouTube item -> imported media links ---------------
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_youtube_import_media_links(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__youtube_import_media (
|
||||||
|
item_id VARCHAR(36) NOT NULL
|
||||||
|
REFERENCES furumusic__youtube_download_item(id) ON DELETE CASCADE,
|
||||||
|
media_file_id BIGINT NOT NULL
|
||||||
|
REFERENCES furumusic__media_file(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (item_id, media_file_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_youtube_import_media_file
|
||||||
|
ON furumusic__youtube_import_media (media_file_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// Backfill links for existing imports through the inbox review hash.
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"INSERT INTO furumusic__youtube_import_media (item_id, media_file_id)
|
||||||
|
SELECT DISTINCT item.id, media.id
|
||||||
|
FROM furumusic__youtube_download_item item
|
||||||
|
JOIN furumusic__pending_review review
|
||||||
|
ON item.inbox_path IS NOT NULL
|
||||||
|
AND (review.input_path = item.inbox_path
|
||||||
|
OR left(review.input_path, length(item.inbox_path) + 1)
|
||||||
|
= item.inbox_path || '/')
|
||||||
|
JOIN furumusic__media_file media
|
||||||
|
ON media.sha256_hash::text = substring(
|
||||||
|
review.context_json
|
||||||
|
from '\"sha256\"[[:space:]]*:[[:space:]]*\"([0-9a-fA-F]{64})\"'
|
||||||
|
)
|
||||||
|
JOIN furumusic__track track ON track.audio_file_id = media.id
|
||||||
|
WHERE review.context_json IS NOT NULL
|
||||||
|
ON CONFLICT (item_id, media_file_id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0047CreateYouTubeImportMediaLinks;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0047CreateYouTubeImportMediaLinks {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0047_create_youtube_import_media_links";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||||
|
migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0046_create_local_upload_history",
|
||||||
|
),
|
||||||
|
migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0027_create_processing_stats",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_youtube_import_media_links).build()];
|
||||||
|
}
|
||||||
|
|
||||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||||
&M0006CreateMediaFile,
|
&M0006CreateMediaFile,
|
||||||
&M0007CreateArtist,
|
&M0007CreateArtist,
|
||||||
@@ -1937,5 +2809,16 @@ pub mod db_migrations {
|
|||||||
&M0034CreateArtworkLookupState,
|
&M0034CreateArtworkLookupState,
|
||||||
&M0035CreateEntityGenreTags,
|
&M0035CreateEntityGenreTags,
|
||||||
&M0036CreateExternalMetadataIds,
|
&M0036CreateExternalMetadataIds,
|
||||||
|
&M0037CreatePlaylistShareLinks,
|
||||||
|
&M0038CreateFedDeviceSync,
|
||||||
|
&M0039EnsureFederationContentIdCache,
|
||||||
|
&M0040CreateContentAddressedMusicRefs,
|
||||||
|
&M0041CreateSyncedListenHistory,
|
||||||
|
&M0042RepairLegacyListenQualification,
|
||||||
|
&M0043CreateSimilarityEmbeddings,
|
||||||
|
&M0044AddSimilarityRoutingSignature,
|
||||||
|
&M0045CreateYouTubeDownloads,
|
||||||
|
&M0046CreateLocalUploadHistory,
|
||||||
|
&M0047CreateYouTubeImportMediaLinks,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+677
-4
@@ -4,6 +4,7 @@ use std::sync::LazyLock;
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use cot::db::Database;
|
use cot::db::Database;
|
||||||
|
use cot::request::extractors::UrlQuery;
|
||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
use openidconnect::core::{CoreClient, CoreProviderMetadata};
|
use openidconnect::core::{CoreClient, CoreProviderMetadata};
|
||||||
use openidconnect::{
|
use openidconnect::{
|
||||||
@@ -54,6 +55,13 @@ const SESSION_NONCE: &str = "oidc_nonce";
|
|||||||
const SESSION_PKCE_VERIFIER: &str = "oidc_pkce_verifier";
|
const SESSION_PKCE_VERIFIER: &str = "oidc_pkce_verifier";
|
||||||
const SESSION_REDIRECT_URI: &str = "oidc_redirect_uri";
|
const SESSION_REDIRECT_URI: &str = "oidc_redirect_uri";
|
||||||
|
|
||||||
|
const SESSION_MOBILE_CSRF_STATE: &str = "mobile_oidc_csrf_state";
|
||||||
|
const SESSION_MOBILE_NONCE: &str = "mobile_oidc_nonce";
|
||||||
|
const SESSION_MOBILE_PKCE_VERIFIER: &str = "mobile_oidc_pkce_verifier";
|
||||||
|
const SESSION_MOBILE_PROVIDER_REDIRECT_URI: &str = "mobile_oidc_provider_redirect_uri";
|
||||||
|
const SESSION_MOBILE_APP_REDIRECT_URI: &str = "mobile_oidc_app_redirect_uri";
|
||||||
|
const DEFAULT_MOBILE_REDIRECT_URI: &str = "furumi://auth/callback";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Provider cache
|
// Provider cache
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -247,13 +255,23 @@ pub struct OidcCallbackQuery {
|
|||||||
state: String,
|
state: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct MobileOidcStartQuery {
|
||||||
|
redirect_uri: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct MobileOidcCallbackQuery {
|
||||||
|
code: Option<String>,
|
||||||
|
state: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn oidc_callback_handler(
|
pub async fn oidc_callback_handler(
|
||||||
i18n: I18n,
|
i18n: I18n,
|
||||||
db: Database,
|
db: Database,
|
||||||
session: Session,
|
session: Session,
|
||||||
cot::request::extractors::UrlQuery(query): cot::request::extractors::UrlQuery<
|
UrlQuery(query): UrlQuery<OidcCallbackQuery>,
|
||||||
OidcCallbackQuery,
|
|
||||||
>,
|
|
||||||
) -> cot::Result<cot::response::Response> {
|
) -> cot::Result<cot::response::Response> {
|
||||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
|
||||||
@@ -430,8 +448,13 @@ pub async fn oidc_callback_handler(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let redirect_to = auth::get_post_login_redirect(&session)
|
||||||
|
.await?
|
||||||
|
.unwrap_or_else(|| "/".to_string());
|
||||||
|
|
||||||
// Log the user in.
|
// Log the user in.
|
||||||
auth::login(&session, user.id_val()).await?;
|
auth::login(&session, user.id_val()).await?;
|
||||||
|
auth::clear_post_login_redirect(&session).await?;
|
||||||
crate::metrics::record_auth_attempt("oidc", "success", "ok");
|
crate::metrics::record_auth_attempt("oidc", "success", "ok");
|
||||||
crate::metrics::record_session_created("oidc");
|
crate::metrics::record_session_created("oidc");
|
||||||
|
|
||||||
@@ -453,7 +476,297 @@ pub async fn oidc_callback_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
Ok(auth::redirect("/"))
|
Ok(auth::redirect(&redirect_to))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mobile OIDC flow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn oidc_mobile_start_handler(
|
||||||
|
origin: RequestOrigin,
|
||||||
|
db: Database,
|
||||||
|
session: Session,
|
||||||
|
UrlQuery(query): UrlQuery<MobileOidcStartQuery>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(app_redirect_uri) = safe_mobile_redirect_uri(query.redirect_uri.as_deref()) else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "bad_redirect_uri");
|
||||||
|
return Ok(text_response(
|
||||||
|
cot::http::StatusCode::BAD_REQUEST,
|
||||||
|
"invalid mobile redirect_uri",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
|
||||||
|
if !config.auth_sso_enabled
|
||||||
|
|| config.oidc_issuer.is_empty()
|
||||||
|
|| config.oidc_client_id.is_empty()
|
||||||
|
|| config.oidc_client_secret.is_empty()
|
||||||
|
{
|
||||||
|
tracing::warn!("Mobile OIDC start requested but SSO is not configured");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_configured");
|
||||||
|
return Ok(mobile_redirect_error(
|
||||||
|
&app_redirect_uri,
|
||||||
|
"sso_not_configured",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let http = oidc_http_client();
|
||||||
|
let client = match get_or_refresh_provider(&config, &http).await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC provider error: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_error");
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "provider_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider_redirect_uri = format!("{}/auth/mobile/oidc/callback", origin.0);
|
||||||
|
let redirect_url = RedirectUrl::new(provider_redirect_uri.clone())
|
||||||
|
.map_err(|e| cot::Error::internal(format!("bad mobile redirect URI: {e}")))?;
|
||||||
|
let client = client.set_redirect_uri(redirect_url);
|
||||||
|
|
||||||
|
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||||
|
let (auth_url, csrf_state, nonce) = client
|
||||||
|
.authorize_url(
|
||||||
|
openidconnect::AuthenticationFlow::<openidconnect::core::CoreResponseType>::AuthorizationCode,
|
||||||
|
CsrfToken::new_random,
|
||||||
|
Nonce::new_random,
|
||||||
|
)
|
||||||
|
.add_scope(Scope::new("email".to_string()))
|
||||||
|
.add_scope(Scope::new("profile".to_string()))
|
||||||
|
.set_pkce_challenge(pkce_challenge)
|
||||||
|
.url();
|
||||||
|
|
||||||
|
session
|
||||||
|
.insert(SESSION_MOBILE_CSRF_STATE, csrf_state.secret().clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
session
|
||||||
|
.insert(SESSION_MOBILE_NONCE, nonce.secret().clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
session
|
||||||
|
.insert(SESSION_MOBILE_PKCE_VERIFIER, pkce_verifier.secret().clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
session
|
||||||
|
.insert(
|
||||||
|
SESSION_MOBILE_PROVIDER_REDIRECT_URI,
|
||||||
|
provider_redirect_uri.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
session
|
||||||
|
.insert(SESSION_MOBILE_APP_REDIRECT_URI, app_redirect_uri)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
auth_url = %auth_url,
|
||||||
|
provider_redirect_uri = %provider_redirect_uri,
|
||||||
|
"Mobile OIDC start: redirecting to provider",
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(auth::redirect(auth_url.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn oidc_mobile_callback_handler(
|
||||||
|
db: Database,
|
||||||
|
session: Session,
|
||||||
|
UrlQuery(query): UrlQuery<MobileOidcCallbackQuery>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let app_redirect_uri = mobile_app_redirect_uri_from_session(&session).await?;
|
||||||
|
|
||||||
|
if query.error.is_some() {
|
||||||
|
tracing::warn!("Mobile OIDC callback returned provider error");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_denied");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "provider_denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(code) = query.code else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_code");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_code"));
|
||||||
|
};
|
||||||
|
let Some(state) = query.state else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_state");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_state"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let saved_csrf: Option<String> = session
|
||||||
|
.get(SESSION_MOBILE_CSRF_STATE)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let saved_nonce: Option<String> = session
|
||||||
|
.get(SESSION_MOBILE_NONCE)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let saved_pkce: Option<String> = session
|
||||||
|
.get(SESSION_MOBILE_PKCE_VERIFIER)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let provider_redirect_uri: Option<String> = session
|
||||||
|
.get(SESSION_MOBILE_PROVIDER_REDIRECT_URI)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let Some(saved_csrf) = saved_csrf else {
|
||||||
|
tracing::warn!("Mobile OIDC callback: no CSRF state in session");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_state");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_state"));
|
||||||
|
};
|
||||||
|
if state != saved_csrf {
|
||||||
|
tracing::warn!("Mobile OIDC callback: CSRF state mismatch");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "csrf");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "csrf"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(nonce_str) = saved_nonce else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_nonce");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_nonce"));
|
||||||
|
};
|
||||||
|
let Some(pkce_str) = saved_pkce else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_pkce");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_pkce"));
|
||||||
|
};
|
||||||
|
let Some(provider_redirect_uri) = provider_redirect_uri else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_redirect_uri");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(
|
||||||
|
&app_redirect_uri,
|
||||||
|
"missing_redirect_uri",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
if !config.auth_sso_enabled
|
||||||
|
|| config.oidc_issuer.is_empty()
|
||||||
|
|| config.oidc_client_id.is_empty()
|
||||||
|
|| config.oidc_client_secret.is_empty()
|
||||||
|
{
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_configured");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(
|
||||||
|
&app_redirect_uri,
|
||||||
|
"sso_not_configured",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let http = oidc_http_client();
|
||||||
|
let client = match get_or_refresh_provider(&config, &http).await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC provider error during callback: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_error");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "provider_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let redirect_url = RedirectUrl::new(provider_redirect_uri)
|
||||||
|
.map_err(|e| cot::Error::internal(format!("bad mobile redirect URI from session: {e}")))?;
|
||||||
|
let client = client.set_redirect_uri(redirect_url);
|
||||||
|
|
||||||
|
let token_request = match client.exchange_code(AuthorizationCode::new(code)) {
|
||||||
|
Ok(req) => req,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC token endpoint not configured: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "token_config");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let token_response = token_request
|
||||||
|
.set_pkce_verifier(PkceCodeVerifier::new(pkce_str))
|
||||||
|
.request_async(&http)
|
||||||
|
.await;
|
||||||
|
let token_response = match token_response {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC token exchange failed: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "token_exchange");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
use openidconnect::TokenResponse;
|
||||||
|
let id_token = match token_response.id_token() {
|
||||||
|
Some(t) => t,
|
||||||
|
None => {
|
||||||
|
tracing::error!("Mobile OIDC response missing ID token");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_id_token");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let nonce = Nonce::new(nonce_str);
|
||||||
|
let claims = match id_token.claims(&client.id_token_verifier(), &nonce) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC ID token verification failed: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "id_token_verify");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let sub = claims.subject().to_string();
|
||||||
|
let issuer = claims.issuer().to_string();
|
||||||
|
let email = claims.email().map(|e| e.to_string());
|
||||||
|
let name = claims
|
||||||
|
.name()
|
||||||
|
.and_then(|n| n.get(None))
|
||||||
|
.map(|n| n.to_string());
|
||||||
|
let groups = extract_groups_from_jwt(&id_token.to_string());
|
||||||
|
|
||||||
|
if !is_allowed_by_groups(&groups, &config.oidc_user_groups, &config.oidc_admin_groups) {
|
||||||
|
tracing::warn!(
|
||||||
|
"Mobile OIDC login denied by group allowlist: sub={sub}, groups={groups:?}, user_groups={:?}, admin_groups={:?}",
|
||||||
|
config.oidc_user_groups,
|
||||||
|
config.oidc_admin_groups,
|
||||||
|
);
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_in_group");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "access_denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = match provision_user(
|
||||||
|
&db,
|
||||||
|
&issuer,
|
||||||
|
&sub,
|
||||||
|
email.as_deref(),
|
||||||
|
name.as_deref(),
|
||||||
|
&groups,
|
||||||
|
&config.oidc_admin_groups,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC user provisioning failed: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provisioning");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let exchange_code = auth::create_mobile_exchange_code(&db, user.id_val())
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "success", "ok");
|
||||||
|
crate::metrics::record_session_created("mobile_oidc");
|
||||||
|
Ok(mobile_redirect_success(&app_redirect_uri, &exchange_code))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -611,6 +924,307 @@ fn redirect_login_with_error(message: &str) -> cot::Result<cot::response::Respon
|
|||||||
Ok(auth::redirect(&format!("/login?error={encoded}")))
|
Ok(auth::redirect(&format!("/login?error={encoded}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn text_response(status: cot::http::StatusCode, message: &str) -> cot::response::Response {
|
||||||
|
cot::http::Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.body(cot::Body::fixed(message.to_owned()))
|
||||||
|
.expect("valid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mobile_app_redirect_uri_from_session(session: &Session) -> cot::Result<String> {
|
||||||
|
let saved: Option<String> = session
|
||||||
|
.get(SESSION_MOBILE_APP_REDIRECT_URI)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Ok(safe_mobile_redirect_uri(saved.as_deref())
|
||||||
|
.unwrap_or_else(|| DEFAULT_MOBILE_REDIRECT_URI.to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear_mobile_oidc_session(session: &Session) -> cot::Result<()> {
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_MOBILE_CSRF_STATE)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_MOBILE_NONCE)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_MOBILE_PKCE_VERIFIER)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_MOBILE_PROVIDER_REDIRECT_URI)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_MOBILE_APP_REDIRECT_URI)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_mobile_redirect_uri(raw: Option<&str>) -> Option<String> {
|
||||||
|
let value = raw
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(DEFAULT_MOBILE_REDIRECT_URI);
|
||||||
|
if value.len() > 2048 || value.bytes().any(|b| matches!(b, b'\r' | b'\n')) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lower = value.to_ascii_lowercase();
|
||||||
|
if lower.starts_with("furumi://") || lower.starts_with("furumusic://") {
|
||||||
|
return Some(value.to_owned());
|
||||||
|
}
|
||||||
|
if is_loopback_http_redirect(&lower) {
|
||||||
|
return Some(value.to_owned());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RFC 8252 §7.3: native apps without a custom URL scheme (the CLI client)
|
||||||
|
/// receive the callback on a loopback listener with an ephemeral port.
|
||||||
|
fn is_loopback_http_redirect(lower: &str) -> bool {
|
||||||
|
let Some(rest) = lower.strip_prefix("http://") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let host_port = rest.split(['/', '?', '#']).next().unwrap_or("");
|
||||||
|
let Some((host, port)) = host_port.rsplit_once(':') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
matches!(host, "127.0.0.1" | "localhost" | "[::1]")
|
||||||
|
&& !port.is_empty()
|
||||||
|
&& port.len() <= 5
|
||||||
|
&& port.bytes().all(|b| b.is_ascii_digit())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mobile_redirect_success(app_redirect_uri: &str, code: &str) -> cot::response::Response {
|
||||||
|
let deep_link = append_query_param(app_redirect_uri, "code", code);
|
||||||
|
if is_loopback_http_redirect(&app_redirect_uri.to_ascii_lowercase()) {
|
||||||
|
return auth::redirect(&deep_link);
|
||||||
|
}
|
||||||
|
mobile_deep_link_page(
|
||||||
|
"success",
|
||||||
|
"Sign-in complete",
|
||||||
|
"Furumi should open automatically. If it doesn't, use the button or copy the code below.",
|
||||||
|
None,
|
||||||
|
Some(code),
|
||||||
|
&deep_link,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response {
|
||||||
|
let deep_link = append_query_param(app_redirect_uri, "error", error);
|
||||||
|
if is_loopback_http_redirect(&app_redirect_uri.to_ascii_lowercase()) {
|
||||||
|
return auth::redirect(&deep_link);
|
||||||
|
}
|
||||||
|
mobile_deep_link_page(
|
||||||
|
"error",
|
||||||
|
"Sign-in failed",
|
||||||
|
"Furumi should open automatically and show the sign-in error.",
|
||||||
|
Some(error),
|
||||||
|
None,
|
||||||
|
&deep_link,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mobile_deep_link_page(
|
||||||
|
state: &str,
|
||||||
|
title: &str,
|
||||||
|
message: &str,
|
||||||
|
detail: Option<&str>,
|
||||||
|
code: Option<&str>,
|
||||||
|
deep_link: &str,
|
||||||
|
) -> cot::response::Response {
|
||||||
|
let state_class = html_escape(state);
|
||||||
|
let title_html = html_escape(title);
|
||||||
|
let message_html = html_escape(message);
|
||||||
|
let detail_html = detail
|
||||||
|
.map(|value| format!(r#"<p class="detail">Reason: {}</p>"#, html_escape(value)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let code_html = code
|
||||||
|
.map(|value| {
|
||||||
|
format!(
|
||||||
|
r#"<p class="hint">Signing in from a terminal? Paste this code there:</p>
|
||||||
|
<input class="code" readonly value="{}" onclick="this.select()">"#,
|
||||||
|
html_escape(value)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let deep_link_html = html_escape(deep_link);
|
||||||
|
let deep_link_js =
|
||||||
|
serde_json::to_string(deep_link).expect("serializing URL string cannot fail");
|
||||||
|
|
||||||
|
let html = format!(
|
||||||
|
r#"<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{title_html}</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
color-scheme: light dark;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: #101114;
|
||||||
|
color: #f5f2ea;
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
main {{
|
||||||
|
width: min(420px, 100%);
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
.mark {{
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
margin: 0 auto 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #2f7d52;
|
||||||
|
color: white;
|
||||||
|
}}
|
||||||
|
.mark.error {{
|
||||||
|
background: #9d3d42;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}}
|
||||||
|
p {{
|
||||||
|
margin: 0;
|
||||||
|
color: #c9c2b7;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}}
|
||||||
|
.detail {{
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #f1b3b7;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}}
|
||||||
|
a {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e8d8a8;
|
||||||
|
color: #17150f;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}}
|
||||||
|
.hint {{
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #89847c;
|
||||||
|
}}
|
||||||
|
.code {{
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #3a3c42;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1a1c20;
|
||||||
|
color: #e8d8a8;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="mark {state_class}" aria-hidden="true">{mark}</div>
|
||||||
|
<h1>{title_html}</h1>
|
||||||
|
<p>{message_html}</p>
|
||||||
|
{detail_html}
|
||||||
|
<a href="{deep_link_html}">Open Furumi</a>
|
||||||
|
<p class="hint">If nothing happens, use the button above.</p>
|
||||||
|
{code_html}
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const deepLink = {deep_link_js};
|
||||||
|
window.setTimeout(() => {{
|
||||||
|
window.location.href = deepLink;
|
||||||
|
}}, 100);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"#,
|
||||||
|
mark = if state == "error" { "!" } else { "OK" }
|
||||||
|
);
|
||||||
|
|
||||||
|
cot::http::Response::builder()
|
||||||
|
.status(cot::http::StatusCode::OK)
|
||||||
|
.header(cot::http::header::CONTENT_TYPE, "text/html; charset=utf-8")
|
||||||
|
.header(cot::http::header::CACHE_CONTROL, "no-store")
|
||||||
|
.body(cot::Body::fixed(html))
|
||||||
|
.expect("valid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_query_param(uri: &str, key: &str, value: &str) -> String {
|
||||||
|
let (base, fragment) = uri.split_once('#').unwrap_or((uri, ""));
|
||||||
|
let separator = if base.contains('?') { '&' } else { '?' };
|
||||||
|
let mut out = format!("{base}{separator}{key}={}", urlencoded(value));
|
||||||
|
if !fragment.is_empty() {
|
||||||
|
out.push('#');
|
||||||
|
out.push_str(fragment);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn html_escape(value: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(value.len());
|
||||||
|
for ch in value.chars() {
|
||||||
|
match ch {
|
||||||
|
'&' => out.push_str("&"),
|
||||||
|
'<' => out.push_str("<"),
|
||||||
|
'>' => out.push_str(">"),
|
||||||
|
'"' => out.push_str("""),
|
||||||
|
'\'' => out.push_str("'"),
|
||||||
|
_ => out.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_groups_from_jwt(token: &str) -> Vec<String> {
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
let Some(payload_b64) = token.split('.').nth(1) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Ok(payload_bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode(payload_b64)
|
||||||
|
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64))
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Ok(value) = serde_json::from_slice::<serde_json::Value>(&payload_bytes) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Some(arr) = value.get("groups").and_then(|value| value.as_array()) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|value| value.as_str().map(String::from))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Minimal percent-encoding for query parameter values.
|
/// Minimal percent-encoding for query parameter values.
|
||||||
fn urlencoded(s: &str) -> String {
|
fn urlencoded(s: &str) -> String {
|
||||||
let mut out = String::with_capacity(s.len() * 2);
|
let mut out = String::with_capacity(s.len() * 2);
|
||||||
@@ -627,3 +1241,62 @@ fn urlencoded(s: &str) -> String {
|
|||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mobile_oidc_append_query_param_preserves_fragment() {
|
||||||
|
assert_eq!(
|
||||||
|
append_query_param("furumi://auth/callback#done", "code", "a b"),
|
||||||
|
"furumi://auth/callback?code=a%20b#done"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
append_query_param("furumi://auth/callback?desktop=1", "error", "oidc_error"),
|
||||||
|
"furumi://auth/callback?desktop=1&error=oidc_error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mobile_oidc_html_escape_escapes_page_values() {
|
||||||
|
assert_eq!(
|
||||||
|
html_escape(r#"<tag attr="x&y">'text'</tag>"#),
|
||||||
|
"<tag attr="x&y">'text'</tag>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mobile_oidc_redirect_uri_allows_only_furumi_schemes() {
|
||||||
|
assert_eq!(
|
||||||
|
safe_mobile_redirect_uri(Some("furumi://auth/callback")).as_deref(),
|
||||||
|
Some("furumi://auth/callback")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
safe_mobile_redirect_uri(Some("furumusic://auth/callback")).as_deref(),
|
||||||
|
Some("furumusic://auth/callback")
|
||||||
|
);
|
||||||
|
assert!(safe_mobile_redirect_uri(Some("https://example.com/callback")).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mobile_oidc_redirect_uri_allows_loopback_http() {
|
||||||
|
assert_eq!(
|
||||||
|
safe_mobile_redirect_uri(Some("http://127.0.0.1:8753/callback")).as_deref(),
|
||||||
|
Some("http://127.0.0.1:8753/callback")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
safe_mobile_redirect_uri(Some("http://localhost:1234/callback")).as_deref(),
|
||||||
|
Some("http://localhost:1234/callback")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
safe_mobile_redirect_uri(Some("http://[::1]:1234/callback")).as_deref(),
|
||||||
|
Some("http://[::1]:1234/callback")
|
||||||
|
);
|
||||||
|
// Non-loopback hosts, missing ports and https stay rejected.
|
||||||
|
assert!(safe_mobile_redirect_uri(Some("http://127.0.0.1/callback")).is_none());
|
||||||
|
assert!(safe_mobile_redirect_uri(Some("http://evil.com:80/callback")).is_none());
|
||||||
|
assert!(safe_mobile_redirect_uri(Some("https://127.0.0.1:80/callback")).is_none());
|
||||||
|
assert!(safe_mobile_redirect_uri(Some("http://127.0.0.1:notaport/x")).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+125
-3
@@ -51,6 +51,7 @@ pub(super) struct ArtistRef {
|
|||||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
pub(super) struct TrackItem {
|
pub(super) struct TrackItem {
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
|
pub(super) content_id: Option<String>,
|
||||||
pub(super) title: String,
|
pub(super) title: String,
|
||||||
pub(super) track_number: Option<i32>,
|
pub(super) track_number: Option<i32>,
|
||||||
pub(super) disc_number: Option<i32>,
|
pub(super) disc_number: Option<i32>,
|
||||||
@@ -74,6 +75,15 @@ pub(super) struct TrackItem {
|
|||||||
pub(super) lastfm_updated_at: Option<String>,
|
pub(super) lastfm_updated_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct PlaylistTrackItem {
|
||||||
|
pub(super) playlist_track_id: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(super) sort_key: Option<i64>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub(super) track: TrackItem,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct ArtistAppearanceTrack {
|
pub(super) struct ArtistAppearanceTrack {
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
@@ -265,6 +275,24 @@ pub(super) struct PlayerDevicesResponse {
|
|||||||
pub(super) playback_state: Option<PlayerDevicePlaybackStateDto>,
|
pub(super) playback_state: Option<PlayerDevicePlaybackStateDto>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FedDeviceConnectRequest {
|
||||||
|
pub(super) invite: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FedDevicePairingAnswerRequest {
|
||||||
|
pub(super) request_id: String,
|
||||||
|
pub(super) accept: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) use_requester_group: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FedDeviceRevokeRequest {
|
||||||
|
pub(super) device_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct PlayerDevicePollResponse {
|
pub(super) struct PlayerDevicePollResponse {
|
||||||
pub(super) device_id: String,
|
pub(super) device_id: String,
|
||||||
@@ -286,6 +314,19 @@ pub(super) struct PlaylistDetail {
|
|||||||
pub(super) is_public: bool,
|
pub(super) is_public: bool,
|
||||||
pub(super) is_saved: bool,
|
pub(super) is_saved: bool,
|
||||||
pub(super) kind: String,
|
pub(super) kind: String,
|
||||||
|
pub(super) tracks: Vec<PlaylistTrackItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct ShareLinkResponse {
|
||||||
|
pub(super) token: String,
|
||||||
|
pub(super) url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct PlaylistShareDetail {
|
||||||
|
pub(super) token: String,
|
||||||
|
pub(super) title: String,
|
||||||
pub(super) tracks: Vec<TrackItem>,
|
pub(super) tracks: Vec<TrackItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +353,45 @@ pub(super) struct UserProfile {
|
|||||||
pub(super) stats: UserStats,
|
pub(super) stats: UserStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct OfflineManifestResponse {
|
||||||
|
pub(super) generated_at: String,
|
||||||
|
pub(super) tracks: Vec<OfflineTrackManifestItem>,
|
||||||
|
pub(super) playlists: Vec<OfflinePlaylistManifestItem>,
|
||||||
|
pub(super) liked_track_ids: Vec<i64>,
|
||||||
|
pub(super) followed_artist_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct OfflineTrackManifestItem {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) updated_at: String,
|
||||||
|
pub(super) stream_url: String,
|
||||||
|
pub(super) audio_file_id: i64,
|
||||||
|
pub(super) audio_hash: String,
|
||||||
|
pub(super) audio_size_bytes: i64,
|
||||||
|
pub(super) audio_mime_type: String,
|
||||||
|
pub(super) audio_updated_at: String,
|
||||||
|
pub(super) cover_file_id: Option<i64>,
|
||||||
|
pub(super) cover_url: Option<String>,
|
||||||
|
pub(super) cover_hash: Option<String>,
|
||||||
|
pub(super) cover_updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct OfflinePlaylistManifestItem {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) description: Option<String>,
|
||||||
|
pub(super) updated_at: String,
|
||||||
|
pub(super) is_own: bool,
|
||||||
|
pub(super) owner_name: Option<String>,
|
||||||
|
pub(super) is_public: bool,
|
||||||
|
pub(super) is_saved: bool,
|
||||||
|
pub(super) kind: String,
|
||||||
|
pub(super) track_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct LastfmStatus {
|
pub(super) struct LastfmStatus {
|
||||||
pub(super) configured: bool,
|
pub(super) configured: bool,
|
||||||
@@ -461,14 +541,16 @@ pub(super) struct UserUploadReviewUpdateRequest {
|
|||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct PlayHistoryItem {
|
pub(super) struct PlayHistoryItem {
|
||||||
pub(super) id: i64,
|
pub(super) id: String,
|
||||||
pub(super) track_id: i64,
|
pub(super) track_id: Option<i64>,
|
||||||
pub(super) track_title: String,
|
pub(super) track_title: String,
|
||||||
pub(super) release_title: Option<String>,
|
pub(super) release_title: Option<String>,
|
||||||
pub(super) track: TrackItem,
|
pub(super) track: serde_json::Value,
|
||||||
pub(super) played_at: String,
|
pub(super) played_at: String,
|
||||||
pub(super) duration_listened: Option<i32>,
|
pub(super) duration_listened: Option<i32>,
|
||||||
pub(super) completed: bool,
|
pub(super) completed: bool,
|
||||||
|
pub(super) device_id: String,
|
||||||
|
pub(super) device_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
@@ -484,6 +566,46 @@ pub(super) struct LikeStatus {
|
|||||||
pub(super) liked: bool,
|
pub(super) liked: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct ContentTrackMutation {
|
||||||
|
pub(super) content_id: String,
|
||||||
|
pub(super) liked: Option<bool>,
|
||||||
|
pub(super) playlist_id: Option<i64>,
|
||||||
|
pub(super) position: Option<i64>,
|
||||||
|
pub(super) federation: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct PrepareFederatedTrackRequest {
|
||||||
|
pub(super) content_id: String,
|
||||||
|
pub(super) owner: Option<String>,
|
||||||
|
pub(super) item_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FederationArtworkQuery {
|
||||||
|
pub(super) owner: String,
|
||||||
|
pub(super) item_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FederationArtistQuery {
|
||||||
|
pub(super) name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FederationCatalogArtworkQuery {
|
||||||
|
pub(super) owner: String,
|
||||||
|
pub(super) artist: String,
|
||||||
|
pub(super) release: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FederationArtworkDiscoveryQuery {
|
||||||
|
pub(super) artist: String,
|
||||||
|
pub(super) release: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct LikedIds {
|
pub(super) struct LikedIds {
|
||||||
pub(super) track_ids: Vec<i64>,
|
pub(super) track_ids: Vec<i64>,
|
||||||
|
|||||||
+3918
-566
File diff suppressed because it is too large
Load Diff
+15
-1
@@ -3,9 +3,11 @@ use serde::Deserialize;
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct HistoryEntry {
|
pub(super) struct HistoryEntry {
|
||||||
pub(super) track_id: i64,
|
pub(super) track_id: i64,
|
||||||
|
pub(super) listen_id: Option<String>,
|
||||||
pub(super) started_at: Option<i64>,
|
pub(super) started_at: Option<i64>,
|
||||||
pub(super) duration_listened: Option<i32>,
|
pub(super) duration_listened: Option<i32>,
|
||||||
pub(super) completed: bool,
|
pub(super) completed: bool,
|
||||||
|
pub(super) ended_reason: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -42,7 +44,19 @@ pub(super) struct AddTracksRequest {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct RemoveTrackRequest {
|
pub(super) struct RemoveTrackRequest {
|
||||||
pub(super) track_id: i64,
|
pub(super) track_id: Option<i64>,
|
||||||
|
pub(super) playlist_track_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct ReorderPlaylistRequest {
|
||||||
|
pub(super) playlist_track_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct CreatePlaylistShareRequest {
|
||||||
|
pub(super) track_ids: Vec<i64>,
|
||||||
|
pub(super) title: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
+30
-28
@@ -56,6 +56,8 @@ pub(super) struct MediaFileRow {
|
|||||||
pub(super) file_path: String,
|
pub(super) file_path: String,
|
||||||
pub(super) mime_type: String,
|
pub(super) mime_type: String,
|
||||||
pub(super) file_size_bytes: i64,
|
pub(super) file_size_bytes: i64,
|
||||||
|
pub(super) sha256_hash: String,
|
||||||
|
pub(super) created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@@ -93,6 +95,7 @@ pub(super) struct PlaylistInfoRow {
|
|||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
pub(super) struct PlaylistTrackRow {
|
pub(super) struct PlaylistTrackRow {
|
||||||
|
pub(super) playlist_track_id: Option<i64>,
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
pub(super) title: String,
|
pub(super) title: String,
|
||||||
pub(super) track_number: Option<i32>,
|
pub(super) track_number: Option<i32>,
|
||||||
@@ -251,34 +254,6 @@ pub(super) struct ReleaseUploaderRow {
|
|||||||
pub(super) track_count: i64,
|
pub(super) track_count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
pub(super) struct PlayHistoryTrackRow {
|
|
||||||
pub(super) history_id: i64,
|
|
||||||
pub(super) played_at: String,
|
|
||||||
pub(super) duration_listened: Option<i32>,
|
|
||||||
pub(super) completed: bool,
|
|
||||||
pub(super) id: i64,
|
|
||||||
pub(super) title: String,
|
|
||||||
pub(super) track_number: Option<i32>,
|
|
||||||
pub(super) disc_number: Option<i32>,
|
|
||||||
pub(super) duration_seconds: f64,
|
|
||||||
pub(super) cover_file_id: Option<i64>,
|
|
||||||
pub(super) release_cover_file_id: Option<i64>,
|
|
||||||
pub(super) release_id: i64,
|
|
||||||
pub(super) release_title: String,
|
|
||||||
pub(super) release_year: Option<i32>,
|
|
||||||
pub(super) uploader_name: String,
|
|
||||||
pub(super) audio_format: Option<String>,
|
|
||||||
pub(super) audio_bitrate: Option<i32>,
|
|
||||||
pub(super) audio_sample_rate: Option<i32>,
|
|
||||||
pub(super) audio_bit_depth: Option<i32>,
|
|
||||||
pub(super) file_size_bytes: Option<i64>,
|
|
||||||
pub(super) lastfm_listeners: Option<i64>,
|
|
||||||
pub(super) lastfm_playcount: Option<i64>,
|
|
||||||
pub(super) lastfm_rating: Option<f64>,
|
|
||||||
pub(super) lastfm_updated_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
pub(super) struct ReleaseInfoRow {
|
pub(super) struct ReleaseInfoRow {
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
@@ -287,3 +262,30 @@ pub(super) struct ReleaseInfoRow {
|
|||||||
pub(super) year: Option<i32>,
|
pub(super) year: Option<i32>,
|
||||||
pub(super) cover_file_id: Option<i64>,
|
pub(super) cover_file_id: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct OfflineTrackManifestRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) updated_at: String,
|
||||||
|
pub(super) audio_file_id: i64,
|
||||||
|
pub(super) audio_hash: String,
|
||||||
|
pub(super) audio_size_bytes: i64,
|
||||||
|
pub(super) audio_mime_type: String,
|
||||||
|
pub(super) audio_updated_at: String,
|
||||||
|
pub(super) cover_file_id: Option<i64>,
|
||||||
|
pub(super) cover_hash: Option<String>,
|
||||||
|
pub(super) cover_updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct OfflinePlaylistManifestRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) description: Option<String>,
|
||||||
|
pub(super) updated_at: String,
|
||||||
|
pub(super) is_own: bool,
|
||||||
|
pub(super) owner_name: String,
|
||||||
|
pub(super) is_public: bool,
|
||||||
|
pub(super) is_saved: bool,
|
||||||
|
pub(super) track_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|||||||
+19
-8
@@ -496,14 +496,24 @@ impl PendingReview {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn exists_for_path(db: &Database, path: &str) -> cot::db::Result<bool> {
|
/// Latest review row for an inbox path: `(id, status, updated_at)`.
|
||||||
let all = Self::objects().all(db).await?;
|
///
|
||||||
let exists = all.iter().any(|r| {
|
/// Used by inbox_discover to decide whether a file needs a new review,
|
||||||
let s = r.status.as_str();
|
/// a requeue of its existing row, or nothing at all — without creating
|
||||||
// "rejected" and "failed" reviews should not block re-discovery
|
/// a fresh row per retry.
|
||||||
s != "rejected" && s != "failed" && r.input_path.as_deref() == Some(path)
|
pub async fn latest_for_path(
|
||||||
});
|
pool: &sqlx::PgPool,
|
||||||
Ok(exists)
|
path: &str,
|
||||||
|
) -> anyhow::Result<Option<(i64, String, String)>> {
|
||||||
|
let row: Option<(i64, String, String)> = sqlx::query_as(
|
||||||
|
"SELECT id, status::text, updated_at::text \
|
||||||
|
FROM furumusic__pending_review WHERE input_path = $1 \
|
||||||
|
ORDER BY id DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(path)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark all "processing" reviews as "failed" — called at scheduler
|
/// Mark all "processing" reviews as "failed" — called at scheduler
|
||||||
@@ -1372,6 +1382,7 @@ async fn run_scheduled_job(
|
|||||||
if !live_config.agent_enabled
|
if !live_config.agent_enabled
|
||||||
&& job_name != "lastfm_popularity"
|
&& job_name != "lastfm_popularity"
|
||||||
&& job_name != "lastfm_scrobble"
|
&& job_name != "lastfm_scrobble"
|
||||||
|
&& job_name != "archive_cleanup"
|
||||||
&& job_name != "artwork_backfill"
|
&& job_name != "artwork_backfill"
|
||||||
{
|
{
|
||||||
tracing::warn!(job = job_name, "Skipping: agent_enabled=false");
|
tracing::warn!(job = job_name, "Skipping: agent_enabled=false");
|
||||||
|
|||||||
+1569
File diff suppressed because it is too large
Load Diff
+70
-39
@@ -373,7 +373,8 @@ impl TorrentJob {
|
|||||||
|
|
||||||
pub struct TorrentService {
|
pub struct TorrentService {
|
||||||
temp_root: PathBuf,
|
temp_root: PathBuf,
|
||||||
session: OnceCell<Arc<Session>>,
|
sessions: Mutex<HashMap<String, Arc<Session>>>,
|
||||||
|
job_sessions: Mutex<HashMap<String, Arc<Session>>>,
|
||||||
jobs: Mutex<HashMap<String, TorrentJob>>,
|
jobs: Mutex<HashMap<String, TorrentJob>>,
|
||||||
resolving_jobs: Mutex<HashSet<String>>,
|
resolving_jobs: Mutex<HashSet<String>>,
|
||||||
scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>,
|
scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>,
|
||||||
@@ -383,36 +384,47 @@ impl TorrentService {
|
|||||||
pub fn new(scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>) -> Self {
|
pub fn new(scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
temp_root: std::env::temp_dir().join("furumusic").join("torrents"),
|
temp_root: std::env::temp_dir().join("furumusic").join("torrents"),
|
||||||
session: OnceCell::new(),
|
sessions: Mutex::new(HashMap::new()),
|
||||||
|
job_sessions: Mutex::new(HashMap::new()),
|
||||||
jobs: Mutex::new(HashMap::new()),
|
jobs: Mutex::new(HashMap::new()),
|
||||||
resolving_jobs: Mutex::new(HashSet::new()),
|
resolving_jobs: Mutex::new(HashSet::new()),
|
||||||
scheduler_handle,
|
scheduler_handle,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn session(&self) -> anyhow::Result<Arc<Session>> {
|
async fn session(&self, proxy_url: Option<&str>) -> anyhow::Result<Arc<Session>> {
|
||||||
let temp_root = self.temp_root.clone();
|
let key = proxy_url.unwrap_or_default().to_string();
|
||||||
self.session
|
let mut sessions = self.sessions.lock().await;
|
||||||
.get_or_try_init(|| async move {
|
if let Some(session) = sessions.get(&key) {
|
||||||
tokio::fs::create_dir_all(&temp_root).await?;
|
return Ok(Arc::clone(session));
|
||||||
Session::new_with_opts(
|
}
|
||||||
temp_root,
|
|
||||||
SessionOptions {
|
tokio::fs::create_dir_all(&self.temp_root).await?;
|
||||||
disable_upload: true,
|
let session = Session::new_with_opts(
|
||||||
enable_upnp_port_forwarding: false,
|
self.temp_root.clone(),
|
||||||
..Default::default()
|
SessionOptions {
|
||||||
},
|
// SOCKS is intentionally limited to peer TCP and HTTP(S)
|
||||||
)
|
// tracker traffic. DHT and other UDP discovery stay direct.
|
||||||
.await
|
disable_dht: false,
|
||||||
})
|
// Sessions are keyed by proxy and can coexist, so they cannot
|
||||||
.await
|
// safely share one persisted DHT socket configuration.
|
||||||
.cloned()
|
disable_dht_persistence: true,
|
||||||
|
disable_upload: true,
|
||||||
|
enable_upnp_port_forwarding: false,
|
||||||
|
socks_proxy_url: proxy_url.map(str::to_owned),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
sessions.insert(key, Arc::clone(&session));
|
||||||
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list(
|
pub async fn list(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
|
proxy_url: Option<String>,
|
||||||
) -> anyhow::Result<Vec<TorrentJobDto>> {
|
) -> anyhow::Result<Vec<TorrentJobDto>> {
|
||||||
let rows = sqlx::query_as::<_, TorrentSessionRow>(
|
let rows = sqlx::query_as::<_, TorrentSessionRow>(
|
||||||
r#"SELECT id, user_id, name, info_hash, source_kind, source_label, torrent_bytes,
|
r#"SELECT id, user_id, name, info_hash, source_kind, source_label, torrent_bytes,
|
||||||
@@ -445,6 +457,7 @@ impl TorrentService {
|
|||||||
row.id.clone(),
|
row.id.clone(),
|
||||||
magnet,
|
magnet,
|
||||||
row.created_at.clone(),
|
row.created_at.clone(),
|
||||||
|
proxy_url.clone(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -483,8 +496,9 @@ impl TorrentService {
|
|||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
request: TorrentPreviewRequest,
|
request: TorrentPreviewRequest,
|
||||||
|
proxy_url: Option<&str>,
|
||||||
) -> anyhow::Result<TorrentSessionDto> {
|
) -> anyhow::Result<TorrentSessionDto> {
|
||||||
let session = self.session().await?;
|
let session = self.session(proxy_url).await?;
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
let output_dir = self.temp_root.join(&id).join("download");
|
let output_dir = self.temp_root.join(&id).join("download");
|
||||||
tokio::fs::create_dir_all(&output_dir).await?;
|
tokio::fs::create_dir_all(&output_dir).await?;
|
||||||
@@ -511,8 +525,15 @@ impl TorrentService {
|
|||||||
.unwrap_or_else(|| info_hash.clone());
|
.unwrap_or_else(|| info_hash.clone());
|
||||||
let now = now_string();
|
let now = now_string();
|
||||||
insert_pending_magnet(pool, &id, user_id, &name, &info_hash, &magnet, &now).await?;
|
insert_pending_magnet(pool, &id, user_id, &name, &info_hash, &magnet, &now).await?;
|
||||||
self.spawn_resolve_pending_magnet(pool.clone(), user_id, id.clone(), magnet, now)
|
self.spawn_resolve_pending_magnet(
|
||||||
.await;
|
pool.clone(),
|
||||||
|
user_id,
|
||||||
|
id.clone(),
|
||||||
|
magnet,
|
||||||
|
now,
|
||||||
|
proxy_url.map(str::to_owned),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let row = load_row(pool, user_id, &id).await?;
|
let row = load_row(pool, user_id, &id).await?;
|
||||||
return Ok(TorrentSessionDto {
|
return Ok(TorrentSessionDto {
|
||||||
@@ -611,6 +632,7 @@ impl TorrentService {
|
|||||||
id: String,
|
id: String,
|
||||||
magnet: String,
|
magnet: String,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
|
proxy_url: Option<String>,
|
||||||
) {
|
) {
|
||||||
{
|
{
|
||||||
let mut resolving = self.resolving_jobs.lock().await;
|
let mut resolving = self.resolving_jobs.lock().await;
|
||||||
@@ -622,7 +644,14 @@ impl TorrentService {
|
|||||||
let service = Arc::clone(self);
|
let service = Arc::clone(self);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let result = service
|
let result = service
|
||||||
.resolve_pending_magnet(&pool, user_id, &id, &magnet, &created_at)
|
.resolve_pending_magnet(
|
||||||
|
&pool,
|
||||||
|
user_id,
|
||||||
|
&id,
|
||||||
|
&magnet,
|
||||||
|
&created_at,
|
||||||
|
proxy_url.as_deref(),
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
if let Err(err) = result {
|
if let Err(err) = result {
|
||||||
update_resolving_error(&pool, &id, &err.to_string()).await;
|
update_resolving_error(&pool, &id, &err.to_string()).await;
|
||||||
@@ -638,8 +667,9 @@ impl TorrentService {
|
|||||||
id: &str,
|
id: &str,
|
||||||
magnet: &str,
|
magnet: &str,
|
||||||
created_at: &str,
|
created_at: &str,
|
||||||
|
proxy_url: Option<&str>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let session = self.session().await?;
|
let session = self.session(proxy_url).await?;
|
||||||
let output_dir = self.temp_root.join(id).join("download");
|
let output_dir = self.temp_root.join(id).join("download");
|
||||||
tokio::fs::create_dir_all(&output_dir).await?;
|
tokio::fs::create_dir_all(&output_dir).await?;
|
||||||
let response = tokio::time::timeout(
|
let response = tokio::time::timeout(
|
||||||
@@ -743,7 +773,7 @@ impl TorrentService {
|
|||||||
jobs.remove(id).and_then(|job| job.handle)
|
jobs.remove(id).and_then(|job| job.handle)
|
||||||
};
|
};
|
||||||
if let Some(handle) = removed {
|
if let Some(handle) = removed {
|
||||||
self.stop_torrent(&handle).await;
|
self.stop_torrent(id, &handle).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result =
|
let result =
|
||||||
@@ -766,6 +796,7 @@ impl TorrentService {
|
|||||||
selected_files: Vec<usize>,
|
selected_files: Vec<usize>,
|
||||||
inbox_dir: String,
|
inbox_dir: String,
|
||||||
uploader_user_id: i64,
|
uploader_user_id: i64,
|
||||||
|
proxy_url: Option<&str>,
|
||||||
) -> anyhow::Result<TorrentJobDto> {
|
) -> anyhow::Result<TorrentJobDto> {
|
||||||
if selected_files.is_empty() {
|
if selected_files.is_empty() {
|
||||||
bail!("select at least one file");
|
bail!("select at least one file");
|
||||||
@@ -810,7 +841,7 @@ impl TorrentService {
|
|||||||
tokio::fs::create_dir_all(&output_dir).await?;
|
tokio::fs::create_dir_all(&output_dir).await?;
|
||||||
mark_job_started(pool, id, &selected_files, &self.memory_job_dto(id).await?).await?;
|
mark_job_started(pool, id, &selected_files, &self.memory_job_dto(id).await?).await?;
|
||||||
|
|
||||||
let session = self.session().await?;
|
let session = self.session(proxy_url).await?;
|
||||||
let response = match session
|
let response = match session
|
||||||
.add_torrent(
|
.add_torrent(
|
||||||
AddTorrent::from_bytes(torrent_bytes),
|
AddTorrent::from_bytes(torrent_bytes),
|
||||||
@@ -838,6 +869,10 @@ impl TorrentService {
|
|||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
self.job_sessions
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(id.to_string(), Arc::clone(&session));
|
||||||
|
|
||||||
let dto = {
|
let dto = {
|
||||||
let mut jobs = self.jobs.lock().await;
|
let mut jobs = self.jobs.lock().await;
|
||||||
@@ -856,7 +891,7 @@ impl TorrentService {
|
|||||||
if service.is_paused(&id).await {
|
if service.is_paused(&id).await {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
service.stop_torrent(&handle).await;
|
service.stop_torrent(&id, &handle).await;
|
||||||
service.fail_job(&pool, &id, err.to_string()).await;
|
service.fail_job(&pool, &id, err.to_string()).await;
|
||||||
crate::metrics::record_torrent_download(
|
crate::metrics::record_torrent_download(
|
||||||
"failed",
|
"failed",
|
||||||
@@ -865,7 +900,7 @@ impl TorrentService {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
service.stop_torrent(&handle).await;
|
service.stop_torrent(&id, &handle).await;
|
||||||
if let Err(err) = service
|
if let Err(err) = service
|
||||||
.finalize_completed(&pool, &id, &inbox_dir, uploader_user_id)
|
.finalize_completed(&pool, &id, &inbox_dir, uploader_user_id)
|
||||||
.await
|
.await
|
||||||
@@ -911,7 +946,7 @@ impl TorrentService {
|
|||||||
|
|
||||||
persist_progress(pool, &dto).await?;
|
persist_progress(pool, &dto).await?;
|
||||||
if let Some(handle) = handle {
|
if let Some(handle) = handle {
|
||||||
self.stop_torrent(&handle).await;
|
self.stop_torrent(id, &handle).await;
|
||||||
}
|
}
|
||||||
Ok(dto)
|
Ok(dto)
|
||||||
}
|
}
|
||||||
@@ -981,16 +1016,12 @@ impl TorrentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stop_torrent(&self, handle: &Arc<ManagedTorrent>) {
|
async fn stop_torrent(&self, id: &str, handle: &Arc<ManagedTorrent>) {
|
||||||
match self.session().await {
|
let session = self.job_sessions.lock().await.remove(id);
|
||||||
Ok(session) => {
|
if let Some(session) = session
|
||||||
if let Err(err) = session.delete(handle.id().into(), false).await {
|
&& let Err(err) = session.delete(handle.id().into(), false).await
|
||||||
tracing::warn!("failed to stop completed torrent: {err}");
|
{
|
||||||
}
|
tracing::warn!("failed to stop completed torrent: {err}");
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
tracing::warn!("failed to access torrent session for shutdown: {err}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2090
File diff suppressed because it is too large
Load Diff
+1743
-67
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23111827'/%3E%3Cpath d='M27 15v31.5a9 9 0 1 1-5-8.1V22l27-6v24.5a9 9 0 1 1-5-8.1V15.9L27 20.5' fill='%2367e8f9'/%3E%3C/svg%3E">
|
||||||
<title>{% block title %}{{ t.site_name }}{% endblock title %}</title>
|
<title>{% block title %}{{ t.site_name }}{% endblock title %}</title>
|
||||||
{% block head_extra %}{% endblock head_extra %}
|
{% block head_extra %}{% endblock head_extra %}
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+436
-30
@@ -85,7 +85,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Torrent Import Modal -->
|
<!-- Download Manager Modal -->
|
||||||
<template x-if="$store.torrents.modal">
|
<template x-if="$store.torrents.modal">
|
||||||
<div class="modal-overlay" @click.self="$store.torrents.close()">
|
<div class="modal-overlay" @click.self="$store.torrents.close()">
|
||||||
<div class="modal-box torrent-modal">
|
<div class="modal-box torrent-modal">
|
||||||
@@ -107,25 +107,47 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="torrent-client-status">
|
<div class="torrent-client-status">
|
||||||
<span class="torrent-status-pill"
|
<span class="torrent-status-pill"
|
||||||
|
x-show="$store.torrents.sourceTab === 'torrents'"
|
||||||
:class="{ active: $store.torrents.activeCount() > 0 }"
|
:class="{ active: $store.torrents.activeCount() > 0 }"
|
||||||
x-text="$store.torrents.clientSummary()"></span>
|
x-text="$store.torrents.clientSummary()"></span>
|
||||||
|
<span class="torrent-status-pill"
|
||||||
|
x-show="$store.torrents.sourceTab === 'youtube'"
|
||||||
|
:class="{ active: $store.torrents.youtubeActiveCount() > 0 }"
|
||||||
|
x-text="$store.torrents.youtubeSummary()"></span>
|
||||||
<span class="torrent-status-pill torrent-agent-pill"
|
<span class="torrent-status-pill torrent-agent-pill"
|
||||||
:class="{ active: $store.torrents.agentBusy() }">
|
:class="{ active: $store.torrents.agentBusy() }">
|
||||||
<span class="torrent-agent-dot"></span>
|
<span class="torrent-agent-dot"></span>
|
||||||
<span x-text="$store.torrents.agentSummary()"></span>
|
<span x-text="$store.torrents.agentSummary()"></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="torrent-status-pill"
|
<span class="torrent-status-pill"
|
||||||
|
x-show="$store.torrents.sourceTab === 'torrents'"
|
||||||
x-text="$store.torrents.sessions.length + ' ' + T.saved"></span>
|
x-text="$store.torrents.sessions.length + ' ' + T.saved"></span>
|
||||||
|
<span class="torrent-status-pill"
|
||||||
|
x-show="$store.torrents.sourceTab === 'youtube'"
|
||||||
|
x-text="$store.torrents.youtubeJobs.length + ' ' + T.saved"></span>
|
||||||
|
<span class="torrent-status-pill"
|
||||||
|
x-show="$store.torrents.sourceTab === 'files'"
|
||||||
|
x-text="$store.torrents.localUploadHistory.length + ' ' + T.saved"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="torrent-tabs">
|
<div class="torrent-tabs download-source-tabs">
|
||||||
|
{% if youtube_downloads_enabled %}
|
||||||
<button class="torrent-tab-btn"
|
<button class="torrent-tab-btn"
|
||||||
:class="{ active: $store.torrents.activeTab === 'import' }"
|
:class="{ active: $store.torrents.sourceTab === 'youtube' }"
|
||||||
@click="$store.torrents.showImportTab()">{{ t.player_import }}</button>
|
@click="$store.torrents.showSourceTab('youtube')">{{ t.player_youtube }}</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if torrent_downloads_enabled %}
|
||||||
<button class="torrent-tab-btn"
|
<button class="torrent-tab-btn"
|
||||||
:class="{ active: $store.torrents.activeTab === 'uploads' }"
|
:class="{ active: $store.torrents.sourceTab === 'torrents' }"
|
||||||
@click="$store.torrents.showUploadsTab()">
|
@click="$store.torrents.showSourceTab('torrents')">{{ t.player_torrents }}</button>
|
||||||
|
{% endif %}
|
||||||
|
<button class="torrent-tab-btn"
|
||||||
|
:class="{ active: $store.torrents.sourceTab === 'files' }"
|
||||||
|
@click="$store.torrents.showSourceTab('files')">{{ t.player_files }}</button>
|
||||||
|
<button class="torrent-tab-btn"
|
||||||
|
:class="{ active: $store.torrents.sourceTab === 'uploads' }"
|
||||||
|
@click="$store.torrents.showSourceTab('uploads')">
|
||||||
<span>{{ t.player_my_uploads }}</span>
|
<span>{{ t.player_my_uploads }}</span>
|
||||||
<span class="torrent-tab-count"
|
<span class="torrent-tab-count"
|
||||||
x-show="$store.torrents.uploadPendingTotal + $store.torrents.uploadQueuedTotal > 0"
|
x-show="$store.torrents.uploadPendingTotal + $store.torrents.uploadQueuedTotal > 0"
|
||||||
@@ -133,7 +155,173 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template x-if="$store.torrents.activeTab === 'import'">
|
<div class="youtube-manager-panel" x-show="$store.torrents.sourceTab === 'youtube'">
|
||||||
|
<form class="youtube-download-form" @submit.prevent="$store.torrents.previewYoutubeUrl()">
|
||||||
|
<label for="youtube-download-url">{{ t.player_youtube_url }}</label>
|
||||||
|
<div class="youtube-download-form-row">
|
||||||
|
<input id="youtube-download-url"
|
||||||
|
type="text"
|
||||||
|
inputmode="url"
|
||||||
|
autocomplete="url"
|
||||||
|
x-model="$store.torrents.youtubeUrl"
|
||||||
|
@input="$store.torrents.clearYoutubePreview()"
|
||||||
|
placeholder="https://www.youtube.com/watch?v=...">
|
||||||
|
<button type="submit"
|
||||||
|
class="modal-btn modal-btn-primary"
|
||||||
|
:disabled="$store.torrents.youtubePreviewLoading || $store.torrents.youtubeSubmitting || !$store.torrents.youtubeUrl.trim()">
|
||||||
|
<span x-text="$store.torrents.youtubePreviewLoading ? T.youtubeParsing : T.youtubeParse"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="youtube-download-hint">{{ t.player_youtube_url_hint }}</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<template x-if="$store.torrents.youtubePreview">
|
||||||
|
<section class="youtube-preview-card">
|
||||||
|
<div class="youtube-preview-head">
|
||||||
|
<div>
|
||||||
|
<h4>{{ t.player_youtube_preview_title }}</h4>
|
||||||
|
<p>
|
||||||
|
<strong x-text="$store.torrents.youtubePreview.title"></strong>
|
||||||
|
<span> · </span>
|
||||||
|
<span x-text="$store.torrents.youtubePreview.items.length + ' ' + T.youtubeItems"></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="youtube-preview-controls">
|
||||||
|
<button type="button" class="modal-btn modal-btn-ghost"
|
||||||
|
@click="$store.torrents.selectAllYoutubePreview()">{{ t.player_youtube_select_all }}</button>
|
||||||
|
<button type="button" class="modal-btn modal-btn-ghost"
|
||||||
|
@click="$store.torrents.clearYoutubePreviewSelection()">{{ t.player_youtube_clear_selection }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="youtube-preview-list">
|
||||||
|
<template x-for="item in $store.torrents.youtubePreview.items" :key="item.source_id">
|
||||||
|
<label class="youtube-preview-row"
|
||||||
|
:class="{ selected: $store.torrents.youtubePreviewIsSelected(item.source_id) }">
|
||||||
|
<input type="checkbox"
|
||||||
|
:checked="$store.torrents.youtubePreviewIsSelected(item.source_id)"
|
||||||
|
@change="$store.torrents.toggleYoutubePreviewItem(item.source_id)">
|
||||||
|
<span class="youtube-item-index" x-text="String(item.playlist_index).padStart(2, '0')"></span>
|
||||||
|
<span class="youtube-preview-item-title" x-text="item.title"></span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="youtube-preview-footer">
|
||||||
|
<span x-text="$store.torrents.youtubePreviewSelectedCount() + ' ' + T.youtubeSelectedCount"></span>
|
||||||
|
<div>
|
||||||
|
<button type="button" class="modal-btn modal-btn-ghost"
|
||||||
|
@click="$store.torrents.clearYoutubePreview()">{{ t.player_cancel }}</button>
|
||||||
|
<button type="button" class="modal-btn modal-btn-primary"
|
||||||
|
@click="$store.torrents.startYoutubeDownload()"
|
||||||
|
:disabled="$store.torrents.youtubeSubmitting || $store.torrents.youtubePreviewSelectedCount() === 0">
|
||||||
|
{{ t.player_youtube_start_import }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="youtube-download-list-head">
|
||||||
|
<span>{{ t.player_youtube_downloads }}</span>
|
||||||
|
<button class="modal-btn modal-btn-ghost"
|
||||||
|
@click="$store.torrents.loadYoutubeJobs()"
|
||||||
|
:disabled="$store.torrents.youtubeLoading">{{ t.player_refresh }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="youtube-download-list">
|
||||||
|
<template x-if="!$store.torrents.youtubeLoading && $store.torrents.youtubeJobs.length === 0">
|
||||||
|
<div class="empty-state youtube-empty-state">
|
||||||
|
<p>{{ t.player_no_youtube_downloads }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-for="job in $store.torrents.youtubeJobs" :key="job.id">
|
||||||
|
<article class="youtube-job-card">
|
||||||
|
<div class="youtube-job-head">
|
||||||
|
<div class="youtube-job-heading">
|
||||||
|
<div class="youtube-job-title" x-text="job.title"></div>
|
||||||
|
<div class="youtube-job-meta" x-text="$store.torrents.youtubeJobMeta(job)"></div>
|
||||||
|
</div>
|
||||||
|
<span class="torrent-status-badge"
|
||||||
|
:class="$store.torrents.youtubeStatusClass(job.status)"
|
||||||
|
x-text="$store.torrents.youtubeStatusLabel(job.status)"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="youtube-job-progress">
|
||||||
|
<div class="torrent-session-progress">
|
||||||
|
<div class="torrent-session-progress-bar"
|
||||||
|
:style="'width:' + $store.torrents.youtubeJobProgress(job) + '%'">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span x-text="$store.torrents.youtubeJobProgressText(job)"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="youtube-job-error" x-show="job.error" x-text="job.error"></p>
|
||||||
|
|
||||||
|
<div class="youtube-item-list">
|
||||||
|
<template x-for="item in job.items" :key="item.id">
|
||||||
|
<div class="youtube-item-row" :class="{ failed: $store.torrents.youtubeIsError(item.status) }">
|
||||||
|
<div class="youtube-item-head">
|
||||||
|
<span class="youtube-item-index" x-text="String(item.playlist_index).padStart(2, '0')"></span>
|
||||||
|
<div class="youtube-item-main">
|
||||||
|
<div class="youtube-item-title" x-text="item.title"></div>
|
||||||
|
<div class="youtube-item-meta" x-text="$store.torrents.youtubeItemMeta(item)"></div>
|
||||||
|
</div>
|
||||||
|
<span class="torrent-status-badge"
|
||||||
|
:class="$store.torrents.youtubeStatusClass(item.status)"
|
||||||
|
x-text="$store.torrents.youtubeStatusLabel(item.status)"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="youtube-item-download-progress" x-show="item.status === 'downloading'">
|
||||||
|
<div class="torrent-session-progress">
|
||||||
|
<div class="torrent-session-progress-bar"
|
||||||
|
:style="'width:' + Number(item.progress_percent || 0) + '%'">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span x-text="$store.torrents.youtubeDownloadMeta(item)"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="youtube-step-list" aria-label="{{ t.player_download_steps }}">
|
||||||
|
<span class="youtube-step" :class="$store.torrents.youtubeStepClass(item, 1)">
|
||||||
|
<i></i><b>{{ t.player_downloading }}</b>
|
||||||
|
</span>
|
||||||
|
<span class="youtube-step" :class="$store.torrents.youtubeStepClass(item, 2)">
|
||||||
|
<i></i><b>FFmpeg</b>
|
||||||
|
</span>
|
||||||
|
<span class="youtube-step" :class="$store.torrents.youtubeStepClass(item, 3)">
|
||||||
|
<i></i><b>{{ t.player_ai_prefix }}</b>
|
||||||
|
</span>
|
||||||
|
<span class="youtube-step" :class="$store.torrents.youtubeStepClass(item, 4)">
|
||||||
|
<i></i><b x-text="$store.torrents.youtubeFinalStepLabel(item)"></b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="youtube-item-error"
|
||||||
|
x-show="item.error"
|
||||||
|
x-text="item.error"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="youtube-job-actions">
|
||||||
|
<button class="modal-btn modal-btn-ghost"
|
||||||
|
x-show="job.review_items > 0 || job.items.some(item => item.status === 'ai_failed')"
|
||||||
|
@click="$store.torrents.openYoutubeReviews()">{{ t.player_my_uploads }}</button>
|
||||||
|
<button class="modal-btn modal-btn-pause"
|
||||||
|
x-show="$store.torrents.youtubeJobCancellable(job.status)"
|
||||||
|
:disabled="$store.torrents.youtubeCancellingIds.has(job.id)"
|
||||||
|
@click="$store.torrents.cancelYoutubeJob(job.id)">{{ t.player_youtube_stop }}</button>
|
||||||
|
<button class="modal-btn modal-btn-pause"
|
||||||
|
x-show="job.status !== 'cancelled' && (job.items.some(item => item.status === 'failed') || (job.status === 'failed' && job.total_items === 0))"
|
||||||
|
@click="$store.torrents.retryYoutubeJob(job.id)">{{ t.player_retry_failed }}</button>
|
||||||
|
<button class="modal-btn modal-btn-danger"
|
||||||
|
x-show="$store.torrents.youtubeJobTerminal(job.status)"
|
||||||
|
@click="$store.torrents.removeYoutubeJob(job.id)">{{ t.player_remove_from_history }}</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="download-torrent-panel" x-show="$store.torrents.sourceTab === 'torrents'">
|
||||||
<div class="torrent-manager-layout">
|
<div class="torrent-manager-layout">
|
||||||
<aside class="torrent-manager-sidebar">
|
<aside class="torrent-manager-sidebar">
|
||||||
<div class="torrent-manager-title">
|
<div class="torrent-manager-title">
|
||||||
@@ -173,7 +361,7 @@
|
|||||||
@click="$store.torrents.addNew()"
|
@click="$store.torrents.addNew()"
|
||||||
:disabled="$store.torrents.loading">
|
:disabled="$store.torrents.loading">
|
||||||
<span class="torrent-session-add-icon">+</span>
|
<span class="torrent-session-add-icon">+</span>
|
||||||
<span>{{ t.player_upload }}</span>
|
<span>{{ t.player_add_torrent }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -188,12 +376,6 @@
|
|||||||
<template x-if="$store.torrents.isImporting()">
|
<template x-if="$store.torrents.isImporting()">
|
||||||
<div class="torrent-import-panel">
|
<div class="torrent-import-panel">
|
||||||
<div class="torrent-modal-grid">
|
<div class="torrent-modal-grid">
|
||||||
<div>
|
|
||||||
<label for="local-file-input">{{ t.player_local_files }}</label>
|
|
||||||
<input id="local-file-input" type="file" multiple accept="audio/*,.mp3,.flac,.wav,.m4a,.ogg,.opus,.aac"
|
|
||||||
@change="$store.torrents.setLocalFiles($event.target.files)">
|
|
||||||
<div class="torrent-upload-summary" x-text="$store.torrents.localUploadSummary()"></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label for="torrent-magnet-input">{{ t.player_magnet_link }}</label>
|
<label for="torrent-magnet-input">{{ t.player_magnet_link }}</label>
|
||||||
<input id="torrent-magnet-input" type="text"
|
<input id="torrent-magnet-input" type="text"
|
||||||
@@ -206,17 +388,6 @@
|
|||||||
@change="$store.torrents.file = $event.target.files[0] || null">
|
@change="$store.torrents.file = $event.target.files[0] || null">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="torrent-upload-progress"
|
|
||||||
x-show="$store.torrents.uploadProgress > 0 || ($store.torrents.localFiles.length > 0 && $store.torrents.loading)">
|
|
||||||
<div class="torrent-progress-head">
|
|
||||||
<span x-text="$store.torrents.uploadProgress >= 100 ? T.uploadComplete : T.uploadingFiles"></span>
|
|
||||||
<span x-text="$store.torrents.uploadProgressText"></span>
|
|
||||||
</div>
|
|
||||||
<div class="torrent-progress-track">
|
|
||||||
<div class="torrent-progress-bar"
|
|
||||||
:style="'width:' + $store.torrents.uploadProgress + '%'"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="torrent-actions">
|
<div class="torrent-actions">
|
||||||
<button class="modal-btn modal-btn-primary" @click="$store.torrents.preview()" :disabled="$store.torrents.loading">
|
<button class="modal-btn modal-btn-primary" @click="$store.torrents.preview()" :disabled="$store.torrents.loading">
|
||||||
{{ t.player_upload_content }}
|
{{ t.player_upload_content }}
|
||||||
@@ -282,7 +453,7 @@
|
|||||||
<button class="modal-btn modal-btn-danger"
|
<button class="modal-btn modal-btn-danger"
|
||||||
@click="$store.torrents.removeSession($store.torrents.previewData.id)"
|
@click="$store.torrents.removeSession($store.torrents.previewData.id)"
|
||||||
:disabled="$store.torrents.loading">
|
:disabled="$store.torrents.loading">
|
||||||
{{ t.player_delete }}
|
{{ t.player_remove_from_history }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -341,9 +512,87 @@
|
|||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
|
||||||
<template x-if="$store.torrents.activeTab === 'uploads'">
|
<section class="file-upload-panel" x-show="$store.torrents.sourceTab === 'files'">
|
||||||
|
<input id="direct-audio-file-input"
|
||||||
|
class="file-upload-input"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="audio/*,.mp3,.flac,.wav,.m4a,.ogg,.opus,.aac"
|
||||||
|
@change="$store.torrents.setLocalFiles($event.target.files); $event.target.value = ''">
|
||||||
|
<label class="file-drop-zone"
|
||||||
|
for="direct-audio-file-input"
|
||||||
|
:class="{ dragging: $store.torrents.localFilesDragging }"
|
||||||
|
@dragenter.prevent="$store.torrents.localFilesDragging = true"
|
||||||
|
@dragover.prevent="$store.torrents.localFilesDragging = true"
|
||||||
|
@dragleave.prevent="$store.torrents.leaveLocalFileDrop($event)"
|
||||||
|
@drop.prevent="$store.torrents.dropLocalFiles($event)">
|
||||||
|
<span class="file-drop-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path d="M12 16V4"/><polyline points="7 9 12 4 17 9"/>
|
||||||
|
<path d="M5 14v4a2 2 0 002 2h10a2 2 0 002-2v-4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<strong>{{ t.player_drop_audio_title }}</strong>
|
||||||
|
<span>{{ t.player_drop_audio_hint }}</span>
|
||||||
|
<small>{{ t.player_drop_audio_formats }}</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="file-upload-selection" x-show="$store.torrents.localFiles.length > 0">
|
||||||
|
<span x-text="$store.torrents.localUploadSummary()"></span>
|
||||||
|
<div>
|
||||||
|
<button type="button" class="modal-btn modal-btn-ghost"
|
||||||
|
@click="$store.torrents.clearLocalFiles()"
|
||||||
|
:disabled="$store.torrents.localFilesUploading">{{ t.player_cancel }}</button>
|
||||||
|
<button type="button" class="modal-btn modal-btn-primary"
|
||||||
|
@click="$store.torrents.uploadLocalFiles()"
|
||||||
|
:disabled="$store.torrents.localFilesUploading">{{ t.player_upload_selected_files }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="torrent-upload-progress"
|
||||||
|
x-show="$store.torrents.localFilesUploading || $store.torrents.uploadProgress > 0">
|
||||||
|
<div class="torrent-progress-head">
|
||||||
|
<span x-text="$store.torrents.uploadProgress >= 100 ? T.uploadComplete : T.uploadingFiles"></span>
|
||||||
|
<span x-text="$store.torrents.uploadProgressText"></span>
|
||||||
|
</div>
|
||||||
|
<div class="torrent-progress-track">
|
||||||
|
<div class="torrent-progress-bar"
|
||||||
|
:style="'width:' + $store.torrents.uploadProgress + '%'"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="youtube-download-list-head file-upload-history-head">
|
||||||
|
<span>{{ t.player_upload_history }}</span>
|
||||||
|
<button class="modal-btn modal-btn-ghost"
|
||||||
|
@click="$store.torrents.loadLocalUploadHistory()"
|
||||||
|
:disabled="$store.torrents.localUploadHistoryLoading">{{ t.player_refresh }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-upload-history">
|
||||||
|
<template x-if="!$store.torrents.localUploadHistoryLoading && $store.torrents.localUploadHistory.length === 0">
|
||||||
|
<div class="empty-state youtube-empty-state"><p>{{ t.player_no_file_uploads }}</p></div>
|
||||||
|
</template>
|
||||||
|
<template x-for="item in $store.torrents.localUploadHistory" :key="item.id">
|
||||||
|
<article class="file-upload-history-row">
|
||||||
|
<div class="file-upload-history-main">
|
||||||
|
<div class="file-upload-history-title" x-text="item.filename"></div>
|
||||||
|
<div class="file-upload-history-meta"
|
||||||
|
x-text="$store.torrents.bytes(item.size_bytes) + ' · ' + $store.torrents.formatUploadDate(item.created_at)"></div>
|
||||||
|
<p class="youtube-item-error" x-show="item.error" x-text="item.error"></p>
|
||||||
|
</div>
|
||||||
|
<span class="torrent-status-badge"
|
||||||
|
:class="$store.torrents.youtubeStatusClass(item.status)"
|
||||||
|
x-text="$store.torrents.youtubeStatusLabel(item.status)"></span>
|
||||||
|
<button class="modal-btn modal-btn-danger"
|
||||||
|
@click="$store.torrents.removeLocalUploadHistory(item.id)">{{ t.player_remove_from_history }}</button>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<template x-if="$store.torrents.sourceTab === 'uploads'">
|
||||||
<section class="upload-manager-panel">
|
<section class="upload-manager-panel">
|
||||||
<div class="upload-manager-head">
|
<div class="upload-manager-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -636,6 +885,155 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- User Settings Modal -->
|
||||||
|
<template x-if="$store.user.settingsOpen">
|
||||||
|
<div class="modal-overlay" @click.self="$store.user.closeSettings()">
|
||||||
|
<div class="modal-box user-settings-modal">
|
||||||
|
<div class="user-settings-head">
|
||||||
|
<div>
|
||||||
|
<h3>User settings</h3>
|
||||||
|
<p>Personal services, listening history and trusted devices.</p>
|
||||||
|
</div>
|
||||||
|
<button class="mobile-list-action" @click="$store.user.closeSettings()" title="{{ t.player_close }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="user-settings-section">
|
||||||
|
<div class="user-settings-section-head">
|
||||||
|
<div>
|
||||||
|
<h4>{{ t.player_interface_language }}</h4>
|
||||||
|
<p>{{ t.player_language_description }}</p>
|
||||||
|
</div>
|
||||||
|
<button class="settings-secondary-btn"
|
||||||
|
onclick="location.href='/set-lang?lang={% if t.lang.code() == "en" %}ru{% else %}en{% endif %}&next='+encodeURIComponent(location.pathname+location.search+location.hash)">{{ t.player_switch_language }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="user-settings-section">
|
||||||
|
<div class="user-settings-section-head">
|
||||||
|
<div>
|
||||||
|
<h4>Listening history</h4>
|
||||||
|
<p>Review plays recorded by this web player.</p>
|
||||||
|
</div>
|
||||||
|
<button class="settings-secondary-btn" @click="$store.user.openHistoryFromSettings()">Open history</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="user-settings-section">
|
||||||
|
<div class="user-settings-section-head">
|
||||||
|
<div>
|
||||||
|
<h4>Last.fm</h4>
|
||||||
|
<p x-text="$store.user.lastfmStatusLabel()"></p>
|
||||||
|
</div>
|
||||||
|
<button class="settings-secondary-btn"
|
||||||
|
:class="$store.user.lastfmClass()"
|
||||||
|
:disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured"
|
||||||
|
@click="$store.user.handleLastfm()"
|
||||||
|
x-text="$store.user.lastfm?.connected && !$store.user.lastfm?.reauth_required ? 'Disconnect' : 'Connect'"></button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="user-settings-section user-settings-devices">
|
||||||
|
<div class="user-settings-section-head">
|
||||||
|
<div>
|
||||||
|
<h4>Connected devices</h4>
|
||||||
|
<p x-text="$store.devices.fedSummary()"></p>
|
||||||
|
</div>
|
||||||
|
<button class="settings-secondary-btn"
|
||||||
|
:disabled="$store.devices.fedBusy"
|
||||||
|
@click="$store.devices.syncFedDevices()">Sync now</button>
|
||||||
|
</div>
|
||||||
|
<template x-if="$store.devices.fedError">
|
||||||
|
<div class="fed-device-error" x-text="$store.devices.fedError"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="settings-device-group">
|
||||||
|
<div class="settings-device-label">Web player sessions</div>
|
||||||
|
<template x-for="device in $store.devices.webDevices()" :key="'settings-web-' + device.id">
|
||||||
|
<div class="settings-device-row">
|
||||||
|
<span class="device-row-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="4" width="18" height="12" rx="2"/>
|
||||||
|
<path d="M8 20h8M12 16v4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="fed-device-main">
|
||||||
|
<span class="fed-device-name" x-text="device.name"></span>
|
||||||
|
<span class="fed-device-meta"
|
||||||
|
x-text="device.is_current ? 'This browser session' : (device.is_active ? 'Active web session' : 'Web session')"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-device-group">
|
||||||
|
<div class="settings-device-label">Trusted federation devices</div>
|
||||||
|
<template x-for="request in $store.devices.fedPending()" :key="request.request_id">
|
||||||
|
<div class="fed-pairing-card">
|
||||||
|
<div class="fed-pairing-title" x-text="request.name || request.device_id"></div>
|
||||||
|
<div class="fed-pairing-meta"
|
||||||
|
x-text="request.requester_group_id ? 'Already belongs to another sync group' : (request.client_version || 'Waiting for approval')"></div>
|
||||||
|
<div class="fed-device-actions">
|
||||||
|
<button class="fed-action-btn primary"
|
||||||
|
@click="$store.devices.answerFedPairing(request, true, !!request.requester_group_id)"
|
||||||
|
x-text="request.requester_group_id ? 'Use existing group' : 'Approve'"></button>
|
||||||
|
<button class="fed-action-btn"
|
||||||
|
@click="$store.devices.answerFedPairing(request, false, false)">Reject</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-for="device in $store.devices.fedDevices()" :key="'settings-fed-' + device.device_id">
|
||||||
|
<div class="settings-device-row federation">
|
||||||
|
<span class="device-row-icon federation-device-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
|
||||||
|
<path d="M2.8 5.8a11 11 0 000 12.4M21.2 5.8a11 11 0 010 12.4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="fed-device-main">
|
||||||
|
<span class="fed-device-name" x-text="device.name || device.device_id"></span>
|
||||||
|
<span class="fed-device-meta"
|
||||||
|
x-text="device.is_self ? 'This web player' : (device.client_version || 'Trusted device')"></span>
|
||||||
|
</span>
|
||||||
|
<button class="fed-revoke-btn"
|
||||||
|
x-show="!device.is_self"
|
||||||
|
:disabled="$store.devices.fedBusy"
|
||||||
|
@click="$store.devices.revokeFedDevice(device)">Revoke</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-pairing-actions">
|
||||||
|
<button class="settings-primary-btn"
|
||||||
|
:disabled="$store.devices.fedBusy"
|
||||||
|
@click="$store.devices.generateFedInvite()">Invite a device</button>
|
||||||
|
<template x-if="$store.devices.fedInvite">
|
||||||
|
<input class="fed-device-input"
|
||||||
|
readonly
|
||||||
|
:value="$store.devices.fedInvite"
|
||||||
|
@focus="$event.target.select()">
|
||||||
|
</template>
|
||||||
|
<div class="fed-connect-row">
|
||||||
|
<input class="fed-device-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Paste frid:// invite"
|
||||||
|
x-model="$store.devices.fedInviteInput"
|
||||||
|
@keydown.enter.prevent="$store.devices.connectFedInvite()">
|
||||||
|
<button class="settings-secondary-btn"
|
||||||
|
:disabled="$store.devices.fedBusy || !$store.devices.fedInviteInput.trim()"
|
||||||
|
@click="$store.devices.connectFedInvite()">Connect</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Play History Modal -->
|
<!-- Play History Modal -->
|
||||||
<template x-if="$store.history.modal">
|
<template x-if="$store.history.modal">
|
||||||
<div class="modal-overlay" @click.self="$store.history.close()">
|
<div class="modal-overlay" @click.self="$store.history.close()">
|
||||||
@@ -676,14 +1074,19 @@
|
|||||||
@click.stop="$store.history.playFrom(idx)"
|
@click.stop="$store.history.playFrom(idx)"
|
||||||
:title="item.track?.title || item.track_title">
|
:title="item.track?.title || item.track_title">
|
||||||
<template x-if="item.track && item.track.cover_url">
|
<template x-if="item.track && item.track.cover_url">
|
||||||
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
|
<img :src="item.track.cover_url" alt="" aria-hidden="true" loading="lazy">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!item.track || !item.track.cover_url">
|
<template x-if="!item.track || !item.track.cover_url">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||||
</template>
|
</template>
|
||||||
</button>
|
</button>
|
||||||
<div class="track-info">
|
<div class="track-info">
|
||||||
<div class="track-title" x-text="item.track?.title || item.track_title"></div>
|
<div class="track-title">
|
||||||
|
<span x-text="item.track?.title || item.track_title"></span>
|
||||||
|
<span class="history-device-badge"
|
||||||
|
:title="item.device_id"
|
||||||
|
x-text="item.device_name"></span>
|
||||||
|
</div>
|
||||||
<div class="track-artists-inline">
|
<div class="track-artists-inline">
|
||||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||||
<span>
|
<span>
|
||||||
@@ -722,6 +1125,9 @@
|
|||||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([item.track])" title="{{ t.player_add_to_queue }}">
|
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([item.track])" title="{{ t.player_add_to_queue }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(item.track || item.track_id, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
|
||||||
|
</button>
|
||||||
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([item.track_id])" title="{{ t.player_add_to_playlist }}">
|
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([item.track_id])" title="{{ t.player_add_to_playlist }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+2712
-51
File diff suppressed because it is too large
Load Diff
+846
-207
File diff suppressed because it is too large
Load Diff
+1656
-73
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user