From 5402d9595d8add2a2f3763cf5e4202f6b5b399a6 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Fri, 14 Aug 2026 10:54:56 +0100 Subject: [PATCH] Added socks proxy, reworked download manager configuration --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/admin/v2.rs | 150 ++++++++++++++++++++++-- src/config.rs | 195 ++++++++++++++++++++++++++++++++ src/main.rs | 8 +- src/player/mod.rs | 159 +++++++++++++++++++++++++- src/torrents.rs | 109 +++++++++++------- src/youtube.rs | 77 ++++++++++--- templates/admin/v2.html | 207 +++++++++++++++++++++++++++++++++- templates/player/modals.html | 4 + templates/player/scripts.html | 11 +- templates/player/shell.html | 2 + 12 files changed, 854 insertions(+), 72 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b1a7054..3f36b54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1938,7 +1938,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.10.2" +version = "0.10.4" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 0636872..486d107 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.10.3" +version = "0.10.4" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/admin/v2.rs b/src/admin/v2.rs index 2d27ba0..34345a8 100644 --- a/src/admin/v2.rs +++ b/src/admin/v2.rs @@ -16,7 +16,7 @@ use sqlx::{PgPool, Postgres, QueryBuilder}; use super::BUILD_INFO; use crate::agent; use crate::auth::{self, AuthenticatedUser, Role}; -use crate::config::{AppConfig, ConfigEntry, ConfigSources}; +use crate::config::{AppConfig, ConfigEntry, ConfigSources, DownloadProxy}; use crate::i18n::{I18n, Translations}; use crate::scheduler::{self, JobRegistry, JobRun, ScheduledJob}; @@ -462,6 +462,50 @@ struct AdminSettingsValues { similarity_profile: String, #[serde(default = "default_similarity_workers")] similarity_workers: String, + #[serde(default = "default_true")] + downloads_enabled: bool, + #[serde(default = "default_true")] + torrent_downloads_enabled: bool, + #[serde(default = "default_true")] + youtube_downloads_enabled: bool, + #[serde(default)] + download_proxies: Vec, + #[serde(default)] + torrent_proxy_id: String, + #[serde(default)] + youtube_proxy_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +struct AdminDownloadProxy { + id: String, + address: String, + #[serde(default)] + username: String, + #[serde(default)] + password: String, +} + +impl From for AdminDownloadProxy { + fn from(proxy: DownloadProxy) -> Self { + Self { + id: proxy.id, + address: proxy.address, + username: proxy.username, + password: proxy.password, + } + } +} + +impl From for DownloadProxy { + fn from(proxy: AdminDownloadProxy) -> Self { + Self { + id: proxy.id, + address: proxy.address, + username: proxy.username, + password: proxy.password, + } + } } #[derive(Debug, Clone, Serialize, JsonSchema)] @@ -493,6 +537,12 @@ struct AdminSettingsSources { similarity_model: &'static str, similarity_profile: &'static str, similarity_workers: &'static str, + downloads_enabled: &'static str, + torrent_downloads_enabled: &'static str, + youtube_downloads_enabled: &'static str, + download_proxies: &'static str, + torrent_proxy_id: &'static str, + youtube_proxy_id: &'static str, } #[derive(Debug, Deserialize)] @@ -531,6 +581,18 @@ pub(super) struct UpdateSettingsRequest { similarity_profile: String, #[serde(default = "default_similarity_workers")] similarity_workers: String, + #[serde(default = "default_true")] + downloads_enabled: bool, + #[serde(default = "default_true")] + torrent_downloads_enabled: bool, + #[serde(default = "default_true")] + youtube_downloads_enabled: bool, + #[serde(default)] + download_proxies: Vec, + #[serde(default)] + torrent_proxy_id: String, + #[serde(default)] + youtube_proxy_id: String, } fn default_similarity_model() -> String { @@ -980,15 +1042,15 @@ pub async fn update_settings( if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } - let similarity_model = body.similarity_model.trim(); - if crate::similarity::model_by_id(similarity_model).is_none() { + let similarity_model = body.similarity_model.trim().to_string(); + if crate::similarity::model_by_id(&similarity_model).is_none() { return Ok(json_error( StatusCode::BAD_REQUEST, "unknown similarity model", )); } - let similarity_profile = body.similarity_profile.trim(); - if crate::similarity::profile_by_id(similarity_profile).is_none() { + let similarity_profile = body.similarity_profile.trim().to_string(); + if crate::similarity::profile_by_id(&similarity_profile).is_none() { return Ok(json_error( StatusCode::BAD_REQUEST, "unknown similarity preprocessing profile", @@ -1003,6 +1065,47 @@ pub async fn update_settings( )); } }; + if body.download_proxies.len() > 32 { + return Ok(json_error( + StatusCode::BAD_REQUEST, + "at most 32 download proxies can be saved", + )); + } + let mut download_proxies = Vec::with_capacity(body.download_proxies.len()); + let mut proxy_ids = HashSet::new(); + for (index, proxy) in body.download_proxies.into_iter().enumerate() { + let proxy = match DownloadProxy::from(proxy).normalized() { + Ok(proxy) => proxy, + Err(error) => { + return Ok(json_error( + StatusCode::BAD_REQUEST, + &format!("proxy {}: {error}", index + 1), + )); + } + }; + if !proxy_ids.insert(proxy.id.clone()) { + return Ok(json_error( + StatusCode::BAD_REQUEST, + "download proxy ids must be unique", + )); + } + download_proxies.push(proxy); + } + let torrent_proxy_id = body.torrent_proxy_id.trim().to_string(); + let youtube_proxy_id = body.youtube_proxy_id.trim().to_string(); + for (method, proxy_id) in [ + ("torrent", torrent_proxy_id.as_str()), + ("YouTube", youtube_proxy_id.as_str()), + ] { + if !proxy_id.is_empty() && !proxy_ids.contains(proxy_id) { + return Ok(json_error( + StatusCode::BAD_REQUEST, + &format!("selected {method} proxy is not in the saved proxy list"), + )); + } + } + let download_proxies_json = serde_json::to_string(&download_proxies) + .map_err(|error| cot::Error::internal(error.to_string()))?; let fields = [ ( "auth_password_enabled", @@ -1058,9 +1161,21 @@ pub async fn update_settings( body.federation_save_on_listen.to_string(), ), ("similarity_enabled", body.similarity_enabled.to_string()), - ("similarity_model", similarity_model.to_string()), - ("similarity_profile", similarity_profile.to_string()), + ("similarity_model", similarity_model), + ("similarity_profile", similarity_profile), ("similarity_workers", similarity_workers.to_string()), + ("downloads_enabled", body.downloads_enabled.to_string()), + ( + "torrent_downloads_enabled", + body.torrent_downloads_enabled.to_string(), + ), + ( + "youtube_downloads_enabled", + body.youtube_downloads_enabled.to_string(), + ), + ("download_proxies", download_proxies_json), + ("torrent_proxy_id", torrent_proxy_id), + ("youtube_proxy_id", youtube_proxy_id), ]; for (key, value) in fields { let mut entry = ConfigEntry::new(key.to_string(), value); @@ -1235,6 +1350,15 @@ pub async fn settings_probe( } fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto { + let download_proxies = config + .parsed_download_proxies() + .unwrap_or_else(|error| { + tracing::warn!(%error, "ignoring invalid saved download proxy list"); + Vec::new() + }) + .into_iter() + .map(AdminDownloadProxy::from) + .collect(); AdminSettingsDto { lastfm_api_key_configured: !config.lastfm_api_key.trim().is_empty(), lastfm_shared_secret_configured: !config.lastfm_shared_secret.trim().is_empty(), @@ -1268,6 +1392,12 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto { similarity_model: config.similarity_model, similarity_profile: config.similarity_profile, similarity_workers: config.similarity_workers.to_string(), + downloads_enabled: config.downloads_enabled, + torrent_downloads_enabled: config.torrent_downloads_enabled, + youtube_downloads_enabled: config.youtube_downloads_enabled, + download_proxies, + torrent_proxy_id: config.torrent_proxy_id, + youtube_proxy_id: config.youtube_proxy_id, }, sources: AdminSettingsSources { auth_password_enabled: sources.auth_password_enabled.code(), @@ -1297,6 +1427,12 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto { similarity_model: sources.similarity_model.code(), similarity_profile: sources.similarity_profile.code(), similarity_workers: sources.similarity_workers.code(), + downloads_enabled: sources.downloads_enabled.code(), + torrent_downloads_enabled: sources.torrent_downloads_enabled.code(), + youtube_downloads_enabled: sources.youtube_downloads_enabled.code(), + download_proxies: sources.download_proxies.code(), + torrent_proxy_id: sources.torrent_proxy_id.code(), + youtube_proxy_id: sources.youtube_proxy_id.code(), }, } } diff --git a/src/config.rs b/src/config.rs index a4c7fbd..ac93016 100644 --- a/src/config.rs +++ b/src/config.rs @@ -142,6 +142,12 @@ pub struct ConfigSources { pub similarity_model: ConfigSource, pub similarity_profile: ConfigSource, pub similarity_workers: ConfigSource, + pub downloads_enabled: ConfigSource, + pub torrent_downloads_enabled: ConfigSource, + pub youtube_downloads_enabled: ConfigSource, + pub download_proxies: ConfigSource, + pub torrent_proxy_id: ConfigSource, + pub youtube_proxy_id: ConfigSource, } impl Default for ConfigSources { @@ -176,6 +182,12 @@ impl Default for ConfigSources { similarity_model: ConfigSource::Default, similarity_profile: ConfigSource::Default, similarity_workers: ConfigSource::Default, + downloads_enabled: ConfigSource::Default, + torrent_downloads_enabled: ConfigSource::Default, + youtube_downloads_enabled: ConfigSource::Default, + download_proxies: ConfigSource::Default, + torrent_proxy_id: ConfigSource::Default, + youtube_proxy_id: ConfigSource::Default, } } } @@ -238,6 +250,84 @@ macro_rules! impl_env_overrides { // AppConfig // --------------------------------------------------------------------------- +/// Saved SOCKS5 proxy used by user-facing download methods. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DownloadProxy { + pub id: String, + pub address: String, + #[serde(default)] + pub username: String, + #[serde(default)] + pub password: String, +} + +impl DownloadProxy { + /// Validate and normalize a proxy without performing network or DNS I/O. + pub fn normalized(mut self) -> anyhow::Result { + self.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 { + let proxy = self.clone().normalized()?; + let mut url = reqwest::Url::parse(&format!("socks5://{}", proxy.address))?; + if !proxy.username.is_empty() && !proxy.password.is_empty() { + url.set_username(&proxy.username) + .map_err(|_| anyhow::anyhow!("invalid proxy username"))?; + url.set_password(Some(&proxy.password)) + .map_err(|_| anyhow::anyhow!("invalid proxy password"))?; + } + Ok(url.to_string()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { /// PostgreSQL connection URL. @@ -301,6 +391,18 @@ pub struct AppConfig { pub similarity_profile: String, /// Maximum number of concurrent CPU embedding workers. pub similarity_workers: u64, + /// Whether the download manager and local-file uploads are available. + pub downloads_enabled: bool, + /// Whether torrent imports are available when the download manager is enabled. + pub torrent_downloads_enabled: bool, + /// Whether YouTube imports are available when the download manager is enabled. + pub youtube_downloads_enabled: bool, + /// JSON-encoded list of [`DownloadProxy`] entries. + pub download_proxies: String, + /// Saved proxy id used for torrent downloads; empty means a direct connection. + pub torrent_proxy_id: String, + /// Saved proxy id used for YouTube downloads; empty means a direct connection. + pub youtube_proxy_id: String, } impl Default for AppConfig { @@ -337,6 +439,13 @@ impl Default for AppConfig { similarity_workers: std::thread::available_parallelism() .map(|count| (count.get() / 2).clamp(1, 4) as u64) .unwrap_or(1), + // Preserve the behavior from before these controls were added. + downloads_enabled: true, + torrent_downloads_enabled: true, + youtube_downloads_enabled: true, + download_proxies: "[]".into(), + torrent_proxy_id: String::new(), + youtube_proxy_id: String::new(), } } } @@ -372,6 +481,12 @@ impl_env_overrides!( similarity_model, similarity_profile, similarity_workers, + downloads_enabled, + torrent_downloads_enabled, + youtube_downloads_enabled, + download_proxies, + torrent_proxy_id, + youtube_proxy_id, ); impl AppConfig { @@ -506,6 +621,39 @@ impl AppConfig { apply_db_field!(similarity_model); apply_db_field!(similarity_profile); apply_db_field!(similarity_workers); + apply_db_field!(downloads_enabled); + apply_db_field!(torrent_downloads_enabled); + apply_db_field!(youtube_downloads_enabled); + apply_db_field!(download_proxies); + apply_db_field!(torrent_proxy_id); + apply_db_field!(youtube_proxy_id); + } + + pub fn parsed_download_proxies(&self) -> anyhow::Result> { + let proxies: Vec = 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> { + 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> { + self.selected_proxy_url(&self.torrent_proxy_id) + } + + pub fn youtube_proxy_url(&self) -> anyhow::Result> { + self.selected_proxy_url(&self.youtube_proxy_id) } } @@ -532,6 +680,53 @@ mod tests { crate::similarity::DEFAULT_PROFILE_ID ); assert!((1..=4).contains(&cfg.similarity_workers)); + assert!(cfg.downloads_enabled); + assert!(cfg.torrent_downloads_enabled); + assert!(cfg.youtube_downloads_enabled); + assert!(cfg.parsed_download_proxies().unwrap().is_empty()); + } + + #[test] + fn download_proxy_url_encodes_complete_credentials() { + let proxy = DownloadProxy { + id: "proxy-1".into(), + address: "proxy.example:1080".into(), + username: "user name".into(), + password: "p@ss:word".into(), + }; + assert_eq!( + proxy.socks_url().unwrap(), + "socks5://user%20name:p%40ss%3Aword@proxy.example:1080" + ); + } + + #[test] + fn download_proxy_url_omits_partial_credentials() { + let proxy = DownloadProxy { + id: "proxy-1".into(), + address: "127.0.0.1:1080".into(), + username: "user".into(), + password: String::new(), + }; + assert_eq!(proxy.socks_url().unwrap(), "socks5://127.0.0.1:1080"); + } + + #[test] + fn selected_download_proxy_is_resolved_by_id() { + let mut cfg = AppConfig::default(); + cfg.download_proxies = serde_json::to_string(&[DownloadProxy { + id: "youtube".into(), + address: "[::1]:9050".into(), + username: String::new(), + password: String::new(), + }]) + .unwrap(); + cfg.youtube_proxy_id = "youtube".into(); + assert_eq!( + cfg.youtube_proxy_url().unwrap().as_deref(), + Some("socks5://[::1]:9050") + ); + assert_eq!(cfg.torrent_proxy_url().unwrap(), None); } #[test] diff --git a/src/main.rs b/src/main.rs index 5b484f5..7dd6d80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -90,7 +90,13 @@ async fn index( return Ok(auth::redirect("/login")); } }; - let template = player::PlayerPageTemplate { t: i18n.t }; + let (config, _) = AppConfig::load_with_db(&db).await; + let template = player::PlayerPageTemplate { + t: i18n.t, + downloads_enabled: config.downloads_enabled, + torrent_downloads_enabled: config.downloads_enabled && config.torrent_downloads_enabled, + youtube_downloads_enabled: config.downloads_enabled && config.youtube_downloads_enabled, + }; Html::new(template.render()?).into_response() } diff --git a/src/player/mod.rs b/src/player/mod.rs index afeb72d..22ef7ea 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -49,6 +49,50 @@ fn json_error(status: StatusCode, message: &str) -> cot::response::Response { .expect("valid response") } +#[derive(Debug, Clone, Copy)] +enum DownloadMethod { + LocalFile, + Torrent, + YouTube, +} + +fn require_download_method( + config: &AppConfig, + method: DownloadMethod, +) -> Result<(), cot::response::Response> { + let enabled = config.downloads_enabled + && match method { + DownloadMethod::LocalFile => true, + DownloadMethod::Torrent => config.torrent_downloads_enabled, + DownloadMethod::YouTube => config.youtube_downloads_enabled, + }; + if enabled { + Ok(()) + } else { + Err(json_error( + StatusCode::FORBIDDEN, + match method { + DownloadMethod::LocalFile => "downloads are disabled by the administrator", + DownloadMethod::Torrent => "torrent downloads are disabled by the administrator", + DownloadMethod::YouTube => "YouTube downloads are disabled by the administrator", + }, + )) + } +} + +fn download_proxy_for( + config: &AppConfig, + method: DownloadMethod, +) -> Result, cot::response::Response> { + require_download_method(config, method)?; + let result = match method { + DownloadMethod::LocalFile => Ok(None), + DownloadMethod::Torrent => config.torrent_proxy_url(), + DownloadMethod::YouTube => config.youtube_proxy_url(), + }; + result.map_err(|error| json_error(StatusCode::BAD_REQUEST, &error.to_string())) +} + #[derive(serde::Serialize)] struct LocalUploadResponse { ok: bool, @@ -1375,6 +1419,40 @@ struct LastfmCallbackQuery { #[template(path = "player.html")] pub struct PlayerPageTemplate { pub t: &'static Translations, + pub downloads_enabled: bool, + pub torrent_downloads_enabled: bool, + pub youtube_downloads_enabled: bool, +} + +#[cfg(test)] +mod page_template_tests { + use super::*; + use crate::i18n::Lang; + + #[test] + fn download_manager_button_follows_the_global_switch() { + let disabled = PlayerPageTemplate { + t: Translations::for_lang(Lang::En), + downloads_enabled: false, + torrent_downloads_enabled: false, + youtube_downloads_enabled: false, + } + .render() + .unwrap(); + assert!(!disabled.contains(" + +
No proxies saved. Both methods use a direct connection.
+ +
Enter only host:port (IPv6 may use [address]:port). Credentials are omitted unless both username and password are filled.
+ + +
@@ -3250,7 +3418,13 @@ function adminV2() { similarity_enabled: false, similarity_model: 'discogs-effnet-bsdynamic-1', similarity_profile: 'furumi-full-track-v1', - similarity_workers: '1' + similarity_workers: '1', + downloads_enabled: true, + torrent_downloads_enabled: true, + youtube_downloads_enabled: true, + download_proxies: [], + torrent_proxy_id: '', + youtube_proxy_id: '' }, settingsProbe: { status: 'idle', ok: false }, settingsProbeLoading: false, @@ -3531,7 +3705,12 @@ function adminV2() { async loadSettings(showErrors = true) { try { this.settings = await this.request(`${this.apiBase}/settings`); - this.settingsDraft = Object.assign({}, this.settingsDraft, this.settings.values || {}); + const values = this.settings.values || {}; + this.settingsDraft = Object.assign({}, this.settingsDraft, values, { + download_proxies: Array.isArray(values.download_proxies) + ? values.download_proxies.map(proxy => ({ ...proxy })) + : [] + }); } catch (error) { if (showErrors) this.showToast(error.message); } finally { @@ -3539,6 +3718,30 @@ function adminV2() { } }, + addDownloadProxy() { + const id = (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') + ? globalThis.crypto.randomUUID() + : `proxy-${Date.now()}-${Math.random().toString(16).slice(2)}`; + this.settingsDraft.download_proxies = [ + ...(this.settingsDraft.download_proxies || []), + { id, address: '', username: '', password: '' } + ]; + this.$nextTick(() => this.icons()); + }, + + removeDownloadProxy(id) { + this.settingsDraft.download_proxies = (this.settingsDraft.download_proxies || []) + .filter(proxy => proxy.id !== id); + if (this.settingsDraft.torrent_proxy_id === id) this.settingsDraft.torrent_proxy_id = ''; + if (this.settingsDraft.youtube_proxy_id === id) this.settingsDraft.youtube_proxy_id = ''; + this.$nextTick(() => this.icons()); + }, + + downloadProxyLabel(proxy) { + const address = String(proxy?.address || '').trim(); + return address || 'New proxy'; + }, + async saveSettings() { if (this.settingsSaving) return; const networkSimilarityWasEnabled = Boolean( diff --git a/templates/player/modals.html b/templates/player/modals.html index 6d81fb3..7af6620 100644 --- a/templates/player/modals.html +++ b/templates/player/modals.html @@ -132,12 +132,16 @@
+ {% if youtube_downloads_enabled %} + {% endif %} + {% if torrent_downloads_enabled %} + {% endif %} diff --git a/templates/player/scripts.html b/templates/player/scripts.html index 4794526..60f4fa3 100644 --- a/templates/player/scripts.html +++ b/templates/player/scripts.html @@ -4604,7 +4604,10 @@ document.addEventListener('alpine:init', () => { // ----------------------------------------------------------------------- Alpine.store('torrents', { modal: false, - sourceTab: 'youtube', + downloadsEnabled: {{ downloads_enabled }}, + torrentDownloadsEnabled: {{ torrent_downloads_enabled }}, + youtubeDownloadsEnabled: {{ youtube_downloads_enabled }}, + sourceTab: {% if youtube_downloads_enabled %}'youtube'{% else if torrent_downloads_enabled %}'torrents'{% else %}'files'{% endif %}, youtubeUrl: '', youtubePreview: null, youtubePreviewSelected: new Set(), @@ -4671,6 +4674,7 @@ document.addEventListener('alpine:init', () => { }, open() { + if (!this.downloadsEnabled) return; this.modal = true; this.message = ''; this.error = false; @@ -4700,7 +4704,10 @@ document.addEventListener('alpine:init', () => { }, showSourceTab(tab) { - this.sourceTab = ['youtube', 'torrents', 'files', 'uploads'].includes(tab) ? tab : 'youtube'; + const tabs = ['files', 'uploads']; + if (this.youtubeDownloadsEnabled) tabs.unshift('youtube'); + if (this.torrentDownloadsEnabled) tabs.unshift('torrents'); + this.sourceTab = tabs.includes(tab) ? tab : tabs[0]; this._setMessage(''); if (this.sourceTab === 'youtube') this.loadYoutubeJobs(); else if (this.sourceTab === 'uploads') { diff --git a/templates/player/shell.html b/templates/player/shell.html index 73615b7..6156ca0 100644 --- a/templates/player/shell.html +++ b/templates/player/shell.html @@ -325,6 +325,7 @@ Ctrl+K
+ {% if downloads_enabled %} + {% endif %}