Fix dedup apply lock-hang, mawk-broken tag passes; add track delete, fix button/select CSS

confirm_and_apply no longer marks candidates confirmed before their apply
job actually succeeds (a stuck/skipped_lock run was silently vanishing
confirmed deletes without deleting anything), switches the fuzzy pass to
--apply-pairs to avoid a full rescan on every confirm, and adds a job
timeout so a hung subprocess can't hold the global pipeline lock forever.

dedup-library.sh's Pass 2-4 use gawk-only features (gensub, POSIX interval
regex) but were running under mawk (Debian's default awk) in the deployed
image, throwing silent syntax errors on every run -- switched those
invocations to gawk explicitly and added it to the Dockerfile.

Also: per-track delete button on the library list/detail pages, and two
CSS fixes (the primary button's hover gradient was getting clobbered by
the base .btn:hover rule's plain background, and the select dropdown
arrow had no background-size so it rendered oversized).
This commit is contained in:
andrew
2026-07-09 09:12:02 -06:00
parent 9d1eee3337
commit 92e5326437
8 changed files with 182 additions and 41 deletions
+37
View File
@@ -13,6 +13,14 @@ from app.services import beets_service, pipeline_runner
EDITABLE_FIELDS = ["artist", "title", "album", "albumartist", "genres", "grouping", "track", "year"]
def _run_beet_remove(item_id: int) -> subprocess.CompletedProcess:
return subprocess.run(
["beet", "remove", "-d", "-f", f"id:{item_id}"],
capture_output=True,
text=True,
)
def _run_beet_modify(item_id: int, changes: dict[str, str]) -> subprocess.CompletedProcess:
"""subprocess with an argument list (no shell=True) -- user-submitted
field values never pass through a shell, so there's no injection risk
@@ -87,3 +95,32 @@ async def update_track_fields(
)
return changed_fields
async def delete_track(db: Session, item_id: int, deleted_by: str) -> None:
"""Remove a single track from beets and delete its file on disk, via
the same `beet remove -d -f` primitive the dedup passes use to delete a
loser -- exposed directly here for "I just want this one gone" cases
dedup doesn't cover (a bad rip, something you decided you don't want,
etc.)."""
item = beets_service.get_item(item_id)
if item is None:
raise ValueError(f"no beets item with id={item_id}")
result = await asyncio.to_thread(_run_beet_remove, item_id)
if result.returncode != 0:
raise RuntimeError(f"beet remove failed: {result.stderr.strip()}")
db.add(
ManualFixAudit(
beets_item_id=item_id,
file_path=item.get("path", ""),
field="__deleted__",
old_value=item.get("path"),
new_value=None,
changed_by=deleted_by,
changed_at=time.time(),
source="manual_delete",
)
)
db.commit()