Added youtube import. Reworked Download Manager
Build and Publish / Build and Publish Docker Image (push) Successful in 3m52s
Build and Publish / Build and Publish Docker Image (push) Successful in 3m52s
This commit is contained in:
Generated
+2
@@ -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
@@ -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"
|
||||
|
||||
+12
-1
@@ -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"]
|
||||
|
||||
@@ -21,7 +21,7 @@ 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 torrent or magnet imports;
|
||||
- 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;
|
||||
|
||||
@@ -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
@@ -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" , "Не удалось загрузить очередь ИИ";
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+273
-28
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user