Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e90b33f6 | ||
|
|
1ab53e3898 | ||
|
|
0b32d7e813 | ||
|
|
5402d9595d | ||
|
|
9d9edcfec8 | ||
|
|
943191a0ff | ||
|
|
2b254f417f | ||
|
|
69883af8bd |
Generated
+3
-1
@@ -1938,7 +1938,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.10.1"
|
version = "0.10.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@@ -1953,6 +1953,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"id3",
|
"id3",
|
||||||
"image",
|
"image",
|
||||||
|
"libc",
|
||||||
"librqbit",
|
"librqbit",
|
||||||
"md-5",
|
"md-5",
|
||||||
"music-dht",
|
"music-dht",
|
||||||
@@ -1969,6 +1970,7 @@ dependencies = [
|
|||||||
"symphonia",
|
"symphonia",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-cron-scheduler",
|
"tokio-cron-scheduler",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.10.1"
|
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"
|
||||||
|
|
||||||
@@ -13,7 +13,9 @@ 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", "stream"] }
|
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"
|
async-stream = "0.3"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
|
|||||||
+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,208 +1,143 @@
|
|||||||
# furumusic
|
# Furumusic
|
||||||
|
|
||||||
Furumusic can join the decentralized Furumi federation while remaining a
|
**Your library. Your users. Your network.**
|
||||||
complete local web player. Optional similarity search stores versioned audio
|
|
||||||
embeddings in PostgreSQL and uses signed two-level LSH summaries in a separate
|
|
||||||
DHT to discover compatible peers without a central recommendation index. The
|
|
||||||
shared `music-dht` layer owns routing and wire compatibility; model inference
|
|
||||||
and exact cosine ranking stay local to each instance.
|
|
||||||
|
|
||||||
Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL.
|
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.
|
||||||
|
|
||||||
Built with Rust ([cot](https://cot.rs) framework).
|
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.
|
||||||
|
|
||||||
## Quick start
|
## 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` |
|
|
||||||
|
|||||||
@@ -28,7 +28,10 @@
|
|||||||
|
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
cacert
|
cacert
|
||||||
|
deno
|
||||||
|
ffmpeg-headless
|
||||||
openssl
|
openssl
|
||||||
|
yt-dlp
|
||||||
] ++ lib.optionals stdenv.isDarwin [ libiconv ];
|
] ++ lib.optionals stdenv.isDarwin [ libiconv ];
|
||||||
|
|
||||||
RUST_SRC_PATH = "${pkgs.rustPlatform.rustLibSrc}";
|
RUST_SRC_PATH = "${pkgs.rustPlatform.rustLibSrc}";
|
||||||
|
|||||||
+56
-13
@@ -711,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(
|
||||||
"/",
|
"/",
|
||||||
@@ -1069,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",
|
||||||
|
|||||||
+396
-97
@@ -16,7 +16,7 @@ use sqlx::{PgPool, Postgres, QueryBuilder};
|
|||||||
use super::BUILD_INFO;
|
use super::BUILD_INFO;
|
||||||
use crate::agent;
|
use crate::agent;
|
||||||
use crate::auth::{self, AuthenticatedUser, Role};
|
use crate::auth::{self, AuthenticatedUser, Role};
|
||||||
use crate::config::{AppConfig, ConfigEntry, ConfigSources};
|
use crate::config::{AppConfig, ConfigEntry, ConfigSources, DownloadProxy};
|
||||||
use crate::i18n::{I18n, Translations};
|
use crate::i18n::{I18n, Translations};
|
||||||
use crate::scheduler::{self, JobRegistry, JobRun, ScheduledJob};
|
use crate::scheduler::{self, JobRegistry, JobRun, ScheduledJob};
|
||||||
|
|
||||||
@@ -69,6 +69,21 @@ pub(super) struct BulkLibraryRequest {
|
|||||||
filter: Option<LibraryFilter>,
|
filter: Option<LibraryFilter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct MergeReleasesRequest {
|
||||||
|
release_ids: Vec<i64>,
|
||||||
|
target_release_id: i64,
|
||||||
|
title: String,
|
||||||
|
release_type: String,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_stringish")]
|
||||||
|
year: Option<String>,
|
||||||
|
hidden: bool,
|
||||||
|
cover_file_id: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
artist_ids: Vec<i64>,
|
||||||
|
tracks: Vec<ReleaseTrackUpdateRequest>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct MetadataBackfillRunRequest {
|
pub struct MetadataBackfillRunRequest {
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
@@ -417,6 +432,14 @@ struct MutationResponse {
|
|||||||
affected: u64,
|
affected: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct MergeReleasesResponse {
|
||||||
|
ok: bool,
|
||||||
|
merged_releases: u64,
|
||||||
|
moved_tracks: u64,
|
||||||
|
item: LibraryItemDto,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
struct AdminSettingsDto {
|
struct AdminSettingsDto {
|
||||||
values: AdminSettingsValues,
|
values: AdminSettingsValues,
|
||||||
@@ -462,6 +485,50 @@ struct AdminSettingsValues {
|
|||||||
similarity_profile: String,
|
similarity_profile: String,
|
||||||
#[serde(default = "default_similarity_workers")]
|
#[serde(default = "default_similarity_workers")]
|
||||||
similarity_workers: String,
|
similarity_workers: String,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
downloads_enabled: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
torrent_downloads_enabled: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
youtube_downloads_enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
download_proxies: Vec<AdminDownloadProxy>,
|
||||||
|
#[serde(default)]
|
||||||
|
torrent_proxy_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
youtube_proxy_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
struct AdminDownloadProxy {
|
||||||
|
id: String,
|
||||||
|
address: String,
|
||||||
|
#[serde(default)]
|
||||||
|
username: String,
|
||||||
|
#[serde(default)]
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DownloadProxy> for AdminDownloadProxy {
|
||||||
|
fn from(proxy: DownloadProxy) -> Self {
|
||||||
|
Self {
|
||||||
|
id: proxy.id,
|
||||||
|
address: proxy.address,
|
||||||
|
username: proxy.username,
|
||||||
|
password: proxy.password,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AdminDownloadProxy> for DownloadProxy {
|
||||||
|
fn from(proxy: AdminDownloadProxy) -> Self {
|
||||||
|
Self {
|
||||||
|
id: proxy.id,
|
||||||
|
address: proxy.address,
|
||||||
|
username: proxy.username,
|
||||||
|
password: proxy.password,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
@@ -493,6 +560,12 @@ struct AdminSettingsSources {
|
|||||||
similarity_model: &'static str,
|
similarity_model: &'static str,
|
||||||
similarity_profile: &'static str,
|
similarity_profile: &'static str,
|
||||||
similarity_workers: &'static str,
|
similarity_workers: &'static str,
|
||||||
|
downloads_enabled: &'static str,
|
||||||
|
torrent_downloads_enabled: &'static str,
|
||||||
|
youtube_downloads_enabled: &'static str,
|
||||||
|
download_proxies: &'static str,
|
||||||
|
torrent_proxy_id: &'static str,
|
||||||
|
youtube_proxy_id: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -531,6 +604,18 @@ pub(super) struct UpdateSettingsRequest {
|
|||||||
similarity_profile: String,
|
similarity_profile: String,
|
||||||
#[serde(default = "default_similarity_workers")]
|
#[serde(default = "default_similarity_workers")]
|
||||||
similarity_workers: String,
|
similarity_workers: String,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
downloads_enabled: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
torrent_downloads_enabled: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
youtube_downloads_enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
download_proxies: Vec<AdminDownloadProxy>,
|
||||||
|
#[serde(default)]
|
||||||
|
torrent_proxy_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
youtube_proxy_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_similarity_model() -> String {
|
fn default_similarity_model() -> String {
|
||||||
@@ -597,6 +682,7 @@ struct LibraryItemDetailDto {
|
|||||||
release_id: Option<i64>,
|
release_id: Option<i64>,
|
||||||
track_number: Option<i32>,
|
track_number: Option<i32>,
|
||||||
disc_number: Option<i32>,
|
disc_number: Option<i32>,
|
||||||
|
current_image_file_id: Option<i64>,
|
||||||
current_image_url: Option<String>,
|
current_image_url: Option<String>,
|
||||||
selected_artist_ids: Vec<i64>,
|
selected_artist_ids: Vec<i64>,
|
||||||
artists: Vec<ArtistOptionDto>,
|
artists: Vec<ArtistOptionDto>,
|
||||||
@@ -980,15 +1066,15 @@ pub async fn update_settings(
|
|||||||
if let Err(response) = require_admin_json(&session, &db).await {
|
if let Err(response) = require_admin_json(&session, &db).await {
|
||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
let similarity_model = body.similarity_model.trim();
|
let similarity_model = body.similarity_model.trim().to_string();
|
||||||
if crate::similarity::model_by_id(similarity_model).is_none() {
|
if crate::similarity::model_by_id(&similarity_model).is_none() {
|
||||||
return Ok(json_error(
|
return Ok(json_error(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"unknown similarity model",
|
"unknown similarity model",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let similarity_profile = body.similarity_profile.trim();
|
let similarity_profile = body.similarity_profile.trim().to_string();
|
||||||
if crate::similarity::profile_by_id(similarity_profile).is_none() {
|
if crate::similarity::profile_by_id(&similarity_profile).is_none() {
|
||||||
return Ok(json_error(
|
return Ok(json_error(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"unknown similarity preprocessing profile",
|
"unknown similarity preprocessing profile",
|
||||||
@@ -1003,6 +1089,47 @@ pub async fn update_settings(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if body.download_proxies.len() > 32 {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"at most 32 download proxies can be saved",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut download_proxies = Vec::with_capacity(body.download_proxies.len());
|
||||||
|
let mut proxy_ids = HashSet::new();
|
||||||
|
for (index, proxy) in body.download_proxies.into_iter().enumerate() {
|
||||||
|
let proxy = match DownloadProxy::from(proxy).normalized() {
|
||||||
|
Ok(proxy) => proxy,
|
||||||
|
Err(error) => {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
&format!("proxy {}: {error}", index + 1),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !proxy_ids.insert(proxy.id.clone()) {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"download proxy ids must be unique",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
download_proxies.push(proxy);
|
||||||
|
}
|
||||||
|
let torrent_proxy_id = body.torrent_proxy_id.trim().to_string();
|
||||||
|
let youtube_proxy_id = body.youtube_proxy_id.trim().to_string();
|
||||||
|
for (method, proxy_id) in [
|
||||||
|
("torrent", torrent_proxy_id.as_str()),
|
||||||
|
("YouTube", youtube_proxy_id.as_str()),
|
||||||
|
] {
|
||||||
|
if !proxy_id.is_empty() && !proxy_ids.contains(proxy_id) {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
&format!("selected {method} proxy is not in the saved proxy list"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let download_proxies_json = serde_json::to_string(&download_proxies)
|
||||||
|
.map_err(|error| cot::Error::internal(error.to_string()))?;
|
||||||
let fields = [
|
let fields = [
|
||||||
(
|
(
|
||||||
"auth_password_enabled",
|
"auth_password_enabled",
|
||||||
@@ -1058,9 +1185,21 @@ pub async fn update_settings(
|
|||||||
body.federation_save_on_listen.to_string(),
|
body.federation_save_on_listen.to_string(),
|
||||||
),
|
),
|
||||||
("similarity_enabled", body.similarity_enabled.to_string()),
|
("similarity_enabled", body.similarity_enabled.to_string()),
|
||||||
("similarity_model", similarity_model.to_string()),
|
("similarity_model", similarity_model),
|
||||||
("similarity_profile", similarity_profile.to_string()),
|
("similarity_profile", similarity_profile),
|
||||||
("similarity_workers", similarity_workers.to_string()),
|
("similarity_workers", similarity_workers.to_string()),
|
||||||
|
("downloads_enabled", body.downloads_enabled.to_string()),
|
||||||
|
(
|
||||||
|
"torrent_downloads_enabled",
|
||||||
|
body.torrent_downloads_enabled.to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"youtube_downloads_enabled",
|
||||||
|
body.youtube_downloads_enabled.to_string(),
|
||||||
|
),
|
||||||
|
("download_proxies", download_proxies_json),
|
||||||
|
("torrent_proxy_id", torrent_proxy_id),
|
||||||
|
("youtube_proxy_id", youtube_proxy_id),
|
||||||
];
|
];
|
||||||
for (key, value) in fields {
|
for (key, value) in fields {
|
||||||
let mut entry = ConfigEntry::new(key.to_string(), value);
|
let mut entry = ConfigEntry::new(key.to_string(), value);
|
||||||
@@ -1235,6 +1374,15 @@ pub async fn settings_probe(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
||||||
|
let download_proxies = config
|
||||||
|
.parsed_download_proxies()
|
||||||
|
.unwrap_or_else(|error| {
|
||||||
|
tracing::warn!(%error, "ignoring invalid saved download proxy list");
|
||||||
|
Vec::new()
|
||||||
|
})
|
||||||
|
.into_iter()
|
||||||
|
.map(AdminDownloadProxy::from)
|
||||||
|
.collect();
|
||||||
AdminSettingsDto {
|
AdminSettingsDto {
|
||||||
lastfm_api_key_configured: !config.lastfm_api_key.trim().is_empty(),
|
lastfm_api_key_configured: !config.lastfm_api_key.trim().is_empty(),
|
||||||
lastfm_shared_secret_configured: !config.lastfm_shared_secret.trim().is_empty(),
|
lastfm_shared_secret_configured: !config.lastfm_shared_secret.trim().is_empty(),
|
||||||
@@ -1268,6 +1416,12 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
|||||||
similarity_model: config.similarity_model,
|
similarity_model: config.similarity_model,
|
||||||
similarity_profile: config.similarity_profile,
|
similarity_profile: config.similarity_profile,
|
||||||
similarity_workers: config.similarity_workers.to_string(),
|
similarity_workers: config.similarity_workers.to_string(),
|
||||||
|
downloads_enabled: config.downloads_enabled,
|
||||||
|
torrent_downloads_enabled: config.torrent_downloads_enabled,
|
||||||
|
youtube_downloads_enabled: config.youtube_downloads_enabled,
|
||||||
|
download_proxies,
|
||||||
|
torrent_proxy_id: config.torrent_proxy_id,
|
||||||
|
youtube_proxy_id: config.youtube_proxy_id,
|
||||||
},
|
},
|
||||||
sources: AdminSettingsSources {
|
sources: AdminSettingsSources {
|
||||||
auth_password_enabled: sources.auth_password_enabled.code(),
|
auth_password_enabled: sources.auth_password_enabled.code(),
|
||||||
@@ -1297,6 +1451,12 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
|||||||
similarity_model: sources.similarity_model.code(),
|
similarity_model: sources.similarity_model.code(),
|
||||||
similarity_profile: sources.similarity_profile.code(),
|
similarity_profile: sources.similarity_profile.code(),
|
||||||
similarity_workers: sources.similarity_workers.code(),
|
similarity_workers: sources.similarity_workers.code(),
|
||||||
|
downloads_enabled: sources.downloads_enabled.code(),
|
||||||
|
torrent_downloads_enabled: sources.torrent_downloads_enabled.code(),
|
||||||
|
youtube_downloads_enabled: sources.youtube_downloads_enabled.code(),
|
||||||
|
download_proxies: sources.download_proxies.code(),
|
||||||
|
torrent_proxy_id: sources.torrent_proxy_id.code(),
|
||||||
|
youtube_proxy_id: sources.youtube_proxy_id.code(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1930,10 +2090,182 @@ pub async fn bulk_library(
|
|||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let affected = apply_library_action(pool, &kind, action, &ids).await?;
|
let storage_dir = if action == "delete" && matches!(kind.as_str(), "releases" | "tracks") {
|
||||||
|
AppConfig::load_with_db(&db).await.0.agent_storage_dir
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let affected = apply_library_action(pool, &kind, action, &ids, &storage_dir).await?;
|
||||||
Json(MutationResponse { ok: true, affected }).into_response()
|
Json(MutationResponse { ok: true, affected }).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn merge_releases(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &PgPool,
|
||||||
|
Json(body): Json<MergeReleasesRequest>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
if let Err(response) = require_admin_json(&session, &db).await {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut release_ids = body.release_ids;
|
||||||
|
release_ids.retain(|id| *id > 0);
|
||||||
|
release_ids.sort_unstable();
|
||||||
|
release_ids.dedup();
|
||||||
|
if release_ids.len() < 2 {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"select at least two releases to merge",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if release_ids.len() > 250 {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"at most 250 releases can be merged at once",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !release_ids.contains(&body.target_release_id) {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"destination release must be part of the selection",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = body.title.trim();
|
||||||
|
if title.is_empty() {
|
||||||
|
return Ok(json_error(StatusCode::BAD_REQUEST, "title cannot be empty"));
|
||||||
|
}
|
||||||
|
if title.chars().count() > 255 {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"title cannot exceed 255 characters",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let release_type = body.release_type.trim().to_lowercase();
|
||||||
|
if !matches!(
|
||||||
|
release_type.as_str(),
|
||||||
|
"album"
|
||||||
|
| "single"
|
||||||
|
| "ep"
|
||||||
|
| "compilation"
|
||||||
|
| "mixtape"
|
||||||
|
| "live"
|
||||||
|
| "soundtrack"
|
||||||
|
| "remix"
|
||||||
|
| "demo"
|
||||||
|
| "unknown"
|
||||||
|
) {
|
||||||
|
return Ok(json_error(StatusCode::BAD_REQUEST, "invalid release type"));
|
||||||
|
}
|
||||||
|
let year = match parse_merge_optional_i32(body.year.as_deref(), 0, 3000, "year") {
|
||||||
|
Ok(year) => year,
|
||||||
|
Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, &message)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tracks = Vec::with_capacity(body.tracks.len());
|
||||||
|
for track in body.tracks {
|
||||||
|
if track.id <= 0 {
|
||||||
|
return Ok(json_error(StatusCode::BAD_REQUEST, "invalid track id"));
|
||||||
|
}
|
||||||
|
let track_number = match parse_merge_optional_i32(
|
||||||
|
track.track_number.as_deref(),
|
||||||
|
1,
|
||||||
|
9999,
|
||||||
|
"track number",
|
||||||
|
) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, &message)),
|
||||||
|
};
|
||||||
|
let disc_number =
|
||||||
|
match parse_merge_optional_i32(track.disc_number.as_deref(), 1, 999, "disc number") {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, &message)),
|
||||||
|
};
|
||||||
|
tracks.push(crate::library_cleanup::ReleaseMergeTrack {
|
||||||
|
id: track.id,
|
||||||
|
track_number,
|
||||||
|
disc_number,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing_release_ids: Vec<i64> =
|
||||||
|
sqlx::query_scalar("SELECT id FROM furumusic__release WHERE id = ANY($1) ORDER BY id")
|
||||||
|
.bind(&release_ids)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|error| cot::Error::internal(error.to_string()))?;
|
||||||
|
if existing_release_ids != release_ids {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"one or more selected releases no longer exist",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let selected_track_ids: Vec<i64> = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM furumusic__track WHERE release_id = ANY($1) ORDER BY id",
|
||||||
|
)
|
||||||
|
.bind(&release_ids)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|error| cot::Error::internal(error.to_string()))?;
|
||||||
|
let mut requested_track_ids = 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])
|
||||||
|
|| requested_track_ids != selected_track_ids
|
||||||
|
{
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"track list does not match the selected releases; reopen the merge wizard",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let storage_dir = AppConfig::load_with_db(&db).await.0.agent_storage_dir;
|
||||||
|
let result = match crate::library_cleanup::merge_releases(
|
||||||
|
pool,
|
||||||
|
crate::library_cleanup::ReleaseMergeSpec {
|
||||||
|
release_ids,
|
||||||
|
target_release_id: body.target_release_id,
|
||||||
|
title: title.to_owned(),
|
||||||
|
title_sort: normalize_name(title),
|
||||||
|
release_type,
|
||||||
|
year,
|
||||||
|
hidden: body.hidden,
|
||||||
|
cover_file_id: body.cover_file_id,
|
||||||
|
artist_ids: body.artist_ids,
|
||||||
|
tracks,
|
||||||
|
},
|
||||||
|
&storage_dir,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(?error, "release merge failed");
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"release merge failed; no changes were committed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(item) = fetch_library_item(pool, "releases", body.target_release_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| cot::Error::internal(error.to_string()))?
|
||||||
|
else {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"merged release could not be loaded",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
Json(MergeReleasesResponse {
|
||||||
|
ok: true,
|
||||||
|
merged_releases: result.merged_releases,
|
||||||
|
moved_tracks: result.moved_tracks,
|
||||||
|
item,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn require_admin_json(
|
async fn require_admin_json(
|
||||||
session: &Session,
|
session: &Session,
|
||||||
db: &Database,
|
db: &Database,
|
||||||
@@ -3003,6 +3335,7 @@ async fn load_library_item_detail(
|
|||||||
release_id: None,
|
release_id: None,
|
||||||
track_number: None,
|
track_number: None,
|
||||||
disc_number: None,
|
disc_number: None,
|
||||||
|
current_image_file_id: None,
|
||||||
current_image_url: None,
|
current_image_url: None,
|
||||||
selected_artist_ids: Vec::new(),
|
selected_artist_ids: Vec::new(),
|
||||||
artists: Vec::new(),
|
artists: Vec::new(),
|
||||||
@@ -3023,6 +3356,7 @@ async fn load_library_item_detail(
|
|||||||
.flatten();
|
.flatten();
|
||||||
detail.current_image_url =
|
detail.current_image_url =
|
||||||
image_file_id.map(|id| format!("/api/player/cover/{id}/large"));
|
image_file_id.map(|id| format!("/api/player/cover/{id}/large"));
|
||||||
|
detail.current_image_file_id = image_file_id;
|
||||||
detail.available_covers = artist_available_covers(pool, detail.item.id).await?;
|
detail.available_covers = artist_available_covers(pool, detail.item.id).await?;
|
||||||
}
|
}
|
||||||
"releases" => {
|
"releases" => {
|
||||||
@@ -3037,6 +3371,7 @@ async fn load_library_item_detail(
|
|||||||
detail.year = year;
|
detail.year = year;
|
||||||
detail.current_image_url =
|
detail.current_image_url =
|
||||||
cover_file_id.map(|id| format!("/api/player/cover/{id}/large"));
|
cover_file_id.map(|id| format!("/api/player/cover/{id}/large"));
|
||||||
|
detail.current_image_file_id = cover_file_id;
|
||||||
}
|
}
|
||||||
detail.selected_artist_ids = sqlx::query_as::<_, IdRow>(
|
detail.selected_artist_ids = sqlx::query_as::<_, IdRow>(
|
||||||
"SELECT artist_id AS id FROM furumusic__release_artist WHERE release_id = $1 ORDER BY position, artist_id",
|
"SELECT artist_id AS id FROM furumusic__release_artist WHERE release_id = $1 ORDER BY position, artist_id",
|
||||||
@@ -3484,10 +3819,11 @@ async fn apply_library_action(
|
|||||||
kind: &str,
|
kind: &str,
|
||||||
action: &str,
|
action: &str,
|
||||||
ids: &[i64],
|
ids: &[i64],
|
||||||
|
storage_dir: &str,
|
||||||
) -> cot::Result<u64> {
|
) -> cot::Result<u64> {
|
||||||
match action {
|
match action {
|
||||||
"hide" | "show" => set_library_visibility(pool, kind, ids, action == "hide").await,
|
"hide" | "show" => set_library_visibility(pool, kind, ids, action == "hide").await,
|
||||||
"delete" => delete_library_items(pool, kind, ids).await,
|
"delete" => delete_library_items(pool, kind, ids, storage_dir).await,
|
||||||
_ => Ok(0),
|
_ => Ok(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3538,10 +3874,15 @@ async fn set_library_visibility(
|
|||||||
Ok(result.rows_affected())
|
Ok(result.rows_affected())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_library_items(pool: &PgPool, kind: &str, ids: &[i64]) -> cot::Result<u64> {
|
async fn delete_library_items(
|
||||||
|
pool: &PgPool,
|
||||||
|
kind: &str,
|
||||||
|
ids: &[i64],
|
||||||
|
storage_dir: &str,
|
||||||
|
) -> cot::Result<u64> {
|
||||||
match kind {
|
match kind {
|
||||||
"releases" => delete_releases(pool, ids).await,
|
"releases" => delete_releases(pool, ids, storage_dir).await,
|
||||||
"tracks" => delete_tracks(pool, ids).await,
|
"tracks" => delete_tracks(pool, ids, storage_dir).await,
|
||||||
"playlists" => delete_playlists(pool, ids).await,
|
"playlists" => delete_playlists(pool, ids).await,
|
||||||
_ => delete_artists(pool, ids).await,
|
_ => delete_artists(pool, ids).await,
|
||||||
}
|
}
|
||||||
@@ -3571,95 +3912,16 @@ async fn delete_artists(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> {
|
|||||||
Ok(result.rows_affected())
|
Ok(result.rows_affected())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_releases(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> {
|
async fn delete_releases(pool: &PgPool, ids: &[i64], storage_dir: &str) -> cot::Result<u64> {
|
||||||
let track_ids =
|
crate::library_cleanup::delete_releases(pool, ids, storage_dir)
|
||||||
sqlx::query_as::<_, IdRow>("SELECT id FROM furumusic__track WHERE release_id = ANY($1)")
|
|
||||||
.bind(ids)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?
|
|
||||||
.into_iter()
|
|
||||||
.map(|row| row.id)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
if !track_ids.is_empty() {
|
|
||||||
sqlx::query("DELETE FROM furumusic__playlist_track WHERE track_id = ANY($1)")
|
|
||||||
.bind(&track_ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
sqlx::query("DELETE FROM furumusic__user_liked_track WHERE track_id = ANY($1)")
|
|
||||||
.bind(&track_ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
sqlx::query("DELETE FROM furumusic__play_history WHERE track_id = ANY($1)")
|
|
||||||
.bind(&track_ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
sqlx::query("DELETE FROM furumusic__track_genre WHERE track_id = ANY($1)")
|
|
||||||
.bind(&track_ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
sqlx::query("DELETE FROM furumusic__track_artist WHERE track_id = ANY($1)")
|
|
||||||
.bind(&track_ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlx::query("DELETE FROM furumusic__track WHERE release_id = ANY($1)")
|
|
||||||
.bind(ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|error| cot::Error::internal(error.to_string()))
|
||||||
sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = ANY($1)")
|
|
||||||
.bind(ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
let result = sqlx::query("DELETE FROM furumusic__release WHERE id = ANY($1)")
|
|
||||||
.bind(ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
Ok(result.rows_affected())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_tracks(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> {
|
async fn delete_tracks(pool: &PgPool, ids: &[i64], storage_dir: &str) -> cot::Result<u64> {
|
||||||
sqlx::query("DELETE FROM furumusic__playlist_track WHERE track_id = ANY($1)")
|
crate::library_cleanup::delete_tracks(pool, ids, storage_dir)
|
||||||
.bind(ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|error| cot::Error::internal(error.to_string()))
|
||||||
sqlx::query("DELETE FROM furumusic__user_liked_track WHERE track_id = ANY($1)")
|
|
||||||
.bind(ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
sqlx::query("DELETE FROM furumusic__play_history WHERE track_id = ANY($1)")
|
|
||||||
.bind(ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
sqlx::query("DELETE FROM furumusic__track_genre WHERE track_id = ANY($1)")
|
|
||||||
.bind(ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
sqlx::query("DELETE FROM furumusic__track_artist WHERE track_id = ANY($1)")
|
|
||||||
.bind(ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
let result = sqlx::query("DELETE FROM furumusic__track WHERE id = ANY($1)")
|
|
||||||
.bind(ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
Ok(result.rows_affected())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_playlists(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> {
|
async fn delete_playlists(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> {
|
||||||
@@ -4046,6 +4308,24 @@ fn parse_optional_admin_i32(value: Option<&str>, min: i32, max: i32) -> Option<i
|
|||||||
.map(|parsed| parsed.clamp(min, max))
|
.map(|parsed| parsed.clamp(min, max))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_merge_optional_i32(
|
||||||
|
value: Option<&str>,
|
||||||
|
min: i32,
|
||||||
|
max: i32,
|
||||||
|
field: &str,
|
||||||
|
) -> Result<Option<i32>, String> {
|
||||||
|
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let parsed = value
|
||||||
|
.parse::<i32>()
|
||||||
|
.map_err(|_| format!("{field} must be an integer from {min} to {max}"))?;
|
||||||
|
if !(min..=max).contains(&parsed) {
|
||||||
|
return Err(format!("{field} must be from {min} to {max}"));
|
||||||
|
}
|
||||||
|
Ok(Some(parsed))
|
||||||
|
}
|
||||||
|
|
||||||
fn deserialize_optional_stringish<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
fn deserialize_optional_stringish<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
||||||
where
|
where
|
||||||
D: Deserializer<'de>,
|
D: Deserializer<'de>,
|
||||||
@@ -4119,3 +4399,22 @@ fn size_display(bytes: i64) -> String {
|
|||||||
format!("{bytes} B")
|
format!("{bytes} B")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::parse_merge_optional_i32;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_numbers_preserve_empty_values_and_reject_invalid_input() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_merge_optional_i32(None, 1, 9999, "track number").unwrap(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_merge_optional_i32(Some(" 38 "), 1, 9999, "track number").unwrap(),
|
||||||
|
Some(38)
|
||||||
|
);
|
||||||
|
assert!(parse_merge_optional_i32(Some("0"), 1, 9999, "track number").is_err());
|
||||||
|
assert!(parse_merge_optional_i32(Some("nope"), 1, 9999, "track number").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+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"))
|
||||||
|
|||||||
+195
@@ -142,6 +142,12 @@ pub struct ConfigSources {
|
|||||||
pub similarity_model: ConfigSource,
|
pub similarity_model: ConfigSource,
|
||||||
pub similarity_profile: ConfigSource,
|
pub similarity_profile: ConfigSource,
|
||||||
pub similarity_workers: 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 {
|
||||||
@@ -176,6 +182,12 @@ impl Default for ConfigSources {
|
|||||||
similarity_model: ConfigSource::Default,
|
similarity_model: ConfigSource::Default,
|
||||||
similarity_profile: ConfigSource::Default,
|
similarity_profile: ConfigSource::Default,
|
||||||
similarity_workers: 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,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.
|
||||||
@@ -301,6 +391,18 @@ pub struct AppConfig {
|
|||||||
pub similarity_profile: String,
|
pub similarity_profile: String,
|
||||||
/// Maximum number of concurrent CPU embedding workers.
|
/// Maximum number of concurrent CPU embedding workers.
|
||||||
pub similarity_workers: u64,
|
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 {
|
||||||
@@ -337,6 +439,13 @@ impl Default for AppConfig {
|
|||||||
similarity_workers: std::thread::available_parallelism()
|
similarity_workers: std::thread::available_parallelism()
|
||||||
.map(|count| (count.get() / 2).clamp(1, 4) as u64)
|
.map(|count| (count.get() / 2).clamp(1, 4) as u64)
|
||||||
.unwrap_or(1),
|
.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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,6 +481,12 @@ impl_env_overrides!(
|
|||||||
similarity_model,
|
similarity_model,
|
||||||
similarity_profile,
|
similarity_profile,
|
||||||
similarity_workers,
|
similarity_workers,
|
||||||
|
downloads_enabled,
|
||||||
|
torrent_downloads_enabled,
|
||||||
|
youtube_downloads_enabled,
|
||||||
|
download_proxies,
|
||||||
|
torrent_proxy_id,
|
||||||
|
youtube_proxy_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
@@ -506,6 +621,39 @@ impl AppConfig {
|
|||||||
apply_db_field!(similarity_model);
|
apply_db_field!(similarity_model);
|
||||||
apply_db_field!(similarity_profile);
|
apply_db_field!(similarity_profile);
|
||||||
apply_db_field!(similarity_workers);
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,6 +680,53 @@ mod tests {
|
|||||||
crate::similarity::DEFAULT_PROFILE_ID
|
crate::similarity::DEFAULT_PROFILE_ID
|
||||||
);
|
);
|
||||||
assert!((1..=4).contains(&cfg.similarity_workers));
|
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]
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ pub struct TrackDto {
|
|||||||
pub key: TrackKeyDto,
|
pub key: TrackKeyDto,
|
||||||
pub metadata: TrackMetadataDto,
|
pub metadata: TrackMetadataDto,
|
||||||
pub availability: TrackAvailabilityDto,
|
pub availability: TrackAvailabilityDto,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub similarity_score: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -151,6 +153,7 @@ impl Federation {
|
|||||||
local: None,
|
local: None,
|
||||||
federation: vec![FederationSourceDto { owner, item_id }],
|
federation: vec![FederationSourceDto { owner, item_id }],
|
||||||
},
|
},
|
||||||
|
similarity_score: Some(track.similarity_score),
|
||||||
};
|
};
|
||||||
persist_track_ref(&pool, &dto).await?;
|
persist_track_ref(&pool, &dto).await?;
|
||||||
prepared.push(dto);
|
prepared.push(dto);
|
||||||
@@ -491,6 +494,7 @@ fn track_from_item(
|
|||||||
local,
|
local,
|
||||||
federation: vec![FederationSourceDto { owner, item_id }],
|
federation: vec![FederationSourceDto { owner, item_id }],
|
||||||
},
|
},
|
||||||
|
similarity_score: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+50
-2
@@ -213,6 +213,50 @@ impl TransportStats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn enrich_transport_users(pool: &PgPool, transport: &mut Value) {
|
||||||
|
let Some(samples) = transport.get_mut("last").and_then(Value::as_array_mut) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let peer_ids: Vec<String> = samples
|
||||||
|
.iter()
|
||||||
|
.filter_map(|sample| sample.get("peer_id").and_then(Value::as_str))
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect();
|
||||||
|
if peer_ids.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(rows) = sqlx::query(
|
||||||
|
"SELECT DISTINCT ON (d.endpoint_id)
|
||||||
|
d.endpoint_id,
|
||||||
|
COALESCE(NULLIF(u.display_name, ''), u.username::text) AS user_name
|
||||||
|
FROM furumusic__fed_device d
|
||||||
|
JOIN furumusic__user u ON u.id = d.user_id
|
||||||
|
WHERE d.endpoint_id = ANY($1) AND d.revoked_at_ms IS NULL
|
||||||
|
ORDER BY d.endpoint_id, d.last_seen_ms DESC NULLS LAST",
|
||||||
|
)
|
||||||
|
.bind(&peer_ids)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let users: HashMap<String, String> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| (row.get("endpoint_id"), row.get("user_name")))
|
||||||
|
.collect();
|
||||||
|
for sample in samples {
|
||||||
|
let Some(peer_id) = sample.get("peer_id").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(user_name) = users.get(peer_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(object) = sample.as_object_mut() {
|
||||||
|
object.insert("user_name".to_owned(), Value::String(user_name.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn record_stream_transport(
|
pub fn record_stream_transport(
|
||||||
stats: &Arc<TransportStats>,
|
stats: &Arc<TransportStats>,
|
||||||
protocol: &'static str,
|
protocol: &'static str,
|
||||||
@@ -849,6 +893,10 @@ impl Federation {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|p| p.to_string())
|
.map(|p| p.to_string())
|
||||||
.collect();
|
.collect();
|
||||||
|
let mut transport = self.transport_stats.snapshot();
|
||||||
|
if let Ok(pool) = self.pool().await {
|
||||||
|
enrich_transport_users(&pool, &mut transport).await;
|
||||||
|
}
|
||||||
json!({
|
json!({
|
||||||
"running": true,
|
"running": true,
|
||||||
"network": running.network_name,
|
"network": running.network_name,
|
||||||
@@ -857,7 +905,7 @@ impl Federation {
|
|||||||
"known_contacts": service.known_peers().len(),
|
"known_contacts": service.known_peers().len(),
|
||||||
"similarity_routing_peers": running.similarity_dht.known_peers(),
|
"similarity_routing_peers": running.similarity_dht.known_peers(),
|
||||||
"published_items": published,
|
"published_items": published,
|
||||||
"transport": self.transport_stats.snapshot(),
|
"transport": transport,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
None => json!({ "running": false }),
|
None => json!({ "running": false }),
|
||||||
@@ -895,7 +943,7 @@ impl Federation {
|
|||||||
&self,
|
&self,
|
||||||
query: crate::similarity::QueryVector,
|
query: crate::similarity::QueryVector,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<similarity::RemoteSimilarityTrack>> {
|
) -> Result<similarity::SimilaritySearchOutcome> {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
crate::similarity::handle().enabled(),
|
crate::similarity::handle().enabled(),
|
||||||
"similarity search is disabled"
|
"similarity search is disabled"
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ pub struct RemoteSimilarityTrack {
|
|||||||
pub release_title: Option<String>,
|
pub release_title: Option<String>,
|
||||||
pub track_number: Option<i32>,
|
pub track_number: Option<i32>,
|
||||||
pub disc_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(
|
pub async fn serve_peers(
|
||||||
@@ -151,7 +157,7 @@ pub async fn search(
|
|||||||
query: QueryVector,
|
query: QueryVector,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
transport: Arc<TransportStats>,
|
transport: Arc<TransportStats>,
|
||||||
) -> Result<Vec<RemoteSimilarityTrack>> {
|
) -> Result<SimilaritySearchOutcome> {
|
||||||
let own = service.endpoint_id();
|
let own = service.endpoint_id();
|
||||||
let routed = match tokio::time::timeout(
|
let routed = match tokio::time::timeout(
|
||||||
ROUTING_TIMEOUT,
|
ROUTING_TIMEOUT,
|
||||||
@@ -204,6 +210,7 @@ pub async fn search(
|
|||||||
|
|
||||||
let mut hits = Vec::new();
|
let mut hits = Vec::new();
|
||||||
let initial = peers.len().min(INITIAL_QUERY_PEERS);
|
let initial = peers.len().min(INITIAL_QUERY_PEERS);
|
||||||
|
let mut queried_peers = initial;
|
||||||
let responses = query_peers(
|
let responses = query_peers(
|
||||||
Arc::clone(&service),
|
Arc::clone(&service),
|
||||||
&peers[..initial],
|
&peers[..initial],
|
||||||
@@ -222,6 +229,7 @@ pub async fn search(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if initial < peers.len() && (hits.len() < limit || successful < initial.min(4)) {
|
if initial < peers.len() && (hits.len() < limit || successful < initial.min(4)) {
|
||||||
|
queried_peers += peers.len() - initial;
|
||||||
for response in query_peers(
|
for response in query_peers(
|
||||||
Arc::clone(&service),
|
Arc::clone(&service),
|
||||||
&peers[initial..],
|
&peers[initial..],
|
||||||
@@ -282,7 +290,10 @@ pub async fn search(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(tracks)
|
Ok(SimilaritySearchOutcome {
|
||||||
|
tracks,
|
||||||
|
queried_peers,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type PeerHits = Vec<(
|
type PeerHits = Vec<(
|
||||||
@@ -360,6 +371,7 @@ async fn query_peer(
|
|||||||
release_title: hit.release_title,
|
release_title: hit.release_title,
|
||||||
track_number: hit.track_number,
|
track_number: hit.track_number,
|
||||||
disc_number: hit.disc_number,
|
disc_number: hit.disc_number,
|
||||||
|
similarity_score: score,
|
||||||
},
|
},
|
||||||
score,
|
score,
|
||||||
signature,
|
signature,
|
||||||
|
|||||||
+69
-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. Попробуйте ещё раз.";
|
||||||
@@ -389,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" , "ИИ простаивает";
|
||||||
@@ -467,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" , "Развернуть всё";
|
||||||
@@ -501,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" , "Загрузка не удалась";
|
||||||
@@ -511,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." , "Скачивание началось. После завершения файлы будут перенесены во входящие.";
|
||||||
@@ -523,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" , "Не удалось загрузить очередь ИИ";
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-16
@@ -988,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,
|
||||||
@@ -1020,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}"))?;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
+10
-1
@@ -7,6 +7,8 @@ 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;
|
||||||
@@ -16,6 +18,7 @@ mod scheduler;
|
|||||||
mod similarity;
|
mod similarity;
|
||||||
mod torrents;
|
mod torrents;
|
||||||
mod user;
|
mod user;
|
||||||
|
mod youtube;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -88,7 +91,13 @@ async fn index(
|
|||||||
return Ok(auth::redirect("/login"));
|
return Ok(auth::redirect("/login"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let template = player::PlayerPageTemplate { t: i18n.t };
|
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,
|
||||||
|
};
|
||||||
Html::new(template.render()?).into_response()
|
Html::new(template.render()?).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-1
@@ -841,6 +841,7 @@ const KNOWN_HTTP_ROUTES: &[&str] = &[
|
|||||||
"/admin/v2/api/library/item/image",
|
"/admin/v2/api/library/item/image",
|
||||||
"/admin/v2/api/library/item/upload-image",
|
"/admin/v2/api/library/item/upload-image",
|
||||||
"/admin/v2/api/library/bulk",
|
"/admin/v2/api/library/bulk",
|
||||||
|
"/admin/v2/api/library/releases/merge",
|
||||||
"/admin/debug",
|
"/admin/debug",
|
||||||
"/admin/settings",
|
"/admin/settings",
|
||||||
"/admin/settings/probe",
|
"/admin/settings/probe",
|
||||||
@@ -884,10 +885,18 @@ const KNOWN_HTTP_ROUTES: &[&str] = &[
|
|||||||
"/api/player/lastfm/scrobble",
|
"/api/player/lastfm/scrobble",
|
||||||
"/api/player/agent-queue",
|
"/api/player/agent-queue",
|
||||||
"/api/player/offline/manifest",
|
"/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",
|
||||||
"/api/player/torrents/session/{id}",
|
"/api/player/torrents/session/{id}",
|
||||||
"/api/player/torrents/preview",
|
"/api/player/torrents/preview",
|
||||||
"/api/player/uploads/local",
|
|
||||||
"/api/player/uploads/tracks",
|
"/api/player/uploads/tracks",
|
||||||
"/api/player/uploads/tracks/{track_id}",
|
"/api/player/uploads/tracks/{track_id}",
|
||||||
"/api/player/uploads/bulk-tracks",
|
"/api/player/uploads/bulk-tracks",
|
||||||
@@ -951,6 +960,22 @@ mod tests {
|
|||||||
known_http_route("/share/release/42"),
|
known_http_route("/share/release/42"),
|
||||||
Some("/share/release/{id}")
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -2569,6 +2569,219 @@ pub mod db_migrations {
|
|||||||
&[Operation::custom(add_similarity_routing_signature).build()];
|
&[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,
|
||||||
@@ -2604,5 +2817,8 @@ pub mod db_migrations {
|
|||||||
&M0042RepairLegacyListenQualification,
|
&M0042RepairLegacyListenQualification,
|
||||||
&M0043CreateSimilarityEmbeddings,
|
&M0043CreateSimilarityEmbeddings,
|
||||||
&M0044AddSimilarityRoutingSignature,
|
&M0044AddSimilarityRoutingSignature,
|
||||||
|
&M0045CreateYouTubeDownloads,
|
||||||
|
&M0046CreateLocalUploadHistory,
|
||||||
|
&M0047CreateYouTubeImportMediaLinks,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+701
-39
@@ -14,15 +14,17 @@ use cot::router::method::{delete, get, post};
|
|||||||
use cot::router::{Route, Router};
|
use cot::router::{Route, Router};
|
||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
use cot::{App, Body, Template};
|
use cot::{App, Body, Template};
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::Row as _;
|
use sqlx::Row as _;
|
||||||
|
|
||||||
use crate::auth;
|
use crate::auth;
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::i18n::Translations;
|
use crate::i18n::Translations;
|
||||||
use crate::lastfm::{LastfmClient, LastfmCredentials, LastfmTrackPayload};
|
use crate::lastfm::{LastfmClient, LastfmCredentials, LastfmTrackPayload};
|
||||||
|
use crate::local_uploads::LocalUploadDto;
|
||||||
use crate::scheduler::SchedulerHandle;
|
use crate::scheduler::SchedulerHandle;
|
||||||
use crate::torrents::{TorrentPreviewRequest, TorrentService, TorrentStartRequest};
|
use crate::torrents::{TorrentPreviewRequest, TorrentService, TorrentStartRequest};
|
||||||
|
use crate::youtube::{YouTubePreviewRequest, YouTubeService, YouTubeStartRequest};
|
||||||
|
|
||||||
mod dto;
|
mod dto;
|
||||||
mod helpers;
|
mod helpers;
|
||||||
@@ -47,11 +49,54 @@ fn json_error(status: StatusCode, message: &str) -> cot::response::Response {
|
|||||||
.expect("valid response")
|
.expect("valid response")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum DownloadMethod {
|
||||||
|
LocalFile,
|
||||||
|
Torrent,
|
||||||
|
YouTube,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_download_method(
|
||||||
|
config: &AppConfig,
|
||||||
|
method: DownloadMethod,
|
||||||
|
) -> Result<(), cot::response::Response> {
|
||||||
|
let enabled = config.downloads_enabled
|
||||||
|
&& match method {
|
||||||
|
DownloadMethod::LocalFile => true,
|
||||||
|
DownloadMethod::Torrent => config.torrent_downloads_enabled,
|
||||||
|
DownloadMethod::YouTube => config.youtube_downloads_enabled,
|
||||||
|
};
|
||||||
|
if enabled {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(json_error(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
match method {
|
||||||
|
DownloadMethod::LocalFile => "downloads are disabled by the administrator",
|
||||||
|
DownloadMethod::Torrent => "torrent downloads are disabled by the administrator",
|
||||||
|
DownloadMethod::YouTube => "YouTube downloads are disabled by the administrator",
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_proxy_for(
|
||||||
|
config: &AppConfig,
|
||||||
|
method: DownloadMethod,
|
||||||
|
) -> Result<Option<String>, cot::response::Response> {
|
||||||
|
require_download_method(config, method)?;
|
||||||
|
let result = match method {
|
||||||
|
DownloadMethod::LocalFile => Ok(None),
|
||||||
|
DownloadMethod::Torrent => config.torrent_proxy_url(),
|
||||||
|
DownloadMethod::YouTube => config.youtube_proxy_url(),
|
||||||
|
};
|
||||||
|
result.map_err(|error| json_error(StatusCode::BAD_REQUEST, &error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
struct LocalUploadResponse {
|
struct LocalUploadResponse {
|
||||||
ok: bool,
|
ok: bool,
|
||||||
filename: String,
|
upload: LocalUploadDto,
|
||||||
size: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLAYER_DEVICE_TTL_MS: i64 = 30_000;
|
const PLAYER_DEVICE_TTL_MS: i64 = 30_000;
|
||||||
@@ -1374,6 +1419,40 @@ struct LastfmCallbackQuery {
|
|||||||
#[template(path = "player.html")]
|
#[template(path = "player.html")]
|
||||||
pub struct PlayerPageTemplate {
|
pub struct PlayerPageTemplate {
|
||||||
pub t: &'static Translations,
|
pub t: &'static Translations,
|
||||||
|
pub downloads_enabled: bool,
|
||||||
|
pub torrent_downloads_enabled: bool,
|
||||||
|
pub youtube_downloads_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod page_template_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::i18n::Lang;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_manager_button_follows_the_global_switch() {
|
||||||
|
let disabled = PlayerPageTemplate {
|
||||||
|
t: Translations::for_lang(Lang::En),
|
||||||
|
downloads_enabled: false,
|
||||||
|
torrent_downloads_enabled: false,
|
||||||
|
youtube_downloads_enabled: false,
|
||||||
|
}
|
||||||
|
.render()
|
||||||
|
.unwrap();
|
||||||
|
assert!(!disabled.contains("<button class=\"torrent-import-btn\""));
|
||||||
|
assert!(disabled.contains("downloadsEnabled: false"));
|
||||||
|
|
||||||
|
let enabled = PlayerPageTemplate {
|
||||||
|
t: Translations::for_lang(Lang::En),
|
||||||
|
downloads_enabled: true,
|
||||||
|
torrent_downloads_enabled: true,
|
||||||
|
youtube_downloads_enabled: true,
|
||||||
|
}
|
||||||
|
.render()
|
||||||
|
.unwrap();
|
||||||
|
assert!(enabled.contains("<button class=\"torrent-import-btn\""));
|
||||||
|
assert!(enabled.contains("downloadsEnabled: true"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -4318,9 +4397,25 @@ async fn load_track_items_by_ids(pool: &sqlx::PgPool, ids: &[i64]) -> cot::Resul
|
|||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct SimilaritySearchResponse {
|
struct SimilaritySearchResponse {
|
||||||
label: String,
|
label: String,
|
||||||
tracks: Vec<TrackItem>,
|
tracks: Vec<ScoredSimilarityTrack>,
|
||||||
federation_tracks: Vec<crate::federation::client::TrackDto>,
|
federation_tracks: Vec<crate::federation::client::TrackDto>,
|
||||||
federation_error: Option<String>,
|
federation_error: Option<String>,
|
||||||
|
queried_peers: usize,
|
||||||
|
elapsed_ms: u64,
|
||||||
|
complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ScoredSimilarityTrack {
|
||||||
|
#[serde(flatten)]
|
||||||
|
track: TrackItem,
|
||||||
|
similarity_score: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SimilaritySearchQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
local_only: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn similarity_search_handler(
|
async fn similarity_search_handler(
|
||||||
@@ -4329,7 +4424,9 @@ async fn similarity_search_handler(
|
|||||||
db: Database,
|
db: Database,
|
||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
Path(path): Path<PathId>,
|
Path(path): Path<PathId>,
|
||||||
|
options: cot::request::extractors::UrlQuery<SimilaritySearchQuery>,
|
||||||
) -> cot::Result<cot::response::Response> {
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let started = std::time::Instant::now();
|
||||||
let Some(_user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
let Some(_user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||||
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
};
|
};
|
||||||
@@ -4385,28 +4482,48 @@ async fn similarity_search_handler(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|track| track.track_id)
|
.map(|track| track.track_id)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let mut tracks = Vec::with_capacity(ids.len() + 1);
|
let scores: HashMap<i64, f32> = ranked
|
||||||
tracks.push(source_track.clone());
|
.iter()
|
||||||
tracks.extend(load_track_items_by_ids(pool, &ids).await?);
|
.map(|track| (track.track_id, track.score))
|
||||||
|
.collect();
|
||||||
|
let mut local_tracks = Vec::with_capacity(ids.len() + 1);
|
||||||
|
local_tracks.push(source_track.clone());
|
||||||
|
local_tracks.extend(load_track_items_by_ids(pool, &ids).await?);
|
||||||
|
let tracks = local_tracks
|
||||||
|
.into_iter()
|
||||||
|
.map(|track| ScoredSimilarityTrack {
|
||||||
|
similarity_score: if track.id == path.id {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
scores.get(&track.id).copied().unwrap_or_default()
|
||||||
|
},
|
||||||
|
track,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
let (federation_tracks, federation_error) = if config.federation_enabled {
|
let (federation_tracks, federation_error, queried_peers) =
|
||||||
match crate::federation::handle()
|
if config.federation_enabled && !options.0.local_only {
|
||||||
.search_similarity(query, 50)
|
match crate::federation::handle()
|
||||||
.await
|
.search_similarity(query, 50)
|
||||||
{
|
|
||||||
Ok(remote) => match crate::federation::handle()
|
|
||||||
.prepare_similarity_tracks(remote)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(tracks) => (tracks, None),
|
Ok(outcome) => match crate::federation::handle()
|
||||||
Err(error) => (Vec::new(), Some(format!("{error:#}"))),
|
.prepare_similarity_tracks(outcome.tracks)
|
||||||
},
|
.await
|
||||||
Err(error) => (Vec::new(), Some(format!("{error:#}"))),
|
{
|
||||||
}
|
Ok(tracks) => (tracks, None, outcome.queried_peers),
|
||||||
} else {
|
Err(error) => (
|
||||||
(Vec::new(), None)
|
Vec::new(),
|
||||||
};
|
Some(format!("{error:#}")),
|
||||||
|
outcome.queried_peers,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Err(error) => (Vec::new(), Some(format!("{error:#}")), 0),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(Vec::new(), None, 0)
|
||||||
|
};
|
||||||
let artists = source_track
|
let artists = source_track
|
||||||
.artists
|
.artists
|
||||||
.iter()
|
.iter()
|
||||||
@@ -4423,6 +4540,9 @@ async fn similarity_search_handler(
|
|||||||
tracks,
|
tracks,
|
||||||
federation_tracks,
|
federation_tracks,
|
||||||
federation_error,
|
federation_error,
|
||||||
|
queried_peers,
|
||||||
|
elapsed_ms: started.elapsed().as_millis() as u64,
|
||||||
|
complete: !options.0.local_only,
|
||||||
})
|
})
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
@@ -4789,12 +4909,16 @@ async fn local_upload_handler(
|
|||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
|
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
|
||||||
request: cot::request::Request,
|
request: cot::request::Request,
|
||||||
) -> cot::Result<cot::http::Response<Body>> {
|
) -> cot::Result<cot::http::Response<Body>> {
|
||||||
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||||
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
};
|
};
|
||||||
|
if let Err(response) = require_download_method(&config, DownloadMethod::LocalFile) {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
let inbox_dir = config.agent_inbox_dir.trim();
|
let inbox_dir = config.agent_inbox_dir.trim();
|
||||||
if inbox_dir.is_empty() {
|
if inbox_dir.is_empty() {
|
||||||
@@ -4821,6 +4945,14 @@ async fn local_upload_handler(
|
|||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.unwrap_or_else(|| "upload.mp3".to_string());
|
.unwrap_or_else(|| "upload.mp3".to_string());
|
||||||
let filename = sanitize_upload_filename(&original_name);
|
let filename = sanitize_upload_filename(&original_name);
|
||||||
|
let upload_id_header = HeaderName::from_static("x-furumusic-upload-id");
|
||||||
|
let upload_id = request
|
||||||
|
.headers()
|
||||||
|
.get(upload_id_header)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| uuid::Uuid::parse_str(value.trim()).ok())
|
||||||
|
.unwrap_or_else(uuid::Uuid::new_v4)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let bytes = request
|
let bytes = request
|
||||||
.into_body()
|
.into_body()
|
||||||
@@ -4837,14 +4969,53 @@ async fn local_upload_handler(
|
|||||||
let upload_dir = inbox_root
|
let upload_dir = inbox_root
|
||||||
.join("user_uploads")
|
.join("user_uploads")
|
||||||
.join(user.id.to_string())
|
.join(user.id.to_string())
|
||||||
.join(format!("local-{}", uuid::Uuid::new_v4()));
|
.join(format!("local-{upload_id}"));
|
||||||
tokio::fs::create_dir_all(&upload_dir)
|
|
||||||
.await
|
|
||||||
.map_err(|err| cot::Error::internal(err.to_string()))?;
|
|
||||||
let destination = upload_dir.join(&filename);
|
let destination = upload_dir.join(&filename);
|
||||||
tokio::fs::write(&destination, &bytes)
|
let Some(inbox_path) =
|
||||||
.await
|
crate::media_paths::path_for_root(&inbox_root.to_string_lossy(), &destination)
|
||||||
.map_err(|err| cot::Error::internal(err.to_string()))?;
|
else {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"upload destination escaped agent_inbox_dir",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if let Err(err) = crate::local_uploads::create(
|
||||||
|
pool,
|
||||||
|
&upload_id,
|
||||||
|
user.id,
|
||||||
|
&filename,
|
||||||
|
bytes.len() as u64,
|
||||||
|
&inbox_path,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let temporary = upload_dir.join(format!(".{upload_id}.uploading"));
|
||||||
|
let write_result = async {
|
||||||
|
tokio::fs::create_dir_all(&upload_dir).await?;
|
||||||
|
tokio::fs::write(&temporary, &bytes).await?;
|
||||||
|
tokio::fs::rename(&temporary, &destination).await?;
|
||||||
|
Ok::<(), std::io::Error>(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
if let Err(err) = write_result {
|
||||||
|
let message = format!("could not save uploaded file: {err}");
|
||||||
|
let _ = tokio::fs::remove_dir_all(&upload_dir).await;
|
||||||
|
let _ = crate::local_uploads::mark_failed(pool, &upload_id, user.id, &message).await;
|
||||||
|
return Ok(json_error(StatusCode::INTERNAL_SERVER_ERROR, &message));
|
||||||
|
}
|
||||||
|
|
||||||
|
let upload = match crate::local_uploads::mark_queued(pool, &upload_id, user.id).await {
|
||||||
|
Ok(upload) => upload,
|
||||||
|
Err(err) => {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&err.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(handle) = scheduler_handle.get() {
|
if let Some(handle) = scheduler_handle.get() {
|
||||||
let handle = Arc::clone(handle);
|
let handle = Arc::clone(handle);
|
||||||
@@ -4855,12 +5026,46 @@ async fn local_upload_handler(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Json(LocalUploadResponse {
|
Json(LocalUploadResponse { ok: true, upload }).into_response()
|
||||||
ok: true,
|
}
|
||||||
filename,
|
|
||||||
size: bytes.len() as u64,
|
async fn local_upload_history_handler(
|
||||||
})
|
auth_ctx: auth::AuthContext,
|
||||||
.into_response()
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||||
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
|
};
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
if let Err(response) = require_download_method(&config, DownloadMethod::LocalFile) {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
match crate::local_uploads::list(pool, user.id, &config.agent_inbox_dir).await {
|
||||||
|
Ok(items) => Json(items).into_response(),
|
||||||
|
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn local_upload_history_remove_handler(
|
||||||
|
auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
path: Path<PathStringId>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||||
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
|
};
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
if let Err(response) = require_download_method(&config, DownloadMethod::LocalFile) {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
match crate::local_uploads::remove(pool, user.id, &path.0.id).await {
|
||||||
|
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||||
|
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sanitize_upload_filename(value: &str) -> String {
|
fn sanitize_upload_filename(value: &str) -> String {
|
||||||
@@ -8066,6 +8271,8 @@ impl App for PlayerApp {
|
|||||||
let pool: Arc<tokio::sync::OnceCell<sqlx::PgPool>> = Arc::new(tokio::sync::OnceCell::new());
|
let pool: Arc<tokio::sync::OnceCell<sqlx::PgPool>> = Arc::new(tokio::sync::OnceCell::new());
|
||||||
let torrent_service: Arc<tokio::sync::OnceCell<Arc<TorrentService>>> =
|
let torrent_service: Arc<tokio::sync::OnceCell<Arc<TorrentService>>> =
|
||||||
Arc::new(tokio::sync::OnceCell::new());
|
Arc::new(tokio::sync::OnceCell::new());
|
||||||
|
let youtube_service: Arc<tokio::sync::OnceCell<Arc<YouTubeService>>> =
|
||||||
|
Arc::new(tokio::sync::OnceCell::new());
|
||||||
let device_hub = Arc::clone(&self.device_hub);
|
let device_hub = Arc::clone(&self.device_hub);
|
||||||
|
|
||||||
Router::with_urls([
|
Router::with_urls([
|
||||||
@@ -8304,6 +8511,365 @@ impl App for PlayerApp {
|
|||||||
},
|
},
|
||||||
"player_agent_queue",
|
"player_agent_queue",
|
||||||
),
|
),
|
||||||
|
// -- YouTube downloads --
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/youtube/preview",
|
||||||
|
{
|
||||||
|
let youtube_service = Arc::clone(&youtube_service);
|
||||||
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
||||||
|
post(
|
||||||
|
move |auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
json: Json<YouTubePreviewRequest>| {
|
||||||
|
let youtube_service = Arc::clone(&youtube_service);
|
||||||
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||||
|
async move {
|
||||||
|
if auth::get_request_user(&auth_ctx, &session, &db)
|
||||||
|
.await
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"not authenticated",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let proxy_url = match download_proxy_for(
|
||||||
|
&live_config,
|
||||||
|
DownloadMethod::YouTube,
|
||||||
|
) {
|
||||||
|
Ok(proxy_url) => proxy_url,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
let service = youtube_service
|
||||||
|
.get_or_init(|| async {
|
||||||
|
Arc::new(YouTubeService::new(Arc::clone(&scheduler_handle)))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match service.preview(json.0, proxy_url.as_deref()).await {
|
||||||
|
Ok(preview) => Json(preview).into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"player_youtube_preview",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/youtube",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let youtube_service = Arc::clone(&youtube_service);
|
||||||
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
||||||
|
get(
|
||||||
|
move |auth_ctx: auth::AuthContext, session: Session, db: Database| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let youtube_service = Arc::clone(&youtube_service);
|
||||||
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||||
|
async move {
|
||||||
|
let Some(user) =
|
||||||
|
auth::get_request_user(&auth_ctx, &session, &db).await
|
||||||
|
else {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"not authenticated",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let service = youtube_service
|
||||||
|
.get_or_init(|| async {
|
||||||
|
Arc::new(YouTubeService::new(Arc::clone(&scheduler_handle)))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let proxy_url = match download_proxy_for(
|
||||||
|
&live_config,
|
||||||
|
DownloadMethod::YouTube,
|
||||||
|
) {
|
||||||
|
Ok(proxy_url) => proxy_url,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
match service
|
||||||
|
.list(
|
||||||
|
pg_pool,
|
||||||
|
user.id,
|
||||||
|
&live_config.agent_inbox_dir,
|
||||||
|
proxy_url,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(items) => Json(items).into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"player_youtube_list",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/youtube/start",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let youtube_service = Arc::clone(&youtube_service);
|
||||||
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
||||||
|
post(
|
||||||
|
move |auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
json: Json<YouTubeStartRequest>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let youtube_service = Arc::clone(&youtube_service);
|
||||||
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||||
|
async move {
|
||||||
|
let Some(user) =
|
||||||
|
auth::get_request_user(&auth_ctx, &session, &db).await
|
||||||
|
else {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"not authenticated",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let service = youtube_service
|
||||||
|
.get_or_init(|| async {
|
||||||
|
Arc::new(YouTubeService::new(Arc::clone(&scheduler_handle)))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let proxy_url = match download_proxy_for(
|
||||||
|
&live_config,
|
||||||
|
DownloadMethod::YouTube,
|
||||||
|
) {
|
||||||
|
Ok(proxy_url) => proxy_url,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
match service
|
||||||
|
.start(
|
||||||
|
pg_pool,
|
||||||
|
user.id,
|
||||||
|
json.0,
|
||||||
|
&live_config.agent_inbox_dir,
|
||||||
|
proxy_url,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(job) => Json(job).into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"player_youtube_start",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/youtube/{id}/retry",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let youtube_service = Arc::clone(&youtube_service);
|
||||||
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
||||||
|
post(
|
||||||
|
move |auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
path: Path<PathStringId>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let youtube_service = Arc::clone(&youtube_service);
|
||||||
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||||
|
async move {
|
||||||
|
let Some(user) =
|
||||||
|
auth::get_request_user(&auth_ctx, &session, &db).await
|
||||||
|
else {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"not authenticated",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let service = youtube_service
|
||||||
|
.get_or_init(|| async {
|
||||||
|
Arc::new(YouTubeService::new(Arc::clone(&scheduler_handle)))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let proxy_url = match download_proxy_for(
|
||||||
|
&live_config,
|
||||||
|
DownloadMethod::YouTube,
|
||||||
|
) {
|
||||||
|
Ok(proxy_url) => proxy_url,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
match service
|
||||||
|
.retry(
|
||||||
|
pg_pool,
|
||||||
|
user.id,
|
||||||
|
&path.0.id,
|
||||||
|
&live_config.agent_inbox_dir,
|
||||||
|
proxy_url,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(job) => Json(job).into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"player_youtube_retry",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/youtube/{id}/cancel",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let youtube_service = Arc::clone(&youtube_service);
|
||||||
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
||||||
|
post(
|
||||||
|
move |auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
path: Path<PathStringId>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let youtube_service = Arc::clone(&youtube_service);
|
||||||
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||||
|
async move {
|
||||||
|
let Some(user) =
|
||||||
|
auth::get_request_user(&auth_ctx, &session, &db).await
|
||||||
|
else {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"not authenticated",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let service = youtube_service
|
||||||
|
.get_or_init(|| async {
|
||||||
|
Arc::new(YouTubeService::new(Arc::clone(&scheduler_handle)))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match service.cancel(pg_pool, user.id, &path.0.id).await {
|
||||||
|
Ok(job) => Json(job).into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"player_youtube_cancel",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/youtube/{id}",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let youtube_service = Arc::clone(&youtube_service);
|
||||||
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
||||||
|
delete(
|
||||||
|
move |auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
path: Path<PathStringId>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let youtube_service = Arc::clone(&youtube_service);
|
||||||
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||||
|
async move {
|
||||||
|
let Some(user) =
|
||||||
|
auth::get_request_user(&auth_ctx, &session, &db).await
|
||||||
|
else {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"not authenticated",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let service = youtube_service
|
||||||
|
.get_or_init(|| async {
|
||||||
|
Arc::new(YouTubeService::new(Arc::clone(&scheduler_handle)))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
match service
|
||||||
|
.remove(
|
||||||
|
pg_pool,
|
||||||
|
user.id,
|
||||||
|
&path.0.id,
|
||||||
|
&live_config.agent_inbox_dir,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"player_youtube_remove",
|
||||||
|
),
|
||||||
// -- Torrent import widget --
|
// -- Torrent import widget --
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/torrents",
|
"/torrents",
|
||||||
@@ -8336,12 +8902,20 @@ impl App for PlayerApp {
|
|||||||
.expect("player pool")
|
.expect("player pool")
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let proxy_url = match download_proxy_for(
|
||||||
|
&live_config,
|
||||||
|
DownloadMethod::Torrent,
|
||||||
|
) {
|
||||||
|
Ok(proxy_url) => proxy_url,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
let service = torrent_service
|
let service = torrent_service
|
||||||
.get_or_init(|| async {
|
.get_or_init(|| async {
|
||||||
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
match service.list(pg_pool, user.id).await {
|
match service.list(pg_pool, user.id, proxy_url).await {
|
||||||
Ok(items) => Json(items).into_response(),
|
Ok(items) => Json(items).into_response(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
||||||
@@ -8485,12 +9059,23 @@ impl App for PlayerApp {
|
|||||||
.expect("player pool")
|
.expect("player pool")
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let proxy_url = match download_proxy_for(
|
||||||
|
&live_config,
|
||||||
|
DownloadMethod::Torrent,
|
||||||
|
) {
|
||||||
|
Ok(proxy_url) => proxy_url,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
let service = torrent_service
|
let service = torrent_service
|
||||||
.get_or_init(|| async {
|
.get_or_init(|| async {
|
||||||
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
match service.preview(pg_pool, user.id, json.0).await {
|
match service
|
||||||
|
.preview(pg_pool, user.id, json.0, proxy_url.as_deref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(preview) => Json(preview).into_response(),
|
Ok(preview) => Json(preview).into_response(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
||||||
@@ -8505,20 +9090,34 @@ impl App for PlayerApp {
|
|||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/uploads/local",
|
"/uploads/local",
|
||||||
{
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
||||||
post(
|
post(
|
||||||
move |auth_ctx: auth::AuthContext,
|
move |auth_ctx: auth::AuthContext,
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
request: cot::request::Request| {
|
request: cot::request::Request| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
let scheduler_handle = Arc::clone(&scheduler_handle);
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||||
async move {
|
async move {
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
local_upload_handler(
|
local_upload_handler(
|
||||||
auth_ctx,
|
auth_ctx,
|
||||||
session,
|
session,
|
||||||
db,
|
db,
|
||||||
live_config,
|
live_config,
|
||||||
|
pg_pool,
|
||||||
scheduler_handle,
|
scheduler_handle,
|
||||||
request,
|
request,
|
||||||
)
|
)
|
||||||
@@ -8529,6 +9128,60 @@ impl App for PlayerApp {
|
|||||||
},
|
},
|
||||||
"player_local_upload",
|
"player_local_upload",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/uploads/local/history",
|
||||||
|
get({
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
move |auth_ctx: auth::AuthContext, session: Session, db: Database| {
|
||||||
|
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("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
local_upload_history_handler(auth_ctx, session, db, pg_pool).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"player_local_upload_history",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/uploads/local/history/{id}",
|
||||||
|
delete({
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
move |auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
path: Path<PathStringId>| {
|
||||||
|
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("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
local_upload_history_remove_handler(
|
||||||
|
auth_ctx, session, db, pg_pool, path,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"player_local_upload_history_remove",
|
||||||
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/uploads/tracks",
|
"/uploads/tracks",
|
||||||
get({
|
get({
|
||||||
@@ -8774,6 +9427,13 @@ impl App for PlayerApp {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let proxy_url = match download_proxy_for(
|
||||||
|
&live_config,
|
||||||
|
DownloadMethod::Torrent,
|
||||||
|
) {
|
||||||
|
Ok(proxy_url) => proxy_url,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
let service = torrent_service
|
let service = torrent_service
|
||||||
.get_or_init(|| async {
|
.get_or_init(|| async {
|
||||||
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
||||||
@@ -8786,6 +9446,7 @@ impl App for PlayerApp {
|
|||||||
json.0.selected_files,
|
json.0.selected_files,
|
||||||
live_config.agent_inbox_dir,
|
live_config.agent_inbox_dir,
|
||||||
user.id,
|
user.id,
|
||||||
|
proxy_url.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -9934,7 +10595,8 @@ impl App for PlayerApp {
|
|||||||
move |auth_ctx: auth::AuthContext,
|
move |auth_ctx: auth::AuthContext,
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
path: Path<PathId>| {
|
path: Path<PathId>,
|
||||||
|
query: cot::request::extractors::UrlQuery<SimilaritySearchQuery>| {
|
||||||
let pool = Arc::clone(&pool);
|
let pool = Arc::clone(&pool);
|
||||||
let pool_config = Arc::clone(&pool_config);
|
let pool_config = Arc::clone(&pool_config);
|
||||||
async move {
|
async move {
|
||||||
@@ -9947,7 +10609,7 @@ impl App for PlayerApp {
|
|||||||
.expect("player pool")
|
.expect("player pool")
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
similarity_search_handler(auth_ctx, session, db, pg_pool, path).await
|
similarity_search_handler(auth_ctx, session, db, pg_pool, path, query).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
+160
-1
@@ -7,6 +7,7 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||||
use std::sync::{Arc, Mutex, OnceLock, RwLock};
|
use std::sync::{Arc, Mutex, OnceLock, RwLock};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -244,10 +245,61 @@ impl Manager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let mut effective = config.clone();
|
||||||
|
let mut rows = None;
|
||||||
|
for attempt in 0..20 {
|
||||||
|
match sqlx::query(
|
||||||
|
"SELECT key, value FROM furumusic__config_entry
|
||||||
|
WHERE key IN ('similarity_enabled', 'similarity_model',
|
||||||
|
'similarity_profile', 'similarity_workers',
|
||||||
|
'agent_storage_dir')",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(loaded) => {
|
||||||
|
rows = Some(loaded);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(error) if attempt < 19 => {
|
||||||
|
tracing::debug!(attempt, %error, "similarity boot: settings table not ready");
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "similarity boot: database settings unavailable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for row in rows.unwrap_or_default() {
|
||||||
|
let key: String = row.get(0);
|
||||||
|
let value: String = row.get(1);
|
||||||
|
let env_key = format!("FURU_{}", key.to_ascii_uppercase());
|
||||||
|
if std::env::var(&env_key).is_ok() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match key.as_str() {
|
||||||
|
"similarity_enabled" => {
|
||||||
|
if let Ok(parsed) = value.parse() {
|
||||||
|
effective.similarity_enabled = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"similarity_model" => effective.similarity_model = value,
|
||||||
|
"similarity_profile" => effective.similarity_profile = value,
|
||||||
|
"similarity_workers" => {
|
||||||
|
if let Ok(parsed) = value.parse() {
|
||||||
|
effective.similarity_workers = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"agent_storage_dir" => {
|
||||||
|
effective.agent_storage_dir = crate::media_paths::resolve_config_path(&value);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Err(error) = self.restore_stored_status(&pool).await {
|
if let Err(error) = self.restore_stored_status(&pool).await {
|
||||||
tracing::warn!(%error, "similarity boot: stored status unavailable");
|
tracing::warn!(%error, "similarity boot: stored status unavailable");
|
||||||
}
|
}
|
||||||
self.apply(config);
|
self.apply(&effective);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply(self: &Arc<Self>, config: &AppConfig) {
|
pub fn apply(self: &Arc<Self>, config: &AppConfig) {
|
||||||
@@ -1143,6 +1195,23 @@ fn decode_mono_window(
|
|||||||
path: &Path,
|
path: &Path,
|
||||||
start_seconds: f64,
|
start_seconds: f64,
|
||||||
length_seconds: Option<f64>,
|
length_seconds: Option<f64>,
|
||||||
|
) -> Result<Vec<f32>> {
|
||||||
|
match decode_mono_window_native(path, start_seconds, length_seconds) {
|
||||||
|
Ok(samples) => Ok(samples),
|
||||||
|
Err(native_error) => decode_mono_window_ffmpeg(path, start_seconds, length_seconds)
|
||||||
|
.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"native decoder failed for {} ({native_error:#}); FFmpeg fallback failed",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_mono_window_native(
|
||||||
|
path: &Path,
|
||||||
|
start_seconds: f64,
|
||||||
|
length_seconds: Option<f64>,
|
||||||
) -> Result<Vec<f32>> {
|
) -> Result<Vec<f32>> {
|
||||||
let file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
|
let file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
|
||||||
let mut decoder =
|
let mut decoder =
|
||||||
@@ -1178,6 +1247,64 @@ fn decode_mono_window(
|
|||||||
Ok(resample_sinc(&mono, source_rate, SAMPLE_RATE))
|
Ok(resample_sinc(&mono, source_rate, SAMPLE_RATE))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn decode_mono_window_ffmpeg(
|
||||||
|
path: &Path,
|
||||||
|
start_seconds: f64,
|
||||||
|
length_seconds: Option<f64>,
|
||||||
|
) -> Result<Vec<f32>> {
|
||||||
|
let mut command = Command::new("ffmpeg");
|
||||||
|
command.arg("-v").arg("error").arg("-nostdin");
|
||||||
|
if start_seconds > 0.0 {
|
||||||
|
command.arg("-ss").arg(format!("{start_seconds:.6}"));
|
||||||
|
}
|
||||||
|
command.arg("-i").arg(path);
|
||||||
|
if let Some(length_seconds) = length_seconds {
|
||||||
|
command.arg("-t").arg(format!("{length_seconds:.6}"));
|
||||||
|
}
|
||||||
|
let output = command
|
||||||
|
.arg("-map")
|
||||||
|
.arg("0:a:0")
|
||||||
|
.arg("-vn")
|
||||||
|
.arg("-sn")
|
||||||
|
.arg("-dn")
|
||||||
|
.arg("-ac")
|
||||||
|
.arg("1")
|
||||||
|
.arg("-ar")
|
||||||
|
.arg(SAMPLE_RATE.to_string())
|
||||||
|
.arg("-f")
|
||||||
|
.arg("f32le")
|
||||||
|
.arg("pipe:1")
|
||||||
|
.output()
|
||||||
|
.with_context(|| "starting FFmpeg; install FFmpeg to decode this audio format")?;
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
let detail = stderr
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.rfind(|line| !line.is_empty())
|
||||||
|
.unwrap_or("unknown FFmpeg error");
|
||||||
|
anyhow::bail!("FFmpeg exited with {}: {detail}", output.status);
|
||||||
|
}
|
||||||
|
anyhow::ensure!(
|
||||||
|
output
|
||||||
|
.stdout
|
||||||
|
.len()
|
||||||
|
.is_multiple_of(std::mem::size_of::<f32>()),
|
||||||
|
"FFmpeg returned a truncated f32le stream"
|
||||||
|
);
|
||||||
|
let samples: Vec<f32> = output
|
||||||
|
.stdout
|
||||||
|
.chunks_exact(std::mem::size_of::<f32>())
|
||||||
|
.map(|bytes| f32::from_le_bytes(bytes.try_into().expect("four-byte sample")))
|
||||||
|
.collect();
|
||||||
|
anyhow::ensure!(!samples.is_empty(), "FFmpeg decoded track is empty");
|
||||||
|
anyhow::ensure!(
|
||||||
|
samples.iter().all(|sample| sample.is_finite()),
|
||||||
|
"FFmpeg decoded non-finite samples"
|
||||||
|
);
|
||||||
|
Ok(samples)
|
||||||
|
}
|
||||||
|
|
||||||
fn resample_sinc(input: &[f32], source_rate: usize, target_rate: usize) -> Vec<f32> {
|
fn resample_sinc(input: &[f32], source_rate: usize, target_rate: usize) -> Vec<f32> {
|
||||||
if input.len() < 2 || source_rate == 0 {
|
if input.len() < 2 || source_rate == 0 {
|
||||||
return input.to_vec();
|
return input.to_vec();
|
||||||
@@ -1407,4 +1534,36 @@ mod tests {
|
|||||||
assert_eq!(output.len(), 160);
|
assert_eq!(output.len(), 160);
|
||||||
assert!(output.iter().all(|value| (*value - 0.25).abs() < 1e-6));
|
assert!(output.iter().all(|value| (*value - 0.25).abs() < 1e-6));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_opus_with_ffmpeg_fallback() {
|
||||||
|
if Command::new("ffmpeg").arg("-version").output().is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"furumusic-similarity-{}.opus",
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
let generated = Command::new("ffmpeg")
|
||||||
|
.args([
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
"sine=frequency=440:sample_rate=48000:duration=0.25",
|
||||||
|
"-c:a",
|
||||||
|
"libopus",
|
||||||
|
"-y",
|
||||||
|
])
|
||||||
|
.arg(&path)
|
||||||
|
.status()
|
||||||
|
.expect("start FFmpeg fixture generation");
|
||||||
|
assert!(generated.success(), "generate Opus fixture");
|
||||||
|
|
||||||
|
let decoded = decode_mono_window(&path, 0.0, None).expect("decode Opus with fallback");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
assert!(decoded.len() >= SAMPLE_RATE / 5);
|
||||||
|
assert!(decoded.iter().all(|sample| sample.is_finite()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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
+1090
-88
File diff suppressed because it is too large
Load Diff
+278
-29
@@ -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>
|
||||||
@@ -825,7 +1074,7 @@
|
|||||||
@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>
|
||||||
|
|||||||
+714
-43
@@ -136,6 +136,42 @@ const T = {
|
|||||||
openTorrentFailed: "{{ t.player_open_torrent_failed }}",
|
openTorrentFailed: "{{ t.player_open_torrent_failed }}",
|
||||||
deleteTorrentFailed: "{{ t.player_delete_torrent_failed }}",
|
deleteTorrentFailed: "{{ t.player_delete_torrent_failed }}",
|
||||||
loadAiQueueFailed: "{{ t.player_load_ai_queue_failed }}",
|
loadAiQueueFailed: "{{ t.player_load_ai_queue_failed }}",
|
||||||
|
fileUploadLoadFailed: "{{ t.player_file_upload_load_failed }}",
|
||||||
|
removeFileUploadConfirm: "{{ t.player_remove_file_upload_confirm }}",
|
||||||
|
fileUploadHistoryRemoved: "{{ t.player_file_upload_history_removed }}",
|
||||||
|
fileUploadHistoryRemoveFailed: "{{ t.player_file_upload_history_remove_failed }}",
|
||||||
|
noSupportedAudioFiles: "{{ t.player_no_supported_audio_files }}",
|
||||||
|
youtubeQueued: "{{ t.player_youtube_queued }}",
|
||||||
|
youtubeResolving: "{{ t.player_youtube_resolving }}",
|
||||||
|
youtubePostprocessing: "{{ t.player_youtube_postprocessing }}",
|
||||||
|
youtubeAwaitingAi: "{{ t.player_youtube_awaiting_ai }}",
|
||||||
|
youtubeAiProcessing: "{{ t.player_youtube_ai_processing }}",
|
||||||
|
youtubeNeedsReview: "{{ t.player_youtube_needs_review }}",
|
||||||
|
youtubeCompleteWithErrors: "{{ t.player_youtube_complete_with_errors }}",
|
||||||
|
youtubeSkipped: "{{ t.player_youtube_skipped }}",
|
||||||
|
youtubeStarting: "{{ t.player_youtube_starting }}",
|
||||||
|
youtubeStarted: "{{ t.player_youtube_started }}",
|
||||||
|
youtubeLoadFailed: "{{ t.player_youtube_load_failed }}",
|
||||||
|
youtubeStartFailed: "{{ t.player_youtube_start_failed }}",
|
||||||
|
youtubeRetryFailed: "{{ t.player_youtube_retry_failed }}",
|
||||||
|
youtubeDeleteFailed: "{{ t.player_youtube_delete_failed }}",
|
||||||
|
youtubeDeleteConfirm: "{{ t.player_youtube_delete_confirm }}",
|
||||||
|
youtubeParse: "{{ t.player_youtube_parse }}",
|
||||||
|
youtubeParsing: "{{ t.player_youtube_parsing }}",
|
||||||
|
youtubePreviewFailed: "{{ t.player_youtube_preview_failed }}",
|
||||||
|
youtubeSelectAll: "{{ t.player_youtube_select_all }}",
|
||||||
|
youtubeClearSelection: "{{ t.player_youtube_clear_selection }}",
|
||||||
|
youtubeSelectedCount: "{{ t.player_youtube_selected_count }}",
|
||||||
|
youtubeCancelled: "{{ t.player_youtube_cancelled }}",
|
||||||
|
youtubeStopConfirm: "{{ t.player_youtube_stop_confirm }}",
|
||||||
|
youtubeStopping: "{{ t.player_youtube_stopping }}",
|
||||||
|
youtubeStopped: "{{ t.player_youtube_stopped }}",
|
||||||
|
youtubeStopFailed: "{{ t.player_youtube_stop_failed }}",
|
||||||
|
youtubeVideo: "{{ t.player_youtube_video }}",
|
||||||
|
youtubePlaylist: "{{ t.player_youtube_playlist }}",
|
||||||
|
youtubeItems: "{{ t.player_youtube_items }}",
|
||||||
|
youtubeErrors: "{{ t.player_youtube_errors }}",
|
||||||
|
chapters: "{{ t.player_chapters }}",
|
||||||
deletePlaylistConfirm: "{{ t.player_delete_playlist_confirm }}",
|
deletePlaylistConfirm: "{{ t.player_delete_playlist_confirm }}",
|
||||||
albums: "{{ t.player_albums }}",
|
albums: "{{ t.player_albums }}",
|
||||||
eps: "{{ t.player_eps }}",
|
eps: "{{ t.player_eps }}",
|
||||||
@@ -2198,6 +2234,8 @@ document.addEventListener('alpine:init', () => {
|
|||||||
_dragOverIdx: null,
|
_dragOverIdx: null,
|
||||||
_pointerDragMove: null,
|
_pointerDragMove: null,
|
||||||
_pointerDragEnd: null,
|
_pointerDragEnd: null,
|
||||||
|
_playNextGroupId: null,
|
||||||
|
_playNextGroupSequence: 0,
|
||||||
|
|
||||||
add(track) {
|
add(track) {
|
||||||
this.addToEnd([track]);
|
this.addToEnd([track]);
|
||||||
@@ -2210,13 +2248,21 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
effectiveCurrentIndex() {
|
effectiveCurrentIndex() {
|
||||||
const currentTrack = Alpine.store('player')?.currentTrack || null;
|
const currentTrack = Alpine.store('player')?.currentTrack || null;
|
||||||
if (currentTrack?.id) {
|
const currentKey = this._trackIdentity(currentTrack);
|
||||||
return this.tracks.findIndex(track => Number(track?.id) === Number(currentTrack.id));
|
if (currentKey) {
|
||||||
|
const index = this.tracks.findIndex(track => this._trackIdentity(track) === currentKey);
|
||||||
|
if (index >= 0) return index;
|
||||||
}
|
}
|
||||||
if (!this.tracks.length) return -1;
|
if (!this.tracks.length) return -1;
|
||||||
return Math.max(0, Math.min(Number(this.currentIndex || 0), this.tracks.length - 1));
|
return Math.max(0, Math.min(Number(this.currentIndex || 0), this.tracks.length - 1));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_trackIdentity(track) {
|
||||||
|
if (track?.content_id) return `content:${track.content_id}`;
|
||||||
|
if (track?.id != null && track.id !== '') return `id:${String(track.id)}`;
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
|
||||||
queueItemState(index) {
|
queueItemState(index) {
|
||||||
const current = this.effectiveCurrentIndex();
|
const current = this.effectiveCurrentIndex();
|
||||||
if (current < 0) return 'upcoming';
|
if (current < 0) return 'upcoming';
|
||||||
@@ -2252,8 +2298,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
syncCurrentIndexToTrack(track) {
|
syncCurrentIndexToTrack(track) {
|
||||||
if (!track?.id || !this.tracks.length) return -1;
|
const key = this._trackIdentity(track);
|
||||||
const index = this.tracks.findIndex(item => Number(item?.id) === Number(track.id));
|
if (!key || !this.tracks.length) return -1;
|
||||||
|
const index = this.tracks.findIndex(item => this._trackIdentity(item) === key);
|
||||||
if (index >= 0) this.currentIndex = index;
|
if (index >= 0) this.currentIndex = index;
|
||||||
return index;
|
return index;
|
||||||
},
|
},
|
||||||
@@ -2280,6 +2327,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
playRelease(tracks, startIndex) {
|
playRelease(tracks, startIndex) {
|
||||||
this.tracks = this._tracksForQueueAdd(tracks);
|
this.tracks = this._tracksForQueueAdd(tracks);
|
||||||
|
this._playNextGroupId = null;
|
||||||
this.playFromIndex(startIndex || 0);
|
this.playFromIndex(startIndex || 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -2447,8 +2495,35 @@ document.addEventListener('alpine:init', () => {
|
|||||||
_addNextLocal(tracks) {
|
_addNextLocal(tracks) {
|
||||||
const items = this._tracksWithJamDefaults(tracks);
|
const items = this._tracksWithJamDefaults(tracks);
|
||||||
if (!items.length) return;
|
if (!items.length) return;
|
||||||
const insertAt = Math.min(this.currentIndex + 1, this.tracks.length);
|
const current = this.effectiveCurrentIndex();
|
||||||
this.tracks.splice(insertAt, 0, ...items);
|
let insertAt = Math.min(Math.max(0, current + 1), this.tracks.length);
|
||||||
|
let groupId = this._playNextGroupId
|
||||||
|
|| this.tracks[insertAt]?._playNextGroupId
|
||||||
|
|| null;
|
||||||
|
if (groupId) this._playNextGroupId = groupId;
|
||||||
|
if (groupId) {
|
||||||
|
let lastGroupIndex = -1;
|
||||||
|
for (let index = current; index < this.tracks.length; index++) {
|
||||||
|
if (this.tracks[index]?._playNextGroupId === groupId) {
|
||||||
|
lastGroupIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastGroupIndex >= current) {
|
||||||
|
insertAt = lastGroupIndex + 1;
|
||||||
|
} else {
|
||||||
|
groupId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!groupId) {
|
||||||
|
this._playNextGroupSequence += 1;
|
||||||
|
groupId = `next-${Date.now()}-${this._playNextGroupSequence}`;
|
||||||
|
this._playNextGroupId = groupId;
|
||||||
|
}
|
||||||
|
const groupedItems = items.map(item => ({
|
||||||
|
...item,
|
||||||
|
_playNextGroupId: groupId,
|
||||||
|
}));
|
||||||
|
this.tracks.splice(insertAt, 0, ...groupedItems);
|
||||||
},
|
},
|
||||||
|
|
||||||
_removeLocal(idx) {
|
_removeLocal(idx) {
|
||||||
@@ -2471,6 +2546,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
if (toIdx < 0 || toIdx >= this.tracks.length) return;
|
if (toIdx < 0 || toIdx >= this.tracks.length) return;
|
||||||
const [track] = this.tracks.splice(fromIdx, 1);
|
const [track] = this.tracks.splice(fromIdx, 1);
|
||||||
this.tracks.splice(toIdx, 0, track);
|
this.tracks.splice(toIdx, 0, track);
|
||||||
|
this._playNextGroupId = null;
|
||||||
// Adjust currentIndex to follow the currently playing track
|
// Adjust currentIndex to follow the currently playing track
|
||||||
if (this.currentIndex === fromIdx) {
|
if (this.currentIndex === fromIdx) {
|
||||||
this.currentIndex = toIdx;
|
this.currentIndex = toIdx;
|
||||||
@@ -2484,6 +2560,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
_clearLocal() {
|
_clearLocal() {
|
||||||
this.tracks = [];
|
this.tracks = [];
|
||||||
this.currentIndex = 0;
|
this.currentIndex = 0;
|
||||||
|
this._playNextGroupId = null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2508,6 +2585,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
searchLoading: false,
|
searchLoading: false,
|
||||||
similaritySearchLabel: '',
|
similaritySearchLabel: '',
|
||||||
similaritySearchError: '',
|
similaritySearchError: '',
|
||||||
|
similaritySearchStats: { loading: false, tracks: 0, artists: 0, peers: 0, elapsed_ms: 0 },
|
||||||
federationSearch: { loading: false, error: '', artists: [], releases: [], tracks: [] },
|
federationSearch: { loading: false, error: '', artists: [], releases: [], tracks: [] },
|
||||||
artistFederation: { loading: false, error: '', releases: [], tracks: [] },
|
artistFederation: { loading: false, error: '', releases: [], tracks: [] },
|
||||||
federationPreparing: {},
|
federationPreparing: {},
|
||||||
@@ -3400,6 +3478,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const res = await fetch(`/api/player/search?q=${encodeURIComponent(q)}&limit=10`);
|
const res = await fetch(`/api/player/search?q=${encodeURIComponent(q)}&limit=10`);
|
||||||
if (!res.ok) throw new Error('failed');
|
if (!res.ok) throw new Error('failed');
|
||||||
this.searchResults = await res.json();
|
this.searchResults = await res.json();
|
||||||
|
this.applyFederationArtworkFallbacks();
|
||||||
} catch {
|
} catch {
|
||||||
this.searchResults = { artists: [], releases: [], tracks: [] };
|
this.searchResults = { artists: [], releases: [], tracks: [] };
|
||||||
}
|
}
|
||||||
@@ -3425,18 +3504,30 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.searchLoading = true;
|
this.searchLoading = true;
|
||||||
this.searchResults = null;
|
this.searchResults = null;
|
||||||
this.federationSearch = { loading: true, error: '', artists: [], releases: [], tracks: [] };
|
this.federationSearch = { loading: true, error: '', artists: [], releases: [], tracks: [] };
|
||||||
|
this.similaritySearchStats = { loading: true, tracks: 0, artists: 0, peers: 0, elapsed_ms: 0 };
|
||||||
Alpine.store('info').close();
|
Alpine.store('info').close();
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/player/similarity/${id}`);
|
const completeRequest = fetch(`/api/player/similarity/${id}`);
|
||||||
const data = await response.json().catch(() => ({}));
|
const localResponse = await fetch(`/api/player/similarity/${id}?local_only=true`);
|
||||||
if (!response.ok) throw new Error(data.error || T.similarityFailed);
|
const localData = await localResponse.json().catch(() => ({}));
|
||||||
this.similaritySearchLabel = data.label || initialLabel;
|
if (!localResponse.ok) throw new Error(localData.error || T.similarityFailed);
|
||||||
|
this.similaritySearchLabel = localData.label || initialLabel;
|
||||||
this.searchQuery = this.similaritySearchLabel;
|
this.searchQuery = this.similaritySearchLabel;
|
||||||
this.searchResults = {
|
this.searchResults = {
|
||||||
artists: [],
|
artists: [],
|
||||||
releases: [],
|
releases: [],
|
||||||
tracks: Array.isArray(data.tracks) ? data.tracks : [],
|
tracks: Array.isArray(localData.tracks) ? localData.tracks : [],
|
||||||
};
|
};
|
||||||
|
this.searchLoading = false;
|
||||||
|
this.similaritySearchStats = {
|
||||||
|
loading: true,
|
||||||
|
...this.similarityResultCounts(this.searchResults.tracks, []),
|
||||||
|
peers: 0,
|
||||||
|
elapsed_ms: localData.elapsed_ms || 0,
|
||||||
|
};
|
||||||
|
const response = await completeRequest;
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(data.error || T.similarityFailed);
|
||||||
this.federationSearch = {
|
this.federationSearch = {
|
||||||
loading: false,
|
loading: false,
|
||||||
error: data.federation_error || '',
|
error: data.federation_error || '',
|
||||||
@@ -3444,15 +3535,94 @@ document.addEventListener('alpine:init', () => {
|
|||||||
releases: [],
|
releases: [],
|
||||||
tracks: Array.isArray(data.federation_tracks) ? data.federation_tracks : [],
|
tracks: Array.isArray(data.federation_tracks) ? data.federation_tracks : [],
|
||||||
};
|
};
|
||||||
|
this.similaritySearchStats = {
|
||||||
|
loading: false,
|
||||||
|
...this.similarityResultCounts(
|
||||||
|
this.searchResults.tracks,
|
||||||
|
this.federationSearch.tracks
|
||||||
|
),
|
||||||
|
peers: Number(data.queried_peers || 0),
|
||||||
|
elapsed_ms: Number(data.elapsed_ms || 0),
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.searchResults = { artists: [], releases: [], tracks: [] };
|
if (!this.searchResults) {
|
||||||
this.federationSearch = { loading: false, error: '', artists: [], releases: [], tracks: [] };
|
this.searchResults = { artists: [], releases: [], tracks: [] };
|
||||||
this.similaritySearchError = error?.message || T.similarityFailed;
|
this.similaritySearchError = error?.message || T.similarityFailed;
|
||||||
|
} else {
|
||||||
|
this.federationSearch = {
|
||||||
|
...this.federationSearch,
|
||||||
|
loading: false,
|
||||||
|
error: error?.message || T.similarityFailed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.similaritySearchStats = {
|
||||||
|
...this.similaritySearchStats,
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
this.searchLoading = false;
|
this.searchLoading = false;
|
||||||
this._afterNavigation(options);
|
this._afterNavigation(options);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
similarityResultCounts(localTracks = [], federationTracks = []) {
|
||||||
|
const artists = new Set();
|
||||||
|
for (const track of localTracks) {
|
||||||
|
for (const artist of [...(track?.artists || []), ...(track?.featured_artists || [])]) {
|
||||||
|
const name = this.normalizeFederationSearchText(artist?.name);
|
||||||
|
if (name) artists.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const track of federationTracks) {
|
||||||
|
const metadata = track?.metadata || {};
|
||||||
|
for (const artist of [...(metadata.artists || []), ...(metadata.featured_artists || [])]) {
|
||||||
|
const name = this.normalizeFederationSearchText(artist?.name);
|
||||||
|
if (name) artists.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tracks: localTracks.length + federationTracks.length,
|
||||||
|
artists: artists.size,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
similarityTrackOrder(track) {
|
||||||
|
const score = Number(track?.similarity_score);
|
||||||
|
if (!Number.isFinite(score)) return 1000000;
|
||||||
|
return Math.max(0, Math.round((1 - score) * 100000));
|
||||||
|
},
|
||||||
|
|
||||||
|
similarityQueueTracks() {
|
||||||
|
const local = (this.searchResults?.tracks || []).map(track => ({ ...track }));
|
||||||
|
const federated = (this.federationSearch?.tracks || []).map(track => ({
|
||||||
|
...this.federationQueueTrack(track),
|
||||||
|
similarity_score: track.similarity_score,
|
||||||
|
}));
|
||||||
|
return [...local, ...federated].sort((left, right) => {
|
||||||
|
const score = Number(right?.similarity_score || 0)
|
||||||
|
- Number(left?.similarity_score || 0);
|
||||||
|
if (score) return score;
|
||||||
|
return String(left?.title || '').localeCompare(String(right?.title || ''));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
playSimilarityResult(track) {
|
||||||
|
const queue = Alpine.store('queue');
|
||||||
|
const tracks = this.similarityQueueTracks();
|
||||||
|
const key = queue._trackIdentity(track);
|
||||||
|
const index = tracks.findIndex(item => queue._trackIdentity(item) === key);
|
||||||
|
if (index >= 0) queue.playRelease(tracks, index);
|
||||||
|
},
|
||||||
|
|
||||||
|
formatSearchDuration(milliseconds) {
|
||||||
|
const ms = Math.max(0, Number(milliseconds) || 0);
|
||||||
|
if (ms < 10000) return `${(ms / 1000).toFixed(1)} s`;
|
||||||
|
if (ms < 60000) return `${Math.round(ms / 1000)} s`;
|
||||||
|
const totalSeconds = Math.round(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
|
||||||
|
},
|
||||||
|
|
||||||
clearSearch() {
|
clearSearch() {
|
||||||
this.stopFederationSearch();
|
this.stopFederationSearch();
|
||||||
this.searchQuery = '';
|
this.searchQuery = '';
|
||||||
@@ -3460,6 +3630,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.searchLoading = false;
|
this.searchLoading = false;
|
||||||
this.similaritySearchLabel = '';
|
this.similaritySearchLabel = '';
|
||||||
this.similaritySearchError = '';
|
this.similaritySearchError = '';
|
||||||
|
this.similaritySearchStats = { loading: false, tracks: 0, artists: 0, peers: 0, elapsed_ms: 0 };
|
||||||
if (this.view === 'search') {
|
if (this.view === 'search') {
|
||||||
this.view = this._previousView || 'artists';
|
this.view = this._previousView || 'artists';
|
||||||
this._setHash(this.view === 'my_uploads' ? '#uploads' : '#artists');
|
this._setHash(this.view === 'my_uploads' ? '#uploads' : '#artists');
|
||||||
@@ -3518,13 +3689,17 @@ document.addEventListener('alpine:init', () => {
|
|||||||
};
|
};
|
||||||
source.addEventListener('federation.track', upsertTrack);
|
source.addEventListener('federation.track', upsertTrack);
|
||||||
source.addEventListener('federation.artist', event => {
|
source.addEventListener('federation.artist', event => {
|
||||||
const artist = JSON.parse(event.data)?.entity;
|
const artist = this.withFederationArtistFallback(
|
||||||
|
JSON.parse(event.data)?.entity
|
||||||
|
);
|
||||||
const key = artist?.key?.normalized_name;
|
const key = artist?.key?.normalized_name;
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
updateResults('artists', item => item.key.normalized_name, item => item.name, artist);
|
updateResults('artists', item => item.key.normalized_name, item => item.name, artist);
|
||||||
});
|
});
|
||||||
source.addEventListener('federation.release', event => {
|
source.addEventListener('federation.release', event => {
|
||||||
const release = JSON.parse(event.data)?.entity;
|
const release = this.hydrateFederationSearchRelease(
|
||||||
|
JSON.parse(event.data)?.entity
|
||||||
|
);
|
||||||
if (!release?.key) return;
|
if (!release?.key) return;
|
||||||
updateResults('releases', item => JSON.stringify(item.key || {}), item => item.title, release);
|
updateResults('releases', item => JSON.stringify(item.key || {}), item => item.title, release);
|
||||||
});
|
});
|
||||||
@@ -3601,9 +3776,98 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
federationArtistImage(artist) {
|
federationArtistImage(artist) {
|
||||||
if (!artist?.name) return '';
|
if (!artist?.name) return '';
|
||||||
|
if (artist._federationArtworkFailed) return artist.local_image_url || '';
|
||||||
return this.federationDiscoveredArtwork(artist.name);
|
return this.federationDiscoveredArtwork(artist.name);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
localArtistImage(name) {
|
||||||
|
const key = this.normalizeFederationSearchText(name);
|
||||||
|
return (this.searchResults?.artists || []).find(candidate =>
|
||||||
|
this.normalizeFederationSearchText(candidate.name) === key
|
||||||
|
)?.image_url || '';
|
||||||
|
},
|
||||||
|
|
||||||
|
withFederationArtistFallback(artist) {
|
||||||
|
if (!artist) return artist;
|
||||||
|
return { ...artist, local_image_url: this.localArtistImage(artist.name) };
|
||||||
|
},
|
||||||
|
|
||||||
|
applyFederationArtworkFallbacks() {
|
||||||
|
this.federationSearch = {
|
||||||
|
...this.federationSearch,
|
||||||
|
artists: this.federationSearch.artists.map(artist =>
|
||||||
|
this.withFederationArtistFallback(artist)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
federationArtistImageFailed(artist) {
|
||||||
|
const key = artist?.key?.normalized_name;
|
||||||
|
if (!key) return;
|
||||||
|
this.federationSearch = {
|
||||||
|
...this.federationSearch,
|
||||||
|
artists: this.federationSearch.artists.map(candidate =>
|
||||||
|
candidate?.key?.normalized_name === key
|
||||||
|
? {
|
||||||
|
...candidate,
|
||||||
|
_federationArtworkFailed: true,
|
||||||
|
local_image_url: candidate.local_image_url
|
||||||
|
|| this.localArtistImage(candidate.name),
|
||||||
|
}
|
||||||
|
: candidate
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
hydrateFederationSearchRelease(release) {
|
||||||
|
if (!release) return release;
|
||||||
|
const title = this.normalizeFederationSearchText(release.title);
|
||||||
|
const primaryArtists = (release.key?.primary_artists || [])
|
||||||
|
.map(name => this.normalizeFederationSearchText(name));
|
||||||
|
const tracks = this.federationSearch.tracks.filter(track => {
|
||||||
|
const metadata = track?.metadata || {};
|
||||||
|
if (this.normalizeFederationSearchText(metadata.release?.title) !== title) return false;
|
||||||
|
if (release.year && metadata.year && Number(release.year) !== Number(metadata.year)) return false;
|
||||||
|
if (!primaryArtists.length) return true;
|
||||||
|
const trackArtists = (metadata.artists || []).map(artist =>
|
||||||
|
this.normalizeFederationSearchText(artist.name)
|
||||||
|
);
|
||||||
|
return primaryArtists.some(artist => trackArtists.includes(artist));
|
||||||
|
});
|
||||||
|
const owners = [...new Set([
|
||||||
|
...(release.sources || []).map(source => source.owner),
|
||||||
|
...tracks.flatMap(track =>
|
||||||
|
(track.availability?.federation || []).map(source => source.owner)
|
||||||
|
),
|
||||||
|
].filter(Boolean))];
|
||||||
|
return { ...release, tracks, owners };
|
||||||
|
},
|
||||||
|
|
||||||
|
federationReleaseCover(release) {
|
||||||
|
if (!release) return '';
|
||||||
|
if (release._federationArtworkFailed) return release._discoveredCoverUrl || '';
|
||||||
|
return release.cover_url
|
||||||
|
|| this.federationDiscoveredArtwork(release.artists?.[0], release.title);
|
||||||
|
},
|
||||||
|
|
||||||
|
federationReleaseCoverFailed(release, failedUrl) {
|
||||||
|
const discovered = this.federationDiscoveredArtwork(release?.artists?.[0], release?.title);
|
||||||
|
if (!release?.key) return;
|
||||||
|
const key = JSON.stringify(release.key);
|
||||||
|
this.federationSearch = {
|
||||||
|
...this.federationSearch,
|
||||||
|
releases: this.federationSearch.releases.map(candidate =>
|
||||||
|
JSON.stringify(candidate?.key) === key
|
||||||
|
? {
|
||||||
|
...candidate,
|
||||||
|
_federationArtworkFailed: true,
|
||||||
|
_discoveredCoverUrl: failedUrl === discovered ? '' : discovered,
|
||||||
|
}
|
||||||
|
: candidate
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
federationDiscoveredArtwork(artist, release = '') {
|
federationDiscoveredArtwork(artist, release = '') {
|
||||||
if (!artist) return '';
|
if (!artist) return '';
|
||||||
const params = new URLSearchParams({ artist });
|
const params = new URLSearchParams({ artist });
|
||||||
@@ -3696,22 +3960,26 @@ document.addEventListener('alpine:init', () => {
|
|||||||
uploader_name: 'Federation',
|
uploader_name: 'Federation',
|
||||||
federation_pending: true,
|
federation_pending: true,
|
||||||
_federationTrack: track,
|
_federationTrack: track,
|
||||||
|
similarity_score: track.similarity_score,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
openFederatedRelease(release, options = {}) {
|
openFederatedRelease(release, options = {}) {
|
||||||
if (!release?.key) return;
|
if (!release?.key) return;
|
||||||
this._federatedReleaseCache[release.key] = release;
|
const cacheKey = typeof release.key === 'string'
|
||||||
this._beginNavigation('#releasefed?key=' + encodeURIComponent(release.key), options);
|
? release.key
|
||||||
|
: JSON.stringify(release.key);
|
||||||
|
this._federatedReleaseCache[cacheKey] = release;
|
||||||
|
this._beginNavigation('#releasefed?key=' + encodeURIComponent(cacheKey), options);
|
||||||
const queuedTracks = (release.tracks || []).map(track => this.federationQueueTrack(track));
|
const queuedTracks = (release.tracks || []).map(track => this.federationQueueTrack(track));
|
||||||
const first = queuedTracks[0];
|
const first = queuedTracks[0];
|
||||||
this.currentRelease = {
|
this.currentRelease = {
|
||||||
id: null,
|
id: null,
|
||||||
title: release.title,
|
title: release.title,
|
||||||
release_type: release.release_type || 'release',
|
release_type: release.release_type || release.key?.release_type || 'release',
|
||||||
year: release.year,
|
year: release.year,
|
||||||
cover_url: release.cover_url,
|
cover_url: this.federationReleaseCover(release),
|
||||||
artists: first?.artists || [],
|
artists: first?.artists || (release.artists || []).map(name => ({ id: null, name })),
|
||||||
tracks: queuedTracks,
|
tracks: queuedTracks,
|
||||||
uploaders: (release.owners || []).map(owner => ({
|
uploaders: (release.owners || []).map(owner => ({
|
||||||
name: `Federation ${owner.slice(0, 10)}`,
|
name: `Federation ${owner.slice(0, 10)}`,
|
||||||
@@ -4336,8 +4604,24 @@ document.addEventListener('alpine:init', () => {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
Alpine.store('torrents', {
|
Alpine.store('torrents', {
|
||||||
modal: false,
|
modal: false,
|
||||||
|
downloadsEnabled: {{ downloads_enabled }},
|
||||||
|
torrentDownloadsEnabled: {{ torrent_downloads_enabled }},
|
||||||
|
youtubeDownloadsEnabled: {{ youtube_downloads_enabled }},
|
||||||
|
sourceTab: {% if youtube_downloads_enabled %}'youtube'{% else if torrent_downloads_enabled %}'torrents'{% else %}'files'{% endif %},
|
||||||
|
youtubeUrl: '',
|
||||||
|
youtubePreview: null,
|
||||||
|
youtubePreviewSelected: new Set(),
|
||||||
|
youtubePreviewLoading: false,
|
||||||
|
youtubeJobs: [],
|
||||||
|
youtubeLoading: false,
|
||||||
|
youtubeSubmitting: false,
|
||||||
|
youtubeCancellingIds: new Set(),
|
||||||
file: null,
|
file: null,
|
||||||
localFiles: [],
|
localFiles: [],
|
||||||
|
localFilesDragging: false,
|
||||||
|
localFilesUploading: false,
|
||||||
|
localUploadHistory: [],
|
||||||
|
localUploadHistoryLoading: false,
|
||||||
magnet: '',
|
magnet: '',
|
||||||
sessions: [],
|
sessions: [],
|
||||||
loadingSessions: false,
|
loadingSessions: false,
|
||||||
@@ -4358,7 +4642,6 @@ document.addEventListener('alpine:init', () => {
|
|||||||
loadingAgentStatus: false,
|
loadingAgentStatus: false,
|
||||||
uploadProgress: 0,
|
uploadProgress: 0,
|
||||||
uploadProgressText: '',
|
uploadProgressText: '',
|
||||||
activeTab: 'import',
|
|
||||||
uploadTracks: [],
|
uploadTracks: [],
|
||||||
uploadReleases: [],
|
uploadReleases: [],
|
||||||
uploadPending: [],
|
uploadPending: [],
|
||||||
@@ -4391,12 +4674,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
open() {
|
open() {
|
||||||
|
if (!this.downloadsEnabled) return;
|
||||||
this.modal = true;
|
this.modal = true;
|
||||||
this.message = '';
|
this.message = '';
|
||||||
this.error = false;
|
this.error = false;
|
||||||
this.loadSessions();
|
if (this.sourceTab === 'youtube') this.loadYoutubeJobs();
|
||||||
|
else if (this.sourceTab === 'uploads') this.loadUploads();
|
||||||
|
else if (this.sourceTab === 'files') this.loadLocalUploadHistory();
|
||||||
|
else this.loadSessions();
|
||||||
|
if (this.sourceTab !== 'uploads') this.loadUploads({ silent: true });
|
||||||
this.loadAgentStatus();
|
this.loadAgentStatus();
|
||||||
if (this.activeTab === 'uploads') this.loadUploads();
|
|
||||||
this._startRefresh();
|
this._startRefresh();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -4416,16 +4703,327 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return this.workspaceMode === 'new';
|
return this.workspaceMode === 'new';
|
||||||
},
|
},
|
||||||
|
|
||||||
showImportTab() {
|
showSourceTab(tab) {
|
||||||
this.activeTab = 'import';
|
const tabs = ['files', 'uploads'];
|
||||||
|
if (this.youtubeDownloadsEnabled) tabs.unshift('youtube');
|
||||||
|
if (this.torrentDownloadsEnabled) tabs.unshift('torrents');
|
||||||
|
this.sourceTab = tabs.includes(tab) ? tab : tabs[0];
|
||||||
this._setMessage('');
|
this._setMessage('');
|
||||||
|
if (this.sourceTab === 'youtube') this.loadYoutubeJobs();
|
||||||
|
else if (this.sourceTab === 'uploads') {
|
||||||
|
this._stopPoll();
|
||||||
|
this.loadUploads();
|
||||||
|
}
|
||||||
|
else if (this.sourceTab === 'files') {
|
||||||
|
this._stopPoll();
|
||||||
|
this.loadLocalUploadHistory();
|
||||||
|
}
|
||||||
|
else this.loadSessions();
|
||||||
},
|
},
|
||||||
|
|
||||||
showUploadsTab() {
|
async loadYoutubeJobs({ silent = false } = {}) {
|
||||||
this.activeTab = 'uploads';
|
if (!silent) this.youtubeLoading = true;
|
||||||
this._stopPoll();
|
try {
|
||||||
this._setMessage('');
|
const res = await fetch('/api/player/youtube');
|
||||||
this.loadUploads();
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) throw new Error(data?.error || T.youtubeLoadFailed);
|
||||||
|
this.youtubeJobs = Array.isArray(data) ? data : [];
|
||||||
|
} catch (err) {
|
||||||
|
if (!silent) this._setMessage(err.message || T.youtubeLoadFailed, true);
|
||||||
|
} finally {
|
||||||
|
if (!silent) this.youtubeLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearYoutubePreview() {
|
||||||
|
this.youtubePreview = null;
|
||||||
|
this.youtubePreviewSelected = new Set();
|
||||||
|
},
|
||||||
|
|
||||||
|
async previewYoutubeUrl() {
|
||||||
|
const url = String(this.youtubeUrl || '').trim();
|
||||||
|
if (!url || this.youtubePreviewLoading || this.youtubeSubmitting) return;
|
||||||
|
this.youtubePreviewLoading = true;
|
||||||
|
this.clearYoutubePreview();
|
||||||
|
this._setMessage(T.youtubeParsing);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/youtube/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) throw new Error(data?.error || T.youtubePreviewFailed);
|
||||||
|
const items = Array.isArray(data?.items) ? data.items : [];
|
||||||
|
if (!items.length) throw new Error(T.youtubePreviewFailed);
|
||||||
|
if (String(this.youtubeUrl || '').trim() !== url) return;
|
||||||
|
this.youtubePreview = { ...data, items };
|
||||||
|
this.youtubePreviewSelected = new Set(
|
||||||
|
items.filter(item => item.selected_by_default).map(item => item.source_id)
|
||||||
|
);
|
||||||
|
this._setMessage('');
|
||||||
|
} catch (err) {
|
||||||
|
this._setMessage(err.message || T.youtubePreviewFailed, true);
|
||||||
|
} finally {
|
||||||
|
this.youtubePreviewLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubePreviewIsSelected(sourceId) {
|
||||||
|
return this.youtubePreviewSelected.has(sourceId);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleYoutubePreviewItem(sourceId) {
|
||||||
|
const selected = new Set(this.youtubePreviewSelected);
|
||||||
|
if (selected.has(sourceId)) selected.delete(sourceId);
|
||||||
|
else selected.add(sourceId);
|
||||||
|
this.youtubePreviewSelected = selected;
|
||||||
|
},
|
||||||
|
|
||||||
|
selectAllYoutubePreview() {
|
||||||
|
const items = Array.isArray(this.youtubePreview?.items) ? this.youtubePreview.items : [];
|
||||||
|
this.youtubePreviewSelected = new Set(items.map(item => item.source_id));
|
||||||
|
},
|
||||||
|
|
||||||
|
clearYoutubePreviewSelection() {
|
||||||
|
this.youtubePreviewSelected = new Set();
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubePreviewSelectedCount() {
|
||||||
|
return this.youtubePreviewSelected.size;
|
||||||
|
},
|
||||||
|
|
||||||
|
async startYoutubeDownload() {
|
||||||
|
const preview = this.youtubePreview;
|
||||||
|
const selectedSourceIds = Array.from(this.youtubePreviewSelected);
|
||||||
|
if (!preview || !selectedSourceIds.length || this.youtubeSubmitting) return;
|
||||||
|
this.youtubeSubmitting = true;
|
||||||
|
this._setMessage(T.youtubeStarting);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/youtube/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: preview.source_url,
|
||||||
|
selected_source_ids: selectedSourceIds,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) throw new Error(data?.error || T.youtubeStartFailed);
|
||||||
|
this.youtubeJobs = [data, ...this.youtubeJobs.filter(job => job.id !== data.id)];
|
||||||
|
this.youtubeUrl = '';
|
||||||
|
this.clearYoutubePreview();
|
||||||
|
this._setMessage(T.youtubeStarted);
|
||||||
|
} catch (err) {
|
||||||
|
this._setMessage(err.message || T.youtubeStartFailed, true);
|
||||||
|
} finally {
|
||||||
|
this.youtubeSubmitting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancelYoutubeJob(id) {
|
||||||
|
if (this.youtubeCancellingIds.has(id) || !confirm(T.youtubeStopConfirm)) return;
|
||||||
|
const cancelling = new Set(this.youtubeCancellingIds);
|
||||||
|
cancelling.add(id);
|
||||||
|
this.youtubeCancellingIds = cancelling;
|
||||||
|
this._setMessage(T.youtubeStopping);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/player/youtube/${encodeURIComponent(id)}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) throw new Error(data?.error || T.youtubeStopFailed);
|
||||||
|
this.youtubeJobs = this.youtubeJobs.map(job => job.id === id ? data : job);
|
||||||
|
this._setMessage(T.youtubeStopped);
|
||||||
|
} catch (err) {
|
||||||
|
this._setMessage(err.message || T.youtubeStopFailed, true);
|
||||||
|
} finally {
|
||||||
|
const remaining = new Set(this.youtubeCancellingIds);
|
||||||
|
remaining.delete(id);
|
||||||
|
this.youtubeCancellingIds = remaining;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async retryYoutubeJob(id) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/player/youtube/${encodeURIComponent(id)}/retry`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) throw new Error(data?.error || T.youtubeRetryFailed);
|
||||||
|
this.youtubeJobs = this.youtubeJobs.map(job => job.id === id ? data : job);
|
||||||
|
this._setMessage(T.youtubeStarted);
|
||||||
|
} catch (err) {
|
||||||
|
this._setMessage(err.message || T.youtubeRetryFailed, true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeYoutubeJob(id) {
|
||||||
|
if (!confirm(T.youtubeDeleteConfirm)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/player/youtube/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) throw new Error(data?.error || T.youtubeDeleteFailed);
|
||||||
|
this.youtubeJobs = this.youtubeJobs.filter(job => job.id !== id);
|
||||||
|
this._setMessage('');
|
||||||
|
} catch (err) {
|
||||||
|
this._setMessage(err.message || T.youtubeDeleteFailed, true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openYoutubeReviews() {
|
||||||
|
this.showSourceTab('uploads');
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubeActiveCount() {
|
||||||
|
return this.youtubeJobs.filter(job => !this.youtubeJobTerminal(job.status)).length;
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubeSummary() {
|
||||||
|
const active = this.youtubeActiveCount();
|
||||||
|
return active > 0 ? active + ' ' + T.active : T.clientIdle;
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubeStatusLabel(status) {
|
||||||
|
const labels = {
|
||||||
|
queued: T.youtubeQueued,
|
||||||
|
resolving: T.youtubeResolving,
|
||||||
|
downloading: T.downloading,
|
||||||
|
postprocessing: T.youtubePostprocessing,
|
||||||
|
awaiting_ai: T.youtubeAwaitingAi,
|
||||||
|
ai_processing: T.youtubeAiProcessing,
|
||||||
|
needs_review: T.youtubeNeedsReview,
|
||||||
|
complete: T.completed,
|
||||||
|
complete_with_errors: T.youtubeCompleteWithErrors,
|
||||||
|
failed: T.failed,
|
||||||
|
ai_failed: T.failed,
|
||||||
|
skipped: T.youtubeSkipped,
|
||||||
|
cancelled: T.youtubeCancelled,
|
||||||
|
uploading: T.uploadingFiles,
|
||||||
|
};
|
||||||
|
return labels[String(status || '').toLowerCase()] || status || T.unknown;
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubeStatusClass(status) {
|
||||||
|
const classes = {
|
||||||
|
queued: 'status-preview',
|
||||||
|
resolving: 'status-resolving',
|
||||||
|
downloading: 'status-downloading',
|
||||||
|
postprocessing: 'status-moving',
|
||||||
|
awaiting_ai: 'status-preview',
|
||||||
|
ai_processing: 'status-moving',
|
||||||
|
needs_review: 'status-paused',
|
||||||
|
complete: 'status-completed',
|
||||||
|
complete_with_errors: 'status-failed',
|
||||||
|
failed: 'status-failed',
|
||||||
|
ai_failed: 'status-failed',
|
||||||
|
skipped: 'status-completed',
|
||||||
|
cancelled: 'status-paused',
|
||||||
|
uploading: 'status-downloading',
|
||||||
|
};
|
||||||
|
return classes[String(status || '').toLowerCase()] || 'status-preview';
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubeIsError(status) {
|
||||||
|
return ['failed', 'ai_failed'].includes(String(status || '').toLowerCase());
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubeJobTerminal(status) {
|
||||||
|
return ['complete', 'complete_with_errors', 'failed', 'needs_review', 'cancelled'].includes(String(status || '').toLowerCase());
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubeItemTerminal(status) {
|
||||||
|
return ['complete', 'skipped', 'needs_review', 'failed', 'ai_failed', 'cancelled'].includes(String(status || '').toLowerCase());
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubeJobCancellable(status) {
|
||||||
|
return ['queued', 'resolving', 'downloading', 'postprocessing'].includes(String(status || '').toLowerCase());
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubeJobMeta(job) {
|
||||||
|
const kind = job.source_kind === 'playlist' ? T.youtubePlaylist : T.youtubeVideo;
|
||||||
|
const parts = [kind];
|
||||||
|
if (Number(job.total_items || 0) > 0) parts.push(Number(job.total_items) + ' ' + T.youtubeItems);
|
||||||
|
if (Number(job.failed_items || 0) > 0) parts.push(Number(job.failed_items) + ' ' + T.youtubeErrors);
|
||||||
|
return parts.join(' · ');
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubeItemMeta(item) {
|
||||||
|
const parts = [];
|
||||||
|
if (Number(item.chapter_count || 0) > 0) parts.push(Number(item.chapter_count) + ' ' + T.chapters);
|
||||||
|
if (Number(item.audio_file_count || 0) > 0) parts.push(Number(item.audio_file_count) + ' ' + T.trackWord);
|
||||||
|
if (Number(item.total_bytes || 0) > 0) parts.push(this.bytes(item.total_bytes));
|
||||||
|
return parts.join(' · ');
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubeDownloadMeta(item) {
|
||||||
|
const downloaded = this.bytes(item.downloaded_bytes || 0);
|
||||||
|
const total = Number(item.total_bytes || 0) > 0 ? ' / ' + this.bytes(item.total_bytes) : '';
|
||||||
|
const speed = Number(item.speed_bytes_per_sec || 0) > 0 ? ' · ' + this.bytes(item.speed_bytes_per_sec) + '/s' : '';
|
||||||
|
const eta = Number(item.eta_seconds || 0) > 0 ? ' · ' + T.eta + ' ' + formatTime(item.eta_seconds) : '';
|
||||||
|
return downloaded + total + speed + eta;
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubeItemProgress(item) {
|
||||||
|
const status = String(item?.status || 'queued').toLowerCase();
|
||||||
|
if (status === 'cancelled') {
|
||||||
|
const downloaded = Number(item.progress_percent || 0);
|
||||||
|
return downloaded > 0 ? Math.max(5, Math.min(55, 5 + downloaded * 0.5)) : 0;
|
||||||
|
}
|
||||||
|
if (this.youtubeItemTerminal(status)) return 100;
|
||||||
|
if (status === 'downloading') return 5 + Math.max(0, Math.min(100, Number(item.progress_percent || 0))) * 0.5;
|
||||||
|
if (status === 'postprocessing') return 62;
|
||||||
|
if (status === 'awaiting_ai') return 75;
|
||||||
|
if (status === 'ai_processing') return 88;
|
||||||
|
return 2;
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubeJobProgress(job) {
|
||||||
|
const items = Array.isArray(job?.items) ? job.items : [];
|
||||||
|
if (!items.length) return job?.status === 'failed' ? 100 : 2;
|
||||||
|
return Math.round(items.reduce((sum, item) => sum + this.youtubeItemProgress(item), 0) / items.length);
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubeJobProgressText(job) {
|
||||||
|
const items = Array.isArray(job?.items) ? job.items : [];
|
||||||
|
if (!items.length) return this.youtubeStatusLabel(job?.status);
|
||||||
|
const done = items.filter(item => this.youtubeItemTerminal(item.status) && item.status !== 'cancelled').length;
|
||||||
|
return done + ' ' + T.ofWord + ' ' + items.length;
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubeStage(item) {
|
||||||
|
const status = String(item?.status || 'queued').toLowerCase();
|
||||||
|
if (status === 'cancelled') {
|
||||||
|
if (Number(item?.progress_percent || 0) >= 99) return 2;
|
||||||
|
return Number(item?.progress_percent || 0) > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
if (status === 'downloading') return 1;
|
||||||
|
if (status === 'postprocessing') return 2;
|
||||||
|
if (['awaiting_ai', 'ai_processing', 'ai_failed'].includes(status)) return 3;
|
||||||
|
if (status === 'failed') return Number(item?.progress_percent || 0) >= 99 ? 2 : 1;
|
||||||
|
if (this.youtubeItemTerminal(status)) return 4;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubeStepClass(item, step) {
|
||||||
|
const status = String(item?.status || '').toLowerCase();
|
||||||
|
const stage = this.youtubeStage(item);
|
||||||
|
if (status === 'cancelled') {
|
||||||
|
if (step < stage) return 'done';
|
||||||
|
if (step === stage) return 'cancelled';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (this.youtubeIsError(status) && step === stage) return 'failed';
|
||||||
|
if (['complete', 'skipped'].includes(status)) return 'done';
|
||||||
|
if (step < stage) return 'done';
|
||||||
|
if (step === stage) return 'active';
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubeFinalStepLabel(item) {
|
||||||
|
return this.youtubeStatusLabel(item?.status === 'complete' ? 'complete' : item?.status);
|
||||||
},
|
},
|
||||||
|
|
||||||
addNew() {
|
addNew() {
|
||||||
@@ -5097,9 +5695,15 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this._stopRefresh();
|
this._stopRefresh();
|
||||||
this._refreshTimer = setInterval(() => {
|
this._refreshTimer = setInterval(() => {
|
||||||
if (!this.modal) return;
|
if (!this.modal) return;
|
||||||
if (this.activeTab === 'uploads') {
|
if (this.sourceTab === 'youtube') {
|
||||||
|
this.loadYoutubeJobs({ silent: true });
|
||||||
|
}
|
||||||
|
else if (this.sourceTab === 'uploads') {
|
||||||
this.loadUploads({ silent: true });
|
this.loadUploads({ silent: true });
|
||||||
}
|
}
|
||||||
|
else if (this.sourceTab === 'files') {
|
||||||
|
this.loadLocalUploadHistory({ silent: true });
|
||||||
|
}
|
||||||
else this.loadSessions();
|
else this.loadSessions();
|
||||||
this.loadAgentStatus();
|
this.loadAgentStatus();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
@@ -5237,7 +5841,36 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
setLocalFiles(files) {
|
setLocalFiles(files) {
|
||||||
this.localFiles = Array.from(files || []);
|
const supported = new Set(['mp3', 'flac', 'wav', 'm4a', 'ogg', 'opus', 'aac']);
|
||||||
|
const selected = Array.from(files || []).filter(file => {
|
||||||
|
const extension = String(file.name || '').split('.').pop().toLowerCase();
|
||||||
|
return String(file.type || '').startsWith('audio/') || supported.has(extension);
|
||||||
|
});
|
||||||
|
this.localFiles = selected;
|
||||||
|
this.localFilesDragging = false;
|
||||||
|
this.uploadProgress = 0;
|
||||||
|
this.uploadProgressText = '';
|
||||||
|
if (!selected.length && Array.from(files || []).length) {
|
||||||
|
this._setMessage(T.noSupportedAudioFiles, true);
|
||||||
|
} else {
|
||||||
|
this._setMessage('');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearLocalFiles() {
|
||||||
|
if (this.localFilesUploading) return;
|
||||||
|
this.localFiles = [];
|
||||||
|
this.uploadProgress = 0;
|
||||||
|
this.uploadProgressText = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
leaveLocalFileDrop(event) {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget)) this.localFilesDragging = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
dropLocalFiles(event) {
|
||||||
|
this.localFilesDragging = false;
|
||||||
|
this.setLocalFiles(event?.dataTransfer?.files || []);
|
||||||
},
|
},
|
||||||
|
|
||||||
localUploadBytes() {
|
localUploadBytes() {
|
||||||
@@ -5256,6 +5889,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
xhr.open('POST', '/api/player/uploads/local');
|
xhr.open('POST', '/api/player/uploads/local');
|
||||||
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
|
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
|
||||||
xhr.setRequestHeader('X-Furumusic-Filename', encodeURIComponent(file.name || 'upload.mp3'));
|
xhr.setRequestHeader('X-Furumusic-Filename', encodeURIComponent(file.name || 'upload.mp3'));
|
||||||
|
xhr.setRequestHeader('X-Furumusic-Upload-Id', crypto.randomUUID());
|
||||||
xhr.upload.onprogress = event => {
|
xhr.upload.onprogress = event => {
|
||||||
if (!event.lengthComputable || totalBytes <= 0) return;
|
if (!event.lengthComputable || totalBytes <= 0) return;
|
||||||
const loaded = loadedBefore + event.loaded;
|
const loaded = loadedBefore + event.loaded;
|
||||||
@@ -5274,38 +5908,75 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async uploadLocalFiles() {
|
async uploadLocalFiles() {
|
||||||
if (this.loading || this.localFiles.length === 0) return;
|
if (this.localFilesUploading || this.localFiles.length === 0) return;
|
||||||
this.loading = true;
|
this.localFilesUploading = true;
|
||||||
this.uploadProgress = 0;
|
this.uploadProgress = 0;
|
||||||
this.uploadProgressText = '0.0%';
|
this.uploadProgressText = '0.0%';
|
||||||
this._setMessage(T.uploadingFiles);
|
this._setMessage(T.uploadingFiles);
|
||||||
const totalBytes = this.localUploadBytes();
|
const totalBytes = this.localUploadBytes();
|
||||||
let loadedBefore = 0;
|
let loadedBefore = 0;
|
||||||
try {
|
try {
|
||||||
for (const file of this.localFiles) {
|
for (const file of [...this.localFiles]) {
|
||||||
await this.uploadLocalFile(file, loadedBefore, totalBytes);
|
const data = await this.uploadLocalFile(file, loadedBefore, totalBytes);
|
||||||
|
if (data?.upload) {
|
||||||
|
this.localUploadHistory = [
|
||||||
|
data.upload,
|
||||||
|
...this.localUploadHistory.filter(item => item.id !== data.upload.id),
|
||||||
|
];
|
||||||
|
}
|
||||||
loadedBefore += Number(file.size || 0);
|
loadedBefore += Number(file.size || 0);
|
||||||
|
this.localFiles = this.localFiles.filter(item => item !== file);
|
||||||
this.uploadProgress = totalBytes > 0 ? Math.min(100, loadedBefore / totalBytes * 100) : 100;
|
this.uploadProgress = totalBytes > 0 ? Math.min(100, loadedBefore / totalBytes * 100) : 100;
|
||||||
this.uploadProgressText = this.uploadProgress.toFixed(1) + '%';
|
this.uploadProgressText = this.uploadProgress.toFixed(1) + '%';
|
||||||
}
|
}
|
||||||
this.localFiles = [];
|
|
||||||
this.uploadProgress = 100;
|
this.uploadProgress = 100;
|
||||||
this.uploadProgressText = '100.0%';
|
this.uploadProgressText = '100.0%';
|
||||||
this._setMessage(T.uploadComplete);
|
this._setMessage(T.uploadComplete);
|
||||||
await this.loadAgentStatus();
|
await this.loadAgentStatus();
|
||||||
|
await this.loadLocalUploadHistory({ silent: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._setMessage(err.message || String(err), true);
|
this._setMessage(err.message || String(err), true);
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.localFilesUploading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async loadLocalUploadHistory({ silent = false } = {}) {
|
||||||
|
if (!silent) this.localUploadHistoryLoading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/uploads/local/history');
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) throw new Error(data?.error || T.fileUploadLoadFailed);
|
||||||
|
this.localUploadHistory = Array.isArray(data) ? data : [];
|
||||||
|
} catch (err) {
|
||||||
|
if (!silent) this._setMessage(err.message || T.fileUploadLoadFailed, true);
|
||||||
|
} finally {
|
||||||
|
if (!silent) this.localUploadHistoryLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeLocalUploadHistory(id) {
|
||||||
|
if (!confirm(T.removeFileUploadConfirm)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/player/uploads/local/history/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) throw new Error(data?.error || T.fileUploadHistoryRemoveFailed);
|
||||||
|
this.localUploadHistory = this.localUploadHistory.filter(item => item.id !== id);
|
||||||
|
this._setMessage(T.fileUploadHistoryRemoved);
|
||||||
|
} catch (err) {
|
||||||
|
this._setMessage(err.message || T.fileUploadHistoryRemoveFailed, true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatUploadDate(value) {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? String(value || '') : date.toLocaleString();
|
||||||
|
},
|
||||||
|
|
||||||
async preview() {
|
async preview() {
|
||||||
if (this.loading) return;
|
if (this.loading) return;
|
||||||
if (this.localFiles.length > 0) {
|
|
||||||
await this.uploadLocalFiles();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const magnet = this.magnet.trim();
|
const magnet = this.magnet.trim();
|
||||||
if (!this.file && !magnet) {
|
if (!this.file && !magnet) {
|
||||||
this._setMessage(T.chooseTorrent, true);
|
this._setMessage(T.chooseTorrent, true);
|
||||||
|
|||||||
+91
-52
@@ -17,6 +17,17 @@
|
|||||||
<div class="user-role" x-text="$store.user.profile?.role || ''"></div>
|
<div class="user-role" x-text="$store.user.profile?.role || ''"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-widget-actions">
|
<div class="user-widget-actions">
|
||||||
|
<button class="user-logout-btn"
|
||||||
|
x-show="$store.user.profile?.role === 'admin'"
|
||||||
|
x-cloak
|
||||||
|
@click="window.location.href = '/admin/'"
|
||||||
|
title="{{ t.player_admin_panel }}"
|
||||||
|
aria-label="{{ t.player_admin_panel }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path d="M12 3l7 3v5c0 4.6-2.9 8.1-7 10-4.1-1.9-7-5.4-7-10V6l7-3z"/>
|
||||||
|
<path d="M9.5 12l1.7 1.7 3.6-4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button class="user-logout-btn" @click="$store.user.openSettings()" title="User settings" aria-label="User settings">
|
<button class="user-logout-btn" @click="$store.user.openSettings()" title="User settings" aria-label="User settings">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
<circle cx="12" cy="12" r="3"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
@@ -80,7 +91,7 @@
|
|||||||
@click="$store.library.openArtist(artist.id)">
|
@click="$store.library.openArtist(artist.id)">
|
||||||
<div class="following-avatar">
|
<div class="following-avatar">
|
||||||
<template x-if="artist.image_url">
|
<template x-if="artist.image_url">
|
||||||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
<img :src="artist.image_url" alt="" aria-hidden="true" loading="lazy">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!artist.image_url">
|
<template x-if="!artist.image_url">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||||
@@ -143,9 +154,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-bottom">
|
|
||||||
<a href="/admin/">{{ t.player_admin_panel }}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template x-if="$store.mobile.libraryOpen">
|
<template x-if="$store.mobile.libraryOpen">
|
||||||
@@ -196,7 +204,7 @@
|
|||||||
@click="$store.library.openArtist(artist.id); $store.mobile.closeLibrary()">
|
@click="$store.library.openArtist(artist.id); $store.mobile.closeLibrary()">
|
||||||
<div class="following-avatar">
|
<div class="following-avatar">
|
||||||
<template x-if="artist.image_url">
|
<template x-if="artist.image_url">
|
||||||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
<img :src="artist.image_url" alt="" aria-hidden="true" loading="lazy">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!artist.image_url">
|
<template x-if="!artist.image_url">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||||
@@ -317,6 +325,7 @@
|
|||||||
<span class="search-shortcut">Ctrl+K</span>
|
<span class="search-shortcut">Ctrl+K</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
{% if downloads_enabled %}
|
||||||
<button class="torrent-import-btn"
|
<button class="torrent-import-btn"
|
||||||
@click="$store.torrents.open()"
|
@click="$store.torrents.open()"
|
||||||
title="{{ t.player_import_torrent }}">
|
title="{{ t.player_import_torrent }}">
|
||||||
@@ -326,6 +335,7 @@
|
|||||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
{% endif %}
|
||||||
<button class="mobile-account-chip"
|
<button class="mobile-account-chip"
|
||||||
x-show="$store.user.profile"
|
x-show="$store.user.profile"
|
||||||
x-cloak
|
x-cloak
|
||||||
@@ -359,6 +369,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-account-actions">
|
<div class="mobile-account-actions">
|
||||||
|
<button class="user-logout-btn"
|
||||||
|
x-show="$store.user.profile?.role === 'admin'"
|
||||||
|
x-cloak
|
||||||
|
@click="window.location.href = '/admin/'"
|
||||||
|
title="{{ t.player_admin_panel }}"
|
||||||
|
aria-label="{{ t.player_admin_panel }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path d="M12 3l7 3v5c0 4.6-2.9 8.1-7 10-4.1-1.9-7-5.4-7-10V6l7-3z"/>
|
||||||
|
<path d="M9.5 12l1.7 1.7 3.6-4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button class="user-logout-btn"
|
<button class="user-logout-btn"
|
||||||
@click="$store.user.menuOpen = false; $store.user.openSettings()"
|
@click="$store.user.menuOpen = false; $store.user.openSettings()"
|
||||||
title="User settings"
|
title="User settings"
|
||||||
@@ -379,12 +400,24 @@
|
|||||||
<!-- Search Results -->
|
<!-- Search Results -->
|
||||||
<template x-if="$store.library.view === 'search'">
|
<template x-if="$store.library.view === 'search'">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="search-similarity-title"
|
<div class="search-similarity-heading"
|
||||||
x-show="$store.library.similaritySearchLabel"
|
x-show="$store.library.similaritySearchLabel"
|
||||||
x-cloak>
|
x-cloak>
|
||||||
<span>{{ t.player_search_similar_to }}</span>
|
<h2 class="search-similarity-title">
|
||||||
<strong x-text="$store.library.similaritySearchLabel"></strong>
|
<span>{{ t.player_search_similar_to }}</span>
|
||||||
</h2>
|
<strong x-text="$store.library.similaritySearchLabel"></strong>
|
||||||
|
</h2>
|
||||||
|
<div class="search-similarity-progress"
|
||||||
|
:class="{ loading: $store.library.similaritySearchStats.loading }">
|
||||||
|
<template x-if="$store.library.similaritySearchStats.loading">
|
||||||
|
<span class="search-progress-live"><i></i> Searching peers…</span>
|
||||||
|
</template>
|
||||||
|
<span x-text="`${$store.library.similaritySearchStats.tracks} tracks · ${$store.library.similaritySearchStats.artists} artists`"></span>
|
||||||
|
<template x-if="!$store.library.similaritySearchStats.loading">
|
||||||
|
<span x-text="`${$store.library.similaritySearchStats.peers} peers · ${$store.library.formatSearchDuration($store.library.similaritySearchStats.elapsed_ms)}`"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<template x-if="$store.library.searchLoading">
|
<template x-if="$store.library.searchLoading">
|
||||||
<div class="loading-spinner"><div class="spinner"></div></div>
|
<div class="loading-spinner"><div class="spinner"></div></div>
|
||||||
</template>
|
</template>
|
||||||
@@ -394,7 +427,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!$store.library.searchLoading && $store.library.searchResults">
|
<template x-if="!$store.library.searchLoading && $store.library.searchResults">
|
||||||
<div>
|
<div :class="{ 'similarity-unified-results': $store.library.similaritySearchLabel }">
|
||||||
<template x-if="!$store.library.similaritySearchError && $store.library.searchResults.artists.length === 0 && $store.library.searchResults.releases.length === 0 && $store.library.searchResults.tracks.length === 0">
|
<template x-if="!$store.library.similaritySearchError && $store.library.searchResults.artists.length === 0 && $store.library.searchResults.releases.length === 0 && $store.library.searchResults.tracks.length === 0">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
@@ -409,11 +442,9 @@
|
|||||||
<template x-for="artist in $store.library.searchResults.artists" :key="artist.id">
|
<template x-for="artist in $store.library.searchResults.artists" :key="artist.id">
|
||||||
<div class="search-artist-card" @click="$store.library.openArtist(artist.id)">
|
<div class="search-artist-card" @click="$store.library.openArtist(artist.id)">
|
||||||
<div class="search-artist-img">
|
<div class="search-artist-img">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||||
<template x-if="artist.image_url">
|
<template x-if="artist.image_url">
|
||||||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
<img class="artwork-image" :src="artist.image_url" alt="" aria-hidden="true" loading="lazy" @load="$event.currentTarget.classList.add('artwork-loaded')" @error="$event.currentTarget.classList.remove('artwork-loaded')">
|
||||||
</template>
|
|
||||||
<template x-if="!artist.image_url">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-artist-name" x-text="artist.name"></div>
|
<div class="search-artist-name" x-text="artist.name"></div>
|
||||||
@@ -430,11 +461,9 @@
|
|||||||
<template x-for="release in $store.library.searchResults.releases" :key="release.id">
|
<template x-for="release in $store.library.searchResults.releases" :key="release.id">
|
||||||
<div class="search-release-card" @click="$store.library.openRelease(release.id)" style="position:relative">
|
<div class="search-release-card" @click="$store.library.openRelease(release.id)" style="position:relative">
|
||||||
<div class="search-release-cover" style="position:relative">
|
<div class="search-release-cover" style="position:relative">
|
||||||
|
<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 x-if="release.cover_url">
|
<template x-if="release.cover_url">
|
||||||
<img :src="release.cover_url" :alt="release.title" loading="lazy">
|
<img class="artwork-image" :src="release.cover_url" alt="" aria-hidden="true" loading="lazy" @load="$event.currentTarget.classList.add('artwork-loaded')" @error="$event.currentTarget.classList.remove('artwork-loaded')">
|
||||||
</template>
|
|
||||||
<template x-if="!release.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>
|
|
||||||
</template>
|
</template>
|
||||||
<button class="card-info-btn" @click.stop="$store.library.openReleaseInfo(release)" :title="$store.library.releaseInfo(release)" aria-label="{{ t.player_release_info }}">
|
<button class="card-info-btn" @click.stop="$store.library.openReleaseInfo(release)" :title="$store.library.releaseInfo(release)" aria-label="{{ t.player_release_info }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
||||||
@@ -464,7 +493,8 @@
|
|||||||
<template x-for="(track, idx) in $store.library.searchResults.tracks" :key="track.id">
|
<template x-for="(track, idx) in $store.library.searchResults.tracks" :key="track.id">
|
||||||
<div class="track-row"
|
<div class="track-row"
|
||||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
||||||
@dblclick="$store.library.playSearchTrack(idx)">
|
:style="$store.library.similaritySearchLabel ? `order:${$store.library.similarityTrackOrder(track)}` : ''"
|
||||||
|
@dblclick="$store.library.similaritySearchLabel ? $store.library.playSimilarityResult(track) : $store.library.playSearchTrack(idx)">
|
||||||
<span class="track-num" x-text="idx + 1"></span>
|
<span class="track-num" x-text="idx + 1"></span>
|
||||||
<div class="track-info">
|
<div class="track-info">
|
||||||
<div class="track-title" x-text="track.title"></div>
|
<div class="track-title" x-text="track.title"></div>
|
||||||
@@ -509,8 +539,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="search-section federation-search-section">
|
<div class="search-section federation-search-section"
|
||||||
<h2 class="search-section-title">
|
:class="{ 'similarity-federation-merged': $store.library.similaritySearchLabel }">
|
||||||
|
<h2 class="search-section-title" x-show="!$store.library.similaritySearchLabel">
|
||||||
Federation
|
Federation
|
||||||
<span class="federation-live-badge"
|
<span class="federation-live-badge"
|
||||||
x-show="$store.library.federationSearch.loading"
|
x-show="$store.library.federationSearch.loading"
|
||||||
@@ -520,11 +551,11 @@
|
|||||||
<div class="federation-search-status error"
|
<div class="federation-search-status error"
|
||||||
x-text="$store.library.federationSearch.error"></div>
|
x-text="$store.library.federationSearch.error"></div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="$store.library.federationSearch.loading && $store.library.federationSearch.tracks.length === 0">
|
<template x-if="!$store.library.similaritySearchLabel && $store.library.federationSearch.loading && $store.library.federationSearch.tracks.length === 0">
|
||||||
<div class="federation-search-status">Searching peers…</div>
|
<div class="federation-search-status">Searching peers…</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="search-artists-row"
|
<div class="search-artists-row"
|
||||||
x-show="$store.library.federationSearch.artists.length > 0"
|
x-show="!$store.library.similaritySearchLabel && $store.library.federationSearch.artists.length > 0"
|
||||||
x-cloak>
|
x-cloak>
|
||||||
<template x-for="artist in $store.library.federationSearch.artists"
|
<template x-for="artist in $store.library.federationSearch.artists"
|
||||||
:key="artist.key.normalized_name">
|
:key="artist.key.normalized_name">
|
||||||
@@ -532,10 +563,12 @@
|
|||||||
@click="$store.library.openFederatedArtist(artist)">
|
@click="$store.library.openFederatedArtist(artist)">
|
||||||
<div class="search-artist-img">
|
<div class="search-artist-img">
|
||||||
<img x-show="$store.library.federationArtistImage(artist)"
|
<img x-show="$store.library.federationArtistImage(artist)"
|
||||||
|
class="artwork-image"
|
||||||
:src="$store.library.federationArtistImage(artist)"
|
:src="$store.library.federationArtistImage(artist)"
|
||||||
:alt="artist.name"
|
alt="" aria-hidden="true"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
@error="$event.currentTarget.style.display = 'none'">
|
@load="$event.currentTarget.classList.add('artwork-loaded')"
|
||||||
|
@error="$event.currentTarget.classList.remove('artwork-loaded'); $store.library.federationArtistImageFailed(artist)">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
<circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/>
|
<circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -547,18 +580,21 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-releases-row"
|
<div class="search-releases-row"
|
||||||
x-show="$store.library.federationSearch.releases.length > 0"
|
x-show="!$store.library.similaritySearchLabel && $store.library.federationSearch.releases.length > 0"
|
||||||
x-cloak>
|
x-cloak>
|
||||||
<template x-for="release in $store.library.federationSearch.releases"
|
<template x-for="release in $store.library.federationSearch.releases"
|
||||||
:key="JSON.stringify(release.key)">
|
:key="JSON.stringify(release.key)">
|
||||||
<div class="search-release-card federation-entity-card">
|
<div class="search-release-card federation-entity-card"
|
||||||
|
@click="$store.library.openFederatedRelease($store.library.hydrateFederationSearchRelease(release))">
|
||||||
<div class="search-release-cover">
|
<div class="search-release-cover">
|
||||||
<img x-show="release.cover_url"
|
<img x-show="$store.library.federationReleaseCover(release)"
|
||||||
:src="release.cover_url"
|
class="artwork-image"
|
||||||
:alt="release.title"
|
:src="$store.library.federationReleaseCover(release)"
|
||||||
loading="lazy">
|
alt="" aria-hidden="true"
|
||||||
<svg x-show="!release.cover_url"
|
loading="lazy"
|
||||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
@load="$event.currentTarget.classList.add('artwork-loaded')"
|
||||||
|
@error="$event.currentTarget.classList.remove('artwork-loaded'); $store.library.federationReleaseCoverFailed(release, $event.currentTarget.getAttribute('src'))">
|
||||||
|
<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"/>
|
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -571,7 +607,8 @@
|
|||||||
<template x-for="(track, idx) in $store.library.federationSearch.tracks"
|
<template x-for="(track, idx) in $store.library.federationSearch.tracks"
|
||||||
:key="track.key.content_id">
|
:key="track.key.content_id">
|
||||||
<div class="track-row federation-track-row"
|
<div class="track-row federation-track-row"
|
||||||
@dblclick="$store.library.playFederatedTrack(track)">
|
:style="$store.library.similaritySearchLabel ? `order:${$store.library.similarityTrackOrder(track)}` : ''"
|
||||||
|
@dblclick="$store.library.similaritySearchLabel ? $store.library.playSimilarityResult($store.library.federationQueueTrack(track)) : $store.library.playFederatedTrack(track)">
|
||||||
<span class="track-num federation-track-status">
|
<span class="track-num federation-track-status">
|
||||||
<template x-if="!$store.library.federationDownload(track.key.content_id) && !$store.library.federationFailure(track.key.content_id) && !$store.library.isTrackLocal(track)">
|
<template x-if="!$store.library.federationDownload(track.key.content_id) && !$store.library.federationFailure(track.key.content_id) && !$store.library.isTrackLocal(track)">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
@@ -645,6 +682,12 @@
|
|||||||
x-text="formatTime(track.metadata.duration_seconds)"></span>
|
x-text="formatTime(track.metadata.duration_seconds)"></span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<div class="similarity-search-inline-progress"
|
||||||
|
x-show="$store.library.similaritySearchLabel && $store.library.similaritySearchStats.loading"
|
||||||
|
x-cloak>
|
||||||
|
<span class="similarity-search-inline-spinner" aria-hidden="true"></span>
|
||||||
|
<span>Searching federation for more similar tracks…</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -666,7 +709,7 @@
|
|||||||
<div class="card" @click="$store.library.openArtist(artist.id)">
|
<div class="card" @click="$store.library.openArtist(artist.id)">
|
||||||
<div class="card-img">
|
<div class="card-img">
|
||||||
<template x-if="artist.image_url">
|
<template x-if="artist.image_url">
|
||||||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
<img :src="artist.image_url" alt="" aria-hidden="true" loading="lazy">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!artist.image_url">
|
<template x-if="!artist.image_url">
|
||||||
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg></span>
|
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg></span>
|
||||||
@@ -722,7 +765,7 @@
|
|||||||
<div class="artist-img">
|
<div class="artist-img">
|
||||||
<template x-if="$store.library.currentArtist.image_url">
|
<template x-if="$store.library.currentArtist.image_url">
|
||||||
<img :src="$store.library.currentArtist.image_url"
|
<img :src="$store.library.currentArtist.image_url"
|
||||||
:alt="$store.library.currentArtist.name"
|
alt="" aria-hidden="true"
|
||||||
@error="$store.library.currentArtist.image_url = null">
|
@error="$store.library.currentArtist.image_url = null">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!$store.library.currentArtist.image_url">
|
<template x-if="!$store.library.currentArtist.image_url">
|
||||||
@@ -805,7 +848,7 @@
|
|||||||
:title="track.release_title"
|
:title="track.release_title"
|
||||||
aria-label="{{ t.player_release }}">
|
aria-label="{{ t.player_release }}">
|
||||||
<template x-if="track.cover_url">
|
<template x-if="track.cover_url">
|
||||||
<img :src="track.cover_url" :alt="track.release_title" loading="lazy">
|
<img :src="track.cover_url" alt="" aria-hidden="true" loading="lazy">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!track.cover_url">
|
<template x-if="!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>
|
||||||
@@ -868,7 +911,7 @@
|
|||||||
<div class="card" @click="$store.library.openRelease(release.id)">
|
<div class="card" @click="$store.library.openRelease(release.id)">
|
||||||
<div class="card-img">
|
<div class="card-img">
|
||||||
<template x-if="release.cover_url">
|
<template x-if="release.cover_url">
|
||||||
<img :src="release.cover_url" :alt="release.title"
|
<img :src="release.cover_url" alt="" aria-hidden="true"
|
||||||
loading="lazy" @error="release.cover_url = null">
|
loading="lazy" @error="release.cover_url = null">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!release.cover_url">
|
<template x-if="!release.cover_url">
|
||||||
@@ -904,7 +947,7 @@
|
|||||||
@click="$store.library.openFederatedRelease(release)">
|
@click="$store.library.openFederatedRelease(release)">
|
||||||
<div class="card-img">
|
<div class="card-img">
|
||||||
<template x-if="release.cover_url">
|
<template x-if="release.cover_url">
|
||||||
<img :src="release.cover_url" :alt="release.title"
|
<img :src="release.cover_url" alt="" aria-hidden="true"
|
||||||
loading="lazy" @error="release.cover_url = null">
|
loading="lazy" @error="release.cover_url = null">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!release.cover_url">
|
<template x-if="!release.cover_url">
|
||||||
@@ -1034,7 +1077,7 @@
|
|||||||
:title="track.release_title"
|
:title="track.release_title"
|
||||||
aria-label="{{ t.player_release }}">
|
aria-label="{{ t.player_release }}">
|
||||||
<template x-if="track.cover_url">
|
<template x-if="track.cover_url">
|
||||||
<img :src="track.cover_url" :alt="track.release_title" loading="lazy">
|
<img :src="track.cover_url" alt="" aria-hidden="true" loading="lazy">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!track.cover_url">
|
<template x-if="!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>
|
||||||
@@ -1108,7 +1151,7 @@
|
|||||||
<div class="release-header">
|
<div class="release-header">
|
||||||
<div class="release-cover">
|
<div class="release-cover">
|
||||||
<template x-if="$store.library.currentRelease.cover_url">
|
<template x-if="$store.library.currentRelease.cover_url">
|
||||||
<img :src="$store.library.currentRelease.cover_url" :alt="$store.library.currentRelease.title">
|
<img :src="$store.library.currentRelease.cover_url" alt="" aria-hidden="true">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!$store.library.currentRelease.cover_url">
|
<template x-if="!$store.library.currentRelease.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>
|
||||||
@@ -1486,11 +1529,9 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="queue-track-cover">
|
<div class="queue-track-cover">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||||
<template x-if="item.track.cover_url">
|
<template x-if="item.track.cover_url">
|
||||||
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
|
<img class="artwork-image" :src="item.track.cover_url" alt="" aria-hidden="true" loading="lazy" @load="$event.currentTarget.classList.add('artwork-loaded')" @error="$event.currentTarget.classList.remove('artwork-loaded')">
|
||||||
</template>
|
|
||||||
<template x-if="!item.track.cover_url">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
|
||||||
</template>
|
</template>
|
||||||
<span class="queue-federation-status federation-track-status"
|
<span class="queue-federation-status federation-track-status"
|
||||||
x-show="item.track.federation_pending"
|
x-show="item.track.federation_pending"
|
||||||
@@ -1570,7 +1611,7 @@
|
|||||||
<div class="player-cover"
|
<div class="player-cover"
|
||||||
@click.stop="$store.mobile.openPlayerFullscreen()">
|
@click.stop="$store.mobile.openPlayerFullscreen()">
|
||||||
<template x-if="$store.player.currentTrack.cover_url">
|
<template x-if="$store.player.currentTrack.cover_url">
|
||||||
<img :src="$store.player.currentTrack.cover_url" :alt="$store.player.currentTrack.title">
|
<img :src="$store.player.currentTrack.cover_url" alt="" aria-hidden="true">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!$store.player.currentTrack.cover_url">
|
<template x-if="!$store.player.currentTrack.cover_url">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||||
@@ -1868,11 +1909,9 @@
|
|||||||
type="button"
|
type="button"
|
||||||
@click="item.index >= 0 ? $store.queue.playFromIndex(item.index) : $store.player.play(item.track)">
|
@click="item.index >= 0 ? $store.queue.playFromIndex(item.index) : $store.player.play(item.track)">
|
||||||
<div class="mobile-expanded-queue-cover">
|
<div class="mobile-expanded-queue-cover">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||||
<template x-if="item.track.cover_url">
|
<template x-if="item.track.cover_url">
|
||||||
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
|
<img class="artwork-image" :src="item.track.cover_url" alt="" aria-hidden="true" loading="lazy" @load="$event.currentTarget.classList.add('artwork-loaded')" @error="$event.currentTarget.classList.remove('artwork-loaded')">
|
||||||
</template>
|
|
||||||
<template x-if="!item.track.cover_url">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-expanded-queue-info">
|
<div class="mobile-expanded-queue-info">
|
||||||
|
|||||||
+628
-19
@@ -450,22 +450,6 @@ button.user-stat:hover {
|
|||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-bottom {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-bottom a {
|
|
||||||
color: var(--text-subdued);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-bottom a:hover { color: var(--text-secondary); }
|
|
||||||
|
|
||||||
/* Center Content */
|
/* Center Content */
|
||||||
.center-content {
|
.center-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -1411,7 +1395,7 @@ button.user-stat:hover {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-track-cover img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
|
.queue-track-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
|
||||||
.queue-track-cover svg { width: 20px; height: 20px; color: var(--text-subdued); }
|
.queue-track-cover svg { width: 20px; height: 20px; color: var(--text-subdued); }
|
||||||
|
|
||||||
.queue-track-cover .queue-federation-status {
|
.queue-track-cover .queue-federation-status {
|
||||||
@@ -2751,6 +2735,41 @@ button.user-stat:hover {
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
padding-top: 18px;
|
padding-top: 18px;
|
||||||
}
|
}
|
||||||
|
.federation-search-section.similarity-federation-merged {
|
||||||
|
margin-top: -24px;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
.similarity-unified-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.similarity-unified-results > .search-section {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.similarity-unified-results .search-section-title { order: -1000002; }
|
||||||
|
.similarity-unified-results .track-list-header { order: -1000001; }
|
||||||
|
.similarity-unified-results .federation-search-status { order: 1000001; }
|
||||||
|
.similarity-search-inline-progress {
|
||||||
|
order: 1000000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 9px;
|
||||||
|
min-height: 42px;
|
||||||
|
margin-top: 4px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.similarity-search-inline-spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, .16);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 999px;
|
||||||
|
animation: federation-progress-spin .8s linear infinite;
|
||||||
|
}
|
||||||
.federation-live-badge {
|
.federation-live-badge {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
@@ -2872,12 +2891,20 @@ button.user-stat:hover {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-similarity-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
.search-similarity-title {
|
.search-similarity-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin: 0 0 20px;
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -2886,6 +2913,42 @@ button.user-stat:hover {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
.search-similarity-progress {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.search-similarity-progress.loading { border-color: rgba(29, 185, 84, .38); }
|
||||||
|
.search-progress-live {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.search-progress-live i {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: currentColor;
|
||||||
|
animation: similarity-search-pulse 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes similarity-search-pulse {
|
||||||
|
50% { opacity: .3; transform: scale(.75); }
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.search-similarity-heading { align-items: flex-start; flex-direction: column; }
|
||||||
|
.search-similarity-progress { max-width: 100%; flex-wrap: wrap; white-space: normal; }
|
||||||
|
}
|
||||||
|
|
||||||
.search-artists-row {
|
.search-artists-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2959,6 +3022,7 @@ button.user-stat:hover {
|
|||||||
.search-release-card:hover { background: var(--bg-elevated); }
|
.search-release-card:hover { background: var(--bg-elevated); }
|
||||||
|
|
||||||
.search-release-cover {
|
.search-release-cover {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -2970,9 +3034,12 @@ button.user-stat:hover {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-release-cover img { width: 100%; height: 100%; object-fit: cover; }
|
.search-release-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||||
.search-release-cover svg { width: 40px; height: 40px; color: var(--text-subdued); }
|
.search-release-cover svg { width: 40px; height: 40px; color: var(--text-subdued); }
|
||||||
|
|
||||||
|
.artwork-image { z-index: 1; opacity: 0; }
|
||||||
|
.artwork-image.artwork-loaded { opacity: 1; }
|
||||||
|
|
||||||
/* Like button */
|
/* Like button */
|
||||||
.like-btn {
|
.like-btn {
|
||||||
background: none;
|
background: none;
|
||||||
@@ -3348,6 +3415,487 @@ button.user-stat:hover {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.download-source-tabs {
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-source-tabs .torrent-tab-btn {
|
||||||
|
min-width: 112px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-torrent-panel,
|
||||||
|
.youtube-manager-panel {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-panel {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-drop-zone {
|
||||||
|
min-height: 176px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 24px;
|
||||||
|
border: 2px dashed var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-subdued);
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-drop-zone:hover,
|
||||||
|
.file-drop-zone.dragging {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(29,185,84,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-drop-zone.dragging {
|
||||||
|
transform: scale(0.995);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-drop-zone strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-drop-zone span {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-drop-zone small {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-drop-icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(29,185,84,0.14);
|
||||||
|
color: #7ee29e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-drop-icon svg {
|
||||||
|
width: 23px;
|
||||||
|
height: 23px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-selection {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-selection > div {
|
||||||
|
display: flex;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-history-head {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-history {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-history-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-history-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-history-title {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-history-meta {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-torrent-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-manager-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-download-form {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-download-form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-modal .youtube-download-form-row input[type="text"] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-download-hint {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-card {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 0 1 360px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid rgba(29,185,84,0.35);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-head,
|
||||||
|
.youtube-preview-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-head h4 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-head p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-head strong {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-controls,
|
||||||
|
.youtube-preview-footer > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-list {
|
||||||
|
min-height: 72px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 30px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-row:hover,
|
||||||
|
.youtube-preview-row.selected {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-row input[type="checkbox"] {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: #1db954;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-item-title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-footer {
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-download-list-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-download-list {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-empty-state {
|
||||||
|
min-height: 180px;
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-job-card {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 13px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-job-head,
|
||||||
|
.youtube-item-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-job-heading,
|
||||||
|
.youtube-item-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-job-title,
|
||||||
|
.youtube-item-title {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-job-title { font-size: 14px; }
|
||||||
|
.youtube-item-title { font-size: 12px; }
|
||||||
|
|
||||||
|
.youtube-job-meta,
|
||||||
|
.youtube-item-meta {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-job-progress {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 9px;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-job-progress .torrent-session-progress,
|
||||||
|
.youtube-item-download-progress .torrent-session-progress {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-job-error,
|
||||||
|
.youtube-item-error {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #ffb9b9;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-item-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-item-row {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.07);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-item-row.failed {
|
||||||
|
border-color: rgba(229,96,96,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-item-head {
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-item-index {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-item-download-progress {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-step-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-step {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-step i {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
flex: 0 0 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-step b {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-step.done { color: #9ff0b9; }
|
||||||
|
.youtube-step.done i { border-color: var(--accent); background: var(--accent); }
|
||||||
|
.youtube-step.active { color: #ffd78a; }
|
||||||
|
.youtube-step.active i {
|
||||||
|
border-color: #f0b84d;
|
||||||
|
background: #f0b84d;
|
||||||
|
box-shadow: 0 0 0 3px rgba(240,184,77,0.13);
|
||||||
|
}
|
||||||
|
.youtube-step.failed { color: #ffb9b9; }
|
||||||
|
.youtube-step.failed i { border-color: #e56060; background: #e56060; }
|
||||||
|
.youtube-step.cancelled { color: #ffd78a; }
|
||||||
|
.youtube-step.cancelled i { border-color: #f0b84d; background: #f0b84d; }
|
||||||
|
|
||||||
|
.youtube-job-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.torrent-manager-layout {
|
.torrent-manager-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
|
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
|
||||||
@@ -5585,6 +6133,7 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mobile-expanded-queue-cover {
|
.mobile-expanded-queue-cover {
|
||||||
|
position: relative;
|
||||||
width: 42px;
|
width: 42px;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -5596,6 +6145,8 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mobile-expanded-queue-cover img {
|
.mobile-expanded-queue-cover img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
@@ -5946,6 +6497,64 @@ button.user-stat:hover {
|
|||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.youtube-manager-panel {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-panel {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-history {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-history-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-history-row .modal-btn-danger {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-selection {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-download-form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-download-form-row .modal-btn {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-card {
|
||||||
|
flex-basis: auto;
|
||||||
|
max-height: 330px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-head,
|
||||||
|
.youtube-preview-footer {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-controls {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-download-list {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-step-list {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
row-gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
.upload-manager-panel {
|
.upload-manager-panel {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user