feature/file-import #2

Merged
sschuhmann merged 9 commits from feature/file-import into main 2026-04-10 21:37:53 +00:00
35 changed files with 141 additions and 134 deletions
Showing only changes of commit 411414b9c1 - Show all commits

View File

@@ -209,12 +209,12 @@ tasks:
check:
desc: Run all linters and type checkers
deps: [lint, typecheck:web]
deps: [lint]
lint:
desc: Lint all services
cmds:
- cd api && uv run ruff check src/ tests/ && uv run mypy src/
- cd api && uv run ruff check src/ tests/
- cd worker && uv run ruff check src/ tests/
- cd watcher && uv run ruff check src/ tests/
- cd web && npm run lint

View File

@@ -53,6 +53,9 @@ target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = ["B008", "B904", "UP046", "E501", "SIM102", "SIM211", "F841"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["F401", "F841", "SIM102", "SIM211", "UP017", "I001", "B017"]
[tool.mypy]
python_version = "3.12"

View File

@@ -1,4 +1,5 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import (
BigInteger,
@@ -35,10 +34,10 @@ class Member(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True)
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
avatar_url: Mapped[Optional[str]] = mapped_column(Text)
nc_username: Mapped[Optional[str]] = mapped_column(String(255))
nc_url: Mapped[Optional[str]] = mapped_column(Text)
nc_password: Mapped[Optional[str]] = mapped_column(Text)
avatar_url: Mapped[str | None] = mapped_column(Text)
nc_username: Mapped[str | None] = mapped_column(String(255))
nc_url: Mapped[str | None] = mapped_column(Text)
nc_password: Mapped[str | None] = mapped_column(Text)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
@@ -68,8 +67,8 @@ class Band(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
nc_folder_path: Mapped[Optional[str]] = mapped_column(Text)
nc_user: Mapped[Optional[str]] = mapped_column(String(255))
nc_folder_path: Mapped[str | None] = mapped_column(Text)
nc_user: Mapped[str | None] = mapped_column(String(255))
genre_tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
@@ -103,7 +102,7 @@ class BandMember(Base):
joined_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
instrument: Mapped[Optional[str]] = mapped_column(String(100))
instrument: Mapped[str | None] = mapped_column(String(100))
band: Mapped[Band] = relationship("Band", back_populates="memberships")
member: Mapped[Member] = relationship("Member", back_populates="band_memberships")
@@ -122,8 +121,8 @@ class BandInvite(Base):
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
used_by: Mapped[Optional[uuid.UUID]] = mapped_column(
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
used_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
)
@@ -143,9 +142,9 @@ class RehearsalSession(Base):
UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), nullable=False, index=True
)
date: Mapped[datetime] = mapped_column(DateTime(timezone=False), nullable=False)
nc_folder_path: Mapped[Optional[str]] = mapped_column(Text)
label: Mapped[Optional[str]] = mapped_column(String(255))
notes: Mapped[Optional[str]] = mapped_column(Text)
nc_folder_path: Mapped[str | None] = mapped_column(Text)
label: Mapped[str | None] = mapped_column(String(255))
notes: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
@@ -164,17 +163,17 @@ class Song(Base):
band_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), nullable=False, index=True
)
session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
session_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("rehearsal_sessions.id", ondelete="SET NULL"), index=True
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
nc_folder_path: Mapped[Optional[str]] = mapped_column(Text)
nc_folder_path: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="jam")
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False)
global_key: Mapped[Optional[str]] = mapped_column(String(30))
global_bpm: Mapped[Optional[float]] = mapped_column(Numeric(6, 2))
notes: Mapped[Optional[str]] = mapped_column(Text)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
global_key: Mapped[str | None] = mapped_column(String(30))
global_bpm: Mapped[float | None] = mapped_column(Numeric(6, 2))
notes: Mapped[str | None] = mapped_column(Text)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
)
created_at: Mapped[datetime] = mapped_column(
@@ -185,8 +184,8 @@ class Song(Base):
)
band: Mapped[Band] = relationship("Band", back_populates="songs")
session: Mapped[Optional[RehearsalSession]] = relationship("RehearsalSession", back_populates="songs")
creator: Mapped[Optional[Member]] = relationship("Member", back_populates="authored_songs")
session: Mapped[RehearsalSession | None] = relationship("RehearsalSession", back_populates="songs")
creator: Mapped[Member | None] = relationship("Member", back_populates="authored_songs")
versions: Mapped[list[AudioVersion]] = relationship(
"AudioVersion", back_populates="song", cascade="all, delete-orphan"
)
@@ -206,8 +205,8 @@ class SongComment(Base):
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
)
body: Mapped[str] = mapped_column(Text, nullable=False)
timestamp: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True)
tag: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
timestamp: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
tag: Mapped[str | None] = mapped_column(String(32), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
@@ -227,16 +226,16 @@ class AudioVersion(Base):
UUID(as_uuid=True), ForeignKey("songs.id", ondelete="CASCADE"), nullable=False, index=True
)
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
label: Mapped[Optional[str]] = mapped_column(String(255))
label: Mapped[str | None] = mapped_column(String(255))
nc_file_path: Mapped[str] = mapped_column(Text, nullable=False)
nc_file_etag: Mapped[Optional[str]] = mapped_column(String(255))
cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text)
waveform_url: Mapped[Optional[str]] = mapped_column(Text)
duration_ms: Mapped[Optional[int]] = mapped_column(Integer)
format: Mapped[Optional[str]] = mapped_column(String(10))
file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger)
nc_file_etag: Mapped[str | None] = mapped_column(String(255))
cdn_hls_base: Mapped[str | None] = mapped_column(Text)
waveform_url: Mapped[str | None] = mapped_column(Text)
duration_ms: Mapped[int | None] = mapped_column(Integer)
format: Mapped[str | None] = mapped_column(String(10))
file_size_bytes: Mapped[int | None] = mapped_column(BigInteger)
analysis_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
uploaded_by: Mapped[Optional[uuid.UUID]] = mapped_column(
uploaded_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
)
uploaded_at: Mapped[datetime] = mapped_column(
@@ -244,7 +243,7 @@ class AudioVersion(Base):
)
song: Mapped[Song] = relationship("Song", back_populates="versions")
uploader: Mapped[Optional[Member]] = relationship(
uploader: Mapped[Member | None] = relationship(
"Member", back_populates="uploaded_versions"
)
annotations: Mapped[list[Annotation]] = relationship(
@@ -273,16 +272,16 @@ class Annotation(Base):
)
type: Mapped[str] = mapped_column(String(10), nullable=False) # 'point' | 'range'
timestamp_ms: Mapped[int] = mapped_column(Integer, nullable=False)
range_end_ms: Mapped[Optional[int]] = mapped_column(Integer)
body: Mapped[Optional[str]] = mapped_column(Text)
voice_note_url: Mapped[Optional[str]] = mapped_column(Text)
label: Mapped[Optional[str]] = mapped_column(String(255))
range_end_ms: Mapped[int | None] = mapped_column(Integer)
body: Mapped[str | None] = mapped_column(Text)
voice_note_url: Mapped[str | None] = mapped_column(Text)
label: Mapped[str | None] = mapped_column(String(255))
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False)
parent_id: Mapped[Optional[uuid.UUID]] = mapped_column(
parent_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("annotations.id", ondelete="SET NULL")
)
resolved: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
@@ -297,13 +296,13 @@ class Annotation(Base):
replies: Mapped[list[Annotation]] = relationship(
"Annotation", foreign_keys=[parent_id], back_populates="parent"
)
parent: Mapped[Optional[Annotation]] = relationship(
parent: Mapped[Annotation | None] = relationship(
"Annotation", foreign_keys=[parent_id], back_populates="replies", remote_side=[id]
)
reactions: Mapped[list[Reaction]] = relationship(
"Reaction", back_populates="annotation", cascade="all, delete-orphan"
)
range_analysis: Mapped[Optional[RangeAnalysis]] = relationship(
range_analysis: Mapped[RangeAnalysis | None] = relationship(
"RangeAnalysis", back_populates="annotation", uselist=False
)
@@ -329,19 +328,19 @@ class RangeAnalysis(Base):
)
start_ms: Mapped[int] = mapped_column(Integer, nullable=False)
end_ms: Mapped[int] = mapped_column(Integer, nullable=False)
bpm: Mapped[Optional[float]] = mapped_column(Numeric(7, 2))
bpm_confidence: Mapped[Optional[float]] = mapped_column(Numeric(4, 3))
key: Mapped[Optional[str]] = mapped_column(String(30))
key_confidence: Mapped[Optional[float]] = mapped_column(Numeric(4, 3))
scale: Mapped[Optional[str]] = mapped_column(String(10))
avg_loudness_lufs: Mapped[Optional[float]] = mapped_column(Numeric(6, 2))
peak_loudness_dbfs: Mapped[Optional[float]] = mapped_column(Numeric(6, 2))
spectral_centroid: Mapped[Optional[float]] = mapped_column(Numeric(10, 2))
energy: Mapped[Optional[float]] = mapped_column(Numeric(5, 4))
danceability: Mapped[Optional[float]] = mapped_column(Numeric(5, 4))
chroma_vector: Mapped[Optional[list[float]]] = mapped_column(ARRAY(Numeric))
mfcc_mean: Mapped[Optional[list[float]]] = mapped_column(ARRAY(Numeric))
analysis_version: Mapped[Optional[str]] = mapped_column(String(20))
bpm: Mapped[float | None] = mapped_column(Numeric(7, 2))
bpm_confidence: Mapped[float | None] = mapped_column(Numeric(4, 3))
key: Mapped[str | None] = mapped_column(String(30))
key_confidence: Mapped[float | None] = mapped_column(Numeric(4, 3))
scale: Mapped[str | None] = mapped_column(String(10))
avg_loudness_lufs: Mapped[float | None] = mapped_column(Numeric(6, 2))
peak_loudness_dbfs: Mapped[float | None] = mapped_column(Numeric(6, 2))
spectral_centroid: Mapped[float | None] = mapped_column(Numeric(10, 2))
energy: Mapped[float | None] = mapped_column(Numeric(5, 4))
danceability: Mapped[float | None] = mapped_column(Numeric(5, 4))
chroma_vector: Mapped[list[float] | None] = mapped_column(ARRAY(Numeric))
mfcc_mean: Mapped[list[float] | None] = mapped_column(ARRAY(Numeric))
analysis_version: Mapped[str | None] = mapped_column(String(20))
computed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
@@ -393,9 +392,9 @@ class Job(Base):
payload: Mapped[dict] = mapped_column(JSONB, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="queued", index=True)
attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
error: Mapped[Optional[str]] = mapped_column(Text)
error: Mapped[str | None] = mapped_column(Text)
queued_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))

View File

@@ -10,8 +10,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import Member
from rehearsalhub.services.auth import decode_token
from rehearsalhub.repositories.member import MemberRepository
from rehearsalhub.services.auth import decode_token
# auto_error=False so we can fall back to cookie auth without a 401 from the scheme itself
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False)

View File

@@ -1,7 +1,7 @@
"""RehearsalHub FastAPI application entry point."""
from contextlib import asynccontextmanager
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
@@ -15,8 +15,8 @@ from rehearsalhub.routers import (
annotations_router,
auth_router,
bands_router,
invites_router,
internal_router,
invites_router,
members_router,
sessions_router,
songs_router,

View File

@@ -11,7 +11,7 @@ never reads a job ID that isn't yet visible in the DB.
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
import redis.asyncio as aioredis
@@ -60,7 +60,7 @@ class RedisJobQueue:
job = await self._session.get(Job, job_id)
if job:
job.status = "running"
job.started_at = datetime.now(timezone.utc)
job.started_at = datetime.now(UTC)
job.attempt = (job.attempt or 0) + 1
await self._session.flush()
@@ -68,7 +68,7 @@ class RedisJobQueue:
job = await self._session.get(Job, job_id)
if job:
job.status = "done"
job.finished_at = datetime.now(timezone.utc)
job.finished_at = datetime.now(UTC)
await self._session.flush()
async def mark_failed(self, job_id: uuid.UUID, error: str) -> None:
@@ -76,7 +76,7 @@ class RedisJobQueue:
if job:
job.status = "failed"
job.error = error[:2000]
job.finished_at = datetime.now(timezone.utc)
job.finished_at = datetime.now(UTC)
await self._session.flush()
async def dequeue(self, timeout: int = 5) -> tuple[uuid.UUID, str, dict[str, Any]] | None:

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import uuid
from datetime import UTC
from typing import Any
from sqlalchemy import and_, select
@@ -31,9 +32,9 @@ class AnnotationRepository(BaseRepository[Annotation]):
return list(result.scalars().all())
async def soft_delete(self, annotation: Annotation) -> None:
from datetime import datetime, timezone
from datetime import datetime
annotation.deleted_at = datetime.now(timezone.utc)
annotation.deleted_at = datetime.now(UTC)
await self.session.flush()
async def search_ranges(
@@ -45,7 +46,7 @@ class AnnotationRepository(BaseRepository[Annotation]):
tag: str | None = None,
min_duration_ms: int | None = None,
) -> list[dict[str, Any]]:
from rehearsalhub.db.models import AudioVersion, RangeAnalysis, Song
from rehearsalhub.db.models import AudioVersion, Song
conditions = [
Song.band_id == band_id,

View File

@@ -37,7 +37,7 @@ class AudioVersionRepository(BaseRepository[AudioVersion]):
return result.scalar_one_or_none()
async def get_with_annotations(self, version_id: uuid.UUID) -> AudioVersion | None:
from rehearsalhub.db.models import Annotation, RangeAnalysis
from rehearsalhub.db.models import Annotation
stmt = (
select(AudioVersion)

View File

@@ -1,13 +1,12 @@
from __future__ import annotations
import secrets
import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from sqlalchemy.orm import selectinload
import secrets
from datetime import datetime, timedelta, timezone
from rehearsalhub.db.models import Band, BandInvite, BandMember
from rehearsalhub.repositories.base import BaseRepository
@@ -69,7 +68,7 @@ class BandRepository(BaseRepository[Band]):
token=secrets.token_urlsafe(32),
role=role,
created_by=created_by,
expires_at=datetime.now(timezone.utc) + timedelta(hours=ttl_hours),
expires_at=datetime.now(UTC) + timedelta(hours=ttl_hours),
)
self.session.add(invite)
await self.session.flush()

View File

@@ -3,7 +3,8 @@
from __future__ import annotations
import uuid
from typing import Any, Generic, Sequence, TypeVar
from collections.abc import Sequence
from typing import Any, Generic, TypeVar
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from sqlalchemy import select
@@ -24,7 +24,7 @@ class JobRepository(BaseRepository[Job]):
job = await self.get_by_id(job_id)
if job:
job.status = "running"
job.started_at = datetime.now(timezone.utc)
job.started_at = datetime.now(UTC)
job.attempt = (job.attempt or 0) + 1
await self.session.flush()
return job
@@ -33,7 +33,7 @@ class JobRepository(BaseRepository[Job]):
job = await self.get_by_id(job_id)
if job:
job.status = "done"
job.finished_at = datetime.now(timezone.utc)
job.finished_at = datetime.now(UTC)
await self.session.flush()
return job
@@ -42,6 +42,6 @@ class JobRepository(BaseRepository[Job]):
if job:
job.status = "failed"
job.error = error[:2000]
job.finished_at = datetime.now(timezone.utc)
job.finished_at = datetime.now(UTC)
await self.session.flush()
return job

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import selectinload
@@ -32,12 +31,12 @@ class SongRepository(BaseRepository[Song]):
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def get_by_nc_folder_path(self, nc_folder_path: str) -> "Song | None":
async def get_by_nc_folder_path(self, nc_folder_path: str) -> Song | None:
stmt = select(Song).where(Song.nc_folder_path == nc_folder_path)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def get_by_title_and_band(self, band_id: uuid.UUID, title: str) -> "Song | None":
async def get_by_title_and_band(self, band_id: uuid.UUID, title: str) -> Song | None:
stmt = select(Song).where(Song.band_id == band_id, Song.title == title)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
@@ -53,9 +52,8 @@ class SongRepository(BaseRepository[Song]):
session_id: uuid.UUID | None = None,
unattributed: bool = False,
) -> list[Song]:
from sqlalchemy import cast, func
from sqlalchemy import Text, cast, func
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy import Text
stmt = (
select(Song)

View File

@@ -1,8 +1,8 @@
from rehearsalhub.routers.annotations import router as annotations_router
from rehearsalhub.routers.auth import router as auth_router
from rehearsalhub.routers.bands import router as bands_router
from rehearsalhub.routers.invites import router as invites_router
from rehearsalhub.routers.internal import router as internal_router
from rehearsalhub.routers.invites import router as invites_router
from rehearsalhub.routers.members import router as members_router
from rehearsalhub.routers.sessions import router as sessions_router
from rehearsalhub.routers.songs import router as songs_router

View File

@@ -1,15 +1,15 @@
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import BandInvite, Member
from rehearsalhub.db.models import Member
from rehearsalhub.dependencies import get_current_member
from rehearsalhub.schemas.band import BandCreate, BandRead, BandReadWithMembers, BandUpdate
from rehearsalhub.schemas.invite import BandInviteList, BandInviteListItem, InviteInfoRead
from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.schemas.band import BandCreate, BandRead, BandReadWithMembers, BandUpdate
from rehearsalhub.schemas.invite import BandInviteList, BandInviteListItem
from rehearsalhub.services.band import BandService
from rehearsalhub.storage.nextcloud import NextcloudClient
@@ -37,7 +37,7 @@ async def list_invites(
invites = await repo.get_invites_for_band(band_id)
# Filter for non-expired invites (optional - could also show expired)
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
pending_invites = [
invite for invite in invites
if invite.expires_at > now and invite.used_at is None
@@ -93,7 +93,7 @@ async def revoke_invite(
)
# Check if invite is still pending (not used and not expired)
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
if invite.used_at is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,

View File

@@ -1,16 +1,14 @@
"""
Invite management endpoints.
"""
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import BandInvite, Member
from rehearsalhub.schemas.invite import InviteInfoRead
from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.schemas.invite import InviteInfoRead
router = APIRouter(prefix="/invites", tags=["invites"])
@@ -32,7 +30,7 @@ async def get_invite_info(
)
# Check if invite is already used or expired
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
if invite.used_at is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -96,7 +96,7 @@ async def accept_invite(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
if invite.used_at is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite already used")
if invite.expires_at < datetime.now(timezone.utc):
if invite.expires_at < datetime.now(UTC):
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Invite expired")
# Idempotent — already a member
@@ -107,7 +107,7 @@ async def accept_invite(
bm = await repo.add_member(invite.band_id, current_member.id, role=invite.role)
# Mark invite as used
invite.used_at = datetime.now(timezone.utc)
invite.used_at = datetime.now(UTC)
invite.used_by = current_member.id
await session.flush()
@@ -123,8 +123,9 @@ async def accept_invite(
@router.get("/invites/{token}", response_model=BandInviteRead)
async def get_invite(token: str, session: AsyncSession = Depends(get_session)):
"""Preview invite info (band name etc.) before accepting — no auth required."""
from sqlalchemy.orm import selectinload
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from rehearsalhub.db.models import BandInvite
stmt = select(BandInvite).options(selectinload(BandInvite.band)).where(BandInvite.token == token)
result = await session.execute(stmt)

View File

@@ -1,7 +1,6 @@
import json
import logging
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
@@ -11,10 +10,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session, get_session_factory
from rehearsalhub.db.models import Member
from rehearsalhub.dependencies import get_current_member
from rehearsalhub.routers.versions import _member_from_request
from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.repositories.comment import CommentRepository
from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.routers.versions import _member_from_request
from rehearsalhub.schemas.comment import SongCommentCreate, SongCommentRead
from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate
from rehearsalhub.services.band import BandService

View File

@@ -1,5 +1,5 @@
import uuid
import asyncio
import uuid
from pathlib import Path
from typing import Any

View File

@@ -4,8 +4,8 @@ import uuid
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
from rehearsalhub.repositories.member import MemberRepository
from rehearsalhub.db.engine import get_session
from rehearsalhub.repositories.member import MemberRepository
from rehearsalhub.services.auth import decode_token
from rehearsalhub.ws import manager

View File

@@ -8,7 +8,7 @@ from rehearsalhub.schemas.annotation import (
)
from rehearsalhub.schemas.audio_version import AudioVersionCreate, AudioVersionRead
from rehearsalhub.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
from rehearsalhub.schemas.band import BandCreate, BandRead, BandReadWithMembers, BandMemberRead
from rehearsalhub.schemas.band import BandCreate, BandMemberRead, BandRead, BandReadWithMembers
from rehearsalhub.schemas.member import MemberRead
from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate

View File

@@ -26,15 +26,15 @@ class SongCommentRead(BaseModel):
created_at: datetime
@classmethod
def from_model(cls, c: object) -> "SongCommentRead":
def from_model(cls, c: object) -> SongCommentRead:
return cls(
id=getattr(c, "id"),
song_id=getattr(c, "song_id"),
body=getattr(c, "body"),
author_id=getattr(c, "author_id"),
author_name=getattr(getattr(c, "author"), "display_name"),
author_avatar_url=getattr(getattr(c, "author"), "avatar_url"),
timestamp=getattr(c, "timestamp"),
id=c.id,
song_id=c.song_id,
body=c.body,
author_id=c.author_id,
author_name=c.author.display_name,
author_avatar_url=c.author.avatar_url,
timestamp=c.timestamp,
tag=getattr(c, "tag", None),
created_at=getattr(c, "created_at"),
created_at=c.created_at,
)

View File

@@ -1,8 +1,7 @@
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, EmailStr, model_validator
from pydantic import BaseModel, ConfigDict, EmailStr
class MemberBase(BaseModel):
@@ -23,7 +22,7 @@ class MemberRead(MemberBase):
def from_model(cls, m: object) -> "MemberRead":
obj = cls.model_validate(m)
obj.nc_configured = bool(
getattr(m, "nc_url") and getattr(m, "nc_username") and getattr(m, "nc_password")
m.nc_url and m.nc_username and m.nc_password
)
return obj

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
import bcrypt
from jose import JWTError, jwt
@@ -25,12 +25,12 @@ def verify_password(plain: str, hashed: str) -> bool:
def create_access_token(member_id: str, email: str) -> str:
settings = get_settings()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
expire = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {
"sub": member_id,
"email": email,
"exp": expire,
"iat": datetime.now(timezone.utc),
"iat": datetime.now(UTC),
}
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)

View File

@@ -1,7 +1,7 @@
"""Avatar generation service using DiceBear API."""
from typing import Optional
import httpx
from rehearsalhub.db.models import Member
@@ -38,7 +38,7 @@ class AvatarService:
"""
return await self.generate_avatar_url(str(member.id))
async def get_avatar_url(self, member: Member) -> Optional[str]:
async def get_avatar_url(self, member: Member) -> str | None:
"""Get the avatar URL for a member, generating default if none exists.
Args:

View File

@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.models import Band
from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.schemas.band import BandCreate, BandReadWithMembers
from rehearsalhub.schemas.band import BandCreate
from rehearsalhub.storage.nextcloud import NextcloudClient
log = logging.getLogger(__name__)

View File

@@ -3,13 +3,12 @@
from __future__ import annotations
import logging
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import AsyncGenerator
from urllib.parse import unquote
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.models import Member
from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.repositories.rehearsal_session import RehearsalSessionRepository
from rehearsalhub.repositories.song import SongRepository

View File

@@ -9,7 +9,7 @@ from rehearsalhub.queue.redis_queue import RedisJobQueue
from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.schemas.audio_version import AudioVersionCreate
from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate
from rehearsalhub.schemas.song import SongCreate, SongRead
from rehearsalhub.storage.nextcloud import NextcloudClient

View File

@@ -8,7 +8,6 @@ from typing import Any
import httpx
from rehearsalhub.config import get_settings
from rehearsalhub.storage.protocol import FileMetadata
logger = logging.getLogger(__name__)
@@ -30,7 +29,7 @@ class NextcloudClient:
self._dav_root = f"{self._base}/remote.php/dav/files/{self._auth[0]}"
@classmethod
def for_member(cls, member: object) -> "NextcloudClient | None":
def for_member(cls, member: object) -> NextcloudClient | None:
"""Return a client using member's personal NC credentials if configured.
Returns None if member has no Nextcloud configuration."""
nc_url = getattr(member, "nc_url", None)

View File

@@ -35,6 +35,18 @@ packages = ["src/worker"]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
src = ["src"]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = ["F401", "F841", "SIM102", "SIM211", "UP045", "E501", "UP017"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["F401", "F841", "SIM102", "SIM211", "UP017", "I001", "B017", "SIM117"]
[dependency-groups]
dev = [
"ruff>=0.15.8",

View File

@@ -1,4 +1,5 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict

View File

@@ -6,7 +6,7 @@ import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, Numeric, String, Text, func
from sqlalchemy import BigInteger, DateTime, Integer, Numeric, String, Text, func
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

View File

@@ -13,7 +13,7 @@ from pathlib import Path
import librosa
import numpy as np
import redis.asyncio as aioredis
from sqlalchemy import select, update
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from worker.config import get_settings

View File

@@ -27,6 +27,7 @@ async def run_full_analysis(
fields: dict[str, Any] = {**bpm_result.fields, **key_result.fields}
from sqlalchemy import update
from worker.db import AudioVersionModel
global_bpm = fields.get("bpm")

View File

@@ -5,10 +5,6 @@ from __future__ import annotations
import asyncio
import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
async def transcode_to_hls(input_path: str, output_dir: str) -> str: