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:
parent
096c98a678
commit
7499deeab0
8 changed files with 447 additions and 87 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue