92e5326437
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).
127 lines
4.7 KiB
Python
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()
|