Compare commits

..
3 Commits
Author SHA1 Message Date
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
16 changed files with 4291 additions and 268 deletions
Generated
+2
View File
@@ -1953,6 +1953,7 @@ dependencies = [
"futures-util",
"id3",
"image",
"libc",
"librqbit",
"md-5",
"music-dht",
@@ -1969,6 +1970,7 @@ dependencies = [
"symphonia",
"tokio",
"tokio-cron-scheduler",
"tokio-util",
"tower",
"tracing",
"tracing-subscriber",
+4 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "furumusic"
version = "0.10.2"
version = "0.10.3"
edition = "2024"
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"] }
openidconnect = "4.0"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream"] }
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
tokio = { version = "1", features = ["sync", "fs", "io-util", "process"] }
tokio-util = "0.7"
libc = "0.2"
async-stream = "0.3"
bytes = "1"
tower = "0.5"
+13 -2
View File
@@ -1,4 +1,4 @@
FROM rust:1-slim AS builder
FROM rust:1-bookworm AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates \
@@ -14,14 +14,25 @@ COPY templates ./templates
RUN cargo build --release
FROM denoland/deno:bin-2.8.3 AS deno
FROM debian:bookworm-slim
ARG YT_DLP_VERSION=2026.07.04
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/*
WORKDIR /data
COPY --from=builder /app/target/release/furumusic /usr/local/bin/furumusic
COPY --from=deno /deno /usr/local/bin/deno
EXPOSE 8000
CMD ["furumusic", "-l", "0.0.0.0:8000"]
+124 -189
View File
@@ -1,208 +1,143 @@
# furumusic
# Furumusic
Furumusic can join the decentralized Furumi federation while remaining a
complete local web player. Optional similarity search stores versioned audio
embeddings in PostgreSQL and uses signed two-level LSH summaries in a separate
DHT to discover compatible peers without a central recommendation index. The
shared `music-dht` layer owns routing and wire compatibility; model inference
and exact cosine ranking stay local to each instance.
**Your library. Your users. Your network.**
Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL.
Furumusic is a self-hosted, multi-user music server with a full web player and
support for the Furumi federated network. It turns a collection of music files
into a shared library that is available from any modern browser, while every
user keeps their own playlists, likes, listening history, and playback state.
Built with Rust ([cot](https://cot.rs) framework).
Music can be uploaded directly, imported from a `.torrent` file, or downloaded
from a magnet link. An optional AI-assisted import pipeline reads the available
tags and path information, reconstructs inconsistent metadata, finds artwork,
and organizes the result into artists, releases, and tracks. Uncertain matches
are kept for review instead of silently entering the library with bad data.
## Quick start
## Why Furumusic?
Furumusic is for a household, a small community, or anyone who wants one music
library without handing it to a subscription service. The server owns the
catalog and media files; the browser is only the player.
- one shared library with separate user accounts;
- a responsive web player with artists, releases, search, queue, and playlists;
- direct file uploads and imports from YouTube, torrent files, or magnet links;
- optional AI-assisted recognition and normalization of metadata;
- password login or OIDC/SSO with group-based access control;
- optional federation without a central catalog or search service;
- trusted-device pairing, playback handoff, and synchronization between Furumi
players;
- Last.fm scrobbling and similarity-based discovery when configured.
Furumusic does not include music. Import only media you are allowed to store and
share.
## Importing music
Users can add audio files from the web interface or ask the server to download
selected files from a torrent. Both routes feed the same import pipeline, so a
library remains consistent regardless of where its files came from.
The importer combines embedded tags, filenames, folder structure, and existing
catalog context. With an OpenAI-compatible language model configured, it can
normalize artist and release names, separate featured artists, recover track
numbers and release types, and flag ambiguous results for a user to approve.
Cover art is taken from nearby image files or embedded artwork when available.
The inbox and permanent library are separate directories. New files are first
processed in the inbox and are moved into the organized library only after
their metadata has been accepted.
## Federation
Federation is optional. Independent Furumusic and Furumi players that use the
same Network ID discover one another as a logical network. Each peer keeps its
own library and can continue working alone.
When federation is enabled, local and remote artists, releases, and tracks can
appear in the same search and library views. Missing audio is streamed from an
available peer and can optionally be retained locally. Similarity search also
stays decentralized: embeddings and exact ranking remain on the instance that
owns the music.
Trusted-device pairing is separate from public catalog discovery. It connects
a user's own Furumi players so likes, playlists, playback state, and control can
move between approved devices.
## Build and run
Furumusic requires PostgreSQL and a Rust toolchain with Rust 2024 edition
support. Create an empty database, provide its connection URL, and start the
server:
```bash
export FURU_DATABASE_URL=postgresql://user:pass@localhost/furumusic
cargo run
# Open http://localhost:8000/admin/setup to create the first admin account
export FURU_DATABASE_URL='postgresql://furumusic:password@127.0.0.1/furumusic'
cargo run --release --locked
```
## 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
src/
main.rs Entrypoint; HTTP router, login/logout handlers, tracing init
config.rs 3-tier config system (default → DB → env); FURU_* env vars
auth.rs Session auth, Role enum (Admin/User), login/logout/guards
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
A Nix development shell is included for Linux and macOS:
```bash
nix develop
cargo run --locked
```
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
### 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()`
2. **Database override** — rows in the `furumusic__config_entry` table
3. **Environment variable**`FURU_<FIELD_NAME>` (highest priority)
## Contributing
`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:**
1. Add the field to `AppConfig` struct
2. Set its default in `AppConfig::default()`
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", "Русский текст";
```bash
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo test --all-targets
```
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; [
cacert
deno
ffmpeg-headless
openssl
yt-dlp
] ++ lib.optionals stdenv.isDarwin [ libiconv ];
RUST_SRC_PATH = "${pkgs.rustPlatform.rustLibSrc}";
+67 -9
View File
@@ -389,9 +389,54 @@ translations! {
player_live_releases: "Live releases" , "Концертные релизы";
player_soundtracks: "Soundtracks" , "Саундтреки";
// Player torrent/history UI
player_torrent_manager: "Torrent manager" , "Торрент-менеджер";
player_import_torrent: "Import torrent" , "Импортировать торрент";
// Player download/history UI
player_torrent_manager: "Download Manager" , "Менеджер загрузок";
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_active: "active" , "активно";
player_ai_idle: "AI idle" , "ИИ простаивает";
@@ -467,11 +512,24 @@ translations! {
player_track_approved_imported: "Track approved and imported" , "Трек подтверждён и импортирован";
player_failed_update_selected_tracks: "Failed to update selected tracks" , "Не удалось обновить выбранные треки";
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_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_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_pause_download: "Pause download" , "Поставить на паузу";
player_expand_all: "Expand all" , "Развернуть всё";
@@ -501,7 +559,7 @@ translations! {
player_no_plays_yet: "No plays yet" , "Прослушиваний пока нет";
player_page: "Page" , "Страница";
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_upload_complete: "Upload complete. Files are queued for processing." , "Загрузка завершена. Файлы поставлены в обработку.";
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_opening_saved_torrent: "Opening saved torrent..." , "Открываю сохранённый торрент...";
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_torrent_removed: "Torrent removed from the client list." , "Торрент удалён из списка клиента.";
player_remove_torrent_confirm: "Remove this torrent from history? Downloaded files will stay on disk." , "Удалить этот торрент из истории? Скачанные файлы останутся на диске.";
player_torrent_removed: "Torrent removed from history." , "Торрент удалён из истории.";
player_select_one_file: "Select at least one file." , "Выберите хотя бы один файл.";
player_starting_download: "Starting download..." , "Запускаю скачивание...";
player_download_started: "Download started. Files will move to inbox when complete." , "Скачивание началось. После завершения файлы будут перенесены во входящие.";
@@ -523,6 +581,6 @@ translations! {
player_pause_failed: "Pause failed" , "Не удалось поставить на паузу";
player_load_torrents_failed: "Could not load torrents" , "Не удалось загрузить торренты";
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" , "Не удалось загрузить очередь ИИ";
}
+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()
}
+2
View File
@@ -7,6 +7,7 @@ mod federation;
mod i18n;
mod jobs;
mod lastfm;
mod local_uploads;
mod media_paths;
mod metrics;
mod music;
@@ -16,6 +17,7 @@ mod scheduler;
mod similarity;
mod torrents;
mod user;
mod youtube;
use std::sync::Arc;
+25 -1
View File
@@ -884,10 +884,18 @@ const KNOWN_HTTP_ROUTES: &[&str] = &[
"/api/player/lastfm/scrobble",
"/api/player/agent-queue",
"/api/player/offline/manifest",
"/api/player/youtube",
"/api/player/youtube/preview",
"/api/player/youtube/start",
"/api/player/youtube/{id}/retry",
"/api/player/youtube/{id}/cancel",
"/api/player/youtube/{id}",
"/api/player/uploads/local",
"/api/player/uploads/local/history",
"/api/player/uploads/local/history/{id}",
"/api/player/torrents",
"/api/player/torrents/session/{id}",
"/api/player/torrents/preview",
"/api/player/uploads/local",
"/api/player/uploads/tracks",
"/api/player/uploads/tracks/{track_id}",
"/api/player/uploads/bulk-tracks",
@@ -951,6 +959,22 @@ mod tests {
known_http_route("/share/release/42"),
Some("/share/release/{id}")
);
assert_eq!(
known_http_route("/api/player/youtube/start"),
Some("/api/player/youtube/start")
);
assert_eq!(
known_http_route("/api/player/youtube/job-42/retry"),
Some("/api/player/youtube/{id}/retry")
);
assert_eq!(
known_http_route("/api/player/youtube/job-42/cancel"),
Some("/api/player/youtube/{id}/cancel")
);
assert_eq!(
known_http_route("/api/player/uploads/local/history/upload-42"),
Some("/api/player/uploads/local/history/{id}")
);
}
#[test]
+148
View File
@@ -2569,6 +2569,152 @@ pub mod db_migrations {
&[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] = &[
&M0006CreateMediaFile,
&M0007CreateArtist,
@@ -2604,5 +2750,7 @@ pub mod db_migrations {
&M0042RepairLegacyListenQualification,
&M0043CreateSimilarityEmbeddings,
&M0044AddSimilarityRoutingSignature,
&M0045CreateYouTubeDownloads,
&M0046CreateLocalUploadHistory,
];
}
+484 -15
View File
@@ -21,8 +21,10 @@ use crate::auth;
use crate::config::AppConfig;
use crate::i18n::Translations;
use crate::lastfm::{LastfmClient, LastfmCredentials, LastfmTrackPayload};
use crate::local_uploads::LocalUploadDto;
use crate::scheduler::SchedulerHandle;
use crate::torrents::{TorrentPreviewRequest, TorrentService, TorrentStartRequest};
use crate::youtube::{YouTubePreviewRequest, YouTubeService, YouTubeStartRequest};
mod dto;
mod helpers;
@@ -50,8 +52,7 @@ fn json_error(status: StatusCode, message: &str) -> cot::response::Response {
#[derive(serde::Serialize)]
struct LocalUploadResponse {
ok: bool,
filename: String,
size: u64,
upload: LocalUploadDto,
}
const PLAYER_DEVICE_TTL_MS: i64 = 30_000;
@@ -4830,6 +4831,7 @@ async fn local_upload_handler(
session: Session,
db: Database,
config: AppConfig,
pool: &sqlx::PgPool,
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
request: cot::request::Request,
) -> cot::Result<cot::http::Response<Body>> {
@@ -4862,6 +4864,14 @@ async fn local_upload_handler(
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "upload.mp3".to_string());
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
.into_body()
@@ -4878,14 +4888,53 @@ async fn local_upload_handler(
let upload_dir = inbox_root
.join("user_uploads")
.join(user.id.to_string())
.join(format!("local-{}", uuid::Uuid::new_v4()));
tokio::fs::create_dir_all(&upload_dir)
.await
.map_err(|err| cot::Error::internal(err.to_string()))?;
.join(format!("local-{upload_id}"));
let destination = upload_dir.join(&filename);
tokio::fs::write(&destination, &bytes)
.await
.map_err(|err| cot::Error::internal(err.to_string()))?;
let Some(inbox_path) =
crate::media_paths::path_for_root(&inbox_root.to_string_lossy(), &destination)
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() {
let handle = Arc::clone(handle);
@@ -4896,12 +4945,39 @@ async fn local_upload_handler(
});
}
Json(LocalUploadResponse {
ok: true,
filename,
size: bytes.len() as u64,
})
.into_response()
Json(LocalUploadResponse { ok: true, upload }).into_response()
}
async fn local_upload_history_handler(
auth_ctx: auth::AuthContext,
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;
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"));
};
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 {
@@ -8107,6 +8183,8 @@ impl App for PlayerApp {
let pool: Arc<tokio::sync::OnceCell<sqlx::PgPool>> = Arc::new(tokio::sync::OnceCell::new());
let torrent_service: Arc<tokio::sync::OnceCell<Arc<TorrentService>>> =
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);
Router::with_urls([
@@ -8345,6 +8423,329 @@ impl App for PlayerApp {
},
"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 service = youtube_service
.get_or_init(|| async {
Arc::new(YouTubeService::new(Arc::clone(&scheduler_handle)))
})
.await;
match service.preview(json.0).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;
match service
.list(pg_pool, user.id, &live_config.agent_inbox_dir)
.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;
match service
.start(
pg_pool,
user.id,
json.0,
&live_config.agent_inbox_dir,
)
.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;
match service
.retry(
pg_pool,
user.id,
&path.0.id,
&live_config.agent_inbox_dir,
)
.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 --
Route::with_handler_and_name(
"/torrents",
@@ -8546,20 +8947,34 @@ impl App for PlayerApp {
Route::with_handler_and_name(
"/uploads/local",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let scheduler_handle = Arc::clone(&self.scheduler_handle);
post(
move |auth_ctx: auth::AuthContext,
session: Session,
db: Database,
request: cot::request::Request| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let scheduler_handle = Arc::clone(&scheduler_handle);
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;
local_upload_handler(
auth_ctx,
session,
db,
live_config,
pg_pool,
scheduler_handle,
request,
)
@@ -8570,6 +8985,60 @@ impl App for PlayerApp {
},
"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(
"/uploads/tracks",
get({
+108
View File
@@ -7,6 +7,7 @@
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use std::time::{Duration, Instant};
@@ -1194,6 +1195,23 @@ fn decode_mono_window(
path: &Path,
start_seconds: 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>> {
let file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
let mut decoder =
@@ -1229,6 +1247,64 @@ fn decode_mono_window(
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> {
if input.len() < 2 || source_rate == 0 {
return input.to_vec();
@@ -1458,4 +1534,36 @@ mod tests {
assert_eq!(output.len(), 160);
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()));
}
}
+1786
View File
File diff suppressed because it is too large Load Diff
+273 -28
View File
@@ -85,7 +85,7 @@
</div>
</template>
<!-- Torrent Import Modal -->
<!-- Download Manager Modal -->
<template x-if="$store.torrents.modal">
<div class="modal-overlay" @click.self="$store.torrents.close()">
<div class="modal-box torrent-modal">
@@ -107,25 +107,43 @@
</button>
<div class="torrent-client-status">
<span class="torrent-status-pill"
x-show="$store.torrents.sourceTab === 'torrents'"
:class="{ active: $store.torrents.activeCount() > 0 }"
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"
:class="{ active: $store.torrents.agentBusy() }">
<span class="torrent-agent-dot"></span>
<span x-text="$store.torrents.agentSummary()"></span>
</span>
<span class="torrent-status-pill"
x-show="$store.torrents.sourceTab === 'torrents'"
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 class="torrent-tabs">
<div class="torrent-tabs download-source-tabs">
<button class="torrent-tab-btn"
:class="{ active: $store.torrents.activeTab === 'import' }"
@click="$store.torrents.showImportTab()">{{ t.player_import }}</button>
:class="{ active: $store.torrents.sourceTab === 'youtube' }"
@click="$store.torrents.showSourceTab('youtube')">{{ t.player_youtube }}</button>
<button class="torrent-tab-btn"
:class="{ active: $store.torrents.activeTab === 'uploads' }"
@click="$store.torrents.showUploadsTab()">
:class="{ active: $store.torrents.sourceTab === 'torrents' }"
@click="$store.torrents.showSourceTab('torrents')">{{ t.player_torrents }}</button>
<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 class="torrent-tab-count"
x-show="$store.torrents.uploadPendingTotal + $store.torrents.uploadQueuedTotal > 0"
@@ -133,7 +151,173 @@
</button>
</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">
<aside class="torrent-manager-sidebar">
<div class="torrent-manager-title">
@@ -173,7 +357,7 @@
@click="$store.torrents.addNew()"
:disabled="$store.torrents.loading">
<span class="torrent-session-add-icon">+</span>
<span>{{ t.player_upload }}</span>
<span>{{ t.player_add_torrent }}</span>
</button>
</div>
</aside>
@@ -188,12 +372,6 @@
<template x-if="$store.torrents.isImporting()">
<div class="torrent-import-panel">
<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>
<label for="torrent-magnet-input">{{ t.player_magnet_link }}</label>
<input id="torrent-magnet-input" type="text"
@@ -206,17 +384,6 @@
@change="$store.torrents.file = $event.target.files[0] || null">
</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">
<button class="modal-btn modal-btn-primary" @click="$store.torrents.preview()" :disabled="$store.torrents.loading">
{{ t.player_upload_content }}
@@ -282,7 +449,7 @@
<button class="modal-btn modal-btn-danger"
@click="$store.torrents.removeSession($store.torrents.previewData.id)"
:disabled="$store.torrents.loading">
{{ t.player_delete }}
{{ t.player_remove_from_history }}
</button>
</div>
</div>
@@ -341,9 +508,87 @@
</template>
</section>
</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">
<div class="upload-manager-head">
<div>
+454 -22
View File
@@ -136,6 +136,42 @@ const T = {
openTorrentFailed: "{{ t.player_open_torrent_failed }}",
deleteTorrentFailed: "{{ t.player_delete_torrent_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 }}",
albums: "{{ t.player_albums }}",
eps: "{{ t.player_eps }}",
@@ -4568,8 +4604,21 @@ document.addEventListener('alpine:init', () => {
// -----------------------------------------------------------------------
Alpine.store('torrents', {
modal: false,
sourceTab: 'youtube',
youtubeUrl: '',
youtubePreview: null,
youtubePreviewSelected: new Set(),
youtubePreviewLoading: false,
youtubeJobs: [],
youtubeLoading: false,
youtubeSubmitting: false,
youtubeCancellingIds: new Set(),
file: null,
localFiles: [],
localFilesDragging: false,
localFilesUploading: false,
localUploadHistory: [],
localUploadHistoryLoading: false,
magnet: '',
sessions: [],
loadingSessions: false,
@@ -4590,7 +4639,6 @@ document.addEventListener('alpine:init', () => {
loadingAgentStatus: false,
uploadProgress: 0,
uploadProgressText: '',
activeTab: 'import',
uploadTracks: [],
uploadReleases: [],
uploadPending: [],
@@ -4626,9 +4674,12 @@ document.addEventListener('alpine:init', () => {
this.modal = true;
this.message = '';
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();
if (this.activeTab === 'uploads') this.loadUploads();
this._startRefresh();
},
@@ -4648,16 +4699,324 @@ document.addEventListener('alpine:init', () => {
return this.workspaceMode === 'new';
},
showImportTab() {
this.activeTab = 'import';
showSourceTab(tab) {
this.sourceTab = ['youtube', 'torrents', 'files', 'uploads'].includes(tab) ? tab : 'youtube';
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() {
this.activeTab = 'uploads';
this._stopPoll();
this._setMessage('');
this.loadUploads();
async loadYoutubeJobs({ silent = false } = {}) {
if (!silent) this.youtubeLoading = true;
try {
const res = await fetch('/api/player/youtube');
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() {
@@ -5329,9 +5688,15 @@ document.addEventListener('alpine:init', () => {
this._stopRefresh();
this._refreshTimer = setInterval(() => {
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 });
}
else if (this.sourceTab === 'files') {
this.loadLocalUploadHistory({ silent: true });
}
else this.loadSessions();
this.loadAgentStatus();
}, 5000);
@@ -5469,7 +5834,36 @@ document.addEventListener('alpine:init', () => {
},
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() {
@@ -5488,6 +5882,7 @@ document.addEventListener('alpine:init', () => {
xhr.open('POST', '/api/player/uploads/local');
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
xhr.setRequestHeader('X-Furumusic-Filename', encodeURIComponent(file.name || 'upload.mp3'));
xhr.setRequestHeader('X-Furumusic-Upload-Id', crypto.randomUUID());
xhr.upload.onprogress = event => {
if (!event.lengthComputable || totalBytes <= 0) return;
const loaded = loadedBefore + event.loaded;
@@ -5506,38 +5901,75 @@ document.addEventListener('alpine:init', () => {
},
async uploadLocalFiles() {
if (this.loading || this.localFiles.length === 0) return;
this.loading = true;
if (this.localFilesUploading || this.localFiles.length === 0) return;
this.localFilesUploading = true;
this.uploadProgress = 0;
this.uploadProgressText = '0.0%';
this._setMessage(T.uploadingFiles);
const totalBytes = this.localUploadBytes();
let loadedBefore = 0;
try {
for (const file of this.localFiles) {
await this.uploadLocalFile(file, loadedBefore, totalBytes);
for (const file of [...this.localFiles]) {
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);
this.localFiles = this.localFiles.filter(item => item !== file);
this.uploadProgress = totalBytes > 0 ? Math.min(100, loadedBefore / totalBytes * 100) : 100;
this.uploadProgressText = this.uploadProgress.toFixed(1) + '%';
}
this.localFiles = [];
this.uploadProgress = 100;
this.uploadProgressText = '100.0%';
this._setMessage(T.uploadComplete);
await this.loadAgentStatus();
await this.loadLocalUploadHistory({ silent: true });
} catch (err) {
this._setMessage(err.message || String(err), true);
} 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() {
if (this.loading) return;
if (this.localFiles.length > 0) {
await this.uploadLocalFiles();
return;
}
const magnet = this.magnet.trim();
if (!this.file && !magnet) {
this._setMessage(T.chooseTorrent, true);
+539
View File
@@ -3415,6 +3415,487 @@ button.user-stat:hover {
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 {
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
@@ -6016,6 +6497,64 @@ button.user-stat:hover {
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 {
overflow-y: auto;
}