diff --git a/app/main.py b/app/main.py index cd3dc05..3a4392d 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from app.db import enable_beets_db_wal, init_db -from app.routers import auth, dashboard +from app.routers import auth, dashboard, import_, library from app.services import scheduler_service from app.settings import settings @@ -33,6 +33,8 @@ def create_app() -> FastAPI: app.include_router(auth.router) app.include_router(dashboard.router) + app.include_router(library.router) + app.include_router(import_.router) return app diff --git a/app/routers/import_.py b/app/routers/import_.py new file mode 100644 index 0000000..665c64c --- /dev/null +++ b/app/routers/import_.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, Depends, Form, Request, UploadFile +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates + +from app.db import get_db +from app.models import Playlist +from app.security.deps import require_auth +from app.services import manual_import + +router = APIRouter(prefix="/import", tags=["import"]) +templates = Jinja2Templates(directory="app/templates") + + +@router.get("") +async def import_index(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)): + from sqlalchemy import select + + contents = manual_import.list_import_me_contents() + playlists = list(db.execute(select(Playlist).order_by(Playlist.name)).scalars()) + return templates.TemplateResponse( + request, + "import/index.html", + {"contents": contents, "playlists": playlists}, + ) + + +@router.post("/upload") +async def upload( + request: Request, + file: UploadFile, + user: dict = Depends(require_auth), +): + content = await file.read() + manual_import.save_uploaded_file(file.filename, content) + return RedirectResponse(url="/import", status_code=303) + + +@router.post("/run") +async def run_import( + source: str = Form(""), + playlist_name: str = Form(""), + user: dict = Depends(require_auth), + db=Depends(get_db), +): + imported_by = user.get("email") or user.get("sub", "unknown") + record = await manual_import.import_track( + db, source or None, playlist_name or None, imported_by + ) + return RedirectResponse(url=f"/import?run_id={record.id}", status_code=303) diff --git a/app/routers/library.py b/app/routers/library.py new file mode 100644 index 0000000..76f6031 --- /dev/null +++ b/app/routers/library.py @@ -0,0 +1,75 @@ +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates + +from app.db import get_db +from app.security.deps import require_auth +from app.services import beets_service, genre_fix, library_edit, retag + +router = APIRouter(prefix="/library", tags=["library"]) +templates = Jinja2Templates(directory="app/templates") + + +@router.get("") +async def library_index(request: Request, grouping: str | None = None, user: dict = Depends(require_auth)): + items = beets_service.query_items(grouping=grouping) + return templates.TemplateResponse( + request, "library/index.html", {"items": items, "grouping": grouping} + ) + + +@router.get("/track/{item_id}") +async def track_detail(request: Request, item_id: int, user: dict = Depends(require_auth)): + item = beets_service.get_item(item_id) + if item is None: + raise HTTPException(404, "no such track") + return templates.TemplateResponse( + request, + "library/track_detail.html", + {"item": item, "editable_fields": library_edit.EDITABLE_FIELDS}, + ) + + +@router.post("/track/{item_id}") +async def update_track( + request: Request, + item_id: int, + user: dict = Depends(require_auth), + db=Depends(get_db), +): + form = await request.form() + changes = { + field: value + for field, value in form.items() + if field in library_edit.EDITABLE_FIELDS and value != "" + } + changed_by = user.get("email") or user.get("sub", "unknown") + changed = await library_edit.update_track_fields(db, item_id, changes, changed_by) + return RedirectResponse(url=f"/library/track/{item_id}?changed={len(changed)}", status_code=303) + + +@router.post("/track/{item_id}/genre") +async def set_genre( + item_id: int, + artist: str = Form(...), + genre: str = Form(...), + user: dict = Depends(require_auth), +): + changed_by = user.get("email") or user.get("sub", "unknown") + await genre_fix.set_artist_genre(artist, genre, changed_by) + return RedirectResponse(url=f"/library/track/{item_id}", status_code=303) + + +@router.post("/track/{item_id}/retag") +async def retag_track( + item_id: int, + url: str = Form(...), + keep_genre: bool = Form(False), + user: dict = Depends(require_auth), +): + item = beets_service.get_item(item_id) + if item is None: + raise HTTPException(404, "no such track") + changed_by = user.get("email") or user.get("sub", "unknown") + await retag.retag_from_url(item["path"], url, changed_by, keep_genre=keep_genre) + return RedirectResponse(url=f"/library/track/{item_id}", status_code=303) diff --git a/app/services/genre_fix.py b/app/services/genre_fix.py new file mode 100644 index 0000000..de8b223 --- /dev/null +++ b/app/services/genre_fix.py @@ -0,0 +1,22 @@ +from app.models import JobRun +from app.services import pipeline_runner + + +async def set_artist_genre(artist: str, genre: str, changed_by: str, dry_run: bool = False) -> JobRun: + """Wraps the existing fix-genre.sh (per-format FLAC/MP3/MP4/OGG tag + writing + GENRE_LOCK + beets DB sync) rather than reimplementing its + mutagen logic here -- it's already tested and handles every format this + library contains. + + No manual_fix_audit row: this is an artist-wide bulk edit (could touch + dozens of tracks), not a single beets_item_id the audit table's schema + is shaped for. The job_runs row (with its full log) is the audit trail + for this action instead. + """ + args = [] + if dry_run: + args.append("--dry") + args.extend([artist, genre]) + return await pipeline_runner.run_lib_script( + "manual:fix_genre", "fix-genre.sh", args, triggered_by=f"manual:{changed_by}" + ) diff --git a/app/services/library_edit.py b/app/services/library_edit.py new file mode 100644 index 0000000..67a13fc --- /dev/null +++ b/app/services/library_edit.py @@ -0,0 +1,89 @@ +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_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 diff --git a/app/services/manual_import.py b/app/services/manual_import.py new file mode 100644 index 0000000..e185353 --- /dev/null +++ b/app/services/manual_import.py @@ -0,0 +1,66 @@ +import time +from pathlib import Path + +from sqlalchemy.orm import Session + +from app.models import ManualImport +from app.services import pipeline_runner +from app.settings import settings + + +def import_me_dir() -> Path: + return settings.music_data_dir / "import-me" + + +def list_import_me_contents() -> list[str]: + root = import_me_dir() + if not root.exists(): + return [] + return sorted(p.name for p in root.iterdir()) + + +def save_uploaded_file(filename: str, content: bytes) -> Path: + """Write an uploaded file into import-me/ alongside whatever's already + there via SMB. Path-traversal guard: only the basename of the supplied + filename is used, so an upload can't escape import-me/.""" + root = import_me_dir() + root.mkdir(parents=True, exist_ok=True) + safe_name = Path(filename).name + if not safe_name: + raise ValueError("empty filename") + dest = root / safe_name + dest.write_bytes(content) + return dest + + +async def import_track( + db: Session, source: str | None, playlist_name: str | None, imported_by: str +) -> ManualImport: + """source: a filename/subdir relative to import-me/, or None/empty to + import everything currently sitting in import-me/. Mirrors + import-track.sh's own argument-count-based disambiguation (see its + docstring): 0 args = import everything untagged, 1 arg = either a real + path under import-me/ or a playlist name (the script figures out which), + 2 args = path + playlist name explicitly.""" + args: list[str] = [] + if source: + args.append(source) + if playlist_name: + args.append(playlist_name) + elif playlist_name: + args.append(playlist_name) + + job_run = await pipeline_runner.run_import_track(args, triggered_by="manual") + + record = ManualImport( + source_path=source or "(all of import-me/)", + playlist_name=playlist_name, + imported_by=imported_by, + imported_at=time.time(), + status=job_run.status, + job_run_id=job_run.id, + ) + db.add(record) + db.commit() + db.refresh(record) + return record diff --git a/app/services/retag.py b/app/services/retag.py new file mode 100644 index 0000000..e92f692 --- /dev/null +++ b/app/services/retag.py @@ -0,0 +1,16 @@ +from app.models import JobRun +from app.services import pipeline_runner + + +async def retag_from_url(file_path: str, url: str, changed_by: str, keep_genre: bool = False) -> JobRun: + """Wraps the existing fix-track-metadata.py (Spotify/iTunes URL -> fetch + canonical metadata, write tags, replace cover art, beet update + move, + refill genre, trigger Navidrome scan) rather than reimplementing its + metadata-fetch and multi-format tag-writing logic here.""" + args = [] + if keep_genre: + args.append("--keep-genre") + args.extend([file_path, url]) + return await pipeline_runner.run_lib_script( + "manual:retag_from_url", "fix-track-metadata.py", args, triggered_by=f"manual:{changed_by}" + ) diff --git a/app/templates/import/index.html b/app/templates/import/index.html new file mode 100644 index 0000000..5c1d1c9 --- /dev/null +++ b/app/templates/import/index.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}Import — alembic{% endblock %} +{% block content %} +

Manual import

+ +{% if request.query_params.get('run_id') %} +

Import job started (run id {{ request.query_params.get('run_id') }}). Check the Jobs page for progress.

+{% endif %} + +

Upload a file into import-me/

+
+ + +
+ +

Contents of import-me/

+ + +

Run import

+
+ + + + {% for name in contents %} + + + + + + +
+{% endblock %} diff --git a/app/templates/library/index.html b/app/templates/library/index.html new file mode 100644 index 0000000..f2fbba2 --- /dev/null +++ b/app/templates/library/index.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}Library — alembic{% endblock %} +{% block content %} +

Library{% if grouping %} — {{ grouping }}{% endif %}

+ + + + + + {% for item in items %} + + + + + + + + {% else %} + + {% endfor %} + +
ArtistTitleAlbumFormat
{{ item.artist }}{{ item.title }}{{ item.albumartist }}{{ item.format }}edit
No tracks found{% if grouping %} for grouping "{{ grouping }}"{% endif %}.
+{% endblock %} diff --git a/app/templates/library/track_detail.html b/app/templates/library/track_detail.html new file mode 100644 index 0000000..d002eaa --- /dev/null +++ b/app/templates/library/track_detail.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}{{ item.artist }} — {{ item.title }} — alembic{% endblock %} +{% block content %} +

← back to library

+

{{ item.artist }} — {{ item.title }}

+

{{ item.path }}

+ +{% if request.query_params.get('changed') %} +

Updated {{ request.query_params.get('changed') }} field(s).

+{% endif %} + +

Edit tags

+
+ {% for field in editable_fields %} +
+ + +
+ {% endfor %} + +
+ +

Set genre for this artist (all their tracks)

+
+ + + + +
+ +

Re-fetch metadata from Spotify/iTunes URL

+
+ + + + +
+{% endblock %}