Compare commits

...
6 Commits
Author SHA1 Message Date
Ultradesu 5402d9595d Added socks proxy, reworked download manager configuration
Build and Publish / Build and Publish Docker Image (push) Successful in 3m41s
2026-08-14 10:54:56 +01:00
Aleksandr Bogomiakov 9d9edcfec8 Fix Docker build
Build and Publish / Build and Publish Docker Image (push) Successful in 4m4s
2026-08-14 01:59:16 +01:00
Aleksandr Bogomiakov 943191a0ff Added youtube import. Reworked Download Manager
Build and Publish / Build and Publish Docker Image (push) Successful in 3m52s
2026-08-14 01:21:51 +01:00
Ultradesu 2b254f417f Updated readme 2026-08-12 01:41:00 +01:00
Ultradesu 69883af8bd Reworked settings page
Build and Publish / Build and Publish Docker Image (push) Successful in 3m28s
2026-08-11 12:26:16 +01:00
Aleksandr Bogomiakov 6f337ee626 Add DHT routing for similarity search
Build and Publish / Build and Publish Docker Image (push) Successful in 3m30s
2026-08-10 19:54:40 +01:00
25 changed files with 6413 additions and 569 deletions
Generated
+7 -5
View File
@@ -1793,9 +1793,9 @@ dependencies = [
[[package]] [[package]]
name = "federation-net" name = "federation-net"
version = "0.2.0" version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15a8707baeccb46b5935138f9cb3df3c988c0730b807a2634d901f26b39250d6" checksum = "c3e690b370c505d153bef214b21a8f2aa55d667367ac1e16bde8bc0de88963c2"
dependencies = [ dependencies = [
"blake3", "blake3",
"data-encoding", "data-encoding",
@@ -1938,7 +1938,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]] [[package]]
name = "furumusic" name = "furumusic"
version = "0.9.8" 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",
@@ -3775,9 +3777,9 @@ dependencies = [
[[package]] [[package]]
name = "music-dht" name = "music-dht"
version = "0.3.1" version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91592b40de9f3c2158a39da105c821e9bf17f461fe142a56a8607fb0faf56a9c" checksum = "0c5b429b90a8f1b0980b3a35a6fa5445d7a275c737eb04db18db4d7f14c81478"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"blake3", "blake3",
+5 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "furumusic" name = "furumusic"
version = "0.10.0" version = "0.10.4"
edition = "2024" edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
@@ -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"
@@ -43,4 +45,4 @@ uuid = "1"
librqbit = { version = "8.1.1", features = ["disable-upload"] } librqbit = { version = "8.1.1", features = ["disable-upload"] }
# P2P federation: publishes the library into a shared DHT and serves audio / # P2P federation: publishes the library into a shared DHT and serves audio /
# catalogs to furumi peers (TUI clients) over the frid stack. # catalogs to furumi peers (TUI clients) over the frid stack.
music-dht = "0.3.1" music-dht = "0.4.0"
+13 -2
View File
@@ -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"]
+125 -183
View File
@@ -1,201 +1,143 @@
# furumusic # Furumusic
Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL. **Your library. Your users. Your network.**
Built with Rust ([cot](https://cot.rs) framework). Furumusic is a self-hosted, multi-user music server with a full web player and
support for the Furumi federated network. It turns a collection of music files
into a shared library that is available from any modern browser, while every
user keeps their own playlists, likes, listening history, and playback state.
## Quick start Music can be uploaded directly, imported from a `.torrent` file, or downloaded
from a magnet link. An optional AI-assisted import pipeline reads the available
tags and path information, reconstructs inconsistent metadata, finds artwork,
and organizes the result into artists, releases, and tracks. Uncertain matches
are kept for review instead of silently entering the library with bad data.
## Why Furumusic?
Furumusic is for a household, a small community, or anyone who wants one music
library without handing it to a subscription service. The server owns the
catalog and media files; the browser is only the player.
- one shared library with separate user accounts;
- a responsive web player with artists, releases, search, queue, and playlists;
- direct file uploads and imports from YouTube, torrent files, or magnet links;
- optional AI-assisted recognition and normalization of metadata;
- password login or OIDC/SSO with group-based access control;
- optional federation without a central catalog or search service;
- trusted-device pairing, playback handoff, and synchronization between Furumi
players;
- Last.fm scrobbling and similarity-based discovery when configured.
Furumusic does not include music. Import only media you are allowed to store and
share.
## Importing music
Users can add audio files from the web interface or ask the server to download
selected files from a torrent. Both routes feed the same import pipeline, so a
library remains consistent regardless of where its files came from.
The importer combines embedded tags, filenames, folder structure, and existing
catalog context. With an OpenAI-compatible language model configured, it can
normalize artist and release names, separate featured artists, recover track
numbers and release types, and flag ambiguous results for a user to approve.
Cover art is taken from nearby image files or embedded artwork when available.
The inbox and permanent library are separate directories. New files are first
processed in the inbox and are moved into the organized library only after
their metadata has been accepted.
## Federation
Federation is optional. Independent Furumusic and Furumi players that use the
same Network ID discover one another as a logical network. Each peer keeps its
own library and can continue working alone.
When federation is enabled, local and remote artists, releases, and tracks can
appear in the same search and library views. Missing audio is streamed from an
available peer and can optionally be retained locally. Similarity search also
stays decentralized: embeddings and exact ranking remain on the instance that
owns the music.
Trusted-device pairing is separate from public catalog discovery. It connects
a user's own Furumi players so likes, playlists, playback state, and control can
move between approved devices.
## Build and run
Furumusic requires PostgreSQL and a Rust toolchain with Rust 2024 edition
support. Create an empty database, provide its connection URL, and start the
server:
```bash ```bash
export FURU_DATABASE_URL=postgresql://user:pass@localhost/furumusic export FURU_DATABASE_URL='postgresql://furumusic:password@127.0.0.1/furumusic'
cargo run cargo run --release --locked
# Open http://localhost:8000/admin/setup to create the first admin account
``` ```
## Project structure Open <http://127.0.0.1:8000/admin/setup> to create the first administrator.
After setup, configure the inbox and library directories under **Admin →
Settings** before importing music.
To listen on another address or port:
```bash
cargo run --release --locked -- -l 0.0.0.0:8000
``` ```
Cargo.toml Project manifest and dependencies
build.rs Captures rustc version + target at compile time A Nix development shell is included for Linux and macOS:
src/
main.rs Entrypoint; HTTP router, login/logout handlers, tracing init ```bash
config.rs 3-tier config system (default → DB → env); FURU_* env vars nix develop
auth.rs Session auth, Role enum (Admin/User), login/logout/guards cargo run --locked
user.rs User + OidcLink DB models, CRUD, password hashing, migrations
oidc.rs OIDC/SSO flow: discovery, PKCE, token exchange, user provisioning
i18n/
mod.rs Language resolution (cookie → Accept-Language → default), extractor
phrases.rs All UI strings in English and Russian (translations! macro)
api/
mod.rs JSON API endpoints (mounted at /api), session-based auth
admin/
mod.rs Admin sub-app router: dashboard, settings, users, debug, setup
views.rs Admin page handlers and templates
templates/
base.html Root HTML layout with lang/title blocks
login.html Login page (password + optional SSO button)
admin/
layout.html Admin sidebar/nav wrapper
index.html Admin dashboard
debug.html Build info + config table (with secret redaction)
settings.html OIDC and auth settings form
setup.html First-run admin account creation
users.html User list
user_form.html User create/edit form
``` ```
The repository also contains a multi-stage `Dockerfile` for building a small
runtime image. A deployment must provide PostgreSQL plus persistent, writable
volumes for the inbox and music library.
## Configuration
Most settings can be changed from the administration interface. Every setting
also has a `FURU_`-prefixed environment variable; environment values take
priority over values stored in PostgreSQL.
The settings needed for a useful first installation are:
| Setting | Purpose |
| --- | --- |
| `FURU_DATABASE_URL` | PostgreSQL connection URL; required to run the service |
| `FURU_AGENT_INBOX_DIR` | Temporary inbox for uploads and downloaded files |
| `FURU_AGENT_STORAGE_DIR` | Permanent, organized music library |
| `FURU_AGENT_ENABLED` | Enables the background metadata import pipeline |
| `FURU_AGENT_LLM_URL` | Base URL of an OpenAI-compatible model server |
| `FURU_AGENT_LLM_MODEL` | Model used to recognize and normalize metadata |
| `FURU_FEDERATION_ENABLED` | Publishes the library and enables peer discovery |
| `FURU_FEDERATION_NETWORK_ID` | Joins peers with the same value into one logical network |
AI recognition, federation, similarity search, Last.fm, and OIDC are optional.
A local password-authenticated server can be used without any of them.
## Architecture ## Architecture
### Config system (`src/config.rs`) Furumusic is written in Rust on the
[Cot](https://cot.rs) web framework. PostgreSQL stores the catalog, accounts,
playlists, configuration, and background-job state. Audio inspection uses
Symphonia, torrent downloads use librqbit, and the shared `music-dht`/Frid
protocol stack provides decentralized catalog search, media transfer, and
connected-device synchronization.
Every setting lives in `AppConfig` and is resolved in three layers: The browser interface and JSON API are served by the same application. Import,
artwork, metadata enrichment, similarity indexing, and maintenance run as
durable background jobs rather than blocking playback requests.
1. **Compiled default**`AppConfig::default()` ## Contributing
2. **Database override** — rows in the `furumusic__config_entry` table
3. **Environment variable**`FURU_<FIELD_NAME>` (highest priority)
`ConfigSources` tracks where each field's effective value came from (shown in the admin debug page). Bug reports, design discussions, and patches are welcome. Before submitting a
change, run:
**To add a new config field:** ```bash
cargo fmt --all -- --check
1. Add the field to `AppConfig` struct cargo clippy --all-targets -- -D warnings
2. Set its default in `AppConfig::default()` cargo test --all-targets
3. Add the field to `ConfigSources` struct and its `Default` impl
4. Add it to the `impl_env_overrides!(…)` invocation
5. Add an `apply_db_field!()` call in `apply_db_overrides`
6. Add an `entry!()` line in `admin/views.rs → config_display_entries()`
### Auth (`src/auth.rs`)
Session-based authentication with two roles:
- **`Role::Admin`** — full access to admin panel
- **`Role::User`** — standard user
Key functions:
- `login(session, user_id)` — sets session, cycles session ID
- `logout(session)` — flushes session
- `get_session_user(session, db)` — returns `AuthenticatedUser` if active
- `require_admin_or_redirect(session, db)` — guard that returns 403 or redirects to `/login`
### OIDC/SSO (`src/oidc.rs`)
Full OpenID Connect authorization code flow with PKCE:
1. `GET /auth/oidc/start` — discovers provider, builds auth URL, stores CSRF/nonce/PKCE in session, redirects to IdP
2. `GET /auth/oidc/callback` — validates CSRF, exchanges code for tokens, verifies ID token, provisions user
Provider metadata is cached for 1 hour and invalidated when OIDC config changes.
**Group access and role mapping:** The `oidc_user_groups` config field lists OIDC group names (comma-separated) allowed to access the service. When it is set, users outside both `oidc_user_groups` and `oidc_admin_groups` are denied before provisioning/login. The `oidc_admin_groups` config field lists OIDC group names that grant the admin role. Groups are extracted from the `groups` claim in the ID token JWT payload.
**User provisioning order:**
1. Find existing `OidcLink` by issuer+sub → update claims, update role
2. Find existing `User` by email → create OidcLink, update role
3. Create new user (no password) + OidcLink
Stale links (pointing to deleted users) are cleaned up automatically.
### User model (`src/user.rs`)
Two database models:
- **`User`** — id, username (unique), password (optional for OIDC-only), email, display_name, avatar_url, role, is_active
- **`OidcLink`** — id, user_id, issuer, sub, email, name, avatar_url; unique index on (issuer, sub)
Migrations: M0003 (User table), M0004 (OidcLink table), M0005 (OidcLink indexes).
### i18n (`src/i18n/`)
Compile-time bilingual UI (English + Russian).
- `translations!` macro in `phrases.rs` generates a `Translations` struct with static `EN` and `RU` instances
- Language resolution: `furu_lang` cookie → `Accept-Language` header → English default
- `I18n` is a cot request extractor — handlers receive it automatically
- `set_lang` endpoint (`/set-lang?lang=ru&next=/`) sets the cookie
### API (`src/api/`)
JSON API mounted at `/api`. Uses the same session cookie as HTML pages — works automatically for same-origin frontend requests (no CORS, no tokens needed).
Helpers in `api/mod.rs`:
- `json_ok(value)` — 200 with `application/json`
- `json_error(status, message)` — error response as `{"error": "..."}`
| Route | Method | Description |
|-------|--------|-------------|
| `/api/me` | GET | Current user (id, name, role) or 401 |
**Swagger UI** is available at `/swagger/` when `FURU_SWAGGER_ENABLED=true`. The OpenAPI spec is auto-generated from handler types.
To add a new API endpoint:
1. Define request/response structs with `#[derive(Serialize, JsonSchema)]`
2. Write an async handler, return `Json(response).into_response()`
3. Add a `Route::with_api_handler_and_name(…, api_get(handler), …)` in `ApiApp::router()`
4. The endpoint appears automatically in Swagger UI
### Admin panel (`src/admin/`)
Mounted at `/admin`. All routes (except `/admin/setup`) require `Role::Admin`.
| Route | Purpose |
|-------|---------|
| `/admin/setup` | First-run: create initial admin (only works when zero users exist) |
| `/admin/` | Dashboard |
| `/admin/debug` | Build info, config values with sources, DB connectivity |
| `/admin/settings` | OIDC config, auth toggles (saved to DB config table) |
| `/admin/users` | User list |
| `/admin/users/new` | Create user |
| `/admin/users/{id}/edit` | Edit user |
| `/admin/users/{id}/delete` | Delete user (POST) |
## How to extend
### 1. Add a config field
See [Config system](#config-system-srcconfigrs) above — 6 locations to update.
### 2. Add a database model
1. Define a struct with `#[cot::db::model]` in a new or existing file
2. Write a migration struct implementing `cot::db::migrations::Migration`
3. Register the migration in the `AdminApp::migrations()` method in `src/admin/mod.rs`
### 3. Add a page
1. Create a template in `templates/`
2. Write a handler function that returns `Html`
3. Add a `Route::with_handler_and_name(…)` in the appropriate `router()` method
4. If admin-only, wrap with `require_admin_or_redirect`
### 4. Add a translation
Add a line to the `translations!` macro in `src/i18n/phrases.rs`:
```rust
my_key: "English text", "Русский текст";
``` ```
Access it in handlers/templates as `i18n.t.my_key` (or `t.my_key` in templates).
### 5. Add an API endpoint
Same as adding a page, but return a JSON response instead of `Html`. The `json` feature is enabled in Cargo.toml.
## Environment variables
All prefixed with `FURU_`. Priority: env var > DB override > compiled default.
| Variable | Description | Default |
|----------|-------------|---------|
| `FURU_DATABASE_URL` | PostgreSQL connection URL | *(empty — required)* |
| `FURU_LOG_LEVEL` | Tracing filter (e.g. `info`, `debug`, `warn,furumusic=trace`) | `info` |
| `FURU_AUTH_PASSWORD_ENABLED` | Enable password login | `true` |
| `FURU_AUTH_SSO_ENABLED` | Enable SSO/OIDC login | `false` |
| `FURU_OIDC_ISSUER` | OIDC issuer URL | *(empty)* |
| `FURU_OIDC_CLIENT_ID` | OIDC client ID | *(empty)* |
| `FURU_OIDC_CLIENT_SECRET` | OIDC client secret | *(empty)* |
| `FURU_OIDC_BUTTON_TEXT` | SSO button label | `Sign in with SSO` |
| `FURU_OIDC_ADMIN_GROUPS` | Comma-separated OIDC groups that grant admin | *(empty)* |
| `FURU_OIDC_USER_GROUPS` | Comma-separated OIDC groups allowed to access the service. Empty means any authenticated SSO user is allowed. | *(empty)* |
| `FURU_SWAGGER_ENABLED` | Serve Swagger UI at `/swagger/` | `false` |
+3
View File
@@ -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}";
+143 -7
View File
@@ -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};
@@ -462,6 +462,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 +537,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 +581,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 {
@@ -980,15 +1042,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 +1065,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 +1161,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 +1350,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 +1392,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 +1427,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(),
}, },
} }
} }
+195
View File
@@ -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]
+6
View File
@@ -69,6 +69,12 @@ mod tests {
manifest.protocols.get(SIMILARITY_ID), manifest.protocols.get(SIMILARITY_ID),
Some(&music_dht::similarity::SIMILARITY_PROTOCOL_VERSION) Some(&music_dht::similarity::SIMILARITY_PROTOCOL_VERSION)
); );
assert_eq!(
manifest
.protocols
.get(music_dht::capabilities::SIMILARITY_DHT_ID),
Some(&music_dht::similarity_lsh::SIMILARITY_DHT_PROTOCOL_VERSION)
);
manifest.validate().unwrap(); manifest.validate().unwrap();
} }
} }
+4
View File
@@ -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,
} }
} }
+167 -7
View File
@@ -29,6 +29,8 @@ use std::time::Duration;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use music_dht::capabilities::CAPABILITIES_ALPN; use music_dht::capabilities::CAPABILITIES_ALPN;
use music_dht::similarity_dht::SimilarityDht;
use music_dht::similarity_lsh::SIMILARITY_DHT_ALPN;
use music_dht::{ use music_dht::{
ByteStream, ByteStreamConnectionStats, ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService, ByteStream, ByteStreamConnectionStats, ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService,
NetworkId, PeerTicket, PublishStats, RendezvousConfig, SyncStats, NetworkId, PeerTicket, PublishStats, RendezvousConfig, SyncStats,
@@ -49,6 +51,7 @@ const TRANSPORT_SAMPLE_LIMIT: usize = 16;
struct Running { struct Running {
service: Arc<MusicDhtService>, service: Arc<MusicDhtService>,
similarity_dht: Arc<SimilarityDht>,
network_name: String, network_name: String,
tasks: Vec<tokio::task::JoinHandle<()>>, tasks: Vec<tokio::task::JoinHandle<()>>,
} }
@@ -210,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,
@@ -221,7 +268,8 @@ pub fn record_stream_transport(
} }
pub struct Federation { pub struct Federation {
/// Transport data directory; server-side DHT state and identity live in PostgreSQL. /// Transport files and replaceable similarity-routing cache. Durable
/// catalog DHT state and identity live in PostgreSQL.
data_dir: PathBuf, data_dir: PathBuf,
database_url: std::sync::Mutex<String>, database_url: std::sync::Mutex<String>,
storage_dir: std::sync::Mutex<String>, storage_dir: std::sync::Mutex<String>,
@@ -380,6 +428,9 @@ impl Federation {
let dht_storage = Arc::new(PostgresFederationStorage::new(pool.clone()).await?); let dht_storage = Arc::new(PostgresFederationStorage::new(pool.clone()).await?);
let secret_key = dht_storage.load_or_create_secret_key().await?; let secret_key = dht_storage.load_or_create_secret_key().await?;
self.transport_stats.reset(); self.transport_stats.reset();
tokio::fs::create_dir_all(&self.data_dir)
.await
.with_context(|| format!("creating {}", self.data_dir.display()))?;
let config = MusicDhtConfig::builder() let config = MusicDhtConfig::builder()
.data_dir(&self.data_dir) .data_dir(&self.data_dir)
@@ -389,7 +440,8 @@ impl Federation {
.stream_protocol(AUDIO_ALPN) .stream_protocol(AUDIO_ALPN)
.stream_protocol(CATALOG_ALPN) .stream_protocol(CATALOG_ALPN)
.stream_protocol(devices::SYNC_ALPN) .stream_protocol(devices::SYNC_ALPN)
.stream_protocol(SIMILARITY_ALPN) .schema_independent_stream_protocol(SIMILARITY_ALPN)
.schema_independent_stream_protocol(SIMILARITY_DHT_ALPN)
.schema_independent_stream_protocol(CAPABILITIES_ALPN) .schema_independent_stream_protocol(CAPABILITIES_ALPN)
.build() .build()
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?; .map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
@@ -404,6 +456,25 @@ impl Federation {
"federation started" "federation started"
); );
let similarity_dht = SimilarityDht::open(
Arc::clone(&service),
self.data_dir.join("similarity-routing.sqlite3"),
)
.await
.map_err(|error| anyhow::anyhow!("failed to start the similarity DHT: {error}"))?;
let similarity_dht_acceptor = service
.stream_acceptor(SIMILARITY_DHT_ALPN)
.map_err(|error| anyhow::anyhow!("failed to take similarity DHT acceptor: {error}"))?;
let similarity_dht_serve_task =
tokio::spawn(Arc::clone(&similarity_dht).serve(similarity_dht_acceptor));
let similarity_dht_maintenance_task =
tokio::spawn(Arc::clone(&similarity_dht).maintenance());
let similarity_manager = crate::similarity::handle();
let similarity_dht_sync_task = tokio::spawn(similarity_route_sync_loop(
Arc::clone(&similarity_dht),
Arc::clone(&similarity_manager),
));
// Drain DHT events into the log; the channel is bounded. // Drain DHT events into the log; the channel is bounded.
let event_task = tokio::spawn(async move { let event_task = tokio::spawn(async move {
while let Some(event) = events.recv().await { while let Some(event) = events.recv().await {
@@ -467,13 +538,14 @@ impl Federation {
.map_err(|err| anyhow::anyhow!("failed to take the similarity acceptor: {err}"))?; .map_err(|err| anyhow::anyhow!("failed to take the similarity acceptor: {err}"))?;
let similarity_task = tokio::spawn(similarity::serve_peers( let similarity_task = tokio::spawn(similarity::serve_peers(
similarity_acceptor, similarity_acceptor,
crate::similarity::handle(), similarity_manager,
service.endpoint_id(), service.endpoint_id(),
Arc::clone(&self.transport_stats), Arc::clone(&self.transport_stats),
)); ));
*guard = Some(Running { *guard = Some(Running {
service, service,
similarity_dht,
network_name, network_name,
tasks: vec![ tasks: vec![
event_task, event_task,
@@ -484,6 +556,9 @@ impl Federation {
device_sync_task, device_sync_task,
capabilities_task, capabilities_task,
similarity_task, similarity_task,
similarity_dht_serve_task,
similarity_dht_maintenance_task,
similarity_dht_sync_task,
], ],
}); });
self.set_error(None); self.set_error(None);
@@ -507,6 +582,20 @@ impl Federation {
.context("federation is not running") .context("federation is not running")
} }
async fn similarity_services(&self) -> Result<(Arc<MusicDhtService>, Arc<SimilarityDht>)> {
self.running
.lock()
.await
.as_ref()
.map(|running| {
(
Arc::clone(&running.service),
Arc::clone(&running.similarity_dht),
)
})
.context("federation is not running")
}
async fn spawn_sync_soon(self: &Arc<Self>) { async fn spawn_sync_soon(self: &Arc<Self>) {
if let Ok(service) = self.service().await { if let Ok(service) = self.service().await {
let fed = Arc::clone(self); let fed = Arc::clone(self);
@@ -804,14 +893,19 @@ 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,
"endpoint_id": service.endpoint_id().to_string(), "endpoint_id": service.endpoint_id().to_string(),
"connected_peers": peers, "connected_peers": peers,
"known_contacts": service.known_peers().len(), "known_contacts": service.known_peers().len(),
"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 }),
@@ -849,13 +943,20 @@ 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"
); );
let service = self.service().await?; let (service, similarity_dht) = self.similarity_services().await?;
similarity::search(service, query, limit, Arc::clone(&self.transport_stats)).await similarity::search(
service,
similarity_dht,
query,
limit,
Arc::clone(&self.transport_stats),
)
.await
} }
pub async fn fed_device_status( pub async fn fed_device_status(
@@ -983,6 +1084,65 @@ impl Federation {
} }
} }
async fn similarity_route_sync_loop(
routing: Arc<SimilarityDht>,
manager: Arc<crate::similarity::Manager>,
) {
let mut interval = tokio::time::interval(SYNC_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut published_marker: Option<(String, blake3::Hash)> = None;
loop {
interval.tick().await;
if !manager.enabled() {
if published_marker.take().is_some() {
routing.clear_local_signatures();
tracing::info!("local similarity DHT publication disabled");
}
continue;
}
let status = manager.status();
let Some(profile_id) = status.active_profile else {
continue;
};
if status.phase != crate::similarity::Phase::Ready {
continue;
}
let signatures = match manager.routing_signatures(&profile_id).await {
Ok(signatures) => signatures,
Err(error) => {
tracing::warn!(%error, %profile_id, "similarity routing signatures unavailable");
continue;
}
};
let mut hasher = blake3::Hasher::new();
for signature in &signatures {
hasher.update(signature);
}
let marker = (profile_id.clone(), hasher.finalize());
if published_marker.as_ref() == Some(&marker) {
continue;
}
match routing
.sync_local_signatures(profile_id.clone(), signatures)
.await
{
Ok(stats) => {
tracing::info!(
profile = %profile_id,
records = stats.records,
keys = stats.keys,
remote_nodes = stats.remote_nodes,
"local similarity DHT index synchronized"
);
published_marker = Some(marker);
}
Err(error) => {
tracing::warn!(%error, %profile_id, "similarity DHT synchronization failed");
}
}
}
}
async fn persist_content_id( async fn persist_content_id(
pool: &PgPool, pool: &PgPool,
media_file_id: i64, media_file_id: i64,
+125 -37
View File
@@ -7,7 +7,10 @@ use std::time::Duration;
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use futures_util::stream::{self, StreamExt as _}; use futures_util::stream::{self, StreamExt as _};
use music_dht::similarity::{self as wire, SimilarityHit, SimilarityRequest, SimilarityResponse}; use music_dht::similarity::{self as wire, SimilarityHit, SimilarityRequest, SimilarityResponse};
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, StreamAcceptor}; use music_dht::similarity_dht::SimilarityDht;
use music_dht::{
ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, PeerTicket, StreamAcceptor,
};
use crate::similarity::{Manager, QueryVector}; use crate::similarity::{Manager, QueryVector};
@@ -15,9 +18,11 @@ use super::TransportStats;
pub use music_dht::similarity::SIMILARITY_ALPN; pub use music_dht::similarity::SIMILARITY_ALPN;
const MAX_QUERY_PEERS: usize = 16; const INITIAL_QUERY_PEERS: usize = 16;
const QUERY_CONCURRENCY: usize = 6; const MAX_QUERY_PEERS: usize = 48;
const QUERY_CONCURRENCY: usize = 8;
const QUERY_TIMEOUT: Duration = Duration::from_secs(5); const QUERY_TIMEOUT: Duration = Duration::from_secs(5);
const ROUTING_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_PER_ARTIST: usize = 3; const MAX_PER_ARTIST: usize = 3;
const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8; const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8;
@@ -34,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(
@@ -142,20 +153,49 @@ async fn serve_one(
pub async fn search( pub async fn search(
service: Arc<MusicDhtService>, service: Arc<MusicDhtService>,
routing: Arc<SimilarityDht>,
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 mut peers = Vec::new(); let routed = match tokio::time::timeout(
ROUTING_TIMEOUT,
routing.find_peers(&query.profile_id, &query.vector, MAX_QUERY_PEERS),
)
.await
{
Ok(Ok(peers)) => peers,
Err(_) => {
tracing::debug!("similarity DHT lookup timed out; using known peers");
Vec::new()
}
Ok(Err(error)) => {
tracing::debug!(%error, "similarity DHT lookup unavailable; using known peers");
Vec::new()
}
};
let mut seen = HashSet::new(); let mut seen = HashSet::new();
let mut peers: Vec<QueryPeer> = routed
.into_iter()
.filter_map(|ticket| {
let owner = ticket.endpoint_id();
(owner != own && seen.insert(owner)).then_some(QueryPeer {
owner,
ticket: Some(ticket),
})
})
.collect();
for peer in service for peer in service
.connected_peers() .connected_peers()
.into_iter() .into_iter()
.chain(service.known_peers().into_iter().map(|peer| peer.peer_id)) .chain(service.known_peers().into_iter().map(|peer| peer.peer_id))
{ {
if peer != own && seen.insert(peer) { if peer != own && seen.insert(peer) {
peers.push(peer); peers.push(QueryPeer {
owner: peer,
ticket: None,
});
} }
if peers.len() >= MAX_QUERY_PEERS { if peers.len() >= MAX_QUERY_PEERS {
break; break;
@@ -168,30 +208,42 @@ pub async fn search(
limit.clamp(1, wire::MAX_SIMILARITY_RESULTS), limit.clamp(1, wire::MAX_SIMILARITY_RESULTS),
)?); )?);
let responses = stream::iter(peers.into_iter().map(|peer| {
let service = Arc::clone(&service);
let request = Arc::clone(&request);
let transport = Arc::clone(&transport);
async move {
tokio::time::timeout(
QUERY_TIMEOUT,
query_peer(service, peer, &request, transport),
)
.await
.map_err(|_| anyhow::anyhow!("similarity peer timed out"))?
}
}))
.buffer_unordered(QUERY_CONCURRENCY)
.collect::<Vec<_>>()
.await;
let mut hits = Vec::new(); let mut hits = Vec::new();
let initial = peers.len().min(INITIAL_QUERY_PEERS);
let mut queried_peers = initial;
let responses = query_peers(
Arc::clone(&service),
&peers[..initial],
Arc::clone(&request),
Arc::clone(&transport),
)
.await;
let mut successful = 0usize;
for response in responses { for response in responses {
match response { match response {
Ok(peer_hits) => hits.extend(peer_hits), Ok(peer_hits) => {
successful += 1;
hits.extend(peer_hits);
}
Err(error) => tracing::debug!(%error, "similarity peer query skipped"), Err(error) => tracing::debug!(%error, "similarity peer query skipped"),
} }
} }
if initial < peers.len() && (hits.len() < limit || successful < initial.min(4)) {
queried_peers += peers.len() - initial;
for response in query_peers(
Arc::clone(&service),
&peers[initial..],
Arc::clone(&request),
Arc::clone(&transport),
)
.await
{
match response {
Ok(peer_hits) => hits.extend(peer_hits),
Err(error) => tracing::debug!(%error, "fallback similarity peer query skipped"),
}
}
}
hits.sort_by(|left, right| right.1.total_cmp(&left.1)); hits.sort_by(|left, right| right.1.total_cmp(&left.1));
let mut dedup = HashSet::new(); let mut dedup = HashSet::new();
let mut signatures = vec![query_signature]; let mut signatures = vec![query_signature];
@@ -238,25 +290,60 @@ pub async fn search(
break; break;
} }
} }
Ok(tracks) Ok(SimilaritySearchOutcome {
tracks,
queried_peers,
})
}
type PeerHits = Vec<(
RemoteSimilarityTrack,
f32,
Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>,
)>;
#[derive(Clone)]
struct QueryPeer {
owner: EndpointId,
ticket: Option<PeerTicket>,
}
async fn query_peers(
service: Arc<MusicDhtService>,
peers: &[QueryPeer],
request: Arc<SimilarityRequest>,
transport: Arc<TransportStats>,
) -> Vec<Result<PeerHits>> {
stream::iter(peers.iter().cloned().map(|peer| {
let service = Arc::clone(&service);
let request = Arc::clone(&request);
let transport = Arc::clone(&transport);
async move {
tokio::time::timeout(
QUERY_TIMEOUT,
query_peer(service, peer, &request, transport),
)
.await
.map_err(|_| anyhow::anyhow!("similarity peer timed out"))?
}
}))
.buffer_unordered(QUERY_CONCURRENCY)
.collect()
.await
} }
async fn query_peer( async fn query_peer(
service: Arc<MusicDhtService>, service: Arc<MusicDhtService>,
owner: EndpointId, peer: QueryPeer,
request: &SimilarityRequest, request: &SimilarityRequest,
transport: Arc<TransportStats>, transport: Arc<TransportStats>,
) -> Result< ) -> Result<PeerHits> {
Vec<( let owner = peer.owner;
RemoteSimilarityTrack, let mut stream = match peer.ticket {
f32, Some(ticket) => service.open_stream_to(&ticket, SIMILARITY_ALPN).await,
Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>, None => service.open_stream(owner, SIMILARITY_ALPN).await,
)>, }
> { .map_err(|error| anyhow::anyhow!("cannot reach similarity peer: {error}"))?;
let mut stream = service
.open_stream(owner, SIMILARITY_ALPN)
.await
.map_err(|error| anyhow::anyhow!("cannot reach similarity peer: {error}"))?;
super::record_stream_transport(&transport, "similarity", "outbound", "open", &stream); super::record_stream_transport(&transport, "similarity", "outbound", "open", &stream);
let response = wire::exchange(&mut stream, request).await?; let response = wire::exchange(&mut stream, request).await?;
super::record_stream_transport(&transport, "similarity", "outbound", "done", &stream); super::record_stream_transport(&transport, "similarity", "outbound", "done", &stream);
@@ -284,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
View File
@@ -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" , "Не удалось загрузить очередь ИИ";
} }
+259
View File
@@ -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()
}
+9 -1
View File
@@ -7,6 +7,7 @@ mod federation;
mod i18n; mod i18n;
mod jobs; mod jobs;
mod lastfm; mod lastfm;
mod local_uploads;
mod media_paths; mod media_paths;
mod metrics; mod metrics;
mod music; mod music;
@@ -16,6 +17,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 +90,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()
} }
+25 -1
View File
@@ -884,10 +884,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 +959,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]
+177
View File
@@ -2541,6 +2541,180 @@ pub mod db_migrations {
&[Operation::custom(create_similarity_embeddings).build()]; &[Operation::custom(create_similarity_embeddings).build()];
} }
#[cot::db::migrations::migration_op]
async fn add_similarity_routing_signature(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"ALTER TABLE furumusic__track_embedding
ADD COLUMN IF NOT EXISTS routing_signature BYTEA",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0044AddSimilarityRoutingSignature;
impl migrations::Migration for M0044AddSimilarityRoutingSignature {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0044_add_similarity_routing_signature";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0043_create_similarity_embeddings",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(add_similarity_routing_signature).build()];
}
// -- M0045: persistent YouTube download jobs ----------------------------
#[cot::db::migrations::migration_op]
async fn create_youtube_downloads(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__youtube_download (
id VARCHAR(36) PRIMARY KEY,
user_id BIGINT NOT NULL,
source_url TEXT NOT NULL,
title TEXT NOT NULL,
source_kind VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL,
total_items INTEGER NOT NULL DEFAULT 0,
completed_items INTEGER NOT NULL DEFAULT 0,
failed_items INTEGER NOT NULL DEFAULT 0,
review_items INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL,
completed_at VARCHAR(32)
)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__youtube_download_item (
id VARCHAR(36) PRIMARY KEY,
job_id VARCHAR(36) NOT NULL REFERENCES furumusic__youtube_download(id) ON DELETE CASCADE,
source_id VARCHAR(128) NOT NULL,
source_url TEXT NOT NULL,
title TEXT NOT NULL,
playlist_index INTEGER NOT NULL,
status VARCHAR(32) NOT NULL,
progress_percent DOUBLE PRECISION NOT NULL DEFAULT 0,
downloaded_bytes BIGINT NOT NULL DEFAULT 0,
total_bytes BIGINT,
speed_bytes_per_sec BIGINT,
eta_seconds BIGINT,
chapter_count INTEGER NOT NULL DEFAULT 0,
audio_file_count INTEGER NOT NULL DEFAULT 0,
inbox_path TEXT,
error TEXT,
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL,
completed_at VARCHAR(32),
UNIQUE(job_id, source_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_youtube_download_user_updated
ON furumusic__youtube_download (user_id, updated_at DESC)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_youtube_download_user_status
ON furumusic__youtube_download (user_id, status)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_youtube_download_item_job_status
ON furumusic__youtube_download_item (job_id, status, playlist_index)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_youtube_download_item_source
ON furumusic__youtube_download_item (source_id)",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0045CreateYouTubeDownloads;
impl migrations::Migration for M0045CreateYouTubeDownloads {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0045_create_youtube_downloads";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0044_add_similarity_routing_signature",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_youtube_downloads).build()];
}
// -- M0046: persistent direct-file upload history -----------------------
#[cot::db::migrations::migration_op]
async fn create_local_upload_history(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__local_upload (
id VARCHAR(36) PRIMARY KEY,
user_id BIGINT NOT NULL,
filename TEXT NOT NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
inbox_path TEXT NOT NULL,
error TEXT,
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL,
completed_at VARCHAR(32)
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_local_upload_user_updated
ON furumusic__local_upload (user_id, updated_at DESC)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_local_upload_user_status
ON furumusic__local_upload (user_id, status)",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0046CreateLocalUploadHistory;
impl migrations::Migration for M0046CreateLocalUploadHistory {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0046_create_local_upload_history";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0045_create_youtube_downloads",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_local_upload_history).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[ pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0006CreateMediaFile, &M0006CreateMediaFile,
&M0007CreateArtist, &M0007CreateArtist,
@@ -2575,5 +2749,8 @@ pub mod db_migrations {
&M0041CreateSyncedListenHistory, &M0041CreateSyncedListenHistory,
&M0042RepairLegacyListenQualification, &M0042RepairLegacyListenQualification,
&M0043CreateSimilarityEmbeddings, &M0043CreateSimilarityEmbeddings,
&M0044AddSimilarityRoutingSignature,
&M0045CreateYouTubeDownloads,
&M0046CreateLocalUploadHistory,
]; ];
} }
+701 -39
View File
@@ -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
} }
} }
}), }),
+250 -4
View File
@@ -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) {
@@ -283,6 +335,90 @@ impl Manager {
lock(&self.status).clone() lock(&self.status).clone()
} }
/// Loads compact routing signatures for every current visible embedding.
/// Embeddings created before DHT routing existed are upgraded in place;
/// the CPU-heavy projection runs outside the async runtime.
pub async fn routing_signatures(&self, profile_id: &str) -> Result<Vec<[u8; 32]>> {
let pool = self.pool().await?;
let missing = sqlx::query(
"SELECT e.track_id, e.dimensions, e.vector
FROM furumusic__track_embedding e
JOIN furumusic__track t ON t.id = e.track_id
JOIN furumusic__release r ON r.id = t.release_id
JOIN furumusic__media_file m ON m.id = t.audio_file_id
WHERE e.profile_id = $1 AND e.source_sha256 = m.sha256_hash
AND t.is_hidden = FALSE AND r.is_hidden = FALSE
AND (e.routing_signature IS NULL
OR octet_length(e.routing_signature) != 32)
ORDER BY e.track_id",
)
.bind(profile_id)
.fetch_all(&pool)
.await?
.into_iter()
.map(|row| {
(
row.get::<i64, _>(0),
row.get::<i32, _>(1),
row.get::<Vec<u8>, _>(2),
)
})
.collect::<Vec<_>>();
let computed = tokio::task::spawn_blocking(move || {
missing
.into_iter()
.map(|(track_id, dimensions, bytes)| {
let vector = embedding_from_bytes(dimensions, &bytes)?;
let signature = music_dht::similarity_lsh::routing_signature(&vector)?;
Ok::<_, anyhow::Error>((track_id, signature))
})
.collect::<Result<Vec<_>>>()
})
.await
.context("similarity routing backfill task failed")??;
if !computed.is_empty() {
let mut transaction = pool.begin().await?;
for (track_id, signature) in computed {
sqlx::query(
"UPDATE furumusic__track_embedding
SET routing_signature = $3
WHERE track_id = $1 AND profile_id = $2
AND (routing_signature IS NULL
OR octet_length(routing_signature) != 32)",
)
.bind(track_id)
.bind(profile_id)
.bind(signature.as_slice())
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
}
let stored = sqlx::query_scalar::<_, Vec<u8>>(
"SELECT e.routing_signature
FROM furumusic__track_embedding e
JOIN furumusic__track t ON t.id = e.track_id
JOIN furumusic__release r ON r.id = t.release_id
JOIN furumusic__media_file m ON m.id = t.audio_file_id
WHERE e.profile_id = $1 AND e.source_sha256 = m.sha256_hash
AND t.is_hidden = FALSE AND r.is_hidden = FALSE
ORDER BY e.track_id",
)
.bind(profile_id)
.fetch_all(&pool)
.await?;
stored
.into_iter()
.map(|signature| {
<[u8; 32]>::try_from(signature)
.map_err(|_| anyhow::anyhow!("invalid similarity routing signature length"))
})
.collect()
}
pub fn start(self: &Arc<Self>) { pub fn start(self: &Arc<Self>) {
let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1; let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1;
let manager = Arc::clone(self); let manager = Arc::clone(self);
@@ -846,14 +982,16 @@ async fn store_embedding(
vector.iter().all(|value| value.is_finite()), vector.iter().all(|value| value.is_finite()),
"embedding contains a non-finite value" "embedding contains a non-finite value"
); );
let routing_signature = music_dht::similarity_lsh::routing_signature(vector)?;
sqlx::query( sqlx::query(
"INSERT INTO furumusic__track_embedding "INSERT INTO furumusic__track_embedding
(track_id, profile_id, dimensions, vector, source_sha256, (track_id, profile_id, dimensions, vector, routing_signature,
source_content_id, computed_at) source_sha256, source_content_id, computed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (track_id, profile_id) DO UPDATE SET ON CONFLICT (track_id, profile_id) DO UPDATE SET
dimensions = EXCLUDED.dimensions, dimensions = EXCLUDED.dimensions,
vector = EXCLUDED.vector, vector = EXCLUDED.vector,
routing_signature = EXCLUDED.routing_signature,
source_sha256 = EXCLUDED.source_sha256, source_sha256 = EXCLUDED.source_sha256,
source_content_id = EXCLUDED.source_content_id, source_content_id = EXCLUDED.source_content_id,
computed_at = EXCLUDED.computed_at", computed_at = EXCLUDED.computed_at",
@@ -862,6 +1000,7 @@ async fn store_embedding(
.bind(profile_id) .bind(profile_id)
.bind(vector.len() as i32) .bind(vector.len() as i32)
.bind(embedding_to_bytes(vector)) .bind(embedding_to_bytes(vector))
.bind(routing_signature.as_slice())
.bind(&track.source_sha256) .bind(&track.source_sha256)
.bind(&track.source_content_id) .bind(&track.source_content_id)
.bind(now_iso()) .bind(now_iso())
@@ -1056,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 =
@@ -1091,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();
@@ -1320,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
View File
@@ -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}");
}
} }
} }
+1833
View File
File diff suppressed because it is too large Load Diff
+516 -87
View File
@@ -806,35 +806,70 @@ tbody tr:hover {
} }
.settings-page { .settings-page {
max-width: none; max-width: 1440px;
margin: 0 auto;
} }
.settings-layout { .settings-layout {
display: grid; display: grid;
grid-template-columns: minmax(620px, 1fr) minmax(360px, 440px); grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 14px; grid-template-areas:
align-items: start; "access access access access oidc oidc oidc oidc oidc oidc oidc oidc"
"agent agent agent agent agent agent agent agent agentstatus agentstatus agentstatus agentstatus"
"similarity similarity similarity similarity similarity similarity similarity similarity similaritystatus similaritystatus similaritystatus similaritystatus"
"downloads downloads downloads downloads downloads downloads downloads downloads downloads downloads downloads downloads"
"federation federation federation federation federation federation federation federation federation federation federation federation"
"lastfm lastfm lastfm lastfm lastfm lastfm lastfm lastfm developer developer developer developer"
"actions actions actions actions actions actions actions actions actions actions actions actions";
gap: 16px;
align-items: stretch;
} }
.settings-column { .settings-column {
display: grid; display: contents;
gap: 14px;
align-content: start;
} }
.settings-side .settings-grid { .settings-section {
min-width: 0;
margin: 0;
}
.settings-access { grid-area: access; }
.settings-oidc { grid-area: oidc; }
.settings-agent { grid-area: agent; }
.settings-agent-status { grid-area: agentstatus; }
.settings-similarity { grid-area: similarity; }
.settings-similarity-status { grid-area: similaritystatus; }
.settings-downloads { grid-area: downloads; }
.settings-federation { grid-area: federation; }
.settings-lastfm { grid-area: lastfm; }
.settings-developer { grid-area: developer; }
.settings-section-narrow { border-left: 2px solid rgba(29, 185, 84, 0.55); }
.settings-access .settings-grid,
.settings-developer .settings-grid {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.settings-section-narrow .panel-head {
background: rgba(29, 185, 84, 0.035);
}
.settings-actions { .settings-actions {
grid-column: 1 / -1; grid-area: actions;
position: sticky;
bottom: 0;
z-index: 5;
border: 1px solid var(--border-color);
border-radius: 8px;
background: rgba(35, 35, 35, 0.96);
backdrop-filter: blur(10px);
} }
.settings-grid { .settings-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px; gap: 14px 16px;
padding: 14px; padding: 16px;
} }
.settings-card { .settings-card {
@@ -843,22 +878,26 @@ tbody tr:hover {
.setting-field { .setting-field {
min-width: 0; min-width: 0;
max-width: 480px;
} }
.setting-field.settings-short { max-width: 150px; }
.settings-wide { max-width: 680px; }
.setting-field label, .setting-field label,
.setting-toggle label { .setting-toggle label {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
margin-bottom: 6px; margin-bottom: 7px;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 11px; font-size: 12px;
font-weight: 800; font-weight: 700;
text-transform: uppercase;
} }
.setting-field input { .setting-field input,
.setting-field select {
width: 100%; width: 100%;
height: 34px; height: 34px;
padding: 0 10px; padding: 0 10px;
@@ -869,13 +908,93 @@ tbody tr:hover {
outline: none; outline: none;
} }
.setting-field input:focus { .setting-field textarea {
width: 100%;
min-height: 68px;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-primary);
outline: none;
resize: vertical;
}
.download-method-setting {
min-width: 0;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 7px;
background: rgba(255, 255, 255, 0.018);
}
.download-method-setting > label,
.proxy-editor-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 9px;
color: var(--text-secondary);
font-size: 12px;
font-weight: 750;
}
.download-method-controls {
display: grid;
grid-template-columns: minmax(150px, 0.75fr) minmax(220px, 1.25fr);
gap: 10px;
align-items: center;
}
.download-method-controls select,
.proxy-row input {
width: 100%;
height: 34px;
padding: 0 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-primary);
outline: none;
}
.proxy-editor {
grid-column: 1 / -1;
min-width: 0;
padding-top: 4px;
}
.proxy-row {
display: grid;
grid-template-columns: minmax(210px, 1.4fr) minmax(150px, 0.8fr) minmax(150px, 0.8fr) auto;
gap: 10px;
align-items: end;
padding: 10px;
margin-top: 8px;
border: 1px solid var(--border-color);
border-radius: 7px;
background: rgba(255, 255, 255, 0.018);
}
.proxy-row label {
display: grid;
gap: 6px;
min-width: 0;
color: var(--text-subdued);
font-size: 11px;
font-weight: 700;
}
.setting-field input:focus,
.setting-field select:focus,
.setting-field textarea:focus {
border-color: var(--accent); border-color: var(--accent);
} }
.setting-toggle { .setting-toggle {
min-height: 74px; min-height: 68px;
padding: 12px; padding: 11px 12px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
background: var(--bg-primary); background: var(--bg-primary);
@@ -894,9 +1013,12 @@ tbody tr:hover {
font-weight: 800; font-weight: 800;
} }
.setting-toggle input { .setting-toggle input,
.setting-toggle-row input[type="checkbox"] {
flex: 0 0 auto;
width: 18px; width: 18px;
height: 18px; height: 18px;
padding: 0;
accent-color: var(--accent); accent-color: var(--accent);
} }
@@ -904,7 +1026,8 @@ tbody tr:hover {
margin-top: 6px; margin-top: 6px;
color: var(--text-subdued); color: var(--text-subdued);
font-size: 11px; font-size: 11px;
line-height: 1.4; line-height: 1.45;
max-width: 68ch;
} }
.source-pill { .source-pill {
@@ -929,6 +1052,104 @@ tbody tr:hover {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.settings-federation-body {
display: grid;
grid-template-columns: minmax(360px, 4fr) minmax(580px, 8fr);
gap: 20px;
padding: 16px;
}
.settings-federation-body > .settings-grid,
.settings-federation-body > .probe-body {
padding: 0;
}
.federation-status-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.transport-log {
grid-column: 1 / -1;
min-width: 0;
}
.transport-log-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 12px;
margin: 14px 0 7px;
}
.transport-log-head strong { font-size: 12px; }
.transport-log-head span { color: var(--text-subdued); font-size: 11px; }
.transport-log-scroll {
max-height: 210px;
overflow: auto;
border: 1px solid var(--border-color);
border-radius: 7px;
background: var(--bg-primary);
}
.transport-row {
display: grid;
grid-template-columns: 68px minmax(100px, 1.2fr) 84px 72px 54px 64px 64px 66px 66px 66px;
gap: 8px;
align-items: center;
min-width: 850px;
min-height: 30px;
padding: 5px 9px;
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
color: var(--text-secondary);
font: 11px/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.transport-row:last-child { border-bottom: 0; }
.transport-row.transport-header {
position: sticky;
top: 0;
z-index: 1;
color: var(--text-subdued);
background: var(--bg-elevated);
font-size: 10px;
font-weight: 850;
text-transform: uppercase;
}
.transport-row > span { overflow: hidden; text-overflow: ellipsis; }
.transport-number { text-align: right; }
@media (max-width: 1000px) {
.settings-layout {
grid-template-columns: 1fr;
grid-template-areas:
"access"
"oidc"
"agent"
"agentstatus"
"similarity"
"similaritystatus"
"downloads"
"federation"
"lastfm"
"developer"
"actions";
}
.settings-federation-body { grid-template-columns: 1fr; }
}
@media (max-width: 700px) {
.settings-grid,
.federation-status-grid { grid-template-columns: 1fr; }
.setting-field { max-width: none; }
.download-method-controls,
.proxy-row { grid-template-columns: 1fr; }
}
.settings-note { .settings-note {
padding: 14px; padding: 14px;
color: var(--text-secondary); color: var(--text-secondary);
@@ -937,7 +1158,7 @@ tbody tr:hover {
} }
.probe-body { .probe-body {
padding: 14px; padding: 16px;
} }
.probe-intro { .probe-intro {
@@ -955,9 +1176,43 @@ tbody tr:hover {
} }
.probe-row { .probe-row {
display: flex; display: grid;
justify-content: space-between; grid-template-columns: minmax(0, 1fr) auto;
gap: 10px; align-items: baseline;
gap: 12px;
min-height: 22px;
}
.probe-row strong {
max-width: 210px;
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.similarity-profile-details {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
.similarity-profile-details > span {
display: block;
margin-bottom: 5px;
color: var(--text-subdued);
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
}
.similarity-profile-details pre {
margin: 0;
color: var(--text-secondary);
font: inherit;
font-size: 11px;
line-height: 1.45;
white-space: pre-wrap;
} }
.library-row { .library-row {
@@ -1560,7 +1815,7 @@ tbody tr:hover {
<p x-text="pageSubtitle()"></p> <p x-text="pageSubtitle()"></p>
</div> </div>
<div class="top-actions"> <div class="top-actions">
<button class="btn" @click="refreshAll()"> <button class="btn" @click="refreshAll()" x-show="activeView !== 'settings'">
<i data-lucide="refresh-cw"></i> <i data-lucide="refresh-cw"></i>
Refresh Refresh
</button> </button>
@@ -2059,7 +2314,7 @@ tbody tr:hover {
<div class="settings-page"> <div class="settings-page">
<form class="settings-layout" @submit.prevent="saveSettings()"> <form class="settings-layout" @submit.prevent="saveSettings()">
<div class="settings-column"> <div class="settings-column">
<section class="panel"> <section class="panel settings-section settings-section-wide settings-oidc">
<div class="panel-head"> <div class="panel-head">
<div class="panel-title"> <div class="panel-title">
<strong>OIDC</strong> <strong>OIDC</strong>
@@ -2070,6 +2325,7 @@ tbody tr:hover {
<div class="setting-field settings-wide"> <div class="setting-field settings-wide">
<label>Callback URL</label> <label>Callback URL</label>
<input readonly :value="callbackUrl()" /> <input readonly :value="callbackUrl()" />
<div class="setting-help">Register this exact redirect URL in your identity provider.</div>
</div> </div>
<div class="setting-field"> <div class="setting-field">
<label> <label>
@@ -2105,6 +2361,7 @@ tbody tr:hover {
<span class="source-pill" :class="sourceClass('oidc_admin_groups')" x-text="settingSource('oidc_admin_groups')"></span> <span class="source-pill" :class="sourceClass('oidc_admin_groups')" x-text="settingSource('oidc_admin_groups')"></span>
</label> </label>
<input x-model="settingsDraft.oidc_admin_groups" placeholder="/admin,/furumusic-admins" /> <input x-model="settingsDraft.oidc_admin_groups" placeholder="/admin,/furumusic-admins" />
<div class="setting-help">Comma-separated identity-provider groups whose members receive administrator access.</div>
</div> </div>
<div class="setting-field"> <div class="setting-field">
<label> <label>
@@ -2112,11 +2369,12 @@ tbody tr:hover {
<span class="source-pill" :class="sourceClass('oidc_user_groups')" x-text="settingSource('oidc_user_groups')"></span> <span class="source-pill" :class="sourceClass('oidc_user_groups')" x-text="settingSource('oidc_user_groups')"></span>
</label> </label>
<input x-model="settingsDraft.oidc_user_groups" /> <input x-model="settingsDraft.oidc_user_groups" />
<div class="setting-help">Comma-separated groups allowed to sign in. Leave empty to allow any authenticated OIDC user.</div>
</div> </div>
</div> </div>
</section> </section>
<section class="panel"> <section class="panel settings-section settings-section-wide settings-agent">
<div class="panel-head"> <div class="panel-head">
<div class="panel-title"> <div class="panel-title">
<strong>Agent</strong> <strong>Agent</strong>
@@ -2134,12 +2392,13 @@ tbody tr:hover {
<input type="checkbox" x-model="settingsDraft.agent_enabled" /> <input type="checkbox" x-model="settingsDraft.agent_enabled" />
</div> </div>
</div> </div>
<div class="setting-field"> <div class="setting-field settings-short">
<label> <label>
<span>Concurrency</span> <span>Concurrency</span>
<span class="source-pill" :class="sourceClass('agent_concurrency')" x-text="settingSource('agent_concurrency')"></span> <span class="source-pill" :class="sourceClass('agent_concurrency')" x-text="settingSource('agent_concurrency')"></span>
</label> </label>
<input type="number" min="1" max="32" x-model="settingsDraft.agent_concurrency" /> <input type="number" min="1" max="32" x-model="settingsDraft.agent_concurrency" />
<div class="setting-help">Maximum number of inbox items processed at the same time. Higher values use more CPU and LLM capacity.</div>
</div> </div>
<div class="setting-field settings-wide"> <div class="setting-field settings-wide">
<label> <label>
@@ -2161,6 +2420,7 @@ tbody tr:hover {
<span class="source-pill" :class="sourceClass('agent_llm_url')" x-text="settingSource('agent_llm_url')"></span> <span class="source-pill" :class="sourceClass('agent_llm_url')" x-text="settingSource('agent_llm_url')"></span>
</label> </label>
<input x-model="settingsDraft.agent_llm_url" /> <input x-model="settingsDraft.agent_llm_url" />
<div class="setting-help">Base URL of an OpenAI-compatible service. The agent sends chat requests to its <code>/v1/chat/completions</code> endpoint.</div>
</div> </div>
<div class="setting-field"> <div class="setting-field">
<label> <label>
@@ -2168,6 +2428,7 @@ tbody tr:hover {
<span class="source-pill" :class="sourceClass('agent_llm_model')" x-text="settingSource('agent_llm_model')"></span> <span class="source-pill" :class="sourceClass('agent_llm_model')" x-text="settingSource('agent_llm_model')"></span>
</label> </label>
<input x-model="settingsDraft.agent_llm_model" /> <input x-model="settingsDraft.agent_llm_model" />
<div class="setting-help">Model identifier sent to the configured LLM service, for example the name exposed by your local model server.</div>
</div> </div>
<div class="setting-field"> <div class="setting-field">
<label> <label>
@@ -2175,6 +2436,7 @@ tbody tr:hover {
<span class="source-pill" :class="sourceClass('agent_llm_auth')" x-text="settingSource('agent_llm_auth')"></span> <span class="source-pill" :class="sourceClass('agent_llm_auth')" x-text="settingSource('agent_llm_auth')"></span>
</label> </label>
<input type="password" x-model="settingsDraft.agent_llm_auth" autocomplete="off" /> <input type="password" x-model="settingsDraft.agent_llm_auth" autocomplete="off" />
<div class="setting-help">Complete HTTP Authorization value expected by the LLM endpoint, for example <code>Bearer …</code>.</div>
</div> </div>
<div class="setting-field"> <div class="setting-field">
<label> <label>
@@ -2182,6 +2444,7 @@ tbody tr:hover {
<span class="source-pill" :class="sourceClass('agent_confidence_threshold')" x-text="settingSource('agent_confidence_threshold')"></span> <span class="source-pill" :class="sourceClass('agent_confidence_threshold')" x-text="settingSource('agent_confidence_threshold')"></span>
</label> </label>
<input x-model="settingsDraft.agent_confidence_threshold" /> <input x-model="settingsDraft.agent_confidence_threshold" />
<div class="setting-help">Minimum confidence required to accept generated metadata automatically. Lower-confidence results are sent for review.</div>
</div> </div>
<div class="setting-field"> <div class="setting-field">
<label> <label>
@@ -2189,11 +2452,12 @@ tbody tr:hover {
<span class="source-pill" :class="sourceClass('agent_context_limit')" x-text="settingSource('agent_context_limit')"></span> <span class="source-pill" :class="sourceClass('agent_context_limit')" x-text="settingSource('agent_context_limit')"></span>
</label> </label>
<input x-model="settingsDraft.agent_context_limit" /> <input x-model="settingsDraft.agent_context_limit" />
<div class="setting-help">Maximum model context budget in tokens. Reduce it for smaller models or increase it when processing large batches.</div>
</div> </div>
</div> </div>
</section> </section>
<section class="panel"> <section class="panel settings-section settings-section-wide settings-similarity">
<div class="panel-head"> <div class="panel-head">
<div class="panel-title"> <div class="panel-title">
<strong>Similarity Search</strong> <strong>Similarity Search</strong>
@@ -2210,7 +2474,7 @@ tbody tr:hover {
<span x-text="settingsDraft.similarity_enabled ? 'Enabled for this instance' : 'Disabled'"></span> <span x-text="settingsDraft.similarity_enabled ? 'Enabled for this instance' : 'Disabled'"></span>
<input type="checkbox" x-model="settingsDraft.similarity_enabled" /> <input type="checkbox" x-model="settingsDraft.similarity_enabled" />
</div> </div>
<div class="setting-help">Downloads the selected model and processes every visible local track. When federation is also enabled, this instance sends anonymized query embeddings to peers and answers their searches.</div> <div class="setting-help">Builds an audio fingerprint index for finding musically similar tracks. When federation is enabled, compatible peers can also participate in searches.</div>
</div> </div>
<div class="setting-field settings-wide"> <div class="setting-field settings-wide">
<label> <label>
@@ -2222,7 +2486,10 @@ tbody tr:hover {
<option :value="model.id" x-text="`${model.id} · ${model.dimensions}d`"></option> <option :value="model.id" x-text="`${model.id} · ${model.dimensions}d`"></option>
</template> </template>
</select> </select>
<div class="setting-help" x-text="(similarityStatus.models || []).find(model => model.id === settingsDraft.similarity_model)?.license || ''"></div> <div class="setting-help">
<span>The model converts audio into vectors used for comparison. Changing it rebuilds the search index.</span>
<span x-show="(similarityStatus.models || []).find(model => model.id === settingsDraft.similarity_model)?.license" x-text="' License: ' + (similarityStatus.models || []).find(model => model.id === settingsDraft.similarity_model)?.license"></span>
</div>
</div> </div>
<div class="setting-field settings-wide"> <div class="setting-field settings-wide">
<label> <label>
@@ -2234,25 +2501,119 @@ tbody tr:hover {
<option :value="profile.id" x-text="profile.title"></option> <option :value="profile.id" x-text="profile.title"></option>
</template> </template>
</select> </select>
<details class="setting-help" style="margin-top:8px"> <div class="setting-help">Controls how audio is decoded and normalized before comparison. Changing it rebuilds the search index.</div>
<summary style="cursor:pointer">Show profile details</summary>
<pre style="white-space:pre-wrap;font:inherit;margin:8px 0 0" x-text="selectedSimilarityProfile()?.details || 'Profile details are loading…'"></pre>
</details>
</div> </div>
<div class="setting-field"> <div class="setting-field settings-short">
<label> <label>
<span>Background workers</span> <span>Background workers</span>
<span class="source-pill" :class="sourceClass('similarity_workers')" x-text="settingSource('similarity_workers')"></span> <span class="source-pill" :class="sourceClass('similarity_workers')" x-text="settingSource('similarity_workers')"></span>
</label> </label>
<input type="number" min="1" max="16" step="1" x-model="settingsDraft.similarity_workers" /> <input type="number" min="1" max="16" step="1" x-model="settingsDraft.similarity_workers" />
<div class="setting-help">Applied immediately after saving.</div> <div class="setting-help">Number of tracks indexed in parallel. Higher values finish sooner but use more CPU and memory.</div>
</div>
</div>
</section>
<section class="panel settings-section settings-section-full settings-downloads">
<div class="panel-head">
<div class="panel-title">
<strong>Download manager</strong>
<span>User-visible download methods and their SOCKS5 routes</span>
</div>
<span class="badge" :class="settingsDraft.downloads_enabled ? 'ok' : 'disabled'" x-text="settingsDraft.downloads_enabled ? 'enabled' : 'disabled'"></span>
</div>
<div class="settings-grid">
<div class="setting-toggle settings-wide">
<label>
<span>Enable download feature</span>
<span class="source-pill" :class="sourceClass('downloads_enabled')" x-text="settingSource('downloads_enabled')"></span>
</label>
<div class="setting-toggle-row">
<span x-text="settingsDraft.downloads_enabled ? 'Manager and local file uploads are available' : 'Hidden from the player'"></span>
<input type="checkbox" x-model="settingsDraft.downloads_enabled" />
</div>
<div class="setting-help">Controls the download-manager button and local audio-file uploads for every user.</div>
</div>
<div class="download-method-setting">
<label>
<span>Allow torrents</span>
<span class="source-pill" :class="sourceClass('torrent_downloads_enabled')" x-text="settingSource('torrent_downloads_enabled')"></span>
</label>
<div class="download-method-controls">
<div class="setting-toggle-row">
<span x-text="settingsDraft.torrent_downloads_enabled ? 'Enabled' : 'Disabled'"></span>
<input type="checkbox" x-model="settingsDraft.torrent_downloads_enabled" :disabled="!settingsDraft.downloads_enabled" />
</div>
<select x-model="settingsDraft.torrent_proxy_id" :disabled="!settingsDraft.downloads_enabled || !settingsDraft.torrent_downloads_enabled">
<option value="">No proxy</option>
<template x-for="proxy in settingsDraft.download_proxies || []" :key="proxy.id">
<option :value="proxy.id" x-text="downloadProxyLabel(proxy)"></option>
</template>
</select>
</div>
<div class="setting-help">The selected SOCKS5 proxy routes peer TCP traffic and HTTP(S) trackers. DHT and UDP discovery remain direct.</div>
</div>
<div class="download-method-setting">
<label>
<span>Allow YouTube / yt-dlp</span>
<span class="source-pill" :class="sourceClass('youtube_downloads_enabled')" x-text="settingSource('youtube_downloads_enabled')"></span>
</label>
<div class="download-method-controls">
<div class="setting-toggle-row">
<span x-text="settingsDraft.youtube_downloads_enabled ? 'Enabled' : 'Disabled'"></span>
<input type="checkbox" x-model="settingsDraft.youtube_downloads_enabled" :disabled="!settingsDraft.downloads_enabled" />
</div>
<select x-model="settingsDraft.youtube_proxy_id" :disabled="!settingsDraft.downloads_enabled || !settingsDraft.youtube_downloads_enabled">
<option value="">No proxy</option>
<template x-for="proxy in settingsDraft.download_proxies || []" :key="proxy.id">
<option :value="proxy.id" x-text="downloadProxyLabel(proxy)"></option>
</template>
</select>
</div>
<div class="setting-help">The selected proxy is passed to metadata lookup and downloads through yt-dlp's <code>--proxy</code> option.</div>
</div>
<div class="proxy-editor">
<div class="proxy-editor-head">
<span>
Saved SOCKS5 proxies
<span class="source-pill" :class="sourceClass('download_proxies')" x-text="settingSource('download_proxies')"></span>
</span>
<button class="btn" type="button" @click="addDownloadProxy()">
<i data-lucide="plus"></i>
Add proxy
</button>
</div>
<div class="settings-note" x-show="!(settingsDraft.download_proxies || []).length">No proxies saved. Both methods use a direct connection.</div>
<template x-for="(proxy, index) in settingsDraft.download_proxies || []" :key="proxy.id">
<div class="proxy-row">
<label>
Address and port
<input x-model="proxy.address" placeholder="127.0.0.1:1080" autocomplete="off" />
</label>
<label>
Username
<input x-model="proxy.username" autocomplete="off" />
</label>
<label>
Password
<input type="password" x-model="proxy.password" autocomplete="new-password" />
</label>
<button class="icon-btn danger" type="button" @click="removeDownloadProxy(proxy.id)" title="Remove proxy">
<i data-lucide="trash-2"></i>
</button>
</div>
</template>
<div class="setting-help" style="margin-top:9px">Enter only <code>host:port</code> (IPv6 may use <code>[address]:port</code>). Credentials are omitted unless both username and password are filled.</div>
</div> </div>
</div> </div>
</section> </section>
</div> </div>
<div class="settings-column settings-side"> <div class="settings-column settings-side">
<section class="panel"> <section class="panel settings-section settings-section-narrow settings-access">
<div class="panel-head"> <div class="panel-head">
<div class="panel-title"> <div class="panel-title">
<strong>Authentication</strong> <strong>Authentication</strong>
@@ -2283,26 +2644,15 @@ tbody tr:hover {
</div> </div>
</section> </section>
<section class="panel"> <section class="panel settings-section settings-lastfm">
<div class="panel-head"> <div class="panel-head">
<div class="panel-title"> <div class="panel-title">
<strong>API</strong> <strong>Last.fm Integration</strong>
<span>Developer and enrichment integrations</span> <span>Metadata enrichment and scrobbling credentials</span>
</div> </div>
<span class="badge" :class="settings.lastfm_scrobbling_configured ? 'ok' : 'disabled'" x-text="settings.lastfm_scrobbling_configured ? 'Last.fm configured' : 'Last.fm missing'"></span> <span class="badge" :class="settings.lastfm_scrobbling_configured ? 'ok' : 'disabled'" x-text="settings.lastfm_scrobbling_configured ? 'Last.fm configured' : 'Last.fm missing'"></span>
</div> </div>
<div class="settings-grid"> <div class="settings-grid">
<div class="setting-toggle">
<label>
<span>Swagger UI</span>
<span class="source-pill" :class="sourceClass('swagger_enabled')" x-text="settingSource('swagger_enabled')"></span>
</label>
<div class="setting-toggle-row">
<span x-text="settingsDraft.swagger_enabled ? 'Enabled' : 'Disabled'"></span>
<input type="checkbox" x-model="settingsDraft.swagger_enabled" />
</div>
<div class="setting-help">Interactive API docs at /swagger/ after restart.</div>
</div>
<div class="setting-field"> <div class="setting-field">
<label> <label>
<span>{{ t.settings_lastfm_api_key }}</span> <span>{{ t.settings_lastfm_api_key }}</span>
@@ -2322,7 +2672,29 @@ tbody tr:hover {
</div> </div>
</section> </section>
<section class="panel"> <section class="panel settings-section settings-developer">
<div class="panel-head">
<div class="panel-title">
<strong>Developer API</strong>
<span>Interactive API documentation</span>
</div>
</div>
<div class="settings-grid">
<div class="setting-toggle settings-wide">
<label>
<span>Swagger UI</span>
<span class="source-pill" :class="sourceClass('swagger_enabled')" x-text="settingSource('swagger_enabled')"></span>
</label>
<div class="setting-toggle-row">
<span x-text="settingsDraft.swagger_enabled ? 'Available at /swagger/' : 'Disabled' "></span>
<input type="checkbox" x-model="settingsDraft.swagger_enabled" />
</div>
<div class="setting-help">Exposes interactive API documentation at <code>/swagger/</code> for developers and integrations.</div>
</div>
</div>
</section>
<section class="panel settings-section settings-section-full settings-federation">
<div class="panel-head"> <div class="panel-head">
<div class="panel-title"> <div class="panel-title">
<strong>Federation</strong> <strong>Federation</strong>
@@ -2330,6 +2702,7 @@ tbody tr:hover {
</div> </div>
<span class="badge" :class="federationStatus.node && federationStatus.node.running ? 'ok' : 'disabled'" x-text="federationStatus.node && federationStatus.node.running ? 'running' : 'stopped'"></span> <span class="badge" :class="federationStatus.node && federationStatus.node.running ? 'ok' : 'disabled'" x-text="federationStatus.node && federationStatus.node.running ? 'running' : 'stopped'"></span>
</div> </div>
<div class="settings-federation-body">
<div class="settings-grid"> <div class="settings-grid">
<div class="setting-toggle"> <div class="setting-toggle">
<label> <label>
@@ -2340,7 +2713,7 @@ tbody tr:hover {
<span x-text="settingsDraft.federation_enabled ? 'Enabled' : 'Disabled'"></span> <span x-text="settingsDraft.federation_enabled ? 'Enabled' : 'Disabled'"></span>
<input type="checkbox" x-model="settingsDraft.federation_enabled" /> <input type="checkbox" x-model="settingsDraft.federation_enabled" />
</div> </div>
<div class="setting-help">Applies immediately on save — no restart needed. Peers can browse and stream every visible track.</div> <div class="setting-help">Lets other peers in this logical network discover the visible library and request audio streams from this instance.</div>
</div> </div>
<div class="setting-field settings-wide"> <div class="setting-field settings-wide">
<label> <label>
@@ -2348,9 +2721,9 @@ tbody tr:hover {
<span class="source-pill" :class="sourceClass('federation_network_id')" x-text="settingSource('federation_network_id')"></span> <span class="source-pill" :class="sourceClass('federation_network_id')" x-text="settingSource('federation_network_id')"></span>
</label> </label>
<input x-model="settingsDraft.federation_network_id" placeholder="my-crew-music-7f3a" autocomplete="off" /> <input x-model="settingsDraft.federation_network_id" placeholder="my-crew-music-7f3a" autocomplete="off" />
<div class="setting-help">Every peer using the same id finds the others automatically.</div> <div class="setting-help">Peers with the same Network ID form one isolated logical network and discover each other automatically. You may choose any private value; use the exact same value on every peer that should join this network.</div>
</div> </div>
<div class="setting-field"> <div class="setting-field settings-wide">
<label> <label>
<span>Save federated tracks on play</span> <span>Save federated tracks on play</span>
<span class="source-pill" :class="sourceClass('federation_save_on_listen')" x-text="settingSource('federation_save_on_listen')"></span> <span class="source-pill" :class="sourceClass('federation_save_on_listen')" x-text="settingSource('federation_save_on_listen')"></span>
@@ -2359,19 +2732,21 @@ tbody tr:hover {
<span x-text="settingsDraft.federation_save_on_listen ? 'Import into the shared library' : 'Use temporary cache'"></span> <span x-text="settingsDraft.federation_save_on_listen ? 'Import into the shared library' : 'Use temporary cache'"></span>
<input type="checkbox" x-model="settingsDraft.federation_save_on_listen" /> <input type="checkbox" x-model="settingsDraft.federation_save_on_listen" />
</div> </div>
<div class="setting-help">Server-wide policy. Imported tracks become available to every user and are published by this peer. Federation metadata is trusted and bypasses the AI agent.</div> <div class="setting-help">When enabled, a federated track is permanently imported after playback and becomes available to every local user. Otherwise it remains only in the temporary cache. Imported peer metadata is trusted as provided and does not enter AI review.</div>
</div> </div>
</div> </div>
<div class="probe-body" x-show="federationStatus.node"> <div class="probe-body" x-show="federationStatus.node">
<div class="federation-status-grid">
<div class="probe-table" x-show="federationStatus.node && federationStatus.node.running"> <div class="probe-table" x-show="federationStatus.node && federationStatus.node.running">
<div class="probe-row"><span>Endpoint</span><strong x-text="fedShort(federationStatus.node && federationStatus.node.endpoint_id)"></strong></div> <div class="probe-row"><span>Endpoint</span><strong x-text="fedShort(federationStatus.node && federationStatus.node.endpoint_id)"></strong></div>
<div class="probe-row"><span>Network</span><strong x-text="(federationStatus.node && federationStatus.node.network) || '-'"></strong></div> <div class="probe-row"><span>Network</span><strong x-text="(federationStatus.node && federationStatus.node.network) || '-'"></strong></div>
<div class="probe-row"><span>Connected peers</span><strong x-text="federationStatus.node && federationStatus.node.connected_peers ? federationStatus.node.connected_peers.length : 0"></strong></div> <div class="probe-row"><span>Connected peers</span><strong x-text="federationStatus.node && federationStatus.node.connected_peers ? federationStatus.node.connected_peers.length : 0"></strong></div>
<div class="probe-row"><span>Known contacts</span><strong x-text="(federationStatus.node && federationStatus.node.known_contacts) ?? '-'"></strong></div> <div class="probe-row"><span>Known contacts</span><strong x-text="(federationStatus.node && federationStatus.node.known_contacts) ?? '-'"></strong></div>
<div class="probe-row"><span>Similarity routing peers</span><strong x-text="(federationStatus.node && federationStatus.node.similarity_routing_peers) ?? '-'"></strong></div>
<div class="probe-row"><span>Published items</span><strong x-text="(federationStatus.node && federationStatus.node.published_items) ?? '-'"></strong></div> <div class="probe-row"><span>Published items</span><strong x-text="(federationStatus.node && federationStatus.node.published_items) ?? '-'"></strong></div>
<div class="probe-row"><span>Last sync</span><strong x-text="federationStatus.last_sync || 'not yet'"></strong></div> <div class="probe-row"><span>Last sync</span><strong x-text="federationStatus.last_sync || 'not yet'"></strong></div>
</div> </div>
<div class="probe-table" x-show="fedTransport().total_samples > 0" style="margin-top:10px"> <div class="probe-table" x-show="fedTransport().total_samples > 0">
<div class="probe-row"> <div class="probe-row">
<span>Transport path</span> <span>Transport path</span>
<strong> <strong>
@@ -2383,23 +2758,34 @@ tbody tr:hover {
<div class="probe-row"><span>Protocols</span><strong x-text="`${fedTransport().audio_samples || 0} audio · ${fedTransport().catalog_samples || 0} catalog · ${fedTransport().similarity_samples || 0} similarity · ${fedTransport().sync_samples || 0} sync`"></strong></div> <div class="probe-row"><span>Protocols</span><strong x-text="`${fedTransport().audio_samples || 0} audio · ${fedTransport().catalog_samples || 0} catalog · ${fedTransport().similarity_samples || 0} similarity · ${fedTransport().sync_samples || 0} sync`"></strong></div>
<div class="probe-row"><span>Last peer</span><strong x-text="fedShort(fedTransport().last_peer)"></strong></div> <div class="probe-row"><span>Last peer</span><strong x-text="fedShort(fedTransport().last_peer)"></strong></div>
</div> </div>
<div class="probe-table" x-show="fedTransport().last && fedTransport().last.length" style="margin-top:10px"> <div class="transport-log" x-show="fedTransport().last && fedTransport().last.length">
<template x-for="(sample, index) in fedTransport().last.slice(0, 5)" :key="`${sample.at}-${sample.protocol}-${sample.direction}-${sample.phase}-${sample.peer_id}-${index}`"> <div class="transport-log-head">
<div class="probe-row"> <strong>Recent transport operations</strong>
<span x-text="`${sample.protocol} · ${sample.direction} · ${sample.phase}`"></span> <span>Newest first · updates automatically</span>
<strong> </div>
<span class="badge" :class="fedPathBadge(sample.selected_path)" x-text="sample.selected_path || 'unknown'"></span> <div class="transport-log-scroll">
<span x-text="` ${fedRtt(sample.selected_rtt_ms)} · tx ${formatBytes(sample.total_tx_bytes || 0)} · rx ${formatBytes(sample.total_rx_bytes || 0)} · lost ${formatBytes(sample.lost_bytes || 0)}`"></span> <div class="transport-row transport-header">
</strong> <span>Time</span><span>User / peer</span><span>Protocol</span><span>Direction</span><span>Phase</span><span>Path</span><span class="transport-number">RTT</span><span class="transport-number">TX</span><span class="transport-number">RX</span><span class="transport-number">Lost</span>
</div> </div>
</template> <template x-for="(sample, index) in fedTransport().last" :key="`${sample.at}-${sample.protocol}-${sample.direction}-${sample.phase}-${sample.peer_id}-${index}`">
<div class="transport-row">
<span :title="sample.at" x-text="formatTransportTime(sample.at)"></span>
<span :title="sample.user_name || sample.peer_id" x-text="sample.user_name || fedShort(sample.peer_id)"></span>
<span x-text="sample.protocol || '-'"></span>
<span x-text="sample.direction || '-'"></span>
<span x-text="sample.phase || '-'"></span>
<span x-text="sample.selected_path || 'unknown'"></span>
<span class="transport-number" x-text="fedRtt(sample.selected_rtt_ms)"></span>
<span class="transport-number" x-text="formatBytes(sample.total_tx_bytes || 0)"></span>
<span class="transport-number" x-text="formatBytes(sample.total_rx_bytes || 0)"></span>
<span class="transport-number" x-text="formatBytes(sample.lost_bytes || 0)"></span>
</div>
</template>
</div>
</div>
</div> </div>
<p class="probe-intro muted" x-show="federationStatus.last_error" x-text="federationStatus.last_error"></p> <p class="probe-intro muted" x-show="federationStatus.last_error" x-text="federationStatus.last_error"></p>
<div class="toolbar" style="margin-top:14px; flex-wrap:wrap; gap:8px"> <div class="toolbar" style="margin-top:14px; flex-wrap:wrap; gap:8px">
<button class="btn" type="button" @click="loadFederation()" :disabled="federationLoading">
<i data-lucide="refresh-cw"></i>
Refresh
</button>
<button class="btn" type="button" @click="fedSyncNow()" :disabled="federationLoading || !(federationStatus.node && federationStatus.node.running)"> <button class="btn" type="button" @click="fedSyncNow()" :disabled="federationLoading || !(federationStatus.node && federationStatus.node.running)">
<i data-lucide="upload-cloud"></i> <i data-lucide="upload-cloud"></i>
Publish now Publish now
@@ -2421,13 +2807,14 @@ tbody tr:hover {
</div> </div>
</div> </div>
</div> </div>
</div>
</section> </section>
<section class="panel"> <section class="panel settings-section settings-section-narrow settings-similarity-status">
<div class="panel-head"> <div class="panel-head">
<div class="panel-title"> <div class="panel-title">
<strong>Similarity Status</strong> <strong>Search Index Statistics</strong>
<span>Model download, indexing, and active profile</span> <span>Similarity model, indexing progress, and storage</span>
</div> </div>
<span class="badge" :class="similarityBadge()" x-text="similarityStatus.status?.phase || 'disabled'"></span> <span class="badge" :class="similarityBadge()" x-text="similarityStatus.status?.phase || 'disabled'"></span>
</div> </div>
@@ -2444,15 +2831,15 @@ tbody tr:hover {
<div class="probe-row"><span>Stored</span><strong x-text="`${similarityStatus.status?.stored_vectors || 0} vectors · ${formatBytes(similarityStatus.status?.stored_bytes || 0)}`"></strong></div> <div class="probe-row"><span>Stored</span><strong x-text="`${similarityStatus.status?.stored_vectors || 0} vectors · ${formatBytes(similarityStatus.status?.stored_bytes || 0)}`"></strong></div>
<div class="probe-row" x-show="similarityStatus.status?.current_track"><span>Current track</span><strong x-text="similarityStatus.status?.current_track"></strong></div> <div class="probe-row" x-show="similarityStatus.status?.current_track"><span>Current track</span><strong x-text="similarityStatus.status?.current_track"></strong></div>
</div> </div>
<div class="similarity-profile-details">
<span>Selected preprocessing profile</span>
<pre x-text="selectedSimilarityProfile()?.details || 'Profile information is not available.'"></pre>
</div>
<div style="height:6px;background:rgba(255,255,255,.08);border-radius:999px;overflow:hidden;margin-top:12px" x-show="similarityStatus.status?.phase === 'processing'"> <div style="height:6px;background:rgba(255,255,255,.08);border-radius:999px;overflow:hidden;margin-top:12px" x-show="similarityStatus.status?.phase === 'processing'">
<div style="height:100%;background:var(--accent);transition:width .25s" :style="`width:${similarityProgress()}%`"></div> <div style="height:100%;background:var(--accent);transition:width .25s" :style="`width:${similarityProgress()}%`"></div>
</div> </div>
<p class="probe-intro muted" x-show="similarityStatus.status?.last_error" x-text="similarityStatus.status?.last_error"></p> <p class="probe-intro muted" x-show="similarityStatus.status?.last_error" x-text="similarityStatus.status?.last_error"></p>
<div class="toolbar" style="margin-top:14px;flex-wrap:wrap;gap:8px"> <div class="toolbar" style="margin-top:14px;flex-wrap:wrap;gap:8px">
<button class="btn" type="button" @click="loadSimilarity()" :disabled="similarityLoading">
<i data-lucide="refresh-cw"></i>
Refresh
</button>
<button class="btn danger" type="button" @click="clearSimilarityEmbeddings()" :disabled="similarityLoading || !(similarityStatus.status?.stored_vectors > 0)"> <button class="btn danger" type="button" @click="clearSimilarityEmbeddings()" :disabled="similarityLoading || !(similarityStatus.status?.stored_vectors > 0)">
<i data-lucide="trash-2"></i> <i data-lucide="trash-2"></i>
Clear all embeddings Clear all embeddings
@@ -2461,7 +2848,7 @@ tbody tr:hover {
</div> </div>
</section> </section>
<section class="panel"> <section class="panel settings-section settings-section-narrow settings-agent-status">
<div class="panel-head"> <div class="panel-head">
<div class="panel-title"> <div class="panel-title">
<strong>Agent Status</strong> <strong>Agent Status</strong>
@@ -2492,10 +2879,6 @@ tbody tr:hover {
<div class="action-strip settings-actions"> <div class="action-strip settings-actions">
<span class="selection-summary">Settings are stored as database overrides unless an environment variable wins.</span> <span class="selection-summary">Settings are stored as database overrides unless an environment variable wins.</span>
<div class="toolbar"> <div class="toolbar">
<button class="btn" type="button" @click="loadSettings()">
<i data-lucide="refresh-cw"></i>
Reload
</button>
<button class="btn primary" type="submit" :disabled="settingsSaving"> <button class="btn primary" type="submit" :disabled="settingsSaving">
<i :data-lucide="settingsSaving ? 'loader-circle' : 'save'"></i> <i :data-lucide="settingsSaving ? 'loader-circle' : 'save'"></i>
<span x-text="settingsSaving ? 'Saving...' : 'Save settings'"></span> <span x-text="settingsSaving ? 'Saving...' : 'Save settings'"></span>
@@ -2567,7 +2950,7 @@ tbody tr:hover {
<div class="user-activity-row"> <div class="user-activity-row">
<div class="user-activity-cover"> <div class="user-activity-cover">
<template x-if="play.cover_url"> <template x-if="play.cover_url">
<img :src="play.cover_url" :alt="play.release_title || play.title" loading="lazy"> <img :src="play.cover_url" alt="" aria-hidden="true" loading="lazy">
</template> </template>
<template x-if="!play.cover_url"> <template x-if="!play.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>
@@ -3035,7 +3418,13 @@ function adminV2() {
similarity_enabled: false, similarity_enabled: false,
similarity_model: 'discogs-effnet-bsdynamic-1', similarity_model: 'discogs-effnet-bsdynamic-1',
similarity_profile: 'furumi-full-track-v1', similarity_profile: 'furumi-full-track-v1',
similarity_workers: '1' similarity_workers: '1',
downloads_enabled: true,
torrent_downloads_enabled: true,
youtube_downloads_enabled: true,
download_proxies: [],
torrent_proxy_id: '',
youtube_proxy_id: ''
}, },
settingsProbe: { status: 'idle', ok: false }, settingsProbe: { status: 'idle', ok: false },
settingsProbeLoading: false, settingsProbeLoading: false,
@@ -3316,7 +3705,12 @@ function adminV2() {
async loadSettings(showErrors = true) { async loadSettings(showErrors = true) {
try { try {
this.settings = await this.request(`${this.apiBase}/settings`); this.settings = await this.request(`${this.apiBase}/settings`);
this.settingsDraft = Object.assign({}, this.settingsDraft, this.settings.values || {}); const values = this.settings.values || {};
this.settingsDraft = Object.assign({}, this.settingsDraft, values, {
download_proxies: Array.isArray(values.download_proxies)
? values.download_proxies.map(proxy => ({ ...proxy }))
: []
});
} catch (error) { } catch (error) {
if (showErrors) this.showToast(error.message); if (showErrors) this.showToast(error.message);
} finally { } finally {
@@ -3324,6 +3718,30 @@ function adminV2() {
} }
}, },
addDownloadProxy() {
const id = (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function')
? globalThis.crypto.randomUUID()
: `proxy-${Date.now()}-${Math.random().toString(16).slice(2)}`;
this.settingsDraft.download_proxies = [
...(this.settingsDraft.download_proxies || []),
{ id, address: '', username: '', password: '' }
];
this.$nextTick(() => this.icons());
},
removeDownloadProxy(id) {
this.settingsDraft.download_proxies = (this.settingsDraft.download_proxies || [])
.filter(proxy => proxy.id !== id);
if (this.settingsDraft.torrent_proxy_id === id) this.settingsDraft.torrent_proxy_id = '';
if (this.settingsDraft.youtube_proxy_id === id) this.settingsDraft.youtube_proxy_id = '';
this.$nextTick(() => this.icons());
},
downloadProxyLabel(proxy) {
const address = String(proxy?.address || '').trim();
return address || 'New proxy';
},
async saveSettings() { async saveSettings() {
if (this.settingsSaving) return; if (this.settingsSaving) return;
const networkSimilarityWasEnabled = Boolean( const networkSimilarityWasEnabled = Boolean(
@@ -3496,6 +3914,17 @@ function adminV2() {
return ms != null ? `${Math.round(Number(ms))} ms` : '-'; return ms != null ? `${Math.round(Number(ms))} ms` : '-';
}, },
formatTransportTime(value) {
const date = new Date(value);
if (!value || Number.isNaN(date.getTime())) return '-';
return date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
},
async loadSettingsProbe(showErrors = true) { async loadSettingsProbe(showErrors = true) {
this.settingsProbeLoading = true; this.settingsProbeLoading = true;
try { try {
+278 -29
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
} }