Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e07e931c45 |
@@ -73,10 +73,10 @@ Optional, add these later if you want them:
|
||||
Pull the prebuilt image onto your Docker host:
|
||||
|
||||
```bash
|
||||
docker pull git.kretzer.club/andrew/alembic:0.6.11
|
||||
docker pull git.kretzer.club/andrew/alembic:0.6.12
|
||||
```
|
||||
|
||||
That is the whole install. You do not need to download the source or build anything. The `0.6.11` is the version; you can pin to it so nothing changes under you, or use `latest` to always get the newest.
|
||||
That is the whole install. You do not need to download the source or build anything. The `0.6.12` is the version; you can pin to it so nothing changes under you, or use `latest` to always get the newest.
|
||||
|
||||
(If you would rather build it yourself from source, you can, but you do not need to.)
|
||||
|
||||
@@ -136,7 +136,7 @@ Create a file called `docker-compose.yml` on your server (put it wherever you ke
|
||||
```yaml
|
||||
services:
|
||||
alembic:
|
||||
image: git.kretzer.club/andrew/alembic:0.6.11
|
||||
image: git.kretzer.club/andrew/alembic:0.6.12
|
||||
container_name: alembic
|
||||
ports:
|
||||
- "8420:8420"
|
||||
|
||||
+68
-5
@@ -1,3 +1,6 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
@@ -30,16 +33,76 @@ def _display_path(path: str) -> str:
|
||||
return path[len(_LIBRARY_PREFIX):] if path.startswith(_LIBRARY_PREFIX) else path
|
||||
|
||||
|
||||
# Compilation-style import paths carry a "$track " prefix on the filename
|
||||
# (see pipeline/configs/beets config.yaml paths: comp/albumtype_soundtrack);
|
||||
# singleton paths (the vast majority of this library) don't. Strip it either
|
||||
# way so the title reads clean.
|
||||
_TRACK_PREFIX_RE = re.compile(r"^\d{1,3}[\s.\-]+")
|
||||
|
||||
# rank_file() in dedup-library.sh always ranks FLAC above every other format
|
||||
# and WAV below MP3 (a download glitch, never desirable) -- mirror that
|
||||
# judgment in the format pill's color so it reads as "this one's the keeper"
|
||||
# at a glance, not just a bare extension string.
|
||||
_FORMAT_BADGE = {"flac": "badge-success", "wav": "badge-warning"}
|
||||
|
||||
|
||||
def _split_display(path: str) -> dict:
|
||||
"""Break a library display path into (artist, album, title, ext).
|
||||
|
||||
Beets' singleton path template is always
|
||||
%the{$albumartist}/$album/$title (pipeline/configs/beets config.yaml,
|
||||
paths:) -- every track in this library lands at exactly that shape, so
|
||||
the last two segments are reliably album/filename. A path that doesn't
|
||||
fit (an unexpected root-level file) degrades to showing the raw display
|
||||
path as the title instead of guessing at structure that isn't there.
|
||||
"""
|
||||
display = _display_path(path)
|
||||
parts = display.split("/")
|
||||
if len(parts) >= 2:
|
||||
artist, album, filename = parts[0], parts[-2], parts[-1]
|
||||
title, ext = os.path.splitext(filename)
|
||||
title = _TRACK_PREFIX_RE.sub("", title)
|
||||
else:
|
||||
artist, album = "", ""
|
||||
title, ext = os.path.splitext(display)
|
||||
return {
|
||||
"path": path,
|
||||
"display": display,
|
||||
"artist": artist,
|
||||
"album": album,
|
||||
"title": title,
|
||||
"ext": ext.lstrip(".").lower(),
|
||||
}
|
||||
|
||||
|
||||
def _file_size(path: str) -> int | None:
|
||||
try:
|
||||
return os.path.getsize(path)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _to_row(c) -> dict:
|
||||
keep = _split_display(c.keep_path)
|
||||
delete = _split_display(c.delete_path)
|
||||
keep["size_bytes"] = _file_size(c.keep_path)
|
||||
keep["badge"] = _FORMAT_BADGE.get(keep["ext"], "badge-muted")
|
||||
delete["size_bytes"] = c.delete_size_bytes if c.delete_size_bytes is not None else _file_size(c.delete_path)
|
||||
delete["badge"] = _FORMAT_BADGE.get(delete["ext"], "badge-muted")
|
||||
return {
|
||||
"id": c.id,
|
||||
"pass_name": c.pass_name,
|
||||
"pass_label": _pass_label(c.pass_name),
|
||||
"keep_path": c.keep_path,
|
||||
"delete_path": c.delete_path,
|
||||
"keep_display": _display_path(c.keep_path),
|
||||
"delete_display": _display_path(c.delete_path),
|
||||
"delete_size_bytes": c.delete_size_bytes,
|
||||
"keep": keep,
|
||||
"delete": delete,
|
||||
# Same-tag passes (numbered_sibling/case_insensitive/normalized) share
|
||||
# artist/title almost always; cross_album_fuzzy and fuzzy_audio can
|
||||
# legitimately differ (different credit, different album entirely) --
|
||||
# the template only collapses shared context onto one line when it's
|
||||
# actually shared, otherwise shows both sides in full.
|
||||
"same_title": keep["title"].lower() == delete["title"].lower(),
|
||||
"same_artist": keep["artist"].lower() == delete["artist"].lower(),
|
||||
"same_album": keep["album"].lower() == delete["album"].lower(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+54
-5
@@ -475,11 +475,60 @@ tbody tr:hover { background: rgba(167, 139, 250, 0.055); }
|
||||
tbody td.actions-cell { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
|
||||
.dedup-table { table-layout: fixed; }
|
||||
.dedup-table .path-cell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 0; /* forces the cell to respect the colgroup width instead of the content's natural width */
|
||||
|
||||
/* Each candidate is its own <tbody> (title row + detail row) so the pair
|
||||
highlights together on hover, and so :hover can bubble from either row
|
||||
up to the shared group without JS. That means every detail row is now
|
||||
a tbody's last-child, which would otherwise strip the separator between
|
||||
one candidate and the next (the generic `tbody tr:last-child` rule above
|
||||
assumed one shared tbody) -- restore it here, and only drop it for the
|
||||
actual last group in the table. */
|
||||
.dedup-table .dedup-row-group:hover td { background: rgba(167, 139, 250, 0.06); }
|
||||
.dedup-table .dedup-detail-row td { border-bottom: 1px solid var(--border); }
|
||||
.dedup-table .dedup-row-group:last-of-type .dedup-detail-row td { border-bottom: none; }
|
||||
|
||||
.dedup-title-row td { padding: 0.75rem 1.1rem 0.2rem; border-bottom: none; }
|
||||
.dedup-detail-row td { padding-top: 0.15rem; }
|
||||
|
||||
.dedup-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.02rem;
|
||||
font-weight: 700;
|
||||
color: var(--fg);
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.dedup-title-alt { font-weight: 400; color: var(--muted); }
|
||||
|
||||
.dedup-context { font-size: 0.8rem; margin-top: 0.15rem; }
|
||||
|
||||
/* A thin colored rail on each side echoes the keep/delete verdict itself --
|
||||
green for the copy that survives, muted-pink for the one on the chopping
|
||||
block -- so which side is which reads before you've even read the words. */
|
||||
.dedup-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding-left: 0.65rem;
|
||||
border-left: 2px solid transparent;
|
||||
min-height: 1.6rem;
|
||||
}
|
||||
.dedup-side-keep { border-left-color: var(--status-success); }
|
||||
.dedup-side-delete { border-left-color: var(--status-danger); }
|
||||
|
||||
.dedup-side-size { font-size: 0.78rem; white-space: nowrap; flex-shrink: 0; }
|
||||
|
||||
/* The actual filename/path -- always visible, never truncated. Tags can be
|
||||
wrong; this is the ground truth for judging whether two files are really
|
||||
the same recording, so it can't be hover-only the way it was in the first
|
||||
pass of this redesign. */
|
||||
.dedup-side-path {
|
||||
padding-left: 0.65rem;
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted-2);
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.empty-row td { padding: 2.5rem 1rem; text-align: center; }
|
||||
|
||||
@@ -1,5 +1,65 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dedup — alembic{% endblock %}
|
||||
|
||||
{% macro candidate_rows(c, mode) %}
|
||||
<tbody class="dedup-row-group">
|
||||
<tr class="dedup-title-row">
|
||||
<td colspan="{{ 5 if mode == 'pending' else 4 }}">
|
||||
{% if c.same_title %}
|
||||
<div class="dedup-title">{{ c.keep.title }}</div>
|
||||
{% else %}
|
||||
<div class="dedup-title">{{ c.keep.title }} <span class="dedup-title-alt">/ {{ c.delete.title }}</span></div>
|
||||
{% endif %}
|
||||
{% if c.same_artist and c.same_album and c.keep.album %}
|
||||
<div class="dedup-context muted">{{ c.keep.artist }} · {{ c.keep.album }}</div>
|
||||
{% elif c.same_artist %}
|
||||
<div class="dedup-context muted">{{ c.keep.artist }}</div>
|
||||
{% else %}
|
||||
<div class="dedup-context muted">{{ c.keep.artist }} / {{ c.delete.artist }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="dedup-detail-row">
|
||||
{% if mode == 'pending' %}
|
||||
<td><input type="checkbox" name="candidate_id" value="{{ c.id }}" form="bulk-delete-form"></td>
|
||||
{% endif %}
|
||||
<td><span class="badge {{ 'badge-warning' if c.pass_label == 'Acoustically Similar' else 'badge-info' }}" title="{{ c.pass_name }}">{{ c.pass_label }}</span></td>
|
||||
<td>
|
||||
<div class="dedup-side dedup-side-keep">
|
||||
<span class="badge {{ c.keep.badge }}">{{ c.keep.ext or '?' }}</span>
|
||||
{% if c.keep.size_bytes %}<span class="muted dedup-side-size">{{ (c.keep.size_bytes / 1024 / 1024) | round(1) }} MB</span>{% endif %}
|
||||
</div>
|
||||
<div class="dedup-side-path mono muted">{{ c.keep.display }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="dedup-side dedup-side-delete">
|
||||
<span class="badge {{ c.delete.badge }}">{{ c.delete.ext or '?' }}</span>
|
||||
{% if c.delete.size_bytes %}<span class="muted dedup-side-size">{{ (c.delete.size_bytes / 1024 / 1024) | round(1) }} MB</span>{% endif %}
|
||||
</div>
|
||||
<div class="dedup-side-path mono muted">{{ c.delete.display }}</div>
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
{% if mode == 'pending' %}
|
||||
<form method="post" action="/dedup/confirm" class="inline"
|
||||
onsubmit="return confirm('Permanently delete this file from disk?\n{{ c.delete.display }}')">
|
||||
<input type="hidden" name="candidate_id" value="{{ c.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
<form method="post" action="/dedup/ignore" class="inline">
|
||||
<input type="hidden" name="candidate_id" value="{{ c.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-ghost" title="Keep both copies and never flag this pair again">Keep both</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/dedup/unignore" class="inline">
|
||||
<input type="hidden" name="candidate_id" value="{{ c.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-ghost">Un-ignore</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
@@ -18,48 +78,30 @@
|
||||
{% endif %}
|
||||
|
||||
<h2>Pending candidates</h2>
|
||||
<p class="muted" style="margin-top:-0.5rem;">Paths are relative to the library root. Hover a name for the full path.</p>
|
||||
<p class="muted" style="margin-top:-0.5rem;">Song title up top so you can scan straight down the list; the actual file path is always shown underneath each side so you can check they're really the same file before confirming.</p>
|
||||
<form id="bulk-delete-form" method="post" action="/dedup/confirm"
|
||||
onsubmit="return confirm('Permanently delete every selected file from disk? This cannot be undone.')"></form>
|
||||
<div class="table-wrap scroll">
|
||||
<table class="dedup-table">
|
||||
<colgroup>
|
||||
<col style="width:2.2rem"><col style="width:9.5rem"><col style="width:28%">
|
||||
<col style="width:28%"><col style="width:5rem"><col style="width:9rem">
|
||||
<col style="width:2.2rem"><col style="width:9.5rem"><col style="width:38%">
|
||||
<col style="width:38%"><col style="width:10rem">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr><th></th><th>Caught by</th><th>Keep</th><th>Delete</th><th>Size</th><th>Action</th></tr>
|
||||
<tr><th></th><th>Caught by</th><th>Keep</th><th>Delete</th><th>Action</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set pass_badge = {"File Naming": "badge-info", "Acoustically Similar": "badge-warning"} %}
|
||||
{% for c in candidates %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="candidate_id" value="{{ c.id }}" form="bulk-delete-form"></td>
|
||||
<td><span class="badge {{ pass_badge.get(c.pass_label, 'badge-info') }}" title="{{ c.pass_name }}">{{ c.pass_label }}</span></td>
|
||||
<td class="muted mono path-cell" title="{{ c.keep_path }}">{{ c.keep_display }}</td>
|
||||
<td class="mono path-cell" title="{{ c.delete_path }}">{{ c.delete_display }}</td>
|
||||
<td class="muted">{{ (c.delete_size_bytes / 1024 / 1024) | round(1) if c.delete_size_bytes else '?' }} MB</td>
|
||||
<td class="actions-cell">
|
||||
<form method="post" action="/dedup/confirm" class="inline"
|
||||
onsubmit="return confirm('Permanently delete this file from disk?\n{{ c.delete_display }}')">
|
||||
<input type="hidden" name="candidate_id" value="{{ c.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
<form method="post" action="/dedup/ignore" class="inline">
|
||||
<input type="hidden" name="candidate_id" value="{{ c.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-ghost" title="Keep both copies and never flag this pair again">Keep both</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{ candidate_rows(c, 'pending') }}
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="6">
|
||||
<tbody>
|
||||
<tr class="empty-row"><td colspan="5">
|
||||
<div class="empty-state">
|
||||
<span class="sparkle" style="position:relative; display:inline-block; margin-bottom:0.5rem;"></span>
|
||||
<p class="muted" style="margin:0;">No pending candidates. Run a scan.</p>
|
||||
</div>
|
||||
</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% if candidates %}
|
||||
@@ -72,28 +114,15 @@
|
||||
<div class="table-wrap scroll">
|
||||
<table class="dedup-table">
|
||||
<colgroup>
|
||||
<col style="width:9.5rem"><col style="width:33%">
|
||||
<col style="width:33%"><col style="width:5rem"><col style="width:7rem">
|
||||
<col style="width:9.5rem"><col style="width:40%">
|
||||
<col style="width:40%"><col style="width:7rem">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr><th>Caught by</th><th>Keep</th><th>Delete</th><th>Size</th><th></th></tr>
|
||||
<tr><th>Caught by</th><th>Keep</th><th>Delete</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in ignored %}
|
||||
<tr>
|
||||
<td><span class="badge badge-muted" title="{{ c.pass_name }}">{{ c.pass_label }}</span></td>
|
||||
<td class="muted mono path-cell" title="{{ c.keep_path }}">{{ c.keep_display }}</td>
|
||||
<td class="muted mono path-cell" title="{{ c.delete_path }}">{{ c.delete_display }}</td>
|
||||
<td class="muted">{{ (c.delete_size_bytes / 1024 / 1024) | round(1) if c.delete_size_bytes else '?' }} MB</td>
|
||||
<td>
|
||||
<form method="post" action="/dedup/unignore" class="inline">
|
||||
<input type="hidden" name="candidate_id" value="{{ c.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-ghost">Un-ignore</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{ candidate_rows(c, 'ignored') }}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user