1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat(core): playlist per-item quality + incremental sync

ytdlp.rs:
- Add PlaylistItemProbe / PlaylistProbeResult (pub, serde::Serialize)
- Add private available_video_heights_from_value() helper for Value entries
- Add probe_playlist_qualities(): yt-dlp -J (full metadata, no flat flag)
  returns per-video quality lists in one subprocess call

capture.rs:
- Add per_item_quality: HashMap<String,String> and sync: bool to CaptureConfig
  (both Default; keyed by yt-dlp video ID, not URL)
- Add pub locator_to_playlist_url(): validates only the three playlist sources,
  expands shorthands; keeps locator_to_ytdlp_url's no-playlists contract
- Playlist capture block: sync-aware container resolution
  - sync + existing container → reuse it via complete_archive_run_item,
    skip already-archived children (by canonical URL) before creating
    run items so refresh_run_counters only counts new items
  - sync + no container → create normally (first sync run)
  - non-sync → always create fresh container (existing behaviour)
- Per-item quality: config.per_item_quality.get(id) falls back to child_quality

archive.rs:
- Add get_archived_playlist_child_urls(): returns HashSet of canonical URLs
  of all children under any container matching the playlist canonical URL
- Add find_container_entry_id_by_canonical_url(): returns most-recent
  container entry id (parent_entry_id IS NULL) for a given canonical URL

routes.rs: stub per_item_quality/sync on both CaptureConfig sites (server
agent will wire body fields in Phase 2)
This commit is contained in:
TheGeneralist 2026-07-20 22:34:10 +02:00
parent 9c1d416463
commit 814aa76a1d
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
4 changed files with 277 additions and 26 deletions

View file

@ -529,6 +529,59 @@ pub fn list_child_entries(
Ok(entries)
}
/// Returns the set of canonical URLs for all child entries archived under
/// **any** container entry whose own canonical URL matches `playlist_canonical_url`.
///
/// Used in sync mode so the playlist capture path can skip videos that were
/// already downloaded in a previous run of the same playlist.
pub fn get_archived_playlist_child_urls(
conn: &rusqlite::Connection,
playlist_canonical_url: &str,
) -> Result<std::collections::HashSet<String>> {
let mut stmt = conn.prepare(
"SELECT si_child.canonical_url \
FROM archived_entries child \
JOIN source_identities si_child ON si_child.id = child.source_identity_id \
WHERE child.parent_entry_id IN ( \
SELECT e.id \
FROM archived_entries e \
JOIN source_identities si ON si.id = e.source_identity_id \
WHERE si.canonical_url = ?1 \
AND e.parent_entry_id IS NULL \
)",
)?;
let urls = stmt
.query_map([playlist_canonical_url], |row| row.get::<_, String>(0))?
.filter_map(|r| r.ok())
.collect::<std::collections::HashSet<_>>();
Ok(urls)
}
/// Finds the most recent container entry (parent_entry_id IS NULL) whose
/// canonical URL matches `canonical_url`. Returns its row id, or None if
/// no such entry exists.
///
/// Used in sync mode to reuse an existing playlist/channel container instead
/// of creating a duplicate root entry on every sync run.
pub fn find_container_entry_id_by_canonical_url(
conn: &rusqlite::Connection,
canonical_url: &str,
) -> Result<Option<i64>> {
let mut stmt = conn.prepare(
"SELECT e.id \
FROM archived_entries e \
JOIN source_identities si ON si.id = e.source_identity_id \
WHERE si.canonical_url = ?1 \
AND e.parent_entry_id IS NULL \
ORDER BY e.archived_at DESC \
LIMIT 1",
)?;
let id = stmt
.query_row([canonical_url], |row| row.get::<_, i64>(0))
.optional()?;
Ok(id)
}
/// Resolves an artifact to its absolute on-disk path under `store_path`.
///
/// `artifact.relpath` is a store-relative path (e.g. `raw/a/b/abc.pdf`).

View file

@ -7,7 +7,7 @@ use std::{
fs,
path::{Path, PathBuf},
};
use crate::{archive::ArchivePaths, database, downloader, twitter::parse_tweet_id};
use crate::{archive::{self, ArchivePaths}, database, downloader, twitter::parse_tweet_id};
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum Source {
@ -90,6 +90,14 @@ pub struct CaptureConfig {
/// Route WebPage captures through the Freedium mirror to bypass paywalls.
/// The original locator is still recorded in the DB; only the fetch URL changes.
pub via_freedium: bool,
/// Per-item quality overrides for playlist captures.
/// Keys are yt-dlp video IDs (e.g. "dQw4w9WgXcQ"); values are quality strings
/// accepted by `quality_format` ("best", "audio", "1080p", etc.).
/// Empty map = apply the global `quality` to all items.
pub per_item_quality: HashMap<String, String>,
/// When true, skip playlist items whose URL is already archived as a child
/// of any container entry with the same canonical playlist URL.
pub sync: bool,
}
/// Resolves which cookies apply to `url` by evaluating all rules in ordinal order.
@ -536,6 +544,19 @@ pub fn locator_to_ytdlp_url(locator: &str) -> Option<String> {
}
}
/// Returns the canonical URL for playlist/channel locators that yt-dlp can
/// expand with `--flat-playlist`, or `None` for non-playlist sources.
/// Intentionally separate from `locator_to_ytdlp_url`, which excludes playlists.
pub fn locator_to_playlist_url(locator: &str) -> Option<String> {
let source = determine_source(locator);
match source {
Source::YouTubePlaylist | Source::YouTubeChannel | Source::YouTubeMusicPlaylist => {
Some(expand_shorthand_to_url(locator, &source))
}
_ => None,
}
}
fn hash_exists(hash: &str, file_extension: &str, store_path: &Path) -> Result<bool> {
let path = store_path.join(raw_relative_path_from_hash(hash, file_extension)?);
Ok(path.exists())
@ -1049,28 +1070,58 @@ pub fn perform_capture(
.clone()
.or_else(|| playlist_info.uploader.clone().map(|u| format!("{u} (channel)")));
let container_entry = match record_container_entry(
&conn,
store_path,
user_id,
&run,
&item,
locator,
&canonical_url,
source,
container_title,
&playlist_info.playlist_id,
playlist_info.uploader.as_deref(),
) {
Ok(e) => e,
Err(e) => {
return Err(fail_run(
&conn,
&run,
&item,
&format!("Failed to create container entry: {e:#}"),
));
// Sync mode: reuse an existing container so we don't create a duplicate
// root entry on every sync run. Non-sync always creates a fresh container.
let (container_id, already_archived) = if config.sync {
match archive::find_container_entry_id_by_canonical_url(&conn, &canonical_url) {
Err(e) => {
return Err(fail_run(
&conn, &run, &item,
&format!("Failed to query existing container: {e:#}"),
));
}
Ok(Some(existing_id)) => {
// Container already exists — mark the run item done pointing at it;
// don't call record_container_entry (that would create a duplicate).
if let Err(e) = database::complete_archive_run_item(&conn, item.id, existing_id) {
return Err(fail_run(
&conn, &run, &item,
&format!("Failed to complete run item for existing container: {e:#}"),
));
}
let archived = archive::get_archived_playlist_child_urls(&conn, &canonical_url)
.unwrap_or_default();
(existing_id, archived)
}
Ok(None) => {
// First sync run for this playlist — create the container normally.
let e = match record_container_entry(
&conn, store_path, user_id, &run, &item, locator, &canonical_url,
source, container_title, &playlist_info.playlist_id,
playlist_info.uploader.as_deref(),
) {
Ok(e) => e,
Err(e) => return Err(fail_run(
&conn, &run, &item,
&format!("Failed to create container entry: {e:#}"),
)),
};
(e.id, std::collections::HashSet::new())
}
}
} else {
let e = match record_container_entry(
&conn, store_path, user_id, &run, &item, locator, &canonical_url,
source, container_title, &playlist_info.playlist_id,
playlist_info.uploader.as_deref(),
) {
Ok(e) => e,
Err(e) => return Err(fail_run(
&conn, &run, &item,
&format!("Failed to create container entry: {e:#}"),
)),
};
(e.id, std::collections::HashSet::new())
};
let is_audio = source == Source::YouTubeMusicPlaylist;
@ -1078,6 +1129,12 @@ pub fn perform_capture(
let child_entity_kind = if is_audio { "music" } else { "video" };
for (ordinal, playlist_item) in playlist_info.items.iter().enumerate() {
// Sync: skip items already archived — no run item created, so
// refresh_run_counters sees only newly-attempted items.
if config.sync && already_archived.contains(&playlist_item.url) {
continue;
}
let child_timestamp = format!(
"{}-{}",
Local::now().format("%Y-%m-%dT%H-%M-%S%.3f"),
@ -1114,12 +1171,19 @@ pub fn perform_capture(
None => playlist_item.title.clone(),
};
// Per-item quality override keyed by yt-dlp video ID; fall back to playlist-level.
let effective_child_quality: Option<&str> = config
.per_item_quality
.get(&playlist_item.id)
.map(String::as_str)
.or(child_quality);
// Download the media.
match downloader::ytdlp::download(
playlist_item.url.clone(),
store_path,
&child_timestamp,
child_quality,
effective_child_quality,
&cookies,
) {
Ok((hash, file_extension)) => {
@ -1165,8 +1229,8 @@ pub fn perform_capture(
&file_extension,
byte_size,
child_title,
Some(container_entry.id),
Some(container_entry.id),
Some(container_id),
Some(container_id),
) {
Ok(child_entry) => {
let _ = database::refresh_entry_cached_bytes(&conn, child_entry.id);
@ -1191,7 +1255,7 @@ pub fn perform_capture(
}
}
database::refresh_entry_cached_bytes(&conn, container_entry.id)?;
database::refresh_entry_cached_bytes(&conn, container_id)?;
database::finish_archive_run(&conn, run.id)?;
let run_status: String = conn

View file

@ -29,6 +29,27 @@ pub struct PlaylistInfo {
pub items: Vec<PlaylistItem>,
}
/// Per-item quality data returned by `probe_playlist_qualities`.
#[derive(Debug, serde::Serialize)]
pub struct PlaylistItemProbe {
pub id: String,
pub url: String,
pub title: Option<String>,
/// Available video heights as strings (e.g. "1080p"), sorted highest-first.
/// Empty vec means audio-only (no video track).
pub qualities: Vec<String>,
pub has_audio: bool,
}
/// Full playlist probe result with per-item quality data.
#[derive(Debug, serde::Serialize)]
pub struct PlaylistProbeResult {
pub playlist_id: String,
pub title: Option<String>,
pub uploader: Option<String>,
pub items: Vec<PlaylistItemProbe>,
}
/// Returns the yt-dlp `-f` format selector for `quality`.
///
/// - `"audio"` → prefers native Opus/WebM (most efficient), then native
@ -93,6 +114,23 @@ pub fn available_video_heights(json: &str) -> Vec<u32> {
heights
}
/// Extracts distinct video heights from a serde_json entry Value's `formats` array,
/// sorted highest-first. Audio-only formats (vcodec == "none") are excluded.
fn available_video_heights_from_value(entry: &serde_json::Value) -> Vec<u32> {
let Some(fmts) = entry.get("formats").and_then(|v| v.as_array()) else {
return vec![];
};
let mut heights: Vec<u32> = fmts
.iter()
.filter(|f| f.get("vcodec").and_then(|c| c.as_str()).unwrap_or("none") != "none")
.filter_map(|f| f.get("height").and_then(|h| h.as_u64()).map(|h| h as u32))
.filter(|&h| h > 0)
.collect();
heights.sort_unstable_by(|a, b| b.cmp(a));
heights.dedup();
heights
}
/// Returns true when the yt-dlp `--dump-json` response contains at least one
/// format with a real audio codec (i.e. `acodec != "none"`).
pub fn has_audio_track(json: &str) -> bool {
@ -365,6 +403,98 @@ pub fn fetch_playlist_info(url: &str, cookies: &HashMap<String, String>) -> Resu
Ok(PlaylistInfo { playlist_id, title, uploader, items })
}
/// Runs `yt-dlp -J <url>` (full metadata, NOT --flat-playlist) and returns
/// per-item quality data for every entry in the playlist.
///
/// This makes one yt-dlp subprocess call that fetches full format data for
/// all videos — expensive for large playlists but gives accurate per-video
/// quality lists. Intended for pre-capture quality selection only.
pub fn probe_playlist_qualities(
url: &str,
cookies: &HashMap<String, String>,
) -> Result<PlaylistProbeResult> {
let ytdlp = std::env::var("ARCHIVR_YT_DLP").unwrap_or_else(|_| "yt-dlp".to_string());
let cookie_file: Option<PathBuf> = if !cookies.is_empty() {
let domain = domain_from_url(url);
let p = std::env::temp_dir()
.join(format!("archivr-cookies-{}.txt", Uuid::new_v4().simple()));
write_netscape_cookie_file(cookies, &domain, &p)
.context("failed to write yt-dlp cookie file")?;
Some(p)
} else {
None
};
let mut cmd = std::process::Command::new(&ytdlp);
cmd.arg("-J"); // full metadata — NOT --flat-playlist
if let Some(cf) = &cookie_file {
cmd.arg("--cookies").arg(cf);
}
cmd.arg(url);
let out = cmd.output();
if let Some(cf) = &cookie_file {
let _ = std::fs::remove_file(cf);
}
let out = out.with_context(|| format!("failed to spawn {ytdlp}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
bail!("yt-dlp -J failed for {url}: {stderr}");
}
let json: serde_json::Value = serde_json::from_slice(&out.stdout)
.context("yt-dlp -J output is not valid JSON")?;
let ty = json.get("_type").and_then(|v| v.as_str()).unwrap_or("");
if ty != "playlist" {
bail!("yt-dlp output _type is {ty:?}, expected \"playlist\"");
}
let playlist_id = json.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
let title = json.get("title").and_then(|v| v.as_str()).map(str::to_owned);
let uploader = json.get("uploader").and_then(|v| v.as_str()).map(str::to_owned);
// Same URL normalization as fetch_playlist_info
let fallback_host = if url.contains("music.youtube.com") {
"music.youtube.com"
} else {
"www.youtube.com"
};
let is_absolute = |s: &str| s.starts_with("http://") || s.starts_with("https://");
let raw_entries = json
.get("entries")
.and_then(|v| v.as_array())
.map(|a| a.as_slice())
.unwrap_or(&[]);
let mut items = Vec::with_capacity(raw_entries.len());
for entry in raw_entries {
if entry.is_null() { continue; }
let id = match entry.get("id").and_then(|v| v.as_str()) {
Some(s) => s.to_owned(),
None => continue,
};
let item_url = entry
.get("webpage_url").and_then(|v| v.as_str()).filter(|s| is_absolute(s)).map(str::to_owned)
.or_else(|| entry.get("url").and_then(|v| v.as_str()).filter(|s| is_absolute(s)).map(str::to_owned))
.unwrap_or_else(|| format!("https://{fallback_host}/watch?v={id}"));
let item_title = entry.get("title").and_then(|v| v.as_str()).map(str::to_owned);
let heights = available_video_heights_from_value(entry);
let qualities: Vec<String> = heights.iter().map(|h| format!("{h}p")).collect();
let has_audio = entry
.get("formats").and_then(|v| v.as_array())
.map(|fmts| fmts.iter().any(|f| {
f.get("acodec").and_then(|c| c.as_str()).unwrap_or("none") != "none"
}))
.unwrap_or(false);
items.push(PlaylistItemProbe { id, url: item_url, title: item_title, qualities, has_audio });
}
Ok(PlaylistProbeResult { playlist_id, title, uploader, items })
}
#[cfg(test)]
mod tests {
use super::{available_video_heights, has_audio_track, quality_format};

View file

@ -879,6 +879,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,
};
// Spawn background capture.
@ -998,6 +1000,8 @@ async fn rearchive_handler(
modal_closer_enabled: None,
reader_mode: false,
via_freedium: false,
per_item_quality: std::collections::HashMap::new(),
sync: false,
};
let job_uid_bg = job_uid.clone();