Compare commits

...
4 Commits
Author SHA1 Message Date
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
18 changed files with 2979 additions and 210 deletions
Generated
+1 -1
View File
@@ -1938,7 +1938,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "furumusic"
version = "0.10.2"
version = "0.10.4"
dependencies = [
"anyhow",
"async-stream",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "furumusic"
version = "0.10.3"
version = "0.10.4"
edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
+56 -13
View File
@@ -711,6 +711,34 @@ impl App for AdminApp {
},
"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 ----------------------------------------------------
Route::with_handler_and_name(
"/",
@@ -1069,19 +1097,34 @@ impl App for AdminApp {
),
"admin_releases_edit",
),
Route::with_handler_and_name(
"/releases/{id}/delete",
cot::router::method::post(
|session: Session, db: Database, path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::releases_delete(admin, &db, path.0.id).await
},
),
"admin_releases_delete",
),
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
Route::with_handler_and_name(
"/releases/{id}/delete",
cot::router::method::post(move |session: Session, db: Database, path: Path<PathId>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
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 --------------------------------------------------
Route::with_handler_and_name(
"/media-files",
+396 -97
View File
@@ -16,7 +16,7 @@ use sqlx::{PgPool, Postgres, QueryBuilder};
use super::BUILD_INFO;
use crate::agent;
use crate::auth::{self, AuthenticatedUser, Role};
use crate::config::{AppConfig, ConfigEntry, ConfigSources};
use crate::config::{AppConfig, ConfigEntry, ConfigSources, DownloadProxy};
use crate::i18n::{I18n, Translations};
use crate::scheduler::{self, JobRegistry, JobRun, ScheduledJob};
@@ -69,6 +69,21 @@ pub(super) struct BulkLibraryRequest {
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)]
pub struct MetadataBackfillRunRequest {
#[serde(default = "default_true")]
@@ -417,6 +432,14 @@ struct MutationResponse {
affected: u64,
}
#[derive(Debug, Serialize, JsonSchema)]
struct MergeReleasesResponse {
ok: bool,
merged_releases: u64,
moved_tracks: u64,
item: LibraryItemDto,
}
#[derive(Debug, Serialize, JsonSchema)]
struct AdminSettingsDto {
values: AdminSettingsValues,
@@ -462,6 +485,50 @@ struct AdminSettingsValues {
similarity_profile: String,
#[serde(default = "default_similarity_workers")]
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,
}
#[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)]
@@ -493,6 +560,12 @@ struct AdminSettingsSources {
similarity_model: &'static str,
similarity_profile: &'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,
}
#[derive(Debug, Deserialize)]
@@ -531,6 +604,18 @@ pub(super) struct UpdateSettingsRequest {
similarity_profile: String,
#[serde(default = "default_similarity_workers")]
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,
}
fn default_similarity_model() -> String {
@@ -597,6 +682,7 @@ struct LibraryItemDetailDto {
release_id: Option<i64>,
track_number: Option<i32>,
disc_number: Option<i32>,
current_image_file_id: Option<i64>,
current_image_url: Option<String>,
selected_artist_ids: Vec<i64>,
artists: Vec<ArtistOptionDto>,
@@ -980,15 +1066,15 @@ pub async fn update_settings(
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
let similarity_model = body.similarity_model.trim();
if crate::similarity::model_by_id(similarity_model).is_none() {
let similarity_model = body.similarity_model.trim().to_string();
if crate::similarity::model_by_id(&similarity_model).is_none() {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"unknown similarity model",
));
}
let similarity_profile = body.similarity_profile.trim();
if crate::similarity::profile_by_id(similarity_profile).is_none() {
let similarity_profile = body.similarity_profile.trim().to_string();
if crate::similarity::profile_by_id(&similarity_profile).is_none() {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"unknown similarity preprocessing profile",
@@ -1003,6 +1089,47 @@ 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();
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"),
));
}
}
let download_proxies_json = serde_json::to_string(&download_proxies)
.map_err(|error| cot::Error::internal(error.to_string()))?;
let fields = [
(
"auth_password_enabled",
@@ -1058,9 +1185,21 @@ pub async fn update_settings(
body.federation_save_on_listen.to_string(),
),
("similarity_enabled", body.similarity_enabled.to_string()),
("similarity_model", similarity_model.to_string()),
("similarity_profile", similarity_profile.to_string()),
("similarity_model", similarity_model),
("similarity_profile", similarity_profile),
("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),
];
for (key, value) in fields {
let mut entry = ConfigEntry::new(key.to_string(), value);
@@ -1235,6 +1374,15 @@ pub async fn settings_probe(
}
fn settings_dto(config: AppConfig, sources: ConfigSources) -> 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 {
lastfm_api_key_configured: !config.lastfm_api_key.trim().is_empty(),
lastfm_shared_secret_configured: !config.lastfm_shared_secret.trim().is_empty(),
@@ -1268,6 +1416,12 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
similarity_model: config.similarity_model,
similarity_profile: config.similarity_profile,
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,
},
sources: AdminSettingsSources {
auth_password_enabled: sources.auth_password_enabled.code(),
@@ -1297,6 +1451,12 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
similarity_model: sources.similarity_model.code(),
similarity_profile: sources.similarity_profile.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(),
},
}
}
@@ -1930,10 +2090,182 @@ pub async fn bulk_library(
.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()
}
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(
session: &Session,
db: &Database,
@@ -3003,6 +3335,7 @@ async fn load_library_item_detail(
release_id: None,
track_number: None,
disc_number: None,
current_image_file_id: None,
current_image_url: None,
selected_artist_ids: Vec::new(),
artists: Vec::new(),
@@ -3023,6 +3356,7 @@ async fn load_library_item_detail(
.flatten();
detail.current_image_url =
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?;
}
"releases" => {
@@ -3037,6 +3371,7 @@ async fn load_library_item_detail(
detail.year = year;
detail.current_image_url =
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>(
"SELECT artist_id AS id FROM furumusic__release_artist WHERE release_id = $1 ORDER BY position, artist_id",
@@ -3484,10 +3819,11 @@ async fn apply_library_action(
kind: &str,
action: &str,
ids: &[i64],
storage_dir: &str,
) -> cot::Result<u64> {
match action {
"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),
}
}
@@ -3538,10 +3874,15 @@ async fn set_library_visibility(
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 {
"releases" => delete_releases(pool, ids).await,
"tracks" => delete_tracks(pool, ids).await,
"releases" => delete_releases(pool, ids, storage_dir).await,
"tracks" => delete_tracks(pool, ids, storage_dir).await,
"playlists" => delete_playlists(pool, ids).await,
_ => delete_artists(pool, ids).await,
}
@@ -3571,95 +3912,16 @@ async fn delete_artists(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> {
Ok(result.rows_affected())
}
async fn delete_releases(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> {
let track_ids =
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)
async fn delete_releases(pool: &PgPool, ids: &[i64], storage_dir: &str) -> cot::Result<u64> {
crate::library_cleanup::delete_releases(pool, ids, storage_dir)
.await
.map_err(|e| cot::Error::internal(e.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())
.map_err(|error| cot::Error::internal(error.to_string()))
}
async fn delete_tracks(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> {
sqlx::query("DELETE FROM furumusic__playlist_track WHERE track_id = ANY($1)")
.bind(ids)
.execute(pool)
async fn delete_tracks(pool: &PgPool, ids: &[i64], storage_dir: &str) -> cot::Result<u64> {
crate::library_cleanup::delete_tracks(pool, ids, storage_dir)
.await
.map_err(|e| cot::Error::internal(e.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())
.map_err(|error| cot::Error::internal(error.to_string()))
}
async fn delete_playlists(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> {
@@ -4046,6 +4308,24 @@ fn parse_optional_admin_i32(value: Option<&str>, min: i32, max: i32) -> Option<i
.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>
where
D: Deserializer<'de>,
@@ -4119,3 +4399,22 @@ fn size_display(bytes: i64) -> String {
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(
_admin: AuthenticatedUser,
db: &Database,
pool: &sqlx::PgPool,
release_id: i64,
) -> 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
.map_err(|e| cot::Error::internal(format!("failed to delete release: {e}")))?;
Ok(auth::redirect("/admin/releases"))
+195
View File
@@ -142,6 +142,12 @@ pub struct ConfigSources {
pub similarity_model: ConfigSource,
pub similarity_profile: 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,
}
impl Default for ConfigSources {
@@ -176,6 +182,12 @@ impl Default for ConfigSources {
similarity_model: ConfigSource::Default,
similarity_profile: 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,
}
}
}
@@ -238,6 +250,84 @@ macro_rules! impl_env_overrides {
// 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)]
pub struct AppConfig {
/// PostgreSQL connection URL.
@@ -301,6 +391,18 @@ pub struct AppConfig {
pub similarity_profile: String,
/// Maximum number of concurrent CPU embedding workers.
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,
}
impl Default for AppConfig {
@@ -337,6 +439,13 @@ impl Default for AppConfig {
similarity_workers: std::thread::available_parallelism()
.map(|count| (count.get() / 2).clamp(1, 4) as u64)
.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(),
}
}
}
@@ -372,6 +481,12 @@ impl_env_overrides!(
similarity_model,
similarity_profile,
similarity_workers,
downloads_enabled,
torrent_downloads_enabled,
youtube_downloads_enabled,
download_proxies,
torrent_proxy_id,
youtube_proxy_id,
);
impl AppConfig {
@@ -506,6 +621,39 @@ impl AppConfig {
apply_db_field!(similarity_model);
apply_db_field!(similarity_profile);
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);
}
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 +680,53 @@ mod tests {
crate::similarity::DEFAULT_PROFILE_ID
);
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]
+81 -16
View File
@@ -988,23 +988,59 @@ pub async fn finalize_approved(
})?
};
let media_file = 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),
let reusable_media_id: Option<i64> = sqlx::query_scalar(
r#"SELECT media.id
FROM furumusic__media_file media
WHERE media.file_type = 'audio'
AND media.file_path = $1
AND media.sha256_hash = $2
AND NOT EXISTS (
SELECT 1 FROM furumusic__track track
WHERE track.audio_file_id = media.id OR track.cover_file_id = media.id
)
AND NOT EXISTS (
SELECT 1 FROM furumusic__release release
WHERE release.cover_file_id = media.id
)
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
.map_err(|e| anyhow::anyhow!("failed to create media file: {e}"))?;
.bind(&storage_path)
.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(
db,
@@ -1020,6 +1056,35 @@ pub async fn finalize_approved(
.await
.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)
.await
.map_err(|e| anyhow::anyhow!("failed to link track-artist: {e}"))?;
+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]));
}
}
+8 -1
View File
@@ -7,6 +7,7 @@ mod federation;
mod i18n;
mod jobs;
mod lastfm;
mod library_cleanup;
mod local_uploads;
mod media_paths;
mod metrics;
@@ -90,7 +91,13 @@ async fn index(
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()
}
+1
View File
@@ -841,6 +841,7 @@ const KNOWN_HTTP_ROUTES: &[&str] = &[
"/admin/v2/api/library/item/image",
"/admin/v2/api/library/item/upload-image",
"/admin/v2/api/library/bulk",
"/admin/v2/api/library/releases/merge",
"/admin/debug",
"/admin/settings",
"/admin/settings/probe",
+68
View File
@@ -2715,6 +2715,73 @@ pub mod db_migrations {
&[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] = &[
&M0006CreateMediaFile,
&M0007CreateArtist,
@@ -2752,5 +2819,6 @@ pub mod db_migrations {
&M0044AddSimilarityRoutingSignature,
&M0045CreateYouTubeDownloads,
&M0046CreateLocalUploadHistory,
&M0047CreateYouTubeImportMediaLinks,
];
}
+155 -4
View File
@@ -49,6 +49,50 @@ fn json_error(status: StatusCode, message: &str) -> cot::response::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()))
}
#[derive(serde::Serialize)]
struct LocalUploadResponse {
ok: bool,
@@ -1375,6 +1419,40 @@ struct LastfmCallbackQuery {
#[template(path = "player.html")]
pub struct PlayerPageTemplate {
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 +4916,9 @@ async fn local_upload_handler(
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
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();
if inbox_dir.is_empty() {
@@ -4958,6 +5039,9 @@ async fn local_upload_history_handler(
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::list(pool, user.id, &config.agent_inbox_dir).await {
Ok(items) => Json(items).into_response(),
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string())),
@@ -4974,6 +5058,10 @@ async fn local_upload_history_remove_handler(
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
};
let (config, _) = AppConfig::load_with_db(&db).await;
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 {
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string())),
@@ -8446,12 +8534,20 @@ impl App for PlayerApp {
"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 service = youtube_service
.get_or_init(|| async {
Arc::new(YouTubeService::new(Arc::clone(&scheduler_handle)))
})
.await;
match service.preview(json.0).await {
match service.preview(json.0, proxy_url.as_deref()).await {
Ok(preview) => Json(preview).into_response(),
Err(err) => {
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
@@ -8500,8 +8596,20 @@ impl App for PlayerApp {
})
.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),
};
match service
.list(pg_pool, user.id, &live_config.agent_inbox_dir)
.list(
pg_pool,
user.id,
&live_config.agent_inbox_dir,
proxy_url,
)
.await
{
Ok(items) => Json(items).into_response(),
@@ -8555,12 +8663,20 @@ impl App for PlayerApp {
})
.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),
};
match service
.start(
pg_pool,
user.id,
json.0,
&live_config.agent_inbox_dir,
proxy_url,
)
.await
{
@@ -8615,12 +8731,20 @@ impl App for PlayerApp {
})
.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),
};
match service
.retry(
pg_pool,
user.id,
&path.0.id,
&live_config.agent_inbox_dir,
proxy_url,
)
.await
{
@@ -8778,12 +8902,20 @@ impl App for PlayerApp {
.expect("player pool")
})
.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
.get_or_init(|| async {
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
})
.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(),
Err(err) => {
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
@@ -8927,12 +9059,23 @@ impl App for PlayerApp {
.expect("player pool")
})
.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
.get_or_init(|| async {
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
})
.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(),
Err(err) => {
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
@@ -9284,6 +9427,13 @@ impl App for PlayerApp {
})
.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
.get_or_init(|| async {
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
@@ -9296,6 +9446,7 @@ impl App for PlayerApp {
json.0.selected_files,
live_config.agent_inbox_dir,
user.id,
proxy_url.as_deref(),
)
.await
{
+70 -39
View File
@@ -373,7 +373,8 @@ impl TorrentJob {
pub struct TorrentService {
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>>,
resolving_jobs: Mutex<HashSet<String>>,
scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>,
@@ -383,36 +384,47 @@ impl TorrentService {
pub fn new(scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>) -> Self {
Self {
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()),
resolving_jobs: Mutex::new(HashSet::new()),
scheduler_handle,
}
}
async fn session(&self) -> anyhow::Result<Arc<Session>> {
let temp_root = self.temp_root.clone();
self.session
.get_or_try_init(|| async move {
tokio::fs::create_dir_all(&temp_root).await?;
Session::new_with_opts(
temp_root,
SessionOptions {
disable_upload: true,
enable_upnp_port_forwarding: false,
..Default::default()
},
)
.await
})
.await
.cloned()
async fn session(&self, proxy_url: Option<&str>) -> anyhow::Result<Arc<Session>> {
let key = proxy_url.unwrap_or_default().to_string();
let mut sessions = self.sessions.lock().await;
if let Some(session) = sessions.get(&key) {
return Ok(Arc::clone(session));
}
tokio::fs::create_dir_all(&self.temp_root).await?;
let session = Session::new_with_opts(
self.temp_root.clone(),
SessionOptions {
// SOCKS is intentionally limited to peer TCP and HTTP(S)
// tracker traffic. DHT and other UDP discovery stay direct.
disable_dht: false,
// Sessions are keyed by proxy and can coexist, so they cannot
// safely share one persisted DHT socket configuration.
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(
self: &Arc<Self>,
pool: &PgPool,
user_id: i64,
proxy_url: Option<String>,
) -> anyhow::Result<Vec<TorrentJobDto>> {
let rows = sqlx::query_as::<_, TorrentSessionRow>(
r#"SELECT id, user_id, name, info_hash, source_kind, source_label, torrent_bytes,
@@ -445,6 +457,7 @@ impl TorrentService {
row.id.clone(),
magnet,
row.created_at.clone(),
proxy_url.clone(),
)
.await;
}
@@ -483,8 +496,9 @@ impl TorrentService {
pool: &PgPool,
user_id: i64,
request: TorrentPreviewRequest,
proxy_url: Option<&str>,
) -> anyhow::Result<TorrentSessionDto> {
let session = self.session().await?;
let session = self.session(proxy_url).await?;
let id = Uuid::new_v4().to_string();
let output_dir = self.temp_root.join(&id).join("download");
tokio::fs::create_dir_all(&output_dir).await?;
@@ -511,8 +525,15 @@ impl TorrentService {
.unwrap_or_else(|| info_hash.clone());
let now = now_string();
insert_pending_magnet(pool, &id, user_id, &name, &info_hash, &magnet, &now).await?;
self.spawn_resolve_pending_magnet(pool.clone(), user_id, id.clone(), magnet, now)
.await;
self.spawn_resolve_pending_magnet(
pool.clone(),
user_id,
id.clone(),
magnet,
now,
proxy_url.map(str::to_owned),
)
.await;
let row = load_row(pool, user_id, &id).await?;
return Ok(TorrentSessionDto {
@@ -611,6 +632,7 @@ impl TorrentService {
id: String,
magnet: String,
created_at: String,
proxy_url: Option<String>,
) {
{
let mut resolving = self.resolving_jobs.lock().await;
@@ -622,7 +644,14 @@ impl TorrentService {
let service = Arc::clone(self);
tokio::spawn(async move {
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;
if let Err(err) = result {
update_resolving_error(&pool, &id, &err.to_string()).await;
@@ -638,8 +667,9 @@ impl TorrentService {
id: &str,
magnet: &str,
created_at: &str,
proxy_url: Option<&str>,
) -> anyhow::Result<()> {
let session = self.session().await?;
let session = self.session(proxy_url).await?;
let output_dir = self.temp_root.join(id).join("download");
tokio::fs::create_dir_all(&output_dir).await?;
let response = tokio::time::timeout(
@@ -743,7 +773,7 @@ impl TorrentService {
jobs.remove(id).and_then(|job| job.handle)
};
if let Some(handle) = removed {
self.stop_torrent(&handle).await;
self.stop_torrent(id, &handle).await;
}
let result =
@@ -766,6 +796,7 @@ impl TorrentService {
selected_files: Vec<usize>,
inbox_dir: String,
uploader_user_id: i64,
proxy_url: Option<&str>,
) -> anyhow::Result<TorrentJobDto> {
if selected_files.is_empty() {
bail!("select at least one file");
@@ -810,7 +841,7 @@ impl TorrentService {
tokio::fs::create_dir_all(&output_dir).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
.add_torrent(
AddTorrent::from_bytes(torrent_bytes),
@@ -838,6 +869,10 @@ impl TorrentService {
return Err(err);
}
};
self.job_sessions
.lock()
.await
.insert(id.to_string(), Arc::clone(&session));
let dto = {
let mut jobs = self.jobs.lock().await;
@@ -856,7 +891,7 @@ impl TorrentService {
if service.is_paused(&id).await {
return;
}
service.stop_torrent(&handle).await;
service.stop_torrent(&id, &handle).await;
service.fail_job(&pool, &id, err.to_string()).await;
crate::metrics::record_torrent_download(
"failed",
@@ -865,7 +900,7 @@ impl TorrentService {
);
return;
}
service.stop_torrent(&handle).await;
service.stop_torrent(&id, &handle).await;
if let Err(err) = service
.finalize_completed(&pool, &id, &inbox_dir, uploader_user_id)
.await
@@ -911,7 +946,7 @@ impl TorrentService {
persist_progress(pool, &dto).await?;
if let Some(handle) = handle {
self.stop_torrent(&handle).await;
self.stop_torrent(id, &handle).await;
}
Ok(dto)
}
@@ -981,16 +1016,12 @@ impl TorrentService {
}
}
async fn stop_torrent(&self, handle: &Arc<ManagedTorrent>) {
match self.session().await {
Ok(session) => {
if let Err(err) = session.delete(handle.id().into(), false).await {
tracing::warn!("failed to stop completed torrent: {err}");
}
}
Err(err) => {
tracing::warn!("failed to access torrent session for shutdown: {err}");
}
async fn stop_torrent(&self, id: &str, handle: &Arc<ManagedTorrent>) {
let session = self.job_sessions.lock().await.remove(id);
if let Some(session) = session
&& let Err(err) = session.delete(handle.id().into(), false).await
{
tracing::warn!("failed to stop completed torrent: {err}");
}
}
+336 -32
View File
@@ -19,6 +19,8 @@ use crate::scheduler::SchedulerHandle;
const YOUTUBE_LIST_LIMIT: i64 = 100;
const RESOLVE_TIMEOUT: Duration = Duration::from_secs(180);
const HTTP_403_RETRY_DELAY: Duration = Duration::from_secs(30);
const HTTP_403_MAX_RETRIES: usize = 3;
const MAX_ERROR_LEN: usize = 4_000;
const AUDIO_EXTENSIONS: &[&str] = &[
"mp3", "flac", "ogg", "opus", "aac", "m4a", "wav", "ape", "wv", "wma", "tta", "aiff", "aif",
@@ -200,6 +202,20 @@ struct PreparedFolder {
all_files_known: bool,
}
#[derive(Debug)]
struct YtDlpDownloadFailure {
message: String,
http_forbidden: bool,
}
impl std::fmt::Display for YtDlpDownloadFailure {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for YtDlpDownloadFailure {}
pub struct YouTubeService {
running_jobs: Mutex<HashSet<String>>,
cancellations: Mutex<HashMap<String, CancellationToken>>,
@@ -220,9 +236,10 @@ impl YouTubeService {
pub async fn preview(
&self,
request: YouTubePreviewRequest,
proxy_url: Option<&str>,
) -> anyhow::Result<YouTubePreviewDto> {
let url = validate_youtube_url(&request.url)?;
let resolved = resolve_source(&url).await?;
let resolved = resolve_source(&url, proxy_url).await?;
let requested_video_id = requested_video_id(&url);
let select_requested_only = resolved.kind == "playlist" && requested_video_id.is_some();
Ok(YouTubePreviewDto {
@@ -252,6 +269,7 @@ impl YouTubeService {
user_id: i64,
request: YouTubeStartRequest,
inbox_dir: &str,
proxy_url: Option<String>,
) -> anyhow::Result<YouTubeJobDto> {
let url = validate_youtube_url(&request.url)?;
validate_inbox_dir(inbox_dir)?;
@@ -268,7 +286,7 @@ impl YouTubeService {
bail!("YouTube selection contains an invalid video ID");
}
let resolved = resolve_source(&url).await?;
let resolved = resolve_source(&url, proxy_url.as_deref()).await?;
let selected_items: Vec<ResolvedItem> = resolved
.items
.into_iter()
@@ -339,7 +357,7 @@ impl YouTubeService {
}
transaction.commit().await?;
self.spawn_job(pool.clone(), id.clone(), inbox_dir.to_string())
self.spawn_job(pool.clone(), id.clone(), inbox_dir.to_string(), proxy_url)
.await;
load_job_dto(pool, user_id, &id).await
}
@@ -349,6 +367,7 @@ impl YouTubeService {
pool: &PgPool,
user_id: i64,
inbox_dir: &str,
proxy_url: Option<String>,
) -> anyhow::Result<Vec<YouTubeJobDto>> {
validate_inbox_dir(inbox_dir)?;
sync_ai_statuses(pool, user_id).await?;
@@ -364,7 +383,7 @@ impl YouTubeService {
.fetch_all(pool)
.await?;
for (id, _) in resumable {
self.spawn_job(pool.clone(), id, inbox_dir.to_string())
self.spawn_job(pool.clone(), id, inbox_dir.to_string(), proxy_url.clone())
.await;
}
@@ -393,6 +412,7 @@ impl YouTubeService {
user_id: i64,
id: &str,
inbox_dir: &str,
proxy_url: Option<String>,
) -> anyhow::Result<YouTubeJobDto> {
validate_inbox_dir(inbox_dir)?;
let job = load_job_row(pool, user_id, id).await?;
@@ -423,8 +443,13 @@ impl YouTubeService {
.execute(pool)
.await?;
self.spawn_job(pool.clone(), id.to_string(), inbox_dir.to_string())
.await;
self.spawn_job(
pool.clone(),
id.to_string(),
inbox_dir.to_string(),
proxy_url,
)
.await;
load_job_dto(pool, user_id, id).await
}
@@ -507,7 +532,13 @@ impl YouTubeService {
Ok(())
}
async fn spawn_job(self: &Arc<Self>, pool: PgPool, id: String, inbox_dir: String) {
async fn spawn_job(
self: &Arc<Self>,
pool: PgPool,
id: String,
inbox_dir: String,
proxy_url: Option<String>,
) {
{
let mut running = self.running_jobs.lock().await;
if !running.insert(id.clone()) {
@@ -527,7 +558,9 @@ impl YouTubeService {
_ = cancel.cancelled() => None,
};
let result = if let Some(permit) = permit {
let result = service.run_job(&pool, &id, &inbox_dir, &cancel).await;
let result = service
.run_job(&pool, &id, &inbox_dir, proxy_url.as_deref(), &cancel)
.await;
drop(permit);
result
} else {
@@ -554,6 +587,7 @@ impl YouTubeService {
pool: &PgPool,
id: &str,
inbox_dir: &str,
proxy_url: Option<&str>,
cancel: &CancellationToken,
) -> anyhow::Result<()> {
if cancel.is_cancelled() {
@@ -576,7 +610,7 @@ impl YouTubeService {
return Ok(());
}
set_parent_status(pool, id, "resolving", None).await?;
let resolved = resolve_source(&job.source_url).await?;
let resolved = resolve_source(&job.source_url, proxy_url).await?;
if cancel.is_cancelled() {
return Ok(());
}
@@ -643,7 +677,7 @@ impl YouTubeService {
continue;
}
if let Err(err) = self
.process_item(pool, &job, &item, &inbox_root, cancel)
.process_item(pool, &job, &item, &inbox_root, proxy_url, cancel)
.await
{
if cancel.is_cancelled() {
@@ -673,6 +707,7 @@ impl YouTubeService {
job: &YouTubeJobRow,
item: &YouTubeItemRow,
inbox_root: &Path,
proxy_url: Option<&str>,
cancel: &CancellationToken,
) -> anyhow::Result<()> {
if cancel.is_cancelled() {
@@ -683,7 +718,41 @@ impl YouTubeService {
let stage = staging_item_root(inbox_root, &job.id, &item.id);
tokio::fs::create_dir_all(&stage).await?;
run_ytdlp_download(pool, &item.id, &item.source_url, &stage, cancel).await?;
let mut forbidden_retries = 0;
loop {
match run_ytdlp_download(pool, &item.id, &item.source_url, &stage, proxy_url, cancel)
.await
{
Ok(()) => break,
Err(error)
if !cancel.is_cancelled()
&& forbidden_retries < HTTP_403_MAX_RETRIES
&& is_http_403_download_failure(&error) =>
{
forbidden_retries += 1;
tracing::warn!(
job_id = %job.id,
item_id = %item.id,
source_id = %item.source_id,
delay_seconds = HTTP_403_RETRY_DELAY.as_secs(),
"yt-dlp received HTTP 403; restarting the download after a delay"
);
set_item_status(
pool,
&item.id,
"downloading",
Some("HTTP 403 received; retrying automatically in 30 seconds"),
)
.await?;
tokio::select! {
_ = tokio::time::sleep(HTTP_403_RETRY_DELAY) => {}
_ = cancel.cancelled() => bail!("YouTube import cancelled"),
}
set_item_status(pool, &item.id, "downloading", None).await?;
}
Err(error) => return Err(error),
}
}
if cancel.is_cancelled() {
bail!("YouTube import cancelled");
@@ -746,8 +815,8 @@ impl YouTubeService {
}
}
async fn resolve_source(url: &str) -> anyhow::Result<ResolvedSource> {
let mut command = base_ytdlp_command();
async fn resolve_source(url: &str, proxy_url: Option<&str>) -> anyhow::Result<ResolvedSource> {
let mut command = base_ytdlp_command(proxy_url);
command
.arg("--flat-playlist")
.arg("--dump-single-json")
@@ -784,11 +853,13 @@ async fn resolve_source(url: &str) -> anyhow::Result<ResolvedSource> {
continue;
}
let title = json_string(entry, "title").unwrap_or_else(|| source_id.clone());
let playlist_index = json_positive_i32(entry, "playlist_index")
.unwrap_or_else(|| i32::try_from(index + 1).unwrap_or(i32::MAX));
items.push(ResolvedItem {
source_url: format!("https://www.youtube.com/watch?v={source_id}"),
source_id,
title,
playlist_index: i32::try_from(index + 1).unwrap_or(i32::MAX),
playlist_index,
});
}
if items.is_empty() {
@@ -816,7 +887,7 @@ async fn resolve_source(url: &str) -> anyhow::Result<ResolvedSource> {
})
}
fn base_ytdlp_command() -> Command {
fn base_ytdlp_command(proxy_url: Option<&str>) -> Command {
let mut command = Command::new("yt-dlp");
command
.arg("--no-config")
@@ -825,6 +896,9 @@ fn base_ytdlp_command() -> Command {
.arg("--js-runtimes")
.arg("deno")
.stdin(Stdio::null());
if let Some(proxy_url) = proxy_url {
command.arg("--proxy").arg(proxy_url);
}
command
}
@@ -833,9 +907,10 @@ async fn run_ytdlp_download(
item_id: &str,
url: &str,
stage: &Path,
proxy_url: Option<&str>,
cancel: &CancellationToken,
) -> anyhow::Result<()> {
let mut command = base_ytdlp_command();
let mut command = base_ytdlp_command(proxy_url);
command
.arg("--no-playlist")
.arg("--continue")
@@ -912,16 +987,39 @@ async fn run_ytdlp_download(
let stdout_lines = stdout_task.await??;
let stderr_lines = stderr_task.await??;
if !exit.success() {
let details = if stderr_lines.is_empty() {
stdout_lines.join("\n")
} else {
stderr_lines.join("\n")
};
bail!("yt-dlp failed: {}", useful_error(&details));
let details = stdout_lines
.into_iter()
.chain(stderr_lines)
.collect::<Vec<_>>()
.join("\n");
return Err(YtDlpDownloadFailure {
message: format!("yt-dlp failed: {}", useful_error(&details)),
http_forbidden: output_reports_http_403(&details),
}
.into());
}
Ok(())
}
fn is_http_403_download_failure(error: &anyhow::Error) -> bool {
error
.downcast_ref::<YtDlpDownloadFailure>()
.is_some_and(|failure| failure.http_forbidden)
}
fn output_reports_http_403(output: &str) -> bool {
let normalized = output.to_ascii_lowercase();
[
"http error 403",
"http status 403",
"status code 403",
"403: forbidden",
"403 forbidden",
]
.iter()
.any(|marker| normalized.contains(marker))
}
#[cfg(unix)]
fn configure_process_group(command: &mut Command) {
use std::os::unix::process::CommandExt as _;
@@ -1090,7 +1188,8 @@ async fn prepare_downloaded_folder(
.and_then(|v| v.to_str())
.unwrap_or("opus");
let destination = stage.join(format!(
"{} [{}].{}",
"{:03} - {} [{}].{}",
item.playlist_index.max(1),
sanitize_component(&item.title),
item.source_id,
extension
@@ -1107,6 +1206,8 @@ async fn prepare_downloaded_folder(
bail!("yt-dlp produced no supported audio files");
}
preserve_track_numbers(&mut audio_files, chapter_count, item.playlist_index, cancel).await?;
for audio in &audio_files {
let data = tokio::select! {
data = tokio::fs::read(audio) => data?,
@@ -1145,10 +1246,17 @@ async fn prepare_downloaded_folder(
if tokio::fs::try_exists(&destination).await? {
let existing = find_audio_files(&destination).await?;
if existing.is_empty() {
bail!("YouTube inbox destination already exists without audio");
// A completed inbox import moves audio into the media library but
// may leave cover.jpg behind. Treat that directory as an orphan so
// deleting a release does not make the same source impossible to
// import again.
tokio::fs::remove_dir_all(&destination).await?;
tokio::fs::rename(stage, &destination).await?;
audio_files = find_audio_files(&destination).await?;
} else {
tokio::fs::remove_dir_all(stage).await?;
audio_files = existing;
}
tokio::fs::remove_dir_all(stage).await?;
audio_files = existing;
} else {
tokio::fs::rename(stage, &destination).await?;
}
@@ -1164,6 +1272,113 @@ async fn prepare_downloaded_folder(
})
}
async fn preserve_track_numbers(
audio_files: &mut [PathBuf],
chapter_count: i32,
playlist_index: i32,
cancel: &CancellationToken,
) -> anyhow::Result<()> {
for (position, audio) in audio_files.iter_mut().enumerate() {
if cancel.is_cancelled() {
bail!("YouTube import cancelled");
}
let track_number = if chapter_count > 0 {
track_number_from_file_name(audio)
.unwrap_or_else(|| i32::try_from(position + 1).unwrap_or(i32::MAX))
} else {
playlist_index.max(1)
};
if track_number_from_file_name(audio) != Some(track_number) {
let numbered =
audio.with_file_name(format!("{track_number:03} - {}", file_name(audio)));
tokio::fs::rename(&*audio, &numbered).await?;
*audio = numbered;
}
embed_track_number(audio, track_number, cancel).await?;
}
Ok(())
}
fn track_number_from_file_name(path: &Path) -> Option<i32> {
let stem = path.file_stem()?.to_str()?.trim_start();
let digit_count = stem.bytes().take_while(u8::is_ascii_digit).count();
if digit_count == 0 {
return None;
}
let separator = stem[digit_count..].trim_start();
if !separator.starts_with('-') && !separator.starts_with('.') {
return None;
}
stem[..digit_count]
.parse::<i32>()
.ok()
.filter(|number| *number > 0)
}
async fn embed_track_number(
audio: &Path,
track_number: i32,
cancel: &CancellationToken,
) -> anyhow::Result<()> {
let extension = audio
.extension()
.and_then(|value| value.to_str())
.unwrap_or("audio");
let temporary =
audio.with_file_name(format!(".furumusic-track-{}.{}", Uuid::new_v4(), extension));
let mut command = Command::new("ffmpeg");
command
.arg("-hide_banner")
.arg("-loglevel")
.arg("error")
.arg("-y")
.arg("-i")
.arg(audio)
.arg("-map")
.arg("0")
.arg("-c")
.arg("copy")
.arg("-metadata")
.arg(format!("track={track_number}"))
.arg(&temporary)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true);
configure_process_group(&mut command);
let output = tokio::select! {
output = command.output() => output?,
_ = cancel.cancelled() => {
let _ = tokio::fs::remove_file(&temporary).await;
bail!("YouTube import cancelled");
}
};
if !output.status.success() {
let _ = tokio::fs::remove_file(&temporary).await;
bail!(
"ffmpeg could not preserve track number {}: {}",
track_number,
useful_error(&String::from_utf8_lossy(&output.stderr))
);
}
let backup = audio.with_file_name(format!(
".furumusic-track-backup-{}.{}",
Uuid::new_v4(),
extension
));
tokio::fs::rename(audio, &backup).await?;
if let Err(error) = tokio::fs::rename(&temporary, audio).await {
let _ = tokio::fs::rename(&backup, audio).await;
let _ = tokio::fs::remove_file(&temporary).await;
return Err(error.into());
}
tokio::fs::remove_file(backup).await?;
Ok(())
}
async fn read_info_json(stage: &Path) -> anyhow::Result<Option<serde_json::Value>> {
let mut entries = tokio::fs::read_dir(stage).await?;
while let Some(entry) = entries.next_entry().await? {
@@ -1437,9 +1652,18 @@ async fn source_already_imported(
FROM furumusic__youtube_download_item i
JOIN furumusic__youtube_download j ON j.id = i.job_id
WHERE j.user_id = $1 AND i.job_id <> $2 AND i.source_id = $3
AND i.status IN (
'queued', 'downloading', 'postprocessing', 'awaiting_ai',
'ai_processing', 'complete', 'needs_review', 'skipped'
AND (
i.status IN (
'queued', 'downloading', 'postprocessing', 'awaiting_ai',
'ai_processing', 'needs_review'
)
OR (
i.status IN ('complete', 'skipped')
AND (SELECT COUNT(*)
FROM furumusic__youtube_import_media imported
WHERE imported.item_id = i.id) >= i.audio_file_count
AND i.audio_file_count > 0
)
)
)"#,
)
@@ -1461,9 +1685,18 @@ async fn already_imported_source_ids(
FROM furumusic__youtube_download_item i
JOIN furumusic__youtube_download j ON j.id = i.job_id
WHERE j.user_id = $1 AND i.source_id = ANY($2)
AND i.status IN (
'queued', 'downloading', 'postprocessing', 'awaiting_ai',
'ai_processing', 'complete', 'needs_review', 'skipped'
AND (
i.status IN (
'queued', 'downloading', 'postprocessing', 'awaiting_ai',
'ai_processing', 'needs_review'
)
OR (
i.status IN ('complete', 'skipped')
AND (SELECT COUNT(*)
FROM furumusic__youtube_import_media imported
WHERE imported.item_id = i.id) >= i.audio_file_count
AND i.audio_file_count > 0
)
)"#,
)
.bind(user_id)
@@ -1681,6 +1914,14 @@ fn json_string(value: &serde_json::Value, key: &str) -> Option<String> {
.map(str::to_string)
}
fn json_positive_i32(value: &serde_json::Value, key: &str) -> Option<i32> {
let value = value.get(key)?;
let parsed = value
.as_i64()
.or_else(|| value.as_str()?.trim().parse::<i64>().ok())?;
i32::try_from(parsed).ok().filter(|value| *value > 0)
}
fn file_name(path: &Path) -> String {
path.file_name()
.and_then(|value| value.to_str())
@@ -1767,6 +2008,69 @@ mod tests {
assert_eq!(sanitize_component("..."), "YouTube audio");
}
#[test]
fn recognizes_http_403_download_failures() {
assert!(output_reports_http_403(
"ERROR: unable to download video data: HTTP Error 403: Forbidden"
));
assert!(output_reports_http_403(
"server returned status code 403 while downloading a fragment"
));
assert!(!output_reports_http_403(
"ERROR: unable to download video data: HTTP Error 404: Not Found"
));
let error = anyhow::Error::new(YtDlpDownloadFailure {
message: "yt-dlp failed".to_string(),
http_forbidden: true,
});
assert!(is_http_403_download_failure(&error));
}
#[test]
fn preserves_playlist_and_chapter_numbers_from_source_metadata() {
let entry = serde_json::json!({"playlist_index": 7});
let string_entry = serde_json::json!({"playlist_index": "12"});
assert_eq!(json_positive_i32(&entry, "playlist_index"), Some(7));
assert_eq!(json_positive_i32(&string_entry, "playlist_index"), Some(12));
assert_eq!(
track_number_from_file_name(Path::new("007 - Playlist track.opus")),
Some(7)
);
assert_eq!(
track_number_from_file_name(Path::new("003 - Chapter title.m4a")),
Some(3)
);
assert_eq!(
track_number_from_file_name(Path::new("1984 remix.webm")),
None
);
}
#[test]
fn ytdlp_command_receives_selected_proxy() {
let command = base_ytdlp_command(Some("socks5://user:pass@127.0.0.1:1080/"));
let args: Vec<String> = command
.as_std()
.get_args()
.map(|argument| argument.to_string_lossy().into_owned())
.collect();
assert!(args.windows(2).any(|pair| {
pair == [
"--proxy".to_string(),
"socks5://user:pass@127.0.0.1:1080/".to_string(),
]
}));
let direct = base_ytdlp_command(None);
assert!(
direct
.as_std()
.get_args()
.all(|argument| argument != "--proxy")
);
}
#[test]
fn extracts_explicit_video_from_playlist_links() {
assert_eq!(
+780 -3
View File
@@ -817,6 +817,7 @@ tbody tr:hover {
"access access access access oidc oidc oidc oidc oidc oidc oidc oidc"
"agent agent agent agent agent agent agent agent agentstatus agentstatus agentstatus agentstatus"
"similarity similarity similarity similarity similarity similarity similarity similarity similaritystatus similaritystatus similaritystatus similaritystatus"
"downloads downloads downloads downloads downloads downloads downloads downloads downloads downloads downloads downloads"
"federation federation federation federation federation federation federation federation federation federation federation federation"
"lastfm lastfm lastfm lastfm lastfm lastfm lastfm lastfm developer developer developer developer"
"actions actions actions actions actions actions actions actions actions actions actions actions";
@@ -838,6 +839,7 @@ tbody tr:hover {
.settings-agent-status { grid-area: agentstatus; }
.settings-similarity { grid-area: similarity; }
.settings-similarity-status { grid-area: similaritystatus; }
.settings-downloads { grid-area: downloads; }
.settings-federation { grid-area: federation; }
.settings-lastfm { grid-area: lastfm; }
.settings-developer { grid-area: developer; }
@@ -918,6 +920,72 @@ tbody tr:hover {
resize: vertical;
}
.download-method-setting {
min-width: 0;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 7px;
background: rgba(255, 255, 255, 0.018);
}
.download-method-setting > label,
.proxy-editor-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 9px;
color: var(--text-secondary);
font-size: 12px;
font-weight: 750;
}
.download-method-controls {
display: grid;
grid-template-columns: minmax(150px, 0.75fr) minmax(220px, 1.25fr);
gap: 10px;
align-items: center;
}
.download-method-controls select,
.proxy-row input {
width: 100%;
height: 34px;
padding: 0 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-primary);
outline: none;
}
.proxy-editor {
grid-column: 1 / -1;
min-width: 0;
padding-top: 4px;
}
.proxy-row {
display: grid;
grid-template-columns: minmax(210px, 1.4fr) minmax(150px, 0.8fr) minmax(150px, 0.8fr) auto;
gap: 10px;
align-items: end;
padding: 10px;
margin-top: 8px;
border: 1px solid var(--border-color);
border-radius: 7px;
background: rgba(255, 255, 255, 0.018);
}
.proxy-row label {
display: grid;
gap: 6px;
min-width: 0;
color: var(--text-subdued);
font-size: 11px;
font-weight: 700;
}
.setting-field input:focus,
.setting-field select:focus,
.setting-field textarea:focus {
@@ -1065,6 +1133,7 @@ tbody tr:hover {
"agentstatus"
"similarity"
"similaritystatus"
"downloads"
"federation"
"lastfm"
"developer"
@@ -1077,6 +1146,8 @@ tbody tr:hover {
.settings-grid,
.federation-status-grid { grid-template-columns: 1fr; }
.setting-field { max-width: none; }
.download-method-controls,
.proxy-row { grid-template-columns: 1fr; }
}
.settings-note {
@@ -1448,6 +1519,159 @@ tbody tr:hover {
font-size: 11px;
}
.merge-modal {
width: min(1120px, calc(100vw - 56px));
}
.merge-release-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 8px;
margin-bottom: 14px;
}
.merge-release-card {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
gap: 10px;
align-items: center;
min-height: 68px;
padding: 9px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-primary);
text-align: left;
cursor: pointer;
}
.merge-release-card:hover,
.merge-release-card.active {
border-color: rgba(29, 185, 84, 0.78);
background: rgba(29, 185, 84, 0.08);
}
.merge-release-cover {
width: 48px;
height: 48px;
border-radius: 6px;
background: var(--bg-elevated);
object-fit: cover;
}
.merge-release-cover.empty {
display: grid;
place-items: center;
padding: 0;
font-size: 10px;
}
.merge-release-title,
.merge-release-meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.merge-release-title {
font-size: 12px;
font-weight: 800;
}
.merge-release-meta {
margin-top: 4px;
color: var(--text-subdued);
font-size: 10px;
}
.merge-cover-grid {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.merge-cover-option {
width: 82px;
padding: 6px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-subdued);
cursor: pointer;
}
.merge-cover-option.active {
border-color: rgba(29, 185, 84, 0.78);
color: var(--text-primary);
}
.merge-cover-option img,
.merge-cover-empty {
display: grid;
place-items: center;
width: 68px;
height: 68px;
margin-bottom: 5px;
border-radius: 5px;
background: var(--bg-elevated);
object-fit: cover;
}
.merge-track-list {
min-width: 760px;
}
.merge-track-head,
.merge-track-row {
display: grid;
grid-template-columns: 52px 70px 74px minmax(180px, 1.5fr) minmax(130px, 1fr) minmax(130px, 1fr) 62px;
gap: 8px;
align-items: center;
padding: 8px;
}
.merge-track-head {
border-bottom: 1px solid var(--border-color);
color: var(--text-subdued);
font-size: 10px;
font-weight: 850;
text-transform: uppercase;
}
.merge-track-row {
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
}
.merge-track-row:last-child {
border-bottom: 0;
}
.merge-track-row input {
width: 100%;
height: 30px;
min-height: 30px;
padding: 0 7px;
}
.merge-order-actions {
display: flex;
gap: 3px;
}
.merge-footer {
position: sticky;
bottom: -14px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin: 14px -14px -14px;
padding: 12px 14px;
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.image-actions {
display: flex;
align-items: center;
@@ -2095,7 +2319,7 @@ tbody tr:hover {
<i data-lucide="plus"></i>
New release
</button>
<button class="btn warn" @click="mockAction('Merge wizard will open from this action slot')">
<button class="btn warn" x-show="libraryKind === 'releases'" @click="openReleaseMerge()" :disabled="!canOpenReleaseMerge()">
<i data-lucide="git-merge"></i>
Merge
</button>
@@ -2442,6 +2666,103 @@ tbody tr:hover {
</div>
</div>
</section>
<section class="panel settings-section settings-section-full settings-downloads">
<div class="panel-head">
<div class="panel-title">
<strong>Download manager</strong>
<span>User-visible download methods and their SOCKS5 routes</span>
</div>
<span class="badge" :class="settingsDraft.downloads_enabled ? 'ok' : 'disabled'" x-text="settingsDraft.downloads_enabled ? 'enabled' : 'disabled'"></span>
</div>
<div class="settings-grid">
<div class="setting-toggle settings-wide">
<label>
<span>Enable download feature</span>
<span class="source-pill" :class="sourceClass('downloads_enabled')" x-text="settingSource('downloads_enabled')"></span>
</label>
<div class="setting-toggle-row">
<span x-text="settingsDraft.downloads_enabled ? 'Manager and local file uploads are available' : 'Hidden from the player'"></span>
<input type="checkbox" x-model="settingsDraft.downloads_enabled" />
</div>
<div class="setting-help">Controls the download-manager button and local audio-file uploads for every user.</div>
</div>
<div class="download-method-setting">
<label>
<span>Allow torrents</span>
<span class="source-pill" :class="sourceClass('torrent_downloads_enabled')" x-text="settingSource('torrent_downloads_enabled')"></span>
</label>
<div class="download-method-controls">
<div class="setting-toggle-row">
<span x-text="settingsDraft.torrent_downloads_enabled ? 'Enabled' : 'Disabled'"></span>
<input type="checkbox" x-model="settingsDraft.torrent_downloads_enabled" :disabled="!settingsDraft.downloads_enabled" />
</div>
<select x-model="settingsDraft.torrent_proxy_id" :disabled="!settingsDraft.downloads_enabled || !settingsDraft.torrent_downloads_enabled">
<option value="">No proxy</option>
<template x-for="proxy in settingsDraft.download_proxies || []" :key="proxy.id">
<option :value="proxy.id" x-text="downloadProxyLabel(proxy)"></option>
</template>
</select>
</div>
<div class="setting-help">The selected SOCKS5 proxy routes peer TCP traffic and HTTP(S) trackers. DHT and UDP discovery remain direct.</div>
</div>
<div class="download-method-setting">
<label>
<span>Allow YouTube / yt-dlp</span>
<span class="source-pill" :class="sourceClass('youtube_downloads_enabled')" x-text="settingSource('youtube_downloads_enabled')"></span>
</label>
<div class="download-method-controls">
<div class="setting-toggle-row">
<span x-text="settingsDraft.youtube_downloads_enabled ? 'Enabled' : 'Disabled'"></span>
<input type="checkbox" x-model="settingsDraft.youtube_downloads_enabled" :disabled="!settingsDraft.downloads_enabled" />
</div>
<select x-model="settingsDraft.youtube_proxy_id" :disabled="!settingsDraft.downloads_enabled || !settingsDraft.youtube_downloads_enabled">
<option value="">No proxy</option>
<template x-for="proxy in settingsDraft.download_proxies || []" :key="proxy.id">
<option :value="proxy.id" x-text="downloadProxyLabel(proxy)"></option>
</template>
</select>
</div>
<div class="setting-help">The selected proxy is passed to metadata lookup and downloads through yt-dlp's <code>--proxy</code> option.</div>
</div>
<div class="proxy-editor">
<div class="proxy-editor-head">
<span>
Saved SOCKS5 proxies
<span class="source-pill" :class="sourceClass('download_proxies')" x-text="settingSource('download_proxies')"></span>
</span>
<button class="btn" type="button" @click="addDownloadProxy()">
<i data-lucide="plus"></i>
Add proxy
</button>
</div>
<div class="settings-note" x-show="!(settingsDraft.download_proxies || []).length">No proxies saved. Both methods use a direct connection.</div>
<template x-for="(proxy, index) in settingsDraft.download_proxies || []" :key="proxy.id">
<div class="proxy-row">
<label>
Address and port
<input x-model="proxy.address" placeholder="127.0.0.1:1080" autocomplete="off" />
</label>
<label>
Username
<input x-model="proxy.username" autocomplete="off" />
</label>
<label>
Password
<input type="password" x-model="proxy.password" autocomplete="new-password" />
</label>
<button class="icon-btn danger" type="button" @click="removeDownloadProxy(proxy.id)" title="Remove proxy">
<i data-lucide="trash-2"></i>
</button>
</div>
</template>
<div class="setting-help" style="margin-top:9px">Enter only <code>host:port</code> (IPv6 may use <code>[address]:port</code>). Credentials are omitted unless both username and password are filled.</div>
</div>
</div>
</section>
</div>
<div class="settings-column settings-side">
@@ -2901,6 +3222,175 @@ tbody tr:hover {
</section>
</div>
<div class="modal-backdrop" x-show="mergeOpen" x-transition @click.self="closeReleaseMerge()">
<section class="modal merge-modal">
<div class="modal-head">
<div class="panel-title">
<strong>Merge releases</strong>
<span x-text="mergeSubtitle()"></span>
</div>
<button class="icon-btn" type="button" @click="closeReleaseMerge()" :disabled="mergeSaving">
<i data-lucide="x"></i>
</button>
</div>
<div class="modal-body">
<div class="empty" x-show="mergeLoading">Loading selected releases...</div>
<div x-show="!mergeLoading">
<div class="field">
<label>Release to keep</label>
<div class="merge-release-grid">
<template x-for="release in mergeDetails" :key="release.item.id">
<button class="merge-release-card" type="button" :class="{active: Number(mergeDraft.target_release_id) === Number(release.item.id)}" @click="chooseMergeTarget(release.item.id)">
<template x-if="release.current_image_url">
<img class="merge-release-cover" :src="release.current_image_url" alt="" />
</template>
<span class="merge-release-cover empty" x-show="!release.current_image_url">No cover</span>
<span>
<span class="merge-release-title" x-text="release.title"></span>
<span class="merge-release-meta" x-text="mergeReleaseMeta(release)"></span>
</span>
</button>
</template>
</div>
<p class="muted">This release keeps its ID. All tracks and genre tags are moved here, then the other release records are removed.</p>
</div>
<div class="editor-grid">
<div class="field">
<label>Final title</label>
<input x-model="mergeDraft.title" maxlength="255" />
</div>
<div class="field">
<label>Release type</label>
<select x-model="mergeDraft.release_type">
<option value="album">Album</option>
<option value="single">Single</option>
<option value="ep">EP</option>
<option value="compilation">Compilation</option>
<option value="mixtape">Mixtape</option>
<option value="soundtrack">Soundtrack</option>
<option value="live">Live</option>
<option value="remix">Remix</option>
<option value="demo">Demo</option>
<option value="unknown">Unknown</option>
</select>
</div>
<div class="field">
<label>Year</label>
<input type="number" min="0" max="3000" x-model="mergeDraft.year" />
</div>
<div class="field">
<label>Visibility</label>
<select x-model="mergeDraft.hidden">
<option value="false">Visible in player</option>
<option value="true">Hidden from player</option>
</select>
</div>
</div>
<div class="field">
<label>Release artists</label>
<div class="artist-tags">
<template x-for="artist in selectedMergeArtists()" :key="artist.id">
<span class="tag relation">
<span x-text="artist.name"></span>
<button type="button" @click="removeMergeArtist(artist.id)">x</button>
</span>
</template>
<span class="muted" x-show="selectedMergeArtists().length === 0">No artists attached</span>
</div>
<div class="artist-picker">
<input class="search" placeholder="Search artist" x-model="mergeArtistSearch" @keydown.enter.prevent="addMergeArtist()" @keydown.escape="mergeArtistSearch = ''" />
<div class="artist-results" x-show="mergeArtistSearchOpen()" x-transition>
<template x-for="artist in filteredMergeArtists()" :key="artist.id">
<button class="artist-result" type="button" @click="addMergeArtist(artist)" x-text="artist.name"></button>
</template>
<div class="artist-result muted" x-show="filteredMergeArtists().length === 0">No matching artists</div>
</div>
</div>
</div>
<div class="field">
<label>Final cover</label>
<div class="merge-cover-grid">
<button class="merge-cover-option" type="button" :class="{active: !mergeDraft.cover_file_id}" @click="mergeDraft.cover_file_id = null">
<span class="merge-cover-empty">No cover</span>
<span>None</span>
</button>
<template x-for="release in mergeCoverOptions()" :key="release.current_image_file_id">
<button class="merge-cover-option" type="button" :class="{active: Number(mergeDraft.cover_file_id) === Number(release.current_image_file_id)}" @click="mergeDraft.cover_file_id = Number(release.current_image_file_id)">
<img :src="release.current_image_url" alt="" />
<span x-text="release.title"></span>
</button>
</template>
</div>
</div>
<div class="field">
<div class="action-strip" style="padding:0 0 8px; border:0">
<label style="margin:0">Tracks</label>
<div class="toolbar">
<button class="btn" type="button" @click="sortMergeTracks()">
<i data-lucide="arrow-down-0-1"></i>
Sort by number
</button>
<button class="btn" type="button" @click="renumberMergeTracks()">
<i data-lucide="list-ordered"></i>
Renumber 1..N
</button>
</div>
</div>
<div style="overflow:auto" class="release-track-list" x-show="mergeDraft.tracks.length">
<div class="merge-track-list">
<div class="merge-track-head">
<span>Order</span>
<span>Disc</span>
<span>Track #</span>
<span>Title</span>
<span>Artist</span>
<span>From release</span>
<span>Move</span>
</div>
<template x-for="(track, index) in mergeDraft.tracks" :key="track.id">
<div class="merge-track-row">
<span class="release-track-meta" x-text="index + 1"></span>
<input type="number" min="1" max="999" x-model="track.disc_number" />
<input type="number" min="1" max="9999" x-model="track.track_number" />
<div>
<div class="release-track-title" x-text="track.title"></div>
<div class="release-track-meta" x-text="trackDuration(track.duration_seconds)"></div>
</div>
<div class="release-track-meta" x-text="track.artists || 'Unknown artist'"></div>
<div class="release-track-meta" x-text="track.release_title || 'Unknown release'"></div>
<div class="merge-order-actions">
<button class="icon-btn" type="button" @click="moveMergeTrack(index, -1)" :disabled="index === 0" title="Move up">
<i data-lucide="chevron-up"></i>
</button>
<button class="icon-btn" type="button" @click="moveMergeTrack(index, 1)" :disabled="index === mergeDraft.tracks.length - 1" title="Move down">
<i data-lucide="chevron-down"></i>
</button>
</div>
</div>
</template>
</div>
</div>
</div>
<div class="merge-footer">
<span class="selection-summary" x-text="mergeConfirmationText()"></span>
<div class="toolbar">
<button class="btn" type="button" @click="closeReleaseMerge()" :disabled="mergeSaving">Cancel</button>
<button class="btn warn" type="button" @click="saveReleaseMerge()" :disabled="!canSaveReleaseMerge()">
<i :data-lucide="mergeSaving ? 'loader-circle' : 'git-merge'"></i>
<span x-text="mergeSaving ? 'Merging...' : 'Merge releases'"></span>
</button>
</div>
</div>
</div>
</div>
</section>
</div>
<div class="modal-backdrop" x-show="editorOpen && activeLibraryItem" x-transition @click.self="editorOpen = false">
<section class="modal">
<div class="modal-head">
@@ -3222,6 +3712,12 @@ function adminV2() {
releaseTrackSearchToken: 0,
editorDetail: null,
editorDraft: { title: '', hidden: 'false', release_type: 'album', year: '', release_id: null, track_number: '', disc_number: '', artist_ids: [], release_tracks: [] },
mergeOpen: false,
mergeLoading: false,
mergeSaving: false,
mergeDetails: [],
mergeArtistSearch: '',
mergeDraft: { release_ids: [], target_release_id: null, title: '', release_type: 'album', year: '', hidden: 'false', cover_file_id: null, artist_ids: [], tracks: [] },
settings: { values: {}, sources: {}, lastfm_api_key_configured: false, lastfm_shared_secret_configured: false, lastfm_scrobbling_configured: false },
settingsDraft: {
auth_password_enabled: false,
@@ -3250,7 +3746,13 @@ function adminV2() {
similarity_enabled: false,
similarity_model: 'discogs-effnet-bsdynamic-1',
similarity_profile: 'furumi-full-track-v1',
similarity_workers: '1'
similarity_workers: '1',
downloads_enabled: true,
torrent_downloads_enabled: true,
youtube_downloads_enabled: true,
download_proxies: [],
torrent_proxy_id: '',
youtube_proxy_id: ''
},
settingsProbe: { status: 'idle', ok: false },
settingsProbeLoading: false,
@@ -3531,7 +4033,12 @@ function adminV2() {
async loadSettings(showErrors = true) {
try {
this.settings = await this.request(`${this.apiBase}/settings`);
this.settingsDraft = Object.assign({}, this.settingsDraft, this.settings.values || {});
const values = this.settings.values || {};
this.settingsDraft = Object.assign({}, this.settingsDraft, values, {
download_proxies: Array.isArray(values.download_proxies)
? values.download_proxies.map(proxy => ({ ...proxy }))
: []
});
} catch (error) {
if (showErrors) this.showToast(error.message);
} finally {
@@ -3539,6 +4046,30 @@ function adminV2() {
}
},
addDownloadProxy() {
const id = (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function')
? globalThis.crypto.randomUUID()
: `proxy-${Date.now()}-${Math.random().toString(16).slice(2)}`;
this.settingsDraft.download_proxies = [
...(this.settingsDraft.download_proxies || []),
{ id, address: '', username: '', password: '' }
];
this.$nextTick(() => this.icons());
},
removeDownloadProxy(id) {
this.settingsDraft.download_proxies = (this.settingsDraft.download_proxies || [])
.filter(proxy => proxy.id !== id);
if (this.settingsDraft.torrent_proxy_id === id) this.settingsDraft.torrent_proxy_id = '';
if (this.settingsDraft.youtube_proxy_id === id) this.settingsDraft.youtube_proxy_id = '';
this.$nextTick(() => this.icons());
},
downloadProxyLabel(proxy) {
const address = String(proxy?.address || '').trim();
return address || 'New proxy';
},
async saveSettings() {
if (this.settingsSaving) return;
const networkSimilarityWasEnabled = Boolean(
@@ -4558,6 +5089,252 @@ function adminV2() {
this.setLibraryImage(null);
},
canOpenReleaseMerge() {
return this.libraryKind === 'releases'
&& this.librarySelectionScope === 'ids'
&& this.currentLibrarySelectionKeys().length >= 2;
},
async openReleaseMerge() {
if (!this.canOpenReleaseMerge()) {
this.showToast(this.librarySelectionScope === 'filter'
? 'Select individual releases to merge'
: 'Select at least two releases');
return;
}
const releaseIds = this.currentLibrarySelectionKeys()
.map(key => Number(key.split(':')[1]))
.filter(Boolean);
this.mergeOpen = true;
this.mergeLoading = true;
this.mergeSaving = false;
this.mergeDetails = [];
this.mergeArtistSearch = '';
try {
const details = await Promise.all(releaseIds.map(async id => {
const params = new URLSearchParams({ kind: 'releases', id: String(id) });
return this.request(`${this.apiBase}/library/item/detail?${params.toString()}`);
}));
if (!this.mergeOpen) return;
this.mergeDetails = details;
const target = details.slice().sort((left, right) => {
const leftAlbum = left.release_type === 'album' ? 1 : 0;
const rightAlbum = right.release_type === 'album' ? 1 : 0;
return (right.release_tracks || []).length - (left.release_tracks || []).length
|| rightAlbum - leftAlbum
|| Number(left.item.id) - Number(right.item.id);
})[0];
const artistIds = [];
const seenArtists = new Set();
const tracks = [];
for (const detail of details) {
for (const artistId of detail.selected_artist_ids || []) {
const id = Number(artistId);
if (id && !seenArtists.has(id)) {
seenArtists.add(id);
artistIds.push(id);
}
}
for (const row of detail.release_tracks || []) {
tracks.push(this.normalizeReleaseTrack(row));
}
}
this.mergeDraft = {
release_ids: releaseIds,
target_release_id: Number(target.item.id),
title: target.title || '',
release_type: target.release_type || 'album',
year: target.year || '',
hidden: target.hidden ? 'true' : 'false',
cover_file_id: target.current_image_file_id == null ? null : Number(target.current_image_file_id),
artist_ids: artistIds,
tracks
};
this.sortMergeTracks();
} catch (error) {
this.mergeOpen = false;
this.showToast(error.message);
} finally {
this.mergeLoading = false;
this.icons();
}
},
closeReleaseMerge() {
if (this.mergeSaving) return;
this.mergeOpen = false;
this.mergeDetails = [];
this.mergeArtistSearch = '';
},
mergeSubtitle() {
if (this.mergeLoading) return 'Preparing merge workspace';
return `${this.mergeDetails.length} releases / ${this.mergeDraft.tracks.length} tracks`;
},
mergeReleaseMeta(release) {
const parts = [`${(release.release_tracks || []).length} tracks`];
if (release.release_type) parts.push(release.release_type);
if (release.year) parts.push(release.year);
return parts.join(' / ');
},
chooseMergeTarget(id) {
const release = this.mergeDetails.find(row => Number(row.item.id) === Number(id));
if (!release) return;
this.mergeDraft.target_release_id = Number(id);
this.mergeDraft.title = release.title || '';
this.mergeDraft.release_type = release.release_type || 'album';
this.mergeDraft.year = release.year || '';
this.mergeDraft.hidden = release.hidden ? 'true' : 'false';
this.mergeDraft.cover_file_id = release.current_image_file_id == null
? null
: Number(release.current_image_file_id);
},
mergeCoverOptions() {
const seen = new Set();
return this.mergeDetails.filter(release => {
const id = Number(release.current_image_file_id || 0);
if (!id || !release.current_image_url || seen.has(id)) return false;
seen.add(id);
return true;
});
},
mergeArtists() {
return this.mergeDetails.length && Array.isArray(this.mergeDetails[0].artists)
? this.mergeDetails[0].artists
: [];
},
selectedMergeArtists() {
const selected = this.mergeDraft.artist_ids || [];
const artists = this.mergeArtists();
return selected.map(id => artists.find(row => Number(row.id) === Number(id)) || { id, name: `Artist #${id}` });
},
filteredMergeArtists() {
const selected = new Set((this.mergeDraft.artist_ids || []).map(Number));
const query = String(this.mergeArtistSearch || '').trim().toLowerCase();
return this.mergeArtists()
.filter(artist => !selected.has(Number(artist.id)))
.filter(artist => !query || String(artist.name || '').toLowerCase().includes(query))
.sort((left, right) => left.name.localeCompare(right.name))
.slice(0, 12);
},
mergeArtistSearchOpen() {
return String(this.mergeArtistSearch || '').trim().length > 0;
},
addMergeArtist(artist = null) {
artist = artist || this.filteredMergeArtists()[0];
if (!artist) return;
const id = Number(artist.id);
if (!(this.mergeDraft.artist_ids || []).map(Number).includes(id)) {
this.mergeDraft.artist_ids = (this.mergeDraft.artist_ids || []).concat([id]);
}
this.mergeArtistSearch = '';
},
removeMergeArtist(id) {
this.mergeDraft.artist_ids = (this.mergeDraft.artist_ids || []).filter(value => Number(value) !== Number(id));
},
sortMergeTracks() {
this.mergeDraft.tracks = (this.mergeDraft.tracks || []).slice().sort((left, right) => {
const leftDisc = Number(left.disc_number || 1);
const rightDisc = Number(right.disc_number || 1);
const leftTrack = left.track_number === '' ? Number.MAX_SAFE_INTEGER : Number(left.track_number || Number.MAX_SAFE_INTEGER);
const rightTrack = right.track_number === '' ? Number.MAX_SAFE_INTEGER : Number(right.track_number || Number.MAX_SAFE_INTEGER);
return leftDisc - rightDisc
|| leftTrack - rightTrack
|| String(left.release_title || '').localeCompare(String(right.release_title || ''))
|| Number(left.id) - Number(right.id);
});
this.$nextTick(() => this.icons());
},
renumberMergeTracks() {
this.mergeDraft.tracks = (this.mergeDraft.tracks || []).map((track, index) => ({
...track,
disc_number: '1',
track_number: String(index + 1)
}));
},
moveMergeTrack(index, direction) {
const target = index + direction;
const tracks = (this.mergeDraft.tracks || []).slice();
if (target < 0 || target >= tracks.length) return;
const moving = { ...tracks[index] };
const displaced = { ...tracks[target] };
const movingPosition = {
disc_number: moving.disc_number,
track_number: moving.track_number
};
moving.disc_number = displaced.disc_number;
moving.track_number = displaced.track_number;
displaced.disc_number = movingPosition.disc_number;
displaced.track_number = movingPosition.track_number;
tracks[index] = displaced;
tracks[target] = moving;
this.mergeDraft.tracks = tracks;
this.$nextTick(() => this.icons());
},
mergeConfirmationText() {
const removed = Math.max(0, this.mergeDetails.length - 1);
return `${this.mergeDraft.tracks.length} tracks will remain; ${removed} source release${removed === 1 ? '' : 's'} will be removed`;
},
canSaveReleaseMerge() {
return this.mergeOpen
&& !this.mergeLoading
&& !this.mergeSaving
&& this.mergeDetails.length >= 2
&& Boolean(this.mergeDraft.target_release_id)
&& Boolean(String(this.mergeDraft.title || '').trim());
},
async saveReleaseMerge() {
if (!this.canSaveReleaseMerge()) return;
this.mergeSaving = true;
try {
const result = await this.request(`${this.apiBase}/library/releases/merge`, {
method: 'POST',
body: JSON.stringify({
release_ids: this.mergeDraft.release_ids.map(Number),
target_release_id: Number(this.mergeDraft.target_release_id),
title: String(this.mergeDraft.title || '').trim(),
release_type: this.mergeDraft.release_type || 'album',
year: this.mergeDraft.year || '',
hidden: this.mergeDraft.hidden === 'true',
cover_file_id: this.mergeDraft.cover_file_id == null ? null : Number(this.mergeDraft.cover_file_id),
artist_ids: (this.mergeDraft.artist_ids || []).map(Number),
tracks: (this.mergeDraft.tracks || []).map(track => ({
id: Number(track.id),
track_number: track.track_number || '',
disc_number: track.disc_number || ''
}))
})
});
this.mergeSaving = false;
this.closeReleaseMerge();
this.clearLibrarySelection();
await this.loadLibrary(false);
await this.refreshCountsOnly();
this.showToast(`${result.moved_tracks} tracks merged into “${result.item.title}”`);
this.openEditor(result.item);
} catch (error) {
this.showToast(error.message);
} finally {
this.mergeSaving = false;
this.icons();
}
},
toggleLibrary(item) {
this.librarySelectionScope = 'ids';
const key = `${item.kind}:${item.id}`;
+4
View File
@@ -132,12 +132,16 @@
</div>
<div class="torrent-tabs download-source-tabs">
{% if youtube_downloads_enabled %}
<button class="torrent-tab-btn"
:class="{ active: $store.torrents.sourceTab === 'youtube' }"
@click="$store.torrents.showSourceTab('youtube')">{{ t.player_youtube }}</button>
{% endif %}
{% if torrent_downloads_enabled %}
<button class="torrent-tab-btn"
:class="{ active: $store.torrents.sourceTab === 'torrents' }"
@click="$store.torrents.showSourceTab('torrents')">{{ t.player_torrents }}</button>
{% endif %}
<button class="torrent-tab-btn"
:class="{ active: $store.torrents.sourceTab === 'files' }"
@click="$store.torrents.showSourceTab('files')">{{ t.player_files }}</button>
+9 -2
View File
@@ -4604,7 +4604,10 @@ document.addEventListener('alpine:init', () => {
// -----------------------------------------------------------------------
Alpine.store('torrents', {
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: '',
youtubePreview: null,
youtubePreviewSelected: new Set(),
@@ -4671,6 +4674,7 @@ document.addEventListener('alpine:init', () => {
},
open() {
if (!this.downloadsEnabled) return;
this.modal = true;
this.message = '';
this.error = false;
@@ -4700,7 +4704,10 @@ document.addEventListener('alpine:init', () => {
},
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('');
if (this.sourceTab === 'youtube') this.loadYoutubeJobs();
else if (this.sourceTab === 'uploads') {
+2
View File
@@ -325,6 +325,7 @@
<span class="search-shortcut">Ctrl+K</span>
</template>
</div>
{% if downloads_enabled %}
<button class="torrent-import-btn"
@click="$store.torrents.open()"
title="{{ t.player_import_torrent }}">
@@ -334,6 +335,7 @@
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
{% endif %}
<button class="mobile-account-chip"
x-show="$store.user.profile"
x-cloak