Fix library filter, compact dashboard/credentials UI, add fuzzy audio dedup
- beets_service: filter params now use truthy checks instead of `is not None`, since a real <select> left on "(any)" submits an empty string, not an absent param -- the filter form silently matched zero rows for any real submission. Also match grouping tokens within "; "-joined multi-playlist values instead of requiring an exact string match. - dashboard: credential status renders as compact dot indicators instead of badge+text chips, so long scope names (telegram) stay on one line. - credentials page: two-column grid layout instead of one long column. - find-fuzzy-dupes.py: add --json/--only-paths flags matching dedup-library.sh's convention, so it can plug into the same review queue. - dedup_review_service: add scan_fuzzy() and route confirm_and_apply() to the correct underlying script (dedup-library.sh vs find-fuzzy-dupes.py) per candidate's pass_name, since tag-based passes miss duplicates whose tags differ even when the audio is identical (e.g. a remix credited to different artists between two copies). - dedup page: add a "Scan for audio duplicates" trigger alongside the existing tag-based scan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,12 @@ async def scan(user: dict = Depends(require_auth)):
|
|||||||
return RedirectResponse(url="/dedup", status_code=303)
|
return RedirectResponse(url="/dedup", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scan-fuzzy")
|
||||||
|
async def scan_fuzzy(user: dict = Depends(require_auth)):
|
||||||
|
await dedup_review_service.scan_fuzzy(triggered_by="manual")
|
||||||
|
return RedirectResponse(url="/dedup", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/confirm")
|
@router.post("/confirm")
|
||||||
async def confirm(request: Request, user: dict = Depends(require_auth)):
|
async def confirm(request: Request, user: dict = Depends(require_auth)):
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
|
|||||||
@@ -34,6 +34,40 @@ def _row_to_dict(row: sqlite3.Row) -> dict:
|
|||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _grouping_clause(grouping: str) -> tuple[str, list]:
|
||||||
|
"""grouping is a single tag OR a "; "-joined set (e.g. "techno; djstuff"
|
||||||
|
for a track that's in two playlists' libraries). Matching only the exact
|
||||||
|
combined string would silently exclude combo-tagged tracks from a
|
||||||
|
single-tag filter -- match the token in any position instead."""
|
||||||
|
return (
|
||||||
|
"(grouping = ? OR grouping LIKE ? OR grouping LIKE ? OR grouping LIKE ?)",
|
||||||
|
[grouping, f"{grouping}; %", f"%; {grouping}", f"%; {grouping}; %"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_where(grouping: str | None, search: str | None, format: str | None) -> tuple[str, list]:
|
||||||
|
where = []
|
||||||
|
params: list = []
|
||||||
|
# Truthy checks, not `is not None`: a <select> filter left on its
|
||||||
|
# "(any)" option submits an EMPTY STRING, not an absent param -- every
|
||||||
|
# real form submission includes it. `is not None` treated that as "filter
|
||||||
|
# to grouping/format == ''", which matches nothing and made every filter
|
||||||
|
# combination except an all-fields-filled one look completely broken.
|
||||||
|
if grouping:
|
||||||
|
clause, clause_params = _grouping_clause(grouping)
|
||||||
|
where.append(clause)
|
||||||
|
params.extend(clause_params)
|
||||||
|
if format:
|
||||||
|
where.append("format = ?")
|
||||||
|
params.append(format)
|
||||||
|
if search:
|
||||||
|
where.append("(artist LIKE ? OR title LIKE ? OR albumartist LIKE ?)")
|
||||||
|
like = f"%{search}%"
|
||||||
|
params.extend([like, like, like])
|
||||||
|
where_sql = f"WHERE {' AND '.join(where)}" if where else ""
|
||||||
|
return where_sql, params
|
||||||
|
|
||||||
|
|
||||||
def query_items(
|
def query_items(
|
||||||
grouping: str | None = None,
|
grouping: str | None = None,
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
@@ -48,23 +82,11 @@ def query_items(
|
|||||||
if not db_exists():
|
if not db_exists():
|
||||||
return []
|
return []
|
||||||
cols = ", ".join(_ITEM_COLUMNS)
|
cols = ", ".join(_ITEM_COLUMNS)
|
||||||
where = []
|
where_sql, params = _build_where(grouping, search, format)
|
||||||
params: list = []
|
|
||||||
if grouping is not None:
|
|
||||||
where.append("grouping = ?")
|
|
||||||
params.append(grouping)
|
|
||||||
if format is not None:
|
|
||||||
where.append("format = ?")
|
|
||||||
params.append(format)
|
|
||||||
if search:
|
|
||||||
where.append("(artist LIKE ? OR title LIKE ? OR albumartist LIKE ?)")
|
|
||||||
like = f"%{search}%"
|
|
||||||
params.extend([like, like, like])
|
|
||||||
where_sql = f"WHERE {' AND '.join(where)}" if where else ""
|
|
||||||
limit_sql = ""
|
limit_sql = ""
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
limit_sql = "LIMIT ? OFFSET ?"
|
limit_sql = "LIMIT ? OFFSET ?"
|
||||||
params.extend([limit, offset])
|
params = params + [limit, offset]
|
||||||
|
|
||||||
conn = _connect()
|
conn = _connect()
|
||||||
try:
|
try:
|
||||||
@@ -81,20 +103,7 @@ def count_items(grouping: str | None = None, search: str | None = None, format:
|
|||||||
"""Matching row count for query_items()'s filters -- for pagination."""
|
"""Matching row count for query_items()'s filters -- for pagination."""
|
||||||
if not db_exists():
|
if not db_exists():
|
||||||
return 0
|
return 0
|
||||||
where = []
|
where_sql, params = _build_where(grouping, search, format)
|
||||||
params: list = []
|
|
||||||
if grouping is not None:
|
|
||||||
where.append("grouping = ?")
|
|
||||||
params.append(grouping)
|
|
||||||
if format is not None:
|
|
||||||
where.append("format = ?")
|
|
||||||
params.append(format)
|
|
||||||
if search:
|
|
||||||
where.append("(artist LIKE ? OR title LIKE ? OR albumartist LIKE ?)")
|
|
||||||
like = f"%{search}%"
|
|
||||||
params.extend([like, like, like])
|
|
||||||
where_sql = f"WHERE {' AND '.join(where)}" if where else ""
|
|
||||||
|
|
||||||
conn = _connect()
|
conn = _connect()
|
||||||
try:
|
try:
|
||||||
return conn.execute(f"SELECT COUNT(*) FROM items {where_sql}", params).fetchone()[0]
|
return conn.execute(f"SELECT COUNT(*) FROM items {where_sql}", params).fetchone()[0]
|
||||||
@@ -130,10 +139,6 @@ def stats() -> dict:
|
|||||||
try:
|
try:
|
||||||
total = conn.execute("SELECT COUNT(*) FROM items").fetchone()[0]
|
total = conn.execute("SELECT COUNT(*) FROM items").fetchone()[0]
|
||||||
|
|
||||||
grouping_rows = conn.execute(
|
|
||||||
"SELECT DISTINCT grouping FROM items WHERE grouping IS NOT NULL AND grouping != ''"
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
format_rows = conn.execute(
|
format_rows = conn.execute(
|
||||||
"SELECT format, COUNT(*) as n FROM items "
|
"SELECT format, COUNT(*) as n FROM items "
|
||||||
"WHERE format IS NOT NULL AND format != '' "
|
"WHERE format IS NOT NULL AND format != '' "
|
||||||
@@ -147,7 +152,7 @@ def stats() -> dict:
|
|||||||
return {
|
return {
|
||||||
"db_exists": True,
|
"db_exists": True,
|
||||||
"total_tracks": total,
|
"total_tracks": total,
|
||||||
"groupings": sorted(r[0] for r in grouping_rows),
|
"groupings": distinct_groupings(conn),
|
||||||
"formats": [{"format": r["format"], "count": r["n"]} for r in format_rows],
|
"formats": [{"format": r["format"], "count": r["n"]} for r in format_rows],
|
||||||
"total_bytes": int(total_bytes),
|
"total_bytes": int(total_bytes),
|
||||||
}
|
}
|
||||||
@@ -155,6 +160,31 @@ def stats() -> dict:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def distinct_groupings(conn: sqlite3.Connection | None = None) -> list[str]:
|
||||||
|
"""Individual grouping tokens for the library page's filter dropdown --
|
||||||
|
split on "; " so a combo value like "techno; djstuff" contributes
|
||||||
|
"techno" and "djstuff" separately rather than cluttering the dropdown
|
||||||
|
with every raw combination that happens to exist in the DB."""
|
||||||
|
if not db_exists():
|
||||||
|
return []
|
||||||
|
owns_conn = conn is None
|
||||||
|
conn = conn or _connect()
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT DISTINCT grouping FROM items WHERE grouping IS NOT NULL AND grouping != ''"
|
||||||
|
).fetchall()
|
||||||
|
tokens = set()
|
||||||
|
for row in rows:
|
||||||
|
for part in row[0].split(";"):
|
||||||
|
part = part.strip()
|
||||||
|
if part:
|
||||||
|
tokens.add(part)
|
||||||
|
return sorted(tokens)
|
||||||
|
finally:
|
||||||
|
if owns_conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def distinct_formats() -> list[str]:
|
def distinct_formats() -> list[str]:
|
||||||
"""For the library page's format filter dropdown."""
|
"""For the library page's format filter dropdown."""
|
||||||
if not db_exists():
|
if not db_exists():
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ from app.services import pipeline_runner
|
|||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
||||||
_SCRIPT = "dedup-library.sh"
|
_SCRIPT = "dedup-library.sh"
|
||||||
|
_FUZZY_SCRIPT = "find-fuzzy-dupes.py"
|
||||||
|
_FUZZY_PASS = "fuzzy_audio"
|
||||||
|
|
||||||
|
|
||||||
|
def _script_for_pass(pass_name: str) -> str:
|
||||||
|
return _FUZZY_SCRIPT if pass_name == _FUZZY_PASS else _SCRIPT
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_lines(output: str) -> list[dict]:
|
def _parse_json_lines(output: str) -> list[dict]:
|
||||||
@@ -25,15 +31,10 @@ def _parse_json_lines(output: str) -> list[dict]:
|
|||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
async def scan(triggered_by: str = "manual") -> DedupRun:
|
async def _run_scan(job_key: str, script_name: str, triggered_by: str) -> DedupRun:
|
||||||
"""Dry-run dedup-library.sh --json, persist every candidate deletion
|
script = str(settings.pipeline_dir / "lib" / script_name)
|
||||||
into a fresh dedup_runs/dedup_candidates pair. Never deletes anything
|
|
||||||
-- the scheduled maintenance:dedup job also only ever calls this (no
|
|
||||||
--apply), matching the false-negative-biased dedup preference; actual
|
|
||||||
deletion only ever happens through confirm_and_apply() below."""
|
|
||||||
script = str(settings.pipeline_dir / "lib" / _SCRIPT)
|
|
||||||
job_run, output = await pipeline_runner.run_job_capture(
|
job_run, output = await pipeline_runner.run_job_capture(
|
||||||
"dedup:scan", [script, "--json"], triggered_by=triggered_by
|
job_key, [script, "--json"], triggered_by=triggered_by
|
||||||
)
|
)
|
||||||
candidates = _parse_json_lines(output)
|
candidates = _parse_json_lines(output)
|
||||||
|
|
||||||
@@ -69,19 +70,42 @@ async def scan(triggered_by: str = "manual") -> DedupRun:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def scan(triggered_by: str = "manual") -> DedupRun:
|
||||||
|
"""Dry-run dedup-library.sh --json, persist every candidate deletion
|
||||||
|
into a fresh dedup_runs/dedup_candidates pair. Never deletes anything
|
||||||
|
-- the scheduled maintenance:dedup job also only ever calls this (no
|
||||||
|
--apply), matching the false-negative-biased dedup preference; actual
|
||||||
|
deletion only ever happens through confirm_and_apply() below."""
|
||||||
|
return await _run_scan("dedup:scan", _SCRIPT, triggered_by)
|
||||||
|
|
||||||
|
|
||||||
|
async def scan_fuzzy(triggered_by: str = "manual") -> DedupRun:
|
||||||
|
"""Dry-run find-fuzzy-dupes.py --json. Tag-based passes (scan() above)
|
||||||
|
only catch duplicates whose artist/title tags overlap; this compares
|
||||||
|
Chromaprint audio fingerprints instead, so it also catches the same
|
||||||
|
recording filed under different tags (e.g. a remix credited to
|
||||||
|
different artists between two copies)."""
|
||||||
|
return await _run_scan("dedup:scan_fuzzy", _FUZZY_SCRIPT, triggered_by)
|
||||||
|
|
||||||
|
|
||||||
async def confirm_and_apply(candidate_ids: list[int], confirmed_by: str) -> DedupRun | None:
|
async def confirm_and_apply(candidate_ids: list[int], confirmed_by: str) -> DedupRun | None:
|
||||||
"""Apply only the confirmed candidate deletions.
|
"""Apply only the confirmed candidate deletions.
|
||||||
|
|
||||||
Cheap pre-check here: skip anything where delete_path or keep_path no
|
Cheap pre-check here: skip anything where delete_path or keep_path no
|
||||||
longer exists (something already changed it since the scan). The real
|
longer exists (something already changed it since the scan). The real
|
||||||
re-verification of ranking happens for free inside dedup-library.sh
|
re-verification of ranking happens for free inside the underlying
|
||||||
itself: --apply --only-paths re-runs all 4 passes from scratch and
|
script itself: --apply --only-paths re-runs its passes from scratch and
|
||||||
recomputes keep/delete for every group before consulting the only-paths
|
recomputes keep/delete for every group before consulting the only-paths
|
||||||
allowlist, so if a group's ranking flipped since the scan (e.g. the old
|
allowlist, so if a group's ranking flipped since the scan (e.g. the old
|
||||||
keep_path is gone and delete_path is now the last copy), the script's
|
keep_path is gone and delete_path is now the last copy), the script's
|
||||||
fresh pass will assign delete_path the KEEP role instead -- it never
|
fresh pass will assign delete_path the KEEP role instead -- it never
|
||||||
reaches a DELETE branch for it, so --only-paths naming it is simply
|
reaches a DELETE branch for it, so --only-paths naming it is simply
|
||||||
never consulted. No duplicate ranking logic needed here.
|
never consulted. No duplicate ranking logic needed here.
|
||||||
|
|
||||||
|
dedup-library.sh (tag-based passes) and find-fuzzy-dupes.py (acoustic
|
||||||
|
fingerprint pass) are separate scripts, each with its own --apply
|
||||||
|
--only-paths invocation -- candidates are grouped by pass_name and each
|
||||||
|
group is applied through the script that actually produced it.
|
||||||
"""
|
"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -102,16 +126,26 @@ async def confirm_and_apply(candidate_ids: list[int], confirmed_by: str) -> Dedu
|
|||||||
if not confirmed_candidates:
|
if not confirmed_candidates:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
confirm_file = settings.logs_dir / f"dedup-confirm-{int(now * 1000)}.txt"
|
by_script: dict[str, list[DedupCandidate]] = {}
|
||||||
confirm_file.parent.mkdir(parents=True, exist_ok=True)
|
for c in confirmed_candidates:
|
||||||
confirm_file.write_text("\n".join(c.delete_path for c in confirmed_candidates) + "\n")
|
by_script.setdefault(_script_for_pass(c.pass_name), []).append(c)
|
||||||
|
|
||||||
script = str(settings.pipeline_dir / "lib" / _SCRIPT)
|
finished_at = now
|
||||||
|
log_paths = []
|
||||||
|
for script_name, group in by_script.items():
|
||||||
|
confirm_file = settings.logs_dir / f"dedup-confirm-{int(now * 1000)}-{script_name}.txt"
|
||||||
|
confirm_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
confirm_file.write_text("\n".join(c.delete_path for c in group) + "\n")
|
||||||
|
|
||||||
|
script = str(settings.pipeline_dir / "lib" / script_name)
|
||||||
job_run, _output = await pipeline_runner.run_job_capture(
|
job_run, _output = await pipeline_runner.run_job_capture(
|
||||||
"dedup:apply",
|
"dedup:apply",
|
||||||
[script, "--apply", "--only-paths", str(confirm_file), "--json"],
|
[script, "--apply", "--only-paths", str(confirm_file), "--json"],
|
||||||
triggered_by=f"manual:{confirmed_by}",
|
triggered_by=f"manual:{confirmed_by}",
|
||||||
)
|
)
|
||||||
|
finished_at = job_run.finished_at
|
||||||
|
if job_run.log_path:
|
||||||
|
log_paths.append(job_run.log_path)
|
||||||
|
|
||||||
still_there = {c.delete_path for c in confirmed_candidates if Path(c.delete_path).exists()}
|
still_there = {c.delete_path for c in confirmed_candidates if Path(c.delete_path).exists()}
|
||||||
actually_deleted = 0
|
actually_deleted = 0
|
||||||
@@ -122,12 +156,12 @@ async def confirm_and_apply(candidate_ids: list[int], confirmed_by: str) -> Dedu
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
apply_run = DedupRun(
|
apply_run = DedupRun(
|
||||||
started_at=job_run.started_at,
|
started_at=now,
|
||||||
finished_at=job_run.finished_at,
|
finished_at=finished_at,
|
||||||
mode="apply",
|
mode="apply",
|
||||||
deleted=actually_deleted,
|
deleted=actually_deleted,
|
||||||
kept=len(confirmed_candidates) - actually_deleted,
|
kept=len(confirmed_candidates) - actually_deleted,
|
||||||
log_path=job_run.log_path,
|
log_path=";".join(log_paths) or None,
|
||||||
)
|
)
|
||||||
db.add(apply_run)
|
db.add(apply_run)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -491,6 +491,42 @@ tbody td.actions-cell { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
|||||||
|
|
||||||
.scope-header { display: flex; align-items: center; gap: 0.6rem; }
|
.scope-header { display: flex; align-items: center; gap: 0.6rem; }
|
||||||
|
|
||||||
|
/* Compact variant for the dashboard's credential-status row -- a dot
|
||||||
|
indicator instead of a full badge pill so all 7 scopes fit on one line
|
||||||
|
at normal desktop widths instead of the last one wrapping to its own row. */
|
||||||
|
.auth-chip-list li {
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--status-muted);
|
||||||
|
}
|
||||||
|
.status-dot-success { background: var(--status-success); box-shadow: 0 0 6px var(--status-success); }
|
||||||
|
.status-dot-muted { background: var(--status-muted); }
|
||||||
|
.status-dot-warning { background: var(--status-warning); box-shadow: 0 0 6px var(--status-warning); }
|
||||||
|
|
||||||
|
/* Credentials page: two columns on wide viewports instead of one long
|
||||||
|
single-column stack of 7 scopes. align-items: start so a taller form
|
||||||
|
(bandcamp's cookie textarea) doesn't stretch its row-mate. */
|
||||||
|
.cred-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0 2rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.cred-scope .panel { max-width: none; }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.cred-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1040px) {
|
@media (max-width: 1040px) {
|
||||||
.topbar-inner { flex-wrap: wrap; }
|
.topbar-inner { flex-wrap: wrap; }
|
||||||
.nav-pills { order: 3; width: 100%; flex-wrap: wrap; overflow: visible; row-gap: 0.3rem; }
|
.nav-pills { order: 3; width: 100%; flex-wrap: wrap; overflow: visible; row-gap: 0.3rem; }
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% for scope, fields in scope_fields.items() %}
|
<div class="cred-grid">
|
||||||
<h2 class="scope-header">{{ scope }}</h2>
|
{% for scope, fields in scope_fields.items() %}
|
||||||
<div class="panel" style="max-width:560px;">
|
<div class="cred-scope">
|
||||||
|
<h2 class="scope-header">{{ scope }}</h2>
|
||||||
|
<div class="panel">
|
||||||
<form method="post" action="/settings/credentials/{{ scope }}">
|
<form method="post" action="/settings/credentials/{{ scope }}">
|
||||||
{% for field in fields %}
|
{% for field in fields %}
|
||||||
<div class="field wide">
|
<div class="field wide">
|
||||||
@@ -27,6 +29,8 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
<button type="submit" class="btn">Save {{ scope }}</button>
|
<button type="submit" class="btn">Save {{ scope }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -42,16 +42,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<h2>Credential status</h2>
|
<h2>Credential status</h2>
|
||||||
<ul class="chip-list">
|
<ul class="chip-list auth-chip-list">
|
||||||
{% for a in auth_states %}
|
{% for a in auth_states %}
|
||||||
<li>
|
<li title="{{ a.scope }}: {{ 'configured' if a.configured else 'not configured' }}{% if a.days_left is defined %}, cookie expires in {{ a.days_left }}d{% endif %}"><span class="status-dot {{ 'status-dot-success' if a.configured else 'status-dot-muted' }}{% if a.days_left is defined and a.days_left < 14 %} status-dot-warning{% endif %}"></span>{{ a.scope }}{% if a.days_left is defined %}<span class="muted"> {{ a.days_left }}d</span>{% endif %}</li>
|
||||||
{% if a.configured %}<span class="badge badge-success">set</span>{% else %}<span class="badge badge-muted">unset</span>{% endif %}
|
|
||||||
{{ a.scope }}
|
|
||||||
{% if a.days_left is defined %}
|
|
||||||
{% if a.days_left < 14 %}<span class="badge badge-warning">{{ a.days_left }}d left</span>
|
|
||||||
{% else %}<span class="muted">({{ a.days_left }}d left)</span>{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
<p><a href="/settings/credentials">Manage credentials →</a></p>
|
<p><a href="/settings/credentials">Manage credentials →</a></p>
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<form method="post" action="/dedup/scan" class="inline">
|
<form method="post" action="/dedup/scan" class="inline">
|
||||||
<button type="submit" class="btn btn-primary">Scan now</button>
|
<button type="submit" class="btn btn-primary" title="Tag-based: matches on artist/title/albumartist across 4 passes">Scan now</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/dedup/scan-fuzzy" class="inline">
|
||||||
|
<button type="submit" class="btn" title="Acoustic fingerprint match -- catches the same recording filed under different tags">Scan for audio duplicates</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -141,8 +141,20 @@ def main() -> int:
|
|||||||
ap.add_argument("--duration-tol", type=int, default=DURATION_TOLERANCE)
|
ap.add_argument("--duration-tol", type=int, default=DURATION_TOLERANCE)
|
||||||
ap.add_argument("--no-cache", action="store_true",
|
ap.add_argument("--no-cache", action="store_true",
|
||||||
help="ignore the scan cache and re-fingerprint everything")
|
help="ignore the scan cache and re-fingerprint everything")
|
||||||
|
ap.add_argument("--json", action="store_true",
|
||||||
|
help="additionally emit one JSON line per candidate deletion to stdout, "
|
||||||
|
"for dedup_review_service to parse -- same convention as "
|
||||||
|
"dedup-library.sh --json. Purely additive.")
|
||||||
|
ap.add_argument("--only-paths", metavar="FILE",
|
||||||
|
help="in --apply mode, only delete a candidate if its path is listed "
|
||||||
|
"(one per line) in FILE -- same convention as dedup-library.sh.")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
only_paths = None
|
||||||
|
if args.only_paths:
|
||||||
|
with open(args.only_paths) as f:
|
||||||
|
only_paths = {line.strip() for line in f if line.strip()}
|
||||||
|
|
||||||
if not os.path.exists(FINGERPRINT_DB):
|
if not os.path.exists(FINGERPRINT_DB):
|
||||||
print(f"ERROR: fingerprint index missing at {FINGERPRINT_DB}", file=sys.stderr)
|
print(f"ERROR: fingerprint index missing at {FINGERPRINT_DB}", file=sys.stderr)
|
||||||
print("Run build-fingerprint-index.py first.", file=sys.stderr)
|
print("Run build-fingerprint-index.py first.", file=sys.stderr)
|
||||||
@@ -258,6 +270,14 @@ def main() -> int:
|
|||||||
continue
|
continue
|
||||||
print(f" DELETE [{loser_ext} {loser_size / 1024 / 1024:.1f}M sim={score:.3f}] {loser_path}")
|
print(f" DELETE [{loser_ext} {loser_size / 1024 / 1024:.1f}M sim={score:.3f}] {loser_path}")
|
||||||
delete_targets.append((beets_id, loser_path))
|
delete_targets.append((beets_id, loser_path))
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps({
|
||||||
|
"pass": "fuzzy_audio",
|
||||||
|
"keep_path": keeper_path,
|
||||||
|
"delete_path": loser_path,
|
||||||
|
"delete_size_bytes": loser_size,
|
||||||
|
"similarity": round(score, 3),
|
||||||
|
}))
|
||||||
print()
|
print()
|
||||||
if skipped_transitive:
|
if skipped_transitive:
|
||||||
print(f"[fuzzy-dupes] skipped {skipped_transitive} transitive false-positives "
|
print(f"[fuzzy-dupes] skipped {skipped_transitive} transitive false-positives "
|
||||||
@@ -269,7 +289,12 @@ def main() -> int:
|
|||||||
|
|
||||||
print(f"\n[fuzzy-dupes] APPLY: deleting {len(delete_targets)} files via beet remove -d -f")
|
print(f"\n[fuzzy-dupes] APPLY: deleting {len(delete_targets)} files via beet remove -d -f")
|
||||||
failed = 0
|
failed = 0
|
||||||
|
skipped_unconfirmed = 0
|
||||||
for beets_id, path in delete_targets:
|
for beets_id, path in delete_targets:
|
||||||
|
if only_paths is not None and path not in only_paths:
|
||||||
|
print(f" SKIP (not in --only-paths confirm list) {path}")
|
||||||
|
skipped_unconfirmed += 1
|
||||||
|
continue
|
||||||
# Escape regex metacharacters for path:: regex query
|
# Escape regex metacharacters for path:: regex query
|
||||||
escaped = re.escape(path)
|
escaped = re.escape(path)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -279,7 +304,8 @@ def main() -> int:
|
|||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print(f" FAILED: {path} — {result.stderr.strip()}", file=sys.stderr)
|
print(f" FAILED: {path} — {result.stderr.strip()}", file=sys.stderr)
|
||||||
failed += 1
|
failed += 1
|
||||||
print(f"[fuzzy-dupes] done. {len(delete_targets) - failed} deleted, {failed} failed.")
|
deleted = len(delete_targets) - failed - skipped_unconfirmed
|
||||||
|
print(f"[fuzzy-dupes] done. {deleted} deleted, {failed} failed, {skipped_unconfirmed} skipped (unconfirmed).")
|
||||||
return 1 if failed else 0
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user