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:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
47
api/src/rehearsalhub/services/session.py
Normal file
47
api/src/rehearsalhub/services/session.py
Normal 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("/") + "/"
|
||||||
Reference in New Issue
Block a user