diff --git a/backend/.env.example b/backend/.env.example index 3a45a8b..279d84c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,21 +1,41 @@ # App Settings -app_name= -app_env= -app_version= - +APP_NAME=musicstreamer +APP_ENV=development +APP_VERSION=1.0.0 # Database Settings -postgres_db= -postgres_user= -postgres_password= -postgres_server= -postgres_port= +POSTGRES_DB=music_stream_secure +POSTGRES_USER=music_admin +POSTGRES_PASSWORD=your_secure_password_here +POSTGRES_SERVER=localhost +POSTGRES_PORT=5432 # JWT Authentication -jwt_secret_key= -jwt_algorithm= -access_token_expire_minutes= -refresh_token_expire_minutes= +JWT_SECRET_KEY=your_jwt_secret_key_here +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=60 +REFRESH_TOKEN_EXPIRE_MINUTES=43200 # Password Security -password_pepper= +PASSWORD_PEPPER=password_pepper_here + +# Test User Credentials +TEST_ADMIN_USERNAME=test_admin +TEST_ADMIN_EMAIL=admin@test.com +TEST_ADMIN_PASSWORD=AdminPass123! +TEST_ADMIN_FIRST_NAME=Test +TEST_ADMIN_LAST_NAME=Admin + +TEST_MUSICIAN_USERNAME=test_musician +TEST_MUSICIAN_EMAIL=musician@test.com +TEST_MUSICIAN_PASSWORD=MusicianPass123! +TEST_MUSICIAN_FIRST_NAME=Test +TEST_MUSICIAN_LAST_NAME=Musician +TEST_MUSICIAN_STAGE_NAME=Test Musician +TEST_MUSICIAN_BIO=A test musician for development + +TEST_LISTENER_USERNAME=test_listener +TEST_LISTENER_EMAIL=listener@test.com +TEST_LISTENER_PASSWORD=ListenerPass123! +TEST_LISTENER_FIRST_NAME=Test +TEST_LISTENER_LAST_NAME=Listener diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..c7b551c --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,63 @@ +# Upload directories - exclude all uploaded files +uploads/ +uploads/* + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +*.log +logs/ + +# Database +*.db +*.sqlite3 + +# Temporary files +*.tmp +*.temp +temp/ diff --git a/backend/alembic/versions/51eb42f5babc_add_playlist_sharing_and_collaboration_.py b/backend/alembic/versions/51eb42f5babc_add_playlist_sharing_and_collaboration_.py new file mode 100644 index 0000000..8568902 --- /dev/null +++ b/backend/alembic/versions/51eb42f5babc_add_playlist_sharing_and_collaboration_.py @@ -0,0 +1,43 @@ +"""Add playlist sharing and collaboration fields + +Revision ID: 51eb42f5babc +Revises: 95b5ebff5e7a +Create Date: 2025-08-12 03:31:03.437557 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '51eb42f5babc' +down_revision: Union[str, Sequence[str], None] = '95b5ebff5e7a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('playlists', sa.Column('share_token', sa.String(length=64), nullable=True)) + op.add_column('playlists', sa.Column('allow_collaboration', sa.Boolean(), nullable=True)) + + # Set default value for existing records + op.execute("UPDATE playlists SET allow_collaboration = FALSE WHERE allow_collaboration IS NULL") + + # Make the column NOT NULL after setting default values + op.alter_column('playlists', 'allow_collaboration', nullable=False) + + op.create_unique_constraint(None, 'playlists', ['share_token']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'playlists', type_='unique') + op.drop_column('playlists', 'allow_collaboration') + op.drop_column('playlists', 'share_token') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/95b5ebff5e7a_add_is_cleared_to_history.py b/backend/alembic/versions/95b5ebff5e7a_add_is_cleared_to_history.py new file mode 100644 index 0000000..4e2c8ed --- /dev/null +++ b/backend/alembic/versions/95b5ebff5e7a_add_is_cleared_to_history.py @@ -0,0 +1,34 @@ +"""add_is_cleared_to_history + +Revision ID: 95b5ebff5e7a +Revises: 407106d49b66 +Create Date: 2025-08-11 06:37:39.301845 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '95b5ebff5e7a' +down_revision: Union[str, Sequence[str], None] = '407106d49b66' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('histories', sa.Column('is_cleared', sa.Boolean(), nullable=False)) + op.create_index('idx_history_cleared', 'histories', ['is_cleared'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('idx_history_cleared', table_name='histories') + op.drop_column('histories', 'is_cleared') + # ### end Alembic commands ### diff --git a/backend/app/api/v1/album.py b/backend/app/api/v1/album.py new file mode 100644 index 0000000..6d0faa7 --- /dev/null +++ b/backend/app/api/v1/album.py @@ -0,0 +1,247 @@ +""" +Album API endpoints. +""" + +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.core.deps import get_current_user, get_db, get_current_admin +from app.crud.album import ( + create_album, get_album_by_id, get_all_albums, update_album, + get_albums_by_artist, get_albums_by_band, get_albums_by_title, + get_albums_by_artist_name, get_albums_by_band_name, + get_albums_released_between, get_albums_by_user, + get_album_with_songs, get_album_count, album_exists +) +from app.schemas.album import ( + AlbumCreate, AlbumUpdate, AlbumOut, AlbumWithRelations, + AlbumList, AlbumListWithRelations, AlbumFilter, AlbumSearch, AlbumStats +) +from app.db.models.user import User + +router = APIRouter() + + +@router.post("/", response_model=AlbumOut) +async def create_album_endpoint( + album_data: AlbumCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Create a new album. + + - **current_user**: Authenticated user (admin or musician) + """ + if current_user.role not in ["admin", "musician"]: + raise HTTPException(status_code=403, detail="Only admins and musicians can create albums") + + try: + album = create_album(db, album_data, current_user.id) + return album + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/{album_id}", response_model=AlbumWithRelations) +async def get_album_endpoint( + album_id: int, + db: Session = Depends(get_db) +): + """ + Get album by ID with relationships. + + - **album_id**: ID of the album to retrieve + """ + album = get_album_with_songs(db, album_id) + if not album: + raise HTTPException(status_code=404, detail="Album not found") + + return album + + +@router.put("/{album_id}", response_model=AlbumOut) +async def update_album_endpoint( + album_id: int, + album_data: AlbumUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Update album information. + + - **album_id**: ID of the album to update + - **current_user**: Authenticated user (admin or album owner) + """ + album = get_album_by_id(db, album_id) + if not album: + raise HTTPException(status_code=404, detail="Album not found") + + if current_user.role != "admin" and album.uploaded_by_user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not authorized to update this album") + + updated_album = update_album(db, album_id, album_data) + if not updated_album: + raise HTTPException(status_code=404, detail="Album not found") + + return updated_album + + +@router.get("/", response_model=AlbumListWithRelations) +async def get_albums_endpoint( + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db) +): + """ + Get paginated list of all albums with relationships. + + - **skip**: Number of records to skip + - **limit**: Maximum number of records to return + """ + albums = get_all_albums(db, skip, limit) + total = get_album_count(db) + + return AlbumListWithRelations( + albums=albums, + total=total, + page=skip // limit + 1, + per_page=limit, + total_pages=(total + limit - 1) // limit + ) + + +@router.get("/artist/{artist_id}", response_model=List[AlbumOut]) +async def get_albums_by_artist_endpoint( + artist_id: int, + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db) +): + """ + Get albums by artist ID. + + - **artist_id**: ID of the artist + - **skip**: Number of records to skip + - **limit**: Maximum number of records to return + """ + albums = get_albums_by_artist(db, artist_id, skip, limit) + return albums + + +@router.get("/band/{band_id}", response_model=List[AlbumOut]) +async def get_albums_by_band_endpoint( + band_id: int, + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db) +): + """ + Get albums by band ID. + + - **band_id**: ID of the band + - **skip**: Number of records to skip + - **limit**: Maximum number of records to return + """ + albums = get_albums_by_band(db, band_id, skip, limit) + return albums + + +@router.get("/search/title", response_model=List[AlbumOut]) +async def search_albums_by_title_endpoint( + title: str = Query(..., min_length=1), + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db) +): + """ + Search albums by title. + + - **title**: Title to search for + - **skip**: Number of records to skip + - **limit**: Maximum number of records to return + """ + albums = get_albums_by_title(db, title, skip, limit) + return albums + + +@router.get("/search/artist", response_model=List[AlbumOut]) +async def search_albums_by_artist_name_endpoint( + artist_name: str = Query(..., min_length=1), + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db) +): + """ + Search albums by artist name. + + - **artist_name**: Artist name to search for + - **skip**: Number of records to skip + - **limit**: Maximum number of records to return + """ + albums = get_albums_by_artist_name(db, artist_name, skip, limit) + return albums + + +@router.get("/search/band", response_model=List[AlbumOut]) +async def search_albums_by_band_name_endpoint( + band_name: str = Query(..., min_length=1), + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db) +): + """ + Search albums by band name. + + - **band_name**: Band name to search for + - **skip**: Number of records to skip + - **limit**: Maximum number of records to return + """ + albums = get_albums_by_band_name(db, band_name, skip, limit) + return albums + + +@router.get("/user/{user_id}", response_model=List[AlbumOut]) +async def get_albums_by_user_endpoint( + user_id: int, + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Get albums uploaded by a specific user. + + - **user_id**: ID of the user + - **skip**: Number of records to skip + - **limit**: Maximum number of records to return + - **current_user**: Authenticated user (admin or same user) + """ + if current_user.role != "admin" and current_user.id != user_id: + raise HTTPException(status_code=403, detail="Not authorized to view this user's albums") + + albums = get_albums_by_user(db, user_id, skip, limit) + return albums + + +@router.get("/admin/stats", response_model=AlbumStats) +async def get_album_statistics_endpoint( + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """ + Get album statistics for admin dashboard. + + - **current_admin**: Authenticated admin user + """ + # This would need to be implemented in the CRUD + total_albums = get_album_count(db) + + return AlbumStats( + total_albums=total_albums, + albums_by_artist=0, # TODO: Implement + albums_by_band=0, + most_uploaded_artist=None, + most_uploaded_band=None + ) diff --git a/backend/app/api/v1/album_song.py b/backend/app/api/v1/album_song.py new file mode 100644 index 0000000..0906d20 --- /dev/null +++ b/backend/app/api/v1/album_song.py @@ -0,0 +1,256 @@ +""" +AlbumSong API endpoints. +""" + +from typing import List +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.core.deps import get_current_user, get_db +from app.crud.album_song import ( + create_album_song, get_album_song_by_id, get_album_songs_by_album, + update_album_song, delete_album_song, delete_album_song_by_album_and_song, + get_album_song_statistics, reorder_album_tracks +) +from app.crud.album import get_album_by_id +from app.schemas.album_song import ( + AlbumSongCreate, AlbumSongUpdate, AlbumSongOut, AlbumSongWithRelations, + AlbumSongList, AlbumSongListWithRelations, AlbumSongAdd, + AlbumSongBulkAdd, AlbumSongBulkReorder, AlbumSongStats +) +from app.db.models.user import User + +router = APIRouter() + + +@router.post("/{album_id}/songs", response_model=AlbumSongOut) +async def add_song_to_album_endpoint( + album_id: int, + song_data: AlbumSongAdd, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Add a song to an album. + + - **album_id**: ID of the album + - **song_data**: Song data with track number + - **current_user**: Authenticated user (admin or album owner) + """ + # Check if album exists + album = get_album_by_id(db, album_id) + if not album: + raise HTTPException(status_code=404, detail="Album not found") + + if current_user.role != "admin" and album.uploaded_by_user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not authorized to add songs to this album") + + try: + album_song_data = AlbumSongCreate( + album_id=album_id, + song_id=song_data.song_id, + track_number=song_data.track_number + ) + album_song = create_album_song(db, album_song_data) + return album_song + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/{album_id}/songs/bulk", response_model=List[AlbumSongOut]) +async def add_songs_to_album_bulk_endpoint( + album_id: int, + songs_data: AlbumSongBulkAdd, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Add multiple songs to an album. + + - **album_id**: ID of the album + - **songs_data**: List of songs with track numbers + - **current_user**: Authenticated user (admin or album owner) + """ + album = get_album_by_id(db, album_id) + if not album: + raise HTTPException(status_code=404, detail="Album not found") + + if current_user.role != "admin" and album.uploaded_by_user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not authorized to add songs to this album") + + try: + album_songs = [] + for song_data in songs_data.songs: + album_song_data = AlbumSongCreate( + album_id=album_id, + song_id=song_data.song_id, + track_number=song_data.track_number + ) + album_song = create_album_song(db, album_song_data) + album_songs.append(album_song) + return album_songs + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/{album_id}/songs", response_model=List[AlbumSongWithRelations]) +async def get_album_songs_endpoint( + album_id: int, + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + db: Session = Depends(get_db) +): + """ + Get all songs in an album. + + - **album_id**: ID of the album + - **skip**: Number of records to skip + - **limit**: Maximum number of records to return + """ + album = get_album_by_id(db, album_id) + if not album: + raise HTTPException(status_code=404, detail="Album not found") + + album_songs = get_album_songs_by_album(db, album_id, skip, limit) + return album_songs + + +@router.put("/{album_id}/songs/reorder", response_model=List[AlbumSongOut]) +async def reorder_album_songs_endpoint( + album_id: int, + reorder_data: AlbumSongBulkReorder, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Bulk reorder songs in an album. + + - **album_id**: ID of the album + - **reorder_data**: List of songs with new track numbers + - **current_user**: Authenticated user (admin or album owner) + """ + album = get_album_by_id(db, album_id) + if not album: + raise HTTPException(status_code=404, detail="Album not found") + + if current_user.role != "admin" and album.uploaded_by_user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not authorized to reorder this album") + + try: + track_orders = [ + {"song_id": track.song_id, "track_number": track.track_number} + for track in reorder_data.tracks + ] + + success = reorder_album_tracks(db, album_id, track_orders) + if not success: + raise HTTPException(status_code=400, detail="Failed to reorder tracks") + + album_songs = get_album_songs_by_album(db, album_id) + return album_songs + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.put("/{album_id}/songs/{album_song_id}", response_model=AlbumSongOut) +async def update_album_song_endpoint( + album_id: int, + album_song_id: int, + song_data: AlbumSongUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Update track number for a song in an album. + + - **album_id**: ID of the album + - **album_song_id**: ID of the album-song relationship + - **song_data**: New track number + - **current_user**: Authenticated user (admin or album owner) + """ + album = get_album_by_id(db, album_id) + if not album: + raise HTTPException(status_code=404, detail="Album not found") + + if current_user.role != "admin" and album.uploaded_by_user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not authorized to update this album") + + try: + updated_album_song = update_album_song(db, album_song_id, song_data) + if not updated_album_song: + raise HTTPException(status_code=404, detail="Album-song relationship not found") + return updated_album_song + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.delete("/{album_id}/songs/{song_id}") +async def remove_song_from_album_endpoint( + album_id: int, + song_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Remove a song from an album. + + - **album_id**: ID of the album + - **song_id**: ID of the song to remove + - **current_user**: Authenticated user (admin or album owner) + """ + album = get_album_by_id(db, album_id) + if not album: + raise HTTPException(status_code=404, detail="Album not found") + + if current_user.role != "admin" and album.uploaded_by_user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not authorized to remove songs from this album") + + success = delete_album_song_by_album_and_song(db, album_id, song_id) + if not success: + raise HTTPException(status_code=404, detail="Song not found in album") + + return {"message": "Song removed from album successfully"} + + +@router.delete("/{album_id}/songs") +async def clear_album_songs_endpoint( + album_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Remove all songs from an album. + + - **album_id**: ID of the album + - **current_user**: Authenticated user (admin or album owner) + """ + album = get_album_by_id(db, album_id) + if not album: + raise HTTPException(status_code=404, detail="Album not found") + + if current_user.role != "admin" and album.uploaded_by_user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not authorized to clear songs from this album") + + album_songs = get_album_songs_by_album(db, album_id) + for album_song in album_songs: + delete_album_song(db, album_song.id) + + return {"message": "All songs removed from album successfully"} + + +@router.get("/{album_id}/songs/stats", response_model=AlbumSongStats) +async def get_album_song_statistics_endpoint( + album_id: int, + db: Session = Depends(get_db) +): + """ + Get statistics for songs in an album. + + - **album_id**: ID of the album + """ + album = get_album_by_id(db, album_id) + if not album: + raise HTTPException(status_code=404, detail="Album not found") + + stats = get_album_song_statistics(db, album_id) + return AlbumSongStats(**stats) diff --git a/backend/app/api/v1/artist.py b/backend/app/api/v1/artist.py index e118b3b..b9f5c33 100644 --- a/backend/app/api/v1/artist.py +++ b/backend/app/api/v1/artist.py @@ -17,7 +17,7 @@ disable_artist, enable_artist, delete_artist, artist_exists, get_artist_with_related_entities, get_artists_followed_by_user ) -from app.api.v1.deps import ( +from app.core.deps import ( get_current_active_user, get_current_admin, get_current_musician ) diff --git a/backend/app/api/v1/artist_band_member.py b/backend/app/api/v1/artist_band_member.py index 65041c7..d15fb98 100644 --- a/backend/app/api/v1/artist_band_member.py +++ b/backend/app/api/v1/artist_band_member.py @@ -2,7 +2,7 @@ from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session -from app.api.v1.deps import get_db, get_current_musician, get_current_admin +from app.core.deps import get_db, get_current_musician, get_current_admin from app.crud.artist_band_member import ( create_artist_band_member, get_artist_band_member_by_id, diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 93f4173..81904b8 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -6,7 +6,7 @@ from app.schemas.user import UserLogin, UserOut from app.schemas.token import TokenResponse, TokenRefresh from app.services.auth import AuthService -from app.api.v1.deps import get_current_active_user, get_current_admin, get_auth_service +from app.core.deps import get_current_active_user, get_current_admin, get_auth_service router = APIRouter() @@ -119,6 +119,9 @@ async def get_current_user_info( """ return current_user +''' + +TODO: use cron job -- refer to issues for assistance @router.post("/cleanup-expired") async def cleanup_expired_tokens( @@ -136,3 +139,4 @@ async def cleanup_expired_tokens( "message": f"Cleaned up {cleaned_count} expired tokens", "tokens_removed": cleaned_count } +''' diff --git a/backend/app/api/v1/band.py b/backend/app/api/v1/band.py index fdb3f6f..0ae4e5d 100644 --- a/backend/app/api/v1/band.py +++ b/backend/app/api/v1/band.py @@ -9,15 +9,17 @@ BandCreate, BandOut, BandUpdate, BandStats, BandWithRelations ) from app.crud.band import ( - create_band, get_band_by_id, get_band_by_name, get_all_bands, + create_band, get_band_by_id, get_all_bands, get_active_bands, search_bands_by_name, update_band, disable_band, enable_band, delete_band_permanently, get_band_statistics ) -from app.api.v1.deps import ( +from app.core.deps import ( get_current_active_user, get_current_admin, get_current_musician ) router = APIRouter() +# TODO: add slug later on; +# resource: https://stackoverflow.com/questions/10018100/identify-item-by-either-an-id-or-a-slug-in-a-restful-api """ AUTHENTICATION LEVELS: - None: Public endpoint, no authentication required @@ -61,7 +63,7 @@ async def get_bands_public( db: Session = Depends(get_db), skip: int = Query(0, ge=0, description="Number of records to skip"), limit: int = Query(20, ge=1, le=100, description="Maximum number of records to return"), - search: Optional[str] = Query(None, min_length=1, description="Search bands by name"), + name: Optional[str] = Query(None, min_length=1, description="Filter bands by name (case-insensitive, partial)"), active_only: bool = Query(True, description="Return only active bands") ): """ @@ -70,13 +72,13 @@ async def get_bands_public( Query Parameters: - skip: Number of records to skip (pagination) - limit: Maximum number of records to return (pagination) - - search: Search bands by name + - name: Filter bands by name (case-insensitive, partial) - active_only: Return only active bands (default: True) Returns: 200 OK - List of bands """ - if search: - bands = search_bands_by_name(db, search, skip=skip, limit=limit) + if name: + bands = search_bands_by_name(db, name, skip=skip, limit=limit) elif active_only: bands = get_active_bands(db, skip=skip, limit=limit) else: @@ -105,28 +107,8 @@ async def get_band_public( ) return band - - -@router.get("/name/{name}", response_model=BandOut) -async def get_band_by_name_public( - name: str, - db: Session = Depends(get_db) -): - """ - Get public band profile by name. - Returns basic band information for public viewing. - Only active bands are returned. - Returns: 200 OK - Band profile found - Returns: 404 Not Found - Band not found or inactive - """ - band = get_band_by_name(db, name) - if not band or band.is_disabled: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Band not found" - ) - - return band +# removed {name} function as it can be fetched alrdy via query param in get_bands_public + @router.get("/me/bands", response_model=List[BandOut]) diff --git a/backend/app/api/v1/following.py b/backend/app/api/v1/following.py new file mode 100644 index 0000000..a56996b --- /dev/null +++ b/backend/app/api/v1/following.py @@ -0,0 +1,225 @@ +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.deps import get_current_user, get_current_admin +from app.db.models.user import User +from app.crud.following import ( + toggle_following, + is_user_following_artist, + is_user_following_band, + get_user_followings, + get_user_followings_with_targets, + count_artist_followers, + count_band_followers, + get_following_statistics, + get_user_following_summary +) +from app.schemas.following import ( + FollowingToggle, + FollowingOut, + FollowingList, + FollowingStats, + UserFollowingSummary, + FollowingWithTarget +) + +router = APIRouter() + + +# Public endpoints (no auth required) +@router.get("/artist/{artist_id}/count", response_model=dict) +def get_artist_follower_count( + artist_id: int, + db: Session = Depends(get_db) +): + """ + Get the total number of followers for an artist (public). + """ + count = count_artist_followers(db, artist_id) + return {"artist_id": artist_id, "follower_count": count} + + +@router.get("/band/{band_id}/count", response_model=dict) +def get_band_follower_count( + band_id: int, + db: Session = Depends(get_db) +): + """ + Get the total number of followers for a band (public). + """ + count = count_band_followers(db, band_id) + return {"band_id": band_id, "follower_count": count} + + +# Authenticated user endpoints +@router.post("/toggle", response_model=dict) +def toggle_following_endpoint( + following_data: FollowingToggle, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Toggle follow/unfollow status for an artist or band. + """ + following, was_created = toggle_following( + db, + current_user.id, + following_data.artist_id, + following_data.band_id + ) + + action = "followed" if was_created else "unfollowed" + target_type = "artist" if following_data.artist_id else "band" + target_id = following_data.artist_id or following_data.band_id + + return { + "message": f"Successfully {action} {target_type}", + "action": action, + "target_type": target_type, + "target_id": target_id, + "following_id": following.id if was_created else None + } + + +@router.get("/artist/{artist_id}/is-following", response_model=dict) +def check_artist_following_status( + artist_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Check if the current user is following a specific artist. + """ + is_following = is_user_following_artist(db, current_user.id, artist_id) + return { + "artist_id": artist_id, + "is_following": is_following, + "user_id": current_user.id + } + + +@router.get("/band/{band_id}/is-following", response_model=dict) +def check_band_following_status( + band_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Check if the current user is following a specific band. + """ + is_following = is_user_following_band(db, current_user.id, band_id) + return { + "band_id": band_id, + "is_following": is_following, + "user_id": current_user.id + } + + +@router.get("/user/me", response_model=FollowingList) +def get_current_user_followings( + page: int = Query(1, ge=1, description="Page number"), + per_page: int = Query(20, ge=1, le=100, description="Items per page"), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Get all followings by the current user with pagination. + """ + skip = (page - 1) * per_page + followings_with_targets = get_user_followings_with_targets(db, current_user.id, skip, per_page) + + # Convert to FollowingWithTarget objects + followings = [] + for following, artist, band in followings_with_targets: + following_data = { + "id": following.id, + "user_id": following.user_id, + "started_at": following.started_at, + "artist": artist, + "band": band + } + followings.append(FollowingWithTarget(**following_data)) + + total = len(followings_with_targets) # This is a simplified count + total_pages = (total + per_page - 1) // per_page + + return FollowingList( + followings=followings, + total=total, + page=page, + per_page=per_page, + total_pages=total_pages + ) + + +@router.get("/user/me/artists", response_model=List[dict]) +def get_current_user_followed_artists( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Get all artists that the current user is following. + """ + followings_with_targets = get_user_followings_with_targets(db, current_user.id, skip=0, limit=1000) + + followed_artists = [] + for following, artist, band in followings_with_targets: + if artist: + followed_artists.append({ + "id": artist.id, + "artist_stage_name": artist.artist_stage_name, + "artist_profile_image": artist.artist_profile_image, + "followed_since": following.started_at + }) + + return followed_artists + + +@router.get("/user/me/bands", response_model=List[dict]) +def get_current_user_followed_bands( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Get all bands that the current user is following. + """ + followings_with_targets = get_user_followings_with_targets(db, current_user.id, skip=0, limit=1000) + + followed_bands = [] + for following, artist, band in followings_with_targets: + if band: + followed_bands.append({ + "id": band.id, + "name": band.name, + "profile_picture": band.profile_picture, + "followed_since": following.started_at + }) + + return followed_bands + + +@router.get("/user/me/summary", response_model=UserFollowingSummary) +def get_current_user_following_summary( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Get a summary of the current user's followings. + """ + summary = get_user_following_summary(db, current_user.id) + return UserFollowingSummary(**summary) + + +# Admin-only endpoints +@router.get("/admin/statistics", response_model=FollowingStats) +def get_following_statistics_admin( + current_user: User = Depends(get_current_admin), + db: Session = Depends(get_db) +): + """ + Get overall following statistics (admin only). + """ + stats = get_following_statistics(db) + return FollowingStats(**stats) diff --git a/backend/app/api/v1/genre.py b/backend/app/api/v1/genre.py new file mode 100644 index 0000000..c3d09ec --- /dev/null +++ b/backend/app/api/v1/genre.py @@ -0,0 +1,158 @@ +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from app.db.session import get_db +from app.core.deps import get_current_admin, get_current_user_optional +from app.schemas.genre import GenreCreate, GenreUpdate, GenreOut, GenreStats +from app.crud.genre import ( + create_genre, genre_exists, get_genre_by_id, get_genre_by_name, get_all_genres, + get_all_active_genres, get_genres_by_fuzzy_name, get_genres_by_partial_name, update_genre, disable_genre, enable_genre, + genre_name_taken, get_genre_statistics, + get_genre_by_name_any, get_genres_by_partial_name_any, get_genres_by_fuzzy_name_any +) + +router = APIRouter() +# TODO: add slug later on; +# https://stackoverflow.com/questions/10018100/identify-item-by-either-an-id-or-a-slug-in-a-restful-api + +@router.get("/", response_model=List[GenreOut]) +async def list_genres( + name: Optional[str] = Query(None, description="Exact genre name to filter"), + q: Optional[str] = Query(None, description="Partial/fuzzy name search"), + db: Session = Depends(get_db), + current_user = Depends(get_current_user_optional) +): + """List genres with rbac: admins see all; others see active only.""" + is_admin = bool(current_user and getattr(current_user, "role", None) == "admin") + + if name: + genre = get_genre_by_name_any(db, name) if is_admin else get_genre_by_name(db, name) + if not genre: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found") + return [genre] + if q: + if is_admin: + partial = get_genres_by_partial_name_any(db, q) + if partial: + return partial + return get_genres_by_fuzzy_name_any(db, q) + else: + partial = get_genres_by_partial_name(db, q) + if partial: + return partial + return get_genres_by_fuzzy_name(db, q) + return get_all_genres(db) if is_admin else get_all_active_genres(db) + + +@router.get("/{genre_id}", response_model=GenreOut) +async def get_genre(genre_id: int, db: Session = Depends(get_db)): + """Get a specific genre by ID - public access""" + genre = get_genre_by_id(db, genre_id) + if not genre: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Genre not found" + ) + return genre + + +@router.post("/", response_model=GenreOut, status_code=status.HTTP_201_CREATED) +async def create_new_genre( + genre_data: GenreCreate, + db: Session = Depends(get_db), + current_admin: dict = Depends(get_current_admin) +): + """Create a new genre - admin only""" + created = create_genre(db, genre_data) + if created is None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Genre name already exists") + return created + + +@router.put("/{genre_id}", response_model=GenreOut) +async def update_genre_endpoint( + genre_id: int, + genre_data: GenreUpdate, + db: Session = Depends(get_db), + current_admin: dict = Depends(get_current_admin) +): + """Update a genre - admin only""" + if genre_data.name and genre_name_taken(db, genre_data.name, exclude_genre_id=genre_id): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Genre name already exists" + ) + + updated_genre = update_genre(db, genre_id, genre_data) + if not updated_genre: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Genre not found" + ) + + return updated_genre + + +@router.patch("/{genre_id}", response_model=GenreOut) +async def partial_update_genre( + genre_id: int, + genre_data: GenreUpdate, + db: Session = Depends(get_db), + current_admin: dict = Depends(get_current_admin) +): + """Partially update a genre - admin only""" + if genre_data.name and genre_name_taken(db, genre_data.name, exclude_genre_id=genre_id): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Genre name already exists" + ) + + updated_genre = update_genre(db, genre_id, genre_data) + if not updated_genre: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Genre not found" + ) + + return updated_genre + + +@router.post("/{genre_id}/disable") +async def disable_genre_endpoint( + genre_id: int, + db: Session = Depends(get_db), + current_admin: dict = Depends(get_current_admin) +): + """Disable a genre - admin only""" + success = disable_genre(db, genre_id) + if not success: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found") + return {"message": "Genre disabled successfully"} + + +@router.post("/{genre_id}/enable") +async def enable_genre_endpoint( + genre_id: int, + db: Session = Depends(get_db), + current_admin: dict = Depends(get_current_admin) +): + """Enable a genre - admin only""" + success = enable_genre(db, genre_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Genre not found" + ) + + return {"message": "Genre enabled successfully"} + + +@router.get("/statistics", response_model=GenreStats) +async def get_genre_statistics_endpoint( + db: Session = Depends(get_db), + current_admin: dict = Depends(get_current_admin) +): + """Get genre statistics""" + stats = get_genre_statistics(db) + return GenreStats(**stats) + diff --git a/backend/app/api/v1/history.py b/backend/app/api/v1/history.py new file mode 100644 index 0000000..30cac4a --- /dev/null +++ b/backend/app/api/v1/history.py @@ -0,0 +1,132 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from app.db.session import get_db +from app.core.deps import get_current_user, get_current_active_user, get_current_admin +from app.db.models.user import User +from app.schemas.history import ( + HistoryWithSong, HistoryList, HistoryToggle, HistoryStats, GlobalHistoryStats +) +from app.crud.history import ( + create_history_entry, get_user_history, clear_user_history, + get_user_history_stats, get_global_history_stats, count_song_plays +) +from app.crud.song import get_song_by_id + +router = APIRouter() + + +@router.get("/song/{song_id}/plays", response_model=int) +def get_song_play_count(song_id: int, db: Session = Depends(get_db)): + """ + Get total play count for a specific song (public endpoint) + """ + song = get_song_by_id(db, song_id) + if not song: + raise HTTPException(status_code=404, detail="Song not found") + + return count_song_plays(db, song_id) + + +@router.post("/add", response_model=HistoryWithSong) +def add_history_entry( + history_data: HistoryToggle, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Add a song to user's listening history (authenticated users only) + """ + song = get_song_by_id(db, history_data.song_id) + if not song: + raise HTTPException(status_code=404, detail="Song not found") + + history_entry = create_history_entry(db, current_user.id, history_data.song_id) + + if not history_entry: + raise HTTPException( + status_code=429, + detail="Too many requests. Please wait before playing this song again." + ) + + return HistoryWithSong( + id=history_entry.id, + user_id=history_entry.user_id, + song_id=history_entry.song_id, + played_at=history_entry.played_at, + is_cleared=history_entry.is_cleared, + song=song + ) + + +@router.get("/my", response_model=HistoryList) +def get_my_history( + page: int = Query(1, ge=1, description="Page number"), + per_page: int = Query(50, ge=1, le=100, description="Items per page"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Get current user's listening history (authenticated users only) + """ + skip = (page - 1) * per_page + history, total = get_user_history(db, current_user.id, skip, per_page) + + history_with_songs = [] + for entry in history: + history_with_songs.append(HistoryWithSong( + id=entry.id, + user_id=entry.user_id, + song_id=entry.song_id, + played_at=entry.played_at, + is_cleared=entry.is_cleared, + song=entry.song + )) + + total_pages = (total + per_page - 1) // per_page + + return HistoryList( + history=history_with_songs, + total=total, + page=page, + per_page=per_page, + total_pages=total_pages + ) + + +@router.get("/my/stats", response_model=HistoryStats) +def get_my_history_stats( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Get current user's listening statistics (authenticated users only) + """ + return get_user_history_stats(db, current_user.id) + + +@router.delete("/my/clear") +def clear_my_history( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Clear current user's listening history (authenticated users only) + """ + cleared_count = clear_user_history(db, current_user.id) + + return { + "message": f"Successfully cleared {cleared_count} history entries", + "cleared_count": cleared_count + } + + +@router.get("/admin/stats", response_model=GlobalHistoryStats) +def get_global_stats( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin) +): + """ + Get global listening statistics (admin only) + """ + return get_global_history_stats(db) diff --git a/backend/app/api/v1/like.py b/backend/app/api/v1/like.py new file mode 100644 index 0000000..e6d6be4 --- /dev/null +++ b/backend/app/api/v1/like.py @@ -0,0 +1,201 @@ +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.deps import get_current_active_user, get_current_admin +from app.db.models.user import User +from app.schemas.like import ( + LikeOut, LikeList, LikeToggle, LikeStats, UserLikesSummary, + LikeWithSong, LikeListWithSongs, SongMinimal +) +from app.crud.like import ( + get_like_by_id, get_user_likes, get_user_likes_with_songs, is_song_liked_by_user, toggle_like, + count_song_likes, count_user_likes, get_top_liked_songs, + get_like_statistics, get_user_likes_summary +) +from app.crud.song import get_song_by_id + + +router = APIRouter() + + + +@router.get("/song/{song_id}/count", tags=["likes"]) +async def get_song_like_count( + song_id: int, + db: Session = Depends(get_db) +): + """ + Get the total number of likes for a song (Public). + Returns only the count, not individual user data. + """ + song = get_song_by_id(db, song_id) + if not song: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + + count = count_song_likes(db, song_id) + return {"song_id": song_id, "like_count": count} + + +@router.get("/top-songs", tags=["likes"]) +async def get_top_liked_songs_endpoint( + limit: int = Query(default=10, ge=1, le=50), + db: Session = Depends(get_db) +): + """ + Get top liked songs (Public). + Returns the most liked songs in the system (aggregated data only). + """ + top_songs = get_top_liked_songs(db, limit=limit) + return [ + { + "song": { + "id": song.id, + "title": song.title, + "artist_name": song.artist_name, + "band_name": song.band_name, + "cover_image": song.cover_image + }, + "like_count": count + } + for song, count in top_songs + ] + + + +@router.post("/toggle", response_model=dict, tags=["likes"]) +async def toggle_like_endpoint( + like_data: LikeToggle, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + """ + Toggle like status for a song (Authenticated users). + Likes the song if not liked, unlikes if already liked. + """ + song = get_song_by_id(db, like_data.song_id) + if not song: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + + like, was_created = toggle_like(db, current_user.id, like_data.song_id) + + return { + "message": "Song liked" if was_created else "Song unliked", + "song_id": like_data.song_id, + "user_id": current_user.id, + "was_created": was_created, + "like_count": count_song_likes(db, like_data.song_id) + } + + +@router.get("/song/{song_id}/is-liked", tags=["likes"]) +async def check_song_liked_status( + song_id: int, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + """ + Check if current user has liked a specific song (Authenticated users). + Returns whether the current user has liked the specified song. + """ + song = get_song_by_id(db, song_id) + if not song: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + + is_liked = is_song_liked_by_user(db, current_user.id, song_id) + return { + "song_id": song_id, + "user_id": current_user.id, + "is_liked": is_liked + } + + +@router.get("/user/me", response_model=LikeListWithSongs, tags=["likes"]) +async def get_my_likes( + skip: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=100), + search: Optional[str] = Query(default=None, description="Search songs by title"), + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + """ + Get current user's likes with full song details (Authenticated users). + Returns a paginated list of songs liked by the current user with song title, image, etc. + Perfect for Flutter widgets displaying liked songs. + """ + likes_with_songs = get_user_likes_with_songs( + db, current_user.id, skip=skip, limit=limit, search=search + ) + + likes = [] + for like, song in likes_with_songs: + like_with_song = LikeWithSong( + id=like.id, + user_id=like.user_id, + song_id=like.song_id, + liked_at=like.liked_at, + song=SongMinimal( + id=song.id, + title=song.title, + song_duration=song.song_duration, + cover_image=song.cover_image, + artist_name=song.artist_name, + band_name=song.band_name + ) + ) + likes.append(like_with_song) + + if search: + total = len(likes_with_songs) + else: + total = count_user_likes(db, current_user.id) + + return LikeListWithSongs( + likes=likes, + total=total, + page=skip // limit + 1, + per_page=limit, + total_pages=(total + limit - 1) // limit + ) + + +@router.get("/user/me/summary", response_model=UserLikesSummary, tags=["likes"]) +async def get_my_likes_summary( + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + """ + Get current user's likes summary (Authenticated users). + Returns a summary of the current user's likes including favorite artists and genres. + """ + summary = get_user_likes_summary(db, current_user.id) + return UserLikesSummary(**summary) + + + + +@router.get("/admin/statistics", response_model=LikeStats, tags=["likes"]) +async def get_like_statistics_admin( + current_admin: User = Depends(get_current_admin), + db: Session = Depends(get_db) +): + """ + Get overall like statistics (Admin only). + Returns comprehensive statistics about likes in the system. + """ + stats = get_like_statistics(db) + return LikeStats(**stats) + + + + diff --git a/backend/app/api/v1/playlist.py b/backend/app/api/v1/playlist.py new file mode 100644 index 0000000..3c0b918 --- /dev/null +++ b/backend/app/api/v1/playlist.py @@ -0,0 +1,199 @@ +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from app.db.session import get_db +from app.core.deps import get_current_user +from app.db.models.user import User +from app.schemas.playlist import ( + PlaylistCreate, PlaylistUpdate, PlaylistOut, PlaylistWithOwner, + PlaylistList, PlaylistListWithOwner, PlaylistStats +) +from app.crud.playlist import ( + create_playlist, get_playlist_by_id, get_playlist_with_owner, + get_user_playlists, get_user_playlists_with_owner, search_playlists, + update_playlist, delete_playlist, user_can_edit_playlist, + user_can_view_playlist, get_playlist_stats, get_user_playlist_stats +) + +router = APIRouter() + + +@router.post("/", response_model=PlaylistOut) +def create_new_playlist( + playlist_data: PlaylistCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Create a new playlist + """ + try: + playlist = create_playlist(db, playlist_data, current_user.id) + return playlist + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/my", response_model=PlaylistList) +def get_my_playlists( + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Get current user's playlists + """ + playlists, total = get_user_playlists(db, current_user.id, skip, limit) + + total_pages = (total + limit - 1) // limit + page = (skip // limit) + 1 + + return PlaylistList( + playlists=playlists, + total=total, + page=page, + per_page=limit, + total_pages=total_pages + ) + + +@router.get("/my/with-owner", response_model=PlaylistListWithOwner) +def get_my_playlists_with_owner( + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Get current user's playlists with owner details + """ + playlists, total = get_user_playlists_with_owner(db, current_user.id, skip, limit) + + total_pages = (total + limit - 1) // limit + page = (skip // limit) + 1 + + return PlaylistListWithOwner( + playlists=playlists, + total=total, + page=page, + per_page=limit, + total_pages=total_pages + ) + + +@router.get("/search", response_model=PlaylistList) +def search_my_playlists( + q: str = Query(..., min_length=1, description="Search query"), + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Search current user's playlists + """ + playlists, total = search_playlists(db, q, current_user.id, skip, limit) + + total_pages = (total + limit - 1) // limit + page = (skip // limit) + 1 + + return PlaylistList( + playlists=playlists, + total=total, + page=page, + per_page=limit, + total_pages=total_pages + ) + + +@router.get("/{playlist_id}", response_model=PlaylistWithOwner) +def get_playlist( + playlist_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Get a specific playlist by ID + """ + playlist = get_playlist_with_owner(db, playlist_id) + if not playlist: + raise HTTPException(status_code=404, detail="Playlist not found") + + if not user_can_view_playlist(db, current_user.id, playlist_id): + raise HTTPException(status_code=403, detail="Access denied") + + return playlist + + +@router.put("/{playlist_id}", response_model=PlaylistOut) +def update_playlist_info( + playlist_id: int, + playlist_data: PlaylistUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Update playlist information + """ + if not user_can_edit_playlist(db, current_user.id, playlist_id): + raise HTTPException(status_code=403, detail="Access denied") + + try: + playlist = update_playlist(db, playlist_id, playlist_data) + if not playlist: + raise HTTPException(status_code=404, detail="Playlist not found") + return playlist + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.delete("/{playlist_id}") +def delete_playlist_by_id( + playlist_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Delete a playlist + """ + if not user_can_edit_playlist(db, current_user.id, playlist_id): + raise HTTPException(status_code=403, detail="Access denied") + + success = delete_playlist(db, playlist_id) + if not success: + raise HTTPException(status_code=404, detail="Playlist not found") + + return {"message": "Playlist deleted successfully"} + + +@router.get("/my/stats", response_model=PlaylistStats) +def get_my_playlist_statistics( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Get current user's playlist statistics + """ + stats = get_user_playlist_stats(db, current_user.id) + return stats + + +@router.get("/{playlist_id}/stats", response_model=PlaylistStats) +def get_playlist_statistics( + playlist_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Get playlist statistics + """ + if not user_can_view_playlist(db, current_user.id, playlist_id): + raise HTTPException(status_code=403, detail="Access denied") + + try: + stats = get_playlist_stats(db, playlist_id) + return stats + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + diff --git a/backend/app/api/v1/playlist_collaborator.py b/backend/app/api/v1/playlist_collaborator.py new file mode 100644 index 0000000..9143dea --- /dev/null +++ b/backend/app/api/v1/playlist_collaborator.py @@ -0,0 +1,150 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.core.deps import get_db, get_current_user +from app.db.models.user import User +from app.schemas.playlist_collaborator import ( + PlaylistCollaboratorCreate, PlaylistCollaboratorList, PlaylistCollaboratorStats +) +from app.crud.playlist import user_can_edit_playlist, access_playlist_by_token, generate_collaboration_link +from app.crud.playlist_collaborator import ( + add_collaborator_to_playlist, get_playlist_collaborators, remove_collaborator_from_playlist, + get_playlist_collaborator_stats +) +from app.crud.user import get_user_by_username + +router = APIRouter() + + +@router.post("/{playlist_id}/collaborate") +def generate_collaboration_link_endpoint( + playlist_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Generate a collaboration link for the playlist + """ + if not user_can_edit_playlist(db, current_user.id, playlist_id): + raise HTTPException(status_code=403, detail="Access denied") + + try: + collaboration_link = generate_collaboration_link(db, playlist_id) + return {"collaboration_link": collaboration_link} + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.post("/{playlist_id}/collaborators/{username}") +def add_collaborator_endpoint( + playlist_id: int, + username: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Add a collaborator to a playlist by username + """ + if not user_can_edit_playlist(db, current_user.id, playlist_id): + raise HTTPException(status_code=403, detail="Access denied") + + user = get_user_by_username(db, username) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + try: + collaborator = add_collaborator_to_playlist( + db, playlist_id, user.id, current_user.id, can_edit=True + ) + return {"message": f"Added {username} as collaborator"} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.delete("/{playlist_id}/collaborators/{username}") +def remove_collaborator_endpoint( + playlist_id: int, + username: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Remove a collaborator from a playlist by username + """ + if not user_can_edit_playlist(db, current_user.id, playlist_id): + raise HTTPException(status_code=403, detail="Access denied") + + user = get_user_by_username(db, username) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + success = remove_collaborator_from_playlist(db, playlist_id, user.id) + if not success: + raise HTTPException(status_code=404, detail="Collaborator not found") + + return {"message": f"Removed {username} as collaborator"} + + +@router.get("/{playlist_id}/collaborators", response_model=PlaylistCollaboratorList) +def get_playlist_collaborators_endpoint( + playlist_id: int, + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Get all collaborators for a playlist + """ + if not user_can_edit_playlist(db, current_user.id, playlist_id): + raise HTTPException(status_code=403, detail="Access denied") + + collaborators, total = get_playlist_collaborators(db, playlist_id, skip, limit) + + total_pages = (total + limit - 1) // limit + page = (skip // limit) + 1 + + return PlaylistCollaboratorList( + collaborators=collaborators, + total=total, + page=page, + per_page=limit, + total_pages=total_pages + ) + + +@router.get("/{playlist_id}/collaborators/stats", response_model=PlaylistCollaboratorStats) +def get_playlist_collaborator_statistics( + playlist_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Get playlist collaborator statistics + """ + if not user_can_edit_playlist(db, current_user.id, playlist_id): + raise HTTPException(status_code=403, detail="Access denied") + + return get_playlist_collaborator_stats(db, playlist_id) + + +@router.get("/collaborate/{token}") +def access_playlist_via_token( + token: str, + db: Session = Depends(get_db) +): + """ + Access a playlist via collaboration token + """ + playlist = access_playlist_by_token(db, token) + if not playlist: + raise HTTPException(status_code=404, detail="Invalid or expired collaboration link") + + return { + "playlist_id": playlist.id, + "name": playlist.name, + "description": playlist.description, + "owner_id": playlist.owner_id, + "message": "Use this token to join the playlist as a collaborator" + } diff --git a/backend/app/api/v1/playlist_song.py b/backend/app/api/v1/playlist_song.py new file mode 100644 index 0000000..d3c9640 --- /dev/null +++ b/backend/app/api/v1/playlist_song.py @@ -0,0 +1,175 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.core.deps import get_db, get_current_user +from app.db.models.user import User +from app.schemas.playlist_song import ( + PlaylistSongAdd, PlaylistSongReorder, PlaylistSongBulkReorder, + PlaylistSongList, PlaylistSongStats +) +from app.crud.playlist import user_can_edit_playlist, user_can_view_playlist +from app.crud.playlist_song import ( + add_song_to_playlist, get_songs_in_playlist, remove_song_from_playlist, + reorder_playlist_song, reorder_playlist_bulk, clear_playlist, + get_playlist_song_stats +) +from app.crud.song import get_song_by_id + +router = APIRouter() + + +@router.post("/{playlist_id}/songs", response_model=PlaylistSongList) +def add_song_to_playlist_endpoint( + playlist_id: int, + song_data: PlaylistSongAdd, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Add a song to a playlist + """ + if not user_can_edit_playlist(db, current_user.id, playlist_id): + raise HTTPException(status_code=403, detail="Access denied") + + + song = get_song_by_id(db, song_data.song_id) + if not song: + raise HTTPException(status_code=404, detail="Song not found") + + try: + add_song_to_playlist(db, playlist_id, song_data.song_id, song_data.song_order) + + + songs, total = get_songs_in_playlist(db, playlist_id) + return PlaylistSongList( + songs=songs, + total=total, + page=1, + per_page=total, + total_pages=1 + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/{playlist_id}/songs", response_model=PlaylistSongList) +def get_playlist_songs( + playlist_id: int, + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Get songs in a playlist + """ + if not user_can_view_playlist(db, current_user.id, playlist_id): + raise HTTPException(status_code=403, detail="Access denied") + + songs, total = get_songs_in_playlist(db, playlist_id, skip, limit) + + total_pages = (total + limit - 1) // limit + page = (skip // limit) + 1 + + return PlaylistSongList( + songs=songs, + total=total, + page=page, + per_page=limit, + total_pages=total_pages + ) + + +@router.put("/{playlist_id}/songs/reorder") +def reorder_playlist_song_endpoint( + playlist_id: int, + reorder_data: PlaylistSongReorder, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Reorder a song in a playlist + """ + if not user_can_edit_playlist(db, current_user.id, playlist_id): + raise HTTPException(status_code=403, detail="Access denied") + + playlist_song = reorder_playlist_song(db, playlist_id, reorder_data.song_id, reorder_data.new_order) + if not playlist_song: + raise HTTPException(status_code=404, detail="Song not found in playlist") + + return {"message": "Song reordered successfully"} + + +@router.put("/{playlist_id}/songs/reorder-bulk") +def reorder_playlist_songs_bulk( + playlist_id: int, + reorder_data: PlaylistSongBulkReorder, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Bulk reorder songs in a playlist + """ + if not user_can_edit_playlist(db, current_user.id, playlist_id): + raise HTTPException(status_code=403, detail="Access denied") + + # Convert to list of dicts for the CRUD function + song_orders = [{"song_id": item.song_id, "new_order": item.new_order} for item in reorder_data.song_orders] + + success = reorder_playlist_bulk(db, playlist_id, song_orders) + if not success: + raise HTTPException(status_code=400, detail="Failed to reorder songs") + + return {"message": "Songs reordered successfully"} + + +@router.delete("/{playlist_id}/songs/{song_id}") +def remove_song_from_playlist_endpoint( + playlist_id: int, + song_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Remove a song from a playlist + """ + if not user_can_edit_playlist(db, current_user.id, playlist_id): + raise HTTPException(status_code=403, detail="Access denied") + + success = remove_song_from_playlist(db, playlist_id, song_id) + if not success: + raise HTTPException(status_code=404, detail="Song not found in playlist") + + return {"message": "Song removed from playlist"} + + +@router.delete("/{playlist_id}/songs") +def clear_playlist_songs( + playlist_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Remove all songs from a playlist + """ + if not user_can_edit_playlist(db, current_user.id, playlist_id): + raise HTTPException(status_code=403, detail="Access denied") + + removed_count = clear_playlist(db, playlist_id) + return {"message": f"Removed {removed_count} songs from playlist"} + + +@router.get("/{playlist_id}/songs/stats", response_model=PlaylistSongStats) +def get_playlist_song_statistics( + playlist_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Get playlist song statistics + """ + if not user_can_view_playlist(db, current_user.id, playlist_id): + raise HTTPException(status_code=403, detail="Access denied") + + return get_playlist_song_stats(db, playlist_id) diff --git a/backend/app/api/v1/song.py b/backend/app/api/v1/song.py new file mode 100644 index 0000000..4079dc3 --- /dev/null +++ b/backend/app/api/v1/song.py @@ -0,0 +1,231 @@ +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from app.db.session import get_db +from app.core.deps import get_current_active_user, get_current_admin, get_current_musician +from app.schemas.song import ( + SongUploadByArtist, SongUploadByBand, SongUploadByAdmin, SongUpdate, + SongOut, SongWithRelations, SongStats +) +from app.crud.song import ( + create_song_by_artist, create_song_by_band, create_song_by_admin, + get_song_by_id, get_all_songs_paginated, search_songs, search_songs_fuzzy, + get_songs_by_artist, get_songs_by_band, get_songs_by_genre, song_exists, + update_song_file_path, update_song_metadata, disable_song, enable_song, + can_user_upload_for_band, get_song_statistics +) +from app.crud.user import get_user_by_id +from app.db.models.user import User + +router = APIRouter() + + +@router.get("/", response_model=List[SongOut]) +async def get_all_songs( + skip: int = Query(0, ge=0, description="Number of records to skip"), + limit: int = Query(20, ge=1, le=100, description="Maximum number of records to return"), + q: Optional[str] = Query(None, min_length=1, description="Search by title/artist/band"), + db: Session = Depends(get_db) +): + """List songs: when query param is provided, performs search; otherwise paginated list.""" + if q: + results = search_songs(db, q, skip=skip, limit=limit) + if results: + return results + return search_songs_fuzzy(db, q, skip=skip, limit=limit) + return get_all_songs_paginated(db, skip=skip, limit=limit) + + +@router.get("/{song_id}", response_model=SongOut) +async def get_song(song_id: int, db: Session = Depends(get_db)): + """Get a specific song by ID - public access""" + song = get_song_by_id(db, song_id) + if not song or song.is_disabled: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + return song + + +@router.get("/artist/{artist_id}", response_model=List[SongOut]) +async def get_songs_by_artist_endpoint( + artist_id: int, + skip: int = Query(0, ge=0, description="Number of records to skip"), + limit: int = Query(20, ge=1, le=100, description="Maximum number of records to return"), + db: Session = Depends(get_db) +): + """Get songs by artist ID - public access""" + return get_songs_by_artist(db, artist_id, skip=skip, limit=limit) + + +@router.get("/band/{band_id}", response_model=List[SongOut]) +async def get_songs_by_band_endpoint( + band_id: int, + skip: int = Query(0, ge=0, description="Number of records to skip"), + limit: int = Query(20, ge=1, le=100, description="Maximum number of records to return"), + db: Session = Depends(get_db) +): + """Get songs by band ID - public access""" + return get_songs_by_band(db, band_id, skip=skip, limit=limit) + + +@router.get("/genre/{genre_id}", response_model=List[SongOut]) +async def get_songs_by_genre_endpoint( + genre_id: int, + skip: int = Query(0, ge=0, description="Number of records to skip"), + limit: int = Query(20, ge=1, le=100, description="Maximum number of records to return"), + db: Session = Depends(get_db) +): + """Get songs by genre ID - public access""" + return get_songs_by_genre(db, genre_id, skip=skip, limit=limit) + + +@router.post("/artist/upload", response_model=SongOut, status_code=status.HTTP_201_CREATED) +async def upload_song_by_artist( + song_data: SongUploadByArtist, + current_user: User = Depends(get_current_musician), + db: Session = Depends(get_db) +): + """Upload a song as an artist - artist only""" + # Verify the artist_id belongs to the current user + from app.crud.artist import get_artist_by_user_id + artist = get_artist_by_user_id(db, current_user.id) + if not artist or artist.id != song_data.artist_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You can only upload songs for your own artist profile" + ) + + return create_song_by_artist(db, song_data, current_user.id) + + +@router.post("/band/upload", response_model=SongOut, status_code=status.HTTP_201_CREATED) +async def upload_song_by_band( + song_data: SongUploadByBand, + current_user: User = Depends(get_current_musician), + db: Session = Depends(get_db) +): + """Upload a song as a band member - band member only""" + # Check if user can upload for this band + if not can_user_upload_for_band(db, current_user.id, song_data.band_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You can only upload songs for bands you are a member of" + ) + + return create_song_by_band(db, song_data, current_user.id) + + +@router.post("/admin/upload", response_model=SongOut, status_code=status.HTTP_201_CREATED) +async def upload_song_by_admin( + song_data: SongUploadByAdmin, + current_admin: User = Depends(get_current_admin), + db: Session = Depends(get_db) +): + """Upload a song as admin (for any artist/band including dead artists) - admin only""" + return create_song_by_admin(db, song_data, current_admin.id) + + +@router.put("/{song_id}/file-path", response_model=SongOut) +async def update_song_file_path_endpoint( + song_id: int, + file_path: str, + current_admin: User = Depends(get_current_admin), + db: Session = Depends(get_db) +): + """Update song file path - admin only""" + if not song_exists(db, song_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + + updated_song = update_song_file_path(db, song_id, file_path) + if not updated_song: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + + return updated_song + + +@router.patch("/{song_id}/metadata", response_model=SongOut) +async def update_song_metadata_endpoint( + song_id: int, + song_data: SongUpdate, + current_admin: User = Depends(get_current_admin), + db: Session = Depends(get_db) +): + """Update song metadata - admin only""" + if not song_exists(db, song_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + + updated_song = update_song_metadata(db, song_id, song_data) + if not updated_song: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + + return updated_song + + +@router.post("/{song_id}/disable") +async def disable_song_endpoint( + song_id: int, + current_admin: User = Depends(get_current_admin), + db: Session = Depends(get_db) +): + """Disable a song - admin only""" + if not song_exists(db, song_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + + success = disable_song(db, song_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + + return {"message": "Song disabled successfully"} + + +@router.post("/{song_id}/enable") +async def enable_song_endpoint( + song_id: int, + current_admin: User = Depends(get_current_admin), + db: Session = Depends(get_db) +): + """Enable a song - admin only""" + if not song_exists(db, song_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + + success = enable_song(db, song_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + + return {"message": "Song enabled successfully"} + + +@router.get("/admin/statistics", response_model=SongStats) +async def get_song_statistics_endpoint( + current_admin: User = Depends(get_current_admin), + db: Session = Depends(get_db) +): + """Get song statistics - admin only""" + stats = get_song_statistics(db) + return SongStats(**stats) diff --git a/backend/app/api/v1/stream.py b/backend/app/api/v1/stream.py new file mode 100644 index 0000000..d516e6a --- /dev/null +++ b/backend/app/api/v1/stream.py @@ -0,0 +1,176 @@ +""" +Streaming API endpoints for serving audio files and images. +""" + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi.responses import FileResponse, StreamingResponse +from sqlalchemy.orm import Session +from pathlib import Path +import os +import mimetypes +from typing import Optional + +from app.core.deps import get_db +from app.db.models.song import Song +from app.services.file_service import file_service + +router = APIRouter() + +@router.get("/song/{song_id}") +async def stream_song( + song_id: int, + request: Request, + db: Session = Depends(get_db) +): + """ + Stream audio file with range request support for seeking. + """ + song = db.query(Song).filter(Song.id == song_id).first() + if not song: + raise HTTPException(status_code=404, detail="Song not found") + + if not song.file_path or not os.path.exists(song.file_path): + raise HTTPException(status_code=404, detail="Audio file not found") + + file_path = Path(song.file_path) + + file_size = os.path.getsize(file_path) + + range_header = request.headers.get("range") + if range_header: + try: + range_str = range_header.replace("bytes=", "") + start, end = range_str.split("-") + start = int(start) + end = int(end) if end else file_size - 1 + + if start >= file_size or end >= file_size or start > end: + raise HTTPException(status_code=416, detail="Range not satisfiable") + + content_length = end - start + 1 + + with open(file_path, "rb") as f: + f.seek(start) + chunk = f.read(content_length) + + headers = { + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Accept-Ranges": "bytes", + "Content-Length": str(content_length), + "Content-Type": "audio/mpeg", + "Cache-Control": "public, max-age=31536000" + } + + return Response(content=chunk, headers=headers, status_code=206) + + except (ValueError, IndexError): + raise HTTPException(status_code=400, detail="Invalid range header") + + return FileResponse( + path=file_path, + media_type="audio/mpeg", + headers={ + "Accept-Ranges": "bytes", + "Cache-Control": "public, max-age=31536000" + } + ) + +@router.get("/cover/{filename}") +async def stream_cover_image(filename: str): + """ + Serve song/album cover images. + """ + file_path = Path("uploads/covers") / filename + + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Cover image not found") + + content_type, _ = mimetypes.guess_type(str(file_path)) + if not content_type: + content_type = "image/jpeg" + + return FileResponse( + path=file_path, + media_type=content_type, + headers={ + "Cache-Control": "public, max-age=31536000" + } + ) + +@router.get("/album/{filename}") +async def stream_album_image(filename: str): + """ + Serve album cover images. + """ + file_path = Path("uploads/albums") / filename + + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Album image not found") + + content_type, _ = mimetypes.guess_type(str(file_path)) + if not content_type: + content_type = "image/jpeg" + + return FileResponse( + path=file_path, + media_type=content_type, + headers={ + "Cache-Control": "public, max-age=31536000" + } + ) + +@router.get("/profile/{filename}") +async def stream_profile_image(filename: str): + """ + Serve user/artist/band profile images. + """ + file_path = Path("uploads/profiles") / filename + + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Profile image not found") + + content_type, _ = mimetypes.guess_type(str(file_path)) + if not content_type: + content_type = "image/jpeg" + + return FileResponse( + path=file_path, + media_type=content_type, + headers={ + "Cache-Control": "public, max-age=31536000" + } + ) + +@router.get("/song/{song_id}/info") +async def get_song_file_info(song_id: int, db: Session = Depends(get_db)): + """ + Get information about a song's audio file. + """ + song = db.query(Song).filter(Song.id == song_id).first() + if not song: + raise HTTPException(status_code=404, detail="Song not found") + + if not song.file_path or not os.path.exists(song.file_path): + raise HTTPException(status_code=404, detail="Audio file not found") + + file_path = Path(song.file_path) + + file_size = os.path.getsize(file_path) + file_stat = os.stat(file_path) + + content_type, _ = mimetypes.guess_type(str(file_path)) + if not content_type: + content_type = "audio/mpeg" + + return { + "song_id": song_id, + "title": song.title, + "file_path": str(file_path), + "file_size": file_size, + "file_size_mb": round(file_size / (1024 * 1024), 2), + "content_type": content_type, + "created_at": file_stat.st_ctime, + "modified_at": file_stat.st_mtime, + "duration": song.song_duration, + "stream_url": f"/stream/song/{song_id}" + } diff --git a/backend/app/api/v1/upload.py b/backend/app/api/v1/upload.py new file mode 100644 index 0000000..64ca42a --- /dev/null +++ b/backend/app/api/v1/upload.py @@ -0,0 +1,517 @@ +""" +Upload API endpoints for file uploads and combined creation/upload operations. +""" + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session +from typing import Optional +import tempfile +import os +from pathlib import Path + +from app.core.deps import get_db, get_current_user, get_current_admin, get_current_musician +from app.db.models.user import User +from app.db.models.artist import Artist +from app.db.models.band import Band +from app.db.models.album import Album +from app.db.models.song import Song +from app.crud.song import create_song_by_artist, create_song_by_band, create_song_by_admin +from app.crud.album import create_album, update_album +from app.crud.band import create_band, update_band +from app.crud.artist import update_artist +from app.services.file_service import file_service +from app.schemas.song_upload import ( + SongCreateWithUpload, SongCreateWithUploadByArtist, + SongCreateWithUploadByBand, SongCreateWithUploadByAdmin, + SongUploadResponse +) +from app.schemas.album import AlbumCreate, AlbumUpdate +from app.schemas.band import BandCreate, BandUpdate +from app.schemas.artist import ArtistUpdate +from app.schemas.upload import FileUploadResponse, AudioUploadResponse, ImageUploadResponse + +router = APIRouter() + + +@router.post("/song/artist", response_model=SongUploadResponse) +async def create_song_with_upload_by_artist( + audio_file: UploadFile = File(...), + title: str = Form(...), + genre_id: int = Form(...), + artist_id: int = Form(...), + release_date: str = Form(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_musician) +): + """ + Create song and upload audio file as artist. + Combines song creation and file upload in one operation. + """ + try: + if not audio_file.filename: + raise HTTPException(status_code=400, detail="No file provided") + + with tempfile.NamedTemporaryFile(delete=False, suffix=Path(audio_file.filename).suffix) as temp_file: + content = await audio_file.read() + temp_file.write(content) + temp_path = Path(temp_file.name) + + is_valid, error_msg = file_service.validate_audio_file(temp_path, audio_file.content_type) + if not is_valid: + os.unlink(temp_path) + raise HTTPException(status_code=400, detail=error_msg) + + metadata = file_service.get_audio_metadata(temp_path) + + song_data = SongCreateWithUploadByArtist( + title=title, + genre_id=genre_id, + artist_id=artist_id, + release_date=release_date, + song_duration=metadata.get("duration", 0), + file_path="", # Will be set after file save + cover_image=None + ) + + song = create_song_by_artist(db, song_data, current_user.id) + + filename = file_service.generate_unique_filename(audio_file.filename, song.id, "song") + destination_path = Path("uploads/songs") / filename + + success = await file_service.save_uploaded_file(temp_path, destination_path) + if not success: + db.delete(song) + db.commit() + raise HTTPException(status_code=500, detail="Failed to save file") + + song.file_path = str(destination_path) + db.commit() + db.refresh(song) + + return SongUploadResponse( + song_id=song.id, + title=song.title, + filename=filename, + stream_url=f"/stream/song/{song.id}", + duration=metadata.get("duration", 0), + file_size=temp_path.stat().st_size, + message="Song created and uploaded successfully" + ) + + except Exception as e: + if 'temp_path' in locals() and os.path.exists(temp_path): + try: + os.unlink(temp_path) + except OSError: + pass # File might already be deleted + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/song/band", response_model=SongUploadResponse) +async def create_song_with_upload_by_band( + audio_file: UploadFile = File(...), + title: str = Form(...), + genre_id: int = Form(...), + band_id: int = Form(...), + release_date: str = Form(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_musician) +): + """ + Create song and upload audio file as band member. + Combines song creation and file upload in one operation. + """ + try: + if not audio_file.filename: + raise HTTPException(status_code=400, detail="No file provided") + + with tempfile.NamedTemporaryFile(delete=False, suffix=Path(audio_file.filename).suffix) as temp_file: + content = await audio_file.read() + temp_file.write(content) + temp_path = Path(temp_file.name) + + is_valid, error_msg = file_service.validate_audio_file(temp_path, audio_file.content_type) + if not is_valid: + os.unlink(temp_path) + raise HTTPException(status_code=400, detail=error_msg) + + metadata = file_service.get_audio_metadata(temp_path) + + song_data = SongCreateWithUploadByBand( + title=title, + genre_id=genre_id, + band_id=band_id, + release_date=release_date, + song_duration=metadata.get("duration", 0), + file_path="", # Will be set after file save + cover_image=None + ) + + song = create_song_by_band(db, song_data, current_user.id) + + filename = file_service.generate_unique_filename(audio_file.filename, song.id, "song") + destination_path = Path("uploads/songs") / filename + + success = await file_service.save_uploaded_file(temp_path, destination_path) + if not success: + db.delete(song) + db.commit() + raise HTTPException(status_code=500, detail="Failed to save file") + + song.file_path = str(destination_path) + db.commit() + db.refresh(song) + + return SongUploadResponse( + song_id=song.id, + title=song.title, + filename=filename, + stream_url=f"/stream/song/{song.id}", + duration=metadata.get("duration", 0), + file_size=temp_path.stat().st_size, + message="Song created and uploaded successfully" + ) + + except Exception as e: + if 'temp_path' in locals() and os.path.exists(temp_path): + try: + os.unlink(temp_path) + except OSError: + pass # File might already be deleted + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/song/admin", response_model=SongUploadResponse) +async def create_song_with_upload_by_admin( + audio_file: UploadFile = File(...), + title: str = Form(...), + genre_id: int = Form(...), + artist_id: Optional[int] = Form(None), + band_id: Optional[int] = Form(None), + artist_name: Optional[str] = Form(None), + band_name: Optional[str] = Form(None), + release_date: str = Form(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin) +): + """ + Create song and upload audio file as admin. + Combines song creation and file upload in one operation. + """ + try: + if not audio_file.filename: + raise HTTPException(status_code=400, detail="No file provided") + + with tempfile.NamedTemporaryFile(delete=False, suffix=Path(audio_file.filename).suffix) as temp_file: + content = await audio_file.read() + temp_file.write(content) + temp_path = Path(temp_file.name) + + is_valid, error_msg = file_service.validate_audio_file(temp_path, audio_file.content_type) + if not is_valid: + os.unlink(temp_path) + raise HTTPException(status_code=400, detail=error_msg) + + metadata = file_service.get_audio_metadata(temp_path) + + song_data = SongCreateWithUploadByAdmin( + title=title, + genre_id=genre_id, + artist_id=artist_id, + band_id=band_id, + artist_name=artist_name, + band_name=band_name, + release_date=release_date, + song_duration=metadata.get("duration", 0), + file_path="", # Will be set after file save + cover_image=None + ) + + song = create_song_by_admin(db, song_data, current_user.id) + + filename = file_service.generate_unique_filename(audio_file.filename, song.id, "song") + destination_path = Path("uploads/songs") / filename + + success = await file_service.save_uploaded_file(temp_path, destination_path) + if not success: + db.delete(song) + db.commit() + raise HTTPException(status_code=500, detail="Failed to save file") + + song.file_path = str(destination_path) + db.commit() + db.refresh(song) + + return SongUploadResponse( + song_id=song.id, + title=song.title, + filename=filename, + stream_url=f"/stream/song/{song.id}", + duration=metadata.get("duration", 0), + file_size=temp_path.stat().st_size, + message="Song created and uploaded successfully" + ) + + except Exception as e: + if 'temp_path' in locals() and os.path.exists(temp_path): + try: + os.unlink(temp_path) + except OSError: + pass # File might already be deleted + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/album", response_model=dict) +async def create_album_with_cover( + cover_file: UploadFile = File(...), + title: str = Form(...), + description: Optional[str] = Form(None), + album_artist_id: Optional[int] = Form(None), + album_band_id: Optional[int] = Form(None), + artist_name: Optional[str] = Form(None), + band_name: Optional[str] = Form(None), + release_date: str = Form(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_musician) +): + """ + Create album and upload cover image. + Combines album creation and cover upload in one operation. + """ + try: + if not cover_file.filename: + raise HTTPException(status_code=400, detail="No file provided") + + with tempfile.NamedTemporaryFile(delete=False, suffix=Path(cover_file.filename).suffix) as temp_file: + content = await cover_file.read() + temp_file.write(content) + temp_path = Path(temp_file.name) + + is_valid, error_msg = file_service.validate_image_file(temp_path, cover_file.content_type) + if not is_valid: + os.unlink(temp_path) + raise HTTPException(status_code=400, detail=error_msg) + + album_data = AlbumCreate( + title=title, + description=description, + cover_image="", # Will be set after file save + release_date=release_date, + album_artist_id=album_artist_id, + album_band_id=album_band_id, + artist_name=artist_name, + band_name=band_name + ) + + album = create_album(db, album_data, current_user.id) + + filename = file_service.generate_unique_filename(cover_file.filename, album.id, "album") + destination_path = Path("uploads/albums") / filename + + success = await file_service.save_uploaded_file(temp_path, destination_path) + if not success: + db.delete(album) + db.commit() + raise HTTPException(status_code=500, detail="Failed to save file") + + album.cover_image = str(destination_path) + db.commit() + db.refresh(album) + + return { + "album_id": album.id, + "title": album.title, + "cover_filename": filename, + "cover_url": f"/stream/album/{filename}", + "message": "Album created and cover uploaded successfully" + } + + except Exception as e: + if 'temp_path' in locals() and os.path.exists(temp_path): + try: + os.unlink(temp_path) + except OSError: + pass # File might already be deleted + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/band", response_model=dict) +async def create_band_with_profile( + profile_file: UploadFile = File(...), + name: str = Form(...), + description: Optional[str] = Form(None), + formed_date: Optional[str] = Form(None), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_musician) +): + """ + Create band and upload profile image. + Combines band creation and profile upload in one operation. + """ + try: + if not profile_file.filename: + raise HTTPException(status_code=400, detail="No file provided") + + with tempfile.NamedTemporaryFile(delete=False, suffix=Path(profile_file.filename).suffix) as temp_file: + content = await profile_file.read() + temp_file.write(content) + temp_path = Path(temp_file.name) + + is_valid, error_msg = file_service.validate_image_file(temp_path, profile_file.content_type) + if not is_valid: + os.unlink(temp_path) + raise HTTPException(status_code=400, detail=error_msg) + + band_data = BandCreate( + name=name, + description=description, + profile_picture="", # Will be set after file save + formed_date=formed_date + ) + + band = create_band(db, band_data, current_user.id) + + filename = file_service.generate_unique_filename(profile_file.filename, band.id, "band") + destination_path = Path("uploads/profiles") / filename + + success = await file_service.save_uploaded_file(temp_path, destination_path) + if not success: + db.delete(band) + db.commit() + raise HTTPException(status_code=500, detail="Failed to save file") + + band.profile_picture = str(destination_path) + db.commit() + db.refresh(band) + + return { + "band_id": band.id, + "name": band.name, + "profile_filename": filename, + "profile_url": f"/stream/profile/{filename}", + "message": "Band created and profile uploaded successfully" + } + + except Exception as e: + if 'temp_path' in locals() and os.path.exists(temp_path): + try: + os.unlink(temp_path) + except OSError: + pass # File might already be deleted + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/artist/profile", response_model=dict) +async def upload_artist_profile( + profile_file: UploadFile = File(...), + artist_id: int = Form(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Upload profile image for existing artist. + """ + try: + if not profile_file.filename: + raise HTTPException(status_code=400, detail="No file provided") + + with tempfile.NamedTemporaryFile(delete=False, suffix=Path(profile_file.filename).suffix) as temp_file: + content = await profile_file.read() + temp_file.write(content) + temp_path = Path(temp_file.name) + + is_valid, error_msg = file_service.validate_image_file(temp_path, profile_file.content_type) + if not is_valid: + os.unlink(temp_path) + raise HTTPException(status_code=400, detail=error_msg) + + artist = db.query(Artist).filter(Artist.id == artist_id).first() + if not artist: + os.unlink(temp_path) + raise HTTPException(status_code=404, detail="Artist not found") + + if current_user.role != "admin" and artist.linked_user_account != current_user.id: + os.unlink(temp_path) + raise HTTPException(status_code=403, detail="Not authorized to update this artist") + + filename = file_service.generate_unique_filename(profile_file.filename, artist.id, "artist") + destination_path = Path("uploads/profiles") / filename + + success = await file_service.save_uploaded_file(temp_path, destination_path) + if not success: + os.unlink(temp_path) + raise HTTPException(status_code=500, detail="Failed to save file") + + artist.artist_profile_image = str(destination_path) + db.commit() + db.refresh(artist) + + return { + "artist_id": artist.id, + "artist_name": artist.artist_stage_name, + "profile_filename": filename, + "profile_url": f"/stream/profile/{filename}", + "message": "Artist profile uploaded successfully" + } + + except Exception as e: + if 'temp_path' in locals() and os.path.exists(temp_path): + try: + os.unlink(temp_path) + except OSError: + pass # File might already be deleted + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/song/cover", response_model=dict) +async def upload_song_cover( + cover_file: UploadFile = File(...), + song_id: int = Form(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Upload cover image for existing song. + """ + try: + if not cover_file.filename: + raise HTTPException(status_code=400, detail="No file provided") + + with tempfile.NamedTemporaryFile(delete=False, suffix=Path(cover_file.filename).suffix) as temp_file: + content = await cover_file.read() + temp_file.write(content) + temp_path = Path(temp_file.name) + + is_valid, error_msg = file_service.validate_image_file(temp_path, cover_file.content_type) + if not is_valid: + os.unlink(temp_path) + raise HTTPException(status_code=400, detail=error_msg) + + song = db.query(Song).filter(Song.id == song_id).first() + if not song: + os.unlink(temp_path) + raise HTTPException(status_code=404, detail="Song not found") + + if current_user.role != "admin" and song.uploaded_by_user_id != current_user.id: + os.unlink(temp_path) + raise HTTPException(status_code=403, detail="Not authorized to update this song") + + filename = file_service.generate_unique_filename(cover_file.filename, song.id, "cover") + destination_path = Path("uploads/covers") / filename + + success = await file_service.save_uploaded_file(temp_path, destination_path) + if not success: + os.unlink(temp_path) + raise HTTPException(status_code=500, detail="Failed to save file") + + song.cover_image = str(destination_path) + db.commit() + db.refresh(song) + + return { + "song_id": song.id, + "title": song.title, + "cover_filename": filename, + "cover_url": f"/stream/cover/{filename}", + "message": "Song cover uploaded successfully" + } + + except Exception as e: + if 'temp_path' in locals(): + os.unlink(temp_path) + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/api/v1/user.py b/backend/app/api/v1/user.py index e2f8bc4..a16bb81 100644 --- a/backend/app/api/v1/user.py +++ b/backend/app/api/v1/user.py @@ -18,7 +18,7 @@ bulk_update_user_status, get_user_count_by_role, get_active_user_count, ) -from app.api.v1.deps import ( +from app.core.deps import ( get_current_active_user, get_current_admin ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e8776df..f25d71d 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -29,6 +29,27 @@ class Settings(BaseSettings): # Password pepper PASSWORD_PEPPER: str + # Test User Credentials (optional, only for development) + TEST_ADMIN_USERNAME: Optional[str] = None + TEST_ADMIN_EMAIL: Optional[str] = None + TEST_ADMIN_PASSWORD: Optional[str] = None + TEST_ADMIN_FIRST_NAME: Optional[str] = None + TEST_ADMIN_LAST_NAME: Optional[str] = None + + TEST_MUSICIAN_USERNAME: Optional[str] = None + TEST_MUSICIAN_EMAIL: Optional[str] = None + TEST_MUSICIAN_PASSWORD: Optional[str] = None + TEST_MUSICIAN_FIRST_NAME: Optional[str] = None + TEST_MUSICIAN_LAST_NAME: Optional[str] = None + TEST_MUSICIAN_STAGE_NAME: Optional[str] = None + TEST_MUSICIAN_BIO: Optional[str] = None + + TEST_LISTENER_USERNAME: Optional[str] = None + TEST_LISTENER_EMAIL: Optional[str] = None + TEST_LISTENER_PASSWORD: Optional[str] = None + TEST_LISTENER_FIRST_NAME: Optional[str] = None + TEST_LISTENER_LAST_NAME: Optional[str] = None + @property def DATABASE_URL(self) -> PostgresDsn: return ( @@ -39,7 +60,8 @@ def DATABASE_URL(self) -> PostgresDsn: class Config: env_file = ".env" env_file_encoding = "utf-8" - case_sensitive = False + case_sensitive = False + extra = "allow" settings = Settings() diff --git a/backend/app/api/v1/deps.py b/backend/app/core/deps.py similarity index 100% rename from backend/app/api/v1/deps.py rename to backend/app/core/deps.py diff --git a/backend/app/core/upload.py b/backend/app/core/upload.py new file mode 100644 index 0000000..6d4b7ea --- /dev/null +++ b/backend/app/core/upload.py @@ -0,0 +1,179 @@ +""" +Upload utilities for file handling, validation, and security. +""" + +import os +import mimetypes +from pathlib import Path +from typing import Optional, Tuple +from fastapi import UploadFile, HTTPException +import logging + +logger = logging.getLogger(__name__) + +ALLOWED_AUDIO_EXTENSIONS = {'.mp3', '.wav', '.flac'} +ALLOWED_IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp'} + +AUDIO_MIME_TYPES = { + 'audio/mpeg': '.mp3', + 'audio/wav': '.wav', + 'audio/flac': '.flac', + 'audio/x-flac': '.flac' +} + +IMAGE_MIME_TYPES = { + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp' +} + + +def validate_upload_file(file: UploadFile, allowed_extensions: set, max_size: int) -> Tuple[bool, str]: + """ + Validate uploaded file for type, size, and security. + + Args: + file: FastAPI UploadFile object + allowed_extensions: Set of allowed file extensions + max_size: Maximum file size in bytes + + Returns: + Tuple of (is_valid, error_message) + """ + if not file: + return False, "No file provided" + + if hasattr(file, 'size') and file.size and file.size > max_size: + return False, f"File too large. Maximum size is {max_size // (1024*1024)}MB" + + if not file.filename: + return False, "No filename provided" + + file_ext = Path(file.filename).suffix.lower() + if file_ext not in allowed_extensions: + return False, f"Unsupported file type. Allowed: {', '.join(allowed_extensions)}" + + if file.content_type: + if file_ext in ALLOWED_AUDIO_EXTENSIONS: + if file.content_type not in AUDIO_MIME_TYPES: + return False, f"Invalid audio content type: {file.content_type}" + expected_ext = AUDIO_MIME_TYPES[file.content_type] + if file_ext != expected_ext: + return False, f"Content type doesn't match extension. Expected: {expected_ext}" + + elif file_ext in ALLOWED_IMAGE_EXTENSIONS: + if file.content_type not in IMAGE_MIME_TYPES: + return False, f"Invalid image content type: {file.content_type}" + expected_ext = IMAGE_MIME_TYPES[file.content_type] + if file_ext != expected_ext: + return False, f"Content type doesn't match extension. Expected: {expected_ext}" + + return True, "" + + +def get_safe_filename(filename: str) -> str: + """ + Generate a safe filename by removing potentially dangerous characters. + + Args: + filename: Original filename + + Returns: + Safe filename + """ + dangerous_chars = ['<', '>', ':', '"', '|', '?', '*', '\\', '/'] + safe_filename = filename + + for char in dangerous_chars: + safe_filename = safe_filename.replace(char, '_') + + safe_filename = safe_filename.strip(' .') + + if not safe_filename: + safe_filename = "unnamed_file" + + return safe_filename + + +def get_file_extension_from_mime_type(content_type: str) -> Optional[str]: + """ + Get file extension from MIME type. + Args: + content_type: MIME type string + Returns: + File extension with dot (e.g., '.mp3') or None if not found + """ + if content_type in AUDIO_MIME_TYPES: + return AUDIO_MIME_TYPES[content_type] + if content_type in IMAGE_MIME_TYPES: + return IMAGE_MIME_TYPES[content_type] + ext = mimetypes.guess_extension(content_type) + return ext if ext else None + + +def validate_audio_upload(file: UploadFile, max_size: int = 100 * 1024 * 1024) -> Tuple[bool, str]: + """ + Validate audio file upload. + Args: + file: FastAPI UploadFile object + max_size: Maximum file size in bytes (default: 100MB) + Returns: + Tuple of (is_valid, error_message) + """ + return validate_upload_file(file, ALLOWED_AUDIO_EXTENSIONS, max_size) + + +def validate_image_upload(file: UploadFile, max_size: int = 10 * 1024 * 1024) -> Tuple[bool, str]: + """ + Validate image file upload. + Args: + file: FastAPI UploadFile object + max_size: Maximum file size in bytes (default: 10MB) + Returns: + Tuple of (is_valid, error_message) + """ + return validate_upload_file(file, ALLOWED_IMAGE_EXTENSIONS, max_size) + + +def ensure_upload_directory(directory_path: Path) -> None: + """ + Ensure upload directory exists. + Args: + directory_path: Path to the directory + """ + try: + directory_path.mkdir(parents=True, exist_ok=True) + logger.info(f"Ensured upload directory exists: {directory_path}") + except Exception as e: + logger.error(f"Error creating upload directory {directory_path}: {e}") + raise HTTPException(status_code=500, detail="Failed to create upload directory") + + +def get_file_size_mb(size_bytes: int) -> float: + """ + Convert file size from bytes to megabytes. + Args: + size_bytes: File size in bytes + Returns: + File size in megabytes + """ + return round(size_bytes / (1024 * 1024), 2) + + +def format_file_size(size_bytes: int) -> str: + """ + Format file size in human-readable format. + Args: + size_bytes: File size in bytes + Returns: + Formatted file size string + """ + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + return f"{size_bytes // 1024} KB" + elif size_bytes < 1024 * 1024 * 1024: + return f"{get_file_size_mb(size_bytes)} MB" + else: + return f"{size_bytes // (1024 * 1024 * 1024)} GB" diff --git a/backend/app/crud/album.py b/backend/app/crud/album.py index f36ffd2..24adba7 100644 --- a/backend/app/crud/album.py +++ b/backend/app/crud/album.py @@ -1,28 +1,304 @@ -# TODO: ALBUM CRUD IMPLEMENTATION - -# CREATE -# [ ] create_album(album_data: AlbumCreate) -> Album -# - Nullable artist or band references allowed -# - Validate artist_id and band_id exist - -# GET -# [ ] get_album_by_id(album_id: int) -> Optional[Album] -# [ ] get_albums_by_artist(artist_id: int, skip: int = 0, limit: int = 20) -> List[Album] -# [ ] get_albums_by_band(band_id: int, skip: int = 0, limit: int = 20) -> List[Album] -# [ ] get_albums_by_title(title: str, skip: int = 0, limit: int = 20) -> List[Album] -# [ ] get_all_albums(skip: int = 0, limit: int = 50) -> List[Album] - -# UPDATE -# [ ] update_album(album_id: int, data: AlbumUpdate) -> Optional[Album] -# - Allow updating title, description, cover_image - - -# [ ] get_album_with_songs(album_id: int) -> Optional[Album] # eager load songs, artist, band -# [ ] get_albums_released_between(start_date: datetime, end_date: datetime, skip: int = 0, limit: int = 20) -> List[Album] -# [ ] get_albums_by_artist_name(artist_name: str, skip: int = 0, limit: int = 20) -> List[Album] -# [ ] get_albums_by_band_name(band_name: str, skip: int = 0, limit: int = 20) -> List[Album] - -# HELPERS -# [ ] album_exists(album_id: int) -> bool -# [ ] validate_artist_exists(artist_id: int) -> bool -# [ ] validate_band_exists(band_id: int) -> bool +""" +CRUD operations for Album model. +""" + +from typing import Optional, List +from datetime import datetime, timezone +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import func +from app.db.models.album import Album +from app.db.models.artist import Artist +from app.db.models.band import Band +from app.schemas.album import AlbumCreate, AlbumUpdate + + +def create_album(db: Session, album_data: AlbumCreate, uploaded_by_user_id: int) -> Album: + """ + Create a new album. + + Args: + db: Database session + album_data: Album creation data + uploaded_by_user_id: ID of the user uploading the album + + Returns: + Created album object + + Raises: + ValueError: If artist_id or band_id doesn't exist + """ + if album_data.album_artist_id: + artist = db.query(Artist).filter(Artist.id == album_data.album_artist_id).first() + if not artist: + raise ValueError("Artist not found") + + if album_data.album_band_id: + band = db.query(Band).filter(Band.id == album_data.album_band_id).first() + if not band: + raise ValueError("Band not found") + + album = Album( + title=album_data.title, + description=album_data.description, + cover_image=album_data.cover_image, + release_date=album_data.release_date, + uploaded_by_user_id=uploaded_by_user_id, + album_artist_id=album_data.album_artist_id, + album_band_id=album_data.album_band_id, + artist_name=album_data.artist_name, + band_name=album_data.band_name + ) + + db.add(album) + db.commit() + db.refresh(album) + return album + + +def get_album_by_id(db: Session, album_id: int) -> Optional[Album]: + """ + Get album by primary key. + + Args: + db: Database session + album_id: Album ID to retrieve + + Returns: + Album object if found, None otherwise + """ + return db.query(Album).filter(Album.id == album_id).first() + + +def get_albums_by_artist(db: Session, artist_id: int, skip: int = 0, limit: int = 20) -> List[Album]: + """ + Get albums by artist ID. + + Args: + db: Database session + artist_id: Artist ID + skip: Number of records to skip + limit: Maximum number of records to return + + Returns: + List of album objects + """ + return db.query(Album).filter(Album.album_artist_id == artist_id).offset(skip).limit(limit).all() + + +def get_albums_by_band(db: Session, band_id: int, skip: int = 0, limit: int = 20) -> List[Album]: + """ + Get albums by band ID. + + Args: + db: Database session + band_id: Band ID + skip: Number of records to skip + limit: Maximum number of records to return + + Returns: + List of album objects + """ + return db.query(Album).filter(Album.album_band_id == band_id).offset(skip).limit(limit).all() + + +def get_albums_by_title(db: Session, title: str, skip: int = 0, limit: int = 20) -> List[Album]: + """ + Search albums by title (case-insensitive). + + Args: + db: Database session + title: Title to search for + skip: Number of records to skip + limit: Maximum number of records to return + + Returns: + List of matching album objects + """ + return db.query(Album).filter(Album.title.ilike(f"%{title}%")).offset(skip).limit(limit).all() + + +def get_all_albums(db: Session, skip: int = 0, limit: int = 50) -> List[Album]: + """ + Get paginated list of all albums. + + Args: + db: Database session + skip: Number of records to skip + limit: Maximum number of records to return + + Returns: + List of album objects + """ + return db.query(Album).offset(skip).limit(limit).all() + + +def update_album(db: Session, album_id: int, album_data: AlbumUpdate) -> Optional[Album]: + """ + Update album information. + + Args: + db: Database session + album_id: Album ID to update + album_data: Update data + + Returns: + Updated album object if found, None otherwise + """ + album = get_album_by_id(db, album_id) + if not album: + return None + + for field, value in album_data.dict(exclude_unset=True).items(): + setattr(album, field, value) + + db.add(album) + db.commit() + db.refresh(album) + return album + + +def get_album_with_songs(db: Session, album_id: int) -> Optional[Album]: + """ + Get album with eager-loaded songs, artist, and band. + + Args: + db: Database session + album_id: Album ID to retrieve + + Returns: + Album object with loaded relationships if found, None otherwise + """ + return db.query(Album).options( + joinedload(Album.album_songs), + joinedload(Album.artist), + joinedload(Album.band) + ).filter(Album.id == album_id).first() + + +def get_albums_released_between( + db: Session, + start_date: datetime, + end_date: datetime, + skip: int = 0, + limit: int = 20 +) -> List[Album]: + """ + Get albums released between two dates. + + Args: + db: Database session + start_date: Start date + end_date: End date + skip: Number of records to skip + limit: Maximum number of records to return + + Returns: + List of album objects + """ + return db.query(Album).filter( + Album.release_date >= start_date, + Album.release_date <= end_date + ).offset(skip).limit(limit).all() + + +def get_albums_by_artist_name(db: Session, artist_name: str, skip: int = 0, limit: int = 20) -> List[Album]: + """ + Get albums by artist name (case-insensitive). + + Args: + db: Database session + artist_name: Artist name to search for + skip: Number of records to skip + limit: Maximum number of records to return + + Returns: + List of matching album objects + """ + return db.query(Album).filter(Album.artist_name.ilike(f"%{artist_name}%")).offset(skip).limit(limit).all() + + +def get_albums_by_band_name(db: Session, band_name: str, skip: int = 0, limit: int = 20) -> List[Album]: + """ + Get albums by band name (case-insensitive). + + Args: + db: Database session + band_name: Band name to search for + skip: Number of records to skip + limit: Maximum number of records to return + + Returns: + List of matching album objects + """ + return db.query(Album).filter(Album.band_name.ilike(f"%{band_name}%")).offset(skip).limit(limit).all() + + +def album_exists(db: Session, album_id: int) -> bool: + """ + Check if album exists. + + Args: + db: Database session + album_id: Album ID to check + + Returns: + True if album exists, False otherwise + """ + return db.query(Album).filter(Album.id == album_id).first() is not None + + +def validate_artist_exists(db: Session, artist_id: int) -> bool: + """ + Validate that artist exists. + + Args: + db: Database session + artist_id: Artist ID to validate + + Returns: + True if artist exists, False otherwise + """ + return db.query(Artist).filter(Artist.id == artist_id).first() is not None + + +def validate_band_exists(db: Session, band_id: int) -> bool: + """ + Validate that band exists. + + Args: + db: Database session + band_id: Band ID to validate + + Returns: + True if band exists, False otherwise + """ + return db.query(Band).filter(Band.id == band_id).first() is not None + + +def get_album_count(db: Session) -> int: + """ + Get total number of albums. + + Args: + db: Database session + + Returns: + Total count of albums + """ + return db.query(func.count(Album.id)).scalar() + + +def get_albums_by_user(db: Session, user_id: int, skip: int = 0, limit: int = 20) -> List[Album]: + """ + Get albums uploaded by a specific user. + + Args: + db: Database session + user_id: User ID + skip: Number of records to skip + limit: Maximum number of records to return + + Returns: + List of album objects + """ + return db.query(Album).filter(Album.uploaded_by_user_id == user_id).offset(skip).limit(limit).all() diff --git a/backend/app/crud/album_song.py b/backend/app/crud/album_song.py index 74c4dbf..581a0ae 100644 --- a/backend/app/crud/album_song.py +++ b/backend/app/crud/album_song.py @@ -1,26 +1,315 @@ -# TODO: ALBUMSONG CRUD IMPLEMENTATION - -# CREATE -# [ ] create_album_song(album_song_data: AlbumSongCreate) -> AlbumSong -# - Ensure unique track_number per album (check uniqueness before insert) -# - Validate album_id and song_id exist - -# GET -# [ ] get_album_song_by_id(album_song_id: int) -> Optional[AlbumSong] -# [ ] get_album_songs_by_album(album_id: int) -> List[AlbumSong] -# [ ] get_all_album_songs(skip: int = 0, limit: int = 20) -> List[AlbumSong] # paginated for admins - -# UPDATE -# [ ] update_album_song(album_song_id: int, data: AlbumSongUpdate) -> Optional[AlbumSong] -# - Allow updating track_number -# - On track_number update, check uniqueness within album - -# DELETE -# [ ] delete_album_song(album_song_id: int) -> bool -# - Delete by ID, return success/failure - -# HELPERS -# [ ] album_song_exists(album_song_id: int) -> bool -# [ ] is_track_number_taken(album_id: int, track_number: int, exclude_id: Optional[int] = None) -> bool -# - Check if a track number already exists for the album, excluding the current record +""" +CRUD operations for AlbumSong model. +""" + +from typing import Optional, List +from datetime import datetime, timezone +from sqlalchemy.orm import Session +from sqlalchemy import func +from app.db.models.album_song import AlbumSong +from app.db.models.album import Album +from app.db.models.song import Song +from app.schemas.album_song import AlbumSongCreate, AlbumSongUpdate + + +def create_album_song(db: Session, album_song_data: AlbumSongCreate) -> AlbumSong: + """ + Create a new album-song relationship. + + Args: + db: Database session + album_song_data: Album-song creation data + + Returns: + Created album-song object + + Raises: + ValueError: If album_id or song_id doesn't exist, or track_number is taken + """ + album = db.query(Album).filter(Album.id == album_song_data.album_id).first() + if not album: + raise ValueError("Album not found") + + song = db.query(Song).filter(Song.id == album_song_data.song_id).first() + if not song: + raise ValueError("Song not found") + + if is_track_number_taken(db, album_song_data.album_id, album_song_data.track_number): + raise ValueError("Track number already exists for this album") + + album_song = AlbumSong( + album_id=album_song_data.album_id, + song_id=album_song_data.song_id, + track_number=album_song_data.track_number + ) + + db.add(album_song) + db.commit() + db.refresh(album_song) + return album_song + + +def get_album_song_by_id(db: Session, album_song_id: int) -> Optional[AlbumSong]: + """ + Get album-song relationship by primary key. + + Args: + db: Database session + album_song_id: Album-song ID to retrieve + + Returns: + AlbumSong object if found, None otherwise + """ + return db.query(AlbumSong).filter(AlbumSong.id == album_song_id).first() + + +def get_album_songs_by_album(db: Session, album_id: int, skip: int = 0, limit: int = 50) -> List[AlbumSong]: + """ + Get all songs for a specific album, ordered by track number. + + Args: + db: Database session + album_id: Album ID + skip: Number of records to skip + limit: Maximum number of records to return + + Returns: + List of album-song objects + """ + return db.query(AlbumSong).filter( + AlbumSong.album_id == album_id + ).order_by(AlbumSong.track_number).offset(skip).limit(limit).all() + + +def get_album_songs_by_song(db: Session, song_id: int, skip: int = 0, limit: int = 20) -> List[AlbumSong]: + """ + Get all albums that contain a specific song. + + Args: + db: Database session + song_id: Song ID + skip: Number of records to skip + limit: Maximum number of records to return + + Returns: + List of album-song objects + """ + return db.query(AlbumSong).filter( + AlbumSong.song_id == song_id + ).order_by(AlbumSong.track_number).offset(skip).limit(limit).all() + + +def update_album_song(db: Session, album_song_id: int, album_song_data: AlbumSongUpdate) -> Optional[AlbumSong]: + """ + Update album-song relationship (track number). + + Args: + db: Database session + album_song_id: Album-song ID to update + album_song_data: Update data + + Returns: + Updated album-song object if found, None otherwise + + Raises: + ValueError: If new track number is already taken + """ + album_song = get_album_song_by_id(db, album_song_id) + if not album_song: + return None + + if is_track_number_taken(db, album_song.album_id, album_song_data.track_number, exclude_id=album_song_id): + raise ValueError("Track number already exists for this album") + + album_song.track_number = album_song_data.track_number + db.add(album_song) + db.commit() + db.refresh(album_song) + return album_song + + +def delete_album_song(db: Session, album_song_id: int) -> bool: + """ + Delete album-song relationship. + + Args: + db: Database session + album_song_id: Album-song ID to delete + + Returns: + True if deleted, False if not found + """ + album_song = get_album_song_by_id(db, album_song_id) + if not album_song: + return False + + db.delete(album_song) + db.commit() + return True + + +def delete_album_song_by_album_and_song(db: Session, album_id: int, song_id: int) -> bool: + """ + Delete album-song relationship by album and song IDs. + + Args: + db: Database session + album_id: Album ID + song_id: Song ID + + Returns: + True if deleted, False if not found + """ + album_song = db.query(AlbumSong).filter( + AlbumSong.album_id == album_id, + AlbumSong.song_id == song_id + ).first() + + if not album_song: + return False + + db.delete(album_song) + db.commit() + return True + + +def album_song_exists(db: Session, album_song_id: int) -> bool: + """ + Check if album-song relationship exists. + + Args: + db: Database session + album_song_id: Album-song ID to check + + Returns: + True if exists, False otherwise + """ + return db.query(AlbumSong).filter(AlbumSong.id == album_song_id).first() is not None + + +def is_track_number_taken(db: Session, album_id: int, track_number: int, exclude_id: Optional[int] = None) -> bool: + """ + Check if track number is already taken for an album. + + Args: + db: Database session + album_id: Album ID + track_number: Track number to check + exclude_id: Album-song ID to exclude from check (for updates) + + Returns: + True if track number is taken, False otherwise + """ + query = db.query(AlbumSong).filter( + AlbumSong.album_id == album_id, + AlbumSong.track_number == track_number + ) + + if exclude_id: + query = query.filter(AlbumSong.id != exclude_id) + + return query.first() is not None + + +def get_album_song_count(db: Session, album_id: int) -> int: + """ + Get total number of songs in an album. + + Args: + db: Database session + album_id: Album ID + + Returns: + Total count of songs in album + """ + return db.query(func.count(AlbumSong.id)).filter(AlbumSong.album_id == album_id).scalar() + + +def get_album_total_duration(db: Session, album_id: int) -> int: + """ + Get total duration of all songs in an album. + + Args: + db: Database session + album_id: Album ID + + Returns: + Total duration in seconds + """ + result = db.query(func.sum(Song.song_duration)).join( + AlbumSong, Song.id == AlbumSong.song_id + ).filter(AlbumSong.album_id == album_id).scalar() + + return result or 0 + + +def get_album_song_statistics(db: Session, album_id: int) -> dict: + """ + Get comprehensive statistics for an album. + + Args: + db: Database session + album_id: Album ID + + Returns: + Dictionary with album statistics + """ + total_tracks = get_album_song_count(db, album_id) + total_duration = get_album_total_duration(db, album_id) + + shortest_track = db.query(Song).join( + AlbumSong, Song.id == AlbumSong.song_id + ).filter(AlbumSong.album_id == album_id).order_by(Song.song_duration.asc()).first() + + longest_track = db.query(Song).join( + AlbumSong, Song.id == AlbumSong.song_id + ).filter(AlbumSong.album_id == album_id).order_by(Song.song_duration.desc()).first() + + average_duration = total_duration / total_tracks if total_tracks > 0 else 0 + + return { + "album_id": album_id, + "total_tracks": total_tracks, + "total_duration": total_duration, + "average_track_duration": round(average_duration, 2), + "shortest_track": shortest_track, + "longest_track": longest_track + } + + +def reorder_album_tracks(db: Session, album_id: int, track_orders: List[dict]) -> bool: + """ + Bulk reorder tracks in an album. + + Args: + db: Database session + album_id: Album ID + track_orders: List of dicts with song_id and new track_number + + Returns: + True if successful, False otherwise + """ + try: + for track_order in track_orders: + song_id = track_order["song_id"] + new_track_number = track_order["track_number"] + + album_song = db.query(AlbumSong).filter( + AlbumSong.album_id == album_id, + AlbumSong.song_id == song_id + ).first() + + if album_song: + if is_track_number_taken(db, album_id, new_track_number, exclude_id=album_song.id): + raise ValueError(f"Track number {new_track_number} already exists") + + album_song.track_number = new_track_number + db.add(album_song) + + db.commit() + return True + + except Exception as e: + db.rollback() + raise ValueError(f"Failed to reorder tracks: {str(e)}") diff --git a/backend/app/crud/artist.py b/backend/app/crud/artist.py index a66507a..56a0d14 100644 --- a/backend/app/crud/artist.py +++ b/backend/app/crud/artist.py @@ -30,7 +30,7 @@ def create_artist(db: Session, artist_data: ArtistCreate, user_id: int) -> Artis artist = Artist( artist_stage_name=artist_data.artist_stage_name, artist_bio=artist_data.artist_bio, - artist_profile_image=artist_data.artist_profile_image, + artist_profile_image=None, artist_social_link=artist_data.artist_social_link, linked_user_account=user_id, created_at=datetime.now(timezone.utc) diff --git a/backend/app/crud/following.py b/backend/app/crud/following.py index d1dd28b..a9080c2 100644 --- a/backend/app/crud/following.py +++ b/backend/app/crud/following.py @@ -1,36 +1,293 @@ -# TODO: FOLLOWING CRUD IMPLEMENTATION - -# CREATE -# [ ] create_following_for_artist(user_id: int, artist_id: int) -> Following -# - Ensure user is not already following the artist (enforced by unique constraint) -# [ ] create_following_for_band(user_id: int, band_id: int) -> Following -# - Ensure user is not already following the band (enforced by unique constraint) - -# GET -# [ ] get_following_by_id(following_id: int) -> Optional[Following] -# [ ] get_all_followings_of_user(user_id: int, skip: int = 0, limit: int = 50) -> List[Following] -# [ ] get_followings_of_user_artists(user_id: int, skip: int = 0, limit: int = 50) -> List[Following] -# [ ] get_followings_of_user_bands(user_id: int, skip: int = 0, limit: int = 50) -> List[Following] -# [ ] get_all_followers_of_artist(artist_id: int, skip: int = 0, limit: int = 50) -> List[Following] -# [ ] get_all_followers_of_band(band_id: int, skip: int = 0, limit: int = 50) -> List[Following] - -# DELETE -# [ ] delete_following_artist(user_id: int, artist_id: int) -> bool -# - Allow a user to unfollow an artist -# [ ] delete_following_band(user_id: int, band_id: int) -> bool -# - Allow a user to unfollow a band -# [ ] delete_following_by_id(following_id: int) -> bool - -# CHECK -# [ ] is_user_following_artist(user_id: int, artist_id: int) -> bool -# [ ] is_user_following_band(user_id: int, band_id: int) -> bool - -# HELPERS -# [ ] count_followers_of_artist(artist_id: int) -> int -# [ ] count_followers_of_band(band_id: int) -> int -# [ ] count_followings_of_user(user_id: int) -> int - -# REPORTS -# [ ] get_recent_followings_of_user(user_id: int, limit: int = 10) -> List[Following] -# [ ] get_recent_followers_of_artist_or_band(entity_type: str, entity_id: int, limit: int = 10) -> List[Following] -# - entity_type can be "artist" or "band" to handle both cases +from typing import List, Optional, Tuple +from sqlalchemy.orm import Session +from sqlalchemy import and_, func, desc +from datetime import datetime, timezone +from fastapi import HTTPException + +from app.db.models.following import Following +from app.db.models.user import User +from app.db.models.artist import Artist +from app.db.models.band import Band +from app.schemas.following import FollowingCreate + + +def get_following_by_id(db: Session, following_id: int) -> Optional[Following]: + """ + Get a following by its ID. + Args: + db: Database session + following_id: ID of the following + Returns: + Optional[Following]: The following object if found, None otherwise + """ + return db.query(Following).filter(Following.id == following_id).first() + + +def get_following_by_user_and_target( + db: Session, user_id: int, artist_id: Optional[int] = None, band_id: Optional[int] = None +) -> Optional[Following]: + """ + Get a following by user ID and target (artist or band). + Args: + db: Database session + user_id: ID of the user + artist_id: ID of the artist (optional) + band_id: ID of the band (optional) + Returns: + Optional[Following]: The following object if found, None otherwise + """ + query = db.query(Following).filter(Following.user_id == user_id) + + if artist_id is not None: + query = query.filter(Following.artist_id == artist_id) + elif band_id is not None: + query = query.filter(Following.band_id == band_id) + + return query.first() + + +def create_following( + db: Session, user_id: int, artist_id: Optional[int] = None, band_id: Optional[int] = None +) -> Following: + """ + Create a new following relationship. + Args: + db: Database session + user_id: ID of the user + artist_id: ID of the artist (optional) + band_id: ID of the band (optional) + Returns: + Following: The created following object + """ + following_data = FollowingCreate( + user_id=user_id, + artist_id=artist_id, + band_id=band_id + ) + db_following = Following(**following_data.model_dump()) + db_following.started_at = datetime.now(timezone.utc) + + db.add(db_following) + db.commit() + db.refresh(db_following) + return db_following + + +def delete_following( + db: Session, user_id: int, artist_id: Optional[int] = None, band_id: Optional[int] = None +) -> bool: + """ + Delete a following relationship. + Args: + db: Database session + user_id: ID of the user + artist_id: ID of the artist (optional) + band_id: ID of the band (optional) + Returns: + bool: True if deleted, False if not found + """ + following = get_following_by_user_and_target(db, user_id, artist_id, band_id) + if following: + db.delete(following) + db.commit() + return True + return False + + +def toggle_following( + db: Session, user_id: int, artist_id: Optional[int] = None, band_id: Optional[int] = None +) -> Tuple[Following, bool]: + """ + Toggle following status (follow if not following, unfollow if following). + Args: + db: Database session + user_id: ID of the user + artist_id: ID of the artist (optional) + band_id: ID of the band (optional) + Returns: + Tuple[Following, bool]: (following object, was_created) + """ + existing_following = get_following_by_user_and_target(db, user_id, artist_id, band_id) + + if existing_following: + db.delete(existing_following) + db.commit() + return existing_following, False + else: + new_following = create_following(db, user_id, artist_id, band_id) + return new_following, True + + +def get_user_followings( + db: Session, user_id: int, skip: int = 0, limit: int = 50 +) -> List[Following]: + """ + Get all followings by a specific user with pagination. + Args: + db: Database session + user_id: ID of the user + skip: Number of records to skip + limit: Maximum number of records to return + Returns: + List[Following]: List of followings by the user + """ + return db.query(Following).filter( + Following.user_id == user_id + ).order_by(desc(Following.started_at)).offset(skip).limit(limit).all() + + +def get_user_followings_with_targets( + db: Session, user_id: int, skip: int = 0, limit: int = 50 +) -> List[Tuple[Following, Optional[Artist], Optional[Band]]]: + """ + Get all followings by a user with target details (artist or band). + Args: + db: Database session + user_id: ID of the user + skip: Number of records to skip + limit: Maximum number of records to return + Returns: + List[Tuple[Following, Optional[Artist], Optional[Band]]]: List of (following, artist, band) tuples + """ + return db.query(Following, Artist, Band).outerjoin( + Artist, Following.artist_id == Artist.id + ).outerjoin( + Band, Following.band_id == Band.id + ).filter( + Following.user_id == user_id + ).order_by(desc(Following.started_at)).offset(skip).limit(limit).all() + + +def is_user_following_artist(db: Session, user_id: int, artist_id: int) -> bool: + """ + Check if a user is following a specific artist. + Args: + db: Database session + user_id: ID of the user + artist_id: ID of the artist + Returns: + bool: True if user is following the artist, False otherwise + """ + following = db.query(Following).filter( + and_(Following.user_id == user_id, Following.artist_id == artist_id) + ).first() + return following is not None + + +def is_user_following_band(db: Session, user_id: int, band_id: int) -> bool: + """ + Check if a user is following a specific band. + Args: + db: Database session + user_id: ID of the user + band_id: ID of the band + Returns: + bool: True if user is following the band, False otherwise + """ + following = db.query(Following).filter( + and_(Following.user_id == user_id, Following.band_id == band_id) + ).first() + return following is not None + + +def count_artist_followers(db: Session, artist_id: int) -> int: + """ + Count total followers for an artist. + Args: + db: Database session + artist_id: ID of the artist + Returns: + int: Number of followers for the artist + """ + return db.query(func.count(Following.id)).filter(Following.artist_id == artist_id).scalar() + + +def count_band_followers(db: Session, band_id: int) -> int: + """ + Count total followers for a band. + Args: + db: Database session + band_id: ID of the band + Returns: + int: Number of followers for the band + """ + return db.query(func.count(Following.id)).filter(Following.band_id == band_id).scalar() + + +def count_user_followings(db: Session, user_id: int) -> int: + """ + Count total followings by a user. + Args: + db: Database session + user_id: ID of the user + Returns: + int: Number of followings by the user + """ + return db.query(func.count(Following.id)).filter(Following.user_id == user_id).scalar() + + +def get_following_statistics(db: Session) -> dict: + """ + Get overall following statistics. + Args: + db: Database session + Returns: + dict: Dictionary with following statistics + """ + total_followings = db.query(func.count(Following.id)).scalar() + unique_users = db.query(func.count(func.distinct(Following.user_id))).scalar() + unique_artists = db.query(func.count(func.distinct(Following.artist_id))).scalar() + unique_bands = db.query(func.count(func.distinct(Following.band_id))).scalar() + + most_followed_artist = db.query( + Artist, + func.count(Following.id).label('follower_count') + ).join(Following).group_by(Artist.id).order_by( + desc('follower_count') + ).first() + + most_followed_band = db.query( + Band, + func.count(Following.id).label('follower_count') + ).join(Following).group_by(Band.id).order_by( + desc('follower_count') + ).first() + + return { + "total_followings": total_followings, + "unique_users": unique_users, + "unique_artists": unique_artists, + "unique_bands": unique_bands, + "most_followed_artist": most_followed_artist[0] if most_followed_artist else None, + "most_followed_band": most_followed_band[0] if most_followed_band else None + } + + +def get_user_following_summary(db: Session, user_id: int) -> dict: + """ + Get a summary of user's followings including counts and lists. + Args: + db: Database session + user_id: ID of the user + Returns: + dict: Summary of user's followings + """ + followings_with_targets = get_user_followings_with_targets(db, user_id, skip=0, limit=1000) + + followed_artists = [] + followed_bands = [] + + for following, artist, band in followings_with_targets: + if artist: + followed_artists.append(artist) + if band: + followed_bands.append(band) + + return { + "user_id": user_id, + "total_following": len(followings_with_targets), + "artist_count": len(followed_artists), + "band_count": len(followed_bands), + "followed_artists": followed_artists, + "followed_bands": followed_bands + } diff --git a/backend/app/crud/genre.py b/backend/app/crud/genre.py index 896f102..7561e00 100644 --- a/backend/app/crud/genre.py +++ b/backend/app/crud/genre.py @@ -1,27 +1,195 @@ -# TODO: GENRE CRUD IMPLEMENTATION - -# CREATE -# [ ] create_genre(genre_data: GenreCreate) -> Genre -# - Ensures unique genre name before insert -# - Set created_at, is_active=True by default - -# READ / GET -# [ ] get_genre_by_id(genre_id: int) -> Optional[Genre] -# [ ] get_genre_by_name(name: str) -> Optional[Genre] -# [ ] get_all_genres() -> List[Genre] # no pagination needed, small list -# [ ] get_all_active_genres() -> List[Genre] - -# UPDATE -# [ ] update_genre(genre_id: int, data: GenreUpdate) -> Optional[Genre] -# - Allows updating name and description -# - Check uniqueness of name on update - -# DEACTIVATION -# [ ] disable_genre(genre_id: int) -> bool -# - Set is_active=False, disabled_at=datetime.utcnow() -# [ ] enable_genre(genre_id: int) -> bool -# - Set is_active=True, disabled_at=None - -# HELPERS -# [ ] genre_exists(genre_id: int) -> bool -# [ ] genre_name_taken(name: str, exclude_genre_id: Optional[int] = None) -> bool +from typing import List, Optional, Tuple +from sqlalchemy.orm import Session +from sqlalchemy import func +import difflib +from sqlalchemy.exc import IntegrityError +from datetime import datetime, timezone +from app.db.models.genre import Genre +from app.schemas.genre import GenreCreate, GenreUpdate + + +def create_genre(db: Session, genre_data: GenreCreate) -> Optional[Genre]: + """Create a new genre in the database""" + db_genre = Genre( + name=genre_data.name, + description=genre_data.description, + is_active=True + ) + try: + db.add(db_genre) + db.commit() + db.refresh(db_genre) + return db_genre + except IntegrityError: # this does the same job as checking if name exists in raw sql + db.rollback() + return None + + +def get_genre_by_id(db: Session, genre_id: int) -> Optional[Genre]: + """Get a genre by its ID""" + return db.query(Genre).filter(Genre.id == genre_id).first() + + +def get_genre_by_name(db: Session, name: str) -> Optional[Genre]: + """Get an active genre by its name case-insensitive exact match""" + return ( + db.query(Genre) + .filter(func.lower(Genre.name) == name.lower(), Genre.is_active == True) + .first() + ) + + +def get_genre_by_name_any(db: Session, name: str) -> Optional[Genre]: + """Get a genre by its name (case-insensitive), regardless of active status.""" + return ( + db.query(Genre) + .filter(func.lower(Genre.name) == name.lower()) + .first() + ) + + +def get_all_genres(db: Session) -> List[Genre]: + """Get all genres (active and inactive)""" + return db.query(Genre).all() + + +def get_all_active_genres(db: Session) -> List[Genre]: + """Get all active genres only""" + return db.query(Genre).filter(Genre.is_active == True).all() + + +def get_genres_by_partial_name(db: Session, query_text: str) -> List[Genre]: + """Get active genres whose names partially match the query """ + like_pattern = f"%{query_text}%" + return ( + db.query(Genre) + .filter(Genre.is_active == True) + .filter(Genre.name.ilike(like_pattern)) + .all() + ) + + +def get_genres_by_partial_name_any(db: Session, query_text: str) -> List[Genre]: + """Get genres whose names partially match the query (any status).""" + like_pattern = f"%{query_text}%" + return ( + db.query(Genre) + .filter(Genre.name.ilike(like_pattern)) + .all() + ) + + +def get_genres_by_fuzzy_name( + db: Session,query_text: str, + max_results: int = 10, min_ratio: float = 0.6, +) -> List[Genre]: + """Fuzzy search, time consuming + """ + active_genres: List[Genre] = get_all_active_genres(db) + scored: List[Tuple[float, Genre]] = [] + for genre in active_genres: + ratio = difflib.SequenceMatcher(None, query_text.lower(), genre.name.lower()).ratio() + if ratio >= min_ratio: + scored.append((ratio, genre)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [g for _, g in scored[:max_results]] + + +def get_genres_by_fuzzy_name_any( + db: Session, query_text: str, + max_results: int = 10, min_ratio: float = 0.6, +) -> List[Genre]: + """Fuzzy search across all genres (any status).""" + all_genres: List[Genre] = get_all_genres(db) + scored: List[Tuple[float, Genre]] = [] + for genre in all_genres: + ratio = difflib.SequenceMatcher(None, query_text.lower(), genre.name.lower()).ratio() + if ratio >= min_ratio: + scored.append((ratio, genre)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [g for _, g in scored[:max_results]] + + +def update_genre(db: Session, genre_id: int, genre_data: GenreUpdate) -> Optional[Genre]: + """Update a genre with new data""" + db_genre = get_genre_by_id(db, genre_id) + if not db_genre: + return None + + update_data = genre_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_genre, field, value) + + db.commit() + db.refresh(db_genre) + return db_genre + + +def disable_genre(db: Session, genre_id: int) -> bool: + """Disable a genre by setting is_active to False""" + db_genre = get_genre_by_id(db, genre_id) + if not db_genre: + return False + + db_genre.is_active = False + db_genre.disabled_at = datetime.now(timezone.utc) + db.commit() + return True + + +def enable_genre(db: Session, genre_id: int) -> bool: + """Enable a genre by setting is_active to True""" + db_genre = get_genre_by_id(db, genre_id) + if not db_genre: + return False + + db_genre.is_active = True + db_genre.disabled_at = None + db.commit() + return True + + +def genre_exists(db: Session, genre_id: int) -> bool: + """Check if a genre exists by ID""" + return db.query(Genre).filter(Genre.id == genre_id).first() is not None + + +def genre_name_taken(db: Session, name: str, exclude_genre_id: Optional[int] = None) -> bool: + """Check if a genre name is already taken (case-insensitive)""" + query = db.query(Genre).filter(func.lower(Genre.name) == name.lower()) + if exclude_genre_id: + query = query.filter(Genre.id != exclude_genre_id) + return query.first() is not None + + +def get_genre_statistics(db: Session) -> dict: + """Get comprehensive statistics about genres""" + total_genres = db.query(Genre).count() + active_genres = db.query(Genre).filter(Genre.is_active == True).count() + inactive_genres = total_genres - active_genres + + genres_with_songs = db.query(Genre).join(Genre.songs).distinct().count() + + genre_usage = db.query( + Genre.name, + func.count(Genre.songs).label('song_count') + ).outerjoin(Genre.songs).group_by(Genre.name).all() + + most_used = None + least_used = None + + if genre_usage: + sorted_usage = sorted(genre_usage, key=lambda x: x.song_count, reverse=True) + most_used = sorted_usage[0].name if sorted_usage[0].song_count > 0 else None + least_used = sorted_usage[-1].name if sorted_usage[-1].song_count > 0 else None + + return { + "total_genres": total_genres, + "active_genres": active_genres, + "inactive_genres": inactive_genres, + "genres_with_songs": genres_with_songs, + "most_used_genre": most_used, + "least_used_genre": least_used + } diff --git a/backend/app/crud/history.py b/backend/app/crud/history.py index 32f8c87..9870fbd 100644 --- a/backend/app/crud/history.py +++ b/backend/app/crud/history.py @@ -1,31 +1,226 @@ -# TODO: HISTORY CRUD IMPLEMENTATION - -# CREATE -# [ ] create_history_entry(user_id: int, song_id: int) -> History -# - Automatically sets `played_at` to current UTC time -# - prevent spamming by checking if same user played same song within short time (120 seconds) - -# GET -# [ ] get_history_by_id(history_id: int) -> Optional[History] -# [ ] get_user_history(user_id: int, skip: int = 0, limit: int = 50) -> List[History] -# - Ordered by `played_at` DESC (most recent first) -# [ ] get_recent_plays_of_song(song_id: int, limit: int = 10) -> List[History] -# [ ] get_song_play_history_by_user(user_id: int, song_id: int) -> List[History] - -# DELETE - thoughts on archiving instead of deleting - so song play history is preserved -# [ ] delete_history_by_id(history_id: int) -> bool -# [ ] delete_user_history(user_id: int) -> int -# - Returns number of deleted records - -# FILTERING -# [ ] get_user_history_in_date_range(user_id: int, start: datetime, end: datetime) -> List[History] - -# ANALYTICS -# [ ] count_song_plays(song_id: int) -> int -# [ ] count_user_total_plays(user_id: int) -> int -# [ ] count_song_plays_by_user(user_id: int, song_id: int) -> int - -# STATS -# [ ] get_most_played_songs_by_user(user_id: int, limit: int = 10) -> List[Tuple[Song, int]] -# [ ] get_most_active_users(limit: int = 10) -> List[Tuple[User, int]] -# [ ] get_global_top_songs(limit: int = 10) -> List[Tuple[Song, int]] +from datetime import datetime, timezone, timedelta +from typing import List, Optional, Tuple +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import func, desc, and_ +from app.db.models.history import History +from app.db.models.song import Song +from app.db.models.user import User +from app.db.models.artist import Artist +from app.db.models.band import Band +from app.db.models.genre import Genre +from app.schemas.history import HistoryCreate, HistoryStats, GlobalHistoryStats + + +def create_history_entry(db: Session, user_id: int, song_id: int) -> Optional[History]: + """ + Create a history entry with spam prevention (120-second cooldown for same song) + """ + # no spam + recent_play = db.query(History).filter( + and_( + History.user_id == user_id, + History.song_id == song_id, + History.played_at >= datetime.now(timezone.utc) - timedelta(seconds=120) + ) + ).first() + if recent_play: + return None + + history_entry = History( + user_id=user_id, + song_id=song_id, + played_at=datetime.now(timezone.utc), + is_cleared=False + ) + + db.add(history_entry) + db.commit() + db.refresh(history_entry) + return history_entry + + +def get_user_history( + db: Session, + user_id: int, + skip: int = 0, + limit: int = 50, + include_cleared: bool = False +) -> Tuple[List[History], int]: + """ + Get user's listening history with song details, paginated + """ + query = db.query(History).options( + joinedload(History.song).joinedload(Song.artist), + joinedload(History.song).joinedload(Song.band), + joinedload(History.song).joinedload(Song.genre) + ).filter(History.user_id == user_id) + + if not include_cleared: + query = query.filter(History.is_cleared == False) + + total = query.count() + history = query.order_by(desc(History.played_at)).offset(skip).limit(limit).all() + + return history, total + + +def clear_user_history(db: Session, user_id: int) -> int: + """ + Mark all user's history entries as cleared (soft delete for analytics) + """ + result = db.query(History).filter( + and_( + History.user_id == user_id, + History.is_cleared == False + ) + ).update({"is_cleared": True}) + + db.commit() + return result + + +def get_user_history_stats(db: Session, user_id: int) -> HistoryStats: + """ + Get comprehensive listening statistics for a user + """ + + total_listens = db.query(func.count(History.id)).filter( + and_( + History.user_id == user_id, + History.is_cleared == False + ) + ).scalar() + + unique_songs = db.query(func.count(func.distinct(History.song_id))).filter( + and_( + History.user_id == user_id, + History.is_cleared == False + ) + ).scalar() + + total_duration = db.query(func.sum(Song.song_duration)).join(History).filter( + and_( + History.user_id == user_id, + History.is_cleared == False + ) + ).scalar() or 0 + + most_listened_song = db.query(Song, func.count(History.id).label('play_count')).join(History).filter( + and_( + History.user_id == user_id, + History.is_cleared == False + ) + ).group_by(Song.id).order_by(desc('play_count')).first() + + most_listened_artist = db.query( + Artist.artist_stage_name, func.count(History.id).label('play_count') + ).select_from(History).join(Song).join(Artist).filter( + and_( + History.user_id == user_id, + History.is_cleared == False + ) + ).group_by(Artist.artist_stage_name).order_by(desc('play_count')).first() + + most_listened_genre = db.query( + Genre.name, func.count(History.id).label('play_count') + ).select_from(History).join(Song).join(Genre).filter( + and_( + History.user_id == user_id, + History.is_cleared == False + ) + ).group_by(Genre.name).order_by(desc('play_count')).first() + + last_listened = db.query(History.played_at).filter( + and_( + History.user_id == user_id, + History.is_cleared == False + ) + ).order_by(desc(History.played_at)).first() + listening_streak = _calculate_listening_streak(db, user_id) + + return HistoryStats( + total_listens=total_listens, + unique_songs=unique_songs, + total_duration=total_duration, + most_listened_song=most_listened_song[0] if most_listened_song else None, + most_listened_artist=most_listened_artist[0] if most_listened_artist else None, + most_listened_genre=most_listened_genre[0] if most_listened_genre else None, + listening_streak=listening_streak, + last_listened=last_listened[0] if last_listened else None + ) + + +def get_global_history_stats(db: Session) -> GlobalHistoryStats: + """ + Get global listening statistics for admin dashboard + """ + total_listens = db.query(func.count(History.id)).filter(History.is_cleared == False).scalar() + unique_songs = db.query(func.count(func.distinct(History.song_id))).filter(History.is_cleared == False).scalar() + unique_users = db.query(func.count(func.distinct(History.user_id))).filter(History.is_cleared == False).scalar() + + average_listens_per_user = total_listens / unique_users if unique_users > 0 else 0 + + most_listened_song = db.query(Song, func.count(History.id).label('play_count')).join(History).filter( + History.is_cleared == False + ).group_by(Song.id).order_by(desc('play_count')).first() + + most_listened_artist = db.query( + Artist.artist_stage_name, func.count(History.id).label('play_count') + ).select_from(History).join(Song).join(Artist).filter(History.is_cleared == False).group_by(Artist.artist_stage_name).order_by(desc('play_count')).first() + + most_listened_genre = db.query( + Genre.name, func.count(History.id).label('play_count') + ).select_from(History).join(Song).join(Genre).filter(History.is_cleared == False).group_by(Genre.name).order_by(desc('play_count')).first() + + return GlobalHistoryStats( + total_listens=total_listens, + unique_songs=unique_songs, + unique_users=unique_users, + most_listened_song=most_listened_song[0] if most_listened_song else None, + most_listened_artist=most_listened_artist[0] if most_listened_artist else None, + most_listened_genre=most_listened_genre[0] if most_listened_genre else None, + average_listens_per_user=average_listens_per_user + ) + + +def count_song_plays(db: Session, song_id: int) -> int: + """ + Get total play count for a specific song (public endpoint) + """ + return db.query(func.count(History.id)).filter( + and_( + History.song_id == song_id, + History.is_cleared == False + ) + ).scalar() + + +def _calculate_listening_streak(db: Session, user_id: int) -> int: + """ + Calculate consecutive days of listening for a user + """ + listening_dates = db.query( + func.date(History.played_at).label('listen_date') + ).filter( + and_( + History.user_id == user_id, + History.is_cleared == False + ) + ).distinct().order_by(desc('listen_date')).all() + + if not listening_dates: + return 0 + + dates = [date[0] for date in listening_dates] + + streak = 1 + current_date = dates[0] + + for i in range(1, len(dates)): + if (current_date - dates[i]).days == 1: + streak += 1 + current_date = dates[i] + else: + break + + return streak diff --git a/backend/app/crud/like.py b/backend/app/crud/like.py index 69353f8..021731b 100644 --- a/backend/app/crud/like.py +++ b/backend/app/crud/like.py @@ -1,25 +1,241 @@ -# TODO: LIKE CRUD IMPLEMENTATION - -# CREATE -# [ ] like_song(user_id: int, song_id: int) -> Like -# - Check if already liked; if so, raise 409 Conflict -# - Auto-fills `liked_at` as current UTC time - -# GET -# [ ] get_like_by_id(like_id: int) -> Optional[Like] -# [ ] get_user_likes(user_id: int, skip: int = 0, limit: int = 50) -> List[Like] -# - Recent likes first (order by `liked_at` DESC) -# [ ] get_users_who_liked_song(song_id: int) -> List[User] //is it needed? -# [ ] is_song_liked_by_user(user_id: int, song_id: int) -> bool //helper? - -# DELETE -# [ ] unlike_song(user_id: int, song_id: int) -> bool -# - Deletes if exists, else returns False - -# COUNTING -# [ ] count_song_likes(song_id: int) -> int -# [ ] count_user_likes(user_id: int) -> int - -# STATS -# [ ] get_top_liked_songs(limit: int = 10) -> List[Tuple[Song, int]] +from typing import List, Optional, Tuple +from sqlalchemy.orm import Session +from sqlalchemy import and_, func, desc +from datetime import datetime, timezone +from fastapi import HTTPException + +from app.db.models.like import Like +from app.db.models.user import User +from app.db.models.song import Song +from app.schemas.like import LikeCreate + + +def get_like_by_id(db: Session, like_id: int) -> Optional[Like]: + """ + Get a like by its ID. + Args: + db: Database session + like_id: ID of the like + Returns: + Optional[Like]: The like object if found, None otherwise + """ + return db.query(Like).filter(Like.id == like_id).first() + + +def get_user_likes( + db: Session, + user_id: Optional[int] = None, + skip: int = 0, + limit: int = 50 +) -> List[Like]: + """ + Get all likes by a specific user with pagination, or all likes if user_id is None. + Args: + db: Database session + user_id: ID of the user (None for all likes) + skip: Number of records to skip + limit: Maximum number of records to return + Returns: + List[Like]: List of likes + """ + query = db.query(Like) + if user_id is not None: + query = query.filter(Like.user_id == user_id) + return query.order_by(desc(Like.liked_at)).offset(skip).limit(limit).all() + + +def get_user_likes_with_songs( + db: Session, + user_id: int, + skip: int = 0, + limit: int = 50, + search: Optional[str] = None +) -> List[Tuple[Like, Song]]: + """ + Get all likes by a specific user with full song details and optional search. + Args: + db: Database session + user_id: ID of the user + skip: Number of records to skip + limit: Maximum number of records to return + search: Optional search term to filter songs by title + Returns: + List[Tuple[Like, Song]]: List of (like, song) tuples + """ + query = db.query(Like, Song).join(Song, Like.song_id == Song.id).filter( + Like.user_id == user_id + ) + + if search: + search_term = f"%{search}%" + query = query.filter( + Song.title.ilike(search_term) + ) + + return query.order_by(desc(Like.liked_at)).offset(skip).limit(limit).all() + + +def is_song_liked_by_user(db: Session, user_id: int, song_id: int) -> bool: + """ + Check if a user has liked a specific song. + Args: + db: Database session + user_id: ID of the user + song_id: ID of the song + Returns: + bool: True if user liked the song, False otherwise + """ + like = db.query(Like).filter( + and_(Like.user_id == user_id, Like.song_id == song_id) + ).first() + return like is not None + + +def toggle_like(db: Session, user_id: int, song_id: int) -> Tuple[Like, bool]: + """ + Toggle like status for a song (like if not liked, unlike if liked). + Args: + db: Database session + user_id: ID of the user + song_id: ID of the song + Returns: + Tuple[Like, bool]: (like object, was_created) + """ + existing_like = db.query(Like).filter( + and_(Like.user_id == user_id, Like.song_id == song_id) + ).first() + + if existing_like: + db.delete(existing_like) + db.commit() + return existing_like, False + else: + # Create new like + like_data = LikeCreate(user_id=user_id, song_id=song_id) + db_like = Like(**like_data.model_dump()) + db_like.liked_at = datetime.now(timezone.utc) + + db.add(db_like) + db.commit() + db.refresh(db_like) + return db_like, True + + +def count_song_likes(db: Session, song_id: int) -> int: + """ + Count total likes for a song. + Args: + db: Database session + song_id: ID of the song + Returns: + int: Number of likes for the song + """ + return db.query(func.count(Like.id)).filter(Like.song_id == song_id).scalar() + + +def count_user_likes(db: Session, user_id: Optional[int] = None) -> int: + """ + Count total likes by a user, or all likes if user_id is None. + Args: + db: Database session + user_id: ID of the user (None for all likes) + Returns: + int: Number of likes + """ + query = db.query(func.count(Like.id)) + if user_id is not None: + query = query.filter(Like.user_id == user_id) + return query.scalar() + + +def get_top_liked_songs(db: Session, limit: int = 10) -> List[Tuple[Song, int]]: + """ + Get top liked songs with their like counts. + Args: + db: Database session + limit: Maximum number of songs to return + Returns: + List[Tuple[Song, int]]: List of (song, like_count) tuples + """ + result = db.query( + Song, + func.count(Like.id).label('like_count') + ).outerjoin(Like).group_by(Song.id).order_by( + desc('like_count') + ).limit(limit).all() + + return [(song, like_count) for song, like_count in result] + + +def get_like_statistics(db: Session) -> dict: + """ + Get overall like statistics. + Args: + db: Database session + Returns: + dict: Dictionary with like statistics + """ + total_likes = db.query(func.count(Like.id)).scalar() + unique_songs = db.query(func.count(func.distinct(Like.song_id))).scalar() + unique_users = db.query(func.count(func.distinct(Like.user_id))).scalar() + + most_liked_song = db.query( + Song, + func.count(Like.id).label('like_count') + ).join(Like).group_by(Song.id).order_by( + desc('like_count') + ).first() + + return { + "total_likes": total_likes, + "unique_songs": unique_songs, + "unique_users": unique_users, + "most_liked_song": most_liked_song[0] if most_liked_song else None, + "most_liked_song_count": most_liked_song[1] if most_liked_song else 0 + } + + + + + +def get_user_likes_summary(db: Session, user_id: int) -> dict: + """ + Get a summary of user's likes including favorite artists and genres. + Args: + db: Database session + user_id: ID of the user + Returns: + dict: Summary of user's likes + """ + from app.db.models.genre import Genre + + liked_songs = db.query(Song).join(Like).filter( + Like.user_id == user_id + ).all() + + favorite_artists = db.query( + Song.artist_name, + func.count(Like.id).label('like_count') + ).join(Like).filter( + and_(Like.user_id == user_id, Song.artist_name.isnot(None)) + ).group_by(Song.artist_name).order_by( + desc('like_count') + ).limit(5).all() + + favorite_genres = db.query( + Genre.name, + func.count(Like.id).label('like_count') + ).join(Song, Genre.id == Song.genre_id).join(Like).filter( + Like.user_id == user_id + ).group_by(Genre.name).order_by( + desc('like_count') + ).limit(5).all() + + return { + "user_id": user_id, + "total_likes": len(liked_songs), + "liked_songs": liked_songs, + "favorite_artists": [artist[0] for artist in favorite_artists], + "favorite_genres": [genre[0] for genre in favorite_genres] + } diff --git a/backend/app/crud/playlist.py b/backend/app/crud/playlist.py index d55e443..4fb91b9 100644 --- a/backend/app/crud/playlist.py +++ b/backend/app/crud/playlist.py @@ -1,26 +1,271 @@ -# TODO: PLAYLIST CRUD IMPLEMENTATION +from typing import List, Optional, Tuple +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import func, desc, and_ +import secrets +from app.db.models.playlist import Playlist +from app.db.models.playlist_collaborator import PlaylistCollaborator +from app.db.models.user import User +from app.schemas.playlist import PlaylistCreate, PlaylistUpdate, PlaylistStats -# CREATE -# [ ] create_playlist(data: PlaylistCreate, owner_id: int) -> Playlist -# - Enforce (owner_id, name) uniqueness -# - Set created_at -# GET -# [ ] get_playlist_by_id(playlist_id: int) -> Optional[Playlist] -# [ ] get_user_playlists(owner_id: int, skip: int = 0, limit: int = 20) -> List[Playlist] -# [ ] search_user_playlists(owner_id: int, keyword: str, skip: int = 0, limit: int = 20) -> List[Playlist] -# - Search by playlist name or description +def create_playlist(db: Session, playlist_data: PlaylistCreate, owner_id: int) -> Playlist: + """ + Create a new playlist for a user + """ + existing_playlist = db.query(Playlist).filter( + and_( + Playlist.owner_id == owner_id, + Playlist.name == playlist_data.name + ) + ).first() + + if existing_playlist: + raise ValueError(f"Playlist with name '{playlist_data.name}' already exists") + + playlist = Playlist( + owner_id=owner_id, + name=playlist_data.name, + description=playlist_data.description + ) + + db.add(playlist) + db.commit() + db.refresh(playlist) + return playlist -# UPDATE -# [ ] update_playlist_info(playlist_id: int, name: Optional[str], description: Optional[str]) -> Optional[Playlist] -# - Validate new name uniqueness -# DELETE -# [ ] delete_playlist(playlist_id: int, requesting_user_id: int) -> bool +def get_playlist_by_id(db: Session, playlist_id: int) -> Optional[Playlist]: + """ + Get a playlist by ID + """ + return db.query(Playlist).filter(Playlist.id == playlist_id).first() -# HELPERS -# [ ] user_can_edit_playlist(user_id: int, playlist_id: int) -> bool -# - Returns true if user is owner or collaborator +def get_playlist_with_owner(db: Session, playlist_id: int) -> Optional[Playlist]: + """ + Get a playlist with owner details + """ + return db.query(Playlist).options( + joinedload(Playlist.owner) + ).filter(Playlist.id == playlist_id).first() + + +def get_user_playlists( + db: Session, + user_id: int, + skip: int = 0, + limit: int = 20 +) -> Tuple[List[Playlist], int]: + """ + Get playlists owned by a user, paginated + """ + query = db.query(Playlist).filter(Playlist.owner_id == user_id) + total = query.count() + playlists = query.order_by(desc(Playlist.created_at)).offset(skip).limit(limit).all() + + return playlists, total + + +def get_user_playlists_with_owner( + db: Session, + user_id: int, + skip: int = 0, + limit: int = 20 +) -> Tuple[List[Playlist], int]: + """ + Get playlists owned by a user with owner details, paginated + """ + query = db.query(Playlist).options( + joinedload(Playlist.owner) + ).filter(Playlist.owner_id == user_id) + + total = query.count() + playlists = query.order_by(desc(Playlist.created_at)).offset(skip).limit(limit).all() + + return playlists, total + + +def search_playlists( + db: Session, + query: str, + user_id: Optional[int] = None, + skip: int = 0, + limit: int = 20 +) -> Tuple[List[Playlist], int]: + """ + Search playlists by name or description + """ + search_filter = Playlist.name.ilike(f"%{query}%") | Playlist.description.ilike(f"%{query}%") + + if user_id: + db_query = db.query(Playlist).filter( + and_( + search_filter, + Playlist.owner_id == user_id + ) + ) + else: + db_query = db.query(Playlist).filter(search_filter) + + total = db_query.count() + playlists = db_query.order_by(desc(Playlist.created_at)).offset(skip).limit(limit).all() + + return playlists, total + + +def update_playlist( + db: Session, + playlist_id: int, + playlist_data: PlaylistUpdate +) -> Optional[Playlist]: + """ + Update playlist information + """ + playlist = get_playlist_by_id(db, playlist_id) + if not playlist: + return None + + if playlist_data.name and playlist_data.name != playlist.name: + existing_playlist = db.query(Playlist).filter( + and_( + Playlist.owner_id == playlist.owner_id, + Playlist.name == playlist_data.name, + Playlist.id != playlist_id + ) + ).first() + + if existing_playlist: + raise ValueError(f"Playlist with name '{playlist_data.name}' already exists") + + if playlist_data.name is not None: + playlist.name = playlist_data.name + if playlist_data.description is not None: + playlist.description = playlist_data.description + + db.commit() + db.refresh(playlist) + return playlist + + +def delete_playlist(db: Session, playlist_id: int) -> bool: + """ + Delete a playlist + """ + playlist = get_playlist_by_id(db, playlist_id) + if not playlist: + return False + + db.delete(playlist) + db.commit() + return True + + +def user_can_edit_playlist(db: Session, user_id: int, playlist_id: int) -> bool: + """ + Check if user can edit a playlist (owner or collaborator with can_edit=True) + """ + playlist = get_playlist_by_id(db, playlist_id) + if not playlist: + return False + + if playlist.owner_id == user_id: + return True + + collaborator = db.query(PlaylistCollaborator).filter( + and_( + PlaylistCollaborator.playlist_id == playlist_id, + PlaylistCollaborator.collaborator_id == user_id, + PlaylistCollaborator.can_edit == True + ) + ).first() + + return collaborator is not None + + +def user_can_view_playlist(db: Session, user_id: int, playlist_id: int) -> bool: + """ + Check if user can view a playlist (owner or collaborator) + """ + playlist = get_playlist_by_id(db, playlist_id) + if not playlist: + return False + + if playlist.owner_id == user_id: + return True + + collaborator = db.query(PlaylistCollaborator).filter( + and_( + PlaylistCollaborator.playlist_id == playlist_id, + PlaylistCollaborator.collaborator_id == user_id + ) + ).first() + + return collaborator is not None + + +def get_playlist_stats(db: Session, playlist_id: int) -> PlaylistStats: + """ + Get basic statistics for a playlist + """ + playlist = get_playlist_by_id(db, playlist_id) + if not playlist: + raise ValueError("Playlist not found") + + return PlaylistStats( + total_playlists=1, + total_owned_playlists=1, + total_collaborated_playlists=0, + created_at=playlist.created_at, + last_modified=playlist.created_at + ) + + +def get_user_playlist_stats(db: Session, user_id: int) -> PlaylistStats: + """ + Get playlist statistics for a user + """ + total_owned = db.query(Playlist).filter(Playlist.owner_id == user_id).count() + + total_collaborated = db.query(PlaylistCollaborator).filter( + PlaylistCollaborator.collaborator_id == user_id + ).count() + + return PlaylistStats( + total_playlists=total_owned + total_collaborated, + total_owned_playlists=total_owned, + total_collaborated_playlists=total_collaborated, + created_at=db.query(func.min(Playlist.created_at)).filter(Playlist.owner_id == user_id).scalar(), + last_modified=db.query(func.max(Playlist.created_at)).filter(Playlist.owner_id == user_id).scalar() + ) +# helpers +def generate_share_token() -> str: + """ + Generate a secure share token + """ + return secrets.token_urlsafe(32) + + +def generate_collaboration_link(db: Session, playlist_id: int) -> str: + """ + Generate a collaboration link for a playlist + """ + playlist = get_playlist_by_id(db, playlist_id) + if not playlist: + raise ValueError("Playlist not found") + + collaboration_token = generate_share_token() + + playlist.share_token = collaboration_token + playlist.allow_collaboration = True + db.commit() + + return f"http://localhost:8000/playlist/collaborate/{collaboration_token}" + + +def access_playlist_by_token(db: Session, token: str) -> Optional[Playlist]: + """ + Access a playlist using a share token + """ + return db.query(Playlist).filter(Playlist.share_token == token).first() diff --git a/backend/app/crud/playlist_collaborator.py b/backend/app/crud/playlist_collaborator.py index 0bbe367..4dc064f 100644 --- a/backend/app/crud/playlist_collaborator.py +++ b/backend/app/crud/playlist_collaborator.py @@ -1,23 +1,120 @@ -# TODO: PLAYLIST COLLABORATOR CRUD IMPLEMENTATION +from typing import List, Optional, Tuple +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import func, desc, and_ +from app.db.models.playlist_collaborator import PlaylistCollaborator +from app.db.models.playlist import Playlist +from app.db.models.user import User +from app.schemas.playlist_collaborator import PlaylistCollaboratorCreate, PlaylistCollaboratorUpdate, PlaylistCollaboratorStats -# CREATE -# [ ] add_collaborator_to_playlist(playlist_id: int, collaborator_id: int, added_by_user_id: int,can_edit: bool = False) -> PlaylistCollaborator -# - Check if already exists → raise 409 Conflict -# - Check that added_by_user_id is owner of playlist -# - Save with added_at timestamp -# READ -# [ ] get_collaborator_entry(playlist_id: int, user_id: int) -> Optional[PlaylistCollaborator] -# [ ] get_playlist_collaborators(playlist_id: int) -> List[PlaylistCollaborator] +def add_collaborator_to_playlist( + db: Session, + playlist_id: int, + collaborator_id: int, + added_by_user_id: int, + can_edit: bool = False +) -> PlaylistCollaborator: + """ + Add a collaborator to a playlist + """ + existing_collaborator = get_collaborator_entry(db, playlist_id, collaborator_id) + if existing_collaborator: + raise ValueError("User is already a collaborator on this playlist") + + playlist = db.query(Playlist).filter(Playlist.id == playlist_id).first() + if not playlist or playlist.owner_id != added_by_user_id: + raise ValueError("Only playlist owner can add collaborators") + + if collaborator_id == playlist.owner_id: + raise ValueError("Cannot add playlist owner as collaborator") + + playlist_collaborator = PlaylistCollaborator( + playlist_id=playlist_id, + collaborator_id=collaborator_id, + can_edit=can_edit, + added_by_user_id=added_by_user_id + ) + + db.add(playlist_collaborator) + db.commit() + db.refresh(playlist_collaborator) + return playlist_collaborator -# UPDATE -# [ ] update_collaborator_permissions(playlist_id: int, collaborator_id: int, can_edit: bool) -> PlaylistCollaborator -# - Only allowed if current user is playlist owner -# DELETE -# [ ] remove_collaborator_from_playlist(playlist_id: int,collaborator_id: int) -> bool -# - only owner can remove others +def get_collaborator_entry( + db: Session, + playlist_id: int, + user_id: int +) -> Optional[PlaylistCollaborator]: + """ + Get a specific collaborator entry + """ + return db.query(PlaylistCollaborator).filter( + and_( + PlaylistCollaborator.playlist_id == playlist_id, + PlaylistCollaborator.collaborator_id == user_id + ) + ).first() -# PERMISSION -# [ ] is_user_playlist_editor(user_id: int, playlist_id: int) -> bool -# [ ] is_user_playlist_owner_or_editor(user_id: int, playlist_id: int) -> bool + +def get_playlist_collaborators( + db: Session, + playlist_id: int, + skip: int = 0, + limit: int = 50 +) -> Tuple[List[PlaylistCollaborator], int]: + """ + Get all collaborators for a playlist + """ + query = db.query(PlaylistCollaborator).options( + joinedload(PlaylistCollaborator.collaborator), + joinedload(PlaylistCollaborator.added_by) + ).filter(PlaylistCollaborator.playlist_id == playlist_id) + + total = query.count() + collaborators = query.order_by(desc(PlaylistCollaborator.added_at)).offset(skip).limit(limit).all() + + return collaborators, total + + +def remove_collaborator_from_playlist( + db: Session, + playlist_id: int, + collaborator_id: int +) -> bool: + """ + Remove a collaborator from a playlist + """ + playlist = db.query(Playlist).filter(Playlist.id == playlist_id).first() + if not playlist: + return False + + collaborator = get_collaborator_entry(db, playlist_id, collaborator_id) + if not collaborator: + return False + + db.delete(collaborator) + db.commit() + return True + + +def get_playlist_collaborator_stats(db: Session, playlist_id: int) -> PlaylistCollaboratorStats: + """ + Get statistics for playlist collaborators + """ + + collaborator_stats = db.query( + func.count(PlaylistCollaborator.id).label('total_collaborators'), + func.sum(PlaylistCollaborator.can_edit.cast(func.Integer)).label('can_edit_count') + ).filter(PlaylistCollaborator.playlist_id == playlist_id).first() + + most_collaborative = db.query( + User, func.count(PlaylistCollaborator.id).label('collab_count') + ).join(PlaylistCollaborator).group_by(User.id).order_by(desc('collab_count')).first() + + return PlaylistCollaboratorStats( + total_collaborators=collaborator_stats.total_collaborators or 0, + can_edit_collaborators=collaborator_stats.can_edit_count or 0, + read_only_collaborators=(collaborator_stats.total_collaborators or 0) - (collaborator_stats.can_edit_count or 0), + most_collaborative_user=most_collaborative[0] if most_collaborative else None + ) diff --git a/backend/app/crud/playlist_song.py b/backend/app/crud/playlist_song.py index bd2ad7d..12d43d5 100644 --- a/backend/app/crud/playlist_song.py +++ b/backend/app/crud/playlist_song.py @@ -1,25 +1,198 @@ -# TODO: PLAYLIST SONG CRUD IMPLEMENTATION +from typing import List, Optional, Tuple +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import func, desc, and_ +from app.db.models.playlist_song import PlaylistSong +from app.db.models.playlist import Playlist +from app.db.models.song import Song +from app.db.models.artist import Artist +from app.db.models.genre import Genre +from app.schemas.playlist_song import PlaylistSongCreate, PlaylistSongUpdate, PlaylistSongStats -# ADD (CREATE) -# [ ] add_song_to_playlist(playlist_id: int, song_id: int, order: Optional[int], added_by_user_id: int) -> PlaylistSong -# - Prevent duplicates: playlist_id + song_id should be unique -# - Auto-calculate order if not provided (append to end) -# READ -# [ ] get_songs_in_playlist(playlist_id: int, include_disabled: bool = False) -> List[Song] -# - JOIN with `Song` and return ordered list (sorted by `song_order`) +def add_song_to_playlist( + db: Session, + playlist_id: int, + song_id: int, + song_order: Optional[int] = None +) -> PlaylistSong: + """ + Add a song to a playlist with optional order + """ + # Check if song already exists in playlist + existing_song = db.query(PlaylistSong).filter( + and_( + PlaylistSong.playlist_id == playlist_id, + PlaylistSong.song_id == song_id + ) + ).first() + + if existing_song: + raise ValueError("Song is already in this playlist") + + # Auto-calculate order if not provided + if song_order is None: + max_order = db.query(func.max(PlaylistSong.song_order)).filter( + PlaylistSong.playlist_id == playlist_id + ).scalar() + song_order = (max_order or 0) + 1 + + playlist_song = PlaylistSong( + playlist_id=playlist_id, + song_id=song_id, + song_order=song_order + ) + + db.add(playlist_song) + db.commit() + db.refresh(playlist_song) + return playlist_song -# [ ] get_playlist_song_entry(playlist_id: int, song_id: int) -> Optional[PlaylistSong] -# - To check if a song is already added +def get_songs_in_playlist( + db: Session, + playlist_id: int, + skip: int = 0, + limit: int = 50 +) -> Tuple[List[PlaylistSong], int]: + """ + Get songs in a playlist, ordered by song_order + """ + query = db.query(PlaylistSong).options( + joinedload(PlaylistSong.song).joinedload(Song.artist), + joinedload(PlaylistSong.song).joinedload(Song.band), + joinedload(PlaylistSong.song).joinedload(Song.genre) + ).filter(PlaylistSong.playlist_id == playlist_id) + + total = query.count() + songs = query.order_by(PlaylistSong.song_order).offset(skip).limit(limit).all() + + return songs, total -# DELETE (REMOVE SONG) -# [ ] remove_song_from_playlist(playlist_id: int, song_id: int) -> bool -# DELETE ALL (on playlist delete, cascades automatically, no manual cleanup needed) +def get_playlist_song_entry( + db: Session, + playlist_id: int, + song_id: int +) -> Optional[PlaylistSong]: + """ + Get a specific playlist-song entry + """ + return db.query(PlaylistSong).filter( + and_( + PlaylistSong.playlist_id == playlist_id, + PlaylistSong.song_id == song_id + ) + ).first() -# [ ] reorder_playlist(playlist_id: int, song_ids_in_order: List[int]) -> None -# - Bulk update song_order to match given order -# -# [ ] clear_playlist(playlist_id: int) -> int -# - Delete all PlaylistSong entries for a playlist + +def remove_song_from_playlist(db: Session, playlist_id: int, song_id: int) -> bool: + """ + Remove a song from a playlist + """ + playlist_song = get_playlist_song_entry(db, playlist_id, song_id) + if not playlist_song: + return False + + db.delete(playlist_song) + db.commit() + return True + + +def reorder_playlist_song( + db: Session, + playlist_id: int, + song_id: int, + new_order: int +) -> Optional[PlaylistSong]: + """ + Reorder a song within a playlist + """ + playlist_song = get_playlist_song_entry(db, playlist_id, song_id) + if not playlist_song: + return None + + playlist_song.song_order = new_order + db.commit() + db.refresh(playlist_song) + return playlist_song + + +def reorder_playlist_bulk( + db: Session, + playlist_id: int, + song_orders: List[dict] +) -> bool: + """ + Bulk reorder songs in a playlist + song_orders format: [{"song_id": 1, "new_order": 3}, ...] + """ + try: + for order_item in song_orders: + song_id = order_item.get("song_id") + new_order = order_item.get("new_order") + + if song_id is not None and new_order is not None: + reorder_playlist_song(db, playlist_id, song_id, new_order) + + return True + except Exception: + db.rollback() + return False + + +def clear_playlist(db: Session, playlist_id: int) -> int: + """ + Remove all songs from a playlist + """ + result = db.query(PlaylistSong).filter( + PlaylistSong.playlist_id == playlist_id + ).delete() + + db.commit() + return result + + +def get_playlist_song_stats(db: Session, playlist_id: int) -> PlaylistSongStats: + """ + Get comprehensive statistics for songs in a playlist + """ + # Basic song statistics + song_stats = db.query( + func.count(PlaylistSong.id).label('total_songs'), + func.sum(Song.song_duration).label('total_duration'), + func.avg(Song.song_duration).label('average_duration') + ).join(Song).filter(PlaylistSong.playlist_id == playlist_id).first() + + # Shortest song + shortest_song = db.query(Song).join(PlaylistSong).filter( + PlaylistSong.playlist_id == playlist_id + ).order_by(Song.song_duration).first() + + # Longest song + longest_song = db.query(Song).join(PlaylistSong).filter( + PlaylistSong.playlist_id == playlist_id + ).order_by(desc(Song.song_duration)).first() + + # Most common artist + most_common_artist = db.query( + Artist.artist_stage_name, func.count(PlaylistSong.id).label('song_count') + ).select_from(PlaylistSong).join(Song).join(Artist).filter( + PlaylistSong.playlist_id == playlist_id + ).group_by(Artist.artist_stage_name).order_by(desc('song_count')).first() + + # Most common genre + most_common_genre = db.query( + Genre.name, func.count(PlaylistSong.id).label('song_count') + ).select_from(PlaylistSong).join(Song).join(Genre).filter( + PlaylistSong.playlist_id == playlist_id + ).group_by(Genre.name).order_by(desc('song_count')).first() + + return PlaylistSongStats( + total_songs=song_stats.total_songs or 0, + total_duration=song_stats.total_duration or 0, + average_song_duration=song_stats.average_duration or 0.0, + shortest_song=shortest_song, + longest_song=longest_song, + most_common_artist=most_common_artist[0] if most_common_artist else None, + most_common_genre=most_common_genre[0] if most_common_genre else None + ) diff --git a/backend/app/crud/song.py b/backend/app/crud/song.py index 4a0f0dd..e2c6baa 100644 --- a/backend/app/crud/song.py +++ b/backend/app/crud/song.py @@ -1,37 +1,285 @@ -# TODO: SONG CRUD IMPLEMENTATION - -# CREATE -# [ ] create_song(song_data: SongCreate, uploaded_by_user_id: int) -> Song -# - Validate genre, artist/band IDs/User ID (admin) exist -# - Check user permissions (e.g., only verified users can upload?) -# - Enforce required logic: -# - Either artist_id or band_id or admins user ID should be set -# - artist_name and band_name should be fetched from related tables. if admin is uploading either artist_name or band_name should be set -# - Auto-fill `uploaded_at` as current UTC time - - -# READ -# [ ] get_song_by_id(song_id: int) -> Optional[Song] -# - Include relationships (genre, artist, band) -# -# [ ] get_all_songs_paginated(skip: int = 0, limit: int = 20) -> List[Song] -# -# [ ] search_songs_by_title(title: str, skip: int = 0, limit: int = 20) -> List[Song] -# - `ilike` for case-insensitive partial search -# -# [ ] get_songs_by_artist(artist_id: int, skip: int = 0, limit: int = 20) -> List[Song] -# [ ] get_songs_by_band(band_id: int, skip: int = 0, limit: int = 20) -> List[Song] -# [ ] get_songs_by_genre(genre_id: int, skip: int = 0, limit: int = 20) -> List[Song] - - -# UPDATE -# song update not allowed, only admin can update song file_path -# [ ] update_song_file_path(song_id: int, new_file_path: str, by_user_id: int) -> Song -# - Only admin can update file_path -# - Check if song exists -# - Update `file_path` and `updated_at` timestamp - - -# HARD DELETE -# [ ] delete_song_permanently(song_id: int) -> bool +from typing import List, Optional, Dict, Any, Tuple +from sqlalchemy.orm import Session +from sqlalchemy import func +from datetime import datetime, timezone +from app.db.models.song import Song +from app.db.models.artist import Artist +from app.db.models.band import Band +from app.db.models.genre import Genre +from app.db.models.user import User +from app.schemas.song import SongUploadByArtist, SongUploadByBand, SongUploadByAdmin, SongUpdate +import difflib + + +def create_song_by_artist(db: Session, song_data: SongUploadByArtist, uploaded_by_user_id: int) -> Song: + """Create a song uploaded by an artist""" + # Auto-fill artist_name from artist_id + artist = db.query(Artist).filter(Artist.id == song_data.artist_id).first() + if not artist: + raise ValueError("Artist not found") + + db_song = Song( + title=song_data.title, + genre_id=song_data.genre_id, + artist_id=song_data.artist_id, + band_id=None, + release_date=song_data.release_date, + song_duration=song_data.song_duration, + file_path=song_data.file_path, + cover_image=song_data.cover_image, + artist_name=artist.artist_stage_name, + band_name=None, + uploaded_by_user_id=uploaded_by_user_id + ) + + db.add(db_song) + db.commit() + db.refresh(db_song) + return db_song + + +def create_song_by_band(db: Session, song_data: SongUploadByBand, uploaded_by_user_id: int) -> Song: + """Create a song uploaded by a band member""" + # Auto-fill band_name from band_id + band = db.query(Band).filter(Band.id == song_data.band_id).first() + if not band: + raise ValueError("Band not found") + + db_song = Song( + title=song_data.title, + genre_id=song_data.genre_id, + artist_id=None, + band_id=song_data.band_id, + release_date=song_data.release_date, + song_duration=song_data.song_duration, + file_path=song_data.file_path, + cover_image=song_data.cover_image, + artist_name=None, + band_name=band.name, + uploaded_by_user_id=uploaded_by_user_id + ) + + db.add(db_song) + db.commit() + db.refresh(db_song) + return db_song + + +def create_song_by_admin(db: Session, song_data: SongUploadByAdmin, uploaded_by_user_id: int) -> Song: + """Create a song uploaded by admin (for any artist/band including dead artists)""" + db_song = Song( + title=song_data.title, + genre_id=song_data.genre_id, + artist_id=song_data.artist_id, + band_id=song_data.band_id, + release_date=song_data.release_date, + song_duration=song_data.song_duration, + file_path=song_data.file_path, + cover_image=song_data.cover_image, + artist_name=song_data.artist_name, + band_name=song_data.band_name, + uploaded_by_user_id=uploaded_by_user_id + ) + + if song_data.artist_id: + artist = db.query(Artist).filter(Artist.id == song_data.artist_id).first() + if artist: + db_song.artist_name = artist.artist_stage_name + + if song_data.band_id: + band = db.query(Band).filter(Band.id == song_data.band_id).first() + if band: + db_song.band_name = band.name + + db.add(db_song) + db.commit() + db.refresh(db_song) + return db_song + + +def get_song_by_id(db: Session, song_id: int) -> Optional[Song]: + """Get a song by its ID""" + return db.query(Song).filter(Song.id == song_id).first() + + +def get_all_songs_paginated(db: Session, skip: int = 0, limit: int = 20) -> List[Song]: + """Get all songs with pagination""" + return db.query(Song).filter(Song.is_disabled == False).offset(skip).limit(limit).all() + + +def search_songs(db: Session, query: str, skip: int = 0, limit: int = 20) -> List[Song]: + """Search songs by title, artist name, or band name""" + return db.query(Song).filter( + Song.is_disabled == False, + ( + Song.title.ilike(f"%{query}%") | + Song.artist_name.ilike(f"%{query}%") | + Song.band_name.ilike(f"%{query}%") + ) + ).offset(skip).limit(limit).all() + + +def search_songs_fuzzy( + db: Session, + query: str, + skip: int = 0, + limit: int = 20, + min_ratio: float = 0.6, +) -> List[Song]: + """Fuzzy search songs by comparing query with title/artist_name/bandname + """ + active_songs: List[Song] = db.query(Song).filter(Song.is_disabled == False).all() + + scored: List[Tuple[float, Song]] = [] + q = query.lower() + for song in active_songs: + candidates = [ + (song.title or ""), + (song.artist_name or ""), + (song.band_name or ""), + ] + best = 0.0 + for text in candidates: + if not text: + continue + r = difflib.SequenceMatcher(None, q, text.lower()).ratio() + if r > best: + best = r + if best >= min_ratio: + scored.append((best, song)) + + scored.sort(key=lambda x: x[0], reverse=True) + sliced = scored[skip: skip + limit] if limit is not None else scored[skip:] + return [s for _, s in sliced] + + +def get_songs_by_artist(db: Session, artist_id: int, skip: int = 0, limit: int = 20) -> List[Song]: + """Get songs by artist ID""" + return db.query(Song).filter( + Song.artist_id == artist_id, + Song.is_disabled == False + ).offset(skip).limit(limit).all() + + +def get_songs_by_band(db: Session, band_id: int, skip: int = 0, limit: int = 20) -> List[Song]: + """Get songs by band ID""" + return db.query(Song).filter( + Song.band_id == band_id, + Song.is_disabled == False + ).offset(skip).limit(limit).all() + + +def get_songs_by_genre(db: Session, genre_id: int, skip: int = 0, limit: int = 20) -> List[Song]: + """Get songs by genre ID""" + return db.query(Song).filter( + Song.genre_id == genre_id, + Song.is_disabled == False + ).offset(skip).limit(limit).all() + + +def update_song_file_path(db: Session, song_id: int, new_file_path: str) -> Optional[Song]: + """Update song file path (admin only)""" + db_song = get_song_by_id(db, song_id) + if not db_song: + return None + + db_song.file_path = new_file_path + db.commit() + db.refresh(db_song) + return db_song + + +def update_song_metadata(db: Session, song_id: int, song_data: SongUpdate) -> Optional[Song]: + """Update song metadata (admin only)""" + db_song = get_song_by_id(db, song_id) + if not db_song: + return None + + update_data = song_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_song, field, value) + + db.commit() + db.refresh(db_song) + return db_song + + +def disable_song(db: Session, song_id: int) -> bool: + """Disable a song (soft delete)""" + db_song = get_song_by_id(db, song_id) + if not db_song: + return False + + db_song.is_disabled = True + db_song.disabled_at = datetime.now(timezone.utc) + db.commit() + return True + + +def enable_song(db: Session, song_id: int) -> bool: + """Enable a song (re-enable)""" + db_song = get_song_by_id(db, song_id) + if not db_song: + return False + + db_song.is_disabled = False + db_song.disabled_at = None + db.commit() + return True + + +def song_exists(db: Session, song_id: int) -> bool: + """Check if a song exists by ID""" + return db.query(Song).filter(Song.id == song_id).first() is not None + + +def can_user_upload_for_band(db: Session, user_id: int, band_id: int) -> bool: + """Check if user can upload songs for a band (must be band member)""" + from app.crud.artist_band_member import is_current_member + return is_current_member(db, user_id, band_id) + + +def get_song_statistics(db: Session) -> Dict[str, Any]: + """Get comprehensive statistics about songs""" + total_songs = db.query(Song).count() + active_songs = db.query(Song).filter(Song.is_disabled == False).count() + disabled_songs = total_songs - active_songs + + songs_by_artist = db.query(Song).filter( + Song.artist_id.isnot(None), + Song.is_disabled == False + ).count() + + songs_by_band = db.query(Song).filter( + Song.band_id.isnot(None), + Song.is_disabled == False + ).count() + + # Find most uploaded artist + most_uploaded_artist = db.query( + Song.artist_name, + func.count(Song.id).label('song_count') + ).filter( + Song.artist_id.isnot(None), + Song.is_disabled == False + ).group_by(Song.artist_name).order_by(func.count(Song.id).desc()).first() + + # Find most uploaded band + most_uploaded_band = db.query( + Song.band_name, + func.count(Song.id).label('song_count') + ).filter( + Song.band_id.isnot(None), + Song.is_disabled == False + ).group_by(Song.band_name).order_by(func.count(Song.id).desc()).first() + + return { + "total_songs": total_songs, + "active_songs": active_songs, + "disabled_songs": disabled_songs, + "songs_by_artist": songs_by_artist, + "songs_by_band": songs_by_band, + "most_uploaded_artist": most_uploaded_artist.artist_name if most_uploaded_artist else None, + "most_uploaded_band": most_uploaded_band.band_name if most_uploaded_band else None + } diff --git a/backend/app/db/base.py b/backend/app/db/base.py index cd75cf8..e45f802 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -18,7 +18,7 @@ from app.db.models.playlist import Playlist #15 from app.db.models.song import Song #16 from app.db.models.subscription_plan import SubscriptionPlan #17 -from app.db.models.system_config import SystemConfig #18 +from app.db.models.user import User #18 from app.db.models.user_subscription import UserSubscription #19 -from app.db.models.user import User #20 +from app.db.models.system_config import SystemConfig #20 from app.db.models.refresh_token import RefreshToken #21 diff --git a/backend/app/db/models/history.py b/backend/app/db/models/history.py index a14b250..fbb2342 100644 --- a/backend/app/db/models/history.py +++ b/backend/app/db/models/history.py @@ -1,5 +1,5 @@ from datetime import datetime, timezone -from sqlalchemy import Column, Integer, ForeignKey, DateTime, Index +from sqlalchemy import Column, Integer, ForeignKey, DateTime, Boolean, Index from sqlalchemy.orm import relationship from app.db.base_class import Base @@ -10,6 +10,7 @@ class History(Base): user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) song_id = Column(Integer, ForeignKey("songs.id", ondelete="CASCADE"), nullable=False) played_at = Column(DateTime, default=datetime.now(timezone.utc), nullable=False) + is_cleared = Column(Boolean, default=False, nullable=False) # Relationships user = relationship("User", back_populates="history", lazy="select") @@ -17,8 +18,9 @@ class History(Base): __table_args__ = ( Index("idx_history_user_song", "user_id", "song_id"), + Index("idx_history_cleared", "is_cleared"), ) def __repr__(self): - return f"" + return f"" diff --git a/backend/app/db/models/playlist.py b/backend/app/db/models/playlist.py index 35d5d6e..3f08f0c 100644 --- a/backend/app/db/models/playlist.py +++ b/backend/app/db/models/playlist.py @@ -1,5 +1,5 @@ from datetime import datetime, timezone -from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Index, UniqueConstraint +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Index, UniqueConstraint, Boolean from sqlalchemy.orm import relationship from app.db.base_class import Base @@ -16,6 +16,11 @@ class Playlist(Base): name = Column(String(100), nullable=False) description = Column(String(255), nullable=True) created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + + # Sharing and Collaboration + share_token = Column(String(64), unique=True, nullable=True) + allow_collaboration = Column(Boolean, default=False, nullable=False) + # Relationships owner = relationship("User", back_populates="playlists", lazy="select") diff --git a/backend/app/main.py b/backend/app/main.py index f62c76a..4049f0c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -35,51 +35,37 @@ from app.api.v1.artist import router as artist_router from app.api.v1.band import router as band_router from app.api.v1.artist_band_member import router as artist_band_member_router +from app.api.v1.genre import router as genre_router +from app.api.v1.song import router as song_router +from app.api.v1.like import router as like_router +from app.api.v1.following import router as following_router +from app.api.v1.history import router as history_router +from app.api.v1.playlist import router as playlist_router +from app.api.v1.playlist_song import router as playlist_song_router +from app.api.v1.playlist_collaborator import router as playlist_collaborator_router +from app.api.v1.upload import router as upload_router +from app.api.v1.stream import router as stream_router +from app.api.v1.album import router as album_router +from app.api.v1.album_song import router as album_song_router # Include routers with proper prefixes and tags -app.include_router( - auth_router, prefix="/auth", tags=["authentication"], - responses={401: {"description": "Unauthorized"}} -) - -app.include_router( - user_router, tags=["users"], prefix="/user", - responses={ - 401: {"description": "Unauthorized"}, - 403: {"description": "Forbidden - Insufficient permissions"}, - 404: {"description": "User not found"} - } -) - -app.include_router( - artist_router, tags=["artists"], prefix="/artist", - responses={ - 401: {"description": "Unauthorized"}, - 403: {"description": "Forbidden - Insufficient permissions"}, - 404: {"description": "Artist not found"}, - 409: {"description": "Conflict - Resource already exists"} - } -) - -app.include_router( - band_router, tags=["bands"], prefix="/band", - responses={ - 401: {"description": "Unauthorized"}, - 403: {"description": "Forbidden - Insufficient permissions"}, - 404: {"description": "Band not found"}, - 409: {"description": "Conflict - Resource already exists"} - } -) - -app.include_router( - artist_band_member_router, tags=["artist-band-members"], prefix="/artist-band-member", - responses={ - 401: {"description": "Unauthorized"}, - 403: {"description": "Forbidden - Insufficient permissions"}, - 404: {"description": "Membership not found"}, - 409: {"description": "Conflict - Resource already exists"} - } -) +app.include_router(auth_router, prefix="/auth", tags=["authentication"]) +app.include_router(user_router, tags=["users"], prefix="/user") +app.include_router(artist_router, tags=["artists"], prefix="/artist") +app.include_router(band_router, tags=["bands"], prefix="/band") +app.include_router(artist_band_member_router, tags=["artist-band-members"], prefix="/artist-band-member") +app.include_router(genre_router, tags=["genres"], prefix="/genre") +app.include_router(song_router, tags=["songs"], prefix="/song") +app.include_router(like_router, tags=["likes"], prefix="/like") +app.include_router(following_router, tags=["following"], prefix="/following") +app.include_router(history_router, tags=["history"], prefix="/history") +app.include_router(playlist_router, tags=["playlists"], prefix="/playlist") +app.include_router(playlist_song_router, tags=["playlist-songs"], prefix="/playlist") +app.include_router(playlist_collaborator_router, tags=["playlist-collaborators"], prefix="/playlist") +app.include_router(upload_router, tags=["uploads"], prefix="/upload") +app.include_router(stream_router, tags=["streaming"], prefix="/stream") +app.include_router(album_router, tags=["albums"], prefix="/album") +app.include_router(album_song_router, tags=["album-songs"], prefix="/album") # CORS configuration app.add_middleware( @@ -119,6 +105,15 @@ async def root(): "artists": "/artist", "bands": "/band", "artist-band-members": "/artist-band-member", + "genres": "/genre", + "songs": "/song", + "likes": "/like", + "following": "/following", + "history": "/history", + "playlists": "/playlist", + "albums": "/album", + "uploads": "/upload", + "streaming": "/stream", "health": "/health" } } diff --git a/backend/app/schemas/album.py b/backend/app/schemas/album.py index ac64199..3ffbd51 100644 --- a/backend/app/schemas/album.py +++ b/backend/app/schemas/album.py @@ -1,10 +1,14 @@ +""" +Clean Album schemas without redundancy. +""" + from typing import Optional, List, Annotated from datetime import datetime from pydantic import BaseModel, StringConstraints, Field, model_validator -# Base schema for album class AlbumBase(BaseModel): + """Base schema for album data with common fields""" title: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=255)] description: Optional[str] = None cover_image: Optional[Annotated[str, StringConstraints(max_length=255)]] = None @@ -26,24 +30,23 @@ class Config: class AlbumCreate(AlbumBase): - uploaded_by_user_id: int + """Schema for creating a new album""" + pass class AlbumUpdate(BaseModel): + """Schema for updating album metadata""" title: Optional[Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=255)]] = None description: Optional[str] = None cover_image: Optional[Annotated[str, StringConstraints(max_length=255)]] = None release_date: Optional[datetime] = None - album_artist_id: Optional[int] = None - album_band_id: Optional[int] = None - artist_name: Optional[Annotated[str, StringConstraints(max_length=100)]] = None - band_name: Optional[Annotated[str, StringConstraints(max_length=100)]] = None class Config: from_attributes = True class AlbumOut(AlbumBase): + """Schema for album output with all fields""" id: int uploaded_by_user_id: int @@ -51,8 +54,9 @@ class Config: from_attributes = True -# Schemas for relationships +# Minimal schemas for relationships (reused across modules) class ArtistMinimal(BaseModel): + """Minimal artist schema for relationships""" id: int artist_stage_name: str artist_profile_image: Optional[str] = None @@ -62,6 +66,7 @@ class Config: class BandMinimal(BaseModel): + """Minimal band schema for relationships""" id: int name: str profile_picture: Optional[str] = None @@ -71,6 +76,7 @@ class Config: class UserMinimal(BaseModel): + """Minimal user schema for relationships""" id: int username: str first_name: str @@ -81,40 +87,34 @@ class Config: class SongMinimal(BaseModel): + """Minimal song schema for relationships""" id: int title: str song_duration: int cover_image: Optional[str] = None + artist_name: Optional[str] = None + band_name: Optional[str] = None class Config: from_attributes = True -# Album song relationship schema -class AlbumSongTrack(BaseModel): - track_number: int - song: SongMinimal - - class Config: - from_attributes = True - - -# album output with relationships class AlbumWithRelations(AlbumOut): + """Schema for album output with relationships""" artist: Optional[ArtistMinimal] = None band: Optional[BandMinimal] = None uploaded_by: UserMinimal - album_songs: List[AlbumSongTrack] = [] -# Album with songs list class AlbumWithSongs(AlbumWithRelations): + """Schema for album with songs list""" total_songs: int total_duration: int # in seconds # List schemas for pagination class AlbumList(BaseModel): + """Schema for paginated album list""" albums: List[AlbumOut] total: int page: int @@ -123,6 +123,7 @@ class AlbumList(BaseModel): class AlbumListWithRelations(BaseModel): + """Schema for paginated album list with relationships""" albums: List[AlbumWithRelations] total: int page: int @@ -132,6 +133,7 @@ class AlbumListWithRelations(BaseModel): # Search and filter schemas class AlbumFilter(BaseModel): + """Schema for album filtering""" title: Optional[str] = None album_artist_id: Optional[int] = None album_band_id: Optional[int] = None @@ -141,6 +143,7 @@ class AlbumFilter(BaseModel): class AlbumSearch(BaseModel): + """Schema for album search""" query: str album_artist_id: Optional[int] = None album_band_id: Optional[int] = None @@ -148,15 +151,10 @@ class AlbumSearch(BaseModel): offset: int = Field(default=0, ge=0) -# Album song management schemas -class AlbumSongAdd(BaseModel): - song_id: int - track_number: Annotated[int, Field(gt=0)] - - -class AlbumSongUpdate(BaseModel): - track_number: Annotated[int, Field(gt=0)] - - -class AlbumSongRemove(BaseModel): - song_id: int +class AlbumStats(BaseModel): + """Schema for album statistics""" + total_albums: int + albums_by_artist: int + albums_by_band: int + most_uploaded_artist: Optional[str] = None + most_uploaded_band: Optional[str] = None diff --git a/backend/app/schemas/album_song.py b/backend/app/schemas/album_song.py index 9441923..a009a35 100644 --- a/backend/app/schemas/album_song.py +++ b/backend/app/schemas/album_song.py @@ -1,10 +1,14 @@ +""" +Clean AlbumSong schemas without redundancy. +""" + from typing import Optional, List, Annotated from datetime import datetime from pydantic import BaseModel, Field, model_validator -# Base schema for album-song relationship class AlbumSongBase(BaseModel): + """Base schema for album-song relationship data""" album_id: int song_id: int track_number: Annotated[int, Field(gt=0)] @@ -14,10 +18,12 @@ class Config: class AlbumSongCreate(AlbumSongBase): + """Schema for creating album-song relationship""" pass class AlbumSongUpdate(BaseModel): + """Schema for updating album-song relationship""" track_number: Annotated[int, Field(gt=0)] class Config: @@ -25,14 +31,16 @@ class Config: class AlbumSongOut(AlbumSongBase): + """Schema for album-song output with all fields""" id: int class Config: from_attributes = True -# Nested schemas for relationships +# Minimal schemas for relationships (reused from album.py) class AlbumMinimal(BaseModel): + """Minimal album schema for relationships""" id: int title: str cover_image: Optional[str] = None @@ -43,6 +51,7 @@ class Config: class SongMinimal(BaseModel): + """Minimal song schema for relationships""" id: int title: str song_duration: int @@ -54,22 +63,15 @@ class Config: from_attributes = True -# album-song output with relationships class AlbumSongWithRelations(AlbumSongOut): + """Schema for album-song output with relationships""" album: AlbumMinimal song: SongMinimal -# Album track list -class AlbumTrackList(BaseModel): - album_id: int - tracks: List[AlbumSongWithRelations] - total_tracks: int - total_duration: int # in seconds - - # List schemas for pagination class AlbumSongList(BaseModel): + """Schema for paginated album-song list""" album_songs: List[AlbumSongOut] total: int page: int @@ -78,6 +80,7 @@ class AlbumSongList(BaseModel): class AlbumSongListWithRelations(BaseModel): + """Schema for paginated album-song list with relationships""" album_songs: List[AlbumSongWithRelations] total: int page: int @@ -85,59 +88,28 @@ class AlbumSongListWithRelations(BaseModel): total_pages: int -# Search and filter schemas -class AlbumSongFilter(BaseModel): - album_id: Optional[int] = None - song_id: Optional[int] = None - track_number: Optional[int] = None - - # Album song management schemas class AlbumSongAdd(BaseModel): - album_id: int + """Schema for adding song to album""" song_id: int track_number: Annotated[int, Field(gt=0)] -class AlbumSongRemove(BaseModel): - album_id: int - song_id: int - - -class AlbumSongReorder(BaseModel): - album_id: int - song_id: int - new_track_number: Annotated[int, Field(gt=0)] - - class AlbumSongBulkAdd(BaseModel): - album_id: int + """Schema for bulk adding songs to album""" songs: List[AlbumSongAdd] -class AlbumSongBulkRemove(BaseModel): - album_id: int - song_ids: List[int] +class AlbumSongBulkReorder(BaseModel): + """Schema for bulk reordering album songs""" + tracks: List[AlbumSongAdd] # List of song_id and new track_number -# Album song statistics class AlbumSongStats(BaseModel): + """Schema for album song statistics""" album_id: int total_tracks: int total_duration: int # in seconds average_track_duration: float shortest_track: Optional[SongMinimal] = None - longest_track: Optional[SongMinimal] = None - - -# track validation schema -class TrackNumberValidation(BaseModel): - album_id: int - track_number: int - - @model_validator(mode="after") - def validate_track_number(self) -> "TrackNumberValidation": - """Ensure track number is positive""" - if self.track_number <= 0: - raise ValueError("Track number must be positive") - return self + longest_track: Optional[SongMinimal] = None diff --git a/backend/app/schemas/artist.py b/backend/app/schemas/artist.py index bf927d1..a0b1aa8 100644 --- a/backend/app/schemas/artist.py +++ b/backend/app/schemas/artist.py @@ -16,8 +16,10 @@ class ArtistBase(BaseModel): class Config: from_attributes = True -class ArtistCreate(ArtistBase): - pass +class ArtistCreate(BaseModel): + artist_stage_name: ShortStr + artist_bio: Optional[LongStr] = None + artist_social_link: Optional[Dict[str, Any]] = None class ArtistUpdate(BaseModel): artist_stage_name: Optional[ShortStr] = None @@ -47,7 +49,6 @@ class ArtistSignup(BaseModel): artist_stage_name: ShortStr artist_bio: Optional[LongStr] = None - artist_profile_image: Optional[UrlStr] = None artist_social_link: Optional[Dict[str, Any]] = None @field_validator("artist_stage_name") diff --git a/backend/app/schemas/following.py b/backend/app/schemas/following.py index 9a42e49..f8febe1 100644 --- a/backend/app/schemas/following.py +++ b/backend/app/schemas/following.py @@ -1,10 +1,10 @@ -from typing import Optional, List, Annotated +from typing import Optional, List from datetime import datetime from pydantic import BaseModel, Field, model_validator -# Base schema for following class FollowingBase(BaseModel): + """Base schema for following operations""" user_id: int artist_id: Optional[int] = None band_id: Optional[int] = None @@ -23,17 +23,12 @@ class Config: class FollowingCreate(FollowingBase): + """Schema for creating a new following""" pass -class FollowingUpdate(BaseModel): - started_at: Optional[datetime] = None - - class Config: - from_attributes = True - - class FollowingOut(FollowingBase): + """Schema for following output""" id: int started_at: datetime @@ -41,18 +36,23 @@ class Config: from_attributes = True -# Schemas for relationships -class UserMinimal(BaseModel): - id: int - username: str - first_name: str - last_name: str +class FollowingToggle(BaseModel): + """Schema for toggling follow status""" + artist_id: Optional[int] = None + band_id: Optional[int] = None - class Config: - from_attributes = True + @model_validator(mode="after") + def validate_toggle_target(self) -> "FollowingToggle": + """Ensure either artist_id or band_id is provided, but not both""" + if self.artist_id is not None and self.band_id is not None: + raise ValueError("Cannot toggle both artist and band simultaneously") + if self.artist_id is None and self.band_id is None: + raise ValueError("Must specify either artist_id or band_id") + return self class ArtistMinimal(BaseModel): + """Minimal artist information for following relationships""" id: int artist_stage_name: str artist_profile_image: Optional[str] = None @@ -62,6 +62,7 @@ class Config: class BandMinimal(BaseModel): + """Minimal band information for following relationships""" id: int name: str profile_picture: Optional[str] = None @@ -70,151 +71,42 @@ class Config: from_attributes = True -# Following output with relationships -class FollowingWithRelations(FollowingOut): - user: UserMinimal +class FollowingWithTarget(BaseModel): + """Following with target details (artist or band)""" + id: int + user_id: int + started_at: datetime artist: Optional[ArtistMinimal] = None band: Optional[BandMinimal] = None - -# User following list -class UserFollowingList(BaseModel): - user_id: int - following: List[FollowingWithRelations] - following_artists: List[ArtistMinimal] - following_bands: List[BandMinimal] - total_following: int - artist_count: int - band_count: int - - -# Artist followers list -class ArtistFollowersList(BaseModel): - artist_id: int - followers: List[UserMinimal] - total_followers: int - - -# Band followers list -class BandFollowersList(BaseModel): - band_id: int - followers: List[UserMinimal] - total_followers: int + class Config: + from_attributes = True -# List schemas for pagination class FollowingList(BaseModel): - followings: List[FollowingOut] + """Paginated list of followings""" + followings: List[FollowingWithTarget] total: int page: int per_page: int total_pages: int -class FollowingListWithRelations(BaseModel): - followings: List[FollowingWithRelations] - total: int - page: int - per_page: int - total_pages: int - - -# Search and filter schemas -class FollowingFilter(BaseModel): - user_id: Optional[int] = None - artist_id: Optional[int] = None - band_id: Optional[int] = None - started_at_from: Optional[datetime] = None - started_at_to: Optional[datetime] = None - - -class FollowingSearch(BaseModel): - user_id: int - query: Optional[str] = None # Search in artist/band names - follow_type: Optional[str] = None # "artist", "band", or None for both - limit: int = Field(default=50, ge=1, le=100) - offset: int = Field(default=0, ge=0) - - -# Following management schemas -class FollowingAdd(BaseModel): - user_id: int - artist_id: Optional[int] = None - band_id: Optional[int] = None - - -class FollowingRemove(BaseModel): - user_id: int - artist_id: Optional[int] = None - band_id: Optional[int] = None - - -class FollowingToggle(BaseModel): - user_id: int - artist_id: Optional[int] = None - band_id: Optional[int] = None - - -# Following statistics class FollowingStats(BaseModel): + """Following statistics""" total_followings: int unique_users: int unique_artists: int unique_bands: int most_followed_artist: Optional[ArtistMinimal] = None most_followed_band: Optional[BandMinimal] = None - most_active_follower: Optional[UserMinimal] = None -# User following statistics -class UserFollowingStats(BaseModel): +class UserFollowingSummary(BaseModel): + """User's following summary""" user_id: int total_following: int artist_count: int band_count: int - following_since: Optional[datetime] = None - most_recent_follow: Optional[datetime] = None - - -# Artist/Band following statistics -class ArtistBandFollowingStats(BaseModel): - artist_id: Optional[int] = None - band_id: Optional[int] = None - total_followers: int - followers_growth_rate: float # followers per day - top_followers: List[UserMinimal] # most active followers - - -# Following recommendations -class FollowingRecommendation(BaseModel): - user_id: int - recommended_artists: List[ArtistMinimal] - recommended_bands: List[BandMinimal] - recommendation_reason: str # e.g., "Based on your listening history", "Popular among similar users" - confidence_score: float # 0.0 to 1.0 - - -# Following activity -class FollowingActivity(BaseModel): - user_id: int - activities: List[dict] # Timeline of following activities - # [{"date": "2024-01-01", "action": "followed", "target": {...}}, ...] - - -# Following export -class FollowingExport(BaseModel): - user_id: Optional[int] = None - artist_id: Optional[int] = None - band_id: Optional[int] = None - format: str = "json" # json, csv, etc. - include_details: bool = True - - -# Following notifications -class FollowingNotification(BaseModel): - user_id: int - target_id: int # artist_id or band_id - target_type: str # "artist" or "band" - notification_type: str # "new_release", "new_song", "new_album", etc. - message: str - timestamp: datetime + followed_artists: List[ArtistMinimal] + followed_bands: List[BandMinimal] diff --git a/backend/app/schemas/genre.py b/backend/app/schemas/genre.py index 1309382..ea949b0 100644 --- a/backend/app/schemas/genre.py +++ b/backend/app/schemas/genre.py @@ -1,30 +1,30 @@ from typing import Optional, Annotated from datetime import datetime -from pydantic import BaseModel, StringConstraints, model_validator - +from pydantic import BaseModel, StringConstraints, model_validator class GenreBase(BaseModel): + """Base schema for genre data with common fields""" name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=255)] description: Optional[str] = None class Config: - from_attributes = True # enables ORM mode with SQLAlchemy models + from_attributes = True class GenreCreate(GenreBase): - pass # no extra fields for creation after base + """Schema for creating a new genre""" + pass class GenreUpdate(BaseModel): + """Schema for updating a genre""" name: Optional[Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=255)]] = None description: Optional[str] = None @model_validator(mode="after") def check_at_least_one_field(self) -> "GenreUpdate": - """ - Ensures that at least one of the optional fields is provided. - """ + """Ensures that at least one of the optional fields is provided""" if self.name is None and self.description is None: raise ValueError("At least one field ('name' or 'description') must be provided.") return self @@ -34,6 +34,7 @@ class Config: class GenreOut(GenreBase): + """Schema for genre output with all fields""" id: int is_active: bool created_at: datetime @@ -43,9 +44,14 @@ class Config: from_attributes = True -class GenreStatus(BaseModel): - is_active: bool - disabled_at: Optional[datetime] = None +class GenreStats(BaseModel): + """Schema for genre statistics""" + total_genres: int + active_genres: int + inactive_genres: int + genres_with_songs: int + most_used_genre: Optional[str] = None + least_used_genre: Optional[str] = None class Config: from_attributes = True diff --git a/backend/app/schemas/history.py b/backend/app/schemas/history.py index c162089..e360612 100644 --- a/backend/app/schemas/history.py +++ b/backend/app/schemas/history.py @@ -1,46 +1,27 @@ -from typing import Optional, List, Annotated +from typing import Optional, List from datetime import datetime from pydantic import BaseModel, Field - -# Base schema for history class HistoryBase(BaseModel): user_id: int song_id: int + played_at: datetime + is_cleared: bool = False class Config: from_attributes = True - class HistoryCreate(HistoryBase): pass -class HistoryUpdate(BaseModel): - played_at: Optional[datetime] = None - - class Config: - from_attributes = True - - class HistoryOut(HistoryBase): id: int - played_at: datetime class Config: from_attributes = True -# Schemas for relationships -class UserMinimal(BaseModel): - id: int - username: str - first_name: str - last_name: str - - class Config: - from_attributes = True - class SongMinimal(BaseModel): id: int @@ -54,66 +35,29 @@ class Config: from_attributes = True -# History output with relationships -class HistoryWithRelations(HistoryOut): - user: UserMinimal + +class HistoryWithSong(HistoryOut): song: SongMinimal + class Config: + from_attributes = True -# History with song details -class HistoryWithSongDetails(HistoryWithRelations): - song_genre: Optional[str] = None - song_album: Optional[str] = None -# List schemas for pagination class HistoryList(BaseModel): - history: List[HistoryOut] - total: int - page: int - per_page: int - total_pages: int - - -class HistoryListWithRelations(BaseModel): - history: List[HistoryWithRelations] + history: List[HistoryWithSong] total: int page: int per_page: int total_pages: int -# Search and filter schemas -class HistoryFilter(BaseModel): - user_id: Optional[int] = None - song_id: Optional[int] = None - played_at_from: Optional[datetime] = None - played_at_to: Optional[datetime] = None - - -class HistorySearch(BaseModel): - user_id: int - query: Optional[str] = None # Search in song title, artist, or band name - limit: int = Field(default=50, ge=1, le=100) - offset: int = Field(default=0, ge=0) - -# History management schemas -class HistoryAdd(BaseModel): - user_id: int +class HistoryToggle(BaseModel): song_id: int -class HistoryRemove(BaseModel): - user_id: int - song_id: int - -class HistoryClear(BaseModel): - user_id: int - - -# History statistics class HistoryStats(BaseModel): total_listens: int unique_songs: int @@ -125,20 +69,12 @@ class HistoryStats(BaseModel): last_listened: Optional[datetime] = None -# User listening history -class UserListeningHistory(BaseModel): - user_id: int - recent_listens: List[HistoryWithRelations] - top_songs: List[SongMinimal] - top_artists: List[str] - top_genres: List[str] - listening_stats: HistoryStats - -# History export schema -class HistoryExport(BaseModel): - user_id: int - format: str = "json" - date_from: Optional[datetime] = None - date_to: Optional[datetime] = None - include_song_details: bool = True +class GlobalHistoryStats(BaseModel): + total_listens: int + unique_songs: int + unique_users: int + most_listened_song: Optional[SongMinimal] = None + most_listened_artist: Optional[str] = None + most_listened_genre: Optional[str] = None + average_listens_per_user: float diff --git a/backend/app/schemas/like.py b/backend/app/schemas/like.py index fc47cc2..3661831 100644 --- a/backend/app/schemas/like.py +++ b/backend/app/schemas/like.py @@ -1,10 +1,10 @@ -from typing import Optional, List, Annotated +from typing import Optional, List from datetime import datetime from pydantic import BaseModel, Field -# Base schema for like class LikeBase(BaseModel): + """Base schema for like operations""" user_id: int song_id: int @@ -13,17 +13,12 @@ class Config: class LikeCreate(LikeBase): + """Schema for creating a new like""" pass -class LikeUpdate(BaseModel): - liked_at: Optional[datetime] = None - - class Config: - from_attributes = True - - class LikeOut(LikeBase): + """Schema for like output""" id: int liked_at: datetime @@ -31,8 +26,8 @@ class Config: from_attributes = True -# Nested schemas for relationships class UserMinimal(BaseModel): + """Minimal user information for like relationships""" id: int username: str first_name: str @@ -43,6 +38,7 @@ class Config: class SongMinimal(BaseModel): + """Minimal song information for like relationships""" id: int title: str song_duration: int @@ -54,14 +50,8 @@ class Config: from_attributes = True -# like output with relationships -class LikeWithRelations(LikeOut): - user: UserMinimal - song: SongMinimal - - -# List schemas for pagination class LikeList(BaseModel): + """Paginated list of likes""" likes: List[LikeOut] total: int page: int @@ -69,47 +59,13 @@ class LikeList(BaseModel): total_pages: int -class LikeListWithRelations(BaseModel): - likes: List[LikeWithRelations] - total: int - page: int - per_page: int - total_pages: int - - -# Search and filter schemas -class LikeFilter(BaseModel): - user_id: Optional[int] = None - song_id: Optional[int] = None - liked_at_from: Optional[datetime] = None - liked_at_to: Optional[datetime] = None - - -class LikeSearch(BaseModel): - user_id: int - query: Optional[str] = None # Search in song title, artist, or band name - limit: int = Field(default=50, ge=1, le=100) - offset: int = Field(default=0, ge=0) - - -# Like management schemas -class LikeAdd(BaseModel): - user_id: int - song_id: int - - -class LikeRemove(BaseModel): - user_id: int - song_id: int - - class LikeToggle(BaseModel): - user_id: int + """Schema for toggling like status""" song_id: int -# Like statistics class LikeStats(BaseModel): + """Like statistics""" total_likes: int unique_songs: int unique_users: int @@ -118,8 +74,8 @@ class LikeStats(BaseModel): most_liked_genre: Optional[str] = None -# User likes summary class UserLikesSummary(BaseModel): + """Summary of user's likes""" user_id: int total_likes: int liked_songs: List[SongMinimal] @@ -127,24 +83,22 @@ class UserLikesSummary(BaseModel): favorite_genres: List[str] -# Song likes summary -class SongLikesSummary(BaseModel): +class LikeWithSong(BaseModel): + """Like with full song details for Flutter widgets.""" + id: int + user_id: int song_id: int - total_likes: int - liked_by_users: List[UserMinimal] - like_percentage: float # Percentage of users who liked this song - + liked_at: datetime + song: SongMinimal -# Like export schema -class LikeExport(BaseModel): - user_id: int - format: str = "json" # json, csv, etc. - include_song_details: bool = True + class Config: + from_attributes = True -# Like recommendations -class LikeRecommendation(BaseModel): - user_id: int - recommended_songs: List[SongMinimal] - recommendation_reason: str # "Based on your likes", "Popular among similar users" - confidence_score: float # 0.0 to 1.0 +class LikeListWithSongs(BaseModel): + """Paginated list of likes with full song details.""" + likes: List[LikeWithSong] + total: int + page: int + per_page: int + total_pages: int diff --git a/backend/app/schemas/playlist.py b/backend/app/schemas/playlist.py index 840214a..541d5e5 100644 --- a/backend/app/schemas/playlist.py +++ b/backend/app/schemas/playlist.py @@ -1,27 +1,23 @@ -from typing import Optional, List, Annotated +from typing import Optional, List from datetime import datetime -from pydantic import BaseModel, StringConstraints, Field, model_validator +from pydantic import BaseModel, Field -# Base schema for playlist class PlaylistBase(BaseModel): - name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=100)] - description: Optional[Annotated[str, StringConstraints(max_length=255)]] = None + name: str = Field(..., min_length=1, max_length=100, strip_whitespace=True) + description: Optional[str] = Field(None, max_length=255) class Config: from_attributes = True class PlaylistCreate(PlaylistBase): - owner_id: int + pass -class PlaylistUpdate(BaseModel): - name: Optional[Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=100)]] = None - description: Optional[Annotated[str, StringConstraints(max_length=255)]] = None - - class Config: - from_attributes = True +class PlaylistUpdate(PlaylistBase): + name: Optional[str] = Field(None, min_length=1, max_length=100, strip_whitespace=True) + description: Optional[str] = Field(None, max_length=255) class PlaylistOut(PlaylistBase): @@ -33,7 +29,6 @@ class Config: from_attributes = True -# Schemas for relationships class UserMinimal(BaseModel): id: int username: str @@ -44,53 +39,13 @@ class Config: from_attributes = True -class SongMinimal(BaseModel): - id: int - title: str - song_duration: int - cover_image: Optional[str] = None - artist_name: Optional[str] = None - band_name: Optional[str] = None - - class Config: - from_attributes = True - - -# Playlist song relationship schema -class PlaylistSongTrack(BaseModel): - song_order: Optional[int] = None - song: SongMinimal - - class Config: - from_attributes = True - - -# Playlist collaborator schema -class PlaylistCollaborator(BaseModel): - id: int - collaborator: UserMinimal - can_edit: bool - added_at: datetime - added_by: Optional[UserMinimal] = None +class PlaylistWithOwner(PlaylistOut): + owner: UserMinimal class Config: from_attributes = True -# playlist output with relationships -class PlaylistWithRelations(PlaylistOut): - owner: UserMinimal - playlist_songs: List[PlaylistSongTrack] = [] - playlist_collaborators: List[PlaylistCollaborator] = [] - - -# Playlist with songs list -class PlaylistWithSongs(PlaylistWithRelations): - total_songs: int - total_duration: int # in seconds - - -# List schemas for pagination class PlaylistList(BaseModel): playlists: List[PlaylistOut] total: int @@ -99,69 +54,17 @@ class PlaylistList(BaseModel): total_pages: int -class PlaylistListWithRelations(BaseModel): - playlists: List[PlaylistWithRelations] +class PlaylistListWithOwner(BaseModel): + playlists: List[PlaylistWithOwner] total: int page: int per_page: int total_pages: int -# Search and filter schemas -class PlaylistFilter(BaseModel): - name: Optional[str] = None - owner_id: Optional[int] = None - collaborator_id: Optional[int] = None - created_at_from: Optional[datetime] = None - created_at_to: Optional[datetime] = None - - -class PlaylistSearch(BaseModel): - query: str - owner_id: Optional[int] = None - limit: int = Field(default=20, ge=1, le=100) - offset: int = Field(default=0, ge=0) - - -# Playlist song management schemas -class PlaylistSongAdd(BaseModel): - song_id: int - song_order: Optional[int] = None - - -class PlaylistSongUpdate(BaseModel): - song_order: int - - -class PlaylistSongRemove(BaseModel): - song_id: int - - -# Playlist collaborator management schemas -class PlaylistCollaboratorAdd(BaseModel): - collaborator_id: int - can_edit: bool = False - - -class PlaylistCollaboratorUpdate(BaseModel): - can_edit: bool - - -class PlaylistCollaboratorRemove(BaseModel): - collaborator_id: int - - -# Playlist sharing schemas -class PlaylistShare(BaseModel): - playlist_id: int - user_ids: List[int] - can_edit: bool = False - - -# Playlist statistics class PlaylistStats(BaseModel): - total_songs: int - total_duration: int # in seconds - total_collaborators: int + total_playlists: int + total_owned_playlists: int + total_collaborated_playlists: int created_at: datetime - last_modified: datetime + last_modified: Optional[datetime] = None diff --git a/backend/app/schemas/playlist_collaborator.py b/backend/app/schemas/playlist_collaborator.py index 4d8330f..3de9ba6 100644 --- a/backend/app/schemas/playlist_collaborator.py +++ b/backend/app/schemas/playlist_collaborator.py @@ -1,9 +1,8 @@ -from typing import Optional, List, Annotated +from typing import Optional, List from datetime import datetime -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field -# Base schema for playlist collaborator class PlaylistCollaboratorBase(BaseModel): playlist_id: int collaborator_id: int @@ -18,11 +17,11 @@ class PlaylistCollaboratorCreate(PlaylistCollaboratorBase): pass -class PlaylistCollaboratorUpdate(BaseModel): +class PlaylistCollaboratorUpdate(PlaylistCollaboratorBase): + playlist_id: Optional[int] = None + collaborator_id: Optional[int] = None can_edit: Optional[bool] = None - - class Config: - from_attributes = True + added_by_user_id: Optional[int] = None class PlaylistCollaboratorOut(PlaylistCollaboratorBase): @@ -33,17 +32,6 @@ class Config: from_attributes = True -# Schemas for relationships -class PlaylistMinimal(BaseModel): - id: int - name: str - description: Optional[str] = None - created_at: datetime - - class Config: - from_attributes = True - - class UserMinimal(BaseModel): id: int username: str @@ -54,157 +42,37 @@ class Config: from_attributes = True -# Playlist collaborator output with relationships -class PlaylistCollaboratorWithRelations(PlaylistCollaboratorOut): - playlist: PlaylistMinimal + +class PlaylistCollaboratorWithUser(PlaylistCollaboratorOut): collaborator: UserMinimal added_by: Optional[UserMinimal] = None + class Config: + from_attributes = True -# Playlist collaboration list -class PlaylistCollaborationList(BaseModel): - playlist_id: int - collaborators: List[PlaylistCollaboratorWithRelations] - total_collaborators: int - can_edit_count: int - read_only_count: int - - -# User collaboration list -class UserCollaborationList(BaseModel): - user_id: int - collaborations: List[PlaylistCollaboratorWithRelations] - owned_playlists: List[PlaylistMinimal] - total_collaborations: int - can_edit_count: int - read_only_count: int -# List schemas for pagination class PlaylistCollaboratorList(BaseModel): - collaborators: List[PlaylistCollaboratorOut] + collaborators: List[PlaylistCollaboratorWithUser] total: int page: int per_page: int total_pages: int -class PlaylistCollaboratorListWithRelations(BaseModel): - collaborators: List[PlaylistCollaboratorWithRelations] - total: int - page: int - per_page: int - total_pages: int - - -# Search and filter schemas -class PlaylistCollaboratorFilter(BaseModel): - playlist_id: Optional[int] = None - collaborator_id: Optional[int] = None - added_by_user_id: Optional[int] = None - can_edit: Optional[bool] = None - added_at_from: Optional[datetime] = None - added_at_to: Optional[datetime] = None - -# Collaboration management schemas class PlaylistCollaboratorAdd(BaseModel): - playlist_id: int collaborator_id: int can_edit: bool = False -class PlaylistCollaboratorRemove(BaseModel): - playlist_id: int - collaborator_id: int - - class PlaylistCollaboratorUpdatePermissions(BaseModel): - playlist_id: int - collaborator_id: int can_edit: bool -class PlaylistCollaboratorBulkAdd(BaseModel): - playlist_id: int - collaborators: List[PlaylistCollaboratorAdd] - - -class PlaylistCollaboratorBulkRemove(BaseModel): - playlist_id: int - collaborator_ids: List[int] - - -class PlaylistCollaboratorBulkUpdate(BaseModel): - playlist_id: int - updates: List[dict] # TODO HINT: [{"collaborator_id": 1, "can_edit": true}, ...] - - -# Collaboration invitation schemas -class PlaylistCollaboratorInvite(BaseModel): - playlist_id: int - collaborator_email: str - can_edit: bool = False - message: Optional[str] = None - - -class PlaylistCollaboratorInviteResponse(BaseModel): - invite_id: int - accept: bool - message: Optional[str] = None - -# Collaboration statistics class PlaylistCollaboratorStats(BaseModel): - total_collaborations: int - active_collaborations: int - can_edit_collaborations: int - read_only_collaborations: int - most_collaborative_playlist: Optional[PlaylistMinimal] = None - most_collaborative_user: Optional[UserMinimal] = None - - -# Collaboration validation schema -class PlaylistCollaboratorValidation(BaseModel): - playlist_id: int - collaborator_id: int - - @model_validator(mode="after") - def validate_unique_collaboration(self) -> "PlaylistCollaboratorValidation": - """Ensure user is not already a collaborator on this playlist""" - # TODO: validation would be done in the service layer - return self - - -# Collaboration search -class PlaylistCollaboratorSearch(BaseModel): - playlist_id: Optional[int] = None - query: str # Search in collaborator username, first_name, and last_name - can_edit: Optional[bool] = None - limit: int = Field(default=20, ge=1, le=100) - offset: int = Field(default=0, ge=0) - - -# Collaboration export -class PlaylistCollaboratorExport(BaseModel): - playlist_id: Optional[int] = None - user_id: Optional[int] = None - format: str = "json" - include_permissions: bool = True - include_timestamps: bool = True - - -# Collaboration notifications -class PlaylistCollaboratorNotification(BaseModel): - playlist_id: int - collaborator_id: int - notification_type: str # "added", "removed", "permission_changed" - message: str - timestamp: datetime - - -# Collaboration activity -class PlaylistCollaboratorActivity(BaseModel): - playlist_id: int - activities: List[dict] # Timeline - # TODO HINT: [{"date": "2024-01-01", "action": "added", "user": {...}}, ...] + total_collaborators: int + can_edit_collaborators: int + read_only_collaborators: int + most_collaborative_user: Optional[UserMinimal] = None diff --git a/backend/app/schemas/playlist_song.py b/backend/app/schemas/playlist_song.py index e10f61b..13f0b9d 100644 --- a/backend/app/schemas/playlist_song.py +++ b/backend/app/schemas/playlist_song.py @@ -1,11 +1,9 @@ -from typing import Optional, List, Annotated +from typing import Optional, List from datetime import datetime -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field -# Base schema for playlist-song relationship class PlaylistSongBase(BaseModel): - playlist_id: int song_id: int song_order: Optional[int] = None @@ -26,17 +24,7 @@ class Config: class PlaylistSongOut(PlaylistSongBase): id: int - - class Config: - from_attributes = True - - -# Schemas for relationships -class PlaylistMinimal(BaseModel): - id: int - name: str - description: Optional[str] = None - created_at: datetime + playlist_id: int class Config: from_attributes = True @@ -54,120 +42,40 @@ class Config: from_attributes = True -# playlist_song output with relationships -class PlaylistSongWithRelations(PlaylistSongOut): - playlist: PlaylistMinimal +class PlaylistSongWithSong(PlaylistSongOut): song: SongMinimal - -# Playlist song list -class PlaylistSongList(BaseModel): - playlist_id: int - songs: List[PlaylistSongWithRelations] - total_songs: int - total_duration: int # seconds - - -# List schemas for pagination -class PlaylistSongListPaginated(BaseModel): - playlist_songs: List[PlaylistSongOut] - total: int - page: int - per_page: int - total_pages: int + class Config: + from_attributes = True -class PlaylistSongListWithRelations(BaseModel): - playlist_songs: List[PlaylistSongWithRelations] +class PlaylistSongList(BaseModel): + songs: List[PlaylistSongWithSong] total: int page: int per_page: int total_pages: int -# Search and filter schemas -class PlaylistSongFilter(BaseModel): - playlist_id: Optional[int] = None - song_id: Optional[int] = None - song_order: Optional[int] = None - - -# Playlist song management schemas class PlaylistSongAdd(BaseModel): - playlist_id: int - song_id: int - song_order: Optional[int] = None # In case none, add to end - - -class PlaylistSongRemove(BaseModel): - playlist_id: int song_id: int + song_order: Optional[int] = None class PlaylistSongReorder(BaseModel): - playlist_id: int song_id: int - new_song_order: int - - -class PlaylistSongBulkAdd(BaseModel): - playlist_id: int - songs: List[PlaylistSongAdd] - - -class PlaylistSongBulkRemove(BaseModel): - playlist_id: int - song_ids: List[int] + new_order: int class PlaylistSongBulkReorder(BaseModel): - playlist_id: int - song_orders: List[dict] # TODO HINT: [{"song_id": 1, "new_order": 3}, ...] + song_orders: List[PlaylistSongReorder] -# Playlist song statistics class PlaylistSongStats(BaseModel): - playlist_id: int total_songs: int total_duration: int # in seconds average_song_duration: float shortest_song: Optional[SongMinimal] = None longest_song: Optional[SongMinimal] = None most_common_artist: Optional[str] = None - most_common_genre: Optional[str] = None - - -# Playlist song validation schema -class PlaylistSongValidation(BaseModel): - playlist_id: int - song_id: int - - @model_validator(mode="after") - def validate_unique_song(self) -> "PlaylistSongValidation": - """Ensure song is not already in playlist""" - # TODO: validation would be done in the service layer - return self - - -# Playlist song search -class PlaylistSongSearch(BaseModel): - playlist_id: int - query: str # Search in song title, artist, or band name - limit: int = Field(default=20, ge=1, le=100) - offset: int = Field(default=0, ge=0) - - -# Playlist song export -class PlaylistSongExport(BaseModel): - playlist_id: int - format: str = "json" # json, csv, m3u, etc. - include_song_details: bool = True - include_metadata: bool = True - - -# Playlist song recommendations -class PlaylistSongRecommendation(BaseModel): - playlist_id: int - recommended_songs: List[SongMinimal] - recommendation_reason: str # Response to frontend: "Based on playlist genre", "Similar to existing songs" - confidence_score: float # 0.0 to 1.0 + most_common_genre: Optional[str] = None diff --git a/backend/app/schemas/song.py b/backend/app/schemas/song.py index e8abe05..038088c 100644 --- a/backend/app/schemas/song.py +++ b/backend/app/schemas/song.py @@ -3,8 +3,8 @@ from pydantic import BaseModel, StringConstraints, Field, model_validator -# Base schema for song class SongBase(BaseModel): + """Base schema for song data with common fields""" title: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=150)] genre_id: int band_id: Optional[int] = None @@ -29,27 +29,44 @@ class Config: from_attributes = True -class SongCreate(SongBase): - uploaded_by_user_id: int +class SongUploadByArtist(SongBase): + """Schema for artist uploading their own song""" + pass + + +class SongUploadByBand(SongBase): + """Schema for band member uploading band song""" + pass + + +class SongUploadByAdmin(SongBase): + """Schema for admin uploading for any artist/band (including dead artists)""" + artist_name: Optional[Annotated[str, StringConstraints(max_length=100)]] = None + band_name: Optional[Annotated[str, StringConstraints(max_length=100)]] = None + + @model_validator(mode="after") + def validate_admin_upload(self) -> "SongUploadByAdmin": + """For admin uploads, either artist_name or band_name must be provided if no IDs""" + if self.artist_id is None and self.band_id is None: + if not self.artist_name and not self.band_name: + raise ValueError("Admin upload must specify either artist_name or band_name when no IDs provided") + return self class SongUpdate(BaseModel): + """Schema for updating song metadata""" title: Optional[Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=150)]] = None genre_id: Optional[int] = None - band_id: Optional[int] = None - artist_id: Optional[int] = None release_date: Optional[datetime] = None song_duration: Optional[Annotated[int, Field(gt=0)]] = None - file_path: Optional[Annotated[str, StringConstraints(strip_whitespace=True, max_length=255)]] = None cover_image: Optional[Annotated[str, StringConstraints(max_length=255)]] = None - artist_name: Optional[Annotated[str, StringConstraints(max_length=100)]] = None - band_name: Optional[Annotated[str, StringConstraints(max_length=100)]] = None class Config: from_attributes = True class SongOut(SongBase): + """Schema for song output with all fields""" id: int uploaded_by_user_id: int created_at: datetime @@ -60,8 +77,19 @@ class Config: from_attributes = True -# Schema for relationships +class SongWithRelations(SongOut): + """Schema for song output with relationships""" + genre: "GenreMinimal" + artist: Optional["ArtistMinimal"] = None + band: Optional["BandMinimal"] = None + uploaded_by: "UserMinimal" + + class Config: + from_attributes = True + + class GenreMinimal(BaseModel): + """Minimal genre schema for relationships""" id: int name: str @@ -70,6 +98,7 @@ class Config: class ArtistMinimal(BaseModel): + """Minimal artist schema for relationships""" id: int artist_stage_name: str artist_profile_image: Optional[str] = None @@ -79,6 +108,7 @@ class Config: class BandMinimal(BaseModel): + """Minimal band schema for relationships""" id: int name: str profile_picture: Optional[str] = None @@ -88,6 +118,7 @@ class Config: class UserMinimal(BaseModel): + """Minimal user schema for relationships""" id: int username: str first_name: str @@ -97,18 +128,15 @@ class Config: from_attributes = True -# song output with relationships -class SongWithRelations(SongOut): - genre: GenreMinimal - artist: Optional[ArtistMinimal] = None - band: Optional[BandMinimal] = None - uploaded_by: UserMinimal - - -# Song status update -class SongStatus(BaseModel): - is_disabled: bool - disabled_at: Optional[datetime] = None +class SongStats(BaseModel): + """Schema for song statistics""" + total_songs: int + active_songs: int + disabled_songs: int + songs_by_artist: int + songs_by_band: int + most_uploaded_artist: Optional[str] = None + most_uploaded_band: Optional[str] = None class Config: from_attributes = True diff --git a/backend/app/schemas/song_upload.py b/backend/app/schemas/song_upload.py new file mode 100644 index 0000000..2cb831c --- /dev/null +++ b/backend/app/schemas/song_upload.py @@ -0,0 +1,83 @@ +""" +Schemas for combined song creation and file upload operations. +""" + +from typing import Optional, List +from datetime import datetime +from pydantic import BaseModel, Field, model_validator + + +class SongCreateWithUpload(BaseModel): + """Schema for creating a song with file upload in one operation.""" + + title: str = Field(..., min_length=1, max_length=150, description="Song title") + genre_id: int = Field(..., description="Genre ID") + band_id: Optional[int] = Field(None, description="Band ID (optional if artist_id provided)") + artist_id: Optional[int] = Field(None, description="Artist ID (optional if band_id provided)") + release_date: str = Field(..., description="Song release date") + song_duration: int = Field(0, description="Audio duration in seconds") + file_path: str = Field("", description="File path (set after upload)") + cover_image: Optional[str] = Field(None, description="Cover image path") + artist_name: Optional[str] = Field(None, max_length=100, description="Artist name (for admin uploads)") + band_name: Optional[str] = Field(None, max_length=100, description="Band name (for admin uploads)") + + @model_validator(mode="after") + def validate_artist_or_band(self) -> "SongCreateWithUpload": + """Ensure either artist_id or band_id is provided, but not both""" + if self.artist_id is not None and self.band_id is not None: + raise ValueError("Song cannot have both artist_id and band_id") + if self.artist_id is None and self.band_id is None: + raise ValueError("Song must have either artist_id or band_id") + return self + + +class SongCreateWithUploadByArtist(SongCreateWithUpload): + """Schema for artist uploading their own song.""" + artist_id: int = Field(..., description="Artist ID (required for artist uploads)") + band_id: Optional[int] = Field(None, description="Band ID (not used for artist uploads)") + + +class SongCreateWithUploadByBand(SongCreateWithUpload): + """Schema for band member uploading band song.""" + band_id: int = Field(..., description="Band ID (required for band uploads)") + artist_id: Optional[int] = Field(None, description="Artist ID (not used for band uploads)") + + +class SongCreateWithUploadByAdmin(SongCreateWithUpload): + """Schema for admin uploading for any artist/band.""" + + @model_validator(mode="after") + def validate_admin_upload(self) -> "SongCreateWithUploadByAdmin": + """For admin uploads, either artist_name or band_name must be provided if no IDs""" + if self.artist_id is None and self.band_id is None: + if not self.artist_name and not self.band_name: + raise ValueError("Admin upload must specify either artist_name or band_name when no IDs provided") + return self + + +class SongUploadResponse(BaseModel): + """Response schema for successful song creation with upload.""" + + song_id: int = Field(description="Created song ID") + title: str = Field(description="Song title") + filename: str = Field(description="Generated unique filename") + stream_url: str = Field(description="URL to stream the song") + duration: int = Field(description="Audio duration in seconds") + file_size: int = Field(description="File size in bytes") + message: str = Field(description="Success message") + + +class CoverUploadRequest(BaseModel): + """Schema for uploading cover image for existing song.""" + + song_id: int = Field(..., description="ID of the song to add cover to") + + +class CoverUploadResponse(BaseModel): + """Response schema for successful cover upload.""" + + song_id: int = Field(description="Song ID") + title: str = Field(description="Song title") + cover_filename: str = Field(description="Generated unique filename") + cover_url: str = Field(description="URL to view the cover image") + message: str = Field(description="Success message") diff --git a/backend/app/schemas/upload.py b/backend/app/schemas/upload.py new file mode 100644 index 0000000..154f434 --- /dev/null +++ b/backend/app/schemas/upload.py @@ -0,0 +1,68 @@ +""" +Schemas for file upload and metadata responses. +""" + +from pydantic import BaseModel, Field +from typing import Optional, Dict, Any +from datetime import datetime + + +class FileUploadResponse(BaseModel): + """Response schema for successful file upload.""" + + success: bool = Field(description="Upload success status") + filename: str = Field(description="Generated unique filename") + original_filename: str = Field(description="Original uploaded filename") + file_size: int = Field(description="File size in bytes") + file_size_mb: float = Field(description="File size in megabytes") + content_type: str = Field(description="MIME type of the file") + metadata: Optional[Dict[str, Any]] = Field(default=None, description="Extracted file metadata") + + +class AudioUploadResponse(FileUploadResponse): + """Response schema for audio file upload.""" + + duration: Optional[int] = Field(default=None, description="Audio duration in seconds") + bitrate: Optional[int] = Field(default=None, description="Audio bitrate") + sample_rate: Optional[int] = Field(default=None, description="Audio sample rate") + channels: Optional[int] = Field(default=None, description="Number of audio channels") + + +class ImageUploadResponse(FileUploadResponse): + """Response schema for image file upload.""" + + width: Optional[int] = Field(default=None, description="Image width in pixels") + height: Optional[int] = Field(default=None, description="Image height in pixels") + format: Optional[str] = Field(default=None, description="Image format") + + +class FileMetadata(BaseModel): + """Schema for file metadata.""" + + filename: str = Field(description="File filename") + file_size: int = Field(description="File size in bytes") + content_type: str = Field(description="MIME type") + created_at: datetime = Field(description="File creation timestamp") + metadata: Optional[Dict[str, Any]] = Field(default=None, description="Additional metadata") + + +class UploadError(BaseModel): + """Schema for upload error responses.""" + + error: str = Field(description="Error message") + details: Optional[str] = Field(default=None, description="Additional error details") + field: Optional[str] = Field(default=None, description="Field that caused the error") + + +class FileInfo(BaseModel): + """Schema for file information.""" + + id: int = Field(description="File ID") + filename: str = Field(description="File filename") + file_path: str = Field(description="File path") + file_size: int = Field(description="File size in bytes") + content_type: str = Field(description="MIME type") + created_at: datetime = Field(description="File creation timestamp") + updated_at: Optional[datetime] = Field(default=None, description="File last update timestamp") + is_public: bool = Field(description="Whether file is publicly accessible") + access_url: Optional[str] = Field(default=None, description="Public access URL") diff --git a/backend/app/services/file_service.py b/backend/app/services/file_service.py new file mode 100644 index 0000000..d6cc217 --- /dev/null +++ b/backend/app/services/file_service.py @@ -0,0 +1,325 @@ +""" +File service for handling uploads, validation, and file management. +Handles audio files, images, and metadata extraction. +""" + +import os +import uuid +import shutil +from pathlib import Path +from typing import Optional, Dict, Any, Tuple +from datetime import datetime +import aiofiles +from mutagen import File as MutagenFile +from PIL import Image +import logging + +logger = logging.getLogger(__name__) + +ALLOWED_AUDIO_TYPES = { + 'audio/mpeg': '.mp3', + 'audio/wav': '.wav', + 'audio/flac': '.flac', + 'audio/x-flac': '.flac' +} + +ALLOWED_IMAGE_TYPES = { + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp' +} + +MAX_AUDIO_SIZE = 100 * 1024 * 1024 # 100MB +MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB + +UPLOAD_BASE = Path("uploads") +SONGS_DIR = UPLOAD_BASE / "songs" +COVERS_DIR = UPLOAD_BASE / "covers" +PROFILES_DIR = UPLOAD_BASE / "profiles" +ALBUMS_DIR = UPLOAD_BASE / "albums" +TEMP_DIR = UPLOAD_BASE / "temp" + + +class FileService: + """Service for handling file uploads, validation, and management.""" + + def __init__(self): + """Initialize file service and ensure directories exist.""" + self._ensure_directories() + + def _ensure_directories(self) -> None: + """Create upload directories if they don't exist.""" + directories = [SONGS_DIR, COVERS_DIR, PROFILES_DIR, ALBUMS_DIR, TEMP_DIR] + for directory in directories: + directory.mkdir(parents=True, exist_ok=True) + logger.info(f"Ensured directory exists: {directory}") + + def validate_audio_file(self, file_path: Path, content_type: str) -> Tuple[bool, str]: + """ + Validate audio file type and size. + + Args: + file_path: Path to the audio file + content_type: MIME type of the file + + Returns: + Tuple of (is_valid, error_message) + """ + if not file_path.exists(): + return False, "File not found" + + try: + if file_path.stat().st_size > MAX_AUDIO_SIZE: + return False, f"File too large. Maximum size is {MAX_AUDIO_SIZE // (1024*1024)}MB" + + if content_type not in ALLOWED_AUDIO_TYPES: + return False, f"Unsupported audio format. Allowed: {', '.join(ALLOWED_AUDIO_TYPES.keys())}" + + expected_ext = ALLOWED_AUDIO_TYPES[content_type] + if file_path.suffix.lower() != expected_ext: + return False, f"File extension doesn't match content type. Expected: {expected_ext}" + + return True, "" + except OSError as e: + return False, f"Error accessing file: {str(e)}" + + def validate_image_file(self, file_path: Path, content_type: str) -> Tuple[bool, str]: + """ + Validate image file type and size. + + Args: + file_path: Path to the image file + content_type: MIME type of the file + + Returns: + Tuple of (is_valid, error_message) + """ + if not file_path.exists(): + return False, "File not found" + + try: + if file_path.stat().st_size > MAX_IMAGE_SIZE: + return False, f"File too large. Maximum size is {MAX_IMAGE_SIZE // (1024*1024)}MB" + + if content_type not in ALLOWED_IMAGE_TYPES: + return False, f"Unsupported image format. Allowed: {', '.join(ALLOWED_IMAGE_TYPES.keys())}" + + expected_ext = ALLOWED_IMAGE_TYPES[content_type] + if file_path.suffix.lower() != expected_ext: + return False, f"File extension doesn't match content type. Expected: {expected_ext}" + + return True, "" + except OSError as e: + return False, f"Error accessing file: {str(e)}" + + def generate_unique_filename(self, original_filename: str, file_id: int, file_type: str) -> str: + """ + Generate a unique filename to avoid conflicts. + + Args: + original_filename: Original uploaded filename + file_id: Database ID of the file (song_id, album_id, etc.) + file_type: Type of file (song, cover, profile, album) + + Returns: + Unique filename + """ + ext = Path(original_filename).suffix.lower() + + unique_id = str(uuid.uuid4())[:8] + + return f"{file_type}_{file_id}_{unique_id}{ext}" + + def get_audio_metadata(self, file_path: Path) -> Dict[str, Any]: + """ + Extract metadata from audio file. + + Args: + file_path: Path to the audio file + + Returns: + Dictionary containing metadata + """ + try: + if not file_path.exists(): + logger.error(f"File not found: {file_path}") + return {"duration": 0, "bitrate": 0} + + audio = MutagenFile(str(file_path)) + + if audio is None: + return {"duration": 0, "bitrate": 0} + + metadata = { + "duration": int(audio.info.length) if hasattr(audio.info, 'length') else 0, + "bitrate": audio.info.bitrate if hasattr(audio.info, 'bitrate') else 0, + "sample_rate": audio.info.sample_rate if hasattr(audio.info, 'sample_rate') else 0, + "channels": audio.info.channels if hasattr(audio.info, 'channels') else 0 + } + + if hasattr(audio, 'tags'): + tags = audio.tags + if tags: + metadata.update({ + "title": str(tags.get('title', [''])[0]) if 'title' in tags else None, + "artist": str(tags.get('artist', [''])[0]) if 'artist' in tags else None, + "album": str(tags.get('album', [''])[0]) if 'album' in tags else None, + "genre": str(tags.get('genre', [''])[0]) if 'genre' in tags else None, + "year": str(tags.get('date', [''])[0]) if 'date' in tags else None + }) + + return metadata + + except Exception as e: + logger.error(f"Error extracting audio metadata from {file_path}: {e}") + return {"duration": 0, "bitrate": 0} + + def get_image_metadata(self, file_path: Path) -> Dict[str, Any]: + """ + Extract metadata from image file. + + Args: + file_path: Path to the image file + + Returns: + Dictionary containing metadata + """ + try: + with Image.open(file_path) as img: + return { + "width": img.width, + "height": img.height, + "format": img.format, + "mode": img.mode, + "size_bytes": file_path.stat().st_size + } + except Exception as e: + logger.error(f"Error extracting image metadata from {file_path}: {e}") + return {"width": 0, "height": 0, "format": None, "mode": None, "size_bytes": 0} + + async def save_uploaded_file(self, temp_path: Path, destination_path: Path) -> bool: + """ + Save uploaded file from temporary location to final destination. + + Args: + temp_path: Temporary file path + destination_path: Final destination path + + Returns: + True if successful, False otherwise + """ + try: + destination_path.parent.mkdir(parents=True, exist_ok=True) + + shutil.copy2(temp_path, destination_path) + + + logger.info(f"File saved successfully: {destination_path}") + return True + + except Exception as e: + logger.error(f"Error saving file from {temp_path} to {destination_path}: {e}") + return False + + def get_file_path(self, file_type: str, filename: str) -> Path: + """ + Get the full path for a file based on its type. + + Args: + file_type: Type of file (song, cover, profile, album) + filename: Name of the file + + Returns: + Full path to the file + """ + type_to_dir = { + "song": SONGS_DIR, + "cover": COVERS_DIR, + "profile": PROFILES_DIR, + "album": ALBUMS_DIR + } + + directory = type_to_dir.get(file_type, TEMP_DIR) + return directory / filename + + def file_exists(self, file_type: str, filename: str) -> bool: + """ + Check if a file exists. + + Args: + file_type: Type of file (song, cover, profile, album) + filename: Name of the file + + Returns: + True if file exists, False otherwise + """ + file_path = self.get_file_path(file_type, filename) + return file_path.exists() + + def delete_file(self, file_type: str, filename: str) -> bool: + """ + Delete a file. + + Args: + file_type: Type of file (song, cover, profile, album) + filename: Name of the file + + Returns: + True if successful, False otherwise + """ + try: + file_path = self.get_file_path(file_type, filename) + if file_path.exists(): + file_path.unlink() + logger.info(f"File deleted: {file_path}") + return True + return False + except Exception as e: + logger.error(f"Error deleting file {filename}: {e}") + return False + + def get_file_size(self, file_type: str, filename: str) -> int: + """ + Get file size in bytes. + + Args: + file_type: Type of file (song, cover, profile, album) + filename: Name of the file + + Returns: + File size in bytes + """ + file_path = self.get_file_path(file_type, filename) + return file_path.stat().st_size if file_path.exists() else 0 + + def cleanup_temp_files(self, max_age_hours: int = 24) -> int: + """ + Clean up temporary files older than specified age. + + Args: + max_age_hours: Maximum age in hours before cleanup + + Returns: + Number of files cleaned up + """ + try: + cleaned_count = 0 + current_time = datetime.now() + + for temp_file in TEMP_DIR.glob("*"): + if temp_file.is_file(): + file_age = current_time - datetime.fromtimestamp(temp_file.stat().st_mtime) + if file_age.total_seconds() > max_age_hours * 3600: + temp_file.unlink() + cleaned_count += 1 + logger.info(f"Cleaned up temp file: {temp_file}") + + return cleaned_count + + except Exception as e: + logger.error(f"Error cleaning up temp files: {e}") + return 0 + + +file_service = FileService() diff --git a/backend/docker/Dockerfile b/backend/docker/Dockerfile index 8806b08..6297539 100644 --- a/backend/docker/Dockerfile +++ b/backend/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-slim +FROM python:3.11-slim AS builder WORKDIR /app @@ -8,17 +8,40 @@ RUN apt-get update && apt-get install -y \ curl \ && rm -rf /var/lib/apt/lists/* + RUN pip install poetry + COPY pyproject.toml poetry.lock ./ + RUN poetry config virtualenvs.create false \ - && poetry install --no-dev --no-interaction --no-ansi + && poetry install --no-root --no-interaction --no-ansi + +# stage 2 : runtime + +FROM python:3.11-slim AS runtime + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + libpq5 \ + curl \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + +COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin COPY . . +COPY docker/start.sh /app/start.sh RUN chmod +x /app/start.sh +RUN groupadd -r appuser && useradd -r -g appuser appuser +RUN chown -R appuser:appuser /app +USER appuser + EXPOSE 8000 CMD ["bash", "/app/start.sh"] diff --git a/backend/docker/docker-compose.yml b/backend/docker/docker-compose.yml index 55820ff..4efb108 100644 --- a/backend/docker/docker-compose.yml +++ b/backend/docker/docker-compose.yml @@ -1,10 +1,10 @@ -version: '3.8' - services: db: image: postgres:15 container_name: music-db-1 restart: unless-stopped + env_file: + - ../.env environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} diff --git a/backend/docker/start.sh b/backend/docker/start.sh index ae03f57..c03a17b 100644 --- a/backend/docker/start.sh +++ b/backend/docker/start.sh @@ -5,12 +5,11 @@ echo "🎵Music Player Backend Starting..." echo "🎵========================================" echo "" -echo "📦 Installing Python dependencies..." -poetry install --no-dev --no-interaction --no-ansi +echo "📦 Dependencies already installed in Docker build..." echo "" echo "Waiting for PostgreSQL database to be ready..." -until poetry run python -c " +until python -c " import psycopg2 import os try: @@ -34,7 +33,7 @@ done echo "" echo "Running Alembic database migrations..." -poetry run alembic upgrade head +alembic upgrade head echo "" echo "Database migrations completed successfully!" @@ -50,4 +49,4 @@ echo "========================================" echo "Music Player Backend is ready!" echo "========================================" -poetry run uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload diff --git a/backend/poetry.lock b/backend/poetry.lock index 081aa39..77ce8aa 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1,5 +1,17 @@ # This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +[[package]] +name = "aiofiles" +version = "24.1.0" +description = "File support for asyncio." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"}, + {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, +] + [[package]] name = "alembic" version = "1.16.4" @@ -542,6 +554,143 @@ files = [ {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, ] +[[package]] +name = "mutagen" +version = "1.47.0" +description = "read and write audio tags for many formats" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719"}, + {file = "mutagen-1.47.0.tar.gz", hash = "sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99"}, +] + +[[package]] +name = "pillow" +version = "11.3.0" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860"}, + {file = "pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae"}, + {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9"}, + {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e"}, + {file = "pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6"}, + {file = "pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f"}, + {file = "pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f"}, + {file = "pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722"}, + {file = "pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f"}, + {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e"}, + {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94"}, + {file = "pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0"}, + {file = "pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac"}, + {file = "pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd"}, + {file = "pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4"}, + {file = "pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024"}, + {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809"}, + {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d"}, + {file = "pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149"}, + {file = "pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d"}, + {file = "pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f"}, + {file = "pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c"}, + {file = "pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8"}, + {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2"}, + {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b"}, + {file = "pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3"}, + {file = "pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51"}, + {file = "pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580"}, + {file = "pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e"}, + {file = "pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59"}, + {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe"}, + {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c"}, + {file = "pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788"}, + {file = "pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31"}, + {file = "pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e"}, + {file = "pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12"}, + {file = "pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77"}, + {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874"}, + {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a"}, + {file = "pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214"}, + {file = "pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635"}, + {file = "pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6"}, + {file = "pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae"}, + {file = "pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477"}, + {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50"}, + {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b"}, + {file = "pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12"}, + {file = "pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db"}, + {file = "pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa"}, + {file = "pillow-11.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:48d254f8a4c776de343051023eb61ffe818299eeac478da55227d96e241de53f"}, + {file = "pillow-11.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7aee118e30a4cf54fdd873bd3a29de51e29105ab11f9aad8c32123f58c8f8081"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:23cff760a9049c502721bdb743a7cb3e03365fafcdfc2ef9784610714166e5a4"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6359a3bc43f57d5b375d1ad54a0074318a0844d11b76abccf478c37c986d3cfc"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:092c80c76635f5ecb10f3f83d76716165c96f5229addbd1ec2bdbbda7d496e06"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cadc9e0ea0a2431124cde7e1697106471fc4c1da01530e679b2391c37d3fbb3a"}, + {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6a418691000f2a418c9135a7cf0d797c1bb7d9a485e61fe8e7722845b95ef978"}, + {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:97afb3a00b65cc0804d1c7abddbf090a81eaac02768af58cbdcaaa0a931e0b6d"}, + {file = "pillow-11.3.0-cp39-cp39-win32.whl", hash = "sha256:ea944117a7974ae78059fcc1800e5d3295172bb97035c0c1d9345fca1419da71"}, + {file = "pillow-11.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:e5c5858ad8ec655450a7c7df532e9842cf8df7cc349df7225c60d5d348c8aada"}, + {file = "pillow-11.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:6abdbfd3aea42be05702a8dd98832329c167ee84400a1d1f61ab11437f1717eb"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8"}, + {file = "pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] +typing = ["typing-extensions ; python_version < \"3.10\""] +xmp = ["defusedxml"] + [[package]] name = "psycopg2-binary" version = "2.9.10" @@ -823,6 +972,21 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python-multipart" +version = "0.0.9" +description = "A streaming multipart parser for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"}, + {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"}, +] + +[package.extras] +dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatch", "invoke (==2.2.0)", "more-itertools (==10.2.0)", "pbr (==6.0.0)", "pluggy (==1.4.0)", "py (==1.11.0)", "pytest (==8.0.0)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.2.0)", "pyyaml (==6.0.1)", "ruff (==0.2.1)"] + [[package]] name = "six" version = "1.17.0" @@ -1029,4 +1193,4 @@ standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3) [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "f06e0751df9dd48560a703ad2476202a4aed26dc4e684f57c0985193cf94b5f5" +content-hash = "6677e8a4c7cf456e65e905e6bd6850ab86fb17ad650511a001c62dca39005339" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3429d88..40793c2 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -25,6 +25,10 @@ argon2-cffi = "^25.1.0" pyjwt = "^2.10.1" email-validator = "^2.2.0" bcrypt = "4.0.1" +python-multipart = "^0.0.9" +mutagen = ">=1.45.0" +aiofiles = "^24.1.0" +Pillow = "^11.1.0" diff --git a/backend/seed_users_sql.py b/backend/seed_users_sql.py new file mode 100644 index 0000000..11aa278 --- /dev/null +++ b/backend/seed_users_sql.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +""" +SQL-based User Seeding Script for Music Player API + +This script creates test users (admin, musician, listener) for development and testing. +Users are created with credentials from the .env file using raw SQL to avoid model issues. +""" + +import os +import sys +from pathlib import Path +from datetime import datetime, timezone + +# Add the backend directory to Python path +backend_dir = Path(__file__).parent +sys.path.insert(0, str(backend_dir)) + +from sqlalchemy.orm import Session +from sqlalchemy import text +from app.db.session import get_db +from app.core.config import settings +from app.core.security import hash_password + + +def load_env_vars(): + """Load environment variables from settings with fallbacks""" + # Check if test credentials are configured + if not settings.TEST_ADMIN_USERNAME: + print("Test credentials not found in .env file") + print("Please add test credentials to your .env file:") + print(""" +# Test User Credentials +TEST_ADMIN_USERNAME=test_admin +TEST_ADMIN_EMAIL=admin@test.com +TEST_ADMIN_PASSWORD=AdminPass123! +TEST_ADMIN_FIRST_NAME=Test +TEST_ADMIN_LAST_NAME=Admin + +TEST_MUSICIAN_USERNAME=test_musician +TEST_MUSICIAN_EMAIL=musician@test.com +TEST_MUSICIAN_PASSWORD=MusicianPass123! +TEST_MUSICIAN_FIRST_NAME=Test +TEST_MUSICIAN_LAST_NAME=Musician +TEST_MUSICIAN_STAGE_NAME=Test Musician +TEST_MUSICIAN_BIO=A test musician for development + +TEST_LISTENER_USERNAME=test_listener +TEST_LISTENER_EMAIL=listener@test.com +TEST_LISTENER_PASSWORD=ListenerPass123! +TEST_LISTENER_FIRST_NAME=Test +TEST_LISTENER_LAST_NAME=Listener + """) + return None + + return { + 'admin': { + 'username': settings.TEST_ADMIN_USERNAME, + 'email': settings.TEST_ADMIN_EMAIL, + 'password': settings.TEST_ADMIN_PASSWORD, + 'first_name': settings.TEST_ADMIN_FIRST_NAME, + 'last_name': settings.TEST_ADMIN_LAST_NAME, + 'role': 'admin' + }, + 'musician': { + 'username': settings.TEST_MUSICIAN_USERNAME, + 'email': settings.TEST_MUSICIAN_EMAIL, + 'password': settings.TEST_MUSICIAN_PASSWORD, + 'first_name': settings.TEST_MUSICIAN_FIRST_NAME, + 'last_name': settings.TEST_MUSICIAN_LAST_NAME, + 'role': 'musician', + 'stage_name': settings.TEST_MUSICIAN_STAGE_NAME, + 'bio': settings.TEST_MUSICIAN_BIO + }, + 'listener': { + 'username': settings.TEST_LISTENER_USERNAME, + 'email': settings.TEST_LISTENER_EMAIL, + 'password': settings.TEST_LISTENER_PASSWORD, + 'first_name': settings.TEST_LISTENER_FIRST_NAME, + 'last_name': settings.TEST_LISTENER_LAST_NAME, + 'role': 'listener' + } + } + + +def create_test_user_sql(db: Session, user_data: dict, user_type: str): + """Create a test user using raw SQL""" + # Check if user already exists + result = db.execute( + text("SELECT id, username, role FROM users WHERE username = :username OR email = :email"), + {"username": user_data['username'], "email": user_data['email']} + ).first() + + if result: + # If user exists but has wrong role, update it + if result.role != user_data['role']: + db.execute( + text("UPDATE users SET role = :role WHERE id = :id"), + {"role": user_data['role'], "id": result.id} + ) + db.commit() + print(f"Updated {user_type.capitalize()} user '{user_data['username']}' role from '{result.role}' to '{user_data['role']}' (ID: {result.id})") + else: + print(f"{user_type.capitalize()} user '{user_data['username']}' already exists with correct role (ID: {result.id})") + return result.id + + # Hash password + hashed_password = hash_password(user_data['password']) + now = datetime.now(timezone.utc) + + # Create user using SQL + result = db.execute( + text(""" + INSERT INTO users (username, email, password, first_name, last_name, role, created_at, is_active) + VALUES (:username, :email, :password, :first_name, :last_name, :role, :created_at, :is_active) + RETURNING id + """), + { + "username": user_data['username'], + "email": user_data['email'], + "password": hashed_password, + "first_name": user_data['first_name'], + "last_name": user_data['last_name'], + "role": user_data['role'], + "created_at": now, + "is_active": True + } + ) + + user_id = result.scalar() + db.commit() + print(f"Created {user_type} user: {user_data['username']} (ID: {user_id})") + return user_id + + +def create_test_artist_sql(db: Session, user_id: int, artist_data: dict): + """Create a test artist profile using raw SQL""" + # Check if artist profile already exists + result = db.execute( + text("SELECT id FROM artists WHERE linked_user_account = :user_id"), + {"user_id": user_id} + ).first() + + if result: + print(f"Artist profile for user ID {user_id} already exists (ID: {result.id})") + return result.id + + now = datetime.now(timezone.utc) + + # Create artist using SQL + result = db.execute( + text(""" + INSERT INTO artists (artist_stage_name, artist_bio, artist_profile_image, artist_social_link, + linked_user_account, created_at, is_disabled) + VALUES (:stage_name, :bio, :profile_image, :social_link, :user_id, :created_at, :is_disabled) + RETURNING id + """), + { + "stage_name": artist_data['stage_name'], + "bio": artist_data['bio'], + "profile_image": None, + "social_link": None, + "user_id": user_id, + "created_at": now, + "is_disabled": False + } + ) + + artist_id = result.scalar() + db.commit() + print(f"Created artist profile: {artist_data['stage_name']} (ID: {artist_id})") + return artist_id + + +def main(): + """Main seeding function""" + print("Music Player API - User Seeding Script") + print("=" * 50) + + # Load environment variables + try: + env_vars = load_env_vars() + if env_vars is None: + return + print("Loaded environment variables from settings") + except Exception as e: + print(f"Error loading environment variables: {e}") + return + + # Get database session + db = next(get_db()) + + try: + # Create admin user + print("\nCreating admin user...") + admin_user_id = create_test_user_sql(db, env_vars['admin'], 'admin') + + # Create musician user + print("\nCreating musician user...") + musician_user_id = create_test_user_sql(db, env_vars['musician'], 'musician') + + # Create artist profile for musician + print("\nCreating artist profile...") + create_test_artist_sql(db, musician_user_id, env_vars['musician']) + + # Create listener user + print("\nCreating listener user...") + listener_user_id = create_test_user_sql(db, env_vars['listener'], 'listener') + + print("\n" + "=" * 50) + print("Seeding completed successfully!") + print("\nTest User Credentials:") + print(f"Admin: {env_vars['admin']['username']} / {env_vars['admin']['password']}") + print(f"Musician: {env_vars['musician']['username']} / {env_vars['musician']['password']}") + print(f"Listener: {env_vars['listener']['username']} / {env_vars['listener']['password']}") + print("\nUse these credentials to test the API endpoints!") + + except Exception as e: + print(f"Error during seeding: {e}") + import traceback + traceback.print_exc() + db.rollback() + finally: + db.close() + + +if __name__ == "__main__": + main()