Improved upload UI
Build and Publish / Build and Publish Docker Image (push) Successful in 3m2s

This commit is contained in:
Ultradesu
2026-05-26 16:59:36 +03:00
parent 82923c871e
commit d425bf3087
8 changed files with 738 additions and 97 deletions
+9 -4
View File
@@ -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" , "Предпросмотр не удался";
+164 -1
View File
@@ -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<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
request: cot::request::Request,
) -> cot::Result<cot::http::Response<Body>> {
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<u8> {
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",
{
+316 -34
View File
@@ -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<Arc<Session>>,
jobs: Mutex<HashMap<String, TorrentJob>>,
resolving_jobs: Mutex<HashSet<String>>,
scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>,
}
@@ -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<Vec<TorrentJobDto>> {
pub async fn list(
self: &Arc<Self>,
pool: &PgPool,
user_id: i64,
) -> anyhow::Result<Vec<TorrentJobDto>> {
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::<HashMap<_, _>>()
};
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<Self>,
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<Self>,
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(|_| "<invalid filename>".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::<Vec<_>>();
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::<u8>::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<String> {
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<String> {
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<u8> {
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()