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 {