Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e90b33f6 | ||
|
|
1ab53e3898 | ||
|
|
0b32d7e813 | ||
|
|
5402d9595d |
Generated
+1
-1
@@ -1938,7 +1938,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "furumusic"
|
||||
version = "0.10.2"
|
||||
version = "0.10.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}"))?;
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user