Watcher: - Accept both NC 22+ (type="file_created") and older NC (subject="created_self") so the upload filter works across all Nextcloud versions - Add .opus to audio_extensions - Fix tests: set nc.username on mocks, use realistic activity dicts with type field - Add tests for old NC style, non-band path filter, normalize_nc_path, cursor advance API: - Fix internal.py title extraction: always use filename stem (was using parts[-2] for >3-part paths, which gave folder name instead of song title) - nc-scan now returns NcScanResult with folder, files_found, imported, skipped counts instead of bare song list — gives the UI actionable feedback Web: - Show rich scan result message: folder scanned, count imported, count already registered Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
287 lines
11 KiB
Python
287 lines
11 KiB
Python
import logging
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from rehearsalhub.db.engine import get_session
|
|
from rehearsalhub.db.models import Member
|
|
from rehearsalhub.dependencies import get_current_member
|
|
from rehearsalhub.repositories.audio_version import AudioVersionRepository
|
|
from rehearsalhub.repositories.band import BandRepository
|
|
from rehearsalhub.repositories.comment import CommentRepository
|
|
from rehearsalhub.repositories.song import SongRepository
|
|
from rehearsalhub.schemas.comment import SongCommentCreate, SongCommentRead
|
|
from rehearsalhub.schemas.song import SongCreate, SongRead
|
|
from rehearsalhub.services.band import BandService
|
|
from rehearsalhub.services.song import SongService
|
|
from rehearsalhub.storage.nextcloud import NextcloudClient
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(tags=["songs"])
|
|
|
|
AUDIO_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".opus"}
|
|
|
|
|
|
class NcScanResult(BaseModel):
|
|
folder: str
|
|
files_found: int
|
|
imported: int
|
|
skipped: int
|
|
songs: list[SongRead]
|
|
|
|
|
|
@router.get("/bands/{band_id}/songs", response_model=list[SongRead])
|
|
async def list_songs(
|
|
band_id: uuid.UUID,
|
|
session: AsyncSession = Depends(get_session),
|
|
current_member: Member = Depends(get_current_member),
|
|
):
|
|
band_svc = BandService(session)
|
|
try:
|
|
await band_svc.assert_membership(band_id, current_member.id)
|
|
except PermissionError:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
|
|
song_svc = SongService(session)
|
|
return await song_svc.list_songs(band_id)
|
|
|
|
|
|
@router.post("/bands/{band_id}/songs", response_model=SongRead, status_code=status.HTTP_201_CREATED)
|
|
async def create_song(
|
|
band_id: uuid.UUID,
|
|
data: SongCreate,
|
|
session: AsyncSession = Depends(get_session),
|
|
current_member: Member = Depends(get_current_member),
|
|
):
|
|
band_svc = BandService(session)
|
|
try:
|
|
await band_svc.assert_membership(band_id, current_member.id)
|
|
except PermissionError:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
|
|
|
|
band_repo = BandRepository(session)
|
|
band = await band_repo.get_by_id(band_id)
|
|
if band is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Band not found")
|
|
|
|
song_svc = SongService(session)
|
|
song = await song_svc.create_song(band_id, data, current_member.id, band.slug, creator=current_member)
|
|
read = SongRead.model_validate(song)
|
|
read.version_count = 0
|
|
return read
|
|
|
|
|
|
@router.post("/bands/{band_id}/nc-scan", response_model=NcScanResult)
|
|
async def scan_nextcloud(
|
|
band_id: uuid.UUID,
|
|
session: AsyncSession = Depends(get_session),
|
|
current_member: Member = Depends(get_current_member),
|
|
):
|
|
"""
|
|
Scan the band's Nextcloud folder for audio files and import any not yet
|
|
registered as songs/versions. Idempotent — safe to call multiple times.
|
|
"""
|
|
band_svc = BandService(session)
|
|
try:
|
|
await band_svc.assert_membership(band_id, current_member.id)
|
|
except PermissionError:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
|
|
|
|
band_repo = BandRepository(session)
|
|
band = await band_repo.get_by_id(band_id)
|
|
if band is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Band not found")
|
|
|
|
nc = NextcloudClient.for_member(current_member)
|
|
version_repo = AudioVersionRepository(session)
|
|
song_svc = SongService(session)
|
|
|
|
# dav_prefix to strip full WebDAV hrefs → user-relative paths
|
|
dav_prefix = f"/remote.php/dav/files/{nc._auth[0]}/"
|
|
|
|
def relative(href: str) -> str:
|
|
if href.startswith(dav_prefix):
|
|
return href[len(dav_prefix):]
|
|
return href.lstrip("/")
|
|
|
|
imported_songs: list[SongRead] = []
|
|
skipped_count = 0
|
|
band_folder = band.nc_folder_path or f"bands/{band.slug}/"
|
|
|
|
log.info("Starting NC scan for band '%s' in folder '%s'", band.slug, band_folder)
|
|
|
|
try:
|
|
items = await nc.list_folder(band_folder)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Nextcloud unreachable: {exc}")
|
|
|
|
log.info("Found %d top-level entries in '%s'", len(items), band_folder)
|
|
|
|
# Collect (nc_file_path, nc_folder, song_title, rehearsal_label) tuples.
|
|
# nc_folder is the directory that groups versions of the same song.
|
|
# For YYMMDD / dated rehearsal subfolders each file is its own song —
|
|
# the song title comes from the filename stem, not the folder name.
|
|
to_import: list[tuple[str, str, str, str | None]] = []
|
|
|
|
for item in items:
|
|
rel = relative(item.path)
|
|
if rel.endswith("/"):
|
|
dir_name = Path(rel.rstrip("/")).name
|
|
try:
|
|
sub_items = await nc.list_folder(rel)
|
|
except Exception as exc:
|
|
log.warning("Could not list subfolder '%s': %s", rel, exc)
|
|
continue
|
|
|
|
audio_files = [s for s in sub_items if Path(relative(s.path)).suffix.lower() in AUDIO_EXTENSIONS]
|
|
log.info("Subfolder '%s': %d audio files found", dir_name, len(audio_files))
|
|
|
|
for sub in audio_files:
|
|
sub_rel = relative(sub.path)
|
|
song_title = Path(sub_rel).stem
|
|
# Each file in a rehearsal folder is its own song,
|
|
# grouped under its own sub-subfolder path for version tracking.
|
|
song_folder = str(Path(sub_rel).parent) + "/"
|
|
rehearsal_label = dir_name # e.g. "231015" or "2023-10-15"
|
|
to_import.append((sub_rel, song_folder, song_title, rehearsal_label))
|
|
else:
|
|
if Path(rel).suffix.lower() in AUDIO_EXTENSIONS:
|
|
folder = str(Path(rel).parent) + "/"
|
|
title = Path(rel).stem
|
|
to_import.append((rel, folder, title, None))
|
|
|
|
log.info("NC scan: %d audio files to evaluate for import", len(to_import))
|
|
|
|
song_repo = SongRepository(session)
|
|
from rehearsalhub.schemas.audio_version import AudioVersionCreate # noqa: PLC0415
|
|
|
|
for nc_file_path, nc_folder, song_title, rehearsal_label in to_import:
|
|
# Skip if this exact file version is already registered
|
|
try:
|
|
meta = await nc.get_file_metadata(nc_file_path)
|
|
etag = meta.etag
|
|
except Exception as exc:
|
|
log.warning("Could not fetch metadata for '%s': %s — skipping", nc_file_path, exc)
|
|
continue
|
|
|
|
if etag and await version_repo.get_by_etag(etag):
|
|
log.debug("Skipping '%s' — etag already registered", nc_file_path)
|
|
skipped_count += 1
|
|
continue
|
|
|
|
# Find or create song record
|
|
song = await song_repo.get_by_nc_folder_path(nc_folder)
|
|
if song is None:
|
|
song = await song_repo.get_by_title_and_band(band_id, song_title)
|
|
if song is None:
|
|
log.info("Creating new song '%s' (folder: %s)", song_title, nc_folder)
|
|
song = await song_repo.create(
|
|
band_id=band_id,
|
|
title=song_title,
|
|
status="jam",
|
|
notes=f"Rehearsal: {rehearsal_label}" if rehearsal_label else None,
|
|
nc_folder_path=nc_folder,
|
|
created_by=current_member.id,
|
|
)
|
|
else:
|
|
log.info("Found existing song '%s' (id: %s)", song.title, song.id)
|
|
|
|
await song_svc.register_version(
|
|
song.id,
|
|
AudioVersionCreate(
|
|
nc_file_path=nc_file_path,
|
|
nc_file_etag=etag,
|
|
format=Path(nc_file_path).suffix.lstrip(".").lower(),
|
|
file_size_bytes=meta.size if etag else None,
|
|
),
|
|
current_member.id,
|
|
)
|
|
|
|
read = SongRead.model_validate(song)
|
|
read.version_count = 1
|
|
imported_songs.append(read)
|
|
label_info = f" [rehearsal: {rehearsal_label}]" if rehearsal_label else ""
|
|
log.info("Imported '%s' as song '%s'%s", nc_file_path, song_title, label_info)
|
|
|
|
log.info(
|
|
"NC scan complete for '%s': %d imported, %d skipped (already registered)",
|
|
band_folder, len(imported_songs), skipped_count,
|
|
)
|
|
return NcScanResult(
|
|
folder=band_folder,
|
|
files_found=len(to_import),
|
|
imported=len(imported_songs),
|
|
skipped=skipped_count,
|
|
songs=imported_songs,
|
|
)
|
|
|
|
|
|
# ── Comments ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def _assert_song_membership(
|
|
song_id: uuid.UUID, member_id: uuid.UUID, session: AsyncSession
|
|
) -> None:
|
|
song_repo = SongRepository(session)
|
|
song = await song_repo.get_by_id(song_id)
|
|
if song is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Song not found")
|
|
band_svc = BandService(session)
|
|
try:
|
|
await band_svc.assert_membership(song.band_id, member_id)
|
|
except PermissionError:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
|
|
|
|
|
|
@router.get("/songs/{song_id}/comments", response_model=list[SongCommentRead])
|
|
async def list_comments(
|
|
song_id: uuid.UUID,
|
|
session: AsyncSession = Depends(get_session),
|
|
current_member: Member = Depends(get_current_member),
|
|
):
|
|
await _assert_song_membership(song_id, current_member.id, session)
|
|
repo = CommentRepository(session)
|
|
comments = await repo.list_for_song(song_id)
|
|
return [SongCommentRead.from_model(c) for c in comments]
|
|
|
|
|
|
@router.post("/songs/{song_id}/comments", response_model=SongCommentRead, status_code=status.HTTP_201_CREATED)
|
|
async def create_comment(
|
|
song_id: uuid.UUID,
|
|
data: SongCommentCreate,
|
|
session: AsyncSession = Depends(get_session),
|
|
current_member: Member = Depends(get_current_member),
|
|
):
|
|
await _assert_song_membership(song_id, current_member.id, session)
|
|
repo = CommentRepository(session)
|
|
comment = await repo.create(song_id=song_id, author_id=current_member.id, body=data.body)
|
|
comment = await repo.get_with_author(comment.id)
|
|
return SongCommentRead.from_model(comment)
|
|
|
|
|
|
@router.delete("/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_comment(
|
|
comment_id: uuid.UUID,
|
|
session: AsyncSession = Depends(get_session),
|
|
current_member: Member = Depends(get_current_member),
|
|
):
|
|
repo = CommentRepository(session)
|
|
comment = await repo.get_with_author(comment_id)
|
|
if comment is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
|
|
|
|
# Allow author or band admin
|
|
if comment.author_id != current_member.id:
|
|
song_repo = SongRepository(session)
|
|
song = await song_repo.get_by_id(comment.song_id)
|
|
band_svc = BandService(session)
|
|
try:
|
|
await band_svc.assert_admin(song.band_id, current_member.id) # type: ignore[union-attr]
|
|
except PermissionError:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
|
|
|
|
await repo.delete(comment)
|