From b882c9ea6d8f5c232c893f85d4155121febaf56a Mon Sep 17 00:00:00 2001 From: Steffen Schuhmann Date: Sun, 29 Mar 2026 13:41:01 +0200 Subject: [PATCH] feat(api): auto-link rehearsal sessions on watcher upload and nc-scan parse_rehearsal_date() extracts YYMMDD / YYYYMMDD from the file path and get_or_create() a RehearsalSession. Both the watcher nc-upload endpoint and the nc-scan endpoint now set song.session_id when a dated folder is detected. Existing songs without a session_id are back-filled on the next import of the same folder. Co-Authored-By: Claude Sonnet 4.6 --- api/src/rehearsalhub/routers/internal.py | 14 +++++++ api/src/rehearsalhub/routers/songs.py | 15 +++++++- api/src/rehearsalhub/services/session.py | 47 ++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 api/src/rehearsalhub/services/session.py diff --git a/api/src/rehearsalhub/routers/internal.py b/api/src/rehearsalhub/routers/internal.py index 92065b3..bbf6895 100644 --- a/api/src/rehearsalhub/routers/internal.py +++ b/api/src/rehearsalhub/routers/internal.py @@ -10,8 +10,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from rehearsalhub.db.engine import get_session from rehearsalhub.repositories.audio_version import AudioVersionRepository from rehearsalhub.repositories.band import BandRepository +from rehearsalhub.repositories.rehearsal_session import RehearsalSessionRepository from rehearsalhub.repositories.song import SongRepository from rehearsalhub.schemas.audio_version import AudioVersionCreate +from rehearsalhub.services.session import parse_rehearsal_date from rehearsalhub.services.song import SongService log = logging.getLogger(__name__) @@ -69,6 +71,15 @@ async def nc_upload( if event.nc_file_etag and await version_repo.get_by_etag(event.nc_file_etag): return {"status": "skipped", "reason": "version already registered"} + # Resolve or create rehearsal session from YYMMDD folder segment + session_repo = RehearsalSessionRepository(session) + rehearsal_date = parse_rehearsal_date(path) + rehearsal_session_id = None + if rehearsal_date: + rehearsal_session = await session_repo.get_or_create(band.id, rehearsal_date, nc_folder) + rehearsal_session_id = rehearsal_session.id + log.debug("nc-upload: linked to session %s (%s)", rehearsal_session_id, rehearsal_date) + song_repo = SongRepository(session) song = await song_repo.get_by_nc_folder_path(nc_folder) if song is None: @@ -76,6 +87,7 @@ async def nc_upload( if song is None: song = await song_repo.create( band_id=band.id, + session_id=rehearsal_session_id, title=title, status="jam", notes=None, @@ -83,6 +95,8 @@ async def nc_upload( created_by=None, ) log.info("nc-upload: created song '%s' for band '%s'", title, band_slug) + elif rehearsal_session_id and song.session_id is None: + song = await song_repo.update(song, session_id=rehearsal_session_id) # Use first member of the band as uploader (best-effort for watcher uploads) from sqlalchemy import select diff --git a/api/src/rehearsalhub/routers/songs.py b/api/src/rehearsalhub/routers/songs.py index 68d997c..0f627f4 100644 --- a/api/src/rehearsalhub/routers/songs.py +++ b/api/src/rehearsalhub/routers/songs.py @@ -12,10 +12,12 @@ 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.rehearsal_session import RehearsalSessionRepository from rehearsalhub.repositories.song import SongRepository from rehearsalhub.schemas.comment import SongCommentCreate, SongCommentRead from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate from rehearsalhub.services.band import BandService +from rehearsalhub.services.session import parse_rehearsal_date from rehearsalhub.services.song import SongService from rehearsalhub.storage.nextcloud import NextcloudClient @@ -156,6 +158,7 @@ async def scan_nextcloud( nc = NextcloudClient.for_member(current_member) version_repo = AudioVersionRepository(session) + session_repo = RehearsalSessionRepository(session) song_svc = SongService(session) # dav_prefix to strip full WebDAV hrefs → user-relative paths @@ -231,6 +234,13 @@ async def scan_nextcloud( skipped_count += 1 continue + # Resolve rehearsal session from YYMMDD folder segment + rehearsal_date = parse_rehearsal_date(nc_file_path) + rehearsal_session_id = None + if rehearsal_date: + rs = await session_repo.get_or_create(band_id, rehearsal_date, nc_folder) + rehearsal_session_id = rs.id + # Find or create song record song = await song_repo.get_by_nc_folder_path(nc_folder) if song is None: @@ -239,14 +249,17 @@ async def scan_nextcloud( log.info("Creating new song '%s' (folder: %s)", song_title, nc_folder) song = await song_repo.create( band_id=band_id, + session_id=rehearsal_session_id, title=song_title, status="jam", - notes=f"Rehearsal: {rehearsal_label}" if rehearsal_label else None, + notes=None, nc_folder_path=nc_folder, created_by=current_member.id, ) else: log.info("Found existing song '%s' (id: %s)", song.title, song.id) + if rehearsal_session_id and song.session_id is None: + song = await song_repo.update(song, session_id=rehearsal_session_id) await song_svc.register_version( song.id, diff --git a/api/src/rehearsalhub/services/session.py b/api/src/rehearsalhub/services/session.py new file mode 100644 index 0000000..fec01b2 --- /dev/null +++ b/api/src/rehearsalhub/services/session.py @@ -0,0 +1,47 @@ +"""Helpers for parsing rehearsal session dates from Nextcloud paths.""" + +from __future__ import annotations + +import re +from datetime import date + +# Matches a YYMMDD or YYYYMMDD directory segment anywhere in the path. +# e.g. "bands/my-band/231015/take.wav" → "231015" → date(2023, 10, 15) +_YYMMDD_RE = re.compile(r"(?:^|/)(\d{6})(?:/|$)") +_YYYYMMDD_RE = re.compile(r"(?:^|/)(\d{8})(?:/|$)") + + +def parse_rehearsal_date(nc_file_path: str) -> date | None: + """ + Extract a rehearsal date from a Nextcloud file path. + + Supports YYMMDD (e.g. 231015 → 2023-10-15) and + YYYYMMDD (e.g. 20231015 → 2023-10-15) folder names. + Returns None if no date segment is found or the date is invalid. + """ + # Try YYYYMMDD first (more specific) + m = _YYYYMMDD_RE.search(nc_file_path) + if m: + s = m.group(1) + try: + return date(int(s[:4]), int(s[4:6]), int(s[6:8])) + except ValueError: + pass + + m = _YYMMDD_RE.search(nc_file_path) + if m: + s = m.group(1) + yy, mm, dd = int(s[0:2]), int(s[2:4]), int(s[4:6]) + # Assume 2000s + try: + return date(2000 + yy, mm, dd) + except ValueError: + pass + + return None + + +def nc_folder_for_path(nc_file_path: str) -> str: + """Return the parent directory of a file path, with trailing slash.""" + from pathlib import Path + return str(Path(nc_file_path).parent).rstrip("/") + "/"