diff --git a/Cargo.lock b/Cargo.lock index 944ef01..6954237 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.1.10" +version = "0.1.12" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index a66d969..1d1b6bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.1.11" +version = "0.1.12" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/config.rs b/src/config.rs index 444cfa6..1182730 100644 --- a/src/config.rs +++ b/src/config.rs @@ -316,11 +316,17 @@ impl_env_overrides!( ); impl AppConfig { + fn normalize_host_paths(&mut self) { + self.agent_inbox_dir = normalize_host_path(&self.agent_inbox_dir); + self.agent_storage_dir = normalize_host_path(&self.agent_storage_dir); + } + /// Build config: start from defaults, then overlay env vars. /// Used at startup before the DB is available (to get `database_url`). pub fn load() -> Self { let mut cfg = Self::default(); cfg.apply_env_overrides(); + cfg.normalize_host_paths(); cfg } @@ -331,6 +337,7 @@ impl AppConfig { let mut sources = ConfigSources::default(); cfg.apply_db_overrides(db, &mut sources).await; cfg.apply_env_overrides_tracked(&mut sources); + cfg.normalize_host_paths(); (cfg, sources) } @@ -392,6 +399,44 @@ impl AppConfig { } } +fn normalize_host_path(value: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + return String::new(); + } + + normalize_windows_user_path(trimmed).unwrap_or_else(|| trimmed.to_owned()) +} + +#[cfg(not(windows))] +fn normalize_windows_user_path(value: &str) -> Option { + let normalized = value.replace('\\', "/"); + let mut parts = normalized.split('/').filter(|part| !part.is_empty()); + let drive = parts.next()?; + if drive.len() != 2 || !drive.ends_with(':') { + return None; + } + if !parts.next()?.eq_ignore_ascii_case("Users") { + return None; + } + let user = parts.next()?; + if user.is_empty() { + return None; + } + + let mut out = format!("/Users/{user}"); + for part in parts { + out.push('/'); + out.push_str(part); + } + Some(out) +} + +#[cfg(windows)] +fn normalize_windows_user_path(_value: &str) -> Option { + None +} + #[cfg(test)] mod tests { use super::*; @@ -403,6 +448,24 @@ mod tests { assert_eq!(cfg.log_level, "info"); } + #[cfg(not(windows))] + #[test] + fn normalizes_windows_user_path_on_unix() { + assert_eq!( + normalize_host_path(r"C:\Users\ab\repos\furumusic\media\uploads"), + "/Users/ab/repos/furumusic/media/uploads" + ); + } + + #[cfg(not(windows))] + #[test] + fn leaves_unix_path_unchanged() { + assert_eq!( + normalize_host_path("/Users/ab/repos/furumusic/media/uploads"), + "/Users/ab/repos/furumusic/media/uploads" + ); + } + // SAFETY: tests run with --test-threads=1 so no concurrent env access. unsafe fn set(k: &str, v: &str) { unsafe { std::env::set_var(k, v) }; diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index c751760..b46ca2f 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -264,4 +264,146 @@ translations! { settings_agent_completion_tokens: "Completion tokens" , "Токенов на ответ"; settings_agent_tokens_per_sec: "Tokens/sec" , "Токенов/сек"; settings_agent_status_loading: "Checking connection" , "Проверка подключения"; + + // Player UI + player_library: "Library" , "Библиотека"; + player_artists: "Artists" , "Артисты"; + player_releases: "Releases" , "Релизы"; + player_tracks: "Tracks" , "Треки"; + player_title: "Title" , "Название"; + player_duration: "Duration" , "Длительность"; + player_following: "Following" , "Подписки"; + player_follow: "Follow" , "Подписаться"; + player_followed: "Following" , "Вы подписаны"; + player_unfollow_artist: "Unfollow artist" , "Отписаться от артиста"; + player_follow_artist: "Follow artist" , "Подписаться на артиста"; + player_no_followed_artists: "No followed artists" , "Нет подписок на артистов"; + player_playlists: "Playlists" , "Плейлисты"; + player_published_playlists: "Published Playlists" , "Опубликованные плейлисты"; + player_public: "Public" , "Публичный"; + player_published: "Published" , "Опубликован"; + player_by: "by" , "от"; + player_tracks_count: "tracks" , "треков"; + player_files_count: "files" , "файлов"; + player_releases_count: "releases" , "релизов"; + player_plays_count: "plays" , "прослушиваний"; + player_likes_count: "likes" , "лайков"; + player_likes_playlist: "Likes" , "Лайки"; + player_listened: "listened" , "прослушано"; + player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков..."; + player_no_results: "No results found" , "Ничего не найдено"; + player_new_playlist: "New Playlist" , "Новый плейлист"; + player_rename_playlist: "Rename Playlist" , "Переименовать плейлист"; + player_playlist_name: "Playlist name" , "Название плейлиста"; + player_add_to_playlist: "Add to Playlist" , "Добавить в плейлист"; + player_cancel: "Cancel" , "Отмена"; + player_create: "Create" , "Создать"; + player_save: "Save" , "Сохранить"; + player_delete: "Delete" , "Удалить"; + player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?"; + player_rename: "Rename" , "Переименовать"; + player_close: "Close" , "Закрыть"; + player_log_out: "Log out" , "Выйти"; + player_admin_panel: "Admin Panel" , "Админка"; + player_info: "Info" , "Информация"; + player_no_details: "No details available." , "Нет подробностей."; + player_release_info: "Release info" , "Информация о релизе"; + player_track_info: "Track info" , "Информация о треке"; + player_type: "Type" , "Тип"; + player_year: "Year" , "Год"; + player_uploaders: "Uploaders" , "Загрузили"; + player_unknown: "unknown" , "неизвестно"; + player_unknown_size: "unknown size" , "размер неизвестен"; + player_unknown_release: "Unknown release" , "Неизвестный релиз"; + player_unknown_track: "Unknown track" , "Неизвестный трек"; + player_unknown_audio: "unknown audio details" , "детали аудио неизвестны"; + player_release_year: "Release year" , "Год релиза"; + player_audio: "Audio" , "Аудио"; + player_size: "Size" , "Размер"; + player_uploader: "Uploader" , "Загрузил"; + player_play: "Play" , "Играть"; + player_like: "Like" , "Лайк"; + player_add_to_queue: "Add to queue" , "Добавить в очередь"; + player_add_to_end_queue: "Add to end of queue" , "Добавить в конец очереди"; + player_play_next: "Play next" , "Играть следующим"; + player_queue: "Queue" , "Очередь"; + player_next: "Next" , "Далее"; + player_previous: "Previous" , "Назад"; + player_clear: "Clear" , "Очистить"; + player_remove: "Remove" , "Удалить"; + player_queue_empty: "Queue is empty" , "Очередь пуста"; + player_shuffle: "Shuffle" , "Перемешать"; + player_repeat: "Repeat" , "Повтор"; + player_volume: "Volume" , "Громкость"; + player_appears_on: "Appears on" , "Участвует в"; + player_albums: "Albums" , "Альбомы"; + player_eps: "EPs" , "EP"; + player_singles: "Singles" , "Синглы"; + player_compilations: "Compilations" , "Сборники"; + player_mixtapes: "Mixtapes" , "Микстейпы"; + player_live_releases: "Live releases" , "Концертные релизы"; + player_soundtracks: "Soundtracks" , "Саундтреки"; + + // Player torrent/history UI + player_torrent_manager: "Torrent manager" , "Торрент-менеджер"; + player_import_torrent: "Import torrent" , "Импортировать торрент"; + player_client_idle: "Client idle" , "Клиент простаивает"; + player_active: "active" , "активно"; + player_ai_idle: "AI idle" , "ИИ простаивает"; + player_ai_prefix: "AI" , "ИИ"; + player_processing: "processing" , "обрабатывается"; + player_queued: "queued" , "в очереди"; + player_saved: "saved" , "сохранено"; + player_saved_torrents: "Saved torrents" , "Сохранённые торренты"; + player_refresh: "Refresh" , "Обновить"; + player_no_saved_torrents: "No saved torrents" , "Сохранённых торрентов нет"; + player_torrent_file: "Torrent file" , "Torrent-файл"; + player_magnet_link: "Magnet link" , "Magnet-ссылка"; + player_preview_content: "Preview content" , "Предпросмотр"; + player_download_selected: "Download selected" , "Скачать выбранное"; + player_pause_download: "Pause download" , "Поставить на паузу"; + player_expand_all: "Expand all" , "Развернуть всё"; + player_collapse: "Collapse" , "Свернуть"; + player_selected: "selected" , "выбрано"; + player_preview: "Preview" , "Предпросмотр"; + player_downloading: "Downloading" , "Скачивается"; + player_moving: "Moving" , "Перемещается"; + player_completed: "Completed" , "Готово"; + player_failed: "Failed" , "Ошибка"; + player_paused: "Paused" , "Пауза"; + player_no_torrent_selected: "No torrent selected" , "Торрент не выбран"; + player_down: "down" , "вниз"; + player_up: "up" , "вверх"; + player_peers: "peers" , "пиры"; + player_live: "live" , "активных"; + player_seen: "seen" , "видели"; + player_eta: "eta" , "осталось"; + player_loading_history: "Loading history..." , "Загрузка истории..."; + player_failed_load_history: "Failed to load history" , "Не удалось загрузить историю"; + player_total_plays: "total plays" , "прослушиваний всего"; + player_play_history: "Play history" , "История прослушиваний"; + player_no_plays_yet: "No plays yet" , "Прослушиваний пока нет"; + player_page: "Page" , "Страница"; + player_of: "of" , "из"; + player_choose_torrent: "Choose a .torrent file or paste a magnet link." , "Выберите .torrent файл или вставьте magnet-ссылку."; + player_reading_torrent: "Reading torrent file..." , "Читаю torrent-файл..."; + player_resolving_magnet: "Resolving magnet metadata. This can take a while..." , "Получаю метаданные magnet-ссылки. Это может занять время..."; + player_preview_failed: "Preview failed" , "Предпросмотр не удался"; + 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_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." , "Скачивание началось. После завершения файлы будут перенесены во входящие."; + player_pausing_download: "Pausing download..." , "Ставлю скачивание на паузу..."; + player_download_paused: "Download paused. Start again when you are ready." , "Скачивание на паузе. Можно продолжить позже."; + player_status_failed: "Status failed" , "Не удалось получить статус"; + player_start_failed: "Start failed" , "Не удалось запустить"; + 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_load_ai_queue_failed: "Could not load AI queue" , "Не удалось загрузить очередь ИИ"; } diff --git a/src/player/dto.rs b/src/player/dto.rs index fa4bf63..9018e13 100644 --- a/src/player/dto.rs +++ b/src/player/dto.rs @@ -162,6 +162,12 @@ pub(super) struct UserProfile { pub(super) stats: UserStats, } +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct AgentQueueStatus { + pub(super) queued_count: i64, + pub(super) processing_count: i64, +} + #[derive(Debug, Serialize, JsonSchema)] pub(super) struct PlayHistoryItem { pub(super) id: i64, diff --git a/src/player/mod.rs b/src/player/mod.rs index a6ab270..8e2d9d2 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -105,6 +105,36 @@ async fn me_handler( .into_response() } +// --------------------------------------------------------------------------- +// GET /api/player/agent-queue +// --------------------------------------------------------------------------- + +async fn agent_queue_handler( + session: Session, + db: Database, + pool: &sqlx::PgPool, +) -> cot::Result { + let Some(_user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + + let (queued_count, processing_count): (i64, i64) = sqlx::query_as( + r#"SELECT + COUNT(*) FILTER (WHERE status = 'queued') AS queued_count, + COUNT(*) FILTER (WHERE status = 'processing') AS processing_count + FROM furumusic__pending_review"#, + ) + .fetch_one(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + Json(AgentQueueStatus { + queued_count, + processing_count, + }) + .into_response() +} + // --------------------------------------------------------------------------- // GET /api/player/artists?page=N&limit=N // --------------------------------------------------------------------------- @@ -2134,6 +2164,30 @@ impl App for PlayerApp { }, "player_me", ), + Route::with_handler_and_name( + "/agent-queue", + { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + get(move |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; + agent_queue_handler(session, db, pg_pool).await + } + }) + }, + "player_agent_queue", + ), // -- Torrent import widget -- Route::with_handler_and_name( "/torrents", @@ -2370,6 +2424,50 @@ impl App for PlayerApp { }, "player_torrent_start", ), + Route::with_handler_and_name( + "/torrents/{id}/pause", + { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + let torrent_service = Arc::clone(&torrent_service); + let scheduler_handle = Arc::clone(&self.scheduler_handle); + post(move |session: Session, db: Database, path: Path| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + let torrent_service = Arc::clone(&torrent_service); + let scheduler_handle = Arc::clone(&scheduler_handle); + async move { + let Some(user) = auth::get_session_user(&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 = torrent_service + .get_or_init(|| async { + Arc::new(TorrentService::new(Arc::clone(&scheduler_handle))) + }) + .await; + match service.pause(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_torrent_pause", + ), Route::with_handler_and_name( "/torrents/{id}/status", { diff --git a/src/torrents.rs b/src/torrents.rs index 9232294..a7729d7 100644 --- a/src/torrents.rs +++ b/src/torrents.rs @@ -210,6 +210,11 @@ impl TorrentSessionRow { let progress_percent = progress_percent(downloaded_bytes, total_bytes) .unwrap_or(self.progress_percent) .clamp(0.0, 100.0); + let progress_percent = if status == "complete" { + 100.0 + } else { + progress_percent + }; let live = stats.as_ref().and_then(|s| s.live.as_ref()); let peer_stats = live.map(|l| &l.snapshot.peer_stats); @@ -334,9 +339,13 @@ impl TorrentJob { selected_size: self.selected_size(), downloaded_bytes, uploaded_bytes, - progress_percent: progress_percent(downloaded_bytes, total_bytes) + progress_percent: if self.status == TorrentJobStatus::Complete { + 100.0 + } else { + progress_percent(downloaded_bytes, total_bytes) .unwrap_or(self.progress_percent) - .clamp(0.0, 100.0), + .clamp(0.0, 100.0) + }, download_speed_mbps: live.map(|l| l.download_speed.mbps), upload_speed_mbps: live.map(|l| l.upload_speed.mbps), peers_live: peer_stats.map(|p| p.live), @@ -687,6 +696,9 @@ impl TorrentService { let id = id.to_string(); tokio::spawn(async move { if let Err(err) = handle.wait_until_completed().await { + if service.is_paused(&id).await { + return; + } service.stop_torrent(&handle).await; service.fail_job(&pool, &id, err.to_string()).await; return; @@ -703,6 +715,34 @@ impl TorrentService { Ok(dto) } + pub async fn pause( + &self, + pool: &PgPool, + user_id: i64, + id: &str, + ) -> anyhow::Result { + self.ensure_memory_job(pool, user_id, id).await?; + + let (dto, handle) = { + let mut jobs = self.jobs.lock().await; + let job = jobs.get_mut(id).context("torrent job not found")?; + if job.user_id != user_id { + bail!("torrent job not found"); + } + job.refresh_progress(); + job.status = TorrentJobStatus::Paused; + job.updated_at = now_string(); + let handle = job.handle.take(); + (job.dto(), handle) + }; + + persist_progress(pool, &dto).await?; + if let Some(handle) = handle { + self.stop_torrent(&handle).await; + } + Ok(dto) + } + async fn memory_details(&self, user_id: i64, id: &str) -> Option { let jobs = self.jobs.lock().await; let job = jobs.get(id)?; @@ -733,6 +773,13 @@ impl TorrentService { Ok(job.dto()) } + async fn is_paused(&self, id: &str) -> bool { + let jobs = self.jobs.lock().await; + jobs.get(id) + .map(|job| job.status == TorrentJobStatus::Paused) + .unwrap_or(false) + } + async fn fail_job(&self, pool: &PgPool, id: &str, error: String) { let dto = { let mut jobs = self.jobs.lock().await; @@ -824,6 +871,8 @@ impl TorrentService { let job = jobs.get_mut(id).context("torrent job not found")?; job.refresh_progress(); job.status = TorrentJobStatus::Complete; + job.downloaded_bytes = job.selected_size(); + job.progress_percent = 100.0; job.completed_at = Some(now_string()); job.updated_at = now_string(); let dto = job.dto(); diff --git a/templates/player/modals.html b/templates/player/modals.html index 8bb4a80..8a32326 100644 --- a/templates/player/modals.html +++ b/templates/player/modals.html @@ -4,7 +4,7 @@ @@ -36,7 +36,7 @@