Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7098f80e9d | ||
|
|
a0964b651b | ||
|
|
b1ce504db6 | ||
|
|
39d75b07f6 | ||
|
|
34e90b33f6 | ||
|
|
1ab53e3898 | ||
|
|
0b32d7e813 | ||
|
|
5402d9595d |
Generated
+1
-1
@@ -1938,7 +1938,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.10.2"
|
version = "0.10.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.10.3"
|
version = "0.10.5"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||||
|
|
||||||
|
|||||||
+94
-6
@@ -81,6 +81,11 @@ struct PathId {
|
|||||||
id: i64,
|
id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct PathStringId {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct PathName {
|
struct PathName {
|
||||||
name: String,
|
name: String,
|
||||||
@@ -415,6 +420,43 @@ impl App for AdminApp {
|
|||||||
}),
|
}),
|
||||||
"admin_v2_settings_probe",
|
"admin_v2_settings_probe",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/settings/youtube-cookies",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
cot::router::method::post(
|
||||||
|
move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
json: Json<v2::UploadYoutubeCookieFileRequest>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
async move {
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("admin pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
v2::upload_youtube_cookie_file(session, db, pg_pool, json).await
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"admin_v2_youtube_cookie_upload",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/settings/youtube-cookies/{id}",
|
||||||
|
cot::router::method::delete(
|
||||||
|
move |session: Session, db: Database, path: Path<PathStringId>| async move {
|
||||||
|
v2::delete_youtube_cookie_file(session, db, &path.0.id).await
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"admin_v2_youtube_cookie_delete",
|
||||||
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/v2/api/federation",
|
"/v2/api/federation",
|
||||||
get(move |session: Session, db: Database| async move {
|
get(move |session: Session, db: Database| async move {
|
||||||
@@ -711,6 +753,34 @@ impl App for AdminApp {
|
|||||||
},
|
},
|
||||||
"admin_v2_library_bulk",
|
"admin_v2_library_bulk",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/library/releases/merge",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
cot::router::method::post(
|
||||||
|
move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
json: Json<v2::MergeReleasesRequest>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
async move {
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("admin pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
v2::merge_releases(session, db, pg_pool, json).await
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"admin_v2_library_releases_merge",
|
||||||
|
),
|
||||||
// -- Dashboard ----------------------------------------------------
|
// -- Dashboard ----------------------------------------------------
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/",
|
"/",
|
||||||
@@ -1069,19 +1139,34 @@ impl App for AdminApp {
|
|||||||
),
|
),
|
||||||
"admin_releases_edit",
|
"admin_releases_edit",
|
||||||
),
|
),
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/releases/{id}/delete",
|
"/releases/{id}/delete",
|
||||||
cot::router::method::post(
|
cot::router::method::post(move |session: Session, db: Database, path: Path<PathId>| {
|
||||||
|session: Session, db: Database, path: Path<PathId>| async move {
|
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 {
|
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||||
Ok(u) => u,
|
Ok(u) => u,
|
||||||
Err(resp) => return Ok(resp),
|
Err(resp) => return Ok(resp),
|
||||||
};
|
};
|
||||||
views::releases_delete(admin, &db, path.0.id).await
|
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",
|
"admin_releases_delete",
|
||||||
),
|
)
|
||||||
|
},
|
||||||
// -- Media Files --------------------------------------------------
|
// -- Media Files --------------------------------------------------
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/media-files",
|
"/media-files",
|
||||||
@@ -1399,6 +1484,9 @@ impl App for AdminApp {
|
|||||||
all.extend(cot::db::migrations::wrap_migrations(
|
all.extend(cot::db::migrations::wrap_migrations(
|
||||||
crate::auth::db_migrations::MIGRATIONS,
|
crate::auth::db_migrations::MIGRATIONS,
|
||||||
));
|
));
|
||||||
|
all.extend(cot::db::migrations::wrap_migrations(
|
||||||
|
crate::youtube::db_migrations::MIGRATIONS,
|
||||||
|
));
|
||||||
all
|
all
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+578
-99
@@ -16,7 +16,7 @@ use sqlx::{PgPool, Postgres, QueryBuilder};
|
|||||||
use super::BUILD_INFO;
|
use super::BUILD_INFO;
|
||||||
use crate::agent;
|
use crate::agent;
|
||||||
use crate::auth::{self, AuthenticatedUser, Role};
|
use crate::auth::{self, AuthenticatedUser, Role};
|
||||||
use crate::config::{AppConfig, ConfigEntry, ConfigSources};
|
use crate::config::{AppConfig, ConfigEntry, ConfigSource, ConfigSources, DownloadProxy};
|
||||||
use crate::i18n::{I18n, Translations};
|
use crate::i18n::{I18n, Translations};
|
||||||
use crate::scheduler::{self, JobRegistry, JobRun, ScheduledJob};
|
use crate::scheduler::{self, JobRegistry, JobRun, ScheduledJob};
|
||||||
|
|
||||||
@@ -69,6 +69,21 @@ pub(super) struct BulkLibraryRequest {
|
|||||||
filter: Option<LibraryFilter>,
|
filter: Option<LibraryFilter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct MergeReleasesRequest {
|
||||||
|
release_ids: Vec<i64>,
|
||||||
|
target_release_id: i64,
|
||||||
|
title: String,
|
||||||
|
release_type: String,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_stringish")]
|
||||||
|
year: Option<String>,
|
||||||
|
hidden: bool,
|
||||||
|
cover_file_id: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
artist_ids: Vec<i64>,
|
||||||
|
tracks: Vec<ReleaseTrackUpdateRequest>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct MetadataBackfillRunRequest {
|
pub struct MetadataBackfillRunRequest {
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
@@ -150,6 +165,12 @@ pub(super) struct UploadLibraryImageRequest {
|
|||||||
mime_type: String,
|
mime_type: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct UploadYoutubeCookieFileRequest {
|
||||||
|
filename: String,
|
||||||
|
data: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
|
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
|
||||||
struct ReviewFilter {
|
struct ReviewFilter {
|
||||||
status: Option<String>,
|
status: Option<String>,
|
||||||
@@ -417,15 +438,54 @@ struct MutationResponse {
|
|||||||
affected: u64,
|
affected: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct MergeReleasesResponse {
|
||||||
|
ok: bool,
|
||||||
|
merged_releases: u64,
|
||||||
|
moved_tracks: u64,
|
||||||
|
item: LibraryItemDto,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
struct AdminSettingsDto {
|
struct AdminSettingsDto {
|
||||||
values: AdminSettingsValues,
|
values: AdminSettingsValues,
|
||||||
sources: AdminSettingsSources,
|
sources: AdminSettingsSources,
|
||||||
|
youtube_cookie_files: Vec<AdminYoutubeCookieFileDto>,
|
||||||
lastfm_api_key_configured: bool,
|
lastfm_api_key_configured: bool,
|
||||||
lastfm_shared_secret_configured: bool,
|
lastfm_shared_secret_configured: bool,
|
||||||
lastfm_scrobbling_configured: bool,
|
lastfm_scrobbling_configured: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
struct AdminYoutubeCookieFileDto {
|
||||||
|
id: String,
|
||||||
|
filename: String,
|
||||||
|
cookie_count: u64,
|
||||||
|
uploaded_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<crate::youtube::YoutubeCookieFile> for AdminYoutubeCookieFileDto {
|
||||||
|
fn from(file: crate::youtube::YoutubeCookieFile) -> Self {
|
||||||
|
Self {
|
||||||
|
id: file.id_str().to_owned(),
|
||||||
|
filename: file.filename().to_owned(),
|
||||||
|
cookie_count: file.cookie_count(),
|
||||||
|
uploaded_at: file.uploaded_at().to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<crate::youtube::YoutubeCookieFileMetadata> for AdminYoutubeCookieFileDto {
|
||||||
|
fn from(file: crate::youtube::YoutubeCookieFileMetadata) -> Self {
|
||||||
|
Self {
|
||||||
|
id: file.id_str().to_owned(),
|
||||||
|
filename: file.filename().to_owned(),
|
||||||
|
cookie_count: file.cookie_count(),
|
||||||
|
uploaded_at: file.uploaded_at().to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
struct AdminSettingsValues {
|
struct AdminSettingsValues {
|
||||||
auth_password_enabled: bool,
|
auth_password_enabled: bool,
|
||||||
@@ -462,6 +522,52 @@ struct AdminSettingsValues {
|
|||||||
similarity_profile: String,
|
similarity_profile: String,
|
||||||
#[serde(default = "default_similarity_workers")]
|
#[serde(default = "default_similarity_workers")]
|
||||||
similarity_workers: String,
|
similarity_workers: String,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
downloads_enabled: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
torrent_downloads_enabled: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
youtube_downloads_enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
download_proxies: Vec<AdminDownloadProxy>,
|
||||||
|
#[serde(default)]
|
||||||
|
torrent_proxy_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
youtube_proxy_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
youtube_cookie_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
struct AdminDownloadProxy {
|
||||||
|
id: String,
|
||||||
|
address: String,
|
||||||
|
#[serde(default)]
|
||||||
|
username: String,
|
||||||
|
#[serde(default)]
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DownloadProxy> for AdminDownloadProxy {
|
||||||
|
fn from(proxy: DownloadProxy) -> Self {
|
||||||
|
Self {
|
||||||
|
id: proxy.id,
|
||||||
|
address: proxy.address,
|
||||||
|
username: proxy.username,
|
||||||
|
password: proxy.password,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AdminDownloadProxy> for DownloadProxy {
|
||||||
|
fn from(proxy: AdminDownloadProxy) -> Self {
|
||||||
|
Self {
|
||||||
|
id: proxy.id,
|
||||||
|
address: proxy.address,
|
||||||
|
username: proxy.username,
|
||||||
|
password: proxy.password,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
@@ -493,6 +599,13 @@ struct AdminSettingsSources {
|
|||||||
similarity_model: &'static str,
|
similarity_model: &'static str,
|
||||||
similarity_profile: &'static str,
|
similarity_profile: &'static str,
|
||||||
similarity_workers: &'static str,
|
similarity_workers: &'static str,
|
||||||
|
downloads_enabled: &'static str,
|
||||||
|
torrent_downloads_enabled: &'static str,
|
||||||
|
youtube_downloads_enabled: &'static str,
|
||||||
|
download_proxies: &'static str,
|
||||||
|
torrent_proxy_id: &'static str,
|
||||||
|
youtube_proxy_id: &'static str,
|
||||||
|
youtube_cookie_id: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -531,6 +644,20 @@ pub(super) struct UpdateSettingsRequest {
|
|||||||
similarity_profile: String,
|
similarity_profile: String,
|
||||||
#[serde(default = "default_similarity_workers")]
|
#[serde(default = "default_similarity_workers")]
|
||||||
similarity_workers: String,
|
similarity_workers: String,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
downloads_enabled: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
torrent_downloads_enabled: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
youtube_downloads_enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
download_proxies: Vec<AdminDownloadProxy>,
|
||||||
|
#[serde(default)]
|
||||||
|
torrent_proxy_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
youtube_proxy_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
youtube_cookie_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_similarity_model() -> String {
|
fn default_similarity_model() -> String {
|
||||||
@@ -597,6 +724,7 @@ struct LibraryItemDetailDto {
|
|||||||
release_id: Option<i64>,
|
release_id: Option<i64>,
|
||||||
track_number: Option<i32>,
|
track_number: Option<i32>,
|
||||||
disc_number: Option<i32>,
|
disc_number: Option<i32>,
|
||||||
|
current_image_file_id: Option<i64>,
|
||||||
current_image_url: Option<String>,
|
current_image_url: Option<String>,
|
||||||
selected_artist_ids: Vec<i64>,
|
selected_artist_ids: Vec<i64>,
|
||||||
artists: Vec<ArtistOptionDto>,
|
artists: Vec<ArtistOptionDto>,
|
||||||
@@ -969,7 +1097,10 @@ pub async fn settings(session: Session, db: Database) -> cot::Result<cot::respon
|
|||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
let (config, sources) = AppConfig::load_with_db(&db).await;
|
let (config, sources) = AppConfig::load_with_db(&db).await;
|
||||||
Json(settings_dto(config, sources)).into_response()
|
let cookie_files = crate::youtube::list_cookie_files(&db)
|
||||||
|
.await
|
||||||
|
.map_err(|error| cot::Error::internal(error.to_string()))?;
|
||||||
|
Json(settings_dto(config, sources, cookie_files)).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_settings(
|
pub async fn update_settings(
|
||||||
@@ -980,15 +1111,15 @@ pub async fn update_settings(
|
|||||||
if let Err(response) = require_admin_json(&session, &db).await {
|
if let Err(response) = require_admin_json(&session, &db).await {
|
||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
let similarity_model = body.similarity_model.trim();
|
let similarity_model = body.similarity_model.trim().to_string();
|
||||||
if crate::similarity::model_by_id(similarity_model).is_none() {
|
if crate::similarity::model_by_id(&similarity_model).is_none() {
|
||||||
return Ok(json_error(
|
return Ok(json_error(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"unknown similarity model",
|
"unknown similarity model",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let similarity_profile = body.similarity_profile.trim();
|
let similarity_profile = body.similarity_profile.trim().to_string();
|
||||||
if crate::similarity::profile_by_id(similarity_profile).is_none() {
|
if crate::similarity::profile_by_id(&similarity_profile).is_none() {
|
||||||
return Ok(json_error(
|
return Ok(json_error(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"unknown similarity preprocessing profile",
|
"unknown similarity preprocessing profile",
|
||||||
@@ -1003,6 +1134,58 @@ pub async fn update_settings(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if body.download_proxies.len() > 32 {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"at most 32 download proxies can be saved",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut download_proxies = Vec::with_capacity(body.download_proxies.len());
|
||||||
|
let mut proxy_ids = HashSet::new();
|
||||||
|
for (index, proxy) in body.download_proxies.into_iter().enumerate() {
|
||||||
|
let proxy = match DownloadProxy::from(proxy).normalized() {
|
||||||
|
Ok(proxy) => proxy,
|
||||||
|
Err(error) => {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
&format!("proxy {}: {error}", index + 1),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !proxy_ids.insert(proxy.id.clone()) {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"download proxy ids must be unique",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
download_proxies.push(proxy);
|
||||||
|
}
|
||||||
|
let torrent_proxy_id = body.torrent_proxy_id.trim().to_string();
|
||||||
|
let youtube_proxy_id = body.youtube_proxy_id.trim().to_string();
|
||||||
|
let youtube_cookie_id = body.youtube_cookie_id.trim().to_string();
|
||||||
|
for (method, proxy_id) in [
|
||||||
|
("torrent", torrent_proxy_id.as_str()),
|
||||||
|
("YouTube", youtube_proxy_id.as_str()),
|
||||||
|
] {
|
||||||
|
if !proxy_id.is_empty() && !proxy_ids.contains(proxy_id) {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
&format!("selected {method} proxy is not in the saved proxy list"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !youtube_cookie_id.is_empty()
|
||||||
|
&& !crate::youtube::cookie_file_exists(&db, &youtube_cookie_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| cot::Error::internal(error.to_string()))?
|
||||||
|
{
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"selected YouTube cookie file is not in the saved cookie file list",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let download_proxies_json = serde_json::to_string(&download_proxies)
|
||||||
|
.map_err(|error| cot::Error::internal(error.to_string()))?;
|
||||||
let fields = [
|
let fields = [
|
||||||
(
|
(
|
||||||
"auth_password_enabled",
|
"auth_password_enabled",
|
||||||
@@ -1058,9 +1241,22 @@ pub async fn update_settings(
|
|||||||
body.federation_save_on_listen.to_string(),
|
body.federation_save_on_listen.to_string(),
|
||||||
),
|
),
|
||||||
("similarity_enabled", body.similarity_enabled.to_string()),
|
("similarity_enabled", body.similarity_enabled.to_string()),
|
||||||
("similarity_model", similarity_model.to_string()),
|
("similarity_model", similarity_model),
|
||||||
("similarity_profile", similarity_profile.to_string()),
|
("similarity_profile", similarity_profile),
|
||||||
("similarity_workers", similarity_workers.to_string()),
|
("similarity_workers", similarity_workers.to_string()),
|
||||||
|
("downloads_enabled", body.downloads_enabled.to_string()),
|
||||||
|
(
|
||||||
|
"torrent_downloads_enabled",
|
||||||
|
body.torrent_downloads_enabled.to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"youtube_downloads_enabled",
|
||||||
|
body.youtube_downloads_enabled.to_string(),
|
||||||
|
),
|
||||||
|
("download_proxies", download_proxies_json),
|
||||||
|
("torrent_proxy_id", torrent_proxy_id),
|
||||||
|
("youtube_proxy_id", youtube_proxy_id),
|
||||||
|
("youtube_cookie_id", youtube_cookie_id),
|
||||||
];
|
];
|
||||||
for (key, value) in fields {
|
for (key, value) in fields {
|
||||||
let mut entry = ConfigEntry::new(key.to_string(), value);
|
let mut entry = ConfigEntry::new(key.to_string(), value);
|
||||||
@@ -1079,6 +1275,119 @@ pub async fn update_settings(
|
|||||||
Json(serde_json::json!({ "ok": true })).into_response()
|
Json(serde_json::json!({ "ok": true })).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn upload_youtube_cookie_file(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &PgPool,
|
||||||
|
Json(body): Json<UploadYoutubeCookieFileRequest>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
if let Err(response) = require_admin_json(&session, &db).await {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
let saved_count = match crate::youtube::cookie_file_count(&db).await {
|
||||||
|
Ok(count) => count,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(%error, "could not count saved YouTube cookie files");
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"YouTube cookie storage is unavailable; restart the server to apply database migrations",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if saved_count >= crate::youtube::MAX_COOKIE_FILES {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"at most 32 YouTube cookie files can be saved",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let filename = Path::new(body.filename.trim())
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.chars()
|
||||||
|
.filter(|character| !character.is_control())
|
||||||
|
.take(255)
|
||||||
|
.collect::<String>();
|
||||||
|
if filename.is_empty() {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"cookie filename is empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
use base64::Engine;
|
||||||
|
let max_encoded_len = crate::youtube::MAX_COOKIE_FILE_BYTES.div_ceil(3) * 4;
|
||||||
|
if body.data.trim().len() > max_encoded_len {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"cookie file is larger than 2 MiB",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let data = match base64::engine::general_purpose::STANDARD.decode(body.data.trim()) {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"cookie file payload is invalid",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (contents, cookie_count) = match crate::youtube::parse_cookie_file(&data) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
Err(error) => return Ok(json_error(StatusCode::BAD_REQUEST, &error.to_string())),
|
||||||
|
};
|
||||||
|
let file =
|
||||||
|
match crate::youtube::store_cookie_file(pool, &filename, cookie_count, &contents).await {
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(%error, "could not save YouTube cookie file");
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"could not save YouTube cookie file; check the server log for details",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Json(AdminYoutubeCookieFileDto::from(file)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_youtube_cookie_file(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
id: &str,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
if let Err(response) = require_admin_json(&session, &db).await {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
let id = id.trim();
|
||||||
|
if !crate::youtube::cookie_file_exists(&db, id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| cot::Error::internal(error.to_string()))?
|
||||||
|
{
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"YouTube cookie file not found",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (config, sources) = AppConfig::load_with_db(&db).await;
|
||||||
|
if config.youtube_cookie_id == id {
|
||||||
|
if sources.youtube_cookie_id == ConfigSource::Env {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"this cookie file is selected by FURU_YOUTUBE_COOKIE_ID and cannot be deleted",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut entry = ConfigEntry::new("youtube_cookie_id".to_owned(), String::new());
|
||||||
|
entry
|
||||||
|
.save(&db)
|
||||||
|
.await
|
||||||
|
.map_err(|error| cot::Error::internal(error.to_string()))?;
|
||||||
|
}
|
||||||
|
crate::youtube::YoutubeCookieFile::delete_by_id(&db, id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| cot::Error::internal(error.to_string()))?;
|
||||||
|
Json(serde_json::json!({ "ok": true })).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Federation (status + manual controls)
|
// Federation (status + manual controls)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1234,8 +1543,25 @@ pub async fn settings_probe(
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
fn settings_dto(
|
||||||
|
config: AppConfig,
|
||||||
|
sources: ConfigSources,
|
||||||
|
cookie_files: Vec<crate::youtube::YoutubeCookieFileMetadata>,
|
||||||
|
) -> AdminSettingsDto {
|
||||||
|
let download_proxies = config
|
||||||
|
.parsed_download_proxies()
|
||||||
|
.unwrap_or_else(|error| {
|
||||||
|
tracing::warn!(%error, "ignoring invalid saved download proxy list");
|
||||||
|
Vec::new()
|
||||||
|
})
|
||||||
|
.into_iter()
|
||||||
|
.map(AdminDownloadProxy::from)
|
||||||
|
.collect();
|
||||||
AdminSettingsDto {
|
AdminSettingsDto {
|
||||||
|
youtube_cookie_files: cookie_files
|
||||||
|
.into_iter()
|
||||||
|
.map(AdminYoutubeCookieFileDto::from)
|
||||||
|
.collect(),
|
||||||
lastfm_api_key_configured: !config.lastfm_api_key.trim().is_empty(),
|
lastfm_api_key_configured: !config.lastfm_api_key.trim().is_empty(),
|
||||||
lastfm_shared_secret_configured: !config.lastfm_shared_secret.trim().is_empty(),
|
lastfm_shared_secret_configured: !config.lastfm_shared_secret.trim().is_empty(),
|
||||||
lastfm_scrobbling_configured: !config.lastfm_api_key.trim().is_empty()
|
lastfm_scrobbling_configured: !config.lastfm_api_key.trim().is_empty()
|
||||||
@@ -1268,6 +1594,13 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
|||||||
similarity_model: config.similarity_model,
|
similarity_model: config.similarity_model,
|
||||||
similarity_profile: config.similarity_profile,
|
similarity_profile: config.similarity_profile,
|
||||||
similarity_workers: config.similarity_workers.to_string(),
|
similarity_workers: config.similarity_workers.to_string(),
|
||||||
|
downloads_enabled: config.downloads_enabled,
|
||||||
|
torrent_downloads_enabled: config.torrent_downloads_enabled,
|
||||||
|
youtube_downloads_enabled: config.youtube_downloads_enabled,
|
||||||
|
download_proxies,
|
||||||
|
torrent_proxy_id: config.torrent_proxy_id,
|
||||||
|
youtube_proxy_id: config.youtube_proxy_id,
|
||||||
|
youtube_cookie_id: config.youtube_cookie_id,
|
||||||
},
|
},
|
||||||
sources: AdminSettingsSources {
|
sources: AdminSettingsSources {
|
||||||
auth_password_enabled: sources.auth_password_enabled.code(),
|
auth_password_enabled: sources.auth_password_enabled.code(),
|
||||||
@@ -1297,6 +1630,13 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
|||||||
similarity_model: sources.similarity_model.code(),
|
similarity_model: sources.similarity_model.code(),
|
||||||
similarity_profile: sources.similarity_profile.code(),
|
similarity_profile: sources.similarity_profile.code(),
|
||||||
similarity_workers: sources.similarity_workers.code(),
|
similarity_workers: sources.similarity_workers.code(),
|
||||||
|
downloads_enabled: sources.downloads_enabled.code(),
|
||||||
|
torrent_downloads_enabled: sources.torrent_downloads_enabled.code(),
|
||||||
|
youtube_downloads_enabled: sources.youtube_downloads_enabled.code(),
|
||||||
|
download_proxies: sources.download_proxies.code(),
|
||||||
|
torrent_proxy_id: sources.torrent_proxy_id.code(),
|
||||||
|
youtube_proxy_id: sources.youtube_proxy_id.code(),
|
||||||
|
youtube_cookie_id: sources.youtube_cookie_id.code(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1930,10 +2270,182 @@ pub async fn bulk_library(
|
|||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let affected = apply_library_action(pool, &kind, action, &ids).await?;
|
let storage_dir = if action == "delete" && matches!(kind.as_str(), "releases" | "tracks") {
|
||||||
|
AppConfig::load_with_db(&db).await.0.agent_storage_dir
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let affected = apply_library_action(pool, &kind, action, &ids, &storage_dir).await?;
|
||||||
Json(MutationResponse { ok: true, affected }).into_response()
|
Json(MutationResponse { ok: true, affected }).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn merge_releases(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &PgPool,
|
||||||
|
Json(body): Json<MergeReleasesRequest>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
if let Err(response) = require_admin_json(&session, &db).await {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut release_ids = body.release_ids;
|
||||||
|
release_ids.retain(|id| *id > 0);
|
||||||
|
release_ids.sort_unstable();
|
||||||
|
release_ids.dedup();
|
||||||
|
if release_ids.len() < 2 {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"select at least two releases to merge",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if release_ids.len() > 250 {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"at most 250 releases can be merged at once",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !release_ids.contains(&body.target_release_id) {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"destination release must be part of the selection",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = body.title.trim();
|
||||||
|
if title.is_empty() {
|
||||||
|
return Ok(json_error(StatusCode::BAD_REQUEST, "title cannot be empty"));
|
||||||
|
}
|
||||||
|
if title.chars().count() > 255 {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"title cannot exceed 255 characters",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let release_type = body.release_type.trim().to_lowercase();
|
||||||
|
if !matches!(
|
||||||
|
release_type.as_str(),
|
||||||
|
"album"
|
||||||
|
| "single"
|
||||||
|
| "ep"
|
||||||
|
| "compilation"
|
||||||
|
| "mixtape"
|
||||||
|
| "live"
|
||||||
|
| "soundtrack"
|
||||||
|
| "remix"
|
||||||
|
| "demo"
|
||||||
|
| "unknown"
|
||||||
|
) {
|
||||||
|
return Ok(json_error(StatusCode::BAD_REQUEST, "invalid release type"));
|
||||||
|
}
|
||||||
|
let year = match parse_merge_optional_i32(body.year.as_deref(), 0, 3000, "year") {
|
||||||
|
Ok(year) => year,
|
||||||
|
Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, &message)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tracks = Vec::with_capacity(body.tracks.len());
|
||||||
|
for track in body.tracks {
|
||||||
|
if track.id <= 0 {
|
||||||
|
return Ok(json_error(StatusCode::BAD_REQUEST, "invalid track id"));
|
||||||
|
}
|
||||||
|
let track_number = match parse_merge_optional_i32(
|
||||||
|
track.track_number.as_deref(),
|
||||||
|
1,
|
||||||
|
9999,
|
||||||
|
"track number",
|
||||||
|
) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, &message)),
|
||||||
|
};
|
||||||
|
let disc_number =
|
||||||
|
match parse_merge_optional_i32(track.disc_number.as_deref(), 1, 999, "disc number") {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, &message)),
|
||||||
|
};
|
||||||
|
tracks.push(crate::library_cleanup::ReleaseMergeTrack {
|
||||||
|
id: track.id,
|
||||||
|
track_number,
|
||||||
|
disc_number,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing_release_ids: Vec<i64> =
|
||||||
|
sqlx::query_scalar("SELECT id FROM furumusic__release WHERE id = ANY($1) ORDER BY id")
|
||||||
|
.bind(&release_ids)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|error| cot::Error::internal(error.to_string()))?;
|
||||||
|
if existing_release_ids != release_ids {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"one or more selected releases no longer exist",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let selected_track_ids: Vec<i64> = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM furumusic__track WHERE release_id = ANY($1) ORDER BY id",
|
||||||
|
)
|
||||||
|
.bind(&release_ids)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|error| cot::Error::internal(error.to_string()))?;
|
||||||
|
let mut requested_track_ids = tracks.iter().map(|track| track.id).collect::<Vec<_>>();
|
||||||
|
requested_track_ids.sort_unstable();
|
||||||
|
if requested_track_ids.windows(2).any(|ids| ids[0] == ids[1])
|
||||||
|
|| requested_track_ids != selected_track_ids
|
||||||
|
{
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"track list does not match the selected releases; reopen the merge wizard",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let storage_dir = AppConfig::load_with_db(&db).await.0.agent_storage_dir;
|
||||||
|
let result = match crate::library_cleanup::merge_releases(
|
||||||
|
pool,
|
||||||
|
crate::library_cleanup::ReleaseMergeSpec {
|
||||||
|
release_ids,
|
||||||
|
target_release_id: body.target_release_id,
|
||||||
|
title: title.to_owned(),
|
||||||
|
title_sort: normalize_name(title),
|
||||||
|
release_type,
|
||||||
|
year,
|
||||||
|
hidden: body.hidden,
|
||||||
|
cover_file_id: body.cover_file_id,
|
||||||
|
artist_ids: body.artist_ids,
|
||||||
|
tracks,
|
||||||
|
},
|
||||||
|
&storage_dir,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(?error, "release merge failed");
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"release merge failed; no changes were committed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(item) = fetch_library_item(pool, "releases", body.target_release_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| cot::Error::internal(error.to_string()))?
|
||||||
|
else {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"merged release could not be loaded",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
Json(MergeReleasesResponse {
|
||||||
|
ok: true,
|
||||||
|
merged_releases: result.merged_releases,
|
||||||
|
moved_tracks: result.moved_tracks,
|
||||||
|
item,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn require_admin_json(
|
async fn require_admin_json(
|
||||||
session: &Session,
|
session: &Session,
|
||||||
db: &Database,
|
db: &Database,
|
||||||
@@ -3003,6 +3515,7 @@ async fn load_library_item_detail(
|
|||||||
release_id: None,
|
release_id: None,
|
||||||
track_number: None,
|
track_number: None,
|
||||||
disc_number: None,
|
disc_number: None,
|
||||||
|
current_image_file_id: None,
|
||||||
current_image_url: None,
|
current_image_url: None,
|
||||||
selected_artist_ids: Vec::new(),
|
selected_artist_ids: Vec::new(),
|
||||||
artists: Vec::new(),
|
artists: Vec::new(),
|
||||||
@@ -3023,6 +3536,7 @@ async fn load_library_item_detail(
|
|||||||
.flatten();
|
.flatten();
|
||||||
detail.current_image_url =
|
detail.current_image_url =
|
||||||
image_file_id.map(|id| format!("/api/player/cover/{id}/large"));
|
image_file_id.map(|id| format!("/api/player/cover/{id}/large"));
|
||||||
|
detail.current_image_file_id = image_file_id;
|
||||||
detail.available_covers = artist_available_covers(pool, detail.item.id).await?;
|
detail.available_covers = artist_available_covers(pool, detail.item.id).await?;
|
||||||
}
|
}
|
||||||
"releases" => {
|
"releases" => {
|
||||||
@@ -3037,6 +3551,7 @@ async fn load_library_item_detail(
|
|||||||
detail.year = year;
|
detail.year = year;
|
||||||
detail.current_image_url =
|
detail.current_image_url =
|
||||||
cover_file_id.map(|id| format!("/api/player/cover/{id}/large"));
|
cover_file_id.map(|id| format!("/api/player/cover/{id}/large"));
|
||||||
|
detail.current_image_file_id = cover_file_id;
|
||||||
}
|
}
|
||||||
detail.selected_artist_ids = sqlx::query_as::<_, IdRow>(
|
detail.selected_artist_ids = sqlx::query_as::<_, IdRow>(
|
||||||
"SELECT artist_id AS id FROM furumusic__release_artist WHERE release_id = $1 ORDER BY position, artist_id",
|
"SELECT artist_id AS id FROM furumusic__release_artist WHERE release_id = $1 ORDER BY position, artist_id",
|
||||||
@@ -3484,10 +3999,11 @@ async fn apply_library_action(
|
|||||||
kind: &str,
|
kind: &str,
|
||||||
action: &str,
|
action: &str,
|
||||||
ids: &[i64],
|
ids: &[i64],
|
||||||
|
storage_dir: &str,
|
||||||
) -> cot::Result<u64> {
|
) -> cot::Result<u64> {
|
||||||
match action {
|
match action {
|
||||||
"hide" | "show" => set_library_visibility(pool, kind, ids, action == "hide").await,
|
"hide" | "show" => set_library_visibility(pool, kind, ids, action == "hide").await,
|
||||||
"delete" => delete_library_items(pool, kind, ids).await,
|
"delete" => delete_library_items(pool, kind, ids, storage_dir).await,
|
||||||
_ => Ok(0),
|
_ => Ok(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3538,10 +4054,15 @@ async fn set_library_visibility(
|
|||||||
Ok(result.rows_affected())
|
Ok(result.rows_affected())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_library_items(pool: &PgPool, kind: &str, ids: &[i64]) -> cot::Result<u64> {
|
async fn delete_library_items(
|
||||||
|
pool: &PgPool,
|
||||||
|
kind: &str,
|
||||||
|
ids: &[i64],
|
||||||
|
storage_dir: &str,
|
||||||
|
) -> cot::Result<u64> {
|
||||||
match kind {
|
match kind {
|
||||||
"releases" => delete_releases(pool, ids).await,
|
"releases" => delete_releases(pool, ids, storage_dir).await,
|
||||||
"tracks" => delete_tracks(pool, ids).await,
|
"tracks" => delete_tracks(pool, ids, storage_dir).await,
|
||||||
"playlists" => delete_playlists(pool, ids).await,
|
"playlists" => delete_playlists(pool, ids).await,
|
||||||
_ => delete_artists(pool, ids).await,
|
_ => delete_artists(pool, ids).await,
|
||||||
}
|
}
|
||||||
@@ -3571,95 +4092,16 @@ async fn delete_artists(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> {
|
|||||||
Ok(result.rows_affected())
|
Ok(result.rows_affected())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_releases(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> {
|
async fn delete_releases(pool: &PgPool, ids: &[i64], storage_dir: &str) -> cot::Result<u64> {
|
||||||
let track_ids =
|
crate::library_cleanup::delete_releases(pool, ids, storage_dir)
|
||||||
sqlx::query_as::<_, IdRow>("SELECT id FROM furumusic__track WHERE release_id = ANY($1)")
|
|
||||||
.bind(ids)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?
|
.map_err(|error| cot::Error::internal(error.to_string()))
|
||||||
.into_iter()
|
|
||||||
.map(|row| row.id)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
if !track_ids.is_empty() {
|
|
||||||
sqlx::query("DELETE FROM furumusic__playlist_track WHERE track_id = ANY($1)")
|
|
||||||
.bind(&track_ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
sqlx::query("DELETE FROM furumusic__user_liked_track WHERE track_id = ANY($1)")
|
|
||||||
.bind(&track_ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
sqlx::query("DELETE FROM furumusic__play_history WHERE track_id = ANY($1)")
|
|
||||||
.bind(&track_ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
sqlx::query("DELETE FROM furumusic__track_genre WHERE track_id = ANY($1)")
|
|
||||||
.bind(&track_ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
sqlx::query("DELETE FROM furumusic__track_artist WHERE track_id = ANY($1)")
|
|
||||||
.bind(&track_ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlx::query("DELETE FROM furumusic__track WHERE release_id = ANY($1)")
|
|
||||||
.bind(ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_tracks(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> {
|
async fn delete_tracks(pool: &PgPool, ids: &[i64], storage_dir: &str) -> cot::Result<u64> {
|
||||||
sqlx::query("DELETE FROM furumusic__playlist_track WHERE track_id = ANY($1)")
|
crate::library_cleanup::delete_tracks(pool, ids, storage_dir)
|
||||||
.bind(ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|error| cot::Error::internal(error.to_string()))
|
||||||
sqlx::query("DELETE FROM furumusic__user_liked_track WHERE track_id = ANY($1)")
|
|
||||||
.bind(ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
sqlx::query("DELETE FROM furumusic__play_history WHERE track_id = ANY($1)")
|
|
||||||
.bind(ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
sqlx::query("DELETE FROM furumusic__track_genre WHERE track_id = ANY($1)")
|
|
||||||
.bind(ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
sqlx::query("DELETE FROM furumusic__track_artist WHERE track_id = ANY($1)")
|
|
||||||
.bind(ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
let result = sqlx::query("DELETE FROM furumusic__track WHERE id = ANY($1)")
|
|
||||||
.bind(ids)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
Ok(result.rows_affected())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_playlists(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> {
|
async fn delete_playlists(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> {
|
||||||
@@ -4046,6 +4488,24 @@ fn parse_optional_admin_i32(value: Option<&str>, min: i32, max: i32) -> Option<i
|
|||||||
.map(|parsed| parsed.clamp(min, max))
|
.map(|parsed| parsed.clamp(min, max))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_merge_optional_i32(
|
||||||
|
value: Option<&str>,
|
||||||
|
min: i32,
|
||||||
|
max: i32,
|
||||||
|
field: &str,
|
||||||
|
) -> Result<Option<i32>, String> {
|
||||||
|
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let parsed = value
|
||||||
|
.parse::<i32>()
|
||||||
|
.map_err(|_| format!("{field} must be an integer from {min} to {max}"))?;
|
||||||
|
if !(min..=max).contains(&parsed) {
|
||||||
|
return Err(format!("{field} must be from {min} to {max}"));
|
||||||
|
}
|
||||||
|
Ok(Some(parsed))
|
||||||
|
}
|
||||||
|
|
||||||
fn deserialize_optional_stringish<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
fn deserialize_optional_stringish<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
||||||
where
|
where
|
||||||
D: Deserializer<'de>,
|
D: Deserializer<'de>,
|
||||||
@@ -4119,3 +4579,22 @@ fn size_display(bytes: i64) -> String {
|
|||||||
format!("{bytes} B")
|
format!("{bytes} B")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::parse_merge_optional_i32;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_numbers_preserve_empty_values_and_reject_invalid_input() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_merge_optional_i32(None, 1, 9999, "track number").unwrap(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_merge_optional_i32(Some(" 38 "), 1, 9999, "track number").unwrap(),
|
||||||
|
Some(38)
|
||||||
|
);
|
||||||
|
assert!(parse_merge_optional_i32(Some("0"), 1, 9999, "track number").is_err());
|
||||||
|
assert!(parse_merge_optional_i32(Some("nope"), 1, 9999, "track number").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+3
-1
@@ -1262,9 +1262,11 @@ pub async fn releases_update(
|
|||||||
pub async fn releases_delete(
|
pub async fn releases_delete(
|
||||||
_admin: AuthenticatedUser,
|
_admin: AuthenticatedUser,
|
||||||
db: &Database,
|
db: &Database,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
release_id: i64,
|
release_id: i64,
|
||||||
) -> cot::Result<cot::http::Response<Body>> {
|
) -> cot::Result<cot::http::Response<Body>> {
|
||||||
Release::delete_by_id(db, release_id)
|
let (config, _) = AppConfig::load_with_db(db).await;
|
||||||
|
crate::library_cleanup::delete_releases(pool, &[release_id], &config.agent_storage_dir)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(format!("failed to delete release: {e}")))?;
|
.map_err(|e| cot::Error::internal(format!("failed to delete release: {e}")))?;
|
||||||
Ok(auth::redirect("/admin/releases"))
|
Ok(auth::redirect("/admin/releases"))
|
||||||
|
|||||||
+204
@@ -142,6 +142,13 @@ pub struct ConfigSources {
|
|||||||
pub similarity_model: ConfigSource,
|
pub similarity_model: ConfigSource,
|
||||||
pub similarity_profile: ConfigSource,
|
pub similarity_profile: ConfigSource,
|
||||||
pub similarity_workers: ConfigSource,
|
pub similarity_workers: ConfigSource,
|
||||||
|
pub downloads_enabled: ConfigSource,
|
||||||
|
pub torrent_downloads_enabled: ConfigSource,
|
||||||
|
pub youtube_downloads_enabled: ConfigSource,
|
||||||
|
pub download_proxies: ConfigSource,
|
||||||
|
pub torrent_proxy_id: ConfigSource,
|
||||||
|
pub youtube_proxy_id: ConfigSource,
|
||||||
|
pub youtube_cookie_id: ConfigSource,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ConfigSources {
|
impl Default for ConfigSources {
|
||||||
@@ -176,6 +183,13 @@ impl Default for ConfigSources {
|
|||||||
similarity_model: ConfigSource::Default,
|
similarity_model: ConfigSource::Default,
|
||||||
similarity_profile: ConfigSource::Default,
|
similarity_profile: ConfigSource::Default,
|
||||||
similarity_workers: ConfigSource::Default,
|
similarity_workers: ConfigSource::Default,
|
||||||
|
downloads_enabled: ConfigSource::Default,
|
||||||
|
torrent_downloads_enabled: ConfigSource::Default,
|
||||||
|
youtube_downloads_enabled: ConfigSource::Default,
|
||||||
|
download_proxies: ConfigSource::Default,
|
||||||
|
torrent_proxy_id: ConfigSource::Default,
|
||||||
|
youtube_proxy_id: ConfigSource::Default,
|
||||||
|
youtube_cookie_id: ConfigSource::Default,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,6 +252,84 @@ macro_rules! impl_env_overrides {
|
|||||||
// AppConfig
|
// AppConfig
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Saved SOCKS5 proxy used by user-facing download methods.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct DownloadProxy {
|
||||||
|
pub id: String,
|
||||||
|
pub address: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub username: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DownloadProxy {
|
||||||
|
/// Validate and normalize a proxy without performing network or DNS I/O.
|
||||||
|
pub fn normalized(mut self) -> anyhow::Result<Self> {
|
||||||
|
self.id = self.id.trim().to_string();
|
||||||
|
self.address = self.address.trim().to_string();
|
||||||
|
|
||||||
|
if self.id.is_empty() || self.id.len() > 64 {
|
||||||
|
anyhow::bail!("proxy id must contain from 1 to 64 characters");
|
||||||
|
}
|
||||||
|
if !self
|
||||||
|
.id
|
||||||
|
.chars()
|
||||||
|
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
|
||||||
|
{
|
||||||
|
anyhow::bail!("proxy id may contain only letters, digits, '-' and '_'");
|
||||||
|
}
|
||||||
|
if self.address.is_empty() || self.address.len() > 512 {
|
||||||
|
anyhow::bail!("proxy address must contain a host and port");
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.address
|
||||||
|
.chars()
|
||||||
|
.any(|character| matches!(character, '/' | '?' | '#' | '@'))
|
||||||
|
{
|
||||||
|
anyhow::bail!("proxy address must be in host:port format");
|
||||||
|
}
|
||||||
|
if self.username.len() > 256 || self.password.len() > 256 {
|
||||||
|
anyhow::bail!("proxy credentials are too long");
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = reqwest::Url::parse(&format!("socks5://{}", self.address))
|
||||||
|
.map_err(|_| anyhow::anyhow!("proxy address must be in host:port format"))?;
|
||||||
|
let host = parsed
|
||||||
|
.host_str()
|
||||||
|
.filter(|host| !host.trim().is_empty())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("proxy address has no host"))?;
|
||||||
|
let port = parsed
|
||||||
|
.port()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("proxy address has no port"))?;
|
||||||
|
if port == 0 {
|
||||||
|
anyhow::bail!("proxy port must be between 1 and 65535");
|
||||||
|
}
|
||||||
|
self.address = if host.starts_with('[') && host.ends_with(']') {
|
||||||
|
format!("{host}:{port}")
|
||||||
|
} else if host.contains(':') {
|
||||||
|
format!("[{host}]:{port}")
|
||||||
|
} else {
|
||||||
|
format!("{host}:{port}")
|
||||||
|
};
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the URL accepted by librqbit and yt-dlp. Credentials are included
|
||||||
|
/// only when both fields are non-empty.
|
||||||
|
pub fn socks_url(&self) -> anyhow::Result<String> {
|
||||||
|
let proxy = self.clone().normalized()?;
|
||||||
|
let mut url = reqwest::Url::parse(&format!("socks5://{}", proxy.address))?;
|
||||||
|
if !proxy.username.is_empty() && !proxy.password.is_empty() {
|
||||||
|
url.set_username(&proxy.username)
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid proxy username"))?;
|
||||||
|
url.set_password(Some(&proxy.password))
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid proxy password"))?;
|
||||||
|
}
|
||||||
|
Ok(url.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
/// PostgreSQL connection URL.
|
/// PostgreSQL connection URL.
|
||||||
@@ -301,6 +393,20 @@ pub struct AppConfig {
|
|||||||
pub similarity_profile: String,
|
pub similarity_profile: String,
|
||||||
/// Maximum number of concurrent CPU embedding workers.
|
/// Maximum number of concurrent CPU embedding workers.
|
||||||
pub similarity_workers: u64,
|
pub similarity_workers: u64,
|
||||||
|
/// Whether the download manager and local-file uploads are available.
|
||||||
|
pub downloads_enabled: bool,
|
||||||
|
/// Whether torrent imports are available when the download manager is enabled.
|
||||||
|
pub torrent_downloads_enabled: bool,
|
||||||
|
/// Whether YouTube imports are available when the download manager is enabled.
|
||||||
|
pub youtube_downloads_enabled: bool,
|
||||||
|
/// JSON-encoded list of [`DownloadProxy`] entries.
|
||||||
|
pub download_proxies: String,
|
||||||
|
/// Saved proxy id used for torrent downloads; empty means a direct connection.
|
||||||
|
pub torrent_proxy_id: String,
|
||||||
|
/// Saved proxy id used for YouTube downloads; empty means a direct connection.
|
||||||
|
pub youtube_proxy_id: String,
|
||||||
|
/// Saved cookie-file id used by yt-dlp; empty means no cookies.
|
||||||
|
pub youtube_cookie_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
@@ -337,6 +443,14 @@ impl Default for AppConfig {
|
|||||||
similarity_workers: std::thread::available_parallelism()
|
similarity_workers: std::thread::available_parallelism()
|
||||||
.map(|count| (count.get() / 2).clamp(1, 4) as u64)
|
.map(|count| (count.get() / 2).clamp(1, 4) as u64)
|
||||||
.unwrap_or(1),
|
.unwrap_or(1),
|
||||||
|
// Preserve the behavior from before these controls were added.
|
||||||
|
downloads_enabled: true,
|
||||||
|
torrent_downloads_enabled: true,
|
||||||
|
youtube_downloads_enabled: true,
|
||||||
|
download_proxies: "[]".into(),
|
||||||
|
torrent_proxy_id: String::new(),
|
||||||
|
youtube_proxy_id: String::new(),
|
||||||
|
youtube_cookie_id: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,6 +486,13 @@ impl_env_overrides!(
|
|||||||
similarity_model,
|
similarity_model,
|
||||||
similarity_profile,
|
similarity_profile,
|
||||||
similarity_workers,
|
similarity_workers,
|
||||||
|
downloads_enabled,
|
||||||
|
torrent_downloads_enabled,
|
||||||
|
youtube_downloads_enabled,
|
||||||
|
download_proxies,
|
||||||
|
torrent_proxy_id,
|
||||||
|
youtube_proxy_id,
|
||||||
|
youtube_cookie_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
@@ -466,6 +587,7 @@ impl AppConfig {
|
|||||||
sources.$field = ConfigSource::Database;
|
sources.$field = ConfigSource::Database;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
|
if !val.trim().is_empty() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"ignoring invalid DB config value for {}: {:?}",
|
"ignoring invalid DB config value for {}: {:?}",
|
||||||
stringify!($field),
|
stringify!($field),
|
||||||
@@ -474,6 +596,7 @@ impl AppConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,6 +629,40 @@ impl AppConfig {
|
|||||||
apply_db_field!(similarity_model);
|
apply_db_field!(similarity_model);
|
||||||
apply_db_field!(similarity_profile);
|
apply_db_field!(similarity_profile);
|
||||||
apply_db_field!(similarity_workers);
|
apply_db_field!(similarity_workers);
|
||||||
|
apply_db_field!(downloads_enabled);
|
||||||
|
apply_db_field!(torrent_downloads_enabled);
|
||||||
|
apply_db_field!(youtube_downloads_enabled);
|
||||||
|
apply_db_field!(download_proxies);
|
||||||
|
apply_db_field!(torrent_proxy_id);
|
||||||
|
apply_db_field!(youtube_proxy_id);
|
||||||
|
apply_db_field!(youtube_cookie_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parsed_download_proxies(&self) -> anyhow::Result<Vec<DownloadProxy>> {
|
||||||
|
let proxies: Vec<DownloadProxy> = serde_json::from_str(&self.download_proxies)
|
||||||
|
.map_err(|_| anyhow::anyhow!("saved download proxy list is invalid"))?;
|
||||||
|
proxies.into_iter().map(DownloadProxy::normalized).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_proxy_url(&self, proxy_id: &str) -> anyhow::Result<Option<String>> {
|
||||||
|
let proxy_id = proxy_id.trim();
|
||||||
|
if proxy_id.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let proxy = self
|
||||||
|
.parsed_download_proxies()?
|
||||||
|
.into_iter()
|
||||||
|
.find(|proxy| proxy.id == proxy_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("selected download proxy is not configured"))?;
|
||||||
|
proxy.socks_url().map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn torrent_proxy_url(&self) -> anyhow::Result<Option<String>> {
|
||||||
|
self.selected_proxy_url(&self.torrent_proxy_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn youtube_proxy_url(&self) -> anyhow::Result<Option<String>> {
|
||||||
|
self.selected_proxy_url(&self.youtube_proxy_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,6 +689,53 @@ mod tests {
|
|||||||
crate::similarity::DEFAULT_PROFILE_ID
|
crate::similarity::DEFAULT_PROFILE_ID
|
||||||
);
|
);
|
||||||
assert!((1..=4).contains(&cfg.similarity_workers));
|
assert!((1..=4).contains(&cfg.similarity_workers));
|
||||||
|
assert!(cfg.downloads_enabled);
|
||||||
|
assert!(cfg.torrent_downloads_enabled);
|
||||||
|
assert!(cfg.youtube_downloads_enabled);
|
||||||
|
assert!(cfg.parsed_download_proxies().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_proxy_url_encodes_complete_credentials() {
|
||||||
|
let proxy = DownloadProxy {
|
||||||
|
id: "proxy-1".into(),
|
||||||
|
address: "proxy.example:1080".into(),
|
||||||
|
username: "user name".into(),
|
||||||
|
password: "p@ss:word".into(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
proxy.socks_url().unwrap(),
|
||||||
|
"socks5://user%20name:p%40ss%3Aword@proxy.example:1080"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_proxy_url_omits_partial_credentials() {
|
||||||
|
let proxy = DownloadProxy {
|
||||||
|
id: "proxy-1".into(),
|
||||||
|
address: "127.0.0.1:1080".into(),
|
||||||
|
username: "user".into(),
|
||||||
|
password: String::new(),
|
||||||
|
};
|
||||||
|
assert_eq!(proxy.socks_url().unwrap(), "socks5://127.0.0.1:1080");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selected_download_proxy_is_resolved_by_id() {
|
||||||
|
let mut cfg = AppConfig::default();
|
||||||
|
cfg.download_proxies = serde_json::to_string(&[DownloadProxy {
|
||||||
|
id: "youtube".into(),
|
||||||
|
address: "[::1]:9050".into(),
|
||||||
|
username: String::new(),
|
||||||
|
password: String::new(),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
cfg.youtube_proxy_id = "youtube".into();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.youtube_proxy_url().unwrap().as_deref(),
|
||||||
|
Some("socks5://[::1]:9050")
|
||||||
|
);
|
||||||
|
assert_eq!(cfg.torrent_proxy_url().unwrap(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -406,6 +406,13 @@ translations! {
|
|||||||
player_youtube_select_all: "Select all" , "Отметить все";
|
player_youtube_select_all: "Select all" , "Отметить все";
|
||||||
player_youtube_clear_selection: "Clear selection" , "Снять все";
|
player_youtube_clear_selection: "Clear selection" , "Снять все";
|
||||||
player_youtube_selected_count: "selected" , "выбрано";
|
player_youtube_selected_count: "selected" , "выбрано";
|
||||||
|
player_youtube_destination: "Add imported tracks to playlist" , "Добавить импортированные треки в плейлист";
|
||||||
|
player_youtube_no_destination: "Do not add to a playlist" , "Не добавлять в плейлист";
|
||||||
|
player_youtube_create_playlist: "Create a new playlist" , "Создать новый плейлист";
|
||||||
|
player_youtube_new_playlist_name: "New playlist name" , "Название нового плейлиста";
|
||||||
|
player_youtube_destination_hint: "Every track created from the selected videos, including chapters and previously imported videos, will be added automatically." , "Все треки из выбранных видео, включая главы и уже импортированные видео, будут добавлены автоматически.";
|
||||||
|
player_youtube_playlist_create_failed: "Could not create playlist" , "Не удалось создать плейлист";
|
||||||
|
player_youtube_added_to: "added to" , "добавление в";
|
||||||
player_youtube_start_import: "Start import" , "Начать импорт";
|
player_youtube_start_import: "Start import" , "Начать импорт";
|
||||||
player_start_download: "Start download" , "Начать загрузку";
|
player_start_download: "Start download" , "Начать загрузку";
|
||||||
player_retry_failed: "Retry failed" , "Повторить ошибки";
|
player_retry_failed: "Retry failed" , "Повторить ошибки";
|
||||||
@@ -533,6 +540,7 @@ translations! {
|
|||||||
player_download_selected: "Download selected" , "Скачать выбранное";
|
player_download_selected: "Download selected" , "Скачать выбранное";
|
||||||
player_pause_download: "Pause download" , "Поставить на паузу";
|
player_pause_download: "Pause download" , "Поставить на паузу";
|
||||||
player_expand_all: "Expand all" , "Развернуть всё";
|
player_expand_all: "Expand all" , "Развернуть всё";
|
||||||
|
player_expand: "Expand" , "Развернуть";
|
||||||
player_collapse: "Collapse" , "Свернуть";
|
player_collapse: "Collapse" , "Свернуть";
|
||||||
player_selected: "selected" , "выбрано";
|
player_selected: "selected" , "выбрано";
|
||||||
player_preview: "Preview" , "Предпросмотр";
|
player_preview: "Preview" , "Предпросмотр";
|
||||||
|
|||||||
@@ -988,7 +988,42 @@ pub async fn finalize_approved(
|
|||||||
})?
|
})?
|
||||||
};
|
};
|
||||||
|
|
||||||
let media_file = MediaFile::create(
|
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"#,
|
||||||
|
)
|
||||||
|
.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,
|
db,
|
||||||
"audio",
|
"audio",
|
||||||
&storage_path,
|
&storage_path,
|
||||||
@@ -1004,7 +1039,8 @@ pub async fn finalize_approved(
|
|||||||
Some(uploader_name),
|
Some(uploader_name),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("failed to create media file: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("failed to create media file: {e}"))?
|
||||||
|
};
|
||||||
|
|
||||||
let track = Track::create(
|
let track = Track::create(
|
||||||
db,
|
db,
|
||||||
@@ -1020,6 +1056,35 @@ pub async fn finalize_approved(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("failed to create track: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("failed to create track: {e}"))?;
|
||||||
|
|
||||||
|
if let Err(error) = sqlx::query(
|
||||||
|
r#"INSERT INTO furumusic__youtube_import_media (item_id, media_file_id)
|
||||||
|
SELECT DISTINCT item.id, $1
|
||||||
|
FROM furumusic__youtube_download_item item
|
||||||
|
JOIN furumusic__pending_review review
|
||||||
|
ON item.inbox_path IS NOT NULL
|
||||||
|
AND (review.input_path = item.inbox_path
|
||||||
|
OR left(review.input_path, length(item.inbox_path) + 1)
|
||||||
|
= item.inbox_path || '/')
|
||||||
|
WHERE review.context_json IS NOT NULL
|
||||||
|
AND substring(
|
||||||
|
review.context_json
|
||||||
|
from '"sha256"[[:space:]]*:[[:space:]]*"([0-9a-fA-F]{64})"'
|
||||||
|
) = $2
|
||||||
|
ON CONFLICT (item_id, media_file_id) DO NOTHING"#,
|
||||||
|
)
|
||||||
|
.bind(media_file.id_val())
|
||||||
|
.bind(sha256)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
track_id = track.id_val(),
|
||||||
|
media_file_id = media_file.id_val(),
|
||||||
|
error = %error,
|
||||||
|
"failed to link imported media to its YouTube download item"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
TrackArtist::create(db, track.id_val(), artist.id_val(), "main", 0)
|
TrackArtist::create(db, track.id_val(), artist.id_val(), "main", 0)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("failed to link track-artist: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("failed to link track-artist: {e}"))?;
|
||||||
@@ -1131,6 +1196,17 @@ pub async fn finalize_approved(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Err(error) =
|
||||||
|
crate::youtube::sync_target_playlists_for_imported_media(pool, media_file.id_val()).await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
track_id = track.id_val(),
|
||||||
|
media_file_id = media_file.id_val(),
|
||||||
|
%error,
|
||||||
|
"could not add an imported YouTube track to its target playlist; it will be retried"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
track_id = track.id_val(),
|
track_id = track.id_val(),
|
||||||
artist = artist_name,
|
artist = artist_name,
|
||||||
|
|||||||
@@ -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 i18n;
|
||||||
mod jobs;
|
mod jobs;
|
||||||
mod lastfm;
|
mod lastfm;
|
||||||
|
mod library_cleanup;
|
||||||
mod local_uploads;
|
mod local_uploads;
|
||||||
mod media_paths;
|
mod media_paths;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
@@ -90,7 +91,13 @@ async fn index(
|
|||||||
return Ok(auth::redirect("/login"));
|
return Ok(auth::redirect("/login"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let template = player::PlayerPageTemplate { t: i18n.t };
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let template = player::PlayerPageTemplate {
|
||||||
|
t: i18n.t,
|
||||||
|
downloads_enabled: config.downloads_enabled,
|
||||||
|
torrent_downloads_enabled: config.downloads_enabled && config.torrent_downloads_enabled,
|
||||||
|
youtube_downloads_enabled: config.downloads_enabled && config.youtube_downloads_enabled,
|
||||||
|
};
|
||||||
Html::new(template.render()?).into_response()
|
Html::new(template.render()?).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -832,6 +832,8 @@ const KNOWN_HTTP_ROUTES: &[&str] = &[
|
|||||||
"/admin/v2/api/jobs/{name}/run",
|
"/admin/v2/api/jobs/{name}/run",
|
||||||
"/admin/v2/api/settings",
|
"/admin/v2/api/settings",
|
||||||
"/admin/v2/api/settings/probe",
|
"/admin/v2/api/settings/probe",
|
||||||
|
"/admin/v2/api/settings/youtube-cookies",
|
||||||
|
"/admin/v2/api/settings/youtube-cookies/{id}",
|
||||||
"/admin/v2/api/jobs/{name}/toggle",
|
"/admin/v2/api/jobs/{name}/toggle",
|
||||||
"/admin/v2/api/jobs/{name}/runs",
|
"/admin/v2/api/jobs/{name}/runs",
|
||||||
"/admin/v2/api/jobs/{name}/runs/{run_id}",
|
"/admin/v2/api/jobs/{name}/runs/{run_id}",
|
||||||
@@ -841,6 +843,7 @@ const KNOWN_HTTP_ROUTES: &[&str] = &[
|
|||||||
"/admin/v2/api/library/item/image",
|
"/admin/v2/api/library/item/image",
|
||||||
"/admin/v2/api/library/item/upload-image",
|
"/admin/v2/api/library/item/upload-image",
|
||||||
"/admin/v2/api/library/bulk",
|
"/admin/v2/api/library/bulk",
|
||||||
|
"/admin/v2/api/library/releases/merge",
|
||||||
"/admin/debug",
|
"/admin/debug",
|
||||||
"/admin/settings",
|
"/admin/settings",
|
||||||
"/admin/settings/probe",
|
"/admin/settings/probe",
|
||||||
|
|||||||
@@ -2715,6 +2715,73 @@ pub mod db_migrations {
|
|||||||
&[Operation::custom(create_local_upload_history).build()];
|
&[Operation::custom(create_local_upload_history).build()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- M0047: durable YouTube item -> imported media links ---------------
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_youtube_import_media_links(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__youtube_import_media (
|
||||||
|
item_id VARCHAR(36) NOT NULL
|
||||||
|
REFERENCES furumusic__youtube_download_item(id) ON DELETE CASCADE,
|
||||||
|
media_file_id BIGINT NOT NULL
|
||||||
|
REFERENCES furumusic__media_file(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (item_id, media_file_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_youtube_import_media_file
|
||||||
|
ON furumusic__youtube_import_media (media_file_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// Backfill links for existing imports through the inbox review hash.
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"INSERT INTO furumusic__youtube_import_media (item_id, media_file_id)
|
||||||
|
SELECT DISTINCT item.id, media.id
|
||||||
|
FROM furumusic__youtube_download_item item
|
||||||
|
JOIN furumusic__pending_review review
|
||||||
|
ON item.inbox_path IS NOT NULL
|
||||||
|
AND (review.input_path = item.inbox_path
|
||||||
|
OR left(review.input_path, length(item.inbox_path) + 1)
|
||||||
|
= item.inbox_path || '/')
|
||||||
|
JOIN furumusic__media_file media
|
||||||
|
ON media.sha256_hash::text = substring(
|
||||||
|
review.context_json
|
||||||
|
from '\"sha256\"[[:space:]]*:[[:space:]]*\"([0-9a-fA-F]{64})\"'
|
||||||
|
)
|
||||||
|
JOIN furumusic__track track ON track.audio_file_id = media.id
|
||||||
|
WHERE review.context_json IS NOT NULL
|
||||||
|
ON CONFLICT (item_id, media_file_id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0047CreateYouTubeImportMediaLinks;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0047CreateYouTubeImportMediaLinks {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0047_create_youtube_import_media_links";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||||
|
migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0046_create_local_upload_history",
|
||||||
|
),
|
||||||
|
migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0027_create_processing_stats",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_youtube_import_media_links).build()];
|
||||||
|
}
|
||||||
|
|
||||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||||
&M0006CreateMediaFile,
|
&M0006CreateMediaFile,
|
||||||
&M0007CreateArtist,
|
&M0007CreateArtist,
|
||||||
@@ -2752,5 +2819,6 @@ pub mod db_migrations {
|
|||||||
&M0044AddSimilarityRoutingSignature,
|
&M0044AddSimilarityRoutingSignature,
|
||||||
&M0045CreateYouTubeDownloads,
|
&M0045CreateYouTubeDownloads,
|
||||||
&M0046CreateLocalUploadHistory,
|
&M0046CreateLocalUploadHistory,
|
||||||
|
&M0047CreateYouTubeImportMediaLinks,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+190
-4
@@ -49,6 +49,59 @@ fn json_error(status: StatusCode, message: &str) -> cot::response::Response {
|
|||||||
.expect("valid response")
|
.expect("valid response")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum DownloadMethod {
|
||||||
|
LocalFile,
|
||||||
|
Torrent,
|
||||||
|
YouTube,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_download_method(
|
||||||
|
config: &AppConfig,
|
||||||
|
method: DownloadMethod,
|
||||||
|
) -> Result<(), cot::response::Response> {
|
||||||
|
let enabled = config.downloads_enabled
|
||||||
|
&& match method {
|
||||||
|
DownloadMethod::LocalFile => true,
|
||||||
|
DownloadMethod::Torrent => config.torrent_downloads_enabled,
|
||||||
|
DownloadMethod::YouTube => config.youtube_downloads_enabled,
|
||||||
|
};
|
||||||
|
if enabled {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(json_error(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
match method {
|
||||||
|
DownloadMethod::LocalFile => "downloads are disabled by the administrator",
|
||||||
|
DownloadMethod::Torrent => "torrent downloads are disabled by the administrator",
|
||||||
|
DownloadMethod::YouTube => "YouTube downloads are disabled by the administrator",
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_proxy_for(
|
||||||
|
config: &AppConfig,
|
||||||
|
method: DownloadMethod,
|
||||||
|
) -> Result<Option<String>, cot::response::Response> {
|
||||||
|
require_download_method(config, method)?;
|
||||||
|
let result = match method {
|
||||||
|
DownloadMethod::LocalFile => Ok(None),
|
||||||
|
DownloadMethod::Torrent => config.torrent_proxy_url(),
|
||||||
|
DownloadMethod::YouTube => config.youtube_proxy_url(),
|
||||||
|
};
|
||||||
|
result.map_err(|error| json_error(StatusCode::BAD_REQUEST, &error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn youtube_cookie_contents(
|
||||||
|
config: &AppConfig,
|
||||||
|
db: &Database,
|
||||||
|
) -> Result<Option<String>, cot::response::Response> {
|
||||||
|
crate::youtube::selected_cookie_contents(db, &config.youtube_cookie_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| json_error(StatusCode::BAD_REQUEST, &error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
struct LocalUploadResponse {
|
struct LocalUploadResponse {
|
||||||
ok: bool,
|
ok: bool,
|
||||||
@@ -1375,6 +1428,40 @@ struct LastfmCallbackQuery {
|
|||||||
#[template(path = "player.html")]
|
#[template(path = "player.html")]
|
||||||
pub struct PlayerPageTemplate {
|
pub struct PlayerPageTemplate {
|
||||||
pub t: &'static Translations,
|
pub t: &'static Translations,
|
||||||
|
pub downloads_enabled: bool,
|
||||||
|
pub torrent_downloads_enabled: bool,
|
||||||
|
pub youtube_downloads_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod page_template_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::i18n::Lang;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_manager_button_follows_the_global_switch() {
|
||||||
|
let disabled = PlayerPageTemplate {
|
||||||
|
t: Translations::for_lang(Lang::En),
|
||||||
|
downloads_enabled: false,
|
||||||
|
torrent_downloads_enabled: false,
|
||||||
|
youtube_downloads_enabled: false,
|
||||||
|
}
|
||||||
|
.render()
|
||||||
|
.unwrap();
|
||||||
|
assert!(!disabled.contains("<button class=\"torrent-import-btn\""));
|
||||||
|
assert!(disabled.contains("downloadsEnabled: false"));
|
||||||
|
|
||||||
|
let enabled = PlayerPageTemplate {
|
||||||
|
t: Translations::for_lang(Lang::En),
|
||||||
|
downloads_enabled: true,
|
||||||
|
torrent_downloads_enabled: true,
|
||||||
|
youtube_downloads_enabled: true,
|
||||||
|
}
|
||||||
|
.render()
|
||||||
|
.unwrap();
|
||||||
|
assert!(enabled.contains("<button class=\"torrent-import-btn\""));
|
||||||
|
assert!(enabled.contains("downloadsEnabled: true"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -4838,6 +4925,9 @@ async fn local_upload_handler(
|
|||||||
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||||
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
};
|
};
|
||||||
|
if let Err(response) = require_download_method(&config, DownloadMethod::LocalFile) {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
let inbox_dir = config.agent_inbox_dir.trim();
|
let inbox_dir = config.agent_inbox_dir.trim();
|
||||||
if inbox_dir.is_empty() {
|
if inbox_dir.is_empty() {
|
||||||
@@ -4958,6 +5048,9 @@ async fn local_upload_history_handler(
|
|||||||
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
};
|
};
|
||||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
if let Err(response) = require_download_method(&config, DownloadMethod::LocalFile) {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
match crate::local_uploads::list(pool, user.id, &config.agent_inbox_dir).await {
|
match crate::local_uploads::list(pool, user.id, &config.agent_inbox_dir).await {
|
||||||
Ok(items) => Json(items).into_response(),
|
Ok(items) => Json(items).into_response(),
|
||||||
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string())),
|
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string())),
|
||||||
@@ -4974,6 +5067,10 @@ async fn local_upload_history_remove_handler(
|
|||||||
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||||
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
};
|
};
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
if let Err(response) = require_download_method(&config, DownloadMethod::LocalFile) {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
match crate::local_uploads::remove(pool, user.id, &path.0.id).await {
|
match crate::local_uploads::remove(pool, user.id, &path.0.id).await {
|
||||||
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
|
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||||
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string())),
|
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string())),
|
||||||
@@ -8446,12 +8543,31 @@ impl App for PlayerApp {
|
|||||||
"not authenticated",
|
"not authenticated",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let proxy_url = match download_proxy_for(
|
||||||
|
&live_config,
|
||||||
|
DownloadMethod::YouTube,
|
||||||
|
) {
|
||||||
|
Ok(proxy_url) => proxy_url,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
let cookie_contents = match youtube_cookie_contents(&live_config, &db).await {
|
||||||
|
Ok(cookie_contents) => cookie_contents,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
let service = youtube_service
|
let service = youtube_service
|
||||||
.get_or_init(|| async {
|
.get_or_init(|| async {
|
||||||
Arc::new(YouTubeService::new(Arc::clone(&scheduler_handle)))
|
Arc::new(YouTubeService::new(Arc::clone(&scheduler_handle)))
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
match service.preview(json.0).await {
|
match service
|
||||||
|
.preview(
|
||||||
|
json.0,
|
||||||
|
proxy_url.as_deref(),
|
||||||
|
cookie_contents.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(preview) => Json(preview).into_response(),
|
Ok(preview) => Json(preview).into_response(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
||||||
@@ -8500,8 +8616,25 @@ impl App for PlayerApp {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let proxy_url = match download_proxy_for(
|
||||||
|
&live_config,
|
||||||
|
DownloadMethod::YouTube,
|
||||||
|
) {
|
||||||
|
Ok(proxy_url) => proxy_url,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
let cookie_contents = match youtube_cookie_contents(&live_config, &db).await {
|
||||||
|
Ok(cookie_contents) => cookie_contents,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
match service
|
match service
|
||||||
.list(pg_pool, user.id, &live_config.agent_inbox_dir)
|
.list(
|
||||||
|
pg_pool,
|
||||||
|
user.id,
|
||||||
|
&live_config.agent_inbox_dir,
|
||||||
|
proxy_url,
|
||||||
|
cookie_contents,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(items) => Json(items).into_response(),
|
Ok(items) => Json(items).into_response(),
|
||||||
@@ -8555,12 +8688,25 @@ impl App for PlayerApp {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let proxy_url = match download_proxy_for(
|
||||||
|
&live_config,
|
||||||
|
DownloadMethod::YouTube,
|
||||||
|
) {
|
||||||
|
Ok(proxy_url) => proxy_url,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
let cookie_contents = match youtube_cookie_contents(&live_config, &db).await {
|
||||||
|
Ok(cookie_contents) => cookie_contents,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
match service
|
match service
|
||||||
.start(
|
.start(
|
||||||
pg_pool,
|
pg_pool,
|
||||||
user.id,
|
user.id,
|
||||||
json.0,
|
json.0,
|
||||||
&live_config.agent_inbox_dir,
|
&live_config.agent_inbox_dir,
|
||||||
|
proxy_url,
|
||||||
|
cookie_contents,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -8615,12 +8761,25 @@ impl App for PlayerApp {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let proxy_url = match download_proxy_for(
|
||||||
|
&live_config,
|
||||||
|
DownloadMethod::YouTube,
|
||||||
|
) {
|
||||||
|
Ok(proxy_url) => proxy_url,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
let cookie_contents = match youtube_cookie_contents(&live_config, &db).await {
|
||||||
|
Ok(cookie_contents) => cookie_contents,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
match service
|
match service
|
||||||
.retry(
|
.retry(
|
||||||
pg_pool,
|
pg_pool,
|
||||||
user.id,
|
user.id,
|
||||||
&path.0.id,
|
&path.0.id,
|
||||||
&live_config.agent_inbox_dir,
|
&live_config.agent_inbox_dir,
|
||||||
|
proxy_url,
|
||||||
|
cookie_contents,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -8778,12 +8937,20 @@ impl App for PlayerApp {
|
|||||||
.expect("player pool")
|
.expect("player pool")
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let proxy_url = match download_proxy_for(
|
||||||
|
&live_config,
|
||||||
|
DownloadMethod::Torrent,
|
||||||
|
) {
|
||||||
|
Ok(proxy_url) => proxy_url,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
let service = torrent_service
|
let service = torrent_service
|
||||||
.get_or_init(|| async {
|
.get_or_init(|| async {
|
||||||
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
match service.list(pg_pool, user.id).await {
|
match service.list(pg_pool, user.id, proxy_url).await {
|
||||||
Ok(items) => Json(items).into_response(),
|
Ok(items) => Json(items).into_response(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
||||||
@@ -8927,12 +9094,23 @@ impl App for PlayerApp {
|
|||||||
.expect("player pool")
|
.expect("player pool")
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let proxy_url = match download_proxy_for(
|
||||||
|
&live_config,
|
||||||
|
DownloadMethod::Torrent,
|
||||||
|
) {
|
||||||
|
Ok(proxy_url) => proxy_url,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
let service = torrent_service
|
let service = torrent_service
|
||||||
.get_or_init(|| async {
|
.get_or_init(|| async {
|
||||||
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
match service.preview(pg_pool, user.id, json.0).await {
|
match service
|
||||||
|
.preview(pg_pool, user.id, json.0, proxy_url.as_deref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(preview) => Json(preview).into_response(),
|
Ok(preview) => Json(preview).into_response(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
||||||
@@ -9284,6 +9462,13 @@ impl App for PlayerApp {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let proxy_url = match download_proxy_for(
|
||||||
|
&live_config,
|
||||||
|
DownloadMethod::Torrent,
|
||||||
|
) {
|
||||||
|
Ok(proxy_url) => proxy_url,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
let service = torrent_service
|
let service = torrent_service
|
||||||
.get_or_init(|| async {
|
.get_or_init(|| async {
|
||||||
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
||||||
@@ -9296,6 +9481,7 @@ impl App for PlayerApp {
|
|||||||
json.0.selected_files,
|
json.0.selected_files,
|
||||||
live_config.agent_inbox_dir,
|
live_config.agent_inbox_dir,
|
||||||
user.id,
|
user.id,
|
||||||
|
proxy_url.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
+62
-31
@@ -373,7 +373,8 @@ impl TorrentJob {
|
|||||||
|
|
||||||
pub struct TorrentService {
|
pub struct TorrentService {
|
||||||
temp_root: PathBuf,
|
temp_root: PathBuf,
|
||||||
session: OnceCell<Arc<Session>>,
|
sessions: Mutex<HashMap<String, Arc<Session>>>,
|
||||||
|
job_sessions: Mutex<HashMap<String, Arc<Session>>>,
|
||||||
jobs: Mutex<HashMap<String, TorrentJob>>,
|
jobs: Mutex<HashMap<String, TorrentJob>>,
|
||||||
resolving_jobs: Mutex<HashSet<String>>,
|
resolving_jobs: Mutex<HashSet<String>>,
|
||||||
scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>,
|
scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>,
|
||||||
@@ -383,36 +384,47 @@ impl TorrentService {
|
|||||||
pub fn new(scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>) -> Self {
|
pub fn new(scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
temp_root: std::env::temp_dir().join("furumusic").join("torrents"),
|
temp_root: std::env::temp_dir().join("furumusic").join("torrents"),
|
||||||
session: OnceCell::new(),
|
sessions: Mutex::new(HashMap::new()),
|
||||||
|
job_sessions: Mutex::new(HashMap::new()),
|
||||||
jobs: Mutex::new(HashMap::new()),
|
jobs: Mutex::new(HashMap::new()),
|
||||||
resolving_jobs: Mutex::new(HashSet::new()),
|
resolving_jobs: Mutex::new(HashSet::new()),
|
||||||
scheduler_handle,
|
scheduler_handle,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn session(&self) -> anyhow::Result<Arc<Session>> {
|
async fn session(&self, proxy_url: Option<&str>) -> anyhow::Result<Arc<Session>> {
|
||||||
let temp_root = self.temp_root.clone();
|
let key = proxy_url.unwrap_or_default().to_string();
|
||||||
self.session
|
let mut sessions = self.sessions.lock().await;
|
||||||
.get_or_try_init(|| async move {
|
if let Some(session) = sessions.get(&key) {
|
||||||
tokio::fs::create_dir_all(&temp_root).await?;
|
return Ok(Arc::clone(session));
|
||||||
Session::new_with_opts(
|
}
|
||||||
temp_root,
|
|
||||||
|
tokio::fs::create_dir_all(&self.temp_root).await?;
|
||||||
|
let session = Session::new_with_opts(
|
||||||
|
self.temp_root.clone(),
|
||||||
SessionOptions {
|
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,
|
disable_upload: true,
|
||||||
enable_upnp_port_forwarding: false,
|
enable_upnp_port_forwarding: false,
|
||||||
|
socks_proxy_url: proxy_url.map(str::to_owned),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await?;
|
||||||
})
|
sessions.insert(key, Arc::clone(&session));
|
||||||
.await
|
Ok(session)
|
||||||
.cloned()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list(
|
pub async fn list(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
|
proxy_url: Option<String>,
|
||||||
) -> anyhow::Result<Vec<TorrentJobDto>> {
|
) -> anyhow::Result<Vec<TorrentJobDto>> {
|
||||||
let rows = sqlx::query_as::<_, TorrentSessionRow>(
|
let rows = sqlx::query_as::<_, TorrentSessionRow>(
|
||||||
r#"SELECT id, user_id, name, info_hash, source_kind, source_label, torrent_bytes,
|
r#"SELECT id, user_id, name, info_hash, source_kind, source_label, torrent_bytes,
|
||||||
@@ -445,6 +457,7 @@ impl TorrentService {
|
|||||||
row.id.clone(),
|
row.id.clone(),
|
||||||
magnet,
|
magnet,
|
||||||
row.created_at.clone(),
|
row.created_at.clone(),
|
||||||
|
proxy_url.clone(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -483,8 +496,9 @@ impl TorrentService {
|
|||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
request: TorrentPreviewRequest,
|
request: TorrentPreviewRequest,
|
||||||
|
proxy_url: Option<&str>,
|
||||||
) -> anyhow::Result<TorrentSessionDto> {
|
) -> anyhow::Result<TorrentSessionDto> {
|
||||||
let session = self.session().await?;
|
let session = self.session(proxy_url).await?;
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
let output_dir = self.temp_root.join(&id).join("download");
|
let output_dir = self.temp_root.join(&id).join("download");
|
||||||
tokio::fs::create_dir_all(&output_dir).await?;
|
tokio::fs::create_dir_all(&output_dir).await?;
|
||||||
@@ -511,7 +525,14 @@ impl TorrentService {
|
|||||||
.unwrap_or_else(|| info_hash.clone());
|
.unwrap_or_else(|| info_hash.clone());
|
||||||
let now = now_string();
|
let now = now_string();
|
||||||
insert_pending_magnet(pool, &id, user_id, &name, &info_hash, &magnet, &now).await?;
|
insert_pending_magnet(pool, &id, user_id, &name, &info_hash, &magnet, &now).await?;
|
||||||
self.spawn_resolve_pending_magnet(pool.clone(), user_id, id.clone(), magnet, now)
|
self.spawn_resolve_pending_magnet(
|
||||||
|
pool.clone(),
|
||||||
|
user_id,
|
||||||
|
id.clone(),
|
||||||
|
magnet,
|
||||||
|
now,
|
||||||
|
proxy_url.map(str::to_owned),
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let row = load_row(pool, user_id, &id).await?;
|
let row = load_row(pool, user_id, &id).await?;
|
||||||
@@ -611,6 +632,7 @@ impl TorrentService {
|
|||||||
id: String,
|
id: String,
|
||||||
magnet: String,
|
magnet: String,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
|
proxy_url: Option<String>,
|
||||||
) {
|
) {
|
||||||
{
|
{
|
||||||
let mut resolving = self.resolving_jobs.lock().await;
|
let mut resolving = self.resolving_jobs.lock().await;
|
||||||
@@ -622,7 +644,14 @@ impl TorrentService {
|
|||||||
let service = Arc::clone(self);
|
let service = Arc::clone(self);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let result = service
|
let result = service
|
||||||
.resolve_pending_magnet(&pool, user_id, &id, &magnet, &created_at)
|
.resolve_pending_magnet(
|
||||||
|
&pool,
|
||||||
|
user_id,
|
||||||
|
&id,
|
||||||
|
&magnet,
|
||||||
|
&created_at,
|
||||||
|
proxy_url.as_deref(),
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
if let Err(err) = result {
|
if let Err(err) = result {
|
||||||
update_resolving_error(&pool, &id, &err.to_string()).await;
|
update_resolving_error(&pool, &id, &err.to_string()).await;
|
||||||
@@ -638,8 +667,9 @@ impl TorrentService {
|
|||||||
id: &str,
|
id: &str,
|
||||||
magnet: &str,
|
magnet: &str,
|
||||||
created_at: &str,
|
created_at: &str,
|
||||||
|
proxy_url: Option<&str>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let session = self.session().await?;
|
let session = self.session(proxy_url).await?;
|
||||||
let output_dir = self.temp_root.join(id).join("download");
|
let output_dir = self.temp_root.join(id).join("download");
|
||||||
tokio::fs::create_dir_all(&output_dir).await?;
|
tokio::fs::create_dir_all(&output_dir).await?;
|
||||||
let response = tokio::time::timeout(
|
let response = tokio::time::timeout(
|
||||||
@@ -743,7 +773,7 @@ impl TorrentService {
|
|||||||
jobs.remove(id).and_then(|job| job.handle)
|
jobs.remove(id).and_then(|job| job.handle)
|
||||||
};
|
};
|
||||||
if let Some(handle) = removed {
|
if let Some(handle) = removed {
|
||||||
self.stop_torrent(&handle).await;
|
self.stop_torrent(id, &handle).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result =
|
let result =
|
||||||
@@ -766,6 +796,7 @@ impl TorrentService {
|
|||||||
selected_files: Vec<usize>,
|
selected_files: Vec<usize>,
|
||||||
inbox_dir: String,
|
inbox_dir: String,
|
||||||
uploader_user_id: i64,
|
uploader_user_id: i64,
|
||||||
|
proxy_url: Option<&str>,
|
||||||
) -> anyhow::Result<TorrentJobDto> {
|
) -> anyhow::Result<TorrentJobDto> {
|
||||||
if selected_files.is_empty() {
|
if selected_files.is_empty() {
|
||||||
bail!("select at least one file");
|
bail!("select at least one file");
|
||||||
@@ -810,7 +841,7 @@ impl TorrentService {
|
|||||||
tokio::fs::create_dir_all(&output_dir).await?;
|
tokio::fs::create_dir_all(&output_dir).await?;
|
||||||
mark_job_started(pool, id, &selected_files, &self.memory_job_dto(id).await?).await?;
|
mark_job_started(pool, id, &selected_files, &self.memory_job_dto(id).await?).await?;
|
||||||
|
|
||||||
let session = self.session().await?;
|
let session = self.session(proxy_url).await?;
|
||||||
let response = match session
|
let response = match session
|
||||||
.add_torrent(
|
.add_torrent(
|
||||||
AddTorrent::from_bytes(torrent_bytes),
|
AddTorrent::from_bytes(torrent_bytes),
|
||||||
@@ -838,6 +869,10 @@ impl TorrentService {
|
|||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
self.job_sessions
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(id.to_string(), Arc::clone(&session));
|
||||||
|
|
||||||
let dto = {
|
let dto = {
|
||||||
let mut jobs = self.jobs.lock().await;
|
let mut jobs = self.jobs.lock().await;
|
||||||
@@ -856,7 +891,7 @@ impl TorrentService {
|
|||||||
if service.is_paused(&id).await {
|
if service.is_paused(&id).await {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
service.stop_torrent(&handle).await;
|
service.stop_torrent(&id, &handle).await;
|
||||||
service.fail_job(&pool, &id, err.to_string()).await;
|
service.fail_job(&pool, &id, err.to_string()).await;
|
||||||
crate::metrics::record_torrent_download(
|
crate::metrics::record_torrent_download(
|
||||||
"failed",
|
"failed",
|
||||||
@@ -865,7 +900,7 @@ impl TorrentService {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
service.stop_torrent(&handle).await;
|
service.stop_torrent(&id, &handle).await;
|
||||||
if let Err(err) = service
|
if let Err(err) = service
|
||||||
.finalize_completed(&pool, &id, &inbox_dir, uploader_user_id)
|
.finalize_completed(&pool, &id, &inbox_dir, uploader_user_id)
|
||||||
.await
|
.await
|
||||||
@@ -911,7 +946,7 @@ impl TorrentService {
|
|||||||
|
|
||||||
persist_progress(pool, &dto).await?;
|
persist_progress(pool, &dto).await?;
|
||||||
if let Some(handle) = handle {
|
if let Some(handle) = handle {
|
||||||
self.stop_torrent(&handle).await;
|
self.stop_torrent(id, &handle).await;
|
||||||
}
|
}
|
||||||
Ok(dto)
|
Ok(dto)
|
||||||
}
|
}
|
||||||
@@ -981,18 +1016,14 @@ impl TorrentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stop_torrent(&self, handle: &Arc<ManagedTorrent>) {
|
async fn stop_torrent(&self, id: &str, handle: &Arc<ManagedTorrent>) {
|
||||||
match self.session().await {
|
let session = self.job_sessions.lock().await.remove(id);
|
||||||
Ok(session) => {
|
if let Some(session) = session
|
||||||
if let Err(err) = session.delete(handle.id().into(), false).await {
|
&& let Err(err) = session.delete(handle.id().into(), false).await
|
||||||
|
{
|
||||||
tracing::warn!("failed to stop completed torrent: {err}");
|
tracing::warn!("failed to stop completed torrent: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
|
||||||
tracing::warn!("failed to access torrent session for shutdown: {err}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn finalize_completed(
|
async fn finalize_completed(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
+1163
-40
File diff suppressed because it is too large
Load Diff
+962
-4
File diff suppressed because it is too large
Load Diff
@@ -88,7 +88,8 @@
|
|||||||
<!-- Download Manager Modal -->
|
<!-- Download Manager Modal -->
|
||||||
<template x-if="$store.torrents.modal">
|
<template x-if="$store.torrents.modal">
|
||||||
<div class="modal-overlay" @click.self="$store.torrents.close()">
|
<div class="modal-overlay" @click.self="$store.torrents.close()">
|
||||||
<div class="modal-box torrent-modal">
|
<div class="modal-box torrent-modal"
|
||||||
|
:class="{ 'youtube-mode': $store.torrents.sourceTab === 'youtube' }">
|
||||||
<div class="torrent-modal-head">
|
<div class="torrent-modal-head">
|
||||||
<div>
|
<div>
|
||||||
<h3>{{ t.player_torrent_manager }}</h3>
|
<h3>{{ t.player_torrent_manager }}</h3>
|
||||||
@@ -132,12 +133,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="torrent-tabs download-source-tabs">
|
<div class="torrent-tabs download-source-tabs">
|
||||||
|
{% if youtube_downloads_enabled %}
|
||||||
<button class="torrent-tab-btn"
|
<button class="torrent-tab-btn"
|
||||||
:class="{ active: $store.torrents.sourceTab === 'youtube' }"
|
:class="{ active: $store.torrents.sourceTab === 'youtube' }"
|
||||||
@click="$store.torrents.showSourceTab('youtube')">{{ t.player_youtube }}</button>
|
@click="$store.torrents.showSourceTab('youtube')">{{ t.player_youtube }}</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if torrent_downloads_enabled %}
|
||||||
<button class="torrent-tab-btn"
|
<button class="torrent-tab-btn"
|
||||||
:class="{ active: $store.torrents.sourceTab === 'torrents' }"
|
:class="{ active: $store.torrents.sourceTab === 'torrents' }"
|
||||||
@click="$store.torrents.showSourceTab('torrents')">{{ t.player_torrents }}</button>
|
@click="$store.torrents.showSourceTab('torrents')">{{ t.player_torrents }}</button>
|
||||||
|
{% endif %}
|
||||||
<button class="torrent-tab-btn"
|
<button class="torrent-tab-btn"
|
||||||
:class="{ active: $store.torrents.sourceTab === 'files' }"
|
:class="{ active: $store.torrents.sourceTab === 'files' }"
|
||||||
@click="$store.torrents.showSourceTab('files')">{{ t.player_files }}</button>
|
@click="$store.torrents.showSourceTab('files')">{{ t.player_files }}</button>
|
||||||
@@ -201,6 +206,29 @@
|
|||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="youtube-preview-destination">
|
||||||
|
<label for="youtube-target-playlist">{{ t.player_youtube_destination }}</label>
|
||||||
|
<div class="youtube-preview-destination-fields"
|
||||||
|
:class="{ creating: $store.torrents.youtubePlaylistChoice === '__new__' }">
|
||||||
|
<select id="youtube-target-playlist"
|
||||||
|
x-model="$store.torrents.youtubePlaylistChoice"
|
||||||
|
@change="$store.torrents.youtubePlaylistChoiceChanged()">
|
||||||
|
<option value="">{{ t.player_youtube_no_destination }}</option>
|
||||||
|
<template x-for="playlist in $store.torrents.youtubeOwnedPlaylists()" :key="playlist.id">
|
||||||
|
<option :value="String(playlist.id)" x-text="playlist.title"></option>
|
||||||
|
</template>
|
||||||
|
<option value="__new__">{{ t.player_youtube_create_playlist }}</option>
|
||||||
|
</select>
|
||||||
|
<template x-if="$store.torrents.youtubePlaylistChoice === '__new__'">
|
||||||
|
<input type="text"
|
||||||
|
maxlength="255"
|
||||||
|
autocomplete="off"
|
||||||
|
x-model="$store.torrents.youtubeNewPlaylistTitle"
|
||||||
|
placeholder="{{ t.player_youtube_new_playlist_name }}">
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<p>{{ t.player_youtube_destination_hint }}</p>
|
||||||
|
</div>
|
||||||
<div class="youtube-preview-footer">
|
<div class="youtube-preview-footer">
|
||||||
<span x-text="$store.torrents.youtubePreviewSelectedCount() + ' ' + T.youtubeSelectedCount"></span>
|
<span x-text="$store.torrents.youtubePreviewSelectedCount() + ' ' + T.youtubeSelectedCount"></span>
|
||||||
<div>
|
<div>
|
||||||
@@ -208,7 +236,7 @@
|
|||||||
@click="$store.torrents.clearYoutubePreview()">{{ t.player_cancel }}</button>
|
@click="$store.torrents.clearYoutubePreview()">{{ t.player_cancel }}</button>
|
||||||
<button type="button" class="modal-btn modal-btn-primary"
|
<button type="button" class="modal-btn modal-btn-primary"
|
||||||
@click="$store.torrents.startYoutubeDownload()"
|
@click="$store.torrents.startYoutubeDownload()"
|
||||||
:disabled="$store.torrents.youtubeSubmitting || $store.torrents.youtubePreviewSelectedCount() === 0">
|
:disabled="$store.torrents.youtubeSubmitting || $store.torrents.youtubePreviewSelectedCount() === 0 || !$store.torrents.youtubeDestinationValid()">
|
||||||
{{ t.player_youtube_start_import }}
|
{{ t.player_youtube_start_import }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,16 +259,29 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template x-for="job in $store.torrents.youtubeJobs" :key="job.id">
|
<template x-for="job in $store.torrents.youtubeJobs" :key="job.id">
|
||||||
<article class="youtube-job-card">
|
<article class="youtube-job-card"
|
||||||
<div class="youtube-job-head">
|
:class="{ collapsed: !$store.torrents.youtubeJobExpanded(job.id) }">
|
||||||
|
<button type="button"
|
||||||
|
class="youtube-job-summary"
|
||||||
|
:aria-expanded="$store.torrents.youtubeJobExpanded(job.id)"
|
||||||
|
:title="$store.torrents.youtubeJobExpanded(job.id) ? T.collapse : T.expand"
|
||||||
|
@click="$store.torrents.toggleYoutubeJob(job.id)">
|
||||||
|
<span class="youtube-job-chevron" aria-hidden="true"></span>
|
||||||
<div class="youtube-job-heading">
|
<div class="youtube-job-heading">
|
||||||
<div class="youtube-job-title" x-text="job.title"></div>
|
<div class="youtube-job-title" x-text="job.title"></div>
|
||||||
<div class="youtube-job-meta" x-text="$store.torrents.youtubeJobMeta(job)"></div>
|
<div class="youtube-job-meta" x-text="$store.torrents.youtubeJobMeta(job)"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="youtube-job-compact-progress"
|
||||||
|
x-text="$store.torrents.youtubeJobProgress(job) + '%'">
|
||||||
|
</span>
|
||||||
<span class="torrent-status-badge"
|
<span class="torrent-status-badge"
|
||||||
:class="$store.torrents.youtubeStatusClass(job.status)"
|
:class="$store.torrents.youtubeStatusClass(job.status)"
|
||||||
x-text="$store.torrents.youtubeStatusLabel(job.status)"></span>
|
x-text="$store.torrents.youtubeStatusLabel(job.status)"></span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
|
<div class="youtube-job-details"
|
||||||
|
x-show="$store.torrents.youtubeJobExpanded(job.id)"
|
||||||
|
x-cloak>
|
||||||
|
|
||||||
<div class="youtube-job-progress">
|
<div class="youtube-job-progress">
|
||||||
<div class="torrent-session-progress">
|
<div class="torrent-session-progress">
|
||||||
@@ -312,6 +353,7 @@
|
|||||||
x-show="$store.torrents.youtubeJobTerminal(job.status)"
|
x-show="$store.torrents.youtubeJobTerminal(job.status)"
|
||||||
@click="$store.torrents.removeYoutubeJob(job.id)">{{ t.player_remove_from_history }}</button>
|
@click="$store.torrents.removeYoutubeJob(job.id)">{{ t.player_remove_from_history }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ const T = {
|
|||||||
youtubeSelectAll: "{{ t.player_youtube_select_all }}",
|
youtubeSelectAll: "{{ t.player_youtube_select_all }}",
|
||||||
youtubeClearSelection: "{{ t.player_youtube_clear_selection }}",
|
youtubeClearSelection: "{{ t.player_youtube_clear_selection }}",
|
||||||
youtubeSelectedCount: "{{ t.player_youtube_selected_count }}",
|
youtubeSelectedCount: "{{ t.player_youtube_selected_count }}",
|
||||||
|
youtubePlaylistCreateFailed: "{{ t.player_youtube_playlist_create_failed }}",
|
||||||
|
youtubeAddedTo: "{{ t.player_youtube_added_to }}",
|
||||||
youtubeCancelled: "{{ t.player_youtube_cancelled }}",
|
youtubeCancelled: "{{ t.player_youtube_cancelled }}",
|
||||||
youtubeStopConfirm: "{{ t.player_youtube_stop_confirm }}",
|
youtubeStopConfirm: "{{ t.player_youtube_stop_confirm }}",
|
||||||
youtubeStopping: "{{ t.player_youtube_stopping }}",
|
youtubeStopping: "{{ t.player_youtube_stopping }}",
|
||||||
@@ -181,6 +183,8 @@ const T = {
|
|||||||
liveReleases: "{{ t.player_live_releases }}",
|
liveReleases: "{{ t.player_live_releases }}",
|
||||||
soundtracks: "{{ t.player_soundtracks }}",
|
soundtracks: "{{ t.player_soundtracks }}",
|
||||||
likesPlaylist: "{{ t.player_likes_playlist }}",
|
likesPlaylist: "{{ t.player_likes_playlist }}",
|
||||||
|
expand: "{{ t.player_expand }}",
|
||||||
|
collapse: "{{ t.player_collapse }}",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatTime(seconds) {
|
function formatTime(seconds) {
|
||||||
@@ -4604,12 +4608,20 @@ document.addEventListener('alpine:init', () => {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
Alpine.store('torrents', {
|
Alpine.store('torrents', {
|
||||||
modal: false,
|
modal: false,
|
||||||
sourceTab: 'youtube',
|
downloadsEnabled: {{ downloads_enabled }},
|
||||||
|
torrentDownloadsEnabled: {{ torrent_downloads_enabled }},
|
||||||
|
youtubeDownloadsEnabled: {{ youtube_downloads_enabled }},
|
||||||
|
sourceTab: {% if youtube_downloads_enabled %}'youtube'{% else if torrent_downloads_enabled %}'torrents'{% else %}'files'{% endif %},
|
||||||
youtubeUrl: '',
|
youtubeUrl: '',
|
||||||
youtubePreview: null,
|
youtubePreview: null,
|
||||||
youtubePreviewSelected: new Set(),
|
youtubePreviewSelected: new Set(),
|
||||||
|
youtubePlaylistChoice: '',
|
||||||
|
youtubeNewPlaylistTitle: '',
|
||||||
|
youtubePlaylistSyncKey: '',
|
||||||
youtubePreviewLoading: false,
|
youtubePreviewLoading: false,
|
||||||
youtubeJobs: [],
|
youtubeJobs: [],
|
||||||
|
youtubeExpandedJobId: null,
|
||||||
|
youtubeJobsInitialized: false,
|
||||||
youtubeLoading: false,
|
youtubeLoading: false,
|
||||||
youtubeSubmitting: false,
|
youtubeSubmitting: false,
|
||||||
youtubeCancellingIds: new Set(),
|
youtubeCancellingIds: new Set(),
|
||||||
@@ -4671,6 +4683,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
open() {
|
open() {
|
||||||
|
if (!this.downloadsEnabled) return;
|
||||||
this.modal = true;
|
this.modal = true;
|
||||||
this.message = '';
|
this.message = '';
|
||||||
this.error = false;
|
this.error = false;
|
||||||
@@ -4700,7 +4713,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
showSourceTab(tab) {
|
showSourceTab(tab) {
|
||||||
this.sourceTab = ['youtube', 'torrents', 'files', 'uploads'].includes(tab) ? tab : 'youtube';
|
const tabs = ['files', 'uploads'];
|
||||||
|
if (this.youtubeDownloadsEnabled) tabs.unshift('youtube');
|
||||||
|
if (this.torrentDownloadsEnabled) tabs.unshift('torrents');
|
||||||
|
this.sourceTab = tabs.includes(tab) ? tab : tabs[0];
|
||||||
this._setMessage('');
|
this._setMessage('');
|
||||||
if (this.sourceTab === 'youtube') this.loadYoutubeJobs();
|
if (this.sourceTab === 'youtube') this.loadYoutubeJobs();
|
||||||
else if (this.sourceTab === 'uploads') {
|
else if (this.sourceTab === 'uploads') {
|
||||||
@@ -4721,6 +4737,31 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
if (!res.ok) throw new Error(data?.error || T.youtubeLoadFailed);
|
if (!res.ok) throw new Error(data?.error || T.youtubeLoadFailed);
|
||||||
this.youtubeJobs = Array.isArray(data) ? data : [];
|
this.youtubeJobs = Array.isArray(data) ? data : [];
|
||||||
|
if (
|
||||||
|
this.youtubeExpandedJobId !== null
|
||||||
|
&& !this.youtubeJobs.some(job => job.id === this.youtubeExpandedJobId)
|
||||||
|
) {
|
||||||
|
this.youtubeExpandedJobId = null;
|
||||||
|
}
|
||||||
|
if (!this.youtubeJobsInitialized) {
|
||||||
|
const activeJob = this.youtubeJobs.find(job => !this.youtubeJobTerminal(job.status));
|
||||||
|
this.youtubeExpandedJobId = this.youtubePreview ? null : (activeJob?.id || null);
|
||||||
|
this.youtubeJobsInitialized = true;
|
||||||
|
}
|
||||||
|
const playlistSyncKey = this.youtubeJobs
|
||||||
|
.filter(job => Number(job.target_playlist_id || 0) > 0)
|
||||||
|
.map(job => [
|
||||||
|
job.id,
|
||||||
|
job.status,
|
||||||
|
Number(job.completed_items || 0),
|
||||||
|
Number(job.failed_items || 0),
|
||||||
|
Number(job.review_items || 0),
|
||||||
|
].join(':'))
|
||||||
|
.join('|');
|
||||||
|
if (playlistSyncKey && playlistSyncKey !== this.youtubePlaylistSyncKey) {
|
||||||
|
this.youtubePlaylistSyncKey = playlistSyncKey;
|
||||||
|
Alpine.store('playlists')?.reload?.();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!silent) this._setMessage(err.message || T.youtubeLoadFailed, true);
|
if (!silent) this._setMessage(err.message || T.youtubeLoadFailed, true);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -4731,6 +4772,8 @@ document.addEventListener('alpine:init', () => {
|
|||||||
clearYoutubePreview() {
|
clearYoutubePreview() {
|
||||||
this.youtubePreview = null;
|
this.youtubePreview = null;
|
||||||
this.youtubePreviewSelected = new Set();
|
this.youtubePreviewSelected = new Set();
|
||||||
|
this.youtubePlaylistChoice = '';
|
||||||
|
this.youtubeNewPlaylistTitle = '';
|
||||||
},
|
},
|
||||||
|
|
||||||
async previewYoutubeUrl() {
|
async previewYoutubeUrl() {
|
||||||
@@ -4754,6 +4797,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.youtubePreviewSelected = new Set(
|
this.youtubePreviewSelected = new Set(
|
||||||
items.filter(item => item.selected_by_default).map(item => item.source_id)
|
items.filter(item => item.selected_by_default).map(item => item.source_id)
|
||||||
);
|
);
|
||||||
|
this.youtubeExpandedJobId = null;
|
||||||
this._setMessage('');
|
this._setMessage('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._setMessage(err.message || T.youtubePreviewFailed, true);
|
this._setMessage(err.message || T.youtubePreviewFailed, true);
|
||||||
@@ -4786,24 +4830,90 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return this.youtubePreviewSelected.size;
|
return this.youtubePreviewSelected.size;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
youtubeOwnedPlaylists() {
|
||||||
|
return (Alpine.store('playlists')?.list || []).filter(playlist => (
|
||||||
|
playlist.kind === 'user' && playlist.is_own && Number(playlist.id) > 0
|
||||||
|
));
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubePlaylistChoiceChanged() {
|
||||||
|
if (
|
||||||
|
this.youtubePlaylistChoice === '__new__'
|
||||||
|
&& !String(this.youtubeNewPlaylistTitle || '').trim()
|
||||||
|
) {
|
||||||
|
this.youtubeNewPlaylistTitle = String(this.youtubePreview?.title || '').slice(0, 255);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubeDestinationValid() {
|
||||||
|
return this.youtubePlaylistChoice !== '__new__'
|
||||||
|
|| String(this.youtubeNewPlaylistTitle || '').trim().length > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
youtubePlaylistTitle(playlistId) {
|
||||||
|
const wanted = Number(playlistId || 0);
|
||||||
|
return this.youtubeOwnedPlaylists().find(playlist => Number(playlist.id) === wanted)?.title || '';
|
||||||
|
},
|
||||||
|
|
||||||
|
async resolveYoutubeTargetPlaylist() {
|
||||||
|
if (this.youtubePlaylistChoice !== '__new__') {
|
||||||
|
const playlistId = Number(this.youtubePlaylistChoice || 0);
|
||||||
|
return playlistId > 0 ? playlistId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = String(this.youtubeNewPlaylistTitle || '').trim();
|
||||||
|
if (!title) throw new Error(T.youtubePlaylistCreateFailed);
|
||||||
|
const res = await fetch('/api/player/playlists', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title }),
|
||||||
|
});
|
||||||
|
const playlist = await res.json().catch(() => null);
|
||||||
|
if (!res.ok || Number(playlist?.id || 0) <= 0) {
|
||||||
|
throw new Error(playlist?.error || T.youtubePlaylistCreateFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to the created playlist before starting the job. If the
|
||||||
|
// YouTube request fails, retrying will reuse it instead of creating
|
||||||
|
// another playlist with the same name.
|
||||||
|
this.youtubePlaylistChoice = String(playlist.id);
|
||||||
|
const playlists = Alpine.store('playlists');
|
||||||
|
if (playlists) {
|
||||||
|
playlists.list = [
|
||||||
|
...(playlists.list || []).filter(item => Number(item.id) !== Number(playlist.id)),
|
||||||
|
playlist,
|
||||||
|
];
|
||||||
|
await playlists.reload();
|
||||||
|
}
|
||||||
|
return Number(playlist.id);
|
||||||
|
},
|
||||||
|
|
||||||
async startYoutubeDownload() {
|
async startYoutubeDownload() {
|
||||||
const preview = this.youtubePreview;
|
const preview = this.youtubePreview;
|
||||||
const selectedSourceIds = Array.from(this.youtubePreviewSelected);
|
const selectedSourceIds = Array.from(this.youtubePreviewSelected);
|
||||||
if (!preview || !selectedSourceIds.length || this.youtubeSubmitting) return;
|
if (
|
||||||
|
!preview
|
||||||
|
|| !selectedSourceIds.length
|
||||||
|
|| !this.youtubeDestinationValid()
|
||||||
|
|| this.youtubeSubmitting
|
||||||
|
) return;
|
||||||
this.youtubeSubmitting = true;
|
this.youtubeSubmitting = true;
|
||||||
this._setMessage(T.youtubeStarting);
|
this._setMessage(T.youtubeStarting);
|
||||||
try {
|
try {
|
||||||
|
const targetPlaylistId = await this.resolveYoutubeTargetPlaylist();
|
||||||
const res = await fetch('/api/player/youtube/start', {
|
const res = await fetch('/api/player/youtube/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url: preview.source_url,
|
url: preview.source_url,
|
||||||
selected_source_ids: selectedSourceIds,
|
selected_source_ids: selectedSourceIds,
|
||||||
|
target_playlist_id: targetPlaylistId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
if (!res.ok) throw new Error(data?.error || T.youtubeStartFailed);
|
if (!res.ok) throw new Error(data?.error || T.youtubeStartFailed);
|
||||||
this.youtubeJobs = [data, ...this.youtubeJobs.filter(job => job.id !== data.id)];
|
this.youtubeJobs = [data, ...this.youtubeJobs.filter(job => job.id !== data.id)];
|
||||||
|
this.youtubeExpandedJobId = data.id;
|
||||||
this.youtubeUrl = '';
|
this.youtubeUrl = '';
|
||||||
this.clearYoutubePreview();
|
this.clearYoutubePreview();
|
||||||
this._setMessage(T.youtubeStarted);
|
this._setMessage(T.youtubeStarted);
|
||||||
@@ -4935,11 +5045,21 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return ['queued', 'resolving', 'downloading', 'postprocessing'].includes(String(status || '').toLowerCase());
|
return ['queued', 'resolving', 'downloading', 'postprocessing'].includes(String(status || '').toLowerCase());
|
||||||
},
|
},
|
||||||
|
|
||||||
|
youtubeJobExpanded(jobId) {
|
||||||
|
return this.youtubeExpandedJobId === jobId;
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleYoutubeJob(jobId) {
|
||||||
|
this.youtubeExpandedJobId = this.youtubeExpandedJobId === jobId ? null : jobId;
|
||||||
|
},
|
||||||
|
|
||||||
youtubeJobMeta(job) {
|
youtubeJobMeta(job) {
|
||||||
const kind = job.source_kind === 'playlist' ? T.youtubePlaylist : T.youtubeVideo;
|
const kind = job.source_kind === 'playlist' ? T.youtubePlaylist : T.youtubeVideo;
|
||||||
const parts = [kind];
|
const parts = [kind];
|
||||||
if (Number(job.total_items || 0) > 0) parts.push(Number(job.total_items) + ' ' + T.youtubeItems);
|
if (Number(job.total_items || 0) > 0) parts.push(Number(job.total_items) + ' ' + T.youtubeItems);
|
||||||
if (Number(job.failed_items || 0) > 0) parts.push(Number(job.failed_items) + ' ' + T.youtubeErrors);
|
if (Number(job.failed_items || 0) > 0) parts.push(Number(job.failed_items) + ' ' + T.youtubeErrors);
|
||||||
|
const playlistTitle = this.youtubePlaylistTitle(job.target_playlist_id);
|
||||||
|
if (playlistTitle) parts.push(T.youtubeAddedTo + ' ' + playlistTitle);
|
||||||
return parts.join(' · ');
|
return parts.join(' · ');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -325,6 +325,7 @@
|
|||||||
<span class="search-shortcut">Ctrl+K</span>
|
<span class="search-shortcut">Ctrl+K</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
{% if downloads_enabled %}
|
||||||
<button class="torrent-import-btn"
|
<button class="torrent-import-btn"
|
||||||
@click="$store.torrents.open()"
|
@click="$store.torrents.open()"
|
||||||
title="{{ t.player_import_torrent }}">
|
title="{{ t.player_import_torrent }}">
|
||||||
@@ -334,6 +335,7 @@
|
|||||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
{% endif %}
|
||||||
<button class="mobile-account-chip"
|
<button class="mobile-account-chip"
|
||||||
x-show="$store.user.profile"
|
x-show="$store.user.profile"
|
||||||
x-cloak
|
x-cloak
|
||||||
|
|||||||
@@ -3291,6 +3291,11 @@ button.user-stat:hover {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.torrent-modal.youtube-mode {
|
||||||
|
width: min(1440px, calc(100vw - 32px));
|
||||||
|
max-width: 1440px;
|
||||||
|
}
|
||||||
|
|
||||||
.torrent-modal-head {
|
.torrent-modal-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -3613,8 +3618,8 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.youtube-preview-card {
|
.youtube-preview-card {
|
||||||
min-height: 0;
|
min-height: 370px;
|
||||||
flex: 0 1 360px;
|
flex: 1 1 520px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -3626,6 +3631,7 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.youtube-preview-head,
|
.youtube-preview-head,
|
||||||
.youtube-preview-footer {
|
.youtube-preview-footer {
|
||||||
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -3657,13 +3663,55 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.youtube-preview-list {
|
.youtube-preview-list {
|
||||||
min-height: 72px;
|
min-height: 190px;
|
||||||
|
flex: 1 1 260px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.youtube-preview-destination {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
column-gap: 14px;
|
||||||
|
row-gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-destination > label {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-destination-fields {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-destination-fields.creating {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-destination p {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.youtube-preview-row {
|
.youtube-preview-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 30px minmax(0, 1fr);
|
grid-template-columns: auto 30px minmax(0, 1fr);
|
||||||
@@ -3722,6 +3770,7 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.youtube-download-list {
|
.youtube-download-list {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
flex: 1 1 180px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -3737,10 +3786,63 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.youtube-job-card {
|
.youtube-job-card {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 13px;
|
padding: 0;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-job-card.collapsed {
|
||||||
|
border-color: rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-job-summary {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 50px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 16px minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 13px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-job-summary:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-job-chevron {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-right: 2px solid var(--text-subdued);
|
||||||
|
border-bottom: 2px solid var(--text-subdued);
|
||||||
|
transform: rotate(45deg) translate(-2px, -2px);
|
||||||
|
transition: transform 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-job-card.collapsed .youtube-job-chevron {
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-job-card.collapsed .youtube-job-meta {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-job-compact-progress {
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-job-details {
|
||||||
|
padding: 0 13px 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.youtube-job-head,
|
.youtube-job-head,
|
||||||
@@ -3781,7 +3883,7 @@ button.user-stat:hover {
|
|||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 9px;
|
margin-top: 0;
|
||||||
color: var(--text-subdued);
|
color: var(--text-subdued);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
@@ -6462,6 +6564,11 @@ button.user-stat:hover {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.torrent-modal.youtube-mode {
|
||||||
|
width: 100vw;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
.torrent-modal-head {
|
.torrent-modal-head {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -6533,7 +6640,8 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.youtube-preview-card {
|
.youtube-preview-card {
|
||||||
flex-basis: auto;
|
flex-basis: auto;
|
||||||
max-height: 330px;
|
min-height: 420px;
|
||||||
|
max-height: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.youtube-preview-head,
|
.youtube-preview-head,
|
||||||
@@ -6546,6 +6654,32 @@ button.user-stat:hover {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.youtube-preview-destination-fields {
|
||||||
|
grid-column: auto;
|
||||||
|
grid-row: auto;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-destination {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-destination > label {
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-preview-destination p {
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-job-summary {
|
||||||
|
grid-template-columns: 16px minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-job-summary .youtube-job-compact-progress {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.youtube-download-list {
|
.youtube-download-list {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user