Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7098f80e9d | ||
|
|
a0964b651b | ||
|
|
b1ce504db6 | ||
|
|
39d75b07f6 |
Generated
+1
-1
@@ -1938,7 +1938,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.10.4"
|
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.4"
|
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"
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -1442,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+183
-3
@@ -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, DownloadProxy};
|
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};
|
||||||
|
|
||||||
@@ -165,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>,
|
||||||
@@ -444,11 +450,42 @@ struct MergeReleasesResponse {
|
|||||||
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,
|
||||||
@@ -497,6 +534,8 @@ struct AdminSettingsValues {
|
|||||||
torrent_proxy_id: String,
|
torrent_proxy_id: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
youtube_proxy_id: String,
|
youtube_proxy_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
youtube_cookie_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
@@ -566,6 +605,7 @@ struct AdminSettingsSources {
|
|||||||
download_proxies: &'static str,
|
download_proxies: &'static str,
|
||||||
torrent_proxy_id: &'static str,
|
torrent_proxy_id: &'static str,
|
||||||
youtube_proxy_id: &'static str,
|
youtube_proxy_id: &'static str,
|
||||||
|
youtube_cookie_id: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -616,6 +656,8 @@ pub(super) struct UpdateSettingsRequest {
|
|||||||
torrent_proxy_id: String,
|
torrent_proxy_id: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
youtube_proxy_id: String,
|
youtube_proxy_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
youtube_cookie_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_similarity_model() -> String {
|
fn default_similarity_model() -> String {
|
||||||
@@ -1055,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(
|
||||||
@@ -1117,6 +1162,7 @@ pub async fn update_settings(
|
|||||||
}
|
}
|
||||||
let torrent_proxy_id = body.torrent_proxy_id.trim().to_string();
|
let torrent_proxy_id = body.torrent_proxy_id.trim().to_string();
|
||||||
let youtube_proxy_id = body.youtube_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 [
|
for (method, proxy_id) in [
|
||||||
("torrent", torrent_proxy_id.as_str()),
|
("torrent", torrent_proxy_id.as_str()),
|
||||||
("YouTube", youtube_proxy_id.as_str()),
|
("YouTube", youtube_proxy_id.as_str()),
|
||||||
@@ -1128,6 +1174,16 @@ pub async fn update_settings(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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)
|
let download_proxies_json = serde_json::to_string(&download_proxies)
|
||||||
.map_err(|error| cot::Error::internal(error.to_string()))?;
|
.map_err(|error| cot::Error::internal(error.to_string()))?;
|
||||||
let fields = [
|
let fields = [
|
||||||
@@ -1200,6 +1256,7 @@ pub async fn update_settings(
|
|||||||
("download_proxies", download_proxies_json),
|
("download_proxies", download_proxies_json),
|
||||||
("torrent_proxy_id", torrent_proxy_id),
|
("torrent_proxy_id", torrent_proxy_id),
|
||||||
("youtube_proxy_id", youtube_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);
|
||||||
@@ -1218,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)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1373,7 +1543,11 @@ 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
|
let download_proxies = config
|
||||||
.parsed_download_proxies()
|
.parsed_download_proxies()
|
||||||
.unwrap_or_else(|error| {
|
.unwrap_or_else(|error| {
|
||||||
@@ -1384,6 +1558,10 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
|||||||
.map(AdminDownloadProxy::from)
|
.map(AdminDownloadProxy::from)
|
||||||
.collect();
|
.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()
|
||||||
@@ -1422,6 +1600,7 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
|||||||
download_proxies,
|
download_proxies,
|
||||||
torrent_proxy_id: config.torrent_proxy_id,
|
torrent_proxy_id: config.torrent_proxy_id,
|
||||||
youtube_proxy_id: config.youtube_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(),
|
||||||
@@ -1457,6 +1636,7 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
|||||||
download_proxies: sources.download_proxies.code(),
|
download_proxies: sources.download_proxies.code(),
|
||||||
torrent_proxy_id: sources.torrent_proxy_id.code(),
|
torrent_proxy_id: sources.torrent_proxy_id.code(),
|
||||||
youtube_proxy_id: sources.youtube_proxy_id.code(),
|
youtube_proxy_id: sources.youtube_proxy_id.code(),
|
||||||
|
youtube_cookie_id: sources.youtube_cookie_id.code(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-5
@@ -148,6 +148,7 @@ pub struct ConfigSources {
|
|||||||
pub download_proxies: ConfigSource,
|
pub download_proxies: ConfigSource,
|
||||||
pub torrent_proxy_id: ConfigSource,
|
pub torrent_proxy_id: ConfigSource,
|
||||||
pub youtube_proxy_id: ConfigSource,
|
pub youtube_proxy_id: ConfigSource,
|
||||||
|
pub youtube_cookie_id: ConfigSource,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ConfigSources {
|
impl Default for ConfigSources {
|
||||||
@@ -188,6 +189,7 @@ impl Default for ConfigSources {
|
|||||||
download_proxies: ConfigSource::Default,
|
download_proxies: ConfigSource::Default,
|
||||||
torrent_proxy_id: ConfigSource::Default,
|
torrent_proxy_id: ConfigSource::Default,
|
||||||
youtube_proxy_id: ConfigSource::Default,
|
youtube_proxy_id: ConfigSource::Default,
|
||||||
|
youtube_cookie_id: ConfigSource::Default,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,6 +405,8 @@ pub struct AppConfig {
|
|||||||
pub torrent_proxy_id: String,
|
pub torrent_proxy_id: String,
|
||||||
/// Saved proxy id used for YouTube downloads; empty means a direct connection.
|
/// Saved proxy id used for YouTube downloads; empty means a direct connection.
|
||||||
pub youtube_proxy_id: String,
|
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 {
|
||||||
@@ -446,6 +450,7 @@ impl Default for AppConfig {
|
|||||||
download_proxies: "[]".into(),
|
download_proxies: "[]".into(),
|
||||||
torrent_proxy_id: String::new(),
|
torrent_proxy_id: String::new(),
|
||||||
youtube_proxy_id: String::new(),
|
youtube_proxy_id: String::new(),
|
||||||
|
youtube_cookie_id: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -487,6 +492,7 @@ impl_env_overrides!(
|
|||||||
download_proxies,
|
download_proxies,
|
||||||
torrent_proxy_id,
|
torrent_proxy_id,
|
||||||
youtube_proxy_id,
|
youtube_proxy_id,
|
||||||
|
youtube_cookie_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
@@ -581,11 +587,13 @@ impl AppConfig {
|
|||||||
sources.$field = ConfigSource::Database;
|
sources.$field = ConfigSource::Database;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
tracing::warn!(
|
if !val.trim().is_empty() {
|
||||||
"ignoring invalid DB config value for {}: {:?}",
|
tracing::warn!(
|
||||||
stringify!($field),
|
"ignoring invalid DB config value for {}: {:?}",
|
||||||
val,
|
stringify!($field),
|
||||||
);
|
val,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -627,6 +635,7 @@ impl AppConfig {
|
|||||||
apply_db_field!(download_proxies);
|
apply_db_field!(download_proxies);
|
||||||
apply_db_field!(torrent_proxy_id);
|
apply_db_field!(torrent_proxy_id);
|
||||||
apply_db_field!(youtube_proxy_id);
|
apply_db_field!(youtube_proxy_id);
|
||||||
|
apply_db_field!(youtube_cookie_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parsed_download_proxies(&self) -> anyhow::Result<Vec<DownloadProxy>> {
|
pub fn parsed_download_proxies(&self) -> anyhow::Result<Vec<DownloadProxy>> {
|
||||||
|
|||||||
@@ -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" , "Предпросмотр";
|
||||||
|
|||||||
@@ -1196,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,
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
+36
-1
@@ -93,6 +93,15 @@ fn download_proxy_for(
|
|||||||
result.map_err(|error| json_error(StatusCode::BAD_REQUEST, &error.to_string()))
|
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,
|
||||||
@@ -8542,12 +8551,23 @@ impl App for PlayerApp {
|
|||||||
Ok(proxy_url) => proxy_url,
|
Ok(proxy_url) => proxy_url,
|
||||||
Err(response) => return Ok(response),
|
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, proxy_url.as_deref()).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()))
|
||||||
@@ -8603,12 +8623,17 @@ impl App for PlayerApp {
|
|||||||
Ok(proxy_url) => proxy_url,
|
Ok(proxy_url) => proxy_url,
|
||||||
Err(response) => return Ok(response),
|
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(
|
.list(
|
||||||
pg_pool,
|
pg_pool,
|
||||||
user.id,
|
user.id,
|
||||||
&live_config.agent_inbox_dir,
|
&live_config.agent_inbox_dir,
|
||||||
proxy_url,
|
proxy_url,
|
||||||
|
cookie_contents,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -8670,6 +8695,10 @@ impl App for PlayerApp {
|
|||||||
Ok(proxy_url) => proxy_url,
|
Ok(proxy_url) => proxy_url,
|
||||||
Err(response) => return Ok(response),
|
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,
|
||||||
@@ -8677,6 +8706,7 @@ impl App for PlayerApp {
|
|||||||
json.0,
|
json.0,
|
||||||
&live_config.agent_inbox_dir,
|
&live_config.agent_inbox_dir,
|
||||||
proxy_url,
|
proxy_url,
|
||||||
|
cookie_contents,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -8738,6 +8768,10 @@ impl App for PlayerApp {
|
|||||||
Ok(proxy_url) => proxy_url,
|
Ok(proxy_url) => proxy_url,
|
||||||
Err(response) => return Ok(response),
|
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,
|
||||||
@@ -8745,6 +8779,7 @@ impl App for PlayerApp {
|
|||||||
&path.0.id,
|
&path.0.id,
|
||||||
&live_config.agent_inbox_dir,
|
&live_config.agent_inbox_dir,
|
||||||
proxy_url,
|
proxy_url,
|
||||||
|
cookie_contents,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
+880
-61
File diff suppressed because it is too large
Load Diff
+184
-3
@@ -959,6 +959,89 @@ tbody tr:hover {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.youtube-cookie-settings {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
margin-top: 11px;
|
||||||
|
padding-top: 11px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-cookie-picker,
|
||||||
|
.youtube-cookie-upload,
|
||||||
|
.youtube-cookie-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-cookie-picker > span {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-cookie-picker select {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-cookie-upload {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-cookie-upload span,
|
||||||
|
.youtube-cookie-empty {
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-cookie-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-cookie-row {
|
||||||
|
justify-content: space-between;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 9px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-cookie-row.active {
|
||||||
|
border-color: rgba(29, 185, 84, 0.55);
|
||||||
|
background: rgba(29, 185, 84, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-cookie-info {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-cookie-info strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-cookie-info span {
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.proxy-editor {
|
.proxy-editor {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1148,6 +1231,8 @@ tbody tr:hover {
|
|||||||
.setting-field { max-width: none; }
|
.setting-field { max-width: none; }
|
||||||
.download-method-controls,
|
.download-method-controls,
|
||||||
.proxy-row { grid-template-columns: 1fr; }
|
.proxy-row { grid-template-columns: 1fr; }
|
||||||
|
.youtube-cookie-picker,
|
||||||
|
.youtube-cookie-upload { align-items: stretch; flex-direction: column; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-note {
|
.settings-note {
|
||||||
@@ -2725,7 +2810,43 @@ tbody tr:hover {
|
|||||||
</template>
|
</template>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-help">The selected proxy is passed to metadata lookup and downloads through yt-dlp's <code>--proxy</code> option.</div>
|
<div class="youtube-cookie-settings">
|
||||||
|
<div class="youtube-cookie-picker">
|
||||||
|
<span>
|
||||||
|
yt-dlp cookies
|
||||||
|
<span class="source-pill" :class="sourceClass('youtube_cookie_id')" x-text="settingSource('youtube_cookie_id')"></span>
|
||||||
|
</span>
|
||||||
|
<select x-model="settingsDraft.youtube_cookie_id" :disabled="!settingsDraft.downloads_enabled || !settingsDraft.youtube_downloads_enabled">
|
||||||
|
<option value="">No cookies</option>
|
||||||
|
<template x-for="file in settings.youtube_cookie_files || []" :key="file.id">
|
||||||
|
<option :value="file.id" x-text="youtubeCookieLabel(file)"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input type="file" accept=".txt,text/plain" x-ref="youtubeCookieInput" style="display:none" @change="uploadYoutubeCookieFile($event)" />
|
||||||
|
<div class="youtube-cookie-upload">
|
||||||
|
<span>Netscape <code>cookies.txt</code>; maximum size 2 MiB.</span>
|
||||||
|
<button class="btn" type="button" @click="$refs.youtubeCookieInput.click()" :disabled="youtubeCookieUploading">
|
||||||
|
<i data-lucide="upload"></i>
|
||||||
|
<span x-text="youtubeCookieUploading ? 'Uploading…' : 'Upload cookie file'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="youtube-cookie-empty" x-show="!(settings.youtube_cookie_files || []).length">No cookie files saved. yt-dlp will run without cookies.</div>
|
||||||
|
<div class="youtube-cookie-list" x-show="(settings.youtube_cookie_files || []).length">
|
||||||
|
<template x-for="file in settings.youtube_cookie_files || []" :key="file.id">
|
||||||
|
<div class="youtube-cookie-row" :class="{ active: settingsDraft.youtube_cookie_id === file.id }">
|
||||||
|
<div class="youtube-cookie-info">
|
||||||
|
<strong :title="file.filename" x-text="file.filename"></strong>
|
||||||
|
<span x-text="`${fmt(file.cookie_count)} cookies · uploaded ${shortDate(file.uploaded_at)}`"></span>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn danger" type="button" @click="deleteYoutubeCookieFile(file)" :disabled="youtubeCookieUploading" title="Delete cookie file">
|
||||||
|
<i data-lucide="trash-2"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-help">The selected proxy and cookie file are passed to both metadata lookup and downloads through yt-dlp.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="proxy-editor">
|
<div class="proxy-editor">
|
||||||
@@ -3718,7 +3839,7 @@ function adminV2() {
|
|||||||
mergeDetails: [],
|
mergeDetails: [],
|
||||||
mergeArtistSearch: '',
|
mergeArtistSearch: '',
|
||||||
mergeDraft: { release_ids: [], target_release_id: null, title: '', release_type: 'album', year: '', hidden: 'false', cover_file_id: null, artist_ids: [], tracks: [] },
|
mergeDraft: { release_ids: [], target_release_id: null, title: '', release_type: 'album', year: '', hidden: 'false', cover_file_id: null, artist_ids: [], tracks: [] },
|
||||||
settings: { values: {}, sources: {}, lastfm_api_key_configured: false, lastfm_shared_secret_configured: false, lastfm_scrobbling_configured: false },
|
settings: { values: {}, sources: {}, youtube_cookie_files: [], lastfm_api_key_configured: false, lastfm_shared_secret_configured: false, lastfm_scrobbling_configured: false },
|
||||||
settingsDraft: {
|
settingsDraft: {
|
||||||
auth_password_enabled: false,
|
auth_password_enabled: false,
|
||||||
auth_sso_enabled: false,
|
auth_sso_enabled: false,
|
||||||
@@ -3752,7 +3873,8 @@ function adminV2() {
|
|||||||
youtube_downloads_enabled: true,
|
youtube_downloads_enabled: true,
|
||||||
download_proxies: [],
|
download_proxies: [],
|
||||||
torrent_proxy_id: '',
|
torrent_proxy_id: '',
|
||||||
youtube_proxy_id: ''
|
youtube_proxy_id: '',
|
||||||
|
youtube_cookie_id: ''
|
||||||
},
|
},
|
||||||
settingsProbe: { status: 'idle', ok: false },
|
settingsProbe: { status: 'idle', ok: false },
|
||||||
settingsProbeLoading: false,
|
settingsProbeLoading: false,
|
||||||
@@ -3763,6 +3885,7 @@ function adminV2() {
|
|||||||
similarityStatus: { status: { phase: 'disabled' }, models: [], profiles: [] },
|
similarityStatus: { status: { phase: 'disabled' }, models: [], profiles: [] },
|
||||||
similarityLoading: false,
|
similarityLoading: false,
|
||||||
settingsSaving: false,
|
settingsSaving: false,
|
||||||
|
youtubeCookieUploading: false,
|
||||||
routeReady: false,
|
routeReady: false,
|
||||||
poller: null,
|
poller: null,
|
||||||
|
|
||||||
@@ -4070,6 +4193,64 @@ function adminV2() {
|
|||||||
return address || 'New proxy';
|
return address || 'New proxy';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
youtubeCookieLabel(file) {
|
||||||
|
const filename = String(file?.filename || '').trim() || 'Cookie file';
|
||||||
|
return `${filename} (${this.fmt(file?.cookie_count)} cookies)`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadYoutubeCookieFile(event) {
|
||||||
|
const input = event?.target;
|
||||||
|
const file = input?.files && input.files.length ? input.files[0] : null;
|
||||||
|
if (!file || this.youtubeCookieUploading) return;
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
this.showToast('Cookie file is larger than 2 MiB');
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.youtubeCookieUploading = true;
|
||||||
|
try {
|
||||||
|
const dataUrl = await this.readFileAsDataUrl(file);
|
||||||
|
const data = String(dataUrl).split(',')[1] || '';
|
||||||
|
const uploaded = await this.request(`${this.apiBase}/settings/youtube-cookies`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ filename: file.name, data })
|
||||||
|
});
|
||||||
|
this.settings.youtube_cookie_files = [
|
||||||
|
uploaded,
|
||||||
|
...(this.settings.youtube_cookie_files || []).filter(item => item.id !== uploaded.id)
|
||||||
|
];
|
||||||
|
this.settingsDraft.youtube_cookie_id = uploaded.id;
|
||||||
|
this.showToast('Cookie file uploaded; save settings to activate it');
|
||||||
|
} catch (error) {
|
||||||
|
this.showToast(error.message);
|
||||||
|
} finally {
|
||||||
|
this.youtubeCookieUploading = false;
|
||||||
|
if (input) input.value = '';
|
||||||
|
this.icons();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteYoutubeCookieFile(file) {
|
||||||
|
if (!file || this.youtubeCookieUploading) return;
|
||||||
|
if (!window.confirm(`Delete cookie file "${file.filename}"? This cannot be undone.`)) return;
|
||||||
|
this.youtubeCookieUploading = true;
|
||||||
|
try {
|
||||||
|
await this.request(`${this.apiBase}/settings/youtube-cookies/${encodeURIComponent(file.id)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
this.settings.youtube_cookie_files = (this.settings.youtube_cookie_files || [])
|
||||||
|
.filter(item => item.id !== file.id);
|
||||||
|
if (this.settingsDraft.youtube_cookie_id === file.id) this.settingsDraft.youtube_cookie_id = '';
|
||||||
|
if (this.settings.values?.youtube_cookie_id === file.id) this.settings.values.youtube_cookie_id = '';
|
||||||
|
this.showToast('Cookie file deleted');
|
||||||
|
} catch (error) {
|
||||||
|
this.showToast(error.message);
|
||||||
|
} finally {
|
||||||
|
this.youtubeCookieUploading = false;
|
||||||
|
this.icons();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async saveSettings() {
|
async saveSettings() {
|
||||||
if (this.settingsSaving) return;
|
if (this.settingsSaving) return;
|
||||||
const networkSimilarityWasEnabled = Boolean(
|
const networkSimilarityWasEnabled = Boolean(
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -205,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>
|
||||||
@@ -212,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>
|
||||||
@@ -235,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">
|
||||||
@@ -316,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) {
|
||||||
@@ -4611,8 +4615,13 @@ document.addEventListener('alpine:init', () => {
|
|||||||
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(),
|
||||||
@@ -4728,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 {
|
||||||
@@ -4738,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() {
|
||||||
@@ -4761,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);
|
||||||
@@ -4793,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);
|
||||||
@@ -4942,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(' · ');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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