1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 03:05:32 +02:00

feat(server,frontend): playlist quality selector + per-video overrides + sync UI

routes.rs:
- CaptureBody gains per_item_quality (HashMap<String,String>, serde(default))
  and sync (bool, serde(default)); both validated before use
  - per_item_quality values validated against same quality predicate as top-level
    quality field ("best"|"audio"|"NNNp") so bad per-video values are rejected
    at the API boundary rather than silently falling through to quality_format
- capture_handler threads body.per_item_quality + body.sync into CaptureConfig
  (replaces hardcoded empty stubs); rearchive_handler keeps empty defaults
- New POST /api/archives/:id/captures/probe-playlist: calls
  probe_playlist_qualities via spawn_blocking; 400 for non-playlist locator,
  502 on yt-dlp failure, returns PlaylistProbeResult as JSON

api.js:
- probePlaylist(archiveId, locator): POST probe-playlist endpoint
- submitCapture: forwards per_item_quality (non-empty) and sync:true from
  extraExtensions param added to submitBgJob

CaptureDialog.jsx:
- isPlaylistSource(): detects yt:/youtube: playlist/@/channel, ytm:playlist/,
  YouTube/YTM HTTP(S) URLs with list= param or channel pathnames
- makeItem(): 6 new playlist state fields
- applyPlaylistQuality(): conflict logic — videos that can reach selected
  quality get it set; videos that can't and have no prior selection are left
  null (conflict); videos with a prior selection keep it when quality is raised
- hasConflict(): any playlistItems entry with quality===null
- updateLocator(): isPlaylistSource branch with 800ms debounce→probePlaylist;
  existing isVideoSource path unchanged
- Archive button disabled when anyConflict or any probe in flight
- Per-video expand list with individual quality selects, conflict badges,
  sync toggle (appears after probe completes)

styles.css: playlist expansion, conflict, sync toggle CSS
This commit is contained in:
TheGeneralist 2026-07-20 22:46:30 +02:00
parent 096c98a678
commit 7499deeab0
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
8 changed files with 447 additions and 87 deletions

View file

@ -264,6 +264,10 @@ pub fn app_with_state(state: AppState) -> Router {
"/api/archives/:archive_id/captures/probe",
get(probe_handler),
)
.route(
"/api/archives/:archive_id/captures/probe-playlist",
post(probe_playlist_handler),
)
.route(
"/api/archives/:archive_id/capture_jobs/:job_uid",
get(get_capture_job_handler),
@ -765,6 +769,13 @@ struct CaptureBody {
modal_closer_enabled: Option<bool>,
/// Route through Freedium mirror for WebPage captures. Absent = true (on by default).
via_freedium: Option<bool>,
/// Per-video quality overrides for playlist captures.
/// Keys are yt-dlp video IDs; values are quality strings ("best", "1080p", "audio", etc.).
#[serde(default)]
per_item_quality: std::collections::HashMap<String, String>,
/// When true, skip playlist items already archived under an existing container.
#[serde(default)]
sync: bool,
}
#[derive(Debug, serde::Deserialize)]
@ -772,6 +783,11 @@ struct ProbeQuery {
locator: String,
}
#[derive(Debug, serde::Deserialize)]
struct ProbePlaylistBody {
locator: String,
}
#[derive(Debug, serde::Deserialize)]
struct LoginBody {
username: String,
@ -842,6 +858,20 @@ async fn capture_handler(
));
}
}
{
let is_valid_quality = |q: &str| {
q == "best"
|| q == "audio"
|| q.strip_suffix('p')
.and_then(|n| n.parse::<u32>().ok())
.is_some()
};
if let Some(bad) = body.per_item_quality.values().find(|q| !is_valid_quality(q)) {
return Err(ApiError::bad_request(&format!(
"invalid per_item_quality value {bad:?}: must be \"best\", \"audio\", or a height string like \"1080p\""
)));
}
}
let mounted = mounted_archive(&state, &archive_id)?;
let archive_paths =
archive::read_archive_paths(&mounted.archive_path).map_err(ApiError::from)?;
@ -879,8 +909,8 @@ async fn capture_handler(
modal_closer_enabled: Some(effective_modal_closer),
reader_mode: body.reader_mode.unwrap_or(false),
via_freedium: body.via_freedium.unwrap_or(true),
per_item_quality: std::collections::HashMap::new(),
sync: false,
per_item_quality: body.per_item_quality.clone(),
sync: body.sync,
};
// Spawn background capture.
@ -1130,6 +1160,41 @@ async fn probe_handler(
})))
}
async fn probe_playlist_handler(
State(state): State<AppState>,
auth_user: AuthUser,
Path(archive_id): Path<String>,
Json(body): Json<ProbePlaylistBody>,
) -> Result<Json<downloader::ytdlp::PlaylistProbeResult>, ApiError> {
auth_user.require_role(ROLE_USER)?;
let locator = body.locator.trim().to_string();
if locator.is_empty() {
return Err(ApiError::bad_request("locator must not be empty"));
}
// Validate it's a playlist/channel source and expand shorthands.
let canonical_url = capture::locator_to_playlist_url(&locator)
.ok_or_else(|| ApiError::bad_request("locator is not a YouTube playlist, channel, or YTM playlist"))?;
// Verify archive exists.
let _ = mounted_archive(&state, &archive_id)?;
// Resolve cookies.
let cookie_rules = match database::open_auth_db(&state.auth_db_path) {
Ok(conn) => database::list_cookie_rules(&conn).unwrap_or_default(),
Err(_) => vec![],
};
let cookies = capture::resolve_cookies_for_url(&cookie_rules, &canonical_url);
// Shell out to yt-dlp in a blocking task.
let result = tokio::task::spawn_blocking(move || {
downloader::ytdlp::probe_playlist_qualities(&canonical_url, &cookies)
})
.await
.map_err(|_| ApiError::internal("probe-playlist task panicked"))?
.map_err(|e| ApiError {
status: StatusCode::BAD_GATEWAY,
message: format!("playlist probe failed: {e:#}"),
})?;
Ok(Json(result))
}
async fn auth_setup_status(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, ApiError> {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title>
<script type="module" crossorigin src="/assets/index-DILm4nQE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ba3TFwml.css">
<script type="module" crossorigin src="/assets/index-JerAE27R.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-vCRGeW0A.css">
</head>
<body>
<div id="root"></div>