mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-04 20:43:04 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,586 @@
|
||||
"""Recap generation and persistence using existing scrobble + in-memory stores."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import datetime as dt
|
||||
import json
|
||||
import secrets
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from swingmusic.db.engine import DbEngine
|
||||
from swingmusic.db.userdata import ScrobbleTable
|
||||
from swingmusic.utils.stats import (
|
||||
get_albums_in_period,
|
||||
get_artists_in_period,
|
||||
get_tracks_in_period,
|
||||
)
|
||||
|
||||
|
||||
class RecapStore:
|
||||
def __init__(self):
|
||||
self._ensure_schema()
|
||||
|
||||
def _ensure_schema(self):
|
||||
with DbEngine.manager(commit=True) as session:
|
||||
session.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS recap_cache (
|
||||
user_id INTEGER NOT NULL,
|
||||
year INTEGER NOT NULL,
|
||||
recap_json TEXT NOT NULL,
|
||||
generated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, year)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
session.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS recap_shares (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
year INTEGER NOT NULL,
|
||||
recap_json TEXT NOT NULL,
|
||||
include_personal_data INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _year_bounds(year: int) -> tuple[int, int]:
|
||||
start = int(dt.datetime(year, 1, 1, tzinfo=dt.UTC).timestamp())
|
||||
end = int(dt.datetime(year + 1, 1, 1, tzinfo=dt.UTC).timestamp()) - 1
|
||||
return start, end
|
||||
|
||||
@staticmethod
|
||||
def _minutes(seconds: float | int) -> int:
|
||||
return int(round(float(seconds or 0) / 60.0))
|
||||
|
||||
@staticmethod
|
||||
def _compute_streak(day_values: set[dt.date]) -> int:
|
||||
if not day_values:
|
||||
return 0
|
||||
|
||||
days = sorted(day_values)
|
||||
best = 1
|
||||
current = 1
|
||||
|
||||
for prev, curr in zip(days, days[1:], strict=False):
|
||||
if (curr - prev).days == 1:
|
||||
current += 1
|
||||
best = max(best, current)
|
||||
else:
|
||||
current = 1
|
||||
|
||||
return best
|
||||
|
||||
@staticmethod
|
||||
def _build_personality(
|
||||
total_tracks: int, unique_tracks: int, top_artists: list[dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
if total_tracks <= 0:
|
||||
return {
|
||||
"personality_type": "Balanced",
|
||||
"description": "You kept a steady listening rhythm this year.",
|
||||
"traits": ["Steady", "Curious", "Open-minded"],
|
||||
}
|
||||
|
||||
diversity = unique_tracks / max(total_tracks, 1)
|
||||
top_artist_share = 0.0
|
||||
if top_artists:
|
||||
top_artist_share = float(top_artists[0].get("play_count") or 0) / max(
|
||||
total_tracks, 1
|
||||
)
|
||||
|
||||
if diversity >= 0.72:
|
||||
return {
|
||||
"personality_type": "Explorer",
|
||||
"description": "You explored a wide range of music and discovered new sounds often.",
|
||||
"traits": ["Curious", "Adventurous", "Varied taste"],
|
||||
}
|
||||
|
||||
if top_artist_share >= 0.35:
|
||||
return {
|
||||
"personality_type": "Loyalist",
|
||||
"description": "You go deep on your favorite artists and keep strong repeat favorites.",
|
||||
"traits": ["Focused", "Dedicated", "Consistent"],
|
||||
}
|
||||
|
||||
return {
|
||||
"personality_type": "Balanced",
|
||||
"description": "You balance comfort favorites with enough variety to keep it fresh.",
|
||||
"traits": ["Versatile", "Balanced", "Mood-driven"],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_milestones(
|
||||
total_minutes: int, total_tracks: int, unique_tracks: int
|
||||
) -> list[dict[str, Any]]:
|
||||
milestones: list[dict[str, Any]] = []
|
||||
|
||||
def add_minutes(level: str, threshold: int):
|
||||
milestones.append(
|
||||
{
|
||||
"type": "listening_time",
|
||||
"icon": "clock",
|
||||
"title": "Listening Time",
|
||||
"description": f"Reached {threshold:,} minutes listened",
|
||||
"level": level,
|
||||
}
|
||||
)
|
||||
|
||||
if total_minutes >= 20000:
|
||||
add_minutes("gold", 20000)
|
||||
elif total_minutes >= 8000:
|
||||
add_minutes("silver", 8000)
|
||||
elif total_minutes >= 2000:
|
||||
add_minutes("bronze", 2000)
|
||||
|
||||
if total_tracks >= 5000:
|
||||
milestones.append(
|
||||
{
|
||||
"type": "plays",
|
||||
"icon": "play",
|
||||
"title": "Heavy Rotation",
|
||||
"description": "Played over 5,000 tracks this year",
|
||||
"level": "gold",
|
||||
}
|
||||
)
|
||||
elif total_tracks >= 1500:
|
||||
milestones.append(
|
||||
{
|
||||
"type": "plays",
|
||||
"icon": "play",
|
||||
"title": "Regular Listener",
|
||||
"description": "Played over 1,500 tracks this year",
|
||||
"level": "silver",
|
||||
}
|
||||
)
|
||||
|
||||
if unique_tracks >= 1000:
|
||||
milestones.append(
|
||||
{
|
||||
"type": "discovery",
|
||||
"icon": "compass",
|
||||
"title": "Discovery Mode",
|
||||
"description": "Listened to more than 1,000 unique tracks",
|
||||
"level": "gold",
|
||||
}
|
||||
)
|
||||
elif unique_tracks >= 400:
|
||||
milestones.append(
|
||||
{
|
||||
"type": "discovery",
|
||||
"icon": "compass",
|
||||
"title": "Explorer",
|
||||
"description": "Listened to more than 400 unique tracks",
|
||||
"level": "silver",
|
||||
}
|
||||
)
|
||||
|
||||
return milestones
|
||||
|
||||
def _get_cached_recap(self, user_id: int, year: int) -> dict[str, Any] | None:
|
||||
with DbEngine.manager() as session:
|
||||
row = (
|
||||
session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT recap_json
|
||||
FROM recap_cache
|
||||
WHERE user_id = :user_id
|
||||
AND year = :year
|
||||
"""
|
||||
),
|
||||
{"user_id": int(user_id), "year": int(year)},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
try:
|
||||
recap = json.loads(row["recap_json"])
|
||||
return recap if isinstance(recap, dict) else None
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
def _save_recap(self, user_id: int, year: int, recap: dict[str, Any]):
|
||||
with DbEngine.manager(commit=True) as session:
|
||||
session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO recap_cache (user_id, year, recap_json, generated_at)
|
||||
VALUES (:user_id, :year, :recap_json, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id, year) DO UPDATE SET
|
||||
recap_json = excluded.recap_json,
|
||||
generated_at = CURRENT_TIMESTAMP
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": int(user_id),
|
||||
"year": int(year),
|
||||
"recap_json": json.dumps(recap),
|
||||
},
|
||||
)
|
||||
|
||||
def get_available_years(self, user_id: int) -> list[int]:
|
||||
years: set[int] = set()
|
||||
try:
|
||||
with DbEngine.manager() as session:
|
||||
scrobble_rows = session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT DISTINCT CAST(strftime('%Y', datetime(timestamp, 'unixepoch')) AS INTEGER) AS y
|
||||
FROM scrobble
|
||||
WHERE userid = :user_id
|
||||
"""
|
||||
),
|
||||
{"user_id": int(user_id)},
|
||||
)
|
||||
|
||||
for row in scrobble_rows:
|
||||
y = row[0]
|
||||
if y:
|
||||
years.add(int(y))
|
||||
|
||||
cache_rows = session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT DISTINCT year
|
||||
FROM recap_cache
|
||||
WHERE user_id = :user_id
|
||||
"""
|
||||
),
|
||||
{"user_id": int(user_id)},
|
||||
)
|
||||
for row in cache_rows:
|
||||
years.add(int(row[0]))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
return sorted(years, reverse=True)
|
||||
|
||||
def generate_recap(self, user_id: int, year: int) -> dict[str, Any] | None:
|
||||
try:
|
||||
start_ts, end_ts = self._year_bounds(int(year))
|
||||
scrobbles = list(
|
||||
ScrobbleTable.get_all_in_period(start_ts, end_ts, int(user_id))
|
||||
)
|
||||
|
||||
if not scrobbles:
|
||||
return None
|
||||
|
||||
tracks, total_tracks, total_duration = get_tracks_in_period(
|
||||
start_ts, end_ts, int(user_id)
|
||||
)
|
||||
tracks = sorted(
|
||||
tracks,
|
||||
key=lambda t: int(getattr(t, "playduration", 0) or 0),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
top_tracks = []
|
||||
for track in tracks[:50]:
|
||||
artists = getattr(track, "artists", []) or []
|
||||
artist_name = (
|
||||
artists[0].get("name")
|
||||
if artists and isinstance(artists[0], dict)
|
||||
else "Unknown Artist"
|
||||
)
|
||||
top_tracks.append(
|
||||
{
|
||||
"id": track.trackhash,
|
||||
"title": track.title,
|
||||
"artist": artist_name,
|
||||
"album": track.album,
|
||||
"image": track.image,
|
||||
"play_count": int(track.playcount or 0),
|
||||
"total_duration": self._minutes(track.playduration),
|
||||
}
|
||||
)
|
||||
|
||||
artist_entries = get_artists_in_period(start_ts, end_ts, int(user_id))
|
||||
top_artists = []
|
||||
for item in artist_entries[:50]:
|
||||
top_artists.append(
|
||||
{
|
||||
"name": item.get("artist", "Unknown Artist"),
|
||||
"play_count": int(item.get("playcount", 0) or 0),
|
||||
"total_duration": self._minutes(item.get("playduration", 0)),
|
||||
"unique_tracks": len(item.get("tracks", {})),
|
||||
}
|
||||
)
|
||||
|
||||
albums = get_albums_in_period(start_ts, end_ts, int(user_id))
|
||||
albums = sorted(
|
||||
albums,
|
||||
key=lambda a: int(getattr(a, "playduration", 0) or 0),
|
||||
reverse=True,
|
||||
)
|
||||
top_albums = []
|
||||
for album in albums[:30]:
|
||||
album_artists = getattr(album, "albumartists", []) or []
|
||||
artist_name = (
|
||||
album_artists[0].get("name")
|
||||
if album_artists and isinstance(album_artists[0], dict)
|
||||
else "Unknown Artist"
|
||||
)
|
||||
top_albums.append(
|
||||
{
|
||||
"name": album.title,
|
||||
"artist": artist_name,
|
||||
"play_count": int(album.playcount or 0),
|
||||
"total_duration": self._minutes(album.playduration),
|
||||
"image": album.image,
|
||||
}
|
||||
)
|
||||
|
||||
unique_trackhashes = {entry.trackhash for entry in scrobbles}
|
||||
day_values = {
|
||||
dt.datetime.fromtimestamp(int(entry.timestamp), tz=dt.UTC).date()
|
||||
for entry in scrobbles
|
||||
}
|
||||
|
||||
monthly_seconds = defaultdict(int)
|
||||
for entry in scrobbles:
|
||||
month = dt.datetime.fromtimestamp(int(entry.timestamp), tz=dt.UTC).month
|
||||
monthly_seconds[month] += int(entry.duration or 0)
|
||||
|
||||
monthly_breakdown = []
|
||||
for month in range(1, 13):
|
||||
monthly_breakdown.append(
|
||||
{
|
||||
"month": month,
|
||||
"month_name": calendar.month_name[month],
|
||||
"total_minutes": self._minutes(monthly_seconds[month]),
|
||||
}
|
||||
)
|
||||
|
||||
total_minutes = self._minutes(total_duration)
|
||||
unique_tracks = len(unique_trackhashes)
|
||||
|
||||
recap = {
|
||||
"year": int(year),
|
||||
"generated_at": dt.datetime.now(dt.UTC).isoformat(),
|
||||
"stats": {
|
||||
"total_minutes": total_minutes,
|
||||
"total_tracks": int(total_tracks),
|
||||
"unique_tracks": unique_tracks,
|
||||
"unique_artists": len({item.get("name") for item in top_artists}),
|
||||
"listening_streak": self._compute_streak(day_values),
|
||||
},
|
||||
"personality": self._build_personality(
|
||||
int(total_tracks), unique_tracks, top_artists
|
||||
),
|
||||
"top_tracks": top_tracks,
|
||||
"top_artists": top_artists,
|
||||
"top_albums": top_albums,
|
||||
"monthly_breakdown": monthly_breakdown,
|
||||
"milestones": self._build_milestones(
|
||||
total_minutes, int(total_tracks), unique_tracks
|
||||
),
|
||||
"discoveries": {
|
||||
"new_artists": max(0, len(top_artists) - 10),
|
||||
"new_tracks": max(0, unique_tracks - 100),
|
||||
},
|
||||
}
|
||||
|
||||
self._save_recap(user_id, year, recap)
|
||||
return recap
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_recap(
|
||||
self, user_id: int, year: int, generate_if_missing: bool = False
|
||||
) -> dict[str, Any] | None:
|
||||
recap = self._get_cached_recap(user_id, year)
|
||||
if recap:
|
||||
return recap
|
||||
|
||||
if generate_if_missing:
|
||||
return self.generate_recap(user_id, year)
|
||||
|
||||
return None
|
||||
|
||||
def get_summary(self, user_id: int, year: int) -> dict[str, Any] | None:
|
||||
recap = self.get_recap(user_id, year, generate_if_missing=False)
|
||||
if not recap:
|
||||
return None
|
||||
|
||||
return {
|
||||
"year": recap.get("year", year),
|
||||
"stats": recap.get("stats", {}),
|
||||
"personality": recap.get("personality", {}),
|
||||
"milestones": recap.get("milestones", []),
|
||||
}
|
||||
|
||||
def create_share_link(
|
||||
self,
|
||||
user_id: int,
|
||||
year: int,
|
||||
include_personal_data: bool,
|
||||
expires_in_days: int,
|
||||
) -> dict[str, Any] | None:
|
||||
recap = self.get_recap(user_id, year, generate_if_missing=True)
|
||||
if not recap:
|
||||
return None
|
||||
|
||||
payload = recap
|
||||
if not include_personal_data:
|
||||
payload = {
|
||||
**recap,
|
||||
"top_tracks": [
|
||||
{
|
||||
**item,
|
||||
"title": "Hidden",
|
||||
"artist": "Hidden",
|
||||
"album": "Hidden",
|
||||
}
|
||||
for item in recap.get("top_tracks", [])
|
||||
],
|
||||
}
|
||||
|
||||
token = secrets.token_urlsafe(24).replace("-", "").replace("_", "")[:32]
|
||||
expires_at = dt.datetime.now(dt.UTC) + dt.timedelta(
|
||||
days=max(1, min(3650, int(expires_in_days)))
|
||||
)
|
||||
|
||||
with DbEngine.manager(commit=True) as session:
|
||||
session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO recap_shares (
|
||||
token,
|
||||
user_id,
|
||||
year,
|
||||
recap_json,
|
||||
include_personal_data,
|
||||
expires_at,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
:token,
|
||||
:user_id,
|
||||
:year,
|
||||
:recap_json,
|
||||
:include_personal_data,
|
||||
:expires_at,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"token": token,
|
||||
"user_id": int(user_id),
|
||||
"year": int(year),
|
||||
"recap_json": json.dumps(payload),
|
||||
"include_personal_data": 1 if include_personal_data else 0,
|
||||
"expires_at": expires_at.isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"share_token": token,
|
||||
"year": int(year),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"include_personal_data": bool(include_personal_data),
|
||||
}
|
||||
|
||||
def get_shared_recap(self, token: str) -> dict[str, Any] | None:
|
||||
with DbEngine.manager() as session:
|
||||
row = (
|
||||
session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT year, recap_json, expires_at
|
||||
FROM recap_shares
|
||||
WHERE token = :token
|
||||
"""
|
||||
),
|
||||
{"token": str(token)},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
try:
|
||||
expires_at = dt.datetime.fromisoformat(row["expires_at"])
|
||||
except Exception:
|
||||
return None
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=dt.UTC)
|
||||
|
||||
if expires_at < dt.datetime.now(dt.UTC):
|
||||
return None
|
||||
|
||||
try:
|
||||
recap = json.loads(row["recap_json"])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
return {
|
||||
"year": int(row["year"]),
|
||||
"recap": recap,
|
||||
"expires_at": row["expires_at"],
|
||||
}
|
||||
|
||||
def compare_years(
|
||||
self, user_id: int, year1: int, year2: int
|
||||
) -> dict[str, Any] | None:
|
||||
recap1 = self.get_recap(user_id, year1, generate_if_missing=True)
|
||||
recap2 = self.get_recap(user_id, year2, generate_if_missing=True)
|
||||
|
||||
if not recap1 or not recap2:
|
||||
return None
|
||||
|
||||
stats1 = recap1.get("stats", {})
|
||||
stats2 = recap2.get("stats", {})
|
||||
|
||||
minutes1 = int(stats1.get("total_minutes", 0) or 0)
|
||||
minutes2 = int(stats2.get("total_minutes", 0) or 0)
|
||||
|
||||
tracks1 = int(stats1.get("total_tracks", 0) or 0)
|
||||
tracks2 = int(stats2.get("total_tracks", 0) or 0)
|
||||
|
||||
def pct(old: int, new: int) -> float:
|
||||
base = max(abs(old), 1)
|
||||
return ((new - old) / base) * 100.0
|
||||
|
||||
return {
|
||||
"year1": int(year1),
|
||||
"year2": int(year2),
|
||||
"listening_time_change": {
|
||||
"absolute": minutes2 - minutes1,
|
||||
"percentage": pct(minutes1, minutes2),
|
||||
},
|
||||
"tracks_change": {
|
||||
"absolute": tracks2 - tracks1,
|
||||
"percentage": pct(tracks1, tracks2),
|
||||
},
|
||||
"personality_change": {
|
||||
"from": recap1.get("personality", {}).get(
|
||||
"personality_type", "Unknown"
|
||||
),
|
||||
"to": recap2.get("personality", {}).get("personality_type", "Unknown"),
|
||||
"changed": recap1.get("personality", {}).get("personality_type")
|
||||
!= recap2.get("personality", {}).get("personality_type"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
recap_store = RecapStore()
|
||||
Reference in New Issue
Block a user