Files
alembic/app/services/library_edit.py
T
andrew 92e5326437 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).
2026-07-09 09:12:02 -06:00

127 lines
4.7 KiB
Python

import asyncio
import subprocess
import time
from sqlalchemy.orm import Session
from app.models import ManualFixAudit
from app.services import beets_service, pipeline_runner
# "genres" (plural) matches this deployment's actual beets DB column and
# every existing script's `beet ls -f '$genres'` convention -- not the
# generic beets docs' singular "genre".
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
to escape against. This also reuses beets' own configured path-format/
move logic exactly as the `beet` CLI does, rather than reimplementing
it against a hand-built beets.library.Library object with guessed
config values (directory/path_formats aren't reliably knowable from
here during the migration's transitional beets-mount period)."""
field_args = [f"{field}={value}" for field, value in changes.items()]
return subprocess.run(
["beet", "modify", "-y", "-m", f"id:{item_id}"] + field_args,
capture_output=True,
text=True,
)
async def update_track_fields(
db: Session, item_id: int, changes: dict[str, str], changed_by: str
) -> list[str]:
"""Apply one or more field edits to a single beets item via `beet
modify -y -m` (writes tags to the file, updates the DB, and moves the
file if the new fields change its path template -- all three in one
beets-native operation).
Every field that actually changed value gets one manual_fix_audit row
with old/new values. Triggers a (share-health-gated) Navidrome rescan
afterward if anything changed, so the edit shows up without waiting for
the next scheduled scan.
Returns the list of field names that were actually different after the
edit (a value re-submitted unchanged doesn't generate an audit row).
"""
unknown = set(changes) - set(EDITABLE_FIELDS)
if unknown:
raise ValueError(f"not editable: {unknown}")
if not changes:
return []
before = beets_service.get_item(item_id)
if before is None:
raise ValueError(f"no beets item with id={item_id}")
result = await asyncio.to_thread(_run_beet_modify, item_id, changes)
if result.returncode != 0:
raise RuntimeError(f"beet modify failed: {result.stderr.strip()}")
after = beets_service.get_item(item_id) or {}
changed_fields = []
now = time.time()
for field in changes:
old_value = before.get(field)
new_value = after.get(field, changes[field])
if old_value != new_value:
db.add(
ManualFixAudit(
beets_item_id=item_id,
file_path=after.get("path", before.get("path", "")),
field=field,
old_value=str(old_value) if old_value is not None else None,
new_value=str(new_value) if new_value is not None else None,
changed_by=changed_by,
changed_at=now,
source="tag_edit",
)
)
changed_fields.append(field)
db.commit()
if changed_fields:
await pipeline_runner.run_lib_script(
"manual:navidrome_scan_after_edit", "navidrome-scan.sh", triggered_by="manual"
)
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()