mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
Merge 6a7826eba5 into d202e177e1
This commit is contained in:
commit
2888f1a6c3
23 changed files with 2141 additions and 2634 deletions
|
|
@ -16,6 +16,8 @@ Three crates with a strict ownership split — **core owns truth; CLI and server
|
|||
|
||||
Capture flow: locator → `determine_source()` (`crates/archivr-core/src/capture.rs`) routes by platform/shorthand (`yt:`, `x:`, `tweet:` …) → platform downloader (`downloader/ytdlp.rs`, `tweets.rs`, `singlefile.rs`, `http.rs`, `local.rs`) stages into `temp/` → SHA3-256 dedup (`hash.rs`, `downloader/store.rs`) moves blobs to `raw/A/B/HASH.EXT` → rows written to `archivr.sqlite` (runs, entries, artifacts, blobs) → served via `/api/archives/:id/...`. `CaptureConfig` carries per-request toggles (uBlock, reader mode, Freedium mirror, etc.); when `via_freedium` is set, the fetch URL is rewritten through `freedium-mirror.cfd` while the canonical DB URL stays the original locator.
|
||||
|
||||
YouTube playlists and channels produce a **parent container entry** with each video captured as a child entry. `downloader/ytdlp.rs` handles the playlist probe (fetching per-video quality metadata before archiving), the multi-video download loop, and sync mode (skipping already-archived videos when re-archiving a playlist or channel).
|
||||
|
||||
Per-archive layout (created by `archivr init`): `.archivr/` (name, store_path, `archivr.sqlite`) + sibling `store/` (`raw/`, `raw_tweets/`, `structured/`, `temp/`). Server-level auth lives in a **separate** `archivr-auth.sqlite` (users, sessions, API tokens, role bits GUEST=1/USER=2/ADMIN=4/OWNER=8).
|
||||
|
||||
The server mounts multiple archives from a TOML registry (`crates/archivr-server/src/registry.rs`); routes are parameterized by `:archive_id`.
|
||||
|
|
@ -78,6 +80,7 @@ No CI is configured; no rustfmt.toml/clippy.toml — default `cargo fmt`/`clippy
|
|||
- `crates/archivr-server/src/main.rs` — server bootstrap: config load, archive mounting, auth DB init, stalled-job recovery (running → failed on startup).
|
||||
- `crates/archivr-server/src/routes.rs` — all HTTP handlers and the router; grep here first for API work.
|
||||
- `crates/archivr-core/src/capture.rs` — `perform_capture()`, `Source` enum, shorthand parsing.
|
||||
- `crates/archivr-core/src/downloader/ytdlp.rs` — yt-dlp integration; YouTube playlist/channel probe and download, sync mode logic.
|
||||
- `crates/archivr-core/src/database.rs` — single source of truth for all SQLite schema and queries (both archive and auth DBs).
|
||||
- `frontend/src/App.jsx` / `frontend/src/api.js` — frontend root state and API surface.
|
||||
- `docker/config.example.toml` — server config schema: `bind`, `auth_db_path`, repeated `[[archives]]` (`id`, `label`, `archive_path`).
|
||||
|
|
|
|||
|
|
@ -64,6 +64,17 @@ label = "Personal"
|
|||
archive_path = "/path/to/archive/.archivr"
|
||||
```
|
||||
|
||||
## Entry Nesting
|
||||
|
||||
Entries support a two-level parent/child hierarchy. A **container entry** (playlist or channel) holds zero or more child entries (individual videos). Container entries have no primary media artifact of their own; their `total_artifact_bytes` is the sum of their children's bytes.
|
||||
|
||||
Rules:
|
||||
- Maximum nesting depth is 2 (root → child). Children cannot have children.
|
||||
- The UI shows child entries collapsed under their parent, expandable with a chevron.
|
||||
- Container entries are created by playlist/channel captures. Single-video and all other source types produce a standalone root entry with no children.
|
||||
|
||||
If a feature touches how entries are parented or how the UI groups them, start in `archivr-core` (`database.rs` for schema, `archive.rs` for listing, `capture.rs` for creation).
|
||||
|
||||
## How To Run It
|
||||
|
||||
There are two user-facing binaries:
|
||||
|
|
@ -155,6 +166,7 @@ sequenceDiagram
|
|||
| Capture orchestration, `Source` routing, `CaptureConfig` | `crates/archivr-core/src/capture.rs` |
|
||||
| Archive opening, listing entries, entry detail, runs | `crates/archivr-core/src/archive.rs` |
|
||||
| Download/save behavior | `crates/archivr-core/src/downloader/` |
|
||||
| YouTube playlist/channel download, playlist probe, sync mode | `crates/archivr-core/src/downloader/ytdlp.rs` and `capture.rs` |
|
||||
| CLI commands, argument parsing, terminal output | `crates/archivr-cli/src/main.rs` |
|
||||
| Server API routes | `crates/archivr-server/src/routes.rs` |
|
||||
| Auth model (users, sessions, tokens, roles) | `crates/archivr-server/src/auth.rs` |
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ pub struct EntrySummary {
|
|||
pub has_favicon: bool,
|
||||
/// Bytes of blobs already on disk from an earlier entry (precomputed at capture time).
|
||||
pub cached_bytes: i64,
|
||||
/// Number of direct child entries; 0 for non-container entries.
|
||||
pub child_count: i64,
|
||||
/// Total non-avatar artifact bytes (query-time; used as denominator for cache-hit %).
|
||||
pub cacheable_bytes: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
|
|
@ -212,10 +216,12 @@ pub fn list_root_entries(
|
|||
e.visibility,
|
||||
si.canonical_url,
|
||||
COUNT(ea.id) AS artifact_count,
|
||||
COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes,
|
||||
COALESCE(SUM(b.byte_size), 0) + COALESCE((SELECT SUM(b2.byte_size) FROM archived_entries c2 JOIN entry_artifacts ea2 ON ea2.entry_id = c2.id JOIN blobs b2 ON b2.id = ea2.blob_id WHERE c2.parent_entry_id = e.id), 0) AS total_artifact_bytes,
|
||||
NULL AS parent_entry_uid,
|
||||
EXISTS(SELECT 1 FROM entry_artifacts fav WHERE fav.entry_id = e.id AND fav.artifact_role = 'favicon') AS has_favicon,
|
||||
e.cached_bytes
|
||||
e.cached_bytes,
|
||||
(SELECT COUNT(*) FROM archived_entries child WHERE child.parent_entry_id = e.id) AS child_count,
|
||||
COALESCE(SUM(CASE WHEN ea.artifact_role != 'avatar' THEN b.byte_size ELSE 0 END), 0) + COALESCE((SELECT SUM(b2.byte_size) FROM archived_entries c2 JOIN entry_artifacts ea2 ON ea2.entry_id = c2.id JOIN blobs b2 ON b2.id = ea2.blob_id WHERE c2.parent_entry_id = e.id), 0) AS cacheable_bytes
|
||||
FROM archived_entries e
|
||||
JOIN source_identities si ON si.id = e.source_identity_id
|
||||
LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id
|
||||
|
|
@ -248,6 +254,8 @@ pub fn list_root_entries(
|
|||
parent_entry_uid: row.get(9)?,
|
||||
has_favicon: row.get::<_, i64>(10)? != 0,
|
||||
cached_bytes: row.get(11)?,
|
||||
child_count: row.get(12)?,
|
||||
cacheable_bytes: row.get(13)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
|
@ -255,6 +263,40 @@ pub fn list_root_entries(
|
|||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Fetches one `EntrySummary` for any entry (root or child) by uid.
|
||||
/// Returns `None` if not found.
|
||||
fn get_entry_summary(
|
||||
conn: &rusqlite::Connection,
|
||||
entry_uid: &str,
|
||||
) -> Result<Option<EntrySummary>> {
|
||||
let sql = format!(
|
||||
"{} {} WHERE e.entry_uid = ?1 GROUP BY e.id",
|
||||
ENTRY_SELECT_COLS, ENTRY_FROM_JOINS,
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let result = stmt
|
||||
.query_row([entry_uid], |row| {
|
||||
Ok(EntrySummary {
|
||||
entry_uid: row.get(0)?,
|
||||
archived_at: row.get(1)?,
|
||||
source_kind: row.get(2)?,
|
||||
entity_kind: row.get(3)?,
|
||||
title: row.get(4)?,
|
||||
visibility: row.get(5)?,
|
||||
original_url: row.get(6)?,
|
||||
artifact_count: row.get(7)?,
|
||||
total_artifact_bytes: row.get(8)?,
|
||||
parent_entry_uid: row.get(9)?,
|
||||
has_favicon: row.get::<_, i64>(10)? != 0,
|
||||
cached_bytes: row.get(11)?,
|
||||
child_count: row.get(12)?,
|
||||
cacheable_bytes: row.get(13)?,
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn get_entry_detail(
|
||||
conn: &rusqlite::Connection,
|
||||
entry_uid: &str,
|
||||
|
|
@ -279,9 +321,7 @@ pub fn get_entry_detail(
|
|||
return Ok(None);
|
||||
};
|
||||
|
||||
let summary = list_root_entries(conn, u32::MAX)?
|
||||
.into_iter()
|
||||
.find(|entry| entry.entry_uid == entry_uid)
|
||||
let summary = get_entry_summary(conn, entry_uid)?
|
||||
.context("entry disappeared while loading detail")?;
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
|
|
@ -438,12 +478,116 @@ pub fn list_entries_for_collection(
|
|||
parent_entry_uid: row.get(9)?,
|
||||
has_favicon: row.get::<_, i64>(10)? != 0,
|
||||
cached_bytes: row.get(11)?,
|
||||
child_count: row.get(12)?,
|
||||
cacheable_bytes: row.get(13)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Returns the direct children of the entry identified by `parent_entry_uid`,
|
||||
/// ordered ascending by `archived_at, id` (preserves playlist ordinal feel).
|
||||
/// Returns an empty vec if the parent has no children or does not exist.
|
||||
pub fn list_child_entries(
|
||||
conn: &rusqlite::Connection,
|
||||
parent_entry_uid: &str,
|
||||
caller_bits: u32,
|
||||
) -> Result<Vec<EntrySummary>> {
|
||||
let sql = format!(
|
||||
"{} {} \
|
||||
WHERE e.parent_entry_id = (SELECT id FROM archived_entries WHERE entry_uid = ?1) \
|
||||
AND (\
|
||||
CAST(?2 AS INTEGER) & 12 != 0 \
|
||||
OR EXISTS (\
|
||||
SELECT 1 FROM collection_entries ce \
|
||||
WHERE ce.entry_id = e.id \
|
||||
AND ce.visibility_bits & CAST(?2 AS INTEGER) != 0\
|
||||
)\
|
||||
) \
|
||||
GROUP BY e.id \
|
||||
ORDER BY e.archived_at ASC, e.id ASC",
|
||||
ENTRY_SELECT_COLS, ENTRY_FROM_JOINS,
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let entries = stmt
|
||||
.query_map(
|
||||
rusqlite::params![parent_entry_uid, caller_bits as i64],
|
||||
|row| {
|
||||
Ok(EntrySummary {
|
||||
entry_uid: row.get(0)?,
|
||||
archived_at: row.get(1)?,
|
||||
source_kind: row.get(2)?,
|
||||
entity_kind: row.get(3)?,
|
||||
title: row.get(4)?,
|
||||
visibility: row.get(5)?,
|
||||
original_url: row.get(6)?,
|
||||
artifact_count: row.get(7)?,
|
||||
total_artifact_bytes: row.get(8)?,
|
||||
parent_entry_uid: row.get(9)?,
|
||||
has_favicon: row.get::<_, i64>(10)? != 0,
|
||||
cached_bytes: row.get(11)?,
|
||||
child_count: row.get(12)?,
|
||||
cacheable_bytes: row.get(13)?,
|
||||
})
|
||||
},
|
||||
)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
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))?
|
||||
.collect::<rusqlite::Result<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`).
|
||||
|
|
@ -552,10 +696,12 @@ pub fn parse_search_query(raw: &str) -> Result<SearchEntriesQuery, String> {
|
|||
|
||||
const ENTRY_SELECT_COLS: &str = "SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title, \
|
||||
e.visibility, si.canonical_url, COUNT(ea.id) AS artifact_count, \
|
||||
COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes, \
|
||||
COALESCE(SUM(b.byte_size), 0) + COALESCE((SELECT SUM(b2.byte_size) FROM archived_entries c2 JOIN entry_artifacts ea2 ON ea2.entry_id = c2.id JOIN blobs b2 ON b2.id = ea2.blob_id WHERE c2.parent_entry_id = e.id), 0) AS total_artifact_bytes, \
|
||||
parent.entry_uid AS parent_entry_uid, \
|
||||
EXISTS(SELECT 1 FROM entry_artifacts fav WHERE fav.entry_id = e.id AND fav.artifact_role = 'favicon') AS has_favicon, \
|
||||
e.cached_bytes";
|
||||
e.cached_bytes, \
|
||||
(SELECT COUNT(*) FROM archived_entries child WHERE child.parent_entry_id = e.id) AS child_count, \
|
||||
COALESCE(SUM(CASE WHEN ea.artifact_role != 'avatar' THEN b.byte_size ELSE 0 END), 0) + COALESCE((SELECT SUM(b2.byte_size) FROM archived_entries c2 JOIN entry_artifacts ea2 ON ea2.entry_id = c2.id JOIN blobs b2 ON b2.id = ea2.blob_id WHERE c2.parent_entry_id = e.id), 0) AS cacheable_bytes";
|
||||
|
||||
const ENTRY_FROM_JOINS: &str = "FROM archived_entries e \
|
||||
JOIN source_identities si ON si.id = e.source_identity_id \
|
||||
|
|
@ -663,6 +809,8 @@ pub fn search_entries(
|
|||
parent_entry_uid: row.get(9)?,
|
||||
has_favicon: row.get::<_, i64>(10)? != 0,
|
||||
cached_bytes: row.get(11)?,
|
||||
child_count: row.get(12)?,
|
||||
cacheable_bytes: row.get(13)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
|
@ -870,7 +1018,9 @@ pub fn entries_for_tag(
|
|||
COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes,
|
||||
parent.entry_uid AS parent_entry_uid,
|
||||
EXISTS(SELECT 1 FROM entry_artifacts fav WHERE fav.entry_id = e.id AND fav.artifact_role = 'favicon') AS has_favicon,
|
||||
e.cached_bytes
|
||||
e.cached_bytes,
|
||||
(SELECT COUNT(*) FROM archived_entries child WHERE child.parent_entry_id = e.id) AS child_count,
|
||||
COALESCE(SUM(CASE WHEN ea.artifact_role != 'avatar' THEN b.byte_size ELSE 0 END), 0) AS cacheable_bytes
|
||||
FROM archived_entries e
|
||||
JOIN source_identities si ON si.id = e.source_identity_id
|
||||
LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id
|
||||
|
|
@ -896,6 +1046,8 @@ pub fn entries_for_tag(
|
|||
parent_entry_uid: row.get(9)?,
|
||||
has_favicon: row.get::<_, i64>(10)? != 0,
|
||||
cached_bytes: row.get(11)?,
|
||||
child_count: row.get(12)?,
|
||||
cacheable_bytes: row.get(13)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
|
@ -1618,4 +1770,234 @@ mod tests {
|
|||
);
|
||||
assert_eq!(results[0].entry_uid, parent.entry_uid);
|
||||
}
|
||||
// ── sync helper tests ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn find_container_entry_id_returns_none_when_absent() {
|
||||
let (conn, _, _) = make_tag_test_db();
|
||||
let result = find_container_entry_id_by_canonical_url(
|
||||
&conn,
|
||||
"https://www.youtube.com/playlist?list=PLnobody",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(result.is_none(), "should return None when no entry exists");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_container_entry_id_returns_root_entry() {
|
||||
let (conn, user_id, run_id) = make_tag_test_db();
|
||||
let playlist_url = "https://www.youtube.com/playlist?list=PLtest";
|
||||
let container = make_entry_in_db(&conn, user_id, run_id, None, None, "My Playlist", playlist_url);
|
||||
let result = find_container_entry_id_by_canonical_url(&conn, playlist_url)
|
||||
.unwrap()
|
||||
.expect("should find the container");
|
||||
assert_eq!(result, container.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_container_entry_id_ignores_child_entries() {
|
||||
let (conn, user_id, run_id) = make_tag_test_db();
|
||||
let child_url = "https://www.youtube.com/watch?v=abc123";
|
||||
// Create a parent container and a child whose canonical URL happens to be
|
||||
// what we're querying — should NOT be returned since it has parent_entry_id set.
|
||||
let parent = make_entry_in_db(&conn, user_id, run_id, None, None, "PL", "https://example.com/pl");
|
||||
let _child = make_entry_in_db(&conn, user_id, run_id, Some(parent.id), Some(parent.id), "Vid", child_url);
|
||||
let result = find_container_entry_id_by_canonical_url(&conn, child_url).unwrap();
|
||||
assert!(result.is_none(), "child entry should not be returned as a container");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_archived_playlist_child_urls_empty_when_no_playlist() {
|
||||
let (conn, _, _) = make_tag_test_db();
|
||||
let urls = get_archived_playlist_child_urls(
|
||||
&conn,
|
||||
"https://www.youtube.com/playlist?list=PLnone",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(urls.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_archived_playlist_child_urls_returns_children() {
|
||||
let (conn, user_id, run_id) = make_tag_test_db();
|
||||
let playlist_url = "https://www.youtube.com/playlist?list=PLchildren";
|
||||
let container = make_entry_in_db(&conn, user_id, run_id, None, None, "Playlist", playlist_url);
|
||||
|
||||
let child1_url = "https://www.youtube.com/watch?v=vid1";
|
||||
let child2_url = "https://www.youtube.com/watch?v=vid2";
|
||||
make_entry_in_db(&conn, user_id, run_id, Some(container.id), Some(container.id), "Vid 1", child1_url);
|
||||
make_entry_in_db(&conn, user_id, run_id, Some(container.id), Some(container.id), "Vid 2", child2_url);
|
||||
|
||||
let urls = get_archived_playlist_child_urls(&conn, playlist_url).unwrap();
|
||||
assert_eq!(urls.len(), 2);
|
||||
assert!(urls.contains(child1_url), "should contain vid1");
|
||||
assert!(urls.contains(child2_url), "should contain vid2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_archived_playlist_child_urls_excludes_other_playlists() {
|
||||
let (conn, user_id, run_id) = make_tag_test_db();
|
||||
let pl_a = "https://www.youtube.com/playlist?list=PLA";
|
||||
let pl_b = "https://www.youtube.com/playlist?list=PLB";
|
||||
let container_a = make_entry_in_db(&conn, user_id, run_id, None, None, "PL A", pl_a);
|
||||
let container_b = make_entry_in_db(&conn, user_id, run_id, None, None, "PL B", pl_b);
|
||||
|
||||
let vid_a = "https://www.youtube.com/watch?v=forA";
|
||||
let vid_b = "https://www.youtube.com/watch?v=forB";
|
||||
make_entry_in_db(&conn, user_id, run_id, Some(container_a.id), Some(container_a.id), "A Vid", vid_a);
|
||||
make_entry_in_db(&conn, user_id, run_id, Some(container_b.id), Some(container_b.id), "B Vid", vid_b);
|
||||
|
||||
let urls_a = get_archived_playlist_child_urls(&conn, pl_a).unwrap();
|
||||
assert_eq!(urls_a.len(), 1);
|
||||
assert!(urls_a.contains(vid_a));
|
||||
assert!(!urls_a.contains(vid_b), "should not include children of other playlists");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_bytes_excludes_avatar_blobs() {
|
||||
let (conn, user_id, run_id) = make_tag_test_db();
|
||||
|
||||
// entry_a is older (created first, gets the lower id)
|
||||
let entry_a = make_entry_in_db(
|
||||
&conn,
|
||||
user_id,
|
||||
run_id,
|
||||
None,
|
||||
None,
|
||||
"Tweet A",
|
||||
"https://twitter.com/user/status/1",
|
||||
);
|
||||
// entry_b is newer (created second, higher id — tiebreak on id when archived_at is equal)
|
||||
let entry_b = make_entry_in_db(
|
||||
&conn,
|
||||
user_id,
|
||||
run_id,
|
||||
None,
|
||||
None,
|
||||
"Tweet B",
|
||||
"https://twitter.com/user/status/2",
|
||||
);
|
||||
|
||||
// Shared avatar blob: 100 bytes
|
||||
let avatar_blob_id = database::upsert_blob(
|
||||
&conn,
|
||||
&database::BlobRecord {
|
||||
sha256: "avatar_sha256_test".to_string(),
|
||||
byte_size: 100,
|
||||
mime_type: Some("image/jpeg".to_string()),
|
||||
extension: Some("jpg".to_string()),
|
||||
raw_relpath: "raw/av/at/avatar.jpg".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Shared media blob: 900 bytes
|
||||
let media_blob_id = database::upsert_blob(
|
||||
&conn,
|
||||
&database::BlobRecord {
|
||||
sha256: "media_sha256_test".to_string(),
|
||||
byte_size: 900,
|
||||
mime_type: Some("image/png".to_string()),
|
||||
extension: Some("png".to_string()),
|
||||
raw_relpath: "raw/me/di/media.png".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Attach both artifacts to entry_a
|
||||
database::add_entry_artifact(
|
||||
&conn,
|
||||
&database::NewArtifact {
|
||||
entry_id: entry_a.id,
|
||||
artifact_role: "avatar".to_string(),
|
||||
storage_area: "raw".to_string(),
|
||||
relpath: "raw/av/at/avatar.jpg".to_string(),
|
||||
blob_id: Some(avatar_blob_id),
|
||||
logical_path: None,
|
||||
metadata_json: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
database::add_entry_artifact(
|
||||
&conn,
|
||||
&database::NewArtifact {
|
||||
entry_id: entry_a.id,
|
||||
artifact_role: "primary_media".to_string(),
|
||||
storage_area: "raw".to_string(),
|
||||
relpath: "raw/me/di/media.png".to_string(),
|
||||
blob_id: Some(media_blob_id),
|
||||
logical_path: None,
|
||||
metadata_json: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Attach both artifacts to entry_b (same blobs — simulates shared avatar/media)
|
||||
database::add_entry_artifact(
|
||||
&conn,
|
||||
&database::NewArtifact {
|
||||
entry_id: entry_b.id,
|
||||
artifact_role: "avatar".to_string(),
|
||||
storage_area: "raw".to_string(),
|
||||
relpath: "raw/av/at/avatar.jpg".to_string(),
|
||||
blob_id: Some(avatar_blob_id),
|
||||
logical_path: None,
|
||||
metadata_json: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
database::add_entry_artifact(
|
||||
&conn,
|
||||
&database::NewArtifact {
|
||||
entry_id: entry_b.id,
|
||||
artifact_role: "primary_media".to_string(),
|
||||
storage_area: "raw".to_string(),
|
||||
relpath: "raw/me/di/media.png".to_string(),
|
||||
blob_id: Some(media_blob_id),
|
||||
logical_path: None,
|
||||
metadata_json: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Recompute cached_bytes for entry_b.
|
||||
// The query excludes avatar-role artifacts and counts only non-avatar blobs
|
||||
// that are already held by an earlier entry. entry_a (lower id) owns the
|
||||
// same media blob, so cached_bytes for entry_b should be 900, not 1000.
|
||||
database::refresh_entry_cached_bytes(&conn, entry_b.id).unwrap();
|
||||
|
||||
// --- Assert raw DB value ---
|
||||
let cached_bytes_db: i64 = conn
|
||||
.query_row(
|
||||
"SELECT cached_bytes FROM archived_entries WHERE id = ?1",
|
||||
[entry_b.id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cached_bytes_db, 900,
|
||||
"cached_bytes should be 900 (media only); avatar blob must be excluded"
|
||||
);
|
||||
|
||||
// --- Assert list_root_entries summary for entry_b ---
|
||||
let entries = list_root_entries(&conn, 12).unwrap(); // 12 = ADMIN bits
|
||||
let summary_b = entries
|
||||
.iter()
|
||||
.find(|e| e.entry_uid == entry_b.entry_uid)
|
||||
.expect("entry_b must appear in list_root_entries");
|
||||
|
||||
assert_eq!(
|
||||
summary_b.cached_bytes, 900,
|
||||
"summary cached_bytes must equal 900 (precomputed, avatar excluded)"
|
||||
);
|
||||
assert_eq!(
|
||||
summary_b.cacheable_bytes, 900,
|
||||
"cacheable_bytes (non-avatar total) must be 900 for entry_b"
|
||||
);
|
||||
assert_eq!(
|
||||
summary_b.total_artifact_bytes, 1000,
|
||||
"total_artifact_bytes must be 1000 (avatar 100 + media 900)"
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,17 @@ 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 and include-set 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: all playlist items are downloaded using the global `quality`.
|
||||
/// - Non-empty map: ONLY items whose ID appears as a key are downloaded;
|
||||
/// items absent from the map are skipped entirely. This is how the UI's
|
||||
/// per-video delete button excludes specific videos from a capture.
|
||||
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.
|
||||
|
|
@ -200,6 +211,12 @@ fn generate_entry_title(source: Source, meta: &PlatformMetadata) -> String {
|
|||
Source::Other => "Archived Content".to_string(),
|
||||
}
|
||||
}
|
||||
/// Returns true when `s` is a valid bare YouTube video ID:
|
||||
/// exactly 11 characters from the set `[A-Za-z0-9_-]`.
|
||||
fn is_youtube_video_id(s: &str) -> bool {
|
||||
s.len() == 11 && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
|
||||
}
|
||||
|
||||
|
||||
fn expand_shorthand_to_url(path: &str, source: &Source) -> String {
|
||||
// YouTube shorthands: yt:video/ID, yt:playlist/ID, yt:@handle, yt:channel/ID, etc.
|
||||
|
|
@ -227,6 +244,10 @@ fn expand_shorthand_to_url(path: &str, source: &Source) -> String {
|
|||
if let Some(handle) = after.strip_prefix("@") {
|
||||
return format!("https://www.youtube.com/@{handle}");
|
||||
}
|
||||
// bare yt:ID — validated 11-char video ID
|
||||
if is_youtube_video_id(after) {
|
||||
return format!("https://www.youtube.com/watch?v={after}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -317,6 +338,11 @@ fn determine_source(path: &str) -> Source {
|
|||
{
|
||||
return Source::YouTubeChannel;
|
||||
}
|
||||
|
||||
// bare yt:ID — exactly 11 chars [A-Za-z0-9_-], no slash/@ prefixes
|
||||
if is_youtube_video_id(after_scheme) {
|
||||
return Source::YouTubeVideo;
|
||||
}
|
||||
}
|
||||
|
||||
// Shorthand scheme: ytm:
|
||||
|
|
@ -536,6 +562,21 @@ 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
|
||||
| Source::SpotifyAlbum
|
||||
| Source::SpotifyPlaylist => 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())
|
||||
|
|
@ -660,6 +701,8 @@ fn record_media_entry(
|
|||
file_extension: &str,
|
||||
byte_size: i64,
|
||||
title: Option<String>,
|
||||
parent_entry_id: Option<i64>,
|
||||
root_entry_id: Option<i64>,
|
||||
) -> Result<database::ArchivedEntry> {
|
||||
debug_assert!(run.run_uid.starts_with("run_"));
|
||||
debug_assert!(item.item_uid.starts_with("item_"));
|
||||
|
|
@ -686,8 +729,8 @@ fn record_media_entry(
|
|||
&database::NewEntry {
|
||||
source_identity_id,
|
||||
archive_run_id: run.id,
|
||||
parent_entry_id: None,
|
||||
root_entry_id: None,
|
||||
parent_entry_id,
|
||||
root_entry_id,
|
||||
created_by_user_id: user_id,
|
||||
owned_by_user_id: user_id,
|
||||
source_kind: source_kind.to_string(),
|
||||
|
|
@ -720,6 +763,59 @@ fn record_media_entry(
|
|||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Creates a playlist/channel container entry with no blob and no primary-media artifact.
|
||||
/// Calls `complete_archive_run_item` on the provided item.
|
||||
fn record_container_entry(
|
||||
conn: &rusqlite::Connection,
|
||||
store_path: &Path,
|
||||
user_id: i64,
|
||||
run: &database::ArchiveRun,
|
||||
item: &database::ArchiveRunItem,
|
||||
requested_locator: &str,
|
||||
canonical_locator: &str,
|
||||
source: Source,
|
||||
title: Option<String>,
|
||||
playlist_id: &str,
|
||||
uploader: Option<&str>,
|
||||
) -> Result<database::ArchivedEntry> {
|
||||
let (source_kind, entity_kind, representation_kind) = source_metadata(source);
|
||||
let source_identity_id = database::upsert_source_identity(
|
||||
conn,
|
||||
source_kind,
|
||||
entity_kind,
|
||||
Some(playlist_id),
|
||||
Some(canonical_locator),
|
||||
canonical_locator,
|
||||
)?;
|
||||
let entry = database::create_archived_entry(
|
||||
conn,
|
||||
&database::NewEntry {
|
||||
source_identity_id,
|
||||
archive_run_id: run.id,
|
||||
parent_entry_id: None,
|
||||
root_entry_id: None,
|
||||
created_by_user_id: user_id,
|
||||
owned_by_user_id: user_id,
|
||||
source_kind: source_kind.to_string(),
|
||||
entity_kind: entity_kind.to_string(),
|
||||
title,
|
||||
visibility: "private".to_string(),
|
||||
representation_kind: representation_kind.to_string(),
|
||||
source_metadata_json: json!({
|
||||
"requested_locator": requested_locator,
|
||||
"canonical_locator": canonical_locator,
|
||||
"playlist_id": playlist_id,
|
||||
"uploader": uploader,
|
||||
})
|
||||
.to_string(),
|
||||
display_metadata_json: None,
|
||||
},
|
||||
)?;
|
||||
create_structured_root(store_path, &entry)?;
|
||||
database::complete_archive_run_item(conn, item.id, entry.id)?;
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Extracts PlatformMetadata from a tweet JSON string.
|
||||
/// Returns Default on any parse failure.
|
||||
fn tweet_metadata_from_json(json_str: &str) -> PlatformMetadata {
|
||||
|
|
@ -957,25 +1053,270 @@ pub fn perform_capture(
|
|||
));
|
||||
}
|
||||
|
||||
// Sources: Spotify — not downloadable; Spotify audio is DRM-protected.
|
||||
if matches!(source, Source::SpotifyTrack | Source::SpotifyAlbum | Source::SpotifyPlaylist) {
|
||||
return Err(fail_run(
|
||||
&conn,
|
||||
&run,
|
||||
&item,
|
||||
"Spotify downloads are not supported: Spotify audio is DRM-protected and cannot \
|
||||
be downloaded by yt-dlp. Archive the equivalent YouTube Music track instead.",
|
||||
));
|
||||
}
|
||||
// Sources: YouTube playlists, YouTube channels, YouTube Music playlists,
|
||||
// Spotify albums, Spotify playlists — probe via yt-dlp flat-playlist,
|
||||
// create a root container entry, then download each track as a child entry.
|
||||
// `run` and `item` are already created above; `item` acts as the container item.
|
||||
if matches!(
|
||||
source,
|
||||
Source::YouTubePlaylist
|
||||
| Source::YouTubeChannel
|
||||
| Source::YouTubeMusicPlaylist
|
||||
| Source::SpotifyAlbum
|
||||
| Source::SpotifyPlaylist
|
||||
) {
|
||||
// `canonical_url` already holds the expanded URL (same value as `path` later).
|
||||
let playlist_info = match downloader::ytdlp::fetch_playlist_info(&canonical_url, &cookies) {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
return Err(fail_run(
|
||||
&conn,
|
||||
&run,
|
||||
&item,
|
||||
&format!("Failed to fetch playlist info: {e:#}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Sources: YouTube Music Playlist — container expansion not yet implemented.
|
||||
if source == Source::YouTubeMusicPlaylist {
|
||||
return Err(fail_run(
|
||||
&conn,
|
||||
&run,
|
||||
&item,
|
||||
"YouTube Music playlist archiving is not yet implemented.",
|
||||
));
|
||||
let container_title = playlist_info
|
||||
.title
|
||||
.clone()
|
||||
.or_else(|| playlist_info.uploader.clone().map(|u| format!("{u} (channel)")));
|
||||
|
||||
// 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 = match archive::get_archived_playlist_child_urls(&conn, &canonical_url) {
|
||||
Ok(set) => set,
|
||||
Err(e) => return Err(fail_run(
|
||||
&conn, &run, &item,
|
||||
&format!("Failed to query archived playlist children: {e:#}"),
|
||||
)),
|
||||
};
|
||||
(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 = matches!(
|
||||
source,
|
||||
Source::YouTubeMusicPlaylist | Source::SpotifyAlbum | Source::SpotifyPlaylist
|
||||
);
|
||||
let child_quality: Option<&str> = if is_audio { Some("audio") } else { quality };
|
||||
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;
|
||||
}
|
||||
// Per-item exclusion: when per_item_quality is non-empty (frontend
|
||||
// probe ran and the user confirmed the item list), only download
|
||||
// items whose ID appears in the map. IDs absent from a non-empty
|
||||
// map were removed by the user via the delete button before submit.
|
||||
if !config.per_item_quality.is_empty()
|
||||
&& !config.per_item_quality.contains_key(&playlist_item.id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
let child_timestamp = format!(
|
||||
"{}-{}",
|
||||
Local::now().format("%Y-%m-%dT%H-%M-%S%.3f"),
|
||||
Uuid::new_v4().simple(),
|
||||
);
|
||||
|
||||
let child_item = match database::create_archive_run_item(
|
||||
&conn,
|
||||
run.id,
|
||||
Some(item.id),
|
||||
ordinal as i64,
|
||||
&playlist_item.url,
|
||||
Some(&playlist_item.url),
|
||||
source_kind,
|
||||
child_entity_kind,
|
||||
) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("warn: playlist item {} create_run_item failed: {e:#}", playlist_item.url);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch metadata for the child title (best-effort).
|
||||
let child_meta_json = downloader::ytdlp::fetch_metadata(&playlist_item.url, &cookies);
|
||||
let child_title: Option<String> = match &child_meta_json {
|
||||
Some(json) => {
|
||||
let meta = downloader::metadata::extract_from_ytdlp_json(json);
|
||||
Some(generate_entry_title(
|
||||
match source {
|
||||
Source::SpotifyAlbum | Source::SpotifyPlaylist => Source::SpotifyTrack,
|
||||
_ if is_audio => Source::YouTubeMusicTrack,
|
||||
_ => Source::YouTubeVideo,
|
||||
},
|
||||
&meta,
|
||||
))
|
||||
}
|
||||
None => playlist_item.title.clone(),
|
||||
};
|
||||
|
||||
// Per-item quality override keyed by yt-dlp video ID; fall back to
|
||||
// playlist-level quality. YTM playlists are always audio-only —
|
||||
// per_item_quality overrides are ignored so the audio-only guarantee
|
||||
// cannot be bypassed by a caller sending e.g. "best".
|
||||
let effective_child_quality: Option<&str> = if is_audio {
|
||||
Some("audio")
|
||||
} else {
|
||||
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,
|
||||
effective_child_quality,
|
||||
&cookies,
|
||||
) {
|
||||
Ok((hash, file_extension)) => {
|
||||
let temp_file = store_path
|
||||
.join("temp")
|
||||
.join(&child_timestamp)
|
||||
.join(format!("{child_timestamp}{file_extension}"));
|
||||
let byte_size = match fs::metadata(&temp_file) {
|
||||
Ok(m) => m.len() as i64,
|
||||
Err(e) => {
|
||||
eprintln!("warn: stat child temp file: {e:#}");
|
||||
let _ = database::fail_archive_run_item(
|
||||
&conn, child_item.id,
|
||||
&format!("failed to stat downloaded file: {e:#}"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !hash_exists(&hash, &file_extension, store_path).unwrap_or(false) {
|
||||
if let Err(e) = move_temp_to_raw(&temp_file, &hash, store_path) {
|
||||
eprintln!("warn: move_temp_to_raw child: {e:#}");
|
||||
let _ = database::fail_archive_run_item(
|
||||
&conn, child_item.id,
|
||||
&format!("failed to move downloaded file: {e:#}"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_dir_all(store_path.join("temp").join(&child_timestamp));
|
||||
|
||||
let child_source = match source {
|
||||
Source::SpotifyAlbum | Source::SpotifyPlaylist => Source::SpotifyTrack,
|
||||
_ if is_audio => Source::YouTubeMusicTrack,
|
||||
_ => Source::YouTubeVideo,
|
||||
};
|
||||
match record_media_entry(
|
||||
&conn,
|
||||
store_path,
|
||||
user_id,
|
||||
&run,
|
||||
&child_item,
|
||||
&playlist_item.url,
|
||||
&playlist_item.url,
|
||||
child_source,
|
||||
&hash,
|
||||
&file_extension,
|
||||
byte_size,
|
||||
child_title,
|
||||
Some(container_id),
|
||||
Some(container_id),
|
||||
) {
|
||||
Ok(child_entry) => {
|
||||
let _ = database::refresh_entry_cached_bytes(&conn, child_entry.id);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("warn: record child entry: {e:#}");
|
||||
let _ = database::fail_archive_run_item(
|
||||
&conn, child_item.id,
|
||||
&format!("failed to record entry: {e:#}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = fs::remove_dir_all(store_path.join("temp").join(&child_timestamp));
|
||||
eprintln!("warn: yt-dlp child download failed for {}: {e:#}", playlist_item.url);
|
||||
let _ = database::fail_archive_run_item(
|
||||
&conn, child_item.id,
|
||||
&format!("yt-dlp download failed: {e:#}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
database::refresh_entry_cached_bytes(&conn, container_id)?;
|
||||
database::finish_archive_run(&conn, run.id)?;
|
||||
|
||||
let run_status: String = conn
|
||||
.query_row(
|
||||
"SELECT status FROM archive_runs WHERE id = ?1",
|
||||
[run.id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or_else(|_| "completed".to_string());
|
||||
|
||||
return Ok(CaptureResult {
|
||||
run_uid: run.run_uid.clone(),
|
||||
status: run_status,
|
||||
ublock_skipped: false,
|
||||
cookie_ext_skipped: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Source: generic HTTP/S file URL
|
||||
|
|
@ -1018,6 +1359,8 @@ pub fn perform_capture(
|
|||
&file_extension,
|
||||
byte_size,
|
||||
title_hint,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
database::refresh_entry_cached_bytes(&conn, entry.id)?;
|
||||
database::finish_archive_run(&conn, run.id)?;
|
||||
|
|
@ -1144,8 +1487,10 @@ pub fn perform_capture(
|
|||
&html_hash,
|
||||
&file_extension,
|
||||
byte_size,
|
||||
entry_title,
|
||||
)?;
|
||||
entry_title,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
|
||||
// 5. Add favicon artifact if we captured one.
|
||||
if let Some((fav_relpath, fav_blob_id)) = favicon_info {
|
||||
|
|
@ -1275,6 +1620,7 @@ pub fn perform_capture(
|
|||
let ytdlp_metadata_json: Option<String> = match source {
|
||||
Source::YouTubeVideo
|
||||
| Source::YouTubeMusicTrack
|
||||
| Source::SpotifyTrack
|
||||
| Source::X
|
||||
| Source::Instagram
|
||||
| Source::Facebook
|
||||
|
|
@ -1315,7 +1661,7 @@ pub fn perform_capture(
|
|||
}
|
||||
}
|
||||
}
|
||||
Source::YouTubeMusicTrack => {
|
||||
Source::YouTubeMusicTrack | Source::SpotifyTrack => {
|
||||
// Music tracks are always audio-only regardless of the caller's quality hint.
|
||||
match downloader::ytdlp::download(path.clone(), store_path, ×tamp, Some("audio"), &cookies) {
|
||||
Ok(result) => result,
|
||||
|
|
@ -1342,14 +1688,7 @@ pub fn perform_capture(
|
|||
}
|
||||
}
|
||||
}
|
||||
Source::YouTubePlaylist | Source::YouTubeChannel => {
|
||||
return Err(fail_run(
|
||||
&conn,
|
||||
&run,
|
||||
&item,
|
||||
"Playlist and channel container expansion are not yet implemented.",
|
||||
));
|
||||
}
|
||||
Source::YouTubePlaylist | Source::YouTubeChannel => unreachable!(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let temp_file = store_path
|
||||
|
|
@ -1402,6 +1741,8 @@ pub fn perform_capture(
|
|||
&file_extension,
|
||||
byte_size,
|
||||
entry_title,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
database::refresh_entry_cached_bytes(&conn, media_entry.id)?;
|
||||
database::finish_archive_run(&conn, run.id)?;
|
||||
|
|
@ -1790,6 +2131,15 @@ mod tests {
|
|||
url: "youtube:@CoreDumpped",
|
||||
expected: Source::YouTubeChannel,
|
||||
},
|
||||
// Bare video ID — exactly 11 chars [A-Za-z0-9_-]
|
||||
TestCase { url: "yt:dQw4w9WgXcQ", expected: Source::YouTubeVideo },
|
||||
TestCase { url: "youtube:dQw4w9WgXcQ", expected: Source::YouTubeVideo },
|
||||
TestCase { url: "yt:a_b-c_d-e_4", expected: Source::YouTubeVideo },
|
||||
// Non-ID: wrong length (9 chars) → Other
|
||||
TestCase { url: "yt:not-video", expected: Source::Other },
|
||||
// Reserved prefixes still route correctly when segment looks like an ID
|
||||
TestCase { url: "yt:playlist/dQw4w9WgXcQ", expected: Source::YouTubePlaylist },
|
||||
TestCase { url: "yt:@dQw4w9WgXcQ", expected: Source::YouTubeChannel },
|
||||
];
|
||||
|
||||
for case in &shorthand_cases {
|
||||
|
|
@ -1802,6 +2152,18 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_youtube_video_id() {
|
||||
assert!(is_youtube_video_id("dQw4w9WgXcQ"), "canonical ID");
|
||||
assert!(is_youtube_video_id("a_b-c_d-e_4"), "11-char ID with _ and -");
|
||||
assert!(!is_youtube_video_id(""), "empty");
|
||||
assert!(!is_youtube_video_id("short"), "too short");
|
||||
assert!(!is_youtube_video_id("toolong12345"), "too long (12 chars)");
|
||||
assert!(!is_youtube_video_id("dQw4w9WgXc!"), "invalid char (! at pos 11)");
|
||||
assert!(!is_youtube_video_id("not-video"), "9 chars — too short");
|
||||
assert!(!is_youtube_video_id("dQw4w9WgXC!!"), "12 chars + bad char");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_youtube_music_sources() {
|
||||
// --- determine_source ---
|
||||
|
|
@ -2326,4 +2688,41 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locator_to_playlist_url_accepts_playlist_shorthands() {
|
||||
// yt: playlist shorthand
|
||||
assert_eq!(
|
||||
locator_to_playlist_url("yt:playlist/PLtest123"),
|
||||
Some("https://www.youtube.com/playlist?list=PLtest123".to_string()),
|
||||
);
|
||||
// YouTube Music playlist shorthand
|
||||
assert_eq!(
|
||||
locator_to_playlist_url("ytm:playlist/PLmus456"),
|
||||
Some("https://music.youtube.com/playlist?list=PLmus456".to_string()),
|
||||
);
|
||||
// Full YT playlist URL passes through (expand_shorthand_to_url is identity for full URLs)
|
||||
let full = "https://www.youtube.com/playlist?list=PLabc";
|
||||
assert_eq!(locator_to_playlist_url(full), Some(full.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locator_to_playlist_url_accepts_channel_shorthands() {
|
||||
let url = locator_to_playlist_url("yt:@MyChan");
|
||||
assert!(url.is_some(), "channel @ shorthand should return Some");
|
||||
let u = url.unwrap();
|
||||
assert!(u.contains("youtube.com"), "should be a youtube URL: {u}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locator_to_playlist_url_rejects_non_playlist_sources() {
|
||||
// Single video
|
||||
assert_eq!(locator_to_playlist_url("yt:dQw4w9WgXcQ"), None);
|
||||
// Tweet
|
||||
assert_eq!(locator_to_playlist_url("tweet:1234567890"), None);
|
||||
// YTM track
|
||||
assert_eq!(locator_to_playlist_url("ytm:MntbN1DdEP0"), None);
|
||||
// Plain web URL
|
||||
assert_eq!(locator_to_playlist_url("https://example.com/page"), None);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -390,6 +390,7 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> {
|
|||
JOIN blobs b ON b.id = ea.blob_id
|
||||
WHERE ea.entry_id = archived_entries.id
|
||||
AND ea.blob_id IS NOT NULL
|
||||
AND ea.artifact_role != 'avatar'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM entry_artifacts ea2
|
||||
|
|
@ -401,6 +402,33 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> {
|
|||
)
|
||||
);",
|
||||
)?;
|
||||
} else {
|
||||
// Re-migration: strip avatar blobs from cached_bytes on entries that
|
||||
// already had the column populated before this filter was introduced.
|
||||
// Scoped to entries with avatar artifacts only; fast and idempotent.
|
||||
conn.execute_batch(
|
||||
"UPDATE archived_entries
|
||||
SET cached_bytes = (
|
||||
SELECT COALESCE(SUM(b.byte_size), 0)
|
||||
FROM entry_artifacts ea
|
||||
JOIN blobs b ON b.id = ea.blob_id
|
||||
WHERE ea.entry_id = archived_entries.id
|
||||
AND ea.blob_id IS NOT NULL
|
||||
AND ea.artifact_role != 'avatar'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM entry_artifacts ea2
|
||||
JOIN archived_entries e2 ON e2.id = ea2.entry_id
|
||||
WHERE ea2.blob_id = ea.blob_id
|
||||
AND (e2.archived_at < archived_entries.archived_at
|
||||
OR (e2.archived_at = archived_entries.archived_at
|
||||
AND e2.id < archived_entries.id))
|
||||
)
|
||||
)
|
||||
WHERE id IN (
|
||||
SELECT DISTINCT entry_id FROM entry_artifacts WHERE artifact_role = 'avatar'
|
||||
);",
|
||||
)?;
|
||||
}
|
||||
|
||||
// Migration: add notes_json column to existing capture_jobs tables.
|
||||
|
|
@ -1482,6 +1510,7 @@ pub fn refresh_entry_cached_bytes(conn: &Connection, entry_id: i64) -> Result<()
|
|||
JOIN archived_entries e ON e.id = ea.entry_id
|
||||
WHERE ea.entry_id = ?1
|
||||
AND ea.blob_id IS NOT NULL
|
||||
AND ea.artifact_role != 'avatar'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM entry_artifacts ea2
|
||||
|
|
@ -1518,6 +1547,7 @@ pub fn cascade_cached_bytes_after_delete(conn: &Connection, entry_id: i64) -> Re
|
|||
JOIN blobs b ON b.id = ea.blob_id
|
||||
WHERE ea.entry_id = archived_entries.id
|
||||
AND ea.blob_id IS NOT NULL
|
||||
AND ea.artifact_role != 'avatar'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM entry_artifacts ea3
|
||||
|
|
@ -1570,6 +1600,7 @@ fn cascade_cached_bytes_after_subtree_delete(conn: &Connection, subtree_ids: &[i
|
|||
JOIN blobs b ON b.id = ea.blob_id
|
||||
WHERE ea.entry_id = archived_entries.id
|
||||
AND ea.blob_id IS NOT NULL
|
||||
AND ea.artifact_role != 'avatar'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM entry_artifacts ea3
|
||||
|
|
@ -1627,9 +1658,14 @@ pub fn delete_entry(conn: &Connection, entry_uid: &str) -> Result<bool> {
|
|||
None => return Ok(false),
|
||||
};
|
||||
|
||||
// Collect the full subtree while rows still exist.
|
||||
// Collect the full subtree (entry itself + any descendants) while rows still exist.
|
||||
// Must include the entry itself: for a child entry root_entry_id = ?1 returns nothing
|
||||
// (no grandchildren), so without `id = ?1` the set would be empty and
|
||||
// cascade_cached_bytes_after_subtree_delete would not recalculate shared-blob totals.
|
||||
let subtree_ids: Vec<i64> = {
|
||||
let mut stmt = conn.prepare("SELECT id FROM archived_entries WHERE root_entry_id = ?1")?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id FROM archived_entries WHERE id = ?1 OR root_entry_id = ?1",
|
||||
)?;
|
||||
stmt.query_map([entry_id], |row| row.get(0))?
|
||||
.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
|
|
@ -1638,12 +1674,15 @@ pub fn delete_entry(conn: &Connection, entry_uid: &str) -> Result<bool> {
|
|||
// shared blobs with any subtree member, excluding every subtree ID simultaneously.
|
||||
cascade_cached_bytes_after_subtree_delete(conn, &subtree_ids)?;
|
||||
|
||||
// Null the FK that has no ON DELETE action (covers root and all descendants).
|
||||
// Null the FK that has no ON DELETE action. Must cover:
|
||||
// - The entry itself (child entry: root_entry_id = playlist root, not self)
|
||||
// - All descendants (root entry: children have root_entry_id = entry_id)
|
||||
conn.execute(
|
||||
"UPDATE archive_run_items SET produced_entry_id = NULL
|
||||
WHERE produced_entry_id IN (
|
||||
SELECT id FROM archived_entries WHERE root_entry_id = ?1
|
||||
)",
|
||||
WHERE produced_entry_id = ?1
|
||||
OR produced_entry_id IN (
|
||||
SELECT id FROM archived_entries WHERE root_entry_id = ?1
|
||||
)",
|
||||
[entry_id],
|
||||
)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,10 +6,50 @@ use std::{
|
|||
process::Command,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
use serde_json;
|
||||
|
||||
use crate::downloader::cookies::{domain_from_url, write_netscape_cookie_file};
|
||||
use crate::hash::hash_file;
|
||||
|
||||
/// A single item in a flat playlist listing from `fetch_playlist_info`.
|
||||
#[derive(Debug)]
|
||||
pub struct PlaylistItem {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub title: Option<String>,
|
||||
pub uploader: Option<String>,
|
||||
}
|
||||
|
||||
/// Container metadata returned by `fetch_playlist_info`.
|
||||
#[derive(Debug)]
|
||||
pub struct PlaylistInfo {
|
||||
pub playlist_id: String,
|
||||
pub title: Option<String>,
|
||||
pub uploader: Option<String>,
|
||||
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
|
||||
|
|
@ -74,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 {
|
||||
|
|
@ -241,6 +298,207 @@ pub fn fetch_metadata(path: &str, cookies: &HashMap<String, String>) -> Option<S
|
|||
if json.trim().is_empty() { None } else { Some(json) }
|
||||
}
|
||||
|
||||
/// Resolves an absolute item URL from a flat-playlist entry JSON object.
|
||||
///
|
||||
/// Priority:
|
||||
/// 1. `webpage_url` — yt-dlp makes this absolute when present.
|
||||
/// 2. `url` when it is already an absolute HTTP(S) URL.
|
||||
/// 3. Platform-specific fallback constructed from `id` + `container_url`:
|
||||
/// - YouTube Music → `https://music.youtube.com/watch?v={id}`
|
||||
/// - YouTube → `https://www.youtube.com/watch?v={id}`
|
||||
/// - Spotify → `https://open.spotify.com/track/{id}`
|
||||
/// - Other → `None` (caller should skip the item and warn).
|
||||
fn normalize_item_url(
|
||||
entry: &serde_json::Value,
|
||||
id: &str,
|
||||
container_url: &str,
|
||||
) -> Option<String> {
|
||||
let is_abs = |s: &str| s.starts_with("http://") || s.starts_with("https://");
|
||||
if let Some(u) = entry.get("webpage_url").and_then(|v| v.as_str()).filter(|s| is_abs(s)) {
|
||||
return Some(u.to_owned());
|
||||
}
|
||||
if let Some(u) = entry.get("url").and_then(|v| v.as_str()).filter(|s| is_abs(s)) {
|
||||
return Some(u.to_owned());
|
||||
}
|
||||
// Bare-ID fallback keyed on the container's platform.
|
||||
if container_url.contains("music.youtube.com") {
|
||||
Some(format!("https://music.youtube.com/watch?v={id}"))
|
||||
} else if container_url.contains("youtube.com") || container_url.contains("youtu.be") {
|
||||
Some(format!("https://www.youtube.com/watch?v={id}"))
|
||||
} else if container_url.contains("open.spotify.com") {
|
||||
Some(format!("https://open.spotify.com/track/{id}"))
|
||||
} else {
|
||||
eprintln!("warn: skipping playlist item {id:?} — no absolute URL from yt-dlp");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `yt-dlp -J --flat-playlist <url>` and parses the single-JSON result.
|
||||
///
|
||||
/// `-J` / `--dump-single-json` returns one JSON object for the whole
|
||||
/// container with reliable top-level `title` / `uploader` fields plus an
|
||||
/// `entries` array of shallow per-item objects.
|
||||
///
|
||||
/// Returns an error if yt-dlp fails, the output is not valid JSON, or
|
||||
/// the root `_type` is not `"playlist"`.
|
||||
pub fn fetch_playlist_info(url: &str, cookies: &HashMap<String, String>) -> Result<PlaylistInfo> {
|
||||
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").arg("--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 --flat-playlist 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);
|
||||
|
||||
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; // unavailable/private item in flat listing
|
||||
}
|
||||
let id = match entry.get("id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_owned(),
|
||||
None => continue,
|
||||
};
|
||||
let item_url = match normalize_item_url(entry, &id, url) {
|
||||
Some(u) => u,
|
||||
None => continue,
|
||||
};
|
||||
let item_title = entry.get("title").and_then(|v| v.as_str()).map(str::to_owned);
|
||||
let item_uploader = entry.get("uploader").and_then(|v| v.as_str()).map(str::to_owned);
|
||||
items.push(PlaylistItem { id, url: item_url, title: item_title, uploader: item_uploader });
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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 = match normalize_item_url(entry, &id, url) {
|
||||
Some(u) => u,
|
||||
None => continue,
|
||||
};
|
||||
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};
|
||||
|
|
|
|||
|
|
@ -241,6 +241,10 @@ pub fn app_with_state(state: AppState) -> Router {
|
|||
.patch(patch_entry_handler)
|
||||
.delete(delete_entry_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/archives/:archive_id/entries/:entry_uid/children",
|
||||
get(list_entry_children),
|
||||
)
|
||||
.route(
|
||||
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index",
|
||||
get(serve_artifact),
|
||||
|
|
@ -260,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),
|
||||
|
|
@ -404,6 +412,18 @@ async fn list_entries(
|
|||
Ok(Json(archive::list_root_entries(&conn, caller_bits)?))
|
||||
}
|
||||
|
||||
async fn list_entry_children(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path((archive_id, entry_uid)): Path<(String, String)>,
|
||||
) -> Result<Json<Vec<archive::EntrySummary>>, ApiError> {
|
||||
auth.require_auth()?;
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
let caller_bits = auth_to_caller_bits(&auth);
|
||||
Ok(Json(archive::list_child_entries(&conn, &entry_uid, caller_bits)?))
|
||||
}
|
||||
|
||||
async fn search_entries_handler(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
|
|
@ -749,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)]
|
||||
|
|
@ -756,6 +783,11 @@ struct ProbeQuery {
|
|||
locator: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct ProbePlaylistBody {
|
||||
locator: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct LoginBody {
|
||||
username: String,
|
||||
|
|
@ -826,6 +858,28 @@ 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\""
|
||||
)));
|
||||
}
|
||||
}
|
||||
// per_item_quality semantics (enforced in capture.rs):
|
||||
// - Absent or empty map: all playlist items are downloaded; quality is the
|
||||
// global `quality` field applied as a yt-dlp cap with graceful fallback.
|
||||
// - Non-empty map: ONLY items whose yt-dlp ID appears as a key are downloaded;
|
||||
// absent IDs are skipped. This is how the frontend's delete-item button works.
|
||||
// The "must choose quality for unsupported videos" invariant is enforced by the
|
||||
// frontend before submission; a direct API caller bypassing the UI accepts
|
||||
// yt-dlp's standard cap-and-fallback behavior for items it includes.
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let archive_paths =
|
||||
archive::read_archive_paths(&mounted.archive_path).map_err(ApiError::from)?;
|
||||
|
|
@ -863,6 +917,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: body.per_item_quality.clone(),
|
||||
sync: body.sync,
|
||||
};
|
||||
|
||||
// Spawn background capture.
|
||||
|
|
@ -902,10 +958,11 @@ async fn capture_handler(
|
|||
notes_str = serde_json::Value::Object(notes_map).to_string();
|
||||
Some(¬es_str)
|
||||
};
|
||||
let job_status = if result.status == "completed" { "completed" } else { "failed" };
|
||||
database::update_capture_job_status(
|
||||
&conn,
|
||||
&job_uid_bg,
|
||||
"completed",
|
||||
job_status,
|
||||
Some(&result.run_uid),
|
||||
None,
|
||||
notes,
|
||||
|
|
@ -981,6 +1038,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();
|
||||
|
|
@ -1109,6 +1168,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
1
crates/archivr-server/static/assets/index-D8ic-z4p.css
Normal file
1
crates/archivr-server/static/assets/index-D8ic-z4p.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
47
crates/archivr-server/static/assets/index-De3b80Fv.js
Normal file
47
crates/archivr-server/static/assets/index-De3b80Fv.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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-YmIQCrug.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DLdY9nrw.css">
|
||||
<script type="module" crossorigin src="/assets/index-De3b80Fv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D8ic-z4p.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ Auth and session handling will be designed when remote or public hosting becomes
|
|||
### Supported Platforms
|
||||
|
||||
- Local files: `file:///absolute/path/to/file.ext`
|
||||
- YouTube media: standard video/short URLs, plus [shorthand video inputs](#supported-shorthand-inputs)
|
||||
- YouTube media: individual videos/shorts, playlists, and channels; standard URLs or [shorthand video inputs](#supported-shorthand-inputs). Playlists and channels archive as a container entry with each video stored as a child entry beneath it.
|
||||
- X/Twitter media from Tweets: normal Tweet URLs or the `tweet:media:ID` shorthand
|
||||
- X/Twitter Tweet content scrape: [Tweet and Thread shorthands](#supported-shorthand-inputs). (These are saved as JSON files in `raw_tweets/`)
|
||||
- Instagram, Facebook, TikTok, Reddit, Snapchat: direct URLs or platform-prefixed shorthand passed through to `yt-dlp`
|
||||
|
|
@ -175,6 +175,14 @@ The probe endpoint (`GET /api/archives/:id/captures/probe?locator=…`) requires
|
|||
```
|
||||
`has_video: false, has_audio: false` means yt-dlp found no downloadable tracks (e.g. a tweet with no media). A 502 means yt-dlp itself failed (transient network error, rate-limit, unsupported extractor) — treat as inconclusive, not "no media."
|
||||
|
||||
#### YouTube playlists and channels
|
||||
|
||||
Capturing a YouTube playlist or channel URL creates a **container entry** for the playlist or channel, with each video archived as a child entry beneath it. Before downloading, the capture UI probes each video to fetch available quality options, letting you set quality per-video or apply a single quality to the whole playlist.
|
||||
|
||||
**Incremental sync:** When re-archiving a playlist or channel, enable **sync mode** in the capture dialog to skip videos that are already in the archive. Only new videos are downloaded; the existing container entry is reused.
|
||||
|
||||
**Excluding individual videos:** In the expanded per-video list, each video has a remove button (×) to exclude it from the current capture. Removed videos are not downloaded; the rest proceed normally.
|
||||
|
||||
### Hosting on NixOS
|
||||
|
||||
The flake exposes a `nixosModules.default` output. Add it to your system flake and
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,403 +0,0 @@
|
|||
# Auth Foundation Design
|
||||
|
||||
**Track:** 4 of the roadmap (inserted after Track 3: Async capture jobs)
|
||||
**Date:** 2026-06-25
|
||||
**Status:** Approved for implementation
|
||||
|
||||
---
|
||||
|
||||
## Context & Roadmap Position
|
||||
|
||||
Archivr is evolving from a local-only tool (single hard-coded user, 127.0.0.1 binding) into a
|
||||
self-hosted multi-user platform — think ArchiveBox but with real accounts, roles, and
|
||||
public/private visibility. This track lays the foundation. All subsequent tracks depend on it.
|
||||
|
||||
**Full decomposition:**
|
||||
|
||||
| Track | Scope | Depends on |
|
||||
|---|---|---|
|
||||
| 4 (this) | Auth foundation | — |
|
||||
| 5 | User management — registration, custom roles, admin panel | Track 4 |
|
||||
| 6 | Permissions & visibility — collection model, per-membership visibility | Track 5 |
|
||||
| 7 | Settings — account profile, instance-wide toggles | Track 5 |
|
||||
| 8 | Collections UI | Tracks 5–6 |
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- Password-protected login with cookie sessions and API tokens
|
||||
- Role table with bitmask-based visibility (extensible to custom roles in Track 5)
|
||||
- Auth middleware that protects write/admin routes
|
||||
- First-run owner setup wizard
|
||||
- Frontend login page and session-aware API calls
|
||||
|
||||
## Non-Goals (explicitly deferred)
|
||||
|
||||
- Custom role creation UI → Track 5
|
||||
- User registration flow → Track 5
|
||||
- Visibility enforcement on queries → Track 6
|
||||
- Collection model (replacing `archived_entries.visibility`) → Track 6
|
||||
- Account settings page → Track 7
|
||||
- API token management UI → Track 7
|
||||
|
||||
---
|
||||
|
||||
## Schema
|
||||
|
||||
### New table: `roles`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
role_uid TEXT NOT NULL UNIQUE,
|
||||
slug TEXT NOT NULL UNIQUE, -- 'guest', 'user', 'admin', 'owner', or custom
|
||||
name TEXT NOT NULL,
|
||||
level INTEGER NOT NULL, -- ordering: guest=0, user=1, admin=3, owner=4
|
||||
bit_position INTEGER NOT NULL UNIQUE, -- position in visibility bitmask
|
||||
is_builtin INTEGER NOT NULL DEFAULT 0 CHECK (is_builtin IN (0, 1))
|
||||
);
|
||||
```
|
||||
|
||||
**Built-in rows seeded at schema init:**
|
||||
|
||||
| slug | level | bit_position | bit value | is_builtin |
|
||||
|---|---|---|---|---|
|
||||
| guest | 0 | 0 | 1 | 1 |
|
||||
| user | 1 | 1 | 2 | 1 |
|
||||
| admin | 3 | 2 | 4 | 1 |
|
||||
| owner | 4 | 3 | 8 | 1 |
|
||||
|
||||
Bit position 2 (value 4) is reserved for `admin`. Bit positions 4+ (values 16, 32, …) are assigned
|
||||
to custom roles in Track 5. Level 2 is reserved for custom roles sitting between `user` and `admin`.
|
||||
|
||||
**role_bits computation — implicit guest floor:**
|
||||
|
||||
`role_bits` for any **authenticated** user is computed as:
|
||||
```
|
||||
role_bits = ROLE_GUEST | (OR of bit values for all rows in user_roles)
|
||||
```
|
||||
The `ROLE_GUEST` bit (1) is always included for authenticated users so they can access
|
||||
public (guest-visible) content. Example: an owner assigned only the `owner` role gets
|
||||
`role_bits = 1 | 8 = 9`, which passes `role_bits & ROLE_USER (2) = 0` — still broken.
|
||||
|
||||
**Therefore, role assignment is cumulative by level.** When a role is assigned, all
|
||||
built-in roles at lower levels are also assigned:
|
||||
- Assigning `owner` (level 4) → also assign `admin`, `user` in `user_roles`
|
||||
- Assigning `admin` (level 3) → also assign `user` in `user_roles`
|
||||
- Assigning `user` (level 1) → no additional rows
|
||||
- `guest` is never assigned; it is the implicit unauthenticated floor
|
||||
|
||||
Setup creates owner with three `user_roles` rows: `user`, `admin`, `owner`.
|
||||
Resulting `role_bits = ROLE_GUEST | ROLE_USER | ROLE_ADMIN | ROLE_OWNER = 1|2|4|8 = 15`.
|
||||
|
||||
**Visibility check:** `viewer.role_bits & content.visibility != 0` passes if the viewer
|
||||
has any bit the content requires. Owner (15) can see everything. User (1|2=3) can see
|
||||
guest-visible (1) and user-visible (2) content but not admin-only (4). ✓
|
||||
|
||||
`is_builtin = 1` rows cannot be deleted.
|
||||
|
||||
### New table: `sessions`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
session_uid TEXT NOT NULL UNIQUE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role_bits INTEGER NOT NULL, -- snapshot of bitmask at login time; role changes take effect on next login
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL, -- 30 days from last_seen_at
|
||||
user_agent TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
```
|
||||
|
||||
### New table: `api_tokens`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id INTEGER PRIMARY KEY,
|
||||
token_uid TEXT NOT NULL UNIQUE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE, -- SHA-256 of raw token; raw token never stored
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
expires_at TEXT -- NULL = never expires
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
|
||||
```
|
||||
|
||||
### `users` table — existing, minimally changed
|
||||
|
||||
The existing `role TEXT NOT NULL CHECK (role IN ('admin','user'))` column is **kept but inert** —
|
||||
auth middleware reads from `user_roles`, not this column. It will be removed in Track 5 cleanup.
|
||||
`ensure_owner_exists` must supply a value for this column; use `'admin'` as the placeholder.
|
||||
|
||||
`ensure_default_user` is replaced by `ensure_owner_exists` which returns `false` if no owner
|
||||
row exists in `user_roles` (triggers setup mode). The old local-admin stub is never created on
|
||||
fresh instances. Session lookup JOINs `users` and checks `users.status = 'active'`; a session
|
||||
belonging to a disabled user resolves to `AuthUser::Guest`.
|
||||
|
||||
### `instance_settings` — one new column
|
||||
|
||||
The column is added **inside** the existing `CREATE TABLE IF NOT EXISTS instance_settings` DDL,
|
||||
not via `ALTER TABLE` (which is not idempotent in `initialize_schema`):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS instance_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
public_index_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_index_enabled IN (0, 1)),
|
||||
public_entry_content_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_entry_content_enabled IN (0, 1)),
|
||||
public_archive_submission_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_archive_submission_enabled IN (0, 1)),
|
||||
default_entry_visibility INTEGER NOT NULL DEFAULT 2 -- 2 = user-visible by default
|
||||
);
|
||||
```
|
||||
|
||||
The existing `INSERT OR IGNORE INTO instance_settings … VALUES (1, 0, 0, 0)` seed row must be
|
||||
updated to include the new column: `VALUES (1, 0, 0, 0, 2)`.
|
||||
|
||||
### `archived_entries.visibility` — deprecated, not removed
|
||||
|
||||
Flagged with a `-- DEPRECATED: replaced by collection_entries.visibility in Track 6` comment in
|
||||
`initialize_schema`. No data migration needed yet; Track 6 handles it.
|
||||
|
||||
---
|
||||
|
||||
## Auth Flow
|
||||
|
||||
### Login
|
||||
|
||||
```
|
||||
POST /api/auth/login
|
||||
Body: { username: string, password: string }
|
||||
```
|
||||
|
||||
1. Look up user by username.
|
||||
2. Verify password with Argon2id (`argon2` crate).
|
||||
3. Compute `role_bits = ROLE_GUEST | (OR of bit values for all user_roles rows)` (cumulative; see Schema § role_bits computation).
|
||||
4. Insert `sessions` row (`session_uid` = UUID, `expires_at` = now + 30 days).
|
||||
5. Set-Cookie: `session=<session_uid>; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000`.
|
||||
Add `Secure` flag when the request arrived over HTTPS (detected via `X-Forwarded-Proto: https`
|
||||
header or TLS connection info). Omit `Secure` for plain HTTP to support local dev without TLS.
|
||||
6. Return `200 { user_uid, username, role_bits }`.
|
||||
|
||||
On failure: `401 { error: "invalid_credentials" }` — same message for unknown user and wrong
|
||||
password (no user enumeration).
|
||||
|
||||
### Logout
|
||||
|
||||
```
|
||||
POST /api/auth/logout
|
||||
```
|
||||
|
||||
Deletes the `sessions` row for the current session cookie. Responds with
|
||||
`Set-Cookie: session=; Max-Age=0` to clear the browser cookie. Returns `204`.
|
||||
|
||||
### Current user
|
||||
|
||||
```
|
||||
GET /api/auth/me
|
||||
```
|
||||
|
||||
Returns `200 { user_uid, username, role_bits }` for an authenticated request, or `401` for a
|
||||
guest. The frontend calls this once on mount to restore session state.
|
||||
|
||||
### First-run setup
|
||||
|
||||
```
|
||||
GET /api/auth/setup → 200 { setup_required: bool }
|
||||
POST /api/auth/setup → 201 { user_uid, username }
|
||||
Body: { username: string, password: string }
|
||||
```
|
||||
|
||||
`setup_required` is `true` when no user has the `owner` role in `user_roles`. On `POST`:
|
||||
- If setup is **already complete** (an owner exists): return `409 { error: "already_configured" }`.
|
||||
- Otherwise: create the user (with `users.role = 'admin'` as placeholder), assign `user_roles`
|
||||
rows for `user`, `admin`, `owner` (cumulative), seed `instance_settings` row if absent.
|
||||
Return `201 { user_uid, username }`. Normal login flow applies immediately after.
|
||||
|
||||
All non-setup API routes return `503 { error: "setup_required" }` until setup is complete.
|
||||
The following routes are **exempt** from the 503 check: `GET /api/auth/setup`,
|
||||
`POST /api/auth/setup`, `GET /` (static), `GET /assets/*` (static).
|
||||
|
||||
### API tokens
|
||||
|
||||
```
|
||||
POST /api/auth/tokens → 201 { token_uid, raw_token, name, created_at }
|
||||
GET /api/auth/tokens → 200 [{ token_uid, name, created_at, last_used_at }]
|
||||
DELETE /api/auth/tokens/:token_uid → 204
|
||||
```
|
||||
|
||||
`raw_token` is a cryptographically random 32-byte value, base64url-encoded, returned once. The
|
||||
server stores only its SHA-256 hash. The management UI for these endpoints is in Track 7; the
|
||||
endpoints are implemented here.
|
||||
|
||||
### Password hashing
|
||||
|
||||
Argon2id with default parameters from the `argon2` crate (memory=19 MiB, iterations=2,
|
||||
parallelism=1). The current `"disabled-local-password"` sentinel in `ensure_default_user` becomes
|
||||
irrelevant once setup is required on fresh instances.
|
||||
|
||||
### Session expiry & cleanup
|
||||
|
||||
`last_seen_at` is updated on every authenticated request using a **conditional update**: the
|
||||
session row is already read during extraction; if `now() - last_seen_at > 60s`, issue an UPDATE.
|
||||
This adds no extra query — only an extra UPDATE when the threshold is crossed.
|
||||
`expires_at` = `last_seen_at + 30 days`, recalculated on each UPDATE. A background task in
|
||||
`archivr-server/src/main.rs` runs `DELETE FROM sessions WHERE expires_at < now()` at startup
|
||||
and every 24 hours via `tokio::time::interval`.
|
||||
|
||||
---
|
||||
|
||||
## Auth Extractor
|
||||
|
||||
New file: `crates/archivr-server/src/auth.rs`
|
||||
|
||||
```rust
|
||||
pub enum AuthUser {
|
||||
Guest,
|
||||
Authenticated { user_id: i64, role_bits: u32 },
|
||||
}
|
||||
|
||||
impl AuthUser {
|
||||
pub fn require_auth(&self) -> Result<(i64, u32), ApiError> // 401 if Guest
|
||||
pub fn require_role(&self, bit: u32) -> Result<(), ApiError> // 403 if bit not set
|
||||
pub fn has_role(&self, bit: u32) -> bool
|
||||
}
|
||||
|
||||
// Role bit constants
|
||||
pub const ROLE_GUEST: u32 = 1;
|
||||
pub const ROLE_USER: u32 = 2;
|
||||
pub const ROLE_ADMIN: u32 = 4;
|
||||
pub const ROLE_OWNER: u32 = 8;
|
||||
```
|
||||
|
||||
Implemented as an Axum `FromRequestParts` extractor. Tries `session` cookie first, then
|
||||
`Authorization: Bearer` header.
|
||||
- **Cookie path**: look up `sessions` row JOIN `users` WHERE `session_uid = ?`
|
||||
AND `users.status = 'active'` AND `expires_at > now()`. Use cached `role_bits` from the
|
||||
session row.
|
||||
- **Bearer path**: SHA-256 the token, look up `api_tokens` row JOIN `users` WHERE
|
||||
`token_hash = ?` AND `users.status = 'active'` AND (`expires_at IS NULL OR expires_at > now()`).
|
||||
Compute `role_bits` live: `ROLE_GUEST | (OR of user_roles bit values for that user)`.
|
||||
Update `api_tokens.last_used_at`.
|
||||
- Missing or invalid credential → `AuthUser::Guest` (never a hard error at extraction time).
|
||||
|
||||
---
|
||||
|
||||
## Route Protection Tiers
|
||||
|
||||
The existing security-boundary comment block in `routes.rs` is updated:
|
||||
|
||||
| Tier | Requirement | Examples |
|
||||
|---|---|---|
|
||||
| `STATIC` | none | `GET /`, `GET /assets/*` |
|
||||
| `PUBLIC_READ` | none (visibility filtering deferred to Track 6) | `GET /api/archives/:id/entries` |
|
||||
| `AUTH_READ` | `ROLE_USER` bit | authenticated entry access |
|
||||
| `WRITE` | `ROLE_USER` bit | `POST /api/archives/:id/captures`, tag mutations |
|
||||
| `ADMIN` | `ROLE_ADMIN` bit | `GET /api/admin/archives`, user management |
|
||||
| `OWNER` | `ROLE_OWNER` bit | instance settings, ownership transfer |
|
||||
|
||||
**Error responses:**
|
||||
- No/invalid session → `401` (frontend redirects to login)
|
||||
- Valid session, insufficient role → `403`
|
||||
- Private resource accessed without sufficient role → `404` (do not reveal existence)
|
||||
|
||||
Track 4 applies `ROLE_USER` enforcement to all existing `WRITE` routes and `ROLE_ADMIN` to
|
||||
`/api/admin/*`. `PUBLIC_READ` routes return all data for now; Track 6 adds visibility filters.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### New components
|
||||
|
||||
| Component | Purpose |
|
||||
|---|---|
|
||||
| `SetupPage.jsx` | First-run owner account creation wizard |
|
||||
| `LoginPage.jsx` | Username/password login form |
|
||||
|
||||
### App.jsx changes
|
||||
|
||||
- On mount: call `GET /api/auth/setup`; if `setup_required`, render `<SetupPage>` and nothing else.
|
||||
- Otherwise: call `GET /api/auth/me`; store result as `currentUser` state (null = guest).
|
||||
- Pass `currentUser` down via React context (`AuthContext`).
|
||||
- Any `401` response from any API call sets `currentUser` to null → triggers `<LoginPage>`.
|
||||
|
||||
### api.js changes
|
||||
|
||||
- Thin response interceptor: if status is `401`, dispatch a global `auth:expired` event that
|
||||
`App.jsx` listens to and handles by clearing `currentUser`.
|
||||
- No token storage in JS — cookies are handled entirely by the browser.
|
||||
|
||||
### Topbar.jsx changes
|
||||
|
||||
- When `currentUser` is set: show `username` and a **Log out** button.
|
||||
- Log out calls `POST /api/auth/logout`, then clears `currentUser`.
|
||||
|
||||
### What is NOT in Track 4 frontend
|
||||
|
||||
- Settings page (Track 7)
|
||||
- User management UI (Track 5)
|
||||
- Role or visibility controls (Track 6)
|
||||
- API token management UI (Track 7)
|
||||
|
||||
---
|
||||
|
||||
## New Dependencies
|
||||
|
||||
| Crate | Purpose |
|
||||
|---|---|
|
||||
| `argon2` | Password hashing (Argon2id) |
|
||||
| `rand` | Cryptographically random token generation |
|
||||
| `tower-cookies` | Cookie extraction in Axum (or use `axum-extra`) |
|
||||
|
||||
Add to `archivr-server/Cargo.toml` and workspace `Cargo.toml` as needed.
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `crates/archivr-core/src/database.rs` | Add `roles`, `user_roles`, `sessions`, `api_tokens` tables; seed built-in roles; add `instance_settings.default_entry_visibility`; replace `ensure_default_user` with `ensure_owner_exists`; add session/token CRUD helpers |
|
||||
| `crates/archivr-server/src/auth.rs` | New: `AuthUser` extractor, role bit constants, session/token lookup |
|
||||
| `crates/archivr-server/src/routes.rs` | Add auth endpoints (`/api/auth/*`); apply `AuthUser` extractor to WRITE/ADMIN routes; update security-boundary comment |
|
||||
| `crates/archivr-server/src/main.rs` | Session cleanup background task |
|
||||
| `frontend/src/App.jsx` | Setup check, auth state, `AuthContext` |
|
||||
| `frontend/src/api.js` | 401 interceptor |
|
||||
| `frontend/src/components/LoginPage.jsx` | New |
|
||||
| `frontend/src/components/SetupPage.jsx` | New |
|
||||
| `frontend/src/components/Topbar.jsx` | User menu + logout |
|
||||
| `Cargo.toml` | Add `argon2`, `rand`, `tower-cookies` (or `axum-extra`) |
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage
|
||||
|
||||
- `database.rs`: role seeding, `ensure_owner_exists`, session CRUD, token hash round-trip
|
||||
- `auth.rs`: extractor resolves cookie → session → user; extractor resolves Bearer → token → user;
|
||||
missing credential → Guest; expired session → Guest
|
||||
- `routes.rs`: login happy path; login wrong password returns 401; logout clears session;
|
||||
setup endpoint returns 503 after setup complete; WRITE route returns 401 for Guest;
|
||||
WRITE route returns 403 for insufficient role; setup flow end-to-end
|
||||
|
||||
---
|
||||
|
||||
## Track Numbering Update for NEXT.md
|
||||
|
||||
Original tracks 4 and 5 shift to 8 and 9. Collections is a named future track (no number until
|
||||
scoped):
|
||||
|
||||
| # | Track |
|
||||
|---|---|
|
||||
| 3 | Async capture jobs |
|
||||
| **4** | **Auth foundation (this spec)** |
|
||||
| **5** | **User management** |
|
||||
| **6** | **Permissions & visibility (collection model)** |
|
||||
| **7** | **Settings** |
|
||||
| **8** | **Collections UI** |
|
||||
| 9 | Cloud backup (was 4) |
|
||||
| 10 | Cloud storage (was 5) |
|
||||
|
|
@ -86,6 +86,7 @@ export default function App() {
|
|||
const [archives, setArchives] = useState([])
|
||||
const [archiveId, setArchiveId] = useState(null)
|
||||
const [entries, setEntries] = useState([])
|
||||
const [deletedUids, setDeletedUids] = useState(() => new Set())
|
||||
const [selectedEntryUid, setSelectedEntryUid] = useState(() => parseLocation().entry)
|
||||
const [selectedEntry, setSelectedEntry] = useState(null)
|
||||
const [selectedUids, setSelectedUids] = useState(() => {
|
||||
|
|
@ -131,6 +132,11 @@ export default function App() {
|
|||
const pendingSearchFocus = useRef(false)
|
||||
const firstArchiveLoad = useRef(true)
|
||||
const lastAnchorIndexRef = useRef(null)
|
||||
// Monotonic token for j/k keyboard navigation; cancels stale fetchEntryDetail calls.
|
||||
const jkSeqRef = useRef(0)
|
||||
// uid → entry object cache; populated on every row click so ctrl/shift
|
||||
// selections can resolve child entries that aren't in the root entries array.
|
||||
const entryCacheRef = useRef(new Map())
|
||||
|
||||
const humanizeTags = currentUser?.humanize_slugs ?? false;
|
||||
|
||||
|
|
@ -260,12 +266,20 @@ export default function App() {
|
|||
}, [])
|
||||
|
||||
const handleRowClick = useCallback((entry, e) => {
|
||||
// Cache every clicked entry so shift/ctrl can resolve child entries
|
||||
// that are not present in the root `entries` array.
|
||||
entryCacheRef.current.set(entry.entry_uid, entry)
|
||||
// Invalidate any in-flight j/k uncached-child fetch.
|
||||
++jkSeqRef.current
|
||||
|
||||
if (e.shiftKey && lastAnchorIndexRef.current !== null) {
|
||||
e.preventDefault()
|
||||
const anchorIdx = entries.findIndex(x => x.entry_uid === lastAnchorIndexRef.current)
|
||||
const clickIdx = entries.findIndex(x => x.entry_uid === entry.entry_uid)
|
||||
// Use DOM order so child rows (not in the `entries` array) participate
|
||||
// in range selection. Every rendered row has data-entry-uid.
|
||||
const allNodes = [...document.querySelectorAll('#entries-body [data-entry-uid]')]
|
||||
const anchorIdx = allNodes.findIndex(n => n.dataset.entryUid === lastAnchorIndexRef.current)
|
||||
const clickIdx = allNodes.findIndex(n => n.dataset.entryUid === entry.entry_uid)
|
||||
if (anchorIdx === -1 || clickIdx === -1) {
|
||||
// anchor evicted by search/filter/delete — fall back to single select
|
||||
lastAnchorIndexRef.current = entry.entry_uid
|
||||
setSelectedUids(new Set([entry.entry_uid]))
|
||||
selectEntry(entry)
|
||||
|
|
@ -273,24 +287,41 @@ export default function App() {
|
|||
}
|
||||
const lo = Math.min(anchorIdx, clickIdx)
|
||||
const hi = Math.max(anchorIdx, clickIdx)
|
||||
const range = entries.slice(lo, hi + 1)
|
||||
const uids = new Set(range.map(x => x.entry_uid))
|
||||
const uids = new Set(allNodes.slice(lo, hi + 1).map(n => n.dataset.entryUid))
|
||||
setSelectedUids(uids)
|
||||
if (uids.size === 1) selectEntry(range[0])
|
||||
if (uids.size === 1) selectEntry(entry)
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
lastAnchorIndexRef.current = entry.entry_uid
|
||||
setSelectedUids(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(entry.entry_uid)) next.delete(entry.entry_uid)
|
||||
else next.add(entry.entry_uid)
|
||||
return next
|
||||
})
|
||||
const next = new Set(selectedUids)
|
||||
if (next.has(entry.entry_uid)) next.delete(entry.entry_uid)
|
||||
else next.add(entry.entry_uid)
|
||||
setSelectedUids(next)
|
||||
if (next.size === 0) {
|
||||
selectEntry(null)
|
||||
} else if (next.size === 1) {
|
||||
// Resolve the remaining UID — may be a child not in the root entries array
|
||||
// and not yet cached (e.g. picked up via shift-range without a direct click).
|
||||
const [remainingUid] = next
|
||||
const cached = entryCacheRef.current.get(remainingUid)
|
||||
?? entries.find(x => x.entry_uid === remainingUid)
|
||||
?? null
|
||||
if (cached) {
|
||||
selectEntry(cached)
|
||||
} else {
|
||||
const tok = ++jkSeqRef.current
|
||||
fetchEntryDetail(archiveId, remainingUid)
|
||||
.then(det => { if (tok === jkSeqRef.current && det?.summary) selectEntry(det.summary) })
|
||||
.catch(() => {})
|
||||
}
|
||||
} else {
|
||||
selectEntry(null)
|
||||
}
|
||||
} else {
|
||||
lastAnchorIndexRef.current = entry.entry_uid
|
||||
setSelectedUids(new Set([entry.entry_uid]))
|
||||
selectEntry(entry)
|
||||
}
|
||||
}, [entries, selectEntry])
|
||||
}, [entries, selectedUids, selectEntry, archiveId])
|
||||
|
||||
const handleTagFilterSet = useCallback((fullPath) => {
|
||||
setTagFilter(fullPath)
|
||||
|
|
@ -342,18 +373,26 @@ export default function App() {
|
|||
}, [archiveId, selectedEntry])
|
||||
|
||||
const handleEntryDeleted = useCallback((entryUid) => {
|
||||
const isRoot = entries.some(e => e.entry_uid === entryUid)
|
||||
setDeletedUids(prev => { const n = new Set(prev); n.add(entryUid); return n })
|
||||
setEntries(prev => prev.filter(e => e.entry_uid !== entryUid))
|
||||
setSelectedEntry(prev => prev?.entry_uid === entryUid ? null : prev)
|
||||
setSelectedEntryUid(prev => prev === entryUid ? null : prev)
|
||||
setSelectedUids(prev => { const n = new Set(prev); n.delete(entryUid); return n })
|
||||
}, [])
|
||||
// Child delete: parent row's child_count/size are stale — reload after state updates.
|
||||
if (!isRoot) loadEntries(archiveId, searchQuery, tagFilter)
|
||||
}, [entries, archiveId, searchQuery, tagFilter, loadEntries])
|
||||
|
||||
const handleBulkDeleted = useCallback((uids) => {
|
||||
const rootUids = new Set(entries.map(e => e.entry_uid))
|
||||
const hasChildDelete = [...uids].some(u => !rootUids.has(u))
|
||||
setDeletedUids(prev => { const n = new Set(prev); uids.forEach(u => n.add(u)); return n })
|
||||
setEntries(prev => prev.filter(e => !uids.has(e.entry_uid)))
|
||||
setSelectedUids(new Set())
|
||||
setSelectedEntry(null)
|
||||
setSelectedEntryUid(null)
|
||||
}, [])
|
||||
if (hasChildDelete) loadEntries(archiveId, searchQuery, tagFilter)
|
||||
}, [entries, archiveId, searchQuery, tagFilter, loadEntries])
|
||||
|
||||
// Auto-snap: drive selectedEntryUid from selectedUids so URL sync and detail
|
||||
// panel stay correct. size >= 2 clears single-entry state (bulk panel takes over).
|
||||
|
|
@ -397,24 +436,81 @@ export default function App() {
|
|||
if (current !== url) history.replaceState(null, '', url)
|
||||
}, [searchQuery, tagFilter, selectedEntryUid, view])
|
||||
|
||||
// ⌘K / Ctrl+K: focus the search input, switching to archive view first if needed.
|
||||
// ⌘K / Ctrl+K / /: focus the search input, switching to archive view first if needed.
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
if (view === 'archive') {
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
} else {
|
||||
pendingSearchFocus.current = true
|
||||
setView('archive')
|
||||
}
|
||||
const isSlash = e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey
|
||||
const isCmdK = (e.metaKey || e.ctrlKey) && e.key === 'k'
|
||||
if (!isSlash && !isCmdK) return
|
||||
// Don't intercept / when already typing in an input
|
||||
const tag = document.activeElement?.tagName
|
||||
if (isSlash && (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT')) return
|
||||
if (isSlash && document.activeElement?.isContentEditable) return
|
||||
e.preventDefault()
|
||||
if (view === 'archive') {
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
} else {
|
||||
pendingSearchFocus.current = true
|
||||
setView('archive')
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [view])
|
||||
|
||||
// j/k: navigate entries down/up when not focused on an input element.
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
// Ignore when a modifier is held (don't steal browser/app shortcuts)
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return
|
||||
if (e.key !== 'j' && e.key !== 'k') return
|
||||
// Ignore when focus is on any editable target
|
||||
const tag = document.activeElement?.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
|
||||
if (document.activeElement?.isContentEditable) return
|
||||
|
||||
const allNodes = [...document.querySelectorAll('#entries-body [data-entry-uid]')]
|
||||
if (allNodes.length === 0) return
|
||||
|
||||
const [currentUid] = selectedUids.size === 1 ? selectedUids : [null]
|
||||
const currentIdx = currentUid
|
||||
? allNodes.findIndex(n => n.dataset.entryUid === currentUid)
|
||||
: -1
|
||||
|
||||
const nextIdx = e.key === 'j'
|
||||
? Math.min(currentIdx + 1, allNodes.length - 1)
|
||||
: Math.max(currentIdx - 1, 0)
|
||||
|
||||
if (nextIdx === currentIdx && currentIdx !== -1) return
|
||||
|
||||
const nextNode = allNodes[nextIdx < 0 ? 0 : nextIdx]
|
||||
const nextUid = nextNode.dataset.entryUid
|
||||
|
||||
lastAnchorIndexRef.current = nextUid
|
||||
const tok = ++jkSeqRef.current
|
||||
setSelectedUids(new Set([nextUid]))
|
||||
|
||||
// Resolve entry object: cache → root entries array → server fetch.
|
||||
// Token guards against a slow fetch settling after the user has moved on.
|
||||
const cached = entryCacheRef.current.get(nextUid)
|
||||
?? entries.find(x => x.entry_uid === nextUid)
|
||||
?? null
|
||||
if (cached) {
|
||||
selectEntry(cached)
|
||||
} else {
|
||||
fetchEntryDetail(archiveId, nextUid)
|
||||
.then(det => { if (tok === jkSeqRef.current && det?.summary) selectEntry(det.summary) })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
nextNode.scrollIntoView({ block: 'nearest' })
|
||||
e.preventDefault()
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [selectedUids, entries, selectEntry, archiveId])
|
||||
|
||||
// After switching to archive view via ⌘K, focus the search input once rendered.
|
||||
useEffect(() => {
|
||||
if (view === 'archive' && pendingSearchFocus.current) {
|
||||
|
|
@ -563,6 +659,7 @@ export default function App() {
|
|||
onRowClick={handleRowClick}
|
||||
archiveId={archiveId}
|
||||
pendingCaptures={pendingCaptures}
|
||||
deletedUids={deletedUids}
|
||||
/>
|
||||
)}
|
||||
{view === 'runs' && <RunsView runs={runs} />}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ export async function fetchEntryDetail(archiveId, entryUid) {
|
|||
return getJson(`/api/archives/${archiveId}/entries/${entryUid}`);
|
||||
}
|
||||
|
||||
export async function fetchEntryChildren(archiveId, entryUid) {
|
||||
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/children`);
|
||||
}
|
||||
|
||||
// Fetch multiple artifact JSON payloads for an entry in parallel.
|
||||
// Returns a Promise<Array> preserving index order.
|
||||
export function fetchEntryArtifacts(archiveId, entryUid, indices) {
|
||||
|
|
@ -149,6 +153,8 @@ export async function submitCapture(archiveId, locator, quality = null, extensio
|
|||
if (typeof extensions.cookie_ext_enabled === 'boolean') payload.cookie_ext_enabled = extensions.cookie_ext_enabled
|
||||
if (typeof extensions.modal_closer_enabled === 'boolean') payload.modal_closer_enabled = extensions.modal_closer_enabled
|
||||
if (typeof extensions.via_freedium === 'boolean') payload.via_freedium = extensions.via_freedium
|
||||
if (extensions.per_item_quality && typeof extensions.per_item_quality === 'object' && Object.keys(extensions.per_item_quality).length > 0) payload.per_item_quality = extensions.per_item_quality
|
||||
if (extensions.sync === true) payload.sync = true
|
||||
}
|
||||
const res = await fetch(`/api/archives/${archiveId}/captures`, {
|
||||
method: "POST",
|
||||
|
|
@ -168,6 +174,19 @@ export async function probeCapture(archiveId, locator) {
|
|||
return getJson(`/api/archives/${archiveId}/captures/probe?locator=${encodeURIComponent(locator)}`);
|
||||
}
|
||||
|
||||
export async function probePlaylist(archiveId, locator) {
|
||||
const res = await fetch(`/api/archives/${archiveId}/captures/probe-playlist`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ locator }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || `HTTP ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function pollCaptureJob(archiveId, jobUid) {
|
||||
return getJson(`/api/archives/${archiveId}/capture_jobs/${jobUid}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||
import { submitCapture, pollCaptureJob, probeCapture, getInstanceSettings } from '../api'
|
||||
import { submitCapture, pollCaptureJob, probeCapture, probePlaylist, getInstanceSettings } from '../api'
|
||||
|
||||
let nextItemId = 1
|
||||
|
||||
|
|
@ -14,7 +14,11 @@ function isVideoSource(locator) {
|
|||
for (const scheme of ['yt:', 'youtube:']) {
|
||||
if (ll.startsWith(scheme)) {
|
||||
const after = ll.slice(scheme.length)
|
||||
return after.startsWith('video/') || after.startsWith('short/') || after.startsWith('shorts/')
|
||||
if (after.startsWith('video/') || after.startsWith('short/') || after.startsWith('shorts/'))
|
||||
return true
|
||||
// bare yt:ID — exactly 11 chars [A-Za-z0-9_-], same predicate as is_youtube_video_id in core
|
||||
if (/^[a-z0-9_-]{11}$/i.test(after)) return true
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +67,52 @@ function isVideoSource(locator) {
|
|||
return false
|
||||
}
|
||||
|
||||
function isPlaylistSource(locator) {
|
||||
const l = locator.trim()
|
||||
const ll = l.toLowerCase()
|
||||
|
||||
// yt: / youtube: shorthands — playlist, channel, @ handles
|
||||
for (const scheme of ['yt:', 'youtube:']) {
|
||||
if (ll.startsWith(scheme)) {
|
||||
const after = ll.slice(scheme.length)
|
||||
return after.startsWith('playlist/') || after.startsWith('@') ||
|
||||
after.startsWith('channel/') || after.startsWith('c/') || after.startsWith('user/')
|
||||
}
|
||||
}
|
||||
|
||||
// ytm: shorthand — playlist
|
||||
if (ll.startsWith('ytm:')) {
|
||||
return ll.slice(4).startsWith('playlist/')
|
||||
}
|
||||
|
||||
// spotify: shorthands — album and playlist (not track)
|
||||
if (ll.startsWith('spotify:')) {
|
||||
const after = ll.slice(8)
|
||||
return after.startsWith('album:') || after.startsWith('playlist:')
|
||||
}
|
||||
|
||||
// HTTP/HTTPS URLs
|
||||
if (ll.startsWith('http://') || ll.startsWith('https://')) {
|
||||
try {
|
||||
const url = new URL(l)
|
||||
const host = url.hostname
|
||||
if (host === 'youtube.com' || host === 'www.youtube.com') {
|
||||
if (url.pathname === '/playlist' && url.searchParams.has('list')) return true
|
||||
if (url.pathname.startsWith('/@') || url.pathname.startsWith('/channel/') ||
|
||||
url.pathname.startsWith('/c/') || url.pathname.startsWith('/user/')) return true
|
||||
}
|
||||
if (host === 'music.youtube.com') {
|
||||
if (url.pathname === '/playlist' && url.searchParams.has('list')) return true
|
||||
}
|
||||
if (host === 'open.spotify.com') {
|
||||
if (url.pathname.startsWith('/album/') || url.pathname.startsWith('/playlist/')) return true
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function makeItem(locator = '') {
|
||||
return {
|
||||
id: nextItemId++, locator, quality: 'best',
|
||||
|
|
@ -71,9 +121,47 @@ function makeItem(locator = '') {
|
|||
probeQualities: null, // null | string[] when done, e.g. ["1080p","720p","480p"]
|
||||
probeHasAudio: false, // true when probe confirms at least one audio track
|
||||
status: 'idle', error: null, jobUid: null, archiveId: null,
|
||||
// playlist probe state
|
||||
playlistProbeState: 'idle', // 'idle' | 'probing' | 'done' | 'error'
|
||||
playlistInfo: null, // raw API response or null
|
||||
playlistItems: null, // [{id, url, title, qualities, has_audio, quality}] or null
|
||||
playlistQuality: null, // selected playlist-level quality string or null
|
||||
playlistExpanded: false, // whether per-video list is expanded
|
||||
syncEnabled: false, // sync mode toggle
|
||||
}
|
||||
}
|
||||
|
||||
function applyPlaylistQuality(newQ, currentItems) {
|
||||
if (newQ === 'best') {
|
||||
return currentItems.map(item => ({ ...item, quality: 'best' }))
|
||||
}
|
||||
if (newQ === 'audio') {
|
||||
return currentItems.map(item => {
|
||||
if (item.has_audio) return { ...item, quality: 'audio' }
|
||||
// No audio track — same conflict rule as unsupported height:
|
||||
// keep a prior selection if one exists, otherwise leave null (blocks archive).
|
||||
if (item.quality !== null) return item
|
||||
return { ...item, quality: null }
|
||||
})
|
||||
}
|
||||
return currentItems.map(item => {
|
||||
// Exact match only: the video must list this quality in its available formats.
|
||||
// maxHeight >= newHeight would be a cap (yt-dlp silent fallback), not what
|
||||
// the user selected; a video with [2160p, 1080p] does NOT support 1440p.
|
||||
if (item.qualities.includes(newQ)) {
|
||||
return { ...item, quality: newQ }
|
||||
}
|
||||
// Quality not available for this item — keep prior selection if one exists,
|
||||
// otherwise null (conflict, blocks archive until user picks manually).
|
||||
if (item.quality !== null) return item
|
||||
return { ...item, quality: null }
|
||||
})
|
||||
}
|
||||
|
||||
function hasConflict(item) {
|
||||
return Array.isArray(item.playlistItems) && item.playlistItems.some(pi => pi.quality === null)
|
||||
}
|
||||
|
||||
export default function CaptureDialog({ open, archiveId, onClose, onCaptured, onToast, onJobStarted, onJobSettled, activeJobs = [] }) {
|
||||
const dialogRef = useRef(null)
|
||||
const isFirstRenderRef = useRef(true)
|
||||
|
|
@ -105,7 +193,9 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
const idle = saved.filter(it => !it.status || it.status === 'idle')
|
||||
if (idle.length > 0) {
|
||||
idle.forEach(it => { if (it.id >= nextItemId) nextItemId = it.id + 1 })
|
||||
return idle
|
||||
// Merge with makeItem() defaults so items saved before the playlist
|
||||
// fields were added don't have undefined where null/false is expected.
|
||||
return idle.map(it => ({ ...makeItem(it.locator), ...it }))
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
|
@ -283,7 +373,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
onToastRef.current(text, null, type, headline)
|
||||
}
|
||||
|
||||
async function submitBgJob(locator, quality, batchId) {
|
||||
async function submitBgJob(locator, quality, batchId, extraExtensions = {}) {
|
||||
const aid = archiveIdRef.current
|
||||
const id = crypto.randomUUID?.() ?? `job-${Date.now()}-${Math.random()}`
|
||||
// Capture session options at call time (synchronous — before first await)
|
||||
|
|
@ -293,6 +383,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
cookie_ext_enabled: cookieExtEnabled,
|
||||
modal_closer_enabled: modalCloserEnabled,
|
||||
via_freedium: freediumEnabled,
|
||||
...extraExtensions,
|
||||
}
|
||||
try {
|
||||
const job = await submitCapture(aid, locator, quality, extensions)
|
||||
|
|
@ -309,19 +400,31 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
function handleArchive() {
|
||||
const toSubmit = items.filter(it => it.locator.trim())
|
||||
if (toSubmit.length === 0) return
|
||||
if (toSubmit.some(it => hasConflict(it))) return
|
||||
if (toSubmit.some(it => it.probeState === 'probing' ||
|
||||
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done'))) return
|
||||
if (toSubmit.some(it => Array.isArray(it.playlistItems) && it.playlistItems.length === 0)) return
|
||||
const batchId = toSubmit.length > 1
|
||||
? (crypto.randomUUID?.() ?? `batch-${Date.now()}`)
|
||||
: null
|
||||
if (batchId) {
|
||||
batchRef.current.set(batchId, { total: toSubmit.length, archived: 0, warnings: 0, failed: 0, failedLocators: [], warningLocators: [] })
|
||||
}
|
||||
// Capture options at call time before any state changes
|
||||
const capturedQuality = toSubmit.map(it => it.quality || 'best')
|
||||
// Capture all submission data before any state changes
|
||||
const submissions = toSubmit.map(it => ({
|
||||
locator: it.locator.trim(),
|
||||
quality: it.playlistItems !== null ? null : (it.quality || 'best'),
|
||||
extraExtensions: it.playlistItems !== null
|
||||
? { per_item_quality: Object.fromEntries(it.playlistItems.map(pi => [pi.id, pi.quality])), sync: it.syncEnabled }
|
||||
: {},
|
||||
}))
|
||||
// Reset form and close dialog immediately
|
||||
setItems([makeItem()])
|
||||
dialogRef.current?.close()
|
||||
// Submit each in background
|
||||
toSubmit.forEach((it, i) => submitBgJob(it.locator.trim(), capturedQuality[i], batchId))
|
||||
submissions.forEach(({ locator, quality, extraExtensions }) =>
|
||||
submitBgJob(locator, quality, batchId, extraExtensions)
|
||||
)
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
|
|
@ -340,45 +443,108 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
function updateLocator(id, val) {
|
||||
// Cancel any in-flight debounce and immediately clear stale probe results.
|
||||
// This prevents old qualities from being visible (and submittable) while
|
||||
// the 600ms debounce is pending for the new URL.
|
||||
// the debounce is pending for the new URL.
|
||||
clearTimeout(probeTimers.current.get(id))
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id === id
|
||||
? { ...it, locator: val, probeState: 'idle', probeQualities: null, probeHasAudio: false, quality: 'best' }
|
||||
? { ...it, locator: val, probeState: 'idle', probeQualities: null, probeHasAudio: false, quality: 'best',
|
||||
playlistProbeState: 'idle', playlistInfo: null, playlistItems: null, playlistQuality: null, playlistExpanded: false }
|
||||
: it
|
||||
))
|
||||
|
||||
if (!isVideoSource(val)) return
|
||||
|
||||
// Schedule a fresh probe after the user stops typing
|
||||
const timer = setTimeout(async () => {
|
||||
probeTimers.current.delete(id)
|
||||
setItems(prev => prev.map(it => it.id === id ? { ...it, probeState: 'probing' } : it))
|
||||
try {
|
||||
const result = await probeCapture(archiveIdRef.current, val.trim())
|
||||
setItems(prev => prev.map(it => {
|
||||
if (it.id !== id || it.locator !== val) return it // stale — locator changed again
|
||||
const qualities = result.qualities ?? []
|
||||
const hasAudio = result.has_audio ?? false
|
||||
// Audio-only source: no video heights but audio confirmed — force audio mode
|
||||
const quality = (qualities.length === 0 && hasAudio) ? 'audio' : 'best'
|
||||
return { ...it, probeState: 'done', probeQualities: qualities, probeHasAudio: hasAudio, quality }
|
||||
}))
|
||||
} catch {
|
||||
// Probe failed (network error, etc.) — clear silently; user can still submit
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id === id ? { ...it, probeState: 'idle', probeQualities: null } : it
|
||||
))
|
||||
}
|
||||
}, 600)
|
||||
probeTimers.current.set(id, timer)
|
||||
if (isVideoSource(val)) {
|
||||
// Schedule a fresh probe after the user stops typing
|
||||
const timer = setTimeout(async () => {
|
||||
probeTimers.current.delete(id)
|
||||
setItems(prev => prev.map(it => it.id === id ? { ...it, probeState: 'probing' } : it))
|
||||
try {
|
||||
const result = await probeCapture(archiveIdRef.current, val.trim())
|
||||
setItems(prev => prev.map(it => {
|
||||
if (it.id !== id || it.locator !== val) return it // stale — locator changed again
|
||||
const qualities = result.qualities ?? []
|
||||
const hasAudio = result.has_audio ?? false
|
||||
// Audio-only source: no video heights but audio confirmed — force audio mode
|
||||
const quality = (qualities.length === 0 && hasAudio) ? 'audio' : 'best'
|
||||
return { ...it, probeState: 'done', probeQualities: qualities, probeHasAudio: hasAudio, quality }
|
||||
}))
|
||||
} catch {
|
||||
// Probe failed (network error, etc.) — clear silently; user can still submit
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id === id ? { ...it, probeState: 'idle', probeQualities: null } : it
|
||||
))
|
||||
}
|
||||
}, 600)
|
||||
probeTimers.current.set(id, timer)
|
||||
} else if (isPlaylistSource(val)) {
|
||||
// Schedule a playlist probe (playlists are slower — 800ms debounce)
|
||||
const timer = setTimeout(async () => {
|
||||
probeTimers.current.delete(id)
|
||||
setItems(prev => prev.map(it => it.id === id ? { ...it, playlistProbeState: 'probing' } : it))
|
||||
try {
|
||||
const result = await probePlaylist(archiveIdRef.current, val.trim())
|
||||
setItems(prev => prev.map(it => {
|
||||
if (it.id !== id || it.locator !== val) return it // stale — locator changed again
|
||||
return {
|
||||
...it,
|
||||
playlistProbeState: 'done',
|
||||
playlistInfo: result,
|
||||
playlistItems: result.items.map(pi => ({ ...pi, quality: null })),
|
||||
playlistQuality: null,
|
||||
}
|
||||
}))
|
||||
} catch {
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id === id ? { ...it, playlistProbeState: 'error' } : it
|
||||
))
|
||||
}
|
||||
}, 800)
|
||||
probeTimers.current.set(id, timer)
|
||||
}
|
||||
}
|
||||
|
||||
function updateQuality(id, val) {
|
||||
setItems(prev => prev.map(it => it.id === id ? { ...it, quality: val } : it))
|
||||
}
|
||||
|
||||
function updatePlaylistQuality(id, q) {
|
||||
setItems(prev => prev.map(it => {
|
||||
if (it.id !== id) return it
|
||||
const newItems = applyPlaylistQuality(q, it.playlistItems)
|
||||
return { ...it, playlistQuality: q, playlistItems: newItems }
|
||||
}))
|
||||
}
|
||||
function updatePlaylistItemQuality(id, videoId, q) {
|
||||
setItems(prev => prev.map(it => {
|
||||
if (it.id !== id) return it
|
||||
return { ...it, playlistItems: it.playlistItems.map(pi => pi.id === videoId ? { ...pi, quality: q } : pi) }
|
||||
}))
|
||||
}
|
||||
function togglePlaylistExpanded(id) {
|
||||
setItems(prev => prev.map(it => it.id === id ? { ...it, playlistExpanded: !it.playlistExpanded } : it))
|
||||
}
|
||||
function updateSync(id, val) {
|
||||
setItems(prev => prev.map(it => it.id === id ? { ...it, syncEnabled: val } : it))
|
||||
}
|
||||
function deletePlaylistItem(itemId, videoId) {
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id !== itemId ? it :
|
||||
{ ...it, playlistItems: it.playlistItems.filter(pi => pi.id !== videoId) }
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
const pendingCount = items.filter(it => it.locator.trim()).length
|
||||
const anyConflict = items.some(it => hasConflict(it))
|
||||
// True if any playlist row has had all its videos deleted — archive would be a no-op.
|
||||
const anyEmptyPlaylist = items.some(it =>
|
||||
Array.isArray(it.playlistItems) && it.playlistItems.length === 0
|
||||
)
|
||||
const anyProbing = items.some(it =>
|
||||
it.probeState === 'probing' ||
|
||||
// For playlist sources block unless probe completed successfully:
|
||||
// idle = debounce not yet fired; probing = in flight; error = no quality data.
|
||||
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done')
|
||||
)
|
||||
|
||||
return (
|
||||
<dialog ref={dialogRef} className="capture-dialog">
|
||||
|
|
@ -407,6 +573,11 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
onQualityChange={val => updateQuality(item.id, val)}
|
||||
onRemove={() => removeRow(item.id)}
|
||||
onSubmit={handleArchive}
|
||||
onPlaylistQualityChange={q => updatePlaylistQuality(item.id, q)}
|
||||
onPlaylistItemQualityChange={(vid, q) => updatePlaylistItemQuality(item.id, vid, q)}
|
||||
onPlaylistToggle={() => togglePlaylistExpanded(item.id)}
|
||||
onSyncChange={val => updateSync(item.id, val)}
|
||||
onPlaylistItemDelete={(vid) => deletePlaylistItem(item.id, vid)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -530,7 +701,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
type="button"
|
||||
className="capture-submit"
|
||||
onClick={handleArchive}
|
||||
disabled={pendingCount === 0}
|
||||
disabled={pendingCount === 0 || anyConflict || anyProbing || anyEmptyPlaylist}
|
||||
>
|
||||
{pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'}
|
||||
</button>
|
||||
|
|
@ -543,7 +714,8 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
)
|
||||
}
|
||||
|
||||
function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemove, onSubmit }) {
|
||||
function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemove, onSubmit,
|
||||
onPlaylistQualityChange, onPlaylistItemQualityChange, onPlaylistToggle, onSyncChange, onPlaylistItemDelete }) {
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -554,6 +726,47 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
|
|||
|
||||
// Quality control shown right of the input
|
||||
const qualityEl = (() => {
|
||||
// Playlist source handling
|
||||
if (isPlaylistSource(item.locator)) {
|
||||
if (item.playlistProbeState === 'probing') {
|
||||
return <span className="capture-quality-probing" aria-label="Probing playlist…">…</span>
|
||||
}
|
||||
if (item.playlistProbeState === 'done') {
|
||||
const allHeights = [...new Set(
|
||||
item.playlistItems.flatMap(pi => pi.qualities.map(q => parseInt(q)))
|
||||
)].sort((a, b) => b - a)
|
||||
const allHaveAudio = item.playlistItems.every(pi => pi.has_audio)
|
||||
const conflictCount = item.playlistItems.filter(pi => pi.quality === null).length
|
||||
return (
|
||||
<>
|
||||
<select
|
||||
className="capture-quality"
|
||||
value={item.playlistQuality ?? ''}
|
||||
onChange={e => onPlaylistQualityChange(e.target.value)}
|
||||
aria-label="Playlist quality"
|
||||
>
|
||||
{!item.playlistQuality && <option value="" disabled>Select quality…</option>}
|
||||
<option value="best">Best quality</option>
|
||||
{allHeights.map(h => <option key={h} value={`${h}p`}>{h}p</option>)}
|
||||
{allHaveAudio && <option value="audio">Audio only</option>}
|
||||
</select>
|
||||
{conflictCount > 0 && (
|
||||
<span className="capture-conflict-badge">{conflictCount} need selection</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (item.playlistProbeState === 'error') {
|
||||
return (
|
||||
<span className="capture-quality-hint capture-quality-hint--error">
|
||||
Probe failed — edit URL to retry
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Video source handling (unchanged)
|
||||
if (!isVideoSource(item.locator)) return null
|
||||
if (item.probeState === 'probing') {
|
||||
return <span className="capture-quality-probing" aria-label="Checking available qualities">…</span>
|
||||
|
|
@ -565,8 +778,6 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
|
|||
return <span className="capture-quality-hint">No media detected</span>
|
||||
}
|
||||
if (qualities.length === 0 && hasAudio) {
|
||||
// Audio-only source: no video tracks, only audio available.
|
||||
// Don't offer "Best quality" — it would request a video format and fail.
|
||||
return (
|
||||
<select
|
||||
className="capture-quality"
|
||||
|
|
@ -594,9 +805,29 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
|
|||
return null // probeState === 'idle', debounce not yet fired
|
||||
})()
|
||||
|
||||
const syncToggle = isPlaylistSource(item.locator) && item.playlistProbeState === 'done' ? (
|
||||
<label className="capture-sync-row">
|
||||
<input type="checkbox" checked={item.syncEnabled} onChange={e => onSyncChange(e.target.checked)} />
|
||||
<span>Sync — skip already-archived videos</span>
|
||||
</label>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="capture-row">
|
||||
<div className="capture-row-main">
|
||||
{isPlaylistSource(item.locator) ? (
|
||||
item.playlistProbeState === 'done' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="capture-playlist-toggle capture-playlist-toggle--left"
|
||||
onClick={onPlaylistToggle}
|
||||
aria-label={item.playlistExpanded ? 'Collapse video list' : 'Expand video list'}
|
||||
aria-expanded={item.playlistExpanded}
|
||||
>
|
||||
{item.playlistExpanded ? '▲' : '▼'}
|
||||
</button>
|
||||
) : null
|
||||
) : null}
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="capture-input"
|
||||
|
|
@ -618,6 +849,41 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
|
|||
{item.error && (
|
||||
<p className="capture-row-error">{item.error}</p>
|
||||
)}
|
||||
{isPlaylistSource(item.locator) && item.playlistProbeState === 'done' && item.playlistExpanded ? (
|
||||
<div className="capture-playlist-items">
|
||||
{item.playlistItems.map(pi => (
|
||||
<div
|
||||
key={pi.id}
|
||||
className={`capture-playlist-item${pi.quality === null ? ' capture-playlist-item--conflict' : ''}`}
|
||||
>
|
||||
<span className="capture-playlist-item-title">{pi.title || pi.url}</span>
|
||||
<select
|
||||
className="capture-item-quality"
|
||||
value={pi.quality ?? ''}
|
||||
onChange={e => onPlaylistItemQualityChange(pi.id, e.target.value)}
|
||||
aria-label={`Quality for ${pi.title || pi.url}`}
|
||||
>
|
||||
{pi.quality === null && <option value="" disabled>Choose…</option>}
|
||||
<option value="best">Best quality</option>
|
||||
{pi.qualities.map(q => <option key={q} value={q}>{q}</option>)}
|
||||
{pi.has_audio && <option value="audio">Audio only</option>}
|
||||
</select>
|
||||
{pi.quality === null && (
|
||||
<span className="capture-playlist-conflict-badge">Choose quality</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="capture-playlist-item-remove"
|
||||
aria-label={`Remove ${pi.title || pi.url}`}
|
||||
onClick={() => onPlaylistItemDelete(pi.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{syncToggle}
|
||||
</div>
|
||||
) : syncToggle}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import SkeletonEntryRow from './SkeletonEntryRow';
|
|||
|
||||
import EntryRow from './EntryRow';
|
||||
|
||||
export default function EntriesView({ entries, selectedUids, onRowClick, archiveId, pendingCaptures = [] }) {
|
||||
export default function EntriesView({ entries, selectedUids, onRowClick, archiveId, pendingCaptures = [], deletedUids }) {
|
||||
return (
|
||||
<section id="archive-view" className="view is-active">
|
||||
<div className="entry-table">
|
||||
|
|
@ -18,14 +18,17 @@ export default function EntriesView({ entries, selectedUids, onRowClick, archive
|
|||
{pendingCaptures.filter(c => c.archiveId === archiveId).reverse().map(cap => (
|
||||
<SkeletonEntryRow key={cap.id} />
|
||||
))}
|
||||
{entries.map(entry => (
|
||||
{entries.map((entry, idx) => (
|
||||
<EntryRow
|
||||
key={entry.entry_uid}
|
||||
entry={entry}
|
||||
rowIndex={idx}
|
||||
archiveId={archiveId}
|
||||
isSelected={selectedUids.size === 1 && selectedUids.has(entry.entry_uid)}
|
||||
isMultiSelected={selectedUids.size >= 2 && selectedUids.has(entry.entry_uid)}
|
||||
onRowClick={onRowClick}
|
||||
selectedUids={selectedUids}
|
||||
deletedUids={deletedUids}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,51 @@
|
|||
import { useState } from 'react';
|
||||
import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils';
|
||||
import { fetchEntryChildren } from '../api';
|
||||
|
||||
export default function EntryRow({ entry, archiveId, isSelected, isMultiSelected, onRowClick }) {
|
||||
function ChildRow({ entry, index, onRowClick, selectedUids }) {
|
||||
const isSelected = (selectedUids?.size === 1) && selectedUids.has(entry.entry_uid);
|
||||
const isMultiSelected = (selectedUids?.size >= 2) && selectedUids.has(entry.entry_uid);
|
||||
|
||||
const cls = ['child-entry-row',
|
||||
index % 2 === 0 ? 'child-entry-row--light' : 'child-entry-row--dark',
|
||||
isSelected && 'is-selected',
|
||||
isMultiSelected && 'is-multi-selected',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls}
|
||||
tabIndex={0}
|
||||
data-entry-uid={entry.entry_uid}
|
||||
onMouseDown={e => { if (e.shiftKey) e.preventDefault(); }}
|
||||
onClick={e => onRowClick(entry, e)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') onRowClick(entry, e); }}
|
||||
>
|
||||
<div className="col-check" aria-hidden="true" />
|
||||
<div className="col-added">{formatTimestamp(entry.archived_at)}</div>
|
||||
<div className="col-title">
|
||||
<span className="source-icon">
|
||||
<span dangerouslySetInnerHTML={{ __html: sourceIconSvg(entry.source_kind) }} />
|
||||
</span>
|
||||
<span className="entry-title">{valueText(entry.title) || valueText(entry.entry_uid)}</span>
|
||||
</div>
|
||||
<div className="col-type">
|
||||
<span className="type-pill">{valueText(entry.entity_kind)}</span>
|
||||
</div>
|
||||
<div className="col-size">
|
||||
<span className="size-total">{formatBytes(entry.total_artifact_bytes)}</span>
|
||||
</div>
|
||||
<div className="url-cell col-url">{valueText(entry.original_url)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EntryRow({ entry, archiveId, rowIndex, isSelected, isMultiSelected, onRowClick, selectedUids, deletedUids }) {
|
||||
const [favFailed, setFavFailed] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [children, setChildren] = useState(null);
|
||||
const [childrenLoading, setChildrenLoading] = useState(false);
|
||||
|
||||
const showFavicon =
|
||||
entry.source_kind === 'web' &&
|
||||
entry.entity_kind === 'page' &&
|
||||
|
|
@ -24,49 +67,108 @@ export default function EntryRow({ entry, archiveId, isSelected, isMultiSelected
|
|||
);
|
||||
|
||||
const checked = isSelected || isMultiSelected;
|
||||
const hasChildren = entry.child_count > 0;
|
||||
|
||||
function handleCheckboxClick(e) {
|
||||
e.stopPropagation();
|
||||
// treat checkbox tap as ctrl+click: toggle this entry without clearing others
|
||||
onRowClick(entry, { ctrlKey: true, metaKey: false, shiftKey: false, preventDefault() {} });
|
||||
}
|
||||
|
||||
async function handleExpandClick(e) {
|
||||
e.stopPropagation();
|
||||
if (expanded) {
|
||||
setExpanded(false);
|
||||
return;
|
||||
}
|
||||
setExpanded(true);
|
||||
if (children === null && !childrenLoading) {
|
||||
setChildrenLoading(true);
|
||||
try {
|
||||
const result = await fetchEntryChildren(archiveId, entry.entry_uid);
|
||||
setChildren(result);
|
||||
} catch (_) {
|
||||
setChildren([]);
|
||||
} finally {
|
||||
setChildrenLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const outerClass = [
|
||||
'entry-row-outer',
|
||||
rowIndex % 2 === 0 ? 'entry-row-outer--light' : 'entry-row-outer--dark',
|
||||
isSelected && 'is-selected',
|
||||
isMultiSelected && 'is-multi-selected',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[isSelected && 'is-selected', isMultiSelected && 'is-multi-selected'].filter(Boolean).join(' ') || undefined}
|
||||
tabIndex={0}
|
||||
data-entry-uid={entry.entry_uid}
|
||||
onMouseDown={e => { if (e.shiftKey) e.preventDefault() }}
|
||||
onClick={e => onRowClick(entry, e)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') onRowClick(entry, e) }}
|
||||
>
|
||||
<div className="col-check">
|
||||
<button
|
||||
type="button"
|
||||
className={`row-checkbox${checked ? ' is-checked' : ''}`}
|
||||
aria-pressed={checked}
|
||||
aria-label={checked ? 'Deselect entry' : 'Select entry'}
|
||||
onClick={handleCheckboxClick}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
<div className={outerClass} data-entry-uid={entry.entry_uid}>
|
||||
<div
|
||||
className="entry-row-main"
|
||||
tabIndex={0}
|
||||
onMouseDown={e => { if (e.shiftKey) e.preventDefault(); }}
|
||||
onClick={e => onRowClick(entry, e)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') onRowClick(entry, e); }}
|
||||
>
|
||||
<div className="col-check">
|
||||
<button
|
||||
type="button"
|
||||
className={`row-checkbox${checked ? ' is-checked' : ''}`}
|
||||
aria-pressed={checked}
|
||||
aria-label={checked ? 'Deselect entry' : 'Select entry'}
|
||||
onClick={handleCheckboxClick}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-added">{formatTimestamp(entry.archived_at)}</div>
|
||||
<div className="col-title">
|
||||
{hasChildren && (
|
||||
<button
|
||||
type="button"
|
||||
className={`entry-expand-btn${expanded ? ' is-expanded' : ''}`}
|
||||
aria-label={expanded ? 'Collapse children' : `Expand ${entry.child_count} items`}
|
||||
aria-expanded={expanded}
|
||||
onClick={handleExpandClick}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<span className="source-icon">{icon}</span>
|
||||
<span className="entry-title">{valueText(entry.title) || valueText(entry.entry_uid)}</span>
|
||||
{hasChildren && (
|
||||
<span className="child-count-badge" aria-hidden="true">{entry.child_count}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-type">
|
||||
<span className="type-pill">{valueText(entry.entity_kind)}</span>
|
||||
</div>
|
||||
<div className="col-size">
|
||||
<span className="size-total">{formatBytes(entry.total_artifact_bytes)}</span>
|
||||
{entry.cached_bytes > 0 && entry.cacheable_bytes > 0 && (
|
||||
<span className="size-cached-pct" title={`${formatBytes(entry.cached_bytes)} already on disk from an earlier entry`}>
|
||||
{Math.round(entry.cached_bytes / entry.cacheable_bytes * 100)}% cached
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="url-cell col-url">{valueText(entry.original_url)}</div>
|
||||
</div>
|
||||
<div className="col-added">{formatTimestamp(entry.archived_at)}</div>
|
||||
<div className="col-title">
|
||||
<span className="source-icon">{icon}</span>
|
||||
<span className="entry-title">{valueText(entry.title) || valueText(entry.entry_uid)}</span>
|
||||
</div>
|
||||
<div className="col-type">
|
||||
<span className="type-pill">{valueText(entry.entity_kind)}</span>
|
||||
</div>
|
||||
<div className="col-size">
|
||||
<span className="size-total">{formatBytes(entry.total_artifact_bytes)}</span>
|
||||
{entry.cached_bytes > 0 && entry.total_artifact_bytes > 0 && (
|
||||
<span className="size-cached-pct" title={`${formatBytes(entry.cached_bytes)} already on disk from an earlier entry`}>
|
||||
{Math.round(entry.cached_bytes / entry.total_artifact_bytes * 100)}% cached
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="url-cell col-url">{valueText(entry.original_url)}</div>
|
||||
{expanded && (
|
||||
<>
|
||||
{childrenLoading && <div className="child-entries-loading">Loading…</div>}
|
||||
<div className="child-entries" aria-label={`${entry.child_count} child entries`}>
|
||||
{children && children
|
||||
.filter(c => !deletedUids?.has(c.entry_uid))
|
||||
.map((child, idx) => (
|
||||
<ChildRow
|
||||
key={child.entry_uid}
|
||||
entry={child}
|
||||
index={idx}
|
||||
onRowClick={onRowClick}
|
||||
selectedUids={selectedUids}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -299,17 +299,19 @@ select {
|
|||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
#entries-body > div > div { padding: 7px 10px; flex-shrink: 0; overflow: hidden; }
|
||||
#entries-body > div:nth-child(even) { background: #f2ede5; }
|
||||
#entries-body > div:nth-child(odd) { background: var(--paper-3); }
|
||||
/* Skeleton rows (no index class) fall back to nth-child; real rows use explicit classes. */
|
||||
#entries-body > div:not(.entry-row-outer):nth-child(even) { background: #f2ede5; }
|
||||
#entries-body > div:not(.entry-row-outer):nth-child(odd) { background: var(--paper-3); }
|
||||
/* Index-based stripes for real entry rows — immune to skeleton sibling count. */
|
||||
#entries-body > .entry-row-outer--light { background: var(--paper-3); }
|
||||
#entries-body > .entry-row-outer--dark { background: #f2ede5; }
|
||||
#entries-body > div.is-selected {
|
||||
background: #eee2d2;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
}
|
||||
#entries-body > div.is-multi-selected {
|
||||
background: #eee2d2;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
}
|
||||
|
||||
.col-added { width: 162px; color: var(--muted); }
|
||||
|
|
@ -339,8 +341,7 @@ select {
|
|||
.source-icon > * { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
.source-icon svg { width: 100%; height: 100%; }
|
||||
.url-cell { color: #555b55; white-space: nowrap; text-overflow: ellipsis; word-break: break-all; }
|
||||
#entries-body .url-cell:hover,
|
||||
#entries-body .is-selected .url-cell { overflow: visible; white-space: normal; }
|
||||
#entries-body .url-cell:hover { overflow: visible; white-space: normal; }
|
||||
.type-pill { display: inline-block; padding: 2px 6px; background: #d8e3df; color: #275a5f; border: 1px solid #bfd0ca; border-radius: var(--r); }
|
||||
|
||||
/* ── Multi-select checkbox column ───────────────────────────────────────── */
|
||||
|
|
@ -890,6 +891,7 @@ select {
|
|||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.capture-quality-hint--error { color: #c05000; font-style: normal; }
|
||||
|
||||
/* Status dot */
|
||||
.cap-dot {
|
||||
|
|
@ -2726,3 +2728,254 @@ body.has-audio-bar { padding-bottom: 56px; }
|
|||
@media (pointer: coarse) {
|
||||
.skeleton-row .col-added { padding-left: 10px; }
|
||||
}
|
||||
|
||||
/* ── Child entry expansion ───────────────────────────────────────────────── */
|
||||
|
||||
/* Outer wrapper: one block per entry-group so nth-child stays correct.
|
||||
#entries-body > .entry-row-outer beats #entries-body > div on specificity
|
||||
(id + class > id + element) so display:flex is safely overridden. */
|
||||
#entries-body > .entry-row-outer {
|
||||
display: block;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Inner flex row: replicates the #entries-body > div row behaviour. */
|
||||
#entries-body > .entry-row-outer > .entry-row-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
/* Reset padding that #entries-body > div > div would otherwise apply. */
|
||||
padding: 0;
|
||||
flex-shrink: unset;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Column cells inside the inner row. */
|
||||
#entries-body > .entry-row-outer > .entry-row-main > div {
|
||||
padding: 7px 10px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
#entries-body > .entry-row-outer > .entry-row-main > div:last-child { padding-right: 22px; }
|
||||
#entries-body > .entry-row-outer > .entry-row-main .col-added { padding-left: 22px; }
|
||||
|
||||
|
||||
/* Selection: class lives on outer wrapper; background + stroke scoped to inner row
|
||||
so expanded child entries don't inherit the selection highlight. */
|
||||
#entries-body > .entry-row-outer.is-selected { background: unset; outline: none; box-shadow: none; }
|
||||
#entries-body > .entry-row-outer.is-multi-selected { background: unset; outline: none; box-shadow: none; }
|
||||
#entries-body > .entry-row-outer.is-selected > .entry-row-main {
|
||||
background: #eee2d2;
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
}
|
||||
#entries-body > .entry-row-outer.is-multi-selected > .entry-row-main {
|
||||
background: #eee2d2;
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
}
|
||||
|
||||
/* URL overflow on hover only — explicit pointer, not selection. */
|
||||
#entries-body > .entry-row-outer .url-cell:hover { overflow: visible; white-space: normal; }
|
||||
|
||||
/* child-entries container: reset the padding that #entries-body > div > div applies. */
|
||||
#entries-body > .entry-row-outer > .child-entries {
|
||||
display: block;
|
||||
padding: 0;
|
||||
flex-shrink: unset;
|
||||
overflow: visible;
|
||||
border-left: 2px solid var(--line-soft);
|
||||
margin-left: 32px;
|
||||
}
|
||||
|
||||
/* Each child row reuses the same .col-* flex widths as normal rows. */
|
||||
.child-entry-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
opacity: 0.88;
|
||||
}
|
||||
.child-entry-row:hover { opacity: 1; }
|
||||
.child-entry-row:last-child { border-bottom: none; }
|
||||
.child-entry-row--light { background: #fafaf8; }
|
||||
.child-entry-row--dark { background: #f2f0ec; }
|
||||
.child-entry-row.is-selected {
|
||||
background: #eee2d2;
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
opacity: 1;
|
||||
}
|
||||
.child-entry-row.is-multi-selected {
|
||||
background: #eee2d2;
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
opacity: 1;
|
||||
}
|
||||
.child-entry-row > div {
|
||||
padding: 6px 10px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.child-entry-row .col-added { padding-left: 22px; }
|
||||
.child-entry-row > div:last-child { padding-right: 22px; }
|
||||
|
||||
/* Expand chevron button */
|
||||
.entry-expand-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
margin-right: 2px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
opacity: 0.45;
|
||||
color: inherit;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.entry-expand-btn:focus { outline: none; }
|
||||
.entry-expand-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; }
|
||||
.entry-expand-btn::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 6px solid currentColor;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.entry-expand-btn:hover { opacity: 1; }
|
||||
.entry-expand-btn.is-expanded::before { transform: rotate(90deg); }
|
||||
|
||||
/* Child count badge next to title */
|
||||
.child-count-badge {
|
||||
display: inline-block;
|
||||
margin-left: 5px;
|
||||
padding: 0 5px;
|
||||
font-size: 0.72em;
|
||||
font-weight: 600;
|
||||
background: color-mix(in srgb, var(--line-soft) 60%, transparent);
|
||||
border-radius: 10px;
|
||||
opacity: 0.75;
|
||||
vertical-align: middle;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Loading placeholder inside child-entries */
|
||||
.child-entries-loading {
|
||||
padding: 8px 12px;
|
||||
font-size: 0.85em;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* ── Playlist quality expansion ──────────────────────────── */
|
||||
.capture-playlist-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.capture-playlist-toggle:hover { color: var(--text); }
|
||||
.capture-playlist-toggle--left {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 4px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.capture-conflict-badge {
|
||||
font-size: 0.7rem;
|
||||
color: #c07000;
|
||||
background: #fff3cd;
|
||||
border: 1px solid #e0a000;
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.capture-playlist-items {
|
||||
border-top: 1px solid var(--line-soft);
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--line) transparent;
|
||||
}
|
||||
|
||||
.capture-playlist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 10px 5px 14px;
|
||||
font-size: 0.8rem;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.capture-playlist-item:last-of-type { border-bottom: none; }
|
||||
.capture-playlist-item--conflict {
|
||||
border-left-color: #c07000;
|
||||
background: color-mix(in srgb, #c07000 6%, transparent);
|
||||
}
|
||||
.capture-playlist-item-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.capture-item-quality {
|
||||
font-size: 0.73rem;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
background: var(--paper-3);
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.capture-playlist-conflict-badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: #c07000;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.capture-playlist-item-remove {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.1s, color 0.1s;
|
||||
}
|
||||
.capture-playlist-item:hover .capture-playlist-item-remove,
|
||||
.capture-playlist-item-remove:focus-visible { opacity: 1; }
|
||||
.capture-playlist-item-remove:hover { color: #c04000; opacity: 1; }
|
||||
@media (pointer: coarse) { .capture-playlist-item-remove { opacity: 1; } }
|
||||
.capture-playlist-item-remove { font-size: 0.75rem; line-height: 1; }
|
||||
|
||||
.capture-sync-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
border-top: 1px solid var(--line-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
.capture-sync-row input[type=checkbox] { cursor: pointer; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue