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/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/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/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/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..d6e7a47 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -35,51 +35,29 @@ 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 # 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") # CORS configuration app.add_middleware( @@ -119,6 +97,12 @@ 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", "health": "/health" } } 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/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/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()