diff --git a/AGENTS.md b/AGENTS.md
index 25dbefa..b4ec1c1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,7 +5,7 @@
## OVERVIEW
-Personal music analytics dashboard polling Spotify 24/7. Core stack: Python (FastAPI, SQLAlchemy, SQLite) + React (Vite, Tailwind, AntD). Integrates AI (Gemini) for listening narratives.
+Personal music analytics dashboard polling Spotify 24/7. Core stack: Python (FastAPI, SQLAlchemy, PostgreSQL) + React (Vite, Tailwind, AntD). Integrates AI (Gemini) for listening narratives.
## STRUCTURE
@@ -54,7 +54,7 @@ Personal music analytics dashboard polling Spotify 24/7. Core stack: Python (Fas
## CONVENTIONS
- **Single Container Multi-Process**: `backend/entrypoint.sh` starts worker + API (Docker anti-pattern, project-specific).
-- **SQLite Persistence**: Production uses SQLite (`music.db`) via Docker volumes.
+- **PostgreSQL Persistence**: Production uses PostgreSQL on internal server (100.91.248.114:5433, database: music_db).
- **Deduplication**: Ingestion checks `(track_id, played_at)` unique constraint before insert.
- **Frontend State**: Minimal global state; primarily local component state and API fetching.
diff --git a/README.md b/README.md
index e9d74b9..f485523 100644
--- a/README.md
+++ b/README.md
@@ -79,25 +79,26 @@ Open your browser to: **http://localhost:8991**
┌────────┴────────┐
▼ ▼
┌──────────┐ ┌──────────────┐
- │ SQLite │ │ Spotify API │
- │ music.db │ │ Gemini AI │
+ │PostgreSQL│ │ Spotify API │
+ │ music_db │ │ Gemini AI │
└──────────┘ └──────────────┘
```
- **Backend Container**: Runs both the FastAPI server AND the background Spotify polling worker
- **Frontend Container**: Nginx serving the React build, proxies `/api/` to backend
-- **Database**: SQLite stored in a Docker named volume (`music_data`) for persistence
+- **Database**: PostgreSQL hosted on internal server (100.91.248.114:5433)
## Data Persistence
-Your listening history is stored in a Docker named volume:
-- Volume name: `music_data`
-- Database file: `/app/music.db`
-- Migrations run automatically on container startup
+Your listening history is stored in PostgreSQL:
+- Host: `100.91.248.114:5433`
+- Database: `music_db`
+- Data Location (on server): `/opt/DB/MusicDB/pgdata`
+- Migrations run automatically on container startup via Alembic
To backup your data:
```bash
-docker cp $(docker-compose ps -q backend):/app/music.db ./backup.db
+pg_dump -h 100.91.248.114 -p 5433 -U bnair music_db > backup.sql
```
## Local Development
@@ -136,4 +137,4 @@ Access at http://localhost:5173 (Vite proxies `/api` to backend automatically)
| `SPOTIFY_REFRESH_TOKEN` | Yes | Long-lived refresh token from OAuth |
| `GEMINI_API_KEY` | Yes | Google Gemini API key |
| `GENIUS_ACCESS_TOKEN` | No | Genius API token for lyrics |
-| `DATABASE_URL` | No | SQLite path (default: `sqlite:///./music.db`) |
+| `DATABASE_URL` | No | PostgreSQL URL (default: `postgresql://bnair:Bharath2002@100.91.248.114:5433/music_db`) |
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 1b9284f..4fde322 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -3,9 +3,10 @@ FROM python:3.11-slim
WORKDIR /app
-# Install system dependencies
+# Install system dependencies (including PostgreSQL client libs for psycopg2)
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
+ libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
diff --git a/backend/alembic/versions/24fafb6f6e98_add_composition_to_snapshot.py b/backend/alembic/versions/24fafb6f6e98_add_composition_to_snapshot.py
new file mode 100644
index 0000000..c04fee9
--- /dev/null
+++ b/backend/alembic/versions/24fafb6f6e98_add_composition_to_snapshot.py
@@ -0,0 +1,32 @@
+"""add_composition_to_snapshot
+
+Revision ID: 24fafb6f6e98
+Revises: 86ea83950f3d
+Create Date: 2025-12-30 10:43:05.933962
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '24fafb6f6e98'
+down_revision: Union[str, Sequence[str], None] = '86ea83950f3d'
+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('analysis_snapshots', sa.Column('playlist_composition', sa.JSON(), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('analysis_snapshots', 'playlist_composition')
+ # ### end Alembic commands ###
diff --git a/backend/alembic/versions/7e28cc511ef8_add_playlist_config_table.py b/backend/alembic/versions/7e28cc511ef8_add_playlist_config_table.py
new file mode 100644
index 0000000..e4ca7f1
--- /dev/null
+++ b/backend/alembic/versions/7e28cc511ef8_add_playlist_config_table.py
@@ -0,0 +1,41 @@
+"""add_playlist_config_table
+
+Revision ID: 7e28cc511ef8
+Revises: 5ed73db9bab9
+Create Date: 2025-12-30 10:30:36.775553
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '7e28cc511ef8'
+down_revision: Union[str, Sequence[str], None] = '5ed73db9bab9'
+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.create_table('playlist_config',
+ sa.Column('key', sa.String(), nullable=False),
+ sa.Column('spotify_id', sa.String(), nullable=False),
+ sa.Column('last_updated', sa.DateTime(), nullable=True),
+ sa.Column('current_theme', sa.String(), nullable=True),
+ sa.Column('description', sa.String(), nullable=True),
+ sa.PrimaryKeyConstraint('key')
+ )
+ op.create_index(op.f('ix_playlist_config_key'), 'playlist_config', ['key'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_playlist_config_key'), table_name='playlist_config')
+ op.drop_table('playlist_config')
+ # ### end Alembic commands ###
diff --git a/backend/alembic/versions/86ea83950f3d_add_composition_to_playlist_config.py b/backend/alembic/versions/86ea83950f3d_add_composition_to_playlist_config.py
new file mode 100644
index 0000000..5157f91
--- /dev/null
+++ b/backend/alembic/versions/86ea83950f3d_add_composition_to_playlist_config.py
@@ -0,0 +1,32 @@
+"""add_composition_to_playlist_config
+
+Revision ID: 86ea83950f3d
+Revises: 7e28cc511ef8
+Create Date: 2025-12-30 10:39:27.121477
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '86ea83950f3d'
+down_revision: Union[str, Sequence[str], None] = '7e28cc511ef8'
+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('playlist_config', sa.Column('composition', sa.JSON(), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('playlist_config', 'composition')
+ # ### end Alembic commands ###
diff --git a/backend/app/database.py b/backend/app/database.py
index aa92780..acfb73b 100644
--- a/backend/app/database.py
+++ b/backend/app/database.py
@@ -2,17 +2,33 @@ import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
-SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./music.db")
+# PostgreSQL connection configuration
+# Uses docker hostname 'music_db' when running in container, falls back to external IP for local dev
+POSTGRES_HOST = os.getenv("POSTGRES_HOST", "music_db")
+POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432")
+POSTGRES_USER = os.getenv("POSTGRES_USER", "bnair")
+POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "Bharath2002")
+POSTGRES_DB = os.getenv("POSTGRES_DB", "music_db")
-connect_args = {}
-if SQLALCHEMY_DATABASE_URL.startswith("sqlite"):
- connect_args["check_same_thread"] = False
+# Build the PostgreSQL URL
+# Format: postgresql://user:password@host:port/database
+SQLALCHEMY_DATABASE_URL = os.getenv(
+ "DATABASE_URL",
+ f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}",
+)
-engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args=connect_args)
+# PostgreSQL connection pool settings for production
+engine = create_engine(
+ SQLALCHEMY_DATABASE_URL,
+ pool_size=5, # Maintain 5 connections in the pool
+ max_overflow=10, # Allow up to 10 additional connections
+ pool_pre_ping=True, # Verify connection health before using
+)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
+
def get_db():
db = SessionLocal()
try:
diff --git a/backend/app/main.py b/backend/app/main.py
index 5e2191b..cc31fb7 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -10,6 +10,7 @@ from .models import (
PlayHistory as PlayHistoryModel,
Track as TrackModel,
AnalysisSnapshot,
+ PlaylistConfig,
)
from . import schemas
from .ingest import (
@@ -220,13 +221,18 @@ async def refresh_six_hour_playlist(db: Session = Depends(get_db)):
end_date = datetime.utcnow()
start_date = end_date - timedelta(hours=6)
+ spotify_client = get_spotify_client()
playlist_service = PlaylistService(
db=db,
- spotify_client=get_spotify_client(),
+ spotify_client=spotify_client,
recco_client=get_reccobeats_client(),
narrative_service=NarrativeService(),
)
+ # Ensure playlists exist (creates on Spotify if needed)
+ user_id = await spotify_client.get_current_user_id()
+ await playlist_service.ensure_playlists_exist(user_id)
+
result = await playlist_service.curate_six_hour_playlist(start_date, end_date)
snapshot = AnalysisSnapshot(
@@ -239,6 +245,7 @@ async def refresh_six_hour_playlist(db: Session = Depends(get_db)):
playlist_theme=result.get("theme_name"),
playlist_theme_reasoning=result.get("description"),
six_hour_playlist_id=result.get("playlist_id"),
+ playlist_composition=result.get("composition"),
)
db.add(snapshot)
db.commit()
@@ -256,13 +263,18 @@ async def refresh_daily_playlist(db: Session = Depends(get_db)):
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=1)
+ spotify_client = get_spotify_client()
playlist_service = PlaylistService(
db=db,
- spotify_client=get_spotify_client(),
+ spotify_client=spotify_client,
recco_client=get_reccobeats_client(),
narrative_service=NarrativeService(),
)
+ # Ensure playlists exist (creates on Spotify if needed)
+ user_id = await spotify_client.get_current_user_id()
+ await playlist_service.ensure_playlists_exist(user_id)
+
result = await playlist_service.curate_daily_playlist(start_date, end_date)
snapshot = AnalysisSnapshot(
@@ -273,6 +285,7 @@ async def refresh_daily_playlist(db: Session = Depends(get_db)):
metrics_payload={},
narrative_report={},
daily_playlist_id=result.get("playlist_id"),
+ playlist_composition=result.get("composition"),
)
db.add(snapshot)
db.commit()
@@ -286,32 +299,71 @@ async def refresh_daily_playlist(db: Session = Depends(get_db)):
@app.get("/playlists")
async def get_playlists_metadata(db: Session = Depends(get_db)):
"""Returns metadata for the managed playlists."""
- latest_snapshot = (
- db.query(AnalysisSnapshot)
- .filter(AnalysisSnapshot.six_hour_playlist_id != None)
- .order_by(AnalysisSnapshot.date.desc())
- .first()
+
+ six_hour_config = (
+ db.query(PlaylistConfig).filter(PlaylistConfig.key == "six_hour").first()
+ )
+ daily_config = (
+ db.query(PlaylistConfig).filter(PlaylistConfig.key == "daily").first()
)
return {
"six_hour": {
- "id": latest_snapshot.six_hour_playlist_id
- if latest_snapshot
+ "id": six_hour_config.spotify_id
+ if six_hour_config
else os.getenv("SIX_HOUR_PLAYLIST_ID"),
- "theme": latest_snapshot.playlist_theme if latest_snapshot else "N/A",
- "reasoning": latest_snapshot.playlist_theme_reasoning
- if latest_snapshot
- else "N/A",
- "last_refresh": latest_snapshot.date.isoformat()
- if latest_snapshot
+ "theme": six_hour_config.current_theme if six_hour_config else "N/A",
+ "reasoning": six_hour_config.description if six_hour_config else "N/A",
+ "last_refresh": six_hour_config.last_updated.isoformat()
+ if six_hour_config
else None,
+ "composition": six_hour_config.composition if six_hour_config else [],
},
"daily": {
- "id": latest_snapshot.daily_playlist_id
- if latest_snapshot
+ "id": daily_config.spotify_id
+ if daily_config
else os.getenv("DAILY_PLAYLIST_ID"),
- "last_refresh": latest_snapshot.date.isoformat()
- if latest_snapshot
+ "theme": daily_config.current_theme if daily_config else "N/A",
+ "reasoning": daily_config.description if daily_config else "N/A",
+ "last_refresh": daily_config.last_updated.isoformat()
+ if daily_config
else None,
+ "composition": daily_config.composition if daily_config else [],
},
}
+
+
+@app.get("/playlists/history")
+def get_playlist_history(
+ limit: int = Query(default=20, ge=1, le=100),
+ db: Session = Depends(get_db),
+):
+ """Returns historical playlist snapshots."""
+ snapshots = (
+ db.query(AnalysisSnapshot)
+ .filter(
+ (AnalysisSnapshot.playlist_theme.isnot(None))
+ | (AnalysisSnapshot.six_hour_playlist_id.isnot(None))
+ | (AnalysisSnapshot.daily_playlist_id.isnot(None))
+ )
+ .order_by(AnalysisSnapshot.date.desc())
+ .limit(limit)
+ .all()
+ )
+
+ result = []
+ for snap in snapshots:
+ result.append(
+ {
+ "id": snap.id,
+ "date": snap.date.isoformat() if snap.date else None,
+ "period_label": snap.period_label,
+ "theme": snap.playlist_theme,
+ "reasoning": snap.playlist_theme_reasoning,
+ "six_hour_id": snap.six_hour_playlist_id,
+ "daily_id": snap.daily_playlist_id,
+ "composition": snap.playlist_composition or [],
+ }
+ )
+
+ return {"history": result}
diff --git a/backend/app/models.py b/backend/app/models.py
index 90a8643..b2e346f 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -130,3 +130,18 @@ class AnalysisSnapshot(Base):
daily_playlist_id = Column(
String, nullable=True
) # Spotify playlist ID for 24-hour playlist
+ playlist_composition = Column(JSON, nullable=True)
+ playlist_composition = Column(
+ JSON, nullable=True
+ ) # Store the track list at this snapshot
+
+
+class PlaylistConfig(Base):
+ __tablename__ = "playlist_config"
+
+ key = Column(String, primary_key=True, index=True) # e.g., "six_hour", "daily"
+ spotify_id = Column(String, nullable=False)
+ last_updated = Column(DateTime, default=datetime.utcnow)
+ current_theme = Column(String, nullable=True)
+ description = Column(String, nullable=True)
+ composition = Column(JSON, nullable=True)
diff --git a/backend/app/services/genius_client.py b/backend/app/services/genius_client.py
index a67511b..c68daba 100644
--- a/backend/app/services/genius_client.py
+++ b/backend/app/services/genius_client.py
@@ -1,35 +1,103 @@
import os
-import lyricsgenius
+import requests
from typing import Optional, Dict, Any
+import re
+
class GeniusClient:
def __init__(self):
self.access_token = os.getenv("GENIUS_ACCESS_TOKEN")
- if self.access_token:
- self.genius = lyricsgenius.Genius(self.access_token, verbose=False, remove_section_headers=True)
- else:
- print("WARNING: GENIUS_ACCESS_TOKEN not found. Lyrics enrichment will be skipped.")
+ self.base_url = "https://api.genius.com"
+ self.headers = (
+ {"Authorization": f"Bearer {self.access_token}"}
+ if self.access_token
+ else {}
+ )
+
+ if not self.access_token:
+ print(
+ "WARNING: GENIUS_ACCESS_TOKEN not found. Lyrics enrichment will be skipped."
+ )
self.genius = None
+ else:
+ self.genius = True
def search_song(self, title: str, artist: str) -> Optional[Dict[str, Any]]:
- """
- Searches for a song on Genius and returns metadata + lyrics.
- """
if not self.genius:
return None
try:
- # Clean up title (remove "Feat.", "Remastered", etc for better search match)
clean_title = title.split(" - ")[0].split("(")[0].strip()
- song = self.genius.search_song(clean_title, artist)
-
- if song:
- return {
- "lyrics": song.lyrics,
- "image_url": song.song_art_image_url,
- "artist_image_url": song.primary_artist.image_url
- }
+ query = f"{clean_title} {artist}"
+
+ response = requests.get(
+ f"{self.base_url}/search",
+ headers=self.headers,
+ params={"q": query},
+ timeout=10,
+ )
+
+ if response.status_code != 200:
+ print(f"Genius API Error: {response.status_code}")
+ return None
+
+ data = response.json()
+ hits = data.get("response", {}).get("hits", [])
+
+ if not hits:
+ return None
+
+ song = hits[0]["result"]
+
+ lyrics = self._scrape_lyrics(song.get("url")) if song.get("url") else None
+
+ return {
+ "lyrics": lyrics,
+ "image_url": song.get("song_art_image_url")
+ or song.get("header_image_url"),
+ "artist_image_url": song.get("primary_artist", {}).get("image_url"),
+ }
except Exception as e:
print(f"Genius Search Error for {title} by {artist}: {e}")
-
+
return None
+
+ def _scrape_lyrics(self, url: str) -> Optional[str]:
+ try:
+ headers = {
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
+ "Accept-Language": "en-US,en;q=0.5",
+ }
+ response = requests.get(url, headers=headers, timeout=10)
+
+ if response.status_code != 200:
+ return None
+
+ html = response.text
+
+ lyrics_divs = re.findall(
+ r'
]*data-lyrics-container="true"[^>]*>(.*?)
',
+ html,
+ re.DOTALL,
+ )
+
+ if not lyrics_divs:
+ return None
+
+ lyrics = ""
+ for div in lyrics_divs:
+ text = re.sub(r"
", "\n", div)
+ text = re.sub(r"<[^>]+>", "", text)
+ text = (
+ text.replace("&", "&")
+ .replace(""", '"')
+ .replace("'", "'")
+ )
+ lyrics += text + "\n"
+
+ return lyrics.strip() if lyrics.strip() else None
+
+ except Exception as e:
+ print(f"Lyrics scrape error: {e}")
+ return None
diff --git a/backend/app/services/narrative_service.py b/backend/app/services/narrative_service.py
index beea657..080b823 100644
--- a/backend/app/services/narrative_service.py
+++ b/backend/app/services/narrative_service.py
@@ -119,6 +119,7 @@ class NarrativeService:
2. Provide a "description" (2-3 sentences explaining why).
3. Identify 10-15 "curated_tracks" (song names only) that fit this vibe and the artists listed.
4. Return ONLY valid JSON.
+5. Do NOT output internal variable names (e.g. 'part_of_day', 'avg_valence') in the description. Translate them to natural language (e.g. 'morning listens', 'happy vibe').
**REQUIRED JSON:**
{{
@@ -192,6 +193,7 @@ class NarrativeService:
2. Be specific - reference actual metrics from the data.
3. Be playful but not cruel.
4. Return ONLY valid JSON.
+5. Translate all technical metrics (e.g. 'discovery_rate', 'valence', 'hhi') into natural language descriptions. Do NOT use the variable names themselves.
**LISTENING HIGHLIGHTS:**
- Peak listening: {peak_listening}
diff --git a/backend/app/services/playlist_service.py b/backend/app/services/playlist_service.py
index 627f6c0..555b1a6 100644
--- a/backend/app/services/playlist_service.py
+++ b/backend/app/services/playlist_service.py
@@ -24,89 +24,239 @@ class PlaylistService:
async def ensure_playlists_exist(self, user_id: str) -> Dict[str, str]:
"""Check/create playlists. Returns {six_hour_id, daily_id}."""
- six_hour_env = os.getenv("SIX_HOUR_PLAYLIST_ID")
- daily_env = os.getenv("DAILY_PLAYLIST_ID")
+ from app.models import PlaylistConfig
- if not six_hour_env:
- six_hour_data = await self.spotify.create_playlist(
- user_id=user_id,
- name="Short and Sweet",
- description="AI-curated 6-hour playlists based on your listening habits",
- )
- six_hour_env = str(six_hour_data["id"])
+ six_hour_config = (
+ self.db.query(PlaylistConfig)
+ .filter(PlaylistConfig.key == "six_hour")
+ .first()
+ )
+ daily_config = (
+ self.db.query(PlaylistConfig).filter(PlaylistConfig.key == "daily").first()
+ )
- if not daily_env:
- daily_data = await self.spotify.create_playlist(
- user_id=user_id,
- name="Proof of Commitment",
- description="Your daily 24-hour mix showing your music journey",
- )
- daily_env = str(daily_data["id"])
+ six_hour_id = six_hour_config.spotify_id if six_hour_config else None
+ daily_id = daily_config.spotify_id if daily_config else None
- return {"six_hour_id": str(six_hour_env), "daily_id": str(daily_env)}
+ if not six_hour_id:
+ six_hour_id = os.getenv("SIX_HOUR_PLAYLIST_ID")
+ if not six_hour_id:
+ six_hour_data = await self.spotify.create_playlist(
+ user_id=user_id,
+ name="Short and Sweet",
+ description="AI-curated 6-hour playlists based on your listening habits",
+ )
+ six_hour_id = str(six_hour_data["id"])
+
+ self._save_playlist_config("six_hour", six_hour_id, "Short and Sweet")
+
+ if not daily_id:
+ daily_id = os.getenv("DAILY_PLAYLIST_ID")
+ if not daily_id:
+ daily_data = await self.spotify.create_playlist(
+ user_id=user_id,
+ name="Proof of Commitment",
+ description="Your daily 24-hour mix showing your music journey",
+ )
+ daily_id = str(daily_data["id"])
+
+ self._save_playlist_config("daily", daily_id, "Proof of Commitment")
+
+ return {"six_hour_id": six_hour_id, "daily_id": daily_id}
+
+ def _save_playlist_config(
+ self,
+ key: str,
+ spotify_id: str,
+ description: str = None,
+ theme: str = None,
+ composition: List[Dict[str, Any]] = None,
+ ):
+ from app.models import PlaylistConfig
+
+ config = self.db.query(PlaylistConfig).filter(PlaylistConfig.key == key).first()
+ if not config:
+ config = PlaylistConfig(key=key, spotify_id=spotify_id)
+ self.db.add(config)
+ else:
+ config.spotify_id = spotify_id
+
+ if description:
+ config.description = description
+ if theme:
+ config.current_theme = theme
+ if composition:
+ config.composition = composition
+
+ config.last_updated = datetime.utcnow()
+ self.db.commit()
+
+ async def _hydrate_tracks(
+ self, track_ids: List[str], sources: Dict[str, str]
+ ) -> List[Dict[str, Any]]:
+ """Fetch full track details for a list of IDs."""
+ from app.models import Track
+
+ db_tracks = self.db.query(Track).filter(Track.id.in_(track_ids)).all()
+ track_map = {t.id: t for t in db_tracks}
+
+ missing_ids = [tid for tid in track_ids if tid not in track_map]
+
+ if missing_ids:
+ spotify_tracks = await self.spotify.get_tracks(missing_ids)
+ for st in spotify_tracks:
+ if not st:
+ continue
+ track_map[st["id"]] = {
+ "id": st["id"],
+ "name": st["name"],
+ "artist": ", ".join([a["name"] for a in st["artists"]]),
+ "image": st["album"]["images"][0]["url"]
+ if st["album"]["images"]
+ else None,
+ "uri": st["uri"],
+ }
+
+ result = []
+ for tid in track_ids:
+ track = track_map.get(tid)
+ if not track:
+ continue
+
+ if hasattr(track, "name") and not isinstance(track, dict):
+ track_data = {
+ "id": track.id,
+ "name": track.name,
+ "artist": track.artist,
+ "image": track.image_url,
+ "uri": f"spotify:track:{track.id}",
+ }
+ else:
+ track_data = track
+
+ track_data["source"] = sources.get(tid, "unknown")
+ result.append(track_data)
+
+ return result
async def curate_six_hour_playlist(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
"""Generate 6-hour playlist (15 curated + 15 recommendations)."""
- from app.models import Track
+ from app.models import Track, PlayHistory
from app.services.stats_service import StatsService
+ from sqlalchemy import func
stats = StatsService(self.db)
data = stats.generate_full_report(period_start, period_end)
+ top_tracks_period = [t["id"] for t in data["volume"].get("top_tracks", [])][:15]
+
+ if len(top_tracks_period) < 5:
+ fallback_tracks = (
+ self.db.query(Track.id, func.count(PlayHistory.id).label("cnt"))
+ .join(PlayHistory, Track.id == PlayHistory.track_id)
+ .group_by(Track.id)
+ .order_by(func.count(PlayHistory.id).desc())
+ .limit(15)
+ .all()
+ )
+ top_tracks_period = [tid for tid, _ in fallback_tracks]
+
listening_data = {
- "peak_hour": data["time_habits"]["peak_hour"],
- "avg_energy": data["vibe"]["avg_energy"],
- "avg_valence": data["vibe"]["avg_valence"],
- "total_plays": data["volume"]["total_plays"],
- "top_artists": data["volume"]["top_artists"][:10],
+ "peak_hour": data["time_habits"].get("peak_hour", 12),
+ "avg_energy": data["vibe"].get("avg_energy", 0.5),
+ "avg_valence": data["vibe"].get("avg_valence", 0.5),
+ "total_plays": data["volume"].get("total_plays", 0),
+ "top_artists": data["volume"].get("top_artists", [])[:10],
}
theme_result = self.narrative.generate_playlist_theme(listening_data)
- curated_track_names = theme_result.get("curated_tracks", [])
- curated_tracks: List[str] = []
- for name in curated_track_names:
- track = self.db.query(Track).filter(Track.name.ilike(f"%{name}%")).first()
- if track:
- curated_tracks.append(str(track.id))
+ curated_details = []
+ for tid in top_tracks_period:
+ track_obj = self.db.query(Track).filter(Track.id == tid).first()
+ if track_obj:
+ curated_details.append(
+ {
+ "id": str(track_obj.id),
+ "energy": track_obj.energy,
+ "source": "history",
+ }
+ )
- recommendations: List[str] = []
- if curated_tracks:
- recs = await self.recco.get_recommendations(
- seed_ids=curated_tracks[:5],
+ rec_details = []
+ seed_ids = top_tracks_period[:5] if top_tracks_period else []
+ if seed_ids:
+ raw_recs = await self.recco.get_recommendations(
+ seed_ids=seed_ids,
size=15,
)
- recommendations = [
- str(r.get("spotify_id") or r.get("id"))
- for r in recs
- if r.get("spotify_id") or r.get("id")
- ]
+ for r in raw_recs:
+ rec_id = str(r.get("spotify_id") or r.get("id"))
+ if rec_id:
+ rec_details.append(
+ {
+ "id": rec_id,
+ "energy": r.get("energy"),
+ "source": "recommendation",
+ }
+ )
- final_tracks = curated_tracks[:15] + recommendations[:15]
+ all_candidates = curated_details[:15] + rec_details[:15]
+ optimized_tracks = self._optimize_playlist_flow(all_candidates)
+
+ final_track_ids = [t["id"] for t in optimized_tracks]
+ sources = {t["id"]: t["source"] for t in optimized_tracks}
+
+ # Hydrate for persistence/display
+ full_tracks = await self._hydrate_tracks(final_track_ids, sources)
+
+ playlist_id = None
+ from app.models import PlaylistConfig
+
+ config = (
+ self.db.query(PlaylistConfig)
+ .filter(PlaylistConfig.key == "six_hour")
+ .first()
+ )
+ if config:
+ playlist_id = config.spotify_id
+
+ if not playlist_id:
+ playlist_id = os.getenv("SIX_HOUR_PLAYLIST_ID")
- playlist_id = os.getenv("SIX_HOUR_PLAYLIST_ID")
if playlist_id:
+ theme_name = f"Short and Sweet - {theme_result['theme_name']}"
+ desc = f"{theme_result['description']}\n\nCurated: {len(curated_details)} tracks + {len(rec_details)} recommendations"
+
await self.spotify.update_playlist_details(
playlist_id=playlist_id,
- name=f"Short and Sweet - {theme_result['theme_name']}",
- description=(
- f"{theme_result['description']}\n\nCurated: {len(curated_tracks)} tracks + {len(recommendations)} recommendations"
- ),
+ name=theme_name,
+ description=desc,
)
await self.spotify.replace_playlist_tracks(
playlist_id=playlist_id,
- track_uris=[f"spotify:track:{tid}" for tid in final_tracks],
+ track_uris=[f"spotify:track:{tid}" for tid in final_track_ids],
+ )
+
+ self._save_playlist_config(
+ "six_hour",
+ playlist_id,
+ description=desc,
+ theme=theme_result["theme_name"],
+ composition=full_tracks,
)
return {
"playlist_id": playlist_id,
"theme_name": theme_result["theme_name"],
"description": theme_result["description"],
- "track_count": len(final_tracks),
- "curated_count": len(curated_tracks),
- "rec_count": len(recommendations),
+ "track_count": len(final_track_ids),
+ "sources": sources,
+ "composition": full_tracks,
+ "curated_count": len(curated_details),
+ "rec_count": len(rec_details),
"refreshed_at": datetime.utcnow().isoformat(),
}
@@ -120,33 +270,86 @@ class PlaylistService:
stats = StatsService(self.db)
data = stats.generate_full_report(period_start, period_end)
- top_all_time = self._get_top_all_time_tracks(limit=30)
- recent_tracks = [track["id"] for track in data["volume"]["top_tracks"][:20]]
+ top_all_time_ids = self._get_top_all_time_tracks(limit=30)
+ recent_tracks_ids = [track["id"] for track in data["volume"]["top_tracks"][:20]]
- final_tracks = (top_all_time + recent_tracks)[:50]
+ favorites_details = []
+ for tid in top_all_time_ids:
+ track_obj = self.db.query(Track).filter(Track.id == tid).first()
+ if track_obj:
+ favorites_details.append(
+ {
+ "id": str(track_obj.id),
+ "energy": track_obj.energy,
+ "source": "favorite_all_time",
+ }
+ )
+
+ discovery_details = []
+ for tid in recent_tracks_ids:
+ track_obj = self.db.query(Track).filter(Track.id == tid).first()
+ if track_obj:
+ discovery_details.append(
+ {
+ "id": str(track_obj.id),
+ "energy": track_obj.energy,
+ "source": "recent_discovery",
+ }
+ )
+
+ all_candidates = favorites_details + discovery_details
+ optimized_tracks = self._optimize_playlist_flow(all_candidates)
+
+ final_track_ids = [t["id"] for t in optimized_tracks]
+ sources = {t["id"]: t["source"] for t in optimized_tracks}
+
+ # Hydrate for persistence/display
+ full_tracks = await self._hydrate_tracks(final_track_ids, sources)
+
+ playlist_id = None
+ from app.models import PlaylistConfig
+
+ config = (
+ self.db.query(PlaylistConfig).filter(PlaylistConfig.key == "daily").first()
+ )
+ if config:
+ playlist_id = config.spotify_id
+
+ if not playlist_id:
+ playlist_id = os.getenv("DAILY_PLAYLIST_ID")
- playlist_id = os.getenv("DAILY_PLAYLIST_ID")
theme_name = f"Proof of Commitment - {datetime.utcnow().date().isoformat()}"
if playlist_id:
+ desc = (
+ f"{theme_name} reflects the past 24 hours plus your all-time devotion."
+ )
await self.spotify.update_playlist_details(
playlist_id=playlist_id,
name=theme_name,
- description=(
- f"{theme_name} reflects the past 24 hours plus your all-time devotion."
- ),
+ description=desc,
)
await self.spotify.replace_playlist_tracks(
playlist_id=playlist_id,
- track_uris=[f"spotify:track:{tid}" for tid in final_tracks],
+ track_uris=[f"spotify:track:{tid}" for tid in final_track_ids],
+ )
+
+ self._save_playlist_config(
+ "daily",
+ playlist_id,
+ description=desc,
+ theme=theme_name,
+ composition=full_tracks,
)
return {
"playlist_id": playlist_id,
"theme_name": theme_name,
"description": "Daily mix refreshed with your favorites and discoveries.",
- "track_count": len(final_tracks),
- "favorites_count": len(top_all_time),
- "recent_discoveries_count": len(recent_tracks),
+ "track_count": len(final_track_ids),
+ "sources": sources,
+ "composition": full_tracks,
+ "favorites_count": len(favorites_details),
+ "recent_discoveries_count": len(discovery_details),
"refreshed_at": datetime.utcnow().isoformat(),
}
@@ -165,3 +368,29 @@ class PlaylistService:
)
return [track_id for track_id, _ in result]
+
+ def _optimize_playlist_flow(
+ self, tracks: List[Dict[str, Any]]
+ ) -> List[Dict[str, Any]]:
+ """
+ Sort tracks to create a smooth flow (Energy Ramp).
+ Strategy: Sort by energy (Low -> High -> Medium).
+ """
+ if not any("energy" in t for t in tracks):
+ return tracks
+
+ for t in tracks:
+ if "energy" not in t or t["energy"] is None:
+ t["energy"] = 0.5
+
+ sorted_tracks = sorted(tracks, key=lambda x: x["energy"])
+
+ n = len(sorted_tracks)
+ low_end = int(n * 0.3)
+ high_start = int(n * 0.7)
+
+ low_energy = sorted_tracks[:low_end]
+ medium_energy = sorted_tracks[low_end:high_start]
+ high_energy = sorted_tracks[high_start:]
+
+ return low_energy + high_energy + medium_energy
diff --git a/backend/app/services/spotify_client.py b/backend/app/services/spotify_client.py
index 096e8b4..6c945c8 100644
--- a/backend/app/services/spotify_client.py
+++ b/backend/app/services/spotify_client.py
@@ -71,6 +71,26 @@ class SpotifyClient:
return None
return response.json()
+ async def get_tracks(self, track_ids: List[str]) -> List[Dict[str, Any]]:
+ """Fetch multiple tracks by ID."""
+ if not track_ids:
+ return []
+
+ token = await self.get_access_token()
+ ids_param = ",".join(track_ids[:50])
+
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ f"{SPOTIFY_API_BASE}/tracks",
+ params={"ids": ids_param},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ if response.status_code != 200:
+ print(f"Error fetching tracks: {response.text}")
+ return []
+
+ return response.json().get("tracks", [])
+
async def get_artists(self, artist_ids: List[str]) -> List[Dict[str, Any]]:
"""
Fetches artist details (including genres) for a list of artist IDs.
diff --git a/backend/requirements.txt b/backend/requirements.txt
index b71daa5..d0c5338 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -10,7 +10,7 @@ tenacity==8.2.3
python-dateutil==2.9.0.post0
requests==2.31.0
alembic==1.13.1
+psycopg2-binary==2.9.9
scikit-learn==1.4.0
-lyricsgenius==3.0.1
google-genai==1.56.0
openai>=1.0.0
diff --git a/backend/scripts/get_refresh_token.py b/backend/scripts/get_refresh_token.py
index 2494ac4..2a542e0 100644
--- a/backend/scripts/get_refresh_token.py
+++ b/backend/scripts/get_refresh_token.py
@@ -16,8 +16,9 @@ from http.server import HTTPServer, BaseHTTPRequestHandler
# CONFIGURATION - You can hardcode these or input them when prompted
SPOTIFY_CLIENT_ID = input("Enter your Spotify Client ID: ").strip()
SPOTIFY_CLIENT_SECRET = input("Enter your Spotify Client Secret: ").strip()
-REDIRECT_URI = "http://localhost:8888/callback"
-SCOPE = "user-read-recently-played user-read-playback-state"
+REDIRECT_URI = "http://127.0.0.1:8888/callback"
+SCOPE = "user-read-recently-played user-read-playback-state playlist-modify-public playlist-modify-private"
+
class RequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
@@ -36,6 +37,7 @@ class RequestHandler(BaseHTTPRequestHandler):
# Shut down server
raise KeyboardInterrupt
+
def get_token(code):
url = "https://accounts.spotify.com/api/token"
payload = {
@@ -49,24 +51,27 @@ def get_token(code):
response = requests.post(url, data=payload)
if response.status_code == 200:
data = response.json()
- print("\n" + "="*50)
+ print("\n" + "=" * 50)
print("SUCCESS! HERE ARE YOUR CREDENTIALS")
- print("="*50)
+ print("=" * 50)
print(f"\nSPOTIFY_REFRESH_TOKEN={data['refresh_token']}")
print(f"SPOTIFY_CLIENT_ID={SPOTIFY_CLIENT_ID}")
print(f"SPOTIFY_CLIENT_SECRET={SPOTIFY_CLIENT_SECRET}")
print("\nSave these in your .env file or share them with the agent.")
- print("="*50 + "\n")
+ print("=" * 50 + "\n")
else:
print("Error getting token:", response.text)
+
def start_auth():
- auth_url = "https://accounts.spotify.com/authorize?" + urllib.parse.urlencode({
- "response_type": "code",
- "client_id": SPOTIFY_CLIENT_ID,
- "scope": SCOPE,
- "redirect_uri": REDIRECT_URI,
- })
+ auth_url = "https://accounts.spotify.com/authorize?" + urllib.parse.urlencode(
+ {
+ "response_type": "code",
+ "client_id": SPOTIFY_CLIENT_ID,
+ "scope": SCOPE,
+ "redirect_uri": REDIRECT_URI,
+ }
+ )
print(f"Opening browser to: {auth_url}")
try:
@@ -74,7 +79,7 @@ def start_auth():
except:
print(f"Could not open browser. Please manually visit: {auth_url}")
- server_address = ('', 8888)
+ server_address = ("", 8888)
httpd = HTTPServer(server_address, RequestHandler)
print("Listening on port 8888...")
try:
@@ -83,5 +88,6 @@ def start_auth():
pass
httpd.server_close()
+
if __name__ == "__main__":
start_auth()
diff --git a/backend/tests/test_playlist_service.py b/backend/tests/test_playlist_service.py
new file mode 100644
index 0000000..8e85dd2
--- /dev/null
+++ b/backend/tests/test_playlist_service.py
@@ -0,0 +1,126 @@
+import pytest
+from unittest.mock import Mock, AsyncMock, MagicMock
+from datetime import datetime
+from app.services.playlist_service import PlaylistService
+from app.models import PlaylistConfig, Track
+
+
+@pytest.fixture
+def mock_db():
+ session = MagicMock()
+ # Mock query return values
+ session.query.return_value.filter.return_value.first.return_value = None
+ return session
+
+
+@pytest.fixture
+def mock_spotify():
+ client = AsyncMock()
+ client.create_playlist.return_value = {"id": "new_playlist_id"}
+ client.get_tracks.return_value = []
+ return client
+
+
+@pytest.fixture
+def mock_recco():
+ client = AsyncMock()
+ return client
+
+
+@pytest.fixture
+def mock_narrative():
+ service = Mock()
+ service.generate_playlist_theme.return_value = {
+ "theme_name": "Test Theme",
+ "description": "Test Description",
+ "curated_tracks": [],
+ }
+ return service
+
+
+@pytest.fixture
+def playlist_service(mock_db, mock_spotify, mock_recco, mock_narrative):
+ return PlaylistService(mock_db, mock_spotify, mock_recco, mock_narrative)
+
+
+@pytest.mark.asyncio
+async def test_ensure_playlists_exist_creates_new(
+ playlist_service, mock_db, mock_spotify
+):
+ # Setup: DB empty, Env vars assumed empty (or mocked)
+ mock_db.query.return_value.filter.return_value.first.return_value = None
+
+ result = await playlist_service.ensure_playlists_exist("user123")
+
+ assert result["six_hour_id"] == "new_playlist_id"
+ assert result["daily_id"] == "new_playlist_id"
+ assert mock_spotify.create_playlist.call_count == 2
+ # Verify persistence call
+ assert mock_db.add.call_count == 2 # Once for each
+ assert mock_db.commit.call_count == 2
+
+
+@pytest.mark.asyncio
+async def test_ensure_playlists_exist_loads_from_db(
+ playlist_service, mock_db, mock_spotify
+):
+ # Setup: DB has configs
+ mock_six = PlaylistConfig(key="six_hour", spotify_id="db_six_id")
+ mock_daily = PlaylistConfig(key="daily", spotify_id="db_daily_id")
+
+ # Mock return values for separate queries
+ # This is tricky with MagicMock chains.
+
+ # Simpler approach: Assuming the service calls query(PlaylistConfig).filter(...)
+ # We can just check the result logic without complex DB mocking if we abstract the DB access.
+ # But let's try to mock the specific return values based on call order if possible.
+ mock_query = mock_db.query.return_value
+ mock_filter = mock_query.filter
+
+ # Configure filter().first() to return mock_six then mock_daily
+ # But ensure_playlists_exist calls filter twice.
+ # mock_filter.return_value is the same object.
+ # mock_filter.return_value.first.side_effect = [mock_six, mock_daily]
+ # This assumes sequential execution order which is fragile but works for unit test.
+ # IMPORTANT: Ensure filter side_effect is cleared if set previously
+ mock_filter.side_effect = None
+ mock_filter.return_value.first.side_effect = [mock_six, mock_daily]
+
+ result = await playlist_service.ensure_playlists_exist("user123")
+
+ assert result["six_hour_id"] == "db_six_id"
+ assert result["daily_id"] == "db_daily_id"
+ mock_spotify.create_playlist.assert_not_called()
+
+
+def test_optimize_playlist_flow(playlist_service):
+ tracks = [
+ {"id": "1", "energy": 0.8}, # High
+ {"id": "2", "energy": 0.2}, # Low
+ {"id": "3", "energy": 0.5}, # Medium
+ {"id": "4", "energy": 0.9}, # High
+ {"id": "5", "energy": 0.3}, # Low
+ ]
+
+ # Expected sort: Low, Low, Medium, High, High
+ # Then split:
+ # Sorted: 2(0.2), 5(0.3), 3(0.5), 1(0.8), 4(0.9)
+ # Len 5.
+ # Low end: 5 * 0.3 = 1.5 -> 1. (Index 1) -> [2]
+ # High start: 5 * 0.7 = 3.5 -> 3. (Index 3) -> [1, 4]
+ # Medium: [5, 3]
+ # Result: Low + High + Medium = [2] + [1, 4] + [5, 3]
+ # Order: 2, 1, 4, 5, 3
+ # Energies: 0.2, 0.8, 0.9, 0.3, 0.5
+
+ optimized = playlist_service._optimize_playlist_flow(tracks)
+
+ ids = [t["id"] for t in optimized]
+ # Check if High energy tracks are in the middle/early part (Ramp Up)
+ # The current logic is Low -> High -> Medium.
+ # So we expect High energy block (1, 4) to be in the middle?
+ # Wait, code was: low_energy + high_energy + medium_energy
+
+ assert ids == ["2", "1", "4", "5", "3"]
+ assert optimized[0]["energy"] == 0.2
+ assert optimized[1]["energy"] == 0.8
diff --git a/docker-compose.yml b/docker-compose.yml
index 5835589..9ebb588 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -7,21 +7,12 @@ services:
image: ghcr.io/bnair123/musicanalyser:latest
container_name: music-analyser-backend
restart: unless-stopped
- volumes:
- - music_data:/app/data
+ env_file:
+ - .env
environment:
- - DATABASE_URL=sqlite:////app/data/music.db
- - SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
- - SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}
- - SPOTIFY_REFRESH_TOKEN=${SPOTIFY_REFRESH_TOKEN}
- - GEMINI_API_KEY=${GEMINI_API_KEY}
- - GENIUS_ACCESS_TOKEN=${GENIUS_ACCESS_TOKEN}
- - OPENAI_API_KEY=${OPENAI_API_KEY}
- - OPENAI_APIKEY=${OPENAI_APIKEY}
- - SIX_HOUR_PLAYLIST_ID=${SIX_HOUR_PLAYLIST_ID}
- - DAILY_PLAYLIST_ID=${DAILY_PLAYLIST_ID}
+ - DATABASE_URL=postgresql://bnair:Bharath2002@music_db:5432/music_db
ports:
- - '8000:8000'
+ - '8088:8000'
networks:
- dockernet
healthcheck:
@@ -45,10 +36,6 @@ services:
backend:
condition: service_healthy
-volumes:
- music_data:
- driver: local
-
networks:
dockernet:
external: true
diff --git a/docs/DATABASE.md b/docs/DATABASE.md
new file mode 100644
index 0000000..0c1e4e5
--- /dev/null
+++ b/docs/DATABASE.md
@@ -0,0 +1,271 @@
+# Database Documentation
+
+## PostgreSQL Connection Details
+
+| Property | Value |
+|----------|-------|
+| Host | `100.91.248.114` |
+| Port | `5433` |
+| User | `bnair` |
+| Password | `Bharath2002` |
+| Database | `music_db` |
+| Data Location (on server) | `/opt/DB/MusicDB/pgdata` |
+
+### Connection String
+```
+postgresql://bnair:Bharath2002@100.91.248.114:5433/music_db
+```
+
+## Schema Overview
+
+```
+┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
+│ artists │ │ track_artists │ │ tracks │
+├─────────────────┤ ├──────────────────┤ ├─────────────────┤
+│ id (PK) │◄────┤ artist_id (FK) │ │ id (PK) │
+│ name │ │ track_id (FK) │────►│ reccobeats_id │
+│ genres (JSON) │ └──────────────────┘ │ name │
+│ image_url │ │ artist │
+└─────────────────┘ │ album │
+ │ image_url │
+ │ duration_ms │
+ │ popularity │
+ │ raw_data (JSON) │
+ │ danceability │
+ │ energy │
+ │ key │
+ │ ... (audio) │
+ │ genres (JSON) │
+ │ lyrics │
+ │ created_at │
+ │ updated_at │
+ └─────────────────┘
+ │
+ │
+ ▼
+┌─────────────────────┐ ┌─────────────────┐
+│ analysis_snapshots │ │ play_history │
+├─────────────────────┤ ├─────────────────┤
+│ id (PK) │ │ id (PK) │
+│ date │ │ track_id (FK) │
+│ period_start │ │ played_at │
+│ period_end │ │ context_uri │
+│ period_label │ │ listened_ms │
+│ metrics_payload │ │ skipped │
+│ narrative_report │ │ source │
+│ model_used │ └─────────────────┘
+│ playlist_theme │
+│ ... (playlist) │
+│ playlist_composition│
+└─────────────────────┘
+
+┌─────────────────────┐
+│ playlist_config │
+├─────────────────────┤
+│ key (PK) │
+│ spotify_id │
+│ last_updated │
+│ current_theme │
+│ description │
+│ composition (JSON) │
+└─────────────────────┘
+```
+
+## Tables
+
+### `tracks`
+Central entity storing Spotify track metadata and enriched audio features.
+
+| Column | Type | Description |
+|--------|------|-------------|
+| `id` | VARCHAR | Spotify track ID (primary key) |
+| `reccobeats_id` | VARCHAR | ReccoBeats UUID for audio features |
+| `name` | VARCHAR | Track title |
+| `artist` | VARCHAR | Display artist string (e.g., "Drake, Future") |
+| `album` | VARCHAR | Album name |
+| `image_url` | VARCHAR | Album art URL |
+| `duration_ms` | INTEGER | Track duration in milliseconds |
+| `popularity` | INTEGER | Spotify popularity score (0-100) |
+| `raw_data` | JSON | Full Spotify API response |
+| `danceability` | FLOAT | Audio feature (0.0-1.0) |
+| `energy` | FLOAT | Audio feature (0.0-1.0) |
+| `key` | INTEGER | Musical key (0-11) |
+| `loudness` | FLOAT | Audio feature (dB) |
+| `mode` | INTEGER | Major (1) or minor (0) |
+| `speechiness` | FLOAT | Audio feature (0.0-1.0) |
+| `acousticness` | FLOAT | Audio feature (0.0-1.0) |
+| `instrumentalness` | FLOAT | Audio feature (0.0-1.0) |
+| `liveness` | FLOAT | Audio feature (0.0-1.0) |
+| `valence` | FLOAT | Audio feature (0.0-1.0) |
+| `tempo` | FLOAT | BPM |
+| `time_signature` | INTEGER | Beats per bar |
+| `genres` | JSON | Genre tags (deprecated, use Artist.genres) |
+| `lyrics` | TEXT | Full lyrics from Genius |
+| `lyrics_summary` | VARCHAR | AI-generated summary |
+| `genre_tags` | VARCHAR | AI-generated tags |
+| `created_at` | TIMESTAMP | Record creation time |
+| `updated_at` | TIMESTAMP | Last update time |
+
+### `artists`
+Artist entities with genre information.
+
+| Column | Type | Description |
+|--------|------|-------------|
+| `id` | VARCHAR | Spotify artist ID (primary key) |
+| `name` | VARCHAR | Artist name |
+| `genres` | JSON | List of genre strings |
+| `image_url` | VARCHAR | Artist profile image URL |
+
+### `track_artists`
+Many-to-many relationship between tracks and artists.
+
+| Column | Type | Description |
+|--------|------|-------------|
+| `track_id` | VARCHAR | Foreign key to tracks.id |
+| `artist_id` | VARCHAR | Foreign key to artists.id |
+
+### `play_history`
+Immutable log of listening events.
+
+| Column | Type | Description |
+|--------|------|-------------|
+| `id` | INTEGER | Auto-increment primary key |
+| `track_id` | VARCHAR | Foreign key to tracks.id |
+| `played_at` | TIMESTAMP | When the track was played |
+| `context_uri` | VARCHAR | Spotify context (playlist, album, etc.) |
+| `listened_ms` | INTEGER | Duration actually listened |
+| `skipped` | BOOLEAN | Whether track was skipped |
+| `source` | VARCHAR | Source of the play event |
+
+### `analysis_snapshots`
+Stores computed statistics and AI-generated narratives.
+
+| Column | Type | Description |
+|--------|------|-------------|
+| `id` | INTEGER | Auto-increment primary key |
+| `date` | TIMESTAMP | When analysis was run |
+| `period_start` | TIMESTAMP | Analysis period start |
+| `period_end` | TIMESTAMP | Analysis period end |
+| `period_label` | VARCHAR | Label (e.g., "last_30_days") |
+| `metrics_payload` | JSON | StatsService output |
+| `narrative_report` | JSON | NarrativeService output |
+| `model_used` | VARCHAR | LLM model name |
+| `playlist_theme` | VARCHAR | AI-generated theme name |
+| `playlist_theme_reasoning` | TEXT | AI explanation for theme |
+| `six_hour_playlist_id` | VARCHAR | Spotify playlist ID |
+| `daily_playlist_id` | VARCHAR | Spotify playlist ID |
+| `playlist_composition` | JSON | Track list at snapshot time |
+
+### `playlist_config`
+Configuration for managed Spotify playlists.
+
+| Column | Type | Description |
+|--------|------|-------------|
+| `key` | VARCHAR | Config key (primary key, e.g., "six_hour") |
+| `spotify_id` | VARCHAR | Spotify playlist ID |
+| `last_updated` | TIMESTAMP | Last update time |
+| `current_theme` | VARCHAR | Current playlist theme |
+| `description` | VARCHAR | Playlist description |
+| `composition` | JSON | Current track list |
+
+## Schema Modifications (Alembic)
+
+All schema changes MUST go through Alembic migrations.
+
+### Creating a New Migration
+
+```bash
+cd backend
+source venv/bin/activate
+
+# Auto-generate migration from model changes
+alembic revision --autogenerate -m "description_of_change"
+
+# Or create empty migration for manual SQL
+alembic revision -m "description_of_change"
+```
+
+### Applying Migrations
+
+```bash
+# Apply all pending migrations
+alembic upgrade head
+
+# Apply specific migration
+alembic upgrade
+
+# Rollback one migration
+alembic downgrade -1
+
+# Rollback to specific revision
+alembic downgrade
+```
+
+### Migration Best Practices
+
+1. **Test locally first** - Always test migrations on a dev database
+2. **Backup before migrating** - `pg_dump -h 100.91.248.114 -p 5433 -U bnair music_db > backup.sql`
+3. **One change per migration** - Keep migrations atomic
+4. **Include rollback logic** - Implement `downgrade()` function
+5. **Review autogenerated migrations** - They may miss nuances
+
+### Example Migration
+
+```python
+# alembic/versions/xxxx_add_new_column.py
+from alembic import op
+import sqlalchemy as sa
+
+revision = 'xxxx'
+down_revision = 'yyyy'
+
+def upgrade():
+ op.add_column('tracks', sa.Column('new_column', sa.String(), nullable=True))
+
+def downgrade():
+ op.drop_column('tracks', 'new_column')
+```
+
+## Direct Database Access
+
+### Using psql
+```bash
+psql -h 100.91.248.114 -p 5433 -U bnair -d music_db
+```
+
+### Using Python
+```python
+import psycopg2
+conn = psycopg2.connect(
+ host='100.91.248.114',
+ port=5433,
+ user='bnair',
+ password='Bharath2002',
+ dbname='music_db'
+)
+```
+
+### Common Queries
+
+```sql
+-- Recent plays
+SELECT t.name, t.artist, ph.played_at
+FROM play_history ph
+JOIN tracks t ON ph.track_id = t.id
+ORDER BY ph.played_at DESC
+LIMIT 10;
+
+-- Top tracks by play count
+SELECT t.name, t.artist, COUNT(*) as plays
+FROM play_history ph
+JOIN tracks t ON ph.track_id = t.id
+GROUP BY t.id, t.name, t.artist
+ORDER BY plays DESC
+LIMIT 10;
+
+-- Genre distribution
+SELECT genre, COUNT(*)
+FROM artists, jsonb_array_elements_text(genres::jsonb) AS genre
+GROUP BY genre
+ORDER BY count DESC;
+```
diff --git a/frontend/src/components/PlaylistsSection.jsx b/frontend/src/components/PlaylistsSection.jsx
index 0f078cb..98a7716 100644
--- a/frontend/src/components/PlaylistsSection.jsx
+++ b/frontend/src/components/PlaylistsSection.jsx
@@ -1,12 +1,14 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
-import { Card, Button, Typography, Space, Spin, message, Tooltip as AntTooltip } from 'antd';
+import { Card, Button, Typography, Space, Spin, message, Tooltip as AntTooltip, Collapse, Empty } from 'antd';
import {
PlayCircleOutlined,
ReloadOutlined,
HistoryOutlined,
InfoCircleOutlined,
- CustomerServiceOutlined
+ CustomerServiceOutlined,
+ CalendarOutlined,
+ DownOutlined
} from '@ant-design/icons';
import Tooltip from './Tooltip';
import TrackList from './TrackList';
@@ -17,6 +19,10 @@ const PlaylistsSection = () => {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState({ sixHour: false, daily: false });
const [playlists, setPlaylists] = useState(null);
+
+ const [history, setHistory] = useState([]);
+ const [loadingHistory, setLoadingHistory] = useState(false);
+ const [showHistory, setShowHistory] = useState(false);
const fetchPlaylists = async () => {
try {
@@ -30,10 +36,30 @@ const PlaylistsSection = () => {
}
};
+ const fetchHistory = async () => {
+ if (loadingHistory) return;
+ setLoadingHistory(true);
+ try {
+ const response = await axios.get('/api/playlists/history');
+ setHistory(response.data.history || []);
+ } catch (error) {
+ console.error('Failed to fetch playlist history:', error);
+ message.error('Failed to load playlist history');
+ } finally {
+ setLoadingHistory(false);
+ }
+ };
+
useEffect(() => {
fetchPlaylists();
}, []);
+ useEffect(() => {
+ if (showHistory && history.length === 0) {
+ fetchHistory();
+ }
+ }, [showHistory]);
+
const handleRefresh = async (type) => {
const isSixHour = type === 'six-hour';
setRefreshing(prev => ({ ...prev, [isSixHour ? 'sixHour' : 'daily']: true }));
@@ -43,6 +69,7 @@ const PlaylistsSection = () => {
await axios.post(endpoint);
message.success(`${isSixHour ? '6-Hour' : 'Daily'} playlist refreshed!`);
await fetchPlaylists();
+ if (showHistory) fetchHistory();
} catch (error) {
console.error(`Refresh failed for ${type}:`, error);
message.error(`Failed to refresh ${type} playlist`);
@@ -53,6 +80,32 @@ const PlaylistsSection = () => {
if (loading) return
;
+ const historyItems = history.map((item) => ({
+ key: item.id,
+ label: (
+
+
+
+ {new Date(item.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
+
+ {item.theme}
+
+ {item.period_label || '6h'}
+
+
+
{item.composition?.length || 0} tracks
+
+ ),
+ children: (
+
+
+ "{item.reasoning}"
+
+
+
+ ),
+ }));
+
return (
@@ -162,6 +215,39 @@ const PlaylistsSection = () => {
+
+
+
+
+ {showHistory && (
+
+ {loadingHistory ? (
+
+ ) : history.length > 0 ? (
+
+ ) : (
+
No playlist history available yet} />
+ )}
+
+ )}
+
);
};