Compare commits

...
12 Commits
Author SHA1 Message Date
ab a8bbb4b603 Fix connected devices
Build and Publish / Build and Publish Docker Image (push) Successful in 3m48s
2026-09-10 18:15:55 +03:00
ab ba1c565bdc Integrate shared playback coordination and release 0.10.7
Build and Publish / Build and Publish Docker Image (push) Successful in 5m30s
2026-09-10 17:08:34 +03:00
Ultradesu 3641b073e5 Fix lock
Build and Publish / Build and Publish Docker Image (push) Successful in 3m42s
2026-09-02 15:53:19 +01:00
Ultradesu c9266bad22 fix federation recovery after sleep
Build and Publish / Build and Publish Docker Image (push) Failing after 2m1s
2026-09-02 15:10:30 +01:00
Ultradesu 7098f80e9d Added yt-dlp cookies
Build and Publish / Build and Publish Docker Image (push) Successful in 3m45s
2026-09-01 13:52:04 +01:00
Ultradesu a0964b651b Added yt-dlp cookies
Build and Publish / Build and Publish Docker Image (push) Successful in 3m46s
2026-09-01 13:16:38 +01:00
Ultradesu b1ce504db6 Added yt-dlp cookies
Build and Publish / Build and Publish Docker Image (push) Successful in 3m52s
2026-09-01 12:47:09 +01:00
Ultradesu 39d75b07f6 Added yt-dlp cookies 2026-09-01 12:47:01 +01:00
Ultradesu 34e90b33f6 Fixed merge feature
Build and Publish / Build and Publish Docker Image (push) Successful in 5m23s
2026-08-14 18:15:03 +01:00
Ultradesu 1ab53e3898 Fixed ytdl retries
Build and Publish / Build and Publish Docker Image (push) Successful in 3m40s
2026-08-14 12:56:03 +01:00
Ultradesu 0b32d7e813 Fixed ytdl retries
Build and Publish / Build and Publish Docker Image (push) Successful in 3m40s
2026-08-14 12:18:42 +01:00
Ultradesu 5402d9595d Added socks proxy, reworked download manager configuration
Build and Publish / Build and Publish Docker Image (push) Successful in 3m41s
2026-08-14 10:54:56 +01:00
24 changed files with 6010 additions and 737 deletions
+5
View File
@@ -0,0 +1,5 @@
# Changelog
## v0.10.6 — 2026-09-02
- Recover federation automatically after sleep, prolonged idle, or a degraded rendezvous transport.
Generated
+525 -338
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "furumusic" name = "furumusic"
version = "0.10.3" version = "0.10.7"
edition = "2024" edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
@@ -45,4 +45,4 @@ uuid = "1"
librqbit = { version = "8.1.1", features = ["disable-upload"] } librqbit = { version = "8.1.1", features = ["disable-upload"] }
# P2P federation: publishes the library into a shared DHT and serves audio / # P2P federation: publishes the library into a shared DHT and serves audio /
# catalogs to furumi peers (TUI clients) over the frid stack. # catalogs to furumi peers (TUI clients) over the frid stack.
music-dht = "0.4.0" music-dht = "0.5.0"
+101 -13
View File
@@ -81,6 +81,11 @@ struct PathId {
id: i64, id: i64,
} }
#[derive(Debug, Deserialize)]
struct PathStringId {
id: String,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct PathName { struct PathName {
name: String, name: String,
@@ -415,6 +420,43 @@ impl App for AdminApp {
}), }),
"admin_v2_settings_probe", "admin_v2_settings_probe",
), ),
Route::with_handler_and_name(
"/v2/api/settings/youtube-cookies",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
cot::router::method::post(
move |session: Session,
db: Database,
json: Json<v2::UploadYoutubeCookieFileRequest>| {
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("admin pool")
})
.await;
v2::upload_youtube_cookie_file(session, db, pg_pool, json).await
}
},
)
},
"admin_v2_youtube_cookie_upload",
),
Route::with_handler_and_name(
"/v2/api/settings/youtube-cookies/{id}",
cot::router::method::delete(
move |session: Session, db: Database, path: Path<PathStringId>| async move {
v2::delete_youtube_cookie_file(session, db, &path.0.id).await
},
),
"admin_v2_youtube_cookie_delete",
),
Route::with_handler_and_name( Route::with_handler_and_name(
"/v2/api/federation", "/v2/api/federation",
get(move |session: Session, db: Database| async move { get(move |session: Session, db: Database| async move {
@@ -711,6 +753,34 @@ impl App for AdminApp {
}, },
"admin_v2_library_bulk", "admin_v2_library_bulk",
), ),
Route::with_handler_and_name(
"/v2/api/library/releases/merge",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
cot::router::method::post(
move |session: Session,
db: Database,
json: Json<v2::MergeReleasesRequest>| {
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("admin pool")
})
.await;
v2::merge_releases(session, db, pg_pool, json).await
}
},
)
},
"admin_v2_library_releases_merge",
),
// -- Dashboard ---------------------------------------------------- // -- Dashboard ----------------------------------------------------
Route::with_handler_and_name( Route::with_handler_and_name(
"/", "/",
@@ -1069,19 +1139,34 @@ impl App for AdminApp {
), ),
"admin_releases_edit", "admin_releases_edit",
), ),
Route::with_handler_and_name( {
"/releases/{id}/delete", let pool = Arc::clone(&pool);
cot::router::method::post( let pool_config = Arc::clone(&pool_config);
|session: Session, db: Database, path: Path<PathId>| async move { Route::with_handler_and_name(
let admin = match auth::require_admin_or_redirect(&session, &db).await { "/releases/{id}/delete",
Ok(u) => u, cot::router::method::post(move |session: Session, db: Database, path: Path<PathId>| {
Err(resp) => return Ok(resp), let pool = Arc::clone(&pool);
}; let pool_config = Arc::clone(&pool_config);
views::releases_delete(admin, &db, path.0.id).await async move {
}, let admin = match auth::require_admin_or_redirect(&session, &db).await {
), Ok(u) => u,
"admin_releases_delete", Err(resp) => return Ok(resp),
), };
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
})
.await;
views::releases_delete(admin, &db, pg_pool, path.0.id).await
}
}),
"admin_releases_delete",
)
},
// -- Media Files -------------------------------------------------- // -- Media Files --------------------------------------------------
Route::with_handler_and_name( Route::with_handler_and_name(
"/media-files", "/media-files",
@@ -1399,6 +1484,9 @@ impl App for AdminApp {
all.extend(cot::db::migrations::wrap_migrations( all.extend(cot::db::migrations::wrap_migrations(
crate::auth::db_migrations::MIGRATIONS, crate::auth::db_migrations::MIGRATIONS,
)); ));
all.extend(cot::db::migrations::wrap_migrations(
crate::youtube::db_migrations::MIGRATIONS,
));
all all
} }
} }
+578 -99
View File
@@ -16,7 +16,7 @@ use sqlx::{PgPool, Postgres, QueryBuilder};
use super::BUILD_INFO; use super::BUILD_INFO;
use crate::agent; use crate::agent;
use crate::auth::{self, AuthenticatedUser, Role}; use crate::auth::{self, AuthenticatedUser, Role};
use crate::config::{AppConfig, ConfigEntry, ConfigSources}; use crate::config::{AppConfig, ConfigEntry, ConfigSource, ConfigSources, DownloadProxy};
use crate::i18n::{I18n, Translations}; use crate::i18n::{I18n, Translations};
use crate::scheduler::{self, JobRegistry, JobRun, ScheduledJob}; use crate::scheduler::{self, JobRegistry, JobRun, ScheduledJob};
@@ -69,6 +69,21 @@ pub(super) struct BulkLibraryRequest {
filter: Option<LibraryFilter>, filter: Option<LibraryFilter>,
} }
#[derive(Debug, Deserialize)]
pub(super) struct MergeReleasesRequest {
release_ids: Vec<i64>,
target_release_id: i64,
title: String,
release_type: String,
#[serde(default, deserialize_with = "deserialize_optional_stringish")]
year: Option<String>,
hidden: bool,
cover_file_id: Option<i64>,
#[serde(default)]
artist_ids: Vec<i64>,
tracks: Vec<ReleaseTrackUpdateRequest>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct MetadataBackfillRunRequest { pub struct MetadataBackfillRunRequest {
#[serde(default = "default_true")] #[serde(default = "default_true")]
@@ -150,6 +165,12 @@ pub(super) struct UploadLibraryImageRequest {
mime_type: String, mime_type: String,
} }
#[derive(Debug, Deserialize)]
pub(super) struct UploadYoutubeCookieFileRequest {
filename: String,
data: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
struct ReviewFilter { struct ReviewFilter {
status: Option<String>, status: Option<String>,
@@ -417,15 +438,54 @@ struct MutationResponse {
affected: u64, affected: u64,
} }
#[derive(Debug, Serialize, JsonSchema)]
struct MergeReleasesResponse {
ok: bool,
merged_releases: u64,
moved_tracks: u64,
item: LibraryItemDto,
}
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
struct AdminSettingsDto { struct AdminSettingsDto {
values: AdminSettingsValues, values: AdminSettingsValues,
sources: AdminSettingsSources, sources: AdminSettingsSources,
youtube_cookie_files: Vec<AdminYoutubeCookieFileDto>,
lastfm_api_key_configured: bool, lastfm_api_key_configured: bool,
lastfm_shared_secret_configured: bool, lastfm_shared_secret_configured: bool,
lastfm_scrobbling_configured: bool, lastfm_scrobbling_configured: bool,
} }
#[derive(Debug, Clone, Serialize, JsonSchema)]
struct AdminYoutubeCookieFileDto {
id: String,
filename: String,
cookie_count: u64,
uploaded_at: String,
}
impl From<crate::youtube::YoutubeCookieFile> for AdminYoutubeCookieFileDto {
fn from(file: crate::youtube::YoutubeCookieFile) -> Self {
Self {
id: file.id_str().to_owned(),
filename: file.filename().to_owned(),
cookie_count: file.cookie_count(),
uploaded_at: file.uploaded_at().to_owned(),
}
}
}
impl From<crate::youtube::YoutubeCookieFileMetadata> for AdminYoutubeCookieFileDto {
fn from(file: crate::youtube::YoutubeCookieFileMetadata) -> Self {
Self {
id: file.id_str().to_owned(),
filename: file.filename().to_owned(),
cookie_count: file.cookie_count(),
uploaded_at: file.uploaded_at().to_owned(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
struct AdminSettingsValues { struct AdminSettingsValues {
auth_password_enabled: bool, auth_password_enabled: bool,
@@ -462,6 +522,52 @@ struct AdminSettingsValues {
similarity_profile: String, similarity_profile: String,
#[serde(default = "default_similarity_workers")] #[serde(default = "default_similarity_workers")]
similarity_workers: String, similarity_workers: String,
#[serde(default = "default_true")]
downloads_enabled: bool,
#[serde(default = "default_true")]
torrent_downloads_enabled: bool,
#[serde(default = "default_true")]
youtube_downloads_enabled: bool,
#[serde(default)]
download_proxies: Vec<AdminDownloadProxy>,
#[serde(default)]
torrent_proxy_id: String,
#[serde(default)]
youtube_proxy_id: String,
#[serde(default)]
youtube_cookie_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
struct AdminDownloadProxy {
id: String,
address: String,
#[serde(default)]
username: String,
#[serde(default)]
password: String,
}
impl From<DownloadProxy> for AdminDownloadProxy {
fn from(proxy: DownloadProxy) -> Self {
Self {
id: proxy.id,
address: proxy.address,
username: proxy.username,
password: proxy.password,
}
}
}
impl From<AdminDownloadProxy> for DownloadProxy {
fn from(proxy: AdminDownloadProxy) -> Self {
Self {
id: proxy.id,
address: proxy.address,
username: proxy.username,
password: proxy.password,
}
}
} }
#[derive(Debug, Clone, Serialize, JsonSchema)] #[derive(Debug, Clone, Serialize, JsonSchema)]
@@ -493,6 +599,13 @@ struct AdminSettingsSources {
similarity_model: &'static str, similarity_model: &'static str,
similarity_profile: &'static str, similarity_profile: &'static str,
similarity_workers: &'static str, similarity_workers: &'static str,
downloads_enabled: &'static str,
torrent_downloads_enabled: &'static str,
youtube_downloads_enabled: &'static str,
download_proxies: &'static str,
torrent_proxy_id: &'static str,
youtube_proxy_id: &'static str,
youtube_cookie_id: &'static str,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -531,6 +644,20 @@ pub(super) struct UpdateSettingsRequest {
similarity_profile: String, similarity_profile: String,
#[serde(default = "default_similarity_workers")] #[serde(default = "default_similarity_workers")]
similarity_workers: String, similarity_workers: String,
#[serde(default = "default_true")]
downloads_enabled: bool,
#[serde(default = "default_true")]
torrent_downloads_enabled: bool,
#[serde(default = "default_true")]
youtube_downloads_enabled: bool,
#[serde(default)]
download_proxies: Vec<AdminDownloadProxy>,
#[serde(default)]
torrent_proxy_id: String,
#[serde(default)]
youtube_proxy_id: String,
#[serde(default)]
youtube_cookie_id: String,
} }
fn default_similarity_model() -> String { fn default_similarity_model() -> String {
@@ -597,6 +724,7 @@ struct LibraryItemDetailDto {
release_id: Option<i64>, release_id: Option<i64>,
track_number: Option<i32>, track_number: Option<i32>,
disc_number: Option<i32>, disc_number: Option<i32>,
current_image_file_id: Option<i64>,
current_image_url: Option<String>, current_image_url: Option<String>,
selected_artist_ids: Vec<i64>, selected_artist_ids: Vec<i64>,
artists: Vec<ArtistOptionDto>, artists: Vec<ArtistOptionDto>,
@@ -969,7 +1097,10 @@ pub async fn settings(session: Session, db: Database) -> cot::Result<cot::respon
return Ok(response); return Ok(response);
} }
let (config, sources) = AppConfig::load_with_db(&db).await; let (config, sources) = AppConfig::load_with_db(&db).await;
Json(settings_dto(config, sources)).into_response() let cookie_files = crate::youtube::list_cookie_files(&db)
.await
.map_err(|error| cot::Error::internal(error.to_string()))?;
Json(settings_dto(config, sources, cookie_files)).into_response()
} }
pub async fn update_settings( pub async fn update_settings(
@@ -980,15 +1111,15 @@ pub async fn update_settings(
if let Err(response) = require_admin_json(&session, &db).await { if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response); return Ok(response);
} }
let similarity_model = body.similarity_model.trim(); let similarity_model = body.similarity_model.trim().to_string();
if crate::similarity::model_by_id(similarity_model).is_none() { if crate::similarity::model_by_id(&similarity_model).is_none() {
return Ok(json_error( return Ok(json_error(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
"unknown similarity model", "unknown similarity model",
)); ));
} }
let similarity_profile = body.similarity_profile.trim(); let similarity_profile = body.similarity_profile.trim().to_string();
if crate::similarity::profile_by_id(similarity_profile).is_none() { if crate::similarity::profile_by_id(&similarity_profile).is_none() {
return Ok(json_error( return Ok(json_error(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
"unknown similarity preprocessing profile", "unknown similarity preprocessing profile",
@@ -1003,6 +1134,58 @@ pub async fn update_settings(
)); ));
} }
}; };
if body.download_proxies.len() > 32 {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"at most 32 download proxies can be saved",
));
}
let mut download_proxies = Vec::with_capacity(body.download_proxies.len());
let mut proxy_ids = HashSet::new();
for (index, proxy) in body.download_proxies.into_iter().enumerate() {
let proxy = match DownloadProxy::from(proxy).normalized() {
Ok(proxy) => proxy,
Err(error) => {
return Ok(json_error(
StatusCode::BAD_REQUEST,
&format!("proxy {}: {error}", index + 1),
));
}
};
if !proxy_ids.insert(proxy.id.clone()) {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"download proxy ids must be unique",
));
}
download_proxies.push(proxy);
}
let torrent_proxy_id = body.torrent_proxy_id.trim().to_string();
let youtube_proxy_id = body.youtube_proxy_id.trim().to_string();
let youtube_cookie_id = body.youtube_cookie_id.trim().to_string();
for (method, proxy_id) in [
("torrent", torrent_proxy_id.as_str()),
("YouTube", youtube_proxy_id.as_str()),
] {
if !proxy_id.is_empty() && !proxy_ids.contains(proxy_id) {
return Ok(json_error(
StatusCode::BAD_REQUEST,
&format!("selected {method} proxy is not in the saved proxy list"),
));
}
}
if !youtube_cookie_id.is_empty()
&& !crate::youtube::cookie_file_exists(&db, &youtube_cookie_id)
.await
.map_err(|error| cot::Error::internal(error.to_string()))?
{
return Ok(json_error(
StatusCode::BAD_REQUEST,
"selected YouTube cookie file is not in the saved cookie file list",
));
}
let download_proxies_json = serde_json::to_string(&download_proxies)
.map_err(|error| cot::Error::internal(error.to_string()))?;
let fields = [ let fields = [
( (
"auth_password_enabled", "auth_password_enabled",
@@ -1058,9 +1241,22 @@ pub async fn update_settings(
body.federation_save_on_listen.to_string(), body.federation_save_on_listen.to_string(),
), ),
("similarity_enabled", body.similarity_enabled.to_string()), ("similarity_enabled", body.similarity_enabled.to_string()),
("similarity_model", similarity_model.to_string()), ("similarity_model", similarity_model),
("similarity_profile", similarity_profile.to_string()), ("similarity_profile", similarity_profile),
("similarity_workers", similarity_workers.to_string()), ("similarity_workers", similarity_workers.to_string()),
("downloads_enabled", body.downloads_enabled.to_string()),
(
"torrent_downloads_enabled",
body.torrent_downloads_enabled.to_string(),
),
(
"youtube_downloads_enabled",
body.youtube_downloads_enabled.to_string(),
),
("download_proxies", download_proxies_json),
("torrent_proxy_id", torrent_proxy_id),
("youtube_proxy_id", youtube_proxy_id),
("youtube_cookie_id", youtube_cookie_id),
]; ];
for (key, value) in fields { for (key, value) in fields {
let mut entry = ConfigEntry::new(key.to_string(), value); let mut entry = ConfigEntry::new(key.to_string(), value);
@@ -1079,6 +1275,119 @@ pub async fn update_settings(
Json(serde_json::json!({ "ok": true })).into_response() Json(serde_json::json!({ "ok": true })).into_response()
} }
pub async fn upload_youtube_cookie_file(
session: Session,
db: Database,
pool: &PgPool,
Json(body): Json<UploadYoutubeCookieFileRequest>,
) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
let saved_count = match crate::youtube::cookie_file_count(&db).await {
Ok(count) => count,
Err(error) => {
tracing::error!(%error, "could not count saved YouTube cookie files");
return Ok(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"YouTube cookie storage is unavailable; restart the server to apply database migrations",
));
}
};
if saved_count >= crate::youtube::MAX_COOKIE_FILES {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"at most 32 YouTube cookie files can be saved",
));
}
let filename = Path::new(body.filename.trim())
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default()
.trim()
.chars()
.filter(|character| !character.is_control())
.take(255)
.collect::<String>();
if filename.is_empty() {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"cookie filename is empty",
));
}
use base64::Engine;
let max_encoded_len = crate::youtube::MAX_COOKIE_FILE_BYTES.div_ceil(3) * 4;
if body.data.trim().len() > max_encoded_len {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"cookie file is larger than 2 MiB",
));
}
let data = match base64::engine::general_purpose::STANDARD.decode(body.data.trim()) {
Ok(data) => data,
Err(_) => {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"cookie file payload is invalid",
));
}
};
let (contents, cookie_count) = match crate::youtube::parse_cookie_file(&data) {
Ok(parsed) => parsed,
Err(error) => return Ok(json_error(StatusCode::BAD_REQUEST, &error.to_string())),
};
let file =
match crate::youtube::store_cookie_file(pool, &filename, cookie_count, &contents).await {
Ok(file) => file,
Err(error) => {
tracing::error!(%error, "could not save YouTube cookie file");
return Ok(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not save YouTube cookie file; check the server log for details",
));
}
};
Json(AdminYoutubeCookieFileDto::from(file)).into_response()
}
pub async fn delete_youtube_cookie_file(
session: Session,
db: Database,
id: &str,
) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
let id = id.trim();
if !crate::youtube::cookie_file_exists(&db, id)
.await
.map_err(|error| cot::Error::internal(error.to_string()))?
{
return Ok(json_error(
StatusCode::NOT_FOUND,
"YouTube cookie file not found",
));
}
let (config, sources) = AppConfig::load_with_db(&db).await;
if config.youtube_cookie_id == id {
if sources.youtube_cookie_id == ConfigSource::Env {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"this cookie file is selected by FURU_YOUTUBE_COOKIE_ID and cannot be deleted",
));
}
let mut entry = ConfigEntry::new("youtube_cookie_id".to_owned(), String::new());
entry
.save(&db)
.await
.map_err(|error| cot::Error::internal(error.to_string()))?;
}
crate::youtube::YoutubeCookieFile::delete_by_id(&db, id)
.await
.map_err(|error| cot::Error::internal(error.to_string()))?;
Json(serde_json::json!({ "ok": true })).into_response()
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Federation (status + manual controls) // Federation (status + manual controls)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1234,8 +1543,25 @@ pub async fn settings_probe(
.into_response() .into_response()
} }
fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto { fn settings_dto(
config: AppConfig,
sources: ConfigSources,
cookie_files: Vec<crate::youtube::YoutubeCookieFileMetadata>,
) -> AdminSettingsDto {
let download_proxies = config
.parsed_download_proxies()
.unwrap_or_else(|error| {
tracing::warn!(%error, "ignoring invalid saved download proxy list");
Vec::new()
})
.into_iter()
.map(AdminDownloadProxy::from)
.collect();
AdminSettingsDto { AdminSettingsDto {
youtube_cookie_files: cookie_files
.into_iter()
.map(AdminYoutubeCookieFileDto::from)
.collect(),
lastfm_api_key_configured: !config.lastfm_api_key.trim().is_empty(), lastfm_api_key_configured: !config.lastfm_api_key.trim().is_empty(),
lastfm_shared_secret_configured: !config.lastfm_shared_secret.trim().is_empty(), lastfm_shared_secret_configured: !config.lastfm_shared_secret.trim().is_empty(),
lastfm_scrobbling_configured: !config.lastfm_api_key.trim().is_empty() lastfm_scrobbling_configured: !config.lastfm_api_key.trim().is_empty()
@@ -1268,6 +1594,13 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
similarity_model: config.similarity_model, similarity_model: config.similarity_model,
similarity_profile: config.similarity_profile, similarity_profile: config.similarity_profile,
similarity_workers: config.similarity_workers.to_string(), similarity_workers: config.similarity_workers.to_string(),
downloads_enabled: config.downloads_enabled,
torrent_downloads_enabled: config.torrent_downloads_enabled,
youtube_downloads_enabled: config.youtube_downloads_enabled,
download_proxies,
torrent_proxy_id: config.torrent_proxy_id,
youtube_proxy_id: config.youtube_proxy_id,
youtube_cookie_id: config.youtube_cookie_id,
}, },
sources: AdminSettingsSources { sources: AdminSettingsSources {
auth_password_enabled: sources.auth_password_enabled.code(), auth_password_enabled: sources.auth_password_enabled.code(),
@@ -1297,6 +1630,13 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
similarity_model: sources.similarity_model.code(), similarity_model: sources.similarity_model.code(),
similarity_profile: sources.similarity_profile.code(), similarity_profile: sources.similarity_profile.code(),
similarity_workers: sources.similarity_workers.code(), similarity_workers: sources.similarity_workers.code(),
downloads_enabled: sources.downloads_enabled.code(),
torrent_downloads_enabled: sources.torrent_downloads_enabled.code(),
youtube_downloads_enabled: sources.youtube_downloads_enabled.code(),
download_proxies: sources.download_proxies.code(),
torrent_proxy_id: sources.torrent_proxy_id.code(),
youtube_proxy_id: sources.youtube_proxy_id.code(),
youtube_cookie_id: sources.youtube_cookie_id.code(),
}, },
} }
} }
@@ -1930,10 +2270,182 @@ pub async fn bulk_library(
.into_response(); .into_response();
} }
let affected = apply_library_action(pool, &kind, action, &ids).await?; let storage_dir = if action == "delete" && matches!(kind.as_str(), "releases" | "tracks") {
AppConfig::load_with_db(&db).await.0.agent_storage_dir
} else {
String::new()
};
let affected = apply_library_action(pool, &kind, action, &ids, &storage_dir).await?;
Json(MutationResponse { ok: true, affected }).into_response() Json(MutationResponse { ok: true, affected }).into_response()
} }
pub async fn merge_releases(
session: Session,
db: Database,
pool: &PgPool,
Json(body): Json<MergeReleasesRequest>,
) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
let mut release_ids = body.release_ids;
release_ids.retain(|id| *id > 0);
release_ids.sort_unstable();
release_ids.dedup();
if release_ids.len() < 2 {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"select at least two releases to merge",
));
}
if release_ids.len() > 250 {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"at most 250 releases can be merged at once",
));
}
if !release_ids.contains(&body.target_release_id) {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"destination release must be part of the selection",
));
}
let title = body.title.trim();
if title.is_empty() {
return Ok(json_error(StatusCode::BAD_REQUEST, "title cannot be empty"));
}
if title.chars().count() > 255 {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"title cannot exceed 255 characters",
));
}
let release_type = body.release_type.trim().to_lowercase();
if !matches!(
release_type.as_str(),
"album"
| "single"
| "ep"
| "compilation"
| "mixtape"
| "live"
| "soundtrack"
| "remix"
| "demo"
| "unknown"
) {
return Ok(json_error(StatusCode::BAD_REQUEST, "invalid release type"));
}
let year = match parse_merge_optional_i32(body.year.as_deref(), 0, 3000, "year") {
Ok(year) => year,
Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, &message)),
};
let mut tracks = Vec::with_capacity(body.tracks.len());
for track in body.tracks {
if track.id <= 0 {
return Ok(json_error(StatusCode::BAD_REQUEST, "invalid track id"));
}
let track_number = match parse_merge_optional_i32(
track.track_number.as_deref(),
1,
9999,
"track number",
) {
Ok(value) => value,
Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, &message)),
};
let disc_number =
match parse_merge_optional_i32(track.disc_number.as_deref(), 1, 999, "disc number") {
Ok(value) => value,
Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, &message)),
};
tracks.push(crate::library_cleanup::ReleaseMergeTrack {
id: track.id,
track_number,
disc_number,
});
}
let existing_release_ids: Vec<i64> =
sqlx::query_scalar("SELECT id FROM furumusic__release WHERE id = ANY($1) ORDER BY id")
.bind(&release_ids)
.fetch_all(pool)
.await
.map_err(|error| cot::Error::internal(error.to_string()))?;
if existing_release_ids != release_ids {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"one or more selected releases no longer exist",
));
}
let selected_track_ids: Vec<i64> = sqlx::query_scalar(
"SELECT id FROM furumusic__track WHERE release_id = ANY($1) ORDER BY id",
)
.bind(&release_ids)
.fetch_all(pool)
.await
.map_err(|error| cot::Error::internal(error.to_string()))?;
let mut requested_track_ids = tracks.iter().map(|track| track.id).collect::<Vec<_>>();
requested_track_ids.sort_unstable();
if requested_track_ids.windows(2).any(|ids| ids[0] == ids[1])
|| requested_track_ids != selected_track_ids
{
return Ok(json_error(
StatusCode::BAD_REQUEST,
"track list does not match the selected releases; reopen the merge wizard",
));
}
let storage_dir = AppConfig::load_with_db(&db).await.0.agent_storage_dir;
let result = match crate::library_cleanup::merge_releases(
pool,
crate::library_cleanup::ReleaseMergeSpec {
release_ids,
target_release_id: body.target_release_id,
title: title.to_owned(),
title_sort: normalize_name(title),
release_type,
year,
hidden: body.hidden,
cover_file_id: body.cover_file_id,
artist_ids: body.artist_ids,
tracks,
},
&storage_dir,
)
.await
{
Ok(result) => result,
Err(error) => {
tracing::error!(?error, "release merge failed");
return Ok(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"release merge failed; no changes were committed",
));
}
};
let Some(item) = fetch_library_item(pool, "releases", body.target_release_id)
.await
.map_err(|error| cot::Error::internal(error.to_string()))?
else {
return Ok(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"merged release could not be loaded",
));
};
Json(MergeReleasesResponse {
ok: true,
merged_releases: result.merged_releases,
moved_tracks: result.moved_tracks,
item,
})
.into_response()
}
async fn require_admin_json( async fn require_admin_json(
session: &Session, session: &Session,
db: &Database, db: &Database,
@@ -3003,6 +3515,7 @@ async fn load_library_item_detail(
release_id: None, release_id: None,
track_number: None, track_number: None,
disc_number: None, disc_number: None,
current_image_file_id: None,
current_image_url: None, current_image_url: None,
selected_artist_ids: Vec::new(), selected_artist_ids: Vec::new(),
artists: Vec::new(), artists: Vec::new(),
@@ -3023,6 +3536,7 @@ async fn load_library_item_detail(
.flatten(); .flatten();
detail.current_image_url = detail.current_image_url =
image_file_id.map(|id| format!("/api/player/cover/{id}/large")); image_file_id.map(|id| format!("/api/player/cover/{id}/large"));
detail.current_image_file_id = image_file_id;
detail.available_covers = artist_available_covers(pool, detail.item.id).await?; detail.available_covers = artist_available_covers(pool, detail.item.id).await?;
} }
"releases" => { "releases" => {
@@ -3037,6 +3551,7 @@ async fn load_library_item_detail(
detail.year = year; detail.year = year;
detail.current_image_url = detail.current_image_url =
cover_file_id.map(|id| format!("/api/player/cover/{id}/large")); cover_file_id.map(|id| format!("/api/player/cover/{id}/large"));
detail.current_image_file_id = cover_file_id;
} }
detail.selected_artist_ids = sqlx::query_as::<_, IdRow>( detail.selected_artist_ids = sqlx::query_as::<_, IdRow>(
"SELECT artist_id AS id FROM furumusic__release_artist WHERE release_id = $1 ORDER BY position, artist_id", "SELECT artist_id AS id FROM furumusic__release_artist WHERE release_id = $1 ORDER BY position, artist_id",
@@ -3484,10 +3999,11 @@ async fn apply_library_action(
kind: &str, kind: &str,
action: &str, action: &str,
ids: &[i64], ids: &[i64],
storage_dir: &str,
) -> cot::Result<u64> { ) -> cot::Result<u64> {
match action { match action {
"hide" | "show" => set_library_visibility(pool, kind, ids, action == "hide").await, "hide" | "show" => set_library_visibility(pool, kind, ids, action == "hide").await,
"delete" => delete_library_items(pool, kind, ids).await, "delete" => delete_library_items(pool, kind, ids, storage_dir).await,
_ => Ok(0), _ => Ok(0),
} }
} }
@@ -3538,10 +4054,15 @@ async fn set_library_visibility(
Ok(result.rows_affected()) Ok(result.rows_affected())
} }
async fn delete_library_items(pool: &PgPool, kind: &str, ids: &[i64]) -> cot::Result<u64> { async fn delete_library_items(
pool: &PgPool,
kind: &str,
ids: &[i64],
storage_dir: &str,
) -> cot::Result<u64> {
match kind { match kind {
"releases" => delete_releases(pool, ids).await, "releases" => delete_releases(pool, ids, storage_dir).await,
"tracks" => delete_tracks(pool, ids).await, "tracks" => delete_tracks(pool, ids, storage_dir).await,
"playlists" => delete_playlists(pool, ids).await, "playlists" => delete_playlists(pool, ids).await,
_ => delete_artists(pool, ids).await, _ => delete_artists(pool, ids).await,
} }
@@ -3571,95 +4092,16 @@ async fn delete_artists(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> {
Ok(result.rows_affected()) Ok(result.rows_affected())
} }
async fn delete_releases(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> { async fn delete_releases(pool: &PgPool, ids: &[i64], storage_dir: &str) -> cot::Result<u64> {
let track_ids = crate::library_cleanup::delete_releases(pool, ids, storage_dir)
sqlx::query_as::<_, IdRow>("SELECT id FROM furumusic__track WHERE release_id = ANY($1)")
.bind(ids)
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?
.into_iter()
.map(|row| row.id)
.collect::<Vec<_>>();
if !track_ids.is_empty() {
sqlx::query("DELETE FROM furumusic__playlist_track WHERE track_id = ANY($1)")
.bind(&track_ids)
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
sqlx::query("DELETE FROM furumusic__user_liked_track WHERE track_id = ANY($1)")
.bind(&track_ids)
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
sqlx::query("DELETE FROM furumusic__play_history WHERE track_id = ANY($1)")
.bind(&track_ids)
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
sqlx::query("DELETE FROM furumusic__track_genre WHERE track_id = ANY($1)")
.bind(&track_ids)
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
sqlx::query("DELETE FROM furumusic__track_artist WHERE track_id = ANY($1)")
.bind(&track_ids)
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
}
sqlx::query("DELETE FROM furumusic__track WHERE release_id = ANY($1)")
.bind(ids)
.execute(pool)
.await .await
.map_err(|e| cot::Error::internal(e.to_string()))?; .map_err(|error| cot::Error::internal(error.to_string()))
sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = ANY($1)")
.bind(ids)
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let result = sqlx::query("DELETE FROM furumusic__release WHERE id = ANY($1)")
.bind(ids)
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Ok(result.rows_affected())
} }
async fn delete_tracks(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> { async fn delete_tracks(pool: &PgPool, ids: &[i64], storage_dir: &str) -> cot::Result<u64> {
sqlx::query("DELETE FROM furumusic__playlist_track WHERE track_id = ANY($1)") crate::library_cleanup::delete_tracks(pool, ids, storage_dir)
.bind(ids)
.execute(pool)
.await .await
.map_err(|e| cot::Error::internal(e.to_string()))?; .map_err(|error| cot::Error::internal(error.to_string()))
sqlx::query("DELETE FROM furumusic__user_liked_track WHERE track_id = ANY($1)")
.bind(ids)
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
sqlx::query("DELETE FROM furumusic__play_history WHERE track_id = ANY($1)")
.bind(ids)
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
sqlx::query("DELETE FROM furumusic__track_genre WHERE track_id = ANY($1)")
.bind(ids)
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
sqlx::query("DELETE FROM furumusic__track_artist WHERE track_id = ANY($1)")
.bind(ids)
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let result = sqlx::query("DELETE FROM furumusic__track WHERE id = ANY($1)")
.bind(ids)
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Ok(result.rows_affected())
} }
async fn delete_playlists(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> { async fn delete_playlists(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> {
@@ -4046,6 +4488,24 @@ fn parse_optional_admin_i32(value: Option<&str>, min: i32, max: i32) -> Option<i
.map(|parsed| parsed.clamp(min, max)) .map(|parsed| parsed.clamp(min, max))
} }
fn parse_merge_optional_i32(
value: Option<&str>,
min: i32,
max: i32,
field: &str,
) -> Result<Option<i32>, String> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
let parsed = value
.parse::<i32>()
.map_err(|_| format!("{field} must be an integer from {min} to {max}"))?;
if !(min..=max).contains(&parsed) {
return Err(format!("{field} must be from {min} to {max}"));
}
Ok(Some(parsed))
}
fn deserialize_optional_stringish<'de, D>(deserializer: D) -> Result<Option<String>, D::Error> fn deserialize_optional_stringish<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where where
D: Deserializer<'de>, D: Deserializer<'de>,
@@ -4119,3 +4579,22 @@ fn size_display(bytes: i64) -> String {
format!("{bytes} B") format!("{bytes} B")
} }
} }
#[cfg(test)]
mod tests {
use super::parse_merge_optional_i32;
#[test]
fn merge_numbers_preserve_empty_values_and_reject_invalid_input() {
assert_eq!(
parse_merge_optional_i32(None, 1, 9999, "track number").unwrap(),
None
);
assert_eq!(
parse_merge_optional_i32(Some(" 38 "), 1, 9999, "track number").unwrap(),
Some(38)
);
assert!(parse_merge_optional_i32(Some("0"), 1, 9999, "track number").is_err());
assert!(parse_merge_optional_i32(Some("nope"), 1, 9999, "track number").is_err());
}
}
+3 -1
View File
@@ -1262,9 +1262,11 @@ pub async fn releases_update(
pub async fn releases_delete( pub async fn releases_delete(
_admin: AuthenticatedUser, _admin: AuthenticatedUser,
db: &Database, db: &Database,
pool: &sqlx::PgPool,
release_id: i64, release_id: i64,
) -> cot::Result<cot::http::Response<Body>> { ) -> cot::Result<cot::http::Response<Body>> {
Release::delete_by_id(db, release_id) let (config, _) = AppConfig::load_with_db(db).await;
crate::library_cleanup::delete_releases(pool, &[release_id], &config.agent_storage_dir)
.await .await
.map_err(|e| cot::Error::internal(format!("failed to delete release: {e}")))?; .map_err(|e| cot::Error::internal(format!("failed to delete release: {e}")))?;
Ok(auth::redirect("/admin/releases")) Ok(auth::redirect("/admin/releases"))
+209 -5
View File
@@ -142,6 +142,13 @@ pub struct ConfigSources {
pub similarity_model: ConfigSource, pub similarity_model: ConfigSource,
pub similarity_profile: ConfigSource, pub similarity_profile: ConfigSource,
pub similarity_workers: ConfigSource, pub similarity_workers: ConfigSource,
pub downloads_enabled: ConfigSource,
pub torrent_downloads_enabled: ConfigSource,
pub youtube_downloads_enabled: ConfigSource,
pub download_proxies: ConfigSource,
pub torrent_proxy_id: ConfigSource,
pub youtube_proxy_id: ConfigSource,
pub youtube_cookie_id: ConfigSource,
} }
impl Default for ConfigSources { impl Default for ConfigSources {
@@ -176,6 +183,13 @@ impl Default for ConfigSources {
similarity_model: ConfigSource::Default, similarity_model: ConfigSource::Default,
similarity_profile: ConfigSource::Default, similarity_profile: ConfigSource::Default,
similarity_workers: ConfigSource::Default, similarity_workers: ConfigSource::Default,
downloads_enabled: ConfigSource::Default,
torrent_downloads_enabled: ConfigSource::Default,
youtube_downloads_enabled: ConfigSource::Default,
download_proxies: ConfigSource::Default,
torrent_proxy_id: ConfigSource::Default,
youtube_proxy_id: ConfigSource::Default,
youtube_cookie_id: ConfigSource::Default,
} }
} }
} }
@@ -238,6 +252,84 @@ macro_rules! impl_env_overrides {
// AppConfig // AppConfig
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Saved SOCKS5 proxy used by user-facing download methods.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DownloadProxy {
pub id: String,
pub address: String,
#[serde(default)]
pub username: String,
#[serde(default)]
pub password: String,
}
impl DownloadProxy {
/// Validate and normalize a proxy without performing network or DNS I/O.
pub fn normalized(mut self) -> anyhow::Result<Self> {
self.id = self.id.trim().to_string();
self.address = self.address.trim().to_string();
if self.id.is_empty() || self.id.len() > 64 {
anyhow::bail!("proxy id must contain from 1 to 64 characters");
}
if !self
.id
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
{
anyhow::bail!("proxy id may contain only letters, digits, '-' and '_'");
}
if self.address.is_empty() || self.address.len() > 512 {
anyhow::bail!("proxy address must contain a host and port");
}
if self
.address
.chars()
.any(|character| matches!(character, '/' | '?' | '#' | '@'))
{
anyhow::bail!("proxy address must be in host:port format");
}
if self.username.len() > 256 || self.password.len() > 256 {
anyhow::bail!("proxy credentials are too long");
}
let parsed = reqwest::Url::parse(&format!("socks5://{}", self.address))
.map_err(|_| anyhow::anyhow!("proxy address must be in host:port format"))?;
let host = parsed
.host_str()
.filter(|host| !host.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!("proxy address has no host"))?;
let port = parsed
.port()
.ok_or_else(|| anyhow::anyhow!("proxy address has no port"))?;
if port == 0 {
anyhow::bail!("proxy port must be between 1 and 65535");
}
self.address = if host.starts_with('[') && host.ends_with(']') {
format!("{host}:{port}")
} else if host.contains(':') {
format!("[{host}]:{port}")
} else {
format!("{host}:{port}")
};
Ok(self)
}
/// Build the URL accepted by librqbit and yt-dlp. Credentials are included
/// only when both fields are non-empty.
pub fn socks_url(&self) -> anyhow::Result<String> {
let proxy = self.clone().normalized()?;
let mut url = reqwest::Url::parse(&format!("socks5://{}", proxy.address))?;
if !proxy.username.is_empty() && !proxy.password.is_empty() {
url.set_username(&proxy.username)
.map_err(|_| anyhow::anyhow!("invalid proxy username"))?;
url.set_password(Some(&proxy.password))
.map_err(|_| anyhow::anyhow!("invalid proxy password"))?;
}
Ok(url.to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig { pub struct AppConfig {
/// PostgreSQL connection URL. /// PostgreSQL connection URL.
@@ -301,6 +393,20 @@ pub struct AppConfig {
pub similarity_profile: String, pub similarity_profile: String,
/// Maximum number of concurrent CPU embedding workers. /// Maximum number of concurrent CPU embedding workers.
pub similarity_workers: u64, pub similarity_workers: u64,
/// Whether the download manager and local-file uploads are available.
pub downloads_enabled: bool,
/// Whether torrent imports are available when the download manager is enabled.
pub torrent_downloads_enabled: bool,
/// Whether YouTube imports are available when the download manager is enabled.
pub youtube_downloads_enabled: bool,
/// JSON-encoded list of [`DownloadProxy`] entries.
pub download_proxies: String,
/// Saved proxy id used for torrent downloads; empty means a direct connection.
pub torrent_proxy_id: String,
/// Saved proxy id used for YouTube downloads; empty means a direct connection.
pub youtube_proxy_id: String,
/// Saved cookie-file id used by yt-dlp; empty means no cookies.
pub youtube_cookie_id: String,
} }
impl Default for AppConfig { impl Default for AppConfig {
@@ -337,6 +443,14 @@ impl Default for AppConfig {
similarity_workers: std::thread::available_parallelism() similarity_workers: std::thread::available_parallelism()
.map(|count| (count.get() / 2).clamp(1, 4) as u64) .map(|count| (count.get() / 2).clamp(1, 4) as u64)
.unwrap_or(1), .unwrap_or(1),
// Preserve the behavior from before these controls were added.
downloads_enabled: true,
torrent_downloads_enabled: true,
youtube_downloads_enabled: true,
download_proxies: "[]".into(),
torrent_proxy_id: String::new(),
youtube_proxy_id: String::new(),
youtube_cookie_id: String::new(),
} }
} }
} }
@@ -372,6 +486,13 @@ impl_env_overrides!(
similarity_model, similarity_model,
similarity_profile, similarity_profile,
similarity_workers, similarity_workers,
downloads_enabled,
torrent_downloads_enabled,
youtube_downloads_enabled,
download_proxies,
torrent_proxy_id,
youtube_proxy_id,
youtube_cookie_id,
); );
impl AppConfig { impl AppConfig {
@@ -466,11 +587,13 @@ impl AppConfig {
sources.$field = ConfigSource::Database; sources.$field = ConfigSource::Database;
} }
Err(_) => { Err(_) => {
tracing::warn!( if !val.trim().is_empty() {
"ignoring invalid DB config value for {}: {:?}", tracing::warn!(
stringify!($field), "ignoring invalid DB config value for {}: {:?}",
val, stringify!($field),
); val,
);
}
} }
} }
} }
@@ -506,6 +629,40 @@ impl AppConfig {
apply_db_field!(similarity_model); apply_db_field!(similarity_model);
apply_db_field!(similarity_profile); apply_db_field!(similarity_profile);
apply_db_field!(similarity_workers); apply_db_field!(similarity_workers);
apply_db_field!(downloads_enabled);
apply_db_field!(torrent_downloads_enabled);
apply_db_field!(youtube_downloads_enabled);
apply_db_field!(download_proxies);
apply_db_field!(torrent_proxy_id);
apply_db_field!(youtube_proxy_id);
apply_db_field!(youtube_cookie_id);
}
pub fn parsed_download_proxies(&self) -> anyhow::Result<Vec<DownloadProxy>> {
let proxies: Vec<DownloadProxy> = serde_json::from_str(&self.download_proxies)
.map_err(|_| anyhow::anyhow!("saved download proxy list is invalid"))?;
proxies.into_iter().map(DownloadProxy::normalized).collect()
}
pub fn selected_proxy_url(&self, proxy_id: &str) -> anyhow::Result<Option<String>> {
let proxy_id = proxy_id.trim();
if proxy_id.is_empty() {
return Ok(None);
}
let proxy = self
.parsed_download_proxies()?
.into_iter()
.find(|proxy| proxy.id == proxy_id)
.ok_or_else(|| anyhow::anyhow!("selected download proxy is not configured"))?;
proxy.socks_url().map(Some)
}
pub fn torrent_proxy_url(&self) -> anyhow::Result<Option<String>> {
self.selected_proxy_url(&self.torrent_proxy_id)
}
pub fn youtube_proxy_url(&self) -> anyhow::Result<Option<String>> {
self.selected_proxy_url(&self.youtube_proxy_id)
} }
} }
@@ -532,6 +689,53 @@ mod tests {
crate::similarity::DEFAULT_PROFILE_ID crate::similarity::DEFAULT_PROFILE_ID
); );
assert!((1..=4).contains(&cfg.similarity_workers)); assert!((1..=4).contains(&cfg.similarity_workers));
assert!(cfg.downloads_enabled);
assert!(cfg.torrent_downloads_enabled);
assert!(cfg.youtube_downloads_enabled);
assert!(cfg.parsed_download_proxies().unwrap().is_empty());
}
#[test]
fn download_proxy_url_encodes_complete_credentials() {
let proxy = DownloadProxy {
id: "proxy-1".into(),
address: "proxy.example:1080".into(),
username: "user name".into(),
password: "p@ss:word".into(),
};
assert_eq!(
proxy.socks_url().unwrap(),
"socks5://user%20name:p%40ss%3Aword@proxy.example:1080"
);
}
#[test]
fn download_proxy_url_omits_partial_credentials() {
let proxy = DownloadProxy {
id: "proxy-1".into(),
address: "127.0.0.1:1080".into(),
username: "user".into(),
password: String::new(),
};
assert_eq!(proxy.socks_url().unwrap(), "socks5://127.0.0.1:1080");
}
#[test]
fn selected_download_proxy_is_resolved_by_id() {
let mut cfg = AppConfig::default();
cfg.download_proxies = serde_json::to_string(&[DownloadProxy {
id: "youtube".into(),
address: "[::1]:9050".into(),
username: String::new(),
password: String::new(),
}])
.unwrap();
cfg.youtube_proxy_id = "youtube".into();
assert_eq!(
cfg.youtube_proxy_url().unwrap().as_deref(),
Some("socks5://[::1]:9050")
);
assert_eq!(cfg.torrent_proxy_url().unwrap(), None);
} }
#[test] #[test]
+349 -90
View File
@@ -5,6 +5,9 @@
//! protocol as the TUI clients on `furumi/sync/1` and maps operations into //! protocol as the TUI clients on `furumi/sync/1` and maps operations into
//! user-scoped Postgres state. //! user-scoped Postgres state.
use music_dht::playback::{
Announcement, Checkpoint, CommandStamp, Config as PlaybackConfig, Engine,
};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
@@ -24,7 +27,7 @@ use super::{TransportStats, record_stream_transport};
pub const SYNC_ALPN: &[u8] = b"furumi/sync/2"; pub const SYNC_ALPN: &[u8] = b"furumi/sync/2";
const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION"); const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const PROTOCOL_VERSION: u16 = 2; const PROTOCOL_VERSION: u16 = 3;
const INVITE_TTL_MS: i64 = 10 * 60 * 1000; const INVITE_TTL_MS: i64 = 10 * 60 * 1000;
const PAIRING_WAIT_MS: i64 = 5 * 60 * 1000; const PAIRING_WAIT_MS: i64 = 5 * 60 * 1000;
const PAIRING_RETRY_DELAY: Duration = Duration::from_secs(1); const PAIRING_RETRY_DELAY: Duration = Duration::from_secs(1);
@@ -193,29 +196,8 @@ struct PlaybackStateWire {
repeat: PlaybackRepeat, repeat: PlaybackRepeat,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] type PlaybackSnapshot = music_dht::playback::Snapshot<PlaybackStateWire>;
struct PlaybackSnapshot { type PlaybackCommand = music_dht::playback::Command<PlaybackStateWire>;
device_id: String,
device_name: String,
active: bool,
updated_at_ms: i64,
state: PlaybackStateWire,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum PlaybackCommand {
SetState {
state: PlaybackStateWire,
#[serde(default)]
seek: bool,
},
ActiveChanged {
active_device_id: String,
active_device_name: String,
state: PlaybackStateWire,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
struct SyncOpWire { struct SyncOpWire {
@@ -273,6 +255,8 @@ enum SyncOpPayload {
PlaybackCommand { PlaybackCommand {
target_device_id: String, target_device_id: String,
command: PlaybackCommand, command: PlaybackCommand,
#[serde(default)]
authority: Option<CommandStamp>,
}, },
ListenRecorded { ListenRecorded {
event: ListenEvent, event: ListenEvent,
@@ -1097,50 +1081,53 @@ pub async fn sync_loop(
transport_stats: Arc<TransportStats>, transport_stats: Arc<TransportStats>,
) { ) {
let mut interval = tokio::time::interval(DEVICE_SYNC_INTERVAL); let mut interval = tokio::time::interval(DEVICE_SYNC_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut polls = tokio::task::JoinSet::new();
let mut active = std::collections::HashMap::new();
loop { loop {
interval.tick().await; tokio::select! {
if let Err(err) = sync_once_all( _ = interval.tick() => {
&pool, let users: Vec<i64> = match sqlx::query_scalar(
Arc::clone(&service), "SELECT DISTINCT user_id FROM furumusic__fed_device WHERE trusted_at_ms IS NOT NULL AND revoked_at_ms IS NULL"
Arc::clone(&hub), ).fetch_all(&pool).await {
Arc::clone(&transport_stats), Ok(users) => users,
) Err(error) => { tracing::warn!("device poll listing failed: {error:#}"); continue; }
.await };
{ for user_id in users {
tracing::debug!("web fed device sync tick failed: {err:#}"); let devices = match active_remote_devices(&pool, user_id).await {
Ok(devices) => devices,
Err(error) => { let _ = set_last_error(&pool, user_id, Some(&format!("{error:#}"))).await; continue; }
};
for device in devices {
let key = (user_id, device.device_id.clone());
if device.endpoint_ticket.trim().is_empty() || active.values().any(|id| id == &key) { continue; }
let pool = pool.clone();
let service = Arc::clone(&service);
let hub = Arc::clone(&hub);
let stats = Arc::clone(&transport_stats);
let handle = polls.spawn(async move {
tokio::time::timeout(Duration::from_secs(30), sync_device(&pool, service, hub, stats, user_id, &device))
.await.context("device sync exchange timed out")?
});
active.insert(handle.id(), key);
}
}
}
Some(completed) = polls.join_next_with_id(), if !polls.is_empty() => {
let (task, result) = match completed {
Ok((task, result)) => (task, result),
Err(error) => (error.id(), Err(anyhow::Error::from(error))),
};
if let Some((user_id, device_id)) = active.remove(&task)
&& let Err(error) = result {
tracing::debug!(device = %device_id, "web fed device sync failed: {error:#}");
let _ = set_last_error(&pool, user_id, Some(&format!("{}: {error:#}", short_id(&device_id)))).await;
}
}
} }
} }
} }
pub async fn sync_once_all(
pool: &sqlx::PgPool,
service: Arc<MusicDhtService>,
hub: Arc<PlayerDeviceHub>,
transport_stats: Arc<TransportStats>,
) -> Result<()> {
let rows = sqlx::query(
"SELECT DISTINCT user_id FROM furumusic__fed_device
WHERE trusted_at_ms IS NOT NULL AND revoked_at_ms IS NULL",
)
.fetch_all(pool)
.await?;
for row in rows {
let user_id: i64 = row.get("user_id");
if let Err(err) = sync_once(
pool,
Arc::clone(&service),
Arc::clone(&hub),
Arc::clone(&transport_stats),
user_id,
)
.await
{
set_last_error(pool, user_id, Some(&format!("{err:#}"))).await?;
}
}
Ok(())
}
pub async fn sync_once( pub async fn sync_once(
pool: &sqlx::PgPool, pool: &sqlx::PgPool,
service: Arc<MusicDhtService>, service: Arc<MusicDhtService>,
@@ -1235,7 +1222,14 @@ async fn try_connect_invite(
enforce_single_user_binding(pool, user_id, &profile.device_id).await?; enforce_single_user_binding(pool, user_id, &profile.device_id).await?;
apply_device_profile(pool, user_id, &profile, true).await?; apply_device_profile(pool, user_id, &profile, true).await?;
if let Some(playback) = playback { if let Some(playback) = playback {
apply_playback_snapshot(Arc::clone(&hub), pool, user_id, playback).await?; apply_playback_snapshot(
Arc::clone(&hub),
pool,
user_id,
&profile.device_id,
playback,
)
.await?;
} }
} }
apply_device_profiles(pool, user_id, &devices).await?; apply_device_profiles(pool, user_id, &devices).await?;
@@ -1454,7 +1448,14 @@ async fn handle_pair_request(
let own_profile = own_profile(pool, user_id, "", &own_ticket).await?; let own_profile = own_profile(pool, user_id, "", &own_ticket).await?;
apply_device_profile(pool, user_id, &profile, true).await?; apply_device_profile(pool, user_id, &profile, true).await?;
if let Some(playback) = playback { if let Some(playback) = playback {
apply_playback_snapshot(Arc::clone(&hub), pool, user_id, playback).await?; apply_playback_snapshot(
Arc::clone(&hub),
pool,
user_id,
&profile.device_id,
playback,
)
.await?;
} }
apply_snapshot(pool, user_id, incoming_snapshot).await?; apply_snapshot(pool, user_id, incoming_snapshot).await?;
apply_ops(pool, Arc::clone(&hub), user_id, ops).await?; apply_ops(pool, Arc::clone(&hub), user_id, ops).await?;
@@ -1524,7 +1525,14 @@ async fn handle_hello(
apply_device_profile(pool, user_id, &profile, false).await?; apply_device_profile(pool, user_id, &profile, false).await?;
apply_device_profiles(pool, user_id, &devices).await?; apply_device_profiles(pool, user_id, &devices).await?;
if let Some(playback) = playback { if let Some(playback) = playback {
apply_playback_snapshot(Arc::clone(&hub), pool, user_id, playback).await?; apply_playback_snapshot(
Arc::clone(&hub),
pool,
user_id,
&profile.device_id,
playback,
)
.await?;
} }
apply_snapshot(pool, user_id, incoming_snapshot).await?; apply_snapshot(pool, user_id, incoming_snapshot).await?;
apply_ops(pool, Arc::clone(&hub), user_id, ops).await?; apply_ops(pool, Arc::clone(&hub), user_id, ops).await?;
@@ -1618,7 +1626,14 @@ async fn sync_device(
} => { } => {
apply_device_profiles(pool, user_id, &devices).await?; apply_device_profiles(pool, user_id, &devices).await?;
if let Some(playback) = playback { if let Some(playback) = playback {
apply_playback_snapshot(Arc::clone(&hub), pool, user_id, playback).await?; apply_playback_snapshot(
Arc::clone(&hub),
pool,
user_id,
&device.device_id,
playback,
)
.await?;
} }
apply_snapshot(pool, user_id, snapshot).await?; apply_snapshot(pool, user_id, snapshot).await?;
apply_ops(pool, hub, user_id, ops).await?; apply_ops(pool, hub, user_id, ops).await?;
@@ -1952,8 +1967,33 @@ async fn own_profile(
}) })
} }
async fn record_local_op(pool: &sqlx::PgPool, user_id: i64, payload: SyncOpPayload) -> Result<()> { async fn record_local_op(
pool: &sqlx::PgPool,
user_id: i64,
mut payload: SyncOpPayload,
) -> Result<()> {
let identity = ensure_identity(pool, user_id, "").await?; let identity = ensure_identity(pool, user_id, "").await?;
if let SyncOpPayload::PlaybackCommand {
command, authority, ..
} = &mut payload
{
*authority = Some(
with_playback_engine(pool, user_id, &identity, |engine| {
if let PlaybackCommand::ActiveChanged {
active_device_id, ..
} = command
{
if engine.owner() != Some(active_device_id.as_str()) {
engine.transfer(active_device_id, playback_clock());
}
}
engine.stamp()
})
.await?
.context("no playback owner; select an output first")?,
);
}
let now = now_ms(); let now = now_ms();
let row = sqlx::query( let row = sqlx::query(
"UPDATE furumusic__fed_device_identity "UPDATE furumusic__fed_device_identity
@@ -2193,12 +2233,8 @@ async fn apply_op(
) )
.await .await
} }
SyncOpPayload::PlaybackCommand { SyncOpPayload::PlaybackCommand { .. } => {
target_device_id, apply_playback_command(pool, hub, user_id, op).await?;
command,
} => {
apply_playback_command(pool, hub, user_id, target_device_id, command, &op.op_id)
.await?;
Ok(false) Ok(false)
} }
SyncOpPayload::ListenRecorded { event } => { SyncOpPayload::ListenRecorded { event } => {
@@ -2798,12 +2834,41 @@ async fn apply_playback_command(
pool: &sqlx::PgPool, pool: &sqlx::PgPool,
hub: Arc<PlayerDeviceHub>, hub: Arc<PlayerDeviceHub>,
user_id: i64, user_id: i64,
target_device_id: &str, op: &SyncOpWire,
command: &PlaybackCommand,
op_id: &str,
) -> Result<()> { ) -> Result<()> {
let SyncOpPayload::PlaybackCommand {
target_device_id,
command,
authority,
} = &op.payload
else {
return Ok(());
};
let authority = authority.as_ref();
let origin = &op.origin_device_id;
let op_id = &op.op_id;
let identity = ensure_identity(pool, user_id, "").await?; let identity = ensure_identity(pool, user_id, "").await?;
if target_device_id != identity.device_id { let Some(authority) = authority else {
return Ok(());
};
let handoff = matches!(command, PlaybackCommand::ActiveChanged { .. });
if !handoff && target_device_id != &identity.device_id {
return Ok(());
}
if let PlaybackCommand::ActiveChanged {
active_device_id, ..
} = command
{
if active_device_id != &authority.claim.owner {
return Ok(());
}
}
let accepted = with_playback_engine(pool, user_id, &identity, |engine| {
engine.accept_command(origin, authority, handoff, playback_clock())
&& (handoff || engine.is_owner())
})
.await?;
if !accepted || target_device_id != &identity.device_id {
return Ok(()); return Ok(());
} }
let inserted = sqlx::query( let inserted = sqlx::query(
@@ -2822,17 +2887,36 @@ async fn apply_playback_command(
} }
match command { match command {
PlaybackCommand::SetState { state, .. } => { PlaybackCommand::SetState { state, .. } => {
enqueue_web_transfer(pool, hub, user_id, state).await?; enqueue_web_transfer(pool, hub, user_id, state, op).await?;
} }
PlaybackCommand::ActiveChanged { PlaybackCommand::ActiveChanged {
active_device_id, active_device_id,
state, state,
.. ..
} if active_device_id == &identity.device_id => { } if active_device_id == &identity.device_id => {
enqueue_web_transfer(pool, hub, user_id, state).await?; enqueue_web_transfer(pool, hub, user_id, state, op).await?;
} }
PlaybackCommand::ActiveChanged { .. } => { PlaybackCommand::ActiveChanged {
let _ = hub.enqueue_fed_command(user_id, "pause", serde_json::json!({})); active_device_id,
active_device_name,
state,
} => {
let payload = web_playback_payload(pool, state).await?;
if !with_playback_engine(pool, user_id, &identity, |engine| {
engine.command_is_current(origin, authority)
})
.await?
{
return Ok(());
}
hub.apply_fed_playback_state_json(
user_id,
active_device_id,
active_device_name,
true,
payload,
)
.map_err(|message| anyhow::anyhow!(message))?;
} }
} }
Ok(()) Ok(())
@@ -2842,14 +2926,36 @@ async fn apply_playback_snapshot(
hub: Arc<PlayerDeviceHub>, hub: Arc<PlayerDeviceHub>,
pool: &sqlx::PgPool, pool: &sqlx::PgPool,
user_id: i64, user_id: i64,
sender: &str,
snapshot: PlaybackSnapshot, snapshot: PlaybackSnapshot,
) -> Result<()> { ) -> Result<()> {
if snapshot.device_id != sender {
return Ok(());
}
let identity = ensure_identity(pool, user_id, "").await?;
let Some(coordination) = &snapshot.coordination else {
return Ok(());
};
let owner = with_playback_engine(pool, user_id, &identity, |engine| {
if !engine.observe(&snapshot.device_id, coordination, playback_clock()) {
return None;
}
engine
.announcement_is_current(&snapshot.device_id, coordination)
.then(|| snapshot.device_id.clone())
})
.await?;
let payload = web_playback_payload(pool, &snapshot.state).await?; let payload = web_playback_payload(pool, &snapshot.state).await?;
let active = owner.as_deref() == Some(snapshot.device_id.as_str())
&& with_playback_engine(pool, user_id, &identity, |engine| {
engine.announcement_is_current(&snapshot.device_id, coordination)
})
.await?;
hub.apply_fed_playback_state_json( hub.apply_fed_playback_state_json(
user_id, user_id,
&snapshot.device_id, &snapshot.device_id,
&snapshot.device_name, &snapshot.device_name,
snapshot.active, active,
payload, payload,
) )
.map_err(|message| anyhow::anyhow!(message))?; .map_err(|message| anyhow::anyhow!(message))?;
@@ -2861,8 +2967,24 @@ async fn enqueue_web_transfer(
hub: Arc<PlayerDeviceHub>, hub: Arc<PlayerDeviceHub>,
user_id: i64, user_id: i64,
state: &PlaybackStateWire, state: &PlaybackStateWire,
op: &SyncOpWire,
) -> Result<()> { ) -> Result<()> {
let identity = ensure_identity(pool, user_id, "").await?;
let payload = web_playback_payload(pool, state).await?; let payload = web_playback_payload(pool, state).await?;
let SyncOpPayload::PlaybackCommand {
authority: Some(authority),
..
} = &op.payload
else {
return Ok(());
};
if !with_playback_engine(pool, user_id, &identity, |engine| {
engine.command_is_current(&op.origin_device_id, authority)
})
.await?
{
return Ok(());
}
if payload if payload
.get("tracks") .get("tracks")
.and_then(serde_json::Value::as_array) .and_then(serde_json::Value::as_array)
@@ -2891,6 +3013,7 @@ pub async fn record_web_playback_command(
pool, pool,
user_id, user_id,
SyncOpPayload::PlaybackCommand { SyncOpPayload::PlaybackCommand {
authority: None,
target_device_id: target_device_id.to_string(), target_device_id: target_device_id.to_string(),
command: PlaybackCommand::SetState { command: PlaybackCommand::SetState {
state: wire, state: wire,
@@ -2915,14 +3038,22 @@ pub async fn record_web_active_transfer(
ensure_web_playback_target(pool, user_id, target_device_id).await?; ensure_web_playback_target(pool, user_id, target_device_id).await?;
let wire = playback_state_from_browser_json(pool, state).await?; let wire = playback_state_from_browser_json(pool, state).await?;
let target_name = web_playback_target_name(pool, user_id, target_device_id).await?; let target_name = web_playback_target_name(pool, user_id, target_device_id).await?;
let identity = ensure_identity(pool, user_id, "").await?;
with_playback_engine(pool, user_id, &identity, |engine| {
engine.transfer(target_device_id, playback_clock())
})
.await?;
record_local_op( record_local_op(
pool, pool,
user_id, user_id,
SyncOpPayload::PlaybackCommand { SyncOpPayload::PlaybackCommand {
authority: None,
target_device_id: target_device_id.to_string(), target_device_id: target_device_id.to_string(),
command: PlaybackCommand::SetState { command: PlaybackCommand::ActiveChanged {
active_device_id: target_device_id.to_string(),
active_device_name: target_name.clone(),
state: wire.clone(), state: wire.clone(),
seek: true,
}, },
}, },
) )
@@ -2940,6 +3071,7 @@ pub async fn record_web_active_transfer(
pool, pool,
user_id, user_id,
SyncOpPayload::PlaybackCommand { SyncOpPayload::PlaybackCommand {
authority: None,
target_device_id: previous_device_id.to_string(), target_device_id: previous_device_id.to_string(),
command, command,
}, },
@@ -2958,10 +3090,15 @@ pub async fn record_web_active_takeover(
ensure_web_playback_target(pool, user_id, previous_device_id).await?; ensure_web_playback_target(pool, user_id, previous_device_id).await?;
let identity = ensure_identity(pool, user_id, "").await?; let identity = ensure_identity(pool, user_id, "").await?;
let wire = playback_state_from_browser_json(pool, state).await?; let wire = playback_state_from_browser_json(pool, state).await?;
with_playback_engine(pool, user_id, &identity, |engine| {
engine.transfer(&identity.device_id, playback_clock())
})
.await?;
record_local_op( record_local_op(
pool, pool,
user_id, user_id,
SyncOpPayload::PlaybackCommand { SyncOpPayload::PlaybackCommand {
authority: None,
target_device_id: previous_device_id.to_string(), target_device_id: previous_device_id.to_string(),
command: PlaybackCommand::ActiveChanged { command: PlaybackCommand::ActiveChanged {
active_device_id: identity.device_id, active_device_id: identity.device_id,
@@ -3423,11 +3560,16 @@ async fn local_playback_snapshot(
user_id: i64, user_id: i64,
identity: &Identity, identity: &Identity,
) -> Option<PlaybackSnapshot> { ) -> Option<PlaybackSnapshot> {
// Keep publishing an inactive snapshot after a handoff. Omitting the let coordination = coordinate_web_output(pool, Arc::clone(&hub), user_id, identity)
// snapshot left the last `active: true` value alive on trusted peers until .await
// its TTL elapsed, allowing the always-on web peer to reclaim playback. .ok()?;
let active = hub.federation_playback_is_local(user_id); let active = coordination
let state = hub.playback_state_json_for_commands(user_id)?; .claim
.as_ref()
.is_some_and(|claim| claim.owner == identity.device_id);
let state = hub
.playback_state_json_for_commands(user_id)
.unwrap_or_else(|| serde_json::json!({}));
let wire = playback_state_from_browser_json(pool, state).await.ok()?; let wire = playback_state_from_browser_json(pool, state).await.ok()?;
Some(PlaybackSnapshot { Some(PlaybackSnapshot {
device_id: identity.device_id.clone(), device_id: identity.device_id.clone(),
@@ -3435,9 +3577,123 @@ async fn local_playback_snapshot(
active, active,
updated_at_ms: now_ms(), updated_at_ms: now_ms(),
state: wire, state: wire,
coordination: Some(coordination),
}) })
} }
/// Drive coordination from browser HTTP traffic even before the first peer
/// connects. This does not require a running federation transport.
pub async fn refresh_web_output(
pool: &sqlx::PgPool,
hub: Arc<PlayerDeviceHub>,
user_id: i64,
) -> Result<()> {
let identity = ensure_identity(pool, user_id, "").await?;
coordinate_web_output(pool, hub, user_id, &identity).await?;
Ok(())
}
async fn coordinate_web_output(
pool: &sqlx::PgPool,
hub: Arc<PlayerDeviceHub>,
user_id: i64,
identity: &Identity,
) -> Result<Announcement> {
let (available, playing, report, startup) = hub.federation_output_report(user_id);
let coordination = with_playback_engine(pool, user_id, identity, |engine| {
engine.set_output(available, playing);
if startup {
engine.request_startup();
}
// A server poll cannot refresh the heartbeat of a suspended browser.
engine.output_report(report, playback_clock());
engine.tick(playback_clock());
engine.announcement()
})
.await?;
if let Some(owner) = &coordination.claim {
hub.enforce_federation_owner(user_id, &identity.device_id, &owner.owner);
}
Ok(coordination)
}
// One serialized coordinator per account. The lock covers checkpoint commit so
// a later request cannot publish a term before its predecessor is durable.
struct PlaybackSession {
engine: Option<Engine>,
group_id: String,
}
type PlaybackSessions = std::sync::Mutex<BTreeMap<i64, Arc<tokio::sync::Mutex<PlaybackSession>>>>;
async fn with_playback_engine<R>(
pool: &sqlx::PgPool,
user_id: i64,
identity: &Identity,
f: impl FnOnce(&mut Engine) -> R,
) -> Result<R> {
static SESSIONS: std::sync::OnceLock<PlaybackSessions> = std::sync::OnceLock::new();
let session = {
let mut sessions = SESSIONS
.get_or_init(Default::default)
.lock()
.expect("playback sessions");
sessions
.entry(user_id)
.or_insert_with(|| {
Arc::new(tokio::sync::Mutex::new(PlaybackSession {
engine: None,
group_id: String::new(),
}))
})
.clone()
};
let mut session = session.lock().await;
if session.engine.is_none() || session.group_id != identity.group_id {
session.group_id = identity.group_id.clone();
let row = sqlx::query("SELECT playback_coordination_json, playback_config_json FROM furumusic__fed_device_identity WHERE user_id = $1")
.bind(user_id).fetch_one(pool).await?;
let durable = row
.get::<Option<serde_json::Value>, _>("playback_coordination_json")
.map(serde_json::from_value::<Checkpoint>)
.transpose()?
.filter(|checkpoint| checkpoint.scope == identity.group_id)
.map(|checkpoint| checkpoint.state)
.unwrap_or_default();
let config = row
.get::<Option<serde_json::Value>, _>("playback_config_json")
.map(serde_json::from_value::<PlaybackConfig>)
.transpose()?
.unwrap_or_else(PlaybackConfig::passive);
session.engine = Some(Engine::new(
identity.device_id.clone(),
config,
durable,
playback_clock(),
));
}
let engine = session
.engine
.as_mut()
.expect("initialized playback session");
let previous = engine.clone();
let result = f(engine);
if previous.durable() != engine.durable() {
if let Err(error) = sqlx::query("UPDATE furumusic__fed_device_identity SET playback_coordination_json = $2 WHERE user_id = $1")
.bind(user_id).bind(serde_json::to_value(&Checkpoint { scope: identity.group_id.clone(), state: engine.durable().clone() })?).execute(pool).await {
*engine = previous; return Err(error.into());
}
}
Ok(result)
}
fn playback_clock() -> u64 {
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
START
.get_or_init(std::time::Instant::now)
.elapsed()
.as_millis() as u64
}
async fn playback_state_from_browser_json( async fn playback_state_from_browser_json(
pool: &sqlx::PgPool, pool: &sqlx::PgPool,
state: serde_json::Value, state: serde_json::Value,
@@ -4787,3 +5043,6 @@ fn base64url_decode(value: &str) -> Result<Vec<u8>> {
} }
Ok(out) Ok(out)
} }
#[cfg(test)]
mod interop_tests;
+137
View File
@@ -0,0 +1,137 @@
//! Cross-binary protocol contract; the peer is the TUI's production adapter.
use super::*;
use tokio::io::AsyncWriteExt;
#[tokio::test]
#[ignore = "run furumi_tui/scripts/test_device_interop.py"]
async fn localhost_tui_peer() {
tokio::time::timeout(Duration::from_secs(30), async {
let dir =
std::path::PathBuf::from(std::env::var("FURUMI_INTEROP_DIR").expect("interop runner"));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
std::fs::write(
dir.join("web-address"),
listener.local_addr().unwrap().to_string(),
)
.unwrap();
let (mut stream, _) = listener.accept().await.unwrap();
let hub = PlayerDeviceHub::default();
let web_id = "web-interop";
let mut engine = Engine::new(
web_id.into(),
PlaybackConfig::passive(),
Default::default(),
0,
);
for phase in 0..3 {
let hello: WireMessage =
serde_json::from_slice(&read_line(&mut stream).await.unwrap()).unwrap();
let WireMessage::Hello {
profile,
playback: Some(snapshot),
..
} = hello
else {
panic!("expected TUI hello")
};
assert_eq!(profile.protocol_version, PROTOCOL_VERSION);
assert_eq!(snapshot.device_id, profile.device_id);
let announcement = snapshot
.coordination
.as_ref()
.expect("versioned playback envelope");
assert!(engine.observe(&profile.device_id, announcement, phase * 1000));
if phase == 0 {
assert_eq!(engine.owner(), Some(profile.device_id.as_str()));
assert!(
!engine.tick(10_000),
"passive web server must not seize playback"
);
assert_eq!(snapshot.state.position_secs, 42.5);
assert_eq!(snapshot.state.volume, 73);
assert!(snapshot.state.shuffle);
// Exercise the production browser hub projection without a
// PostgreSQL library or an audio device.
hub.apply_fed_playback_state_json(
1,
&profile.device_id,
&profile.name,
true,
serde_json::json!({"tracks": [], "index": 0, "track": null,
"position_seconds": 42.5, "duration_seconds": 100.0,
"paused": false, "shuffle": true, "repeat_mode": "all",
"volume": 0.73, "updated_at_ms": now_ms()}),
)
.unwrap();
assert_eq!(
hub.active_device_id_for_commands(1),
Some(format!("fed:{}", profile.device_id))
);
} else {
assert_eq!(
engine.owner(),
Some(if phase == 1 {
web_id
} else {
profile.device_id.as_str()
})
);
}
let mut state = snapshot.state;
let command = if phase < 2 {
let owner = if phase == 0 {
web_id
} else {
profile.device_id.as_str()
};
assert!(engine.transfer(owner, (phase + 1) * 1000));
PlaybackCommand::ActiveChanged {
active_device_id: owner.into(),
active_device_name: "interop".into(),
state: state.clone(),
}
} else {
state.paused = true;
state.position_secs = 87.0;
PlaybackCommand::SetState {
state: state.clone(),
seek: true,
}
};
engine.set_output(true, engine.is_owner());
engine.heartbeat((phase + 1) * 1000);
let response = WireMessage::SyncResponse {
accepted: true,
error: None,
devices: vec![],
vector: BTreeMap::new(),
snapshot: SyncSnapshot::default(),
playback: Some(PlaybackSnapshot {
device_id: web_id.into(),
device_name: "WEB".into(),
active: engine.is_owner(),
updated_at_ms: now_ms(),
state,
coordination: Some(engine.announcement()),
}),
ops: vec![SyncOpWire {
op_id: format!("{web_id}:{}", phase + 1),
origin_device_id: web_id.into(),
seq: (phase + 1) as i64,
hlc_ms: now_ms(),
payload: SyncOpPayload::PlaybackCommand {
target_device_id: profile.device_id,
command,
authority: engine.stamp(),
},
}],
};
let mut bytes = serde_json::to_vec(&response).unwrap();
bytes.push(b'\n');
stream.write_all(&bytes).await.unwrap();
}
assert_eq!(read_line(&mut stream).await.unwrap(), b"ok");
})
.await
.expect("TUI/web exchange timed out");
}
+96 -3
View File
@@ -24,8 +24,9 @@ mod storage;
use std::collections::{HashMap, HashSet, VecDeque}; use std::collections::{HashMap, HashSet, VecDeque};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, OnceLock};
use std::time::Duration; use std::time::{Duration, Instant};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use music_dht::capabilities::CAPABILITIES_ALPN; use music_dht::capabilities::CAPABILITIES_ALPN;
@@ -47,6 +48,8 @@ pub use similarity::SIMILARITY_ALPN;
/// How often the published library is re-synchronized with the database. /// How often the published library is re-synchronized with the database.
const SYNC_INTERVAL: Duration = Duration::from_secs(60); const SYNC_INTERVAL: Duration = Duration::from_secs(60);
const SUPERVISOR_INTERVAL: Duration = Duration::from_secs(15);
const RECOVERY_COOLDOWN: Duration = Duration::from_secs(5 * 60);
const TRANSPORT_SAMPLE_LIMIT: usize = 16; const TRANSPORT_SAMPLE_LIMIT: usize = 16;
struct Running { struct Running {
@@ -273,6 +276,7 @@ pub struct Federation {
data_dir: PathBuf, data_dir: PathBuf,
database_url: std::sync::Mutex<String>, database_url: std::sync::Mutex<String>,
storage_dir: std::sync::Mutex<String>, storage_dir: std::sync::Mutex<String>,
desired_network: std::sync::Mutex<Option<String>>,
save_on_listen: std::sync::atomic::AtomicBool, save_on_listen: std::sync::atomic::AtomicBool,
content_cache: std::sync::Mutex<HashMap<i64, (String, String)>>, content_cache: std::sync::Mutex<HashMap<i64, (String, String)>>,
content_pending: std::sync::Mutex<HashSet<i64>>, content_pending: std::sync::Mutex<HashSet<i64>>,
@@ -281,6 +285,8 @@ pub struct Federation {
download_locks: std::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>, download_locks: std::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
pool: tokio::sync::OnceCell<PgPool>, pool: tokio::sync::OnceCell<PgPool>,
running: tokio::sync::Mutex<Option<Running>>, running: tokio::sync::Mutex<Option<Running>>,
supervisor_started: AtomicBool,
recovery_count: AtomicU64,
last_sync: std::sync::Mutex<Option<String>>, last_sync: std::sync::Mutex<Option<String>>,
last_error: std::sync::Mutex<Option<String>>, last_error: std::sync::Mutex<Option<String>>,
transport_stats: Arc<TransportStats>, transport_stats: Arc<TransportStats>,
@@ -304,6 +310,7 @@ pub fn handle() -> Arc<Federation> {
data_dir: PathBuf::from(crate::media_paths::resolve_config_path("federation")), data_dir: PathBuf::from(crate::media_paths::resolve_config_path("federation")),
database_url: std::sync::Mutex::new(String::new()), database_url: std::sync::Mutex::new(String::new()),
storage_dir: std::sync::Mutex::new(String::new()), storage_dir: std::sync::Mutex::new(String::new()),
desired_network: std::sync::Mutex::new(None),
save_on_listen: std::sync::atomic::AtomicBool::new(false), save_on_listen: std::sync::atomic::AtomicBool::new(false),
content_cache: std::sync::Mutex::new(Default::default()), content_cache: std::sync::Mutex::new(Default::default()),
content_pending: std::sync::Mutex::new(Default::default()), content_pending: std::sync::Mutex::new(Default::default()),
@@ -312,6 +319,8 @@ pub fn handle() -> Arc<Federation> {
download_locks: std::sync::Mutex::new(Default::default()), download_locks: std::sync::Mutex::new(Default::default()),
pool: tokio::sync::OnceCell::new(), pool: tokio::sync::OnceCell::new(),
running: tokio::sync::Mutex::new(None), running: tokio::sync::Mutex::new(None),
supervisor_started: AtomicBool::new(false),
recovery_count: AtomicU64::new(0),
last_sync: std::sync::Mutex::new(None), last_sync: std::sync::Mutex::new(None),
last_error: std::sync::Mutex::new(None), last_error: std::sync::Mutex::new(None),
transport_stats: Arc::new(TransportStats::default()), transport_stats: Arc::new(TransportStats::default()),
@@ -324,6 +333,16 @@ impl Federation {
*lock(&self.last_error) = message; *lock(&self.last_error) = message;
} }
fn start_supervisor(self: &Arc<Self>) {
if self.supervisor_started.swap(true, Ordering::SeqCst) {
return;
}
let federation = Arc::clone(self);
tokio::spawn(async move {
federation.supervisor_loop().await;
});
}
async fn pool(&self) -> Result<PgPool> { async fn pool(&self) -> Result<PgPool> {
let url = lock(&self.database_url).clone(); let url = lock(&self.database_url).clone();
anyhow::ensure!(!url.is_empty(), "database is not configured"); anyhow::ensure!(!url.is_empty(), "database is not configured");
@@ -343,6 +362,7 @@ impl Federation {
/// settings live in the config KV table, so this waits for the database /// settings live in the config KV table, so this waits for the database
/// and resolves the same default → DB → env precedence the config uses. /// and resolves the same default → DB → env precedence the config uses.
pub async fn boot(self: &Arc<Self>, config: &AppConfig) { pub async fn boot(self: &Arc<Self>, config: &AppConfig) {
self.start_supervisor();
*lock(&self.database_url) = config.database_url.clone(); *lock(&self.database_url) = config.database_url.clone();
if config.database_url.is_empty() { if config.database_url.is_empty() {
return; return;
@@ -404,11 +424,13 @@ impl Federation {
); );
let network = config.federation_network_id.trim().to_string(); let network = config.federation_network_id.trim().to_string();
if config.federation_enabled && !network.is_empty() { if config.federation_enabled && !network.is_empty() {
*lock(&self.desired_network) = Some(network.clone());
if let Err(err) = self.start(network, config.agent_storage_dir.clone()).await { if let Err(err) = self.start(network, config.agent_storage_dir.clone()).await {
tracing::error!("federation start failed: {err:#}"); tracing::error!("federation start failed: {err:#}");
self.set_error(Some(format!("start failed: {err}"))); self.set_error(Some(format!("start failed: {err}")));
} }
} else { } else {
*lock(&self.desired_network) = None;
self.stop().await; self.stop().await;
} }
} }
@@ -416,13 +438,38 @@ impl Federation {
/// Starts the DHT node. Idempotent per network name; a node on another /// Starts the DHT node. Idempotent per network name; a node on another
/// network is stopped and re-joined. /// network is stopped and re-joined.
async fn start(self: &Arc<Self>, network_name: String, storage_dir: String) -> Result<()> { async fn start(self: &Arc<Self>, network_name: String, storage_dir: String) -> Result<()> {
self.start_mode(network_name, storage_dir, false)
.await
.map(|_| ())
}
async fn start_mode(
self: &Arc<Self>,
network_name: String,
storage_dir: String,
recovery_only: bool,
) -> Result<bool> {
let pool = self.pool().await?; let pool = self.pool().await?;
let mut guard = self.running.lock().await; let mut guard = self.running.lock().await;
if recovery_only && lock(&self.desired_network).as_deref() != Some(network_name.as_str()) {
return Ok(false);
}
if let Some(running) = guard.as_ref() { if let Some(running) = guard.as_ref() {
if running.network_name == network_name { if running.network_name == network_name {
return Ok(()); if !recovery_only || !running.service.network_health().restart_recommended {
return Ok(false);
}
tracing::warn!(
network = %network_name,
health = %running.service.network_health().state,
"restarting degraded federation service"
);
} else if recovery_only {
return Ok(false);
} }
stop_running(guard.take()).await; stop_running(guard.take()).await;
} else if recovery_only {
tracing::warn!(network = %network_name, "retrying stopped federation service");
} }
let dht_storage = Arc::new(PostgresFederationStorage::new(pool.clone()).await?); let dht_storage = Arc::new(PostgresFederationStorage::new(pool.clone()).await?);
@@ -565,7 +612,40 @@ impl Federation {
drop(guard); drop(guard);
// Publish right away instead of waiting for the first timer tick. // Publish right away instead of waiting for the first timer tick.
self.spawn_sync_soon().await; self.spawn_sync_soon().await;
Ok(()) Ok(true)
}
async fn supervisor_loop(self: Arc<Self>) {
let mut interval = tokio::time::interval(SUPERVISOR_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
interval.tick().await;
let mut last_attempt = None;
loop {
interval.tick().await;
if last_attempt.is_some_and(|attempt: Instant| attempt.elapsed() < RECOVERY_COOLDOWN) {
continue;
}
match self.recover_if_needed().await {
Ok(false) => {}
Ok(true) => {
last_attempt = Some(Instant::now());
self.recovery_count.fetch_add(1, Ordering::Relaxed);
}
Err(err) => {
last_attempt = Some(Instant::now());
tracing::error!(error = %err, "federation recovery failed");
self.set_error(Some(format!("network recovery failed: {err}")));
}
}
}
}
async fn recover_if_needed(self: &Arc<Self>) -> Result<bool> {
let Some(network_name) = lock(&self.desired_network).clone() else {
return Ok(false);
};
let storage_dir = lock(&self.storage_dir).clone();
self.start_mode(network_name, storage_dir, true).await
} }
async fn stop(&self) { async fn stop(&self) {
@@ -883,6 +963,7 @@ impl Federation {
let node = match guard.as_ref() { let node = match guard.as_ref() {
Some(running) => { Some(running) => {
let service = &running.service; let service = &running.service;
let health = service.network_health();
let published = service let published = service
.list_local_items() .list_local_items()
.await .await
@@ -903,6 +984,13 @@ impl Federation {
"endpoint_id": service.endpoint_id().to_string(), "endpoint_id": service.endpoint_id().to_string(),
"connected_peers": peers, "connected_peers": peers,
"known_contacts": service.known_peers().len(), "known_contacts": service.known_peers().len(),
"network_health": health.state.to_string(),
"rendezvous_failures": health.consecutive_rendezvous_failures,
"peer_dial_failures": health.consecutive_peer_dial_failures,
"rendezvous_restarts": health.rendezvous_restarts,
"last_rendezvous_success_seconds": health.last_rendezvous_success_ago.map(|age| age.as_secs()),
"last_rendezvous_error": health.last_rendezvous_error,
"recovery_count": self.recovery_count.load(Ordering::Relaxed),
"similarity_routing_peers": running.similarity_dht.known_peers(), "similarity_routing_peers": running.similarity_dht.known_peers(),
"published_items": published, "published_items": published,
"transport": transport, "transport": transport,
@@ -1035,6 +1123,11 @@ impl Federation {
.await .await
} }
pub async fn fed_device_web_refresh(&self, user_id: i64) -> Result<()> {
let pool = self.pool().await?;
devices::refresh_web_output(&pool, crate::player::PlayerDeviceHub::shared(), user_id).await
}
pub async fn fed_device_web_command( pub async fn fed_device_web_command(
&self, &self,
user_id: i64, user_id: i64,
+8
View File
@@ -406,6 +406,13 @@ translations! {
player_youtube_select_all: "Select all" , "Отметить все"; player_youtube_select_all: "Select all" , "Отметить все";
player_youtube_clear_selection: "Clear selection" , "Снять все"; player_youtube_clear_selection: "Clear selection" , "Снять все";
player_youtube_selected_count: "selected" , "выбрано"; player_youtube_selected_count: "selected" , "выбрано";
player_youtube_destination: "Add imported tracks to playlist" , "Добавить импортированные треки в плейлист";
player_youtube_no_destination: "Do not add to a playlist" , "Не добавлять в плейлист";
player_youtube_create_playlist: "Create a new playlist" , "Создать новый плейлист";
player_youtube_new_playlist_name: "New playlist name" , "Название нового плейлиста";
player_youtube_destination_hint: "Every track created from the selected videos, including chapters and previously imported videos, will be added automatically." , "Все треки из выбранных видео, включая главы и уже импортированные видео, будут добавлены автоматически.";
player_youtube_playlist_create_failed: "Could not create playlist" , "Не удалось создать плейлист";
player_youtube_added_to: "added to" , "добавление в";
player_youtube_start_import: "Start import" , "Начать импорт"; player_youtube_start_import: "Start import" , "Начать импорт";
player_start_download: "Start download" , "Начать загрузку"; player_start_download: "Start download" , "Начать загрузку";
player_retry_failed: "Retry failed" , "Повторить ошибки"; player_retry_failed: "Retry failed" , "Повторить ошибки";
@@ -533,6 +540,7 @@ translations! {
player_download_selected: "Download selected" , "Скачать выбранное"; player_download_selected: "Download selected" , "Скачать выбранное";
player_pause_download: "Pause download" , "Поставить на паузу"; player_pause_download: "Pause download" , "Поставить на паузу";
player_expand_all: "Expand all" , "Развернуть всё"; player_expand_all: "Expand all" , "Развернуть всё";
player_expand: "Expand" , "Развернуть";
player_collapse: "Collapse" , "Свернуть"; player_collapse: "Collapse" , "Свернуть";
player_selected: "selected" , "выбрано"; player_selected: "selected" , "выбрано";
player_preview: "Preview" , "Предпросмотр"; player_preview: "Preview" , "Предпросмотр";
+92 -16
View File
@@ -988,23 +988,59 @@ pub async fn finalize_approved(
})? })?
}; };
let media_file = MediaFile::create( let reusable_media_id: Option<i64> = sqlx::query_scalar(
db, r#"SELECT media.id
"audio", FROM furumusic__media_file media
&storage_path, WHERE media.file_type = 'audio'
original_filename, AND media.file_path = $1
mime_type, AND media.sha256_hash = $2
file_size, AND NOT EXISTS (
sha256, SELECT 1 FROM furumusic__track track
Some(ext), WHERE track.audio_file_id = media.id OR track.cover_file_id = media.id
audio_bitrate, )
audio_sample_rate, AND NOT EXISTS (
audio_bit_depth, SELECT 1 FROM furumusic__release release
uploaded_by_user_id, WHERE release.cover_file_id = media.id
Some(uploader_name), )
AND NOT EXISTS (
SELECT 1 FROM furumusic__artist artist
WHERE artist.image_file_id = media.id
)
AND NOT EXISTS (
SELECT 1 FROM furumusic__playlist playlist
WHERE playlist.cover_file_id = media.id
)
ORDER BY media.id
LIMIT 1"#,
) )
.await .bind(&storage_path)
.map_err(|e| anyhow::anyhow!("failed to create media file: {e}"))?; .bind(sha256)
.fetch_optional(pool)
.await?;
let media_file = if let Some(media_file_id) = reusable_media_id {
MediaFile::get_by_id(db, media_file_id)
.await
.map_err(|error| anyhow::anyhow!("failed to load reusable media file: {error}"))?
.ok_or_else(|| anyhow::anyhow!("reusable media file disappeared"))?
} else {
MediaFile::create(
db,
"audio",
&storage_path,
original_filename,
mime_type,
file_size,
sha256,
Some(ext),
audio_bitrate,
audio_sample_rate,
audio_bit_depth,
uploaded_by_user_id,
Some(uploader_name),
)
.await
.map_err(|e| anyhow::anyhow!("failed to create media file: {e}"))?
};
let track = Track::create( let track = Track::create(
db, db,
@@ -1020,6 +1056,35 @@ pub async fn finalize_approved(
.await .await
.map_err(|e| anyhow::anyhow!("failed to create track: {e}"))?; .map_err(|e| anyhow::anyhow!("failed to create track: {e}"))?;
if let Err(error) = sqlx::query(
r#"INSERT INTO furumusic__youtube_import_media (item_id, media_file_id)
SELECT DISTINCT item.id, $1
FROM furumusic__youtube_download_item item
JOIN furumusic__pending_review review
ON item.inbox_path IS NOT NULL
AND (review.input_path = item.inbox_path
OR left(review.input_path, length(item.inbox_path) + 1)
= item.inbox_path || '/')
WHERE review.context_json IS NOT NULL
AND substring(
review.context_json
from '"sha256"[[:space:]]*:[[:space:]]*"([0-9a-fA-F]{64})"'
) = $2
ON CONFLICT (item_id, media_file_id) DO NOTHING"#,
)
.bind(media_file.id_val())
.bind(sha256)
.execute(pool)
.await
{
tracing::warn!(
track_id = track.id_val(),
media_file_id = media_file.id_val(),
error = %error,
"failed to link imported media to its YouTube download item"
);
}
TrackArtist::create(db, track.id_val(), artist.id_val(), "main", 0) TrackArtist::create(db, track.id_val(), artist.id_val(), "main", 0)
.await .await
.map_err(|e| anyhow::anyhow!("failed to link track-artist: {e}"))?; .map_err(|e| anyhow::anyhow!("failed to link track-artist: {e}"))?;
@@ -1131,6 +1196,17 @@ pub async fn finalize_approved(
} }
} }
if let Err(error) =
crate::youtube::sync_target_playlists_for_imported_media(pool, media_file.id_val()).await
{
tracing::warn!(
track_id = track.id_val(),
media_file_id = media_file.id_val(),
%error,
"could not add an imported YouTube track to its target playlist; it will be retried"
);
}
tracing::info!( tracing::info!(
track_id = track.id_val(), track_id = track.id_val(),
artist = artist_name, artist = artist_name,
+813
View File
@@ -0,0 +1,813 @@
use std::collections::HashSet;
use std::io::ErrorKind;
use std::path::PathBuf;
use anyhow::{Context, bail};
use sqlx::{FromRow, PgPool, Postgres, Transaction};
use uuid::Uuid;
#[derive(Debug, FromRow)]
struct MediaFileRow {
id: i64,
file_type: String,
file_path: String,
sha256_hash: String,
}
#[derive(Debug, FromRow)]
struct PlaybackStateRow {
id: i64,
current_track_id: Option<i64>,
position_ms: i32,
queue_json: String,
queue_position: i32,
}
#[derive(Debug)]
struct QuarantinedFile {
original: PathBuf,
quarantined: PathBuf,
}
#[derive(Debug, Default)]
struct Quarantine {
root: Option<PathBuf>,
files: Vec<QuarantinedFile>,
}
pub async fn delete_tracks(
pool: &PgPool,
requested_track_ids: &[i64],
storage_dir: &str,
) -> anyhow::Result<u64> {
delete_scope(pool, requested_track_ids, &[], storage_dir).await
}
pub async fn delete_releases(
pool: &PgPool,
requested_release_ids: &[i64],
storage_dir: &str,
) -> anyhow::Result<u64> {
let mut transaction = pool.begin().await?;
let release_ids: Vec<i64> = sqlx::query_scalar(
"SELECT id FROM furumusic__release WHERE id = ANY($1) ORDER BY id FOR UPDATE",
)
.bind(requested_release_ids)
.fetch_all(&mut *transaction)
.await?;
if release_ids.is_empty() {
transaction.rollback().await?;
return Ok(0);
}
let track_ids: Vec<i64> = sqlx::query_scalar(
"SELECT id FROM furumusic__track WHERE release_id = ANY($1) ORDER BY id FOR UPDATE",
)
.bind(&release_ids)
.fetch_all(&mut *transaction)
.await?;
delete_locked_scope(transaction, track_ids, release_ids, storage_dir, true).await
}
#[derive(Debug)]
pub struct ReleaseMergeTrack {
pub id: i64,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
}
#[derive(Debug)]
pub struct ReleaseMergeSpec {
pub release_ids: Vec<i64>,
pub target_release_id: i64,
pub title: String,
pub title_sort: String,
pub release_type: String,
pub year: Option<i32>,
pub hidden: bool,
pub cover_file_id: Option<i64>,
pub artist_ids: Vec<i64>,
pub tracks: Vec<ReleaseMergeTrack>,
}
#[derive(Debug)]
pub struct ReleaseMergeResult {
pub merged_releases: u64,
pub moved_tracks: u64,
}
/// Merge several releases into one while preserving their tracks and media.
///
/// Source cover files are quarantined before the database transaction commits,
/// just like normal library deletion. The cover selected for the destination
/// and any media still referenced elsewhere are retained.
pub async fn merge_releases(
pool: &PgPool,
mut spec: ReleaseMergeSpec,
storage_dir: &str,
) -> anyhow::Result<ReleaseMergeResult> {
spec.release_ids.retain(|id| *id > 0);
spec.release_ids.sort_unstable();
spec.release_ids.dedup();
if spec.release_ids.len() < 2 {
bail!("select at least two releases to merge");
}
if !spec.release_ids.contains(&spec.target_release_id) {
bail!("destination release must be part of the selection");
}
let mut transaction = pool.begin().await?;
let locked_release_ids: Vec<i64> = sqlx::query_scalar(
"SELECT id FROM furumusic__release WHERE id = ANY($1) ORDER BY id FOR UPDATE",
)
.bind(&spec.release_ids)
.fetch_all(&mut *transaction)
.await?;
if locked_release_ids != spec.release_ids {
bail!("one or more selected releases no longer exist; reopen the merge wizard");
}
let original_target_cover: Option<i64> =
sqlx::query_scalar("SELECT cover_file_id FROM furumusic__release WHERE id = $1")
.bind(spec.target_release_id)
.fetch_one(&mut *transaction)
.await?;
let source_release_ids = spec
.release_ids
.iter()
.copied()
.filter(|id| *id != spec.target_release_id)
.collect::<Vec<_>>();
let locked_track_ids: Vec<i64> = sqlx::query_scalar(
"SELECT id FROM furumusic__track WHERE release_id = ANY($1) ORDER BY id FOR UPDATE",
)
.bind(&spec.release_ids)
.fetch_all(&mut *transaction)
.await?;
let mut requested_track_ids = spec.tracks.iter().map(|track| track.id).collect::<Vec<_>>();
requested_track_ids.sort_unstable();
if requested_track_ids.windows(2).any(|ids| ids[0] == ids[1]) {
bail!("the merge track list contains duplicates");
}
if requested_track_ids != locked_track_ids {
bail!("the selected releases changed; reopen the merge wizard before merging");
}
if let Some(cover_file_id) = spec.cover_file_id {
let valid_cover: Option<i64> = sqlx::query_scalar(
r#"SELECT r.cover_file_id
FROM furumusic__release r
JOIN furumusic__media_file mf ON mf.id = r.cover_file_id
WHERE r.id = ANY($1)
AND r.cover_file_id = $2
AND mf.file_type = 'cover_art'
LIMIT 1"#,
)
.bind(&spec.release_ids)
.bind(cover_file_id)
.fetch_optional(&mut *transaction)
.await?;
if valid_cover.is_none() {
bail!("selected cover does not belong to one of the merged releases");
}
}
let mut seen_artist_ids = HashSet::new();
spec.artist_ids
.retain(|id| *id > 0 && seen_artist_ids.insert(*id));
if !spec.artist_ids.is_empty() {
let existing_artist_ids: Vec<i64> =
sqlx::query_scalar("SELECT id FROM furumusic__artist WHERE id = ANY($1) ORDER BY id")
.bind(&spec.artist_ids)
.fetch_all(&mut *transaction)
.await?;
let mut requested_artist_ids = spec.artist_ids.clone();
requested_artist_ids.sort_unstable();
if existing_artist_ids != requested_artist_ids {
bail!("one or more selected artists no longer exist");
}
}
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let total_discs = spec
.tracks
.iter()
.filter_map(|track| track.disc_number)
.max();
sqlx::query(
r#"UPDATE furumusic__release
SET title = $2, title_sort = $3, release_type = $4, year = $5,
cover_file_id = $6, total_tracks = $7, total_discs = $8,
is_hidden = $9, model_name = NULL, updated_at = $10
WHERE id = $1"#,
)
.bind(spec.target_release_id)
.bind(&spec.title)
.bind(&spec.title_sort)
.bind(&spec.release_type)
.bind(spec.year)
.bind(spec.cover_file_id)
.bind(i32::try_from(spec.tracks.len()).unwrap_or(i32::MAX))
.bind(total_discs)
.bind(spec.hidden)
.bind(&now)
.execute(&mut *transaction)
.await?;
sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = $1")
.bind(spec.target_release_id)
.execute(&mut *transaction)
.await?;
for (position, artist_id) in spec.artist_ids.iter().enumerate() {
sqlx::query(
"INSERT INTO furumusic__release_artist (release_id, artist_id, position) VALUES ($1, $2, $3)",
)
.bind(spec.target_release_id)
.bind(*artist_id)
.bind(i32::try_from(position).unwrap_or(i32::MAX))
.execute(&mut *transaction)
.await?;
}
for track in &spec.tracks {
sqlx::query(
r#"UPDATE furumusic__track
SET release_id = $1, track_number = $2, disc_number = $3,
updated_at = $4
WHERE id = $5"#,
)
.bind(spec.target_release_id)
.bind(track.track_number)
.bind(track.disc_number)
.bind(&now)
.bind(track.id)
.execute(&mut *transaction)
.await?;
}
sqlx::query(
r#"INSERT INTO furumusic__entity_genre_tag
(entity_kind, entity_id, genre_id, source, weight, updated_at)
SELECT 'release', $1, genre_id, source, weight, $3
FROM furumusic__entity_genre_tag
WHERE entity_kind = 'release' AND entity_id = ANY($2)
ON CONFLICT (entity_kind, entity_id, genre_id, source) DO UPDATE
SET weight = GREATEST(furumusic__entity_genre_tag.weight, EXCLUDED.weight),
updated_at = EXCLUDED.updated_at"#,
)
.bind(spec.target_release_id)
.bind(&spec.release_ids)
.bind(&now)
.execute(&mut *transaction)
.await?;
let extra_media_ids = original_target_cover.into_iter().collect::<Vec<_>>();
let media_files = deletable_media_files_with_extra(
&mut transaction,
&[],
&source_release_ids,
&extra_media_ids,
)
.await?;
let quarantine = match quarantine_media_files(storage_dir, &media_files).await {
Ok(quarantine) => quarantine,
Err(error) => {
transaction.rollback().await?;
return Err(error);
}
};
let deletion = delete_database_rows(
&mut transaction,
&[],
&source_release_ids,
&media_files,
true,
)
.await;
if let Err(error) = deletion {
transaction.rollback().await?;
restore_quarantine(&quarantine).await;
return Err(error);
}
if let Err(error) = transaction.commit().await {
restore_quarantine(&quarantine).await;
return Err(error.into());
}
purge_quarantine(&quarantine).await;
remove_empty_storage_parents(storage_dir, &quarantine.files).await;
Ok(ReleaseMergeResult {
merged_releases: u64::try_from(spec.release_ids.len()).unwrap_or(u64::MAX),
moved_tracks: u64::try_from(spec.tracks.len()).unwrap_or(u64::MAX),
})
}
async fn delete_scope(
pool: &PgPool,
requested_track_ids: &[i64],
release_ids: &[i64],
storage_dir: &str,
) -> anyhow::Result<u64> {
let mut transaction = pool.begin().await?;
let track_ids: Vec<i64> = sqlx::query_scalar(
"SELECT id FROM furumusic__track WHERE id = ANY($1) ORDER BY id FOR UPDATE",
)
.bind(requested_track_ids)
.fetch_all(&mut *transaction)
.await?;
if track_ids.is_empty() {
transaction.rollback().await?;
return Ok(0);
}
delete_locked_scope(
transaction,
track_ids,
release_ids.to_vec(),
storage_dir,
false,
)
.await
}
async fn delete_locked_scope(
mut transaction: Transaction<'_, Postgres>,
track_ids: Vec<i64>,
release_ids: Vec<i64>,
storage_dir: &str,
delete_release_rows: bool,
) -> anyhow::Result<u64> {
let media_files = deletable_media_files(&mut transaction, &track_ids, &release_ids).await?;
let quarantine = match quarantine_media_files(storage_dir, &media_files).await {
Ok(quarantine) => quarantine,
Err(error) => {
transaction.rollback().await?;
return Err(error);
}
};
let deletion = delete_database_rows(
&mut transaction,
&track_ids,
&release_ids,
&media_files,
delete_release_rows,
)
.await;
let affected = match deletion {
Ok(affected) => affected,
Err(error) => {
transaction.rollback().await?;
restore_quarantine(&quarantine).await;
return Err(error);
}
};
if let Err(error) = transaction.commit().await {
restore_quarantine(&quarantine).await;
return Err(error.into());
}
purge_quarantine(&quarantine).await;
remove_empty_storage_parents(storage_dir, &quarantine.files).await;
Ok(affected)
}
async fn deletable_media_files(
transaction: &mut Transaction<'_, Postgres>,
track_ids: &[i64],
release_ids: &[i64],
) -> anyhow::Result<Vec<MediaFileRow>> {
deletable_media_files_with_extra(transaction, track_ids, release_ids, &[]).await
}
async fn deletable_media_files_with_extra(
transaction: &mut Transaction<'_, Postgres>,
track_ids: &[i64],
release_ids: &[i64],
extra_media_ids: &[i64],
) -> anyhow::Result<Vec<MediaFileRow>> {
Ok(sqlx::query_as(
r#"WITH seed_media(id) AS (
SELECT audio_file_id FROM furumusic__track WHERE id = ANY($1)
UNION
SELECT cover_file_id FROM furumusic__track
WHERE id = ANY($1) AND cover_file_id IS NOT NULL
UNION
SELECT cover_file_id FROM furumusic__release
WHERE id = ANY($2) AND cover_file_id IS NOT NULL
UNION
SELECT UNNEST($3::bigint[])
), candidate_media(id) AS (
SELECT id FROM seed_media
UNION
SELECT duplicate.id
FROM furumusic__media_file duplicate
JOIN furumusic__media_file seed
ON duplicate.file_path = seed.file_path
AND duplicate.sha256_hash = seed.sha256_hash
JOIN seed_media ON seed_media.id = seed.id
)
SELECT mf.id, mf.file_type::text AS file_type, mf.file_path,
mf.sha256_hash::text AS sha256_hash
FROM furumusic__media_file mf
JOIN candidate_media candidate ON candidate.id = mf.id
WHERE NOT EXISTS (
SELECT 1
FROM furumusic__track track
JOIN furumusic__media_file linked
ON linked.id = track.audio_file_id
OR linked.id = track.cover_file_id
WHERE linked.file_path = mf.file_path
AND linked.sha256_hash = mf.sha256_hash
AND NOT (track.id = ANY($1))
)
AND NOT EXISTS (
SELECT 1
FROM furumusic__release release
JOIN furumusic__media_file linked
ON linked.id = release.cover_file_id
WHERE linked.file_path = mf.file_path
AND linked.sha256_hash = mf.sha256_hash
AND NOT (release.id = ANY($2))
)
AND NOT EXISTS (
SELECT 1
FROM furumusic__artist artist
JOIN furumusic__media_file linked
ON linked.id = artist.image_file_id
WHERE linked.file_path = mf.file_path
AND linked.sha256_hash = mf.sha256_hash
)
AND NOT EXISTS (
SELECT 1
FROM furumusic__playlist playlist
JOIN furumusic__media_file linked
ON linked.id = playlist.cover_file_id
WHERE linked.file_path = mf.file_path
AND linked.sha256_hash = mf.sha256_hash
)
ORDER BY mf.id
FOR UPDATE OF mf"#,
)
.bind(track_ids)
.bind(release_ids)
.bind(extra_media_ids)
.fetch_all(&mut **transaction)
.await?)
}
async fn quarantine_media_files(
storage_dir: &str,
media_files: &[MediaFileRow],
) -> anyhow::Result<Quarantine> {
if media_files.is_empty() {
return Ok(Quarantine::default());
}
if storage_dir.trim().is_empty() {
bail!("agent_storage_dir is not configured; refusing to leave deleted tracks on disk");
}
let storage_root = crate::media_paths::resolve_config_path_buf(storage_dir);
if storage_root.parent().is_none() {
bail!("agent_storage_dir must not be a filesystem root");
}
let quarantine_root = storage_root
.join(".furumusic-trash")
.join(Uuid::new_v4().to_string());
let mut quarantine = Quarantine {
root: Some(quarantine_root.clone()),
files: Vec::new(),
};
let mut seen = HashSet::new();
let result: anyhow::Result<()> =
async {
for media in media_files {
let original = checked_storage_path(storage_dir, &media.file_path)?;
let mut paths = vec![original.clone()];
if media.file_type == "cover_art" {
paths.extend(crate::agent::cover_variants::COVER_VARIANTS.iter().map(
|variant| crate::agent::cover_variants::variant_path(&original, *variant),
));
}
for (index, path) in paths.into_iter().enumerate() {
if !seen.insert(path.clone()) {
continue;
}
match tokio::fs::symlink_metadata(&path).await {
Ok(metadata) if metadata.is_file() || metadata.file_type().is_symlink() => {
}
Ok(_) => bail!("media path is not a regular file: {}", path.display()),
Err(error) if error.kind() == ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()),
}
tokio::fs::create_dir_all(&quarantine_root).await?;
let extension = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("bin");
let quarantined = quarantine_root.join(format!(
"{}-{index}-{}.{}",
media.id,
Uuid::new_v4(),
extension
));
tokio::fs::rename(&path, &quarantined)
.await
.with_context(|| format!("failed to remove {}", path.display()))?;
quarantine.files.push(QuarantinedFile {
original: path,
quarantined,
});
}
}
Ok(())
}
.await;
match result {
Ok(()) => Ok(quarantine),
Err(error) => {
restore_quarantine(&quarantine).await;
Err(error)
}
}
}
fn checked_storage_path(storage_dir: &str, stored_path: &str) -> anyhow::Result<PathBuf> {
let resolved = crate::media_paths::resolve_media_file_path(storage_dir, stored_path);
crate::media_paths::path_for_root(storage_dir, &resolved).with_context(|| {
format!(
"refusing to delete media outside agent_storage_dir: {}",
resolved.display()
)
})?;
Ok(resolved)
}
async fn restore_quarantine(quarantine: &Quarantine) {
for file in quarantine.files.iter().rev() {
if let Some(parent) = file.original.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let _ = tokio::fs::rename(&file.quarantined, &file.original).await;
}
if let Some(root) = &quarantine.root {
let _ = tokio::fs::remove_dir_all(root).await;
}
}
async fn purge_quarantine(quarantine: &Quarantine) {
if let Some(root) = &quarantine.root
&& let Err(error) = tokio::fs::remove_dir_all(root).await
&& error.kind() != ErrorKind::NotFound
{
tracing::warn!(path = %root.display(), error = %error, "failed to purge deleted media quarantine");
}
if let Some(parent) = quarantine.root.as_deref().and_then(|root| root.parent()) {
let _ = tokio::fs::remove_dir(parent).await;
}
}
async fn remove_empty_storage_parents(storage_dir: &str, files: &[QuarantinedFile]) {
let storage_root = crate::media_paths::resolve_config_path_buf(storage_dir);
let mut seen = HashSet::new();
for file in files {
let mut current = file.original.parent();
while let Some(directory) = current {
if directory == storage_root || !directory.starts_with(&storage_root) {
break;
}
if !seen.insert(directory.to_path_buf()) {
break;
}
match tokio::fs::remove_dir(directory).await {
Ok(()) => current = directory.parent(),
Err(_) => break,
}
}
}
}
async fn delete_database_rows(
transaction: &mut Transaction<'_, Postgres>,
track_ids: &[i64],
release_ids: &[i64],
media_files: &[MediaFileRow],
delete_release_rows: bool,
) -> anyhow::Result<u64> {
cleanup_playback_states(transaction, track_ids).await?;
for table in [
"furumusic__playlist_track",
"furumusic__user_liked_track",
"furumusic__play_history",
"furumusic__track_popularity_history",
"furumusic__lastfm_scrobble_outbox",
"furumusic__track_genre",
"furumusic__track_artist",
"furumusic__track_embedding",
] {
let query = format!("DELETE FROM {table} WHERE track_id = ANY($1)");
sqlx::query(&query)
.bind(track_ids)
.execute(&mut **transaction)
.await?;
}
for table in [
"furumusic__entity_genre_tag",
"furumusic__external_metadata_id",
"furumusic__artwork_lookup_state",
] {
let query =
format!("DELETE FROM {table} WHERE entity_kind = 'track' AND entity_id = ANY($1)");
sqlx::query(&query)
.bind(track_ids)
.execute(&mut **transaction)
.await?;
}
for table in [
"furumusic__fed_state_like",
"furumusic__fed_state_playlist_item",
"furumusic__track_ref",
"furumusic__listen_event",
] {
let query =
format!("UPDATE {table} SET local_track_id = NULL WHERE local_track_id = ANY($1)");
sqlx::query(&query)
.bind(track_ids)
.execute(&mut **transaction)
.await?;
}
let tracks_deleted = sqlx::query("DELETE FROM furumusic__track WHERE id = ANY($1)")
.bind(track_ids)
.execute(&mut **transaction)
.await?
.rows_affected();
if delete_release_rows {
for table in [
"furumusic__entity_genre_tag",
"furumusic__external_metadata_id",
"furumusic__artwork_lookup_state",
] {
let query = format!(
"DELETE FROM {table} WHERE entity_kind = 'release' AND entity_id = ANY($1)"
);
sqlx::query(&query)
.bind(release_ids)
.execute(&mut **transaction)
.await?;
}
sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = ANY($1)")
.bind(release_ids)
.execute(&mut **transaction)
.await?;
sqlx::query("DELETE FROM furumusic__release WHERE id = ANY($1)")
.bind(release_ids)
.execute(&mut **transaction)
.await?;
}
let media_ids: Vec<i64> = media_files.iter().map(|media| media.id).collect();
if !media_ids.is_empty() {
let media_hashes: Vec<String> = media_files
.iter()
.map(|media| media.sha256_hash.clone())
.collect();
sqlx::query(
r#"UPDATE furumusic__youtube_download_item
SET status = 'failed', progress_percent = 0,
downloaded_bytes = 0, total_bytes = NULL,
speed_bytes_per_sec = NULL, eta_seconds = NULL,
error = 'Imported library files were deleted; this source can be imported again',
completed_at = NULL, updated_at = $2
WHERE id IN (
SELECT item_id
FROM furumusic__youtube_import_media
WHERE media_file_id = ANY($1)
)"#,
)
.bind(&media_ids)
.bind(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
.execute(&mut **transaction)
.await?;
let review_ids: Vec<i64> = sqlx::query_scalar(
r#"SELECT id FROM furumusic__pending_review
WHERE context_json IS NOT NULL
AND substring(
context_json
from '"sha256"[[:space:]]*:[[:space:]]*"([0-9a-fA-F]{64})"'
) = ANY($1)"#,
)
.bind(&media_hashes)
.fetch_all(&mut **transaction)
.await?;
if !review_ids.is_empty() {
sqlx::query(
"DELETE FROM furumusic__processing_stats WHERE pending_review_id = ANY($1)",
)
.bind(&review_ids)
.execute(&mut **transaction)
.await?;
sqlx::query("DELETE FROM furumusic__pending_review WHERE id = ANY($1)")
.bind(&review_ids)
.execute(&mut **transaction)
.await?;
}
sqlx::query(
"DELETE FROM furumusic__federation_content_id_cache WHERE media_file_id = ANY($1)",
)
.bind(&media_ids)
.execute(&mut **transaction)
.await?;
sqlx::query("DELETE FROM furumusic__media_file WHERE id = ANY($1)")
.bind(&media_ids)
.execute(&mut **transaction)
.await?;
}
Ok(if delete_release_rows {
u64::try_from(release_ids.len()).unwrap_or(u64::MAX)
} else {
tracks_deleted
})
}
async fn cleanup_playback_states(
transaction: &mut Transaction<'_, Postgres>,
track_ids: &[i64],
) -> anyhow::Result<()> {
let deleted: HashSet<i64> = track_ids.iter().copied().collect();
let states: Vec<PlaybackStateRow> = sqlx::query_as(
"SELECT id, current_track_id, position_ms, queue_json, queue_position FROM furumusic__playback_state FOR UPDATE",
)
.fetch_all(&mut **transaction)
.await?;
for state in states {
let mut queue: Vec<i64> = serde_json::from_str(&state.queue_json).unwrap_or_default();
let original_queue = queue.clone();
queue.retain(|track_id| !deleted.contains(track_id));
let current_track_id = state.current_track_id.filter(|id| !deleted.contains(id));
if queue == original_queue && current_track_id == state.current_track_id {
continue;
}
let queue_position = current_track_id
.and_then(|current| queue.iter().position(|id| *id == current))
.map(|position| i32::try_from(position).unwrap_or(i32::MAX))
.unwrap_or_else(|| {
if queue.is_empty() {
0
} else {
state
.queue_position
.clamp(0, i32::try_from(queue.len() - 1).unwrap_or(i32::MAX))
}
});
let position_ms = if current_track_id.is_some() {
state.position_ms
} else {
0
};
let queue_json = serde_json::to_string(&queue)?;
sqlx::query(
r#"UPDATE furumusic__playback_state
SET current_track_id = $2, position_ms = $3,
queue_json = $4, queue_position = $5
WHERE id = $1"#,
)
.bind(state.id)
.bind(current_track_id)
.bind(position_ms)
.bind(queue_json)
.bind(queue_position)
.execute(&mut **transaction)
.await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_media_paths_outside_storage() {
assert!(checked_storage_path("/srv/music", "/etc/passwd").is_err());
assert!(checked_storage_path("/srv/music", "../outside.flac").is_err());
assert_eq!(
checked_storage_path("/srv/music", "Artist/Album/01.flac").unwrap(),
PathBuf::from("/srv/music/Artist/Album/01.flac")
);
}
#[test]
fn deleted_track_ids_are_removed_from_saved_queue() {
let deleted = HashSet::from([2_i64, 4]);
let mut queue = vec![1_i64, 2, 3, 4, 5];
queue.retain(|track_id| !deleted.contains(track_id));
assert_eq!(queue, vec![1, 3, 5]);
let serialized: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&queue).unwrap()).unwrap();
assert_eq!(serialized, serde_json::json!([1, 3, 5]));
}
}
+12 -2
View File
@@ -7,6 +7,7 @@ mod federation;
mod i18n; mod i18n;
mod jobs; mod jobs;
mod lastfm; mod lastfm;
mod library_cleanup;
mod local_uploads; mod local_uploads;
mod media_paths; mod media_paths;
mod metrics; mod metrics;
@@ -25,7 +26,7 @@ use cot::auth::PasswordVerificationResult;
use cot::cli::CliMetadata; use cot::cli::CliMetadata;
use cot::common_types::Password; use cot::common_types::Password;
use cot::config::{ use cot::config::{
DatabaseConfig, MiddlewareConfig, ProjectConfig, SameSite, SessionMiddlewareConfig, DatabaseConfig, Expiry, MiddlewareConfig, ProjectConfig, SameSite, SessionMiddlewareConfig,
SessionStoreConfig, SessionStoreTypeConfig, SessionStoreConfig, SessionStoreTypeConfig,
}; };
use cot::db::Database; use cot::db::Database;
@@ -90,7 +91,13 @@ async fn index(
return Ok(auth::redirect("/login")); return Ok(auth::redirect("/login"));
} }
}; };
let template = player::PlayerPageTemplate { t: i18n.t }; let (config, _) = AppConfig::load_with_db(&db).await;
let template = player::PlayerPageTemplate {
t: i18n.t,
downloads_enabled: config.downloads_enabled,
torrent_downloads_enabled: config.downloads_enabled && config.torrent_downloads_enabled,
youtube_downloads_enabled: config.downloads_enabled && config.youtube_downloads_enabled,
};
Html::new(template.render()?).into_response() Html::new(template.render()?).into_response()
} }
@@ -515,6 +522,9 @@ impl Project for FuruProject {
MiddlewareConfig::builder() MiddlewareConfig::builder()
.session( .session(
SessionMiddlewareConfig::builder() SessionMiddlewareConfig::builder()
.expiry(Expiry::OnInactivity(std::time::Duration::from_secs(
365 * 24 * 60 * 60,
)))
.secure(false) .secure(false)
.same_site(SameSite::Lax) .same_site(SameSite::Lax)
.store( .store(
+3
View File
@@ -832,6 +832,8 @@ const KNOWN_HTTP_ROUTES: &[&str] = &[
"/admin/v2/api/jobs/{name}/run", "/admin/v2/api/jobs/{name}/run",
"/admin/v2/api/settings", "/admin/v2/api/settings",
"/admin/v2/api/settings/probe", "/admin/v2/api/settings/probe",
"/admin/v2/api/settings/youtube-cookies",
"/admin/v2/api/settings/youtube-cookies/{id}",
"/admin/v2/api/jobs/{name}/toggle", "/admin/v2/api/jobs/{name}/toggle",
"/admin/v2/api/jobs/{name}/runs", "/admin/v2/api/jobs/{name}/runs",
"/admin/v2/api/jobs/{name}/runs/{run_id}", "/admin/v2/api/jobs/{name}/runs/{run_id}",
@@ -841,6 +843,7 @@ const KNOWN_HTTP_ROUTES: &[&str] = &[
"/admin/v2/api/library/item/image", "/admin/v2/api/library/item/image",
"/admin/v2/api/library/item/upload-image", "/admin/v2/api/library/item/upload-image",
"/admin/v2/api/library/bulk", "/admin/v2/api/library/bulk",
"/admin/v2/api/library/releases/merge",
"/admin/debug", "/admin/debug",
"/admin/settings", "/admin/settings",
"/admin/settings/probe", "/admin/settings/probe",
+70
View File
@@ -1984,6 +1984,8 @@ pub mod db_migrations {
)", )",
) )
.await?; .await?;
ctx.db.raw("ALTER TABLE furumusic__fed_device_identity ADD COLUMN IF NOT EXISTS playback_coordination_json JSONB").await?;
ctx.db.raw("ALTER TABLE furumusic__fed_device_identity ADD COLUMN IF NOT EXISTS playback_config_json JSONB").await?;
ctx.db ctx.db
.raw( .raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_device ( "CREATE TABLE IF NOT EXISTS furumusic__fed_device (
@@ -2715,6 +2717,73 @@ pub mod db_migrations {
&[Operation::custom(create_local_upload_history).build()]; &[Operation::custom(create_local_upload_history).build()];
} }
// -- M0047: durable YouTube item -> imported media links ---------------
#[cot::db::migrations::migration_op]
async fn create_youtube_import_media_links(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__youtube_import_media (
item_id VARCHAR(36) NOT NULL
REFERENCES furumusic__youtube_download_item(id) ON DELETE CASCADE,
media_file_id BIGINT NOT NULL
REFERENCES furumusic__media_file(id) ON DELETE CASCADE,
PRIMARY KEY (item_id, media_file_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_youtube_import_media_file
ON furumusic__youtube_import_media (media_file_id)",
)
.await?;
// Backfill links for existing imports through the inbox review hash.
ctx.db
.raw(
"INSERT INTO furumusic__youtube_import_media (item_id, media_file_id)
SELECT DISTINCT item.id, media.id
FROM furumusic__youtube_download_item item
JOIN furumusic__pending_review review
ON item.inbox_path IS NOT NULL
AND (review.input_path = item.inbox_path
OR left(review.input_path, length(item.inbox_path) + 1)
= item.inbox_path || '/')
JOIN furumusic__media_file media
ON media.sha256_hash::text = substring(
review.context_json
from '\"sha256\"[[:space:]]*:[[:space:]]*\"([0-9a-fA-F]{64})\"'
)
JOIN furumusic__track track ON track.audio_file_id = media.id
WHERE review.context_json IS NOT NULL
ON CONFLICT (item_id, media_file_id) DO NOTHING",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0047CreateYouTubeImportMediaLinks;
impl migrations::Migration for M0047CreateYouTubeImportMediaLinks {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0047_create_youtube_import_media_links";
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
migrations::MigrationDependency::migration(
"furumusic",
"m_0046_create_local_upload_history",
),
migrations::MigrationDependency::migration(
"furumusic",
"m_0027_create_processing_stats",
),
];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_youtube_import_media_links).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[ pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0006CreateMediaFile, &M0006CreateMediaFile,
&M0007CreateArtist, &M0007CreateArtist,
@@ -2752,5 +2821,6 @@ pub mod db_migrations {
&M0044AddSimilarityRoutingSignature, &M0044AddSimilarityRoutingSignature,
&M0045CreateYouTubeDownloads, &M0045CreateYouTubeDownloads,
&M0046CreateLocalUploadHistory, &M0046CreateLocalUploadHistory,
&M0047CreateYouTubeImportMediaLinks,
]; ];
} }
+473 -62
View File
@@ -49,13 +49,66 @@ fn json_error(status: StatusCode, message: &str) -> cot::response::Response {
.expect("valid response") .expect("valid response")
} }
#[derive(Debug, Clone, Copy)]
enum DownloadMethod {
LocalFile,
Torrent,
YouTube,
}
fn require_download_method(
config: &AppConfig,
method: DownloadMethod,
) -> Result<(), cot::response::Response> {
let enabled = config.downloads_enabled
&& match method {
DownloadMethod::LocalFile => true,
DownloadMethod::Torrent => config.torrent_downloads_enabled,
DownloadMethod::YouTube => config.youtube_downloads_enabled,
};
if enabled {
Ok(())
} else {
Err(json_error(
StatusCode::FORBIDDEN,
match method {
DownloadMethod::LocalFile => "downloads are disabled by the administrator",
DownloadMethod::Torrent => "torrent downloads are disabled by the administrator",
DownloadMethod::YouTube => "YouTube downloads are disabled by the administrator",
},
))
}
}
fn download_proxy_for(
config: &AppConfig,
method: DownloadMethod,
) -> Result<Option<String>, cot::response::Response> {
require_download_method(config, method)?;
let result = match method {
DownloadMethod::LocalFile => Ok(None),
DownloadMethod::Torrent => config.torrent_proxy_url(),
DownloadMethod::YouTube => config.youtube_proxy_url(),
};
result.map_err(|error| json_error(StatusCode::BAD_REQUEST, &error.to_string()))
}
async fn youtube_cookie_contents(
config: &AppConfig,
db: &Database,
) -> Result<Option<String>, cot::response::Response> {
crate::youtube::selected_cookie_contents(db, &config.youtube_cookie_id)
.await
.map_err(|error| json_error(StatusCode::BAD_REQUEST, &error.to_string()))
}
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
struct LocalUploadResponse { struct LocalUploadResponse {
ok: bool, ok: bool,
upload: LocalUploadDto, upload: LocalUploadDto,
} }
const PLAYER_DEVICE_TTL_MS: i64 = 30_000; const PLAYER_DEVICE_TTL_MS: i64 = 120_000;
const PLAYER_DEVICE_RETURN_TAKEOVER_MS: i64 = 30 * 60 * 1_000; const PLAYER_DEVICE_RETURN_TAKEOVER_MS: i64 = 30 * 60 * 1_000;
const PLAYER_DEVICE_COMMAND_TTL_MS: i64 = 20_000; const PLAYER_DEVICE_COMMAND_TTL_MS: i64 = 20_000;
const PLAYER_DEVICE_MAX_COMMANDS: usize = 32; const PLAYER_DEVICE_MAX_COMMANDS: usize = 32;
@@ -71,6 +124,7 @@ struct PlayerDevice {
id: String, id: String,
name: String, name: String,
kind: String, kind: String,
report_sequence: u64,
last_seen_ms: i64, last_seen_ms: i64,
} }
@@ -112,6 +166,8 @@ struct PlayerDeviceHubState {
commands_by_device: HashMap<(i64, String), VecDeque<PendingPlayerDeviceCommand>>, commands_by_device: HashMap<(i64, String), VecDeque<PendingPlayerDeviceCommand>>,
playback_state_by_user: HashMap<i64, PlayerDevicePlaybackStateDto>, playback_state_by_user: HashMap<i64, PlayerDevicePlaybackStateDto>,
jams_by_id: HashMap<String, PlayerJamSession>, jams_by_id: HashMap<String, PlayerJamSession>,
playback_startup_by_user: HashMap<i64, std::time::Instant>,
output_report_sequence: u64,
} }
#[derive(Debug, Default)] #[derive(Debug, Default)]
@@ -134,6 +190,9 @@ impl PlayerDeviceHub {
let now = current_millis(); let now = current_millis();
let mut state = self.state.lock().expect("player device hub lock"); let mut state = self.state.lock().expect("player device hub lock");
self.prune_locked(&mut state, now); self.prune_locked(&mut state, now);
if self.user_has_joined_jam_locked(&state, user_id) {
return Ok(());
}
let devices = state let devices = state
.devices_by_user .devices_by_user
.get(&user_id) .get(&user_id)
@@ -152,17 +211,105 @@ impl PlayerDeviceHub {
.map(|device| device.id.clone()) .map(|device| device.id.clone())
}) })
.ok_or("no browser playback device")?; .ok_or("no browser playback device")?;
state.active_device_by_user.insert(user_id, target.clone()); if command == "transfer_state" {
state.active_device_by_user.insert(user_id, target.clone());
}
self.enqueue_command_locked(&mut state, user_id, &target, command, payload, now); self.enqueue_command_locked(&mut state, user_id, &target, command, payload, now);
Ok(()) Ok(())
} }
pub(crate) fn federation_playback_is_local(&self, user_id: i64) -> bool { pub(crate) fn federation_output_report(&self, user_id: i64) -> (bool, bool, u64, bool) {
let state = self.state.lock().expect("player device hub lock"); let mut state = self.state.lock().expect("player device hub lock");
!state if self.user_has_joined_jam_locked(&state, user_id) {
return (false, false, 0, false);
}
let now = current_millis();
let active = state.active_device_by_user.get(&user_id);
let active_browser = active
.filter(|id| !is_fed_virtual_device_id(id))
.and_then(|id| state.devices_by_user.get(&user_id)?.get(id))
.filter(|device| now.saturating_sub(device.last_seen_ms) < PLAYER_DEVICE_TTL_MS);
let candidate = state.devices_by_user.get(&user_id).and_then(|devices| {
devices
.values()
.filter(|device| {
!is_fed_virtual_device_id(&device.id)
&& now.saturating_sub(device.last_seen_ms) < PLAYER_DEVICE_TTL_MS
})
.max_by_key(|device| (device.report_sequence, &device.id))
});
let available = candidate.is_some();
// Only the selected browser can renew the gateway's owned output.
let report = active_browser.map_or(0, |device| device.report_sequence);
let playing = active_browser.is_some()
&& state
.playback_state_by_user
.get(&user_id)
.is_some_and(|playback| playback.track.is_some() && !playback.paused);
let startup = state
.playback_startup_by_user
.remove(&user_id)
.is_some_and(|started| {
started.elapsed().as_millis() <= PLAYER_DEVICE_COMMAND_TTL_MS as u128
});
(available, playing, report, startup)
}
pub(crate) fn enforce_federation_owner(&self, user_id: i64, local: &str, owner: &str) {
let mut state = self.state.lock().expect("player device hub lock");
if self.user_has_joined_jam_locked(&state, user_id) {
return;
}
if owner == local {
let now = current_millis();
let active_local = state
.active_device_by_user
.get(&user_id)
.is_some_and(|id| !is_fed_virtual_device_id(id));
if !active_local {
let candidate = state.devices_by_user.get(&user_id).and_then(|devices| {
devices
.values()
.filter(|device| {
!is_fed_virtual_device_id(&device.id)
&& now.saturating_sub(device.last_seen_ms) < PLAYER_DEVICE_TTL_MS
})
.max_by_key(|device| (device.report_sequence, &device.id))
.map(|device| device.id.clone())
});
if let Some(candidate) = candidate {
state
.active_device_by_user
.insert(user_id, candidate.clone());
if let Some(playback) =
state
.playback_state_by_user
.get(&user_id)
.and_then(|playback| {
serde_json::to_value(playback_state_at(playback.clone(), now)).ok()
})
{
self.enqueue_command_locked(
&mut state,
user_id,
&candidate,
"transfer_state",
playback,
now,
);
}
}
}
return;
}
state
.active_device_by_user .active_device_by_user
.get(&user_id) .insert(user_id, fed_virtual_device_id(owner));
.is_some_and(|id| is_fed_virtual_device_id(id)) // Poll responses also identify the winner. Purge delayed play/transfer
// commands so reconnecting browsers cannot resume an obsolete session.
state
.commands_by_device
.retain(|(user, _), _| *user != user_id);
} }
pub(crate) fn playback_state_json_for_commands( pub(crate) fn playback_state_json_for_commands(
@@ -216,39 +363,27 @@ impl PlayerDeviceHub {
state.devices_by_user.entry(user_id).or_default().insert( state.devices_by_user.entry(user_id).or_default().insert(
virtual_id.clone(), virtual_id.clone(),
PlayerDevice { PlayerDevice {
report_sequence: 0,
id: virtual_id.clone(), id: virtual_id.clone(),
name: fed_device_name.to_string(), name: fed_device_name.to_string(),
kind: "fed".to_string(), kind: "fed".to_string(),
last_seen_ms: now, last_seen_ms: now,
}, },
); );
// Match the trusted-device playback contract used by the TUI: a // The shared coordinator has already resolved ownership. A local
// background/stale active snapshot must not steal playback from a // playing flag is not permission to reject its winning claim.
// browser that is actively playing. An explicit web handoff changes if self.user_has_joined_jam_locked(&state, user_id) {
// `active_device_by_user` to the federated virtual device before the
// snapshot arrives, so it still passes through here.
let local_playback_is_protected = state
.active_device_by_user
.get(&user_id)
.is_some_and(|active_id| !is_fed_virtual_device_id(active_id))
&& state
.playback_state_by_user
.get(&user_id)
.is_some_and(|playback| playback.track.is_some() && !playback.paused);
if active && local_playback_is_protected {
return Ok(()); return Ok(());
} }
let should_update_playback = active
|| state
.active_device_by_user
.get(&user_id)
.is_some_and(|active_id| active_id == &virtual_id);
if active { if active {
state
.commands_by_device
.retain(|(user, _), _| *user != user_id);
state state
.active_device_by_user .active_device_by_user
.insert(user_id, virtual_id.clone()); .insert(user_id, virtual_id.clone());
} }
if should_update_playback { if active {
state.playback_state_by_user.insert(user_id, playback_state); state.playback_state_by_user.insert(user_id, playback_state);
} }
Ok(()) Ok(())
@@ -277,9 +412,32 @@ impl PlayerDeviceHub {
.playback_state_by_user .playback_state_by_user
.get(&user_id) .get(&user_id)
.is_some_and(|playback| playback.track.is_some() && !playback.paused); .is_some_and(|playback| playback.track.is_some() && !playback.paused);
let should_claim_idle_playback = is_new_or_returning let mut policy = music_dht::playback::Config::default();
&& previous_active_id.as_deref() != Some(device_id) // Local browser failover is permitted. The always-on gateway is not
&& !active_is_playing; // an automatic candidate against another federated output.
if previous_active_id
.as_deref()
.is_some_and(is_fed_virtual_device_id)
{
policy.automatic_failover = false;
}
let owner = previous_active_id.as_ref().map(|id| {
let age = state
.devices_by_user
.get(&user_id)
.and_then(|devices| devices.get(id))
.map_or(u64::MAX, |device| {
now.saturating_sub(device.last_seen_ms).max(0) as u64
});
(active_is_playing, age)
});
let should_claim_idle_playback = previous_active_id.as_deref() != Some(device_id)
&& policy.should_claim(is_new_or_returning, owner);
if is_new_or_returning {
state
.playback_startup_by_user
.insert(user_id, std::time::Instant::now());
}
if should_claim_idle_playback { if should_claim_idle_playback {
let transfer_state = state let transfer_state = state
.playback_state_by_user .playback_state_by_user
@@ -326,9 +484,57 @@ impl PlayerDeviceHub {
let now = current_millis(); let now = current_millis();
let mut state = self.state.lock().expect("player device hub lock"); let mut state = self.state.lock().expect("player device hub lock");
self.prune_locked(&mut state, now); self.prune_locked(&mut state, now);
let previous = state.active_device_by_user.get(&user_id).cloned();
if let Some(previous) =
previous.filter(|id| id != device_id && !is_fed_virtual_device_id(id))
{
let age = state
.devices_by_user
.get(&user_id)
.and_then(|devices| devices.get(&previous))
.map_or(u64::MAX, |device| {
now.saturating_sub(device.last_seen_ms).max(0) as u64
});
let playing = state
.playback_state_by_user
.get(&user_id)
.is_some_and(|playback| playback.track.is_some() && !playback.paused);
if music_dht::playback::Config::default().should_claim(false, Some((playing, age))) {
state
.active_device_by_user
.insert(user_id, device_id.to_string());
if let Some(payload) =
state
.playback_state_by_user
.get(&user_id)
.and_then(|playback| {
serde_json::to_value(playback_state_at(playback.clone(), now)).ok()
})
{
self.enqueue_command_locked(
&mut state,
user_id,
device_id,
"transfer_state",
payload,
now,
);
}
}
}
self.touch_locked(&mut state, user_id, device_id, user_agent, now); self.touch_locked(&mut state, user_id, device_id, user_agent, now);
self.update_playback_state_locked(&mut state, user_id, device_id, playback_state, now); self.update_playback_state_locked(&mut state, user_id, device_id, playback_state, now);
self.touch_jam_locked(&mut state, user_id, device_id, current_jam_id, now); self.touch_jam_locked(&mut state, user_id, device_id, current_jam_id, now);
if current_jam_id.is_none()
&& state
.active_device_by_user
.get(&user_id)
.is_some_and(|active| active != device_id)
{
state
.commands_by_device
.remove(&(user_id, device_id.to_string()));
}
let commands = state let commands = state
.commands_by_device .commands_by_device
.remove(&(user_id, device_id.to_string())) .remove(&(user_id, device_id.to_string()))
@@ -481,8 +687,11 @@ impl PlayerDeviceHub {
user_agent: Option<&str>, user_agent: Option<&str>,
now: i64, now: i64,
) { ) {
state.output_report_sequence = state.output_report_sequence.saturating_add(1);
let report_sequence = state.output_report_sequence;
let devices = state.devices_by_user.entry(user_id).or_default(); let devices = state.devices_by_user.entry(user_id).or_default();
let device = PlayerDevice { let device = PlayerDevice {
report_sequence,
id: device_id.to_string(), id: device_id.to_string(),
name: device_name_from_user_agent(user_agent), name: device_name_from_user_agent(user_agent),
kind: device_kind_from_user_agent(user_agent).to_string(), kind: device_kind_from_user_agent(user_agent).to_string(),
@@ -493,15 +702,7 @@ impl PlayerDeviceHub {
.device_last_seen_ms .device_last_seen_ms
.insert((user_id, device_id.to_string()), now); .insert((user_id, device_id.to_string()), now);
let active_online = state // Discovery only registers devices; startup/select decides ownership.
.active_device_by_user
.get(&user_id)
.is_some_and(|active_id| devices.contains_key(active_id));
if !active_online {
state
.active_device_by_user
.insert(user_id, device_id.to_string());
}
} }
fn update_playback_state_locked( fn update_playback_state_locked(
@@ -923,25 +1124,11 @@ impl PlayerDeviceHub {
devices.retain(|_, device| { devices.retain(|_, device| {
now.saturating_sub(device.last_seen_ms) <= PLAYER_DEVICE_TTL_MS now.saturating_sub(device.last_seen_ms) <= PLAYER_DEVICE_TTL_MS
}); });
let active_valid = state // Keep ownership and queue when presence expires. The shared
.active_device_by_user // protocol decides failover; HashMap order must never choose audio.
.get(user_id) let _ = user_id;
.is_some_and(|active_id| devices.contains_key(active_id));
if !active_valid {
if let Some(first_device_id) = devices.keys().next().cloned() {
state
.active_device_by_user
.insert(*user_id, first_device_id);
} else {
state.active_device_by_user.remove(user_id);
state.playback_state_by_user.remove(user_id);
}
}
!devices.is_empty() !devices.is_empty()
}); });
state
.playback_state_by_user
.retain(|user_id, _| state.devices_by_user.contains_key(user_id));
state state
.commands_by_device .commands_by_device
@@ -1117,6 +1304,83 @@ fn device_kind_from_user_agent(user_agent: Option<&str>) -> &'static str {
mod device_tests { mod device_tests {
use super::*; use super::*;
#[test]
fn gateway_timer_does_not_manufacture_browser_reports() {
let hub = PlayerDeviceHub::default();
assert_eq!(hub.federation_output_report(1), (false, false, 0, false));
hub.heartbeat(1, "browser", None, None, None);
let first = hub.federation_output_report(1);
let repeated = hub.federation_output_report(1);
assert!(first.0);
assert!(first.3);
assert_eq!(first.2, repeated.2);
assert!(!repeated.3);
}
#[test]
fn an_old_browser_startup_does_not_claim_a_late_peer() {
let hub = PlayerDeviceHub::default();
hub.heartbeat(1, "browser", None, None, None);
hub.state.lock().unwrap().playback_startup_by_user.insert(
1,
std::time::Instant::now()
- std::time::Duration::from_millis(PLAYER_DEVICE_COMMAND_TTL_MS as u64 + 1),
);
assert!(!hub.federation_output_report(1).3);
}
#[test]
fn expired_presence_does_not_replace_federated_owner() {
let hub = PlayerDeviceHub::default();
hub.state
.lock()
.unwrap()
.active_device_by_user
.insert(1, "fed:remote".into());
let response = hub.poll(1, "browser", None, None, None);
assert_eq!(response.active_device_id.as_deref(), Some("fed:remote"));
assert!(response.commands.is_empty());
}
#[test]
fn browser_poll_fails_over_only_after_local_owner_timeout() {
let hub = PlayerDeviceHub::default();
hub.heartbeat(1, "first", None, None, None);
assert_eq!(
hub.poll(1, "second", None, None, None)
.active_device_id
.as_deref(),
Some("first")
);
hub.state
.lock()
.unwrap()
.devices_by_user
.get_mut(&1)
.unwrap()
.get_mut("first")
.unwrap()
.last_seen_ms = current_millis() - PLAYER_DEVICE_TTL_MS - 1;
assert_eq!(
hub.poll(1, "second", None, None, None)
.active_device_id
.as_deref(),
Some("second")
);
}
#[test]
fn pruning_keeps_the_owner_when_every_device_is_offline() {
let hub = PlayerDeviceHub::default();
hub.heartbeat(1, "browser", None, None, None);
let mut state = hub.state.lock().unwrap();
hub.prune_locked(&mut state, current_millis() + PLAYER_DEVICE_TTL_MS + 1);
assert_eq!(
state.active_device_by_user.get(&1).map(String::as_str),
Some("browser")
);
}
#[test] #[test]
fn detects_furumi_android_native_client() { fn detects_furumi_android_native_client() {
let user_agent = Some("FurumiAndroid/1.0 Android Mobile"); let user_agent = Some("FurumiAndroid/1.0 Android Mobile");
@@ -1164,7 +1428,7 @@ mod device_tests {
} }
#[test] #[test]
fn federated_snapshot_does_not_steal_active_browser_playback() { fn resolved_federation_owner_overrides_a_playing_browser() {
let hub = PlayerDeviceHub::default(); let hub = PlayerDeviceHub::default();
let user_id = 7; let user_id = 7;
{ {
@@ -1172,6 +1436,7 @@ mod device_tests {
state.devices_by_user.entry(user_id).or_default().insert( state.devices_by_user.entry(user_id).or_default().insert(
"browser".to_string(), "browser".to_string(),
PlayerDevice { PlayerDevice {
report_sequence: 0,
id: "browser".to_string(), id: "browser".to_string(),
name: "Browser".to_string(), name: "Browser".to_string(),
kind: "computer".to_string(), kind: "computer".to_string(),
@@ -1223,7 +1488,7 @@ mod device_tests {
.active_device_by_user .active_device_by_user
.get(&user_id) .get(&user_id)
.map(String::as_str), .map(String::as_str),
Some("browser") Some("fed:remote")
); );
assert!( assert!(
state state
@@ -1319,6 +1584,7 @@ mod device_tests {
state.devices_by_user.entry(user_id).or_default().insert( state.devices_by_user.entry(user_id).or_default().insert(
"browser".to_string(), "browser".to_string(),
PlayerDevice { PlayerDevice {
report_sequence: 0,
id: "browser".to_string(), id: "browser".to_string(),
name: "Browser".to_string(), name: "Browser".to_string(),
kind: "computer".to_string(), kind: "computer".to_string(),
@@ -1375,6 +1641,40 @@ struct LastfmCallbackQuery {
#[template(path = "player.html")] #[template(path = "player.html")]
pub struct PlayerPageTemplate { pub struct PlayerPageTemplate {
pub t: &'static Translations, pub t: &'static Translations,
pub downloads_enabled: bool,
pub torrent_downloads_enabled: bool,
pub youtube_downloads_enabled: bool,
}
#[cfg(test)]
mod page_template_tests {
use super::*;
use crate::i18n::Lang;
#[test]
fn download_manager_button_follows_the_global_switch() {
let disabled = PlayerPageTemplate {
t: Translations::for_lang(Lang::En),
downloads_enabled: false,
torrent_downloads_enabled: false,
youtube_downloads_enabled: false,
}
.render()
.unwrap();
assert!(!disabled.contains("<button class=\"torrent-import-btn\""));
assert!(disabled.contains("downloadsEnabled: false"));
let enabled = PlayerPageTemplate {
t: Translations::for_lang(Lang::En),
downloads_enabled: true,
torrent_downloads_enabled: true,
youtube_downloads_enabled: true,
}
.render()
.unwrap();
assert!(enabled.contains("<button class=\"torrent-import-btn\""));
assert!(enabled.contains("downloadsEnabled: true"));
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -4838,6 +5138,9 @@ async fn local_upload_handler(
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
}; };
if let Err(response) = require_download_method(&config, DownloadMethod::LocalFile) {
return Ok(response);
}
let inbox_dir = config.agent_inbox_dir.trim(); let inbox_dir = config.agent_inbox_dir.trim();
if inbox_dir.is_empty() { if inbox_dir.is_empty() {
@@ -4958,6 +5261,9 @@ async fn local_upload_history_handler(
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
}; };
let (config, _) = AppConfig::load_with_db(&db).await; let (config, _) = AppConfig::load_with_db(&db).await;
if let Err(response) = require_download_method(&config, DownloadMethod::LocalFile) {
return Ok(response);
}
match crate::local_uploads::list(pool, user.id, &config.agent_inbox_dir).await { match crate::local_uploads::list(pool, user.id, &config.agent_inbox_dir).await {
Ok(items) => Json(items).into_response(), Ok(items) => Json(items).into_response(),
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string())), Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string())),
@@ -4974,6 +5280,10 @@ async fn local_upload_history_remove_handler(
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
}; };
let (config, _) = AppConfig::load_with_db(&db).await;
if let Err(response) = require_download_method(&config, DownloadMethod::LocalFile) {
return Ok(response);
}
match crate::local_uploads::remove(pool, user.id, &path.0.id).await { match crate::local_uploads::remove(pool, user.id, &path.0.id).await {
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(), Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string())), Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string())),
@@ -5250,6 +5560,12 @@ async fn devices_heartbeat_handler(
return Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}"))); return Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}")));
} }
} }
if let Err(error) = crate::federation::handle()
.fed_device_web_refresh(user.id)
.await
{
tracing::warn!(user_id = user.id, %error, "playback coordination startup failed");
}
Json(response).into_response() Json(response).into_response()
} }
@@ -5267,6 +5583,12 @@ async fn devices_poll_handler(
return Ok(json_error(StatusCode::BAD_REQUEST, "invalid device id")); return Ok(json_error(StatusCode::BAD_REQUEST, "invalid device id"));
}; };
if let Err(error) = crate::federation::handle()
.fed_device_web_refresh(user.id)
.await
{
tracing::warn!(user_id = user.id, %error, "playback coordination refresh failed");
}
let response = hub.poll( let response = hub.poll(
user.id, user.id,
&device_id, &device_id,
@@ -8446,12 +8768,31 @@ impl App for PlayerApp {
"not authenticated", "not authenticated",
)); ));
} }
let (live_config, _) = AppConfig::load_with_db(&db).await;
let proxy_url = match download_proxy_for(
&live_config,
DownloadMethod::YouTube,
) {
Ok(proxy_url) => proxy_url,
Err(response) => return Ok(response),
};
let cookie_contents = match youtube_cookie_contents(&live_config, &db).await {
Ok(cookie_contents) => cookie_contents,
Err(response) => return Ok(response),
};
let service = youtube_service let service = youtube_service
.get_or_init(|| async { .get_or_init(|| async {
Arc::new(YouTubeService::new(Arc::clone(&scheduler_handle))) Arc::new(YouTubeService::new(Arc::clone(&scheduler_handle)))
}) })
.await; .await;
match service.preview(json.0).await { match service
.preview(
json.0,
proxy_url.as_deref(),
cookie_contents.as_deref(),
)
.await
{
Ok(preview) => Json(preview).into_response(), Ok(preview) => Json(preview).into_response(),
Err(err) => { Err(err) => {
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string())) Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
@@ -8500,8 +8841,25 @@ impl App for PlayerApp {
}) })
.await; .await;
let (live_config, _) = AppConfig::load_with_db(&db).await; let (live_config, _) = AppConfig::load_with_db(&db).await;
let proxy_url = match download_proxy_for(
&live_config,
DownloadMethod::YouTube,
) {
Ok(proxy_url) => proxy_url,
Err(response) => return Ok(response),
};
let cookie_contents = match youtube_cookie_contents(&live_config, &db).await {
Ok(cookie_contents) => cookie_contents,
Err(response) => return Ok(response),
};
match service match service
.list(pg_pool, user.id, &live_config.agent_inbox_dir) .list(
pg_pool,
user.id,
&live_config.agent_inbox_dir,
proxy_url,
cookie_contents,
)
.await .await
{ {
Ok(items) => Json(items).into_response(), Ok(items) => Json(items).into_response(),
@@ -8555,12 +8913,25 @@ impl App for PlayerApp {
}) })
.await; .await;
let (live_config, _) = AppConfig::load_with_db(&db).await; let (live_config, _) = AppConfig::load_with_db(&db).await;
let proxy_url = match download_proxy_for(
&live_config,
DownloadMethod::YouTube,
) {
Ok(proxy_url) => proxy_url,
Err(response) => return Ok(response),
};
let cookie_contents = match youtube_cookie_contents(&live_config, &db).await {
Ok(cookie_contents) => cookie_contents,
Err(response) => return Ok(response),
};
match service match service
.start( .start(
pg_pool, pg_pool,
user.id, user.id,
json.0, json.0,
&live_config.agent_inbox_dir, &live_config.agent_inbox_dir,
proxy_url,
cookie_contents,
) )
.await .await
{ {
@@ -8615,12 +8986,25 @@ impl App for PlayerApp {
}) })
.await; .await;
let (live_config, _) = AppConfig::load_with_db(&db).await; let (live_config, _) = AppConfig::load_with_db(&db).await;
let proxy_url = match download_proxy_for(
&live_config,
DownloadMethod::YouTube,
) {
Ok(proxy_url) => proxy_url,
Err(response) => return Ok(response),
};
let cookie_contents = match youtube_cookie_contents(&live_config, &db).await {
Ok(cookie_contents) => cookie_contents,
Err(response) => return Ok(response),
};
match service match service
.retry( .retry(
pg_pool, pg_pool,
user.id, user.id,
&path.0.id, &path.0.id,
&live_config.agent_inbox_dir, &live_config.agent_inbox_dir,
proxy_url,
cookie_contents,
) )
.await .await
{ {
@@ -8778,12 +9162,20 @@ impl App for PlayerApp {
.expect("player pool") .expect("player pool")
}) })
.await; .await;
let (live_config, _) = AppConfig::load_with_db(&db).await;
let proxy_url = match download_proxy_for(
&live_config,
DownloadMethod::Torrent,
) {
Ok(proxy_url) => proxy_url,
Err(response) => return Ok(response),
};
let service = torrent_service let service = torrent_service
.get_or_init(|| async { .get_or_init(|| async {
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle))) Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
}) })
.await; .await;
match service.list(pg_pool, user.id).await { match service.list(pg_pool, user.id, proxy_url).await {
Ok(items) => Json(items).into_response(), Ok(items) => Json(items).into_response(),
Err(err) => { Err(err) => {
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string())) Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
@@ -8927,12 +9319,23 @@ impl App for PlayerApp {
.expect("player pool") .expect("player pool")
}) })
.await; .await;
let (live_config, _) = AppConfig::load_with_db(&db).await;
let proxy_url = match download_proxy_for(
&live_config,
DownloadMethod::Torrent,
) {
Ok(proxy_url) => proxy_url,
Err(response) => return Ok(response),
};
let service = torrent_service let service = torrent_service
.get_or_init(|| async { .get_or_init(|| async {
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle))) Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
}) })
.await; .await;
match service.preview(pg_pool, user.id, json.0).await { match service
.preview(pg_pool, user.id, json.0, proxy_url.as_deref())
.await
{
Ok(preview) => Json(preview).into_response(), Ok(preview) => Json(preview).into_response(),
Err(err) => { Err(err) => {
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string())) Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
@@ -9284,6 +9687,13 @@ impl App for PlayerApp {
}) })
.await; .await;
let (live_config, _) = AppConfig::load_with_db(&db).await; let (live_config, _) = AppConfig::load_with_db(&db).await;
let proxy_url = match download_proxy_for(
&live_config,
DownloadMethod::Torrent,
) {
Ok(proxy_url) => proxy_url,
Err(response) => return Ok(response),
};
let service = torrent_service let service = torrent_service
.get_or_init(|| async { .get_or_init(|| async {
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle))) Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
@@ -9296,6 +9706,7 @@ impl App for PlayerApp {
json.0.selected_files, json.0.selected_files,
live_config.agent_inbox_dir, live_config.agent_inbox_dir,
user.id, user.id,
proxy_url.as_deref(),
) )
.await .await
{ {
+70 -39
View File
@@ -373,7 +373,8 @@ impl TorrentJob {
pub struct TorrentService { pub struct TorrentService {
temp_root: PathBuf, temp_root: PathBuf,
session: OnceCell<Arc<Session>>, sessions: Mutex<HashMap<String, Arc<Session>>>,
job_sessions: Mutex<HashMap<String, Arc<Session>>>,
jobs: Mutex<HashMap<String, TorrentJob>>, jobs: Mutex<HashMap<String, TorrentJob>>,
resolving_jobs: Mutex<HashSet<String>>, resolving_jobs: Mutex<HashSet<String>>,
scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>, scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>,
@@ -383,36 +384,47 @@ impl TorrentService {
pub fn new(scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>) -> Self { pub fn new(scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>) -> Self {
Self { Self {
temp_root: std::env::temp_dir().join("furumusic").join("torrents"), temp_root: std::env::temp_dir().join("furumusic").join("torrents"),
session: OnceCell::new(), sessions: Mutex::new(HashMap::new()),
job_sessions: Mutex::new(HashMap::new()),
jobs: Mutex::new(HashMap::new()), jobs: Mutex::new(HashMap::new()),
resolving_jobs: Mutex::new(HashSet::new()), resolving_jobs: Mutex::new(HashSet::new()),
scheduler_handle, scheduler_handle,
} }
} }
async fn session(&self) -> anyhow::Result<Arc<Session>> { async fn session(&self, proxy_url: Option<&str>) -> anyhow::Result<Arc<Session>> {
let temp_root = self.temp_root.clone(); let key = proxy_url.unwrap_or_default().to_string();
self.session let mut sessions = self.sessions.lock().await;
.get_or_try_init(|| async move { if let Some(session) = sessions.get(&key) {
tokio::fs::create_dir_all(&temp_root).await?; return Ok(Arc::clone(session));
Session::new_with_opts( }
temp_root,
SessionOptions { tokio::fs::create_dir_all(&self.temp_root).await?;
disable_upload: true, let session = Session::new_with_opts(
enable_upnp_port_forwarding: false, self.temp_root.clone(),
..Default::default() SessionOptions {
}, // SOCKS is intentionally limited to peer TCP and HTTP(S)
) // tracker traffic. DHT and other UDP discovery stay direct.
.await disable_dht: false,
}) // Sessions are keyed by proxy and can coexist, so they cannot
.await // safely share one persisted DHT socket configuration.
.cloned() disable_dht_persistence: true,
disable_upload: true,
enable_upnp_port_forwarding: false,
socks_proxy_url: proxy_url.map(str::to_owned),
..Default::default()
},
)
.await?;
sessions.insert(key, Arc::clone(&session));
Ok(session)
} }
pub async fn list( pub async fn list(
self: &Arc<Self>, self: &Arc<Self>,
pool: &PgPool, pool: &PgPool,
user_id: i64, user_id: i64,
proxy_url: Option<String>,
) -> anyhow::Result<Vec<TorrentJobDto>> { ) -> anyhow::Result<Vec<TorrentJobDto>> {
let rows = sqlx::query_as::<_, TorrentSessionRow>( let rows = sqlx::query_as::<_, TorrentSessionRow>(
r#"SELECT id, user_id, name, info_hash, source_kind, source_label, torrent_bytes, r#"SELECT id, user_id, name, info_hash, source_kind, source_label, torrent_bytes,
@@ -445,6 +457,7 @@ impl TorrentService {
row.id.clone(), row.id.clone(),
magnet, magnet,
row.created_at.clone(), row.created_at.clone(),
proxy_url.clone(),
) )
.await; .await;
} }
@@ -483,8 +496,9 @@ impl TorrentService {
pool: &PgPool, pool: &PgPool,
user_id: i64, user_id: i64,
request: TorrentPreviewRequest, request: TorrentPreviewRequest,
proxy_url: Option<&str>,
) -> anyhow::Result<TorrentSessionDto> { ) -> anyhow::Result<TorrentSessionDto> {
let session = self.session().await?; let session = self.session(proxy_url).await?;
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
let output_dir = self.temp_root.join(&id).join("download"); let output_dir = self.temp_root.join(&id).join("download");
tokio::fs::create_dir_all(&output_dir).await?; tokio::fs::create_dir_all(&output_dir).await?;
@@ -511,8 +525,15 @@ impl TorrentService {
.unwrap_or_else(|| info_hash.clone()); .unwrap_or_else(|| info_hash.clone());
let now = now_string(); let now = now_string();
insert_pending_magnet(pool, &id, user_id, &name, &info_hash, &magnet, &now).await?; insert_pending_magnet(pool, &id, user_id, &name, &info_hash, &magnet, &now).await?;
self.spawn_resolve_pending_magnet(pool.clone(), user_id, id.clone(), magnet, now) self.spawn_resolve_pending_magnet(
.await; pool.clone(),
user_id,
id.clone(),
magnet,
now,
proxy_url.map(str::to_owned),
)
.await;
let row = load_row(pool, user_id, &id).await?; let row = load_row(pool, user_id, &id).await?;
return Ok(TorrentSessionDto { return Ok(TorrentSessionDto {
@@ -611,6 +632,7 @@ impl TorrentService {
id: String, id: String,
magnet: String, magnet: String,
created_at: String, created_at: String,
proxy_url: Option<String>,
) { ) {
{ {
let mut resolving = self.resolving_jobs.lock().await; let mut resolving = self.resolving_jobs.lock().await;
@@ -622,7 +644,14 @@ impl TorrentService {
let service = Arc::clone(self); let service = Arc::clone(self);
tokio::spawn(async move { tokio::spawn(async move {
let result = service let result = service
.resolve_pending_magnet(&pool, user_id, &id, &magnet, &created_at) .resolve_pending_magnet(
&pool,
user_id,
&id,
&magnet,
&created_at,
proxy_url.as_deref(),
)
.await; .await;
if let Err(err) = result { if let Err(err) = result {
update_resolving_error(&pool, &id, &err.to_string()).await; update_resolving_error(&pool, &id, &err.to_string()).await;
@@ -638,8 +667,9 @@ impl TorrentService {
id: &str, id: &str,
magnet: &str, magnet: &str,
created_at: &str, created_at: &str,
proxy_url: Option<&str>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let session = self.session().await?; let session = self.session(proxy_url).await?;
let output_dir = self.temp_root.join(id).join("download"); let output_dir = self.temp_root.join(id).join("download");
tokio::fs::create_dir_all(&output_dir).await?; tokio::fs::create_dir_all(&output_dir).await?;
let response = tokio::time::timeout( let response = tokio::time::timeout(
@@ -743,7 +773,7 @@ impl TorrentService {
jobs.remove(id).and_then(|job| job.handle) jobs.remove(id).and_then(|job| job.handle)
}; };
if let Some(handle) = removed { if let Some(handle) = removed {
self.stop_torrent(&handle).await; self.stop_torrent(id, &handle).await;
} }
let result = let result =
@@ -766,6 +796,7 @@ impl TorrentService {
selected_files: Vec<usize>, selected_files: Vec<usize>,
inbox_dir: String, inbox_dir: String,
uploader_user_id: i64, uploader_user_id: i64,
proxy_url: Option<&str>,
) -> anyhow::Result<TorrentJobDto> { ) -> anyhow::Result<TorrentJobDto> {
if selected_files.is_empty() { if selected_files.is_empty() {
bail!("select at least one file"); bail!("select at least one file");
@@ -810,7 +841,7 @@ impl TorrentService {
tokio::fs::create_dir_all(&output_dir).await?; tokio::fs::create_dir_all(&output_dir).await?;
mark_job_started(pool, id, &selected_files, &self.memory_job_dto(id).await?).await?; mark_job_started(pool, id, &selected_files, &self.memory_job_dto(id).await?).await?;
let session = self.session().await?; let session = self.session(proxy_url).await?;
let response = match session let response = match session
.add_torrent( .add_torrent(
AddTorrent::from_bytes(torrent_bytes), AddTorrent::from_bytes(torrent_bytes),
@@ -838,6 +869,10 @@ impl TorrentService {
return Err(err); return Err(err);
} }
}; };
self.job_sessions
.lock()
.await
.insert(id.to_string(), Arc::clone(&session));
let dto = { let dto = {
let mut jobs = self.jobs.lock().await; let mut jobs = self.jobs.lock().await;
@@ -856,7 +891,7 @@ impl TorrentService {
if service.is_paused(&id).await { if service.is_paused(&id).await {
return; return;
} }
service.stop_torrent(&handle).await; service.stop_torrent(&id, &handle).await;
service.fail_job(&pool, &id, err.to_string()).await; service.fail_job(&pool, &id, err.to_string()).await;
crate::metrics::record_torrent_download( crate::metrics::record_torrent_download(
"failed", "failed",
@@ -865,7 +900,7 @@ impl TorrentService {
); );
return; return;
} }
service.stop_torrent(&handle).await; service.stop_torrent(&id, &handle).await;
if let Err(err) = service if let Err(err) = service
.finalize_completed(&pool, &id, &inbox_dir, uploader_user_id) .finalize_completed(&pool, &id, &inbox_dir, uploader_user_id)
.await .await
@@ -911,7 +946,7 @@ impl TorrentService {
persist_progress(pool, &dto).await?; persist_progress(pool, &dto).await?;
if let Some(handle) = handle { if let Some(handle) = handle {
self.stop_torrent(&handle).await; self.stop_torrent(id, &handle).await;
} }
Ok(dto) Ok(dto)
} }
@@ -981,16 +1016,12 @@ impl TorrentService {
} }
} }
async fn stop_torrent(&self, handle: &Arc<ManagedTorrent>) { async fn stop_torrent(&self, id: &str, handle: &Arc<ManagedTorrent>) {
match self.session().await { let session = self.job_sessions.lock().await.remove(id);
Ok(session) => { if let Some(session) = session
if let Err(err) = session.delete(handle.id().into(), false).await { && let Err(err) = session.delete(handle.id().into(), false).await
tracing::warn!("failed to stop completed torrent: {err}"); {
} tracing::warn!("failed to stop completed torrent: {err}");
}
Err(err) => {
tracing::warn!("failed to access torrent session for shutdown: {err}");
}
} }
} }
+1169 -46
View File
File diff suppressed because it is too large Load Diff
+962 -4
View File
File diff suppressed because it is too large Load Diff
+47 -5
View File
@@ -88,7 +88,8 @@
<!-- Download Manager Modal --> <!-- Download Manager Modal -->
<template x-if="$store.torrents.modal"> <template x-if="$store.torrents.modal">
<div class="modal-overlay" @click.self="$store.torrents.close()"> <div class="modal-overlay" @click.self="$store.torrents.close()">
<div class="modal-box torrent-modal"> <div class="modal-box torrent-modal"
:class="{ 'youtube-mode': $store.torrents.sourceTab === 'youtube' }">
<div class="torrent-modal-head"> <div class="torrent-modal-head">
<div> <div>
<h3>{{ t.player_torrent_manager }}</h3> <h3>{{ t.player_torrent_manager }}</h3>
@@ -132,12 +133,16 @@
</div> </div>
<div class="torrent-tabs download-source-tabs"> <div class="torrent-tabs download-source-tabs">
{% if youtube_downloads_enabled %}
<button class="torrent-tab-btn" <button class="torrent-tab-btn"
:class="{ active: $store.torrents.sourceTab === 'youtube' }" :class="{ active: $store.torrents.sourceTab === 'youtube' }"
@click="$store.torrents.showSourceTab('youtube')">{{ t.player_youtube }}</button> @click="$store.torrents.showSourceTab('youtube')">{{ t.player_youtube }}</button>
{% endif %}
{% if torrent_downloads_enabled %}
<button class="torrent-tab-btn" <button class="torrent-tab-btn"
:class="{ active: $store.torrents.sourceTab === 'torrents' }" :class="{ active: $store.torrents.sourceTab === 'torrents' }"
@click="$store.torrents.showSourceTab('torrents')">{{ t.player_torrents }}</button> @click="$store.torrents.showSourceTab('torrents')">{{ t.player_torrents }}</button>
{% endif %}
<button class="torrent-tab-btn" <button class="torrent-tab-btn"
:class="{ active: $store.torrents.sourceTab === 'files' }" :class="{ active: $store.torrents.sourceTab === 'files' }"
@click="$store.torrents.showSourceTab('files')">{{ t.player_files }}</button> @click="$store.torrents.showSourceTab('files')">{{ t.player_files }}</button>
@@ -201,6 +206,29 @@
</label> </label>
</template> </template>
</div> </div>
<div class="youtube-preview-destination">
<label for="youtube-target-playlist">{{ t.player_youtube_destination }}</label>
<div class="youtube-preview-destination-fields"
:class="{ creating: $store.torrents.youtubePlaylistChoice === '__new__' }">
<select id="youtube-target-playlist"
x-model="$store.torrents.youtubePlaylistChoice"
@change="$store.torrents.youtubePlaylistChoiceChanged()">
<option value="">{{ t.player_youtube_no_destination }}</option>
<template x-for="playlist in $store.torrents.youtubeOwnedPlaylists()" :key="playlist.id">
<option :value="String(playlist.id)" x-text="playlist.title"></option>
</template>
<option value="__new__">{{ t.player_youtube_create_playlist }}</option>
</select>
<template x-if="$store.torrents.youtubePlaylistChoice === '__new__'">
<input type="text"
maxlength="255"
autocomplete="off"
x-model="$store.torrents.youtubeNewPlaylistTitle"
placeholder="{{ t.player_youtube_new_playlist_name }}">
</template>
</div>
<p>{{ t.player_youtube_destination_hint }}</p>
</div>
<div class="youtube-preview-footer"> <div class="youtube-preview-footer">
<span x-text="$store.torrents.youtubePreviewSelectedCount() + ' ' + T.youtubeSelectedCount"></span> <span x-text="$store.torrents.youtubePreviewSelectedCount() + ' ' + T.youtubeSelectedCount"></span>
<div> <div>
@@ -208,7 +236,7 @@
@click="$store.torrents.clearYoutubePreview()">{{ t.player_cancel }}</button> @click="$store.torrents.clearYoutubePreview()">{{ t.player_cancel }}</button>
<button type="button" class="modal-btn modal-btn-primary" <button type="button" class="modal-btn modal-btn-primary"
@click="$store.torrents.startYoutubeDownload()" @click="$store.torrents.startYoutubeDownload()"
:disabled="$store.torrents.youtubeSubmitting || $store.torrents.youtubePreviewSelectedCount() === 0"> :disabled="$store.torrents.youtubeSubmitting || $store.torrents.youtubePreviewSelectedCount() === 0 || !$store.torrents.youtubeDestinationValid()">
{{ t.player_youtube_start_import }} {{ t.player_youtube_start_import }}
</button> </button>
</div> </div>
@@ -231,16 +259,29 @@
</template> </template>
<template x-for="job in $store.torrents.youtubeJobs" :key="job.id"> <template x-for="job in $store.torrents.youtubeJobs" :key="job.id">
<article class="youtube-job-card"> <article class="youtube-job-card"
<div class="youtube-job-head"> :class="{ collapsed: !$store.torrents.youtubeJobExpanded(job.id) }">
<button type="button"
class="youtube-job-summary"
:aria-expanded="$store.torrents.youtubeJobExpanded(job.id)"
:title="$store.torrents.youtubeJobExpanded(job.id) ? T.collapse : T.expand"
@click="$store.torrents.toggleYoutubeJob(job.id)">
<span class="youtube-job-chevron" aria-hidden="true"></span>
<div class="youtube-job-heading"> <div class="youtube-job-heading">
<div class="youtube-job-title" x-text="job.title"></div> <div class="youtube-job-title" x-text="job.title"></div>
<div class="youtube-job-meta" x-text="$store.torrents.youtubeJobMeta(job)"></div> <div class="youtube-job-meta" x-text="$store.torrents.youtubeJobMeta(job)"></div>
</div> </div>
<span class="youtube-job-compact-progress"
x-text="$store.torrents.youtubeJobProgress(job) + '%'">
</span>
<span class="torrent-status-badge" <span class="torrent-status-badge"
:class="$store.torrents.youtubeStatusClass(job.status)" :class="$store.torrents.youtubeStatusClass(job.status)"
x-text="$store.torrents.youtubeStatusLabel(job.status)"></span> x-text="$store.torrents.youtubeStatusLabel(job.status)"></span>
</div> </button>
<div class="youtube-job-details"
x-show="$store.torrents.youtubeJobExpanded(job.id)"
x-cloak>
<div class="youtube-job-progress"> <div class="youtube-job-progress">
<div class="torrent-session-progress"> <div class="torrent-session-progress">
@@ -312,6 +353,7 @@
x-show="$store.torrents.youtubeJobTerminal(job.status)" x-show="$store.torrents.youtubeJobTerminal(job.status)"
@click="$store.torrents.removeYoutubeJob(job.id)">{{ t.player_remove_from_history }}</button> @click="$store.torrents.removeYoutubeJob(job.id)">{{ t.player_remove_from_history }}</button>
</div> </div>
</div>
</article> </article>
</template> </template>
</div> </div>
+144 -6
View File
@@ -1,5 +1,23 @@
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
<script> <script>
// Handle expired sessions centrally, including background player requests.
let loginRedirectStarted = false;
function redirectOnUnauthorized(status) {
if (status !== 401 || loginRedirectStarted) return;
loginRedirectStarted = true;
window.location.replace('/login');
}
const playerFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
const response = await playerFetch(input, init);
const url = new URL(input instanceof Request ? input.url : input, window.location.href);
if (url.origin === window.location.origin && url.pathname.startsWith('/api/')) {
redirectOnUnauthorized(response.status);
}
return response;
};
const T = { const T = {
info: "{{ t.player_info }}", info: "{{ t.player_info }}",
noDetails: "{{ t.player_no_details }}", noDetails: "{{ t.player_no_details }}",
@@ -162,6 +180,8 @@ const T = {
youtubeSelectAll: "{{ t.player_youtube_select_all }}", youtubeSelectAll: "{{ t.player_youtube_select_all }}",
youtubeClearSelection: "{{ t.player_youtube_clear_selection }}", youtubeClearSelection: "{{ t.player_youtube_clear_selection }}",
youtubeSelectedCount: "{{ t.player_youtube_selected_count }}", youtubeSelectedCount: "{{ t.player_youtube_selected_count }}",
youtubePlaylistCreateFailed: "{{ t.player_youtube_playlist_create_failed }}",
youtubeAddedTo: "{{ t.player_youtube_added_to }}",
youtubeCancelled: "{{ t.player_youtube_cancelled }}", youtubeCancelled: "{{ t.player_youtube_cancelled }}",
youtubeStopConfirm: "{{ t.player_youtube_stop_confirm }}", youtubeStopConfirm: "{{ t.player_youtube_stop_confirm }}",
youtubeStopping: "{{ t.player_youtube_stopping }}", youtubeStopping: "{{ t.player_youtube_stopping }}",
@@ -181,6 +201,8 @@ const T = {
liveReleases: "{{ t.player_live_releases }}", liveReleases: "{{ t.player_live_releases }}",
soundtracks: "{{ t.player_soundtracks }}", soundtracks: "{{ t.player_soundtracks }}",
likesPlaylist: "{{ t.player_likes_playlist }}", likesPlaylist: "{{ t.player_likes_playlist }}",
expand: "{{ t.player_expand }}",
collapse: "{{ t.player_collapse }}",
}; };
function formatTime(seconds) { function formatTime(seconds) {
@@ -1684,7 +1706,7 @@ document.addEventListener('alpine:init', () => {
} }
const player = Alpine.store('player'); const player = Alpine.store('player');
if (player && Array.isArray(data.commands)) { if (player && (this.isActive() || this.shouldPlayJamLocally()) && Array.isArray(data.commands)) {
data.commands.forEach(command => player._executeRemoteCommand(command)); data.commands.forEach(command => player._executeRemoteCommand(command));
} }
if (player && !this.isActive()) { if (player && !this.isActive()) {
@@ -1702,7 +1724,6 @@ document.addEventListener('alpine:init', () => {
}, },
_apply(data) { _apply(data) {
const wasActive = this.isActive();
const previousJamId = this.currentJamId; const previousJamId = this.currentJamId;
this.activeDeviceId = data.active_device_id || null; this.activeDeviceId = data.active_device_id || null;
this.devices = Array.isArray(data.devices) ? data.devices : []; this.devices = Array.isArray(data.devices) ? data.devices : [];
@@ -1718,7 +1739,7 @@ document.addEventListener('alpine:init', () => {
if (previousJamId !== this.currentJamId || !this.canPlayJamLocally()) { if (previousJamId !== this.currentJamId || !this.canPlayJamLocally()) {
this._setJamLocalPlayback(false, { pauseLocal: true }); this._setJamLocalPlayback(false, { pauseLocal: true });
} }
if (wasActive && !this.isActive()) { if (!this.isActive() && !this.shouldPlayJamLocally()) {
Alpine.store('player')?._pauseLocal(); Alpine.store('player')?._pauseLocal();
} }
this._maybeShowRemoteHint(); this._maybeShowRemoteHint();
@@ -4604,12 +4625,20 @@ document.addEventListener('alpine:init', () => {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
Alpine.store('torrents', { Alpine.store('torrents', {
modal: false, modal: false,
sourceTab: 'youtube', downloadsEnabled: {{ downloads_enabled }},
torrentDownloadsEnabled: {{ torrent_downloads_enabled }},
youtubeDownloadsEnabled: {{ youtube_downloads_enabled }},
sourceTab: {% if youtube_downloads_enabled %}'youtube'{% else if torrent_downloads_enabled %}'torrents'{% else %}'files'{% endif %},
youtubeUrl: '', youtubeUrl: '',
youtubePreview: null, youtubePreview: null,
youtubePreviewSelected: new Set(), youtubePreviewSelected: new Set(),
youtubePlaylistChoice: '',
youtubeNewPlaylistTitle: '',
youtubePlaylistSyncKey: '',
youtubePreviewLoading: false, youtubePreviewLoading: false,
youtubeJobs: [], youtubeJobs: [],
youtubeExpandedJobId: null,
youtubeJobsInitialized: false,
youtubeLoading: false, youtubeLoading: false,
youtubeSubmitting: false, youtubeSubmitting: false,
youtubeCancellingIds: new Set(), youtubeCancellingIds: new Set(),
@@ -4671,6 +4700,7 @@ document.addEventListener('alpine:init', () => {
}, },
open() { open() {
if (!this.downloadsEnabled) return;
this.modal = true; this.modal = true;
this.message = ''; this.message = '';
this.error = false; this.error = false;
@@ -4700,7 +4730,10 @@ document.addEventListener('alpine:init', () => {
}, },
showSourceTab(tab) { showSourceTab(tab) {
this.sourceTab = ['youtube', 'torrents', 'files', 'uploads'].includes(tab) ? tab : 'youtube'; const tabs = ['files', 'uploads'];
if (this.youtubeDownloadsEnabled) tabs.unshift('youtube');
if (this.torrentDownloadsEnabled) tabs.unshift('torrents');
this.sourceTab = tabs.includes(tab) ? tab : tabs[0];
this._setMessage(''); this._setMessage('');
if (this.sourceTab === 'youtube') this.loadYoutubeJobs(); if (this.sourceTab === 'youtube') this.loadYoutubeJobs();
else if (this.sourceTab === 'uploads') { else if (this.sourceTab === 'uploads') {
@@ -4721,6 +4754,31 @@ document.addEventListener('alpine:init', () => {
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error || T.youtubeLoadFailed); if (!res.ok) throw new Error(data?.error || T.youtubeLoadFailed);
this.youtubeJobs = Array.isArray(data) ? data : []; this.youtubeJobs = Array.isArray(data) ? data : [];
if (
this.youtubeExpandedJobId !== null
&& !this.youtubeJobs.some(job => job.id === this.youtubeExpandedJobId)
) {
this.youtubeExpandedJobId = null;
}
if (!this.youtubeJobsInitialized) {
const activeJob = this.youtubeJobs.find(job => !this.youtubeJobTerminal(job.status));
this.youtubeExpandedJobId = this.youtubePreview ? null : (activeJob?.id || null);
this.youtubeJobsInitialized = true;
}
const playlistSyncKey = this.youtubeJobs
.filter(job => Number(job.target_playlist_id || 0) > 0)
.map(job => [
job.id,
job.status,
Number(job.completed_items || 0),
Number(job.failed_items || 0),
Number(job.review_items || 0),
].join(':'))
.join('|');
if (playlistSyncKey && playlistSyncKey !== this.youtubePlaylistSyncKey) {
this.youtubePlaylistSyncKey = playlistSyncKey;
Alpine.store('playlists')?.reload?.();
}
} catch (err) { } catch (err) {
if (!silent) this._setMessage(err.message || T.youtubeLoadFailed, true); if (!silent) this._setMessage(err.message || T.youtubeLoadFailed, true);
} finally { } finally {
@@ -4731,6 +4789,8 @@ document.addEventListener('alpine:init', () => {
clearYoutubePreview() { clearYoutubePreview() {
this.youtubePreview = null; this.youtubePreview = null;
this.youtubePreviewSelected = new Set(); this.youtubePreviewSelected = new Set();
this.youtubePlaylistChoice = '';
this.youtubeNewPlaylistTitle = '';
}, },
async previewYoutubeUrl() { async previewYoutubeUrl() {
@@ -4754,6 +4814,7 @@ document.addEventListener('alpine:init', () => {
this.youtubePreviewSelected = new Set( this.youtubePreviewSelected = new Set(
items.filter(item => item.selected_by_default).map(item => item.source_id) items.filter(item => item.selected_by_default).map(item => item.source_id)
); );
this.youtubeExpandedJobId = null;
this._setMessage(''); this._setMessage('');
} catch (err) { } catch (err) {
this._setMessage(err.message || T.youtubePreviewFailed, true); this._setMessage(err.message || T.youtubePreviewFailed, true);
@@ -4786,24 +4847,90 @@ document.addEventListener('alpine:init', () => {
return this.youtubePreviewSelected.size; return this.youtubePreviewSelected.size;
}, },
youtubeOwnedPlaylists() {
return (Alpine.store('playlists')?.list || []).filter(playlist => (
playlist.kind === 'user' && playlist.is_own && Number(playlist.id) > 0
));
},
youtubePlaylistChoiceChanged() {
if (
this.youtubePlaylistChoice === '__new__'
&& !String(this.youtubeNewPlaylistTitle || '').trim()
) {
this.youtubeNewPlaylistTitle = String(this.youtubePreview?.title || '').slice(0, 255);
}
},
youtubeDestinationValid() {
return this.youtubePlaylistChoice !== '__new__'
|| String(this.youtubeNewPlaylistTitle || '').trim().length > 0;
},
youtubePlaylistTitle(playlistId) {
const wanted = Number(playlistId || 0);
return this.youtubeOwnedPlaylists().find(playlist => Number(playlist.id) === wanted)?.title || '';
},
async resolveYoutubeTargetPlaylist() {
if (this.youtubePlaylistChoice !== '__new__') {
const playlistId = Number(this.youtubePlaylistChoice || 0);
return playlistId > 0 ? playlistId : null;
}
const title = String(this.youtubeNewPlaylistTitle || '').trim();
if (!title) throw new Error(T.youtubePlaylistCreateFailed);
const res = await fetch('/api/player/playlists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
const playlist = await res.json().catch(() => null);
if (!res.ok || Number(playlist?.id || 0) <= 0) {
throw new Error(playlist?.error || T.youtubePlaylistCreateFailed);
}
// Switch to the created playlist before starting the job. If the
// YouTube request fails, retrying will reuse it instead of creating
// another playlist with the same name.
this.youtubePlaylistChoice = String(playlist.id);
const playlists = Alpine.store('playlists');
if (playlists) {
playlists.list = [
...(playlists.list || []).filter(item => Number(item.id) !== Number(playlist.id)),
playlist,
];
await playlists.reload();
}
return Number(playlist.id);
},
async startYoutubeDownload() { async startYoutubeDownload() {
const preview = this.youtubePreview; const preview = this.youtubePreview;
const selectedSourceIds = Array.from(this.youtubePreviewSelected); const selectedSourceIds = Array.from(this.youtubePreviewSelected);
if (!preview || !selectedSourceIds.length || this.youtubeSubmitting) return; if (
!preview
|| !selectedSourceIds.length
|| !this.youtubeDestinationValid()
|| this.youtubeSubmitting
) return;
this.youtubeSubmitting = true; this.youtubeSubmitting = true;
this._setMessage(T.youtubeStarting); this._setMessage(T.youtubeStarting);
try { try {
const targetPlaylistId = await this.resolveYoutubeTargetPlaylist();
const res = await fetch('/api/player/youtube/start', { const res = await fetch('/api/player/youtube/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
url: preview.source_url, url: preview.source_url,
selected_source_ids: selectedSourceIds, selected_source_ids: selectedSourceIds,
target_playlist_id: targetPlaylistId,
}), }),
}); });
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error || T.youtubeStartFailed); if (!res.ok) throw new Error(data?.error || T.youtubeStartFailed);
this.youtubeJobs = [data, ...this.youtubeJobs.filter(job => job.id !== data.id)]; this.youtubeJobs = [data, ...this.youtubeJobs.filter(job => job.id !== data.id)];
this.youtubeExpandedJobId = data.id;
this.youtubeUrl = ''; this.youtubeUrl = '';
this.clearYoutubePreview(); this.clearYoutubePreview();
this._setMessage(T.youtubeStarted); this._setMessage(T.youtubeStarted);
@@ -4935,11 +5062,21 @@ document.addEventListener('alpine:init', () => {
return ['queued', 'resolving', 'downloading', 'postprocessing'].includes(String(status || '').toLowerCase()); return ['queued', 'resolving', 'downloading', 'postprocessing'].includes(String(status || '').toLowerCase());
}, },
youtubeJobExpanded(jobId) {
return this.youtubeExpandedJobId === jobId;
},
toggleYoutubeJob(jobId) {
this.youtubeExpandedJobId = this.youtubeExpandedJobId === jobId ? null : jobId;
},
youtubeJobMeta(job) { youtubeJobMeta(job) {
const kind = job.source_kind === 'playlist' ? T.youtubePlaylist : T.youtubeVideo; const kind = job.source_kind === 'playlist' ? T.youtubePlaylist : T.youtubeVideo;
const parts = [kind]; const parts = [kind];
if (Number(job.total_items || 0) > 0) parts.push(Number(job.total_items) + ' ' + T.youtubeItems); 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); if (Number(job.failed_items || 0) > 0) parts.push(Number(job.failed_items) + ' ' + T.youtubeErrors);
const playlistTitle = this.youtubePlaylistTitle(job.target_playlist_id);
if (playlistTitle) parts.push(T.youtubeAddedTo + ' ' + playlistTitle);
return parts.join(' · '); return parts.join(' · ');
}, },
@@ -5890,6 +6027,7 @@ document.addEventListener('alpine:init', () => {
this.uploadProgressText = this.uploadProgress.toFixed(1) + '%'; this.uploadProgressText = this.uploadProgress.toFixed(1) + '%';
}; };
xhr.onload = () => { xhr.onload = () => {
redirectOnUnauthorized(xhr.status);
let data = {}; let data = {};
try { data = JSON.parse(xhr.responseText || '{}'); } catch {} try { data = JSON.parse(xhr.responseText || '{}'); } catch {}
if (xhr.status >= 200 && xhr.status < 300) resolve(data); if (xhr.status >= 200 && xhr.status < 300) resolve(data);
+2
View File
@@ -325,6 +325,7 @@
<span class="search-shortcut">Ctrl+K</span> <span class="search-shortcut">Ctrl+K</span>
</template> </template>
</div> </div>
{% if downloads_enabled %}
<button class="torrent-import-btn" <button class="torrent-import-btn"
@click="$store.torrents.open()" @click="$store.torrents.open()"
title="{{ t.player_import_torrent }}"> title="{{ t.player_import_torrent }}">
@@ -334,6 +335,7 @@
<line x1="12" y1="15" x2="12" y2="3"/> <line x1="12" y1="15" x2="12" y2="3"/>
</svg> </svg>
</button> </button>
{% endif %}
<button class="mobile-account-chip" <button class="mobile-account-chip"
x-show="$store.user.profile" x-show="$store.user.profile"
x-cloak x-cloak
+140 -6
View File
@@ -3291,6 +3291,11 @@ button.user-stat:hover {
overflow: hidden; overflow: hidden;
} }
.torrent-modal.youtube-mode {
width: min(1440px, calc(100vw - 32px));
max-width: 1440px;
}
.torrent-modal-head { .torrent-modal-head {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -3613,8 +3618,8 @@ button.user-stat:hover {
} }
.youtube-preview-card { .youtube-preview-card {
min-height: 0; min-height: 370px;
flex: 0 1 360px; flex: 1 1 520px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
@@ -3626,6 +3631,7 @@ button.user-stat:hover {
.youtube-preview-head, .youtube-preview-head,
.youtube-preview-footer { .youtube-preview-footer {
flex: 0 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -3657,13 +3663,55 @@ button.user-stat:hover {
} }
.youtube-preview-list { .youtube-preview-list {
min-height: 72px; min-height: 190px;
flex: 1 1 260px;
overflow-y: auto; overflow-y: auto;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 7px; border-radius: 7px;
background: var(--bg-secondary); background: var(--bg-secondary);
} }
.youtube-preview-destination {
flex: 0 0 auto;
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
grid-template-rows: auto auto;
column-gap: 14px;
row-gap: 4px;
align-items: center;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 7px;
background: var(--bg-secondary);
}
.youtube-preview-destination > label {
grid-column: 1;
grid-row: 1;
margin: 0;
}
.youtube-preview-destination-fields {
grid-column: 2;
grid-row: 1 / 3;
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.youtube-preview-destination-fields.creating {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.youtube-preview-destination p {
grid-column: 1;
grid-row: 2;
margin: 0;
color: var(--text-subdued);
font-size: 10px;
line-height: 1.4;
}
.youtube-preview-row { .youtube-preview-row {
display: grid; display: grid;
grid-template-columns: auto 30px minmax(0, 1fr); grid-template-columns: auto 30px minmax(0, 1fr);
@@ -3722,6 +3770,7 @@ button.user-stat:hover {
.youtube-download-list { .youtube-download-list {
min-height: 0; min-height: 0;
flex: 1 1 180px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
@@ -3737,10 +3786,63 @@ button.user-stat:hover {
.youtube-job-card { .youtube-job-card {
flex: 0 0 auto; flex: 0 0 auto;
padding: 13px; padding: 0;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 9px; border-radius: 9px;
background: var(--bg-primary); background: var(--bg-primary);
overflow: hidden;
}
.youtube-job-card.collapsed {
border-color: rgba(255,255,255,0.08);
}
.youtube-job-summary {
width: 100%;
min-height: 50px;
display: grid;
grid-template-columns: 16px minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
padding: 10px 13px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.youtube-job-summary:hover {
background: var(--bg-hover);
}
.youtube-job-chevron {
width: 8px;
height: 8px;
border-right: 2px solid var(--text-subdued);
border-bottom: 2px solid var(--text-subdued);
transform: rotate(45deg) translate(-2px, -2px);
transition: transform 140ms ease;
}
.youtube-job-card.collapsed .youtube-job-chevron {
transform: rotate(-45deg);
}
.youtube-job-card.collapsed .youtube-job-meta {
display: none;
}
.youtube-job-compact-progress {
color: var(--text-subdued);
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.youtube-job-details {
padding: 0 13px 13px;
} }
.youtube-job-head, .youtube-job-head,
@@ -3781,7 +3883,7 @@ button.user-stat:hover {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
margin-top: 9px; margin-top: 0;
color: var(--text-subdued); color: var(--text-subdued);
font-size: 11px; font-size: 11px;
} }
@@ -6462,6 +6564,11 @@ button.user-stat:hover {
overflow: hidden; overflow: hidden;
} }
.torrent-modal.youtube-mode {
width: 100vw;
max-width: none;
}
.torrent-modal-head { .torrent-modal-head {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@@ -6533,7 +6640,8 @@ button.user-stat:hover {
.youtube-preview-card { .youtube-preview-card {
flex-basis: auto; flex-basis: auto;
max-height: 330px; min-height: 420px;
max-height: none;
} }
.youtube-preview-head, .youtube-preview-head,
@@ -6546,6 +6654,32 @@ button.user-stat:hover {
flex-wrap: wrap; flex-wrap: wrap;
} }
.youtube-preview-destination-fields {
grid-column: auto;
grid-row: auto;
grid-template-columns: 1fr;
}
.youtube-preview-destination {
display: block;
}
.youtube-preview-destination > label {
margin-bottom: 7px;
}
.youtube-preview-destination p {
margin-top: 7px;
}
.youtube-job-summary {
grid-template-columns: 16px minmax(0, 1fr) auto;
}
.youtube-job-summary .youtube-job-compact-progress {
display: none;
}
.youtube-download-list { .youtube-download-list {
overflow: visible; overflow: visible;
} }