Tackle remaining unscoped items: cookies chmod, log redact, Bandcamp, ETag+gzip
Four items knocked off the unscoped list: Auto-chmod 0600 cookies.txt - web::write_cookies now sets mode 0600 on the file after writing, on Unix. Mirrors the same guard we put on yt-offline.db. cookies.txt carries live session credentials; no reason for it to stay umask-default. Redact cookies path from job log - Downloader::spawn_job now runs each stdout/stderr line through a small redactor that replaces the absolute cookies.txt path with the bare filename before forwarding to the Job log. yt-dlp can echo the path in error strings, which leaks $HOME into the UI and /api/progress responses. - 2 new tests cover the strip + pass-through cases. Bandcamp full-discography mode - Bandcamp at the bare artist URL is already a Channel in our classifier and yt-dlp's BandcampUserIE returns the full discography. The output template for Bandcamp's channel case now organizes tracks into per-album subfolders via `%(album|Unknown)s` so a discography pull stays coherent instead of one flat directory. - New `Downloader::apply_platform_extras` adds `--embed-thumbnail` for audio-first platforms (Bandcamp, SoundCloud) so music players see the cover art via embedded tags rather than scanning for a sidecar JPEG. Library ETag + gzip - WebState gains `library_version: AtomicU64`. `bump_library_version()` is called from post_rescan, post_watched, post_maintenance_remove, and post_resume (only when crossing the "Continue watching" >3.0 boundary so playback-time updates don't constantly invalidate the cache). - get_library is now Response-returning; it consults `If-None-Match` and short-circuits with 304 Not Modified when the client's ETag matches. Otherwise it sends `ETag: "<n>"` alongside the JSON. - JS `loadLibrary()` caches the ETag and sends it on subsequent calls. Returns early on 304 keeping the existing in-memory library array. - Adds `tower_http::compression::CompressionLayer` (gzip). Already- compressed media served from ServeDir is auto-skipped by the layer; JSON responses get ~10× smaller. New `compression-gzip` feature on the tower-http dep. 46 unit tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b24ef4be67
commit
8d1c274075
3 changed files with 163 additions and 12 deletions
|
|
@ -16,7 +16,7 @@ axum = { version = "0.7", features = ["macros"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tokio-util = { version = "0.7", features = ["io"] }
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
tower-http = { version = "0.5", features = ["cors", "fs", "compression-gzip"] }
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
# File-picker dialog for the desktop GUI. xdg-portal backend keeps it pure-Rust
|
# File-picker dialog for the desktop GUI. xdg-portal backend keeps it pure-Rust
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,16 @@ impl Downloader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Append platform-specific extra flags. Currently only used to embed
|
||||||
|
/// album art into the audio file on music-first platforms (Bandcamp,
|
||||||
|
/// SoundCloud), where music players read embedded tags rather than
|
||||||
|
/// scanning for sidecar JPEGs.
|
||||||
|
fn apply_platform_extras(&self, platform: Platform, cmd: &mut Command) {
|
||||||
|
if platform.is_audio_first() {
|
||||||
|
cmd.arg("--embed-thumbnail");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Append `--impersonate <target>` chosen per source platform. Both the
|
/// Append `--impersonate <target>` chosen per source platform. Both the
|
||||||
/// bundled venv (which pip-installs `curl_cffi`) and a system yt-dlp
|
/// bundled venv (which pip-installs `curl_cffi`) and a system yt-dlp
|
||||||
/// with curl_cffi can satisfy this. Platforms that prefer no
|
/// with curl_cffi can satisfy this. Platforms that prefer no
|
||||||
|
|
@ -309,10 +319,19 @@ impl Downloader {
|
||||||
// Remember the originating URL so re-checks don't have to
|
// Remember the originating URL so re-checks don't have to
|
||||||
// guess from the folder name.
|
// guess from the folder name.
|
||||||
platform::write_source_url(&dir, &url);
|
platform::write_source_url(&dir, &url);
|
||||||
(
|
// Bandcamp at the bare artist URL is a whole discography.
|
||||||
format!("{}/%(title)s [%(id)s]{live_suffix}.%(ext)s", dir.display()),
|
// Organize each track into its album subfolder so the
|
||||||
format!("{}/{}/{}", platform_label, handle, live_label),
|
// resulting tree mirrors how Bandcamp itself presents the
|
||||||
)
|
// catalog. Other platforms keep their flat per-creator layout.
|
||||||
|
let template = if info.platform == Platform::Bandcamp {
|
||||||
|
format!(
|
||||||
|
"{}/%(album|Unknown)s/%(title)s [%(id)s]{live_suffix}.%(ext)s",
|
||||||
|
dir.display()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("{}/%(title)s [%(id)s]{live_suffix}.%(ext)s", dir.display())
|
||||||
|
};
|
||||||
|
(template, format!("{}/{}/{}", platform_label, handle, live_label))
|
||||||
}
|
}
|
||||||
UrlKind::Playlist => (
|
UrlKind::Playlist => (
|
||||||
format!(
|
format!(
|
||||||
|
|
@ -371,6 +390,7 @@ impl Downloader {
|
||||||
cmd.arg("--download-archive")
|
cmd.arg("--download-archive")
|
||||||
.arg(archive_path.display().to_string());
|
.arg(archive_path.display().to_string());
|
||||||
self.apply_impersonation(info.platform, &mut cmd);
|
self.apply_impersonation(info.platform, &mut cmd);
|
||||||
|
self.apply_platform_extras(info.platform, &mut cmd);
|
||||||
cmd.arg("-o").arg(&out_arg).arg(&url);
|
cmd.arg("-o").arg(&out_arg).arg(&url);
|
||||||
Self::apply_retry_flags(&mut cmd);
|
Self::apply_retry_flags(&mut cmd);
|
||||||
|
|
||||||
|
|
@ -486,6 +506,15 @@ impl Downloader {
|
||||||
cmd.env("PATH", new_path);
|
cmd.env("PATH", new_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Absolute path that we want to redact out of any log line — yt-dlp
|
||||||
|
// sometimes echoes the cookie path in errors and that leaks the
|
||||||
|
// user's home directory into the UI / API responses.
|
||||||
|
let cookies_abs = std::env::current_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.join("cookies.txt")
|
||||||
|
.display()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let mut child = match cmd.spawn() {
|
let mut child = match cmd.spawn() {
|
||||||
Ok(child) => child,
|
Ok(child) => child,
|
||||||
|
|
@ -498,8 +527,10 @@ impl Downloader {
|
||||||
|
|
||||||
if let Some(stderr) = child.stderr.take() {
|
if let Some(stderr) = child.stderr.take() {
|
||||||
let tx = tx.clone();
|
let tx = tx.clone();
|
||||||
|
let cookies_abs = cookies_abs.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
|
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
|
||||||
|
let line = redact_sensitive(&line, &cookies_abs);
|
||||||
let _ = tx.send(Msg::Line(format!("[stderr] {line}")));
|
let _ = tx.send(Msg::Line(format!("[stderr] {line}")));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -513,6 +544,7 @@ impl Downloader {
|
||||||
if line.trim() == "Aborting remaining downloads" {
|
if line.trim() == "Aborting remaining downloads" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let line = redact_sensitive(&line, &cookies_abs);
|
||||||
if let Some(p) = parse_progress(&line) {
|
if let Some(p) = parse_progress(&line) {
|
||||||
let _ = tx.send(Msg::Progress(p));
|
let _ = tx.send(Msg::Progress(p));
|
||||||
}
|
}
|
||||||
|
|
@ -621,6 +653,18 @@ fn format_compact_utc(unix: u64) -> String {
|
||||||
|
|
||||||
fn is_leap(y: u32) -> bool { (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 }
|
fn is_leap(y: u32) -> bool { (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 }
|
||||||
|
|
||||||
|
/// Strip the absolute on-disk path to `cookies.txt` from a log line, leaving
|
||||||
|
/// the bare filename. yt-dlp occasionally echoes the full path in error
|
||||||
|
/// messages ("Unable to load cookies from /home/user/.../cookies.txt"); the
|
||||||
|
/// user's home directory is not something we want to expose in the UI or
|
||||||
|
/// `/api/progress` responses.
|
||||||
|
fn redact_sensitive(line: &str, cookies_abs: &str) -> String {
|
||||||
|
if cookies_abs.is_empty() || !line.contains(cookies_abs) {
|
||||||
|
return line.to_string();
|
||||||
|
}
|
||||||
|
line.replace(cookies_abs, "cookies.txt")
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse a yt-dlp `[download] 42.7% …` line into a `[0.0, 1.0]` fraction.
|
/// Parse a yt-dlp `[download] 42.7% …` line into a `[0.0, 1.0]` fraction.
|
||||||
fn parse_progress(line: &str) -> Option<f32> {
|
fn parse_progress(line: &str) -> Option<f32> {
|
||||||
let rest = line.trim_start().strip_prefix("[download]")?.trim_start();
|
let rest = line.trim_start().strip_prefix("[download]")?.trim_start();
|
||||||
|
|
@ -662,5 +706,20 @@ mod tests {
|
||||||
let url = check_url_for_folder("LinusTechTips");
|
let url = check_url_for_folder("LinusTechTips");
|
||||||
assert_eq!(url, "https://www.youtube.com/@LinusTechTips");
|
assert_eq!(url, "https://www.youtube.com/@LinusTechTips");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redact_sensitive_strips_cookie_path() {
|
||||||
|
let abs = "/home/luna/.config/yt-offline/cookies.txt";
|
||||||
|
let input = format!("Unable to load cookies from {abs}");
|
||||||
|
let out = redact_sensitive(&input, abs);
|
||||||
|
assert_eq!(out, "Unable to load cookies from cookies.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redact_sensitive_pass_through_when_not_present() {
|
||||||
|
let abs = "/home/luna/cookies.txt";
|
||||||
|
let input = "[download] 47.2% of 100MiB";
|
||||||
|
assert_eq!(redact_sensitive(input, abs), input);
|
||||||
|
}
|
||||||
// URL classification tests live in `platform` now — see its tests module.
|
// URL classification tests live in `platform` now — see its tests module.
|
||||||
}
|
}
|
||||||
|
|
|
||||||
106
src/web.rs
106
src/web.rs
|
|
@ -24,7 +24,7 @@
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Path as StdPath, PathBuf};
|
use std::path::{Path as StdPath, PathBuf};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
|
|
@ -38,6 +38,7 @@ use axum::{
|
||||||
};
|
};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tower_http::compression::CompressionLayer;
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||||
use argon2::password_hash::SaltString;
|
use argon2::password_hash::SaltString;
|
||||||
|
|
@ -92,6 +93,12 @@ pub struct WebState {
|
||||||
/// Cached "is password required" — refreshed when the password is changed.
|
/// Cached "is password required" — refreshed when the password is changed.
|
||||||
/// Avoids a DB hit on every request through `auth_middleware`.
|
/// Avoids a DB hit on every request through `auth_middleware`.
|
||||||
pub password_required_cache: AtomicBool,
|
pub password_required_cache: AtomicBool,
|
||||||
|
/// Monotonically-incremented version counter; serves as the ETag for
|
||||||
|
/// `/api/library`. Bumped on any state change that would alter the
|
||||||
|
/// JSON response (rescan, watched toggle, resume position, maintenance
|
||||||
|
/// remove). Combined with `If-None-Match` short-circuits the megabytes
|
||||||
|
/// of library JSON when nothing has changed.
|
||||||
|
pub library_version: AtomicU64,
|
||||||
/// Per-IP failure tracker for [`post_login`]. Each entry is the number of
|
/// Per-IP failure tracker for [`post_login`]. Each entry is the number of
|
||||||
/// recent failures and the instant the lockout (if any) expires.
|
/// recent failures and the instant the lockout (if any) expires.
|
||||||
pub login_attempts: Mutex<HashMap<std::net::IpAddr, LoginAttempt>>,
|
pub login_attempts: Mutex<HashMap<std::net::IpAddr, LoginAttempt>>,
|
||||||
|
|
@ -520,7 +527,20 @@ pub fn write_cookies(text: &str) -> Result<usize, String> {
|
||||||
if !content.ends_with('\n') {
|
if !content.ends_with('\n') {
|
||||||
content.push('\n');
|
content.push('\n');
|
||||||
}
|
}
|
||||||
std::fs::write(cookies_path(), &content).map_err(|e| e.to_string())?;
|
let path = cookies_path();
|
||||||
|
std::fs::write(&path, &content).map_err(|e| e.to_string())?;
|
||||||
|
// cookies.txt carries live session credentials — tighten the mode so it
|
||||||
|
// isn't world-readable on multi-user systems. Best-effort, like the
|
||||||
|
// similar guard on yt-offline.db.
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
if let Ok(meta) = std::fs::metadata(&path) {
|
||||||
|
let mut perms = meta.permissions();
|
||||||
|
perms.set_mode(0o600);
|
||||||
|
let _ = std::fs::set_permissions(&path, perms);
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(count_cookies(&content))
|
Ok(count_cookies(&content))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -648,6 +668,15 @@ fn password_required(state: &WebState) -> bool {
|
||||||
state.password_required_cache.load(Ordering::Relaxed)
|
state.password_required_cache.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bump the library-version counter. Callers should invoke this after any
|
||||||
|
/// state change that would alter `/api/library`'s response (watched flip,
|
||||||
|
/// resume position, rescan, maintenance remove). The counter is consumed
|
||||||
|
/// as an `ETag` so well-behaved clients can short-circuit unchanged GETs
|
||||||
|
/// with `If-None-Match`.
|
||||||
|
fn bump_library_version(state: &WebState) {
|
||||||
|
state.library_version.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
/// Re-read the password setting from the DB and update the cache. Called
|
/// Re-read the password setting from the DB and update the cache. Called
|
||||||
/// after any change that could affect whether a password exists.
|
/// after any change that could affect whether a password exists.
|
||||||
fn refresh_password_cache(state: &WebState) {
|
fn refresh_password_cache(state: &WebState) {
|
||||||
|
|
@ -805,7 +834,34 @@ async fn get_index() -> impl IntoResponse {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_library(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
async fn get_library(
|
||||||
|
State(state): State<Arc<WebState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Response {
|
||||||
|
// Conditional GET: short-circuit with 304 when the client's cached
|
||||||
|
// ETag matches our current library_version. Saves megabytes for
|
||||||
|
// large libraries on every poll.
|
||||||
|
let version = state.library_version.load(Ordering::Relaxed);
|
||||||
|
let etag = format!("\"{}\"", version);
|
||||||
|
if let Some(client_etag) = headers.get(header::IF_NONE_MATCH).and_then(|v| v.to_str().ok()) {
|
||||||
|
if client_etag == etag {
|
||||||
|
return ([
|
||||||
|
(header::ETAG, etag.clone()),
|
||||||
|
(header::CACHE_CONTROL, "no-cache".to_string()),
|
||||||
|
], StatusCode::NOT_MODIFIED).into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let payload = build_library_payload(&state).await;
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(header::ETAG, etag),
|
||||||
|
(header::CACHE_CONTROL, "no-cache".to_string()),
|
||||||
|
],
|
||||||
|
Json(payload),
|
||||||
|
).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_library_payload(state: &Arc<WebState>) -> LibraryResponse {
|
||||||
let lib = state.library.lock().unwrap();
|
let lib = state.library.lock().unwrap();
|
||||||
let watched = state.watched.lock().unwrap();
|
let watched = state.watched.lock().unwrap();
|
||||||
let positions = state.positions.lock().unwrap();
|
let positions = state.positions.lock().unwrap();
|
||||||
|
|
@ -884,7 +940,7 @@ async fn get_library(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||||
}
|
}
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
||||||
Json(LibraryResponse { channels })
|
LibraryResponse { channels }
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_music(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
async fn get_music(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||||
|
|
@ -964,6 +1020,8 @@ async fn post_watched(
|
||||||
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
|
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
|
||||||
}
|
}
|
||||||
if now_watched { watched.insert(video_id); } else { watched.remove(&video_id); }
|
if now_watched { watched.insert(video_id); } else { watched.remove(&video_id); }
|
||||||
|
drop(watched);
|
||||||
|
bump_library_version(&state);
|
||||||
(StatusCode::OK, if now_watched { "watched" } else { "unwatched" }).into_response()
|
(StatusCode::OK, if now_watched { "watched" } else { "unwatched" }).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1214,13 +1272,26 @@ async fn post_resume(
|
||||||
Path(video_id): Path<String>,
|
Path(video_id): Path<String>,
|
||||||
Json(body): Json<ResumeBody>,
|
Json(body): Json<ResumeBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
// Track whether this video had a non-trivial resume position before/after
|
||||||
|
// so we only bump the library ETag on transitions (joins / leaves the
|
||||||
|
// "Continue watching" set). Plain position updates fire every few seconds
|
||||||
|
// during playback — invalidating the cache on each would defeat the
|
||||||
|
// ETag optimisation.
|
||||||
|
let mut positions = state.positions.lock().unwrap();
|
||||||
|
let was_resumable = positions.get(&video_id).copied().is_some_and(|p| p > 3.0);
|
||||||
let db = state.db.lock().unwrap();
|
let db = state.db.lock().unwrap();
|
||||||
if body.position > 3.0 {
|
if body.position > 3.0 {
|
||||||
let _ = db.set_position(&video_id, body.position);
|
let _ = db.set_position(&video_id, body.position);
|
||||||
state.positions.lock().unwrap().insert(video_id, body.position);
|
positions.insert(video_id.clone(), body.position);
|
||||||
} else {
|
} else {
|
||||||
let _ = db.clear_position(&video_id);
|
let _ = db.clear_position(&video_id);
|
||||||
state.positions.lock().unwrap().remove(&video_id);
|
positions.remove(&video_id);
|
||||||
|
}
|
||||||
|
drop(db);
|
||||||
|
drop(positions);
|
||||||
|
let now_resumable = body.position > 3.0;
|
||||||
|
if was_resumable != now_resumable {
|
||||||
|
bump_library_version(&state);
|
||||||
}
|
}
|
||||||
(StatusCode::OK, "ok")
|
(StatusCode::OK, "ok")
|
||||||
}
|
}
|
||||||
|
|
@ -1387,6 +1458,7 @@ async fn post_rescan(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||||
*state.watched.lock().unwrap() = w;
|
*state.watched.lock().unwrap() = w;
|
||||||
}
|
}
|
||||||
*state.library.lock().unwrap() = new_lib;
|
*state.library.lock().unwrap() = new_lib;
|
||||||
|
bump_library_version(&state);
|
||||||
(StatusCode::OK, "rescanned")
|
(StatusCode::OK, "rescanned")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1430,6 +1502,7 @@ async fn post_maintenance_remove(
|
||||||
// Refresh the library so the removed copies disappear from the UI.
|
// Refresh the library so the removed copies disappear from the UI.
|
||||||
let new_lib = library::scan_channels(&state.channels_root);
|
let new_lib = library::scan_channels(&state.channels_root);
|
||||||
*state.library.lock().unwrap() = new_lib;
|
*state.library.lock().unwrap() = new_lib;
|
||||||
|
bump_library_version(&state);
|
||||||
Json(serde_json::json!({ "removed": removed, "errors": errors }))
|
Json(serde_json::json!({ "removed": removed, "errors": errors }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1607,6 +1680,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
||||||
sessions: Mutex::new(HashMap::new()),
|
sessions: Mutex::new(HashMap::new()),
|
||||||
last_scheduled_check: Mutex::new(None),
|
last_scheduled_check: Mutex::new(None),
|
||||||
password_required_cache: AtomicBool::new(password_required_initial),
|
password_required_cache: AtomicBool::new(password_required_initial),
|
||||||
|
library_version: AtomicU64::new(1),
|
||||||
login_attempts: Mutex::new(HashMap::new()),
|
login_attempts: Mutex::new(HashMap::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1684,6 +1758,12 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
||||||
// malicious. Path-specific overrides aren't needed since we have no
|
// malicious. Path-specific overrides aren't needed since we have no
|
||||||
// legitimate large-upload endpoints.
|
// legitimate large-upload endpoints.
|
||||||
.layer(DefaultBodyLimit::max(4 * 1024 * 1024))
|
.layer(DefaultBodyLimit::max(4 * 1024 * 1024))
|
||||||
|
// Compress JSON responses (gzip). `/api/library` in particular can
|
||||||
|
// be megabytes for large collections; gzip slices that ~10×.
|
||||||
|
// ServeDir output (video bytes) is already-compressed media, so the
|
||||||
|
// overhead would be wasted there — tower_http's compression layer
|
||||||
|
// skips already-compressed content types automatically.
|
||||||
|
.layer(CompressionLayer::new().gzip(true))
|
||||||
.layer(middleware::from_fn(security_headers))
|
.layer(middleware::from_fn(security_headers))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
|
|
@ -1937,9 +2017,21 @@ function closeSidebar(){document.getElementById('sidebar').classList.remove('ope
|
||||||
async function api(path,opts){const r=await fetch(path,opts);if(!r.ok)throw new Error(await r.text());return r}
|
async function api(path,opts){const r=await fetch(path,opts);if(!r.ok)throw new Error(await r.text());return r}
|
||||||
|
|
||||||
/* ── Library ────────────────────────────────────────────────────── */
|
/* ── Library ────────────────────────────────────────────────────── */
|
||||||
|
// Cache the ETag of the most-recent library response so subsequent polls
|
||||||
|
// can short-circuit with 304 Not Modified when nothing has changed. Saves
|
||||||
|
// megabytes of JSON for large libraries on every periodic refresh.
|
||||||
|
let libraryEtag=null;
|
||||||
async function loadLibrary(){
|
async function loadLibrary(){
|
||||||
try{
|
try{
|
||||||
const data=(await(await api('/api/library')).json());
|
const opts=libraryEtag?{headers:{'If-None-Match':libraryEtag}}:{};
|
||||||
|
const r=await api('/api/library',opts);
|
||||||
|
if(r.status===304){
|
||||||
|
// Nothing changed; keep our existing `library` array.
|
||||||
|
loadMusic();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
libraryEtag=r.headers.get('ETag')||libraryEtag;
|
||||||
|
const data=await r.json();
|
||||||
library=data.channels;
|
library=data.channels;
|
||||||
channelUrls=library.map(ch=>ch.channel_url||null);
|
channelUrls=library.map(ch=>ch.channel_url||null);
|
||||||
const total=library.reduce((s,c)=>s+c.size_bytes,0);
|
const total=library.reduce((s,c)=>s+c.size_bytes,0);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue