지문을 오디오 우선으로 — 네트워크 소스에서 읽는 양을 크게 줄인다
CI / windows-x64 (push) Canceled after 0s
CI / windows-arm64-cross (push) Canceled after 0s
CI / windows-arm64-native (push) Canceled after 0s
CI / macos (push) Canceled after 0s

26,000편(26TB, SMB) 라이브러리에서 지문 생성이 하루에 10%밖에 진행되지 않았다.
원인은 구조다: 프레임 지문을 만들려고 **파일을 처음부터 끝까지 읽는다**(실측 96.8%).
동시 실행을 2→8로 올려도 차이가 없었다 — 대역 병목이라 읽는 양을 줄이는 것 말고는 방법이 없다.

오디오 지문은 `-vn`으로 영상 청크를 건너뛰어 파일의 일부만 읽는다(컨테이너별 실측):
  mp4 0.4% · mov 0.4% · ts 0.3% · mkv 1.6%   ← 전체 패스 96.8%

그래서 3단계로 나눴다.
- ① 오디오 지문을 전 파일에 먼저 (sig_state=3). 26TB가 아니라 100~400GB를 읽는다.
- ② 저장된 지문으로 후보 쌍을 뽑아 걸린 파일을 표시 (sig_state=4). 파일을 읽지 않는다.
- ③ 프레임은 우선순위대로 — 오디오 짝 있음(4) → 소리 없음(0) → 짝 없음(3).
     중복일 가능성이 높은 것부터 처리하므로 결과가 며칠이 아니라 몇 분 뒤부터 나온다.

후보 판정은 탐지와 같은 함수(candidates_from_tokens)를 쓴다 — 규칙이 갈라지면
"지문 단계에서는 후보였는데 탐지에서는 아니다"는 설명 불가능한 상태가 생긴다.

측정으로 정한 것들:
- 오디오만으로는 그룹이 **0개**다(시각 확인이 프레임을 요구한다). ②③이 없으면 무의미하다.
- 오디오는 배속·피치 변화에 무너진다. 216개 토큰 중 공통:
    워터마크 216 · 레터박스 216 · 재인코딩 211 · 중간자르기 22/59
    1.25배속 3 · 피치+6% 8 · atempo1.05 40/205
  배속 사본은 프레임 경로가 뒤에서 채운다(그쪽에 배속 후보 로직이 있다).
  근거를 tests/audio_speed_evidence.rs에 고정했다.
- 절감 크기는 중복률에 비례한다. 픽스처는 10편 중 8편이 파생 사본이라 20%만 줄지만,
  실제 라이브러리에서 짝이 5%면 26TB가 1.4TB가 된다. 그 비율은 ②가 로그로 알려준다.

함께 고친 것 — 편집 백업이 영구히 쌓이던 문제
- 자르기·반전은 원본을 .archive_trash/edits에 남기는데 지우는 코드가 없었다.
  되돌리기는 최근 배치만 보므로 그 창을 벗어난 백업은 되돌릴 수도 없이 자리만 차지했다.
  미디어 드라이브에 쌓이므로 영상 라이브러리에서는 수십 GB가 된다.
- 창(20배치)을 벗어난 것만 지운다. 앱 시작 시·편집할 때마다·사이드바 버튼으로 돈다.
- 백업이 사라진 배치는 되돌리기 후보에서 뺀다 — Ctrl+Z가 조용히 실패하면 이유를 알 수 없다.
- 사이드바 정리 영역에 「편집 백업 N개 · N MB」로 용량을 노출한다.

빌드: profile.dev에서 의존성 디버그 정보를 뺐다(PDB 200여 개가 시간·디스크를 크게 먹는다).
자체 크레이트는 line-tables-only — 패닉 위치는 그대로 안다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ncakanghan
2026-08-03 18:04:23 +09:00
co-authored by Claude Opus 5
parent 8aa74c1a8d
commit 4be0235d2f
11 changed files with 761 additions and 4 deletions
@@ -844,7 +844,10 @@ fn audio_candidates(sigs: &[&VideoSig]) -> Vec<(usize, usize, u32)> {
}
/// 값 토큰 역색인 → 공통 토큰이 충분한 쌍. 프레임·오디오가 같은 규칙을 쓴다.
fn candidates_from_tokens<I: Iterator<Item = u64>>(
///
/// `sigq`의 오디오 우선 단계도 이 함수를 쓴다 — 후보 판정 규칙이 두 곳에서 갈라지면
/// "지문 단계에서는 후보였는데 탐지에서는 아니다" 같은 설명 불가능한 상태가 생긴다.
pub(crate) fn candidates_from_tokens<I: Iterator<Item = u64>>(
len: usize,
tokens: impl Fn(usize) -> I,
) -> Vec<(usize, usize, u32)> {
@@ -366,8 +366,11 @@ pub struct UndoBatch {
pub fn list_batches(db: &Db, limit: i64) -> Result<Vec<UndoBatch>> {
Ok(db.with_read(move |conn| {
let mut stmt = conn.prepare_cached(
// 되돌릴 수 있는가: trash/delete가 없고, 백업이 정리된 편집도 없어야 한다.
// 백업이 사라진 배치를 후보로 내주면 Ctrl+Z가 조용히 실패한다.
"SELECT batch_id, count(*), group_concat(DISTINCT op), max(performed_at),
sum(CASE WHEN op IN ('trash','delete') THEN 1 ELSE 0 END) = 0
AND sum(CASE WHEN payload = 'restore-gone' THEN 1 ELSE 0 END) = 0
FROM ops_journal WHERE undone = 0
GROUP BY batch_id ORDER BY max(performed_at) DESC LIMIT ?1",
)?;
@@ -492,6 +495,12 @@ pub fn undo_batch(
summary.failed += 1;
continue;
};
// 백업이 정리된 편집은 되돌릴 수 없다. list_batches가 후보에서 빼지만
// 그 사이에 정리가 돌 수 있으므로 여기서도 막는다.
if tok == BACKUP_GONE {
summary.failed += 1;
continue;
}
// 자르기는 역연산이 없다 — 옮겨 둔 원본을 제자리로 되돌린다
if tok == "restore" {
let Some(bk) = e.dst_path.as_deref() else {
@@ -759,6 +768,65 @@ mod tests {
assert_eq!((s.done, s.skipped, s.failed), (0, 1, 0));
}
/// 되돌릴 수 없게 된 편집 백업은 정리하고, 최근 것은 남긴다.
///
/// 이 함수가 없어서 자르기·반전한 원본이 미디어 드라이브에 영원히 쌓였다 —
/// 영상 라이브러리에서는 수십 GB가 된다.
#[test]
fn prune_removes_only_unreachable_backups() {
let (_d, media, db, _sid) = setup();
let bkdir = media.path().join(APP_TRASH_DIR).join(EDIT_BACKUP_DIR);
std::fs::create_dir_all(&bkdir).unwrap();
// 최근 배치 하나 + 아주 오래된 배치 하나를 만든다
let fresh = bkdir.join("fresh.jpg");
let old = bkdir.join("old.jpg");
std::fs::write(&fresh, vec![7u8; 1000]).unwrap();
std::fs::write(&old, vec![7u8; 2000]).unwrap();
let (f, o) = (fresh.to_string_lossy().into_owned(), old.to_string_lossy().into_owned());
db.with_write(move |conn| {
// performed_at을 직접 준다 — 오래된 쪽이 창 밖으로 밀려나야 한다
conn.execute(
"INSERT INTO ops_journal(batch_id, op, file_id, dst_path, payload, performed_at)
VALUES ('b-new', 'edit', 1, ?1, 'restore', 9000)",
[f],
)?;
conn.execute(
"INSERT INTO ops_journal(batch_id, op, file_id, dst_path, payload, performed_at)
VALUES ('b-old', 'edit', 2, ?1, 'restore', 1)",
[o],
)?;
// 창(20개)을 넘기려면 사이에 배치가 더 있어야 한다
for i in 0..25 {
conn.execute(
"INSERT INTO ops_journal(batch_id, op, file_id, performed_at)
VALUES (?1, 'tag', 1, ?2)",
rusqlite::params![format!("filler-{i}"), 100 + i],
)?;
}
Ok(())
})
.unwrap();
let (n, bytes) = super::edit_backup_stats(&db).unwrap();
assert_eq!((n, bytes), (2, 3000), "정리 전 백업 2개 3000바이트");
let (removed, freed) = super::prune_edit_backups(&db).unwrap();
assert_eq!((removed, freed), (1, 2000), "오래된 것 하나만 지워야 한다");
assert!(fresh.is_file(), "최근 백업을 지웠다");
assert!(!old.is_file(), "오래된 백업이 남았다");
// 지운 항목은 되돌리기 후보에서 빠져야 한다 — 조용히 실패하면 안 된다
let batches = list_batches(&db, 40).unwrap();
let gone = batches.iter().find(|b| b.batch_id == "b-old").expect("기록은 남는다");
assert!(!gone.undoable, "백업이 없는데 되돌릴 수 있다고 한다");
let keep = batches.iter().find(|b| b.batch_id == "b-new").unwrap();
assert!(keep.undoable, "최근 편집은 되돌릴 수 있어야 한다");
// 두 번 돌려도 같은 것을 다시 세지 않는다
assert_eq!(super::prune_edit_backups(&db).unwrap().0, 0);
}
#[test]
fn trash_tag_marks_and_lists_pending() {
let (_d, _m, db, _sid) = setup();
@@ -949,6 +1017,8 @@ pub fn edit_files(
}
}
on_progress(total, total);
// 반전은 백업을 남기므로 여기서도 옛 백업을 치운다 (회전만 했으면 지울 게 없다)
let _ = prune_edit_backups(db);
Ok(sum)
}
@@ -1118,6 +1188,9 @@ pub fn cut_file(
)?;
tx.commit()
})?;
// 새 백업을 만든 참에 되돌릴 수 없게 된 옛 백업을 치운다 —
// 따로 청소 시점을 두면 그 시점이 영영 오지 않는 경우가 생긴다.
let _ = prune_edit_backups(db);
Ok(r)
}
@@ -1166,3 +1239,107 @@ fn restore_from_backup(db: &Db, file_id: i64, backup: &str) -> Result<()> {
})?;
Ok(())
}
/// 백업을 남겨 두는 배치 수.
///
/// `undo_last`는 최근 배치 하나만 되돌리지만, 목록을 훑어 고르는 UI가 붙을 수 있으니
/// [`list_batches`]가 노출하는 10개보다 넉넉하게 잡는다. 이 창을 벗어난 백업은
/// **되돌릴 방법이 없는데 자리만 차지한다** — 영상 라이브러리에서는 그게 수십 GB가 된다.
const BACKUP_KEEP_BATCHES: i64 = 20;
/// 백업이 사라진 편집 항목의 표식. `payload`에 넣어 되돌리기 후보에서 빼고,
/// 되돌리기를 시도했을 때 이유를 말할 수 있게 한다.
pub const BACKUP_GONE: &str = "restore-gone";
/// 편집 백업 정리 — 되돌릴 수 없게 된 것만 지운다.
///
/// 자르기·반전은 원본을 `.archive_trash/edits`에 남긴다. 되돌리면 그 파일이 제자리로
/// 돌아가지만(자르기) 반전은 복사본이라 남고, 아예 되돌리지 않으면 영원히 쌓인다.
/// **지우는 코드가 없어서 미디어 드라이브가 차던 문제를 여기서 닫는다.**
///
/// 반환값은 (지운 파일 수, 회수한 바이트).
pub fn prune_edit_backups(db: &Db) -> Result<(u64, u64)> {
// 최근 배치는 남긴다 — 그 안에 있는 것만 실제로 되돌릴 수 있다
let stale: Vec<(i64, String)> = db.with_read(|conn| {
let mut stmt = conn.prepare(
"SELECT id, dst_path FROM ops_journal
WHERE op = 'edit' AND payload = 'restore' AND dst_path IS NOT NULL
AND batch_id NOT IN (
SELECT batch_id FROM ops_journal
GROUP BY batch_id ORDER BY max(performed_at) DESC LIMIT ?1
)",
)?;
let rows = stmt.query_map([BACKUP_KEEP_BATCHES], |r| Ok((r.get(0)?, r.get(1)?)))?;
rows.collect()
})?;
// 되돌리기로 이미 소진된 백업(파일이 없다)도 표식을 정리한다 — 안 하면 매번 다시 훑는다
let consumed: Vec<i64> = db.with_read(|conn| {
let mut stmt = conn.prepare(
"SELECT id, dst_path FROM ops_journal
WHERE op = 'edit' AND payload = 'restore' AND dst_path IS NOT NULL AND undone = 1",
)?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
Ok(rows
.filter_map(|x| x.ok())
.filter(|(_, p)| !Path::new(p).exists())
.map(|(id, _)| id)
.collect::<Vec<_>>())
})?;
let mut removed = 0u64;
let mut bytes = 0u64;
let mut ids: Vec<i64> = consumed;
for (id, path) in stale {
let p = Path::new(&path);
if let Ok(m) = std::fs::metadata(p) {
if std::fs::remove_file(p).is_ok() {
removed += 1;
bytes += m.len();
} else {
// 지울 수 없으면 표식도 바꾸지 않는다 — 다음에 다시 시도한다
continue;
}
}
ids.push(id);
}
if !ids.is_empty() {
db.with_write(move |conn| {
let tx = conn.transaction()?;
for id in &ids {
tx.execute(
"UPDATE ops_journal SET payload = ?2, dst_path = NULL WHERE id = ?1",
rusqlite::params![id, BACKUP_GONE],
)?;
}
tx.commit()
})?;
}
if removed > 0 {
tracing::info!(removed, mb = bytes / 1_048_576, "편집 백업 정리");
}
Ok((removed, bytes))
}
/// 지금 남아 있는 편집 백업의 개수와 용량. '정리' 화면에서 보여준다 —
/// 눈에 보이지 않는 용량은 사용자가 손댈 수 없다.
pub fn edit_backup_stats(db: &Db) -> Result<(u64, u64)> {
let paths: Vec<String> = db.with_read(|conn| {
let mut stmt = conn.prepare(
"SELECT dst_path FROM ops_journal
WHERE op = 'edit' AND payload = 'restore' AND dst_path IS NOT NULL",
)?;
let rows = stmt.query_map([], |r| r.get(0))?;
rows.collect()
})?;
let mut n = 0u64;
let mut bytes = 0u64;
for p in paths {
if let Ok(m) = std::fs::metadata(&p) {
n += 1;
bytes += m.len();
}
}
Ok((n, bytes))
}
+206 -3
View File
@@ -115,9 +115,11 @@ pub fn pending_counts(db: &Db) -> Result<(i64, i64), archive_db::DbError> {
[],
|r| r.get(0),
)?;
// 오디오만 끝난 상태(3)도 '아직 남은 것'으로 센다 — 프레임이 없으면 시각 확인이
// 안 되므로 사용자에게 '완료'라고 말할 수 없다.
let vid: i64 = conn.query_row(
"SELECT count(*) FROM files
WHERE sig_state = 0 AND kind = 1 AND deleted_at IS NULL",
WHERE sig_state IN (0, 3, 4) AND kind = 1 AND deleted_at IS NULL",
[],
|r| r.get(0),
)?;
@@ -283,7 +285,134 @@ fn image_sig(thumb: &Path) -> Option<Computed> {
// ── 영상 ─────────────────────────────────────────────────
/// `sig_state=0`인 영상의 프레임 시퀀스 지문을 채운다. ffmpeg 한 프로세스/영상.
/// 오디오 지문만 만들어진 상태 — 프레임은 아직 없다.
///
/// **왜 단계를 나누는가.** 프레임 지문은 초당 1장을 뽑느라 파일을 처음부터 끝까지 읽는다
/// (실측 96.8%). 오디오 지문은 `-vn`으로 영상 청크를 건너뛰어 **0.4~1.6%만** 읽는다
/// (mp4 0.4 · mov 0.4 · ts 0.3 · mkv 1.6 — 컨테이너별 실측).
///
/// 네트워크 소스에서는 이 차이가 전부다. 26TB 라이브러리를 전부 읽으면 며칠이고,
/// 오디오만 읽으면 100GB 남짓이라 한 시간이면 끝난다. 오디오 지문만 있어도
/// **중간을 잘라낸 사본**은 chromaprint 오프셋 정렬로 후보가 되므로,
/// 사용자는 며칠을 기다리지 않고 그 시점부터 중복을 찾을 수 있다.
pub const SIG_STATE_AUDIO_ONLY: i64 = 3;
/// 영상의 **오디오 지문만** 먼저 채운다 — 파일의 1% 남짓만 읽는다.
///
/// 소리가 없는 파일은 건너뛴다(상태를 그대로 0으로 둬서 프레임 단계가 집어간다).
pub fn run_video_audio_sigs(
db: &Arc<Db>,
tools: &FfTools,
cancel: &AtomicBool,
pause: &AtomicBool,
) -> Result<u64, archive_db::DbError> {
let mut total = 0u64;
loop {
if !wait_if_paused(cancel, pause) {
break;
}
// 오디오가 있는 것만. `acodec`은 메타가 채우므로 meta_state를 요구한다.
let batch: Vec<VideoJob> = db.with_read(|conn| {
let mut stmt = conn.prepare_cached(
"SELECT f.id, fo.path, f.name, f.duration_ms FROM files f
JOIN folders fo ON fo.id = f.folder_id
WHERE f.sig_state = 0 AND f.kind = 1 AND f.meta_state <> 0
AND f.acodec IS NOT NULL AND f.deleted_at IS NULL
LIMIT ?1",
)?;
let rows = stmt.query_map([BATCH as i64], |r| {
let dir: String = r.get(1)?;
let name: String = r.get(2)?;
Ok(VideoJob {
id: r.get(0)?,
path: Path::new(&dir).join(name),
duration_ms: r.get(3)?,
has_audio: true,
})
})?;
rows.collect()
})?;
if batch.is_empty() {
break;
}
let workers = read_speed(db).workers();
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(workers)
.start_handler(|_| crate::prio::set_current_thread_low())
.build()
.ok();
let compute = || -> Vec<(i64, Option<i64>, Vec<u32>)> {
batch
.par_iter()
.map(|j| {
if cancel.load(Ordering::Relaxed) {
return (j.id, j.duration_ms, Vec::new());
}
let secs = j.duration_ms.unwrap_or(0).max(0) as u64 / 1000;
// 오디오만 읽으므로 전체 패스보다 훨씬 짧게 잡아도 된다
let timeout = Duration::from_secs((secs).clamp(30, 600));
let fp = crate::ffmpeg::audio_fingerprint(tools, &j.path, timeout)
.unwrap_or_default();
(j.id, j.duration_ms, fp)
})
.collect()
};
let results = match &pool {
Some(p) => p.install(compute),
None => compute(),
};
if cancel.load(Ordering::Relaxed) {
break;
}
let n = results.len() as u64;
db.with_write(move |conn| {
let tx = conn.transaction()?;
{
// 프레임 칸은 비워 둔 채 오디오만 넣는다. 프레임 단계가 나중에 UPDATE로 채운다 —
// 그쪽에서 INSERT OR REPLACE를 쓰면 여기서 넣은 오디오가 날아간다.
let mut ins = tx.prepare_cached(
"INSERT OR REPLACE INTO file_sigs
(file_id, sig_version, kind, dhash, phash, frame_count, duration_ms, flags, audio)
VALUES (?1, ?2, 1, 0, 0, 0, ?3, 0, ?4)",
)?;
let mut upd = tx.prepare_cached("UPDATE files SET sig_state = ?2 WHERE id = ?1")?;
for (id, dur, fp) in &results {
if fp.is_empty() {
// 오디오를 못 뽑았다 — 상태를 그대로 두면 이 배치를 영원히 다시 집는다.
// 프레임 단계로 넘겨 거기서 판정하게 한다.
continue;
}
// 프레임 단계와 **같은 변환**을 거쳐야 한다 — 앵커 선택률까지 같아야
// 두 경로에서 만든 지문이 서로 비교된다.
ins.execute(rusqlite::params![
id,
sigmatch::SIG_VERSION,
dur,
sigmatch::pack_audio(&audio_anchors(fp))
])?;
upd.execute(rusqlite::params![id, SIG_STATE_AUDIO_ONLY])?;
}
// 오디오 실패분은 프레임 단계가 집도록 표시한다(무한 재시도 방지)
let mut fail = tx.prepare_cached("UPDATE files SET sig_state = ?2 WHERE id = ?1")?;
for (id, _, fp) in &results {
if fp.is_empty() {
fail.execute(rusqlite::params![id, SIG_STATE_AUDIO_ONLY])?;
}
}
}
tx.commit()
})?;
total += n;
}
Ok(total)
}
/// 영상의 프레임 시퀀스 지문을 채운다 — **파일 전체를 읽는다**. ffmpeg 한 프로세스/영상.
///
/// 오디오 단계가 끝난 뒤(`sig_state=3`) 또는 소리가 없어 그 단계를 건너뛴 파일(`0`)을 집는다.
pub fn run_video_sigs(
db: &Arc<Db>,
tools: &FfTools,
@@ -303,7 +432,11 @@ pub fn run_video_sigs(
let mut stmt = conn.prepare_cached(
"SELECT f.id, fo.path, f.name, f.duration_ms, f.acodec FROM files f
JOIN folders fo ON fo.id = f.folder_id
WHERE f.sig_state = 0 AND f.kind = 1 AND f.meta_state <> 0 AND f.deleted_at IS NULL
WHERE f.sig_state IN (0, 3, 4) AND f.kind = 1 AND f.meta_state <> 0
AND f.deleted_at IS NULL
-- 오디오 짝이 있는 것(4)부터, 그다음 소리 없는 것(0),
-- 짝이 없는 것(3)은 맨 뒤. 중복일 가능성이 높은 것부터 결과를 낸다.
ORDER BY CASE f.sig_state WHEN 4 THEN 0 WHEN 0 THEN 1 ELSE 2 END, f.id
LIMIT ?1",
)?;
let rows = stmt.query_map([BATCH as i64], |r| {
@@ -709,3 +842,73 @@ mod tests {
);
}
}
// ── 오디오 후보 우선 프레임 채우기 ───────────────────────
/// 오디오 짝이 있어 **프레임을 먼저 만들어야 하는** 상태.
///
/// 오디오 지문만으로는 그룹이 하나도 안 만들어진다(픽스처로 측정했다) — 시각 확인 단계가
/// 프레임을 요구하기 때문이다. 그렇다고 26,000편의 프레임을 다 만들면 며칠이 걸린다.
///
/// 그래서 **오디오가 겹친 파일만** 먼저 프레임을 만든다. 중복일 가능성이 있는 것부터
/// 처리하므로 결과가 며칠이 아니라 몇 분 뒤부터 나오기 시작한다.
pub const SIG_STATE_FRAMES_WANTED: i64 = 4;
/// 저장된 오디오 지문으로 후보 쌍을 찾아, 걸린 파일을 프레임 우선 대상으로 표시한다.
///
/// **파일을 읽지 않는다** — DB에 있는 지문만 본다. 26,000편이라도 초 단위다.
/// 반환: 우선 대상으로 새로 표시한 파일 수.
pub fn mark_audio_candidates(db: &Arc<Db>) -> Result<u64, archive_db::DbError> {
// 오디오만 끝난 행을 모은다. 이미 프레임이 있는 것(state 1)은 볼 이유가 없다.
let rows: Vec<(i64, Vec<u32>)> = db.with_read(|conn| {
let mut stmt = conn.prepare(
"SELECT s.file_id, s.audio FROM file_sigs s
JOIN files f ON f.id = s.file_id
WHERE f.sig_state = ?1 AND f.deleted_at IS NULL
AND s.sig_version = ?2 AND s.audio IS NOT NULL",
)?;
let it = stmt.query_map(rusqlite::params![SIG_STATE_AUDIO_ONLY, sigmatch::SIG_VERSION], |r| {
let blob: Vec<u8> = r.get::<_, Option<Vec<u8>>>(1)?.unwrap_or_default();
Ok((r.get::<_, i64>(0)?, sigmatch::unpack_audio(&blob).into_iter().map(|(_, v)| v).collect()))
})?;
it.collect()
})?;
if rows.len() < 2 {
return Ok(0);
}
// 탐지와 **같은 함수**로 후보를 뽑는다 — 규칙이 갈라지면 설명할 수 없는 차이가 생긴다
let pairs = crate::dedupe::candidates_from_tokens(rows.len(), |i| {
rows[i].1.iter().map(|v| *v as u64)
});
if pairs.is_empty() {
return Ok(0);
}
let mut wanted: Vec<i64> = Vec::new();
for (a, b, _) in &pairs {
wanted.push(rows[*a].0);
wanted.push(rows[*b].0);
}
wanted.sort_unstable();
wanted.dedup();
let n = wanted.len() as u64;
db.with_write(move |conn| {
let tx = conn.transaction()?;
{
let mut upd = tx.prepare_cached(
"UPDATE files SET sig_state = ?2 WHERE id = ?1 AND sig_state = ?3",
)?;
for id in &wanted {
upd.execute(rusqlite::params![
id,
SIG_STATE_FRAMES_WANTED,
SIG_STATE_AUDIO_ONLY
])?;
}
}
tx.commit()
})?;
tracing::info!(pairs = pairs.len(), files = n, "오디오 후보 — 프레임 우선 대상 표시");
Ok(n)
}