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 <noreply@anthropic.com>
This commit is contained in:
Steffen Schuhmann
2026-03-29 13:41:01 +02:00
parent a779c57a26
commit b882c9ea6d
3 changed files with 75 additions and 1 deletions

View File

@@ -10,8 +10,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session from rehearsalhub.db.engine import get_session
from rehearsalhub.repositories.audio_version import AudioVersionRepository from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.repositories.band import BandRepository from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.repositories.rehearsal_session import RehearsalSessionRepository
from rehearsalhub.repositories.song import SongRepository from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.schemas.audio_version import AudioVersionCreate from rehearsalhub.schemas.audio_version import AudioVersionCreate
from rehearsalhub.services.session import parse_rehearsal_date
from rehearsalhub.services.song import SongService from rehearsalhub.services.song import SongService
log = logging.getLogger(__name__) 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): if event.nc_file_etag and await version_repo.get_by_etag(event.nc_file_etag):
return {"status": "skipped", "reason": "version already registered"} 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_repo = SongRepository(session)
song = await song_repo.get_by_nc_folder_path(nc_folder) song = await song_repo.get_by_nc_folder_path(nc_folder)
if song is None: if song is None:
@@ -76,6 +87,7 @@ async def nc_upload(
if song is None: if song is None:
song = await song_repo.create( song = await song_repo.create(
band_id=band.id, band_id=band.id,
session_id=rehearsal_session_id,
title=title, title=title,
status="jam", status="jam",
notes=None, notes=None,
@@ -83,6 +95,8 @@ async def nc_upload(
created_by=None, created_by=None,
) )
log.info("nc-upload: created song '%s' for band '%s'", title, band_slug) 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) # Use first member of the band as uploader (best-effort for watcher uploads)
from sqlalchemy import select from sqlalchemy import select

View File

@@ -12,10 +12,12 @@ from rehearsalhub.dependencies import get_current_member
from rehearsalhub.repositories.audio_version import AudioVersionRepository from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.repositories.band import BandRepository from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.repositories.comment import CommentRepository from rehearsalhub.repositories.comment import CommentRepository
from rehearsalhub.repositories.rehearsal_session import RehearsalSessionRepository
from rehearsalhub.repositories.song import SongRepository from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.schemas.comment import SongCommentCreate, SongCommentRead from rehearsalhub.schemas.comment import SongCommentCreate, SongCommentRead
from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate
from rehearsalhub.services.band import BandService from rehearsalhub.services.band import BandService
from rehearsalhub.services.session import parse_rehearsal_date
from rehearsalhub.services.song import SongService from rehearsalhub.services.song import SongService
from rehearsalhub.storage.nextcloud import NextcloudClient from rehearsalhub.storage.nextcloud import NextcloudClient
@@ -156,6 +158,7 @@ async def scan_nextcloud(
nc = NextcloudClient.for_member(current_member) nc = NextcloudClient.for_member(current_member)
version_repo = AudioVersionRepository(session) version_repo = AudioVersionRepository(session)
session_repo = RehearsalSessionRepository(session)
song_svc = SongService(session) song_svc = SongService(session)
# dav_prefix to strip full WebDAV hrefs → user-relative paths # dav_prefix to strip full WebDAV hrefs → user-relative paths
@@ -231,6 +234,13 @@ async def scan_nextcloud(
skipped_count += 1 skipped_count += 1
continue 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 # Find or create song record
song = await song_repo.get_by_nc_folder_path(nc_folder) song = await song_repo.get_by_nc_folder_path(nc_folder)
if song is None: if song is None:
@@ -239,14 +249,17 @@ async def scan_nextcloud(
log.info("Creating new song '%s' (folder: %s)", song_title, nc_folder) log.info("Creating new song '%s' (folder: %s)", song_title, nc_folder)
song = await song_repo.create( song = await song_repo.create(
band_id=band_id, band_id=band_id,
session_id=rehearsal_session_id,
title=song_title, title=song_title,
status="jam", status="jam",
notes=f"Rehearsal: {rehearsal_label}" if rehearsal_label else None, notes=None,
nc_folder_path=nc_folder, nc_folder_path=nc_folder,
created_by=current_member.id, created_by=current_member.id,
) )
else: else:
log.info("Found existing song '%s' (id: %s)", song.title, song.id) 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( await song_svc.register_version(
song.id, song.id,

View File

@@ -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("/") + "/"