A kind of initial commit
This commit is contained in:
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Spotify vibe bot application package."""
|
||||
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
82
app/api/routes.py
Normal file
82
app/api/routes.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.services.app_services import AppServices
|
||||
|
||||
|
||||
def get_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
def runtime(request: Request):
|
||||
return request.app.state.runtime
|
||||
|
||||
def services(request: Request) -> AppServices:
|
||||
return request.app.state.services
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
return {"ok": True}
|
||||
|
||||
@router.get("/auth/spotify/start")
|
||||
async def spotify_start(
|
||||
chat_id: str = Query(...),
|
||||
username: str | None = Query(default=None),
|
||||
svc: AppServices = Depends(services),
|
||||
):
|
||||
url = await svc.auth.create_connect_url(chat_id=chat_id, username=username)
|
||||
return {"url": url}
|
||||
|
||||
@router.get("/auth/spotify/callback", response_class=HTMLResponse)
|
||||
async def spotify_callback(
|
||||
request: Request,
|
||||
code: str | None = None,
|
||||
state: str | None = None,
|
||||
error: str | None = None,
|
||||
svc: AppServices = Depends(services),
|
||||
):
|
||||
try:
|
||||
if error:
|
||||
raise ValueError(f"Spotify returned error: {error}")
|
||||
if not code or not state:
|
||||
raise ValueError("Missing code/state in callback")
|
||||
chat_id, display_name = await svc.auth.handle_callback(code=code, state=state)
|
||||
runner = request.app.state.runtime.telegram_runner
|
||||
if runner is not None:
|
||||
await runner.send_message(chat_id, f"Spotify подключен: {display_name}\nТеперь можно /generate")
|
||||
return """
|
||||
<html><body style="font-family:sans-serif;padding:24px">
|
||||
<h2>Spotify connected</h2>
|
||||
<p>Return to Telegram and use <code>/generate</code>.</p>
|
||||
</body></html>
|
||||
"""
|
||||
except Exception as exc:
|
||||
return HTMLResponse(
|
||||
content=(
|
||||
"<html><body style='font-family:sans-serif;padding:24px'>"
|
||||
f"<h2>Auth failed</h2><p>{exc}</p></body></html>"
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
@router.post("/internal/jobs/nightly")
|
||||
async def run_nightly(
|
||||
request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
svc: AppServices = Depends(services),
|
||||
):
|
||||
rt = runtime(request)
|
||||
expected = f"Bearer {rt.settings.internal_job_token}"
|
||||
if authorization != expected:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
outcomes = await svc.jobs.generate_for_all_connected_users()
|
||||
return {
|
||||
"ok": True,
|
||||
"count": len(outcomes),
|
||||
"results": [
|
||||
{"user_id": o.user_id, "ok": o.ok, "message": o.message, "playlist_url": o.playlist_url} for o in outcomes
|
||||
],
|
||||
}
|
||||
|
||||
return router
|
||||
0
app/bot/__init__.py
Normal file
0
app/bot/__init__.py
Normal file
217
app/bot/telegram_bot.py
Normal file
217
app/bot/telegram_bot.py
Normal file
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
|
||||
from telegram import Update
|
||||
from telegram.ext import Application, CommandHandler, ContextTypes
|
||||
|
||||
from app.db.repositories import PlaylistRunRepository, SavedTrackRepository, UserRepository
|
||||
from app.services.app_services import AppServices
|
||||
|
||||
|
||||
class TelegramBotRunner:
|
||||
def __init__(self, token: str, session_factory, services: AppServices, app_base_url: str) -> None:
|
||||
self.token = token
|
||||
self.session_factory = session_factory
|
||||
self.services = services
|
||||
self.app_base_url = app_base_url.rstrip("/")
|
||||
self.application = Application.builder().token(token).build()
|
||||
self._setup_handlers()
|
||||
self._running = False
|
||||
|
||||
def _setup_handlers(self) -> None:
|
||||
self.application.add_handler(CommandHandler("start", self.start))
|
||||
self.application.add_handler(CommandHandler("help", self.help))
|
||||
self.application.add_handler(CommandHandler("connect", self.connect))
|
||||
self.application.add_handler(CommandHandler("status", self.status))
|
||||
self.application.add_handler(CommandHandler("generate", self.generate))
|
||||
self.application.add_handler(CommandHandler("latest", self.latest))
|
||||
self.application.add_handler(CommandHandler("setsize", self.set_size))
|
||||
self.application.add_handler(CommandHandler("setratio", self.set_ratio))
|
||||
self.application.add_handler(CommandHandler("sync", self.sync_likes))
|
||||
|
||||
async def start_polling(self) -> None:
|
||||
await self.application.initialize()
|
||||
await self.application.start()
|
||||
if self.application.updater is None:
|
||||
raise RuntimeError("Telegram updater is not available")
|
||||
await self.application.updater.start_polling(drop_pending_updates=False)
|
||||
self._running = True
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
with contextlib.suppress(Exception):
|
||||
if self.application.updater:
|
||||
await self.application.updater.stop()
|
||||
with contextlib.suppress(Exception):
|
||||
await self.application.stop()
|
||||
with contextlib.suppress(Exception):
|
||||
await self.application.shutdown()
|
||||
self._running = False
|
||||
|
||||
async def send_message(self, chat_id: str, text: str) -> None:
|
||||
await self.application.bot.send_message(chat_id=int(chat_id), text=text, disable_web_page_preview=False)
|
||||
|
||||
async def _ensure_user(self, update: Update):
|
||||
chat = update.effective_chat
|
||||
user = update.effective_user
|
||||
if not chat:
|
||||
return None
|
||||
async with self.session_factory() as session:
|
||||
repo = UserRepository(session)
|
||||
db_user = await repo.get_or_create_by_chat(
|
||||
chat_id=str(chat.id),
|
||||
username=user.username if user else None,
|
||||
)
|
||||
await session.commit()
|
||||
return db_user
|
||||
|
||||
async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
await self._ensure_user(update)
|
||||
await update.message.reply_text(
|
||||
"Я бот для Spotify daily vibe playlist.\n"
|
||||
"Команды: /connect /status /generate /latest /setsize 30 /setratio 0.8 /sync"
|
||||
)
|
||||
|
||||
async def help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
await update.message.reply_text(
|
||||
"/connect - привязать Spotify\n"
|
||||
"/status - статус аккаунта и последнего плейлиста\n"
|
||||
"/generate - сгенерировать плейлист сейчас\n"
|
||||
"/latest - показать ссылку на последний плейлист\n"
|
||||
"/setsize N - размер плейлиста\n"
|
||||
"/setratio X - доля новых треков (0.5..1.0)\n"
|
||||
"/sync - обновить лайкнутые треки из Spotify"
|
||||
)
|
||||
|
||||
async def connect(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
chat = update.effective_chat
|
||||
user = update.effective_user
|
||||
if not chat:
|
||||
return
|
||||
url = await self.services.auth.create_connect_url(str(chat.id), user.username if user else None)
|
||||
await update.message.reply_text(f"Открой ссылку и авторизуй Spotify:\n{url}", disable_web_page_preview=True)
|
||||
|
||||
async def status(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
chat = update.effective_chat
|
||||
if not chat:
|
||||
return
|
||||
async with self.session_factory() as session:
|
||||
users = UserRepository(session)
|
||||
runs = PlaylistRunRepository(session)
|
||||
saved = SavedTrackRepository(session)
|
||||
db_user = await users.get_by_chat_id(str(chat.id))
|
||||
if not db_user:
|
||||
await update.message.reply_text("Пользователь не найден. Напиши /start")
|
||||
return
|
||||
latest = await runs.latest_for_user(db_user.id)
|
||||
saved_count = await saved.count_for_user(db_user.id)
|
||||
connected = "yes" if db_user.spotify_refresh_token else "no"
|
||||
text = (
|
||||
f"Connected: {connected}\n"
|
||||
f"Spotify user: {db_user.spotify_user_id or '-'}\n"
|
||||
f"Liked tracks cached: {saved_count}\n"
|
||||
f"Playlist size: {db_user.playlist_size}\n"
|
||||
f"Min new ratio: {db_user.min_new_ratio:.2f}\n"
|
||||
f"Last generated: {db_user.last_generated_date or '-'}"
|
||||
)
|
||||
if latest:
|
||||
text += f"\nLast run: {latest.status}, tracks={latest.total_tracks}"
|
||||
if latest.playlist_url:
|
||||
text += f"\n{latest.playlist_url}"
|
||||
await update.message.reply_text(text, disable_web_page_preview=False)
|
||||
|
||||
async def latest(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
chat = update.effective_chat
|
||||
if not chat:
|
||||
return
|
||||
async with self.session_factory() as session:
|
||||
users = UserRepository(session)
|
||||
db_user = await users.get_by_chat_id(str(chat.id))
|
||||
if not db_user or not db_user.latest_playlist_url:
|
||||
await update.message.reply_text("Пока нет сгенерированного плейлиста.")
|
||||
return
|
||||
await update.message.reply_text(db_user.latest_playlist_url, disable_web_page_preview=False)
|
||||
|
||||
async def generate(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
chat = update.effective_chat
|
||||
if not chat:
|
||||
return
|
||||
async with self.session_factory() as session:
|
||||
users = UserRepository(session)
|
||||
db_user = await users.get_by_chat_id(str(chat.id))
|
||||
if not db_user:
|
||||
await update.message.reply_text("Пользователь не найден. Напиши /start")
|
||||
return
|
||||
user_id = db_user.id
|
||||
await update.message.reply_text("Генерирую плейлист, это может занять 20-60 секунд...")
|
||||
outcome = await self.services.jobs.generate_for_user(user_id=user_id, force=True, notify=False)
|
||||
if outcome.ok:
|
||||
msg = outcome.message
|
||||
if outcome.playlist_url:
|
||||
msg += f"\n{outcome.playlist_url}"
|
||||
await update.message.reply_text(msg, disable_web_page_preview=False)
|
||||
else:
|
||||
await update.message.reply_text(f"Ошибка: {outcome.message}")
|
||||
|
||||
async def set_size(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
chat = update.effective_chat
|
||||
if not chat:
|
||||
return
|
||||
if not context.args:
|
||||
await update.message.reply_text("Использование: /setsize 30")
|
||||
return
|
||||
try:
|
||||
value = int(context.args[0])
|
||||
if value < 5 or value > 100:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
await update.message.reply_text("Размер должен быть числом от 5 до 100.")
|
||||
return
|
||||
async with self.session_factory() as session:
|
||||
users = UserRepository(session)
|
||||
db_user = await users.get_or_create_by_chat(str(chat.id), update.effective_user.username if update.effective_user else None)
|
||||
db_user.playlist_size = value
|
||||
await session.commit()
|
||||
await update.message.reply_text(f"Размер плейлиста установлен: {value}")
|
||||
|
||||
async def set_ratio(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
chat = update.effective_chat
|
||||
if not chat:
|
||||
return
|
||||
if not context.args:
|
||||
await update.message.reply_text("Использование: /setratio 0.8")
|
||||
return
|
||||
try:
|
||||
value = float(context.args[0])
|
||||
if value < 0.5 or value > 1.0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
await update.message.reply_text("Значение должно быть от 0.5 до 1.0")
|
||||
return
|
||||
async with self.session_factory() as session:
|
||||
users = UserRepository(session)
|
||||
db_user = await users.get_or_create_by_chat(str(chat.id), update.effective_user.username if update.effective_user else None)
|
||||
db_user.min_new_ratio = value
|
||||
await session.commit()
|
||||
await update.message.reply_text(f"Минимальная доля новых треков: {value:.2f}")
|
||||
|
||||
async def sync_likes(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
chat = update.effective_chat
|
||||
if not chat:
|
||||
return
|
||||
async with self.session_factory() as session:
|
||||
users = UserRepository(session)
|
||||
db_user = await users.get_by_chat_id(str(chat.id))
|
||||
if not db_user:
|
||||
await update.message.reply_text("Пользователь не найден. Напиши /start")
|
||||
return
|
||||
if not db_user.spotify_refresh_token:
|
||||
await update.message.reply_text("Сначала /connect")
|
||||
return
|
||||
access_token = await self.services.auth.ensure_valid_access_token(session, db_user)
|
||||
await self.services.recommendation.sync_saved_tracks(session, db_user, access_token)
|
||||
await session.commit()
|
||||
saved_count = await SavedTrackRepository(session).count_for_user(db_user.id)
|
||||
await update.message.reply_text(f"Лайкнутые треки обновлены: {saved_count}")
|
||||
0
app/clients/__init__.py
Normal file
0
app/clients/__init__.py
Normal file
56
app/clients/lastfm.py
Normal file
56
app/clients/lastfm.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class LastFmClient:
|
||||
BASE_URL = "https://ws.audioscrobbler.com/2.0/"
|
||||
|
||||
def __init__(self, api_key: str | None, http: httpx.AsyncClient) -> None:
|
||||
self.api_key = api_key
|
||||
self.http = http
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self.api_key)
|
||||
|
||||
async def track_similar(self, *, artist: str, track: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
if not self.api_key:
|
||||
return []
|
||||
params = {
|
||||
"method": "track.getSimilar",
|
||||
"api_key": self.api_key,
|
||||
"artist": artist,
|
||||
"track": track,
|
||||
"limit": limit,
|
||||
"format": "json",
|
||||
"autocorrect": 1,
|
||||
}
|
||||
resp = await self.http.get(self.BASE_URL, params=params, timeout=20)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
similar = ((data.get("similartracks") or {}).get("track")) or []
|
||||
if isinstance(similar, dict):
|
||||
similar = [similar]
|
||||
return similar
|
||||
|
||||
async def artist_similar(self, *, artist: str, limit: int = 15) -> list[dict[str, Any]]:
|
||||
if not self.api_key:
|
||||
return []
|
||||
params = {
|
||||
"method": "artist.getSimilar",
|
||||
"api_key": self.api_key,
|
||||
"artist": artist,
|
||||
"limit": limit,
|
||||
"format": "json",
|
||||
"autocorrect": 1,
|
||||
}
|
||||
resp = await self.http.get(self.BASE_URL, params=params, timeout=20)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
similar = ((data.get("similarartists") or {}).get("artist")) or []
|
||||
if isinstance(similar, dict):
|
||||
similar = [similar]
|
||||
return similar
|
||||
281
app/clients/spotify.py
Normal file
281
app/clients/spotify.py
Normal file
@@ -0,0 +1,281 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import Settings
|
||||
from app.utils.time import parse_spotify_datetime, to_unix_ms
|
||||
|
||||
|
||||
class SpotifyApiError(RuntimeError):
|
||||
def __init__(self, message: str, status_code: int | None = None, payload: Any | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.payload = payload
|
||||
|
||||
|
||||
class SpotifyClient:
|
||||
AUTH_BASE = "https://accounts.spotify.com"
|
||||
API_BASE = "https://api.spotify.com/v1"
|
||||
|
||||
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
|
||||
self.settings = settings
|
||||
self.http = http
|
||||
|
||||
def build_authorize_url(self, state: str, scopes: Iterable[str]) -> str:
|
||||
params = {
|
||||
"client_id": self.settings.spotify_client_id,
|
||||
"response_type": "code",
|
||||
"redirect_uri": self.settings.spotify_redirect_uri,
|
||||
"scope": " ".join(scopes),
|
||||
"state": state,
|
||||
"show_dialog": "false",
|
||||
}
|
||||
return f"{self.AUTH_BASE}/authorize?{urlencode(params)}"
|
||||
|
||||
async def exchange_code(self, code: str) -> dict[str, Any]:
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": self.settings.spotify_redirect_uri,
|
||||
}
|
||||
return await self._token_request(data)
|
||||
|
||||
async def refresh_access_token(self, refresh_token: str) -> dict[str, Any]:
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
return await self._token_request(data)
|
||||
|
||||
async def _token_request(self, data: dict[str, str]) -> dict[str, Any]:
|
||||
creds = f"{self.settings.spotify_client_id}:{self.settings.spotify_client_secret}".encode()
|
||||
auth = base64.b64encode(creds).decode()
|
||||
headers = {
|
||||
"Authorization": f"Basic {auth}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
resp = await self.http.post(f"{self.AUTH_BASE}/api/token", data=data, headers=headers, timeout=30)
|
||||
if resp.status_code >= 400:
|
||||
raise SpotifyApiError("Spotify token request failed", resp.status_code, resp.text)
|
||||
return resp.json()
|
||||
|
||||
async def get_current_user(self, access_token: str) -> dict[str, Any]:
|
||||
return await self._request_json("GET", "/me", access_token=access_token)
|
||||
|
||||
async def get_saved_tracks_all(self, access_token: str) -> list[dict[str, Any]]:
|
||||
tracks: list[dict[str, Any]] = []
|
||||
offset = 0
|
||||
limit = 50
|
||||
while True:
|
||||
payload = await self._request_json(
|
||||
"GET",
|
||||
"/me/tracks",
|
||||
access_token=access_token,
|
||||
params={"limit": limit, "offset": offset},
|
||||
)
|
||||
items = payload.get("items", [])
|
||||
for item in items:
|
||||
t = item.get("track") or {}
|
||||
track_id = t.get("id")
|
||||
if not track_id:
|
||||
continue
|
||||
artists = t.get("artists") or []
|
||||
tracks.append(
|
||||
{
|
||||
"id": track_id,
|
||||
"uri": t.get("uri") or f"spotify:track:{track_id}",
|
||||
"name": t.get("name") or "Unknown",
|
||||
"artist_names": [a.get("name") or "Unknown" for a in artists],
|
||||
"artist_ids": [a.get("id") for a in artists if a.get("id")],
|
||||
"album_name": (t.get("album") or {}).get("name"),
|
||||
"added_at": parse_spotify_datetime(item.get("added_at")),
|
||||
"popularity": t.get("popularity"),
|
||||
}
|
||||
)
|
||||
if not payload.get("next"):
|
||||
break
|
||||
offset += limit
|
||||
return tracks
|
||||
|
||||
async def get_recently_played(
|
||||
self,
|
||||
access_token: str,
|
||||
*,
|
||||
since: datetime,
|
||||
max_pages: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
before: int | None = None
|
||||
pages = 0
|
||||
since_ms = to_unix_ms(since)
|
||||
|
||||
while pages < max_pages:
|
||||
params: dict[str, Any] = {"limit": 50}
|
||||
if before:
|
||||
params["before"] = before
|
||||
payload = await self._request_json(
|
||||
"GET", "/me/player/recently-played", access_token=access_token, params=params
|
||||
)
|
||||
batch = payload.get("items", [])
|
||||
if not batch:
|
||||
break
|
||||
stop = False
|
||||
for item in batch:
|
||||
played_at = parse_spotify_datetime(item.get("played_at"))
|
||||
t = item.get("track") or {}
|
||||
track_id = t.get("id")
|
||||
if not played_at or not track_id:
|
||||
continue
|
||||
if to_unix_ms(played_at) < since_ms:
|
||||
stop = True
|
||||
continue
|
||||
artists = t.get("artists") or []
|
||||
items.append(
|
||||
{
|
||||
"id": track_id,
|
||||
"uri": t.get("uri") or f"spotify:track:{track_id}",
|
||||
"name": t.get("name") or "Unknown",
|
||||
"artist_names": [a.get("name") or "Unknown" for a in artists],
|
||||
"artist_ids": [a.get("id") for a in artists if a.get("id")],
|
||||
"played_at": played_at,
|
||||
"popularity": t.get("popularity"),
|
||||
}
|
||||
)
|
||||
cursors = payload.get("cursors") or {}
|
||||
before = cursors.get("before")
|
||||
pages += 1
|
||||
if stop or not before:
|
||||
break
|
||||
return items
|
||||
|
||||
async def create_playlist(
|
||||
self,
|
||||
access_token: str,
|
||||
*,
|
||||
user_id: str,
|
||||
name: str,
|
||||
description: str,
|
||||
public: bool,
|
||||
) -> dict[str, Any]:
|
||||
# Prefer /me/playlists: in some accounts/apps /users/{id}/playlists returns 403
|
||||
# even with valid playlist-modify scopes, while /me/playlists succeeds.
|
||||
return await self._request_json(
|
||||
"POST",
|
||||
"/me/playlists",
|
||||
access_token=access_token,
|
||||
json={"name": name, "description": description, "public": public},
|
||||
)
|
||||
|
||||
async def delete_playlist(self, access_token: str, playlist_id: str) -> None:
|
||||
# Spotify has no hard delete for playlists; DELETE here unfollows/removes it from the library.
|
||||
await self._request_json(
|
||||
"DELETE",
|
||||
f"/playlists/{playlist_id}/followers",
|
||||
access_token=access_token,
|
||||
)
|
||||
|
||||
async def add_playlist_items(self, access_token: str, playlist_id: str, uris: list[str]) -> None:
|
||||
for i in range(0, len(uris), 100):
|
||||
await self._request_json(
|
||||
"POST",
|
||||
f"/playlists/{playlist_id}/items",
|
||||
access_token=access_token,
|
||||
json={"uris": uris[i : i + 100]},
|
||||
)
|
||||
|
||||
async def get_recommendations(
|
||||
self,
|
||||
access_token: str,
|
||||
*,
|
||||
seed_tracks: list[str],
|
||||
seed_artists: list[str],
|
||||
limit: int = 100,
|
||||
market: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
params: dict[str, Any] = {"limit": limit}
|
||||
if market:
|
||||
params["market"] = market
|
||||
if seed_tracks:
|
||||
params["seed_tracks"] = ",".join(seed_tracks[:5])
|
||||
if seed_artists:
|
||||
params["seed_artists"] = ",".join(seed_artists[:5])
|
||||
if not params.get("seed_tracks") and not params.get("seed_artists"):
|
||||
return []
|
||||
payload = await self._request_json("GET", "/recommendations", access_token=access_token, params=params)
|
||||
return payload.get("tracks", [])
|
||||
|
||||
async def get_artist_top_tracks(self, access_token: str, artist_id: str, market: str) -> list[dict[str, Any]]:
|
||||
payload = await self._request_json(
|
||||
"GET", f"/artists/{artist_id}/top-tracks", access_token=access_token, params={"market": market}
|
||||
)
|
||||
return payload.get("tracks", [])
|
||||
|
||||
async def search_track(
|
||||
self,
|
||||
access_token: str,
|
||||
*,
|
||||
track_name: str,
|
||||
artist_name: str | None = None,
|
||||
market: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
q_parts: list[str] = []
|
||||
if track_name:
|
||||
q_parts.append(f'track:"{track_name}"')
|
||||
if artist_name:
|
||||
q_parts.append(f'artist:"{artist_name}"')
|
||||
if not q_parts:
|
||||
return []
|
||||
q = " ".join(q_parts)
|
||||
params: dict[str, Any] = {"q": q, "type": "track", "limit": 5}
|
||||
if market:
|
||||
params["market"] = market
|
||||
payload = await self._request_json("GET", "/search", access_token=access_token, params=params)
|
||||
return (payload.get("tracks") or {}).get("items", [])
|
||||
|
||||
async def _request_json(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
access_token: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
json: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
last_error: SpotifyApiError | None = None
|
||||
for _ in range(3):
|
||||
resp = await self.http.request(
|
||||
method,
|
||||
f"{self.API_BASE}{path}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
json=json,
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code == 429:
|
||||
retry_after = int(resp.headers.get("Retry-After", "1"))
|
||||
await asyncio.sleep(max(1, retry_after))
|
||||
continue
|
||||
if resp.status_code >= 400:
|
||||
last_error = SpotifyApiError(
|
||||
f"Spotify API request failed: {method} {path}",
|
||||
resp.status_code,
|
||||
resp.text,
|
||||
)
|
||||
break
|
||||
return resp.json() if resp.content else {}
|
||||
if last_error:
|
||||
raise last_error
|
||||
raise SpotifyApiError(f"Spotify API request failed after retries: {method} {path}")
|
||||
|
||||
@staticmethod
|
||||
def token_expiry_from_response(token_payload: dict[str, Any]) -> datetime:
|
||||
expires_in = int(token_payload.get("expires_in", 3600))
|
||||
return datetime.now(timezone.utc) + timedelta(seconds=max(60, expires_in - 60))
|
||||
41
app/config.py
Normal file
41
app/config.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
app_env: str = "dev"
|
||||
app_base_url: str = "http://localhost:8000"
|
||||
app_timezone: str = "UTC"
|
||||
app_internal_url: str = "http://app:8000"
|
||||
app_port: int = 8000
|
||||
|
||||
telegram_bot_token: str
|
||||
|
||||
spotify_client_id: str
|
||||
spotify_client_secret: str
|
||||
spotify_redirect_uri: str
|
||||
spotify_default_market: str = "US"
|
||||
|
||||
lastfm_api_key: str | None = None
|
||||
|
||||
internal_job_token: str
|
||||
db_path: str = "/data/app.db"
|
||||
|
||||
default_playlist_size: int = 30
|
||||
min_new_ratio: float = Field(default=0.8, ge=0.0, le=1.0)
|
||||
recent_days_window: int = 5
|
||||
playlist_visibility: str = "private"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
return f"sqlite+aiosqlite:///{self.db_path}"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
0
app/db/__init__.py
Normal file
0
app/db/__init__.py
Normal file
18
app/db/base.py
Normal file
18
app/db/base.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
|
||||
115
app/db/models.py
Normal file
115
app/db/models.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class User(Base, TimestampMixin):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
telegram_chat_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
telegram_username: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
spotify_user_id: Mapped[str | None] = mapped_column(String(128), unique=True, nullable=True, index=True)
|
||||
spotify_access_token: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
spotify_refresh_token: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
spotify_token_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
spotify_scopes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
timezone: Mapped[str] = mapped_column(String(64), default="UTC")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
playlist_size: Mapped[int] = mapped_column(Integer, default=30)
|
||||
min_new_ratio: Mapped[float] = mapped_column(Float, default=0.8)
|
||||
last_generated_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
latest_playlist_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
latest_playlist_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
saved_tracks: Mapped[list["SavedTrack"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
playlist_runs: Mapped[list["PlaylistRun"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
rec_history: Mapped[list["RecommendationHistory"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class AuthState(Base):
|
||||
__tablename__ = "auth_states"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
state: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
telegram_chat_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class SavedTrack(Base):
|
||||
__tablename__ = "saved_tracks"
|
||||
__table_args__ = (UniqueConstraint("user_id", "spotify_track_id", name="uq_saved_track_user_track"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
spotify_track_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
name: Mapped[str] = mapped_column(Text)
|
||||
artist_names: Mapped[str] = mapped_column(Text)
|
||||
artist_ids_csv: Mapped[str] = mapped_column(Text)
|
||||
album_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
popularity: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="saved_tracks")
|
||||
|
||||
|
||||
class RecommendationHistory(Base):
|
||||
__tablename__ = "recommendation_history"
|
||||
__table_args__ = (UniqueConstraint("user_id", "spotify_track_id", name="uq_rec_history_user_track"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
spotify_track_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
first_recommended_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
last_recommended_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
times_recommended: Mapped[int] = mapped_column(Integer, default=1)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="rec_history")
|
||||
|
||||
|
||||
class PlaylistRun(Base, TimestampMixin):
|
||||
__tablename__ = "playlist_runs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
run_date: Mapped[date] = mapped_column(Date)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||
playlist_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
playlist_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
playlist_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
total_tracks: Mapped[int] = mapped_column(Integer, default=0)
|
||||
new_tracks: Mapped[int] = mapped_column(Integer, default=0)
|
||||
reused_tracks: Mapped[int] = mapped_column(Integer, default=0)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="playlist_runs")
|
||||
tracks: Mapped[list["PlaylistRunTrack"]] = relationship(back_populates="run", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class PlaylistRunTrack(Base):
|
||||
__tablename__ = "playlist_run_tracks"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("run_id", "spotify_track_id", name="uq_run_track"),
|
||||
UniqueConstraint("run_id", "position", name="uq_run_position"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
run_id: Mapped[int] = mapped_column(ForeignKey("playlist_runs.id", ondelete="CASCADE"), index=True)
|
||||
spotify_track_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
name: Mapped[str] = mapped_column(Text)
|
||||
artist_names: Mapped[str] = mapped_column(Text)
|
||||
source: Mapped[str] = mapped_column(String(64))
|
||||
position: Mapped[int] = mapped_column(Integer)
|
||||
is_new_to_bot: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
run: Mapped[PlaylistRun] = relationship(back_populates="tracks")
|
||||
201
app/db/repositories.py
Normal file
201
app/db/repositories.py
Normal file
@@ -0,0 +1,201 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import (
|
||||
AuthState,
|
||||
PlaylistRun,
|
||||
PlaylistRunTrack,
|
||||
RecommendationHistory,
|
||||
SavedTrack,
|
||||
User,
|
||||
)
|
||||
from app.utils.time import ensure_utc
|
||||
|
||||
|
||||
class UserRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get_or_create_by_chat(self, chat_id: str, username: str | None = None) -> User:
|
||||
user = await self.get_by_chat_id(chat_id)
|
||||
if user:
|
||||
if username and user.telegram_username != username:
|
||||
user.telegram_username = username
|
||||
return user
|
||||
user = User(telegram_chat_id=chat_id, telegram_username=username)
|
||||
self.session.add(user)
|
||||
await self.session.flush()
|
||||
return user
|
||||
|
||||
async def get_by_chat_id(self, chat_id: str) -> User | None:
|
||||
result = await self.session.execute(select(User).where(User.telegram_chat_id == chat_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_id(self, user_id: int) -> User | None:
|
||||
result = await self.session.execute(select(User).where(User.id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_active_connected_users(self) -> list[User]:
|
||||
result = await self.session.execute(
|
||||
select(User).where(User.is_active.is_(True), User.spotify_refresh_token.is_not(None))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
class AuthStateRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def create(self, state: str, telegram_chat_id: str, expires_at: datetime) -> AuthState:
|
||||
row = AuthState(
|
||||
state=state,
|
||||
telegram_chat_id=telegram_chat_id,
|
||||
expires_at=expires_at,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return row
|
||||
|
||||
async def pop_valid(self, state: str) -> AuthState | None:
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await self.session.execute(select(AuthState).where(AuthState.state == state))
|
||||
row = result.scalar_one_or_none()
|
||||
if not row:
|
||||
return None
|
||||
await self.session.delete(row)
|
||||
if ensure_utc(row.expires_at) < now:
|
||||
return None
|
||||
return row
|
||||
|
||||
async def delete_expired(self) -> int:
|
||||
result = await self.session.execute(delete(AuthState).where(AuthState.expires_at < datetime.now(timezone.utc)))
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
class SavedTrackRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def replace_for_user(self, user_id: int, tracks: Iterable[dict]) -> None:
|
||||
await self.session.execute(delete(SavedTrack).where(SavedTrack.user_id == user_id))
|
||||
for item in tracks:
|
||||
self.session.add(
|
||||
SavedTrack(
|
||||
user_id=user_id,
|
||||
spotify_track_id=item["id"],
|
||||
name=item["name"],
|
||||
artist_names=", ".join(item["artist_names"]),
|
||||
artist_ids_csv=",".join(item["artist_ids"]),
|
||||
album_name=item.get("album_name"),
|
||||
added_at=item.get("added_at"),
|
||||
popularity=item.get("popularity"),
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
|
||||
async def list_for_user(self, user_id: int) -> list[SavedTrack]:
|
||||
result = await self.session.execute(select(SavedTrack).where(SavedTrack.user_id == user_id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def count_for_user(self, user_id: int) -> int:
|
||||
result = await self.session.execute(select(func.count()).select_from(SavedTrack).where(SavedTrack.user_id == user_id))
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
class RecommendationHistoryRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_track_ids(self, user_id: int) -> set[str]:
|
||||
result = await self.session.execute(
|
||||
select(RecommendationHistory.spotify_track_id).where(RecommendationHistory.user_id == user_id)
|
||||
)
|
||||
return {row[0] for row in result.all()}
|
||||
|
||||
async def mark_tracks(self, user_id: int, track_ids: list[str]) -> None:
|
||||
if not track_ids:
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await self.session.execute(select(RecommendationHistory).where(RecommendationHistory.user_id == user_id))
|
||||
existing = {row.spotify_track_id: row for row in result.scalars().all()}
|
||||
for track_id in track_ids:
|
||||
if track_id in existing:
|
||||
row = existing[track_id]
|
||||
row.last_recommended_at = now
|
||||
row.times_recommended += 1
|
||||
else:
|
||||
self.session.add(
|
||||
RecommendationHistory(
|
||||
user_id=user_id,
|
||||
spotify_track_id=track_id,
|
||||
first_recommended_at=now,
|
||||
last_recommended_at=now,
|
||||
times_recommended=1,
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
|
||||
|
||||
class PlaylistRunRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def create_run(self, user_id: int, run_date: date, notes: str | None = None) -> PlaylistRun:
|
||||
run = PlaylistRun(user_id=user_id, run_date=run_date, status="running", notes=notes)
|
||||
self.session.add(run)
|
||||
await self.session.flush()
|
||||
return run
|
||||
|
||||
async def add_tracks(self, run_id: int, tracks: list[dict]) -> None:
|
||||
for idx, track in enumerate(tracks, start=1):
|
||||
self.session.add(
|
||||
PlaylistRunTrack(
|
||||
run_id=run_id,
|
||||
spotify_track_id=track["id"],
|
||||
name=track["name"],
|
||||
artist_names=", ".join(track["artist_names"]),
|
||||
source=track["source"],
|
||||
position=idx,
|
||||
is_new_to_bot=track.get("is_new_to_bot", True),
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
|
||||
async def mark_success(
|
||||
self,
|
||||
run: PlaylistRun,
|
||||
*,
|
||||
playlist_id: str,
|
||||
playlist_name: str,
|
||||
playlist_url: str | None,
|
||||
total_tracks: int,
|
||||
new_tracks: int,
|
||||
reused_tracks: int,
|
||||
notes: str | None = None,
|
||||
) -> None:
|
||||
run.status = "success"
|
||||
run.playlist_id = playlist_id
|
||||
run.playlist_name = playlist_name
|
||||
run.playlist_url = playlist_url
|
||||
run.total_tracks = total_tracks
|
||||
run.new_tracks = new_tracks
|
||||
run.reused_tracks = reused_tracks
|
||||
run.notes = notes
|
||||
await self.session.flush()
|
||||
|
||||
async def mark_failed(self, run: PlaylistRun, message: str) -> None:
|
||||
run.status = "failed"
|
||||
run.notes = message
|
||||
await self.session.flush()
|
||||
|
||||
async def latest_for_user(self, user_id: int) -> PlaylistRun | None:
|
||||
result = await self.session.execute(
|
||||
select(PlaylistRun).where(PlaylistRun.user_id == user_id).order_by(PlaylistRun.created_at.desc()).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
35
app/db/session.py
Normal file
35
app/db/session.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
def create_engine(settings: Settings) -> AsyncEngine:
|
||||
return create_async_engine(settings.database_url, future=True, echo=False)
|
||||
|
||||
|
||||
def create_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
||||
return async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||
|
||||
|
||||
async def init_db(engine: AsyncEngine) -> None:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def session_scope(factory: async_sessionmaker[AsyncSession]) -> AsyncIterator[AsyncSession]:
|
||||
session = factory()
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
70
app/main.py
Normal file
70
app/main.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.routes import get_router
|
||||
from app.bot.telegram_bot import TelegramBotRunner
|
||||
from app.clients.lastfm import LastFmClient
|
||||
from app.clients.spotify import SpotifyClient
|
||||
from app.config import get_settings
|
||||
from app.db.session import create_engine, create_session_factory, init_db
|
||||
from app.runtime import AppRuntime
|
||||
from app.services.app_services import AppServices
|
||||
from app.services.playlist_job import PlaylistJobService
|
||||
from app.services.recommendation import RecommendationEngine
|
||||
from app.services.spotify_auth import SpotifyAuthService
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
engine = create_engine(settings)
|
||||
session_factory = create_session_factory(engine)
|
||||
await init_db(engine)
|
||||
|
||||
http_client = httpx.AsyncClient(headers={"User-Agent": "spotify-vibe-bot/1.0"})
|
||||
spotify = SpotifyClient(settings, http_client)
|
||||
lastfm = LastFmClient(settings.lastfm_api_key, http_client)
|
||||
auth_service = SpotifyAuthService(settings, spotify, session_factory)
|
||||
rec_engine = RecommendationEngine(settings, spotify, lastfm)
|
||||
job_service = PlaylistJobService(settings, session_factory, auth_service, rec_engine, asyncio.Lock())
|
||||
services = AppServices(auth=auth_service, recommendation=rec_engine, jobs=job_service)
|
||||
|
||||
runtime = AppRuntime(
|
||||
settings=settings,
|
||||
engine=engine,
|
||||
session_factory=session_factory,
|
||||
http_client=http_client,
|
||||
spotify=spotify,
|
||||
lastfm=lastfm,
|
||||
generate_lock=job_service.generate_lock,
|
||||
)
|
||||
app.state.runtime = runtime
|
||||
app.state.services = services
|
||||
|
||||
telegram_runner = TelegramBotRunner(
|
||||
token=settings.telegram_bot_token,
|
||||
session_factory=session_factory,
|
||||
services=services,
|
||||
app_base_url=settings.app_base_url,
|
||||
)
|
||||
runtime.telegram_runner = telegram_runner
|
||||
job_service.set_notifier(telegram_runner.send_message)
|
||||
|
||||
await telegram_runner.start_polling()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await telegram_runner.stop()
|
||||
await http_client.aclose()
|
||||
await engine.dispose()
|
||||
|
||||
app = FastAPI(title="Spotify Vibe Bot", lifespan=lifespan)
|
||||
app.include_router(get_router())
|
||||
return app
|
||||
25
app/runtime.py
Normal file
25
app/runtime.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
||||
|
||||
from app.clients.lastfm import LastFmClient
|
||||
from app.clients.spotify import SpotifyClient
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppRuntime:
|
||||
settings: Settings
|
||||
engine: AsyncEngine
|
||||
session_factory: async_sessionmaker[AsyncSession]
|
||||
http_client: httpx.AsyncClient
|
||||
spotify: SpotifyClient
|
||||
lastfm: LastFmClient
|
||||
generate_lock: asyncio.Lock
|
||||
telegram_runner: Any = None
|
||||
|
||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
15
app/services/app_services.py
Normal file
15
app/services/app_services.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.services.playlist_job import PlaylistJobService
|
||||
from app.services.recommendation import RecommendationEngine
|
||||
from app.services.spotify_auth import SpotifyAuthService
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppServices:
|
||||
auth: SpotifyAuthService
|
||||
recommendation: RecommendationEngine
|
||||
jobs: PlaylistJobService
|
||||
|
||||
149
app/services/playlist_job.py
Normal file
149
app/services/playlist_job.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.repositories import PlaylistRunRepository, RecommendationHistoryRepository, UserRepository
|
||||
from app.services.recommendation import RecommendationEngine
|
||||
from app.services.spotify_auth import SpotifyAuthService
|
||||
from app.types import PlaylistBuildResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobOutcome:
|
||||
user_id: int
|
||||
ok: bool
|
||||
message: str
|
||||
playlist_url: str | None = None
|
||||
|
||||
|
||||
class PlaylistJobService:
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
auth_service: SpotifyAuthService,
|
||||
recommendation_engine: RecommendationEngine,
|
||||
generate_lock: asyncio.Lock,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.session_factory = session_factory
|
||||
self.auth_service = auth_service
|
||||
self.recommendation_engine = recommendation_engine
|
||||
self.generate_lock = generate_lock
|
||||
self._notify = None
|
||||
|
||||
def set_notifier(self, notifier) -> None:
|
||||
self._notify = notifier
|
||||
|
||||
async def generate_for_user(self, user_id: int, *, force: bool = False, notify: bool = True) -> JobOutcome:
|
||||
async with self.generate_lock:
|
||||
async with self.session_factory() as session:
|
||||
users = UserRepository(session)
|
||||
runs = PlaylistRunRepository(session)
|
||||
history = RecommendationHistoryRepository(session)
|
||||
|
||||
user = await users.get_by_id(user_id)
|
||||
if not user:
|
||||
return JobOutcome(user_id=user_id, ok=False, message="User not found")
|
||||
if not user.spotify_refresh_token:
|
||||
return JobOutcome(user_id=user_id, ok=False, message="Spotify is not connected")
|
||||
if not force and user.last_generated_date == date.today():
|
||||
latest = await runs.latest_for_user(user.id)
|
||||
return JobOutcome(
|
||||
user_id=user.id,
|
||||
ok=True,
|
||||
message="Already generated today",
|
||||
playlist_url=latest.playlist_url if latest else user.latest_playlist_url,
|
||||
)
|
||||
|
||||
run = await runs.create_run(user_id=user.id, run_date=date.today())
|
||||
try:
|
||||
access_token = await self.auth_service.ensure_valid_access_token(session, user)
|
||||
# Re-sync likes each run so new likes affect next day's picks.
|
||||
await self.recommendation_engine.sync_saved_tracks(session, user, access_token)
|
||||
build = await self.recommendation_engine.build_daily_playlist(session, user, access_token)
|
||||
if not build.tracks:
|
||||
raise RuntimeError("No candidate tracks found. Try listening more or widen sources.")
|
||||
playlist_meta = await self._create_spotify_playlist(session, user, access_token, build)
|
||||
|
||||
serialized_tracks = []
|
||||
for c in build.tracks:
|
||||
serialized_tracks.append(
|
||||
{
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"artist_names": c.artist_names,
|
||||
"source": c.source,
|
||||
"is_new_to_bot": True, # fixed below
|
||||
}
|
||||
)
|
||||
history_ids = await history.list_track_ids(user.id)
|
||||
for item in serialized_tracks:
|
||||
item["is_new_to_bot"] = item["id"] not in history_ids
|
||||
|
||||
await runs.add_tracks(run.id, serialized_tracks)
|
||||
await history.mark_tracks(user.id, [c.id for c in build.tracks])
|
||||
await runs.mark_success(
|
||||
run,
|
||||
playlist_id=playlist_meta["id"],
|
||||
playlist_name=playlist_meta["name"],
|
||||
playlist_url=playlist_meta.get("url"),
|
||||
total_tracks=len(build.tracks),
|
||||
new_tracks=build.new_count,
|
||||
reused_tracks=build.reused_count,
|
||||
notes=build.notes,
|
||||
)
|
||||
user.last_generated_date = date.today()
|
||||
user.latest_playlist_id = playlist_meta["id"]
|
||||
user.latest_playlist_url = playlist_meta.get("url")
|
||||
await session.commit()
|
||||
message = (
|
||||
f"Playlist ready: {playlist_meta['name']} ({len(build.tracks)} tracks, "
|
||||
f"new {build.new_count}/{len(build.tracks)})"
|
||||
)
|
||||
if notify and self._notify:
|
||||
await self._notify(user.telegram_chat_id, f"{message}\n{playlist_meta.get('url', '')}".strip())
|
||||
return JobOutcome(user_id=user.id, ok=True, message=message, playlist_url=playlist_meta.get("url"))
|
||||
except Exception as exc:
|
||||
await runs.mark_failed(run, str(exc))
|
||||
await session.commit()
|
||||
if notify and self._notify:
|
||||
await self._notify(user.telegram_chat_id, f"Playlist generation failed: {exc}")
|
||||
return JobOutcome(user_id=user.id, ok=False, message=str(exc))
|
||||
|
||||
async def generate_for_all_connected_users(self) -> list[JobOutcome]:
|
||||
async with self.session_factory() as session:
|
||||
users_repo = UserRepository(session)
|
||||
users = await users_repo.list_active_connected_users()
|
||||
outcomes: list[JobOutcome] = []
|
||||
for user in users:
|
||||
outcomes.append(await self.generate_for_user(user.id, notify=True))
|
||||
return outcomes
|
||||
|
||||
async def _create_spotify_playlist(
|
||||
self, session: AsyncSession, user, access_token: str, build: PlaylistBuildResult
|
||||
) -> dict[str, str | None]:
|
||||
public = self.settings.playlist_visibility.lower() == "public"
|
||||
name = f"Daily Vibe {date.today().isoformat()}"
|
||||
desc = (
|
||||
"Auto-generated from your recent listening + liked tracks. "
|
||||
f"New-to-bot: {build.new_count}/{len(build.tracks)}."
|
||||
)
|
||||
playlist = await self.auth_service.spotify.create_playlist(
|
||||
access_token,
|
||||
user_id=user.spotify_user_id,
|
||||
name=name,
|
||||
description=desc,
|
||||
public=public,
|
||||
)
|
||||
await self.auth_service.spotify.add_playlist_items(access_token, playlist["id"], [c.uri for c in build.tracks])
|
||||
return {
|
||||
"id": playlist["id"],
|
||||
"name": playlist["name"],
|
||||
"url": ((playlist.get("external_urls") or {}).get("spotify")),
|
||||
}
|
||||
374
app/services/recommendation.py
Normal file
374
app/services/recommendation.py
Normal file
@@ -0,0 +1,374 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.clients.lastfm import LastFmClient
|
||||
from app.clients.spotify import SpotifyApiError, SpotifyClient
|
||||
from app.config import Settings
|
||||
from app.db.models import SavedTrack, User
|
||||
from app.db.repositories import RecommendationHistoryRepository, SavedTrackRepository
|
||||
from app.types import PlaylistBuildResult, TrackCandidate
|
||||
from app.utils.text import normalize_track_signature
|
||||
from app.utils.time import utcnow
|
||||
|
||||
|
||||
class RecommendationEngine:
|
||||
def __init__(self, settings: Settings, spotify: SpotifyClient, lastfm: LastFmClient) -> None:
|
||||
self.settings = settings
|
||||
self.spotify = spotify
|
||||
self.lastfm = lastfm
|
||||
|
||||
async def sync_saved_tracks(self, session: AsyncSession, user: User, access_token: str) -> list[SavedTrack]:
|
||||
saved_tracks_repo = SavedTrackRepository(session)
|
||||
raw = await self.spotify.get_saved_tracks_all(access_token)
|
||||
await saved_tracks_repo.replace_for_user(user.id, raw)
|
||||
return await saved_tracks_repo.list_for_user(user.id)
|
||||
|
||||
async def build_daily_playlist(self, session: AsyncSession, user: User, access_token: str) -> PlaylistBuildResult:
|
||||
saved_tracks_repo = SavedTrackRepository(session)
|
||||
history_repo = RecommendationHistoryRepository(session)
|
||||
|
||||
saved_rows = await saved_tracks_repo.list_for_user(user.id)
|
||||
if not saved_rows:
|
||||
saved_rows = await self.sync_saved_tracks(session, user, access_token)
|
||||
|
||||
recent_since = utcnow() - timedelta(days=self.settings.recent_days_window)
|
||||
recent_plays = await self.spotify.get_recently_played(access_token, since=recent_since, max_pages=12)
|
||||
|
||||
seed = self._build_seed_profile(saved_rows, recent_plays, user_id=user.id)
|
||||
history_ids = await history_repo.list_track_ids(user.id)
|
||||
liked_ids = {row.spotify_track_id for row in saved_rows}
|
||||
|
||||
market = self._normalize_spotify_market(self.settings.spotify_default_market)
|
||||
candidates = await self._collect_candidates(
|
||||
access_token=access_token,
|
||||
seed=seed,
|
||||
market=market,
|
||||
)
|
||||
result = self._rank_and_select(
|
||||
candidates=candidates,
|
||||
liked_ids=liked_ids,
|
||||
history_ids=history_ids,
|
||||
target_size=max(1, user.playlist_size or self.settings.default_playlist_size),
|
||||
min_new_ratio=user.min_new_ratio if user.min_new_ratio is not None else self.settings.min_new_ratio,
|
||||
)
|
||||
return result
|
||||
|
||||
def _build_seed_profile(self, saved_rows: list[SavedTrack], recent_plays: list[dict[str, Any]], *, user_id: int) -> dict[str, Any]:
|
||||
today = date.today()
|
||||
rng = random.Random(f"{user_id}-{today.isoformat()}")
|
||||
|
||||
recent_track_counts: Counter[str] = Counter()
|
||||
recent_track_meta: dict[str, dict[str, Any]] = {}
|
||||
artist_weights: Counter[str] = Counter()
|
||||
artist_names: dict[str, str] = {}
|
||||
|
||||
for idx, play in enumerate(sorted(recent_plays, key=lambda x: x.get("played_at") or utcnow(), reverse=True)):
|
||||
track_id = play["id"]
|
||||
weight = max(1.0, 3.0 - (idx * 0.04))
|
||||
recent_track_counts[track_id] += weight
|
||||
recent_track_meta[track_id] = play
|
||||
for artist_id, artist_name in zip(play.get("artist_ids", []), play.get("artist_names", [])):
|
||||
artist_weights[artist_id] += weight
|
||||
artist_names[artist_id] = artist_name
|
||||
|
||||
sorted_saved = sorted(saved_rows, key=lambda x: x.added_at or utcnow(), reverse=True)
|
||||
recent_likes = sorted_saved[:120]
|
||||
sampled_older = rng.sample(sorted_saved[120:], k=min(180, max(0, len(sorted_saved) - 120))) if len(sorted_saved) > 120 else []
|
||||
exploration_pool = recent_likes + sampled_older
|
||||
|
||||
for idx, row in enumerate(exploration_pool):
|
||||
base_weight = 1.2 if idx < 50 else 0.6
|
||||
artist_ids = [a for a in row.artist_ids_csv.split(",") if a]
|
||||
artist_list = [x.strip() for x in row.artist_names.split(",") if x.strip()]
|
||||
for artist_id, artist_name in zip(artist_ids, artist_list):
|
||||
artist_weights[artist_id] += base_weight
|
||||
artist_names[artist_id] = artist_name
|
||||
|
||||
seed_track_ids = [t for t, _ in recent_track_counts.most_common(10)]
|
||||
if len(seed_track_ids) < 10:
|
||||
for row in recent_likes[:20]:
|
||||
if row.spotify_track_id not in seed_track_ids:
|
||||
seed_track_ids.append(row.spotify_track_id)
|
||||
if len(seed_track_ids) >= 10:
|
||||
break
|
||||
|
||||
seed_artist_ids = [a for a, _ in artist_weights.most_common(20)]
|
||||
seed_artist_names = [artist_names[a] for a in seed_artist_ids if a in artist_names]
|
||||
|
||||
return {
|
||||
"seed_track_ids": seed_track_ids,
|
||||
"seed_artists": seed_artist_ids,
|
||||
"seed_artist_names": seed_artist_names,
|
||||
"recent_track_meta": recent_track_meta,
|
||||
}
|
||||
|
||||
async def _collect_candidates(self, *, access_token: str, seed: dict[str, Any], market: str | None) -> list[TrackCandidate]:
|
||||
by_id: dict[str, TrackCandidate] = {}
|
||||
sig_to_id: dict[str, str] = {}
|
||||
source_count: Counter[str] = Counter()
|
||||
recent_track_meta = seed["recent_track_meta"]
|
||||
|
||||
def upsert(candidate: TrackCandidate) -> None:
|
||||
sig = normalize_track_signature(candidate.name, candidate.artist_names)
|
||||
existing_id = sig_to_id.get(sig)
|
||||
if existing_id and existing_id != candidate.id and existing_id in by_id:
|
||||
if candidate.score <= by_id[existing_id].score:
|
||||
return
|
||||
del by_id[existing_id]
|
||||
existing = by_id.get(candidate.id)
|
||||
if existing:
|
||||
existing.score = max(existing.score, candidate.score)
|
||||
if candidate.source not in existing.source:
|
||||
existing.source = f"{existing.source}+{candidate.source}"
|
||||
for reason in candidate.seed_reasons:
|
||||
if reason not in existing.seed_reasons:
|
||||
existing.seed_reasons.append(reason)
|
||||
return
|
||||
by_id[candidate.id] = candidate
|
||||
sig_to_id[sig] = candidate.id
|
||||
source_count[candidate.source] += 1
|
||||
|
||||
seed_tracks = list(seed["seed_track_ids"])
|
||||
seed_artists = list(seed["seed_artists"])
|
||||
top_tracks_market = market or "US"
|
||||
|
||||
for batch_idx in range(4):
|
||||
# Spotify recommendations endpoint supports max 5 total seeds.
|
||||
track_start = batch_idx * 2
|
||||
artist_start = batch_idx * 3
|
||||
batch_seed_tracks = seed_tracks[track_start : track_start + 2]
|
||||
remaining_slots = max(0, 5 - len(batch_seed_tracks))
|
||||
batch_seed_artists = seed_artists[artist_start : artist_start + remaining_slots]
|
||||
if not batch_seed_tracks and not batch_seed_artists:
|
||||
continue
|
||||
try:
|
||||
rec_tracks = await self.spotify.get_recommendations(
|
||||
access_token,
|
||||
seed_tracks=batch_seed_tracks,
|
||||
seed_artists=batch_seed_artists,
|
||||
limit=100,
|
||||
market=market,
|
||||
)
|
||||
except SpotifyApiError:
|
||||
rec_tracks = []
|
||||
for raw in rec_tracks:
|
||||
cand = self._candidate_from_spotify_track(raw, source="spotify_recommendations", base_score=1.0)
|
||||
if not cand:
|
||||
continue
|
||||
if any(a in batch_seed_artists for a in cand.artist_ids):
|
||||
cand.score += 0.08
|
||||
upsert(cand)
|
||||
|
||||
for artist_id in seed_artists[:12]:
|
||||
try:
|
||||
top_tracks = await self.spotify.get_artist_top_tracks(
|
||||
access_token, artist_id=artist_id, market=top_tracks_market
|
||||
)
|
||||
except SpotifyApiError:
|
||||
continue
|
||||
for raw in top_tracks:
|
||||
cand = self._candidate_from_spotify_track(raw, source="artist_top_tracks", base_score=0.68)
|
||||
if not cand:
|
||||
continue
|
||||
if artist_id in cand.artist_ids:
|
||||
cand.score += 0.07
|
||||
upsert(cand)
|
||||
|
||||
# Fallback for apps/accounts where top-tracks and recommendations endpoints are restricted.
|
||||
if len(by_id) < 40:
|
||||
for artist_name in seed.get("seed_artist_names", [])[:12]:
|
||||
if not artist_name:
|
||||
continue
|
||||
try:
|
||||
search_hits = await self.spotify.search_track(
|
||||
access_token,
|
||||
track_name="",
|
||||
artist_name=artist_name,
|
||||
market=market,
|
||||
)
|
||||
except SpotifyApiError:
|
||||
continue
|
||||
for raw in search_hits[:3]:
|
||||
cand = self._candidate_from_spotify_track(raw, source="spotify_search_artist", base_score=0.55)
|
||||
if not cand:
|
||||
continue
|
||||
if artist_name.lower() in {a.lower() for a in cand.artist_names}:
|
||||
cand.score += 0.05
|
||||
upsert(cand)
|
||||
|
||||
if self.lastfm.enabled:
|
||||
for track_id in seed_tracks[:10]:
|
||||
meta = recent_track_meta.get(track_id)
|
||||
if not meta:
|
||||
continue
|
||||
artist_name = (meta.get("artist_names") or [None])[0]
|
||||
if not artist_name:
|
||||
continue
|
||||
try:
|
||||
similars = await self.lastfm.track_similar(artist=artist_name, track=meta["name"], limit=10)
|
||||
except Exception:
|
||||
similars = []
|
||||
for item in similars[:5]:
|
||||
lf_name = item.get("name")
|
||||
lf_artist = (item.get("artist") or {}).get("name") if isinstance(item.get("artist"), dict) else None
|
||||
if not lf_name:
|
||||
continue
|
||||
try:
|
||||
search_hits = await self.spotify.search_track(
|
||||
access_token, track_name=lf_name, artist_name=lf_artist, market=market
|
||||
)
|
||||
except SpotifyApiError:
|
||||
search_hits = []
|
||||
for raw in search_hits[:1]:
|
||||
cand = self._candidate_from_spotify_track(raw, source="lastfm_track_similar", base_score=0.9)
|
||||
if cand:
|
||||
upsert(cand)
|
||||
|
||||
for artist_name in seed.get("seed_artist_names", [])[:8]:
|
||||
try:
|
||||
similars = await self.lastfm.artist_similar(artist=artist_name, limit=8)
|
||||
except Exception:
|
||||
similars = []
|
||||
for item in similars[:4]:
|
||||
sim_artist = item.get("name")
|
||||
if not sim_artist:
|
||||
continue
|
||||
try:
|
||||
search_hits = await self.spotify.search_track(
|
||||
access_token, track_name="", artist_name=sim_artist, market=market
|
||||
)
|
||||
except SpotifyApiError:
|
||||
search_hits = []
|
||||
for raw in search_hits[:2]:
|
||||
cand = self._candidate_from_spotify_track(raw, source="lastfm_artist_similar", base_score=0.78)
|
||||
if cand:
|
||||
upsert(cand)
|
||||
|
||||
return list(by_id.values())
|
||||
|
||||
def _candidate_from_spotify_track(self, raw: dict[str, Any], *, source: str, base_score: float) -> TrackCandidate | None:
|
||||
track_id = raw.get("id")
|
||||
if not track_id:
|
||||
return None
|
||||
artists = raw.get("artists") or []
|
||||
artist_names = [a.get("name") or "Unknown" for a in artists]
|
||||
artist_ids = [a.get("id") for a in artists if a.get("id")]
|
||||
popularity = raw.get("popularity")
|
||||
|
||||
score = base_score
|
||||
if isinstance(popularity, int):
|
||||
# Prefer mid-popularity a bit to avoid obvious mainstream repeats and totally obscure misses.
|
||||
score += max(-0.12, 0.15 - abs(popularity - 55) / 250)
|
||||
|
||||
return TrackCandidate(
|
||||
id=track_id,
|
||||
uri=raw.get("uri") or f"spotify:track:{track_id}",
|
||||
name=raw.get("name") or "Unknown",
|
||||
artist_names=artist_names,
|
||||
artist_ids=artist_ids,
|
||||
popularity=popularity,
|
||||
source=source,
|
||||
score=score,
|
||||
)
|
||||
|
||||
def _rank_and_select(
|
||||
self,
|
||||
*,
|
||||
candidates: list[TrackCandidate],
|
||||
liked_ids: set[str],
|
||||
history_ids: set[str],
|
||||
target_size: int,
|
||||
min_new_ratio: float,
|
||||
) -> PlaylistBuildResult:
|
||||
min_new_required = math.ceil(target_size * min_new_ratio)
|
||||
|
||||
filtered = [c for c in candidates if c.id not in liked_ids]
|
||||
liked_fallback_used = False
|
||||
if not filtered and candidates:
|
||||
# If every discovered candidate is already liked, degrade gracefully instead of failing the run.
|
||||
filtered = list(candidates)
|
||||
liked_fallback_used = True
|
||||
for c in filtered:
|
||||
if c.id in liked_ids:
|
||||
c.score -= 0.35
|
||||
if c.id in history_ids:
|
||||
c.score -= 0.2
|
||||
if len(c.artist_ids) > 1:
|
||||
c.score += 0.03
|
||||
c.score += min(0.15, 0.01 * len(c.seed_reasons))
|
||||
|
||||
filtered.sort(key=lambda c: (c.score, c.popularity or 0), reverse=True)
|
||||
|
||||
novel = [c for c in filtered if c.id not in history_ids and c.id not in liked_ids]
|
||||
reused = [c for c in filtered if c.id in history_ids or c.id in liked_ids]
|
||||
|
||||
selected: list[TrackCandidate] = []
|
||||
artist_caps: defaultdict[str, int] = defaultdict(int)
|
||||
|
||||
def try_take(pool: list[TrackCandidate], count: int, hard_artist_cap: int) -> None:
|
||||
for c in pool:
|
||||
if len(selected) >= count:
|
||||
return
|
||||
if any(s.id == c.id for s in selected):
|
||||
continue
|
||||
main_artist = c.artist_ids[0] if c.artist_ids else f"name:{(c.artist_names or [''])[0].lower()}"
|
||||
if artist_caps[main_artist] >= hard_artist_cap:
|
||||
continue
|
||||
artist_caps[main_artist] += 1
|
||||
selected.append(c)
|
||||
|
||||
try_take(novel, min_new_required, hard_artist_cap=2)
|
||||
if len([c for c in selected if c.id not in history_ids and c.id not in liked_ids]) < min_new_required:
|
||||
try_take(novel, min_new_required, hard_artist_cap=4)
|
||||
|
||||
try_take(novel, target_size, hard_artist_cap=3)
|
||||
try_take(reused, target_size, hard_artist_cap=2)
|
||||
if len(selected) < target_size:
|
||||
try_take(reused, target_size, hard_artist_cap=4)
|
||||
|
||||
# Mark novelty for persistence.
|
||||
for c in selected:
|
||||
c.seed_reasons = c.seed_reasons or []
|
||||
|
||||
new_count = len([c for c in selected if c.id not in history_ids and c.id not in liked_ids])
|
||||
reused_count = len(selected) - new_count
|
||||
|
||||
notes_parts: list[str] = []
|
||||
if liked_fallback_used:
|
||||
notes_parts.append("All discovered candidates were already in Liked Songs; allowed liked-track fallback.")
|
||||
if new_count < min_new_required:
|
||||
notes_parts.append(
|
||||
f"Not enough completely new tracks to satisfy {int(min_new_ratio * 100)}% target "
|
||||
f"(got {new_count}/{target_size})."
|
||||
)
|
||||
notes = " ".join(notes_parts) if notes_parts else None
|
||||
|
||||
return PlaylistBuildResult(
|
||||
tracks=selected,
|
||||
target_size=target_size,
|
||||
new_count=new_count,
|
||||
reused_count=reused_count,
|
||||
min_new_required=min_new_required,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_spotify_market(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
market = value.strip().upper()
|
||||
if not market:
|
||||
return None
|
||||
# Common shorthand users put in .env, but Spotify APIs expect a country code.
|
||||
if market in {"EU", "GLOBAL", "WORLD", "ALL"}:
|
||||
return None
|
||||
if len(market) == 2 and market.isalpha():
|
||||
return market
|
||||
return None
|
||||
86
app/services/spotify_auth.py
Normal file
86
app/services/spotify_auth.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.clients.spotify import SpotifyClient
|
||||
from app.config import Settings
|
||||
from app.db.repositories import AuthStateRepository, UserRepository
|
||||
from app.utils.time import ensure_utc, utcnow
|
||||
|
||||
|
||||
SPOTIFY_SCOPES = [
|
||||
"user-library-read",
|
||||
"user-read-recently-played",
|
||||
"playlist-modify-private",
|
||||
"playlist-modify-public",
|
||||
]
|
||||
|
||||
|
||||
class SpotifyAuthService:
|
||||
def __init__(self, settings: Settings, spotify: SpotifyClient, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self.settings = settings
|
||||
self.spotify = spotify
|
||||
self.session_factory = session_factory
|
||||
|
||||
async def create_connect_url(self, chat_id: str, username: str | None = None) -> str:
|
||||
async with self.session_factory() as session:
|
||||
users = UserRepository(session)
|
||||
states = AuthStateRepository(session)
|
||||
user = await users.get_or_create_by_chat(chat_id=chat_id, username=username)
|
||||
user.timezone = user.timezone or self.settings.app_timezone
|
||||
state = secrets.token_urlsafe(24)
|
||||
await states.delete_expired()
|
||||
await states.create(state=state, telegram_chat_id=user.telegram_chat_id, expires_at=utcnow() + timedelta(minutes=15))
|
||||
await session.commit()
|
||||
return self.spotify.build_authorize_url(state=state, scopes=SPOTIFY_SCOPES)
|
||||
|
||||
async def handle_callback(self, code: str, state: str) -> tuple[str, str]:
|
||||
async with self.session_factory() as session:
|
||||
users = UserRepository(session)
|
||||
states = AuthStateRepository(session)
|
||||
auth_state = await states.pop_valid(state)
|
||||
if not auth_state:
|
||||
await session.commit()
|
||||
raise ValueError("OAuth state is invalid or expired")
|
||||
|
||||
user = await users.get_by_chat_id(auth_state.telegram_chat_id)
|
||||
if not user:
|
||||
raise ValueError("User not found for auth state")
|
||||
|
||||
token_payload = await self.spotify.exchange_code(code)
|
||||
access_token = token_payload["access_token"]
|
||||
me = await self.spotify.get_current_user(access_token)
|
||||
|
||||
user.spotify_user_id = me.get("id")
|
||||
user.spotify_access_token = access_token
|
||||
user.spotify_refresh_token = token_payload.get("refresh_token") or user.spotify_refresh_token
|
||||
user.spotify_token_expires_at = self.spotify.token_expiry_from_response(token_payload)
|
||||
user.spotify_scopes = token_payload.get("scope")
|
||||
user.is_active = True
|
||||
if not user.timezone:
|
||||
user.timezone = self.settings.app_timezone
|
||||
|
||||
await session.commit()
|
||||
return user.telegram_chat_id, me.get("display_name") or me.get("id") or "Spotify user"
|
||||
|
||||
async def ensure_valid_access_token(self, session: AsyncSession, user) -> str:
|
||||
if (
|
||||
user.spotify_access_token
|
||||
and user.spotify_token_expires_at
|
||||
and ensure_utc(user.spotify_token_expires_at) > utcnow()
|
||||
):
|
||||
return user.spotify_access_token
|
||||
if not user.spotify_refresh_token:
|
||||
raise RuntimeError("User is not connected to Spotify")
|
||||
token_payload = await self.spotify.refresh_access_token(user.spotify_refresh_token)
|
||||
user.spotify_access_token = token_payload["access_token"]
|
||||
if token_payload.get("refresh_token"):
|
||||
user.spotify_refresh_token = token_payload["refresh_token"]
|
||||
user.spotify_token_expires_at = self.spotify.token_expiry_from_response(token_payload)
|
||||
if token_payload.get("scope"):
|
||||
user.spotify_scopes = token_payload["scope"]
|
||||
await session.flush()
|
||||
return user.spotify_access_token
|
||||
28
app/types.py
Normal file
28
app/types.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackCandidate:
|
||||
id: str
|
||||
uri: str
|
||||
name: str
|
||||
artist_names: list[str]
|
||||
artist_ids: list[str]
|
||||
popularity: int | None = None
|
||||
source: str = "unknown"
|
||||
score: float = 0.0
|
||||
seed_reasons: list[str] = field(default_factory=list)
|
||||
added_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaylistBuildResult:
|
||||
tracks: list[TrackCandidate]
|
||||
target_size: int
|
||||
new_count: int
|
||||
reused_count: int
|
||||
min_new_required: int
|
||||
notes: str | None = None
|
||||
0
app/utils/__init__.py
Normal file
0
app/utils/__init__.py
Normal file
14
app/utils/text.py
Normal file
14
app/utils/text.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
_WS_RE = re.compile(r"\s+")
|
||||
_FEAT_RE = re.compile(r"\s*[\(\[\-–—]\s*(feat|ft)\.?.*$", re.IGNORECASE)
|
||||
|
||||
|
||||
def normalize_track_signature(name: str, artists: list[str]) -> str:
|
||||
clean_name = _FEAT_RE.sub("", name).strip().lower()
|
||||
clean_name = _WS_RE.sub(" ", clean_name)
|
||||
clean_artists = ",".join(sorted(a.strip().lower() for a in artists if a.strip()))
|
||||
return f"{clean_name}::{clean_artists}"
|
||||
25
app/utils/time.py
Normal file
25
app/utils/time.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def ensure_utc(dt: datetime) -> datetime:
|
||||
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def parse_spotify_datetime(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
if value.endswith("Z"):
|
||||
value = value.replace("Z", "+00:00")
|
||||
return datetime.fromisoformat(value)
|
||||
|
||||
|
||||
def to_unix_ms(dt: datetime) -> int:
|
||||
return int(dt.timestamp() * 1000)
|
||||
Reference in New Issue
Block a user