feature/file-import #2

Merged
sschuhmann merged 9 commits from feature/file-import into main 2026-04-10 21:37:53 +00:00
3 changed files with 114 additions and 99 deletions
Showing only changes of commit ba22853bc7 - Show all commits

View File

@@ -12,7 +12,6 @@ from rehearsalhub.config import get_settings
from rehearsalhub.db.engine import get_session from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import AudioVersion, BandMember, Member from rehearsalhub.db.models import AudioVersion, BandMember, Member
from rehearsalhub.queue.redis_queue import RedisJobQueue from rehearsalhub.queue.redis_queue import RedisJobQueue
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.rehearsal_session import RehearsalSessionRepository
from rehearsalhub.repositories.song import SongRepository from rehearsalhub.repositories.song import SongRepository
@@ -91,36 +90,40 @@ async def nc_upload(
if session_folder_path and session_folder_path.rstrip("/") == nc_folder.rstrip("/"): if session_folder_path and session_folder_path.rstrip("/") == nc_folder.rstrip("/"):
nc_folder = nc_folder + title + "/" nc_folder = nc_folder + title + "/"
version_repo = AudioVersionRepository(session)
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 # Resolve or create rehearsal session from YYMMDD folder segment
session_repo = RehearsalSessionRepository(session) session_repo = RehearsalSessionRepository(session)
rehearsal_date = parse_rehearsal_date(path) rehearsal_date = parse_rehearsal_date(path)
rehearsal_session_id = None rehearsal_session_id = None
if rehearsal_date: if rehearsal_date:
rehearsal_session = await session_repo.get_or_create(band.id, rehearsal_date, nc_folder) try:
rehearsal_session_id = rehearsal_session.id rehearsal_session = await session_repo.get_or_create(band.id, rehearsal_date, nc_folder)
log.debug("nc-upload: linked to session %s (%s)", rehearsal_session_id, rehearsal_date) rehearsal_session_id = rehearsal_session.id
log.debug("nc-upload: linked to session %s (%s)", rehearsal_session_id, rehearsal_date)
except Exception as exc:
log.error("nc-upload: failed to resolve session for '%s': %s", path, exc, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to resolve rehearsal session") from exc
song_repo = SongRepository(session) song_repo = SongRepository(session)
song = await song_repo.get_by_nc_folder_path(nc_folder) try:
if song is None: song = await song_repo.get_by_nc_folder_path(nc_folder)
song = await song_repo.get_by_title_and_band(band.id, title) if song is None:
if song is None: song = await song_repo.get_by_title_and_band(band.id, title)
song = await song_repo.create( if song is None:
band_id=band.id, song = await song_repo.create(
session_id=rehearsal_session_id, band_id=band.id,
title=title, session_id=rehearsal_session_id,
status="jam", title=title,
notes=None, status="jam",
nc_folder_path=nc_folder, notes=None,
created_by=None, nc_folder_path=nc_folder,
) 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: log.info("nc-upload: created song '%s' for band '%s'", title, band.slug)
song = await song_repo.update(song, session_id=rehearsal_session_id) elif rehearsal_session_id and song.session_id is None:
song = await song_repo.update(song, session_id=rehearsal_session_id)
except Exception as exc:
log.error("nc-upload: failed to find/create song for '%s': %s", path, exc, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to resolve song") from exc
# 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)
result = await session.execute( result = await session.execute(
@@ -137,16 +140,24 @@ async def nc_upload(
uploader = uploader_result.scalar_one_or_none() uploader = uploader_result.scalar_one_or_none()
storage = NextcloudClient.for_member(uploader) if uploader else None storage = NextcloudClient.for_member(uploader) if uploader else None
song_svc = SongService(session, storage=storage) try:
version = await song_svc.register_version( song_svc = SongService(session, storage=storage)
song.id, version = await song_svc.register_version(
AudioVersionCreate( song.id,
nc_file_path=path, AudioVersionCreate(
nc_file_etag=event.nc_file_etag, nc_file_path=path,
format=Path(path).suffix.lstrip(".").lower(), nc_file_etag=event.nc_file_etag,
), format=Path(path).suffix.lstrip(".").lower(),
uploader_id, ),
) uploader_id,
)
except Exception as exc:
log.error(
"nc-upload: failed to register version for '%s' (song '%s'): %s",
path, song.title, exc, exc_info=True,
)
raise HTTPException(status_code=500, detail="Failed to register version") from exc
log.info("nc-upload: registered version %s for song '%s'", version.id, song.title) log.info("nc-upload: registered version %s for song '%s'", version.id, song.title)
return {"status": "ok", "version_id": str(version.id), "song_id": str(song.id)} return {"status": "ok", "version_id": str(version.id), "song_id": str(song.id)}

View File

@@ -9,7 +9,6 @@ from urllib.parse import unquote
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.repositories.rehearsal_session import RehearsalSessionRepository 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
@@ -103,7 +102,6 @@ async def scan_band_folder(
dav_prefix = f"/remote.php/dav/files/{nc._auth[0]}/" dav_prefix = f"/remote.php/dav/files/{nc._auth[0]}/"
relative = _make_relative(dav_prefix) relative = _make_relative(dav_prefix)
version_repo = AudioVersionRepository(db_session)
session_repo = RehearsalSessionRepository(db_session) session_repo = RehearsalSessionRepository(db_session)
song_repo = SongRepository(db_session) song_repo = SongRepository(db_session)
song_svc = SongService(db_session) song_svc = SongService(db_session)
@@ -133,68 +131,69 @@ async def scan_band_folder(
meta = await nc.get_file_metadata(nc_file_path) meta = await nc.get_file_metadata(nc_file_path)
etag = meta.etag etag = meta.etag
except Exception as exc: except Exception as exc:
log.warning("Metadata error for '%s': %s", nc_file_path, exc) log.error("Metadata fetch failed for '%s': %s", nc_file_path, exc, exc_info=True)
skipped += 1
yield {"type": "skipped", "path": nc_file_path, "reason": f"metadata error: {exc}"} yield {"type": "skipped", "path": nc_file_path, "reason": f"metadata error: {exc}"}
continue continue
# Skip if this exact version is already indexed try:
if etag and await version_repo.get_by_etag(etag): # Resolve or create a RehearsalSession from a YYMMDD folder segment
log.info("Already registered (etag match): %s", nc_file_path) rehearsal_date = parse_rehearsal_date(nc_file_path)
skipped += 1 rehearsal_session_id = None
yield {"type": "skipped", "path": nc_file_path, "reason": "already registered"} if rehearsal_date:
continue session_folder = extract_session_folder(nc_file_path) or song_folder
rs = await session_repo.get_or_create(band_id, rehearsal_date, session_folder)
rehearsal_session_id = rs.id
yield {
"type": "session",
"session": {
"id": str(rs.id),
"date": rs.date.isoformat(),
"label": rs.label,
"nc_folder_path": rs.nc_folder_path,
},
}
# Resolve or create a RehearsalSession from a YYMMDD folder segment # Find or create the Song record
rehearsal_date = parse_rehearsal_date(nc_file_path) song = await song_repo.get_by_nc_folder_path(song_folder)
rehearsal_session_id = None if song is None:
if rehearsal_date: song = await song_repo.get_by_title_and_band(band_id, song_title)
session_folder = extract_session_folder(nc_file_path) or song_folder is_new = song is None
rs = await session_repo.get_or_create(band_id, rehearsal_date, session_folder) if is_new:
rehearsal_session_id = rs.id log.info("Creating song '%s' folder='%s'", song_title, song_folder)
yield { song = await song_repo.create(
"type": "session", band_id=band_id,
"session": { session_id=rehearsal_session_id,
"id": str(rs.id), title=song_title,
"date": rs.date.isoformat(), status="jam",
"label": rs.label, notes=None,
"nc_folder_path": rs.nc_folder_path, nc_folder_path=song_folder,
}, created_by=member_id,
} )
elif rehearsal_session_id and song.session_id is None:
song = await song_repo.update(song, session_id=rehearsal_session_id)
# Find or create the Song record # Register the audio version
song = await song_repo.get_by_nc_folder_path(song_folder) version = await song_svc.register_version(
if song is None: song.id,
song = await song_repo.get_by_title_and_band(band_id, song_title) AudioVersionCreate(
is_new = song is None nc_file_path=nc_file_path,
if is_new: nc_file_etag=etag,
log.info("Creating song '%s' folder='%s'", song_title, song_folder) format=Path(nc_file_path).suffix.lstrip(".").lower(),
song = await song_repo.create( file_size_bytes=meta.size,
band_id=band_id, ),
session_id=rehearsal_session_id, member_id,
title=song_title,
status="jam",
notes=None,
nc_folder_path=song_folder,
created_by=member_id,
) )
elif rehearsal_session_id and song.session_id is None: log.info("Imported '%s' as version %s for song '%s'", nc_file_path, version.id, song.title)
song = await song_repo.update(song, session_id=rehearsal_session_id)
# Register the audio version imported += 1
await song_svc.register_version( read = SongRead.model_validate(song).model_copy(update={"version_count": 1, "session_id": rehearsal_session_id})
song.id, yield {"type": "song", "song": read.model_dump(mode="json"), "is_new": is_new}
AudioVersionCreate(
nc_file_path=nc_file_path,
nc_file_etag=etag,
format=Path(nc_file_path).suffix.lstrip(".").lower(),
file_size_bytes=meta.size,
),
member_id,
)
imported += 1 except Exception as exc:
read = SongRead.model_validate(song).model_copy(update={"version_count": 1, "session_id": rehearsal_session_id}) log.error("Failed to import '%s': %s", nc_file_path, exc, exc_info=True)
yield {"type": "song", "song": read.model_dump(mode="json"), "is_new": is_new} skipped += 1
yield {"type": "skipped", "path": nc_file_path, "reason": f"import error: {exc}"}
yield { yield {
"type": "done", "type": "done",

View File

@@ -1,9 +1,12 @@
from __future__ import annotations from __future__ import annotations
import logging
import uuid import uuid
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
from rehearsalhub.db.models import AudioVersion, Song from rehearsalhub.db.models import AudioVersion, Song
from rehearsalhub.queue.redis_queue import RedisJobQueue from rehearsalhub.queue.redis_queue import RedisJobQueue
from rehearsalhub.repositories.audio_version import AudioVersionRepository from rehearsalhub.repositories.audio_version import AudioVersionRepository
@@ -67,11 +70,6 @@ class SongService:
data: AudioVersionCreate, data: AudioVersionCreate,
uploader_id: uuid.UUID, uploader_id: uuid.UUID,
) -> AudioVersion: ) -> AudioVersion:
if data.nc_file_etag:
existing = await self._version_repo.get_by_etag(data.nc_file_etag)
if existing:
return existing
version_number = await self._repo.next_version_number(song_id) version_number = await self._repo.next_version_number(song_id)
version = await self._version_repo.create( version = await self._version_repo.create(
song_id=song_id, song_id=song_id,
@@ -85,8 +83,15 @@ class SongService:
uploaded_by=uploader_id, uploaded_by=uploader_id,
) )
await self._queue.enqueue( try:
"transcode", await self._queue.enqueue(
{"version_id": str(version.id), "nc_file_path": data.nc_file_path}, "transcode",
) {"version_id": str(version.id), "nc_file_path": data.nc_file_path},
)
except Exception as exc:
log.error(
"Failed to enqueue transcode job for version %s ('%s'): %s",
version.id, data.nc_file_path, exc, exc_info=True,
)
return version return version