Refactor code structure for improved readability and maintainability

This commit is contained in:
JSC
2025-07-22 13:21:44 +02:00
parent 11796b1012
commit fefb7f7bf4
26 changed files with 1424 additions and 7 deletions

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ wheels/
# Virtual environments
.venv
.env

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""App package."""

1
app/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Core package."""

23
app/core/config.py Normal file
View File

@@ -0,0 +1,23 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
env_ignore_empty=True,
extra="ignore",
)
HOST: str = "localhost"
PORT: int = 8000
RELOAD: bool = True
LOG_LEVEL: str = "info"
DATABASE_URL: str = "sqlite+aiosqlite:///data/soundboard.db"
DATABASE_ECHO: bool = False
settings = Settings()

38
app/core/database.py Normal file
View File

@@ -0,0 +1,38 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.config import settings
from app.models import ( # noqa: F401
plan,
playlist,
playlist_sound,
sound,
sound_played,
stream,
user,
user_oauth,
)
engine: AsyncEngine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DATABASE_ECHO,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSession(engine) as session:
try:
yield session
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db() -> None:
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)

26
app/main.py Normal file
View File

@@ -0,0 +1,26 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.core.database import init_db
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
await init_db()
yield
def create_app() -> FastAPI:
app = FastAPI(lifespan=lifespan)
@app.get("/")
def health() -> dict[str, str]:
return {"status": "healthy"}
return app
app = create_app()

View File

@@ -0,0 +1,5 @@
"""Middleware package."""
from app.middleware.logging import LoggingMiddleware
__all__ = ["LoggingMiddleware"]

1
app/models/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Models package."""

13
app/models/base.py Normal file
View File

@@ -0,0 +1,13 @@
from datetime import UTC, datetime
from sqlmodel import Field, SQLModel
class BaseModel(SQLModel):
"""Base model with common fields for all models."""
id: int | None = Field(primary_key=True, default=None)
# timestamps
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))

21
app/models/plan.py Normal file
View File

@@ -0,0 +1,21 @@
from typing import TYPE_CHECKING
from sqlmodel import Field, Relationship
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.user import User
class Plan(BaseModel, table=True):
"""Database model for a plan."""
code: str = Field(index=True, unique=True, nullable=False)
name: str = Field(nullable=False)
description: str | None = Field(default=None)
credits: int = Field(default=0, ge=0, nullable=False)
max_credits: int = Field(default=0, ge=0, nullable=False)
# relationships
users: list["User"] = Relationship(back_populates="plan")

26
app/models/playlist.py Normal file
View File

@@ -0,0 +1,26 @@
from typing import TYPE_CHECKING
from sqlmodel import Field, Relationship
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.playlist_sound import PlaylistSound
from app.models.user import User
class Playlist(BaseModel, table=True):
"""Database model for a playlist."""
user_id: int | None = Field(foreign_key="user.id", default=None)
name: str = Field(unique=True, nullable=False)
description: str | None = Field(default=None)
genre: str | None = Field(default=None)
is_main: bool = Field(default=False, nullable=False)
is_current: bool = Field(default=False, nullable=False)
is_deletable: bool = Field(default=True, nullable=False)
# relationships
user: "User" = Relationship(back_populates="playlists")
playlist_sounds: list["PlaylistSound"] = Relationship(back_populates="playlist")

View File

@@ -0,0 +1,37 @@
from typing import TYPE_CHECKING
from sqlmodel import Field, Relationship, UniqueConstraint
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.playlist import Playlist
from app.models.sound import Sound
class PlaylistSound(BaseModel, table=True):
"""Database model for a sound in a playlist."""
__tablename__ = "playlist_sound" # pyright: ignore[reportAssignmentType]
playlist_id: int = Field(foreign_key="playlist.id", nullable=False)
sound_id: int = Field(foreign_key="sound.id", nullable=False)
position: int = Field(default=0, ge=0, nullable=False)
# constraints
__table_args__ = (
UniqueConstraint(
"playlist_id",
"sound_id",
name="uq_playlist_sound_playlist_sound",
),
UniqueConstraint(
"playlist_id",
"position",
name="uq_playlist_sound_playlist_position",
),
)
# relationships
playlist: "Playlist" = Relationship(back_populates="playlist_sounds")
sound: "Sound" = Relationship(back_populates="playlist_sounds")

34
app/models/sound.py Normal file
View File

@@ -0,0 +1,34 @@
from typing import TYPE_CHECKING
from sqlmodel import Field, Relationship
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.playlist_sound import PlaylistSound
from app.models.stream import Stream
class Sound(BaseModel, table=True):
"""Database model for a sound."""
type: str = Field(nullable=False)
name: str = Field(nullable=False)
filename: str = Field(nullable=False)
duration: int = Field(default=0, ge=0, nullable=False)
size: int = Field(default=0, ge=0, nullable=False)
hash: str = Field(nullable=False)
normalized_filename: str | None = Field(default=None)
normalized_duration: int | None = Field(default=None, ge=0)
normalized_size: int | None = Field(default=None, ge=0)
normalized_hash: str | None = Field(default=None)
thumbnail: str | None = Field(default=None)
play_count: int = Field(default=0, ge=0, nullable=False)
is_normalized: bool = Field(default=False, nullable=False)
is_music: bool = Field(default=False, nullable=False)
is_deletable: bool = Field(default=True, nullable=False)
# relationships
playlist_sounds: list["PlaylistSound"] = Relationship(back_populates="sound")
streams: list["Stream"] = Relationship(back_populates="sound")

View File

@@ -0,0 +1,32 @@
from typing import TYPE_CHECKING
from sqlmodel import Field, Relationship, UniqueConstraint
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.sound import Sound
from app.models.user import User
class SoundPlayed(BaseModel, table=True):
"""Database model for a sound played."""
__tablename__ = "sound_played" # pyright: ignore[reportAssignmentType]
user_id: int = Field(foreign_key="user.id", nullable=False)
sound_id: int = Field(foreign_key="sound.id", nullable=False)
# constraints
__table_args__ = (
UniqueConstraint(
"user_id",
"sound_id",
name="uq_sound_played_user_sound",
),
)
# relationships
user: "User" = Relationship(back_populates="sounds_played")
sound: "Sound" = Relationship(back_populates="play_history")

40
app/models/stream.py Normal file
View File

@@ -0,0 +1,40 @@
from typing import TYPE_CHECKING
from sqlmodel import Field, Relationship, UniqueConstraint
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.sound import Sound
from app.models.user import User
class Stream(BaseModel, table=True):
"""Database model for a stream."""
service: str = Field(nullable=False)
service_id: str = Field(nullable=False)
user_id: int = Field(foreign_key="user.id", nullable=False)
sound_id: int | None = Field(foreign_key="sound.id", default=None)
url: str = Field(nullable=False)
title: str | None = Field(default=None)
track: str | None = Field(default=None)
artist: str | None = Field(default=None)
album: str | None = Field(default=None)
genre: str | None = Field(default=None)
status: str = Field(nullable=False, default="pending")
error: str | None = Field(default=None)
# constraints
__table_args__ = (
UniqueConstraint(
"service",
"service_id",
name="uq_stream_service_service_id",
),
)
# relationships
sound: "Sound" = Relationship(back_populates="streams")
user: "User" = Relationship(back_populates="streams")

32
app/models/user.py Normal file
View File

@@ -0,0 +1,32 @@
from datetime import datetime
from typing import TYPE_CHECKING
from sqlmodel import Field, Relationship
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.plan import Plan
from app.models.playlist import Playlist
from app.models.user_oauth import UserOauth
class User(BaseModel, table=True):
"""Database model for a user."""
plan_id: int = Field(foreign_key="plan.id")
role: str = Field(nullable=False, default="user")
email: str = Field(unique=True, nullable=False)
name: str = Field(nullable=False)
picture: str | None = Field(default=None)
password_hash: str | None = Field(default=None)
is_active: bool = Field(nullable=False, default=True)
credits: int = Field(default=0, ge=0, nullable=False)
api_token: str | None = Field(unique=True, default=None)
api_token_expires_at: datetime | None = Field(default=None)
# relationships
oauths: list["UserOauth"] = Relationship(back_populates="user")
plan: "Plan" = Relationship(back_populates="users")
playlists: list["Playlist"] = Relationship(back_populates="user")

34
app/models/user_oauth.py Normal file
View File

@@ -0,0 +1,34 @@
from typing import TYPE_CHECKING
from sqlmodel import Field, Relationship, UniqueConstraint
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.user import User
class UserOauth(BaseModel, table=True):
"""Database model for a user OAuth."""
__tablename__ = "user_oauth" # pyright: ignore[reportAssignmentType]
user_id: int = Field(foreign_key="user.id", nullable=False)
provider: str = Field(nullable=False)
provider_user_id: str = Field(nullable=False)
email: str = Field(nullable=False)
name: str = Field(nullable=False)
picture: str | None = Field(default=None)
# constraints
__table_args__ = (
UniqueConstraint(
"provider",
"provider_user_id",
name="uq_user_oauth_provider_user_id",
),
)
# relationships
user: "User" = Relationship(back_populates="oauths")

View File

@@ -0,0 +1 @@
"""Repositories package."""

1
app/schemas/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Schemas package."""

1
app/services/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Services package."""

1
app/utils/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Utils package."""

2
data/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -1,6 +0,0 @@
def main():
print("Hello from backend!")
if __name__ == "__main__":
main()

View File

@@ -4,4 +4,31 @@ version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []
dependencies = [
"aiosqlite==0.21.0",
"fastapi[standard]==0.116.1",
"pydantic-settings==2.10.1",
"uvicorn[standard]==0.35.0",
]
[tool.uv]
dev-dependencies = [
"coverage==7.9.2",
"faker==37.4.2",
"mypy==1.17.0",
"pytest==8.4.1",
"ruff==0.12.4",
"sqlmodel==0.0.24",
]
[tool.mypy]
strict = true
exclude = ["venv", ".venv", "alembic"]
[tool.ruff]
target-version = "py312"
exclude = ["alembic"]
[tool.ruff.lint]
select = ["ALL"]
ignore = ["D100", "D103"]

12
run.py Normal file
View File

@@ -0,0 +1,12 @@
import uvicorn
from app.core.config import settings
if __name__ == "__main__":
uvicorn.run(
"app.main:app",
host=settings.HOST,
port=settings.PORT,
reload=settings.RELOAD,
log_level=settings.LOG_LEVEL,
)

1013
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff