From d425bf30872f08ce1ef98fdf9e9d24fb7cce2440 Mon Sep 17 00:00:00 2001
From: Ultradesu
Date: Tue, 26 May 2026 16:59:36 +0300
Subject: [PATCH] Improved upload UI
---
Cargo.lock | 2 +-
Cargo.toml | 2 +-
src/i18n/phrases.rs | 13 +-
src/player/mod.rs | 165 +++++++++++++++-
src/torrents.rs | 350 ++++++++++++++++++++++++++++++----
templates/player/modals.html | 57 ++++--
templates/player/scripts.html | 108 ++++++++++-
templates/player/styles.html | 138 ++++++++++----
8 files changed, 738 insertions(+), 97 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 6954237..903929d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "furumusic"
-version = "0.1.12"
+version = "0.1.13"
dependencies = [
"anyhow",
"async-trait",
diff --git a/Cargo.toml b/Cargo.toml
index 6d20f7f..639d2b7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "furumusic"
-version = "0.1.13"
+version = "0.1.14"
edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs
index ab1ef57..0875a45 100644
--- a/src/i18n/phrases.rs
+++ b/src/i18n/phrases.rs
@@ -357,17 +357,19 @@ translations! {
player_saved_torrents: "Saved torrents" , "Сохранённые торренты";
player_refresh: "Refresh" , "Обновить";
player_no_saved_torrents: "No saved torrents" , "Сохранённых торрентов нет";
- player_add_torrent: "Add torrent" , "Добавить торрент";
- player_choose_saved_or_add_torrent: "Choose a saved torrent or add a new one." , "Выберите сохранённый торрент или добавьте новый.";
+ player_upload: "Upload" , "Загрузить";
+ player_choose_saved_or_add_torrent: "Choose a saved item or upload new files." , "Выберите сохранённый элемент или загрузите новые файлы.";
+ player_local_files: "Local audio files" , "Локальные аудиофайлы";
player_torrent_file: "Torrent file" , "Torrent-файл";
player_magnet_link: "Magnet link" , "Magnet-ссылка";
- player_preview_content: "Preview content" , "Предпросмотр";
+ player_upload_content: "Upload" , "Загрузить";
player_download_selected: "Download selected" , "Скачать выбранное";
player_pause_download: "Pause download" , "Поставить на паузу";
player_expand_all: "Expand all" , "Развернуть всё";
player_collapse: "Collapse" , "Свернуть";
player_selected: "selected" , "выбрано";
player_preview: "Preview" , "Предпросмотр";
+ player_resolving: "Resolving metadata" , "Получаю метаданные";
player_downloading: "Downloading" , "Скачивается";
player_moving: "Moving" , "Перемещается";
player_completed: "Completed" , "Готово";
@@ -389,7 +391,10 @@ translations! {
player_no_plays_yet: "No plays yet" , "Прослушиваний пока нет";
player_page: "Page" , "Страница";
player_of: "of" , "из";
- player_choose_torrent: "Choose a .torrent file or paste a magnet link." , "Выберите .torrent файл или вставьте magnet-ссылку.";
+ player_choose_torrent: "Choose local files, 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" , "Загрузка не удалась";
player_reading_torrent: "Reading torrent file..." , "Читаю torrent-файл...";
player_resolving_magnet: "Resolving magnet metadata. This can take a while..." , "Получаю метаданные magnet-ссылки. Это может занять время...";
player_preview_failed: "Preview failed" , "Предпросмотр не удался";
diff --git a/src/player/mod.rs b/src/player/mod.rs
index 8e2d9d2..4df96fe 100644
--- a/src/player/mod.rs
+++ b/src/player/mod.rs
@@ -2,7 +2,9 @@ use std::sync::Arc;
use cot::db::Database;
use cot::http::StatusCode;
-use cot::http::header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, RANGE};
+use cot::http::header::{
+ ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, HeaderName, RANGE,
+};
use cot::json::Json;
use cot::request::extractors::Path;
use cot::response::IntoResponse;
@@ -40,6 +42,13 @@ fn json_error(status: StatusCode, message: &str) -> cot::response::Response {
.expect("valid response")
}
+#[derive(serde::Serialize)]
+struct LocalUploadResponse {
+ ok: bool,
+ filename: String,
+ size: u64,
+}
+
// ---------------------------------------------------------------------------
// SPA shell
// ---------------------------------------------------------------------------
@@ -910,6 +919,137 @@ async fn stream_handler(
Ok(response)
}
+async fn local_upload_handler(
+ session: Session,
+ db: Database,
+ config: AppConfig,
+ scheduler_handle: Arc>>,
+ request: cot::request::Request,
+) -> cot::Result> {
+ let Some(user) = auth::get_session_user(&session, &db).await else {
+ return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
+ };
+
+ let inbox_dir = config.agent_inbox_dir.trim();
+ if inbox_dir.is_empty() {
+ return Ok(json_error(
+ StatusCode::BAD_REQUEST,
+ "agent_inbox_dir is not configured",
+ ));
+ }
+ let inbox_root = std::path::PathBuf::from(inbox_dir);
+ if !inbox_root.is_absolute() {
+ return Ok(json_error(
+ StatusCode::BAD_REQUEST,
+ "agent_inbox_dir must be an absolute path",
+ ));
+ }
+
+ let filename_header = HeaderName::from_static("x-furumusic-filename");
+ let original_name = request
+ .headers()
+ .get(filename_header)
+ .and_then(|value| value.to_str().ok())
+ .map(percent_decode_header)
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty())
+ .unwrap_or_else(|| "upload.mp3".to_string());
+ let filename = sanitize_upload_filename(&original_name);
+
+ let bytes = request
+ .into_body()
+ .into_bytes()
+ .await
+ .map_err(|err| cot::Error::internal(err.to_string()))?;
+ if bytes.is_empty() {
+ return Ok(json_error(StatusCode::BAD_REQUEST, "uploaded file is empty"));
+ }
+
+ 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()))?;
+ let destination = upload_dir.join(&filename);
+ tokio::fs::write(&destination, &bytes)
+ .await
+ .map_err(|err| cot::Error::internal(err.to_string()))?;
+
+ if let Some(handle) = scheduler_handle.get() {
+ let handle = Arc::clone(handle);
+ tokio::spawn(async move {
+ if let Err(err) = handle.trigger_job_now("inbox_discover").await {
+ tracing::warn!("failed to trigger inbox_discover after local upload: {err}");
+ }
+ });
+ }
+
+ Json(LocalUploadResponse {
+ ok: true,
+ filename,
+ size: bytes.len() as u64,
+ })
+ .into_response()
+}
+
+fn sanitize_upload_filename(value: &str) -> String {
+ let name = std::path::Path::new(value)
+ .file_name()
+ .and_then(|name| name.to_str())
+ .unwrap_or("upload.mp3");
+ let sanitized: String = name
+ .chars()
+ .map(|c| match c {
+ '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
+ c if c.is_control() => '_',
+ c => c,
+ })
+ .collect();
+ let trimmed = sanitized.trim().trim_matches('.').trim();
+ if trimmed.is_empty() {
+ "upload.mp3".to_string()
+ } else {
+ trimmed.to_string()
+ }
+}
+
+fn percent_decode_header(value: &str) -> String {
+ let bytes = value.as_bytes();
+ let mut out = Vec::with_capacity(bytes.len());
+ let mut index = 0;
+ while index < bytes.len() {
+ match bytes[index] {
+ b'%' if index + 2 < bytes.len() => {
+ let hi = hex_value(bytes[index + 1]);
+ let lo = hex_value(bytes[index + 2]);
+ if let (Some(hi), Some(lo)) = (hi, lo) {
+ out.push((hi << 4) | lo);
+ index += 3;
+ } else {
+ out.push(bytes[index]);
+ index += 1;
+ }
+ }
+ byte => {
+ out.push(byte);
+ index += 1;
+ }
+ }
+ }
+ String::from_utf8_lossy(&out).to_string()
+}
+
+fn hex_value(byte: u8) -> Option {
+ match byte {
+ b'0'..=b'9' => Some(byte - b'0'),
+ b'a'..=b'f' => Some(byte - b'a' + 10),
+ b'A'..=b'F' => Some(byte - b'A' + 10),
+ _ => None,
+ }
+}
+
fn parse_range(header: &str, file_size: u64) -> Option<(u64, u64)> {
let bytes_prefix = "bytes=";
if !header.starts_with(bytes_prefix) {
@@ -2365,6 +2505,29 @@ impl App for PlayerApp {
},
"player_torrent_preview",
),
+ Route::with_handler_and_name(
+ "/uploads/local",
+ {
+ let scheduler_handle = Arc::clone(&self.scheduler_handle);
+ post(
+ move |session: Session, db: Database, request: cot::request::Request| {
+ let scheduler_handle = Arc::clone(&scheduler_handle);
+ async move {
+ let (live_config, _) = AppConfig::load_with_db(&db).await;
+ local_upload_handler(
+ session,
+ db,
+ live_config,
+ scheduler_handle,
+ request,
+ )
+ .await
+ }
+ },
+ )
+ },
+ "player_local_upload",
+ ),
Route::with_handler_and_name(
"/torrents/{id}/start",
{
diff --git a/src/torrents.rs b/src/torrents.rs
index e0ce543..f47809c 100644
--- a/src/torrents.rs
+++ b/src/torrents.rs
@@ -1,4 +1,4 @@
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
@@ -98,6 +98,7 @@ pub struct TorrentStartRequest {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TorrentJobStatus {
+ Resolving,
Preview,
Downloading,
Moving,
@@ -110,6 +111,7 @@ impl TorrentJobStatus {
fn as_str(self) -> &'static str {
match self {
Self::Preview => "preview",
+ Self::Resolving => "resolving",
Self::Downloading => "downloading",
Self::Moving => "moving",
Self::Complete => "complete",
@@ -121,6 +123,7 @@ impl TorrentJobStatus {
fn from_str(value: &str) -> Self {
match value {
"downloading" => Self::Downloading,
+ "resolving" => Self::Resolving,
"moving" => Self::Moving,
"complete" => Self::Complete,
"failed" => Self::Failed,
@@ -372,6 +375,7 @@ pub struct TorrentService {
temp_root: PathBuf,
session: OnceCell>,
jobs: Mutex>,
+ resolving_jobs: Mutex>,
scheduler_handle: Arc>>,
}
@@ -381,6 +385,7 @@ impl TorrentService {
temp_root: std::env::temp_dir().join("furumusic").join("torrents"),
session: OnceCell::new(),
jobs: Mutex::new(HashMap::new()),
+ resolving_jobs: Mutex::new(HashSet::new()),
scheduler_handle,
}
}
@@ -404,7 +409,11 @@ impl TorrentService {
.cloned()
}
- pub async fn list(&self, pool: &PgPool, user_id: i64) -> anyhow::Result> {
+ pub async fn list(
+ self: &Arc,
+ pool: &PgPool,
+ user_id: i64,
+ ) -> anyhow::Result> {
let rows = sqlx::query_as::<_, TorrentSessionRow>(
r#"SELECT id, user_id, name, info_hash, source_kind, source_label, torrent_bytes,
files_json, selected_files_json, status, total_size, selected_size,
@@ -412,7 +421,7 @@ impl TorrentService {
created_at, updated_at, completed_at
FROM furumusic__torrent_session
WHERE user_id = $1
- ORDER BY updated_at DESC, created_at DESC
+ ORDER BY created_at DESC, id DESC
LIMIT $2"#,
)
.bind(user_id)
@@ -427,6 +436,21 @@ impl TorrentService {
.collect::>()
};
+ for row in rows.iter().filter(|row| row.status == "resolving") {
+ if row.source_kind == "magnet" {
+ if let Some(magnet) = row.source_label.clone() {
+ self.spawn_resolve_pending_magnet(
+ pool.clone(),
+ user_id,
+ row.id.clone(),
+ magnet,
+ row.created_at.clone(),
+ )
+ .await;
+ }
+ }
+ }
+
Ok(rows
.iter()
.map(|row| row.dto(handles.get(&row.id)))
@@ -455,7 +479,7 @@ impl TorrentService {
}
pub async fn preview(
- &self,
+ self: &Arc,
pool: &PgPool,
user_id: i64,
request: TorrentPreviewRequest,
@@ -473,42 +497,50 @@ impl TorrentService {
.filter(|s| !s.is_empty())
.map(str::to_owned);
- let add = match request.kind {
- TorrentPreviewKind::Magnet => {
- let magnet = request
- .magnet
- .as_deref()
- .map(str::trim)
- .filter(|s| !s.is_empty())
- .context("magnet link is empty")?;
- AddTorrent::from_url(magnet.to_string())
- }
- TorrentPreviewKind::TorrentFile => {
- let encoded = request
- .torrent_base64
- .as_deref()
- .filter(|s| !s.is_empty())
- .context("torrent file is empty")?;
- let bytes = base64::engine::general_purpose::STANDARD
- .decode(encoded)
- .context("invalid torrent file encoding")?;
- AddTorrent::from_bytes(bytes)
- }
- };
+ if matches!(request.kind, TorrentPreviewKind::Magnet) {
+ let magnet = request
+ .magnet
+ .as_deref()
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .context("magnet link is empty")?
+ .to_string();
+ let info_hash = extract_magnet_info_hash(&magnet).context("invalid magnet link")?;
+ let name = magnet_display_name(&magnet)
+ .or(source_label)
+ .unwrap_or_else(|| info_hash.clone());
+ let now = now_string();
+ insert_pending_magnet(pool, &id, user_id, &name, &info_hash, &magnet, &now).await?;
+ self.spawn_resolve_pending_magnet(pool.clone(), user_id, id.clone(), magnet, now)
+ .await;
- let response = tokio::time::timeout(
- METADATA_TIMEOUT,
- session.add_torrent(
- add,
+ let row = load_row(pool, user_id, &id).await?;
+ return Ok(TorrentSessionDto {
+ job: row.dto(None),
+ preview: row.preview()?,
+ selected_files: row.selected_files(),
+ });
+ }
+
+ let encoded = request
+ .torrent_base64
+ .as_deref()
+ .filter(|s| !s.is_empty())
+ .context("torrent file is empty")?;
+ let bytes = base64::engine::general_purpose::STANDARD
+ .decode(encoded)
+ .context("invalid torrent file encoding")?;
+
+ let response = session
+ .add_torrent(
+ AddTorrent::from_bytes(bytes),
Some(AddTorrentOptions {
list_only: true,
output_folder: Some(output_dir.to_string_lossy().to_string()),
..Default::default()
}),
- ),
- )
- .await
- .context("timed out while resolving torrent metadata")??;
+ )
+ .await?;
let AddTorrentResponse::ListOnly(list) = response else {
bail!("torrent was unexpectedly added instead of previewed");
@@ -572,6 +604,114 @@ impl TorrentService {
Ok(dto)
}
+ async fn spawn_resolve_pending_magnet(
+ self: &Arc,
+ pool: PgPool,
+ user_id: i64,
+ id: String,
+ magnet: String,
+ created_at: String,
+ ) {
+ {
+ let mut resolving = self.resolving_jobs.lock().await;
+ if !resolving.insert(id.clone()) {
+ return;
+ }
+ }
+
+ let service = Arc::clone(self);
+ tokio::spawn(async move {
+ let result = service
+ .resolve_pending_magnet(&pool, user_id, &id, &magnet, &created_at)
+ .await;
+ if let Err(err) = result {
+ update_resolving_error(&pool, &id, &err.to_string()).await;
+ }
+ service.resolving_jobs.lock().await.remove(&id);
+ });
+ }
+
+ async fn resolve_pending_magnet(
+ &self,
+ pool: &PgPool,
+ user_id: i64,
+ id: &str,
+ magnet: &str,
+ created_at: &str,
+ ) -> anyhow::Result<()> {
+ let session = self.session().await?;
+ let output_dir = self.temp_root.join(id).join("download");
+ tokio::fs::create_dir_all(&output_dir).await?;
+ let response = tokio::time::timeout(
+ METADATA_TIMEOUT,
+ session.add_torrent(
+ AddTorrent::from_url(magnet.to_string()),
+ Some(AddTorrentOptions {
+ list_only: true,
+ output_folder: Some(output_dir.to_string_lossy().to_string()),
+ ..Default::default()
+ }),
+ ),
+ )
+ .await
+ .context("timed out while resolving torrent metadata")??;
+
+ let AddTorrentResponse::ListOnly(list) = response else {
+ bail!("torrent was unexpectedly added instead of previewed");
+ };
+
+ let name = list
+ .info
+ .name
+ .as_ref()
+ .map(|b| String::from_utf8_lossy(b.as_ref()).to_string())
+ .filter(|s| !s.is_empty())
+ .or_else(|| magnet_display_name(magnet))
+ .unwrap_or_else(|| list.info_hash.as_string());
+
+ let mut files = Vec::new();
+ for (index, details) in list.info.iter_file_details()?.enumerate() {
+ let name = details
+ .filename
+ .to_string()
+ .unwrap_or_else(|_| "".to_string());
+ files.push(TorrentFileDto {
+ index,
+ name,
+ components: details.filename.to_vec().unwrap_or_default(),
+ length: details.len,
+ selected: true,
+ });
+ }
+
+ let selected_files = files.iter().map(|f| f.index).collect::>();
+ let job = TorrentJob {
+ id: id.to_string(),
+ user_id,
+ name,
+ info_hash: list.info_hash.as_string(),
+ source_kind: "magnet".to_string(),
+ source_label: Some(magnet.to_string()),
+ torrent_bytes: list.torrent_bytes.to_vec(),
+ files,
+ status: TorrentJobStatus::Preview,
+ output_dir,
+ selected_files,
+ handle: None,
+ downloaded_bytes: 0,
+ uploaded_bytes: 0,
+ progress_percent: 0.0,
+ error: None,
+ created_at: created_at.to_string(),
+ updated_at: now_string(),
+ completed_at: None,
+ };
+
+ update_resolved_job(pool, &job).await?;
+ self.jobs.lock().await.insert(id.to_string(), job);
+ Ok(())
+ }
+
pub async fn status(
&self,
pool: &PgPool,
@@ -949,6 +1089,89 @@ async fn insert_job(pool: &PgPool, job: &TorrentJob) -> anyhow::Result<()> {
Ok(())
}
+async fn insert_pending_magnet(
+ pool: &PgPool,
+ id: &str,
+ user_id: i64,
+ name: &str,
+ info_hash: &str,
+ magnet: &str,
+ now: &str,
+) -> anyhow::Result<()> {
+ sqlx::query(
+ r#"INSERT INTO furumusic__torrent_session
+ (id, user_id, name, info_hash, source_kind, source_label, torrent_bytes,
+ files_json, selected_files_json, status, total_size, selected_size,
+ downloaded_bytes, uploaded_bytes, progress_percent, error,
+ created_at, updated_at, completed_at)
+ VALUES ($1, $2, $3, $4, 'magnet', $5, $6,
+ '[]', '[]', 'resolving', 0, 0,
+ 0, 0, 0, NULL,
+ $7, $8, NULL)"#,
+ )
+ .bind(id)
+ .bind(user_id)
+ .bind(name)
+ .bind(info_hash)
+ .bind(magnet)
+ .bind(Vec::::new())
+ .bind(now)
+ .bind(now)
+ .execute(pool)
+ .await?;
+ Ok(())
+}
+
+async fn update_resolved_job(pool: &PgPool, job: &TorrentJob) -> anyhow::Result<()> {
+ sqlx::query(
+ r#"UPDATE furumusic__torrent_session
+ SET name = $2,
+ info_hash = $3,
+ torrent_bytes = $4,
+ files_json = $5,
+ selected_files_json = $6,
+ status = 'preview',
+ total_size = $7,
+ selected_size = $8,
+ downloaded_bytes = 0,
+ uploaded_bytes = 0,
+ progress_percent = 0,
+ error = NULL,
+ updated_at = $9,
+ completed_at = NULL
+ WHERE id = $1"#,
+ )
+ .bind(&job.id)
+ .bind(&job.name)
+ .bind(&job.info_hash)
+ .bind(&job.torrent_bytes)
+ .bind(serde_json::to_string(&job.files)?)
+ .bind(serde_json::to_string(&job.selected_files)?)
+ .bind(u64_to_i64(job.total_size()))
+ .bind(u64_to_i64(job.selected_size()))
+ .bind(&job.updated_at)
+ .execute(pool)
+ .await?;
+ Ok(())
+}
+
+async fn update_resolving_error(pool: &PgPool, id: &str, error: &str) {
+ if let Err(err) = sqlx::query(
+ r#"UPDATE furumusic__torrent_session
+ SET error = $2,
+ updated_at = $3
+ WHERE id = $1 AND status = 'resolving'"#,
+ )
+ .bind(id)
+ .bind(error)
+ .bind(now_string())
+ .execute(pool)
+ .await
+ {
+ tracing::warn!("failed to persist torrent metadata resolving error: {err}");
+ }
+}
+
async fn mark_job_started(
pool: &PgPool,
id: &str,
@@ -1056,6 +1279,65 @@ fn i64_to_u64(value: i64) -> u64 {
value.max(0) as u64
}
+fn extract_magnet_info_hash(magnet: &str) -> Option {
+ if !magnet.starts_with("magnet:?") {
+ return None;
+ }
+ magnet
+ .split(['?', '&'])
+ .find_map(|part| part.strip_prefix("xt=urn:btih:"))
+ .map(|hash| percent_decode(hash).to_ascii_lowercase())
+ .filter(|hash| !hash.is_empty())
+}
+
+fn magnet_display_name(magnet: &str) -> Option {
+ magnet
+ .split(['?', '&'])
+ .find_map(|part| part.strip_prefix("dn="))
+ .map(percent_decode)
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty())
+}
+
+fn percent_decode(value: &str) -> String {
+ let bytes = value.as_bytes();
+ let mut out = Vec::with_capacity(bytes.len());
+ let mut index = 0;
+ while index < bytes.len() {
+ match bytes[index] {
+ b'+' => {
+ out.push(b' ');
+ index += 1;
+ }
+ b'%' if index + 2 < bytes.len() => {
+ let hi = hex_value(bytes[index + 1]);
+ let lo = hex_value(bytes[index + 2]);
+ if let (Some(hi), Some(lo)) = (hi, lo) {
+ out.push((hi << 4) | lo);
+ index += 3;
+ } else {
+ out.push(bytes[index]);
+ index += 1;
+ }
+ }
+ byte => {
+ out.push(byte);
+ index += 1;
+ }
+ }
+ }
+ String::from_utf8_lossy(&out).to_string()
+}
+
+fn hex_value(byte: u8) -> Option {
+ match byte {
+ b'0'..=b'9' => Some(byte - b'0'),
+ b'a'..=b'f' => Some(byte - b'a' + 10),
+ b'A'..=b'F' => Some(byte - b'A' + 10),
+ _ => None,
+ }
+}
+
fn sanitize_path_component(value: &str) -> String {
let sanitized: String = value
.chars()
diff --git a/templates/player/modals.html b/templates/player/modals.html
index 0b988b5..8a13527 100644
--- a/templates/player/modals.html
+++ b/templates/player/modals.html
@@ -64,6 +64,15 @@
:class="{ error: $store.torrents.error }"
x-text="$store.torrents.message">
+
-
@@ -135,9 +142,10 @@
-
-
+
+
+
@@ -145,10 +153,26 @@
x-model="$store.torrents.magnet"
placeholder="magnet:?xt=urn:btih:...">
+
+
+
+
+
+
@@ -201,12 +225,19 @@
-
+
+
+
+
{
Alpine.store('torrents', {
modal: false,
file: null,
+ localFiles: [],
magnet: '',
sessions: [],
loadingSessions: false,
@@ -1101,6 +1106,8 @@ document.addEventListener('alpine:init', () => {
queuedTasks: 0,
processingTasks: 0,
loadingAgentStatus: false,
+ uploadProgress: 0,
+ uploadProgressText: '',
open() {
this.modal = true;
@@ -1132,7 +1139,10 @@ document.addEventListener('alpine:init', () => {
this._stopPoll();
this.workspaceMode = 'new';
this.file = null;
+ this.localFiles = [];
this.magnet = '';
+ this.uploadProgress = 0;
+ this.uploadProgressText = '';
this.currentJob = null;
this.previewData = null;
this.treeRoot = null;
@@ -1208,6 +1218,7 @@ document.addEventListener('alpine:init', () => {
statusLabel(job) {
const labels = {
preview: T.preview,
+ resolving: T.resolving,
downloading: T.downloading,
moving: T.moving,
completed: T.completed,
@@ -1283,12 +1294,18 @@ document.addEventListener('alpine:init', () => {
},
actionButtonText() {
+ if (this.normalizedStatus(this.currentJob) === 'resolving') return T.resolving;
if (this.isCurrentCompletedLocked()) return T.completed;
return this.isCurrentDownloading() ? T.pauseDownload : T.downloadSelected;
},
actionButtonDisabled() {
- return this.loading || this.isCurrentCompletedLocked();
+ return this.loading
+ || this.isCurrentCompletedLocked()
+ || this.normalizedStatus(this.currentJob) === 'resolving'
+ || !this.previewData
+ || !Array.isArray(this.previewData.files)
+ || this.previewData.files.length === 0;
},
toggleDownloadAction() {
@@ -1336,7 +1353,7 @@ document.addEventListener('alpine:init', () => {
_rememberJob(job) {
if (!job || !job.id) return;
const rest = this.sessions.filter(item => item.id !== job.id);
- this.sessions = [job, ...rest].sort((a, b) => String(b.updated_at || '').localeCompare(String(a.updated_at || '')));
+ this.sessions = [job, ...rest].sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
if (this.currentJob && this.currentJob.id === job.id) this.currentJob = job;
},
@@ -1359,7 +1376,10 @@ document.addEventListener('alpine:init', () => {
const job = data.job || null;
this.workspaceMode = 'session';
this.file = null;
+ this.localFiles = [];
this.magnet = '';
+ this.uploadProgress = 0;
+ this.uploadProgressText = '';
this.previewData = preview;
this.currentJob = job;
const selected = Array.isArray(data.selected_files) && data.selected_files.length
@@ -1378,6 +1398,7 @@ document.addEventListener('alpine:init', () => {
if (!res.ok) throw new Error(data.error || T.loadTorrentsFailed);
this.sessions = Array.isArray(data) ? data : [];
this._syncCurrentJobFromSessions();
+ await this._refreshResolvedSelection();
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
@@ -1385,6 +1406,19 @@ document.addEventListener('alpine:init', () => {
}
},
+ async _refreshResolvedSelection() {
+ if (!this.currentJob || !this.previewData || (this.previewData.files || []).length > 0) return;
+ const selected = this.sessions.find(job => job.id === this.currentJob.id);
+ if (!selected || this.normalizedStatus(selected) === 'resolving') return;
+ try {
+ const res = await fetch(`/api/player/torrents/session/${selected.id}`);
+ const data = await res.json();
+ if (!res.ok) return;
+ this._applySession(data);
+ this._setMessage(T.allFilesSelected);
+ } catch {}
+ },
+
async openSession(id) {
if (!id || this.loading) return;
this._stopPoll();
@@ -1442,8 +1476,76 @@ document.addEventListener('alpine:init', () => {
});
},
+ setLocalFiles(files) {
+ this.localFiles = Array.from(files || []);
+ },
+
+ localUploadBytes() {
+ return this.localFiles.reduce((sum, file) => sum + Number(file.size || 0), 0);
+ },
+
+ localUploadSummary() {
+ const count = this.localFiles.length;
+ if (count === 0) return '';
+ return count + ' ' + T.selected + ' - ' + this.bytes(this.localUploadBytes());
+ },
+
+ uploadLocalFile(file, loadedBefore, totalBytes) {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ 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.upload.onprogress = event => {
+ if (!event.lengthComputable || totalBytes <= 0) return;
+ const loaded = loadedBefore + event.loaded;
+ this.uploadProgress = Math.max(0, Math.min(100, loaded / totalBytes * 100));
+ this.uploadProgressText = this.uploadProgress.toFixed(1) + '%';
+ };
+ xhr.onload = () => {
+ let data = {};
+ try { data = JSON.parse(xhr.responseText || '{}'); } catch {}
+ if (xhr.status >= 200 && xhr.status < 300) resolve(data);
+ else reject(new Error(data.error || T.uploadFailed));
+ };
+ xhr.onerror = () => reject(new Error(T.uploadFailed));
+ xhr.send(file);
+ });
+ },
+
+ async uploadLocalFiles() {
+ if (this.loading || this.localFiles.length === 0) return;
+ this.loading = 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);
+ loadedBefore += Number(file.size || 0);
+ 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();
+ } catch (err) {
+ this._setMessage(err.message || String(err), true);
+ } finally {
+ this.loading = false;
+ }
+ },
+
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);
@@ -1472,7 +1574,7 @@ document.addEventListener('alpine:init', () => {
if (!res.ok) throw new Error(data.error || T.previewFailed);
this._applySession(data);
- this._setMessage(T.allFilesSelected);
+ this._setMessage((data.preview?.files || []).length ? T.allFilesSelected : T.resolving);
await this.loadSessions();
} catch (err) {
this._setMessage(err.message || String(err), true);
diff --git a/templates/player/styles.html b/templates/player/styles.html
index af9b19e..54333ce 100644
--- a/templates/player/styles.html
+++ b/templates/player/styles.html
@@ -1741,6 +1741,11 @@ button.user-stat:hover {
color: #111;
}
.modal-btn-ghost { background: transparent; color: var(--text-secondary); }
+.modal-btn-danger {
+ background: rgba(229,96,96,0.16);
+ color: #ffb9b9;
+ border: 1px solid rgba(229,96,96,0.32);
+}
.modal-footer {
display: flex;
@@ -1749,9 +1754,10 @@ button.user-stat:hover {
}
.torrent-modal {
- width: min(860px, calc(100vw - 32px));
- max-width: 860px;
- max-height: min(88dvh, 760px);
+ width: min(1180px, calc(100vw - 48px));
+ max-width: 1180px;
+ height: min(820px, calc(100dvh - 64px));
+ max-height: calc(100dvh - 64px);
overflow: hidden;
}
@@ -1761,6 +1767,8 @@ button.user-stat:hover {
justify-content: space-between;
gap: 14px;
margin-bottom: 12px;
+ flex: 0 0 auto;
+ position: relative;
}
.torrent-modal-head h3 {
@@ -1792,6 +1800,25 @@ button.user-stat:hover {
white-space: nowrap;
}
+.torrent-modal-close {
+ display: none;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ width: 34px;
+ height: 34px;
+ border: 1px solid var(--border-color);
+ border-radius: 999px;
+ background: var(--bg-primary);
+ color: var(--text-secondary);
+ cursor: pointer;
+}
+
+.torrent-modal-close svg {
+ width: 18px;
+ height: 18px;
+}
+
.torrent-status-pill.active {
border-color: rgba(29,185,84,0.42);
color: #9ff0b9;
@@ -1816,9 +1843,10 @@ button.user-stat:hover {
.torrent-manager-layout {
display: grid;
- grid-template-columns: minmax(210px, 260px) minmax(0, 1fr);
+ grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
gap: 14px;
min-height: 0;
+ flex: 1 1 auto;
}
.torrent-manager-sidebar,
@@ -1851,13 +1879,14 @@ button.user-stat:hover {
.torrent-session-list {
overflow-y: auto;
- min-height: 150px;
- max-height: min(52vh, 470px);
+ min-height: 0;
+ max-height: none;
+ flex: 1 1 auto;
}
.torrent-session-row {
display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-columns: minmax(0, 1fr);
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
@@ -1935,19 +1964,28 @@ button.user-stat:hover {
}
.torrent-status-badge.status-preview {
- background: rgba(122,162,255,0.14);
- color: #a8c0ff;
+ background: rgba(122,162,255,0.16);
+ color: #adc3ff;
}
-.torrent-status-badge.status-downloading,
-.torrent-status-badge.status-moving {
+.torrent-status-badge.status-resolving {
+ background: rgba(182,141,255,0.16);
+ color: #d0b6ff;
+}
+
+.torrent-status-badge.status-downloading {
background: rgba(29,185,84,0.16);
color: #9ff0b9;
}
+.torrent-status-badge.status-moving {
+ background: rgba(75,198,240,0.16);
+ color: #a8e8ff;
+}
+
.torrent-status-badge.status-completed {
- background: rgba(105,214,161,0.2);
- color: #b8ffd2;
+ background: rgba(110,211,123,0.16);
+ color: #b8f7be;
}
.torrent-status-badge.status-paused {
@@ -1985,23 +2023,6 @@ button.user-stat:hover {
transition: width 0.25s ease;
}
-.torrent-session-remove {
- align-self: flex-start;
- border: 1px solid rgba(229,96,96,0.24);
- background: rgba(229,96,96,0.12);
- color: #ffb9b9;
- border-radius: 5px;
- padding: 4px 7px;
- font-size: 11px;
- font-weight: 800;
- cursor: pointer;
-}
-
-.torrent-session-remove:hover {
- background: rgba(229,96,96,0.2);
- color: #ffd7d7;
-}
-
.torrent-progress-card {
margin-top: 10px;
padding: 10px 12px;
@@ -2143,6 +2164,21 @@ button.user-stat:hover {
min-height: 150px;
}
+.torrent-upload-summary {
+ min-height: 16px;
+ margin-top: 5px;
+ color: var(--text-subdued);
+ font-size: 11px;
+}
+
+.torrent-upload-progress {
+ margin-top: 10px;
+ padding: 10px 12px;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ background: var(--bg-primary);
+}
+
.torrent-modal label {
display: block;
margin-bottom: 6px;
@@ -2187,6 +2223,13 @@ button.user-stat:hover {
margin-top: 16px;
}
+.torrent-preview-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
.torrent-preview-title {
min-width: 0;
font-size: 14px;
@@ -2243,7 +2286,7 @@ button.user-stat:hover {
margin-top: 10px;
overflow-y: auto;
min-height: 140px;
- max-height: min(46vh, 420px);
+ max-height: none;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
@@ -2442,7 +2485,7 @@ button.user-stat:hover {
}
.torrent-modal {
- width: calc(100vw - 24px);
+ width: min(1180px, calc(100vw - 32px));
}
.card-grid {
@@ -2786,34 +2829,49 @@ button.user-stat:hover {
}
.info-modal,
- .torrent-modal,
.history-modal {
width: min(400px, calc(100vw - 24px));
max-width: 400px;
}
.torrent-modal {
- max-height: min(82dvh, 640px);
- padding: 20px;
+ width: 100vw;
+ max-width: none;
+ height: 100dvh;
+ max-height: none;
+ border-radius: 0;
+ padding: calc(14px + env(safe-area-inset-top)) 14px calc(14px + env(safe-area-inset-bottom));
+ overflow: hidden;
}
.torrent-modal-head {
- flex-direction: column;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
+ align-items: start;
}
.torrent-client-status {
+ grid-column: 1 / -1;
justify-content: flex-start;
}
+ .torrent-modal-close {
+ display: inline-flex;
+ }
+
.torrent-manager-layout {
grid-template-columns: 1fr;
gap: 10px;
}
.torrent-session-list {
- max-height: 148px;
- min-height: 96px;
+ max-height: none;
+ min-height: 0;
+ }
+
+ .torrent-manager-sidebar {
+ flex: 0 0 178px;
}
.torrent-progress-head {
@@ -2867,8 +2925,8 @@ button.user-stat:hover {
}
.torrent-file-tree {
- min-height: 120px;
- max-height: min(32dvh, 260px);
+ min-height: 0;
+ max-height: none;
}
.torrent-tree-row {