mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-07-29 07:13:48 +00:00
first commit
This commit is contained in:
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# Go
|
||||||
|
bin/
|
||||||
|
*.test
|
||||||
|
coverage.out
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Local env and logs
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS/editor
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,67 @@
|
|||||||
|
# Flow — Music Discovery
|
||||||
|
|
||||||
|
A Spotify-style recommendation system with a clean, Tidal-inspired web interface.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- `apps/backend` — Go API with content-based + collaborative filtering recommendation engine
|
||||||
|
- `internal/provider/webplayer` — Native Go auth-free Spotify client (TOTP-based)
|
||||||
|
- `internal/provider/songlink` — Cross-platform music URL mapping
|
||||||
|
- `apps/web` — Minimal black/cyan UI for pasting song links and discovering music
|
||||||
|
- `swingmusic/` — Reference Python implementation with advanced features
|
||||||
|
|
||||||
|
## Quick Start (No API Keys Required)
|
||||||
|
|
||||||
|
The backend now includes native Go implementation for auth-free Spotify access - no Python service needed!
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Start the backend (includes native auth-free Spotify client)
|
||||||
|
cd apps/backend
|
||||||
|
STORE_DRIVER=memory SEED_DEMO_DATA=true go run ./cmd/api
|
||||||
|
|
||||||
|
# 2. In another terminal, start the web UI
|
||||||
|
cd apps/web
|
||||||
|
python3 -m http.server 3000
|
||||||
|
|
||||||
|
# 3. Open http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with Docker Compose (coming soon):
|
||||||
|
```bash
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Paste any Spotify, Apple Music, YouTube Music, Tidal, Deezer, or SoundCloud link
|
||||||
|
- Backend imports tracks, resolves supported music URLs to Spotify when possible, extracts audio features, and runs the recommendation algorithm
|
||||||
|
- Recommendations use weighted audio similarity + metadata affinity + collaborative filtering + diversity reranking
|
||||||
|
- Results include links to all major streaming services
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Tidal-inspired: black background (#000), cyan accent (#00d4ff), generous whitespace, Inter typography, minimal UI with no card clutter.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Backend API](apps/backend/README.md) — Go recommendation engine, endpoints, configuration
|
||||||
|
- [Web UI](apps/web/README.md) — Frontend structure and development
|
||||||
|
|
||||||
|
## Auth Options
|
||||||
|
|
||||||
|
**No authentication (default):**
|
||||||
|
The backend includes a native Go webplayer client that generates TOTP tokens (same method as official Web Player) to get anonymous access. No Spotify account, no API keys, no Python service required.
|
||||||
|
|
||||||
|
**With official Spotify API (optional):**
|
||||||
|
Set `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` env vars for official API access. Falls back to native webplayer if not configured.
|
||||||
|
|
||||||
|
## Algorithm
|
||||||
|
|
||||||
|
The recommendation engine combines:
|
||||||
|
1. **Content-based**: Weighted cosine similarity over Spotify-style audio feature ranges
|
||||||
|
2. **Metadata affinity**: Genre and artist matching for cold-start and missing-feature imports
|
||||||
|
3. **Collaborative**: Pearson-style neighborhood scores with overlap shrinkage
|
||||||
|
4. **Exploration**: Controlled distance from taste vector for comfort, balanced, and discovery modes
|
||||||
|
5. **Diversity**: Maximal Marginal Relevance reranking to avoid similar recommendations
|
||||||
|
6. **Safety/Controls**: Explicit filters, artist/track exclusions, skip/dislike suppression, popularity dampening
|
||||||
|
|
||||||
|
See [project.md](project.md) for deep dive into Spotify's algorithm architecture.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
APP_ENV=development
|
||||||
|
APP_VERSION=0.1.0
|
||||||
|
HTTP_ADDR=:8080
|
||||||
|
STORE_DRIVER=postgres
|
||||||
|
DATABASE_URL=postgres://spotify:spotify@localhost:5432/spotifyrec?sslmode=disable
|
||||||
|
API_KEYS=
|
||||||
|
SEED_DEMO_DATA=false
|
||||||
|
|
||||||
|
REC_CONTENT_WEIGHT=0.44
|
||||||
|
REC_COLLAB_WEIGHT=0.28
|
||||||
|
REC_POPULARITY_WEIGHT=0.08
|
||||||
|
REC_EXPLORATION_WEIGHT=0.20
|
||||||
|
REC_DIVERSITY_LAMBDA=0.74
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
FROM golang:1.24-bookworm AS build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY go.mod go.sum* ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN go install github.com/pressly/goose/v3/cmd/[email protected]
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/recommendation-api ./cmd/api
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /out/recommendation-api /app/recommendation-api
|
||||||
|
COPY --from=build /go/bin/goose /app/goose
|
||||||
|
COPY migrations /app/migrations
|
||||||
|
COPY docs /app/docs
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/app/recommendation-api"]
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# SpotifyRecAlg Backend
|
||||||
|
|
||||||
|
Go recommendation API for music catalogs. It combines the approaches from `project.md` and the included papers:
|
||||||
|
|
||||||
|
- content-based exploration over normalized audio features
|
||||||
|
- weighted Spotify-style audio similarity over fixed feature ranges
|
||||||
|
- metadata affinity for genre/artist fallback when audio features are missing
|
||||||
|
- collaborative exploitation using Pearson-style neighborhood scores
|
||||||
|
- seed-track and manual feature targeting
|
||||||
|
- explicit user controls for hidden tracks, genres, artists, and explicit content
|
||||||
|
- popularity dampening, safety penalties, constrained commercial boosts, and diversity reranking
|
||||||
|
- response explanations so clients can show why a track was recommended
|
||||||
|
|
||||||
|
## Authentication Options
|
||||||
|
|
||||||
|
**Option 1: Auth-free (default)** - Native Go webplayer client
|
||||||
|
No Spotify API credentials needed. The backend includes a native webplayer client that generates TOTP tokens (same method as official Web Player) to get anonymous access. No external services required.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/backend
|
||||||
|
STORE_DRIVER=memory SEED_DEMO_DATA=true go run ./cmd/api
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2: Official Spotify API** - Set credentials
|
||||||
|
```bash
|
||||||
|
export SPOTIFY_CLIENT_ID=...
|
||||||
|
export SPOTIFY_CLIENT_SECRET=...
|
||||||
|
cd apps/backend && go run ./cmd/api
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend automatically falls back to the native webplayer client if Spotify credentials are not configured.
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
|
||||||
|
Memory mode, with demo data:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/backend
|
||||||
|
STORE_DRIVER=memory SEED_DEMO_DATA=true go run ./cmd/api
|
||||||
|
```
|
||||||
|
|
||||||
|
Postgres mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f infra/docker-compose.yml up postgres -d
|
||||||
|
cd apps/backend
|
||||||
|
go install github.com/pressly/goose/v3/cmd/goose@latest
|
||||||
|
goose -dir migrations postgres "postgres://spotify:spotify@localhost:5432/spotifyrec?sslmode=disable" up
|
||||||
|
go run ./cmd/api
|
||||||
|
```
|
||||||
|
|
||||||
|
Request recommendations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8080/v1/recommendations \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"user_id":"demo-user","limit":5,"mode":"balanced"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Import one Spotify track (works with unlocker or official API):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8080/v1/providers/spotify/import \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"source":{"type":"url","value":"https://open.spotify.com/track/3n3Ppam7vgaVa1iaRUc9Lp"}, "market":"US", "enrich_musicbrainz":true, "persist":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Surface
|
||||||
|
|
||||||
|
- `POST /v1/tracks` upsert one track
|
||||||
|
- `PUT /v1/tracks/batch` upsert up to 1000 tracks
|
||||||
|
- `POST /v1/interactions` ingest play, skip, like, dislike, save, or hide events
|
||||||
|
- `POST /v1/recommendations` create explainable ranked recommendations
|
||||||
|
- `GET /v1/users/{user_id}/taste-profile` inspect the computed profile
|
||||||
|
- `GET /v1/users/{user_id}/controls` read taste and safety controls
|
||||||
|
- `PUT /v1/users/{user_id}/controls` update controls
|
||||||
|
- `POST /v1/providers/spotify/import` import Spotify track, album, playlist, or artist tracks
|
||||||
|
- `POST /v1/providers/spotify/search` search Spotify tracks with limit capped at 10
|
||||||
|
- `POST /v1/providers/musicbrainz/enrich` enrich existing tracks by ISRC or title/artist search
|
||||||
|
- `GET /v1/providers/status` inspect provider configuration, availability, and cache stats
|
||||||
|
- `GET /healthz` liveness
|
||||||
|
- `GET /readyz` storage readiness
|
||||||
|
|
||||||
|
See `docs/openapi.yaml` for the contract.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The HTTP layer depends on a small storage interface and the recommendation engine depends only on a snapshot provider. That keeps this service wireable to another backend: you can replace Postgres with your own catalog, data lake, event stream, or user service without changing the scorer.
|
||||||
|
|
||||||
|
Core scoring:
|
||||||
|
|
||||||
|
```text
|
||||||
|
final =
|
||||||
|
content_weight * weighted_audio_and_metadata_similarity
|
||||||
|
+ collaborative_weight * overlap_shrunk_neighbor_score
|
||||||
|
+ popularity_weight * mode_aware_popularity_fit
|
||||||
|
+ exploration_weight * target_distance_score
|
||||||
|
+ constrained_commercial_boost
|
||||||
|
|
||||||
|
final *= safety_score * negative_feedback_penalty
|
||||||
|
```
|
||||||
|
|
||||||
|
Candidates are then reranked with a Maximal Marginal Relevance style diversity pass so the top results are not duplicates of the same audio neighborhood.
|
||||||
|
|
||||||
|
## Production Notes
|
||||||
|
|
||||||
|
- Set `API_KEYS` for backend-to-backend API key protection.
|
||||||
|
- Set `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` for Spotify client credentials auth, or `SPOTIFY_BEARER_TOKEN` for a short-lived externally managed bearer token.
|
||||||
|
- Set `SPOTIFY_MARKET` to the default two-letter market, for example `US`.
|
||||||
|
- Set `MUSICBRAINZ_APP_NAME` and `MUSICBRAINZ_CONTACT`; MusicBrainz requires an identifying User-Agent.
|
||||||
|
- Set `PROVIDER_CACHE_TTL_HOURS` to control provider payload cache freshness. Expired cache entries may be used as stale fallback when an upstream provider fails.
|
||||||
|
- Keep user authentication in the parent product and pass stable opaque `user_id` values to this service.
|
||||||
|
- Run goose migrations before starting Postgres mode.
|
||||||
|
- Use bulk ingestion for catalog updates and append-only interaction events.
|
||||||
|
- For large catalogs, replace full snapshots with vector indexes or precomputed candidate sets while keeping the same engine contract.
|
||||||
|
- When Spotify API credentials are provided, the backend uses the official Web API. Otherwise, it uses the native Go webplayer client which generates TOTP tokens for anonymous access (no user account required).
|
||||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,140 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/config"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/httpapi"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/musicbrainz"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/songlink"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/spotify"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/webplayer"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
|
||||||
|
memstore "github.com/tdvorak/spotifyrecalg/apps/backend/internal/storage/memory"
|
||||||
|
pgstore "github.com/tdvorak/spotifyrecalg/apps/backend/internal/storage/postgres"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
logger, err := zap.NewProduction()
|
||||||
|
if cfg.Environment == "development" {
|
||||||
|
logger, err = zap.NewDevelopment()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("create logger: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = logger.Sync() }()
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
store, cleanup, err := buildStore(ctx, cfg, logger)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal("initialize storage", zap.Error(err))
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
engine := recommendation.NewEngine(recommendation.EngineConfig{
|
||||||
|
Now: time.Now,
|
||||||
|
ContentWeight: cfg.ContentWeight,
|
||||||
|
CollabWeight: cfg.CollaborativeWeight,
|
||||||
|
PopularityWeight: cfg.PopularityWeight,
|
||||||
|
ExplorationWeight: cfg.ExplorationWeight,
|
||||||
|
DiversityLambda: cfg.DiversityLambda,
|
||||||
|
})
|
||||||
|
|
||||||
|
router := httpapi.NewRouter(httpapi.RouterConfig{
|
||||||
|
Store: store,
|
||||||
|
Engine: engine,
|
||||||
|
Provider: buildProviderService(store, cfg),
|
||||||
|
Logger: logger,
|
||||||
|
APIKeys: cfg.APIKeys,
|
||||||
|
Version: cfg.Version,
|
||||||
|
})
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: cfg.HTTPAddr,
|
||||||
|
Handler: router,
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
ReadTimeout: 15 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
logger.Info("api listening", zap.String("addr", cfg.HTTPAddr), zap.String("store", cfg.StoreDriver))
|
||||||
|
errCh <- server.ListenAndServe()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||||
|
logger.Error("graceful shutdown failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
case err := <-errCh:
|
||||||
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
logger.Fatal("server stopped", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildProviderService(store httpapi.Store, cfg config.Config) *provider.Service {
|
||||||
|
providerStore, ok := store.(provider.Store)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
spotifyClient := spotify.New(spotify.Config{
|
||||||
|
ClientID: cfg.SpotifyClientID,
|
||||||
|
ClientSecret: cfg.SpotifyClientSecret,
|
||||||
|
BearerToken: cfg.SpotifyBearerToken,
|
||||||
|
Market: cfg.SpotifyMarket,
|
||||||
|
})
|
||||||
|
webplayerClient := webplayer.NewClient()
|
||||||
|
songlinkClient := songlink.NewClient()
|
||||||
|
musicBrainzClient := musicbrainz.New(musicbrainz.Config{
|
||||||
|
AppName: cfg.MusicBrainzAppName,
|
||||||
|
Contact: cfg.MusicBrainzContact,
|
||||||
|
Version: cfg.Version,
|
||||||
|
})
|
||||||
|
return provider.NewService(providerStore, spotifyClient, webplayerClient, songlinkClient, musicBrainzClient, provider.ServiceConfig{
|
||||||
|
DefaultMarket: cfg.SpotifyMarket,
|
||||||
|
CacheTTL: cfg.ProviderCacheTTL,
|
||||||
|
Version: cfg.Version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStore(ctx context.Context, cfg config.Config, logger *zap.Logger) (httpapi.Store, func(), error) {
|
||||||
|
if cfg.StoreDriver == "memory" {
|
||||||
|
store := memstore.New()
|
||||||
|
if cfg.SeedDemoData {
|
||||||
|
memstore.SeedLargeCatalog(store)
|
||||||
|
}
|
||||||
|
return store, func() {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("connected to postgres")
|
||||||
|
return pgstore.New(pool), pool.Close, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,651 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: SpotifyRecAlg Recommendation API
|
||||||
|
version: 0.1.0
|
||||||
|
summary: Explainable hybrid music recommendation API.
|
||||||
|
license:
|
||||||
|
name: MIT
|
||||||
|
identifier: MIT
|
||||||
|
servers:
|
||||||
|
- url: https://api.spotifyrec.local
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
paths:
|
||||||
|
/healthz:
|
||||||
|
get:
|
||||||
|
summary: Check service liveness.
|
||||||
|
security: []
|
||||||
|
tags: [System]
|
||||||
|
operationId: health
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Service is alive.
|
||||||
|
"429":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
/readyz:
|
||||||
|
get:
|
||||||
|
summary: Check storage readiness.
|
||||||
|
security: []
|
||||||
|
tags: [System]
|
||||||
|
operationId: ready
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Storage is reachable.
|
||||||
|
"429":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
"503":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
/v1/tracks:
|
||||||
|
post:
|
||||||
|
tags: [Catalog]
|
||||||
|
operationId: upsertTrack
|
||||||
|
summary: Upsert one catalog track.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Track"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Stored track.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Track"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
"422":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
/v1/tracks/batch:
|
||||||
|
put:
|
||||||
|
tags: [Catalog]
|
||||||
|
operationId: upsertTracks
|
||||||
|
summary: Upsert a batch of tracks.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [tracks]
|
||||||
|
properties:
|
||||||
|
tracks:
|
||||||
|
type: array
|
||||||
|
maxItems: 1000
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Track"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Batch stored.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [stored]
|
||||||
|
properties:
|
||||||
|
stored:
|
||||||
|
type: integer
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
"422":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
/v1/interactions:
|
||||||
|
post:
|
||||||
|
tags: [Events]
|
||||||
|
operationId: recordInteraction
|
||||||
|
summary: Append a listening or feedback event.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Interaction"
|
||||||
|
responses:
|
||||||
|
"202":
|
||||||
|
description: Event accepted.
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
"422":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
/v1/recommendations:
|
||||||
|
post:
|
||||||
|
tags: [Recommendations]
|
||||||
|
operationId: recommend
|
||||||
|
summary: Get ranked recommendations for a user.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/RecommendRequest"
|
||||||
|
examples:
|
||||||
|
balanced:
|
||||||
|
value:
|
||||||
|
user_id: demo-user
|
||||||
|
limit: 10
|
||||||
|
mode: balanced
|
||||||
|
discovery:
|
||||||
|
value:
|
||||||
|
user_id: demo-user
|
||||||
|
limit: 10
|
||||||
|
seed_track_ids: [trk-neon-dawn]
|
||||||
|
mode: discovery
|
||||||
|
exploration_target: 0.35
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Ranked recommendations.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [data, taste_profile, pagination]
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Recommendation"
|
||||||
|
taste_profile:
|
||||||
|
$ref: "#/components/schemas/TasteProfile"
|
||||||
|
pagination:
|
||||||
|
$ref: "#/components/schemas/CursorPage"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
"422":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
/v1/users/{user_id}/taste-profile:
|
||||||
|
get:
|
||||||
|
tags: [Users]
|
||||||
|
operationId: getTasteProfile
|
||||||
|
summary: Inspect the computed taste profile.
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/UserID"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Taste profile.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/TasteProfile"
|
||||||
|
"422":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
/v1/users/{user_id}/controls:
|
||||||
|
get:
|
||||||
|
summary: Read user recommendation controls.
|
||||||
|
tags: [Users]
|
||||||
|
operationId: getUserControls
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/UserID"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: User controls.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UserControls"
|
||||||
|
"429":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
put:
|
||||||
|
summary: Replace user recommendation controls.
|
||||||
|
tags: [Users]
|
||||||
|
operationId: upsertUserControls
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/UserID"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UserControls"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Stored controls.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UserControls"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
/v1/providers/spotify/import:
|
||||||
|
post:
|
||||||
|
tags: [Providers]
|
||||||
|
operationId: importSpotify
|
||||||
|
summary: Import Spotify catalog data into the recommendation catalog.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/SpotifyImportRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Import summary.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProviderImportResponse"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
"422":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
"502":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
"503":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
/v1/providers/spotify/search:
|
||||||
|
post:
|
||||||
|
tags: [Providers]
|
||||||
|
operationId: searchSpotify
|
||||||
|
summary: Search Spotify tracks and optionally persist mapped results.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/SpotifySearchRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Search results.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/SpotifySearchResponse"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
"422":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
"502":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
"503":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
/v1/providers/musicbrainz/enrich:
|
||||||
|
post:
|
||||||
|
tags: [Providers]
|
||||||
|
operationId: enrichMusicBrainz
|
||||||
|
summary: Enrich existing tracks with MusicBrainz recording metadata.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/MusicBrainzEnrichRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Enrichment summary.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/MusicBrainzEnrichResponse"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
"422":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
"502":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
"503":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
/v1/providers/status:
|
||||||
|
get:
|
||||||
|
tags: [Providers]
|
||||||
|
operationId: getProviderStatus
|
||||||
|
summary: Report provider configuration, availability, and cache status.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Provider status.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProviderStatusResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
"503":
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
ApiKeyAuth:
|
||||||
|
type: apiKey
|
||||||
|
in: header
|
||||||
|
name: X-API-Key
|
||||||
|
description: Optional backend-to-backend API key. Disabled when API_KEYS is empty.
|
||||||
|
parameters:
|
||||||
|
UserID:
|
||||||
|
name: user_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
responses:
|
||||||
|
Problem:
|
||||||
|
description: RFC 7807 problem response.
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Problem"
|
||||||
|
schemas:
|
||||||
|
AudioFeatures:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- danceability
|
||||||
|
- energy
|
||||||
|
- loudness
|
||||||
|
- speechiness
|
||||||
|
- acousticness
|
||||||
|
- instrumentalness
|
||||||
|
- liveness
|
||||||
|
- valence
|
||||||
|
- tempo
|
||||||
|
- time_signature
|
||||||
|
properties:
|
||||||
|
danceability: { type: number, minimum: 0, maximum: 1 }
|
||||||
|
energy: { type: number, minimum: 0, maximum: 1 }
|
||||||
|
loudness: { type: number, description: Decibels before service-side normalization. }
|
||||||
|
speechiness: { type: number, minimum: 0, maximum: 1 }
|
||||||
|
acousticness: { type: number, minimum: 0, maximum: 1 }
|
||||||
|
instrumentalness: { type: number, minimum: 0, maximum: 1 }
|
||||||
|
liveness: { type: number, minimum: 0, maximum: 1 }
|
||||||
|
valence: { type: number, minimum: 0, maximum: 1 }
|
||||||
|
tempo: { type: number, minimum: 0 }
|
||||||
|
time_signature: { type: number, minimum: 0 }
|
||||||
|
key: { type: number }
|
||||||
|
mode: { type: number }
|
||||||
|
Track:
|
||||||
|
type: object
|
||||||
|
required: [id, title, artist, popularity, explicit, features]
|
||||||
|
properties:
|
||||||
|
id: { type: string }
|
||||||
|
title: { type: string }
|
||||||
|
artist: { type: string }
|
||||||
|
album: { type: string }
|
||||||
|
genres:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
release_date: { type: string }
|
||||||
|
duration_ms: { type: integer, minimum: 0 }
|
||||||
|
popularity: { type: number, minimum: 0, maximum: 1 }
|
||||||
|
explicit: { type: boolean }
|
||||||
|
features:
|
||||||
|
$ref: "#/components/schemas/AudioFeatures"
|
||||||
|
external:
|
||||||
|
type: object
|
||||||
|
additionalProperties: { type: string }
|
||||||
|
commercial_boost: { type: number, minimum: 0, maximum: 1 }
|
||||||
|
quality_penalty: { type: number, minimum: 0, maximum: 1 }
|
||||||
|
discovery_allowed: { type: boolean }
|
||||||
|
Context:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
locale: { type: string }
|
||||||
|
device: { type: string }
|
||||||
|
time_of_day: { type: string }
|
||||||
|
activity: { type: string }
|
||||||
|
mood: { type: string }
|
||||||
|
Interaction:
|
||||||
|
type: object
|
||||||
|
required: [user_id, track_id, type]
|
||||||
|
properties:
|
||||||
|
user_id: { type: string }
|
||||||
|
track_id: { type: string }
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [play, skip, like, dislike, save, hide]
|
||||||
|
weight:
|
||||||
|
type: number
|
||||||
|
description: Optional override. Leave zero for default behavior weighting.
|
||||||
|
occurred_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
completed_ms:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
context:
|
||||||
|
$ref: "#/components/schemas/Context"
|
||||||
|
RecommendRequest:
|
||||||
|
type: object
|
||||||
|
required: [user_id]
|
||||||
|
properties:
|
||||||
|
user_id: { type: string }
|
||||||
|
limit: { type: integer, minimum: 1, maximum: 100, default: 20 }
|
||||||
|
seed_track_ids:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
feature_targets:
|
||||||
|
$ref: "#/components/schemas/AudioFeatures"
|
||||||
|
context:
|
||||||
|
$ref: "#/components/schemas/Context"
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
enum: [balanced, comfort, discovery]
|
||||||
|
default: balanced
|
||||||
|
exploration_target:
|
||||||
|
type: number
|
||||||
|
minimum: 0
|
||||||
|
maximum: 1
|
||||||
|
min_popularity: { type: number, minimum: 0, maximum: 1 }
|
||||||
|
max_popularity: { type: number, minimum: 0, maximum: 1 }
|
||||||
|
include_explicit: { type: boolean }
|
||||||
|
excluded_track_ids:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
excluded_artist_ids:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
excluded_genres:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
Recommendation:
|
||||||
|
type: object
|
||||||
|
required: [track, score, rank, reason, score_breakdown, explanation]
|
||||||
|
properties:
|
||||||
|
track:
|
||||||
|
$ref: "#/components/schemas/Track"
|
||||||
|
score: { type: number }
|
||||||
|
rank: { type: integer }
|
||||||
|
reason: { type: string }
|
||||||
|
score_breakdown:
|
||||||
|
$ref: "#/components/schemas/ScoreBreakdown"
|
||||||
|
explanation:
|
||||||
|
type: object
|
||||||
|
additionalProperties: { type: number }
|
||||||
|
ScoreBreakdown:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
content: { type: number }
|
||||||
|
collaborative: { type: number }
|
||||||
|
popularity: { type: number }
|
||||||
|
exploration: { type: number }
|
||||||
|
diversity: { type: number }
|
||||||
|
safety: { type: number }
|
||||||
|
commercial: { type: number }
|
||||||
|
final: { type: number }
|
||||||
|
TasteProfile:
|
||||||
|
type: object
|
||||||
|
required: [user_id, vector, top_genres, interaction_count, confidence, exploration_readiness, updated_at]
|
||||||
|
properties:
|
||||||
|
user_id: { type: string }
|
||||||
|
vector:
|
||||||
|
type: array
|
||||||
|
items: { type: number }
|
||||||
|
top_genres:
|
||||||
|
type: object
|
||||||
|
additionalProperties: { type: number }
|
||||||
|
interaction_count: { type: integer }
|
||||||
|
confidence: { type: number }
|
||||||
|
exploration_readiness: { type: number }
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
UserControls:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
user_id: { type: string }
|
||||||
|
allow_explicit: { type: boolean, default: true }
|
||||||
|
excluded_tracks:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
excluded_artists:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
excluded_genres:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
postponed_tracks:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
ProviderSource:
|
||||||
|
type: object
|
||||||
|
required: [type, value]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [track, album, playlist, artist, url]
|
||||||
|
value:
|
||||||
|
type: string
|
||||||
|
SpotifyImportRequest:
|
||||||
|
type: object
|
||||||
|
required: [source]
|
||||||
|
properties:
|
||||||
|
source:
|
||||||
|
$ref: "#/components/schemas/ProviderSource"
|
||||||
|
market:
|
||||||
|
type: string
|
||||||
|
minLength: 2
|
||||||
|
maxLength: 2
|
||||||
|
default: US
|
||||||
|
limit:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100
|
||||||
|
default: 100
|
||||||
|
enrich_musicbrainz:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
persist:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
allow_missing_features:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
SpotifySearchRequest:
|
||||||
|
type: object
|
||||||
|
required: [query]
|
||||||
|
properties:
|
||||||
|
query:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [track, album, artist, playlist]
|
||||||
|
default: track
|
||||||
|
market:
|
||||||
|
type: string
|
||||||
|
minLength: 2
|
||||||
|
maxLength: 2
|
||||||
|
default: US
|
||||||
|
limit:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 10
|
||||||
|
default: 5
|
||||||
|
persist:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
enrich_musicbrainz:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
allow_missing_features:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
ProviderImportResponse:
|
||||||
|
type: object
|
||||||
|
required: [import_id, imported_tracks, updated_tracks, skipped, warnings]
|
||||||
|
properties:
|
||||||
|
import_id: { type: string }
|
||||||
|
imported_tracks: { type: integer, minimum: 0 }
|
||||||
|
updated_tracks: { type: integer, minimum: 0 }
|
||||||
|
skipped: { type: integer, minimum: 0 }
|
||||||
|
warnings:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
SpotifySearchResponse:
|
||||||
|
type: object
|
||||||
|
required: [tracks, persisted, skipped, warnings]
|
||||||
|
properties:
|
||||||
|
tracks:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Track"
|
||||||
|
persisted: { type: integer, minimum: 0 }
|
||||||
|
skipped: { type: integer, minimum: 0 }
|
||||||
|
warnings:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
MusicBrainzEnrichRequest:
|
||||||
|
type: object
|
||||||
|
required: [track_ids]
|
||||||
|
properties:
|
||||||
|
track_ids:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
items: { type: string }
|
||||||
|
force:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
MusicBrainzEnrichResponse:
|
||||||
|
type: object
|
||||||
|
required: [updated, skipped, warnings]
|
||||||
|
properties:
|
||||||
|
updated: { type: integer, minimum: 0 }
|
||||||
|
skipped: { type: integer, minimum: 0 }
|
||||||
|
warnings:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
ProviderStatusResponse:
|
||||||
|
type: object
|
||||||
|
required: [spotify, musicbrainz, cache]
|
||||||
|
properties:
|
||||||
|
spotify:
|
||||||
|
$ref: "#/components/schemas/ProviderStatus"
|
||||||
|
musicbrainz:
|
||||||
|
$ref: "#/components/schemas/ProviderStatus"
|
||||||
|
cache:
|
||||||
|
$ref: "#/components/schemas/ProviderCacheStats"
|
||||||
|
ProviderStatus:
|
||||||
|
type: object
|
||||||
|
required: [configured, available, checked_at]
|
||||||
|
properties:
|
||||||
|
configured: { type: boolean }
|
||||||
|
token_mode:
|
||||||
|
type: string
|
||||||
|
enum: [client_credentials, static_bearer, user_agent, unconfigured]
|
||||||
|
available: { type: boolean }
|
||||||
|
last_error: { type: string }
|
||||||
|
checked_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
ProviderCacheStats:
|
||||||
|
type: object
|
||||||
|
required: [entries, fresh_entries, stale_entries]
|
||||||
|
properties:
|
||||||
|
entries: { type: integer, minimum: 0 }
|
||||||
|
fresh_entries: { type: integer, minimum: 0 }
|
||||||
|
stale_entries: { type: integer, minimum: 0 }
|
||||||
|
CursorPage:
|
||||||
|
type: object
|
||||||
|
required: [has_more]
|
||||||
|
properties:
|
||||||
|
next_cursor:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- "null"
|
||||||
|
has_more:
|
||||||
|
type: boolean
|
||||||
|
Problem:
|
||||||
|
type: object
|
||||||
|
required: [type, title, status]
|
||||||
|
properties:
|
||||||
|
type: { type: string, format: uri }
|
||||||
|
title: { type: string }
|
||||||
|
status: { type: integer }
|
||||||
|
detail: { type: string }
|
||||||
|
instance: { type: string }
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
module github.com/tdvorak/spotifyrecalg/apps/backend
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.11.0
|
||||||
|
github.com/jackc/pgx/v5 v5.7.6
|
||||||
|
go.uber.org/zap v1.27.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
|
golang.org/x/crypto v0.40.0 // indirect
|
||||||
|
golang.org/x/mod v0.25.0 // indirect
|
||||||
|
golang.org/x/net v0.42.0 // indirect
|
||||||
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
|
golang.org/x/text v0.27.0 // indirect
|
||||||
|
golang.org/x/tools v0.34.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||||
|
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||||
|
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||||
|
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||||
|
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
|
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
|
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||||
|
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||||
|
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||||
|
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||||
|
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||||
|
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||||
|
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||||
|
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
|
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||||
|
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||||
|
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||||
|
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||||
|
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||||
|
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||||
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
|
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||||
|
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||||
|
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||||
|
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||||
|
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
|
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Environment string
|
||||||
|
Version string
|
||||||
|
HTTPAddr string
|
||||||
|
StoreDriver string
|
||||||
|
DatabaseURL string
|
||||||
|
APIKeys []string
|
||||||
|
SeedDemoData bool
|
||||||
|
ContentWeight float64
|
||||||
|
CollaborativeWeight float64
|
||||||
|
PopularityWeight float64
|
||||||
|
ExplorationWeight float64
|
||||||
|
DiversityLambda float64
|
||||||
|
SpotifyClientID string
|
||||||
|
SpotifyClientSecret string
|
||||||
|
SpotifyBearerToken string
|
||||||
|
SpotifyMarket string
|
||||||
|
UnlockerURL string
|
||||||
|
MusicBrainzAppName string
|
||||||
|
MusicBrainzContact string
|
||||||
|
ProviderCacheTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() Config {
|
||||||
|
return Config{
|
||||||
|
Environment: env("APP_ENV", "development"),
|
||||||
|
Version: env("APP_VERSION", "0.1.0"),
|
||||||
|
HTTPAddr: env("HTTP_ADDR", ":8080"),
|
||||||
|
StoreDriver: env("STORE_DRIVER", "postgres"),
|
||||||
|
DatabaseURL: env("DATABASE_URL", "postgres://spotify:spotify@localhost:5432/spotifyrec?sslmode=disable"),
|
||||||
|
APIKeys: csv(env("API_KEYS", "")),
|
||||||
|
SeedDemoData: boolEnv("SEED_DEMO_DATA", false),
|
||||||
|
ContentWeight: floatEnv("REC_CONTENT_WEIGHT", 0.44),
|
||||||
|
CollaborativeWeight: floatEnv("REC_COLLAB_WEIGHT", 0.28),
|
||||||
|
PopularityWeight: floatEnv("REC_POPULARITY_WEIGHT", 0.08),
|
||||||
|
ExplorationWeight: floatEnv("REC_EXPLORATION_WEIGHT", 0.20),
|
||||||
|
DiversityLambda: floatEnv("REC_DIVERSITY_LAMBDA", 0.74),
|
||||||
|
SpotifyClientID: env("SPOTIFY_CLIENT_ID", ""),
|
||||||
|
SpotifyClientSecret: env("SPOTIFY_CLIENT_SECRET", ""),
|
||||||
|
SpotifyBearerToken: env("SPOTIFY_BEARER_TOKEN", ""),
|
||||||
|
SpotifyMarket: env("SPOTIFY_MARKET", "US"),
|
||||||
|
MusicBrainzAppName: env("MUSICBRAINZ_APP_NAME", "SpotifyRecAlg"),
|
||||||
|
MusicBrainzContact: env("MUSICBRAINZ_CONTACT", ""),
|
||||||
|
ProviderCacheTTL: time.Duration(intEnv("PROVIDER_CACHE_TTL_HOURS", 24)) * time.Hour,
|
||||||
|
UnlockerURL: env("UNLOCKER_URL", "http://localhost:5000"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func env(key, fallback string) string {
|
||||||
|
value := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func csv(value string) []string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(value, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "" {
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolEnv(key string, fallback bool) bool {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseBool(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func floatEnv(key string, fallback float64) float64 {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseFloat(raw, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func intEnv(key string, fallback int) int {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
value, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const requestIDKey = "request_id"
|
||||||
|
|
||||||
|
func requestID() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.GetHeader("X-Request-ID")
|
||||||
|
if id == "" {
|
||||||
|
id = newRequestID()
|
||||||
|
}
|
||||||
|
c.Set(requestIDKey, id)
|
||||||
|
c.Header("X-Request-ID", id)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func accessLog(logger *zap.Logger) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
start := time.Now()
|
||||||
|
c.Next()
|
||||||
|
logger.Info("http request",
|
||||||
|
zap.String("method", c.Request.Method),
|
||||||
|
zap.String("path", c.FullPath()),
|
||||||
|
zap.Int("status", c.Writer.Status()),
|
||||||
|
zap.Duration("duration", time.Since(start)),
|
||||||
|
zap.String("request_id", requestIDFromContext(c)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recovery(logger *zap.Logger) gin.HandlerFunc {
|
||||||
|
return gin.CustomRecovery(func(c *gin.Context, recovered any) {
|
||||||
|
logger.Error("panic recovered", zap.Any("panic", recovered), zap.String("request_id", requestIDFromContext(c)))
|
||||||
|
problem(c, http.StatusInternalServerError, "https://spotify-rec.local/errors/internal", "Internal server error", "The server encountered an unexpected error.")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiKeyAuth(keys []string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if len(keys) == 0 || c.Request.URL.Path == "/healthz" || c.Request.URL.Path == "/readyz" {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := c.GetHeader("X-API-Key")
|
||||||
|
if !slices.Contains(keys, key) {
|
||||||
|
problem(c, http.StatusUnauthorized, "https://spotify-rec.local/errors/unauthorized", "Unauthorized", "A valid X-API-Key header is required.")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRequestID() string {
|
||||||
|
var b [16]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return time.Now().UTC().Format("20060102150405.000000000")
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestIDFromContext(c *gin.Context) string {
|
||||||
|
value, ok := c.Get(requestIDKey)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
id, _ := value.(string)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func cors() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
origin := c.GetHeader("Origin")
|
||||||
|
if origin == "" {
|
||||||
|
origin = "*"
|
||||||
|
}
|
||||||
|
c.Header("Access-Control-Allow-Origin", origin)
|
||||||
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
c.Header("Access-Control-Allow-Headers", "Content-Type, X-API-Key, X-Request-ID")
|
||||||
|
c.Header("Access-Control-Allow-Credentials", "true")
|
||||||
|
c.Header("Access-Control-Max-Age", "86400")
|
||||||
|
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Problem struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
Instance string `json:"instance,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func problem(c *gin.Context, status int, problemType, title, detail string) {
|
||||||
|
if c.Writer.Written() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "application/problem+json")
|
||||||
|
c.JSON(status, Problem{
|
||||||
|
Type: problemType,
|
||||||
|
Title: title,
|
||||||
|
Status: status,
|
||||||
|
Detail: detail,
|
||||||
|
Instance: c.Request.URL.Path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func notFound(c *gin.Context) {
|
||||||
|
problem(c, http.StatusNotFound, "https://spotify-rec.local/errors/not-found", "Not found", "The requested resource does not exist.")
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/musicbrainz"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/songlink"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/spotify"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/webplayer"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/storage/memory"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSpotifyImportEndpoint(t *testing.T) {
|
||||||
|
store := memory.New()
|
||||||
|
spotifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/v1/tracks/imported":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"id": "imported",
|
||||||
|
"name": "Imported",
|
||||||
|
"artists": []map[string]string{{"name": "Artist"}},
|
||||||
|
"album": map[string]any{"name": "Album", "release_date": "2025-01-01"},
|
||||||
|
"duration_ms": 180000,
|
||||||
|
"popularity": 60,
|
||||||
|
"external_ids": map[string]string{"isrc": "USRC17607839"},
|
||||||
|
})
|
||||||
|
case "/v1/audio-features/imported":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"danceability": 0.6, "energy": 0.7, "loudness": -6, "speechiness": 0.05,
|
||||||
|
"acousticness": 0.2, "instrumentalness": 0, "liveness": 0.1, "valence": 0.5,
|
||||||
|
"tempo": 110, "time_signature": 4, "key": 5, "mode": 1,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer spotifyServer.Close()
|
||||||
|
|
||||||
|
service := provider.NewService(store,
|
||||||
|
spotify.New(spotify.Config{BearerToken: "secret-token", APIBaseURL: spotifyServer.URL + "/v1"}),
|
||||||
|
webplayer.NewClient(),
|
||||||
|
songlink.NewClient(),
|
||||||
|
musicbrainz.New(musicbrainz.Config{AppName: "SpotifyRecAlg", Contact: "[email protected]", BaseURL: "http://127.0.0.1:1", MinDelay: time.Nanosecond}),
|
||||||
|
provider.ServiceConfig{DefaultMarket: "US", CacheTTL: time.Hour},
|
||||||
|
)
|
||||||
|
|
||||||
|
router := NewRouter(RouterConfig{
|
||||||
|
Store: store,
|
||||||
|
Engine: recommendation.NewEngine(recommendation.EngineConfig{}),
|
||||||
|
Provider: service,
|
||||||
|
Logger: zap.NewNop(),
|
||||||
|
})
|
||||||
|
|
||||||
|
body := bytes.NewBufferString(`{"source":{"type":"url","value":"https://open.spotify.com/track/imported"},"market":"US","persist":true}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/providers/spotify/import", body)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp provider.ImportResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if resp.ImportedTracks != 1 {
|
||||||
|
t.Fatalf("imported tracks = %d, want 1", resp.ImportedTracks)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/v1/providers/status", nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status endpoint = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if bytes.Contains(rec.Body.Bytes(), []byte("secret-token")) {
|
||||||
|
t.Fatal("status response leaked bearer token")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store interface {
|
||||||
|
recommendation.SnapshotProvider
|
||||||
|
Ping(ctx context.Context) error
|
||||||
|
UpsertTrack(ctx context.Context, track recommendation.Track) error
|
||||||
|
UpsertTracks(ctx context.Context, tracks []recommendation.Track) error
|
||||||
|
RecordInteraction(ctx context.Context, interaction recommendation.Interaction) error
|
||||||
|
GetControls(ctx context.Context, userID string) (recommendation.UserControls, error)
|
||||||
|
UpsertControls(ctx context.Context, controls recommendation.UserControls) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type RouterConfig struct {
|
||||||
|
Store Store
|
||||||
|
Engine *recommendation.Engine
|
||||||
|
Provider *provider.Service
|
||||||
|
Logger *zap.Logger
|
||||||
|
APIKeys []string
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRouter(cfg RouterConfig) http.Handler {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(recovery(cfg.Logger), cors(), requestID(), accessLog(cfg.Logger), apiKeyAuth(cfg.APIKeys))
|
||||||
|
|
||||||
|
handler := handler{
|
||||||
|
store: cfg.Store,
|
||||||
|
engine: cfg.Engine,
|
||||||
|
provider: cfg.Provider,
|
||||||
|
logger: cfg.Logger,
|
||||||
|
version: cfg.Version,
|
||||||
|
}
|
||||||
|
|
||||||
|
router.GET("/healthz", handler.health)
|
||||||
|
router.GET("/readyz", handler.ready)
|
||||||
|
|
||||||
|
v1 := router.Group("/v1")
|
||||||
|
v1.GET("/openapi.yaml", handler.openapi)
|
||||||
|
v1.POST("/tracks", handler.upsertTrack)
|
||||||
|
v1.PUT("/tracks/batch", handler.upsertTracks)
|
||||||
|
v1.POST("/interactions", handler.recordInteraction)
|
||||||
|
v1.POST("/recommendations", handler.recommend)
|
||||||
|
v1.GET("/users/:user_id/taste-profile", handler.tasteProfile)
|
||||||
|
v1.GET("/users/:user_id/controls", handler.getControls)
|
||||||
|
v1.PUT("/users/:user_id/controls", handler.upsertControls)
|
||||||
|
v1.POST("/providers/spotify/import", handler.importSpotify)
|
||||||
|
v1.POST("/providers/spotify/search", handler.searchSpotify)
|
||||||
|
v1.POST("/providers/musicbrainz/enrich", handler.enrichMusicBrainz)
|
||||||
|
v1.GET("/providers/status", handler.providerStatus)
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
type handler struct {
|
||||||
|
store Store
|
||||||
|
engine *recommendation.Engine
|
||||||
|
provider *provider.Service
|
||||||
|
logger *zap.Logger
|
||||||
|
version string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h handler) health(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "version": h.version})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h handler) ready(c *gin.Context) {
|
||||||
|
if err := h.store.Ping(c.Request.Context()); err != nil {
|
||||||
|
problem(c, http.StatusServiceUnavailable, "https://spotify-rec.local/errors/storage-unavailable", "Storage unavailable", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ready"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h handler) openapi(c *gin.Context) {
|
||||||
|
c.File("docs/openapi.yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h handler) upsertTrack(c *gin.Context) {
|
||||||
|
var req recommendation.Track
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
problem(c, http.StatusBadRequest, "https://spotify-rec.local/errors/invalid-json", "Invalid JSON", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := recommendation.ValidateTrack(req); err != nil {
|
||||||
|
problem(c, http.StatusUnprocessableEntity, "https://spotify-rec.local/errors/validation", "Validation failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.UpsertTrack(c.Request.Context(), req); err != nil {
|
||||||
|
h.logger.Error("upsert track", zap.Error(err))
|
||||||
|
problem(c, http.StatusInternalServerError, "https://spotify-rec.local/errors/storage-write", "Storage write failed", "Track could not be stored.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h handler) upsertTracks(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Tracks []recommendation.Track `json:"tracks" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
problem(c, http.StatusBadRequest, "https://spotify-rec.local/errors/invalid-json", "Invalid JSON", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Tracks) == 0 {
|
||||||
|
problem(c, http.StatusUnprocessableEntity, "https://spotify-rec.local/errors/validation", "Validation failed", "tracks must contain at least one item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Tracks) > 1000 {
|
||||||
|
problem(c, http.StatusUnprocessableEntity, "https://spotify-rec.local/errors/validation", "Validation failed", "batch limit is 1000 tracks")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i, track := range req.Tracks {
|
||||||
|
if err := recommendation.ValidateTrack(track); err != nil {
|
||||||
|
problem(c, http.StatusUnprocessableEntity, "https://spotify-rec.local/errors/validation", "Validation failed", "tracks["+strconv.Itoa(i)+"]: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := h.store.UpsertTracks(c.Request.Context(), req.Tracks); err != nil {
|
||||||
|
h.logger.Error("upsert tracks", zap.Error(err))
|
||||||
|
problem(c, http.StatusInternalServerError, "https://spotify-rec.local/errors/storage-write", "Storage write failed", "Tracks could not be stored.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"stored": len(req.Tracks)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h handler) recordInteraction(c *gin.Context) {
|
||||||
|
var req recommendation.Interaction
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
problem(c, http.StatusBadRequest, "https://spotify-rec.local/errors/invalid-json", "Invalid JSON", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.UserID) == "" || strings.TrimSpace(req.TrackID) == "" || strings.TrimSpace(string(req.Type)) == "" {
|
||||||
|
problem(c, http.StatusUnprocessableEntity, "https://spotify-rec.local/errors/validation", "Validation failed", "user_id, track_id, and type are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.RecordInteraction(c.Request.Context(), req); err != nil {
|
||||||
|
h.logger.Error("record interaction", zap.Error(err))
|
||||||
|
problem(c, http.StatusInternalServerError, "https://spotify-rec.local/errors/storage-write", "Storage write failed", "Interaction could not be stored.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"accepted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h handler) recommend(c *gin.Context) {
|
||||||
|
var req recommendation.RecommendRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
problem(c, http.StatusBadRequest, "https://spotify-rec.local/errors/invalid-json", "Invalid JSON", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recs, profile, err := h.engine.Recommend(c.Request.Context(), h.store, req)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, context.Canceled):
|
||||||
|
return
|
||||||
|
case strings.Contains(err.Error(), "required"), strings.Contains(err.Error(), "empty"):
|
||||||
|
problem(c, http.StatusUnprocessableEntity, "https://spotify-rec.local/errors/validation", "Validation failed", err.Error())
|
||||||
|
default:
|
||||||
|
h.logger.Error("recommend", zap.Error(err))
|
||||||
|
problem(c, http.StatusInternalServerError, "https://spotify-rec.local/errors/recommendation-failed", "Recommendation failed", "The recommendation engine could not complete the request.")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": recs,
|
||||||
|
"taste_profile": profile,
|
||||||
|
"pagination": gin.H{"next_cursor": nil, "has_more": false},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h handler) tasteProfile(c *gin.Context) {
|
||||||
|
userID := c.Param("user_id")
|
||||||
|
profile, err := h.engine.TasteProfile(c.Request.Context(), h.store, userID)
|
||||||
|
if err != nil {
|
||||||
|
problem(c, http.StatusUnprocessableEntity, "https://spotify-rec.local/errors/profile-unavailable", "Taste profile unavailable", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h handler) getControls(c *gin.Context) {
|
||||||
|
controls, err := h.store.GetControls(c.Request.Context(), c.Param("user_id"))
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("get controls", zap.Error(err))
|
||||||
|
problem(c, http.StatusInternalServerError, "https://spotify-rec.local/errors/storage-read", "Storage read failed", "Controls could not be loaded.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, controls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h handler) upsertControls(c *gin.Context) {
|
||||||
|
var req recommendation.UserControls
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
problem(c, http.StatusBadRequest, "https://spotify-rec.local/errors/invalid-json", "Invalid JSON", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.UserID = c.Param("user_id")
|
||||||
|
if strings.TrimSpace(req.UserID) == "" {
|
||||||
|
problem(c, http.StatusUnprocessableEntity, "https://spotify-rec.local/errors/validation", "Validation failed", "user_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.UpsertControls(c.Request.Context(), req); err != nil {
|
||||||
|
h.logger.Error("upsert controls", zap.Error(err))
|
||||||
|
problem(c, http.StatusInternalServerError, "https://spotify-rec.local/errors/storage-write", "Storage write failed", "Controls could not be stored.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h handler) importSpotify(c *gin.Context) {
|
||||||
|
service, ok := h.providerService(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req provider.ImportRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
problem(c, http.StatusBadRequest, "https://spotify-rec.local/errors/invalid-json", "Invalid JSON", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := service.ImportSpotify(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
h.providerProblem(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h handler) searchSpotify(c *gin.Context) {
|
||||||
|
service, ok := h.providerService(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req provider.SearchRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
problem(c, http.StatusBadRequest, "https://spotify-rec.local/errors/invalid-json", "Invalid JSON", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := service.SearchSpotify(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
h.providerProblem(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h handler) enrichMusicBrainz(c *gin.Context) {
|
||||||
|
service, ok := h.providerService(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req provider.EnrichRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
problem(c, http.StatusBadRequest, "https://spotify-rec.local/errors/invalid-json", "Invalid JSON", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := service.EnrichMusicBrainz(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
h.providerProblem(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h handler) providerStatus(c *gin.Context) {
|
||||||
|
service, ok := h.providerService(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, service.Status(c.Request.Context()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h handler) providerService(c *gin.Context) (*provider.Service, bool) {
|
||||||
|
if h.provider == nil {
|
||||||
|
problem(c, http.StatusServiceUnavailable, "https://spotify-rec.local/errors/provider-unavailable", "Provider unavailable", "Provider imports are not configured for this storage backend.")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return h.provider, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h handler) providerProblem(c *gin.Context, err error) {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message := err.Error()
|
||||||
|
switch {
|
||||||
|
case strings.Contains(message, "not configured"), strings.Contains(message, "credentials"):
|
||||||
|
problem(c, http.StatusServiceUnavailable, "https://spotify-rec.local/errors/provider-not-configured", "Provider not configured", message)
|
||||||
|
case strings.Contains(message, "required"), strings.Contains(message, "unsupported"), strings.Contains(message, "must be"):
|
||||||
|
problem(c, http.StatusUnprocessableEntity, "https://spotify-rec.local/errors/provider-validation", "Validation failed", message)
|
||||||
|
default:
|
||||||
|
h.logger.Error("provider request", zap.Error(err))
|
||||||
|
problem(c, http.StatusBadGateway, "https://spotify-rec.local/errors/provider-request-failed", "Provider request failed", "The upstream provider request could not be completed.")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
|
||||||
|
memstore "github.com/tdvorak/spotifyrecalg/apps/backend/internal/storage/memory"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecommendationEndpoint(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
store := memstore.New()
|
||||||
|
memstore.SeedDemo(store)
|
||||||
|
engine := recommendation.NewEngine(recommendation.EngineConfig{
|
||||||
|
Now: func() time.Time { return time.Date(2026, 4, 13, 12, 0, 0, 0, time.UTC) },
|
||||||
|
ContentWeight: 0.44,
|
||||||
|
CollabWeight: 0.28,
|
||||||
|
PopularityWeight: 0.08,
|
||||||
|
ExplorationWeight: 0.20,
|
||||||
|
DiversityLambda: 0.74,
|
||||||
|
})
|
||||||
|
router := NewRouter(RouterConfig{
|
||||||
|
Store: store,
|
||||||
|
Engine: engine,
|
||||||
|
Logger: zap.NewNop(),
|
||||||
|
Version: "test",
|
||||||
|
})
|
||||||
|
|
||||||
|
body := bytes.NewBufferString(`{"user_id":"demo-user","limit":3,"mode":"balanced"}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/recommendations", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rec.Body.Bytes(), []byte(`"taste_profile"`)) {
|
||||||
|
t.Fatalf("expected taste profile in response: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIKeyMiddleware(t *testing.T) {
|
||||||
|
router := NewRouter(RouterConfig{
|
||||||
|
Store: memstore.New(),
|
||||||
|
Engine: recommendation.NewEngine(recommendation.EngineConfig{}),
|
||||||
|
Logger: zap.NewNop(),
|
||||||
|
APIKeys: []string{"secret"},
|
||||||
|
Version: "test",
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/recommendations", bytes.NewBufferString(`{}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/musicbrainz"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/spotify"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mapSpotifyTrack(track spotify.Track, features spotify.AudioFeatures, mb musicbrainz.Recording, missingFeatures bool) recommendation.Track {
|
||||||
|
artist := ""
|
||||||
|
if len(track.Artists) > 0 {
|
||||||
|
artist = track.Artists[0].Name
|
||||||
|
}
|
||||||
|
|
||||||
|
spotifyURL := "https://open.spotify.com/track/" + track.ID
|
||||||
|
external := map[string]string{
|
||||||
|
"source": ProviderSpotify,
|
||||||
|
"spotify_id": track.ID,
|
||||||
|
"spotify": spotifyURL,
|
||||||
|
"spotify_url": spotifyURL,
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(track.ExternalURLs["spotify"]); value != "" {
|
||||||
|
external["spotify"] = value
|
||||||
|
external["spotify_url"] = value
|
||||||
|
}
|
||||||
|
if isrc := strings.ToUpper(strings.TrimSpace(track.ExternalIDs["isrc"])); isrc != "" {
|
||||||
|
external["isrc"] = isrc
|
||||||
|
}
|
||||||
|
if len(track.Album.Images) > 0 && track.Album.Images[0].URL != "" {
|
||||||
|
external["image_url"] = track.Album.Images[0].URL
|
||||||
|
external["spotify_image_url"] = track.Album.Images[0].URL
|
||||||
|
}
|
||||||
|
if missingFeatures {
|
||||||
|
external["features_missing"] = "true"
|
||||||
|
}
|
||||||
|
if mb.ID != "" {
|
||||||
|
external["musicbrainz_recording_id"] = mb.ID
|
||||||
|
}
|
||||||
|
if mb.ArtistID != "" {
|
||||||
|
external["musicbrainz_artist_id"] = mb.ArtistID
|
||||||
|
}
|
||||||
|
if mb.ISRC != "" && external["isrc"] == "" {
|
||||||
|
external["isrc"] = mb.ISRC
|
||||||
|
}
|
||||||
|
|
||||||
|
genres := mergeStrings(nil, mb.Genres...)
|
||||||
|
genres = mergeStrings(genres, mb.Tags...)
|
||||||
|
|
||||||
|
return recommendation.Track{
|
||||||
|
ID: "spotify:track:" + track.ID,
|
||||||
|
Title: track.Name,
|
||||||
|
Artist: artist,
|
||||||
|
Album: track.Album.Name,
|
||||||
|
Genres: genres,
|
||||||
|
ReleaseDate: track.Album.ReleaseDate,
|
||||||
|
DurationMS: track.DurationMS,
|
||||||
|
Popularity: clamp01(float64(track.Popularity) / 100),
|
||||||
|
Explicit: track.Explicit,
|
||||||
|
Features: recommendation.AudioFeatures{
|
||||||
|
Danceability: features.Danceability,
|
||||||
|
Energy: features.Energy,
|
||||||
|
Loudness: features.Loudness,
|
||||||
|
Speechiness: features.Speechiness,
|
||||||
|
Acousticness: features.Acousticness,
|
||||||
|
Instrumentalness: features.Instrumentalness,
|
||||||
|
Liveness: features.Liveness,
|
||||||
|
Valence: features.Valence,
|
||||||
|
Tempo: features.Tempo,
|
||||||
|
TimeSignature: features.TimeSignature,
|
||||||
|
Key: features.Key,
|
||||||
|
Mode: features.Mode,
|
||||||
|
},
|
||||||
|
External: external,
|
||||||
|
DiscoveryAllowed: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeStrings(values []string, next ...string) []string {
|
||||||
|
seen := make(map[string]struct{}, len(values)+len(next))
|
||||||
|
out := make([]string, 0, len(values)+len(next))
|
||||||
|
for _, value := range append(values, next...) {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[value]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp01(value float64) float64 {
|
||||||
|
if value < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if value > 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package musicbrainz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultBaseURL = "https://musicbrainz.org/ws/2"
|
||||||
|
defaultTimeout = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
AppName string
|
||||||
|
Contact string
|
||||||
|
Version string
|
||||||
|
BaseURL string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
Timeout time.Duration
|
||||||
|
MinDelay time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
appName string
|
||||||
|
contact string
|
||||||
|
version string
|
||||||
|
baseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
minDelay time.Duration
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
lastCall time.Time
|
||||||
|
lastError string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Recording struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
ArtistID string
|
||||||
|
ISRC string
|
||||||
|
Genres []string
|
||||||
|
Tags []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Client {
|
||||||
|
timeout := cfg.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaultTimeout
|
||||||
|
}
|
||||||
|
httpClient := cfg.HTTPClient
|
||||||
|
if httpClient == nil {
|
||||||
|
httpClient = &http.Client{Timeout: timeout}
|
||||||
|
}
|
||||||
|
baseURL := strings.TrimRight(cfg.BaseURL, "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = defaultBaseURL
|
||||||
|
}
|
||||||
|
minDelay := cfg.MinDelay
|
||||||
|
if minDelay <= 0 {
|
||||||
|
minDelay = time.Second
|
||||||
|
}
|
||||||
|
version := strings.TrimSpace(cfg.Version)
|
||||||
|
if version == "" {
|
||||||
|
version = "0.1.0"
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
appName: strings.TrimSpace(cfg.AppName),
|
||||||
|
contact: strings.TrimSpace(cfg.Contact),
|
||||||
|
version: version,
|
||||||
|
baseURL: baseURL,
|
||||||
|
httpClient: httpClient,
|
||||||
|
minDelay: minDelay,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Configured() bool {
|
||||||
|
return c.appName != "" && c.contact != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) LastError() string {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) LookupByISRC(ctx context.Context, isrc string) (Recording, []byte, error) {
|
||||||
|
isrc = strings.ToUpper(strings.TrimSpace(isrc))
|
||||||
|
if isrc == "" {
|
||||||
|
return Recording{}, nil, errors.New("isrc is required")
|
||||||
|
}
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("fmt", "json")
|
||||||
|
params.Set("inc", "artist-credits+isrcs+tags")
|
||||||
|
payload, err := c.get(ctx, "/isrc/"+url.PathEscape(isrc), params)
|
||||||
|
if err != nil {
|
||||||
|
return Recording{}, payload, err
|
||||||
|
}
|
||||||
|
recording, err := parseISRCRecording(payload, isrc)
|
||||||
|
return recording, payload, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SearchRecording(ctx context.Context, title, artist string) (Recording, []byte, error) {
|
||||||
|
title = strings.TrimSpace(title)
|
||||||
|
artist = strings.TrimSpace(artist)
|
||||||
|
if title == "" {
|
||||||
|
return Recording{}, nil, errors.New("title is required")
|
||||||
|
}
|
||||||
|
query := `recording:"` + escapeQuery(title) + `"`
|
||||||
|
if artist != "" {
|
||||||
|
query += ` AND artist:"` + escapeQuery(artist) + `"`
|
||||||
|
}
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("fmt", "json")
|
||||||
|
params.Set("query", query)
|
||||||
|
params.Set("limit", "1")
|
||||||
|
payload, err := c.get(ctx, "/recording", params)
|
||||||
|
if err != nil {
|
||||||
|
return Recording{}, payload, err
|
||||||
|
}
|
||||||
|
recording, err := parseSearchRecording(payload)
|
||||||
|
return recording, payload, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) get(ctx context.Context, path string, params url.Values) ([]byte, error) {
|
||||||
|
if !c.Configured() {
|
||||||
|
err := errors.New("musicbrainz app name and contact are required")
|
||||||
|
c.setLastError(err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.wait(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
endpoint := c.baseURL + path
|
||||||
|
if encoded := params.Encode(); encoded != "" {
|
||||||
|
endpoint += "?" + encoded
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", c.userAgent())
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.setLastError(err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
payload, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
if err != nil {
|
||||||
|
c.setLastError(err.Error())
|
||||||
|
return payload, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||||
|
err := fmt.Errorf("musicbrainz request failed with status %d", resp.StatusCode)
|
||||||
|
c.setLastError(err.Error())
|
||||||
|
return payload, err
|
||||||
|
}
|
||||||
|
c.setLastError("")
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) wait(ctx context.Context) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
wait := c.minDelay - time.Since(c.lastCall)
|
||||||
|
if wait > 0 {
|
||||||
|
timer := time.NewTimer(wait)
|
||||||
|
c.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
c.mu.Lock()
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
}
|
||||||
|
c.lastCall = time.Now()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) userAgent() string {
|
||||||
|
return fmt.Sprintf("%s/%s (%s)", c.appName, c.version, c.contact)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setLastError(message string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.lastError = message
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseISRCRecording(payload []byte, isrc string) (Recording, error) {
|
||||||
|
var decoded struct {
|
||||||
|
Recordings []recordingJSON `json:"recordings"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||||
|
return Recording{}, fmt.Errorf("decode musicbrainz isrc: %w", err)
|
||||||
|
}
|
||||||
|
if len(decoded.Recordings) == 0 {
|
||||||
|
return Recording{}, errors.New("musicbrainz isrc lookup returned no recordings")
|
||||||
|
}
|
||||||
|
return decoded.Recordings[0].toRecording(isrc), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSearchRecording(payload []byte) (Recording, error) {
|
||||||
|
var decoded struct {
|
||||||
|
Recordings []recordingJSON `json:"recordings"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||||
|
return Recording{}, fmt.Errorf("decode musicbrainz recording search: %w", err)
|
||||||
|
}
|
||||||
|
if len(decoded.Recordings) == 0 {
|
||||||
|
return Recording{}, errors.New("musicbrainz recording search returned no matches")
|
||||||
|
}
|
||||||
|
return decoded.Recordings[0].toRecording(""), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ArtistCredit []struct {
|
||||||
|
Artist struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"artist"`
|
||||||
|
} `json:"artist-credit"`
|
||||||
|
ISRCs []string `json:"isrcs"`
|
||||||
|
Tags []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"tags"`
|
||||||
|
Genres []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"genres"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r recordingJSON) toRecording(fallbackISRC string) Recording {
|
||||||
|
out := Recording{ID: r.ID, Title: r.Title, ISRC: fallbackISRC}
|
||||||
|
if len(r.ArtistCredit) > 0 {
|
||||||
|
out.Artist = r.ArtistCredit[0].Artist.Name
|
||||||
|
out.ArtistID = r.ArtistCredit[0].Artist.ID
|
||||||
|
}
|
||||||
|
if out.ISRC == "" && len(r.ISRCs) > 0 {
|
||||||
|
out.ISRC = strings.ToUpper(r.ISRCs[0])
|
||||||
|
}
|
||||||
|
for _, genre := range r.Genres {
|
||||||
|
if genre.Name != "" {
|
||||||
|
out.Genres = append(out.Genres, genre.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tag := range r.Tags {
|
||||||
|
if tag.Name != "" {
|
||||||
|
out.Tags = append(out.Tags, tag.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeQuery(value string) string {
|
||||||
|
return strings.ReplaceAll(value, `"`, `\"`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,912 @@
|
|||||||
|
package provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/musicbrainz"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/songlink"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/spotify"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/urlparser"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/webplayer"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServiceConfig struct {
|
||||||
|
DefaultMarket string
|
||||||
|
CacheTTL time.Duration
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
store Store
|
||||||
|
spotify *spotify.Client
|
||||||
|
webplayer *webplayer.Client
|
||||||
|
songlink *songlink.Client
|
||||||
|
urlparser *urlparser.Parser
|
||||||
|
musicbrainz *musicbrainz.Client
|
||||||
|
defaultMarket string
|
||||||
|
cacheTTL time.Duration
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(store Store, spotifyClient *spotify.Client, webplayerClient *webplayer.Client, songlinkClient *songlink.Client, musicBrainzClient *musicbrainz.Client, cfg ServiceConfig) *Service {
|
||||||
|
cacheTTL := cfg.CacheTTL
|
||||||
|
if cacheTTL <= 0 {
|
||||||
|
cacheTTL = 24 * time.Hour
|
||||||
|
}
|
||||||
|
return &Service{
|
||||||
|
store: store,
|
||||||
|
spotify: spotifyClient,
|
||||||
|
webplayer: webplayerClient,
|
||||||
|
songlink: songlinkClient,
|
||||||
|
urlparser: urlparser.NewParser(),
|
||||||
|
musicbrainz: musicBrainzClient,
|
||||||
|
defaultMarket: strings.ToUpper(strings.TrimSpace(cfg.DefaultMarket)),
|
||||||
|
cacheTTL: cacheTTL,
|
||||||
|
now: func() time.Time { return time.Now().UTC() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ImportSpotify(ctx context.Context, req ImportRequest) (ImportResponse, error) {
|
||||||
|
// Try official Spotify API first (more reliable, has audio features)
|
||||||
|
if s.spotify != nil && s.spotify.Configured() {
|
||||||
|
return s.importFromOfficialAPI(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to native webplayer client (auth-free, no API keys needed)
|
||||||
|
if s.webplayer != nil && s.webplayer.Configured() {
|
||||||
|
return s.importFromWebPlayer(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImportResponse{}, spotify.ErrNotConfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) importFromOfficialAPI(ctx context.Context, req ImportRequest) (ImportResponse, error) {
|
||||||
|
persist := true
|
||||||
|
if req.Persist != nil {
|
||||||
|
persist = *req.Persist
|
||||||
|
}
|
||||||
|
limit := capLimit(req.Limit, 100)
|
||||||
|
market := s.market(req.Market)
|
||||||
|
parsed, sourceWarnings, err := s.resolveSpotifySource(ctx, req.Source)
|
||||||
|
if err != nil {
|
||||||
|
return ImportResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
job := ImportJob{
|
||||||
|
ID: newID("import"),
|
||||||
|
Provider: ProviderSpotify,
|
||||||
|
SourceType: parsed.Type,
|
||||||
|
SourceValue: parsed.ID,
|
||||||
|
Market: market,
|
||||||
|
Status: "running",
|
||||||
|
StartedAt: s.now(),
|
||||||
|
}
|
||||||
|
if persist {
|
||||||
|
if err := s.store.CreateImportJob(ctx, job); err != nil {
|
||||||
|
return ImportResponse{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks, skipped, warnings, err := s.importSpotifyTracks(ctx, parsed, market, limit, boolDefault(req.EnrichMusicBrainz, true), req.AllowMissingFields)
|
||||||
|
warnings = append(sourceWarnings, warnings...)
|
||||||
|
if err != nil {
|
||||||
|
job.Status = "failed"
|
||||||
|
job.Warnings = append(warnings, err.Error())
|
||||||
|
job.FinishedAt = s.now()
|
||||||
|
if persist {
|
||||||
|
_ = s.store.FinishImportJob(ctx, job)
|
||||||
|
}
|
||||||
|
return ImportResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
imported, updated := 0, 0
|
||||||
|
if persist && len(tracks) > 0 {
|
||||||
|
existingIDs := make([]string, 0, len(tracks))
|
||||||
|
for _, track := range tracks {
|
||||||
|
existingIDs = append(existingIDs, track.ID)
|
||||||
|
}
|
||||||
|
existing, err := s.store.GetTracksByIDs(ctx, existingIDs)
|
||||||
|
if err != nil {
|
||||||
|
return ImportResponse{}, err
|
||||||
|
}
|
||||||
|
existingSet := make(map[string]struct{}, len(existing))
|
||||||
|
for _, track := range existing {
|
||||||
|
existingSet[track.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, track := range tracks {
|
||||||
|
if _, ok := existingSet[track.ID]; ok {
|
||||||
|
updated++
|
||||||
|
} else {
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.store.UpsertTracks(ctx, tracks); err != nil {
|
||||||
|
return ImportResponse{}, err
|
||||||
|
}
|
||||||
|
if err := s.upsertTrackEnrichments(ctx, tracks); err != nil {
|
||||||
|
return ImportResponse{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Status = "succeeded"
|
||||||
|
job.ImportedTracks = imported
|
||||||
|
job.UpdatedTracks = updated
|
||||||
|
job.Skipped = skipped
|
||||||
|
job.Warnings = warnings
|
||||||
|
job.FinishedAt = s.now()
|
||||||
|
if persist {
|
||||||
|
if err := s.store.FinishImportJob(ctx, job); err != nil {
|
||||||
|
return ImportResponse{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImportResponse{
|
||||||
|
ImportID: job.ID,
|
||||||
|
ImportedTracks: imported,
|
||||||
|
UpdatedTracks: updated,
|
||||||
|
Skipped: skipped,
|
||||||
|
Warnings: warnings,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveSpotifySource(ctx context.Context, source Source) (spotify.ParsedSource, []string, error) {
|
||||||
|
_ = ctx
|
||||||
|
parsed, err := spotify.ParseSource(source.Type, source.Value)
|
||||||
|
if err == nil {
|
||||||
|
return parsed, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.ToLower(strings.TrimSpace(source.Type)) != "url" {
|
||||||
|
return spotify.ParsedSource{}, nil, err
|
||||||
|
}
|
||||||
|
parsedURL := s.urlparser.ParseURL(source.Value)
|
||||||
|
if parsedURL == nil || parsedURL.Service == urlparser.Spotify {
|
||||||
|
return spotify.ParsedSource{}, nil, err
|
||||||
|
}
|
||||||
|
if s.songlink == nil || !s.songlink.Configured() {
|
||||||
|
return spotify.ParsedSource{}, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
links, linkErr := s.songlink.GetLinks(parsedURL.URL)
|
||||||
|
if linkErr != nil {
|
||||||
|
return spotify.ParsedSource{}, nil, fmt.Errorf("could not resolve %s URL to Spotify: %w", parsedURL.Service, linkErr)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(links.SpotifyID) == "" {
|
||||||
|
return spotify.ParsedSource{}, nil, fmt.Errorf("could not resolve %s URL to a Spotify track", parsedURL.Service)
|
||||||
|
}
|
||||||
|
|
||||||
|
spotifyID := strings.TrimSpace(links.SpotifyID)
|
||||||
|
return spotify.ParsedSource{
|
||||||
|
Type: "track",
|
||||||
|
ID: spotifyID,
|
||||||
|
URL: "https://open.spotify.com/track/" + spotifyID,
|
||||||
|
}, []string{"resolved " + string(parsedURL.Service) + " URL to Spotify via Song.link"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SearchSpotify(ctx context.Context, req SearchRequest) (SearchResponse, error) {
|
||||||
|
if s.spotify == nil || !s.spotify.Configured() {
|
||||||
|
// Try webplayer search if available (auth-free)
|
||||||
|
if s.webplayer != nil && s.webplayer.Configured() {
|
||||||
|
return s.searchViaWebPlayer(ctx, req)
|
||||||
|
}
|
||||||
|
return SearchResponse{}, spotify.ErrNotConfigured
|
||||||
|
}
|
||||||
|
itemType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||||
|
if itemType == "" {
|
||||||
|
itemType = "track"
|
||||||
|
}
|
||||||
|
if !validSearchType(itemType) {
|
||||||
|
return SearchResponse{}, errors.New("search type must be track, album, artist, or playlist")
|
||||||
|
}
|
||||||
|
limit := capSearchLimit(req.Limit)
|
||||||
|
market := s.market(req.Market)
|
||||||
|
result, _, warnings, err := s.spotifySearch(ctx, req.Query, itemType, market, limit)
|
||||||
|
if err != nil {
|
||||||
|
return SearchResponse{}, err
|
||||||
|
}
|
||||||
|
ids, idWarnings := s.trackIDsFromSearch(ctx, result, itemType, market, limit)
|
||||||
|
warnings = append(warnings, idWarnings...)
|
||||||
|
tracks := make([]recommendation.Track, 0, len(ids))
|
||||||
|
skipped := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
track, trackWarnings, ok := s.buildTrack(ctx, id, market, boolDefault(req.EnrichMusicBrainz, true), req.AllowMissingFields)
|
||||||
|
warnings = append(warnings, trackWarnings...)
|
||||||
|
if !ok {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tracks = append(tracks, track)
|
||||||
|
}
|
||||||
|
persisted := 0
|
||||||
|
if req.Persist && len(tracks) > 0 {
|
||||||
|
if err := s.store.UpsertTracks(ctx, tracks); err != nil {
|
||||||
|
return SearchResponse{}, err
|
||||||
|
}
|
||||||
|
if err := s.upsertTrackEnrichments(ctx, tracks); err != nil {
|
||||||
|
return SearchResponse{}, err
|
||||||
|
}
|
||||||
|
persisted = len(tracks)
|
||||||
|
}
|
||||||
|
return SearchResponse{Tracks: tracks, Persisted: persisted, Skipped: skipped, Warnings: warnings}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) trackIDsFromSearch(ctx context.Context, result spotify.SearchResult, itemType, market string, limit int) ([]string, []string) {
|
||||||
|
var warnings []string
|
||||||
|
ids := make([]string, 0, limit)
|
||||||
|
addID := func(id string) {
|
||||||
|
if id == "" || len(ids) >= limit {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
switch itemType {
|
||||||
|
case "track":
|
||||||
|
for _, item := range result.Tracks.Items {
|
||||||
|
addID(item.ID)
|
||||||
|
}
|
||||||
|
case "album":
|
||||||
|
for _, album := range result.Albums.Items {
|
||||||
|
refs, _, cacheWarnings, err := s.spotifyAlbumTracks(ctx, album.ID, market, limit-len(ids))
|
||||||
|
warnings = append(warnings, cacheWarnings...)
|
||||||
|
if err != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("spotify album %s skipped: %v", album.ID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, ref := range refs {
|
||||||
|
addID(ref.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "artist":
|
||||||
|
for _, artist := range result.Artists.Items {
|
||||||
|
items, _, cacheWarnings, err := s.spotifyArtistTopTracks(ctx, artist.ID, market)
|
||||||
|
warnings = append(warnings, cacheWarnings...)
|
||||||
|
if err != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("spotify artist %s skipped: %v", artist.ID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
addID(item.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "playlist":
|
||||||
|
for _, playlist := range result.Playlists.Items {
|
||||||
|
refs, _, cacheWarnings, err := s.spotifyPlaylistTracks(ctx, playlist.ID, market, limit-len(ids))
|
||||||
|
warnings = append(warnings, cacheWarnings...)
|
||||||
|
if err != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("spotify playlist %s skipped: %v", playlist.ID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, ref := range refs {
|
||||||
|
addID(ref.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) EnrichMusicBrainz(ctx context.Context, req EnrichRequest) (EnrichResponse, error) {
|
||||||
|
if s.musicbrainz == nil || !s.musicbrainz.Configured() {
|
||||||
|
return EnrichResponse{}, errors.New("musicbrainz app name and contact are required")
|
||||||
|
}
|
||||||
|
tracks, err := s.store.GetTracksByIDs(ctx, req.TrackIDs)
|
||||||
|
if err != nil {
|
||||||
|
return EnrichResponse{}, err
|
||||||
|
}
|
||||||
|
byID := make(map[string]recommendation.Track, len(tracks))
|
||||||
|
for _, track := range tracks {
|
||||||
|
byID[track.ID] = track
|
||||||
|
}
|
||||||
|
var warnings []string
|
||||||
|
updated, skipped := 0, 0
|
||||||
|
for _, id := range req.TrackIDs {
|
||||||
|
track, ok := byID[id]
|
||||||
|
if !ok {
|
||||||
|
skipped++
|
||||||
|
warnings = append(warnings, "track not found: "+id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !req.Force && track.External["musicbrainz_recording_id"] != "" {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mb, raw, warn, ok := s.enrichTrack(ctx, track)
|
||||||
|
if warn != "" {
|
||||||
|
warnings = append(warnings, warn)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if track.External == nil {
|
||||||
|
track.External = map[string]string{}
|
||||||
|
}
|
||||||
|
track.External["musicbrainz_recording_id"] = mb.ID
|
||||||
|
if mb.ArtistID != "" {
|
||||||
|
track.External["musicbrainz_artist_id"] = mb.ArtistID
|
||||||
|
}
|
||||||
|
if mb.ISRC != "" && track.External["isrc"] == "" {
|
||||||
|
track.External["isrc"] = mb.ISRC
|
||||||
|
}
|
||||||
|
track.Genres = mergeStrings(track.Genres, mb.Genres...)
|
||||||
|
track.Genres = mergeStrings(track.Genres, mb.Tags...)
|
||||||
|
if err := s.store.UpsertTrack(ctx, track); err != nil {
|
||||||
|
return EnrichResponse{}, err
|
||||||
|
}
|
||||||
|
if err := s.store.UpsertTrackEnrichment(ctx, TrackEnrichment{
|
||||||
|
TrackID: track.ID,
|
||||||
|
Provider: ProviderMusicBrainz,
|
||||||
|
MusicBrainzRecordingID: mb.ID,
|
||||||
|
MusicBrainzArtistID: mb.ArtistID,
|
||||||
|
ISRC: mb.ISRC,
|
||||||
|
Payload: raw,
|
||||||
|
UpdatedAt: s.now(),
|
||||||
|
}); err != nil {
|
||||||
|
return EnrichResponse{}, err
|
||||||
|
}
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
return EnrichResponse{Updated: updated, Skipped: skipped, Warnings: warnings}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Status(ctx context.Context) StatusResponse {
|
||||||
|
stats, _ := s.store.ProviderCacheStats(ctx)
|
||||||
|
now := s.now()
|
||||||
|
spotifyStatus := ProviderStatus{CheckedAt: now}
|
||||||
|
if s.spotify != nil {
|
||||||
|
spotifyStatus.Configured = s.spotify.Configured()
|
||||||
|
spotifyStatus.TokenMode = s.spotify.TokenMode()
|
||||||
|
spotifyStatus.Available = s.spotify.Configured() && s.spotify.LastError() == ""
|
||||||
|
spotifyStatus.LastError = s.spotify.LastError()
|
||||||
|
}
|
||||||
|
mbStatus := ProviderStatus{CheckedAt: now}
|
||||||
|
if s.musicbrainz != nil {
|
||||||
|
mbStatus.Configured = s.musicbrainz.Configured()
|
||||||
|
mbStatus.TokenMode = "user_agent"
|
||||||
|
mbStatus.Available = s.musicbrainz.Configured() && s.musicbrainz.LastError() == ""
|
||||||
|
mbStatus.LastError = s.musicbrainz.LastError()
|
||||||
|
}
|
||||||
|
return StatusResponse{Spotify: spotifyStatus, MusicBrainz: mbStatus, Cache: stats}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) importSpotifyTracks(ctx context.Context, parsed spotify.ParsedSource, market string, limit int, enrichMB, allowMissing bool) ([]recommendation.Track, int, []string, error) {
|
||||||
|
ids := []string{parsed.ID}
|
||||||
|
var warnings []string
|
||||||
|
switch parsed.Type {
|
||||||
|
case "track":
|
||||||
|
case "album":
|
||||||
|
refs, _, cacheWarnings, err := s.spotifyAlbumTracks(ctx, parsed.ID, market, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, warnings, err
|
||||||
|
}
|
||||||
|
warnings = append(warnings, cacheWarnings...)
|
||||||
|
ids = ids[:0]
|
||||||
|
for _, ref := range refs {
|
||||||
|
if ref.ID != "" {
|
||||||
|
ids = append(ids, ref.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "playlist":
|
||||||
|
refs, _, cacheWarnings, err := s.spotifyPlaylistTracks(ctx, parsed.ID, market, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, warnings, err
|
||||||
|
}
|
||||||
|
warnings = append(warnings, cacheWarnings...)
|
||||||
|
ids = ids[:0]
|
||||||
|
for _, ref := range refs {
|
||||||
|
if ref.ID != "" {
|
||||||
|
ids = append(ids, ref.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "artist":
|
||||||
|
items, _, cacheWarnings, err := s.spotifyArtistTopTracks(ctx, parsed.ID, market)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, warnings, err
|
||||||
|
}
|
||||||
|
warnings = append(warnings, cacheWarnings...)
|
||||||
|
ids = ids[:0]
|
||||||
|
for _, item := range items {
|
||||||
|
if item.ID != "" {
|
||||||
|
ids = append(ids, item.ID)
|
||||||
|
if limit > 0 && len(ids) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, 0, warnings, errors.New("unsupported Spotify source type")
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := make([]recommendation.Track, 0, len(ids))
|
||||||
|
skipped := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
track, trackWarnings, ok := s.buildTrack(ctx, id, market, enrichMB, allowMissing)
|
||||||
|
warnings = append(warnings, trackWarnings...)
|
||||||
|
if !ok {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tracks = append(tracks, track)
|
||||||
|
}
|
||||||
|
return tracks, skipped, warnings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) buildTrack(ctx context.Context, id, market string, enrichMB, allowMissing bool) (recommendation.Track, []string, bool) {
|
||||||
|
var warnings []string
|
||||||
|
item, _, cacheWarnings, err := s.spotifyTrack(ctx, id, market)
|
||||||
|
warnings = append(warnings, cacheWarnings...)
|
||||||
|
if err != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("spotify track %s skipped: %v", id, err))
|
||||||
|
return recommendation.Track{}, warnings, false
|
||||||
|
}
|
||||||
|
features, _, cacheWarnings, err := s.spotifyAudioFeatures(ctx, id)
|
||||||
|
warnings = append(warnings, cacheWarnings...)
|
||||||
|
missingFeatures := false
|
||||||
|
if err != nil {
|
||||||
|
if !allowMissing {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("spotify track %s skipped: audio features unavailable", id))
|
||||||
|
return recommendation.Track{}, warnings, false
|
||||||
|
}
|
||||||
|
missingFeatures = true
|
||||||
|
warnings = append(warnings, fmt.Sprintf("spotify track %s imported without audio features", id))
|
||||||
|
}
|
||||||
|
var mb musicbrainz.Recording
|
||||||
|
if enrichMB {
|
||||||
|
recording, _, warn, ok := s.enrichSpotifyTrack(ctx, item)
|
||||||
|
if warn != "" {
|
||||||
|
warnings = append(warnings, warn)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
mb = recording
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mapSpotifyTrack(item, features, mb, missingFeatures), warnings, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) upsertTrackEnrichments(ctx context.Context, tracks []recommendation.Track) error {
|
||||||
|
for _, track := range tracks {
|
||||||
|
if track.External["musicbrainz_recording_id"] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.store.UpsertTrackEnrichment(ctx, TrackEnrichment{
|
||||||
|
TrackID: track.ID,
|
||||||
|
Provider: ProviderMusicBrainz,
|
||||||
|
MusicBrainzRecordingID: track.External["musicbrainz_recording_id"],
|
||||||
|
MusicBrainzArtistID: track.External["musicbrainz_artist_id"],
|
||||||
|
ISRC: track.External["isrc"],
|
||||||
|
UpdatedAt: s.now(),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) enrichSpotifyTrack(ctx context.Context, track spotify.Track) (musicbrainz.Recording, []byte, string, bool) {
|
||||||
|
if s.musicbrainz == nil || !s.musicbrainz.Configured() {
|
||||||
|
return musicbrainz.Recording{}, nil, "", false
|
||||||
|
}
|
||||||
|
if isrc := strings.ToUpper(strings.TrimSpace(track.ExternalIDs["isrc"])); isrc != "" {
|
||||||
|
mb, raw, warnings, err := s.musicBrainzISRC(ctx, isrc)
|
||||||
|
if err == nil {
|
||||||
|
return mb, raw, "", true
|
||||||
|
}
|
||||||
|
return musicbrainz.Recording{}, raw, appendWarning(warnings, "musicbrainz isrc lookup failed for "+isrc), false
|
||||||
|
}
|
||||||
|
artist := ""
|
||||||
|
if len(track.Artists) > 0 {
|
||||||
|
artist = track.Artists[0].Name
|
||||||
|
}
|
||||||
|
mb, raw, warnings, err := s.musicBrainzSearch(ctx, track.Name, artist)
|
||||||
|
if err != nil {
|
||||||
|
return musicbrainz.Recording{}, raw, appendWarning(warnings, "musicbrainz search failed for "+track.Name), false
|
||||||
|
}
|
||||||
|
return mb, raw, "", true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) enrichTrack(ctx context.Context, track recommendation.Track) (musicbrainz.Recording, []byte, string, bool) {
|
||||||
|
if isrc := strings.TrimSpace(track.External["isrc"]); isrc != "" {
|
||||||
|
mb, raw, warnings, err := s.musicBrainzISRC(ctx, isrc)
|
||||||
|
if err == nil {
|
||||||
|
return mb, raw, "", true
|
||||||
|
}
|
||||||
|
return musicbrainz.Recording{}, raw, appendWarning(warnings, "musicbrainz isrc lookup failed for "+isrc), false
|
||||||
|
}
|
||||||
|
mb, raw, warnings, err := s.musicBrainzSearch(ctx, track.Title, track.Artist)
|
||||||
|
if err != nil {
|
||||||
|
return musicbrainz.Recording{}, raw, appendWarning(warnings, "musicbrainz search failed for "+track.ID), false
|
||||||
|
}
|
||||||
|
return mb, raw, "", true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) spotifyTrack(ctx context.Context, id, market string) (spotify.Track, []byte, []string, error) {
|
||||||
|
var out spotify.Track
|
||||||
|
payload, warnings, err := s.cachedJSON(ctx, ProviderSpotify, "track", id, market, func(context.Context) ([]byte, error) {
|
||||||
|
_, raw, err := s.spotify.GetTrack(ctx, id, market)
|
||||||
|
return raw, err
|
||||||
|
}, &out)
|
||||||
|
return out, payload, warnings, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) spotifyAudioFeatures(ctx context.Context, id string) (spotify.AudioFeatures, []byte, []string, error) {
|
||||||
|
var out spotify.AudioFeatures
|
||||||
|
payload, warnings, err := s.cachedJSON(ctx, ProviderSpotify, "audio_features", id, "", func(context.Context) ([]byte, error) {
|
||||||
|
_, raw, err := s.spotify.GetAudioFeatures(ctx, id)
|
||||||
|
return raw, err
|
||||||
|
}, &out)
|
||||||
|
return out, payload, warnings, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) spotifySearch(ctx context.Context, query, itemType, market string, limit int) (spotify.SearchResult, []byte, []string, error) {
|
||||||
|
var out spotify.SearchResult
|
||||||
|
itemID := itemType + ":" + query + ":" + fmt.Sprint(limit)
|
||||||
|
payload, warnings, err := s.cachedJSON(ctx, ProviderSpotify, "search", itemID, market, func(context.Context) ([]byte, error) {
|
||||||
|
_, raw, err := s.spotify.Search(ctx, query, itemType, market, limit)
|
||||||
|
return raw, err
|
||||||
|
}, &out)
|
||||||
|
return out, payload, warnings, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) spotifyAlbumTracks(ctx context.Context, id, market string, limit int) ([]spotify.TrackRef, []byte, []string, error) {
|
||||||
|
var out []spotify.TrackRef
|
||||||
|
payload, warnings, err := s.cachedJSON(ctx, ProviderSpotify, "album_tracks", id+":"+fmt.Sprint(limit), market, func(context.Context) ([]byte, error) {
|
||||||
|
refs, _, err := s.spotify.GetAlbumTracks(ctx, id, market, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(refs)
|
||||||
|
}, &out)
|
||||||
|
return out, payload, warnings, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) spotifyPlaylistTracks(ctx context.Context, id, market string, limit int) ([]spotify.TrackRef, []byte, []string, error) {
|
||||||
|
var out []spotify.TrackRef
|
||||||
|
payload, warnings, err := s.cachedJSON(ctx, ProviderSpotify, "playlist_tracks", id+":"+fmt.Sprint(limit), market, func(context.Context) ([]byte, error) {
|
||||||
|
refs, _, err := s.spotify.GetPlaylistTracks(ctx, id, market, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(refs)
|
||||||
|
}, &out)
|
||||||
|
return out, payload, warnings, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) spotifyArtistTopTracks(ctx context.Context, id, market string) ([]spotify.Track, []byte, []string, error) {
|
||||||
|
var out []spotify.Track
|
||||||
|
payload, warnings, err := s.cachedJSON(ctx, ProviderSpotify, "artist_top_tracks", id, market, func(context.Context) ([]byte, error) {
|
||||||
|
tracks, _, err := s.spotify.GetArtistTopTracks(ctx, id, market)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(tracks)
|
||||||
|
}, &out)
|
||||||
|
return out, payload, warnings, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) musicBrainzISRC(ctx context.Context, isrc string) (musicbrainz.Recording, []byte, []string, error) {
|
||||||
|
var out musicbrainz.Recording
|
||||||
|
payload, warnings, err := s.cachedJSON(ctx, ProviderMusicBrainz, "isrc", isrc, "", func(context.Context) ([]byte, error) {
|
||||||
|
recording, raw, err := s.musicbrainz.LookupByISRC(ctx, isrc)
|
||||||
|
if err != nil {
|
||||||
|
return raw, err
|
||||||
|
}
|
||||||
|
return json.Marshal(recording)
|
||||||
|
}, &out)
|
||||||
|
return out, payload, warnings, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) musicBrainzSearch(ctx context.Context, title, artist string) (musicbrainz.Recording, []byte, []string, error) {
|
||||||
|
var out musicbrainz.Recording
|
||||||
|
itemID := title + ":" + artist
|
||||||
|
payload, warnings, err := s.cachedJSON(ctx, ProviderMusicBrainz, "recording_search", itemID, "", func(context.Context) ([]byte, error) {
|
||||||
|
recording, raw, err := s.musicbrainz.SearchRecording(ctx, title, artist)
|
||||||
|
if err != nil {
|
||||||
|
return raw, err
|
||||||
|
}
|
||||||
|
return json.Marshal(recording)
|
||||||
|
}, &out)
|
||||||
|
return out, payload, warnings, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) cachedJSON(ctx context.Context, providerName, itemType, itemID, market string, fetch func(context.Context) ([]byte, error), out any) ([]byte, []string, error) {
|
||||||
|
var warnings []string
|
||||||
|
now := s.now()
|
||||||
|
cached, ok, err := s.store.GetProviderCache(ctx, providerName, itemType, itemID, market)
|
||||||
|
if err != nil {
|
||||||
|
return nil, warnings, err
|
||||||
|
}
|
||||||
|
if ok && cached.Fresh(now) {
|
||||||
|
if err := json.Unmarshal(cached.Payload, out); err != nil {
|
||||||
|
return cached.Payload, warnings, err
|
||||||
|
}
|
||||||
|
return cached.Payload, warnings, nil
|
||||||
|
}
|
||||||
|
payload, err := fetch(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if ok && len(cached.Payload) > 0 {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("using stale %s %s cache after provider error", providerName, itemType))
|
||||||
|
if decodeErr := json.Unmarshal(cached.Payload, out); decodeErr != nil {
|
||||||
|
return cached.Payload, warnings, decodeErr
|
||||||
|
}
|
||||||
|
return cached.Payload, warnings, nil
|
||||||
|
}
|
||||||
|
_ = s.store.UpsertProviderCache(ctx, CacheEntry{
|
||||||
|
Provider: providerName,
|
||||||
|
ItemType: itemType,
|
||||||
|
ItemID: itemID,
|
||||||
|
Market: market,
|
||||||
|
FetchedAt: now,
|
||||||
|
ExpiresAt: now,
|
||||||
|
LastError: err.Error(),
|
||||||
|
})
|
||||||
|
return payload, warnings, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, out); err != nil {
|
||||||
|
return payload, warnings, err
|
||||||
|
}
|
||||||
|
if err := s.store.UpsertProviderCache(ctx, CacheEntry{
|
||||||
|
Provider: providerName,
|
||||||
|
ItemType: itemType,
|
||||||
|
ItemID: itemID,
|
||||||
|
Market: market,
|
||||||
|
Payload: payload,
|
||||||
|
FetchedAt: now,
|
||||||
|
ExpiresAt: now.Add(s.cacheTTL),
|
||||||
|
}); err != nil {
|
||||||
|
return payload, warnings, err
|
||||||
|
}
|
||||||
|
return payload, warnings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) market(value string) string {
|
||||||
|
if value = strings.ToUpper(strings.TrimSpace(value)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return s.defaultMarket
|
||||||
|
}
|
||||||
|
|
||||||
|
func capSearchLimit(value int) int {
|
||||||
|
if value <= 0 {
|
||||||
|
return 5
|
||||||
|
}
|
||||||
|
if value > 10 {
|
||||||
|
return 10
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func validSearchType(value string) bool {
|
||||||
|
switch value {
|
||||||
|
case "track", "album", "artist", "playlist":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func capLimit(value, maxValue int) int {
|
||||||
|
if value <= 0 {
|
||||||
|
return maxValue
|
||||||
|
}
|
||||||
|
if value > maxValue {
|
||||||
|
return maxValue
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolDefault(value *bool, fallback bool) bool {
|
||||||
|
if value == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func newID(prefix string) string {
|
||||||
|
var b [12]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return prefix + "_" + strings.ReplaceAll(time.Now().UTC().Format(time.RFC3339Nano), ":", "")
|
||||||
|
}
|
||||||
|
return prefix + "_" + hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendWarning(warnings []string, fallback string) string {
|
||||||
|
if len(warnings) == 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return warnings[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// importFromWebPlayer imports tracks using the native auth-free webplayer client
|
||||||
|
func (s *Service) importFromWebPlayer(ctx context.Context, req ImportRequest) (ImportResponse, error) {
|
||||||
|
persist := true
|
||||||
|
if req.Persist != nil {
|
||||||
|
persist = *req.Persist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the URL to get the Spotify track ID
|
||||||
|
itemType, itemID, err := webplayer.ParseSpotifyURL(req.Source.Value)
|
||||||
|
if err != nil {
|
||||||
|
parsedURL := s.urlparser.ParseURL(req.Source.Value)
|
||||||
|
if parsedURL == nil || parsedURL.Service == urlparser.Spotify || s.songlink == nil || !s.songlink.Configured() {
|
||||||
|
return ImportResponse{}, fmt.Errorf("invalid Spotify URL: %w", err)
|
||||||
|
}
|
||||||
|
links, linkErr := s.songlink.GetLinks(parsedURL.URL)
|
||||||
|
if linkErr != nil {
|
||||||
|
return ImportResponse{}, fmt.Errorf("could not resolve %s URL to Spotify: %w", parsedURL.Service, linkErr)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(links.SpotifyID) == "" {
|
||||||
|
return ImportResponse{}, fmt.Errorf("could not resolve %s URL to a Spotify track", parsedURL.Service)
|
||||||
|
}
|
||||||
|
itemType = "track"
|
||||||
|
itemID = strings.TrimSpace(links.SpotifyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if itemType != "track" {
|
||||||
|
return ImportResponse{}, fmt.Errorf("unsupported item type: %s (only tracks supported for web player import)", itemType)
|
||||||
|
}
|
||||||
|
|
||||||
|
job := ImportJob{
|
||||||
|
ID: newID("import"),
|
||||||
|
Provider: ProviderSpotify,
|
||||||
|
SourceType: itemType,
|
||||||
|
SourceValue: itemID,
|
||||||
|
Status: "running",
|
||||||
|
StartedAt: s.now(),
|
||||||
|
}
|
||||||
|
if persist {
|
||||||
|
if err := s.store.CreateImportJob(ctx, job); err != nil {
|
||||||
|
return ImportResponse{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch track from web player (auth-free using TOTP)
|
||||||
|
wpTrack, err := s.webplayer.GetTrack(itemID)
|
||||||
|
if err != nil {
|
||||||
|
job.Status = "failed"
|
||||||
|
job.Warnings = []string{err.Error()}
|
||||||
|
job.FinishedAt = s.now()
|
||||||
|
if persist {
|
||||||
|
_ = s.store.FinishImportJob(ctx, job)
|
||||||
|
}
|
||||||
|
return ImportResponse{}, fmt.Errorf("web player fetch failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert artist list to string
|
||||||
|
artistName := ""
|
||||||
|
if len(wpTrack.Artists) > 0 {
|
||||||
|
artistNames := make([]string, len(wpTrack.Artists))
|
||||||
|
for i, a := range wpTrack.Artists {
|
||||||
|
artistNames[i] = a.Name
|
||||||
|
}
|
||||||
|
artistName = strings.Join(artistNames, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build external URLs
|
||||||
|
externalURLs := map[string]string{
|
||||||
|
"spotify": fmt.Sprintf("https://open.spotify.com/track/%s", wpTrack.ID),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cross-platform links from Song.link
|
||||||
|
if s.songlink != nil && s.songlink.Configured() {
|
||||||
|
if links, err := s.songlink.GetLinksFromSpotifyID(wpTrack.ID); err == nil && links != nil {
|
||||||
|
for platform, link := range links.Links {
|
||||||
|
externalURLs[platform] = link.URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to recommendation.Track
|
||||||
|
track := recommendation.Track{
|
||||||
|
ID: wpTrack.ID,
|
||||||
|
Title: wpTrack.Name,
|
||||||
|
Artist: artistName,
|
||||||
|
Album: wpTrack.Album.Name,
|
||||||
|
DurationMS: wpTrack.DurationMs,
|
||||||
|
Explicit: wpTrack.Explicit,
|
||||||
|
Popularity: 0.5, // Web player doesn't provide popularity
|
||||||
|
External: externalURLs,
|
||||||
|
CreatedAt: s.now(),
|
||||||
|
UpdatedAt: s.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add image URL if available
|
||||||
|
if len(wpTrack.Album.Images) > 0 {
|
||||||
|
track.External["image_url"] = wpTrack.Album.Images[0].URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally enrich with MusicBrainz
|
||||||
|
if boolDefault(req.EnrichMusicBrainz, true) && s.musicbrainz != nil {
|
||||||
|
mb, _, _, ok := s.enrichTrack(ctx, track)
|
||||||
|
if ok && mb.ID != "" {
|
||||||
|
track.External["musicbrainz_recording_id"] = mb.ID
|
||||||
|
if mb.ISRC != "" {
|
||||||
|
track.External["isrc"] = mb.ISRC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the track
|
||||||
|
imported, updated := 0, 0
|
||||||
|
if persist {
|
||||||
|
existing, _ := s.store.GetTracksByIDs(ctx, []string{track.ID})
|
||||||
|
if len(existing) > 0 {
|
||||||
|
updated = 1
|
||||||
|
} else {
|
||||||
|
imported = 1
|
||||||
|
}
|
||||||
|
if err := s.store.UpsertTrack(ctx, track); err != nil {
|
||||||
|
return ImportResponse{}, err
|
||||||
|
}
|
||||||
|
if err := s.upsertTrackEnrichments(ctx, []recommendation.Track{track}); err != nil {
|
||||||
|
return ImportResponse{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Status = "succeeded"
|
||||||
|
job.ImportedTracks = imported
|
||||||
|
job.UpdatedTracks = updated
|
||||||
|
job.FinishedAt = s.now()
|
||||||
|
if persist {
|
||||||
|
if err := s.store.FinishImportJob(ctx, job); err != nil {
|
||||||
|
return ImportResponse{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImportResponse{
|
||||||
|
ImportID: job.ID,
|
||||||
|
ImportedTracks: imported,
|
||||||
|
UpdatedTracks: updated,
|
||||||
|
Skipped: 0,
|
||||||
|
Warnings: []string{"imported via webplayer (auth-free, native Go)"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchViaWebPlayer searches using the native webplayer client
|
||||||
|
func (s *Service) searchViaWebPlayer(ctx context.Context, req SearchRequest) (SearchResponse, error) {
|
||||||
|
// Use the webplayer's search capability
|
||||||
|
wpTracks, err := s.webplayer.Search(req.Query, req.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return SearchResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var tracks []recommendation.Track
|
||||||
|
for _, wpTrack := range wpTracks {
|
||||||
|
artistName := ""
|
||||||
|
if len(wpTrack.Artists) > 0 {
|
||||||
|
artistNames := make([]string, len(wpTrack.Artists))
|
||||||
|
for i, a := range wpTrack.Artists {
|
||||||
|
artistNames[i] = a.Name
|
||||||
|
}
|
||||||
|
artistName = strings.Join(artistNames, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
track := recommendation.Track{
|
||||||
|
ID: wpTrack.ID,
|
||||||
|
Title: wpTrack.Name,
|
||||||
|
Artist: artistName,
|
||||||
|
Album: wpTrack.Album.Name,
|
||||||
|
DurationMS: wpTrack.DurationMs,
|
||||||
|
Explicit: wpTrack.Explicit,
|
||||||
|
Popularity: 0.5,
|
||||||
|
External: map[string]string{
|
||||||
|
"spotify": fmt.Sprintf("https://open.spotify.com/track/%s", wpTrack.ID),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks = append(tracks, track)
|
||||||
|
}
|
||||||
|
|
||||||
|
return SearchResponse{
|
||||||
|
Tracks: tracks,
|
||||||
|
Persisted: 0,
|
||||||
|
Skipped: 0,
|
||||||
|
Warnings: []string{"search results from webplayer (auth-free)"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package provider_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/musicbrainz"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/songlink"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/spotify"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/webplayer"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/storage/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestImportSpotifyTrackPersistsRecommendableTrack(t *testing.T) {
|
||||||
|
store := memory.New()
|
||||||
|
spotifyServer := fakeSpotifyServer(t)
|
||||||
|
defer spotifyServer.Close()
|
||||||
|
mbServer := fakeMusicBrainzServer(t)
|
||||||
|
defer mbServer.Close()
|
||||||
|
|
||||||
|
service := provider.NewService(store,
|
||||||
|
spotify.New(spotify.Config{BearerToken: "token", APIBaseURL: spotifyServer.URL + "/v1"}),
|
||||||
|
webplayer.NewClient(),
|
||||||
|
songlink.NewClient(),
|
||||||
|
musicbrainz.New(musicbrainz.Config{AppName: "SpotifyRecAlg", Contact: "[email protected]", BaseURL: mbServer.URL + "/ws/2", MinDelay: time.Nanosecond}),
|
||||||
|
provider.ServiceConfig{DefaultMarket: "US", CacheTTL: time.Hour},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp, err := service.ImportSpotify(context.Background(), provider.ImportRequest{
|
||||||
|
Source: provider.Source{Type: "url", Value: "https://open.spotify.com/track/good"},
|
||||||
|
Market: "US",
|
||||||
|
EnrichMusicBrainz: boolPtr(true),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("import spotify: %v", err)
|
||||||
|
}
|
||||||
|
if resp.ImportedTracks != 1 || resp.Skipped != 0 {
|
||||||
|
t.Fatalf("unexpected import response: %+v", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := recommendation.NewEngine(recommendation.EngineConfig{
|
||||||
|
ContentWeight: 0.5,
|
||||||
|
PopularityWeight: 0.2,
|
||||||
|
ExplorationWeight: 0.3,
|
||||||
|
DiversityLambda: 0.7,
|
||||||
|
})
|
||||||
|
recs, _, err := engine.Recommend(context.Background(), store, recommendation.RecommendRequest{UserID: "user", Limit: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recommend after import: %v", err)
|
||||||
|
}
|
||||||
|
if len(recs) != 1 || recs[0].Track.ID != "spotify:track:good" {
|
||||||
|
t.Fatalf("unexpected recommendations: %+v", recs)
|
||||||
|
}
|
||||||
|
if got := recs[0].Track.External["musicbrainz_recording_id"]; got != "mb-recording" {
|
||||||
|
t.Fatalf("musicbrainz recording id = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolPtr(value bool) *bool {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchSpotifyCapsLimitAndPersistFalse(t *testing.T) {
|
||||||
|
store := memory.New()
|
||||||
|
spotifyServer := fakeSpotifyServer(t)
|
||||||
|
defer spotifyServer.Close()
|
||||||
|
|
||||||
|
service := provider.NewService(store,
|
||||||
|
spotify.New(spotify.Config{BearerToken: "token", APIBaseURL: spotifyServer.URL + "/v1"}),
|
||||||
|
webplayer.NewClient(),
|
||||||
|
songlink.NewClient(),
|
||||||
|
nil,
|
||||||
|
provider.ServiceConfig{DefaultMarket: "US", CacheTTL: time.Hour},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp, err := service.SearchSpotify(context.Background(), provider.SearchRequest{Query: "hello", Type: "track", Limit: 50, Persist: false})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search spotify: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Tracks) != 1 || resp.Persisted != 0 {
|
||||||
|
t.Fatalf("unexpected search response: %+v", resp)
|
||||||
|
}
|
||||||
|
if _, _, err := recommendation.NewEngine(recommendation.EngineConfig{}).Recommend(context.Background(), store, recommendation.RecommendRequest{UserID: "user", Limit: 1}); err == nil {
|
||||||
|
t.Fatal("expected empty catalog because persist=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderCacheUsesStaleOnError(t *testing.T) {
|
||||||
|
store := memory.New()
|
||||||
|
spotifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "upstream down", http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer spotifyServer.Close()
|
||||||
|
service := provider.NewService(store,
|
||||||
|
spotify.New(spotify.Config{BearerToken: "token", APIBaseURL: spotifyServer.URL + "/v1", MaxRetries: 1}),
|
||||||
|
webplayer.NewClient(),
|
||||||
|
songlink.NewClient(),
|
||||||
|
nil,
|
||||||
|
provider.ServiceConfig{DefaultMarket: "US", CacheTTL: time.Hour},
|
||||||
|
)
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
trackPayload := []byte(`{"id":"cached","name":"Cached","artists":[{"name":"Artist"}],"album":{"name":"Album"},"popularity":50}`)
|
||||||
|
if err := store.UpsertProviderCache(context.Background(), provider.CacheEntry{
|
||||||
|
Provider: provider.ProviderSpotify,
|
||||||
|
ItemType: "track",
|
||||||
|
ItemID: "cached",
|
||||||
|
Market: "US",
|
||||||
|
Payload: trackPayload,
|
||||||
|
FetchedAt: now.Add(-2 * time.Hour),
|
||||||
|
ExpiresAt: now.Add(-time.Hour),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("upsert track cache: %v", err)
|
||||||
|
}
|
||||||
|
featuresPayload := []byte(`{"danceability":0.5,"energy":0.6,"loudness":-7,"speechiness":0.03,"acousticness":0.2,"instrumentalness":0,"liveness":0.1,"valence":0.4,"tempo":100,"time_signature":4,"key":1,"mode":1}`)
|
||||||
|
if err := store.UpsertProviderCache(context.Background(), provider.CacheEntry{
|
||||||
|
Provider: provider.ProviderSpotify,
|
||||||
|
ItemType: "audio_features",
|
||||||
|
ItemID: "cached",
|
||||||
|
Payload: featuresPayload,
|
||||||
|
FetchedAt: now.Add(-2 * time.Hour),
|
||||||
|
ExpiresAt: now.Add(-time.Hour),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("upsert features cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := service.ImportSpotify(context.Background(), provider.ImportRequest{
|
||||||
|
Source: provider.Source{Type: "url", Value: "https://open.spotify.com/track/cached"},
|
||||||
|
Market: "US",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("import with stale cache: %v", err)
|
||||||
|
}
|
||||||
|
if resp.ImportedTracks != 1 || len(resp.Warnings) == 0 {
|
||||||
|
t.Fatalf("expected stale fallback import with warning, got %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fakeSpotifyServer(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/v1/search":
|
||||||
|
if got := r.URL.Query().Get("limit"); got != "10" {
|
||||||
|
t.Fatalf("search limit = %q, want 10", got)
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"tracks": map[string]any{"items": []map[string]any{{"id": "good"}}},
|
||||||
|
})
|
||||||
|
case "/v1/tracks/good":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"id": "good",
|
||||||
|
"name": "Good Song",
|
||||||
|
"artists": []map[string]any{{"id": "spotify-artist", "name": "Good Artist"}},
|
||||||
|
"album": map[string]any{"id": "album", "name": "Good Album", "release_date": "2024-01-01", "images": []map[string]any{{"url": "https://img.example/good.jpg"}}},
|
||||||
|
"duration_ms": 210000,
|
||||||
|
"popularity": 80,
|
||||||
|
"explicit": false,
|
||||||
|
"external_ids": map[string]string{"isrc": "USRC17607839"},
|
||||||
|
"external_urls": map[string]string{
|
||||||
|
"spotify": "https://open.spotify.com/track/good",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case "/v1/audio-features/good":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"danceability": 0.7,
|
||||||
|
"energy": 0.8,
|
||||||
|
"loudness": -5.0,
|
||||||
|
"speechiness": 0.04,
|
||||||
|
"acousticness": 0.1,
|
||||||
|
"instrumentalness": 0.0,
|
||||||
|
"liveness": 0.12,
|
||||||
|
"valence": 0.6,
|
||||||
|
"tempo": 120,
|
||||||
|
"time_signature": 4,
|
||||||
|
"key": 2,
|
||||||
|
"mode": 1,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func fakeMusicBrainzServer(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("User-Agent"); got == "" {
|
||||||
|
t.Fatal("missing User-Agent")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/ws/2/isrc/USRC17607839":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"recordings": []map[string]any{{
|
||||||
|
"id": "mb-recording",
|
||||||
|
"title": "Good Song",
|
||||||
|
"artist-credit": []map[string]any{{
|
||||||
|
"artist": map[string]string{"id": "mb-artist", "name": "Good Artist"},
|
||||||
|
}},
|
||||||
|
"isrcs": []string{"USRC17607839"},
|
||||||
|
"tags": []map[string]string{{"name": "indie"}},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// Package songlink provides a client for the Song.link/Odesli API.
|
||||||
|
// Song.link offers free cross-platform music URL mapping.
|
||||||
|
package songlink
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
apiBase = "https://api.song.link/v1-alpha.1"
|
||||||
|
minRequestInterval = 7 * time.Second
|
||||||
|
maxRequestsPerMinute = 9
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlatformLink represents a link to a track on a specific platform
|
||||||
|
type PlatformLink struct {
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
EntityType string `json:"entity_type"`
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
NativeURI string `json:"native_uri,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrossPlatformLinks holds links for a track across multiple platforms
|
||||||
|
type CrossPlatformLinks struct {
|
||||||
|
SpotifyID string `json:"spotify_id"`
|
||||||
|
ISRC string `json:"isrc,omitempty"`
|
||||||
|
Links map[string]PlatformLink `json:"links"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client for Song.link API
|
||||||
|
type Client struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
lastRequestTime time.Time
|
||||||
|
requestCount int
|
||||||
|
countResetTime time.Time
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new Song.link client
|
||||||
|
func NewClient() *Client {
|
||||||
|
return &Client{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
countResetTime: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured always returns true for Song.link (no API key needed)
|
||||||
|
func (c *Client) Configured() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) rateLimit() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Reset counter every minute
|
||||||
|
if now.Sub(c.countResetTime) >= time.Minute {
|
||||||
|
c.requestCount = 0
|
||||||
|
c.countResetTime = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we've hit the per-minute limit
|
||||||
|
if c.requestCount >= maxRequestsPerMinute {
|
||||||
|
waitTime := time.Minute - now.Sub(c.countResetTime)
|
||||||
|
if waitTime > 0 {
|
||||||
|
time.Sleep(waitTime)
|
||||||
|
c.requestCount = 0
|
||||||
|
c.countResetTime = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure minimum interval between requests
|
||||||
|
elapsed := now.Sub(c.lastRequestTime)
|
||||||
|
if elapsed < minRequestInterval {
|
||||||
|
time.Sleep(minRequestInterval - elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.lastRequestTime = time.Now()
|
||||||
|
c.requestCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLinksFromSpotifyID gets cross-platform links from a Spotify track ID
|
||||||
|
func (c *Client) GetLinksFromSpotifyID(spotifyID string) (*CrossPlatformLinks, error) {
|
||||||
|
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyID)
|
||||||
|
return c.GetLinks(spotifyURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLinks gets cross-platform links from any music URL
|
||||||
|
func (c *Client) GetLinks(musicURL string) (*CrossPlatformLinks, error) {
|
||||||
|
c.rateLimit()
|
||||||
|
|
||||||
|
params := url.Values{
|
||||||
|
"url": {musicURL},
|
||||||
|
"userCountry": {"US"},
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%s/links?%s", apiBase, params.Encode())
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", "SpotifyRecAlg/1.0")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests {
|
||||||
|
// Rate limited - wait and retry once
|
||||||
|
retryAfter := 15
|
||||||
|
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
||||||
|
fmt.Sscanf(ra, "%d", &retryAfter)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(retryAfter) * time.Second)
|
||||||
|
return c.GetLinks(musicURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("song.link API error: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data struct {
|
||||||
|
EntityUniqueID string `json:"entityUniqueId"`
|
||||||
|
UserCountry string `json:"userCountry"`
|
||||||
|
PageURL string `json:"pageUrl"`
|
||||||
|
LinksByPlatform map[string]struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
EntityUniqueID string `json:"entityUniqueId"`
|
||||||
|
} `json:"linksByPlatform"`
|
||||||
|
EntitiesByUniqueID map[string]struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artist string `json:"artistName"`
|
||||||
|
ThumbnailURL string `json:"thumbnailUrl"`
|
||||||
|
APIProvider string `json:"apiProvider"`
|
||||||
|
Platforms []string `json:"platforms"`
|
||||||
|
} `json:"entitiesByUniqueId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
links := &CrossPlatformLinks{
|
||||||
|
Links: make(map[string]PlatformLink),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract Spotify ID
|
||||||
|
for uniqueID, entity := range data.EntitiesByUniqueID {
|
||||||
|
if entity.APIProvider == "spotify" {
|
||||||
|
links.SpotifyID = entity.ID
|
||||||
|
}
|
||||||
|
if entity.Type == "song" {
|
||||||
|
// ISRC can sometimes be derived from the unique ID format
|
||||||
|
_ = uniqueID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform name mapping
|
||||||
|
platformNames := map[string]string{
|
||||||
|
"spotify": "spotify",
|
||||||
|
"tidal": "tidal",
|
||||||
|
"qobuz": "qobuz",
|
||||||
|
"amazonMusic": "amazonMusic",
|
||||||
|
"amazonStore": "amazon",
|
||||||
|
"deezer": "deezer",
|
||||||
|
"appleMusic": "appleMusic",
|
||||||
|
"youtube": "youtube",
|
||||||
|
"youtubeMusic": "youtubeMusic",
|
||||||
|
"soundcloud": "soundcloud",
|
||||||
|
"napster": "napster",
|
||||||
|
"pandora": "pandora",
|
||||||
|
}
|
||||||
|
|
||||||
|
for platform, linkData := range data.LinksByPlatform {
|
||||||
|
if name, ok := platformNames[platform]; ok {
|
||||||
|
links.Links[name] = PlatformLink{
|
||||||
|
Platform: platform,
|
||||||
|
URL: linkData.URL,
|
||||||
|
EntityType: "track",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return links, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
package spotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultAccountsBaseURL = "https://accounts.spotify.com"
|
||||||
|
defaultAPIBaseURL = "https://api.spotify.com/v1"
|
||||||
|
defaultTimeout = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotConfigured = errors.New("spotify credentials are not configured")
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
BearerToken string
|
||||||
|
Market string
|
||||||
|
AccountsBaseURL string
|
||||||
|
APIBaseURL string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
Timeout time.Duration
|
||||||
|
MaxRetries int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
clientID string
|
||||||
|
clientSecret string
|
||||||
|
staticToken string
|
||||||
|
defaultMarket string
|
||||||
|
accountsBaseURL string
|
||||||
|
apiBaseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
timeout time.Duration
|
||||||
|
maxRetries int
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
token string
|
||||||
|
expiresAt time.Time
|
||||||
|
lastError string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Client {
|
||||||
|
timeout := cfg.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaultTimeout
|
||||||
|
}
|
||||||
|
httpClient := cfg.HTTPClient
|
||||||
|
if httpClient == nil {
|
||||||
|
httpClient = &http.Client{Timeout: timeout}
|
||||||
|
}
|
||||||
|
accountsBaseURL := strings.TrimRight(cfg.AccountsBaseURL, "/")
|
||||||
|
if accountsBaseURL == "" {
|
||||||
|
accountsBaseURL = defaultAccountsBaseURL
|
||||||
|
}
|
||||||
|
apiBaseURL := strings.TrimRight(cfg.APIBaseURL, "/")
|
||||||
|
if apiBaseURL == "" {
|
||||||
|
apiBaseURL = defaultAPIBaseURL
|
||||||
|
}
|
||||||
|
maxRetries := cfg.MaxRetries
|
||||||
|
if maxRetries <= 0 {
|
||||||
|
maxRetries = 2
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
clientID: strings.TrimSpace(cfg.ClientID),
|
||||||
|
clientSecret: strings.TrimSpace(cfg.ClientSecret),
|
||||||
|
staticToken: strings.TrimSpace(cfg.BearerToken),
|
||||||
|
defaultMarket: strings.ToUpper(strings.TrimSpace(cfg.Market)),
|
||||||
|
accountsBaseURL: accountsBaseURL,
|
||||||
|
apiBaseURL: apiBaseURL,
|
||||||
|
httpClient: httpClient,
|
||||||
|
timeout: timeout,
|
||||||
|
maxRetries: maxRetries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Configured() bool {
|
||||||
|
return c.staticToken != "" || (c.clientID != "" && c.clientSecret != "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) TokenMode() string {
|
||||||
|
if c.staticToken != "" {
|
||||||
|
return "static_bearer"
|
||||||
|
}
|
||||||
|
if c.clientID != "" && c.clientSecret != "" {
|
||||||
|
return "client_credentials"
|
||||||
|
}
|
||||||
|
return "unconfigured"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) LastError() string {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetTrack(ctx context.Context, id, market string) (Track, []byte, error) {
|
||||||
|
var out Track
|
||||||
|
payload, err := c.get(ctx, "/tracks/"+url.PathEscape(id), marketParams(marketOrDefault(market, c.defaultMarket)), &out)
|
||||||
|
return out, payload, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetAudioFeatures(ctx context.Context, id string) (AudioFeatures, []byte, error) {
|
||||||
|
var out AudioFeatures
|
||||||
|
payload, err := c.get(ctx, "/audio-features/"+url.PathEscape(id), nil, &out)
|
||||||
|
return out, payload, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Search(ctx context.Context, query, itemType, market string, limit int) (SearchResult, []byte, error) {
|
||||||
|
itemType = strings.ToLower(strings.TrimSpace(itemType))
|
||||||
|
if itemType == "" {
|
||||||
|
itemType = "track"
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 5
|
||||||
|
}
|
||||||
|
if limit > 10 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("q", query)
|
||||||
|
params.Set("type", itemType)
|
||||||
|
params.Set("limit", strconv.Itoa(limit))
|
||||||
|
if market = marketOrDefault(market, c.defaultMarket); market != "" {
|
||||||
|
params.Set("market", market)
|
||||||
|
}
|
||||||
|
var out SearchResult
|
||||||
|
payload, err := c.get(ctx, "/search", params, &out)
|
||||||
|
return out, payload, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetAlbumTracks(ctx context.Context, id, market string, limit int) ([]TrackRef, []byte, error) {
|
||||||
|
return c.getPagedTrackRefs(ctx, "/albums/"+url.PathEscape(id)+"/tracks", "items", market, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetPlaylistTracks(ctx context.Context, id, market string, limit int) ([]TrackRef, []byte, error) {
|
||||||
|
limit = normalizeCollectionLimit(limit)
|
||||||
|
refs := make([]TrackRef, 0, limit)
|
||||||
|
var lastPayload []byte
|
||||||
|
for offset := 0; len(refs) < limit; offset += 50 {
|
||||||
|
params := marketParams(marketOrDefault(market, c.defaultMarket))
|
||||||
|
params.Set("limit", strconv.Itoa(minInt(50, limit-len(refs))))
|
||||||
|
params.Set("offset", strconv.Itoa(offset))
|
||||||
|
params.Set("fields", "items(track(id,is_local,type)),next")
|
||||||
|
payload, err := c.getRaw(ctx, "/playlists/"+url.PathEscape(id)+"/tracks", params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, payload, err
|
||||||
|
}
|
||||||
|
lastPayload = payload
|
||||||
|
var page struct {
|
||||||
|
Items []struct {
|
||||||
|
Track TrackRef `json:"track"`
|
||||||
|
} `json:"items"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &page); err != nil {
|
||||||
|
return nil, payload, fmt.Errorf("decode playlist tracks: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range page.Items {
|
||||||
|
if item.Track.ID != "" && !item.Track.IsLocal {
|
||||||
|
refs = append(refs, item.Track)
|
||||||
|
if len(refs) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if page.Next == "" || len(page.Items) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return refs, lastPayload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetArtistTopTracks(ctx context.Context, id, market string) ([]Track, []byte, error) {
|
||||||
|
params := marketParams(marketOrDefault(market, c.defaultMarket))
|
||||||
|
if params.Get("market") == "" {
|
||||||
|
params.Set("market", "US")
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Tracks []Track `json:"tracks"`
|
||||||
|
}
|
||||||
|
payload, err := c.get(ctx, "/artists/"+url.PathEscape(id)+"/top-tracks", params, &out)
|
||||||
|
return out.Tracks, payload, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getPagedTrackRefs(ctx context.Context, path, listField, market string, limit int) ([]TrackRef, []byte, error) {
|
||||||
|
limit = normalizeCollectionLimit(limit)
|
||||||
|
refs := make([]TrackRef, 0, limit)
|
||||||
|
var lastPayload []byte
|
||||||
|
for offset := 0; len(refs) < limit; offset += 50 {
|
||||||
|
params := marketParams(marketOrDefault(market, c.defaultMarket))
|
||||||
|
params.Set("limit", strconv.Itoa(minInt(50, limit-len(refs))))
|
||||||
|
params.Set("offset", strconv.Itoa(offset))
|
||||||
|
payload, err := c.getRaw(ctx, path, params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, payload, err
|
||||||
|
}
|
||||||
|
lastPayload = payload
|
||||||
|
var page struct {
|
||||||
|
Items []TrackRef `json:"items"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &page); err != nil {
|
||||||
|
return nil, payload, fmt.Errorf("decode %s: %w", listField, err)
|
||||||
|
}
|
||||||
|
for _, item := range page.Items {
|
||||||
|
if item.ID != "" {
|
||||||
|
refs = append(refs, item)
|
||||||
|
if len(refs) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if page.Next == "" || len(page.Items) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return refs, lastPayload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) get(ctx context.Context, path string, params url.Values, out any) ([]byte, error) {
|
||||||
|
payload, err := c.getRaw(ctx, path, params)
|
||||||
|
if err != nil {
|
||||||
|
return payload, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, out); err != nil {
|
||||||
|
return payload, fmt.Errorf("decode spotify response: %w", err)
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getRaw(ctx context.Context, path string, params url.Values) ([]byte, error) {
|
||||||
|
if params == nil {
|
||||||
|
params = url.Values{}
|
||||||
|
}
|
||||||
|
endpoint := c.apiBaseURL + path
|
||||||
|
if encoded := params.Encode(); encoded != "" {
|
||||||
|
endpoint += "?" + encoded
|
||||||
|
}
|
||||||
|
return c.doJSON(ctx, http.MethodGet, endpoint, nil, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) accessToken(ctx context.Context) (string, error) {
|
||||||
|
if c.staticToken != "" {
|
||||||
|
return c.staticToken, nil
|
||||||
|
}
|
||||||
|
if c.clientID == "" || c.clientSecret == "" {
|
||||||
|
c.setLastError(ErrNotConfigured.Error())
|
||||||
|
return "", ErrNotConfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.token != "" && time.Now().Add(60*time.Second).Before(c.expiresAt) {
|
||||||
|
token := c.token
|
||||||
|
c.mu.Unlock()
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
body := strings.NewReader("grant_type=client_credentials")
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.accountsBaseURL+"/api/token", body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
credential := base64.StdEncoding.EncodeToString([]byte(c.clientID + ":" + c.clientSecret))
|
||||||
|
req.Header.Set("Authorization", "Basic "+credential)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.setLastError(err.Error())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
err := fmt.Errorf("spotify token request failed with status %d", resp.StatusCode)
|
||||||
|
c.setLastError(err.Error())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var decoded struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
|
||||||
|
c.setLastError(err.Error())
|
||||||
|
return "", fmt.Errorf("decode spotify token: %w", err)
|
||||||
|
}
|
||||||
|
if decoded.AccessToken == "" {
|
||||||
|
err := errors.New("spotify token response did not include an access token")
|
||||||
|
c.setLastError(err.Error())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
expiresIn := time.Duration(decoded.ExpiresIn) * time.Second
|
||||||
|
if expiresIn <= 0 {
|
||||||
|
expiresIn = time.Hour
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.token = decoded.AccessToken
|
||||||
|
c.expiresAt = time.Now().Add(expiresIn)
|
||||||
|
c.lastError = ""
|
||||||
|
c.mu.Unlock()
|
||||||
|
return decoded.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doJSON(ctx context.Context, method, endpoint string, body []byte, authenticate bool) ([]byte, error) {
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt <= c.maxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(time.Duration(attempt) * 250 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var reader io.Reader
|
||||||
|
if len(body) > 0 {
|
||||||
|
reader = bytes.NewReader(body)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if authenticate {
|
||||||
|
token, err := c.accessToken(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload, readErr := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||||
|
closeErr := resp.Body.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
return payload, readErr
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return payload, closeErr
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode <= 299 {
|
||||||
|
c.setLastError("")
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.token = ""
|
||||||
|
c.expiresAt = time.Time{}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
lastErr = spotifyHTTPError{StatusCode: resp.StatusCode, Body: string(payload)}
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests {
|
||||||
|
wait := retryAfter(resp.Header.Get("Retry-After"))
|
||||||
|
if wait > 0 && attempt < c.maxRetries {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return payload, ctx.Err()
|
||||||
|
case <-time.After(wait):
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 500 && resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusTooManyRequests {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = errors.New("spotify request failed")
|
||||||
|
}
|
||||||
|
c.setLastError(lastErr.Error())
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setLastError(message string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.lastError = message
|
||||||
|
}
|
||||||
|
|
||||||
|
type spotifyHTTPError struct {
|
||||||
|
StatusCode int
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e spotifyHTTPError) Error() string {
|
||||||
|
if e.Body == "" {
|
||||||
|
return fmt.Sprintf("spotify request failed with status %d", e.StatusCode)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("spotify request failed with status %d", e.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsNotFound(err error) bool {
|
||||||
|
var httpErr spotifyHTTPError
|
||||||
|
return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func retryAfter(value string) time.Duration {
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if seconds, err := strconv.Atoi(strings.TrimSpace(value)); err == nil {
|
||||||
|
return time.Duration(seconds) * time.Second
|
||||||
|
}
|
||||||
|
if when, err := http.ParseTime(value); err == nil {
|
||||||
|
return time.Until(when)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func marketParams(market string) url.Values {
|
||||||
|
params := url.Values{}
|
||||||
|
if market = strings.ToUpper(strings.TrimSpace(market)); market != "" {
|
||||||
|
params.Set("market", market)
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
func marketOrDefault(market, fallback string) string {
|
||||||
|
if market = strings.ToUpper(strings.TrimSpace(market)); market != "" {
|
||||||
|
return market
|
||||||
|
}
|
||||||
|
return strings.ToUpper(strings.TrimSpace(fallback))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCollectionLimit(limit int) int {
|
||||||
|
if limit <= 0 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
|
||||||
|
func minInt(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package spotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientCredentialsTokenIsCached(t *testing.T) {
|
||||||
|
var tokenRequests atomic.Int64
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/token":
|
||||||
|
tokenRequests.Add(1)
|
||||||
|
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Basic ") {
|
||||||
|
t.Fatalf("missing basic authorization header")
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "token-a", "expires_in": 3600, "token_type": "Bearer"})
|
||||||
|
case "/v1/tracks/abc":
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer token-a" {
|
||||||
|
t.Fatalf("got authorization %q", got)
|
||||||
|
}
|
||||||
|
writeTrack(w, "abc")
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := New(Config{
|
||||||
|
ClientID: "client-id",
|
||||||
|
ClientSecret: "client-secret",
|
||||||
|
AccountsBaseURL: server.URL,
|
||||||
|
APIBaseURL: server.URL + "/v1",
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if _, _, err := client.GetTrack(context.Background(), "abc", "US"); err != nil {
|
||||||
|
t.Fatalf("get track %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := tokenRequests.Load(); got != 1 {
|
||||||
|
t.Fatalf("token requests = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientRetriesRateLimitedRequest(t *testing.T) {
|
||||||
|
var calls atomic.Int64
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/v1/tracks/abc" {
|
||||||
|
if calls.Add(1) == 1 {
|
||||||
|
w.Header().Set("Retry-After", "0")
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeTrack(w, "abc")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := New(Config{BearerToken: "token", APIBaseURL: server.URL + "/v1", MaxRetries: 1})
|
||||||
|
if _, _, err := client.GetTrack(context.Background(), "abc", "US"); err != nil {
|
||||||
|
t.Fatalf("get track after retry: %v", err)
|
||||||
|
}
|
||||||
|
if got := calls.Load(); got != 2 {
|
||||||
|
t.Fatalf("calls = %d, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientReportsMalformedJSONAndContextCancellation(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := New(Config{BearerToken: "token", APIBaseURL: server.URL, MaxRetries: 0})
|
||||||
|
if _, _, err := client.GetTrack(context.Background(), "abc", "US"); err == nil {
|
||||||
|
t.Fatal("expected malformed JSON error")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if _, _, err := client.GetTrack(ctx, "abc", "US"); err == nil {
|
||||||
|
t.Fatal("expected context cancellation error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTrack(w http.ResponseWriter, id string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(Track{
|
||||||
|
ID: id,
|
||||||
|
Name: "Track",
|
||||||
|
Artists: []Artist{{Name: "Artist"}},
|
||||||
|
Album: Album{Name: "Album", ReleaseDate: "2024-01-01"},
|
||||||
|
DurationMS: int((3 * time.Minute).Milliseconds()),
|
||||||
|
Popularity: 77,
|
||||||
|
ExternalIDs: map[string]string{
|
||||||
|
"isrc": "USRC17607839",
|
||||||
|
},
|
||||||
|
ExternalURLs: map[string]string{
|
||||||
|
"spotify": "https://open.spotify.com/track/" + id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package spotify
|
||||||
|
|
||||||
|
type Track struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Artists []Artist `json:"artists"`
|
||||||
|
Album Album `json:"album"`
|
||||||
|
DurationMS int `json:"duration_ms"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
Explicit bool `json:"explicit"`
|
||||||
|
ExternalIDs map[string]string `json:"external_ids"`
|
||||||
|
ExternalURLs map[string]string `json:"external_urls"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
IsLocal bool `json:"is_local"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackRef struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
IsLocal bool `json:"is_local"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Artist struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Genres []string `json:"genres"`
|
||||||
|
ExternalURLs map[string]string `json:"external_urls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Album struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
Images []Image `json:"images"`
|
||||||
|
Artists []Artist `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Image struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioFeatures struct {
|
||||||
|
Danceability float64 `json:"danceability"`
|
||||||
|
Energy float64 `json:"energy"`
|
||||||
|
Loudness float64 `json:"loudness"`
|
||||||
|
Speechiness float64 `json:"speechiness"`
|
||||||
|
Acousticness float64 `json:"acousticness"`
|
||||||
|
Instrumentalness float64 `json:"instrumentalness"`
|
||||||
|
Liveness float64 `json:"liveness"`
|
||||||
|
Valence float64 `json:"valence"`
|
||||||
|
Tempo float64 `json:"tempo"`
|
||||||
|
TimeSignature float64 `json:"time_signature"`
|
||||||
|
Key float64 `json:"key"`
|
||||||
|
Mode float64 `json:"mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchResult struct {
|
||||||
|
Tracks struct {
|
||||||
|
Items []Track `json:"items"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
Albums struct {
|
||||||
|
Items []Album `json:"items"`
|
||||||
|
} `json:"albums"`
|
||||||
|
Artists struct {
|
||||||
|
Items []Artist `json:"items"`
|
||||||
|
} `json:"artists"`
|
||||||
|
Playlists struct {
|
||||||
|
Items []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"items"`
|
||||||
|
} `json:"playlists"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package spotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
uriPattern = regexp.MustCompile(`(?i)^spotify:(track|album|playlist|artist):([A-Za-z0-9]+)$`)
|
||||||
|
idPattern = regexp.MustCompile(`^[A-Za-z0-9]{10,}$`)
|
||||||
|
pathIDPattern = regexp.MustCompile(`^[A-Za-z0-9]+$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type ParsedSource struct {
|
||||||
|
Type string
|
||||||
|
ID string
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseSource(sourceType, value string) (ParsedSource, error) {
|
||||||
|
sourceType = strings.ToLower(strings.TrimSpace(sourceType))
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ParsedSource{}, errors.New("source value is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if sourceType == "" || sourceType == "url" {
|
||||||
|
parsed, err := ParseURL(value)
|
||||||
|
if err == nil {
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
if sourceType == "url" {
|
||||||
|
return ParsedSource{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sourceType == "" {
|
||||||
|
sourceType = "track"
|
||||||
|
}
|
||||||
|
if !validSpotifyType(sourceType) {
|
||||||
|
return ParsedSource{}, errors.New("source type must be track, album, playlist, artist, or url")
|
||||||
|
}
|
||||||
|
if parsed, err := ParseURL(value); err == nil {
|
||||||
|
if parsed.Type != sourceType {
|
||||||
|
return ParsedSource{}, errors.New("source URL type does not match requested type")
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
if !idPattern.MatchString(value) {
|
||||||
|
return ParsedSource{}, errors.New("source value must be a Spotify ID, URI, or open.spotify.com URL")
|
||||||
|
}
|
||||||
|
return ParsedSource{Type: sourceType, ID: value, URL: "https://open.spotify.com/" + sourceType + "/" + value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseURL(raw string) (ParsedSource, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ParsedSource{}, errors.New("url is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if match := uriPattern.FindStringSubmatch(raw); len(match) == 3 {
|
||||||
|
return ParsedSource{Type: strings.ToLower(match[1]), ID: match[2], URL: "https://open.spotify.com/" + strings.ToLower(match[1]) + "/" + match[2]}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedURL, err := parseURLWithDefaultScheme(raw)
|
||||||
|
if err == nil {
|
||||||
|
if value := parsedURL.Query().Get("uri"); value != "" {
|
||||||
|
return ParseURL(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
host := spotifyHost(parsedURL.Host)
|
||||||
|
switch host {
|
||||||
|
case "open.spotify.com", "play.spotify.com":
|
||||||
|
if parsed, ok := parseSpotifyPath(parsedURL.Path); ok {
|
||||||
|
parsed.URL = canonicalURL(parsed.Type, parsed.ID)
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
case "embed.spotify.com":
|
||||||
|
if parsed, ok := parseSpotifyPath(parsedURL.Path); ok {
|
||||||
|
parsed.URL = canonicalURL(parsed.Type, parsed.ID)
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParsedSource{}, errors.New("unsupported Spotify URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseURLWithDefaultScheme(raw string) (*url.URL, error) {
|
||||||
|
if strings.Contains(raw, "://") {
|
||||||
|
return url.Parse(raw)
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(raw)
|
||||||
|
if strings.HasPrefix(lower, "open.spotify.com/") ||
|
||||||
|
strings.HasPrefix(lower, "play.spotify.com/") ||
|
||||||
|
strings.HasPrefix(lower, "embed.spotify.com/") {
|
||||||
|
return url.Parse("https://" + raw)
|
||||||
|
}
|
||||||
|
return url.Parse(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func spotifyHost(host string) string {
|
||||||
|
host = strings.ToLower(strings.TrimSpace(host))
|
||||||
|
host = strings.TrimPrefix(host, "www.")
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSpotifyPath(path string) (ParsedSource, bool) {
|
||||||
|
parts := pathSegments(path)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ParsedSource{}, false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(strings.ToLower(parts[0]), "intl-") {
|
||||||
|
parts = parts[1:]
|
||||||
|
}
|
||||||
|
if len(parts) > 0 && strings.EqualFold(parts[0], "embed") {
|
||||||
|
parts = parts[1:]
|
||||||
|
}
|
||||||
|
if len(parts) >= 4 && strings.EqualFold(parts[0], "user") && strings.EqualFold(parts[2], "playlist") && pathIDPattern.MatchString(parts[3]) {
|
||||||
|
return ParsedSource{Type: "playlist", ID: parts[3]}, true
|
||||||
|
}
|
||||||
|
itemType := strings.ToLower(parts[0])
|
||||||
|
if len(parts) >= 2 && validSpotifyType(itemType) && pathIDPattern.MatchString(parts[1]) {
|
||||||
|
return ParsedSource{Type: itemType, ID: parts[1]}, true
|
||||||
|
}
|
||||||
|
return ParsedSource{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathSegments(path string) []string {
|
||||||
|
rawParts := strings.Split(path, "/")
|
||||||
|
parts := make([]string, 0, len(rawParts))
|
||||||
|
for _, part := range rawParts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "" {
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalURL(itemType, id string) string {
|
||||||
|
return "https://open.spotify.com/" + itemType + "/" + id
|
||||||
|
}
|
||||||
|
|
||||||
|
func validSpotifyType(value string) bool {
|
||||||
|
switch value {
|
||||||
|
case "track", "album", "playlist", "artist":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package spotify
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseSource(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sourceType string
|
||||||
|
value string
|
||||||
|
wantType string
|
||||||
|
wantID string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "track URL", sourceType: "url", value: "https://open.spotify.com/track/abc123XYZ?si=ignored", wantType: "track", wantID: "abc123XYZ"},
|
||||||
|
{name: "intl track URL", sourceType: "url", value: "https://open.spotify.com/intl-cs/track/7tFiyTwD0nx5a1eklYtX2J?si=ignored", wantType: "track", wantID: "7tFiyTwD0nx5a1eklYtX2J"},
|
||||||
|
{name: "embed URI URL", sourceType: "url", value: "https://embed.spotify.com/?uri=spotify:track:7tFiyTwD0nx5a1eklYtX2J", wantType: "track", wantID: "7tFiyTwD0nx5a1eklYtX2J"},
|
||||||
|
{name: "album URI", sourceType: "url", value: "spotify:album:album123456", wantType: "album", wantID: "album123456"},
|
||||||
|
{name: "album URL with inferred type", sourceType: "", value: "https://open.spotify.com/album/1GbtB4zTqAsyfZEsm1RZfx", wantType: "album", wantID: "1GbtB4zTqAsyfZEsm1RZfx"},
|
||||||
|
{name: "playlist URL", sourceType: "playlist", value: "https://open.spotify.com/playlist/pl123456", wantType: "playlist", wantID: "pl123456"},
|
||||||
|
{name: "legacy user playlist URL", sourceType: "url", value: "https://open.spotify.com/user/someone/playlist/pl123456", wantType: "playlist", wantID: "pl123456"},
|
||||||
|
{name: "artist ID", sourceType: "artist", value: "artist123456", wantType: "artist", wantID: "artist123456"},
|
||||||
|
{name: "invalid URL", sourceType: "url", value: "https://example.com/track/abc", wantErr: true},
|
||||||
|
{name: "type mismatch", sourceType: "track", value: "https://open.spotify.com/album/abc123456", wantErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseSource(tt.sourceType, tt.value)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse source: %v", err)
|
||||||
|
}
|
||||||
|
if got.Type != tt.wantType || got.ID != tt.wantID {
|
||||||
|
t.Fatalf("got type=%q id=%q, want type=%q id=%q", got.Type, got.ID, tt.wantType, tt.wantID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProviderSpotify = "spotify"
|
||||||
|
ProviderMusicBrainz = "musicbrainz"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Source struct {
|
||||||
|
Type string `json:"type" binding:"required"`
|
||||||
|
Value string `json:"value" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportRequest struct {
|
||||||
|
Source Source `json:"source" binding:"required"`
|
||||||
|
Market string `json:"market,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
EnrichMusicBrainz *bool `json:"enrich_musicbrainz,omitempty"`
|
||||||
|
Persist *bool `json:"persist,omitempty"`
|
||||||
|
AllowMissingFields bool `json:"allow_missing_features,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchRequest struct {
|
||||||
|
Query string `json:"query" binding:"required"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Market string `json:"market,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
Persist bool `json:"persist"`
|
||||||
|
EnrichMusicBrainz *bool `json:"enrich_musicbrainz,omitempty"`
|
||||||
|
AllowMissingFields bool `json:"allow_missing_features,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnrichRequest struct {
|
||||||
|
TrackIDs []string `json:"track_ids" binding:"required"`
|
||||||
|
Force bool `json:"force"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportResponse struct {
|
||||||
|
ImportID string `json:"import_id"`
|
||||||
|
ImportedTracks int `json:"imported_tracks"`
|
||||||
|
UpdatedTracks int `json:"updated_tracks"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchResponse struct {
|
||||||
|
Tracks []recommendation.Track `json:"tracks"`
|
||||||
|
Persisted int `json:"persisted"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnrichResponse struct {
|
||||||
|
Updated int `json:"updated"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusResponse struct {
|
||||||
|
Spotify ProviderStatus `json:"spotify"`
|
||||||
|
MusicBrainz ProviderStatus `json:"musicbrainz"`
|
||||||
|
Cache CacheStats `json:"cache"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProviderStatus struct {
|
||||||
|
Configured bool `json:"configured"`
|
||||||
|
TokenMode string `json:"token_mode,omitempty"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
CheckedAt time.Time `json:"checked_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CacheEntry struct {
|
||||||
|
Provider string
|
||||||
|
ItemType string
|
||||||
|
ItemID string
|
||||||
|
Market string
|
||||||
|
Payload []byte
|
||||||
|
FetchedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
LastError string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e CacheEntry) Fresh(now time.Time) bool {
|
||||||
|
return len(e.Payload) > 0 && now.Before(e.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CacheStats struct {
|
||||||
|
Entries int64 `json:"entries"`
|
||||||
|
FreshEntries int64 `json:"fresh_entries"`
|
||||||
|
StaleEntries int64 `json:"stale_entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportJob struct {
|
||||||
|
ID string
|
||||||
|
Provider string
|
||||||
|
SourceType string
|
||||||
|
SourceValue string
|
||||||
|
Market string
|
||||||
|
Status string
|
||||||
|
ImportedTracks int
|
||||||
|
UpdatedTracks int
|
||||||
|
Skipped int
|
||||||
|
Warnings []string
|
||||||
|
StartedAt time.Time
|
||||||
|
FinishedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackEnrichment struct {
|
||||||
|
TrackID string
|
||||||
|
Provider string
|
||||||
|
MusicBrainzRecordingID string
|
||||||
|
MusicBrainzArtistID string
|
||||||
|
ISRC string
|
||||||
|
Payload []byte
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store interface {
|
||||||
|
UpsertTrack(ctx context.Context, track recommendation.Track) error
|
||||||
|
UpsertTracks(ctx context.Context, tracks []recommendation.Track) error
|
||||||
|
GetTracksByIDs(ctx context.Context, ids []string) ([]recommendation.Track, error)
|
||||||
|
GetProviderCache(ctx context.Context, providerName, itemType, itemID, market string) (CacheEntry, bool, error)
|
||||||
|
UpsertProviderCache(ctx context.Context, entry CacheEntry) error
|
||||||
|
ProviderCacheStats(ctx context.Context) (CacheStats, error)
|
||||||
|
CreateImportJob(ctx context.Context, job ImportJob) error
|
||||||
|
FinishImportJob(ctx context.Context, job ImportJob) error
|
||||||
|
UpsertTrackEnrichment(ctx context.Context, enrichment TrackEnrichment) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package unlocker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotConfigured = errors.New("unlocker service not configured")
|
||||||
|
ErrTrackNotFound = errors.New("track not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client for the Python unlocker service (auth-free Spotify access)
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(baseURL string) *Client {
|
||||||
|
if baseURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
baseURL: baseURL,
|
||||||
|
client: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Configured() bool {
|
||||||
|
return c != nil && c.baseURL != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Artists []string `json:"artists"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
DurationMS int `json:"duration_ms"`
|
||||||
|
Explicit bool `json:"explicit"`
|
||||||
|
ExternalURLs map[string]string `json:"external_urls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportRequest struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportResponse struct {
|
||||||
|
Track *TrackResponse `json:"track"`
|
||||||
|
Links map[string]string `json:"links"`
|
||||||
|
Parsed *ParsedInfo `json:"parsed,omitempty"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedInfo struct {
|
||||||
|
Service string `json:"service"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinksResponse struct {
|
||||||
|
SpotifyID string `json:"spotify_id"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
Links map[string]LinkDetails `json:"links"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkDetails struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportFromURL imports a track from any streaming service URL (auth-free)
|
||||||
|
func (c *Client) ImportFromURL(ctx context.Context, url string) (*ImportResponse, error) {
|
||||||
|
if !c.Configured() {
|
||||||
|
return nil, ErrNotConfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
reqBody, _ := json.Marshal(ImportRequest{URL: url})
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/import", bytes.NewReader(reqBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unlocker request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("unlocker returned %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result ImportResponse
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse unlocker response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrack gets a track by Spotify ID (auth-free)
|
||||||
|
func (c *Client) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
|
||||||
|
if !c.Configured() {
|
||||||
|
return nil, ErrNotConfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/spotify/track/"+trackID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unlocker request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, ErrTrackNotFound
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("unlocker returned %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result TrackResponse
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse unlocker response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLinks gets cross-platform links for a Spotify track
|
||||||
|
func (c *Client) GetLinks(ctx context.Context, spotifyID string) (*LinksResponse, error) {
|
||||||
|
if !c.Configured() {
|
||||||
|
return nil, ErrNotConfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/links/"+spotifyID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unlocker request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("unlocker returned %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result LinksResponse
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse unlocker response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
// Package urlparser provides universal music URL parsing for multiple streaming services.
|
||||||
|
package urlparser
|
||||||
|
|
||||||
|
import (
|
||||||
|
neturl "net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service represents a music streaming service
|
||||||
|
type Service string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Spotify Service = "spotify"
|
||||||
|
Tidal Service = "tidal"
|
||||||
|
AppleMusic Service = "apple_music"
|
||||||
|
YouTube Service = "youtube"
|
||||||
|
YouTubeMusic Service = "youtube_music"
|
||||||
|
SoundCloud Service = "soundcloud"
|
||||||
|
Deezer Service = "deezer"
|
||||||
|
Bandcamp Service = "bandcamp"
|
||||||
|
MusicBrainz Service = "musicbrainz"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParsedURL represents a parsed music service URL
|
||||||
|
type ParsedURL struct {
|
||||||
|
Service Service
|
||||||
|
URL string
|
||||||
|
ItemType string
|
||||||
|
ID string
|
||||||
|
Metadata map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parser for music service URLs
|
||||||
|
type Parser struct {
|
||||||
|
patterns map[Service][]*regexp.Regexp
|
||||||
|
services []Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewParser creates a new URL parser
|
||||||
|
func NewParser() *Parser {
|
||||||
|
return &Parser{
|
||||||
|
services: []Service{
|
||||||
|
Spotify,
|
||||||
|
Tidal,
|
||||||
|
AppleMusic,
|
||||||
|
YouTubeMusic,
|
||||||
|
YouTube,
|
||||||
|
SoundCloud,
|
||||||
|
Deezer,
|
||||||
|
Bandcamp,
|
||||||
|
MusicBrainz,
|
||||||
|
},
|
||||||
|
patterns: map[Service][]*regexp.Regexp{
|
||||||
|
Spotify: {
|
||||||
|
regexp.MustCompile(`(?i)^spotify:(track|album|playlist|artist):([a-zA-Z0-9]+)$`),
|
||||||
|
regexp.MustCompile(`(?i)https?://open\.spotify\.com/(?:intl-[a-z]{2}/)?(?:embed/)?(track|album|playlist|artist)/([a-zA-Z0-9]+)`),
|
||||||
|
regexp.MustCompile(`(?i)https://spotify\.link/([a-zA-Z0-9]+)`),
|
||||||
|
},
|
||||||
|
Tidal: {
|
||||||
|
regexp.MustCompile(`(?i)https://tidal\.com/(?:browse/)?(track|album|playlist|artist)/(\d+)`),
|
||||||
|
regexp.MustCompile(`(?i)https://listen\.tidal\.com/(?:browse/)?(track|album|playlist|artist)/(\d+)`),
|
||||||
|
},
|
||||||
|
AppleMusic: {
|
||||||
|
regexp.MustCompile(`(?i)https://music\.apple\.com/([a-z]{2})/(song|album|playlist|artist)/(?:[^/]+/)?(\d+)`),
|
||||||
|
},
|
||||||
|
YouTubeMusic: {
|
||||||
|
regexp.MustCompile(`(?i)https://music\.youtube\.com/(watch|playlist|channel)\?([^#]+)`),
|
||||||
|
},
|
||||||
|
YouTube: {
|
||||||
|
regexp.MustCompile(`(?i)https://(?:www\.)?youtube\.com/watch\?v=([a-zA-Z0-9_-]+)`),
|
||||||
|
regexp.MustCompile(`(?i)https://youtu\.be/([a-zA-Z0-9_-]+)`),
|
||||||
|
regexp.MustCompile(`(?i)https://(?:www\.)?youtube\.com/playlist\?list=([a-zA-Z0-9_-]+)`),
|
||||||
|
},
|
||||||
|
SoundCloud: {
|
||||||
|
regexp.MustCompile(`(?i)https://soundcloud\.com/([^/]+)/sets/([^/?#]+)`),
|
||||||
|
regexp.MustCompile(`(?i)https://soundcloud\.com/([^/]+)/([^/]+)`),
|
||||||
|
},
|
||||||
|
Deezer: {
|
||||||
|
regexp.MustCompile(`(?i)https://www\.deezer\.com/(?:[a-z]{2}/)?(track|album|playlist|artist)/(\d+)`),
|
||||||
|
},
|
||||||
|
Bandcamp: {
|
||||||
|
regexp.MustCompile(`(?i)https://([a-zA-Z0-9-]+)\.bandcamp\.com/(track|album)/(.+)`),
|
||||||
|
},
|
||||||
|
MusicBrainz: {
|
||||||
|
regexp.MustCompile(`(?i)https://musicbrainz\.org/(recording|release|release-group|artist)/([a-f0-9-]+)`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseURL parses a music service URL and extracts service, type, and ID
|
||||||
|
func (p *Parser) ParseURL(url string) *ParsedURL {
|
||||||
|
url = strings.TrimSpace(url)
|
||||||
|
if url == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, service := range p.services {
|
||||||
|
patterns := p.patterns[service]
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
matches := pattern.FindStringSubmatch(url)
|
||||||
|
if matches != nil {
|
||||||
|
return p.extractServiceInfo(service, matches, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Parser) extractServiceInfo(service Service, matches []string, url string) *ParsedURL {
|
||||||
|
switch service {
|
||||||
|
case Spotify:
|
||||||
|
if len(matches) >= 3 {
|
||||||
|
return &ParsedURL{
|
||||||
|
Service: service,
|
||||||
|
URL: url,
|
||||||
|
ItemType: matches[1],
|
||||||
|
ID: matches[2],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(matches) == 2 {
|
||||||
|
return &ParsedURL{
|
||||||
|
Service: service,
|
||||||
|
URL: url,
|
||||||
|
ItemType: "short",
|
||||||
|
ID: matches[1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case Tidal:
|
||||||
|
if len(matches) >= 3 {
|
||||||
|
return &ParsedURL{
|
||||||
|
Service: service,
|
||||||
|
URL: url,
|
||||||
|
ItemType: matches[1],
|
||||||
|
ID: matches[2],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case AppleMusic:
|
||||||
|
if len(matches) >= 4 {
|
||||||
|
itemType := matches[2]
|
||||||
|
id := matches[3]
|
||||||
|
if parsed, err := neturl.Parse(url); err == nil && itemType == "album" {
|
||||||
|
if trackID := parsed.Query().Get("i"); trackID != "" {
|
||||||
|
itemType = "song"
|
||||||
|
id = trackID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &ParsedURL{
|
||||||
|
Service: service,
|
||||||
|
URL: url,
|
||||||
|
ItemType: itemType,
|
||||||
|
ID: id,
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"region": matches[1],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case YouTube, YouTubeMusic:
|
||||||
|
if parsed, err := neturl.Parse(url); err == nil {
|
||||||
|
if v := parsed.Query().Get("v"); v != "" {
|
||||||
|
return &ParsedURL{Service: service, URL: url, ItemType: "video", ID: v}
|
||||||
|
}
|
||||||
|
if list := parsed.Query().Get("list"); list != "" {
|
||||||
|
return &ParsedURL{Service: service, URL: url, ItemType: "playlist", ID: list}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &ParsedURL{
|
||||||
|
Service: service,
|
||||||
|
URL: url,
|
||||||
|
ItemType: "video",
|
||||||
|
ID: matches[1],
|
||||||
|
}
|
||||||
|
|
||||||
|
case SoundCloud:
|
||||||
|
if len(matches) >= 3 {
|
||||||
|
itemType := "track"
|
||||||
|
if strings.EqualFold(matches[1], "sets") || strings.Contains(strings.ToLower(url), "/sets/") {
|
||||||
|
itemType = "playlist"
|
||||||
|
}
|
||||||
|
return &ParsedURL{
|
||||||
|
Service: service,
|
||||||
|
URL: url,
|
||||||
|
ItemType: itemType,
|
||||||
|
ID: matches[1] + "/" + matches[2],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case Deezer:
|
||||||
|
if len(matches) >= 3 {
|
||||||
|
return &ParsedURL{
|
||||||
|
Service: service,
|
||||||
|
URL: url,
|
||||||
|
ItemType: matches[1],
|
||||||
|
ID: matches[2],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case Bandcamp:
|
||||||
|
if len(matches) >= 4 {
|
||||||
|
return &ParsedURL{
|
||||||
|
Service: service,
|
||||||
|
URL: url,
|
||||||
|
ItemType: matches[2],
|
||||||
|
ID: matches[1] + "/" + matches[3],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case MusicBrainz:
|
||||||
|
if len(matches) >= 3 {
|
||||||
|
return &ParsedURL{
|
||||||
|
Service: service,
|
||||||
|
URL: url,
|
||||||
|
ItemType: matches[1],
|
||||||
|
ID: matches[2],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServiceFromURL quickly identifies the service from a URL without full parsing
|
||||||
|
func (p *Parser) GetServiceFromURL(url string) Service {
|
||||||
|
urlLower := strings.ToLower(url)
|
||||||
|
|
||||||
|
if strings.Contains(urlLower, "spotify.com") || strings.Contains(urlLower, "spotify.link") {
|
||||||
|
return Spotify
|
||||||
|
}
|
||||||
|
if strings.Contains(urlLower, "tidal.com") || strings.Contains(urlLower, "listen.tidal.com") {
|
||||||
|
return Tidal
|
||||||
|
}
|
||||||
|
if strings.Contains(urlLower, "music.apple.com") {
|
||||||
|
return AppleMusic
|
||||||
|
}
|
||||||
|
if strings.Contains(urlLower, "music.youtube.com") {
|
||||||
|
return YouTubeMusic
|
||||||
|
}
|
||||||
|
if strings.Contains(urlLower, "youtube.com") || strings.Contains(urlLower, "youtu.be") {
|
||||||
|
return YouTube
|
||||||
|
}
|
||||||
|
if strings.Contains(urlLower, "soundcloud.com") {
|
||||||
|
return SoundCloud
|
||||||
|
}
|
||||||
|
if strings.Contains(urlLower, "deezer.com") {
|
||||||
|
return Deezer
|
||||||
|
}
|
||||||
|
if strings.Contains(urlLower, "bandcamp.com") {
|
||||||
|
return Bandcamp
|
||||||
|
}
|
||||||
|
if strings.Contains(urlLower, "musicbrainz.org") {
|
||||||
|
return MusicBrainz
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateURL checks if a URL is from a supported service
|
||||||
|
func (p *Parser) ValidateURL(url string) bool {
|
||||||
|
return p.ParseURL(url) != nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package urlparser
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseURLDetectsSupportedMusicLinks(t *testing.T) {
|
||||||
|
parser := NewParser()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
service Service
|
||||||
|
itemType string
|
||||||
|
id string
|
||||||
|
}{
|
||||||
|
{name: "spotify intl", url: "https://open.spotify.com/intl-us/track/7tFiyTwD0nx5a1eklYtX2J?si=x", service: Spotify, itemType: "track", id: "7tFiyTwD0nx5a1eklYtX2J"},
|
||||||
|
{name: "spotify uri", url: "spotify:album:1GbtB4zTqAsyfZEsm1RZfx", service: Spotify, itemType: "album", id: "1GbtB4zTqAsyfZEsm1RZfx"},
|
||||||
|
{name: "apple album track", url: "https://music.apple.com/us/album/example/1440857781?i=1440857782", service: AppleMusic, itemType: "song", id: "1440857782"},
|
||||||
|
{name: "youtube music video", url: "https://music.youtube.com/watch?v=abc_DEF-123&si=x", service: YouTubeMusic, itemType: "video", id: "abc_DEF-123"},
|
||||||
|
{name: "youtube playlist", url: "https://www.youtube.com/playlist?list=PL123", service: YouTube, itemType: "playlist", id: "PL123"},
|
||||||
|
{name: "soundcloud set", url: "https://soundcloud.com/artist/sets/mixtape", service: SoundCloud, itemType: "playlist", id: "artist/mixtape"},
|
||||||
|
{name: "tidal", url: "https://listen.tidal.com/browse/track/12345", service: Tidal, itemType: "track", id: "12345"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := parser.ParseURL(tt.url)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected parsed URL")
|
||||||
|
}
|
||||||
|
if got.Service != tt.service || got.ItemType != tt.itemType || got.ID != tt.id {
|
||||||
|
t.Fatalf("got service=%q type=%q id=%q, want service=%q type=%q id=%q", got.Service, got.ItemType, got.ID, tt.service, tt.itemType, tt.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,815 @@
|
|||||||
|
// Package webplayer provides a Go native Spotify Web Player client using TOTP authentication.
|
||||||
|
// This is a port of the Python implementation, allowing auth-free access to Spotify metadata.
|
||||||
|
package webplayer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/base32"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Hardcoded TOTP secret from Spotify Web Player (publicly known)
|
||||||
|
totpSecret = "GM3TMMJTGYZTQNZVGM4DINJZHA4TGOBYGMZTCMRTGEYDSMJRHE4TEOBUG4YTCMRUGQ4DQOJUGQYTAMRRGA2TCMJSHE3TCMBY"
|
||||||
|
totpVersion = 61
|
||||||
|
clientVersion = "1.2.40"
|
||||||
|
minRequestInterval = 100 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// GraphQL persisted query hashes
|
||||||
|
var graphqlHashes = map[string]string{
|
||||||
|
"getTrack": "612585ae06ba435ad26369870deaae23b5c8800a256cd8a57e08eddc25a37294",
|
||||||
|
"getAlbum": "b9bfabef66ed756e5e13f68a942deb60bd4125ec1f1be8cc42769dc0259b4b10",
|
||||||
|
"fetchPlaylist": "bb67e0af06e8d6f52b531f97468ee4acd44cd0f82b988e15c2ea47b1148efc77",
|
||||||
|
"getArtist": "2e7f695dd9c0a6591c2d4f3b9e6e0a7c8d5b4a3f2e1d0c9b8a7f6e5d4c3b2a1",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track represents Spotify track metadata
|
||||||
|
type Track struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Artists []Artist `json:"artists"`
|
||||||
|
Album Album `json:"album"`
|
||||||
|
DurationMs int `json:"duration_ms"`
|
||||||
|
Explicit bool `json:"explicit"`
|
||||||
|
ExternalURLs map[string]string `json:"external_urls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Artist represents a Spotify artist
|
||||||
|
type Artist struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
URI string `json:"uri"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Album represents a Spotify album
|
||||||
|
type Album struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
URI string `json:"uri"`
|
||||||
|
Images []Image `json:"images"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image represents an image asset
|
||||||
|
type Image struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// token holds the Spotify access token
|
||||||
|
type token struct {
|
||||||
|
AccessToken string
|
||||||
|
ClientID string
|
||||||
|
DeviceID string
|
||||||
|
ClientVersion string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
ClientToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is the Spotify Web Player API client
|
||||||
|
type Client struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
baseURL string
|
||||||
|
token *token
|
||||||
|
mu sync.RWMutex
|
||||||
|
lastRequest time.Time
|
||||||
|
cookies map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new Web Player client
|
||||||
|
func NewClient() *Client {
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
return &Client{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
Jar: jar,
|
||||||
|
},
|
||||||
|
baseURL: "https://open.spotify.com",
|
||||||
|
cookies: make(map[string]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured returns true if the client is functional (always true for this client)
|
||||||
|
func (c *Client) Configured() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTOTP generates a TOTP code using the hardcoded secret
|
||||||
|
func generateTOTP() string {
|
||||||
|
// Base32 decode the secret
|
||||||
|
secretBytes, _ := base32.StdEncoding.DecodeString(totpSecret)
|
||||||
|
|
||||||
|
// Get current time in 30-second intervals
|
||||||
|
currentTime := uint64(time.Now().Unix() / 30)
|
||||||
|
|
||||||
|
// Convert to bytes (big-endian, 8 bytes)
|
||||||
|
timeBytes := make([]byte, 8)
|
||||||
|
for i := 7; i >= 0; i-- {
|
||||||
|
timeBytes[i] = byte(currentTime & 0xFF)
|
||||||
|
currentTime >>= 8
|
||||||
|
}
|
||||||
|
|
||||||
|
// HMAC-SHA1
|
||||||
|
h := hmac.New(sha1.New, secretBytes)
|
||||||
|
h.Write(timeBytes)
|
||||||
|
hmacResult := h.Sum(nil)
|
||||||
|
|
||||||
|
// Dynamic truncation
|
||||||
|
offset := hmacResult[len(hmacResult)-1] & 0x0F
|
||||||
|
code := int(hmacResult[offset]&0x7F)<<24 |
|
||||||
|
int(hmacResult[offset+1]&0xFF)<<16 |
|
||||||
|
int(hmacResult[offset+2]&0xFF)<<8 |
|
||||||
|
int(hmacResult[offset+3]&0xFF)
|
||||||
|
|
||||||
|
// Get 6-digit code
|
||||||
|
totpCode := fmt.Sprintf("%06d", code%1000000)
|
||||||
|
|
||||||
|
return totpCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) rateLimit() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
elapsed := now.Sub(c.lastRequest)
|
||||||
|
if elapsed < minRequestInterval {
|
||||||
|
time.Sleep(minRequestInterval - elapsed)
|
||||||
|
}
|
||||||
|
c.lastRequest = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensureToken() error {
|
||||||
|
c.mu.RLock()
|
||||||
|
tok := c.token
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
if tok == nil || time.Now().After(tok.ExpiresAt.Add(-60*time.Second)) {
|
||||||
|
return c.getAccessToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
if tok.ClientToken == "" {
|
||||||
|
return c.getClientToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getAccessToken() error {
|
||||||
|
// Try TOTP generation first (same as official Web Player)
|
||||||
|
if err := c.getAccessTokenTOTP(); err == nil {
|
||||||
|
// Client token is optional
|
||||||
|
_ = c.getClientToken()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to tokener API
|
||||||
|
if err := c.getAccessTokenTokener(); err == nil {
|
||||||
|
// Client token is optional - try to get it but don't fail if unavailable
|
||||||
|
_ = c.getClientToken()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.New("failed to obtain access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getAccessTokenTOTP() error {
|
||||||
|
c.rateLimit()
|
||||||
|
|
||||||
|
totpCode := generateTOTP()
|
||||||
|
|
||||||
|
params := url.Values{
|
||||||
|
"reason": {"init"},
|
||||||
|
"productType": {"web-player"},
|
||||||
|
"totp": {totpCode},
|
||||||
|
"totpVer": {strconv.Itoa(totpVersion)},
|
||||||
|
"totpServer": {totpCode},
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenURL := fmt.Sprintf("%s/api/token?%s", c.baseURL, params.Encode())
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", tokenURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
|
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||||
|
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||||
|
req.Header.Set("Referer", "https://open.spotify.com/")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Read body for debugging - check content length first
|
||||||
|
var bodyBytes []byte
|
||||||
|
if resp.ContentLength > 0 {
|
||||||
|
bodyBytes = make([]byte, resp.ContentLength)
|
||||||
|
_, err = io.ReadFull(resp.Body, bodyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bodyBytes, err = io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("TOTP token request failed: HTTP %d, body: %s, content-length: %d", resp.StatusCode, string(bodyBytes), resp.ContentLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract cookies
|
||||||
|
for _, cookie := range resp.Cookies() {
|
||||||
|
c.cookies[cookie.Name] = cookie.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
var data struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
ClientID string `json:"clientId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(bodyBytes, &data); err != nil {
|
||||||
|
return fmt.Errorf("failed to decode JSON: %w, body: %s", err, string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceID := c.cookies["sp_t"]
|
||||||
|
if deviceID == "" {
|
||||||
|
deviceID = generateDeviceID()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.token = &token{
|
||||||
|
AccessToken: data.AccessToken,
|
||||||
|
ClientID: data.ClientID,
|
||||||
|
DeviceID: deviceID,
|
||||||
|
ClientVersion: clientVersion,
|
||||||
|
ExpiresAt: time.Now().Add(time.Hour),
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getAccessTokenTokener() error {
|
||||||
|
c.rateLimit()
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Get("https://spotify-tokener-api.vercel.app/api/getToken")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("tokener API failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
ClientID string `json:"clientId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.AccessToken == "" || data.ClientID == "" {
|
||||||
|
return errors.New("tokener API returned invalid data")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.token = &token{
|
||||||
|
AccessToken: data.AccessToken,
|
||||||
|
ClientID: data.ClientID,
|
||||||
|
DeviceID: generateDeviceID(),
|
||||||
|
ClientVersion: clientVersion,
|
||||||
|
ExpiresAt: time.Now().Add(time.Hour),
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getClientToken() error {
|
||||||
|
c.mu.RLock()
|
||||||
|
tok := c.token
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
if tok == nil {
|
||||||
|
return errors.New("no access token available")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.rateLimit()
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"client_data": map[string]interface{}{
|
||||||
|
"client_version": tok.ClientVersion,
|
||||||
|
"client_id": tok.ClientID,
|
||||||
|
"js_sdk_data": map[string]interface{}{
|
||||||
|
"device_brand": "unknown",
|
||||||
|
"device_model": "unknown",
|
||||||
|
"os": "windows",
|
||||||
|
"os_version": "NT 10.0",
|
||||||
|
"device_id": tok.DeviceID,
|
||||||
|
"device_type": "computer",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonPayload, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", "https://clienttoken.spotify.com/v1/clienttoken", bytes.NewReader(jsonPayload))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("client token request failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
var data struct {
|
||||||
|
ResponseType string `json:"response_type"`
|
||||||
|
GrantedToken struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
} `json:"granted_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.ResponseType != "RESPONSE_GRANTED_TOKEN_RESPONSE" {
|
||||||
|
return errors.New("invalid client token response type: " + data.ResponseType)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.token.ClientToken = data.GrantedToken.Token
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) graphqlQuery(operationName string, variables map[string]interface{}) (map[string]interface{}, error) {
|
||||||
|
if err := c.ensureToken(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, ok := graphqlHashes[operationName]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown GraphQL operation: %s", operationName)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.RLock()
|
||||||
|
tok := c.token
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
// Use struct with explicit field order to match Python's JSON key ordering
|
||||||
|
// The SHA256 hash is computed on the exact JSON string
|
||||||
|
payload := struct {
|
||||||
|
Variables map[string]interface{} `json:"variables"`
|
||||||
|
OperationName string `json:"operationName"`
|
||||||
|
Extensions struct {
|
||||||
|
PersistedQuery struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Sha256Hash string `json:"sha256Hash"`
|
||||||
|
} `json:"persistedQuery"`
|
||||||
|
} `json:"extensions"`
|
||||||
|
}{
|
||||||
|
Variables: variables,
|
||||||
|
OperationName: operationName,
|
||||||
|
}
|
||||||
|
payload.Extensions.PersistedQuery.Version = 1
|
||||||
|
payload.Extensions.PersistedQuery.Sha256Hash = hash
|
||||||
|
|
||||||
|
jsonPayload, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
c.rateLimit()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", "https://api-partner.spotify.com/pathfinder/v1/query", bytes.NewReader(jsonPayload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
||||||
|
if tok.ClientToken != "" {
|
||||||
|
req.Header.Set("Client-Token", tok.ClientToken)
|
||||||
|
}
|
||||||
|
req.Header.Set("Spotify-App-Version", tok.ClientVersion)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
// Token expired, refresh and retry
|
||||||
|
c.mu.Lock()
|
||||||
|
c.token = nil
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if err := c.ensureToken(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry request
|
||||||
|
c.mu.RLock()
|
||||||
|
tok = c.token
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
||||||
|
if tok.ClientToken != "" {
|
||||||
|
req.Header.Set("Client-Token", tok.ClientToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err = c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ = io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("GraphQL query failed: HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrack fetches track metadata by ID
|
||||||
|
func (c *Client) GetTrack(trackID string) (*Track, error) {
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"uri": fmt.Sprintf("spotify:track:%s", trackID),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := c.graphqlQuery("getTrack", variables)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
trackData, ok := getNestedMap(data, "data", "trackUnion")
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("track not found in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
if getString(trackData, "__typename") != "Track" {
|
||||||
|
return nil, errors.New("item is not a track")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract artists
|
||||||
|
var artists []Artist
|
||||||
|
if firstArtist, ok := getNestedMap(trackData, "firstArtist"); ok {
|
||||||
|
if profile, ok := getNestedMap(firstArtist, "profile"); ok {
|
||||||
|
artists = append(artists, Artist{
|
||||||
|
ID: getString(firstArtist, "id"),
|
||||||
|
Name: getString(profile, "name"),
|
||||||
|
URI: getString(firstArtist, "uri"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if otherArtists, ok := getNestedMap(trackData, "otherArtists"); ok {
|
||||||
|
if items, ok := otherArtists["items"].([]interface{}); ok {
|
||||||
|
for _, item := range items {
|
||||||
|
if artist, ok := item.(map[string]interface{}); ok {
|
||||||
|
if profile, ok := getNestedMap(artist, "profile"); ok {
|
||||||
|
artists = append(artists, Artist{
|
||||||
|
ID: getString(artist, "id"),
|
||||||
|
Name: getString(profile, "name"),
|
||||||
|
URI: getString(artist, "uri"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract album
|
||||||
|
var album Album
|
||||||
|
if albumData, ok := getNestedMap(trackData, "albumOfTrack"); ok {
|
||||||
|
album = Album{
|
||||||
|
ID: getString(albumData, "id"),
|
||||||
|
Name: getString(albumData, "name"),
|
||||||
|
URI: getString(albumData, "uri"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if visualIdentity, ok := getNestedMap(albumData, "visualIdentity"); ok {
|
||||||
|
if avatarImage, ok := getNestedMap(visualIdentity, "avatarImage"); ok {
|
||||||
|
if sources, ok := avatarImage["sources"].([]interface{}); ok && len(sources) > 0 {
|
||||||
|
if img, ok := sources[0].(map[string]interface{}); ok {
|
||||||
|
album.Images = append(album.Images, Image{
|
||||||
|
URL: getString(img, "url"),
|
||||||
|
Width: int(getFloat(img, "width")),
|
||||||
|
Height: int(getFloat(img, "height")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get duration
|
||||||
|
durationMs := 0
|
||||||
|
if duration, ok := getNestedMap(trackData, "duration"); ok {
|
||||||
|
durationMs = int(getFloat(duration, "totalMilliseconds"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check explicit
|
||||||
|
explicit := false
|
||||||
|
if contentRating, ok := getNestedMap(trackData, "contentRating"); ok {
|
||||||
|
explicit = getString(contentRating, "label") == "EXPLICIT"
|
||||||
|
}
|
||||||
|
|
||||||
|
track := &Track{
|
||||||
|
ID: getString(trackData, "id"),
|
||||||
|
Name: getString(trackData, "name"),
|
||||||
|
Artists: artists,
|
||||||
|
Album: album,
|
||||||
|
DurationMs: durationMs,
|
||||||
|
Explicit: explicit,
|
||||||
|
ExternalURLs: map[string]string{
|
||||||
|
"spotify": fmt.Sprintf("https://open.spotify.com/track/%s", trackID),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return track, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search searches for tracks (uses public search endpoint)
|
||||||
|
func (c *Client) Search(query string, limit int) ([]Track, error) {
|
||||||
|
if err := c.ensureToken(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.RLock()
|
||||||
|
tok := c.token
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
if limit > 50 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
params := url.Values{
|
||||||
|
"q": {query},
|
||||||
|
"type": {"track"},
|
||||||
|
"limit": {strconv.Itoa(limit)},
|
||||||
|
"market": {"US"},
|
||||||
|
}
|
||||||
|
|
||||||
|
searchURL := fmt.Sprintf("https://api.spotify.com/v1/search?%s", params.Encode())
|
||||||
|
|
||||||
|
c.rateLimit()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", searchURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data struct {
|
||||||
|
Tracks struct {
|
||||||
|
Items []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Artists []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"artists"`
|
||||||
|
Album struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images []struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
} `json:"images"`
|
||||||
|
} `json:"album"`
|
||||||
|
DurationMs int `json:"duration_ms"`
|
||||||
|
Explicit bool `json:"explicit"`
|
||||||
|
} `json:"items"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var tracks []Track
|
||||||
|
for _, item := range data.Tracks.Items {
|
||||||
|
var artists []Artist
|
||||||
|
for _, a := range item.Artists {
|
||||||
|
artists = append(artists, Artist{
|
||||||
|
ID: a.ID,
|
||||||
|
Name: a.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var images []Image
|
||||||
|
for _, img := range item.Album.Images {
|
||||||
|
images = append(images, Image{
|
||||||
|
URL: img.URL,
|
||||||
|
Width: img.Width,
|
||||||
|
Height: img.Height,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks = append(tracks, Track{
|
||||||
|
ID: item.ID,
|
||||||
|
Name: item.Name,
|
||||||
|
Artists: artists,
|
||||||
|
DurationMs: item.DurationMs,
|
||||||
|
Explicit: item.Explicit,
|
||||||
|
Album: Album{
|
||||||
|
ID: item.Album.ID,
|
||||||
|
Name: item.Album.Name,
|
||||||
|
Images: images,
|
||||||
|
},
|
||||||
|
ExternalURLs: map[string]string{
|
||||||
|
"spotify": fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
func generateDeviceID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNestedMap(m map[string]interface{}, keys ...string) (map[string]interface{}, bool) {
|
||||||
|
current := m
|
||||||
|
for _, key := range keys {
|
||||||
|
next, ok := current[key].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
return current, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func getString(m map[string]interface{}, key string) string {
|
||||||
|
if v, ok := m[key].(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFloat(m map[string]interface{}, key string) float64 {
|
||||||
|
switch v := m[key].(type) {
|
||||||
|
case float64:
|
||||||
|
return v
|
||||||
|
case float32:
|
||||||
|
return float64(v)
|
||||||
|
case int:
|
||||||
|
return float64(v)
|
||||||
|
case string:
|
||||||
|
f, _ := strconv.ParseFloat(v, 64)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL parsing helpers
|
||||||
|
|
||||||
|
var spotifyIDRegex = regexp.MustCompile(`^[A-Za-z0-9]{10,}$`)
|
||||||
|
|
||||||
|
// ParseSpotifyURL extracts the type and ID from a Spotify URL
|
||||||
|
func ParseSpotifyURL(urlStr string) (itemType, itemID string, err error) {
|
||||||
|
urlStr = strings.TrimSpace(urlStr)
|
||||||
|
if urlStr == "" {
|
||||||
|
return "", "", errors.New("invalid Spotify URL")
|
||||||
|
}
|
||||||
|
if matches := regexp.MustCompile(`(?i)^spotify:(track|album|playlist|artist):([A-Za-z0-9]+)$`).FindStringSubmatch(urlStr); len(matches) == 3 {
|
||||||
|
return strings.ToLower(matches[1]), matches[2], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, parseErr := parseSpotifyWebURL(urlStr)
|
||||||
|
if parseErr != nil {
|
||||||
|
return "", "", parseErr
|
||||||
|
}
|
||||||
|
return parsed.itemType, parsed.itemID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type parsedSpotifyWebURL struct {
|
||||||
|
itemType string
|
||||||
|
itemID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSpotifyWebURL(raw string) (parsedSpotifyWebURL, error) {
|
||||||
|
if !strings.Contains(raw, "://") {
|
||||||
|
lower := strings.ToLower(raw)
|
||||||
|
if strings.HasPrefix(lower, "open.spotify.com/") || strings.HasPrefix(lower, "play.spotify.com/") {
|
||||||
|
raw = "https://" + raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return parsedSpotifyWebURL{}, err
|
||||||
|
}
|
||||||
|
if value := u.Query().Get("uri"); value != "" {
|
||||||
|
itemType, itemID, err := ParseSpotifyURL(value)
|
||||||
|
if err != nil {
|
||||||
|
return parsedSpotifyWebURL{}, err
|
||||||
|
}
|
||||||
|
return parsedSpotifyWebURL{itemType: itemType, itemID: itemID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
host := strings.TrimPrefix(strings.ToLower(u.Host), "www.")
|
||||||
|
if host != "open.spotify.com" && host != "play.spotify.com" && host != "embed.spotify.com" {
|
||||||
|
return parsedSpotifyWebURL{}, errors.New("invalid Spotify URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := make([]string, 0, 4)
|
||||||
|
for _, part := range strings.Split(u.Path, "/") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "" {
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(parts) > 0 && strings.HasPrefix(strings.ToLower(parts[0]), "intl-") {
|
||||||
|
parts = parts[1:]
|
||||||
|
}
|
||||||
|
if len(parts) > 0 && strings.EqualFold(parts[0], "embed") {
|
||||||
|
parts = parts[1:]
|
||||||
|
}
|
||||||
|
if len(parts) >= 4 && strings.EqualFold(parts[0], "user") && strings.EqualFold(parts[2], "playlist") && spotifyIDRegex.MatchString(parts[3]) {
|
||||||
|
return parsedSpotifyWebURL{itemType: "playlist", itemID: parts[3]}, nil
|
||||||
|
}
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
itemType := strings.ToLower(parts[0])
|
||||||
|
switch itemType {
|
||||||
|
case "track", "album", "playlist", "artist":
|
||||||
|
if spotifyIDRegex.MatchString(parts[1]) {
|
||||||
|
return parsedSpotifyWebURL{itemType: itemType, itemID: parts[1]}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedSpotifyWebURL{}, errors.New("invalid Spotify URL")
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package webplayer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseSpotifyURLVariants(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
wantType string
|
||||||
|
wantID string
|
||||||
|
}{
|
||||||
|
{name: "open URL", url: "https://open.spotify.com/track/7tFiyTwD0nx5a1eklYtX2J?si=ignored", wantType: "track", wantID: "7tFiyTwD0nx5a1eklYtX2J"},
|
||||||
|
{name: "intl URL", url: "https://open.spotify.com/intl-cs/album/1GbtB4zTqAsyfZEsm1RZfx", wantType: "album", wantID: "1GbtB4zTqAsyfZEsm1RZfx"},
|
||||||
|
{name: "URI", url: "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M", wantType: "playlist", wantID: "37i9dQZF1DXcBWIGoYBM5M"},
|
||||||
|
{name: "embed URI", url: "https://embed.spotify.com/?uri=spotify:track:7tFiyTwD0nx5a1eklYtX2J", wantType: "track", wantID: "7tFiyTwD0nx5a1eklYtX2J"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
itemType, itemID, err := ParseSpotifyURL(tt.url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if itemType != tt.wantType || itemID != tt.wantID {
|
||||||
|
t.Fatalf("got type=%q id=%q, want type=%q id=%q", itemType, itemID, tt.wantType, tt.wantID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebPlayerIntegration tests against real Spotify endpoints
|
||||||
|
// Run with: go test -v -run TestWebPlayerIntegration ./... -tags=integration
|
||||||
|
// Or set WEBPLAYER_TEST=1 environment variable
|
||||||
|
func TestWebPlayerIntegration(t *testing.T) {
|
||||||
|
if os.Getenv("WEBPLAYER_TEST") == "" {
|
||||||
|
t.Skip("Skipping integration test. Set WEBPLAYER_TEST=1 to run")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewClient()
|
||||||
|
|
||||||
|
t.Run("GetTrack", func(t *testing.T) {
|
||||||
|
// Test with "Bohemian Rhapsody" - a well-known track
|
||||||
|
track, err := client.GetTrack("7tFiyTwD0nx5a1eklYtX2J")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTrack failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if track.ID == "" {
|
||||||
|
t.Error("track ID is empty")
|
||||||
|
}
|
||||||
|
if track.Name == "" {
|
||||||
|
t.Error("track name is empty")
|
||||||
|
}
|
||||||
|
if len(track.Artists) == 0 {
|
||||||
|
t.Error("no artists found")
|
||||||
|
}
|
||||||
|
if track.Album.Name == "" {
|
||||||
|
t.Error("album name is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Got track: %s by %s (%d artists) from album %s, duration=%dms",
|
||||||
|
track.Name,
|
||||||
|
track.Artists[0].Name,
|
||||||
|
len(track.Artists),
|
||||||
|
track.Album.Name,
|
||||||
|
track.DurationMs,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Search", func(t *testing.T) {
|
||||||
|
tracks, err := client.Search("Bohemian Rhapsody Queen", 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tracks) == 0 {
|
||||||
|
t.Error("no tracks found in search results")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, track := range tracks {
|
||||||
|
t.Logf("Result %d: %s by %s", i+1, track.Name, track.Artists[0].Name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseSpotifyURL", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
url string
|
||||||
|
wantType string
|
||||||
|
wantID string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
url: "https://open.spotify.com/track/7tFiyTwD0nx5a1eklYtX2J",
|
||||||
|
wantType: "track",
|
||||||
|
wantID: "7tFiyTwD0nx5a1eklYtX2J",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://open.spotify.com/album/1GbtB4zTqAsyfZEsm1RZfx",
|
||||||
|
wantType: "album",
|
||||||
|
wantID: "1GbtB4zTqAsyfZEsm1RZfx",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
itemType, itemID, err := ParseSpotifyURL(tt.url)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseSpotifyURL(%q) error: %v", tt.url, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if itemType != tt.wantType {
|
||||||
|
t.Errorf("ParseSpotifyURL(%q) type = %q, want %q", tt.url, itemType, tt.wantType)
|
||||||
|
}
|
||||||
|
if itemID != tt.wantID {
|
||||||
|
t.Errorf("ParseSpotifyURL(%q) ID = %q, want %q", tt.url, itemID, tt.wantID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTOTPGeneration verifies TOTP generation produces valid codes
|
||||||
|
func TestTOTPGeneration(t *testing.T) {
|
||||||
|
totp := generateTOTP()
|
||||||
|
|
||||||
|
// TOTP should be 6 digits
|
||||||
|
if len(totp) != 6 {
|
||||||
|
t.Errorf("TOTP length = %d, want 6", len(totp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should only contain digits
|
||||||
|
for _, c := range totp {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
t.Errorf("TOTP contains non-digit character: %c", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Generated TOTP: %s", totp)
|
||||||
|
}
|
||||||
@@ -0,0 +1,732 @@
|
|||||||
|
package recommendation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SnapshotProvider interface {
|
||||||
|
Snapshot(ctx context.Context, userID string) (CatalogSnapshot, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Engine struct {
|
||||||
|
now func() time.Time
|
||||||
|
contentWeight float64
|
||||||
|
collabWeight float64
|
||||||
|
popularityWeight float64
|
||||||
|
explorationWeight float64
|
||||||
|
diversityLambda float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type EngineConfig struct {
|
||||||
|
Now func() time.Time
|
||||||
|
ContentWeight float64
|
||||||
|
CollabWeight float64
|
||||||
|
PopularityWeight float64
|
||||||
|
ExplorationWeight float64
|
||||||
|
DiversityLambda float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEngine(cfg EngineConfig) *Engine {
|
||||||
|
if cfg.Now == nil {
|
||||||
|
cfg.Now = time.Now
|
||||||
|
}
|
||||||
|
return &Engine{
|
||||||
|
now: cfg.Now,
|
||||||
|
contentWeight: cfg.ContentWeight,
|
||||||
|
collabWeight: cfg.CollabWeight,
|
||||||
|
popularityWeight: cfg.PopularityWeight,
|
||||||
|
explorationWeight: cfg.ExplorationWeight,
|
||||||
|
diversityLambda: cfg.DiversityLambda,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Recommend(ctx context.Context, provider SnapshotProvider, req RecommendRequest) ([]Recommendation, TasteProfile, error) {
|
||||||
|
if strings.TrimSpace(req.UserID) == "" {
|
||||||
|
return nil, TasteProfile{}, errors.New("user_id is required")
|
||||||
|
}
|
||||||
|
if req.Limit <= 0 {
|
||||||
|
req.Limit = 20
|
||||||
|
}
|
||||||
|
if req.Limit > 100 {
|
||||||
|
req.Limit = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, err := provider.Snapshot(ctx, req.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, TasteProfile{}, err
|
||||||
|
}
|
||||||
|
if len(snapshot.Tracks) == 0 {
|
||||||
|
return nil, TasteProfile{}, errors.New("catalog is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
b := bounds(snapshot.Tracks)
|
||||||
|
byTrackID := indexTracks(snapshot.Tracks)
|
||||||
|
userInteractions := interactionsForUser(snapshot.Interactions, req.UserID)
|
||||||
|
tasteVector, positiveIDs, confidence, hasTasteAudio := e.tasteVector(snapshot.Tracks, byTrackID, userInteractions, req, b)
|
||||||
|
preferences := e.preferenceProfile(byTrackID, userInteractions, req)
|
||||||
|
neighborScores := e.collaborativeScores(snapshot.Interactions, req.UserID)
|
||||||
|
controls := mergeControls(snapshot.Controls, req)
|
||||||
|
candidates := make([]Recommendation, 0, len(snapshot.Tracks))
|
||||||
|
|
||||||
|
for _, track := range snapshot.Tracks {
|
||||||
|
if shouldFilter(track, positiveIDs, controls, req) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
trackVector := normalize(track.Features, b)
|
||||||
|
trackHasAudio := hasAudioFeatures(track.Features)
|
||||||
|
metadataScore := metadataAffinity(track, preferences)
|
||||||
|
contentScore := metadataScore
|
||||||
|
if hasTasteAudio && trackHasAudio {
|
||||||
|
contentScore = clamp01(cosine(tasteVector, trackVector)*0.78 + metadataScore*0.22)
|
||||||
|
}
|
||||||
|
|
||||||
|
collabScore := clamp01(neighborScores[track.ID])
|
||||||
|
popularityScore := popularityFit(track.Popularity, req.Mode)
|
||||||
|
explorationScore := 0.5
|
||||||
|
if hasTasteAudio && trackHasAudio {
|
||||||
|
explorationScore = e.explorationScore(track, trackVector, tasteVector, req)
|
||||||
|
}
|
||||||
|
safetyScore := safetyScore(track, controls) * (1 - 0.52*negativeAffinity(track, preferences))
|
||||||
|
commercialScore := commercialScore(track, contentScore)
|
||||||
|
|
||||||
|
final := 0.0
|
||||||
|
final += e.contentWeight * contentScore
|
||||||
|
final += e.collabWeight * collabScore
|
||||||
|
final += e.popularityWeight * popularityScore
|
||||||
|
final += e.explorationWeight * explorationScore
|
||||||
|
final *= safetyScore
|
||||||
|
final += commercialScore
|
||||||
|
|
||||||
|
candidates = append(candidates, Recommendation{
|
||||||
|
Track: track,
|
||||||
|
Score: final,
|
||||||
|
Reason: reason(contentScore, collabScore, explorationScore, metadataScore, hasTasteAudio && trackHasAudio),
|
||||||
|
ScoreBreakdown: ScoreBreakdown{
|
||||||
|
Content: round(contentScore),
|
||||||
|
Collaborative: round(collabScore),
|
||||||
|
Popularity: round(popularityScore),
|
||||||
|
Exploration: round(explorationScore),
|
||||||
|
Safety: round(safetyScore),
|
||||||
|
Commercial: round(commercialScore),
|
||||||
|
Final: round(final),
|
||||||
|
},
|
||||||
|
Explanation: featureExplanation(tasteVector, trackVector),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.SortFunc(candidates, func(a, b Recommendation) int {
|
||||||
|
return cmp.Compare(b.Score, a.Score)
|
||||||
|
})
|
||||||
|
|
||||||
|
selected := e.diversify(candidates, req.Limit, b)
|
||||||
|
for i := range selected {
|
||||||
|
selected[i].Rank = i + 1
|
||||||
|
selected[i].Score = round(selected[i].Score)
|
||||||
|
selected[i].ScoreBreakdown.Final = selected[i].Score
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := TasteProfile{
|
||||||
|
UserID: req.UserID,
|
||||||
|
Vector: arrayToSlice(tasteVector),
|
||||||
|
TopGenres: topGenres(snapshot.Tracks, byTrackID, userInteractions),
|
||||||
|
InteractionCount: len(userInteractions),
|
||||||
|
Confidence: round(confidence),
|
||||||
|
ExplorationReadiness: round(explorationReadiness(confidence, userInteractions)),
|
||||||
|
UpdatedAt: e.now().UTC(),
|
||||||
|
}
|
||||||
|
return selected, profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) TasteProfile(ctx context.Context, provider SnapshotProvider, userID string) (TasteProfile, error) {
|
||||||
|
recs, profile, err := e.Recommend(ctx, provider, RecommendRequest{UserID: userID, Limit: 1})
|
||||||
|
if err != nil {
|
||||||
|
return TasteProfile{}, err
|
||||||
|
}
|
||||||
|
_ = recs
|
||||||
|
return profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) tasteVector(tracks []Track, byTrackID map[string]Track, interactions []Interaction, req RecommendRequest, b featureBounds) ([featureCount]float64, map[string]struct{}, float64, bool) {
|
||||||
|
var sum [featureCount]float64
|
||||||
|
var total float64
|
||||||
|
var audioTotal float64
|
||||||
|
positive := make(map[string]struct{})
|
||||||
|
|
||||||
|
for _, seedID := range req.SeedTrackIDs {
|
||||||
|
track, ok := byTrackID[seedID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
positive[seedID] = struct{}{}
|
||||||
|
if !hasAudioFeatures(track.Features) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addWeighted(&sum, normalize(track.Features, b), 1.25)
|
||||||
|
total += 1.25
|
||||||
|
audioTotal += 1.25
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, interaction := range interactions {
|
||||||
|
track, ok := byTrackID[interaction.TrackID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
weight := interactionWeight(interaction)
|
||||||
|
if weight > 0 {
|
||||||
|
positive[interaction.TrackID] = struct{}{}
|
||||||
|
}
|
||||||
|
if !hasAudioFeatures(track.Features) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
decay := timeDecay(e.now(), interaction.OccurredAt)
|
||||||
|
addWeighted(&sum, normalize(track.Features, b), weight*decay)
|
||||||
|
total += math.Abs(weight * decay)
|
||||||
|
audioTotal += math.Abs(weight * decay)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.FeatureTargets != nil {
|
||||||
|
addWeighted(&sum, normalize(*req.FeatureTargets, b), 1.15)
|
||||||
|
total += 1.15
|
||||||
|
audioTotal += 1.15
|
||||||
|
}
|
||||||
|
|
||||||
|
if total == 0 {
|
||||||
|
return catalogCentroid(tracks, b), positive, 0, false
|
||||||
|
}
|
||||||
|
for i := range featureCount {
|
||||||
|
sum[i] = clamp01(sum[i] / total)
|
||||||
|
}
|
||||||
|
confidence := clamp01(math.Log1p(total) / math.Log(32))
|
||||||
|
return sum, positive, confidence, audioTotal > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) collaborativeScores(interactions []Interaction, activeUserID string) map[string]float64 {
|
||||||
|
userRatings := make(map[string]map[string]float64)
|
||||||
|
for _, interaction := range interactions {
|
||||||
|
if userRatings[interaction.UserID] == nil {
|
||||||
|
userRatings[interaction.UserID] = make(map[string]float64)
|
||||||
|
}
|
||||||
|
userRatings[interaction.UserID][interaction.TrackID] += interactionWeight(interaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
active := userRatings[activeUserID]
|
||||||
|
scores := make(map[string]float64)
|
||||||
|
if len(active) == 0 {
|
||||||
|
return scores
|
||||||
|
}
|
||||||
|
|
||||||
|
for userID, ratings := range userRatings {
|
||||||
|
if userID == activeUserID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
similarity, overlap := pearson(active, ratings)
|
||||||
|
if similarity <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
similarity *= float64(overlap) / float64(overlap+3)
|
||||||
|
for trackID, rating := range ratings {
|
||||||
|
if _, alreadyKnown := active[trackID]; alreadyKnown || rating <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scores[trackID] += similarity * rating
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
maxScore := 0.0
|
||||||
|
for _, score := range scores {
|
||||||
|
if score > maxScore {
|
||||||
|
maxScore = score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxScore == 0 {
|
||||||
|
return scores
|
||||||
|
}
|
||||||
|
for trackID, score := range scores {
|
||||||
|
scores[trackID] = clamp01(score / maxScore)
|
||||||
|
}
|
||||||
|
return scores
|
||||||
|
}
|
||||||
|
|
||||||
|
type preferenceProfile struct {
|
||||||
|
artists map[string]float64
|
||||||
|
genres map[string]float64
|
||||||
|
negativeArtists map[string]float64
|
||||||
|
negativeGenres map[string]float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) preferenceProfile(byTrackID map[string]Track, interactions []Interaction, req RecommendRequest) preferenceProfile {
|
||||||
|
profile := preferenceProfile{
|
||||||
|
artists: make(map[string]float64),
|
||||||
|
genres: make(map[string]float64),
|
||||||
|
negativeArtists: make(map[string]float64),
|
||||||
|
negativeGenres: make(map[string]float64),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, seedID := range req.SeedTrackIDs {
|
||||||
|
if track, ok := byTrackID[seedID]; ok {
|
||||||
|
addTrackPreference(profile.artists, profile.genres, track, 1.25)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, interaction := range interactions {
|
||||||
|
track, ok := byTrackID[interaction.TrackID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
weight := interactionWeight(interaction) * timeDecay(e.now(), interaction.OccurredAt)
|
||||||
|
switch {
|
||||||
|
case weight > 0:
|
||||||
|
addTrackPreference(profile.artists, profile.genres, track, weight)
|
||||||
|
case weight < 0:
|
||||||
|
addTrackPreference(profile.negativeArtists, profile.negativeGenres, track, math.Abs(weight))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeMap(profile.artists)
|
||||||
|
normalizeMap(profile.genres)
|
||||||
|
normalizeMap(profile.negativeArtists)
|
||||||
|
normalizeMap(profile.negativeGenres)
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
func addTrackPreference(artists, genres map[string]float64, track Track, weight float64) {
|
||||||
|
if artist := normalizedToken(track.Artist); artist != "" {
|
||||||
|
artists[artist] += weight
|
||||||
|
}
|
||||||
|
for _, genre := range track.Genres {
|
||||||
|
if genre = normalizedToken(genre); genre != "" {
|
||||||
|
genres[genre] += weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMap(values map[string]float64) {
|
||||||
|
maxValue := 0.0
|
||||||
|
for _, value := range values {
|
||||||
|
maxValue = math.Max(maxValue, value)
|
||||||
|
}
|
||||||
|
if maxValue == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for key, value := range values {
|
||||||
|
values[key] = clamp01(value / maxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func metadataAffinity(track Track, profile preferenceProfile) float64 {
|
||||||
|
artistScore := profile.artists[normalizedToken(track.Artist)]
|
||||||
|
genreScore := genreAffinity(track.Genres, profile.genres)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case artistScore == 0 && genreScore == 0:
|
||||||
|
return 0.42
|
||||||
|
case artistScore == 0:
|
||||||
|
return clamp01(0.32 + 0.68*genreScore)
|
||||||
|
case genreScore == 0:
|
||||||
|
return clamp01(0.38 + 0.62*artistScore)
|
||||||
|
default:
|
||||||
|
return clamp01(0.48*artistScore + 0.52*genreScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func negativeAffinity(track Track, profile preferenceProfile) float64 {
|
||||||
|
artistScore := profile.negativeArtists[normalizedToken(track.Artist)]
|
||||||
|
genreScore := genreAffinity(track.Genres, profile.negativeGenres)
|
||||||
|
return clamp01(math.Max(artistScore*0.9, genreScore*0.7))
|
||||||
|
}
|
||||||
|
|
||||||
|
func genreAffinity(genres []string, profile map[string]float64) float64 {
|
||||||
|
best := 0.0
|
||||||
|
for _, genre := range genres {
|
||||||
|
best = math.Max(best, profile[normalizedToken(genre)])
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedToken(value string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func popularityFit(popularity float64, mode string) float64 {
|
||||||
|
popularity = clamp01(popularity)
|
||||||
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||||
|
case "comfort":
|
||||||
|
return clamp01(0.35 + 0.65*popularity)
|
||||||
|
case "discovery":
|
||||||
|
return clamp01(1 - math.Abs(popularity-0.52)*1.25)
|
||||||
|
default:
|
||||||
|
familiarity := popularity
|
||||||
|
midTail := clamp01(1 - math.Abs(popularity-0.62)*1.15)
|
||||||
|
return clamp01(0.55*familiarity + 0.45*midTail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) explorationScore(track Track, trackVector, tasteVector [featureCount]float64, req RecommendRequest) float64 {
|
||||||
|
target := req.ExplorationTarget
|
||||||
|
if target == 0 {
|
||||||
|
target = 0.22
|
||||||
|
}
|
||||||
|
if strings.EqualFold(req.Mode, "discovery") {
|
||||||
|
target = math.Max(target, 0.34)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(req.Mode, "comfort") {
|
||||||
|
target = math.Min(target, 0.10)
|
||||||
|
}
|
||||||
|
|
||||||
|
d := distance(trackVector, tasteVector)
|
||||||
|
return clamp01(1 - math.Abs(d-target))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) diversify(candidates []Recommendation, limit int, b featureBounds) []Recommendation {
|
||||||
|
if len(candidates) <= limit {
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := make([]Recommendation, 0, limit)
|
||||||
|
remaining := slices.Clone(candidates)
|
||||||
|
for len(selected) < limit && len(remaining) > 0 {
|
||||||
|
bestIndex := 0
|
||||||
|
bestScore := math.Inf(-1)
|
||||||
|
for i, candidate := range remaining {
|
||||||
|
diversity := minDistanceToSelected(candidate.Track, selected, b)
|
||||||
|
score := e.diversityLambda*candidate.Score + (1-e.diversityLambda)*diversity
|
||||||
|
if score > bestScore {
|
||||||
|
bestScore = score
|
||||||
|
bestIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chosen := remaining[bestIndex]
|
||||||
|
chosen.ScoreBreakdown.Diversity = round(minDistanceToSelected(chosen.Track, selected, b))
|
||||||
|
selected = append(selected, chosen)
|
||||||
|
remaining = append(remaining[:bestIndex], remaining[bestIndex+1:]...)
|
||||||
|
}
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexTracks(tracks []Track) map[string]Track {
|
||||||
|
out := make(map[string]Track, len(tracks))
|
||||||
|
for _, track := range tracks {
|
||||||
|
out[track.ID] = track
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func interactionsForUser(interactions []Interaction, userID string) []Interaction {
|
||||||
|
out := make([]Interaction, 0)
|
||||||
|
for _, interaction := range interactions {
|
||||||
|
if interaction.UserID == userID {
|
||||||
|
out = append(out, interaction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func interactionWeight(interaction Interaction) float64 {
|
||||||
|
if interaction.Weight != 0 {
|
||||||
|
return interaction.Weight
|
||||||
|
}
|
||||||
|
switch interaction.Type {
|
||||||
|
case InteractionLike:
|
||||||
|
return 1
|
||||||
|
case InteractionSave:
|
||||||
|
return 0.9
|
||||||
|
case InteractionPlay:
|
||||||
|
if interaction.CompletedMS > 30_000 {
|
||||||
|
return 0.45
|
||||||
|
}
|
||||||
|
return 0.20
|
||||||
|
case InteractionSkip:
|
||||||
|
return -0.55
|
||||||
|
case InteractionDislike:
|
||||||
|
return -1
|
||||||
|
case InteractionHide:
|
||||||
|
return -1.25
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeDecay(now, occurredAt time.Time) float64 {
|
||||||
|
if occurredAt.IsZero() {
|
||||||
|
return 0.7
|
||||||
|
}
|
||||||
|
days := now.Sub(occurredAt).Hours() / 24
|
||||||
|
if days <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return math.Exp(-days / 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addWeighted(sum *[featureCount]float64, value [featureCount]float64, weight float64) {
|
||||||
|
for i := range featureCount {
|
||||||
|
sum[i] += value[i] * weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogCentroid(tracks []Track, b featureBounds) [featureCount]float64 {
|
||||||
|
var sum [featureCount]float64
|
||||||
|
count := 0
|
||||||
|
for _, track := range tracks {
|
||||||
|
if !hasAudioFeatures(track.Features) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addWeighted(&sum, normalize(track.Features, b), 1)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
for i := range featureCount {
|
||||||
|
sum[i] /= float64(count)
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
|
func pearson(a, b map[string]float64) (float64, int) {
|
||||||
|
common := make([]string, 0)
|
||||||
|
for trackID := range a {
|
||||||
|
if _, ok := b[trackID]; ok {
|
||||||
|
common = append(common, trackID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(common) < 2 {
|
||||||
|
return 0, len(common)
|
||||||
|
}
|
||||||
|
|
||||||
|
var meanA, meanB float64
|
||||||
|
for _, trackID := range common {
|
||||||
|
meanA += a[trackID]
|
||||||
|
meanB += b[trackID]
|
||||||
|
}
|
||||||
|
meanA /= float64(len(common))
|
||||||
|
meanB /= float64(len(common))
|
||||||
|
|
||||||
|
var numerator, denomA, denomB float64
|
||||||
|
for _, trackID := range common {
|
||||||
|
da := a[trackID] - meanA
|
||||||
|
db := b[trackID] - meanB
|
||||||
|
numerator += da * db
|
||||||
|
denomA += da * da
|
||||||
|
denomB += db * db
|
||||||
|
}
|
||||||
|
if denomA == 0 || denomB == 0 {
|
||||||
|
return 0, len(common)
|
||||||
|
}
|
||||||
|
return numerator / (math.Sqrt(denomA) * math.Sqrt(denomB)), len(common)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldFilter(track Track, positive map[string]struct{}, controls UserControls, req RecommendRequest) bool {
|
||||||
|
if _, known := positive[track.ID]; known {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if req.MinPopularity != nil && track.Popularity < *req.MinPopularity {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if req.MaxPopularity != nil && track.Popularity > *req.MaxPopularity {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
includeExplicit := controls.AllowExplicit
|
||||||
|
if req.IncludeExplicit != nil {
|
||||||
|
includeExplicit = *req.IncludeExplicit
|
||||||
|
}
|
||||||
|
if track.Explicit && !includeExplicit {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if contains(controls.ExcludedTracks, track.ID) || contains(controls.PostponedTracks, track.ID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if contains(controls.ExcludedArtists, track.Artist) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, genre := range track.Genres {
|
||||||
|
if containsFold(controls.ExcludedGenres, genre) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeControls(controls UserControls, req RecommendRequest) UserControls {
|
||||||
|
if controls.UserID == "" {
|
||||||
|
controls.UserID = req.UserID
|
||||||
|
controls.AllowExplicit = true
|
||||||
|
}
|
||||||
|
controls.ExcludedTracks = append(controls.ExcludedTracks, req.ExcludedTrackIDs...)
|
||||||
|
controls.ExcludedArtists = append(controls.ExcludedArtists, req.ExcludedArtistIDs...)
|
||||||
|
controls.ExcludedGenres = append(controls.ExcludedGenres, req.ExcludedGenres...)
|
||||||
|
return controls
|
||||||
|
}
|
||||||
|
|
||||||
|
func safetyScore(track Track, controls UserControls) float64 {
|
||||||
|
if track.QualityPenalty > 0 {
|
||||||
|
return clamp01(1 - track.QualityPenalty)
|
||||||
|
}
|
||||||
|
if !controls.AllowExplicit && track.Explicit {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func commercialScore(track Track, contentScore float64) float64 {
|
||||||
|
if !track.DiscoveryAllowed || track.CommercialBoost <= 0 || contentScore < 0.72 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return math.Min(track.CommercialBoost, 0.035)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reason(contentScore, collabScore, explorationScore, metadataScore float64, hasAudioFeatures bool) string {
|
||||||
|
if !hasAudioFeatures && metadataScore >= 0.65 {
|
||||||
|
return "matched by genre, artist, and catalog signals while audio features were limited"
|
||||||
|
}
|
||||||
|
if !hasAudioFeatures {
|
||||||
|
return "balanced catalog match while audio features were limited"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case collabScore >= 0.65:
|
||||||
|
return "listeners with overlapping taste responded strongly to this track"
|
||||||
|
case explorationScore >= 0.82 && contentScore >= 0.58:
|
||||||
|
return "close enough to your taste profile while adding useful variety"
|
||||||
|
case contentScore >= 0.78:
|
||||||
|
return "audio features closely match your current taste profile"
|
||||||
|
default:
|
||||||
|
return "balanced recommendation from catalog, taste, and diversity signals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func featureExplanation(taste, track [featureCount]float64) map[string]float64 {
|
||||||
|
out := make(map[string]float64, featureCount)
|
||||||
|
for i, name := range featureNames {
|
||||||
|
out[name] = round(1 - math.Abs(taste[i]-track[i]))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func minDistanceToSelected(track Track, selected []Recommendation, b featureBounds) float64 {
|
||||||
|
if len(selected) == 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
minDistance := math.Inf(1)
|
||||||
|
for _, other := range selected {
|
||||||
|
d := trackDistance(track, other.Track, b)
|
||||||
|
if d < minDistance {
|
||||||
|
minDistance = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clamp01(minDistance)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trackDistance(a, b Track, bounds featureBounds) float64 {
|
||||||
|
if hasAudioFeatures(a.Features) && hasAudioFeatures(b.Features) {
|
||||||
|
return distance(normalize(a.Features, bounds), normalize(b.Features, bounds))
|
||||||
|
}
|
||||||
|
if strings.EqualFold(a.Artist, b.Artist) && a.Artist != "" {
|
||||||
|
return 0.12
|
||||||
|
}
|
||||||
|
if genreOverlap(a.Genres, b.Genres) {
|
||||||
|
return 0.38
|
||||||
|
}
|
||||||
|
return 0.78
|
||||||
|
}
|
||||||
|
|
||||||
|
func genreOverlap(a, b []string) bool {
|
||||||
|
seen := make(map[string]struct{}, len(a))
|
||||||
|
for _, genre := range a {
|
||||||
|
if genre = normalizedToken(genre); genre != "" {
|
||||||
|
seen[genre] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, genre := range b {
|
||||||
|
if _, ok := seen[normalizedToken(genre)]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func topGenres(tracks []Track, byTrackID map[string]Track, interactions []Interaction) map[string]float64 {
|
||||||
|
scores := make(map[string]float64)
|
||||||
|
for _, interaction := range interactions {
|
||||||
|
track, ok := byTrackID[interaction.TrackID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
weight := interactionWeight(interaction)
|
||||||
|
if weight <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, genre := range track.Genres {
|
||||||
|
scores[strings.ToLower(genre)] += weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maxScore := 0.0
|
||||||
|
for _, score := range scores {
|
||||||
|
maxScore = math.Max(maxScore, score)
|
||||||
|
}
|
||||||
|
if maxScore == 0 {
|
||||||
|
return scores
|
||||||
|
}
|
||||||
|
for genre, score := range scores {
|
||||||
|
scores[genre] = round(score / maxScore)
|
||||||
|
}
|
||||||
|
return scores
|
||||||
|
}
|
||||||
|
|
||||||
|
func explorationReadiness(confidence float64, interactions []Interaction) float64 {
|
||||||
|
negative := 0.0
|
||||||
|
for _, interaction := range interactions {
|
||||||
|
if interactionWeight(interaction) < 0 {
|
||||||
|
negative++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
friction := 0.0
|
||||||
|
if len(interactions) > 0 {
|
||||||
|
friction = negative / float64(len(interactions))
|
||||||
|
}
|
||||||
|
return clamp01((0.45 + confidence*0.55) * (1 - friction*0.6))
|
||||||
|
}
|
||||||
|
|
||||||
|
func arrayToSlice(value [featureCount]float64) []float64 {
|
||||||
|
out := make([]float64, featureCount)
|
||||||
|
for i := range value {
|
||||||
|
out[i] = round(value[i])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(values []string, value string) bool {
|
||||||
|
return slices.Contains(values, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFold(values []string, value string) bool {
|
||||||
|
for _, candidate := range values {
|
||||||
|
if strings.EqualFold(candidate, value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func round(value float64) float64 {
|
||||||
|
return math.Round(value*10000) / 10000
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateTrack(track Track) error {
|
||||||
|
if strings.TrimSpace(track.ID) == "" {
|
||||||
|
return fmt.Errorf("track id is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(track.Title) == "" {
|
||||||
|
return fmt.Errorf("track title is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(track.Artist) == "" {
|
||||||
|
return fmt.Errorf("track artist is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package recommendation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testProvider struct {
|
||||||
|
snapshot CatalogSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p testProvider) Snapshot(context.Context, string) (CatalogSnapshot, error) {
|
||||||
|
return p.snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendBlendsContentAndCollaborativeSignals(t *testing.T) {
|
||||||
|
now := time.Date(2026, 4, 13, 12, 0, 0, 0, time.UTC)
|
||||||
|
engine := NewEngine(EngineConfig{
|
||||||
|
Now: func() time.Time { return now },
|
||||||
|
ContentWeight: 0.44,
|
||||||
|
CollabWeight: 0.28,
|
||||||
|
PopularityWeight: 0.08,
|
||||||
|
ExplorationWeight: 0.20,
|
||||||
|
DiversityLambda: 0.74,
|
||||||
|
})
|
||||||
|
|
||||||
|
tracks := []Track{
|
||||||
|
track("liked", "Known Good", "A", []string{"synth"}, 0.7, AudioFeatures{Danceability: 0.8, Energy: 0.8, Loudness: -5, Valence: 0.7, Tempo: 120, TimeSignature: 4, Key: 1, Mode: 1}),
|
||||||
|
track("neighbor", "Neighbor Pick", "B", []string{"synth"}, 0.6, AudioFeatures{Danceability: 0.76, Energy: 0.77, Loudness: -6, Valence: 0.66, Tempo: 121, TimeSignature: 4, Key: 2, Mode: 1}),
|
||||||
|
track("far", "Far Away", "C", []string{"folk"}, 0.5, AudioFeatures{Danceability: 0.2, Energy: 0.2, Loudness: -18, Acousticness: 0.9, Valence: 0.3, Tempo: 80, TimeSignature: 3, Key: 9, Mode: 0}),
|
||||||
|
}
|
||||||
|
|
||||||
|
recs, profile, err := engine.Recommend(context.Background(), testProvider{snapshot: CatalogSnapshot{
|
||||||
|
Tracks: tracks,
|
||||||
|
Interactions: []Interaction{
|
||||||
|
{UserID: "u1", TrackID: "liked", Type: InteractionLike, OccurredAt: now.Add(-time.Hour)},
|
||||||
|
{UserID: "u1", TrackID: "far", Type: InteractionSkip, OccurredAt: now.Add(-2 * time.Hour)},
|
||||||
|
{UserID: "n1", TrackID: "liked", Type: InteractionLike, OccurredAt: now.Add(-3 * time.Hour)},
|
||||||
|
{UserID: "n1", TrackID: "far", Type: InteractionSkip, OccurredAt: now.Add(-4 * time.Hour)},
|
||||||
|
{UserID: "n1", TrackID: "neighbor", Type: InteractionLike, OccurredAt: now.Add(-5 * time.Hour)},
|
||||||
|
},
|
||||||
|
Controls: UserControls{UserID: "u1", AllowExplicit: true},
|
||||||
|
}}, RecommendRequest{UserID: "u1", Limit: 2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recommend: %v", err)
|
||||||
|
}
|
||||||
|
if len(recs) == 0 {
|
||||||
|
t.Fatal("expected recommendations")
|
||||||
|
}
|
||||||
|
if recs[0].Track.ID != "neighbor" {
|
||||||
|
t.Fatalf("expected neighbor track first, got %q", recs[0].Track.ID)
|
||||||
|
}
|
||||||
|
if profile.Confidence <= 0 {
|
||||||
|
t.Fatalf("expected non-zero confidence, got %v", profile.Confidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendRespectsControls(t *testing.T) {
|
||||||
|
now := time.Date(2026, 4, 13, 12, 0, 0, 0, time.UTC)
|
||||||
|
engine := NewEngine(EngineConfig{Now: func() time.Time { return now }, ContentWeight: 1, DiversityLambda: 0.8})
|
||||||
|
explicit := track("explicit", "Explicit", "A", []string{"pop"}, 0.5, AudioFeatures{Danceability: 0.7, Energy: 0.7, Loudness: -6, Valence: 0.7, Tempo: 120, TimeSignature: 4})
|
||||||
|
explicit.Explicit = true
|
||||||
|
clean := track("clean", "Clean", "A", []string{"pop"}, 0.5, AudioFeatures{Danceability: 0.69, Energy: 0.71, Loudness: -6, Valence: 0.68, Tempo: 121, TimeSignature: 4})
|
||||||
|
|
||||||
|
recs, _, err := engine.Recommend(context.Background(), testProvider{snapshot: CatalogSnapshot{
|
||||||
|
Tracks: []Track{
|
||||||
|
track("seed", "Seed", "A", []string{"pop"}, 0.5, AudioFeatures{Danceability: 0.7, Energy: 0.7, Loudness: -6, Valence: 0.7, Tempo: 120, TimeSignature: 4}),
|
||||||
|
explicit,
|
||||||
|
clean,
|
||||||
|
},
|
||||||
|
Interactions: []Interaction{{UserID: "u1", TrackID: "seed", Type: InteractionLike, OccurredAt: now}},
|
||||||
|
Controls: UserControls{UserID: "u1", AllowExplicit: false},
|
||||||
|
}}, RecommendRequest{UserID: "u1", Limit: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recommend: %v", err)
|
||||||
|
}
|
||||||
|
for _, rec := range recs {
|
||||||
|
if rec.Track.ID == "explicit" {
|
||||||
|
t.Fatal("explicit track should be filtered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(recs) != 1 || recs[0].Track.ID != "clean" {
|
||||||
|
t.Fatalf("expected only clean track, got %#v", recs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendUsesMetadataWhenAudioFeaturesAreMissing(t *testing.T) {
|
||||||
|
now := time.Date(2026, 4, 13, 12, 0, 0, 0, time.UTC)
|
||||||
|
engine := NewEngine(EngineConfig{Now: func() time.Time { return now }, ContentWeight: 1, DiversityLambda: 0.85})
|
||||||
|
|
||||||
|
recs, _, err := engine.Recommend(context.Background(), testProvider{snapshot: CatalogSnapshot{
|
||||||
|
Tracks: []Track{
|
||||||
|
track("seed", "Seed", "Seed Artist", []string{"synthpop"}, 0.5, AudioFeatures{}),
|
||||||
|
track("genre-match", "Genre Match", "Other Artist", []string{"synthpop"}, 0.5, AudioFeatures{}),
|
||||||
|
track("unrelated", "Unrelated", "Far Artist", []string{"folk"}, 0.5, AudioFeatures{}),
|
||||||
|
},
|
||||||
|
Controls: UserControls{UserID: "u1", AllowExplicit: true},
|
||||||
|
}}, RecommendRequest{UserID: "u1", SeedTrackIDs: []string{"seed"}, Limit: 2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recommend: %v", err)
|
||||||
|
}
|
||||||
|
if len(recs) == 0 {
|
||||||
|
t.Fatal("expected recommendations")
|
||||||
|
}
|
||||||
|
if recs[0].Track.ID != "genre-match" {
|
||||||
|
t.Fatalf("expected metadata genre match first, got %q", recs[0].Track.ID)
|
||||||
|
}
|
||||||
|
for _, rec := range recs {
|
||||||
|
if rec.Track.ID == "seed" {
|
||||||
|
t.Fatal("seed track should not be recommended back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendPenalizesSkippedNeighborhoods(t *testing.T) {
|
||||||
|
now := time.Date(2026, 4, 13, 12, 0, 0, 0, time.UTC)
|
||||||
|
engine := NewEngine(EngineConfig{
|
||||||
|
Now: func() time.Time { return now },
|
||||||
|
ContentWeight: 0.74,
|
||||||
|
PopularityWeight: 0.08,
|
||||||
|
ExplorationWeight: 0.18,
|
||||||
|
DiversityLambda: 0.9,
|
||||||
|
})
|
||||||
|
|
||||||
|
audio := AudioFeatures{Danceability: 0.74, Energy: 0.76, Loudness: -5, Speechiness: 0.05, Acousticness: 0.12, Instrumentalness: 0.04, Liveness: 0.12, Valence: 0.66, Tempo: 124, TimeSignature: 4, Key: 2, Mode: 1}
|
||||||
|
recs, _, err := engine.Recommend(context.Background(), testProvider{snapshot: CatalogSnapshot{
|
||||||
|
Tracks: []Track{
|
||||||
|
track("liked", "Liked", "A", []string{"dance"}, 0.7, audio),
|
||||||
|
track("skipped", "Skipped", "B", []string{"metal"}, 0.7, audio),
|
||||||
|
track("penalized", "Penalized", "C", []string{"metal"}, 0.7, audio),
|
||||||
|
track("safe", "Safe", "D", []string{"dance"}, 0.62, AudioFeatures{Danceability: 0.72, Energy: 0.74, Loudness: -6, Speechiness: 0.05, Acousticness: 0.14, Instrumentalness: 0.05, Liveness: 0.1, Valence: 0.64, Tempo: 125, TimeSignature: 4, Key: 3, Mode: 1}),
|
||||||
|
},
|
||||||
|
Interactions: []Interaction{
|
||||||
|
{UserID: "u1", TrackID: "liked", Type: InteractionLike, OccurredAt: now.Add(-time.Hour)},
|
||||||
|
{UserID: "u1", TrackID: "skipped", Type: InteractionSkip, OccurredAt: now.Add(-30 * time.Minute)},
|
||||||
|
},
|
||||||
|
Controls: UserControls{UserID: "u1", AllowExplicit: true},
|
||||||
|
}}, RecommendRequest{UserID: "u1", Limit: 2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recommend: %v", err)
|
||||||
|
}
|
||||||
|
if len(recs) == 0 {
|
||||||
|
t.Fatal("expected recommendations")
|
||||||
|
}
|
||||||
|
if recs[0].Track.ID != "safe" {
|
||||||
|
t.Fatalf("expected non-skipped neighborhood first, got %q", recs[0].Track.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func track(id, title, artist string, genres []string, popularity float64, features AudioFeatures) Track {
|
||||||
|
return Track{
|
||||||
|
ID: id,
|
||||||
|
Title: title,
|
||||||
|
Artist: artist,
|
||||||
|
Genres: genres,
|
||||||
|
Popularity: popularity,
|
||||||
|
Features: features,
|
||||||
|
DiscoveryAllowed: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package recommendation
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Track struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Album string `json:"album,omitempty"`
|
||||||
|
Genres []string `json:"genres,omitempty"`
|
||||||
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
|
DurationMS int `json:"duration_ms,omitempty"`
|
||||||
|
Popularity float64 `json:"popularity"`
|
||||||
|
Explicit bool `json:"explicit"`
|
||||||
|
Features AudioFeatures `json:"features"`
|
||||||
|
External map[string]string `json:"external,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
CommercialBoost float64 `json:"commercial_boost,omitempty"`
|
||||||
|
QualityPenalty float64 `json:"quality_penalty,omitempty"`
|
||||||
|
DiscoveryAllowed bool `json:"discovery_allowed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioFeatures struct {
|
||||||
|
Danceability float64 `json:"danceability"`
|
||||||
|
Energy float64 `json:"energy"`
|
||||||
|
Loudness float64 `json:"loudness"`
|
||||||
|
Speechiness float64 `json:"speechiness"`
|
||||||
|
Acousticness float64 `json:"acousticness"`
|
||||||
|
Instrumentalness float64 `json:"instrumentalness"`
|
||||||
|
Liveness float64 `json:"liveness"`
|
||||||
|
Valence float64 `json:"valence"`
|
||||||
|
Tempo float64 `json:"tempo"`
|
||||||
|
TimeSignature float64 `json:"time_signature"`
|
||||||
|
Key float64 `json:"key"`
|
||||||
|
Mode float64 `json:"mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InteractionType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
InteractionPlay InteractionType = "play"
|
||||||
|
InteractionSkip InteractionType = "skip"
|
||||||
|
InteractionLike InteractionType = "like"
|
||||||
|
InteractionDislike InteractionType = "dislike"
|
||||||
|
InteractionSave InteractionType = "save"
|
||||||
|
InteractionHide InteractionType = "hide"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Interaction struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
TrackID string `json:"track_id"`
|
||||||
|
Type InteractionType `json:"type"`
|
||||||
|
Weight float64 `json:"weight,omitempty"`
|
||||||
|
OccurredAt time.Time `json:"occurred_at"`
|
||||||
|
Context Context `json:"context,omitempty"`
|
||||||
|
CompletedMS int `json:"completed_ms,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Context struct {
|
||||||
|
Locale string `json:"locale,omitempty"`
|
||||||
|
Device string `json:"device,omitempty"`
|
||||||
|
TimeOfDay string `json:"time_of_day,omitempty"`
|
||||||
|
Activity string `json:"activity,omitempty"`
|
||||||
|
Mood string `json:"mood,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserControls struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
AllowExplicit bool `json:"allow_explicit"`
|
||||||
|
ExcludedTracks []string `json:"excluded_tracks,omitempty"`
|
||||||
|
ExcludedArtists []string `json:"excluded_artists,omitempty"`
|
||||||
|
ExcludedGenres []string `json:"excluded_genres,omitempty"`
|
||||||
|
PostponedTracks []string `json:"postponed_tracks,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecommendRequest struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
SeedTrackIDs []string `json:"seed_track_ids,omitempty"`
|
||||||
|
FeatureTargets *AudioFeatures `json:"feature_targets,omitempty"`
|
||||||
|
Context Context `json:"context,omitempty"`
|
||||||
|
Mode string `json:"mode,omitempty"`
|
||||||
|
ExplorationTarget float64 `json:"exploration_target,omitempty"`
|
||||||
|
MinPopularity *float64 `json:"min_popularity,omitempty"`
|
||||||
|
MaxPopularity *float64 `json:"max_popularity,omitempty"`
|
||||||
|
IncludeExplicit *bool `json:"include_explicit,omitempty"`
|
||||||
|
ExcludedTrackIDs []string `json:"excluded_track_ids,omitempty"`
|
||||||
|
ExcludedArtistIDs []string `json:"excluded_artist_ids,omitempty"`
|
||||||
|
ExcludedGenres []string `json:"excluded_genres,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Recommendation struct {
|
||||||
|
Track Track `json:"track"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Rank int `json:"rank"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
ScoreBreakdown ScoreBreakdown `json:"score_breakdown"`
|
||||||
|
Explanation map[string]float64 `json:"explanation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScoreBreakdown struct {
|
||||||
|
Content float64 `json:"content"`
|
||||||
|
Collaborative float64 `json:"collaborative"`
|
||||||
|
Popularity float64 `json:"popularity"`
|
||||||
|
Exploration float64 `json:"exploration"`
|
||||||
|
Diversity float64 `json:"diversity"`
|
||||||
|
Safety float64 `json:"safety"`
|
||||||
|
Commercial float64 `json:"commercial"`
|
||||||
|
Final float64 `json:"final"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TasteProfile struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Vector []float64 `json:"vector"`
|
||||||
|
TopGenres map[string]float64 `json:"top_genres"`
|
||||||
|
InteractionCount int `json:"interaction_count"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
ExplorationReadiness float64 `json:"exploration_readiness"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CatalogSnapshot struct {
|
||||||
|
Tracks []Track
|
||||||
|
Interactions []Interaction
|
||||||
|
Controls UserControls
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package recommendation
|
||||||
|
|
||||||
|
import "math"
|
||||||
|
|
||||||
|
const featureCount = 12
|
||||||
|
|
||||||
|
var featureNames = []string{
|
||||||
|
"danceability",
|
||||||
|
"energy",
|
||||||
|
"loudness",
|
||||||
|
"speechiness",
|
||||||
|
"acousticness",
|
||||||
|
"instrumentalness",
|
||||||
|
"liveness",
|
||||||
|
"valence",
|
||||||
|
"tempo",
|
||||||
|
"time_signature",
|
||||||
|
"key",
|
||||||
|
"mode",
|
||||||
|
}
|
||||||
|
|
||||||
|
type featureSpec struct {
|
||||||
|
min float64
|
||||||
|
max float64
|
||||||
|
weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
var featureSpecs = [featureCount]featureSpec{
|
||||||
|
{min: 0, max: 1, weight: 1.12}, // danceability
|
||||||
|
{min: 0, max: 1, weight: 1.18}, // energy
|
||||||
|
{min: -60, max: 0, weight: 0.78}, // loudness
|
||||||
|
{min: 0, max: 1, weight: 0.72}, // speechiness
|
||||||
|
{min: 0, max: 1, weight: 1.02}, // acousticness
|
||||||
|
{min: 0, max: 1, weight: 0.82}, // instrumentalness
|
||||||
|
{min: 0, max: 1, weight: 0.44}, // liveness
|
||||||
|
{min: 0, max: 1, weight: 1.08}, // valence
|
||||||
|
{min: 40, max: 220, weight: 0.92}, // tempo
|
||||||
|
{min: 1, max: 7, weight: 0.22}, // time signature
|
||||||
|
{min: 0, max: 11, weight: 0.20}, // key
|
||||||
|
{min: 0, max: 1, weight: 0.16}, // mode
|
||||||
|
}
|
||||||
|
|
||||||
|
type featureBounds struct {
|
||||||
|
min [featureCount]float64
|
||||||
|
max [featureCount]float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func vector(features AudioFeatures) [featureCount]float64 {
|
||||||
|
return [featureCount]float64{
|
||||||
|
features.Danceability,
|
||||||
|
features.Energy,
|
||||||
|
features.Loudness,
|
||||||
|
features.Speechiness,
|
||||||
|
features.Acousticness,
|
||||||
|
features.Instrumentalness,
|
||||||
|
features.Liveness,
|
||||||
|
features.Valence,
|
||||||
|
features.Tempo,
|
||||||
|
features.TimeSignature,
|
||||||
|
features.Key,
|
||||||
|
features.Mode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bounds(tracks []Track) featureBounds {
|
||||||
|
var b featureBounds
|
||||||
|
for i := range featureCount {
|
||||||
|
b.min[i] = featureSpecs[i].min
|
||||||
|
b.max[i] = featureSpecs[i].max
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, track := range tracks {
|
||||||
|
if !hasAudioFeatures(track.Features) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := vector(track.Features)
|
||||||
|
for i, value := range v {
|
||||||
|
if value < b.min[i] {
|
||||||
|
b.min[i] = value
|
||||||
|
}
|
||||||
|
if value > b.max[i] {
|
||||||
|
b.max[i] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range featureCount {
|
||||||
|
if math.IsInf(b.min[i], 0) || math.IsInf(b.max[i], 0) || b.min[i] == b.max[i] {
|
||||||
|
b.min[i] = 0
|
||||||
|
b.max[i] = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalize(features AudioFeatures, b featureBounds) [featureCount]float64 {
|
||||||
|
raw := vector(features)
|
||||||
|
var out [featureCount]float64
|
||||||
|
for i, value := range raw {
|
||||||
|
denominator := b.max[i] - b.min[i]
|
||||||
|
if denominator == 0 {
|
||||||
|
out[i] = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[i] = clamp01((value - b.min[i]) / denominator)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cosine(a, b [featureCount]float64) float64 {
|
||||||
|
var dot, normA, normB float64
|
||||||
|
for i := range featureCount {
|
||||||
|
weight := featureSpecs[i].weight
|
||||||
|
dot += weight * a[i] * b[i]
|
||||||
|
normA += weight * a[i] * a[i]
|
||||||
|
normB += weight * b[i] * b[i]
|
||||||
|
}
|
||||||
|
if normA == 0 || normB == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return dot / (math.Sqrt(normA) * math.Sqrt(normB))
|
||||||
|
}
|
||||||
|
|
||||||
|
func distance(a, b [featureCount]float64) float64 {
|
||||||
|
return 1 - cosine(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp01(value float64) float64 {
|
||||||
|
if value < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if value > 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAudioFeatures(features AudioFeatures) bool {
|
||||||
|
raw := vector(features)
|
||||||
|
nonZero := 0
|
||||||
|
for _, value := range raw {
|
||||||
|
if value != 0 {
|
||||||
|
nonZero++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nonZero >= 4 && features.Tempo > 0 && features.TimeSignature > 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
tracks map[string]recommendation.Track
|
||||||
|
interactions []recommendation.Interaction
|
||||||
|
controls map[string]recommendation.UserControls
|
||||||
|
providerCache map[string]provider.CacheEntry
|
||||||
|
importJobs map[string]provider.ImportJob
|
||||||
|
enrichments map[string]provider.TrackEnrichment
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Store {
|
||||||
|
return &Store{
|
||||||
|
tracks: make(map[string]recommendation.Track),
|
||||||
|
controls: make(map[string]recommendation.UserControls),
|
||||||
|
providerCache: make(map[string]provider.CacheEntry),
|
||||||
|
importJobs: make(map[string]provider.ImportJob),
|
||||||
|
enrichments: make(map[string]provider.TrackEnrichment),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Ping(context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpsertTrack(_ context.Context, track recommendation.Track) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
existing := s.tracks[track.ID]
|
||||||
|
if track.CreatedAt.IsZero() {
|
||||||
|
track.CreatedAt = existing.CreatedAt
|
||||||
|
}
|
||||||
|
if track.CreatedAt.IsZero() {
|
||||||
|
track.CreatedAt = now
|
||||||
|
}
|
||||||
|
track.UpdatedAt = now
|
||||||
|
s.tracks[track.ID] = track
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpsertTracks(ctx context.Context, tracks []recommendation.Track) error {
|
||||||
|
for _, track := range tracks {
|
||||||
|
if err := s.UpsertTrack(ctx, track); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetTracksByIDs(_ context.Context, ids []string) ([]recommendation.Track, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
out := make([]recommendation.Track, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if track, ok := s.tracks[id]; ok {
|
||||||
|
out = append(out, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RecordInteraction(_ context.Context, interaction recommendation.Interaction) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if interaction.OccurredAt.IsZero() {
|
||||||
|
interaction.OccurredAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
s.interactions = append(s.interactions, interaction)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetControls(_ context.Context, userID string) (recommendation.UserControls, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
controls, ok := s.controls[userID]
|
||||||
|
if !ok {
|
||||||
|
return recommendation.UserControls{UserID: userID, AllowExplicit: true}, nil
|
||||||
|
}
|
||||||
|
return controls, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpsertControls(_ context.Context, controls recommendation.UserControls) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.controls[controls.UserID] = controls
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Snapshot(_ context.Context, userID string) (recommendation.CatalogSnapshot, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
tracks := make([]recommendation.Track, 0, len(s.tracks))
|
||||||
|
for _, track := range s.tracks {
|
||||||
|
tracks = append(tracks, track)
|
||||||
|
}
|
||||||
|
slices.SortFunc(tracks, func(a, b recommendation.Track) int {
|
||||||
|
if a.ID < b.ID {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if a.ID > b.ID {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
interactions := slices.Clone(s.interactions)
|
||||||
|
controls, ok := s.controls[userID]
|
||||||
|
if !ok {
|
||||||
|
controls = recommendation.UserControls{UserID: userID, AllowExplicit: true}
|
||||||
|
}
|
||||||
|
return recommendation.CatalogSnapshot{
|
||||||
|
Tracks: tracks,
|
||||||
|
Interactions: interactions,
|
||||||
|
Controls: controls,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetProviderCache(_ context.Context, providerName, itemType, itemID, market string) (provider.CacheEntry, bool, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
entry, ok := s.providerCache[providerCacheKey(providerName, itemType, itemID, market)]
|
||||||
|
if !ok {
|
||||||
|
return provider.CacheEntry{}, false, nil
|
||||||
|
}
|
||||||
|
return cloneCacheEntry(entry), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpsertProviderCache(_ context.Context, entry provider.CacheEntry) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.providerCache[providerCacheKey(entry.Provider, entry.ItemType, entry.ItemID, entry.Market)] = cloneCacheEntry(entry)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ProviderCacheStats(context.Context) (provider.CacheStats, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
stats := provider.CacheStats{Entries: int64(len(s.providerCache))}
|
||||||
|
for _, entry := range s.providerCache {
|
||||||
|
if entry.Fresh(now) {
|
||||||
|
stats.FreshEntries++
|
||||||
|
} else {
|
||||||
|
stats.StaleEntries++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateImportJob(_ context.Context, job provider.ImportJob) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.importJobs[job.ID] = job
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) FinishImportJob(_ context.Context, job provider.ImportJob) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.importJobs[job.ID] = job
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpsertTrackEnrichment(_ context.Context, enrichment provider.TrackEnrichment) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.enrichments[enrichment.TrackID+":"+enrichment.Provider] = cloneEnrichment(enrichment)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerCacheKey(providerName, itemType, itemID, market string) string {
|
||||||
|
return providerName + "\x00" + itemType + "\x00" + itemID + "\x00" + market
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneCacheEntry(entry provider.CacheEntry) provider.CacheEntry {
|
||||||
|
if len(entry.Payload) > 0 {
|
||||||
|
entry.Payload = slices.Clone(entry.Payload)
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneEnrichment(enrichment provider.TrackEnrichment) provider.TrackEnrichment {
|
||||||
|
if len(enrichment.Payload) > 0 {
|
||||||
|
enrichment.Payload = slices.Clone(enrichment.Payload)
|
||||||
|
}
|
||||||
|
return enrichment
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SeedDemo(store *Store) {
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
tracks := []recommendation.Track{
|
||||||
|
{ID: "trk-neon-dawn", Title: "Neon Dawn", Artist: "The Arrays", Genres: []string{"synthpop", "indie"}, Popularity: 0.72, Features: recommendation.AudioFeatures{Danceability: 0.74, Energy: 0.70, Loudness: -6, Speechiness: 0.05, Acousticness: 0.12, Instrumentalness: 0.15, Liveness: 0.12, Valence: 0.67, Tempo: 118, TimeSignature: 4, Key: 2, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-static-heart", Title: "Static Heart", Artist: "The Arrays", Genres: []string{"synthpop"}, Popularity: 0.62, Features: recommendation.AudioFeatures{Danceability: 0.68, Energy: 0.66, Loudness: -8, Speechiness: 0.04, Acousticness: 0.18, Instrumentalness: 0.20, Liveness: 0.10, Valence: 0.58, Tempo: 116, TimeSignature: 4, Key: 4, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-glass-road", Title: "Glass Road", Artist: "North Index", Genres: []string{"indie", "rock"}, Popularity: 0.51, Features: recommendation.AudioFeatures{Danceability: 0.55, Energy: 0.78, Loudness: -5, Speechiness: 0.06, Acousticness: 0.20, Instrumentalness: 0.08, Liveness: 0.18, Valence: 0.54, Tempo: 132, TimeSignature: 4, Key: 9, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-late-platform", Title: "Late Platform", Artist: "North Index", Genres: []string{"indie", "ambient"}, Popularity: 0.37, Features: recommendation.AudioFeatures{Danceability: 0.44, Energy: 0.38, Loudness: -13, Speechiness: 0.03, Acousticness: 0.66, Instrumentalness: 0.70, Liveness: 0.09, Valence: 0.35, Tempo: 92, TimeSignature: 4, Key: 11, Mode: 0}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-velvet-motor", Title: "Velvet Motor", Artist: "Signal Choir", Genres: []string{"pop-punk", "rock"}, Popularity: 0.49, Features: recommendation.AudioFeatures{Danceability: 0.60, Energy: 0.88, Loudness: -4, Speechiness: 0.07, Acousticness: 0.09, Instrumentalness: 0.02, Liveness: 0.21, Valence: 0.62, Tempo: 148, TimeSignature: 4, Key: 5, Mode: 1}, DiscoveryAllowed: true, CommercialBoost: 0.02},
|
||||||
|
{ID: "trk-blue-hour", Title: "Blue Hour", Artist: "Mira Vale", Genres: []string{"acoustic", "folk"}, Popularity: 0.43, Features: recommendation.AudioFeatures{Danceability: 0.38, Energy: 0.31, Loudness: -14, Speechiness: 0.04, Acousticness: 0.86, Instrumentalness: 0.12, Liveness: 0.11, Valence: 0.42, Tempo: 82, TimeSignature: 4, Key: 7, Mode: 0}, DiscoveryAllowed: true},
|
||||||
|
}
|
||||||
|
_ = store.UpsertTracks(ctx, tracks)
|
||||||
|
for _, interaction := range []recommendation.Interaction{
|
||||||
|
{UserID: "demo-user", TrackID: "trk-neon-dawn", Type: recommendation.InteractionLike, OccurredAt: now.Add(-24 * time.Hour)},
|
||||||
|
{UserID: "demo-user", TrackID: "trk-static-heart", Type: recommendation.InteractionSave, OccurredAt: now.Add(-48 * time.Hour)},
|
||||||
|
{UserID: "demo-user", TrackID: "trk-blue-hour", Type: recommendation.InteractionSkip, OccurredAt: now.Add(-12 * time.Hour)},
|
||||||
|
{UserID: "neighbor-a", TrackID: "trk-neon-dawn", Type: recommendation.InteractionLike, OccurredAt: now.Add(-72 * time.Hour)},
|
||||||
|
{UserID: "neighbor-a", TrackID: "trk-glass-road", Type: recommendation.InteractionLike, OccurredAt: now.Add(-24 * time.Hour)},
|
||||||
|
{UserID: "neighbor-b", TrackID: "trk-static-heart", Type: recommendation.InteractionLike, OccurredAt: now.Add(-72 * time.Hour)},
|
||||||
|
{UserID: "neighbor-b", TrackID: "trk-velvet-motor", Type: recommendation.InteractionLike, OccurredAt: now.Add(-18 * time.Hour)},
|
||||||
|
} {
|
||||||
|
_ = store.RecordInteraction(ctx, interaction)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeedLargeCatalog creates a diverse catalog for realistic recommendations
|
||||||
|
func SeedLargeCatalog(store *Store) {
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Diverse catalog with many tracks across genres
|
||||||
|
tracks := []recommendation.Track{
|
||||||
|
// Electronic / Dance (similar to Avicii)
|
||||||
|
{ID: "25FTMokYEbEWHEdss5JLZS", Title: "The Nights", Artist: "Avicii", Genres: []string{"electronic", "dance", "edm"}, Popularity: 0.85, Features: recommendation.AudioFeatures{Danceability: 0.72, Energy: 0.78, Loudness: -5, Speechiness: 0.05, Acousticness: 0.15, Instrumentalness: 0.10, Liveness: 0.20, Valence: 0.70, Tempo: 126, TimeSignature: 4, Key: 4, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-wake-me-up", Title: "Wake Me Up", Artist: "Avicii", Genres: []string{"electronic", "dance", "country"}, Popularity: 0.90, Features: recommendation.AudioFeatures{Danceability: 0.68, Energy: 0.82, Loudness: -4, Speechiness: 0.06, Acousticness: 0.22, Instrumentalness: 0.05, Liveness: 0.15, Valence: 0.65, Tempo: 124, TimeSignature: 4, Key: 2, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-levels", Title: "Levels", Artist: "Avicii", Genres: []string{"electronic", "dance", "edm"}, Popularity: 0.88, Features: recommendation.AudioFeatures{Danceability: 0.75, Energy: 0.85, Loudness: -3, Speechiness: 0.04, Acousticness: 0.08, Instrumentalness: 0.20, Liveness: 0.25, Valence: 0.72, Tempo: 128, TimeSignature: 4, Key: 5, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-hey-brother", Title: "Hey Brother", Artist: "Avicii", Genres: []string{"electronic", "dance", "country"}, Popularity: 0.82, Features: recommendation.AudioFeatures{Danceability: 0.65, Energy: 0.80, Loudness: -5, Speechiness: 0.07, Acousticness: 0.30, Instrumentalness: 0.08, Liveness: 0.18, Valence: 0.60, Tempo: 125, TimeSignature: 4, Key: 7, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-waiting-for-love", Title: "Waiting For Love", Artist: "Avicii", Genres: []string{"electronic", "dance", "pop"}, Popularity: 0.83, Features: recommendation.AudioFeatures{Danceability: 0.70, Energy: 0.78, Loudness: -5, Speechiness: 0.05, Acousticness: 0.18, Instrumentalness: 0.12, Liveness: 0.20, Valence: 0.68, Tempo: 126, TimeSignature: 4, Key: 9, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
|
||||||
|
// Pop / Dance
|
||||||
|
{ID: "trk-counting-stars", Title: "Counting Stars", Artist: "OneRepublic", Genres: []string{"pop", "rock"}, Popularity: 0.87, Features: recommendation.AudioFeatures{Danceability: 0.68, Energy: 0.75, Loudness: -6, Speechiness: 0.08, Acousticness: 0.20, Instrumentalness: 0.02, Liveness: 0.15, Valence: 0.55, Tempo: 122, TimeSignature: 4, Key: 2, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-uptown-funk", Title: "Uptown Funk", Artist: "Bruno Mars", Genres: []string{"pop", "funk"}, Popularity: 0.89, Features: recommendation.AudioFeatures{Danceability: 0.82, Energy: 0.88, Loudness: -4, Speechiness: 0.10, Acousticness: 0.05, Instrumentalness: 0.02, Liveness: 0.30, Valence: 0.90, Tempo: 115, TimeSignature: 4, Key: 0, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-happy", Title: "Happy", Artist: "Pharrell Williams", Genres: []string{"pop", "soul"}, Popularity: 0.86, Features: recommendation.AudioFeatures{Danceability: 0.78, Energy: 0.82, Loudness: -5, Speechiness: 0.12, Acousticness: 0.10, Instrumentalness: 0.03, Liveness: 0.22, Valence: 0.95, Tempo: 160, TimeSignature: 4, Key: 5, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-cant-hold-us", Title: "Can't Hold Us", Artist: "Macklemore", Genres: []string{"hip-hop", "pop"}, Popularity: 0.84, Features: recommendation.AudioFeatures{Danceability: 0.72, Energy: 0.86, Loudness: -4, Speechiness: 0.20, Acousticness: 0.15, Instrumentalness: 0.02, Liveness: 0.28, Valence: 0.75, Tempo: 146, TimeSignature: 4, Key: 7, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-i-gotta-feeling", Title: "I Gotta Feeling", Artist: "Black Eyed Peas", Genres: []string{"pop", "dance"}, Popularity: 0.85, Features: recommendation.AudioFeatures{Danceability: 0.76, Energy: 0.80, Loudness: -5, Speechiness: 0.08, Acousticness: 0.12, Instrumentalness: 0.05, Liveness: 0.25, Valence: 0.80, Tempo: 128, TimeSignature: 4, Key: 2, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
|
||||||
|
// Alternative / Indie
|
||||||
|
{ID: "trk-do-i-wanna-know", Title: "Do I Wanna Know?", Artist: "Arctic Monkeys", Genres: []string{"alternative", "indie"}, Popularity: 0.80, Features: recommendation.AudioFeatures{Danceability: 0.58, Energy: 0.68, Loudness: -8, Speechiness: 0.05, Acousticness: 0.35, Instrumentalness: 0.25, Liveness: 0.12, Valence: 0.35, Tempo: 85, TimeSignature: 4, Key: 9, Mode: 0}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-somebody-told-me", Title: "Somebody Told Me", Artist: "The Killers", Genres: []string{"alternative", "rock"}, Popularity: 0.82, Features: recommendation.AudioFeatures{Danceability: 0.62, Energy: 0.85, Loudness: -5, Speechiness: 0.06, Acousticness: 0.08, Instrumentalness: 0.02, Liveness: 0.20, Valence: 0.65, Tempo: 138, TimeSignature: 4, Key: 0, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-mr-brightside", Title: "Mr. Brightside", Artist: "The Killers", Genres: []string{"alternative", "rock"}, Popularity: 0.88, Features: recommendation.AudioFeatures{Danceability: 0.55, Energy: 0.90, Loudness: -4, Speechiness: 0.09, Acousticness: 0.05, Instrumentalness: 0.02, Liveness: 0.25, Valence: 0.60, Tempo: 148, TimeSignature: 4, Key: 2, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-take-me-out", Title: "Take Me Out", Artist: "Franz Ferdinand", Genres: []string{"alternative", "indie"}, Popularity: 0.78, Features: recommendation.AudioFeatures{Danceability: 0.65, Energy: 0.82, Loudness: -5, Speechiness: 0.04, Acousticness: 0.15, Instrumentalness: 0.05, Liveness: 0.18, Valence: 0.55, Tempo: 105, TimeSignature: 4, Key: 7, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
|
||||||
|
// Rock
|
||||||
|
{ID: "trk-boulevard-of-broken-dreams", Title: "Boulevard of Broken Dreams", Artist: "Green Day", Genres: []string{"rock", "punk"}, Popularity: 0.86, Features: recommendation.AudioFeatures{Danceability: 0.52, Energy: 0.78, Loudness: -5, Speechiness: 0.04, Acousticness: 0.12, Instrumentalness: 0.05, Liveness: 0.15, Valence: 0.40, Tempo: 167, TimeSignature: 4, Key: 0, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-knights-of-cydonia", Title: "Knights of Cydonia", Artist: "Muse", Genres: []string{"rock", "alternative"}, Popularity: 0.81, Features: recommendation.AudioFeatures{Danceability: 0.45, Energy: 0.92, Loudness: -3, Speechiness: 0.08, Acousticness: 0.05, Instrumentalness: 0.35, Liveness: 0.22, Valence: 0.45, Tempo: 137, TimeSignature: 4, Key: 2, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-smells-like-teen-spirit", Title: "Smells Like Teen Spirit", Artist: "Nirvana", Genres: []string{"rock", "grunge"}, Popularity: 0.87, Features: recommendation.AudioFeatures{Danceability: 0.48, Energy: 0.95, Loudness: -2, Speechiness: 0.07, Acousticness: 0.03, Instrumentalness: 0.10, Liveness: 0.30, Valence: 0.35, Tempo: 117, TimeSignature: 4, Key: 4, Mode: 0}, DiscoveryAllowed: true},
|
||||||
|
|
||||||
|
// Acoustic / Folk
|
||||||
|
{ID: "trk-ho-hey", Title: "Ho Hey", Artist: "The Lumineers", Genres: []string{"folk", "acoustic"}, Popularity: 0.79, Features: recommendation.AudioFeatures{Danceability: 0.58, Energy: 0.55, Loudness: -10, Speechiness: 0.06, Acousticness: 0.75, Instrumentalness: 0.05, Liveness: 0.18, Valence: 0.55, Tempo: 80, TimeSignature: 4, Key: 7, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-riptide", Title: "Riptide", Artist: "Vance Joy", Genres: []string{"folk", "indie"}, Popularity: 0.83, Features: recommendation.AudioFeatures{Danceability: 0.62, Energy: 0.48, Loudness: -11, Speechiness: 0.08, Acousticness: 0.72, Instrumentalness: 0.08, Liveness: 0.12, Valence: 0.60, Tempo: 102, TimeSignature: 4, Key: 9, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-let-her-go", Title: "Let Her Go", Artist: "Passenger", Genres: []string{"folk", "acoustic"}, Popularity: 0.80, Features: recommendation.AudioFeatures{Danceability: 0.52, Energy: 0.42, Loudness: -12, Speechiness: 0.05, Acousticness: 0.80, Instrumentalness: 0.05, Liveness: 0.15, Valence: 0.35, Tempo: 75, TimeSignature: 4, Key: 4, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
|
||||||
|
// Synth / New Wave
|
||||||
|
{ID: "trk-neon-dawn", Title: "Neon Dawn", Artist: "The Arrays", Genres: []string{"synthpop", "indie"}, Popularity: 0.72, Features: recommendation.AudioFeatures{Danceability: 0.74, Energy: 0.70, Loudness: -6, Speechiness: 0.05, Acousticness: 0.12, Instrumentalness: 0.15, Liveness: 0.12, Valence: 0.67, Tempo: 118, TimeSignature: 4, Key: 2, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-static-heart", Title: "Static Heart", Artist: "The Arrays", Genres: []string{"synthpop"}, Popularity: 0.62, Features: recommendation.AudioFeatures{Danceability: 0.68, Energy: 0.66, Loudness: -8, Speechiness: 0.04, Acousticness: 0.18, Instrumentalness: 0.20, Liveness: 0.10, Valence: 0.58, Tempo: 116, TimeSignature: 4, Key: 4, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-glass-road", Title: "Glass Road", Artist: "North Index", Genres: []string{"indie", "rock"}, Popularity: 0.51, Features: recommendation.AudioFeatures{Danceability: 0.55, Energy: 0.78, Loudness: -5, Speechiness: 0.06, Acousticness: 0.20, Instrumentalness: 0.08, Liveness: 0.18, Valence: 0.54, Tempo: 132, TimeSignature: 4, Key: 9, Mode: 1}, DiscoveryAllowed: true},
|
||||||
|
{ID: "trk-velvet-motor", Title: "Velvet Motor", Artist: "Signal Choir", Genres: []string{"pop-punk", "rock"}, Popularity: 0.49, Features: recommendation.AudioFeatures{Danceability: 0.60, Energy: 0.88, Loudness: -4, Speechiness: 0.07, Acousticness: 0.09, Instrumentalness: 0.02, Liveness: 0.21, Valence: 0.62, Tempo: 148, TimeSignature: 4, Key: 5, Mode: 1}, DiscoveryAllowed: true, CommercialBoost: 0.02},
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = store.UpsertTracks(ctx, tracks)
|
||||||
|
|
||||||
|
// Add collaborative interactions to create taste neighborhoods
|
||||||
|
interactions := []recommendation.Interaction{
|
||||||
|
// User who likes electronic/dance
|
||||||
|
{UserID: "user-electronic", TrackID: "25FTMokYEbEWHEdss5JLZS", Type: recommendation.InteractionLike, OccurredAt: now.Add(-24 * time.Hour)},
|
||||||
|
{UserID: "user-electronic", TrackID: "trk-wake-me-up", Type: recommendation.InteractionLike, OccurredAt: now.Add(-48 * time.Hour)},
|
||||||
|
{UserID: "user-electronic", TrackID: "trk-levels", Type: recommendation.InteractionLike, OccurredAt: now.Add(-72 * time.Hour)},
|
||||||
|
|
||||||
|
// User who likes pop
|
||||||
|
{UserID: "user-pop", TrackID: "trk-counting-stars", Type: recommendation.InteractionLike, OccurredAt: now.Add(-24 * time.Hour)},
|
||||||
|
{UserID: "user-pop", TrackID: "trk-uptown-funk", Type: recommendation.InteractionLike, OccurredAt: now.Add(-48 * time.Hour)},
|
||||||
|
{UserID: "user-pop", TrackID: "25FTMokYEbEWHEdss5JLZS", Type: recommendation.InteractionLike, OccurredAt: now.Add(-36 * time.Hour)},
|
||||||
|
|
||||||
|
// User who likes alternative
|
||||||
|
{UserID: "user-alt", TrackID: "trk-do-i-wanna-know", Type: recommendation.InteractionLike, OccurredAt: now.Add(-24 * time.Hour)},
|
||||||
|
{UserID: "user-alt", TrackID: "trk-somebody-told-me", Type: recommendation.InteractionLike, OccurredAt: now.Add(-48 * time.Hour)},
|
||||||
|
|
||||||
|
// Cross-genre listener
|
||||||
|
{UserID: "user-mixed", TrackID: "25FTMokYEbEWHEdss5JLZS", Type: recommendation.InteractionLike, OccurredAt: now.Add(-24 * time.Hour)},
|
||||||
|
{UserID: "user-mixed", TrackID: "trk-boulevard-of-broken-dreams", Type: recommendation.InteractionLike, OccurredAt: now.Add(-48 * time.Hour)},
|
||||||
|
{UserID: "user-mixed", TrackID: "trk-riptide", Type: recommendation.InteractionLike, OccurredAt: now.Add(-72 * time.Hour)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, interaction := range interactions {
|
||||||
|
_ = store.RecordInteraction(ctx, interaction)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: catalog.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const getControls = `-- name: GetControls :one
|
||||||
|
select user_id, allow_explicit, excluded_tracks, excluded_artists, excluded_genres, postponed_tracks
|
||||||
|
from user_controls
|
||||||
|
where user_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetControlsRow struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
AllowExplicit bool `json:"allow_explicit"`
|
||||||
|
ExcludedTracks []byte `json:"excluded_tracks"`
|
||||||
|
ExcludedArtists []byte `json:"excluded_artists"`
|
||||||
|
ExcludedGenres []byte `json:"excluded_genres"`
|
||||||
|
PostponedTracks []byte `json:"postponed_tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetControls(ctx context.Context, userID string) (GetControlsRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getControls, userID)
|
||||||
|
var i GetControlsRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.UserID,
|
||||||
|
&i.AllowExplicit,
|
||||||
|
&i.ExcludedTracks,
|
||||||
|
&i.ExcludedArtists,
|
||||||
|
&i.ExcludedGenres,
|
||||||
|
&i.PostponedTracks,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTracksByIDs = `-- name: GetTracksByIDs :many
|
||||||
|
select id, title, artist, album, genres, release_date, duration_ms, popularity,
|
||||||
|
explicit, features, external, created_at, updated_at, commercial_boost, quality_penalty, discovery_allowed
|
||||||
|
from tracks
|
||||||
|
where id = any($1::text[])
|
||||||
|
order by id
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetTracksByIDsRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
Genres []byte `json:"genres"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
DurationMs int32 `json:"duration_ms"`
|
||||||
|
Popularity float64 `json:"popularity"`
|
||||||
|
Explicit bool `json:"explicit"`
|
||||||
|
Features []byte `json:"features"`
|
||||||
|
External []byte `json:"external"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
CommercialBoost float64 `json:"commercial_boost"`
|
||||||
|
QualityPenalty float64 `json:"quality_penalty"`
|
||||||
|
DiscoveryAllowed bool `json:"discovery_allowed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []string) ([]GetTracksByIDsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getTracksByIDs, dollar_1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetTracksByIDsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetTracksByIDsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Artist,
|
||||||
|
&i.Album,
|
||||||
|
&i.Genres,
|
||||||
|
&i.ReleaseDate,
|
||||||
|
&i.DurationMs,
|
||||||
|
&i.Popularity,
|
||||||
|
&i.Explicit,
|
||||||
|
&i.Features,
|
||||||
|
&i.External,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.CommercialBoost,
|
||||||
|
&i.QualityPenalty,
|
||||||
|
&i.DiscoveryAllowed,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listRecentInteractions = `-- name: ListRecentInteractions :many
|
||||||
|
select user_id, track_id, type, weight, occurred_at, context, completed_ms
|
||||||
|
from interactions
|
||||||
|
where occurred_at >= now() - interval '365 days'
|
||||||
|
order by occurred_at desc
|
||||||
|
limit 250000
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListRecentInteractionsRow struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
TrackID string `json:"track_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Weight float64 `json:"weight"`
|
||||||
|
OccurredAt pgtype.Timestamptz `json:"occurred_at"`
|
||||||
|
Context []byte `json:"context"`
|
||||||
|
CompletedMs int32 `json:"completed_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListRecentInteractions(ctx context.Context) ([]ListRecentInteractionsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listRecentInteractions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListRecentInteractionsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListRecentInteractionsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.UserID,
|
||||||
|
&i.TrackID,
|
||||||
|
&i.Type,
|
||||||
|
&i.Weight,
|
||||||
|
&i.OccurredAt,
|
||||||
|
&i.Context,
|
||||||
|
&i.CompletedMs,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listTracks = `-- name: ListTracks :many
|
||||||
|
select id, title, artist, album, genres, release_date, duration_ms, popularity,
|
||||||
|
explicit, features, external, created_at, updated_at, commercial_boost, quality_penalty, discovery_allowed
|
||||||
|
from tracks
|
||||||
|
order by id
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListTracksRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
Genres []byte `json:"genres"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
DurationMs int32 `json:"duration_ms"`
|
||||||
|
Popularity float64 `json:"popularity"`
|
||||||
|
Explicit bool `json:"explicit"`
|
||||||
|
Features []byte `json:"features"`
|
||||||
|
External []byte `json:"external"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
CommercialBoost float64 `json:"commercial_boost"`
|
||||||
|
QualityPenalty float64 `json:"quality_penalty"`
|
||||||
|
DiscoveryAllowed bool `json:"discovery_allowed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListTracks(ctx context.Context) ([]ListTracksRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listTracks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListTracksRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListTracksRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Artist,
|
||||||
|
&i.Album,
|
||||||
|
&i.Genres,
|
||||||
|
&i.ReleaseDate,
|
||||||
|
&i.DurationMs,
|
||||||
|
&i.Popularity,
|
||||||
|
&i.Explicit,
|
||||||
|
&i.Features,
|
||||||
|
&i.External,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.CommercialBoost,
|
||||||
|
&i.QualityPenalty,
|
||||||
|
&i.DiscoveryAllowed,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordInteraction = `-- name: RecordInteraction :exec
|
||||||
|
insert into interactions (user_id, track_id, type, weight, occurred_at, context, completed_ms)
|
||||||
|
values ($1, $2, $3, $4, $5, $6::jsonb, $7)
|
||||||
|
`
|
||||||
|
|
||||||
|
type RecordInteractionParams struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
TrackID string `json:"track_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Weight float64 `json:"weight"`
|
||||||
|
OccurredAt pgtype.Timestamptz `json:"occurred_at"`
|
||||||
|
Column6 []byte `json:"column_6"`
|
||||||
|
CompletedMs int32 `json:"completed_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) RecordInteraction(ctx context.Context, arg RecordInteractionParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, recordInteraction,
|
||||||
|
arg.UserID,
|
||||||
|
arg.TrackID,
|
||||||
|
arg.Type,
|
||||||
|
arg.Weight,
|
||||||
|
arg.OccurredAt,
|
||||||
|
arg.Column6,
|
||||||
|
arg.CompletedMs,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertControls = `-- name: UpsertControls :exec
|
||||||
|
insert into user_controls (user_id, allow_explicit, excluded_tracks, excluded_artists, excluded_genres, postponed_tracks)
|
||||||
|
values ($1, $2, $3::jsonb, $4::jsonb, $5::jsonb, $6::jsonb)
|
||||||
|
on conflict (user_id) do update set
|
||||||
|
allow_explicit = excluded.allow_explicit,
|
||||||
|
excluded_tracks = excluded.excluded_tracks,
|
||||||
|
excluded_artists = excluded.excluded_artists,
|
||||||
|
excluded_genres = excluded.excluded_genres,
|
||||||
|
postponed_tracks = excluded.postponed_tracks,
|
||||||
|
updated_at = now()
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertControlsParams struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
AllowExplicit bool `json:"allow_explicit"`
|
||||||
|
Column3 []byte `json:"column_3"`
|
||||||
|
Column4 []byte `json:"column_4"`
|
||||||
|
Column5 []byte `json:"column_5"`
|
||||||
|
Column6 []byte `json:"column_6"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertControls(ctx context.Context, arg UpsertControlsParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, upsertControls,
|
||||||
|
arg.UserID,
|
||||||
|
arg.AllowExplicit,
|
||||||
|
arg.Column3,
|
||||||
|
arg.Column4,
|
||||||
|
arg.Column5,
|
||||||
|
arg.Column6,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertTrack = `-- name: UpsertTrack :exec
|
||||||
|
insert into tracks (
|
||||||
|
id, title, artist, album, genres, release_date, duration_ms, popularity,
|
||||||
|
explicit, features, external, commercial_boost, quality_penalty, discovery_allowed
|
||||||
|
) values (
|
||||||
|
$1, $2, $3, $4, $5::jsonb, $6, $7, $8,
|
||||||
|
$9, $10::jsonb, $11::jsonb, $12, $13, $14
|
||||||
|
)
|
||||||
|
on conflict (id) do update set
|
||||||
|
title = excluded.title,
|
||||||
|
artist = excluded.artist,
|
||||||
|
album = excluded.album,
|
||||||
|
genres = excluded.genres,
|
||||||
|
release_date = excluded.release_date,
|
||||||
|
duration_ms = excluded.duration_ms,
|
||||||
|
popularity = excluded.popularity,
|
||||||
|
explicit = excluded.explicit,
|
||||||
|
features = excluded.features,
|
||||||
|
external = excluded.external,
|
||||||
|
commercial_boost = excluded.commercial_boost,
|
||||||
|
quality_penalty = excluded.quality_penalty,
|
||||||
|
discovery_allowed = excluded.discovery_allowed,
|
||||||
|
updated_at = now()
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertTrackParams struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
Column5 []byte `json:"column_5"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
DurationMs int32 `json:"duration_ms"`
|
||||||
|
Popularity float64 `json:"popularity"`
|
||||||
|
Explicit bool `json:"explicit"`
|
||||||
|
Column10 []byte `json:"column_10"`
|
||||||
|
Column11 []byte `json:"column_11"`
|
||||||
|
CommercialBoost float64 `json:"commercial_boost"`
|
||||||
|
QualityPenalty float64 `json:"quality_penalty"`
|
||||||
|
DiscoveryAllowed bool `json:"discovery_allowed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, upsertTrack,
|
||||||
|
arg.ID,
|
||||||
|
arg.Title,
|
||||||
|
arg.Artist,
|
||||||
|
arg.Album,
|
||||||
|
arg.Column5,
|
||||||
|
arg.ReleaseDate,
|
||||||
|
arg.DurationMs,
|
||||||
|
arg.Popularity,
|
||||||
|
arg.Explicit,
|
||||||
|
arg.Column10,
|
||||||
|
arg.Column11,
|
||||||
|
arg.CommercialBoost,
|
||||||
|
arg.QualityPenalty,
|
||||||
|
arg.DiscoveryAllowed,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DBTX interface {
|
||||||
|
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||||
|
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||||
|
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db DBTX) *Queries {
|
||||||
|
return &Queries{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Queries struct {
|
||||||
|
db DBTX
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
||||||
|
return &Queries{
|
||||||
|
db: tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ImportJob struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
SourceType string `json:"source_type"`
|
||||||
|
SourceValue string `json:"source_value"`
|
||||||
|
Market string `json:"market"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ImportedTracks int32 `json:"imported_tracks"`
|
||||||
|
UpdatedTracks int32 `json:"updated_tracks"`
|
||||||
|
Skipped int32 `json:"skipped"`
|
||||||
|
Warnings []byte `json:"warnings"`
|
||||||
|
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||||
|
FinishedAt pgtype.Timestamptz `json:"finished_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Interaction struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
TrackID string `json:"track_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Weight float64 `json:"weight"`
|
||||||
|
OccurredAt pgtype.Timestamptz `json:"occurred_at"`
|
||||||
|
Context []byte `json:"context"`
|
||||||
|
CompletedMs int32 `json:"completed_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProviderCache struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
ItemType string `json:"item_type"`
|
||||||
|
ItemID string `json:"item_id"`
|
||||||
|
Market string `json:"market"`
|
||||||
|
Payload []byte `json:"payload"`
|
||||||
|
FetchedAt pgtype.Timestamptz `json:"fetched_at"`
|
||||||
|
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||||
|
LastError pgtype.Text `json:"last_error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Track struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
Genres []byte `json:"genres"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
DurationMs int32 `json:"duration_ms"`
|
||||||
|
Popularity float64 `json:"popularity"`
|
||||||
|
Explicit bool `json:"explicit"`
|
||||||
|
Features []byte `json:"features"`
|
||||||
|
External []byte `json:"external"`
|
||||||
|
CommercialBoost float64 `json:"commercial_boost"`
|
||||||
|
QualityPenalty float64 `json:"quality_penalty"`
|
||||||
|
DiscoveryAllowed bool `json:"discovery_allowed"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackEnrichment struct {
|
||||||
|
TrackID string `json:"track_id"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
MusicbrainzRecordingID string `json:"musicbrainz_recording_id"`
|
||||||
|
MusicbrainzArtistID string `json:"musicbrainz_artist_id"`
|
||||||
|
Isrc string `json:"isrc"`
|
||||||
|
Payload []byte `json:"payload"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserControl struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
AllowExplicit bool `json:"allow_explicit"`
|
||||||
|
ExcludedTracks []byte `json:"excluded_tracks"`
|
||||||
|
ExcludedArtists []byte `json:"excluded_artists"`
|
||||||
|
ExcludedGenres []byte `json:"excluded_genres"`
|
||||||
|
PostponedTracks []byte `json:"postponed_tracks"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: provider.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createImportJob = `-- name: CreateImportJob :exec
|
||||||
|
insert into import_jobs (id, provider, source_type, source_value, market, status, imported_tracks, updated_tracks, skipped, warnings, started_at)
|
||||||
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11)
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateImportJobParams struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
SourceType string `json:"source_type"`
|
||||||
|
SourceValue string `json:"source_value"`
|
||||||
|
Market string `json:"market"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ImportedTracks int32 `json:"imported_tracks"`
|
||||||
|
UpdatedTracks int32 `json:"updated_tracks"`
|
||||||
|
Skipped int32 `json:"skipped"`
|
||||||
|
Column10 []byte `json:"column_10"`
|
||||||
|
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateImportJob(ctx context.Context, arg CreateImportJobParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, createImportJob,
|
||||||
|
arg.ID,
|
||||||
|
arg.Provider,
|
||||||
|
arg.SourceType,
|
||||||
|
arg.SourceValue,
|
||||||
|
arg.Market,
|
||||||
|
arg.Status,
|
||||||
|
arg.ImportedTracks,
|
||||||
|
arg.UpdatedTracks,
|
||||||
|
arg.Skipped,
|
||||||
|
arg.Column10,
|
||||||
|
arg.StartedAt,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishImportJob = `-- name: FinishImportJob :exec
|
||||||
|
update import_jobs
|
||||||
|
set status = $2,
|
||||||
|
imported_tracks = $3,
|
||||||
|
updated_tracks = $4,
|
||||||
|
skipped = $5,
|
||||||
|
warnings = $6::jsonb,
|
||||||
|
finished_at = $7
|
||||||
|
where id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type FinishImportJobParams struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ImportedTracks int32 `json:"imported_tracks"`
|
||||||
|
UpdatedTracks int32 `json:"updated_tracks"`
|
||||||
|
Skipped int32 `json:"skipped"`
|
||||||
|
Column6 []byte `json:"column_6"`
|
||||||
|
FinishedAt pgtype.Timestamptz `json:"finished_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) FinishImportJob(ctx context.Context, arg FinishImportJobParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, finishImportJob,
|
||||||
|
arg.ID,
|
||||||
|
arg.Status,
|
||||||
|
arg.ImportedTracks,
|
||||||
|
arg.UpdatedTracks,
|
||||||
|
arg.Skipped,
|
||||||
|
arg.Column6,
|
||||||
|
arg.FinishedAt,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProviderCache = `-- name: GetProviderCache :one
|
||||||
|
select provider, item_type, item_id, market, payload, fetched_at, expires_at, coalesce(last_error, '') as last_error
|
||||||
|
from provider_cache
|
||||||
|
where provider = $1 and item_type = $2 and item_id = $3 and market = $4
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetProviderCacheParams struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
ItemType string `json:"item_type"`
|
||||||
|
ItemID string `json:"item_id"`
|
||||||
|
Market string `json:"market"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetProviderCacheRow struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
ItemType string `json:"item_type"`
|
||||||
|
ItemID string `json:"item_id"`
|
||||||
|
Market string `json:"market"`
|
||||||
|
Payload []byte `json:"payload"`
|
||||||
|
FetchedAt pgtype.Timestamptz `json:"fetched_at"`
|
||||||
|
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetProviderCache(ctx context.Context, arg GetProviderCacheParams) (GetProviderCacheRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getProviderCache,
|
||||||
|
arg.Provider,
|
||||||
|
arg.ItemType,
|
||||||
|
arg.ItemID,
|
||||||
|
arg.Market,
|
||||||
|
)
|
||||||
|
var i GetProviderCacheRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Provider,
|
||||||
|
&i.ItemType,
|
||||||
|
&i.ItemID,
|
||||||
|
&i.Market,
|
||||||
|
&i.Payload,
|
||||||
|
&i.FetchedAt,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.LastError,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerCacheStats = `-- name: ProviderCacheStats :one
|
||||||
|
select count(*)::bigint as entries,
|
||||||
|
count(*) filter (where expires_at > now())::bigint as fresh_entries,
|
||||||
|
count(*) filter (where expires_at <= now())::bigint as stale_entries
|
||||||
|
from provider_cache
|
||||||
|
`
|
||||||
|
|
||||||
|
type ProviderCacheStatsRow struct {
|
||||||
|
Entries int64 `json:"entries"`
|
||||||
|
FreshEntries int64 `json:"fresh_entries"`
|
||||||
|
StaleEntries int64 `json:"stale_entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ProviderCacheStats(ctx context.Context) (ProviderCacheStatsRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, providerCacheStats)
|
||||||
|
var i ProviderCacheStatsRow
|
||||||
|
err := row.Scan(&i.Entries, &i.FreshEntries, &i.StaleEntries)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertProviderCache = `-- name: UpsertProviderCache :exec
|
||||||
|
insert into provider_cache (provider, item_type, item_id, market, payload, fetched_at, expires_at, last_error)
|
||||||
|
values ($1, $2, $3, $4, $5::jsonb, $6, $7, nullif($8, ''))
|
||||||
|
on conflict (provider, item_type, item_id, market) do update set
|
||||||
|
payload = excluded.payload,
|
||||||
|
fetched_at = excluded.fetched_at,
|
||||||
|
expires_at = excluded.expires_at,
|
||||||
|
last_error = excluded.last_error
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertProviderCacheParams struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
ItemType string `json:"item_type"`
|
||||||
|
ItemID string `json:"item_id"`
|
||||||
|
Market string `json:"market"`
|
||||||
|
Column5 []byte `json:"column_5"`
|
||||||
|
FetchedAt pgtype.Timestamptz `json:"fetched_at"`
|
||||||
|
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||||
|
Column8 interface{} `json:"column_8"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertProviderCache(ctx context.Context, arg UpsertProviderCacheParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, upsertProviderCache,
|
||||||
|
arg.Provider,
|
||||||
|
arg.ItemType,
|
||||||
|
arg.ItemID,
|
||||||
|
arg.Market,
|
||||||
|
arg.Column5,
|
||||||
|
arg.FetchedAt,
|
||||||
|
arg.ExpiresAt,
|
||||||
|
arg.Column8,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertTrackEnrichment = `-- name: UpsertTrackEnrichment :exec
|
||||||
|
insert into track_enrichment (track_id, provider, musicbrainz_recording_id, musicbrainz_artist_id, isrc, payload, updated_at)
|
||||||
|
values ($1, $2, $3, $4, $5, $6::jsonb, $7)
|
||||||
|
on conflict (track_id, provider) do update set
|
||||||
|
musicbrainz_recording_id = excluded.musicbrainz_recording_id,
|
||||||
|
musicbrainz_artist_id = excluded.musicbrainz_artist_id,
|
||||||
|
isrc = excluded.isrc,
|
||||||
|
payload = excluded.payload,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertTrackEnrichmentParams struct {
|
||||||
|
TrackID string `json:"track_id"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
MusicbrainzRecordingID string `json:"musicbrainz_recording_id"`
|
||||||
|
MusicbrainzArtistID string `json:"musicbrainz_artist_id"`
|
||||||
|
Isrc string `json:"isrc"`
|
||||||
|
Column6 []byte `json:"column_6"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertTrackEnrichment(ctx context.Context, arg UpsertTrackEnrichmentParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, upsertTrackEnrichment,
|
||||||
|
arg.TrackID,
|
||||||
|
arg.Provider,
|
||||||
|
arg.MusicbrainzRecordingID,
|
||||||
|
arg.MusicbrainzArtistID,
|
||||||
|
arg.Isrc,
|
||||||
|
arg.Column6,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
|
||||||
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/storage/postgres/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
queries *db.Queries
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool, queries: db.New(pool)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Ping(ctx context.Context) error {
|
||||||
|
return s.pool.Ping(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpsertTrack(ctx context.Context, track recommendation.Track) error {
|
||||||
|
params, err := upsertTrackParams(track)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.queries.UpsertTrack(ctx, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpsertTracks(ctx context.Context, tracks []recommendation.Track) error {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
queries := s.queries.WithTx(tx)
|
||||||
|
for _, track := range tracks {
|
||||||
|
params, err := upsertTrackParams(track)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := queries.UpsertTrack(ctx, params); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetTracksByIDs(ctx context.Context, ids []string) ([]recommendation.Track, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
select id, title, artist, album, genres, release_date, duration_ms, popularity,
|
||||||
|
explicit, features, external, created_at, updated_at, commercial_boost, quality_penalty, discovery_allowed
|
||||||
|
from tracks
|
||||||
|
where id = any($1)
|
||||||
|
order by id`, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
tracks := make([]recommendation.Track, 0, len(ids))
|
||||||
|
for rows.Next() {
|
||||||
|
track, err := scanTrack(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tracks = append(tracks, track)
|
||||||
|
}
|
||||||
|
return tracks, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertTrackParams(track recommendation.Track) (db.UpsertTrackParams, error) {
|
||||||
|
features, err := json.Marshal(track.Features)
|
||||||
|
if err != nil {
|
||||||
|
return db.UpsertTrackParams{}, fmt.Errorf("marshal features: %w", err)
|
||||||
|
}
|
||||||
|
genres, err := json.Marshal(track.Genres)
|
||||||
|
if err != nil {
|
||||||
|
return db.UpsertTrackParams{}, fmt.Errorf("marshal genres: %w", err)
|
||||||
|
}
|
||||||
|
external, err := json.Marshal(track.External)
|
||||||
|
if err != nil {
|
||||||
|
return db.UpsertTrackParams{}, fmt.Errorf("marshal external ids: %w", err)
|
||||||
|
}
|
||||||
|
return db.UpsertTrackParams{
|
||||||
|
ID: track.ID,
|
||||||
|
Title: track.Title,
|
||||||
|
Artist: track.Artist,
|
||||||
|
Album: track.Album,
|
||||||
|
Column5: genres,
|
||||||
|
ReleaseDate: track.ReleaseDate,
|
||||||
|
DurationMs: int32(track.DurationMS),
|
||||||
|
Popularity: track.Popularity,
|
||||||
|
Explicit: track.Explicit,
|
||||||
|
Column10: features,
|
||||||
|
Column11: external,
|
||||||
|
CommercialBoost: track.CommercialBoost,
|
||||||
|
QualityPenalty: track.QualityPenalty,
|
||||||
|
DiscoveryAllowed: track.DiscoveryAllowed,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RecordInteraction(ctx context.Context, interaction recommendation.Interaction) error {
|
||||||
|
if interaction.OccurredAt.IsZero() {
|
||||||
|
interaction.OccurredAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
contextJSON, err := json.Marshal(interaction.Context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal interaction context: %w", err)
|
||||||
|
}
|
||||||
|
return s.queries.RecordInteraction(ctx, db.RecordInteractionParams{
|
||||||
|
UserID: interaction.UserID,
|
||||||
|
TrackID: interaction.TrackID,
|
||||||
|
Type: string(interaction.Type),
|
||||||
|
Weight: interaction.Weight,
|
||||||
|
OccurredAt: pgtype.Timestamptz{Time: interaction.OccurredAt, Valid: true},
|
||||||
|
Column6: contextJSON,
|
||||||
|
CompletedMs: int32(interaction.CompletedMS),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetControls(ctx context.Context, userID string) (recommendation.UserControls, error) {
|
||||||
|
row, err := s.queries.GetControls(ctx, userID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return recommendation.UserControls{UserID: userID, AllowExplicit: true}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return recommendation.UserControls{}, err
|
||||||
|
}
|
||||||
|
controls := recommendation.UserControls{UserID: row.UserID, AllowExplicit: row.AllowExplicit}
|
||||||
|
if err := unmarshalStringSlice(row.ExcludedTracks, &controls.ExcludedTracks); err != nil {
|
||||||
|
return recommendation.UserControls{}, err
|
||||||
|
}
|
||||||
|
if err := unmarshalStringSlice(row.ExcludedArtists, &controls.ExcludedArtists); err != nil {
|
||||||
|
return recommendation.UserControls{}, err
|
||||||
|
}
|
||||||
|
if err := unmarshalStringSlice(row.ExcludedGenres, &controls.ExcludedGenres); err != nil {
|
||||||
|
return recommendation.UserControls{}, err
|
||||||
|
}
|
||||||
|
if err := unmarshalStringSlice(row.PostponedTracks, &controls.PostponedTracks); err != nil {
|
||||||
|
return recommendation.UserControls{}, err
|
||||||
|
}
|
||||||
|
return controls, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpsertControls(ctx context.Context, controls recommendation.UserControls) error {
|
||||||
|
excludedTracks, err := json.Marshal(controls.ExcludedTracks)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
excludedArtists, err := json.Marshal(controls.ExcludedArtists)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
excludedGenres, err := json.Marshal(controls.ExcludedGenres)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
postponedTracks, err := json.Marshal(controls.PostponedTracks)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.queries.UpsertControls(ctx, db.UpsertControlsParams{
|
||||||
|
UserID: controls.UserID,
|
||||||
|
AllowExplicit: controls.AllowExplicit,
|
||||||
|
Column3: excludedTracks,
|
||||||
|
Column4: excludedArtists,
|
||||||
|
Column5: excludedGenres,
|
||||||
|
Column6: postponedTracks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Snapshot(ctx context.Context, userID string) (recommendation.CatalogSnapshot, error) {
|
||||||
|
tracks, err := s.listTracks(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return recommendation.CatalogSnapshot{}, err
|
||||||
|
}
|
||||||
|
interactions, err := s.listRecentInteractions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return recommendation.CatalogSnapshot{}, err
|
||||||
|
}
|
||||||
|
controls, err := s.GetControls(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return recommendation.CatalogSnapshot{}, err
|
||||||
|
}
|
||||||
|
return recommendation.CatalogSnapshot{
|
||||||
|
Tracks: tracks,
|
||||||
|
Interactions: interactions,
|
||||||
|
Controls: controls,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) listTracks(ctx context.Context) ([]recommendation.Track, error) {
|
||||||
|
rows, err := s.queries.ListTracks(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tracks := make([]recommendation.Track, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
track, err := trackFromListRow(row)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tracks = append(tracks, track)
|
||||||
|
}
|
||||||
|
return tracks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trackFromListRow(row db.ListTracksRow) (recommendation.Track, error) {
|
||||||
|
track := recommendation.Track{
|
||||||
|
ID: row.ID,
|
||||||
|
Title: row.Title,
|
||||||
|
Artist: row.Artist,
|
||||||
|
Album: row.Album,
|
||||||
|
ReleaseDate: row.ReleaseDate,
|
||||||
|
DurationMS: int(row.DurationMs),
|
||||||
|
Popularity: row.Popularity,
|
||||||
|
Explicit: row.Explicit,
|
||||||
|
CreatedAt: row.CreatedAt.Time,
|
||||||
|
UpdatedAt: row.UpdatedAt.Time,
|
||||||
|
CommercialBoost: row.CommercialBoost,
|
||||||
|
QualityPenalty: row.QualityPenalty,
|
||||||
|
DiscoveryAllowed: row.DiscoveryAllowed,
|
||||||
|
}
|
||||||
|
if err := unmarshalStringSlice(row.Genres, &track.Genres); err != nil {
|
||||||
|
return recommendation.Track{}, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(row.Features, &track.Features); err != nil {
|
||||||
|
return recommendation.Track{}, err
|
||||||
|
}
|
||||||
|
if err := unmarshalStringMap(row.External, &track.External); err != nil {
|
||||||
|
return recommendation.Track{}, err
|
||||||
|
}
|
||||||
|
return track, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type rowScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanTrack(scanner rowScanner) (recommendation.Track, error) {
|
||||||
|
var (
|
||||||
|
genres, features, external []byte
|
||||||
|
createdAt, updatedAt pgtype.Timestamptz
|
||||||
|
track recommendation.Track
|
||||||
|
)
|
||||||
|
if err := scanner.Scan(
|
||||||
|
&track.ID,
|
||||||
|
&track.Title,
|
||||||
|
&track.Artist,
|
||||||
|
&track.Album,
|
||||||
|
&genres,
|
||||||
|
&track.ReleaseDate,
|
||||||
|
&track.DurationMS,
|
||||||
|
&track.Popularity,
|
||||||
|
&track.Explicit,
|
||||||
|
&features,
|
||||||
|
&external,
|
||||||
|
&createdAt,
|
||||||
|
&updatedAt,
|
||||||
|
&track.CommercialBoost,
|
||||||
|
&track.QualityPenalty,
|
||||||
|
&track.DiscoveryAllowed,
|
||||||
|
); err != nil {
|
||||||
|
return recommendation.Track{}, err
|
||||||
|
}
|
||||||
|
track.CreatedAt = createdAt.Time
|
||||||
|
track.UpdatedAt = updatedAt.Time
|
||||||
|
if err := unmarshalStringSlice(genres, &track.Genres); err != nil {
|
||||||
|
return recommendation.Track{}, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(features, &track.Features); err != nil {
|
||||||
|
return recommendation.Track{}, err
|
||||||
|
}
|
||||||
|
if err := unmarshalStringMap(external, &track.External); err != nil {
|
||||||
|
return recommendation.Track{}, err
|
||||||
|
}
|
||||||
|
return track, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetProviderCache(ctx context.Context, providerName, itemType, itemID, market string) (provider.CacheEntry, bool, error) {
|
||||||
|
var entry provider.CacheEntry
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
select provider, item_type, item_id, market, payload, fetched_at, expires_at, coalesce(last_error, '')
|
||||||
|
from provider_cache
|
||||||
|
where provider = $1 and item_type = $2 and item_id = $3 and market = $4`,
|
||||||
|
providerName, itemType, itemID, market,
|
||||||
|
).Scan(&entry.Provider, &entry.ItemType, &entry.ItemID, &entry.Market, &entry.Payload, &entry.FetchedAt, &entry.ExpiresAt, &entry.LastError)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return provider.CacheEntry{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return provider.CacheEntry{}, false, err
|
||||||
|
}
|
||||||
|
return entry, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpsertProviderCache(ctx context.Context, entry provider.CacheEntry) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
insert into provider_cache (provider, item_type, item_id, market, payload, fetched_at, expires_at, last_error)
|
||||||
|
values ($1, $2, $3, $4, $5::jsonb, $6, $7, nullif($8, ''))
|
||||||
|
on conflict (provider, item_type, item_id, market) do update set
|
||||||
|
payload = excluded.payload,
|
||||||
|
fetched_at = excluded.fetched_at,
|
||||||
|
expires_at = excluded.expires_at,
|
||||||
|
last_error = excluded.last_error`,
|
||||||
|
entry.Provider,
|
||||||
|
entry.ItemType,
|
||||||
|
entry.ItemID,
|
||||||
|
entry.Market,
|
||||||
|
emptyObjectIfNil(entry.Payload),
|
||||||
|
entry.FetchedAt,
|
||||||
|
entry.ExpiresAt,
|
||||||
|
entry.LastError,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ProviderCacheStats(ctx context.Context) (provider.CacheStats, error) {
|
||||||
|
var stats provider.CacheStats
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
select count(*)::bigint,
|
||||||
|
count(*) filter (where expires_at > now())::bigint,
|
||||||
|
count(*) filter (where expires_at <= now())::bigint
|
||||||
|
from provider_cache`,
|
||||||
|
).Scan(&stats.Entries, &stats.FreshEntries, &stats.StaleEntries)
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateImportJob(ctx context.Context, job provider.ImportJob) error {
|
||||||
|
warnings, err := json.Marshal(job.Warnings)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.pool.Exec(ctx, `
|
||||||
|
insert into import_jobs (id, provider, source_type, source_value, market, status, imported_tracks, updated_tracks, skipped, warnings, started_at)
|
||||||
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11)`,
|
||||||
|
job.ID, job.Provider, job.SourceType, job.SourceValue, job.Market, job.Status,
|
||||||
|
job.ImportedTracks, job.UpdatedTracks, job.Skipped, warnings, job.StartedAt,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) FinishImportJob(ctx context.Context, job provider.ImportJob) error {
|
||||||
|
warnings, err := json.Marshal(job.Warnings)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.pool.Exec(ctx, `
|
||||||
|
update import_jobs
|
||||||
|
set status = $2,
|
||||||
|
imported_tracks = $3,
|
||||||
|
updated_tracks = $4,
|
||||||
|
skipped = $5,
|
||||||
|
warnings = $6::jsonb,
|
||||||
|
finished_at = $7
|
||||||
|
where id = $1`,
|
||||||
|
job.ID, job.Status, job.ImportedTracks, job.UpdatedTracks, job.Skipped, warnings, job.FinishedAt,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpsertTrackEnrichment(ctx context.Context, enrichment provider.TrackEnrichment) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
insert into track_enrichment (track_id, provider, musicbrainz_recording_id, musicbrainz_artist_id, isrc, payload, updated_at)
|
||||||
|
values ($1, $2, $3, $4, $5, $6::jsonb, $7)
|
||||||
|
on conflict (track_id, provider) do update set
|
||||||
|
musicbrainz_recording_id = excluded.musicbrainz_recording_id,
|
||||||
|
musicbrainz_artist_id = excluded.musicbrainz_artist_id,
|
||||||
|
isrc = excluded.isrc,
|
||||||
|
payload = excluded.payload,
|
||||||
|
updated_at = excluded.updated_at`,
|
||||||
|
enrichment.TrackID,
|
||||||
|
enrichment.Provider,
|
||||||
|
enrichment.MusicBrainzRecordingID,
|
||||||
|
enrichment.MusicBrainzArtistID,
|
||||||
|
enrichment.ISRC,
|
||||||
|
emptyObjectIfNil(enrichment.Payload),
|
||||||
|
enrichment.UpdatedAt,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func emptyObjectIfNil(payload []byte) []byte {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return []byte(`{}`)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) listRecentInteractions(ctx context.Context) ([]recommendation.Interaction, error) {
|
||||||
|
rows, err := s.queries.ListRecentInteractions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
interactions := make([]recommendation.Interaction, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
interaction := recommendation.Interaction{
|
||||||
|
UserID: row.UserID,
|
||||||
|
TrackID: row.TrackID,
|
||||||
|
Type: recommendation.InteractionType(row.Type),
|
||||||
|
Weight: row.Weight,
|
||||||
|
OccurredAt: row.OccurredAt.Time,
|
||||||
|
CompletedMS: int(row.CompletedMs),
|
||||||
|
}
|
||||||
|
if len(row.Context) > 0 {
|
||||||
|
if err := json.Unmarshal(row.Context, &interaction.Context); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interactions = append(interactions, interaction)
|
||||||
|
}
|
||||||
|
return interactions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmarshalStringSlice(raw []byte, out *[]string) error {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
*out = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(raw, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmarshalStringMap(raw []byte, out *map[string]string) error {
|
||||||
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
|
*out = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(raw, out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
-- +goose Up
|
||||||
|
create table if not exists tracks (
|
||||||
|
id text primary key,
|
||||||
|
title text not null,
|
||||||
|
artist text not null,
|
||||||
|
album text not null default '',
|
||||||
|
genres jsonb not null default '[]'::jsonb,
|
||||||
|
release_date text not null default '',
|
||||||
|
duration_ms integer not null default 0 check (duration_ms >= 0),
|
||||||
|
popularity double precision not null default 0 check (popularity >= 0 and popularity <= 1),
|
||||||
|
explicit boolean not null default false,
|
||||||
|
features jsonb not null,
|
||||||
|
external jsonb not null default '{}'::jsonb,
|
||||||
|
commercial_boost double precision not null default 0 check (commercial_boost >= 0 and commercial_boost <= 1),
|
||||||
|
quality_penalty double precision not null default 0 check (quality_penalty >= 0 and quality_penalty <= 1),
|
||||||
|
discovery_allowed boolean not null default true,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists interactions (
|
||||||
|
id bigserial primary key,
|
||||||
|
user_id text not null,
|
||||||
|
track_id text not null references tracks(id) on delete cascade,
|
||||||
|
type text not null check (type in ('play', 'skip', 'like', 'dislike', 'save', 'hide')),
|
||||||
|
weight double precision not null default 0,
|
||||||
|
occurred_at timestamptz not null default now(),
|
||||||
|
context jsonb not null default '{}'::jsonb,
|
||||||
|
completed_ms integer not null default 0 check (completed_ms >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists user_controls (
|
||||||
|
user_id text primary key,
|
||||||
|
allow_explicit boolean not null default true,
|
||||||
|
excluded_tracks jsonb not null default '[]'::jsonb,
|
||||||
|
excluded_artists jsonb not null default '[]'::jsonb,
|
||||||
|
excluded_genres jsonb not null default '[]'::jsonb,
|
||||||
|
postponed_tracks jsonb not null default '[]'::jsonb,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists tracks_artist_idx on tracks (artist);
|
||||||
|
create index if not exists tracks_popularity_idx on tracks (popularity desc);
|
||||||
|
create index if not exists tracks_genres_gin_idx on tracks using gin (genres);
|
||||||
|
create index if not exists interactions_user_time_idx on interactions (user_id, occurred_at desc);
|
||||||
|
create index if not exists interactions_track_time_idx on interactions (track_id, occurred_at desc);
|
||||||
|
create index if not exists interactions_recent_idx on interactions (occurred_at desc);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
drop table if exists user_controls;
|
||||||
|
drop table if exists interactions;
|
||||||
|
drop table if exists tracks;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- +goose Up
|
||||||
|
create table if not exists provider_cache (
|
||||||
|
provider text not null,
|
||||||
|
item_type text not null,
|
||||||
|
item_id text not null,
|
||||||
|
market text not null default '',
|
||||||
|
payload jsonb not null default '{}'::jsonb,
|
||||||
|
fetched_at timestamptz not null default now(),
|
||||||
|
expires_at timestamptz not null,
|
||||||
|
last_error text,
|
||||||
|
primary key (provider, item_type, item_id, market)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists import_jobs (
|
||||||
|
id text primary key,
|
||||||
|
provider text not null,
|
||||||
|
source_type text not null,
|
||||||
|
source_value text not null,
|
||||||
|
market text not null default '',
|
||||||
|
status text not null check (status in ('running', 'succeeded', 'failed')),
|
||||||
|
imported_tracks integer not null default 0 check (imported_tracks >= 0),
|
||||||
|
updated_tracks integer not null default 0 check (updated_tracks >= 0),
|
||||||
|
skipped integer not null default 0 check (skipped >= 0),
|
||||||
|
warnings jsonb not null default '[]'::jsonb,
|
||||||
|
started_at timestamptz not null default now(),
|
||||||
|
finished_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists track_enrichment (
|
||||||
|
track_id text not null references tracks(id) on delete cascade,
|
||||||
|
provider text not null,
|
||||||
|
musicbrainz_recording_id text not null default '',
|
||||||
|
musicbrainz_artist_id text not null default '',
|
||||||
|
isrc text not null default '',
|
||||||
|
payload jsonb not null default '{}'::jsonb,
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
primary key (track_id, provider)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists provider_cache_expiry_idx on provider_cache (expires_at);
|
||||||
|
create index if not exists import_jobs_provider_started_idx on import_jobs (provider, started_at desc);
|
||||||
|
create index if not exists track_enrichment_isrc_idx on track_enrichment (isrc) where isrc <> '';
|
||||||
|
create index if not exists tracks_external_gin_idx on tracks using gin (external);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
drop index if exists tracks_external_gin_idx;
|
||||||
|
drop table if exists track_enrichment;
|
||||||
|
drop table if exists import_jobs;
|
||||||
|
drop table if exists provider_cache;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
-- name: UpsertTrack :exec
|
||||||
|
insert into tracks (
|
||||||
|
id, title, artist, album, genres, release_date, duration_ms, popularity,
|
||||||
|
explicit, features, external, commercial_boost, quality_penalty, discovery_allowed
|
||||||
|
) values (
|
||||||
|
$1, $2, $3, $4, $5::jsonb, $6, $7, $8,
|
||||||
|
$9, $10::jsonb, $11::jsonb, $12, $13, $14
|
||||||
|
)
|
||||||
|
on conflict (id) do update set
|
||||||
|
title = excluded.title,
|
||||||
|
artist = excluded.artist,
|
||||||
|
album = excluded.album,
|
||||||
|
genres = excluded.genres,
|
||||||
|
release_date = excluded.release_date,
|
||||||
|
duration_ms = excluded.duration_ms,
|
||||||
|
popularity = excluded.popularity,
|
||||||
|
explicit = excluded.explicit,
|
||||||
|
features = excluded.features,
|
||||||
|
external = excluded.external,
|
||||||
|
commercial_boost = excluded.commercial_boost,
|
||||||
|
quality_penalty = excluded.quality_penalty,
|
||||||
|
discovery_allowed = excluded.discovery_allowed,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- name: ListTracks :many
|
||||||
|
select id, title, artist, album, genres, release_date, duration_ms, popularity,
|
||||||
|
explicit, features, external, created_at, updated_at, commercial_boost, quality_penalty, discovery_allowed
|
||||||
|
from tracks
|
||||||
|
order by id;
|
||||||
|
|
||||||
|
-- name: GetTracksByIDs :many
|
||||||
|
select id, title, artist, album, genres, release_date, duration_ms, popularity,
|
||||||
|
explicit, features, external, created_at, updated_at, commercial_boost, quality_penalty, discovery_allowed
|
||||||
|
from tracks
|
||||||
|
where id = any($1::text[])
|
||||||
|
order by id;
|
||||||
|
|
||||||
|
-- name: RecordInteraction :exec
|
||||||
|
insert into interactions (user_id, track_id, type, weight, occurred_at, context, completed_ms)
|
||||||
|
values ($1, $2, $3, $4, $5, $6::jsonb, $7);
|
||||||
|
|
||||||
|
-- name: ListRecentInteractions :many
|
||||||
|
select user_id, track_id, type, weight, occurred_at, context, completed_ms
|
||||||
|
from interactions
|
||||||
|
where occurred_at >= now() - interval '365 days'
|
||||||
|
order by occurred_at desc
|
||||||
|
limit 250000;
|
||||||
|
|
||||||
|
-- name: GetControls :one
|
||||||
|
select user_id, allow_explicit, excluded_tracks, excluded_artists, excluded_genres, postponed_tracks
|
||||||
|
from user_controls
|
||||||
|
where user_id = $1;
|
||||||
|
|
||||||
|
-- name: UpsertControls :exec
|
||||||
|
insert into user_controls (user_id, allow_explicit, excluded_tracks, excluded_artists, excluded_genres, postponed_tracks)
|
||||||
|
values ($1, $2, $3::jsonb, $4::jsonb, $5::jsonb, $6::jsonb)
|
||||||
|
on conflict (user_id) do update set
|
||||||
|
allow_explicit = excluded.allow_explicit,
|
||||||
|
excluded_tracks = excluded.excluded_tracks,
|
||||||
|
excluded_artists = excluded.excluded_artists,
|
||||||
|
excluded_genres = excluded.excluded_genres,
|
||||||
|
postponed_tracks = excluded.postponed_tracks,
|
||||||
|
updated_at = now();
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- name: GetProviderCache :one
|
||||||
|
select provider, item_type, item_id, market, payload, fetched_at, expires_at, coalesce(last_error, '') as last_error
|
||||||
|
from provider_cache
|
||||||
|
where provider = $1 and item_type = $2 and item_id = $3 and market = $4;
|
||||||
|
|
||||||
|
-- name: UpsertProviderCache :exec
|
||||||
|
insert into provider_cache (provider, item_type, item_id, market, payload, fetched_at, expires_at, last_error)
|
||||||
|
values ($1, $2, $3, $4, $5::jsonb, $6, $7, nullif($8, ''))
|
||||||
|
on conflict (provider, item_type, item_id, market) do update set
|
||||||
|
payload = excluded.payload,
|
||||||
|
fetched_at = excluded.fetched_at,
|
||||||
|
expires_at = excluded.expires_at,
|
||||||
|
last_error = excluded.last_error;
|
||||||
|
|
||||||
|
-- name: ProviderCacheStats :one
|
||||||
|
select count(*)::bigint as entries,
|
||||||
|
count(*) filter (where expires_at > now())::bigint as fresh_entries,
|
||||||
|
count(*) filter (where expires_at <= now())::bigint as stale_entries
|
||||||
|
from provider_cache;
|
||||||
|
|
||||||
|
-- name: CreateImportJob :exec
|
||||||
|
insert into import_jobs (id, provider, source_type, source_value, market, status, imported_tracks, updated_tracks, skipped, warnings, started_at)
|
||||||
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11);
|
||||||
|
|
||||||
|
-- name: FinishImportJob :exec
|
||||||
|
update import_jobs
|
||||||
|
set status = $2,
|
||||||
|
imported_tracks = $3,
|
||||||
|
updated_tracks = $4,
|
||||||
|
skipped = $5,
|
||||||
|
warnings = $6::jsonb,
|
||||||
|
finished_at = $7
|
||||||
|
where id = $1;
|
||||||
|
|
||||||
|
-- name: UpsertTrackEnrichment :exec
|
||||||
|
insert into track_enrichment (track_id, provider, musicbrainz_recording_id, musicbrainz_artist_id, isrc, payload, updated_at)
|
||||||
|
values ($1, $2, $3, $4, $5, $6::jsonb, $7)
|
||||||
|
on conflict (track_id, provider) do update set
|
||||||
|
musicbrainz_recording_id = excluded.musicbrainz_recording_id,
|
||||||
|
musicbrainz_artist_id = excluded.musicbrainz_artist_id,
|
||||||
|
isrc = excluded.isrc,
|
||||||
|
payload = excluded.payload,
|
||||||
|
updated_at = excluded.updated_at;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
version: "2"
|
||||||
|
sql:
|
||||||
|
- engine: "postgresql"
|
||||||
|
queries: "queries"
|
||||||
|
schema: "migrations"
|
||||||
|
gen:
|
||||||
|
go:
|
||||||
|
package: "db"
|
||||||
|
out: "internal/storage/postgres/db"
|
||||||
|
sql_package: "pgx/v5"
|
||||||
|
emit_json_tags: true
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Music Unlocker
|
||||||
|
|
||||||
|
Auth-free music metadata service using swingmusic's reverse-engineered clients.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- Uses TOTP with hardcoded secret (same as official Spotify Web Player)
|
||||||
|
- No user authentication required
|
||||||
|
- Gets track/album/playlist metadata from Spotify
|
||||||
|
- Cross-platform links via Song.link API
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/unlocker
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Service runs on port 5000.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `POST /parse` - Parse any music URL
|
||||||
|
- `GET /spotify/track/<id>` - Get track metadata
|
||||||
|
- `GET /spotify/album/<id>` - Get album with tracks
|
||||||
|
- `GET /spotify/playlist/<id>` - Get playlist with tracks
|
||||||
|
- `POST /spotify/search` - Search tracks
|
||||||
|
- `GET /links/<spotify_id>` - Get cross-platform links
|
||||||
|
- `POST /import` - Import from any URL (universal)
|
||||||
|
|
||||||
|
## Integration with Go backend
|
||||||
|
|
||||||
|
The Go backend can call this service when `SPOTIFY_CLIENT_ID` is not set.
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""
|
||||||
|
Music Unlocker - Auth-free music metadata service
|
||||||
|
Wraps swingmusic's reverse-engineered clients
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
# Add swingmusic to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "swingmusic"))
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, request
|
||||||
|
from flask_cors import CORS
|
||||||
|
|
||||||
|
from services.spotify_web_player_client import SpotifyWebPlayerClient
|
||||||
|
from services.songlink_client import SongLinkClient
|
||||||
|
from services.universal_url_parser import UniversalMusicURLParser, MusicService
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
CORS(app)
|
||||||
|
|
||||||
|
# Clients
|
||||||
|
spotify_client = SpotifyWebPlayerClient()
|
||||||
|
songlink_client = SongLinkClient()
|
||||||
|
url_parser = UniversalMusicURLParser()
|
||||||
|
|
||||||
|
|
||||||
|
def track_to_dict(track):
|
||||||
|
"""Convert SpotifyTrack to dict"""
|
||||||
|
return {
|
||||||
|
"id": track.id,
|
||||||
|
"title": track.name,
|
||||||
|
"artist": track.artists[0]["name"] if track.artists else "Unknown",
|
||||||
|
"artists": [a["name"] for a in track.artists],
|
||||||
|
"album": track.album.get("name", "") if track.album else "",
|
||||||
|
"duration_ms": track.duration_ms,
|
||||||
|
"explicit": track.explicit,
|
||||||
|
"external_urls": track.external_urls or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/health", methods=["GET"])
|
||||||
|
def health():
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/parse", methods=["POST"])
|
||||||
|
def parse_url():
|
||||||
|
"""Parse any music service URL"""
|
||||||
|
data = request.get_json()
|
||||||
|
url = data.get("url", "")
|
||||||
|
|
||||||
|
parsed = url_parser.parse_url(url)
|
||||||
|
if not parsed:
|
||||||
|
return jsonify({"error": "Unsupported URL"}), 400
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"service": parsed.service.value,
|
||||||
|
"item_type": parsed.item_type,
|
||||||
|
"id": parsed.id,
|
||||||
|
"metadata": parsed.metadata or {}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/spotify/track/<track_id>", methods=["GET"])
|
||||||
|
def get_track(track_id):
|
||||||
|
"""Get track metadata from Spotify (no auth required)"""
|
||||||
|
try:
|
||||||
|
track = spotify_client.get_track(track_id)
|
||||||
|
if not track:
|
||||||
|
return jsonify({"error": "Track not found"}), 404
|
||||||
|
|
||||||
|
return jsonify(track_to_dict(track))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching track: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/spotify/album/<album_id>", methods=["GET"])
|
||||||
|
def get_album(album_id):
|
||||||
|
"""Get album with tracks from Spotify (no auth required)"""
|
||||||
|
try:
|
||||||
|
album = spotify_client.get_album(album_id)
|
||||||
|
if not album:
|
||||||
|
return jsonify({"error": "Album not found"}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"id": album.id,
|
||||||
|
"title": album.name,
|
||||||
|
"artist": album.artists[0]["name"] if album.artists else "Unknown",
|
||||||
|
"tracks": [track_to_dict(t) for t in (album.tracks or [])],
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching album: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/spotify/playlist/<playlist_id>", methods=["GET"])
|
||||||
|
def get_playlist(playlist_id):
|
||||||
|
"""Get playlist with tracks from Spotify (no auth required)"""
|
||||||
|
try:
|
||||||
|
playlist = spotify_client.get_playlist(playlist_id)
|
||||||
|
if not playlist:
|
||||||
|
return jsonify({"error": "Playlist not found"}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"id": playlist.id,
|
||||||
|
"title": playlist.name,
|
||||||
|
"description": playlist.description,
|
||||||
|
"tracks": [track_to_dict(t) for t in (playlist.tracks or [])],
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching playlist: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/spotify/search", methods=["POST"])
|
||||||
|
def search():
|
||||||
|
"""Search Spotify (no auth required)"""
|
||||||
|
data = request.get_json()
|
||||||
|
query = data.get("q", "")
|
||||||
|
item_type = data.get("type", "track")
|
||||||
|
limit = data.get("limit", 10)
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = spotify_client.search(query, item_type, limit)
|
||||||
|
return jsonify({
|
||||||
|
"results": [track_to_dict(r) for r in results]
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error searching: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/links/<spotify_id>", methods=["GET"])
|
||||||
|
def get_links(spotify_id):
|
||||||
|
"""Get cross-platform links for a Spotify track"""
|
||||||
|
try:
|
||||||
|
links = songlink_client.get_links_from_spotify_id(spotify_id, "track")
|
||||||
|
if not links:
|
||||||
|
return jsonify({"error": "Links not found"}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"spotify_id": links.spotify_id,
|
||||||
|
"isrc": links.isrc,
|
||||||
|
"links": {
|
||||||
|
platform: {"url": link.url, "id": link.id}
|
||||||
|
for platform, link in links.links.items()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting links: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/import", methods=["POST"])
|
||||||
|
def import_track():
|
||||||
|
"""Import track from any URL - returns metadata + cross-platform links"""
|
||||||
|
data = request.get_json()
|
||||||
|
url = data.get("url", "")
|
||||||
|
|
||||||
|
# Parse URL
|
||||||
|
parsed = url_parser.parse_url(url)
|
||||||
|
if not parsed:
|
||||||
|
return jsonify({"error": "Unsupported URL format"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
track_data = None
|
||||||
|
|
||||||
|
# Get metadata based on service
|
||||||
|
if parsed.service == MusicService.SPOTIFY:
|
||||||
|
if parsed.item_type == "track":
|
||||||
|
track = spotify_client.get_track(parsed.id)
|
||||||
|
if track:
|
||||||
|
track_data = track_to_dict(track)
|
||||||
|
elif parsed.item_type == "album":
|
||||||
|
album = spotify_client.get_album(parsed.id)
|
||||||
|
if album and album.tracks:
|
||||||
|
track_data = track_to_dict(album.tracks[0])
|
||||||
|
elif parsed.item_type == "playlist":
|
||||||
|
playlist = spotify_client.get_playlist(parsed.id)
|
||||||
|
if playlist and playlist.tracks:
|
||||||
|
track_data = track_to_dict(playlist.tracks[0])
|
||||||
|
|
||||||
|
# For other services, we'd need their respective clients
|
||||||
|
# For now, return the parsed info
|
||||||
|
|
||||||
|
if not track_data:
|
||||||
|
return jsonify({
|
||||||
|
"parsed": {
|
||||||
|
"service": parsed.service.value,
|
||||||
|
"type": parsed.item_type,
|
||||||
|
"id": parsed.id,
|
||||||
|
},
|
||||||
|
"note": "Metadata fetch for this service not yet implemented"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get cross-platform links
|
||||||
|
links = songlink_client.get_links_from_spotify_id(track_data["id"], "track")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"track": track_data,
|
||||||
|
"links": {
|
||||||
|
platform: link.url
|
||||||
|
for platform, link in (links.links.items() if links else [])
|
||||||
|
} if links else {}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error importing: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
flask>=2.3.0
|
||||||
|
flask-cors>=4.0.0
|
||||||
|
requests>=2.31.0
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Flow Web UI
|
||||||
|
|
||||||
|
A minimal, Tidal-inspired music discovery interface. Paste a song link, get recommendations with links to all streaming services.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
- **Colors**: Black background (#000), cyan accent (#00d4ff), subtle grays
|
||||||
|
- **Typography**: Inter, lightweight with careful tracking
|
||||||
|
- **Spacing**: Generous whitespace, breathable layout
|
||||||
|
- **Style**: No cards, minimal borders, focused on content
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1: Start the backend
|
||||||
|
cd ../backend
|
||||||
|
STORE_DRIVER=memory SEED_DEMO_DATA=true go run ./cmd/api
|
||||||
|
|
||||||
|
# Terminal 2: Serve the frontend (any static server)
|
||||||
|
cd apps/web
|
||||||
|
npx serve . -p 3000
|
||||||
|
# or
|
||||||
|
python3 -m http.server 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:3000
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Paste any Spotify, Apple Music, YouTube Music, Tidal, Deezer, or SoundCloud link
|
||||||
|
- Backend imports track and extracts audio features
|
||||||
|
- Recommendation engine uses cosine similarity + collaborative filtering
|
||||||
|
- Results link to all major streaming services via Songlink
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `index.html` — Structure and Tidal-inspired styling
|
||||||
|
- `app.js` — URL parsing, API calls, recommendation display
|
||||||
|
|
||||||
|
The backend handles all the heavy lifting: track import, feature extraction, and the recommendation algorithm.
|
||||||
+329
@@ -0,0 +1,329 @@
|
|||||||
|
const API_BASE = window.location.hostname === 'localhost' ? 'http://localhost:8080' : '';
|
||||||
|
|
||||||
|
const urlInput = document.getElementById('urlInput');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const loading = document.getElementById('loading');
|
||||||
|
const error = document.getElementById('error');
|
||||||
|
const seedTrack = document.getElementById('seedTrack');
|
||||||
|
const recommendations = document.getElementById('recommendations');
|
||||||
|
const emptyState = document.getElementById('emptyState');
|
||||||
|
|
||||||
|
const seedArtwork = document.getElementById('seedArtwork');
|
||||||
|
const seedTitle = document.getElementById('seedTitle');
|
||||||
|
const seedArtist = document.getElementById('seedArtist');
|
||||||
|
const recList = document.getElementById('recList');
|
||||||
|
|
||||||
|
const STREAMING_SERVICES = [
|
||||||
|
{ id: 'spotify', name: 'Spotify', color: '#1DB954' },
|
||||||
|
{ id: 'apple', name: 'Apple', color: '#FA2D48' },
|
||||||
|
{ id: 'youtube', name: 'YouTube', color: '#FF0000' },
|
||||||
|
{ id: 'tidal', name: 'Tidal', color: '#00D4FF' },
|
||||||
|
{ id: 'deezer', name: 'Deezer', color: '#FF0092' },
|
||||||
|
{ id: 'soundcloud', name: 'SoundCloud', color: '#FF5500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
error.textContent = msg;
|
||||||
|
error.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideError() {
|
||||||
|
error.classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(isLoading) {
|
||||||
|
loading.classList.toggle('visible', isLoading);
|
||||||
|
submitBtn.disabled = isLoading;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUrl(url) {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
const uriMatch = trimmed.match(/^spotify:(track|album|playlist|artist):([a-zA-Z0-9]+)$/i);
|
||||||
|
if (uriMatch) {
|
||||||
|
return { type: 'spotify', itemType: uriMatch[1].toLowerCase(), id: uriMatch[2], url: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
|
||||||
|
const parts = parsed.pathname.split('/').filter(Boolean);
|
||||||
|
if (parts[0]?.startsWith('intl-')) parts.shift();
|
||||||
|
if (parts[0] === 'embed') parts.shift();
|
||||||
|
|
||||||
|
if ((host === 'open.spotify.com' || host === 'play.spotify.com') && parts.length >= 2) {
|
||||||
|
const itemType = parts[0].toLowerCase();
|
||||||
|
if (['track', 'album', 'playlist', 'artist'].includes(itemType)) {
|
||||||
|
return { type: 'spotify', itemType, id: parts[1], url: trimmed };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host === 'music.apple.com' && parts.length >= 3) {
|
||||||
|
const itemType = parts.includes('playlist') ? 'playlist' : parsed.searchParams.has('i') ? 'song' : parts[2];
|
||||||
|
const id = parsed.searchParams.get('i') || parts.at(-1);
|
||||||
|
return { type: 'apple', itemType, id, url: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host === 'music.youtube.com' || host === 'youtube.com' || host === 'm.youtube.com' || host === 'youtu.be') {
|
||||||
|
const id = parsed.searchParams.get('v') || parsed.searchParams.get('list') || parts[0];
|
||||||
|
const itemType = parsed.searchParams.has('list') && !parsed.searchParams.has('v') ? 'playlist' : 'video';
|
||||||
|
if (id) return { type: host === 'music.youtube.com' ? 'youtube_music' : 'youtube', itemType, id, url: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host === 'tidal.com' || host === 'listen.tidal.com') {
|
||||||
|
const itemIndex = parts.findIndex(part => ['track', 'album', 'playlist', 'artist'].includes(part));
|
||||||
|
if (itemIndex >= 0 && parts[itemIndex + 1]) {
|
||||||
|
return { type: 'tidal', itemType: parts[itemIndex], id: parts[itemIndex + 1], url: trimmed };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host.endsWith('deezer.com')) {
|
||||||
|
const itemIndex = parts.findIndex(part => ['track', 'album', 'playlist', 'artist'].includes(part));
|
||||||
|
if (itemIndex >= 0 && parts[itemIndex + 1]) {
|
||||||
|
return { type: 'deezer', itemType: parts[itemIndex], id: parts[itemIndex + 1], url: trimmed };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host === 'soundcloud.com' && parts.length >= 2) {
|
||||||
|
const itemType = parts[1] === 'sets' ? 'playlist' : 'track';
|
||||||
|
const id = itemType === 'playlist' ? `${parts[0]}/sets/${parts[2] || ''}` : `${parts[0]}/${parts[1]}`;
|
||||||
|
return { type: 'soundcloud', itemType, id, url: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host.endsWith('bandcamp.com') && parts.length >= 2 && ['track', 'album'].includes(parts[0])) {
|
||||||
|
return { type: 'bandcamp', itemType: parts[0], id: `${host.split('.')[0]}/${parts.slice(1).join('/')}`, url: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importTrack(parsed) {
|
||||||
|
// The backend imports Spotify directly and resolves other supported song URLs through Song.link.
|
||||||
|
const resp = await fetch(`${API_BASE}/v1/providers/spotify/import`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
source: { type: 'url', value: parsed.url },
|
||||||
|
market: 'US',
|
||||||
|
enrich_musicbrainz: true,
|
||||||
|
persist: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || 'Failed to import track');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRecommendations(seedTrackId, limit = 10) {
|
||||||
|
const resp = await fetch(`${API_BASE}/v1/recommendations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: 'anonymous-user',
|
||||||
|
seed_track_ids: [seedTrackId],
|
||||||
|
limit: limit,
|
||||||
|
mode: 'balanced'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || 'Failed to get recommendations');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSonglinkUrls(title, artist) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`https://api.song.link/v1-alpha.1/links?userCountry=US&songTitle=${encodeURIComponent(title)}&artistName=${encodeURIComponent(artist)}`);
|
||||||
|
if (!resp.ok) return {};
|
||||||
|
const data = await resp.json();
|
||||||
|
return data.linksByPlatform || {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displaySeedTrack(track) {
|
||||||
|
seedTitle.textContent = track.title;
|
||||||
|
seedArtist.textContent = track.artist;
|
||||||
|
|
||||||
|
if (track.external?.spotify) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = `https://open.spotify.com/oembed?url=${encodeURIComponent(track.external.spotify)}`;
|
||||||
|
// Note: In production, use proper album art from the API
|
||||||
|
seedArtwork.innerHTML = '♪';
|
||||||
|
}
|
||||||
|
|
||||||
|
seedTrack.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createServiceLinks(songlinkData, track) {
|
||||||
|
const links = [];
|
||||||
|
|
||||||
|
const serviceMap = {
|
||||||
|
spotify: 'Spotify',
|
||||||
|
appleMusic: 'Apple',
|
||||||
|
youtubeMusic: 'YouTube',
|
||||||
|
youtube: 'YouTube',
|
||||||
|
tidal: 'Tidal',
|
||||||
|
deezer: 'Deezer',
|
||||||
|
soundcloud: 'SoundCloud'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [platform, label] of Object.entries(serviceMap)) {
|
||||||
|
const data = songlinkData[platform];
|
||||||
|
if (data?.url) {
|
||||||
|
const svc = STREAMING_SERVICES.find(s =>
|
||||||
|
s.id === platform.toLowerCase().replace('music', '') ||
|
||||||
|
s.name === label
|
||||||
|
) || { name: label, color: '#666' };
|
||||||
|
|
||||||
|
links.push({
|
||||||
|
name: svc.name,
|
||||||
|
url: data.url,
|
||||||
|
color: svc.color
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: always include search links
|
||||||
|
const query = encodeURIComponent(`${track.title} ${track.artist}`);
|
||||||
|
const fallbacks = [
|
||||||
|
{ name: 'Spotify', url: `https://open.spotify.com/search/${query}` },
|
||||||
|
{ name: 'YouTube', url: `https://music.youtube.com/search?q=${query}` },
|
||||||
|
{ name: 'Apple', url: `https://music.apple.com/us/search?term=${query}` },
|
||||||
|
{ name: 'Tidal', url: `https://tidal.com/search?q=${query}` },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add any missing services
|
||||||
|
for (const fb of fallbacks) {
|
||||||
|
if (!links.find(l => l.name === fb.name)) {
|
||||||
|
const svc = STREAMING_SERVICES.find(s => s.name === fb.name);
|
||||||
|
links.push({ ...fb, color: svc?.color || '#666' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return links.slice(0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayRecommendations(recs) {
|
||||||
|
recList.innerHTML = '';
|
||||||
|
|
||||||
|
recs.forEach((rec, i) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'rec-item';
|
||||||
|
|
||||||
|
const rank = document.createElement('div');
|
||||||
|
rank.className = 'rec-rank';
|
||||||
|
rank.textContent = rec.rank || i + 1;
|
||||||
|
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.className = 'rec-info';
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'rec-title';
|
||||||
|
title.textContent = rec.track.title;
|
||||||
|
|
||||||
|
const artist = document.createElement('div');
|
||||||
|
artist.className = 'rec-artist';
|
||||||
|
artist.textContent = rec.track.artist;
|
||||||
|
|
||||||
|
const reason = document.createElement('div');
|
||||||
|
reason.className = 'rec-reason';
|
||||||
|
reason.textContent = rec.reason || '';
|
||||||
|
|
||||||
|
info.appendChild(title);
|
||||||
|
info.appendChild(artist);
|
||||||
|
if (rec.reason) info.appendChild(reason);
|
||||||
|
|
||||||
|
const links = document.createElement('div');
|
||||||
|
links.className = 'rec-links';
|
||||||
|
|
||||||
|
// Generate links for this track
|
||||||
|
const serviceLinks = createServiceLinks({}, rec.track);
|
||||||
|
serviceLinks.forEach(svc => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.className = 'service-link';
|
||||||
|
a.href = svc.url;
|
||||||
|
a.target = '_blank';
|
||||||
|
a.rel = 'noopener';
|
||||||
|
a.title = `Open in ${svc.name}`;
|
||||||
|
a.textContent = svc.name.charAt(0);
|
||||||
|
a.style.borderColor = svc.color + '40';
|
||||||
|
a.style.color = svc.color;
|
||||||
|
links.appendChild(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
item.appendChild(rank);
|
||||||
|
item.appendChild(info);
|
||||||
|
item.appendChild(links);
|
||||||
|
recList.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
recommendations.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const url = urlInput.value.trim();
|
||||||
|
if (!url) {
|
||||||
|
showError('Please enter a song URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseUrl(url);
|
||||||
|
if (!parsed) {
|
||||||
|
showError('Unsupported URL format. Try a Spotify, Apple Music, or YouTube Music link.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hideError();
|
||||||
|
setLoading(true);
|
||||||
|
emptyState.style.display = 'none';
|
||||||
|
seedTrack.classList.remove('visible');
|
||||||
|
recommendations.classList.remove('visible');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Import the track
|
||||||
|
const imported = await importTrack(parsed);
|
||||||
|
|
||||||
|
if (!imported.track) {
|
||||||
|
throw new Error('Could not extract track information');
|
||||||
|
}
|
||||||
|
|
||||||
|
displaySeedTrack(imported.track);
|
||||||
|
|
||||||
|
// Get recommendations
|
||||||
|
const recs = await getRecommendations(imported.track.id, 10);
|
||||||
|
displayRecommendations(recs);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message || 'Something went wrong. Please try again.');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
submitBtn.addEventListener('click', handleSubmit);
|
||||||
|
|
||||||
|
urlInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus input on load
|
||||||
|
urlInput.focus();
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Flow — Music Discovery</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-primary: #000000;
|
||||||
|
--bg-secondary: #0a0a0a;
|
||||||
|
--bg-tertiary: #121212;
|
||||||
|
--bg-input: #181818;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #a0a0a0;
|
||||||
|
--text-muted: #6a6a6a;
|
||||||
|
--accent: #00d4ff;
|
||||||
|
--accent-dim: rgba(0, 212, 255, 0.1);
|
||||||
|
--border: #2a2a2a;
|
||||||
|
--radius: 12px;
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--space-xs: 0.5rem;
|
||||||
|
--space-sm: 0.75rem;
|
||||||
|
--space-md: 1.25rem;
|
||||||
|
--space-lg: 2rem;
|
||||||
|
--space-xl: 3rem;
|
||||||
|
--space-2xl: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-xl) var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: var(--space-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo span {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input Section */
|
||||||
|
.input-section {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--space-xl);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--space-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s, transform 0.1s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Seed Track */
|
||||||
|
.seed-track {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seed-track.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seed-header {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artwork {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artwork img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-meta h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-meta p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recommendations */
|
||||||
|
.recommendations {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-item {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--space-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-item:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-rank {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-title {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-artist {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-reason {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-links {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-link {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-link:hover {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-2xl);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin: 0 auto var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error */
|
||||||
|
.error {
|
||||||
|
background: rgba(255, 50, 50, 0.1);
|
||||||
|
border: 1px solid rgba(255, 50, 50, 0.3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--space-md);
|
||||||
|
color: #ff6666;
|
||||||
|
margin-top: var(--space-md);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-2xl) var(--space-lg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: var(--space-2xl);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.container {
|
||||||
|
padding: var(--space-lg) var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-wrapper {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-links {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
max-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header class="header">
|
||||||
|
<div class="logo">Flow<span>.</span></div>
|
||||||
|
<p class="tagline">Paste a song link. Discover more.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="input-section">
|
||||||
|
<label class="input-label">Song Link</label>
|
||||||
|
<div class="url-input-wrapper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="url-input"
|
||||||
|
id="urlInput"
|
||||||
|
placeholder="https://open.spotify.com/track/..."
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
|
<button class="submit-btn" id="submitBtn">Discover</button>
|
||||||
|
</div>
|
||||||
|
<div class="error" id="error"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="loading" id="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Finding similar music...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="seed-track" id="seedTrack">
|
||||||
|
<div class="seed-header">Starting From</div>
|
||||||
|
<div class="track-info">
|
||||||
|
<div class="track-artwork" id="seedArtwork">♪</div>
|
||||||
|
<div class="track-meta">
|
||||||
|
<h3 id="seedTitle">—</h3>
|
||||||
|
<p id="seedArtist">—</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="recommendations" id="recommendations">
|
||||||
|
<div class="section-title">Recommendations</div>
|
||||||
|
<div class="rec-list" id="recList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty-state" id="emptyState">
|
||||||
|
<p>Supports Spotify, Apple Music, YouTube Music, Tidal, Deezer, SoundCloud</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<p>Powered by <a href="https://developer.spotify.com/documentation/web-api" target="_blank">Spotify API</a> & <a href="https://song.link" target="_blank">Songlink</a></p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "flow-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Minimal music discovery UI",
|
||||||
|
"scripts": {
|
||||||
|
"start": "npx serve . -p 3000 -s",
|
||||||
|
"dev": "npx serve . -p 3000 -s"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: spotify
|
||||||
|
POSTGRES_PASSWORD: spotify
|
||||||
|
POSTGRES_DB: spotifyrec
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- spotifyrec-postgres:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U spotify -d spotifyrec"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ../apps/backend
|
||||||
|
environment:
|
||||||
|
APP_ENV: development
|
||||||
|
HTTP_ADDR: :8080
|
||||||
|
STORE_DRIVER: postgres
|
||||||
|
DATABASE_URL: postgres://spotify:spotify@postgres:5432/spotifyrec?sslmode=disable
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
command:
|
||||||
|
- /app/recommendation-api
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
spotifyrec-postgres:
|
||||||
+255
@@ -0,0 +1,255 @@
|
|||||||
|
# 🎵 Demystifying Spotify's Recommendation Algorithm: A Deep Dive
|
||||||
|
|
||||||
|
[](https://developer.spotify.com/)
|
||||||
|
[](https://python.org)
|
||||||
|
[](https://keras.io/)
|
||||||
|
[](https://scikit-learn.org/)
|
||||||
|
|
||||||
|
> **Disclaimer:** This repository serves as a comprehensive, open-source analysis of how Spotify's recommendation engine operates. Because Spotify's actual production code is proprietary and confidential, the technical architectures, mathematical models, and Python implementations provided here are based on a synthesis of Spotify's official documentation, academic research papers, and widely accepted machine learning theories.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📑 Table of Contents
|
||||||
|
1. [Introduction & Philosophy](#-introduction--philosophy)
|
||||||
|
2. [The Dual-Pillar Approach: Human + Machine](#-the-dual-pillar-approach-human--machine)
|
||||||
|
3. [Data Inputs: Constructing the "Taste Profile"](#-data-inputs-constructing-the-taste-profile)
|
||||||
|
4. [The Core Algorithmic Strategies](#-the-core-algorithmic-strategies)
|
||||||
|
- [Exploitative Filtering (Collaborative)](#41-exploitative-filtering-collaborative)
|
||||||
|
- [Explorative Filtering (Content-Based)](#42-explorative-filtering-content-based)
|
||||||
|
5. [Deep Learning & Advanced Modeling](#-deep-learning--advanced-modeling)
|
||||||
|
- [Neural Network Architecture](#51-neural-network-architecture)
|
||||||
|
- [Clustering & Similarity Metrics](#52-clustering--similarity-metrics)
|
||||||
|
6. [Mathematical Foundations](#-mathematical-foundations)
|
||||||
|
7. [Safety, Ethics & User Controls](#-safety-ethics--user-controls)
|
||||||
|
8. [Commercial Influence: Discovery Mode](#-commercial-influence-discovery-mode)
|
||||||
|
9. [Recreating the System: Technical Blueprint](#-recreating-the-system-technical-blueprint)
|
||||||
|
10. [Conclusion & Future Work](#-conclusion--future-work)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 Introduction & Philosophy
|
||||||
|
|
||||||
|
Spotify hosts over 50 million songs and 4 billion playlists, generating upwards of 600 GB of data daily. With over 500 million monthly users, Spotify's near-monopoly in the audio streaming market is largely attributed to its ability to solve the "paradox of choice" through personalization.
|
||||||
|
|
||||||
|
According to Spotify's official stance, their recommendation system is not designed merely to optimize for clicks or streams. Instead, the goal is to **evolve with the user's taste**, fostering meaningful connections between listeners and creators. No two listeners are the same; therefore, every environment—from the Home screen to Search results and personalized playlists—is uniquely tailored.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚖️ The Dual-Pillar Approach: Human + Machine
|
||||||
|
|
||||||
|
Spotify's recommendations are driven by two distinct pillars:
|
||||||
|
|
||||||
|
### 1. Editorial Curation (The Human Element)
|
||||||
|
Spotify employs human editors worldwide who possess deep knowledge of local music and cultural trends. They use data, a sharp ear for music, and cultural awareness to place content where it will resonate most. Examples include genre-specific mood playlists (e.g., "RapCaviar") or culturally significant collections.
|
||||||
|
|
||||||
|
### 2. Algorithmic Personalization (The Machine Element)
|
||||||
|
This is where the core machine learning happens. Algorithms select and rank content for the Home screen, Search, and personalized playlists (like *Discover Weekly* or *Release Radar*). They rely on a balance of historical user data and real-time content analysis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📥 Data Inputs: Constructing the "Taste Profile"
|
||||||
|
|
||||||
|
To algorithmically recommend content, Spotify constructs a dynamic **"Taste Profile"** for every user. This profile is influenced by four main categories of data:
|
||||||
|
|
||||||
|
1. **Implicit & Explicit User Behavior:**
|
||||||
|
* *What you do:* Listening history, skipping tracks, saving to "Your Library," playlist creation.
|
||||||
|
* *Example:* If you listen to an artist repeatedly, the algorithm feeds you more of that artist. If you search for "decent country and rock," it generates a specific playlist based on that query.
|
||||||
|
2. **User Metadata:**
|
||||||
|
* *Who you are:* General location (not precise), device type, language, age, and who you follow.
|
||||||
|
* *Example:* Selecting German as your language prioritizes German podcasts. Listening to classical music on a desktop client changes desktop Home screen recommendations.
|
||||||
|
3. **Global Trends & Social Signals:**
|
||||||
|
* *What others do:* Aggregate behavior across the platform.
|
||||||
|
* *Example:* If many users interact positively with a specific search result, it gets boosted for similar users.
|
||||||
|
4. **Content Metadata:**
|
||||||
|
* *What the content is:* Genre, release date, podcast category, and relational data (e.g., if a podcast guest wrote a book, the book might be recommended).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ The Core Algorithmic Strategies
|
||||||
|
|
||||||
|
At the heart of Spotify's machine learning engine lies a dual strategy: **Exploitation** and **Exploration**. A successful recommendation system must keep the user in their "comfort zone" while simultaneously expanding their musical horizons.
|
||||||
|
|
||||||
|
### 4.1 Exploitative Filtering (Collaborative)
|
||||||
|
Exploitation relies on *existing data* regarding likes and dislikes. It assumes that if User A and User B agreed in the past, they will agree in the future. It branches into two sub-types:
|
||||||
|
* **History-Based:** Recommending content based on what the active user has listened to before.
|
||||||
|
* **Socially Similar (User-User Collaborative Filtering):** Creating a "network" of neighbors. If User A likes a song, and User B is mathematically determined to be a "close neighbor" to User A, User B gets the recommendation.
|
||||||
|
|
||||||
|
**The Flaws of Exploitation:**
|
||||||
|
* *Cold Start Problem:* Requires substantial data before it can make recommendations.
|
||||||
|
* *Popularity Bias:* Skews toward mainstream music because popular songs appear in many users' histories, regardless of niche taste.
|
||||||
|
* *Heterogeneity:* Fails to account for the diverse ways people consume content (e.g., party music vs. sleep music for the same user).
|
||||||
|
|
||||||
|
### 4.2 Explorative Filtering (Content-Based)
|
||||||
|
Exploration solves the flaws of exploitation by looking *only at the characteristics of the content itself*, completely independent of user history. It analyzes the raw audio and metadata of a track.
|
||||||
|
* *Example:* A pop listener gets a pop-punk track injected into their *Daily Mix*. The algorithm isn't recommending it because similar users liked it; it's recommending it because the tempo, acousticness, and energy closely match the user's typical pop tracks, pushing them slightly out of their comfort zone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤖 Deep Learning & Advanced Modeling
|
||||||
|
|
||||||
|
To process the massive scale of data and capture complex, non-linear patterns, Spotify heavily relies on Deep Learning (DL). DL allows the system to extract high-level representations of both acoustic features (via Convolutional Neural Networks) and sequential listening habits (via Recurrent Neural Networks).
|
||||||
|
|
||||||
|
### 5.1 Neural Network Architecture
|
||||||
|
For content-based exploration, a Deep Learning model can predict user "likeability" (binary classification: 0 for dislike, 1 for like) based purely on a song's audio features.
|
||||||
|
|
||||||
|
**The Architecture Blueprint:**
|
||||||
|
* **Input Layer:** Accepts scaled numerical audio features (e.g., Danceability, Energy, Valence).
|
||||||
|
* **Hidden Layers (Dense/ Fully Connected):** Performs linear transformations (multiplying inputs by a weight matrix and adding a bias vector).
|
||||||
|
* **Activation Functions:**
|
||||||
|
* *ReLU (Rectified Linear Unit):* Applied to hidden layers to introduce non-linearity, allowing the model to learn complex patterns.
|
||||||
|
* *Sigmoid:* Applied to the output layer to squash the result into a probability between 0 and 1.
|
||||||
|
* **Optimizer:** *Adam* (Adaptive Moment Estimation) is used to adjust learning rates dynamically based on gradient moments.
|
||||||
|
* **Loss Function:** *Binary Cross-Entropy*, which penalizes inaccurate predictions.
|
||||||
|
|
||||||
|
### 5.2 Clustering & Similarity Metrics
|
||||||
|
Another highly effective approach (combining ML with DL interpretability) is using **KMeans Clustering** paired with **Logistic Regression**.
|
||||||
|
1. **Clustering:** Songs are grouped into distinct clusters based on audio features (e.g., using the Elbow method to find the optimal number of clusters, *k*).
|
||||||
|
2. **Classification:** A Logistic Regression model is trained to predict which cluster a song belongs to.
|
||||||
|
3. **Vectorization & Cosine Distance:** When a user inputs a few songs they like, the system calculates the "mean vector" (average audio features) of those songs. It then calculates the *Cosine Distance* between this mean vector and all other song vectors in the dataset, recommending the tracks with the lowest distance (highest similarity).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧮 Mathematical Foundations
|
||||||
|
|
||||||
|
To recreate these systems, the following mathematical formulations are required:
|
||||||
|
|
||||||
|
**1. Pearson Correlation Coefficient (for User Similarity in Collaborative Filtering):**
|
||||||
|
$$c_{a,u} = \frac{cov(r_a, r_u)}{\sigma_{r_a} \sigma_{r_u}}$$
|
||||||
|
*(Where $cov$ is covariance between active user $a$ and user $u$, and $\sigma$ is the standard deviation of their ratings).*
|
||||||
|
|
||||||
|
**2. Min-Max Scaling (for Data Preprocessing):**
|
||||||
|
Audio features have vastly different scales (Loudness is in decibels, Acousticness is 0 to 1). They must be normalized:
|
||||||
|
$$F(x) = \frac{x - x_{min}}{x_{max} - x_{min}}$$
|
||||||
|
|
||||||
|
**3. ReLU & Sigmoid Activation Functions:**
|
||||||
|
$$f_{ReLU} = \max(0, x)$$
|
||||||
|
$$f_{sigmoid} = \frac{1}{1 + e^{-x}}$$
|
||||||
|
|
||||||
|
**4. Adam Optimizer Update Rule:**
|
||||||
|
$$w = w - \alpha \cdot \left(\frac{m_0}{\sqrt{m_1} + \epsilon}\right)$$
|
||||||
|
*(Where $m_0$ is the first moment/mean of gradients, $m_1$ is the second moment/variance, $\alpha$ is the learning rate, and $\epsilon$ prevents division by zero).*
|
||||||
|
|
||||||
|
**5. Binary Cross-Entropy Loss:**
|
||||||
|
$$Loss = -\frac{1}{N} \sum_{i=1}^{N} \left(y_i \cdot \log(p_i) + (1 - y_i) \cdot \log(1 - p_i)\right)$$
|
||||||
|
|
||||||
|
**6. Cosine Similarity / Distance:**
|
||||||
|
$$\text{Cosine Similarity} = \frac{A \cdot B}{||A|| \times ||B||}$$
|
||||||
|
*(Distance is simply $1 - \text{Similarity}$).*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ Safety, Ethics & User Controls
|
||||||
|
|
||||||
|
Spotify acknowledges the profound impact algorithms have on listeners and creators. Recommendations are strictly bound by **Spotify's Platform Rules**. If content violates rules, algorithms are instructed to limit its reach.
|
||||||
|
|
||||||
|
Crucially, Spotify provides users with tools to manipulate their "Taste Profile":
|
||||||
|
* **Explicit Exclusion:** Removing a playlist from the taste profile stops it from influencing future recommendations.
|
||||||
|
* **Negative Feedback:** Clicking "Hide," "Don't suggest," or the "X" button reduces similar recommendations.
|
||||||
|
* **Guided Listening:** Using the AI DJ, selecting specific genres for *Discover Weekly*, or using mood filters.
|
||||||
|
* **Smart Shuffle vs. Standard Shuffle:** Smart shuffle injects explorative recommendations into a playlist, while Standard shuffle is purely random.
|
||||||
|
* **Postpone/Hide:** Premium users can hide a song for 30 days across the entire platform.
|
||||||
|
* **Autoplay Toggle:** Users can completely disable algorithmic song continuation at the end of an album/playlist.
|
||||||
|
* **Explicit Filter:** Hides all explicit content from recommendations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💰 Commercial Influence: Discovery Mode
|
||||||
|
|
||||||
|
Algorithms are not entirely divorced from business needs. **Discovery Mode** is a tool where artists and labels can flag a specific song as a priority.
|
||||||
|
* **How it works:** The algorithm receives a "boost signal" for that track, increasing the probability it will appear in personalized algorithmic contexts (like *Release Radar*).
|
||||||
|
* **Constraints:** It does *not* affect editorial playlists. It does *not* guarantee a listen. If a user skips the track, the algorithm registers the negative feedback and stops recommending it.
|
||||||
|
* **Cost:** Spotify charges a lower royalty rate for streams generated through Discovery Mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Recreating the System: Technical Blueprint
|
||||||
|
|
||||||
|
Based on the synthesis of academic research, here is how you can build a miniature version of Spotify's recommendation engine.
|
||||||
|
|
||||||
|
### Step 1: Data Collection (The Spotify API)
|
||||||
|
Use the `Spotipy` library in Python to extract data. You need a song's unique Track ID.
|
||||||
|
```python
|
||||||
|
import spotipy
|
||||||
|
from spotipy.oauth2 import SpotifyClientCredentials
|
||||||
|
|
||||||
|
sp = spotipy.Spotify(auth_manager=SpotifyClientCredentials(client_id="YOUR_ID", client_secret="YOUR_SECRET"))
|
||||||
|
|
||||||
|
# Extract audio features for a track
|
||||||
|
features = sp.audio_features('3n3Ppam7vgaVa1iaRUc9Lp')[0]
|
||||||
|
print(features['danceability'], features['energy'], features['tempo'])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Feature Engineering & Preprocessing
|
||||||
|
Extract the 10-13 core numerical features: `danceability, energy, loudness, speechiness, acousticness, instrumentalness, liveness, valence, tempo, time_signature`.
|
||||||
|
|
||||||
|
Apply **Min-Max Scaling** to bring them all to a `[0, 1]` range.
|
||||||
|
```python
|
||||||
|
from sklearn.preprocessing import MinMaxScaler
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
scaler = MinMaxScaler()
|
||||||
|
df_scaled = pd.DataFrame(scaler.fit_transform(df[numeric_columns]), columns=numeric_columns)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: The Deep Learning Approach (Keras)
|
||||||
|
Build a Dense neural network to predict if a user will like a song based on features.
|
||||||
|
```python
|
||||||
|
from keras.models import Sequential
|
||||||
|
from keras.layers import Dense
|
||||||
|
from keras.optimizers import Adam
|
||||||
|
|
||||||
|
model = Sequential()
|
||||||
|
model.add(Dense(64, input_dim=10, activation='relu')) # Hidden layer 1
|
||||||
|
model.add(Dense(32, activation='relu')) # Hidden layer 2
|
||||||
|
model.add(Dense(1, activation='sigmoid')) # Output layer (Like/Dislike)
|
||||||
|
|
||||||
|
model.compile(optimizer=Adam(learning_rate=0.001),
|
||||||
|
loss='binary_crossentropy',
|
||||||
|
metrics=['accuracy'])
|
||||||
|
|
||||||
|
model.fit(X_train, y_train, epochs=50, validation_data=(X_val, y_val))
|
||||||
|
```
|
||||||
|
*Expected Result:* High training accuracy (~98%), moderate validation accuracy (~80%) due to the heterogeneity of human taste.
|
||||||
|
|
||||||
|
### Step 4: The Clustering Approach (Scikit-Learn)
|
||||||
|
Alternatively, group songs into clusters and recommend via Cosine Distance.
|
||||||
|
```python
|
||||||
|
from sklearn.cluster import KMeans
|
||||||
|
from sklearn.metrics.pairwise import cosine_similarity
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# 1. Cluster the dataset
|
||||||
|
kmeans = KMeans(n_clusters=5, random_state=42)
|
||||||
|
kmeans.fit(df_scaled)
|
||||||
|
df_scaled['cluster'] = kmeans.labels_
|
||||||
|
|
||||||
|
# 2. Get mean vector of user's liked songs
|
||||||
|
user_songs = df_scaled[df_scaled['liked'] == 1]
|
||||||
|
mean_vector = user_songs.mean(axis=0).drop('cluster').values.reshape(1, -1)
|
||||||
|
|
||||||
|
# 3. Calculate Cosine Distance
|
||||||
|
distances = cosine_similarity(mean_vector, df_scaled.drop('cluster', axis=1))
|
||||||
|
df_scaled['similarity'] = distances[0]
|
||||||
|
|
||||||
|
# 4. Recommend top N songs not already liked
|
||||||
|
recommendations = df_scaled[df_scaled['liked'] == 0].sort_values(by='similarity', ascending=False).head(10)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Build the UI (Streamlit)
|
||||||
|
Wrap the backend in a user-friendly web app where users can input songs and manually adjust sliders for "Energy", "Valence", etc., to see real-time recommendation updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏁 Conclusion & Future Work
|
||||||
|
|
||||||
|
Spotify's recommendation system is a masterclass in balancing **Collaborative Filtering** (exploiting what is known) with **Content-Based Filtering** (exploring the unknown), all layered under rigorous safety controls and commercial frameworks like Discovery Mode.
|
||||||
|
|
||||||
|
Deep Learning elevates this system by automatically extracting high-level features from audio files and understanding the sequential nature of human listening habits (via RNNs/LSTMs).
|
||||||
|
|
||||||
|
**Limitations of Current Models & Future Work:**
|
||||||
|
* **Cold Start for New Users:** Pure content-based models struggle with brand-new users. Future systems must better leverage zero-shot learning from minimal demographic/contextual data.
|
||||||
|
* **Overfitting in DL:** As seen in academic reproductions (98% train vs 80% val accuracy), dense networks can overfit to specific users. Implementing Dropout layers or switching to Graph Neural Networks (GNNs) could improve generalization.
|
||||||
|
* **Contextual Awareness:** Future recommenders will likely integrate time-of-day, weather, and biometric data (e.g., from smartwatches) to transition from *Taste Profiles* to *State Profiles*.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Built with ❤️ using insights from Spotify Engineering, academic research by Maheshwaria et al., and Bangera et al.*
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
Pipfile.lock
|
||||||
|
|
||||||
|
# PEP 582
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# Flask specific
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Static files and media
|
||||||
|
static/
|
||||||
|
media/
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Local configuration
|
||||||
|
config_local.py
|
||||||
|
settings_local.py
|
||||||
|
.secrets
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import os
|
||||||
|
from importlib import metadata as importlib_metadata
|
||||||
|
|
||||||
|
try:
|
||||||
|
__version__ = importlib_metadata.version("swingmusic")
|
||||||
|
except importlib_metadata.PackageNotFoundError:
|
||||||
|
# fallback to version.txt
|
||||||
|
version_file = os.path.join(os.path.dirname(__file__), "..", "..", "version.txt")
|
||||||
|
with open(version_file) as f:
|
||||||
|
__version__ = f.read().strip()
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import argparse
|
||||||
|
import contextlib
|
||||||
|
import multiprocessing
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from swingmusic import settings
|
||||||
|
from swingmusic import tools as swing_tools
|
||||||
|
from swingmusic.logger import setup_logger
|
||||||
|
from swingmusic.settings import AssetHandler, Metadata
|
||||||
|
from swingmusic.start_swingmusic import start_swingmusic
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="swingmusic",
|
||||||
|
description="Awesome Music",
|
||||||
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-v", "--version", action="version", version=f"swingmusic v{Metadata.version}"
|
||||||
|
)
|
||||||
|
parser.add_argument("--host", default="0.0.0.0", help="Host to run the app on.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--port", default=1970, help="HTTP port to run the app on.", type=int
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug",
|
||||||
|
default=False,
|
||||||
|
action="store_true",
|
||||||
|
help="If swingmusic should start in debug mode",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--config",
|
||||||
|
default=settings.Paths.get_default_config_parent_dir(),
|
||||||
|
help="The directory to setup the config folder.",
|
||||||
|
type=pathlib.Path,
|
||||||
|
)
|
||||||
|
parser.add_argument("--client", help="Path to the Web UI folder.", type=pathlib.Path)
|
||||||
|
|
||||||
|
tools = parser.add_argument_group(title="Tools")
|
||||||
|
tools.add_argument("--password-reset", help="Reset the password.", action="store_true")
|
||||||
|
|
||||||
|
|
||||||
|
def run(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
Swing Music entry point
|
||||||
|
"""
|
||||||
|
args = parser.parse_args()
|
||||||
|
args = vars(args)
|
||||||
|
|
||||||
|
config_parent = args["config"]
|
||||||
|
client_path = args["client"]
|
||||||
|
|
||||||
|
# INFO: Validate client path
|
||||||
|
if client_path is not None:
|
||||||
|
client_path = pathlib.Path(client_path).resolve()
|
||||||
|
|
||||||
|
if not client_path.exists():
|
||||||
|
print(
|
||||||
|
f"Client path {client_path} does not exist. Please provide a valid path"
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
# INFO: check if client path has index.html
|
||||||
|
if not (client_path / "index.html").exists():
|
||||||
|
print(
|
||||||
|
f"Client path {client_path} does not contain an index.html file. Please provide a valid path"
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
settings.Paths(config_parent=config_parent, client_dir=client_path)
|
||||||
|
AssetHandler.copy_assets_dir()
|
||||||
|
AssetHandler.setup_default_client()
|
||||||
|
|
||||||
|
setup_logger(debug=args["debug"], app_dir=settings.Paths().config_dir)
|
||||||
|
|
||||||
|
# handle tools
|
||||||
|
if args["password_reset"]:
|
||||||
|
swing_tools.handle_password_reset(config_parent)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# start swingmusic
|
||||||
|
start_swingmusic(host=args["host"], port=args["port"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
multiprocessing.freeze_support()
|
||||||
|
# `python -m swingmusic` may run in environments that already selected a
|
||||||
|
# multiprocessing context (for example test runners / embedded launchers).
|
||||||
|
# Keep CLI startup resilient instead of crashing with RuntimeError.
|
||||||
|
with contextlib.suppress(RuntimeError):
|
||||||
|
multiprocessing.set_start_method("spawn")
|
||||||
|
run()
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
Swing Music API package.
|
||||||
|
|
||||||
|
The package intentionally avoids eager imports so a broken or optional API
|
||||||
|
module cannot crash process boot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
_MODULES = {
|
||||||
|
"album": "swingmusic.api.album",
|
||||||
|
"artist": "swingmusic.api.artist",
|
||||||
|
"collections": "swingmusic.api.collections",
|
||||||
|
"colors": "swingmusic.api.colors",
|
||||||
|
"favorites": "swingmusic.api.favorites",
|
||||||
|
"folder": "swingmusic.api.folder",
|
||||||
|
"imgserver": "swingmusic.api.imgserver",
|
||||||
|
"playlist": "swingmusic.api.playlist",
|
||||||
|
"search": "swingmusic.api.search",
|
||||||
|
"settings": "swingmusic.api.settings",
|
||||||
|
"lyrics": "swingmusic.api.lyrics",
|
||||||
|
"plugins": "swingmusic.api.plugins",
|
||||||
|
"scrobble": "swingmusic.api.scrobble",
|
||||||
|
"home": "swingmusic.api.home",
|
||||||
|
"getall": "swingmusic.api.getall",
|
||||||
|
"auth": "swingmusic.api.auth",
|
||||||
|
"stream": "swingmusic.api.stream",
|
||||||
|
"backup_and_restore": "swingmusic.api.backup_and_restore",
|
||||||
|
"spotify": "swingmusic.api.spotify",
|
||||||
|
"spotify_settings": "swingmusic.api.spotify_settings",
|
||||||
|
"enhanced_search": "swingmusic.api.enhanced_search",
|
||||||
|
"universal_downloader": "swingmusic.api.universal_downloader",
|
||||||
|
"music_catalog": "swingmusic.api.music_catalog",
|
||||||
|
"upload": "swingmusic.api.upload",
|
||||||
|
"downloads": "swingmusic.api.downloads",
|
||||||
|
"setup": "swingmusic.api.setup",
|
||||||
|
"plugins_lyrics": "swingmusic.api.plugins.lyrics",
|
||||||
|
"plugins_mixes": "swingmusic.api.plugins.mixes",
|
||||||
|
"dragonfly": "swingmusic.api.dragonfly",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str):
|
||||||
|
module_path = _MODULES.get(name)
|
||||||
|
if module_path is None:
|
||||||
|
raise AttributeError(f"module 'swingmusic.api' has no attribute '{name}'")
|
||||||
|
|
||||||
|
module = importlib.import_module(module_path)
|
||||||
|
globals()[name] = module
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = sorted(_MODULES.keys())
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""Advanced UX endpoints backed by local stores and lightweight persistence."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from swingmusic.services.advanced_ux_store import advanced_ux_store
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
|
||||||
|
advanced_ux_bp = Blueprint("advanced_ux", __name__, url_prefix="/api/ux")
|
||||||
|
|
||||||
|
|
||||||
|
def _user_id() -> int:
|
||||||
|
return int(get_current_userid())
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_limit(value, default: int = 10, max_value: int = 100) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
parsed = default
|
||||||
|
return max(1, min(parsed, max_value))
|
||||||
|
|
||||||
|
|
||||||
|
@advanced_ux_bp.get("/search/suggestions")
|
||||||
|
def search_suggestions():
|
||||||
|
query = str(request.args.get("q") or "")
|
||||||
|
context = str(request.args.get("context") or "general")
|
||||||
|
limit = _safe_limit(request.args.get("limit"), default=10, max_value=50)
|
||||||
|
|
||||||
|
suggestions = advanced_ux_store.search_suggestions(
|
||||||
|
query=query, context=context, limit=limit
|
||||||
|
)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"suggestions": suggestions,
|
||||||
|
"query": query,
|
||||||
|
"context": context,
|
||||||
|
"total_count": len(suggestions),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@advanced_ux_bp.get("/discovery/recommendations")
|
||||||
|
def discovery_recommendations():
|
||||||
|
recommendation_type = str(request.args.get("type") or "mixed")
|
||||||
|
limit = _safe_limit(request.args.get("limit"), default=20, max_value=100)
|
||||||
|
|
||||||
|
recommendations = advanced_ux_store.get_recommendations(recommendation_type, limit)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"recommendations": recommendations,
|
||||||
|
"type": recommendation_type,
|
||||||
|
"total_count": len(recommendations),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@advanced_ux_bp.get("/contextual/suggestions")
|
||||||
|
def contextual_suggestions():
|
||||||
|
track_id = str(request.args.get("track_id") or "")
|
||||||
|
context_type = str(request.args.get("context_type") or "similar")
|
||||||
|
limit = _safe_limit(request.args.get("limit"), default=10, max_value=50)
|
||||||
|
|
||||||
|
suggestions = advanced_ux_store.get_contextual_suggestions(
|
||||||
|
track_id, context_type, limit
|
||||||
|
)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"suggestions": suggestions,
|
||||||
|
"track_id": track_id,
|
||||||
|
"context_type": context_type,
|
||||||
|
"total_count": len(suggestions),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@advanced_ux_bp.get("/download/suggestions")
|
||||||
|
def download_suggestions():
|
||||||
|
query = str(request.args.get("q") or "")
|
||||||
|
limit = _safe_limit(request.args.get("limit"), default=15, max_value=50)
|
||||||
|
|
||||||
|
suggestions = advanced_ux_store.get_download_suggestions(query=query, limit=limit)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"suggestions": suggestions,
|
||||||
|
"query": query,
|
||||||
|
"total_count": len(suggestions),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@advanced_ux_bp.get("/search/filters")
|
||||||
|
def search_filters():
|
||||||
|
filters = advanced_ux_store.get_search_filters()
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"filters": filters,
|
||||||
|
"total_count": len(filters),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@advanced_ux_bp.post("/behavior/track")
|
||||||
|
def behavior_track():
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
event_type = str(payload.get("type") or "unknown")
|
||||||
|
data = payload.get("data") if isinstance(payload.get("data"), dict) else payload
|
||||||
|
|
||||||
|
advanced_ux_store.track_behavior(_user_id(), event_type, data)
|
||||||
|
return jsonify({"enabled": True, "message": "Behavior event tracked"})
|
||||||
|
|
||||||
|
|
||||||
|
@advanced_ux_bp.get("/behavior/profile")
|
||||||
|
def behavior_profile():
|
||||||
|
profile = advanced_ux_store.get_behavior_profile(_user_id())
|
||||||
|
return jsonify({"enabled": True, "profile": profile})
|
||||||
|
|
||||||
|
|
||||||
|
@advanced_ux_bp.get("/trending/content")
|
||||||
|
def trending_content():
|
||||||
|
item_type = str(request.args.get("type") or "mixed")
|
||||||
|
timeframe = str(request.args.get("timeframe") or "week")
|
||||||
|
limit = _safe_limit(request.args.get("limit"), default=20, max_value=100)
|
||||||
|
|
||||||
|
trending = advanced_ux_store.get_trending(
|
||||||
|
item_type=item_type, timeframe=timeframe, limit=limit
|
||||||
|
)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"trending": trending,
|
||||||
|
"type": item_type,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"total_count": len(trending),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@advanced_ux_bp.post("/search/advanced")
|
||||||
|
def advanced_search():
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
result = advanced_ux_store.advanced_search(payload)
|
||||||
|
result["enabled"] = True
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@advanced_ux_bp.get("/suggestions/quick")
|
||||||
|
def quick_suggestions():
|
||||||
|
suggestion_type = str(request.args.get("type") or "search")
|
||||||
|
limit = _safe_limit(request.args.get("limit"), default=5, max_value=30)
|
||||||
|
|
||||||
|
suggestions = advanced_ux_store.quick_suggestions(
|
||||||
|
suggestion_type=suggestion_type, limit=limit
|
||||||
|
)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"suggestions": suggestions,
|
||||||
|
"type": suggestion_type,
|
||||||
|
"total_count": len(suggestions),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@advanced_ux_bp.get("/personalization/preferences")
|
||||||
|
def get_personalization_preferences():
|
||||||
|
prefs = advanced_ux_store.get_preferences(_user_id())
|
||||||
|
return jsonify({"enabled": True, "preferences": prefs})
|
||||||
|
|
||||||
|
|
||||||
|
@advanced_ux_bp.put("/personalization/preferences")
|
||||||
|
def update_personalization_preferences():
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
prefs = advanced_ux_store.update_preferences(_user_id(), payload)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"message": "Preferences updated",
|
||||||
|
"preferences": prefs,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
"""
|
||||||
|
Contains all the album routes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
from dataclasses import asdict, replace
|
||||||
|
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from swingmusic.api.apischemas import (
|
||||||
|
AlbumHashSchema,
|
||||||
|
AlbumLimitSchema,
|
||||||
|
ArtistHashSchema,
|
||||||
|
)
|
||||||
|
from swingmusic.config import UserConfig
|
||||||
|
|
||||||
|
# DragonflyDB integration for album caching
|
||||||
|
from swingmusic.db.dragonfly_client import get_dragonfly_client
|
||||||
|
from swingmusic.db.userdata import SimilarArtistTable
|
||||||
|
from swingmusic.lib.albumslib import sort_by_track_no
|
||||||
|
from swingmusic.models.album import Album
|
||||||
|
from swingmusic.serializers.album import serialize_for_card_many
|
||||||
|
from swingmusic.serializers.track import serialize_tracks
|
||||||
|
from swingmusic.services.user_library_scope import (
|
||||||
|
filter_trackhashes_for_user,
|
||||||
|
get_available_trackhashes,
|
||||||
|
)
|
||||||
|
from swingmusic.store.albums import AlbumStore
|
||||||
|
from swingmusic.store.artists import ArtistStore
|
||||||
|
from swingmusic.store.tracks import TrackStore
|
||||||
|
from swingmusic.utils.hashing import create_hash
|
||||||
|
from swingmusic.utils.stats import get_track_group_stats
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
bp_tag = Tag(name="Album", description="Single album")
|
||||||
|
api = APIBlueprint("album", __name__, url_prefix="/album", abp_tags=[bp_tag])
|
||||||
|
|
||||||
|
|
||||||
|
class GetAlbumVersionsBody(BaseModel):
|
||||||
|
og_album_title: str = Field(
|
||||||
|
description="The original album title (album.og_title)",
|
||||||
|
)
|
||||||
|
|
||||||
|
albumhash: str = Field(
|
||||||
|
description="The album hash of the album to exclude from the results.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GetMoreFromArtistsBody(AlbumLimitSchema):
|
||||||
|
albumartists: list = Field(
|
||||||
|
description="The artist hashes to get more albums from",
|
||||||
|
)
|
||||||
|
|
||||||
|
base_title: str = Field(
|
||||||
|
description="The base title of the album to exclude from the results.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GetAlbumInfoBody(AlbumHashSchema, AlbumLimitSchema):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: Don't use "/" as it will cause redirects (failure)
|
||||||
|
@api.post("")
|
||||||
|
def get_album_tracks_and_info(body: GetAlbumInfoBody):
|
||||||
|
"""
|
||||||
|
Get album and tracks
|
||||||
|
|
||||||
|
Returns album info and tracks for the given albumhash.
|
||||||
|
"""
|
||||||
|
albumhash = body.albumhash
|
||||||
|
|
||||||
|
# Try DragonflyDB cache first
|
||||||
|
cache = get_dragonfly_client()
|
||||||
|
cache_key = f"albums:{albumhash}:{body.limit}"
|
||||||
|
|
||||||
|
if cache.is_available():
|
||||||
|
try:
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
logger.debug(f"Cache hit for album {albumhash}")
|
||||||
|
return json.loads(cached)
|
||||||
|
except Exception:
|
||||||
|
pass # Cache miss is fine
|
||||||
|
|
||||||
|
albumentry = AlbumStore.albummap.get(albumhash)
|
||||||
|
|
||||||
|
if albumentry is None:
|
||||||
|
return {"error": "Album not found"}, 404
|
||||||
|
|
||||||
|
album = replace(albumentry.album)
|
||||||
|
visible_trackhashes = filter_trackhashes_for_user(albumentry.trackhashes)
|
||||||
|
if not visible_trackhashes:
|
||||||
|
return {"error": "Album not found"}, 404
|
||||||
|
|
||||||
|
tracks = TrackStore.get_tracks_by_trackhashes(visible_trackhashes)
|
||||||
|
album.trackcount = len(tracks)
|
||||||
|
album.duration = sum(t.duration for t in tracks)
|
||||||
|
album.check_type(
|
||||||
|
tracks=tracks, singleTrackAsSingle=UserConfig().showAlbumsAsSingles
|
||||||
|
)
|
||||||
|
|
||||||
|
track_total = sum({int(t.extra.get("track_total", 1) or 1) for t in tracks})
|
||||||
|
avg_bitrate = sum(t.bitrate for t in tracks) // (len(tracks) or 1)
|
||||||
|
|
||||||
|
more_from_data = GetMoreFromArtistsBody(
|
||||||
|
albumartists=[a["artisthash"] for a in album.albumartists],
|
||||||
|
albumlimit=body.limit,
|
||||||
|
base_title=album.base_title,
|
||||||
|
)
|
||||||
|
other_versions_data = GetAlbumVersionsBody(
|
||||||
|
albumhash=albumhash,
|
||||||
|
og_album_title=album.og_title,
|
||||||
|
)
|
||||||
|
|
||||||
|
more_from_albums = get_more_from_artist(more_from_data)
|
||||||
|
other_versions = get_album_versions(other_versions_data)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"stats": get_track_group_stats(tracks, is_album=True),
|
||||||
|
"info": {
|
||||||
|
**asdict(album),
|
||||||
|
"is_favorite": album.is_favorite,
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
# INFO: track_total is the sum of a set of track_total values from each track
|
||||||
|
# ASSUMPTIONS
|
||||||
|
# 1. All the tracks have the correct track totals
|
||||||
|
# 2. Tracks with the same track total are from the same disc
|
||||||
|
"track_total": track_total,
|
||||||
|
"avg_bitrate": avg_bitrate,
|
||||||
|
},
|
||||||
|
"copyright": tracks[0].copyright,
|
||||||
|
"tracks": serialize_tracks(tracks, remove_disc=False),
|
||||||
|
"more_from": more_from_albums,
|
||||||
|
"other_versions": other_versions,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache the result for 10 minutes
|
||||||
|
if cache.is_available():
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
cache.set(cache_key, json.dumps(result, default=str), ex=600)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/<albumhash>/tracks")
|
||||||
|
def get_album_tracks(path: AlbumHashSchema):
|
||||||
|
"""
|
||||||
|
Get album tracks
|
||||||
|
|
||||||
|
Returns all the tracks in the given album, sorted by disc and track number.
|
||||||
|
NOTE: No album info is returned.
|
||||||
|
"""
|
||||||
|
entry = AlbumStore.albummap.get(path.albumhash)
|
||||||
|
if not entry:
|
||||||
|
return []
|
||||||
|
|
||||||
|
visible_trackhashes = filter_trackhashes_for_user(entry.trackhashes)
|
||||||
|
tracks = TrackStore.get_tracks_by_trackhashes(visible_trackhashes)
|
||||||
|
tracks = sort_by_track_no(tracks)
|
||||||
|
|
||||||
|
return serialize_tracks(tracks)
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/from-artist")
|
||||||
|
def get_more_from_artist(body: GetMoreFromArtistsBody):
|
||||||
|
"""
|
||||||
|
Get more from artist
|
||||||
|
|
||||||
|
Returns more albums from the given artist hashes.
|
||||||
|
"""
|
||||||
|
albumartists = body.albumartists
|
||||||
|
limit = body.limit
|
||||||
|
base_title = body.base_title
|
||||||
|
|
||||||
|
available_trackhashes = get_available_trackhashes()
|
||||||
|
all_albums: dict[str, list[Album]] = {}
|
||||||
|
|
||||||
|
for artisthash in albumartists:
|
||||||
|
albums = AlbumStore.get_albums_by_artisthash(artisthash)
|
||||||
|
all_albums[artisthash] = [
|
||||||
|
album
|
||||||
|
for album in albums
|
||||||
|
if set(AlbumStore.albummap.get(album.albumhash).trackhashes).intersection(
|
||||||
|
available_trackhashes
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
seen_hashes = set()
|
||||||
|
|
||||||
|
for artisthash, albums in all_albums.items():
|
||||||
|
albums = [
|
||||||
|
a
|
||||||
|
for a in albums
|
||||||
|
# INFO: filter out albums added to other artists
|
||||||
|
if a.albumhash not in seen_hashes
|
||||||
|
and artisthash in a.artisthashes
|
||||||
|
# INFO: filter out albums with the same base title
|
||||||
|
and create_hash(a.base_title) != create_hash(base_title)
|
||||||
|
]
|
||||||
|
|
||||||
|
all_albums[artisthash] = serialize_for_card_many(
|
||||||
|
[a for a in albums if create_hash(a.base_title) != create_hash(base_title)][
|
||||||
|
:limit
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# INFO: record albums added to other artists
|
||||||
|
seen_hashes.update([a.albumhash for a in albums][:limit])
|
||||||
|
|
||||||
|
return all_albums
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/other-versions")
|
||||||
|
def get_album_versions(body: GetAlbumVersionsBody):
|
||||||
|
"""
|
||||||
|
Get other versions
|
||||||
|
|
||||||
|
Returns other versions of the given album.
|
||||||
|
"""
|
||||||
|
albumhash = body.albumhash
|
||||||
|
|
||||||
|
album = AlbumStore.albummap.get(albumhash)
|
||||||
|
if not album:
|
||||||
|
return []
|
||||||
|
artisthash = album.album.artisthashes[0]
|
||||||
|
albums = AlbumStore.get_albums_by_artisthash(artisthash)
|
||||||
|
available_trackhashes = get_available_trackhashes()
|
||||||
|
|
||||||
|
basetitle = album.basetitle
|
||||||
|
albums = [
|
||||||
|
a
|
||||||
|
for a in albums
|
||||||
|
if a.og_title != album.album.og_title
|
||||||
|
if a.base_title == basetitle
|
||||||
|
and artisthash in {a["artisthash"] for a in a.albumartists}
|
||||||
|
and set(AlbumStore.albummap.get(a.albumhash).trackhashes).intersection(
|
||||||
|
available_trackhashes
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
return serialize_for_card_many(albums)
|
||||||
|
|
||||||
|
|
||||||
|
class GetSimilarAlbumsQuery(ArtistHashSchema, AlbumLimitSchema):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/similar")
|
||||||
|
def get_similar_albums(query: GetSimilarAlbumsQuery):
|
||||||
|
"""
|
||||||
|
Get similar albums
|
||||||
|
|
||||||
|
Returns similar albums to the given album.
|
||||||
|
"""
|
||||||
|
artisthash = query.artisthash
|
||||||
|
limit = query.limit
|
||||||
|
|
||||||
|
similar_artists = SimilarArtistTable.get_by_hash(artisthash)
|
||||||
|
|
||||||
|
if similar_artists is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
artisthashes = similar_artists.get_artist_hash_set()
|
||||||
|
|
||||||
|
del similar_artists
|
||||||
|
|
||||||
|
artists = ArtistStore.get_artists_by_hashes(artisthashes)
|
||||||
|
albums = AlbumStore.get_albums_by_artisthashes([a.artisthash for a in artists])
|
||||||
|
available_trackhashes = get_available_trackhashes()
|
||||||
|
albums = [
|
||||||
|
album
|
||||||
|
for album in albums
|
||||||
|
if set(AlbumStore.albummap.get(album.albumhash).trackhashes).intersection(
|
||||||
|
available_trackhashes
|
||||||
|
)
|
||||||
|
]
|
||||||
|
sample = random.sample(albums, min(len(albums), limit))
|
||||||
|
|
||||||
|
return serialize_for_card_many(sample[:limit])
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
Reusable Pydantic basic schemas for the API
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from swingmusic.settings import Defaults
|
||||||
|
|
||||||
|
|
||||||
|
class AlbumHashSchema(BaseModel):
|
||||||
|
"""
|
||||||
|
Extending this class will give you a model with the `albumhash` field
|
||||||
|
"""
|
||||||
|
|
||||||
|
albumhash: str = Field(
|
||||||
|
description="The album hash",
|
||||||
|
json_schema_extra={
|
||||||
|
"example": Defaults.API_ALBUMHASH,
|
||||||
|
},
|
||||||
|
min_length=Defaults.HASH_LENGTH,
|
||||||
|
max_length=Defaults.HASH_LENGTH,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ArtistHashSchema(BaseModel):
|
||||||
|
"""
|
||||||
|
Extending this class will give you a model with the `artisthash` field
|
||||||
|
"""
|
||||||
|
|
||||||
|
artisthash: str = Field(
|
||||||
|
description="The artist hash",
|
||||||
|
json_schema_extra={
|
||||||
|
"example": Defaults.API_ARTISTHASH,
|
||||||
|
},
|
||||||
|
min_length=Defaults.HASH_LENGTH,
|
||||||
|
max_length=Defaults.HASH_LENGTH,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TrackHashSchema(BaseModel):
|
||||||
|
"""
|
||||||
|
Extending this class will give you a model with the `trackhash` field
|
||||||
|
"""
|
||||||
|
|
||||||
|
trackhash: str = Field(
|
||||||
|
description="The track hash",
|
||||||
|
json_schema_extra={
|
||||||
|
"example": Defaults.API_TRACKHASH,
|
||||||
|
},
|
||||||
|
min_length=Defaults.HASH_LENGTH,
|
||||||
|
max_length=Defaults.HASH_LENGTH,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GenericLimitSchema(BaseModel):
|
||||||
|
"""
|
||||||
|
Extending this class will give you a model with the `limit` field
|
||||||
|
"""
|
||||||
|
|
||||||
|
limit: int = Field(
|
||||||
|
description="The number of items to return",
|
||||||
|
json_schema_extra={
|
||||||
|
"example": Defaults.API_CARD_LIMIT,
|
||||||
|
},
|
||||||
|
default=Defaults.API_CARD_LIMIT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# INFO: The following 3 classes are duplicated to specify the type of items
|
||||||
|
class TrackLimitSchema(BaseModel):
|
||||||
|
"""
|
||||||
|
Extending this class will give you a model with the `limit` field
|
||||||
|
"""
|
||||||
|
|
||||||
|
limit: int = Field(
|
||||||
|
description="The number of tracks to return",
|
||||||
|
json_schema_extra={
|
||||||
|
"example": Defaults.API_CARD_LIMIT,
|
||||||
|
},
|
||||||
|
default=5,
|
||||||
|
alias="tracklimit",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AlbumLimitSchema(BaseModel):
|
||||||
|
"""
|
||||||
|
Extending this class will give you a model with the `limit` field
|
||||||
|
"""
|
||||||
|
|
||||||
|
limit: int = Field(
|
||||||
|
description="The number of albums to return",
|
||||||
|
json_schema_extra={
|
||||||
|
"example": Defaults.API_CARD_LIMIT,
|
||||||
|
},
|
||||||
|
default=Defaults.API_CARD_LIMIT,
|
||||||
|
alias="albumlimit",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ArtistLimitSchema(BaseModel):
|
||||||
|
"""
|
||||||
|
Extending this class will give you a model with the `limit` field
|
||||||
|
"""
|
||||||
|
|
||||||
|
limit: int = Field(
|
||||||
|
description="The number of artists to return",
|
||||||
|
json_schema_extra={
|
||||||
|
"example": Defaults.API_CARD_LIMIT,
|
||||||
|
},
|
||||||
|
default=Defaults.API_CARD_LIMIT,
|
||||||
|
alias="artistlimit",
|
||||||
|
)
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"""
|
||||||
|
Contains all the artist(s) routes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import datetime
|
||||||
|
from itertools import groupby
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from swingmusic.api.apischemas import (
|
||||||
|
AlbumLimitSchema,
|
||||||
|
ArtistHashSchema,
|
||||||
|
ArtistLimitSchema,
|
||||||
|
TrackLimitSchema,
|
||||||
|
)
|
||||||
|
from swingmusic.config import UserConfig
|
||||||
|
from swingmusic.db.userdata import SimilarArtistTable
|
||||||
|
from swingmusic.lib.sortlib import sort_tracks
|
||||||
|
from swingmusic.serializers.album import serialize_for_card_many
|
||||||
|
from swingmusic.serializers.artist import serialize_for_card, serialize_for_cards
|
||||||
|
from swingmusic.serializers.track import serialize_track
|
||||||
|
from swingmusic.services.user_library_scope import (
|
||||||
|
filter_trackhashes_for_user,
|
||||||
|
get_available_trackhashes,
|
||||||
|
)
|
||||||
|
from swingmusic.store.albums import AlbumStore
|
||||||
|
from swingmusic.store.artists import ArtistStore
|
||||||
|
from swingmusic.store.tracks import TrackStore
|
||||||
|
from swingmusic.utils.stats import get_track_group_stats
|
||||||
|
|
||||||
|
bp_tag = Tag(name="Artist", description="Single artist")
|
||||||
|
api = APIBlueprint("artist", __name__, url_prefix="/artist", abp_tags=[bp_tag])
|
||||||
|
|
||||||
|
|
||||||
|
class GetArtistAlbumsQuery(AlbumLimitSchema):
|
||||||
|
all: bool = Field(
|
||||||
|
description="Whether to ignore albumlimit and return all albums", default=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GetArtistQuery(TrackLimitSchema, GetArtistAlbumsQuery):
|
||||||
|
albumlimit: int = Field(7, description="The number of albums to return")
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/<string:artisthash>")
|
||||||
|
def get_artist(path: ArtistHashSchema, query: GetArtistQuery):
|
||||||
|
"""
|
||||||
|
Get artist
|
||||||
|
|
||||||
|
Returns artist data, tracks and genres for the given artisthash.
|
||||||
|
"""
|
||||||
|
artisthash = path.artisthash
|
||||||
|
limit = query.limit
|
||||||
|
|
||||||
|
entry = ArtistStore.artistmap.get(artisthash)
|
||||||
|
|
||||||
|
if entry is None:
|
||||||
|
return {"error": "Artist not found"}, 404
|
||||||
|
|
||||||
|
visible_trackhashes = filter_trackhashes_for_user(entry.trackhashes)
|
||||||
|
if not visible_trackhashes:
|
||||||
|
return {"error": "Artist not found"}, 404
|
||||||
|
|
||||||
|
tracks = TrackStore.get_tracks_by_trackhashes(visible_trackhashes)
|
||||||
|
tracks = sort_tracks(tracks, key="playcount", reverse=True)
|
||||||
|
tcount = len(tracks)
|
||||||
|
|
||||||
|
artist = entry.artist
|
||||||
|
if artist.albumcount == 0 and tcount < 10:
|
||||||
|
limit = tcount
|
||||||
|
|
||||||
|
try:
|
||||||
|
year = datetime.fromtimestamp(artist.date).year
|
||||||
|
except ValueError:
|
||||||
|
year = 0
|
||||||
|
|
||||||
|
genres = [*artist.genres]
|
||||||
|
decade = None
|
||||||
|
|
||||||
|
if year:
|
||||||
|
decade = math.floor(year / 10) * 10
|
||||||
|
decade = str(decade)[2:] + "s"
|
||||||
|
|
||||||
|
if decade:
|
||||||
|
genres.insert(0, {"name": decade, "genrehash": decade})
|
||||||
|
|
||||||
|
stats = get_track_group_stats(tracks)
|
||||||
|
duration = sum(t.duration for t in tracks) if tracks else 0
|
||||||
|
tracks = tracks[:limit] if (limit and limit != -1) else tracks
|
||||||
|
tracks = [
|
||||||
|
{
|
||||||
|
**serialize_track(t),
|
||||||
|
"help_text": (
|
||||||
|
"unplayed"
|
||||||
|
if t.playcount == 0
|
||||||
|
else f"{t.playcount} play{'' if t.playcount == 1 else 's'}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for t in tracks
|
||||||
|
]
|
||||||
|
|
||||||
|
query.limit = query.albumlimit
|
||||||
|
albums = get_artist_albums(path, query)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"artist": {
|
||||||
|
**serialize_for_card(artist),
|
||||||
|
"duration": duration,
|
||||||
|
"trackcount": tcount,
|
||||||
|
"albumcount": artist.albumcount,
|
||||||
|
"genres": genres,
|
||||||
|
"is_favorite": artist.is_favorite,
|
||||||
|
},
|
||||||
|
"tracks": tracks,
|
||||||
|
"albums": albums,
|
||||||
|
"stats": stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/<artisthash>/albums")
|
||||||
|
def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery):
|
||||||
|
"""
|
||||||
|
Get artist albums.
|
||||||
|
"""
|
||||||
|
return_all = query.all
|
||||||
|
artisthash = path.artisthash
|
||||||
|
|
||||||
|
limit = query.limit
|
||||||
|
|
||||||
|
entry = ArtistStore.artistmap.get(artisthash)
|
||||||
|
|
||||||
|
if entry is None:
|
||||||
|
return {"error": "Artist not found"}, 404
|
||||||
|
|
||||||
|
visible_trackhashes = set(filter_trackhashes_for_user(entry.trackhashes))
|
||||||
|
if not visible_trackhashes:
|
||||||
|
return {"error": "Artist not found"}, 404
|
||||||
|
|
||||||
|
albums = AlbumStore.get_albums_by_hashes(entry.albumhashes)
|
||||||
|
tracks = TrackStore.get_tracks_by_trackhashes(visible_trackhashes)
|
||||||
|
|
||||||
|
missing_albumhashes = {
|
||||||
|
t.albumhash for t in tracks if t.albumhash not in {a.albumhash for a in albums}
|
||||||
|
}
|
||||||
|
|
||||||
|
albums.extend(AlbumStore.get_albums_by_hashes(missing_albumhashes))
|
||||||
|
albumdict = {a.albumhash: replace(a) for a in albums}
|
||||||
|
|
||||||
|
config = UserConfig()
|
||||||
|
albumgroups = groupby(tracks, key=lambda t: t.albumhash)
|
||||||
|
for albumhash, tracks in albumgroups:
|
||||||
|
album = albumdict.get(albumhash)
|
||||||
|
|
||||||
|
if album:
|
||||||
|
album.check_type(list(tracks), config.showAlbumsAsSingles)
|
||||||
|
|
||||||
|
albums = [
|
||||||
|
album
|
||||||
|
for album in albumdict.values()
|
||||||
|
if set(AlbumStore.albummap.get(album.albumhash).trackhashes).intersection(
|
||||||
|
visible_trackhashes
|
||||||
|
)
|
||||||
|
]
|
||||||
|
all_albums = sorted(albums, key=lambda a: a.date, reverse=True)
|
||||||
|
|
||||||
|
res: dict[str, Any] = {
|
||||||
|
"albums": [],
|
||||||
|
"appearances": [],
|
||||||
|
"compilations": [],
|
||||||
|
"singles_and_eps": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for album in all_albums:
|
||||||
|
if album.type == "single" or album.type == "ep":
|
||||||
|
res["singles_and_eps"].append(album)
|
||||||
|
elif album.type == "compilation":
|
||||||
|
res["compilations"].append(album)
|
||||||
|
elif (
|
||||||
|
album.albumhash in missing_albumhashes
|
||||||
|
or artisthash not in album.artisthashes
|
||||||
|
):
|
||||||
|
res["appearances"].append(album)
|
||||||
|
else:
|
||||||
|
res["albums"].append(album)
|
||||||
|
|
||||||
|
if return_all:
|
||||||
|
limit = len(all_albums)
|
||||||
|
|
||||||
|
# loop through the res dict and serialize the albums
|
||||||
|
for key, value in res.items():
|
||||||
|
res[key] = serialize_for_card_many(value[:limit])
|
||||||
|
|
||||||
|
res["artistname"] = entry.artist.name
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/<artisthash>/tracks")
|
||||||
|
def get_all_artist_tracks(path: ArtistHashSchema):
|
||||||
|
"""
|
||||||
|
Get artist tracks
|
||||||
|
|
||||||
|
Returns all artists by a given artist.
|
||||||
|
"""
|
||||||
|
entry = ArtistStore.artistmap.get(path.artisthash)
|
||||||
|
if entry is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
visible_trackhashes = filter_trackhashes_for_user(entry.trackhashes)
|
||||||
|
tracks = TrackStore.get_tracks_by_trackhashes(visible_trackhashes)
|
||||||
|
tracks = sort_tracks(tracks, key="playcount", reverse=True)
|
||||||
|
tracks = [
|
||||||
|
{
|
||||||
|
**serialize_track(t),
|
||||||
|
"help_text": (
|
||||||
|
"unplayed"
|
||||||
|
if t.playcount == 0
|
||||||
|
else f"{t.playcount} play{'' if t.playcount == 1 else 's'}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for t in tracks
|
||||||
|
]
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/<artisthash>/similar")
|
||||||
|
def get_similar_artists(path: ArtistHashSchema, query: ArtistLimitSchema):
|
||||||
|
"""
|
||||||
|
Get similar artists.
|
||||||
|
"""
|
||||||
|
limit = query.limit
|
||||||
|
result = SimilarArtistTable.get_by_hash(path.artisthash)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
similar = ArtistStore.get_artists_by_hashes(result.get_artist_hash_set())
|
||||||
|
available_trackhashes = get_available_trackhashes()
|
||||||
|
similar = [
|
||||||
|
artist
|
||||||
|
for artist in similar
|
||||||
|
if set(ArtistStore.artistmap.get(artist.artisthash).trackhashes).intersection(
|
||||||
|
available_trackhashes
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(similar) > limit:
|
||||||
|
similar = random.sample(similar, min(limit, len(similar)))
|
||||||
|
|
||||||
|
return serialize_for_cards(similar[:limit])
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Audio quality endpoints for settings, presets and environment hints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from swingmusic.services.audio_quality_store import audio_quality_store
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
|
||||||
|
audio_quality_bp = Blueprint("audio_quality", __name__, url_prefix="/api/audio-quality")
|
||||||
|
|
||||||
|
|
||||||
|
def _user_id() -> int:
|
||||||
|
return int(get_current_userid())
|
||||||
|
|
||||||
|
|
||||||
|
def _error(message: str, status: int = 400):
|
||||||
|
return jsonify({"error": message}), status
|
||||||
|
|
||||||
|
|
||||||
|
@audio_quality_bp.get("/settings")
|
||||||
|
def get_quality_settings():
|
||||||
|
settings = audio_quality_store.get_settings(_user_id())
|
||||||
|
return jsonify({"enabled": True, "settings": settings})
|
||||||
|
|
||||||
|
|
||||||
|
@audio_quality_bp.post("/settings")
|
||||||
|
def update_quality_settings():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return _error("Request body must be an object")
|
||||||
|
|
||||||
|
settings = audio_quality_store.update_settings(_user_id(), data)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"message": "Audio quality settings updated successfully",
|
||||||
|
"settings": settings,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@audio_quality_bp.get("/optimal-streaming")
|
||||||
|
def get_optimal_streaming_quality():
|
||||||
|
context_raw = request.args.get("context")
|
||||||
|
context = {}
|
||||||
|
|
||||||
|
if context_raw:
|
||||||
|
try:
|
||||||
|
decoded = json.loads(context_raw)
|
||||||
|
if isinstance(decoded, dict):
|
||||||
|
context = decoded
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
context = {}
|
||||||
|
|
||||||
|
optimal_quality = audio_quality_store.get_optimal_streaming_quality(
|
||||||
|
_user_id(), context
|
||||||
|
)
|
||||||
|
return jsonify({"optimal_quality": optimal_quality, "context": context})
|
||||||
|
|
||||||
|
|
||||||
|
@audio_quality_bp.post("/apply-preset")
|
||||||
|
def apply_preset():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
preset_name = str(data.get("preset_name") or "").strip()
|
||||||
|
|
||||||
|
if not preset_name:
|
||||||
|
return _error("preset_name is required")
|
||||||
|
|
||||||
|
settings, ok = audio_quality_store.apply_preset(_user_id(), preset_name)
|
||||||
|
if not ok:
|
||||||
|
return _error("Invalid preset_name", 404)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"message": "Preset applied successfully",
|
||||||
|
"preset_name": preset_name,
|
||||||
|
"settings": settings,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@audio_quality_bp.get("/quality-presets")
|
||||||
|
def get_quality_presets():
|
||||||
|
return jsonify({"presets": audio_quality_store.get_presets()})
|
||||||
|
|
||||||
|
|
||||||
|
@audio_quality_bp.get("/formats")
|
||||||
|
def get_supported_formats():
|
||||||
|
return jsonify({"formats": audio_quality_store.get_supported_formats()})
|
||||||
|
|
||||||
|
|
||||||
|
@audio_quality_bp.get("/network/status")
|
||||||
|
def get_network_status():
|
||||||
|
return jsonify({"network_status": audio_quality_store.get_network_status()})
|
||||||
|
|
||||||
|
|
||||||
|
@audio_quality_bp.get("/device/info")
|
||||||
|
def get_device_info():
|
||||||
|
user_agent = request.headers.get("User-Agent", "")
|
||||||
|
return jsonify({"device_info": audio_quality_store.get_device_info(user_agent)})
|
||||||
@@ -0,0 +1,708 @@
|
|||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import current_app, jsonify, request
|
||||||
|
from flask_jwt_extended import (
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
current_user,
|
||||||
|
get_jwt_identity,
|
||||||
|
jwt_required,
|
||||||
|
set_access_cookies,
|
||||||
|
)
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from swingmusic.config import UserConfig
|
||||||
|
|
||||||
|
# DragonflyDB integration for fast session caching
|
||||||
|
from swingmusic.db.dragonfly_extended_client import get_user_session_service
|
||||||
|
from swingmusic.db.production import UserRootDirOwnershipTable
|
||||||
|
from swingmusic.db.userdata import UserTable
|
||||||
|
from swingmusic.services.production_readiness import (
|
||||||
|
accept_invite_token,
|
||||||
|
create_invite_token,
|
||||||
|
default_user_root_dir,
|
||||||
|
get_bootstrap_status,
|
||||||
|
)
|
||||||
|
from swingmusic.services.setup_state import bootstrap_setup, get_setup_status
|
||||||
|
from swingmusic.store.homepage import HomepageStore
|
||||||
|
from swingmusic.utils.auth import check_password, hash_password
|
||||||
|
|
||||||
|
bp_tag = Tag(name="Auth", description="Authentication stuff")
|
||||||
|
api = APIBlueprint("auth", __name__, url_prefix="/auth", abp_tags=[bp_tag])
|
||||||
|
|
||||||
|
|
||||||
|
def get_limiter():
|
||||||
|
"""Get the rate limiter from app context."""
|
||||||
|
# Prefer the global limiter initialized in app_builder.build().
|
||||||
|
# flask-limiter v4 may store a set in current_app.extensions["limiter"],
|
||||||
|
# so resolve defensively across versions.
|
||||||
|
try:
|
||||||
|
from swingmusic.app_builder import limiter as app_limiter
|
||||||
|
|
||||||
|
if app_limiter is not None and hasattr(app_limiter, "limit"):
|
||||||
|
return app_limiter
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ext = current_app.extensions.get("limiter")
|
||||||
|
if ext is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if hasattr(ext, "limit"):
|
||||||
|
return ext
|
||||||
|
|
||||||
|
if isinstance(ext, set):
|
||||||
|
for candidate in ext:
|
||||||
|
if hasattr(candidate, "limit"):
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def rate_limit(limit: str):
|
||||||
|
"""
|
||||||
|
Decorator to apply rate limiting to an endpoint.
|
||||||
|
Falls back gracefully if limiter is not available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(fn):
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
limiter = get_limiter()
|
||||||
|
if limiter:
|
||||||
|
# Apply rate limit using the limiter's decorator
|
||||||
|
return limiter.limit(limit)(fn)(*args, **kwargs)
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required():
|
||||||
|
"""
|
||||||
|
Decorator to require admin role
|
||||||
|
"""
|
||||||
|
|
||||||
|
def wrapper(fn):
|
||||||
|
@wraps(fn)
|
||||||
|
def decorator(*args, **kwargs):
|
||||||
|
if "admin" not in current_user["roles"]:
|
||||||
|
return {"msg": "Only admins can do that!"}, 403
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def create_new_token(user: dict):
|
||||||
|
"""
|
||||||
|
Create a new token response
|
||||||
|
"""
|
||||||
|
access_token = create_access_token(identity=user)
|
||||||
|
max_age: int = current_app.config.get("JWT_ACCESS_TOKEN_EXPIRES")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"msg": f"Logged in as {user['username']}",
|
||||||
|
"accesstoken": access_token,
|
||||||
|
"refreshtoken": create_refresh_token(identity=user),
|
||||||
|
"maxage": max_age,
|
||||||
|
"password_change_required": user.get("password_change_required", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PairTokenStore:
|
||||||
|
def __init__(self, *, ttl_seconds: int = 300, max_codes: int = 2048):
|
||||||
|
self.ttl_seconds = max(30, ttl_seconds)
|
||||||
|
self.max_codes = max(128, max_codes)
|
||||||
|
self._codes: dict[str, dict] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _cleanup_locked(self):
|
||||||
|
now = time.time()
|
||||||
|
expired = [
|
||||||
|
code
|
||||||
|
for code, payload in self._codes.items()
|
||||||
|
if payload.get("expires_at", 0) <= now
|
||||||
|
]
|
||||||
|
for code in expired:
|
||||||
|
self._codes.pop(code, None)
|
||||||
|
|
||||||
|
if len(self._codes) <= self.max_codes:
|
||||||
|
return
|
||||||
|
|
||||||
|
ordered = sorted(
|
||||||
|
self._codes.items(),
|
||||||
|
key=lambda item: item[1].get("created_at", 0),
|
||||||
|
)
|
||||||
|
drop_count = len(self._codes) - self.max_codes
|
||||||
|
for code, _ in ordered[:drop_count]:
|
||||||
|
self._codes.pop(code, None)
|
||||||
|
|
||||||
|
def issue(self, token_payload: dict, user_identity: dict | None = None):
|
||||||
|
code_alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
with self._lock:
|
||||||
|
self._cleanup_locked()
|
||||||
|
|
||||||
|
code = None
|
||||||
|
for _ in range(32):
|
||||||
|
candidate = "".join(secrets.choice(code_alphabet) for _ in range(6))
|
||||||
|
if candidate not in self._codes:
|
||||||
|
code = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
raise RuntimeError("Unable to allocate a unique pairing code")
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
expires_at = now + self.ttl_seconds
|
||||||
|
self._codes[code] = {
|
||||||
|
"created_at": now,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
"payload": token_payload,
|
||||||
|
"user_id": (
|
||||||
|
int(user_identity["id"])
|
||||||
|
if isinstance(user_identity, dict) and user_identity.get("id")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return code, int(expires_at)
|
||||||
|
|
||||||
|
def consume(self, raw_code: str | None):
|
||||||
|
code = (raw_code or "").strip().upper()
|
||||||
|
if not code:
|
||||||
|
return None
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._cleanup_locked()
|
||||||
|
payload = self._codes.pop(code, None)
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if payload.get("expires_at", 0) <= time.time():
|
||||||
|
return None
|
||||||
|
|
||||||
|
return payload.get("payload")
|
||||||
|
|
||||||
|
|
||||||
|
pair_token_store = PairTokenStore(
|
||||||
|
ttl_seconds=int(os.getenv("SWINGMUSIC_PAIR_CODE_TTL_SECONDS", "300")),
|
||||||
|
max_codes=int(os.getenv("SWINGMUSIC_PAIR_CODE_MAX_ACTIVE", "2048")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginBody(BaseModel):
|
||||||
|
username: str = Field(description="The username", example="user0")
|
||||||
|
password: str = Field(description="The password", example="password0")
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/login")
|
||||||
|
@rate_limit("30 per minute")
|
||||||
|
def login(body: LoginBody):
|
||||||
|
"""
|
||||||
|
Authenticate using username and password
|
||||||
|
"""
|
||||||
|
|
||||||
|
user = UserTable.get_by_username(body.username)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
return {"msg": "User not found"}, 404
|
||||||
|
|
||||||
|
password_ok = check_password(body.password, user.password)
|
||||||
|
|
||||||
|
if not password_ok:
|
||||||
|
return {"msg": "Hehe! invalid password"}, 401
|
||||||
|
|
||||||
|
res = create_new_token(user.todict())
|
||||||
|
token = res["accesstoken"]
|
||||||
|
age = res["maxage"]
|
||||||
|
res = jsonify(res)
|
||||||
|
set_access_cookies(res, token, max_age=age)
|
||||||
|
|
||||||
|
# Cache user session in DragonflyDB for fast lookups
|
||||||
|
session_service = get_user_session_service()
|
||||||
|
if session_service.cache.client.is_available():
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
session_service.create_session(
|
||||||
|
token,
|
||||||
|
user.todict(),
|
||||||
|
ttl_hours=max(1, int(age // 3600)),
|
||||||
|
)
|
||||||
|
session_service.set_user_session(user.id, user.todict(), ttl_seconds=age)
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/bootstrap/status")
|
||||||
|
@jwt_required(optional=True)
|
||||||
|
def bootstrap_status():
|
||||||
|
"""
|
||||||
|
Returns owner-bootstrap state for first-run provisioning.
|
||||||
|
"""
|
||||||
|
legacy = get_bootstrap_status()
|
||||||
|
setup = get_setup_status()
|
||||||
|
return {
|
||||||
|
**legacy,
|
||||||
|
**setup,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BootstrapOwnerBody(BaseModel):
|
||||||
|
username: str = Field(description="Owner username")
|
||||||
|
password: str = Field(description="Owner password")
|
||||||
|
root_dirs: list[str] = Field(
|
||||||
|
default_factory=list, description="Initial root directories"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/bootstrap/owner")
|
||||||
|
@rate_limit("5 per minute")
|
||||||
|
def bootstrap_owner(body: BootstrapOwnerBody):
|
||||||
|
"""
|
||||||
|
Creates the first owner account when no users exist.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
owner = bootstrap_setup(
|
||||||
|
username=body.username,
|
||||||
|
password=body.password,
|
||||||
|
root_dirs=body.root_dirs,
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
return {"msg": str(error)}, 400
|
||||||
|
|
||||||
|
res = create_new_token(owner.todict())
|
||||||
|
token = res["accesstoken"]
|
||||||
|
age = res["maxage"]
|
||||||
|
response = jsonify(res)
|
||||||
|
set_access_cookies(response, token, max_age=age)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class InviteCreateBody(BaseModel):
|
||||||
|
roles: list[str] = Field(
|
||||||
|
default_factory=lambda: ["user"], description="Roles for invited account"
|
||||||
|
)
|
||||||
|
expires_in_seconds: int = Field(
|
||||||
|
default=7 * 24 * 3600, description="Invite validity in seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/invite/create")
|
||||||
|
@admin_required()
|
||||||
|
def create_invite(body: InviteCreateBody):
|
||||||
|
"""
|
||||||
|
Create an invite token for onboarding additional users.
|
||||||
|
"""
|
||||||
|
invite = create_invite_token(
|
||||||
|
created_by=current_user["id"],
|
||||||
|
roles=body.roles,
|
||||||
|
expires_in_seconds=body.expires_in_seconds,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"token": invite.token,
|
||||||
|
"expires_at": invite.expires_at,
|
||||||
|
"roles": invite.roles,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class InviteAcceptBody(BaseModel):
|
||||||
|
token: str = Field(description="Invite token")
|
||||||
|
username: str = Field(description="New username")
|
||||||
|
password: str = Field(description="New user password")
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/invite/accept")
|
||||||
|
@rate_limit("5 per minute")
|
||||||
|
def accept_invite(body: InviteAcceptBody):
|
||||||
|
"""
|
||||||
|
Accept an invite token and create a user account.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = accept_invite_token(
|
||||||
|
token=body.token,
|
||||||
|
username=body.username,
|
||||||
|
password=body.password,
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
return {"msg": str(error)}, 400
|
||||||
|
|
||||||
|
res = create_new_token(user.todict())
|
||||||
|
token = res["accesstoken"]
|
||||||
|
age = res["maxage"]
|
||||||
|
response = jsonify(res)
|
||||||
|
set_access_cookies(response, token, max_age=age)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/getpaircode")
|
||||||
|
@jwt_required()
|
||||||
|
def get_pair():
|
||||||
|
"""
|
||||||
|
Get a new pair code to log in to thee Swing Music mobile app
|
||||||
|
"""
|
||||||
|
user_identity = get_jwt_identity()
|
||||||
|
if not isinstance(user_identity, dict) or user_identity.get("id") is None:
|
||||||
|
return {"msg": "Unauthorized"}, 401
|
||||||
|
|
||||||
|
token_payload = create_new_token(user_identity)
|
||||||
|
code, expires_at = pair_token_store.issue(token_payload, user_identity)
|
||||||
|
|
||||||
|
server_url = request.headers.get("Origin", "").strip()
|
||||||
|
if not server_url:
|
||||||
|
server_url = request.host_url.rstrip("/")
|
||||||
|
else:
|
||||||
|
server_url = server_url.rstrip("/")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": code,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
"ttl_seconds": pair_token_store.ttl_seconds,
|
||||||
|
"server_url": server_url,
|
||||||
|
# Keep payload contract explicit for mobile/desktop clients.
|
||||||
|
# Format: "<server_url>|<pair_code>"
|
||||||
|
"qr_payload": f"{server_url}|{code}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PairDeviceQuery(BaseModel):
|
||||||
|
code: str = Field("", description="The code")
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/pair")
|
||||||
|
@jwt_required(optional=True)
|
||||||
|
@rate_limit("20 per minute")
|
||||||
|
def pair_with_code(query: PairDeviceQuery):
|
||||||
|
"""
|
||||||
|
Get an access token by sending a pair code. NOTE: A code can only be used once!
|
||||||
|
"""
|
||||||
|
token = pair_token_store.consume(query.code)
|
||||||
|
if token:
|
||||||
|
return token
|
||||||
|
|
||||||
|
return {"msg": "Invalid or expired code"}, 400
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/refresh")
|
||||||
|
@jwt_required(refresh=True)
|
||||||
|
def refresh():
|
||||||
|
"""
|
||||||
|
Refresh an access token by sending a refresh token in the Authorization header
|
||||||
|
|
||||||
|
>>> Headers:
|
||||||
|
>>> Authorization: Bearer <refresh_token>
|
||||||
|
|
||||||
|
Won't work with cookies!!!
|
||||||
|
"""
|
||||||
|
user = get_jwt_identity()
|
||||||
|
return create_new_token(user)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateProfileBody(BaseModel):
|
||||||
|
id: int = Field(0, description="The user id")
|
||||||
|
email: str = Field("", description="The email")
|
||||||
|
username: str = Field("", description="The username", example="user0")
|
||||||
|
password: str = Field("", description="The password", example="password0")
|
||||||
|
roles: list[str] = Field(None, description="The roles")
|
||||||
|
|
||||||
|
|
||||||
|
@api.put("/profile/update")
|
||||||
|
def update_profile(body: UpdateProfileBody):
|
||||||
|
"""
|
||||||
|
Update user profile
|
||||||
|
"""
|
||||||
|
user = {
|
||||||
|
"id": body.id,
|
||||||
|
"username": body.username,
|
||||||
|
"password": body.password,
|
||||||
|
"roles": body.roles,
|
||||||
|
}
|
||||||
|
|
||||||
|
# prevent updating guest
|
||||||
|
if current_user["username"] == "guest" or user["username"] == "guest":
|
||||||
|
return {"msg": "Cannot update guest user"}, 400
|
||||||
|
|
||||||
|
# if not id, update self
|
||||||
|
if not user["id"]:
|
||||||
|
user["id"] = current_user["id"]
|
||||||
|
|
||||||
|
if body.roles is not None:
|
||||||
|
# only admins can update roles
|
||||||
|
if "admin" not in current_user["roles"]:
|
||||||
|
return {"msg": "Only admins can update roles"}, 403
|
||||||
|
|
||||||
|
all_users = list(UserTable.get_all())
|
||||||
|
if "admin" not in body.roles:
|
||||||
|
# check if we're removing the last admin
|
||||||
|
admins = [user for user in all_users if "admin" in user.roles]
|
||||||
|
|
||||||
|
if len(admins) == 1 and admins[0].id == user["id"]:
|
||||||
|
return {"msg": "Cannot remove the only admin"}, 400
|
||||||
|
|
||||||
|
# guest roles cannot be updated
|
||||||
|
_user = [u for u in all_users if u.id == user["id"]][0]
|
||||||
|
if "guest" in _user.roles:
|
||||||
|
return {"msg": "Cannot update guest user"}, 400
|
||||||
|
|
||||||
|
if user["password"]:
|
||||||
|
user["password"] = hash_password(user["password"])
|
||||||
|
|
||||||
|
# remove empty values
|
||||||
|
clean_user = {k: v for k, v in user.items() if v}
|
||||||
|
|
||||||
|
# finally, convert roles to json string
|
||||||
|
# doing it here to prevent deleting roles from clean user
|
||||||
|
# when body.roles is an empty list
|
||||||
|
if body.roles is not None:
|
||||||
|
clean_user["roles"] = body.roles
|
||||||
|
|
||||||
|
try:
|
||||||
|
# return authdb.update_user(clean_user)
|
||||||
|
UserTable.update_one(clean_user)
|
||||||
|
return UserTable.get_by_id(user["id"]).todict()
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return {"msg": "Username already exists"}, 400
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/profile/create")
|
||||||
|
@admin_required()
|
||||||
|
def create_user(body: UpdateProfileBody):
|
||||||
|
"""
|
||||||
|
Create a new user
|
||||||
|
"""
|
||||||
|
if not body.username or not body.password:
|
||||||
|
return {"msg": "Username and password are required"}, 400
|
||||||
|
|
||||||
|
user = {
|
||||||
|
"username": body.username,
|
||||||
|
"password": hash_password(body.password),
|
||||||
|
"roles": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# check if user already exists
|
||||||
|
if UserTable.get_by_username(user["username"]):
|
||||||
|
return {"msg": "Username already exists"}, 400
|
||||||
|
|
||||||
|
UserTable.insert_one(user)
|
||||||
|
user = UserTable.get_by_username(user["username"])
|
||||||
|
|
||||||
|
if user:
|
||||||
|
user_root = default_user_root_dir(user.username)
|
||||||
|
os.makedirs(user_root, exist_ok=True)
|
||||||
|
UserRootDirOwnershipTable.assign_paths(user.id, [user_root])
|
||||||
|
HomepageStore.entries["recently_played"].add_new_user(user.id)
|
||||||
|
return user.todict()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"msg": "Failed to create user",
|
||||||
|
}, 500
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/profile/guest/create")
|
||||||
|
@admin_required()
|
||||||
|
def create_guest_user():
|
||||||
|
"""
|
||||||
|
Create a guest user
|
||||||
|
"""
|
||||||
|
# check if guest user already exists
|
||||||
|
guest_user = UserTable.get_by_username("guest")
|
||||||
|
|
||||||
|
if guest_user:
|
||||||
|
return {
|
||||||
|
"msg": "Guest user already exists",
|
||||||
|
}, 400
|
||||||
|
|
||||||
|
UserTable.insert_guest_user()
|
||||||
|
user = UserTable.get_by_username("guest")
|
||||||
|
|
||||||
|
if user:
|
||||||
|
# Guest user is isolated too, but kept under a deterministic root.
|
||||||
|
user_root = default_user_root_dir(user.username)
|
||||||
|
os.makedirs(user_root, exist_ok=True)
|
||||||
|
UserRootDirOwnershipTable.assign_paths(user.id, [user_root])
|
||||||
|
HomepageStore.entries["recently_played"].add_new_user(user.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"msg": "Guest user created",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"msg": "Failed to create guest user",
|
||||||
|
}, 500
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteUseBody(BaseModel):
|
||||||
|
username: str = Field("", description="The username")
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordBody(BaseModel):
|
||||||
|
current_password: str = Field(description="Current password")
|
||||||
|
new_password: str = Field(description="New password")
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/password/change")
|
||||||
|
@jwt_required()
|
||||||
|
@rate_limit("5 per minute")
|
||||||
|
def change_password(body: ChangePasswordBody):
|
||||||
|
"""
|
||||||
|
Change the current user's password. Required when password_change_required is True.
|
||||||
|
"""
|
||||||
|
user_id = current_user["id"]
|
||||||
|
user = UserTable.get_by_id(user_id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return {"msg": "User not found"}, 404
|
||||||
|
|
||||||
|
# Verify current password
|
||||||
|
if not check_password(body.current_password, user.password):
|
||||||
|
return {"msg": "Current password is incorrect"}, 401
|
||||||
|
|
||||||
|
# Validate new password
|
||||||
|
if len(body.new_password) < 8:
|
||||||
|
return {"msg": "Password must be at least 8 characters"}, 400
|
||||||
|
|
||||||
|
if body.current_password == body.new_password:
|
||||||
|
return {"msg": "New password must be different from current password"}, 400
|
||||||
|
|
||||||
|
# Update password and clear the change required flag
|
||||||
|
updated_user = {
|
||||||
|
"id": user_id,
|
||||||
|
"password": hash_password(body.new_password),
|
||||||
|
"password_change_required": False,
|
||||||
|
}
|
||||||
|
UserTable.update_one(updated_user)
|
||||||
|
|
||||||
|
return {"msg": "Password changed successfully", "password_change_required": False}
|
||||||
|
|
||||||
|
|
||||||
|
@api.delete("/profile/delete")
|
||||||
|
@admin_required()
|
||||||
|
def delete_user(body: DeleteUseBody):
|
||||||
|
"""
|
||||||
|
Delete a user by username
|
||||||
|
"""
|
||||||
|
# prevent admin from deleting themselves
|
||||||
|
if body.username == current_user["username"]:
|
||||||
|
return {"msg": "Sorry! you cannot delete yourselfu"}, 400
|
||||||
|
|
||||||
|
# prevent deleting the only admin
|
||||||
|
users = UserTable.get_all()
|
||||||
|
admins = [user for user in users if "admin" in user.roles]
|
||||||
|
if len(admins) == 1 and admins[0].username == body.username:
|
||||||
|
return {"msg": "Cannot delete the only admin"}, 400
|
||||||
|
|
||||||
|
UserTable.remove_by_username(body.username)
|
||||||
|
return {"msg": f"User {body.username} deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/logout")
|
||||||
|
@jwt_required(optional=True)
|
||||||
|
def logout():
|
||||||
|
"""
|
||||||
|
Log out and clear the access token cookie
|
||||||
|
"""
|
||||||
|
# Invalidate session in DragonflyDB
|
||||||
|
if current_user:
|
||||||
|
session_service = get_user_session_service()
|
||||||
|
if session_service.cache.client.is_available():
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
session_service.invalidate_user_session(current_user["id"])
|
||||||
|
|
||||||
|
res = jsonify({"msg": "Logged out"})
|
||||||
|
res.delete_cookie("access_token_cookie")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
class GetAllUsersQuery(BaseModel):
|
||||||
|
simplified: bool = Field(
|
||||||
|
False, description="Whether to return simplified user data"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/users")
|
||||||
|
@jwt_required(optional=True)
|
||||||
|
def get_all_users(query: GetAllUsersQuery):
|
||||||
|
"""
|
||||||
|
Get all users (if you're an admin, you will also receive accounts settings)
|
||||||
|
"""
|
||||||
|
config = UserConfig()
|
||||||
|
settings = {
|
||||||
|
"enableGuest": False,
|
||||||
|
"usersOnLogin": config.usersOnLogin,
|
||||||
|
}
|
||||||
|
|
||||||
|
res = {
|
||||||
|
"settings": {},
|
||||||
|
"users": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
users = list(UserTable.get_all())
|
||||||
|
is_admin = current_user and "admin" in current_user["roles"]
|
||||||
|
settings["enableGuest"] = [
|
||||||
|
user for user in users if user.username == "guest"
|
||||||
|
].__len__() > 0
|
||||||
|
|
||||||
|
# if user is admin, also return settings
|
||||||
|
if is_admin:
|
||||||
|
res = {
|
||||||
|
"settings": settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
# if is normal user, return empty response
|
||||||
|
elif current_user or (
|
||||||
|
not current_user
|
||||||
|
and not settings["usersOnLogin"]
|
||||||
|
and not settings["enableGuest"]
|
||||||
|
):
|
||||||
|
return res
|
||||||
|
|
||||||
|
# remove guest user
|
||||||
|
# if not settings["enableGuest"]:
|
||||||
|
# users = [user for user in users if user.username != "guest"]
|
||||||
|
|
||||||
|
if not settings["usersOnLogin"]:
|
||||||
|
users = [user for user in users if user.username == "guest"]
|
||||||
|
|
||||||
|
# reverse list to show latest users first
|
||||||
|
users = reversed(users)
|
||||||
|
# bring admins to the front
|
||||||
|
users = sorted(users, key=lambda x: "admin" in x.roles, reverse=True)
|
||||||
|
# bring current user to index 0
|
||||||
|
if current_user:
|
||||||
|
users = sorted(
|
||||||
|
users,
|
||||||
|
key=lambda x: x.username == current_user["username"],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if query.simplified:
|
||||||
|
res["users"] = [user.todict_simplified() for user in users]
|
||||||
|
else:
|
||||||
|
res["users"] = [user.todict() for user in users]
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/user")
|
||||||
|
@jwt_required(optional=True)
|
||||||
|
def get_logged_in_user():
|
||||||
|
"""
|
||||||
|
Get logged in user
|
||||||
|
"""
|
||||||
|
if get_jwt_identity() is None:
|
||||||
|
return {"authenticated": False}
|
||||||
|
|
||||||
|
user = dict(current_user)
|
||||||
|
user["authenticated"] = True
|
||||||
|
return user
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from dataclasses import asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from time import time
|
||||||
|
|
||||||
|
import sqlalchemy.exc
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from swingmusic.api.auth import admin_required
|
||||||
|
from swingmusic.db.userdata import (
|
||||||
|
CollectionTable,
|
||||||
|
FavoritesTable,
|
||||||
|
PlaylistTable,
|
||||||
|
ScrobbleTable,
|
||||||
|
)
|
||||||
|
from swingmusic.lib.index import index_everything
|
||||||
|
from swingmusic.settings import Paths
|
||||||
|
from swingmusic.utils.dates import timestamp_to_time_passed
|
||||||
|
|
||||||
|
bp_tag = Tag(name="Backup and Restore", description="Backup and Restore")
|
||||||
|
api = APIBlueprint(
|
||||||
|
"backup_and_restore", __name__, url_prefix="/backup", abp_tags=[bp_tag]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/create")
|
||||||
|
@admin_required()
|
||||||
|
def backup():
|
||||||
|
"""
|
||||||
|
Create a backup file of your favorites, playlists, scrobble data, and collections.
|
||||||
|
"""
|
||||||
|
backup_name = f"backup.{int(time())}"
|
||||||
|
backup_dir = Path("~").expanduser() / "swingmusic.backup" / backup_name
|
||||||
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
backup_file = backup_dir / "data.json"
|
||||||
|
img_folder = backup_dir / "images"
|
||||||
|
img_folder_created = img_folder.exists()
|
||||||
|
|
||||||
|
favorites = FavoritesTable.get_all(with_user=True)
|
||||||
|
favorites = [asdict(entry) for entry in favorites]
|
||||||
|
|
||||||
|
scrobbles = ScrobbleTable.get_all(start=0)
|
||||||
|
scrobbles = [asdict(entry) for entry in scrobbles]
|
||||||
|
|
||||||
|
for scrobble in scrobbles:
|
||||||
|
del scrobble["id"]
|
||||||
|
|
||||||
|
# SECTION: Playlists
|
||||||
|
playlists = PlaylistTable.get_all()
|
||||||
|
playlist_dicts = []
|
||||||
|
|
||||||
|
for entry in playlists:
|
||||||
|
playlist = asdict(entry)
|
||||||
|
for key in [
|
||||||
|
"id",
|
||||||
|
"_last_updated",
|
||||||
|
"has_image",
|
||||||
|
"images",
|
||||||
|
"duration",
|
||||||
|
"count",
|
||||||
|
"pinned",
|
||||||
|
"thumb",
|
||||||
|
]:
|
||||||
|
del playlist[key]
|
||||||
|
|
||||||
|
playlist_dicts.append(playlist)
|
||||||
|
|
||||||
|
# copy images
|
||||||
|
img_path = Path(Paths().playlist_img_path) / str(playlist["image"])
|
||||||
|
if img_path.exists():
|
||||||
|
if not img_folder_created:
|
||||||
|
img_folder.mkdir(parents=True)
|
||||||
|
img_folder_created = True
|
||||||
|
|
||||||
|
shutil.copy(img_path, img_folder / playlist["image"])
|
||||||
|
|
||||||
|
# !SECTION
|
||||||
|
|
||||||
|
# SECTION: Collections
|
||||||
|
collections_list = list(CollectionTable.get_all())
|
||||||
|
collections_dicts = []
|
||||||
|
|
||||||
|
for collection in collections_list:
|
||||||
|
# Remove auto-generated id field
|
||||||
|
collection_copy = collection.copy()
|
||||||
|
if "id" in collection_copy:
|
||||||
|
del collection_copy["id"]
|
||||||
|
collections_dicts.append(collection_copy)
|
||||||
|
# !SECTION
|
||||||
|
data = {
|
||||||
|
"favorites": favorites,
|
||||||
|
"scrobbles": scrobbles,
|
||||||
|
"playlists": playlist_dicts,
|
||||||
|
"collections": collections_dicts,
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(backup_file, "w") as f:
|
||||||
|
json.dump(data, f, indent=4)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": backup_name,
|
||||||
|
"date": timestamp_to_time_passed(int(backup_name.split(".")[1])),
|
||||||
|
"scrobbles": len(scrobbles),
|
||||||
|
"favorites": len(favorites),
|
||||||
|
"playlists": len(playlist_dicts),
|
||||||
|
"collections": len(collections_dicts),
|
||||||
|
}, 200
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreBackup:
|
||||||
|
"""
|
||||||
|
Handles restoration of backup data including favorites, playlists,
|
||||||
|
scrobbles, and collections.
|
||||||
|
|
||||||
|
Note: Mixes (plugin-generated playlists) are not currently backed up
|
||||||
|
as they can be regenerated from the plugin. Future enhancement could
|
||||||
|
include caching mix configurations for faster restoration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, backup_dir: Path):
|
||||||
|
self.backup_dir = backup_dir
|
||||||
|
self.backup_file = backup_dir / "data.json"
|
||||||
|
with open(self.backup_file) as f:
|
||||||
|
self.data = json.load(f)
|
||||||
|
|
||||||
|
# Progress tracking for UX feedback
|
||||||
|
self.progress = {
|
||||||
|
"favorites": 0,
|
||||||
|
"playlists": 0,
|
||||||
|
"scrobbles": 0,
|
||||||
|
"collections": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
self.restore_favorites(self.data["favorites"])
|
||||||
|
self.restore_playlists(self.data["playlists"])
|
||||||
|
self.restore_scrobbles(self.data["scrobbles"])
|
||||||
|
self.restore_collections(self.data.get("collections", []))
|
||||||
|
|
||||||
|
def restore(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def restore_favorites(self, favorites: list[dict]):
|
||||||
|
existing_favorites = FavoritesTable.get_all(with_user=True)
|
||||||
|
existing_hashes = {(fav.type, fav.hash) for fav in existing_favorites}
|
||||||
|
|
||||||
|
for fav in favorites:
|
||||||
|
fav_type = str(fav.get("type") or "").strip()
|
||||||
|
if not fav_type:
|
||||||
|
continue
|
||||||
|
|
||||||
|
canonical_hash = FavoritesTable._normalize_item_hash(
|
||||||
|
str(fav.get("hash") or ""),
|
||||||
|
fav_type,
|
||||||
|
)
|
||||||
|
if not canonical_hash:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = (fav_type, canonical_hash)
|
||||||
|
if key in existing_hashes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"hash": canonical_hash,
|
||||||
|
"type": fav_type,
|
||||||
|
"extra": fav.get("extra", {})
|
||||||
|
if isinstance(fav.get("extra"), dict)
|
||||||
|
else {},
|
||||||
|
}
|
||||||
|
if fav.get("timestamp") is not None:
|
||||||
|
payload["timestamp"] = int(fav["timestamp"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
FavoritesTable.insert_item(payload)
|
||||||
|
existing_hashes.add(key)
|
||||||
|
except sqlalchemy.exc.IntegrityError:
|
||||||
|
print("Integrity error, skipping favorite")
|
||||||
|
print(payload)
|
||||||
|
|
||||||
|
def restore_playlists(self, playlists: list[dict]):
|
||||||
|
existing_playlists = PlaylistTable.get_all()
|
||||||
|
existing_names = {playlist.name for playlist in existing_playlists}
|
||||||
|
new_playlists = [
|
||||||
|
playlist for playlist in playlists if playlist["name"] not in existing_names
|
||||||
|
]
|
||||||
|
|
||||||
|
for playlist in new_playlists:
|
||||||
|
try:
|
||||||
|
if playlist.get("_score") is not None:
|
||||||
|
del playlist["_score"]
|
||||||
|
|
||||||
|
PlaylistTable.add_one(playlist)
|
||||||
|
except sqlalchemy.exc.IntegrityError:
|
||||||
|
print("Integrity error, skipping playlist:")
|
||||||
|
print(playlist)
|
||||||
|
|
||||||
|
def restore_scrobbles(self, scrobbles: list[dict]):
|
||||||
|
existing_scrobbles = ScrobbleTable.get_all(0)
|
||||||
|
existing_hashes = {
|
||||||
|
f"{scrobble.trackhash}.{scrobble.timestamp}"
|
||||||
|
for scrobble in existing_scrobbles
|
||||||
|
}
|
||||||
|
new_scrobbles = [
|
||||||
|
scrobble
|
||||||
|
for scrobble in scrobbles
|
||||||
|
if f"{scrobble['trackhash']}.{scrobble['timestamp']}" not in existing_hashes
|
||||||
|
]
|
||||||
|
|
||||||
|
for scrobble in new_scrobbles:
|
||||||
|
try:
|
||||||
|
ScrobbleTable.add(scrobble)
|
||||||
|
except sqlalchemy.exc.IntegrityError:
|
||||||
|
print("Integrity error, skipping scrobble:")
|
||||||
|
print(scrobble)
|
||||||
|
|
||||||
|
def restore_collections(self, collections: list[dict]):
|
||||||
|
existing_collections = list(CollectionTable.get_all())
|
||||||
|
existing_names = {collection["name"] for collection in existing_collections}
|
||||||
|
new_collections = [
|
||||||
|
collection
|
||||||
|
for collection in collections
|
||||||
|
if collection["name"] not in existing_names
|
||||||
|
]
|
||||||
|
|
||||||
|
for collection in new_collections:
|
||||||
|
try:
|
||||||
|
# Ensure userid is set for the collection
|
||||||
|
if collection.get("userid") is None:
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
|
||||||
|
collection["userid"] = get_current_userid()
|
||||||
|
|
||||||
|
CollectionTable.insert_one(collection)
|
||||||
|
except sqlalchemy.exc.IntegrityError:
|
||||||
|
print("Integrity error, skipping collection:")
|
||||||
|
print(collection)
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreBackupBody(BaseModel):
|
||||||
|
backup_dir: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="The name of the backup directory to restore from. If not provided, all backups will be restored.",
|
||||||
|
example="backup.1234567890",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/restore")
|
||||||
|
@admin_required()
|
||||||
|
def restore(body: RestoreBackupBody):
|
||||||
|
"""
|
||||||
|
Restore your favorites, playlists, scrobble data, and collections from a specified backup or all backups.
|
||||||
|
"""
|
||||||
|
backup_base_dir = Path("~").expanduser() / "swingmusic.backup"
|
||||||
|
backups = []
|
||||||
|
|
||||||
|
if body.backup_dir:
|
||||||
|
# Restore from a specific backup
|
||||||
|
specified_backup_dir = backup_base_dir / body.backup_dir
|
||||||
|
if not specified_backup_dir.exists() or not specified_backup_dir.is_dir():
|
||||||
|
return {"msg": f"Backup '{body.backup_dir}' not found"}, 404
|
||||||
|
|
||||||
|
restore_backup = RestoreBackup(specified_backup_dir)
|
||||||
|
restore_backup.restore()
|
||||||
|
backups.append(body.backup_dir)
|
||||||
|
else:
|
||||||
|
# Restore from all backups
|
||||||
|
try:
|
||||||
|
backup_dirs = [d for d in backup_base_dir.iterdir() if d.is_dir()]
|
||||||
|
except FileNotFoundError:
|
||||||
|
backup_dirs = []
|
||||||
|
|
||||||
|
if not backup_dirs:
|
||||||
|
return {"msg": "No backups found"}, 404
|
||||||
|
|
||||||
|
for backup_dir in sorted(backup_dirs, key=lambda x: x.name, reverse=True):
|
||||||
|
restore_backup = RestoreBackup(backup_dir)
|
||||||
|
restore_backup.restore()
|
||||||
|
backups.append(backup_dir.name)
|
||||||
|
|
||||||
|
index_everything()
|
||||||
|
return {"msg": "Restored successfully", "backups": backups}, 200
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/list")
|
||||||
|
@admin_required()
|
||||||
|
def list_backups():
|
||||||
|
"""
|
||||||
|
List all backups with detailed information.
|
||||||
|
"""
|
||||||
|
backup_dir = Path("~").expanduser() / "swingmusic.backup"
|
||||||
|
backups = []
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
try:
|
||||||
|
paths = [p for p in backup_dir.iterdir() if p.is_dir()]
|
||||||
|
except FileNotFoundError:
|
||||||
|
paths = []
|
||||||
|
|
||||||
|
for path in paths:
|
||||||
|
with contextlib.suppress(IndexError, ValueError):
|
||||||
|
entries.append({"path": path, "timestamp": int(path.name.split(".")[1])})
|
||||||
|
|
||||||
|
entries = sorted(entries, key=lambda x: x["timestamp"], reverse=True)
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
backup_info = {
|
||||||
|
"name": entry["path"].name,
|
||||||
|
"date": timestamp_to_time_passed(entry["timestamp"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read the JSON file and count items
|
||||||
|
json_file: Path = entry["path"] / "data.json"
|
||||||
|
if json_file.exists():
|
||||||
|
with json_file.open("r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
backup_info["scrobbles"] = len(data.get("scrobbles", []))
|
||||||
|
backup_info["favorites"] = len(data.get("favorites", []))
|
||||||
|
backup_info["playlists"] = len(data.get("playlists", []))
|
||||||
|
backup_info["collections"] = len(data.get("collections", []))
|
||||||
|
else:
|
||||||
|
backup_info["scrobbles"] = 0
|
||||||
|
backup_info["favorites"] = 0
|
||||||
|
backup_info["playlists"] = 0
|
||||||
|
backup_info["collections"] = 0
|
||||||
|
|
||||||
|
backups.append(backup_info)
|
||||||
|
|
||||||
|
return {"backups": backups}, 200
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteBackupBody(BaseModel):
|
||||||
|
backup_dir: str = Field(
|
||||||
|
..., description="The name of the backup directory to delete."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.delete("/delete")
|
||||||
|
@admin_required()
|
||||||
|
def delete_backup(body: DeleteBackupBody):
|
||||||
|
"""
|
||||||
|
Delete a backup.
|
||||||
|
"""
|
||||||
|
backup_dir = Path("~").expanduser() / "swingmusic.backup"
|
||||||
|
backup_dir = backup_dir / body.backup_dir
|
||||||
|
if not backup_dir.exists() or not backup_dir.is_dir():
|
||||||
|
return {"msg": f"Backup '{body.backup_dir}' not found"}, 404
|
||||||
|
|
||||||
|
shutil.rmtree(backup_dir)
|
||||||
|
return {"msg": f"Backup '{body.backup_dir}' deleted"}, 200
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""
|
||||||
|
Contains all the collection routes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from swingmusic.db.userdata import CollectionTable
|
||||||
|
from swingmusic.lib.pagelib import (
|
||||||
|
recover_page_items,
|
||||||
|
remove_page_items,
|
||||||
|
validate_page_items,
|
||||||
|
)
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
|
||||||
|
bp_tag = Tag(name="Collections", description="Collections")
|
||||||
|
api = APIBlueprint(
|
||||||
|
"collections", __name__, url_prefix="/collections", abp_tags=[bp_tag]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateCollectionBody(BaseModel):
|
||||||
|
name: str = Field(description="The name of the collection")
|
||||||
|
description: str = Field(description="The description of the collection")
|
||||||
|
items: list[dict[str, Any]] = Field(
|
||||||
|
description="The items to add to the collection",
|
||||||
|
json_schema_extra={"example": [{"type": "album", "hash": "1234567890"}]},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("")
|
||||||
|
def create_collection(body: CreateCollectionBody):
|
||||||
|
"""
|
||||||
|
Create a new collection.
|
||||||
|
"""
|
||||||
|
items = validate_page_items(body.items, existing=[])
|
||||||
|
|
||||||
|
if len(items) == 0:
|
||||||
|
return {"error": "No items to add"}, 400
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": body.name,
|
||||||
|
"items": items,
|
||||||
|
"userid": get_current_userid(),
|
||||||
|
"extra": {
|
||||||
|
"description": body.description,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectionTable.insert_one(payload)
|
||||||
|
|
||||||
|
return {"message": "collection created"}, 201
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("")
|
||||||
|
def get_collections():
|
||||||
|
"""
|
||||||
|
Get all collections.
|
||||||
|
"""
|
||||||
|
return list(CollectionTable.get_all())
|
||||||
|
|
||||||
|
|
||||||
|
class AddCollectionItemBody(BaseModel):
|
||||||
|
item: dict[str, Any] = Field(
|
||||||
|
description="The item to add to the collection",
|
||||||
|
json_schema_extra={"example": {"type": "album", "hash": "1234567890"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AddCollectionItemPath(BaseModel):
|
||||||
|
collection_id: int = Field(
|
||||||
|
description="The ID of the collection to add items to",
|
||||||
|
json_schema_extra={"example": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/<int:collection_id>/items")
|
||||||
|
def add_collection_item(path: AddCollectionItemPath, body: AddCollectionItemBody):
|
||||||
|
"""
|
||||||
|
Add an item to a collection.
|
||||||
|
"""
|
||||||
|
collection = CollectionTable.get_by_id(path.collection_id)
|
||||||
|
|
||||||
|
if collection is None:
|
||||||
|
return {"error": "Collection not found"}, 404
|
||||||
|
|
||||||
|
new_items = validate_page_items([body.item], existing=collection["items"])
|
||||||
|
|
||||||
|
if len(new_items) == 0:
|
||||||
|
return {"error": "items already in collection"}, 400
|
||||||
|
|
||||||
|
collection["items"].extend(new_items)
|
||||||
|
CollectionTable.update_items(collection["id"], collection["items"])
|
||||||
|
|
||||||
|
return {"message": "Items added to collection"}
|
||||||
|
|
||||||
|
|
||||||
|
class RemoveCollectionItemBody(BaseModel):
|
||||||
|
item: dict[str, Any] = Field(
|
||||||
|
description="The item to remove from the collection",
|
||||||
|
json_schema_extra={"example": {"type": "album", "hash": "1234567890"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RemoveCollectionItemPath(BaseModel):
|
||||||
|
collection_id: int = Field(
|
||||||
|
description="The ID of the collection to remove items from"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.delete("/<int:collection_id>/items")
|
||||||
|
def remove_collection_item(
|
||||||
|
path: RemoveCollectionItemPath, body: RemoveCollectionItemBody
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Remove an item from a collection.
|
||||||
|
"""
|
||||||
|
collection = CollectionTable.get_by_id(path.collection_id)
|
||||||
|
|
||||||
|
if collection is None:
|
||||||
|
return {"error": "Collection not found"}, 404
|
||||||
|
|
||||||
|
remaining = remove_page_items(collection["items"], body.item)
|
||||||
|
CollectionTable.update_items(collection["id"], remaining)
|
||||||
|
|
||||||
|
return {"message": "Item removed from collection"}
|
||||||
|
|
||||||
|
|
||||||
|
class GetCollectionBody(BaseModel):
|
||||||
|
collection_id: int = Field(description="The ID of the collection to get")
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/<int:collection_id>")
|
||||||
|
def get_collection(path: GetCollectionBody):
|
||||||
|
"""
|
||||||
|
Get a collection.
|
||||||
|
"""
|
||||||
|
collection = CollectionTable.get_by_id(path.collection_id)
|
||||||
|
if not collection:
|
||||||
|
return {"error": "Collection not found"}, 404
|
||||||
|
|
||||||
|
items = recover_page_items(collection["items"])
|
||||||
|
return {
|
||||||
|
"id": collection["id"],
|
||||||
|
"name": collection["name"],
|
||||||
|
"items": items,
|
||||||
|
"extra": collection["extra"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateCollectionBody(BaseModel):
|
||||||
|
name: str = Field(description="The name of the collection")
|
||||||
|
description: str = Field(
|
||||||
|
description="The description of the collection", default=""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.put("/<int:collection_id>")
|
||||||
|
def update_collection(path: GetCollectionBody, body: UpdateCollectionBody):
|
||||||
|
"""
|
||||||
|
Update a collection.
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"id": path.collection_id,
|
||||||
|
"name": body.name,
|
||||||
|
"extra": {"description": body.description},
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectionTable.update_one(payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteCollectionPath(BaseModel):
|
||||||
|
collection_id: int = Field(description="The ID of the collection to delete")
|
||||||
|
|
||||||
|
|
||||||
|
@api.delete("/<int:collection_id>")
|
||||||
|
def delete_collection(path: DeleteCollectionPath):
|
||||||
|
"""
|
||||||
|
Delete a collection.
|
||||||
|
"""
|
||||||
|
CollectionTable.delete_by_id(path.collection_id)
|
||||||
|
return {"message": "Collection deleted"}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
|
||||||
|
from swingmusic.api.apischemas import AlbumHashSchema
|
||||||
|
from swingmusic.store.albums import AlbumStore as Store
|
||||||
|
|
||||||
|
bp_tag = Tag(name="Colors", description="Get item colors")
|
||||||
|
api = APIBlueprint("colors", __name__, url_prefix="/colors", abp_tags=[bp_tag])
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/album/<albumhash>")
|
||||||
|
def get_album_color(path: AlbumHashSchema):
|
||||||
|
"""
|
||||||
|
Get album color
|
||||||
|
"""
|
||||||
|
album = Store.get_album_by_hash(path.albumhash)
|
||||||
|
|
||||||
|
msg = {"color": ""}
|
||||||
|
|
||||||
|
if album is None or len(album.colors) == 0:
|
||||||
|
return msg, 404
|
||||||
|
|
||||||
|
return {"color": album.colors[0]}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask_jwt_extended import get_jwt_identity
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from swingmusic.config import UserConfig
|
||||||
|
from swingmusic.db.production import UserRootDirOwnershipTable
|
||||||
|
from swingmusic.services.download_jobs import download_job_manager
|
||||||
|
from swingmusic.services.library_projection import (
|
||||||
|
get_track_availability,
|
||||||
|
get_track_availability_map,
|
||||||
|
import_existing_track,
|
||||||
|
list_import_candidates,
|
||||||
|
)
|
||||||
|
from swingmusic.services.playlist_tracking import playlist_tracking_service
|
||||||
|
from swingmusic.services.user_library_scope import get_user_root_dirs
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
|
||||||
|
bp_tag = Tag(name="Downloads", description="Unified download jobs and import flow")
|
||||||
|
api = APIBlueprint(
|
||||||
|
"downloads", __name__, url_prefix="/api/downloads", abp_tags=[bp_tag]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JobsQuery(BaseModel):
|
||||||
|
limit: int = Field(default=200, description="Maximum number of jobs to return")
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryQuery(BaseModel):
|
||||||
|
limit: int = Field(default=100, description="Maximum history items")
|
||||||
|
offset: int = Field(default=0, description="History offset")
|
||||||
|
|
||||||
|
|
||||||
|
class CreateDownloadJobBody(BaseModel):
|
||||||
|
source_url: str | None = Field(default=None, description="Original source URL")
|
||||||
|
source: str = Field(default="spotify", description="Source provider")
|
||||||
|
quality: str = Field(default="high", description="Requested quality")
|
||||||
|
codec: str | None = Field(default=None, description="Codec hint")
|
||||||
|
trackhash: str | None = Field(default=None, description="Track hash")
|
||||||
|
title: str | None = Field(default=None, description="Track title")
|
||||||
|
artist: str | None = Field(default=None, description="Track artist")
|
||||||
|
album: str | None = Field(default=None, description="Track album")
|
||||||
|
item_type: str = Field(default="track", description="Item type")
|
||||||
|
target_path: str | None = Field(
|
||||||
|
default=None, description="Optional destination path"
|
||||||
|
)
|
||||||
|
payload: dict = Field(default_factory=dict, description="Extra provider payload")
|
||||||
|
|
||||||
|
|
||||||
|
class JobPath(BaseModel):
|
||||||
|
job_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class ImportCandidatesBody(BaseModel):
|
||||||
|
trackhash: str = Field(description="Trackhash to query import candidates for")
|
||||||
|
|
||||||
|
|
||||||
|
class ImportConfirmBody(BaseModel):
|
||||||
|
trackhash: str = Field(description="Trackhash to import")
|
||||||
|
source_userid: int | None = Field(
|
||||||
|
default=None, description="Specific source user ID"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AvailabilityBody(BaseModel):
|
||||||
|
trackhashes: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class TrackPlaylistBody(BaseModel):
|
||||||
|
source_url: str = Field(
|
||||||
|
description="Trackable playlist URL (Spotify and supported providers)"
|
||||||
|
)
|
||||||
|
quality: str | None = Field(default="lossless", description="Requested quality")
|
||||||
|
codec: str | None = Field(default="flac", description="Requested codec")
|
||||||
|
auto_sync: bool = Field(default=True, description="Enable periodic sync")
|
||||||
|
sync_interval_seconds: int = Field(
|
||||||
|
default=900, description="Sync cadence in seconds"
|
||||||
|
)
|
||||||
|
sync_now: bool = Field(default=True, description="Run immediate sync")
|
||||||
|
|
||||||
|
|
||||||
|
class TrackedPlaylistPath(BaseModel):
|
||||||
|
tracked_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class TrackedPlaylistsQuery(BaseModel):
|
||||||
|
playlist_id: str | None = Field(
|
||||||
|
default=None, description="Filter by Spotify playlist ID"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ToggleAutoSyncBody(BaseModel):
|
||||||
|
enabled: bool = Field(default=True, description="Whether auto sync is enabled")
|
||||||
|
|
||||||
|
|
||||||
|
class StorageRootsBody(BaseModel):
|
||||||
|
root_dirs: list[str] = Field(
|
||||||
|
default_factory=list, description="Root directories for current user"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _current_userid() -> int:
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
if isinstance(identity, dict) and identity.get("id") is not None:
|
||||||
|
return int(identity["id"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return get_current_userid()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_root_path(value: str) -> str:
|
||||||
|
if value == "$home":
|
||||||
|
return "$home"
|
||||||
|
|
||||||
|
return Path(value).expanduser().resolve().as_posix().rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_root_bases() -> list[Path]:
|
||||||
|
bases: list[Path] = []
|
||||||
|
for root in UserConfig().rootDirs or []:
|
||||||
|
if root == "$home":
|
||||||
|
bases.append(Path.home().resolve())
|
||||||
|
else:
|
||||||
|
bases.append(Path(root).expanduser().resolve())
|
||||||
|
return bases
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_user_roots(root_dirs: list[str]) -> list[str]:
|
||||||
|
normalized = [
|
||||||
|
_normalize_root_path(path.strip())
|
||||||
|
for path in root_dirs
|
||||||
|
if path and path.strip()
|
||||||
|
]
|
||||||
|
normalized = list(dict.fromkeys(normalized))
|
||||||
|
|
||||||
|
configured_bases = _allowed_root_bases()
|
||||||
|
configured_raw = UserConfig().rootDirs or []
|
||||||
|
if not configured_bases:
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
for root in normalized:
|
||||||
|
if root == "$home":
|
||||||
|
if "$home" not in configured_raw:
|
||||||
|
raise ValueError(
|
||||||
|
"$home is not allowed because it is not configured as a server root"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidate = Path(root).expanduser().resolve()
|
||||||
|
valid = False
|
||||||
|
for base in configured_bases:
|
||||||
|
if candidate == base or base in candidate.parents:
|
||||||
|
valid = True
|
||||||
|
break
|
||||||
|
if not valid:
|
||||||
|
raise ValueError(
|
||||||
|
"User root directories must be inside configured library roots"
|
||||||
|
)
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/jobs")
|
||||||
|
def list_download_jobs(query: JobsQuery):
|
||||||
|
userid = _current_userid()
|
||||||
|
limit = max(1, min(int(query.limit or 200), 500))
|
||||||
|
jobs = download_job_manager.list_jobs(userid, limit=limit)
|
||||||
|
return {
|
||||||
|
"jobs": jobs,
|
||||||
|
"total": len(jobs),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/queue")
|
||||||
|
def get_download_queue(query: JobsQuery):
|
||||||
|
userid = _current_userid()
|
||||||
|
limit = max(1, min(int(query.limit or 200), 500))
|
||||||
|
jobs = download_job_manager.list_jobs(userid, limit=limit)
|
||||||
|
|
||||||
|
pending = [job for job in jobs if job["state"] == "queued"]
|
||||||
|
active = [job for job in jobs if job["state"] == "downloading"]
|
||||||
|
queued = [job for job in jobs if job["state"] in {"queued", "downloading"}]
|
||||||
|
history = [
|
||||||
|
job for job in jobs if job["state"] in {"completed", "failed", "cancelled"}
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"queue_length": len(pending),
|
||||||
|
"active_downloads": len(active),
|
||||||
|
"queue": queued,
|
||||||
|
"pending": pending,
|
||||||
|
"active": active,
|
||||||
|
"history": history,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/status")
|
||||||
|
def get_download_status(query: JobsQuery):
|
||||||
|
userid = _current_userid()
|
||||||
|
limit = max(1, min(int(query.limit or 500), 2000))
|
||||||
|
jobs = download_job_manager.list_jobs(userid, limit=limit)
|
||||||
|
|
||||||
|
counts = {
|
||||||
|
"queued": 0,
|
||||||
|
"downloading": 0,
|
||||||
|
"completed": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"cancelled": 0,
|
||||||
|
}
|
||||||
|
for job in jobs:
|
||||||
|
state = job.get("state")
|
||||||
|
if state in counts:
|
||||||
|
counts[state] += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"counts": counts,
|
||||||
|
"total": len(jobs),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/history")
|
||||||
|
def get_download_history(query: HistoryQuery):
|
||||||
|
userid = _current_userid()
|
||||||
|
limit = max(1, min(int(query.limit or 100), 500))
|
||||||
|
offset = max(0, int(query.offset or 0))
|
||||||
|
|
||||||
|
jobs = download_job_manager.list_jobs(userid, limit=2000)
|
||||||
|
history = [
|
||||||
|
job for job in jobs if job["state"] in {"completed", "failed", "cancelled"}
|
||||||
|
]
|
||||||
|
sliced = history[offset : offset + limit]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"history": sliced,
|
||||||
|
"total": len(history),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/history/clear")
|
||||||
|
def clear_download_history():
|
||||||
|
userid = _current_userid()
|
||||||
|
deleted = download_job_manager.clear_history(userid)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"deleted": deleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/jobs")
|
||||||
|
def create_download_job(body: CreateDownloadJobBody):
|
||||||
|
userid = _current_userid()
|
||||||
|
|
||||||
|
job_id = download_job_manager.enqueue(
|
||||||
|
userid=userid,
|
||||||
|
source_url=body.source_url,
|
||||||
|
source=body.source,
|
||||||
|
quality=body.quality,
|
||||||
|
codec=body.codec,
|
||||||
|
trackhash=body.trackhash,
|
||||||
|
title=body.title,
|
||||||
|
artist=body.artist,
|
||||||
|
album=body.album,
|
||||||
|
item_type=body.item_type,
|
||||||
|
target_path=body.target_path,
|
||||||
|
payload=body.payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
job = download_job_manager.get_job(job_id, userid=userid)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"job_id": job_id,
|
||||||
|
"job": job,
|
||||||
|
}, 201
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/jobs/<job_id>")
|
||||||
|
def get_download_job(path: JobPath):
|
||||||
|
userid = _current_userid()
|
||||||
|
job = download_job_manager.get_job(path.job_id, userid=userid)
|
||||||
|
|
||||||
|
if not job:
|
||||||
|
return {"error": "Job not found"}, 404
|
||||||
|
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/jobs/<job_id>/cancel")
|
||||||
|
def cancel_download_job(path: JobPath):
|
||||||
|
userid = _current_userid()
|
||||||
|
success = download_job_manager.cancel(path.job_id, userid)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
return {"success": False, "error": "Unable to cancel job"}, 400
|
||||||
|
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/jobs/<job_id>/retry")
|
||||||
|
def retry_download_job(path: JobPath):
|
||||||
|
userid = _current_userid()
|
||||||
|
success = download_job_manager.retry(path.job_id, userid)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
return {"success": False, "error": "Unable to retry job"}, 400
|
||||||
|
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/imports/candidates")
|
||||||
|
def get_import_candidates(body: ImportCandidatesBody):
|
||||||
|
userid = _current_userid()
|
||||||
|
candidates = list_import_candidates(body.trackhash, userid=userid)
|
||||||
|
availability = get_track_availability(body.trackhash, userid=userid)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"trackhash": body.trackhash,
|
||||||
|
"availability": availability,
|
||||||
|
"candidates": candidates,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/imports/confirm")
|
||||||
|
def confirm_import(body: ImportConfirmBody):
|
||||||
|
userid = _current_userid()
|
||||||
|
imported = import_existing_track(
|
||||||
|
body.trackhash,
|
||||||
|
userid=userid,
|
||||||
|
source_userid=body.source_userid,
|
||||||
|
)
|
||||||
|
|
||||||
|
availability = get_track_availability(body.trackhash, userid=userid)
|
||||||
|
|
||||||
|
if not imported:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "No import candidate available",
|
||||||
|
"availability": availability,
|
||||||
|
}, 404
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"availability": availability,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/tracks/availability")
|
||||||
|
def get_tracks_availability(body: AvailabilityBody):
|
||||||
|
userid = _current_userid()
|
||||||
|
availability = get_track_availability_map(body.trackhashes, userid=userid)
|
||||||
|
return {
|
||||||
|
"availability": availability,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/playlists/track")
|
||||||
|
def track_playlist(body: TrackPlaylistBody):
|
||||||
|
userid = _current_userid()
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = playlist_tracking_service.track_playlist(
|
||||||
|
userid=userid,
|
||||||
|
source_url=body.source_url,
|
||||||
|
quality=body.quality,
|
||||||
|
codec=body.codec,
|
||||||
|
auto_sync=body.auto_sync,
|
||||||
|
sync_interval_seconds=body.sync_interval_seconds,
|
||||||
|
sync_now=body.sync_now,
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
return {"success": False, "error": str(error)}, 400
|
||||||
|
except Exception as error:
|
||||||
|
return {"success": False, "error": f"Failed to track playlist: {error}"}, 500
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
**payload,
|
||||||
|
}, 201
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/playlists/tracked")
|
||||||
|
def list_tracked_playlists(query: TrackedPlaylistsQuery):
|
||||||
|
userid = _current_userid()
|
||||||
|
items = playlist_tracking_service.list_tracked_playlists(userid)
|
||||||
|
|
||||||
|
if query.playlist_id:
|
||||||
|
filtered = [
|
||||||
|
item for item in items if item.get("playlist_id") == query.playlist_id
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
filtered = items
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tracked_playlists": filtered,
|
||||||
|
"total": len(filtered),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/playlists/<tracked_id>/sync")
|
||||||
|
def sync_tracked_playlist(path: TrackedPlaylistPath):
|
||||||
|
userid = _current_userid()
|
||||||
|
result = playlist_tracking_service.sync_tracked_playlist(
|
||||||
|
path.tracked_id, userid=userid, force=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.get("success"):
|
||||||
|
if result.get("message") == "Tracked playlist not found":
|
||||||
|
return {"success": False, **result}, 404
|
||||||
|
return {"success": False, **result}, 400
|
||||||
|
|
||||||
|
tracked = playlist_tracking_service.get_tracked_playlist(path.tracked_id, userid)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"result": result,
|
||||||
|
"tracked": tracked,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/playlists/<tracked_id>/auto-sync")
|
||||||
|
def toggle_playlist_auto_sync(path: TrackedPlaylistPath, body: ToggleAutoSyncBody):
|
||||||
|
userid = _current_userid()
|
||||||
|
tracked = playlist_tracking_service.set_auto_sync(
|
||||||
|
path.tracked_id, userid=userid, enabled=body.enabled
|
||||||
|
)
|
||||||
|
if not tracked:
|
||||||
|
return {"success": False, "error": "Tracked playlist not found"}, 404
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"tracked": tracked,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.delete("/playlists/<tracked_id>")
|
||||||
|
def delete_tracked_playlist(path: TrackedPlaylistPath):
|
||||||
|
userid = _current_userid()
|
||||||
|
deleted = playlist_tracking_service.untrack_playlist(path.tracked_id, userid=userid)
|
||||||
|
if not deleted:
|
||||||
|
return {"success": False, "error": "Tracked playlist not found"}, 404
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/storage/roots")
|
||||||
|
def get_storage_roots():
|
||||||
|
userid = _current_userid()
|
||||||
|
configured_roots = UserConfig().rootDirs or []
|
||||||
|
owned_roots = UserRootDirOwnershipTable.get_paths(userid)
|
||||||
|
effective = get_user_root_dirs(userid)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"configured_roots": configured_roots,
|
||||||
|
"owned_roots": owned_roots,
|
||||||
|
"effective_roots": effective,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/storage/roots")
|
||||||
|
def set_storage_roots(body: StorageRootsBody):
|
||||||
|
userid = _current_userid()
|
||||||
|
try:
|
||||||
|
normalized = _validate_user_roots(body.root_dirs)
|
||||||
|
except ValueError as error:
|
||||||
|
return {"success": False, "error": str(error)}, 400
|
||||||
|
|
||||||
|
for root in normalized:
|
||||||
|
if root == "$home":
|
||||||
|
continue
|
||||||
|
os.makedirs(root, exist_ok=True)
|
||||||
|
|
||||||
|
UserRootDirOwnershipTable.replace_paths(userid, normalized)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"owned_roots": UserRootDirOwnershipTable.get_paths(userid),
|
||||||
|
"effective_roots": get_user_root_dirs(userid),
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""
|
||||||
|
DragonflyDB health check and monitoring endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
|
||||||
|
from swingmusic.db.dragonfly_client import get_dragonfly_client
|
||||||
|
from swingmusic.db.dragonfly_extended_client import (
|
||||||
|
get_all_dragonfly_services,
|
||||||
|
get_job_queue_service,
|
||||||
|
get_realtime_service,
|
||||||
|
get_search_cache_service,
|
||||||
|
get_track_cache_service,
|
||||||
|
get_user_session_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
tag = Tag(name="DragonflyDB", description="DragonflyDB cache monitoring")
|
||||||
|
api = APIBlueprint("dragonfly", __name__, url_prefix="/dragonfly", abp_tags=[tag])
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/health")
|
||||||
|
def health_check():
|
||||||
|
"""
|
||||||
|
Check DragonflyDB connection health.
|
||||||
|
|
||||||
|
Returns basic connectivity status and response time.
|
||||||
|
"""
|
||||||
|
client = get_dragonfly_client()
|
||||||
|
|
||||||
|
if not client.is_available():
|
||||||
|
return {
|
||||||
|
"status": "unavailable",
|
||||||
|
"connected": False,
|
||||||
|
"message": "DragonflyDB is not available or not configured",
|
||||||
|
}, 503
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Measure ping response time
|
||||||
|
import time
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
pong = client.ping()
|
||||||
|
latency_ms = round((time.time() - start) * 1000, 2)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"connected": True,
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
"ping": pong,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"connected": False,
|
||||||
|
"message": str(e),
|
||||||
|
}, 503
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/stats")
|
||||||
|
def get_stats():
|
||||||
|
"""
|
||||||
|
Get DragonflyDB statistics and memory usage.
|
||||||
|
|
||||||
|
Returns detailed information about cache usage, memory, and performance.
|
||||||
|
"""
|
||||||
|
client = get_dragonfly_client()
|
||||||
|
|
||||||
|
if not client.is_available():
|
||||||
|
return {"error": "DragonflyDB is not available"}, 503
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = client.info()
|
||||||
|
|
||||||
|
# Extract relevant stats
|
||||||
|
stats = {
|
||||||
|
"memory": {
|
||||||
|
"used_memory": info.get("used_memory_human", "Unknown"),
|
||||||
|
"used_memory_peak": info.get("used_memory_peak_human", "Unknown"),
|
||||||
|
"used_memory_rss": info.get("used_memory_rss_human", "Unknown"),
|
||||||
|
"memory_fragmentation_ratio": info.get("mem_fragmentation_ratio", 0),
|
||||||
|
},
|
||||||
|
"clients": {
|
||||||
|
"connected_clients": info.get("connected_clients", 0),
|
||||||
|
"blocked_clients": info.get("blocked_clients", 0),
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"total_connections_received": info.get("total_connections_received", 0),
|
||||||
|
"total_commands_processed": info.get("total_commands_processed", 0),
|
||||||
|
"instantaneous_ops_per_sec": info.get("instantaneous_ops_per_sec", 0),
|
||||||
|
"keyspace_hits": info.get("keyspace_hits", 0),
|
||||||
|
"keyspace_misses": info.get("keyspace_misses", 0),
|
||||||
|
"hit_rate": _calculate_hit_rate(
|
||||||
|
info.get("keyspace_hits", 0), info.get("keyspace_misses", 0)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"cpu": {
|
||||||
|
"used_cpu_sys": info.get("used_cpu_sys", 0),
|
||||||
|
"used_cpu_user": info.get("used_cpu_user", 0),
|
||||||
|
},
|
||||||
|
"uptime_seconds": info.get("uptime_in_seconds", 0),
|
||||||
|
"version": info.get(
|
||||||
|
"dragonfly_version", info.get("redis_version", "Unknown")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}, 500
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/services")
|
||||||
|
def get_services_status():
|
||||||
|
"""
|
||||||
|
Get status of all DragonflyDB cache services.
|
||||||
|
|
||||||
|
Returns information about each cache namespace and its usage.
|
||||||
|
"""
|
||||||
|
client = get_dragonfly_client()
|
||||||
|
|
||||||
|
if not client.is_available():
|
||||||
|
return {"error": "DragonflyDB is not available"}, 503
|
||||||
|
|
||||||
|
get_all_dragonfly_services()
|
||||||
|
|
||||||
|
service_stats = {}
|
||||||
|
|
||||||
|
# Track cache stats
|
||||||
|
track_service = get_track_cache_service()
|
||||||
|
track_keys = client.keys("tracks:*")
|
||||||
|
service_stats["track_cache"] = {
|
||||||
|
"available": track_service.cache.client.is_available(),
|
||||||
|
"cached_tracks": len(track_keys),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Search cache stats
|
||||||
|
search_service = get_search_cache_service()
|
||||||
|
search_keys = client.keys("search:*")
|
||||||
|
service_stats["search_cache"] = {
|
||||||
|
"available": search_service.cache.client.is_available(),
|
||||||
|
"cached_searches": len(search_keys),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Session cache stats
|
||||||
|
session_service = get_user_session_service()
|
||||||
|
session_keys = client.keys("sessions:*")
|
||||||
|
service_stats["session_cache"] = {
|
||||||
|
"available": session_service.cache.client.is_available(),
|
||||||
|
"active_sessions": len(session_keys),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Realtime features stats
|
||||||
|
realtime_service = get_realtime_service()
|
||||||
|
playcount_keys = client.keys("playcounts:*")
|
||||||
|
recent_keys = client.keys("recent:*")
|
||||||
|
favorite_keys = client.keys("favorites:*")
|
||||||
|
service_stats["realtime_features"] = {
|
||||||
|
"available": realtime_service.playcount_cache.client.is_available(),
|
||||||
|
"playcount_entries": len(playcount_keys),
|
||||||
|
"recent_lists": len(recent_keys),
|
||||||
|
"favorite_entries": len(favorite_keys),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Job queue stats
|
||||||
|
job_service = get_job_queue_service()
|
||||||
|
download_queue_size = job_service.get_queue_size("downloads")
|
||||||
|
service_stats["job_queue"] = {
|
||||||
|
"available": job_service.cache.client.is_available(),
|
||||||
|
"download_queue_size": download_queue_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"services": service_stats,
|
||||||
|
"total_keys": len(client.keys("*")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/keys")
|
||||||
|
def get_key_stats():
|
||||||
|
"""
|
||||||
|
Get statistics about cached keys by namespace.
|
||||||
|
|
||||||
|
Returns count of keys in each cache namespace.
|
||||||
|
"""
|
||||||
|
client = get_dragonfly_client()
|
||||||
|
|
||||||
|
if not client.is_available():
|
||||||
|
return {"error": "DragonflyDB is not available"}, 503
|
||||||
|
|
||||||
|
namespaces = [
|
||||||
|
"tracks",
|
||||||
|
"artists",
|
||||||
|
"albums",
|
||||||
|
"sessions",
|
||||||
|
"users",
|
||||||
|
"search",
|
||||||
|
"homepage",
|
||||||
|
"mobile",
|
||||||
|
"sync",
|
||||||
|
"progress",
|
||||||
|
"playlists",
|
||||||
|
"playcounts",
|
||||||
|
"recent",
|
||||||
|
"favorites",
|
||||||
|
"recommendations",
|
||||||
|
"jobs",
|
||||||
|
"lyrics",
|
||||||
|
"index",
|
||||||
|
"temp",
|
||||||
|
]
|
||||||
|
|
||||||
|
key_stats = {}
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
for namespace in namespaces:
|
||||||
|
keys = client.keys(f"{namespace}:*")
|
||||||
|
count = len(keys)
|
||||||
|
key_stats[namespace] = count
|
||||||
|
total += count
|
||||||
|
|
||||||
|
key_stats["total"] = total
|
||||||
|
|
||||||
|
return key_stats
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/clear/<namespace>")
|
||||||
|
def clear_namespace(namespace: str):
|
||||||
|
"""
|
||||||
|
Clear all keys in a specific cache namespace.
|
||||||
|
|
||||||
|
Use with caution - this will remove all cached data for the namespace.
|
||||||
|
"""
|
||||||
|
client = get_dragonfly_client()
|
||||||
|
|
||||||
|
if not client.is_available():
|
||||||
|
return {"error": "DragonflyDB is not available"}, 503
|
||||||
|
|
||||||
|
# Validate namespace to prevent accidental data loss
|
||||||
|
allowed_namespaces = [
|
||||||
|
"search",
|
||||||
|
"homepage",
|
||||||
|
"temp",
|
||||||
|
"recommendations",
|
||||||
|
"index",
|
||||||
|
]
|
||||||
|
|
||||||
|
if namespace not in allowed_namespaces:
|
||||||
|
return {
|
||||||
|
"error": f"Cannot clear namespace '{namespace}'. Allowed namespaces: {allowed_namespaces}"
|
||||||
|
}, 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
keys = client.keys(f"{namespace}:*")
|
||||||
|
if keys:
|
||||||
|
deleted = client.delete(*keys)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"namespace": namespace,
|
||||||
|
"keys_deleted": deleted,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"namespace": namespace,
|
||||||
|
"keys_deleted": 0,
|
||||||
|
"message": "No keys found in namespace",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}, 500
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_hit_rate(hits: int, misses: int) -> float:
|
||||||
|
"""Calculate cache hit rate percentage"""
|
||||||
|
total = hits + misses
|
||||||
|
if total == 0:
|
||||||
|
return 0.0
|
||||||
|
return round((hits / total) * 100, 2)
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
"""
|
||||||
|
Enhanced Search API for SwingMusic
|
||||||
|
Integrates global music catalog search with existing local search
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from swingmusic.api.search import search_items as local_search
|
||||||
|
from swingmusic.db.spotify import UserCatalogPreferencesTable
|
||||||
|
from swingmusic.services.music_catalog import music_catalog_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Create blueprint
|
||||||
|
enhanced_search_bp = Blueprint("enhanced_search", __name__, url_prefix="/api/search")
|
||||||
|
|
||||||
|
|
||||||
|
@enhanced_search_bp.route("/global", methods=["POST"])
|
||||||
|
def global_search():
|
||||||
|
"""
|
||||||
|
Search across global music catalog (Spotify)
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
{
|
||||||
|
"query": "search query",
|
||||||
|
"type": "all|tracks|albums|artists|playlists",
|
||||||
|
"limit": 20,
|
||||||
|
"user_id": 1
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or not data.get("query"):
|
||||||
|
return jsonify({"error": "Search query is required"}), 400
|
||||||
|
|
||||||
|
query = data["query"].strip()
|
||||||
|
search_type = data.get("type", "all")
|
||||||
|
limit = min(data.get("limit", 20), 50) # Cap at 50
|
||||||
|
user_id = data.get("user_id")
|
||||||
|
|
||||||
|
# Get user preferences if available
|
||||||
|
user_prefs = None
|
||||||
|
if user_id:
|
||||||
|
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
|
||||||
|
limit = min(limit, user_prefs.max_search_results)
|
||||||
|
|
||||||
|
# Run async search
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = loop.run_until_complete(
|
||||||
|
music_catalog_service.search_global_catalog(query, search_type, limit)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
# Filter based on user preferences
|
||||||
|
if user_prefs and not user_prefs.show_explicit:
|
||||||
|
result.tracks = [track for track in result.tracks if not track.explicit]
|
||||||
|
result.albums = [album for album in result.albums if not album.explicit]
|
||||||
|
|
||||||
|
# Convert to dict for JSON response
|
||||||
|
response_data = {
|
||||||
|
"query": result.query,
|
||||||
|
"total": result.total,
|
||||||
|
"tracks": [_catalog_item_to_dict(track) for track in result.tracks],
|
||||||
|
"albums": [_catalog_item_to_dict(album) for album in result.albums],
|
||||||
|
"artists": [_catalog_item_to_dict(artist) for artist in result.artists],
|
||||||
|
"playlists": [
|
||||||
|
_catalog_item_to_dict(playlist) for playlist in result.playlists
|
||||||
|
],
|
||||||
|
"source": "global_catalog",
|
||||||
|
"cache_info": {
|
||||||
|
"from_cache": False, # Cache detection would require tracking query timestamps
|
||||||
|
"expires_at": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(response_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in global search: {e}")
|
||||||
|
return jsonify({"error": "Search failed"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@enhanced_search_bp.route("/combined", methods=["POST"])
|
||||||
|
def combined_search():
|
||||||
|
"""
|
||||||
|
Search both local library and global catalog
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
{
|
||||||
|
"query": "search query",
|
||||||
|
"include_local": true,
|
||||||
|
"include_global": true,
|
||||||
|
"type": "all|tracks|albums|artists",
|
||||||
|
"limit": 20,
|
||||||
|
"user_id": 1
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or not data.get("query"):
|
||||||
|
return jsonify({"error": "Search query is required"}), 400
|
||||||
|
|
||||||
|
query = data["query"].strip()
|
||||||
|
include_local = data.get("include_local", True)
|
||||||
|
include_global = data.get("include_global", True)
|
||||||
|
search_type = data.get("type", "all")
|
||||||
|
limit = min(data.get("limit", 20), 50)
|
||||||
|
user_id = data.get("user_id")
|
||||||
|
|
||||||
|
results = {
|
||||||
|
"query": query,
|
||||||
|
"local": {"tracks": [], "albums": [], "artists": []},
|
||||||
|
"global": {"tracks": [], "albums": [], "artists": [], "playlists": []},
|
||||||
|
"total": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Search local library
|
||||||
|
if include_local:
|
||||||
|
try:
|
||||||
|
# Use existing local search
|
||||||
|
local_results = local_search(query, search_type)
|
||||||
|
results["local"] = (
|
||||||
|
local_results
|
||||||
|
if local_results
|
||||||
|
else {"tracks": [], "albums": [], "artists": []}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in local search: {e}")
|
||||||
|
|
||||||
|
# Search global catalog
|
||||||
|
if include_global:
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
try:
|
||||||
|
global_results = loop.run_until_complete(
|
||||||
|
music_catalog_service.search_global_catalog(
|
||||||
|
query, search_type, limit
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter based on user preferences
|
||||||
|
user_prefs = None
|
||||||
|
if user_id:
|
||||||
|
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
|
||||||
|
if not user_prefs.show_explicit:
|
||||||
|
global_results.tracks = [
|
||||||
|
track
|
||||||
|
for track in global_results.tracks
|
||||||
|
if not track.explicit
|
||||||
|
]
|
||||||
|
global_results.albums = [
|
||||||
|
album
|
||||||
|
for album in global_results.albums
|
||||||
|
if not album.explicit
|
||||||
|
]
|
||||||
|
|
||||||
|
results["global"] = {
|
||||||
|
"tracks": [
|
||||||
|
_catalog_item_to_dict(track) for track in global_results.tracks
|
||||||
|
],
|
||||||
|
"albums": [
|
||||||
|
_catalog_item_to_dict(album) for album in global_results.albums
|
||||||
|
],
|
||||||
|
"artists": [
|
||||||
|
_catalog_item_to_dict(artist)
|
||||||
|
for artist in global_results.artists
|
||||||
|
],
|
||||||
|
"playlists": [
|
||||||
|
_catalog_item_to_dict(playlist)
|
||||||
|
for playlist in global_results.playlists
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
# Calculate total
|
||||||
|
results["total"] = (
|
||||||
|
len(results["local"].get("tracks", []))
|
||||||
|
+ len(results["local"].get("albums", []))
|
||||||
|
+ len(results["local"].get("artists", []))
|
||||||
|
+ len(results["global"].get("tracks", []))
|
||||||
|
+ len(results["global"].get("albums", []))
|
||||||
|
+ len(results["global"].get("artists", []))
|
||||||
|
+ len(results["global"].get("playlists", []))
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(results)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in combined search: {e}")
|
||||||
|
return jsonify({"error": "Search failed"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@enhanced_search_bp.route("/suggestions", methods=["GET"])
|
||||||
|
def search_suggestions():
|
||||||
|
"""
|
||||||
|
Get search suggestions based on query and user preferences
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
- q: search query
|
||||||
|
- type: tracks|albums|artists|all
|
||||||
|
- limit: number of suggestions (default 10)
|
||||||
|
- user_id: user ID for preferences
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = request.args.get("q", "").strip()
|
||||||
|
if not query or len(query) < 2:
|
||||||
|
return jsonify({"suggestions": []})
|
||||||
|
|
||||||
|
search_type = request.args.get("type", "all")
|
||||||
|
limit = min(int(request.args.get("limit", 10)), 20)
|
||||||
|
user_id = request.args.get("user_id")
|
||||||
|
|
||||||
|
# Get user preferences
|
||||||
|
user_prefs = None
|
||||||
|
if user_id:
|
||||||
|
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
|
||||||
|
limit = min(limit, user_prefs.max_search_results)
|
||||||
|
|
||||||
|
# Search cached items for fast suggestions
|
||||||
|
item_types = None
|
||||||
|
if search_type != "all":
|
||||||
|
item_types = [search_type]
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# For suggestions, search both cache and live
|
||||||
|
suggestions = []
|
||||||
|
|
||||||
|
# Search cached items first (fast)
|
||||||
|
from swingmusic.db.spotify import GlobalCatalogCacheTable
|
||||||
|
|
||||||
|
cached_items = GlobalCatalogCacheTable.search_cached(
|
||||||
|
query, item_types, limit
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in cached_items:
|
||||||
|
if user_prefs and not user_prefs.show_explicit and item.explicit:
|
||||||
|
continue
|
||||||
|
|
||||||
|
suggestion = {
|
||||||
|
"id": item.spotify_id,
|
||||||
|
"type": item.item_type,
|
||||||
|
"title": item.title,
|
||||||
|
"artist": item.artist,
|
||||||
|
"album": item.album,
|
||||||
|
"image_url": item.image_url,
|
||||||
|
"popularity": item.popularity,
|
||||||
|
"source": "cache",
|
||||||
|
}
|
||||||
|
suggestions.append(suggestion)
|
||||||
|
|
||||||
|
# If we need more suggestions, search global catalog
|
||||||
|
if len(suggestions) < limit:
|
||||||
|
remaining = limit - len(suggestions)
|
||||||
|
global_results = loop.run_until_complete(
|
||||||
|
music_catalog_service.search_global_catalog(
|
||||||
|
query, search_type, remaining
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for track in global_results.tracks[:remaining]:
|
||||||
|
if user_prefs and not user_prefs.show_explicit and track.explicit:
|
||||||
|
continue
|
||||||
|
|
||||||
|
suggestion = {
|
||||||
|
"id": track.spotify_id,
|
||||||
|
"type": "track",
|
||||||
|
"title": track.title,
|
||||||
|
"artist": track.artist,
|
||||||
|
"album": track.album,
|
||||||
|
"image_url": track.image_url,
|
||||||
|
"popularity": track.popularity,
|
||||||
|
"source": "global",
|
||||||
|
}
|
||||||
|
suggestions.append(suggestion)
|
||||||
|
|
||||||
|
return jsonify({"suggestions": suggestions[:limit]})
|
||||||
|
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in search suggestions: {e}")
|
||||||
|
return jsonify({"suggestions": []})
|
||||||
|
|
||||||
|
|
||||||
|
@enhanced_search_bp.route("/artist/<artist_id>", methods=["GET"])
|
||||||
|
def get_artist_info(artist_id: str):
|
||||||
|
"""
|
||||||
|
Get comprehensive artist information including top tracks and albums
|
||||||
|
|
||||||
|
Path parameters:
|
||||||
|
- artist_id: Spotify artist ID
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
- user_id: user ID for preferences
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user_id = request.args.get("user_id")
|
||||||
|
|
||||||
|
# Get user preferences
|
||||||
|
user_prefs = None
|
||||||
|
if user_id:
|
||||||
|
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
try:
|
||||||
|
artist_info = loop.run_until_complete(
|
||||||
|
music_catalog_service.get_artist_info(artist_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not artist_info:
|
||||||
|
return jsonify({"error": "Artist not found"}), 404
|
||||||
|
|
||||||
|
# Filter based on user preferences
|
||||||
|
if user_prefs and not user_prefs.show_explicit:
|
||||||
|
artist_info.top_tracks = [
|
||||||
|
track
|
||||||
|
for track in artist_info.top_tracks or []
|
||||||
|
if not track.explicit
|
||||||
|
]
|
||||||
|
artist_info.albums = [
|
||||||
|
album for album in artist_info.albums or [] if not album.explicit
|
||||||
|
]
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
"spotify_id": artist_info.spotify_id,
|
||||||
|
"name": artist_info.name,
|
||||||
|
"image_url": artist_info.image_url,
|
||||||
|
"followers": artist_info.followers,
|
||||||
|
"popularity": artist_info.popularity,
|
||||||
|
"genres": artist_info.genres or [],
|
||||||
|
"top_tracks": [
|
||||||
|
_catalog_item_to_dict(track)
|
||||||
|
for track in (artist_info.top_tracks or [])
|
||||||
|
],
|
||||||
|
"albums": [
|
||||||
|
_catalog_item_to_dict(album) for album in (artist_info.albums or [])
|
||||||
|
],
|
||||||
|
"related_artists": artist_info.related_artists or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(response_data)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting artist info: {e}")
|
||||||
|
return jsonify({"error": "Failed to get artist info"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@enhanced_search_bp.route("/album/<album_id>", methods=["GET"])
|
||||||
|
def get_album_details(album_id: str):
|
||||||
|
"""
|
||||||
|
Get detailed album information with tracklist
|
||||||
|
|
||||||
|
Path parameters:
|
||||||
|
- album_id: Spotify album ID
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
- user_id: user ID for preferences
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user_id = request.args.get("user_id")
|
||||||
|
|
||||||
|
# Get user preferences
|
||||||
|
user_prefs = None
|
||||||
|
if user_id:
|
||||||
|
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
try:
|
||||||
|
album = loop.run_until_complete(
|
||||||
|
music_catalog_service.get_album_details(album_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not album:
|
||||||
|
return jsonify({"error": "Album not found"}), 404
|
||||||
|
|
||||||
|
# Filter based on user preferences
|
||||||
|
if user_prefs and not user_prefs.show_explicit and album.explicit:
|
||||||
|
return jsonify({"error": "Explicit content filtered"}), 403
|
||||||
|
|
||||||
|
response_data = _catalog_item_to_dict(album)
|
||||||
|
|
||||||
|
# Add tracklist if available in data
|
||||||
|
if album.data and "tracks" in album.data:
|
||||||
|
response_data["tracks"] = [
|
||||||
|
_catalog_item_to_dict(track) for track in album.data["tracks"]
|
||||||
|
]
|
||||||
|
|
||||||
|
return jsonify(response_data)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting album details: {e}")
|
||||||
|
return jsonify({"error": "Failed to get album details"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@enhanced_search_bp.route("/preferences/<int:user_id>", methods=["GET", "POST"])
|
||||||
|
def user_preferences(user_id: int):
|
||||||
|
"""Get or update user catalog search preferences"""
|
||||||
|
try:
|
||||||
|
if request.method == "GET":
|
||||||
|
prefs = UserCatalogPreferencesTable.get_or_create(user_id)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"user_id": prefs.user_id,
|
||||||
|
"show_explicit": prefs.show_explicit,
|
||||||
|
"default_quality": prefs.default_quality,
|
||||||
|
"auto_download": prefs.auto_download,
|
||||||
|
"show_suggestions": prefs.show_suggestions,
|
||||||
|
"preferred_genres": prefs.preferred_genres or [],
|
||||||
|
"excluded_genres": prefs.excluded_genres or [],
|
||||||
|
"max_search_results": prefs.max_search_results,
|
||||||
|
"cache_ttl_preference": prefs.cache_ttl_preference,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif request.method == "POST":
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({"error": "No data provided"}), 400
|
||||||
|
|
||||||
|
# Update only provided fields
|
||||||
|
update_data = {}
|
||||||
|
allowed_fields = [
|
||||||
|
"show_explicit",
|
||||||
|
"default_quality",
|
||||||
|
"auto_download",
|
||||||
|
"show_suggestions",
|
||||||
|
"preferred_genres",
|
||||||
|
"excluded_genres",
|
||||||
|
"max_search_results",
|
||||||
|
"cache_ttl_preference",
|
||||||
|
]
|
||||||
|
|
||||||
|
for field in allowed_fields:
|
||||||
|
if field in data:
|
||||||
|
update_data[field] = data[field]
|
||||||
|
|
||||||
|
if update_data:
|
||||||
|
UserCatalogPreferencesTable.update_preferences(user_id, update_data)
|
||||||
|
|
||||||
|
return jsonify({"message": "Preferences updated successfully"})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error handling user preferences: {e}")
|
||||||
|
return jsonify({"error": "Failed to handle preferences"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_item_to_dict(item) -> dict[str, Any]:
|
||||||
|
"""Convert CatalogItem to dictionary for JSON response"""
|
||||||
|
if hasattr(item, "__dict__"):
|
||||||
|
# It's a dataclass instance
|
||||||
|
return {
|
||||||
|
"spotify_id": item.spotify_id,
|
||||||
|
"type": item.item_type.value
|
||||||
|
if hasattr(item.item_type, "value")
|
||||||
|
else str(item.item_type),
|
||||||
|
"title": item.title,
|
||||||
|
"artist": item.artist,
|
||||||
|
"album": item.album,
|
||||||
|
"duration_ms": item.duration_ms,
|
||||||
|
"popularity": item.popularity,
|
||||||
|
"preview_url": item.preview_url,
|
||||||
|
"image_url": item.image_url,
|
||||||
|
"release_date": item.release_date,
|
||||||
|
"explicit": item.explicit,
|
||||||
|
"data": item.data,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# It's likely a database model
|
||||||
|
return {
|
||||||
|
"spotify_id": getattr(item, "spotify_id", None),
|
||||||
|
"type": getattr(item, "item_type", None),
|
||||||
|
"title": getattr(item, "title", None),
|
||||||
|
"artist": getattr(item, "artist", None),
|
||||||
|
"album": getattr(item, "album", None),
|
||||||
|
"duration_ms": getattr(item, "duration_ms", None),
|
||||||
|
"popularity": getattr(item, "popularity", None),
|
||||||
|
"preview_url": getattr(item, "preview_url", None),
|
||||||
|
"image_url": getattr(item, "image_url", None),
|
||||||
|
"release_date": getattr(item, "release_date", None),
|
||||||
|
"explicit": getattr(item, "explicit", False),
|
||||||
|
"data": getattr(item, "data", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def register_enhanced_search_api(app):
|
||||||
|
"""Register enhanced search API with Flask app"""
|
||||||
|
app.register_blueprint(enhanced_search_bp)
|
||||||
|
logger.info("Enhanced search API registered")
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from swingmusic.api.apischemas import GenericLimitSchema
|
||||||
|
|
||||||
|
# DragonflyDB integration for instant favorite status caching
|
||||||
|
from swingmusic.db.dragonfly_extended_client import get_realtime_service
|
||||||
|
from swingmusic.db.userdata import FavoritesTable
|
||||||
|
from swingmusic.lib.extras import get_extra_info
|
||||||
|
from swingmusic.models import FavType
|
||||||
|
from swingmusic.serializers.album import serialize_for_card, serialize_for_card_many
|
||||||
|
from swingmusic.serializers.artist import (
|
||||||
|
serialize_for_card as serialize_artist,
|
||||||
|
)
|
||||||
|
from swingmusic.serializers.artist import (
|
||||||
|
serialize_for_cards,
|
||||||
|
)
|
||||||
|
from swingmusic.serializers.track import serialize_track, serialize_tracks
|
||||||
|
from swingmusic.services.user_library_scope import (
|
||||||
|
get_available_trackhashes,
|
||||||
|
get_visible_albums,
|
||||||
|
get_visible_artists,
|
||||||
|
)
|
||||||
|
from swingmusic.settings import Defaults
|
||||||
|
from swingmusic.store.albums import AlbumStore
|
||||||
|
from swingmusic.store.artists import ArtistStore
|
||||||
|
from swingmusic.store.tracks import TrackStore
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
from swingmusic.utils.dates import timestamp_to_time_passed
|
||||||
|
|
||||||
|
bp_tag = Tag(name="Favorites", description="Your favorite items")
|
||||||
|
api = APIBlueprint("favorites", __name__, url_prefix="/favorites", abp_tags=[bp_tag])
|
||||||
|
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
def remove_none(items: list[T]) -> list[T]:
|
||||||
|
return [i for i in items if i is not None]
|
||||||
|
|
||||||
|
|
||||||
|
class FavoritesAddBody(BaseModel):
|
||||||
|
hash: str = Field(
|
||||||
|
description="The hash of the item",
|
||||||
|
min_length=Defaults.HASH_LENGTH,
|
||||||
|
max_length=Defaults.HASH_LENGTH,
|
||||||
|
)
|
||||||
|
type: str = Field(description="The type of the item")
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_fav(type: str, hash: str):
|
||||||
|
"""
|
||||||
|
Toggles a favorite item.
|
||||||
|
"""
|
||||||
|
if type == FavType.track:
|
||||||
|
entry = TrackStore.trackhashmap.get(hash)
|
||||||
|
if entry is not None:
|
||||||
|
entry.toggle_favorite_user()
|
||||||
|
|
||||||
|
elif type == FavType.album:
|
||||||
|
entry = AlbumStore.albummap.get(hash)
|
||||||
|
|
||||||
|
if entry is not None:
|
||||||
|
entry.toggle_favorite_user()
|
||||||
|
elif type == FavType.artist:
|
||||||
|
entry = ArtistStore.artistmap.get(hash)
|
||||||
|
|
||||||
|
if entry is not None:
|
||||||
|
entry.toggle_favorite_user()
|
||||||
|
|
||||||
|
return {"msg": "Added to favorites"}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/add")
|
||||||
|
def toggle_favorite(body: FavoritesAddBody):
|
||||||
|
"""
|
||||||
|
Adds a favorite to the database.
|
||||||
|
"""
|
||||||
|
extra = get_extra_info(body.hash, body.type)
|
||||||
|
userid = get_current_userid()
|
||||||
|
|
||||||
|
try:
|
||||||
|
FavoritesTable.insert_item(
|
||||||
|
{"hash": body.hash, "type": body.type, "extra": extra}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
return {"msg": "Failed! An error occured"}, 500
|
||||||
|
|
||||||
|
toggle_fav(body.type, body.hash)
|
||||||
|
|
||||||
|
# Update DragonflyDB favorite cache for instant status checks
|
||||||
|
realtime = get_realtime_service()
|
||||||
|
if realtime.favorite_cache.client.is_available() and body.type == FavType.track:
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
realtime.toggle_favorite(userid, body.hash)
|
||||||
|
|
||||||
|
return {"msg": "Added to favorites"}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/remove")
|
||||||
|
def remove_favorite(body: FavoritesAddBody):
|
||||||
|
"""
|
||||||
|
Removes a favorite from the database.
|
||||||
|
"""
|
||||||
|
userid = get_current_userid()
|
||||||
|
|
||||||
|
try:
|
||||||
|
FavoritesTable.remove_item({"hash": body.hash, "type": body.type})
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
return {"msg": "Failed! An error occured"}, 500
|
||||||
|
|
||||||
|
toggle_fav(body.type, body.hash)
|
||||||
|
|
||||||
|
# Update DragonflyDB favorite cache for instant status checks
|
||||||
|
realtime = get_realtime_service()
|
||||||
|
if realtime.favorite_cache.client.is_available() and body.type == FavType.track:
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
realtime.toggle_favorite(userid, body.hash)
|
||||||
|
|
||||||
|
return {"msg": "Removed from favorites"}
|
||||||
|
|
||||||
|
|
||||||
|
class GetAllOfTypeQuery(GenericLimitSchema):
|
||||||
|
"""
|
||||||
|
Extending this class will give you a model with the `limit` field
|
||||||
|
"""
|
||||||
|
|
||||||
|
start: int = Field(
|
||||||
|
description="Where to start from",
|
||||||
|
default=Defaults.API_CARD_LIMIT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/albums")
|
||||||
|
def get_favorite_albums(query: GetAllOfTypeQuery):
|
||||||
|
"""
|
||||||
|
Get favorite albums
|
||||||
|
|
||||||
|
Note: Only the first request will return the total number of favorites.
|
||||||
|
Others will return -1
|
||||||
|
"""
|
||||||
|
fav_albums, total = FavoritesTable.get_fav_albums(query.start, query.limit)
|
||||||
|
albums = AlbumStore.get_albums_by_hashes(a.hash for a in fav_albums)
|
||||||
|
visible_albums = {album.albumhash for album in get_visible_albums()}
|
||||||
|
albums = [album for album in albums if album.albumhash in visible_albums]
|
||||||
|
|
||||||
|
return {"albums": serialize_for_card_many(albums), "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/tracks")
|
||||||
|
def get_favorite_tracks(query: GetAllOfTypeQuery):
|
||||||
|
"""
|
||||||
|
Get favorite tracks
|
||||||
|
|
||||||
|
Note: Only the first request will return the total number of favorites.
|
||||||
|
Others will return -1
|
||||||
|
"""
|
||||||
|
tracks, total = FavoritesTable.get_fav_tracks(query.start, query.limit)
|
||||||
|
available_trackhashes = get_available_trackhashes()
|
||||||
|
tracks = TrackStore.get_tracks_by_trackhashes(
|
||||||
|
[t.hash for t in tracks if t.hash in available_trackhashes]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"tracks": serialize_tracks(tracks), "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/artists")
|
||||||
|
def get_favorite_artists(query: GetAllOfTypeQuery):
|
||||||
|
"""
|
||||||
|
Get favorite artists
|
||||||
|
|
||||||
|
Note: Only the first request will return the total number of favorites.
|
||||||
|
Others will return -1
|
||||||
|
"""
|
||||||
|
artists, total = FavoritesTable.get_fav_artists(
|
||||||
|
start=query.start,
|
||||||
|
limit=query.limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
artists = ArtistStore.get_artists_by_hashes(a.hash for a in artists)
|
||||||
|
visible_artists = {artist.artisthash for artist in get_visible_artists()}
|
||||||
|
artists = [artist for artist in artists if artist.artisthash in visible_artists]
|
||||||
|
return {"artists": [serialize_artist(a) for a in artists], "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
class GetAllFavoritesQuery(BaseModel):
|
||||||
|
"""
|
||||||
|
Extending this class will give you a model with the `limit` field
|
||||||
|
"""
|
||||||
|
|
||||||
|
track_limit: int = Field(
|
||||||
|
description="The number of tracks to return",
|
||||||
|
default=Defaults.API_CARD_LIMIT,
|
||||||
|
)
|
||||||
|
|
||||||
|
album_limit: int = Field(
|
||||||
|
description="The number of albums to return",
|
||||||
|
default=Defaults.API_CARD_LIMIT,
|
||||||
|
)
|
||||||
|
|
||||||
|
artist_limit: int = Field(
|
||||||
|
description="The number of artists to return",
|
||||||
|
default=Defaults.API_CARD_LIMIT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("")
|
||||||
|
def get_all_favorites(query: GetAllFavoritesQuery):
|
||||||
|
"""
|
||||||
|
Returns all the favorites in the database.
|
||||||
|
"""
|
||||||
|
track_limit = query.track_limit
|
||||||
|
album_limit = query.album_limit
|
||||||
|
artist_limit = query.artist_limit
|
||||||
|
|
||||||
|
# largest is x2 to accound for broken hashes if any
|
||||||
|
largest = max(track_limit, album_limit, artist_limit)
|
||||||
|
|
||||||
|
favs = FavoritesTable.get_all(with_user=True)
|
||||||
|
favs = sorted(favs, key=lambda x: x.timestamp, reverse=True)
|
||||||
|
|
||||||
|
tracks = []
|
||||||
|
albums = []
|
||||||
|
artists = []
|
||||||
|
|
||||||
|
track_master_hash = get_available_trackhashes()
|
||||||
|
album_master_hash = {album.albumhash for album in get_visible_albums()}
|
||||||
|
artist_master_hash = {artist.artisthash for artist in get_visible_artists()}
|
||||||
|
|
||||||
|
# INFO: Filter out invalid hashes (file not found or tags edited)
|
||||||
|
for fav in favs:
|
||||||
|
hash = fav.hash
|
||||||
|
type = fav.type
|
||||||
|
|
||||||
|
if type == FavType.track:
|
||||||
|
tracks.append(hash) if hash in track_master_hash else None
|
||||||
|
|
||||||
|
if type == FavType.artist:
|
||||||
|
artists.append(hash) if hash in artist_master_hash else None
|
||||||
|
|
||||||
|
if type == FavType.album:
|
||||||
|
albums.append(hash) if hash in album_master_hash else None
|
||||||
|
|
||||||
|
count = {
|
||||||
|
"tracks": len(tracks),
|
||||||
|
"albums": len(albums),
|
||||||
|
"artists": len(artists),
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks = TrackStore.get_tracks_by_trackhashes(tracks[:track_limit])
|
||||||
|
albums = AlbumStore.get_albums_by_hashes(albums[:album_limit])
|
||||||
|
artists = ArtistStore.get_artists_by_hashes(artists[:artist_limit])
|
||||||
|
|
||||||
|
recents = []
|
||||||
|
|
||||||
|
for fav in favs:
|
||||||
|
if len(recents) >= largest:
|
||||||
|
break
|
||||||
|
|
||||||
|
if fav.type == FavType.album:
|
||||||
|
album = next((a for a in albums if a.albumhash == fav.hash), None)
|
||||||
|
|
||||||
|
if album is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
album = serialize_for_card(album)
|
||||||
|
album["help_text"] = "album"
|
||||||
|
album["time"] = timestamp_to_time_passed(fav.timestamp)
|
||||||
|
|
||||||
|
recents.append(
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"item": album,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if fav.type == FavType.artist:
|
||||||
|
artist = next((a for a in artists if a.artisthash == fav.hash), None)
|
||||||
|
|
||||||
|
if artist is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
artist = serialize_artist(artist)
|
||||||
|
artist["help_text"] = "artist"
|
||||||
|
artist["time"] = timestamp_to_time_passed(fav.timestamp)
|
||||||
|
|
||||||
|
recents.append(
|
||||||
|
{
|
||||||
|
"type": "artist",
|
||||||
|
"item": artist,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if fav.type == FavType.track:
|
||||||
|
track = next((t for t in tracks if t.trackhash == fav.hash), None)
|
||||||
|
|
||||||
|
if track is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
track = serialize_track(track)
|
||||||
|
track["help_text"] = "track"
|
||||||
|
track["time"] = timestamp_to_time_passed(fav.timestamp)
|
||||||
|
|
||||||
|
recents.append({"type": "track", "item": track})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"recents": recents[:album_limit],
|
||||||
|
"tracks": serialize_tracks(tracks[:track_limit]),
|
||||||
|
"albums": serialize_for_card_many(albums[:album_limit]),
|
||||||
|
"artists": serialize_for_cards(artists[:artist_limit]),
|
||||||
|
"count": count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/check")
|
||||||
|
def check_favorite(query: FavoritesAddBody):
|
||||||
|
"""
|
||||||
|
Checks if a favorite exists in the database.
|
||||||
|
"""
|
||||||
|
itemhash = query.hash
|
||||||
|
itemtype = query.type
|
||||||
|
|
||||||
|
return {"is_favorite": FavoritesTable.check_exists(itemhash, itemtype)}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
"""
|
||||||
|
Contains all the folder routes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
from datetime import datetime
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from showinfm import show_in_file_manager
|
||||||
|
|
||||||
|
from swingmusic import settings
|
||||||
|
from swingmusic.api.auth import admin_required
|
||||||
|
from swingmusic.config import UserConfig
|
||||||
|
from swingmusic.db.libdata import TrackTable
|
||||||
|
from swingmusic.db.userdata import FavoritesTable, PlaylistTable
|
||||||
|
from swingmusic.lib.folderslib import get_files_and_dirs, get_folders
|
||||||
|
from swingmusic.serializers.track import serialize_track, serialize_tracks
|
||||||
|
from swingmusic.services.user_library_scope import (
|
||||||
|
count_visible_tracks_in_paths,
|
||||||
|
get_available_trackhashes,
|
||||||
|
get_user_root_dirs,
|
||||||
|
is_path_within_user_roots,
|
||||||
|
)
|
||||||
|
from swingmusic.store.tracks import TrackStore
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
from swingmusic.utils.wintools import is_windows
|
||||||
|
|
||||||
|
tag = Tag(name="Folders", description="Get folders and tracks in a directory")
|
||||||
|
api = APIBlueprint("folder", __name__, url_prefix="/folder", abp_tags=[tag])
|
||||||
|
|
||||||
|
|
||||||
|
def is_path_within_root_dirs(filepath: str, userid: int | None = None) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a filepath is within one of the configured root directories.
|
||||||
|
Prevents directory traversal attacks.
|
||||||
|
"""
|
||||||
|
return is_path_within_user_roots(filepath, userid=userid)
|
||||||
|
|
||||||
|
|
||||||
|
class FolderTree(BaseModel):
|
||||||
|
folder: str = Field("$home", description="The folder to things from")
|
||||||
|
sorttracksby: str = Field(
|
||||||
|
"default",
|
||||||
|
description="""The field to sort tracks by. Options: [
|
||||||
|
"default",
|
||||||
|
"album",
|
||||||
|
"albumartists",
|
||||||
|
"artists",
|
||||||
|
"bitrate",
|
||||||
|
"date",
|
||||||
|
"disc",
|
||||||
|
"duration",
|
||||||
|
"last_mod",
|
||||||
|
"lastplayed",
|
||||||
|
"playduration",
|
||||||
|
"playcount",
|
||||||
|
"title",
|
||||||
|
]""",
|
||||||
|
)
|
||||||
|
tracksort_reverse: bool = Field(
|
||||||
|
False,
|
||||||
|
description="Whether to reverse the sort order of the tracks",
|
||||||
|
)
|
||||||
|
sortfoldersby: str = Field(
|
||||||
|
"lastmod",
|
||||||
|
description="""The field to sort folders by.
|
||||||
|
Options: [
|
||||||
|
"default",
|
||||||
|
"name",
|
||||||
|
"lastmod",
|
||||||
|
"trackcount",
|
||||||
|
]
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
foldersort_reverse: bool = Field(
|
||||||
|
False,
|
||||||
|
description="Whether to reverse the sort order of the folders",
|
||||||
|
)
|
||||||
|
start: int = Field(0, description="The start index")
|
||||||
|
limit: int = Field(50, description="The max number of items to return")
|
||||||
|
tracks_only: bool = Field(False, description="Whether to only get tracks")
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("")
|
||||||
|
def get_folder_tree(body: FolderTree):
|
||||||
|
"""
|
||||||
|
Get folder
|
||||||
|
|
||||||
|
Returns a list of all the folders and tracks in the given folder.
|
||||||
|
"""
|
||||||
|
userid = get_current_userid()
|
||||||
|
og_req_dir = body.folder
|
||||||
|
req_dir = body.folder
|
||||||
|
tracks_only = body.tracks_only
|
||||||
|
|
||||||
|
config = UserConfig()
|
||||||
|
root_dirs = get_user_root_dirs(userid)
|
||||||
|
|
||||||
|
if req_dir == "$home" and "$home" in root_dirs:
|
||||||
|
req_dir = settings.Paths().USER_HOME_DIR.as_posix()
|
||||||
|
|
||||||
|
if req_dir == "$home":
|
||||||
|
folders = get_folders(root_dirs)
|
||||||
|
folder_paths = [folder.path for folder in folders]
|
||||||
|
user_counts = count_visible_tracks_in_paths(folder_paths, userid=userid)
|
||||||
|
visible_folders = []
|
||||||
|
for folder in folders:
|
||||||
|
key = Path(folder.path).resolve().as_posix().rstrip("/")
|
||||||
|
visible_folders.append(replace(folder, trackcount=user_counts.get(key, 0)))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"folders": visible_folders,
|
||||||
|
"tracks": [],
|
||||||
|
"path": req_dir,
|
||||||
|
"total": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if req_dir.startswith("$playlist"):
|
||||||
|
splits = req_dir.split("/")
|
||||||
|
|
||||||
|
if len(splits) == 2:
|
||||||
|
pid = splits[1]
|
||||||
|
playlist = PlaylistTable.get_by_id(int(pid))
|
||||||
|
available_trackhashes = get_available_trackhashes(userid)
|
||||||
|
tracks = TrackStore.get_tracks_by_trackhashes(
|
||||||
|
playlist.trackhashes[
|
||||||
|
body.start : body.start + body.limit if body.limit != -1 else None
|
||||||
|
]
|
||||||
|
)
|
||||||
|
tracks = [
|
||||||
|
track for track in tracks if track.trackhash in available_trackhashes
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"path": f"$playlist/{playlist.name}",
|
||||||
|
"folders": [],
|
||||||
|
"tracks": serialize_tracks(tracks),
|
||||||
|
}
|
||||||
|
|
||||||
|
playlists = PlaylistTable.get_all()
|
||||||
|
playlists = sorted(
|
||||||
|
playlists,
|
||||||
|
key=lambda p: datetime.strptime(p.last_updated, "%Y-%m-%d %H:%M:%S"),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
available_trackhashes = get_available_trackhashes(userid)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"path": req_dir,
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"name": p.name,
|
||||||
|
"path": f"$playlist/{p.id}",
|
||||||
|
"trackcount": len(
|
||||||
|
[
|
||||||
|
trackhash
|
||||||
|
for trackhash in p.trackhashes
|
||||||
|
if trackhash in available_trackhashes
|
||||||
|
]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for p in playlists
|
||||||
|
],
|
||||||
|
"tracks": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if req_dir == "$favorites":
|
||||||
|
tracks, total = FavoritesTable.get_fav_tracks(body.start, body.limit)
|
||||||
|
available_trackhashes = get_available_trackhashes(userid)
|
||||||
|
tracks = TrackStore.get_tracks_by_trackhashes(
|
||||||
|
[t.hash for t in tracks if t.hash in available_trackhashes]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tracks": serialize_tracks(tracks),
|
||||||
|
"folders": [],
|
||||||
|
"path": req_dir,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve path to prevent directory traversal attacks
|
||||||
|
resolved_path = pathlib.Path(req_dir).resolve()
|
||||||
|
|
||||||
|
# Validate path is within configured root directories
|
||||||
|
if not is_path_within_root_dirs(str(resolved_path), userid=userid):
|
||||||
|
return {
|
||||||
|
"folders": [],
|
||||||
|
"tracks": [],
|
||||||
|
"error": "Path not within allowed directories",
|
||||||
|
}, 403
|
||||||
|
|
||||||
|
if not resolved_path.exists() or not resolved_path.is_dir():
|
||||||
|
return {
|
||||||
|
"folders": [],
|
||||||
|
"tracks": [],
|
||||||
|
"error": "Invalid directory",
|
||||||
|
}, 400
|
||||||
|
|
||||||
|
results = get_files_and_dirs(
|
||||||
|
resolved_path,
|
||||||
|
start=body.start,
|
||||||
|
limit=body.limit,
|
||||||
|
tracks_only=tracks_only,
|
||||||
|
tracksortby=body.sorttracksby,
|
||||||
|
foldersortby=body.sortfoldersby,
|
||||||
|
tracksort_reverse=body.tracksort_reverse,
|
||||||
|
foldersort_reverse=body.foldersort_reverse,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enforce per-user projection on file-backed track results.
|
||||||
|
available_trackhashes = get_available_trackhashes(userid)
|
||||||
|
results["tracks"] = [
|
||||||
|
track
|
||||||
|
for track in results.get("tracks", [])
|
||||||
|
if track.get("trackhash") in available_trackhashes
|
||||||
|
]
|
||||||
|
|
||||||
|
# Recompute folder counts from visible tracks only for this user.
|
||||||
|
folder_paths = [folder.path for folder in results.get("folders", [])]
|
||||||
|
user_counts = count_visible_tracks_in_paths(folder_paths, userid=userid)
|
||||||
|
visible_folders = []
|
||||||
|
for folder in results.get("folders", []):
|
||||||
|
key = Path(folder.path).resolve().as_posix().rstrip("/")
|
||||||
|
visible_folders.append(replace(folder, trackcount=user_counts.get(key, 0)))
|
||||||
|
results["folders"] = visible_folders
|
||||||
|
|
||||||
|
if og_req_dir == "$home" and config.showPlaylistsInFolderView:
|
||||||
|
# Get all playlists and return them as a list of folders
|
||||||
|
playlists_item = {
|
||||||
|
"name": "Playlists",
|
||||||
|
"path": "$playlists",
|
||||||
|
"trackcount": sum(p.count for p in PlaylistTable.get_all()),
|
||||||
|
}
|
||||||
|
|
||||||
|
favorites_item = {
|
||||||
|
"name": "Favorites",
|
||||||
|
"path": "$favorites",
|
||||||
|
"trackcount": FavoritesTable.get_fav_tracks(0, -1)[1],
|
||||||
|
}
|
||||||
|
|
||||||
|
results["folders"].insert(0, playlists_item)
|
||||||
|
results["folders"].insert(0, favorites_item)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_drives(is_win: bool = False):
|
||||||
|
"""
|
||||||
|
Returns a list of all the drives on a Windows machine.
|
||||||
|
"""
|
||||||
|
drives_ = psutil.disk_partitions(all=True)
|
||||||
|
drives = [Path(d.mountpoint).as_posix() for d in drives_]
|
||||||
|
|
||||||
|
if is_win:
|
||||||
|
return drives
|
||||||
|
else:
|
||||||
|
remove = (
|
||||||
|
"/boot",
|
||||||
|
"/tmp",
|
||||||
|
"/snap",
|
||||||
|
"/var",
|
||||||
|
"/sys",
|
||||||
|
"/proc",
|
||||||
|
"/etc",
|
||||||
|
"/run",
|
||||||
|
"/dev",
|
||||||
|
)
|
||||||
|
drives = [d for d in drives if not d.startswith(remove)]
|
||||||
|
|
||||||
|
return drives
|
||||||
|
|
||||||
|
|
||||||
|
class DirBrowserBody(BaseModel):
|
||||||
|
folder: str = Field(
|
||||||
|
"$root",
|
||||||
|
description="The folder to list directories from",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/dir-browser")
|
||||||
|
@admin_required()
|
||||||
|
def list_folders(body: DirBrowserBody):
|
||||||
|
"""
|
||||||
|
List folders
|
||||||
|
|
||||||
|
Returns a list of all the folders in the given folder.
|
||||||
|
Used when selecting root dirs. Admin only.
|
||||||
|
"""
|
||||||
|
req_dir = body.folder
|
||||||
|
is_win = is_windows()
|
||||||
|
|
||||||
|
if req_dir == "$root":
|
||||||
|
return {
|
||||||
|
"folders": [{"name": d, "path": d} for d in get_all_drives(is_win=is_win)]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve path to prevent directory traversal attacks
|
||||||
|
req_dir = pathlib.Path(req_dir).resolve()
|
||||||
|
|
||||||
|
if not req_dir.exists() or not req_dir.is_dir():
|
||||||
|
return {"folders": [], "error": "Invalid directory"}, 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
entries = os.scandir(req_dir)
|
||||||
|
except PermissionError:
|
||||||
|
return {"folders": []}
|
||||||
|
|
||||||
|
# only get dirs and remove hidden dirs
|
||||||
|
dirs = []
|
||||||
|
for entry in entries:
|
||||||
|
entry = pathlib.Path(entry)
|
||||||
|
name = entry.name
|
||||||
|
|
||||||
|
if name.startswith("$"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if name.startswith("."):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if entry.is_dir():
|
||||||
|
dirs.append({"name": name, "path": entry.resolve().as_posix()})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"folders": sorted(dirs, key=lambda i: i["name"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FolderOpenInFileManagerQuery(BaseModel):
|
||||||
|
path: str = Field(
|
||||||
|
description="The path to open in the file manager",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/show-in-files")
|
||||||
|
def open_in_file_manager(query: FolderOpenInFileManagerQuery):
|
||||||
|
"""
|
||||||
|
Open in file manager
|
||||||
|
|
||||||
|
Opens the given path in the file manager on the host machine.
|
||||||
|
Path must be within configured root directories.
|
||||||
|
"""
|
||||||
|
# Resolve path to prevent directory traversal
|
||||||
|
resolved_path = Path(query.path).resolve()
|
||||||
|
|
||||||
|
# Validate path is within root directories
|
||||||
|
userid = get_current_userid()
|
||||||
|
if not is_path_within_root_dirs(query.path, userid=userid):
|
||||||
|
return {"success": False, "error": "Path not within allowed directories"}, 403
|
||||||
|
|
||||||
|
if not resolved_path.exists():
|
||||||
|
return {"success": False, "error": "Path does not exist"}, 404
|
||||||
|
|
||||||
|
show_in_file_manager(str(resolved_path))
|
||||||
|
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
class GetTracksInPathQuery(BaseModel):
|
||||||
|
path: str = Field(
|
||||||
|
description="The path to get tracks from",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/tracks/all")
|
||||||
|
def get_tracks_in_path(query: GetTracksInPathQuery):
|
||||||
|
"""
|
||||||
|
Get tracks in path
|
||||||
|
|
||||||
|
Gets all (or a max of 300) tracks from the given path and its subdirectories.
|
||||||
|
|
||||||
|
Used when adding tracks to the queue.
|
||||||
|
"""
|
||||||
|
userid = get_current_userid()
|
||||||
|
# Resolve path to prevent directory traversal
|
||||||
|
resolved_path = Path(query.path).resolve()
|
||||||
|
|
||||||
|
# Validate path is within root directories
|
||||||
|
if not is_path_within_root_dirs(str(resolved_path), userid=userid):
|
||||||
|
return {"tracks": [], "error": "Path not within allowed directories"}, 403
|
||||||
|
|
||||||
|
available_trackhashes = get_available_trackhashes(userid)
|
||||||
|
tracks = TrackTable.get_tracks_in_path(str(resolved_path))
|
||||||
|
tracks = (
|
||||||
|
serialize_track(t)
|
||||||
|
for t in tracks
|
||||||
|
if Path(t.filepath).exists() and t.trackhash in available_trackhashes
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tracks": list(tracks)[:300],
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from swingmusic.api.apischemas import GenericLimitSchema
|
||||||
|
from swingmusic.serializers.album import serialize_for_card as serialize_album
|
||||||
|
from swingmusic.serializers.artist import serialize_for_card as serialize_artist
|
||||||
|
from swingmusic.services.user_library_scope import (
|
||||||
|
get_visible_albums,
|
||||||
|
get_visible_artists,
|
||||||
|
)
|
||||||
|
from swingmusic.utils import format_number
|
||||||
|
from swingmusic.utils.dates import (
|
||||||
|
create_new_date,
|
||||||
|
date_string_to_time_passed,
|
||||||
|
seconds_to_time_string,
|
||||||
|
timestamp_to_time_passed,
|
||||||
|
)
|
||||||
|
|
||||||
|
bp_tag = Tag(name="Get all", description="List all items")
|
||||||
|
api = APIBlueprint("getall", __name__, url_prefix="/getall", abp_tags=[bp_tag])
|
||||||
|
|
||||||
|
|
||||||
|
class GetAllItemsQuery(GenericLimitSchema):
|
||||||
|
start: int = Field(
|
||||||
|
description="The start index of the items to return",
|
||||||
|
example=0,
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
sortby: str = Field(
|
||||||
|
description="The key to sort items by",
|
||||||
|
example="created_date",
|
||||||
|
default="created_date",
|
||||||
|
)
|
||||||
|
|
||||||
|
reverse: str = Field(
|
||||||
|
description="Reverse the sort",
|
||||||
|
example=1,
|
||||||
|
default="1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GetAllItemsPath(BaseModel):
|
||||||
|
itemtype: str = Field(
|
||||||
|
description="The type of items to return (albums | artists)",
|
||||||
|
example="albums",
|
||||||
|
default="albums",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/<itemtype>")
|
||||||
|
def get_all_items(path: GetAllItemsPath, query: GetAllItemsQuery):
|
||||||
|
"""
|
||||||
|
Get all items
|
||||||
|
|
||||||
|
Used to show all albums or artists in the library
|
||||||
|
|
||||||
|
Sort keys:
|
||||||
|
-
|
||||||
|
Both albums and artists: `duration`, `created_date`, `playcount`, `playduration`, `lastplayed`, `trackcount`
|
||||||
|
|
||||||
|
Albums only: `title`, `albumartists`, `date`
|
||||||
|
Artists only: `name`, `albumcount`
|
||||||
|
"""
|
||||||
|
is_albums = path.itemtype == "albums"
|
||||||
|
is_artists = path.itemtype == "artists"
|
||||||
|
|
||||||
|
if is_albums:
|
||||||
|
items = get_visible_albums()
|
||||||
|
elif is_artists:
|
||||||
|
items = get_visible_artists()
|
||||||
|
else:
|
||||||
|
return {"items": [], "total": 0}
|
||||||
|
|
||||||
|
total = len(items)
|
||||||
|
|
||||||
|
start = query.start
|
||||||
|
limit = query.limit
|
||||||
|
sort = query.sortby
|
||||||
|
reverse = query.reverse == "1"
|
||||||
|
|
||||||
|
sort_is_count = sort == "trackcount"
|
||||||
|
sort_is_duration = sort == "duration"
|
||||||
|
sort_is_create_date = sort == "created_date"
|
||||||
|
sort_is_playcount = sort == "playcount"
|
||||||
|
sort_is_playduration = sort == "playduration"
|
||||||
|
sort_is_lastplayed = sort == "lastplayed"
|
||||||
|
|
||||||
|
sort_is_date = is_albums and sort == "date"
|
||||||
|
sort_is_artist = is_albums and sort == "albumartists"
|
||||||
|
|
||||||
|
sort_is_artist_trackcount = is_artists and sort == "trackcount"
|
||||||
|
sort_is_artist_albumcount = is_artists and sort == "albumcount"
|
||||||
|
|
||||||
|
def lambda_sort(x):
|
||||||
|
return getattr(x, sort)
|
||||||
|
|
||||||
|
def lambda_sort_casefold(x):
|
||||||
|
return getattr(x, sort).casefold()
|
||||||
|
|
||||||
|
if sort_is_artist:
|
||||||
|
|
||||||
|
def lambda_sort(x):
|
||||||
|
return getattr(x, sort)[0]["name"].casefold()
|
||||||
|
|
||||||
|
try:
|
||||||
|
sorted_items = sorted(items, key=lambda_sort_casefold, reverse=reverse)
|
||||||
|
except AttributeError:
|
||||||
|
sorted_items = sorted(items, key=lambda_sort, reverse=reverse)
|
||||||
|
|
||||||
|
items = sorted_items[start : start + limit]
|
||||||
|
album_list = []
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
item_dict = serialize_album(item) if is_albums else serialize_artist(item)
|
||||||
|
|
||||||
|
if sort_is_date:
|
||||||
|
item_dict["help_text"] = datetime.fromtimestamp(item.date).year
|
||||||
|
|
||||||
|
if sort_is_create_date:
|
||||||
|
date = create_new_date(datetime.fromtimestamp(item.created_date))
|
||||||
|
timeago = date_string_to_time_passed(date)
|
||||||
|
item_dict["help_text"] = timeago
|
||||||
|
|
||||||
|
if sort_is_count:
|
||||||
|
item_dict["help_text"] = (
|
||||||
|
f"{format_number(item.trackcount)} track{'' if item.trackcount == 1 else 's'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if sort_is_duration:
|
||||||
|
item_dict["help_text"] = seconds_to_time_string(item.duration)
|
||||||
|
|
||||||
|
if sort_is_artist_trackcount:
|
||||||
|
item_dict["help_text"] = (
|
||||||
|
f"{format_number(item.trackcount)} track{'' if item.trackcount == 1 else 's'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if sort_is_artist_albumcount:
|
||||||
|
item_dict["help_text"] = (
|
||||||
|
f"{format_number(item.albumcount)} album{'' if item.albumcount == 1 else 's'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if sort_is_playcount:
|
||||||
|
item_dict["help_text"] = (
|
||||||
|
f"{format_number(item.playcount)} play{'' if item.playcount == 1 else 's'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if sort_is_lastplayed:
|
||||||
|
if item.playduration == 0:
|
||||||
|
item_dict["help_text"] = "Never played"
|
||||||
|
else:
|
||||||
|
item_dict["help_text"] = timestamp_to_time_passed(item.lastplayed)
|
||||||
|
|
||||||
|
if sort_is_playduration:
|
||||||
|
item_dict["help_text"] = seconds_to_time_string(item.playduration)
|
||||||
|
|
||||||
|
album_list.append(item_dict)
|
||||||
|
|
||||||
|
return {"items": album_list, "total": total}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from swingmusic.api.apischemas import GenericLimitSchema
|
||||||
|
|
||||||
|
# DragonflyDB integration for homepage caching
|
||||||
|
from swingmusic.db.dragonfly_client import DragonflyCache
|
||||||
|
from swingmusic.lib.home.get_recently_played import get_recently_played
|
||||||
|
from swingmusic.lib.home.recentlyadded import get_recently_added_items
|
||||||
|
from swingmusic.store.homepage import HomepageStore
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
bp_tag = Tag(name="Home", description="Homepage items")
|
||||||
|
api = APIBlueprint("home", __name__, url_prefix="/nothome", abp_tags=[bp_tag])
|
||||||
|
|
||||||
|
# Homepage cache with 5-minute TTL (homepage content changes frequently)
|
||||||
|
homepage_cache = DragonflyCache("homepage")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_homepage_cache_key(userid: int, limit: int) -> str:
|
||||||
|
"""Generate cache key for homepage items"""
|
||||||
|
return f"items:user:{userid}:limit:{limit}"
|
||||||
|
|
||||||
|
|
||||||
|
def _try_get_cached_homepage(userid: int, limit: int) -> list | None:
|
||||||
|
"""Try to get cached homepage items"""
|
||||||
|
if not homepage_cache.client.is_available():
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache_key = _get_homepage_cache_key(userid, limit)
|
||||||
|
cached = homepage_cache.get(cache_key)
|
||||||
|
|
||||||
|
if cached:
|
||||||
|
logger.debug(f"Homepage cache hit for user {userid}")
|
||||||
|
return cached
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_homepage_items(userid: int, limit: int, items: list, ttl_minutes: int = 5):
|
||||||
|
"""Cache homepage items with short TTL"""
|
||||||
|
if not homepage_cache.client.is_available():
|
||||||
|
return
|
||||||
|
|
||||||
|
cache_key = _get_homepage_cache_key(userid, limit)
|
||||||
|
ttl_seconds = ttl_minutes * 60
|
||||||
|
homepage_cache.client.set(cache_key, items, ttl_seconds)
|
||||||
|
logger.debug(f"Cached homepage for user {userid} for {ttl_minutes} minutes")
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/recents/added")
|
||||||
|
def get_recently_added(query: GenericLimitSchema):
|
||||||
|
"""
|
||||||
|
Get recently added
|
||||||
|
"""
|
||||||
|
return {"items": get_recently_added_items(query.limit)}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/recents/played")
|
||||||
|
def get_recent_plays(query: GenericLimitSchema):
|
||||||
|
"""
|
||||||
|
Get recently played
|
||||||
|
"""
|
||||||
|
return {"items": get_recently_played(query.limit)}
|
||||||
|
|
||||||
|
|
||||||
|
class HomepageItem(BaseModel):
|
||||||
|
limit: int = Field(
|
||||||
|
default=9, description="The max number of items per group to return"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/")
|
||||||
|
def homepage_items(query: HomepageItem):
|
||||||
|
userid = get_current_userid()
|
||||||
|
|
||||||
|
# Try to get cached homepage first
|
||||||
|
cached = _try_get_cached_homepage(userid, query.limit)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# Generate fresh homepage items
|
||||||
|
items = HomepageStore.get_homepage_items(limit=query.limit)
|
||||||
|
|
||||||
|
# Cache for 5 minutes (short TTL since homepage changes with plays)
|
||||||
|
_cache_homepage_items(userid, query.limit, items, ttl_minutes=5)
|
||||||
|
|
||||||
|
return items
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import send_from_directory
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from PIL import Image
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from swingmusic.settings import Defaults, Paths
|
||||||
|
from swingmusic.store.albums import AlbumStore
|
||||||
|
from swingmusic.store.tracks import TrackStore
|
||||||
|
from swingmusic.utils.threading import background
|
||||||
|
|
||||||
|
bp_tag = Tag(
|
||||||
|
name="Images", description="Image filenames are constructured as '{itemhash}.webp'"
|
||||||
|
)
|
||||||
|
api = APIBlueprint("imgserver", __name__, url_prefix="/img", abp_tags=[bp_tag])
|
||||||
|
|
||||||
|
|
||||||
|
@background
|
||||||
|
def cache_thumbnails(filepath: Path, trackhash: str):
|
||||||
|
"""
|
||||||
|
Resizes the image and stores it in the cache directory.
|
||||||
|
"""
|
||||||
|
image = Image.open(filepath)
|
||||||
|
path = Path(Paths().image_cache_path)
|
||||||
|
aspect_ratio = image.width / image.height
|
||||||
|
|
||||||
|
sizes = {
|
||||||
|
"xsmall": 64,
|
||||||
|
"small": 96,
|
||||||
|
"medium": 256,
|
||||||
|
"large": 512,
|
||||||
|
}
|
||||||
|
|
||||||
|
for size, width in sizes.items():
|
||||||
|
width = min(width, image.width)
|
||||||
|
height = int(width / aspect_ratio)
|
||||||
|
|
||||||
|
resized_path = path / size / (trackhash + ".webp")
|
||||||
|
resized_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
image.resize((width, height)).save(resized_path, format="webp")
|
||||||
|
|
||||||
|
|
||||||
|
def find_thumbnail(albumhash: str, pathhash: str):
|
||||||
|
# entry = TrackStore.trackhashmap.get(albumhash)
|
||||||
|
entry = AlbumStore.albummap.get(albumhash)
|
||||||
|
|
||||||
|
if entry is None:
|
||||||
|
return None, None, ""
|
||||||
|
|
||||||
|
track_file = None
|
||||||
|
|
||||||
|
tracks = TrackStore.get_tracks_by_trackhashes(entry.trackhashes)
|
||||||
|
for track in tracks:
|
||||||
|
if track.pathhash == pathhash:
|
||||||
|
track_file = track
|
||||||
|
break
|
||||||
|
|
||||||
|
if track_file is None:
|
||||||
|
return None, None, ""
|
||||||
|
|
||||||
|
folder = Path(track_file.folder)
|
||||||
|
|
||||||
|
# INFO: Check if the folder has image files
|
||||||
|
extensions = [".jpg", ".jpeg", ".png", ".webp"]
|
||||||
|
hierarchy = ["cover", "front", "back", "folder", "album", "artwork"]
|
||||||
|
|
||||||
|
images: list[Path] = []
|
||||||
|
for item in folder.iterdir():
|
||||||
|
if item.suffix in extensions:
|
||||||
|
images.append(item)
|
||||||
|
|
||||||
|
if len(images) == 0:
|
||||||
|
return None, None, ""
|
||||||
|
|
||||||
|
# INFO: Check if the folder has image files in the hierarchy
|
||||||
|
for item in hierarchy:
|
||||||
|
for image in images:
|
||||||
|
if image.name.lower().startswith(item.lower()):
|
||||||
|
return image.parent, image.name, track_file.albumhash
|
||||||
|
|
||||||
|
# INFO: If no image falls in the hierarchy, return the first image
|
||||||
|
first_image = images[0]
|
||||||
|
return first_image.parent, first_image.name, track_file.albumhash
|
||||||
|
|
||||||
|
|
||||||
|
def send_fallback_img(filename: str = "default.webp"):
|
||||||
|
"""
|
||||||
|
Returns the fallback image from the assets folder.
|
||||||
|
"""
|
||||||
|
folder = Paths().assets_path
|
||||||
|
img = Path(folder) / filename
|
||||||
|
|
||||||
|
if not img.exists():
|
||||||
|
return "", 404
|
||||||
|
|
||||||
|
return send_from_directory(folder, filename)
|
||||||
|
|
||||||
|
|
||||||
|
def send_file_or_fallback(
|
||||||
|
folder: str, filename: str, fallback: str = "default.webp", pathhash: str = ""
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Returns the file from the folder or the fallback image.
|
||||||
|
"""
|
||||||
|
fpath = Path(folder) / filename
|
||||||
|
|
||||||
|
if fpath.exists():
|
||||||
|
return send_from_directory(folder, filename)
|
||||||
|
|
||||||
|
if pathhash != "":
|
||||||
|
# INFO: Check if the image is in the cache
|
||||||
|
cache_path = Paths().image_cache_path / fpath.parent.name / filename
|
||||||
|
if cache_path.exists():
|
||||||
|
return send_from_directory(cache_path.parent, cache_path.name)
|
||||||
|
|
||||||
|
# INFO: Find the thumbnail
|
||||||
|
parent, file, albumhash = find_thumbnail(
|
||||||
|
filename.replace(".webp", ""), pathhash
|
||||||
|
)
|
||||||
|
|
||||||
|
# INFO: Cache and send the thumbnail
|
||||||
|
if file is not None and parent is not None:
|
||||||
|
cache_thumbnails(parent / file, albumhash)
|
||||||
|
return send_from_directory(parent, file)
|
||||||
|
|
||||||
|
return send_fallback_img(fallback)
|
||||||
|
|
||||||
|
|
||||||
|
class ImagePath(BaseModel):
|
||||||
|
imgpath: str = Field(
|
||||||
|
description="The image filename",
|
||||||
|
example=Defaults.API_ALBUMHASH + ".webp",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageQuery(BaseModel):
|
||||||
|
pathhash: str = Field(
|
||||||
|
description="The path hash used to find the thumbnail",
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# @api.get("/t/o/<imgpath>")
|
||||||
|
# def send_original_thumbnail(path: ImagePath):
|
||||||
|
# """
|
||||||
|
# Get original thumbnail
|
||||||
|
# """
|
||||||
|
# folder = Paths.get_original_thumb_path()
|
||||||
|
# fpath = Path(folder) / path.imgpath
|
||||||
|
|
||||||
|
# if fpath.exists():
|
||||||
|
# return send_from_directory(folder, path.imgpath)
|
||||||
|
|
||||||
|
# return send_fallback_img()
|
||||||
|
|
||||||
|
|
||||||
|
# TRACK THUMBNAILS
|
||||||
|
@api.get("/thumbnail/<imgpath>")
|
||||||
|
def send_lg_thumbnail(path: ImagePath, query: ImageQuery):
|
||||||
|
"""
|
||||||
|
Get large thumbnail (500 x 500)
|
||||||
|
"""
|
||||||
|
folder = Paths().lg_thumb_path
|
||||||
|
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/thumbnail/xsmall/<imgpath>")
|
||||||
|
def send_xsm_thumbnail(path: ImagePath, query: ImageQuery):
|
||||||
|
"""
|
||||||
|
Get extra small thumbnail (64px)
|
||||||
|
"""
|
||||||
|
folder = Paths().xsm_thumb_path
|
||||||
|
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/thumbnail/small/<imgpath>")
|
||||||
|
def send_sm_thumbnail(path: ImagePath, query: ImageQuery):
|
||||||
|
"""
|
||||||
|
Get small thumbnail (96px)
|
||||||
|
"""
|
||||||
|
folder = Paths().sm_thumb_path
|
||||||
|
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/thumbnail/medium/<imgpath>")
|
||||||
|
def send_md_thumbnail(path: ImagePath, query: ImageQuery):
|
||||||
|
"""
|
||||||
|
Get medium thumbnail (256px)
|
||||||
|
"""
|
||||||
|
folder = Paths().md_thumb_path
|
||||||
|
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
|
||||||
|
|
||||||
|
|
||||||
|
# ARTISTS
|
||||||
|
@api.get("/artist/<imgpath>")
|
||||||
|
def send_lg_artist_image(path: ImagePath):
|
||||||
|
"""
|
||||||
|
Get large artist image (500 x 500)
|
||||||
|
"""
|
||||||
|
folder = Paths().lg_artist_img_path
|
||||||
|
return send_file_or_fallback(str(folder), path.imgpath, "artist.webp")
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/artist/small/<imgpath>")
|
||||||
|
def send_sm_artist_image(path: ImagePath):
|
||||||
|
"""
|
||||||
|
Get small artist image (128)
|
||||||
|
"""
|
||||||
|
folder = Paths().sm_artist_img_path
|
||||||
|
return send_file_or_fallback(str(folder), path.imgpath, "artist.webp")
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/artist/medium/<imgpath>")
|
||||||
|
def send_md_artist_image(path: ImagePath):
|
||||||
|
"""
|
||||||
|
Get medium artist image (256px)
|
||||||
|
"""
|
||||||
|
folder = Paths().md_artist_img_path
|
||||||
|
return send_file_or_fallback(folder, path.imgpath, "artist.webp")
|
||||||
|
|
||||||
|
|
||||||
|
# PLAYLISTS
|
||||||
|
class PlaylistImagePath(BaseModel):
|
||||||
|
imgpath: str = Field(
|
||||||
|
description="The image path",
|
||||||
|
example="1.webp",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/playlist/<imgpath>")
|
||||||
|
def send_playlist_image(path: PlaylistImagePath):
|
||||||
|
"""
|
||||||
|
Get playlist image
|
||||||
|
|
||||||
|
Images are constructed as '{playlist_id}.webp'
|
||||||
|
"""
|
||||||
|
folder = Paths().playlist_img_path
|
||||||
|
return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
|
||||||
|
|
||||||
|
|
||||||
|
# MIXES
|
||||||
|
@api.get("/mix/medium/<imgpath>")
|
||||||
|
def send_md_mix_image(path: ImagePath):
|
||||||
|
"""
|
||||||
|
Get medium mix image
|
||||||
|
"""
|
||||||
|
folder = Paths().md_mixes_img_path
|
||||||
|
return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/mix/small/<imgpath>")
|
||||||
|
def send_sm_mix_image(path: ImagePath):
|
||||||
|
"""
|
||||||
|
Get small mix image
|
||||||
|
"""
|
||||||
|
folder = Paths().sm_mixes_img_path
|
||||||
|
return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from swingmusic.api.apischemas import TrackHashSchema
|
||||||
|
|
||||||
|
# DragonflyDB integration for lyrics caching
|
||||||
|
from swingmusic.db.dragonfly_client import get_dragonfly_client
|
||||||
|
from swingmusic.lib.lyrics import (
|
||||||
|
Lyrics as Lyrics_class,
|
||||||
|
)
|
||||||
|
from swingmusic.lib.lyrics import (
|
||||||
|
get_lyrics_file,
|
||||||
|
get_lyrics_from_duplicates,
|
||||||
|
get_lyrics_from_tags,
|
||||||
|
)
|
||||||
|
from swingmusic.plugins.lyrics import Lyrics
|
||||||
|
from swingmusic.store.tracks import TrackStore
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
bp_tag = Tag(name="Lyrics", description="Get lyrics")
|
||||||
|
api = APIBlueprint("lyrics", __name__, url_prefix="/lyrics", abp_tags=[bp_tag])
|
||||||
|
|
||||||
|
|
||||||
|
class SendLyricsBody(TrackHashSchema):
|
||||||
|
filepath: str = Field(description="The path to the file")
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("")
|
||||||
|
def send_lyrics(body: SendLyricsBody):
|
||||||
|
"""
|
||||||
|
Returns the lyrics for a track
|
||||||
|
"""
|
||||||
|
# 1. try to get lyrics by .lrc / .elrc file
|
||||||
|
# 2. try to get lyrics by extra key
|
||||||
|
# 3. try to get by duplicates
|
||||||
|
# 4. iter plugins
|
||||||
|
|
||||||
|
filepath = body.filepath
|
||||||
|
trackhash = body.trackhash
|
||||||
|
|
||||||
|
# Try DragonflyDB cache first
|
||||||
|
cache = get_dragonfly_client()
|
||||||
|
cache_key = f"lyrics:{trackhash}"
|
||||||
|
|
||||||
|
if cache.is_available():
|
||||||
|
try:
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
logger.debug(f"Cache hit for lyrics {trackhash}")
|
||||||
|
return json.loads(cached)
|
||||||
|
except Exception:
|
||||||
|
pass # Cache miss is fine
|
||||||
|
|
||||||
|
# get copyright first
|
||||||
|
copyright = ""
|
||||||
|
if entry := TrackStore.trackhashmap.get(trackhash, None):
|
||||||
|
for track in entry.tracks:
|
||||||
|
copyright = track.copyright
|
||||||
|
|
||||||
|
if copyright:
|
||||||
|
break
|
||||||
|
|
||||||
|
lyrics = get_lyrics_file(filepath)
|
||||||
|
|
||||||
|
if not lyrics:
|
||||||
|
lyrics = get_lyrics_from_tags(trackhash) # type: ignore
|
||||||
|
|
||||||
|
if not lyrics:
|
||||||
|
lyrics = get_lyrics_from_duplicates(filepath, trackhash)
|
||||||
|
|
||||||
|
# check lyrics plugins
|
||||||
|
if not lyrics:
|
||||||
|
try:
|
||||||
|
# Get track metadata for plugin search
|
||||||
|
entry = TrackStore.trackhashmap.get(trackhash, None)
|
||||||
|
if entry and len(entry.tracks) > 0:
|
||||||
|
track = entry.tracks[0] # Use first track for metadata
|
||||||
|
title = getattr(track, "title", "") or ""
|
||||||
|
artist = ""
|
||||||
|
if hasattr(track, "artists") and track.artists:
|
||||||
|
artist = (
|
||||||
|
track.artists[0].name
|
||||||
|
if hasattr(track.artists[0], "name")
|
||||||
|
else str(track.artists[0])
|
||||||
|
)
|
||||||
|
album = ""
|
||||||
|
if hasattr(track, "album") and track.album:
|
||||||
|
album = (
|
||||||
|
track.album.name
|
||||||
|
if hasattr(track.album, "name")
|
||||||
|
else str(track.album)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only proceed if we have basic metadata
|
||||||
|
if title and artist:
|
||||||
|
# Initialize lyrics plugin
|
||||||
|
lyrics_plugin = Lyrics()
|
||||||
|
if lyrics_plugin.enabled:
|
||||||
|
# LRCLIB-first metadata retrieval with provider fallback.
|
||||||
|
lrc_content = lyrics_plugin.download_lyrics_by_metadata(
|
||||||
|
title=title,
|
||||||
|
artist=artist,
|
||||||
|
path=filepath,
|
||||||
|
album=album,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback to provider search result track IDs when metadata fetch fails.
|
||||||
|
if not lrc_content:
|
||||||
|
search_results = (
|
||||||
|
lyrics_plugin.search_lyrics_by_title_and_artist(
|
||||||
|
title, artist
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if search_results and len(search_results) > 0:
|
||||||
|
perfect_match = search_results[0]
|
||||||
|
if album:
|
||||||
|
for result in search_results:
|
||||||
|
result_title = result.get("title", "").lower()
|
||||||
|
result_album = result.get("album", "").lower()
|
||||||
|
if (
|
||||||
|
result_title == title.lower()
|
||||||
|
and result_album == album.lower()
|
||||||
|
):
|
||||||
|
perfect_match = result
|
||||||
|
break
|
||||||
|
|
||||||
|
track_id = perfect_match.get("track_id")
|
||||||
|
if track_id:
|
||||||
|
lrc_content = lyrics_plugin.download_lyrics(
|
||||||
|
track_id, filepath
|
||||||
|
)
|
||||||
|
|
||||||
|
if lrc_content and len(lrc_content.strip()) > 0:
|
||||||
|
lyrics = Lyrics_class(lrc_content)
|
||||||
|
except Exception:
|
||||||
|
# Log error but don't break the lyrics fetching process
|
||||||
|
# In production, you might want to log this error
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not lyrics:
|
||||||
|
return {"error": "No lyrics found"}
|
||||||
|
|
||||||
|
if lyrics.is_synced:
|
||||||
|
text = lyrics.format_synced_lyrics()
|
||||||
|
else:
|
||||||
|
text = lyrics.format_unsynced_lyrics()
|
||||||
|
|
||||||
|
result = {"lyrics": text, "synced": lyrics.is_synced, "copyright": copyright}
|
||||||
|
|
||||||
|
# Cache lyrics for 24 hours (lyrics rarely change)
|
||||||
|
if cache.is_available():
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
cache.set(cache_key, json.dumps(result), ex=86400)
|
||||||
|
|
||||||
|
return result, 200
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/check")
|
||||||
|
def check_lyrics(body: SendLyricsBody):
|
||||||
|
"""
|
||||||
|
Checks if lyrics file or tag exists for a track
|
||||||
|
"""
|
||||||
|
result = send_lyrics(body)
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
return {"exists": False}
|
||||||
|
else:
|
||||||
|
return {"exists": True}, 200
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
"""Mobile offline sync API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Blueprint, request
|
||||||
|
from flask_jwt_extended import jwt_required
|
||||||
|
|
||||||
|
from swingmusic.services.mobile_offline_service import mobile_offline_service
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
|
||||||
|
mobile_offline_bp = Blueprint(
|
||||||
|
"mobile_offline", __name__, url_prefix="/api/mobile-offline"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(payload: dict[str, Any], status: int = 200):
|
||||||
|
return payload, status
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(message: str, status: int = 400):
|
||||||
|
return {"error": message}, status
|
||||||
|
|
||||||
|
|
||||||
|
@mobile_offline_bp.post("/devices/register")
|
||||||
|
@jwt_required()
|
||||||
|
def register_device():
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
userid = get_current_userid()
|
||||||
|
|
||||||
|
try:
|
||||||
|
device = mobile_offline_service.register_device(userid, body)
|
||||||
|
except Exception as error:
|
||||||
|
return _fail(f"Failed to register device: {error}", 500)
|
||||||
|
|
||||||
|
return _ok({"device": device}, 201)
|
||||||
|
|
||||||
|
|
||||||
|
@mobile_offline_bp.get("/devices")
|
||||||
|
@jwt_required()
|
||||||
|
def get_devices():
|
||||||
|
userid = get_current_userid()
|
||||||
|
devices = mobile_offline_service.list_devices(userid)
|
||||||
|
return _ok({"devices": devices, "total_count": len(devices)})
|
||||||
|
|
||||||
|
|
||||||
|
@mobile_offline_bp.get("/devices/<device_id>")
|
||||||
|
@jwt_required()
|
||||||
|
def get_device(device_id: str):
|
||||||
|
userid = get_current_userid()
|
||||||
|
device = mobile_offline_service.get_device(userid, device_id)
|
||||||
|
if not device:
|
||||||
|
return _fail("Device not found", 404)
|
||||||
|
return _ok({"device": device})
|
||||||
|
|
||||||
|
|
||||||
|
@mobile_offline_bp.put("/devices/<device_id>/settings")
|
||||||
|
@jwt_required()
|
||||||
|
def update_device_settings(device_id: str):
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
userid = get_current_userid()
|
||||||
|
|
||||||
|
success = mobile_offline_service.update_device_settings(userid, device_id, body)
|
||||||
|
if not success:
|
||||||
|
return _fail("Device not found", 404)
|
||||||
|
|
||||||
|
return _ok({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@mobile_offline_bp.get("/devices/<device_id>/offline-library")
|
||||||
|
@jwt_required()
|
||||||
|
def get_offline_library(device_id: str):
|
||||||
|
userid = get_current_userid()
|
||||||
|
try:
|
||||||
|
payload = mobile_offline_service.get_offline_library(userid, device_id)
|
||||||
|
except ValueError as error:
|
||||||
|
return _fail(str(error), 404)
|
||||||
|
except Exception as error:
|
||||||
|
return _fail(f"Failed to get offline library: {error}", 500)
|
||||||
|
|
||||||
|
return _ok({"offline_library": payload})
|
||||||
|
|
||||||
|
|
||||||
|
@mobile_offline_bp.post("/devices/<device_id>/add-tracks")
|
||||||
|
@jwt_required()
|
||||||
|
def add_tracks_to_offline(device_id: str):
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
userid = get_current_userid()
|
||||||
|
|
||||||
|
track_items = body.get("tracks") or body.get("track_ids") or []
|
||||||
|
if not isinstance(track_items, list) or not track_items:
|
||||||
|
return _fail("tracks or track_ids must be a non-empty list", 400)
|
||||||
|
|
||||||
|
quality = body.get("quality")
|
||||||
|
collection = body.get("collection")
|
||||||
|
|
||||||
|
try:
|
||||||
|
queue_items = mobile_offline_service.add_to_offline_library(
|
||||||
|
userid,
|
||||||
|
device_id,
|
||||||
|
track_items,
|
||||||
|
quality=quality,
|
||||||
|
collection=collection,
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
return _fail(str(error), 404)
|
||||||
|
except Exception as error:
|
||||||
|
return _fail(f"Failed to add tracks: {error}", 500)
|
||||||
|
|
||||||
|
return _ok(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"queue_items": queue_items,
|
||||||
|
"added_count": len(queue_items),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mobile_offline_bp.post("/devices/<device_id>/sync-playlist/<playlist_id>")
|
||||||
|
@jwt_required()
|
||||||
|
def sync_playlist_offline(device_id: str, playlist_id: str):
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
userid = get_current_userid()
|
||||||
|
|
||||||
|
try:
|
||||||
|
queue_items = mobile_offline_service.sync_playlist_offline(
|
||||||
|
userid,
|
||||||
|
device_id,
|
||||||
|
playlist_id,
|
||||||
|
quality=body.get("quality"),
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
return _fail(str(error), 400)
|
||||||
|
except Exception as error:
|
||||||
|
return _fail(f"Failed to sync playlist: {error}", 500)
|
||||||
|
|
||||||
|
return _ok(
|
||||||
|
{"success": True, "queue_items": queue_items, "added_count": len(queue_items)}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mobile_offline_bp.post("/devices/<device_id>/sync-collection")
|
||||||
|
@jwt_required()
|
||||||
|
def sync_collection_offline(device_id: str):
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
userid = get_current_userid()
|
||||||
|
|
||||||
|
collection_type = str(body.get("collection_type") or "").strip().lower()
|
||||||
|
collection_id = str(body.get("collection_id") or "").strip()
|
||||||
|
quality = body.get("quality")
|
||||||
|
|
||||||
|
if collection_type not in {"album", "artist", "playlist"}:
|
||||||
|
return _fail("collection_type must be one of: album, artist, playlist", 400)
|
||||||
|
if not collection_id:
|
||||||
|
return _fail("collection_id is required", 400)
|
||||||
|
|
||||||
|
trackhashes = mobile_offline_service.tracks_for_collection(
|
||||||
|
collection_type=collection_type,
|
||||||
|
collection_id=collection_id,
|
||||||
|
)
|
||||||
|
if not trackhashes:
|
||||||
|
return _fail("No tracks found for collection", 404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
queue_items = mobile_offline_service.add_to_offline_library(
|
||||||
|
userid,
|
||||||
|
device_id,
|
||||||
|
trackhashes,
|
||||||
|
quality=quality,
|
||||||
|
collection=f"{collection_type}:{collection_id}",
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
return _fail(str(error), 404)
|
||||||
|
except Exception as error:
|
||||||
|
return _fail(f"Failed to sync collection: {error}", 500)
|
||||||
|
|
||||||
|
return _ok(
|
||||||
|
{"success": True, "queue_items": queue_items, "added_count": len(queue_items)}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mobile_offline_bp.post("/devices/<device_id>/remove-tracks")
|
||||||
|
@jwt_required()
|
||||||
|
def remove_tracks_from_offline(device_id: str):
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
userid = get_current_userid()
|
||||||
|
|
||||||
|
trackhashes = body.get("trackhashes") or body.get("track_ids") or []
|
||||||
|
if not isinstance(trackhashes, list) or not trackhashes:
|
||||||
|
return _fail("trackhashes or track_ids must be a non-empty list", 400)
|
||||||
|
|
||||||
|
success = mobile_offline_service.remove_from_offline_library(
|
||||||
|
userid, device_id, trackhashes
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
return _fail("Device not found", 404)
|
||||||
|
|
||||||
|
return _ok({"success": True, "removed_count": len(trackhashes)})
|
||||||
|
|
||||||
|
|
||||||
|
@mobile_offline_bp.get("/devices/<device_id>/sync-progress")
|
||||||
|
@jwt_required()
|
||||||
|
def get_sync_progress(device_id: str):
|
||||||
|
userid = get_current_userid()
|
||||||
|
|
||||||
|
try:
|
||||||
|
progress = mobile_offline_service.get_sync_progress(userid, device_id)
|
||||||
|
except ValueError as error:
|
||||||
|
return _fail(str(error), 404)
|
||||||
|
except Exception as error:
|
||||||
|
return _fail(f"Failed to fetch sync progress: {error}", 500)
|
||||||
|
|
||||||
|
return _ok({"sync_progress": progress})
|
||||||
|
|
||||||
|
|
||||||
|
@mobile_offline_bp.post("/devices/<device_id>/force-sync")
|
||||||
|
@jwt_required()
|
||||||
|
def force_sync_now(device_id: str):
|
||||||
|
userid = get_current_userid()
|
||||||
|
success = mobile_offline_service.force_sync_now(userid, device_id)
|
||||||
|
if not success:
|
||||||
|
return _fail("Device not found", 404)
|
||||||
|
return _ok({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@mobile_offline_bp.get("/devices/<device_id>/storage-info")
|
||||||
|
@jwt_required()
|
||||||
|
def get_storage_info(device_id: str):
|
||||||
|
userid = get_current_userid()
|
||||||
|
try:
|
||||||
|
usage = mobile_offline_service.get_storage_usage(userid, device_id)
|
||||||
|
except ValueError as error:
|
||||||
|
return _fail(str(error), 404)
|
||||||
|
except Exception as error:
|
||||||
|
return _fail(f"Failed to get storage info: {error}", 500)
|
||||||
|
|
||||||
|
usage_percentage = 0.0
|
||||||
|
if usage.total_capacity > 0:
|
||||||
|
usage_percentage = round((usage.used_space / usage.total_capacity) * 100.0, 2)
|
||||||
|
|
||||||
|
return _ok(
|
||||||
|
{
|
||||||
|
"storage_info": {
|
||||||
|
"total_capacity": usage.total_capacity,
|
||||||
|
"used_space": usage.used_space,
|
||||||
|
"available_space": usage.available_space,
|
||||||
|
"usage_percentage": usage_percentage,
|
||||||
|
"offline_tracks_count": usage.offline_tracks_count,
|
||||||
|
"offline_tracks_size": usage.offline_tracks_size,
|
||||||
|
"other_data_size": usage.other_data_size,
|
||||||
|
"quality_breakdown": usage.quality_breakdown,
|
||||||
|
"needs_cleanup": usage_percentage >= 90.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mobile_offline_bp.post("/devices/<device_id>/cleanup")
|
||||||
|
@jwt_required()
|
||||||
|
def cleanup_storage(device_id: str):
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
userid = get_current_userid()
|
||||||
|
|
||||||
|
strategy = str(body.get("strategy") or "least_played")
|
||||||
|
if strategy not in {"least_played", "oldest", "all"}:
|
||||||
|
return _fail("strategy must be one of: least_played, oldest, all", 400)
|
||||||
|
|
||||||
|
free_space_bytes = int(body.get("free_space_bytes") or 0)
|
||||||
|
|
||||||
|
freed = mobile_offline_service.cleanup_device_content(
|
||||||
|
userid,
|
||||||
|
device_id,
|
||||||
|
strategy=strategy,
|
||||||
|
free_space_bytes=free_space_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _ok({"success": True, "freed_space": freed, "strategy": strategy})
|
||||||
|
|
||||||
|
|
||||||
|
@mobile_offline_bp.post("/devices/<device_id>/events/batch")
|
||||||
|
@jwt_required()
|
||||||
|
def append_events(device_id: str):
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
userid = get_current_userid()
|
||||||
|
|
||||||
|
events = body.get("events")
|
||||||
|
if not isinstance(events, list):
|
||||||
|
return _fail("events must be a list", 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = mobile_offline_service.append_events(userid, device_id, events)
|
||||||
|
except ValueError as error:
|
||||||
|
return _fail(str(error), 404)
|
||||||
|
except Exception as error:
|
||||||
|
return _fail(f"Failed to append events: {error}", 500)
|
||||||
|
|
||||||
|
mark_synced = body.get("mark_synced")
|
||||||
|
if isinstance(mark_synced, list):
|
||||||
|
mobile_offline_service.mark_events_synced(userid, device_id, mark_synced)
|
||||||
|
|
||||||
|
return _ok({"success": True, **result})
|
||||||
|
|
||||||
|
|
||||||
|
@mobile_offline_bp.post("/devices/<device_id>/events/mark-synced")
|
||||||
|
@jwt_required()
|
||||||
|
def mark_events_synced(device_id: str):
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
userid = get_current_userid()
|
||||||
|
|
||||||
|
event_ids = body.get("event_ids")
|
||||||
|
if event_ids is not None and not isinstance(event_ids, list):
|
||||||
|
return _fail("event_ids must be a list", 400)
|
||||||
|
|
||||||
|
updated = mobile_offline_service.mark_events_synced(userid, device_id, event_ids)
|
||||||
|
return _ok({"success": True, "updated": updated})
|
||||||
|
|
||||||
|
|
||||||
|
@mobile_offline_bp.get("/quality-presets")
|
||||||
|
@jwt_required()
|
||||||
|
def get_quality_presets():
|
||||||
|
return _ok({"quality_presets": mobile_offline_service.quality_presets()})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,514 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
fallback_ux_bp = Blueprint("fallback_ux", __name__, url_prefix="/api/ux")
|
||||||
|
fallback_updates_bp = Blueprint("fallback_updates", __name__, url_prefix="/api/updates")
|
||||||
|
fallback_audio_quality_bp = Blueprint(
|
||||||
|
"fallback_audio_quality", __name__, url_prefix="/api/audio-quality"
|
||||||
|
)
|
||||||
|
fallback_recap_bp = Blueprint("fallback_recap", __name__, url_prefix="/api/recap")
|
||||||
|
fallback_settings_bp = Blueprint(
|
||||||
|
"fallback_settings", __name__, url_prefix="/api/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_AUDIO_SETTINGS = {
|
||||||
|
"streaming_quality": "high",
|
||||||
|
"adaptive_quality": True,
|
||||||
|
"network_aware_quality": True,
|
||||||
|
"device_specific_quality": True,
|
||||||
|
"download_format": "flac",
|
||||||
|
"download_sample_rate": "44.1kHz",
|
||||||
|
"download_bit_depth": "16bit",
|
||||||
|
"enable_loudness_normalization": True,
|
||||||
|
"target_loudness": -14.0,
|
||||||
|
"enable_adaptive_eq": False,
|
||||||
|
"enable_spatial_audio_processing": False,
|
||||||
|
"spatial_audio_format": "stereo",
|
||||||
|
"enable_crossfade": False,
|
||||||
|
"crossfade_duration": 2.0,
|
||||||
|
"enable_gapless_playback": True,
|
||||||
|
"enable_replaygain": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_UPDATE_SETTINGS = {
|
||||||
|
"enableArtistMonitoring": False,
|
||||||
|
"autoDownloadFavorites": False,
|
||||||
|
"enableNotifications": False,
|
||||||
|
"checkFrequency": "daily",
|
||||||
|
"qualityPreference": "flac",
|
||||||
|
"excludeExplicit": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_UD_SETTINGS = {
|
||||||
|
"defaultQuality": "high",
|
||||||
|
"autoAddToLibrary": True,
|
||||||
|
"maxConcurrentDownloads": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _disabled_payload(feature: str, **payload):
|
||||||
|
return {"enabled": False, "feature": feature, **payload}
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_ux_bp.get("/search/suggestions")
|
||||||
|
def fallback_ux_search_suggestions():
|
||||||
|
query = request.args.get("q", "")
|
||||||
|
context = request.args.get("context", "general")
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"advanced_ux",
|
||||||
|
suggestions=[],
|
||||||
|
query=query,
|
||||||
|
context=context,
|
||||||
|
total_count=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_ux_bp.get("/discovery/recommendations")
|
||||||
|
def fallback_ux_discovery():
|
||||||
|
recommendation_type = request.args.get("type", "mixed")
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"advanced_ux",
|
||||||
|
recommendations=[],
|
||||||
|
type=recommendation_type,
|
||||||
|
total_count=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_ux_bp.get("/contextual/suggestions")
|
||||||
|
def fallback_ux_contextual():
|
||||||
|
track_id = request.args.get("track_id")
|
||||||
|
context_type = request.args.get("context_type", "similar")
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"advanced_ux",
|
||||||
|
suggestions=[],
|
||||||
|
track_id=track_id,
|
||||||
|
context_type=context_type,
|
||||||
|
total_count=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_ux_bp.get("/download/suggestions")
|
||||||
|
def fallback_ux_download_suggestions():
|
||||||
|
query = request.args.get("q", "")
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"advanced_ux",
|
||||||
|
suggestions=[],
|
||||||
|
query=query,
|
||||||
|
total_count=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_ux_bp.get("/search/filters")
|
||||||
|
def fallback_ux_filters():
|
||||||
|
return jsonify(_disabled_payload("advanced_ux", filters=[], total_count=0))
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_ux_bp.post("/behavior/track")
|
||||||
|
def fallback_ux_track_behavior():
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload("advanced_ux", message="Behavior tracking skipped")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_ux_bp.get("/behavior/profile")
|
||||||
|
def fallback_ux_behavior_profile():
|
||||||
|
profile = {
|
||||||
|
"user_id": None,
|
||||||
|
"favorite_genres": [],
|
||||||
|
"favorite_artists": [],
|
||||||
|
"listening_patterns": {},
|
||||||
|
"download_preferences": {},
|
||||||
|
"interaction_patterns": {},
|
||||||
|
"last_updated": None,
|
||||||
|
"search_history_count": 0,
|
||||||
|
"recent_searches": [],
|
||||||
|
}
|
||||||
|
return jsonify(_disabled_payload("advanced_ux", profile=profile))
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_ux_bp.get("/trending/content")
|
||||||
|
def fallback_ux_trending():
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"advanced_ux",
|
||||||
|
trending=[],
|
||||||
|
type=request.args.get("type", "mixed"),
|
||||||
|
timeframe=request.args.get("timeframe", "week"),
|
||||||
|
total_count=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_ux_bp.post("/search/advanced")
|
||||||
|
def fallback_ux_advanced_search():
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"advanced_ux",
|
||||||
|
query=payload.get("query", ""),
|
||||||
|
results={
|
||||||
|
"tracks": [],
|
||||||
|
"albums": [],
|
||||||
|
"artists": [],
|
||||||
|
"playlists": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_ux_bp.get("/suggestions/quick")
|
||||||
|
def fallback_ux_quick_suggestions():
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"advanced_ux",
|
||||||
|
suggestions=[],
|
||||||
|
type=request.args.get("type", "search"),
|
||||||
|
total_count=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_ux_bp.get("/personalization/preferences")
|
||||||
|
def fallback_ux_get_preferences():
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"advanced_ux",
|
||||||
|
preferences={"enable_personalization": False},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_ux_bp.put("/personalization/preferences")
|
||||||
|
def fallback_ux_update_preferences():
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"advanced_ux",
|
||||||
|
message="Preferences saved in fallback mode",
|
||||||
|
preferences=payload,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_updates_bp.get("/stats")
|
||||||
|
def fallback_updates_stats():
|
||||||
|
stats = {
|
||||||
|
"followedArtists": 0,
|
||||||
|
"newReleases": 0,
|
||||||
|
"pendingDownloads": 0,
|
||||||
|
"unreadNotifications": 0,
|
||||||
|
}
|
||||||
|
return jsonify(_disabled_payload("update_tracking", stats=stats))
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_updates_bp.get("/recent")
|
||||||
|
def fallback_updates_recent():
|
||||||
|
limit = request.args.get("limit", 20, type=int)
|
||||||
|
offset = request.args.get("offset", 0, type=int)
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"update_tracking",
|
||||||
|
updates=[],
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
total=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_updates_bp.get("/followed-artists")
|
||||||
|
def fallback_updates_followed_artists():
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"update_tracking",
|
||||||
|
artists=[],
|
||||||
|
limit=50,
|
||||||
|
offset=0,
|
||||||
|
total=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_updates_bp.get("/settings")
|
||||||
|
def fallback_updates_get_settings():
|
||||||
|
return jsonify(_disabled_payload("update_tracking", **DEFAULT_UPDATE_SETTINGS))
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_updates_bp.post("/settings")
|
||||||
|
def fallback_updates_set_settings():
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
merged = {**DEFAULT_UPDATE_SETTINGS, **payload}
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"update_tracking",
|
||||||
|
message="Settings saved in fallback mode",
|
||||||
|
settings=merged,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_updates_bp.get("/search/artists")
|
||||||
|
def fallback_updates_search_artists():
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"update_tracking",
|
||||||
|
artists=[],
|
||||||
|
query=request.args.get("q", ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_updates_bp.post("/follow-artist")
|
||||||
|
def fallback_updates_follow_artist():
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"update_tracking",
|
||||||
|
message="Artist follow stored in fallback mode",
|
||||||
|
artist_id=payload.get("artist_id"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_updates_bp.post("/unfollow-artist")
|
||||||
|
def fallback_updates_unfollow_artist():
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"update_tracking",
|
||||||
|
message="Artist unfollow stored in fallback mode",
|
||||||
|
artist_id=payload.get("artist_id"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_updates_bp.get("/artist/<artist_id>/follow-status")
|
||||||
|
def fallback_updates_follow_status(artist_id: str):
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"update_tracking",
|
||||||
|
is_following=False,
|
||||||
|
artist_id=artist_id,
|
||||||
|
follow_level="followed",
|
||||||
|
auto_download_new_releases=False,
|
||||||
|
preferred_quality="flac",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_updates_bp.post("/artist/<artist_id>")
|
||||||
|
def fallback_updates_update_artist(artist_id: str):
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"update_tracking",
|
||||||
|
message="Artist preferences saved in fallback mode",
|
||||||
|
artist_id=artist_id,
|
||||||
|
settings=payload,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_updates_bp.post("/auto-download/<release_id>")
|
||||||
|
def fallback_updates_auto_download(release_id: str):
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"update_tracking",
|
||||||
|
message="Download queued in fallback mode",
|
||||||
|
release_id=release_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_updates_bp.post("/release/<release_id>/mark-read")
|
||||||
|
def fallback_updates_mark_read(release_id: str):
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"update_tracking",
|
||||||
|
message="Marked as read",
|
||||||
|
release_id=release_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_updates_bp.post("/notifications/mark-all-read")
|
||||||
|
def fallback_updates_mark_all_read():
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"update_tracking",
|
||||||
|
message="All notifications marked as read",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_updates_bp.get("/export/followed-artists")
|
||||||
|
def fallback_updates_export_followed_artists():
|
||||||
|
return jsonify(_disabled_payload("update_tracking", followed_artists=[]))
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_audio_quality_bp.get("/settings")
|
||||||
|
def fallback_audio_get_settings():
|
||||||
|
return jsonify(_disabled_payload("audio_quality", settings=DEFAULT_AUDIO_SETTINGS))
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_audio_quality_bp.post("/settings")
|
||||||
|
def fallback_audio_set_settings():
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
merged = {**DEFAULT_AUDIO_SETTINGS, **payload}
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"audio_quality",
|
||||||
|
message="Audio quality settings saved in fallback mode",
|
||||||
|
settings=merged,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_audio_quality_bp.get("/network/status")
|
||||||
|
def fallback_audio_network_status():
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"audio_quality",
|
||||||
|
network_status={"speed": 0, "quality": "unknown"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_audio_quality_bp.get("/device/info")
|
||||||
|
def fallback_audio_device_info():
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"audio_quality",
|
||||||
|
device_info={"type": "unknown"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_audio_quality_bp.post("/apply-preset")
|
||||||
|
def fallback_audio_apply_preset():
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"audio_quality",
|
||||||
|
message="Preset applied in fallback mode",
|
||||||
|
preset_name=payload.get("preset_name"),
|
||||||
|
settings=DEFAULT_AUDIO_SETTINGS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_recap_bp.get("/available-years")
|
||||||
|
def fallback_recap_available_years():
|
||||||
|
return jsonify(_disabled_payload("recap", available_years=[], total_recaps=0))
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_recap_bp.get("/summary/<int:year>")
|
||||||
|
def fallback_recap_summary(year: int):
|
||||||
|
return jsonify(_disabled_payload("recap", recap=None, year=year))
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_recap_bp.get("/details/<int:year>")
|
||||||
|
def fallback_recap_details(year: int):
|
||||||
|
return jsonify(_disabled_payload("recap", recap=None, year=year))
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_recap_bp.post("/generate/<int:year>")
|
||||||
|
def fallback_recap_generate(year: int):
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"recap",
|
||||||
|
message="Recap generation is unavailable",
|
||||||
|
year=year,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_recap_bp.post("/video/<int:year>")
|
||||||
|
def fallback_recap_video(year: int):
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"recap",
|
||||||
|
message="Recap video generation is unavailable",
|
||||||
|
year=year,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_recap_bp.post("/share/<int:year>")
|
||||||
|
def fallback_recap_share(year: int):
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"recap",
|
||||||
|
message="Share links are unavailable",
|
||||||
|
year=year,
|
||||||
|
share_url=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_recap_bp.get("/shared/<token>")
|
||||||
|
def fallback_recap_shared(token: str):
|
||||||
|
return jsonify(_disabled_payload("recap", recap=None, token=token))
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_recap_bp.get("/compare/<int:year1>/<int:year2>")
|
||||||
|
def fallback_recap_compare(year1: int, year2: int):
|
||||||
|
return jsonify(_disabled_payload("recap", comparison=None, years=[year1, year2]))
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_settings_bp.get("/universal-downloader")
|
||||||
|
def fallback_universal_downloader_get():
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"universal_downloader_settings",
|
||||||
|
success=True,
|
||||||
|
settings=DEFAULT_UD_SETTINGS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_settings_bp.post("/universal-downloader")
|
||||||
|
def fallback_universal_downloader_post():
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
merged = {**DEFAULT_UD_SETTINGS, **payload}
|
||||||
|
return jsonify(
|
||||||
|
_disabled_payload(
|
||||||
|
"universal_downloader_settings",
|
||||||
|
success=True,
|
||||||
|
settings=merged,
|
||||||
|
message="Settings saved in fallback mode",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_route(app, route: str) -> bool:
|
||||||
|
return any(rule.rule == route for rule in app.url_map.iter_rules())
|
||||||
|
|
||||||
|
|
||||||
|
def register_optional_feature_fallbacks(app):
|
||||||
|
if not _has_route(app, "/api/ux/search/suggestions"):
|
||||||
|
app.register_blueprint(fallback_ux_bp)
|
||||||
|
|
||||||
|
if not _has_route(app, "/api/updates/stats"):
|
||||||
|
app.register_blueprint(fallback_updates_bp)
|
||||||
|
|
||||||
|
if not _has_route(app, "/api/audio-quality/settings"):
|
||||||
|
app.register_blueprint(fallback_audio_quality_bp)
|
||||||
|
|
||||||
|
if not _has_route(app, "/api/recap/available-years"):
|
||||||
|
app.register_blueprint(fallback_recap_bp)
|
||||||
|
|
||||||
|
if not _has_route(app, "/api/settings/universal-downloader"):
|
||||||
|
app.register_blueprint(fallback_settings_bp)
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
"""
|
||||||
|
All playlist-related routes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import pathlib
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from flask_openapi3 import FileStorage as _FileStorage
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
from pydantic import BaseModel, Field, GetCoreSchemaHandler
|
||||||
|
from pydantic_core import core_schema
|
||||||
|
|
||||||
|
from swingmusic import models
|
||||||
|
from swingmusic.api.apischemas import GenericLimitSchema
|
||||||
|
|
||||||
|
# DragonflyDB integration for playlist caching
|
||||||
|
from swingmusic.db.dragonfly_client import get_dragonfly_client
|
||||||
|
from swingmusic.db.userdata import PlaylistTable
|
||||||
|
from swingmusic.lib import playlistlib
|
||||||
|
from swingmusic.lib.albumslib import sort_by_track_no
|
||||||
|
from swingmusic.lib.home.recentlyadded import get_recently_added_playlist
|
||||||
|
from swingmusic.lib.home.recentlyplayed import get_recently_played_playlist
|
||||||
|
from swingmusic.lib.sortlib import sort_tracks
|
||||||
|
from swingmusic.models.playlist import Playlist
|
||||||
|
from swingmusic.serializers.playlist import serialize_for_card
|
||||||
|
from swingmusic.serializers.track import serialize_tracks
|
||||||
|
from swingmusic.services.user_library_scope import (
|
||||||
|
filter_trackhashes_for_user,
|
||||||
|
get_available_trackhashes,
|
||||||
|
)
|
||||||
|
from swingmusic.settings import Paths
|
||||||
|
from swingmusic.store.tracks import TrackStore
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
from swingmusic.utils.dates import create_new_date, date_string_to_time_passed
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
tag = Tag(name="Playlists", description="Get and manage playlists")
|
||||||
|
api = APIBlueprint("playlists", __name__, url_prefix="/playlists", abp_tags=[tag])
|
||||||
|
|
||||||
|
|
||||||
|
def insert_playlist(name: str, image: str = None):
|
||||||
|
playlist = {
|
||||||
|
"image": image,
|
||||||
|
"last_updated": create_new_date(),
|
||||||
|
"name": name,
|
||||||
|
"trackhashes": [],
|
||||||
|
"settings": {
|
||||||
|
"has_gif": False,
|
||||||
|
"banner_pos": 50,
|
||||||
|
"square_img": bool(image),
|
||||||
|
"pinned": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rowid = PlaylistTable.add_one(playlist)
|
||||||
|
if rowid:
|
||||||
|
playlist["id"] = rowid
|
||||||
|
return Playlist(**playlist)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_path_trackhashes(
|
||||||
|
path: str, tracksortby: str, reverse: bool, userid: int | None = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Returns a list of trackhashes in a folder.
|
||||||
|
"""
|
||||||
|
tracks = TrackStore.get_tracks_in_path(path)
|
||||||
|
tracks = sort_tracks(tracks, key=tracksortby, reverse=reverse)
|
||||||
|
return filter_trackhashes_for_user([t.trackhash for t in tracks], userid=userid)
|
||||||
|
|
||||||
|
|
||||||
|
def get_album_trackhashes(albumhash: str, userid: int | None = None):
|
||||||
|
"""
|
||||||
|
Returns a list of trackhashes in an album.
|
||||||
|
"""
|
||||||
|
tracks = TrackStore.get_tracks_by_albumhash(albumhash)
|
||||||
|
tracks = sort_by_track_no(tracks)
|
||||||
|
|
||||||
|
return filter_trackhashes_for_user([t.trackhash for t in tracks], userid=userid)
|
||||||
|
|
||||||
|
|
||||||
|
def get_artist_trackhashes(artisthash: str, userid: int | None = None):
|
||||||
|
"""
|
||||||
|
Returns a list of trackhashes for an artist.
|
||||||
|
"""
|
||||||
|
tracks = TrackStore.get_tracks_by_artisthash(artisthash)
|
||||||
|
tracks = sort_tracks(tracks, key="playcount", reverse=True)
|
||||||
|
return filter_trackhashes_for_user([t.trackhash for t in tracks], userid=userid)
|
||||||
|
|
||||||
|
|
||||||
|
def format_custom_playlist(playlist: models.Playlist, tracks: list[models.Track]):
|
||||||
|
playlist.duration = sum(t.duration for t in tracks)
|
||||||
|
playlist.count = len(tracks)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"info": serialize_for_card(playlist),
|
||||||
|
"tracks": serialize_tracks(tracks),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SendAllPlaylistsQuery(BaseModel):
|
||||||
|
no_images: bool = Field(False, description="Whether to include images")
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("")
|
||||||
|
def send_all_playlists(query: SendAllPlaylistsQuery):
|
||||||
|
"""
|
||||||
|
Gets all the playlists.
|
||||||
|
"""
|
||||||
|
playlists = PlaylistTable.get_all()
|
||||||
|
playlists = sorted(
|
||||||
|
playlists,
|
||||||
|
key=lambda p: datetime.strptime(p.last_updated, "%Y-%m-%d %H:%M:%S"),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
available_trackhashes = get_available_trackhashes(get_current_userid())
|
||||||
|
|
||||||
|
for playlist in playlists:
|
||||||
|
visible_trackhashes = [
|
||||||
|
trackhash
|
||||||
|
for trackhash in playlist.trackhashes
|
||||||
|
if trackhash in available_trackhashes
|
||||||
|
]
|
||||||
|
playlist.count = len(visible_trackhashes)
|
||||||
|
|
||||||
|
if not playlist.has_image:
|
||||||
|
playlist.images = playlistlib.get_first_4_images(
|
||||||
|
trackhashes=visible_trackhashes
|
||||||
|
)
|
||||||
|
|
||||||
|
playlist.clear_lists()
|
||||||
|
|
||||||
|
# playlists.sort(
|
||||||
|
# key=lambda p: datetime.strptime(p.last_updated, "%Y-%m-%d %H:%M:%S"),
|
||||||
|
# reverse=True,
|
||||||
|
# )
|
||||||
|
|
||||||
|
return {"data": playlists}
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePlaylistBody(BaseModel):
|
||||||
|
name: str = Field(..., description="The name of the playlist")
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/new")
|
||||||
|
def create_playlist(body: CreatePlaylistBody):
|
||||||
|
"""
|
||||||
|
New playlist
|
||||||
|
|
||||||
|
Creates a new playlist. Accepts POST method with a JSON body.
|
||||||
|
"""
|
||||||
|
exists = PlaylistTable.check_exists_by_name(body.name)
|
||||||
|
|
||||||
|
if exists:
|
||||||
|
return {"error": "Playlist already exists"}, 409
|
||||||
|
|
||||||
|
playlist = insert_playlist(body.name)
|
||||||
|
|
||||||
|
if playlist is None:
|
||||||
|
return {"error": "Playlist could not be created"}, 500
|
||||||
|
|
||||||
|
return {"playlist": playlist}, 201
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistIDPath(BaseModel):
|
||||||
|
# INFO: playlistid string examples: "recentlyadded"
|
||||||
|
playlistid: str = Field(..., description="The ID of the playlist")
|
||||||
|
|
||||||
|
|
||||||
|
class AddItemToPlaylistBody(BaseModel):
|
||||||
|
itemtype: str = Field(
|
||||||
|
default="tracks",
|
||||||
|
description="The type of item to add",
|
||||||
|
examples=["tracks", "folder", "album", "artist"],
|
||||||
|
)
|
||||||
|
sortoptions: dict = Field(
|
||||||
|
default=None,
|
||||||
|
description="The sort options for the tracks",
|
||||||
|
)
|
||||||
|
itemhash: str = Field(..., description="The hash of the item to add")
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/<playlistid>/add")
|
||||||
|
def add_item_to_playlist(path: PlaylistIDPath, body: AddItemToPlaylistBody):
|
||||||
|
"""
|
||||||
|
Add to playlist.
|
||||||
|
|
||||||
|
If itemtype is not "tracks", itemhash is expected to be a folder, album or artist hash.
|
||||||
|
"""
|
||||||
|
itemtype = body.itemtype
|
||||||
|
itemhash = body.itemhash
|
||||||
|
playlist_id = int(path.playlistid)
|
||||||
|
sortoptions = body.sortoptions
|
||||||
|
userid = get_current_userid()
|
||||||
|
|
||||||
|
if itemtype == "tracks":
|
||||||
|
trackhashes = itemhash.split(",")
|
||||||
|
trackhashes = filter_trackhashes_for_user(trackhashes, userid=userid)
|
||||||
|
if len(trackhashes) == 1 and trackhashes[0] in PlaylistTable.get_trackhashes(
|
||||||
|
playlist_id
|
||||||
|
):
|
||||||
|
return {"msg": "Track already exists in playlist"}, 409
|
||||||
|
elif itemtype == "folder":
|
||||||
|
trackhashes = get_path_trackhashes(
|
||||||
|
itemhash,
|
||||||
|
sortoptions.get("tracksortby") or "default",
|
||||||
|
sortoptions.get("tracksortreverse") or False,
|
||||||
|
userid=userid,
|
||||||
|
)
|
||||||
|
elif itemtype == "album":
|
||||||
|
trackhashes = get_album_trackhashes(itemhash, userid=userid)
|
||||||
|
elif itemtype == "artist":
|
||||||
|
trackhashes = get_artist_trackhashes(itemhash, userid=userid)
|
||||||
|
else:
|
||||||
|
trackhashes = []
|
||||||
|
|
||||||
|
PlaylistTable.append_to_playlist(playlist_id, trackhashes)
|
||||||
|
return {"msg": "Done"}, 200
|
||||||
|
|
||||||
|
|
||||||
|
class GetPlaylistQuery(GenericLimitSchema):
|
||||||
|
no_tracks: bool = Field(False, description="Whether to include tracks")
|
||||||
|
start: int = Field(0, description="The start index of the tracks")
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/<playlistid>")
|
||||||
|
def get_playlist(path: PlaylistIDPath, query: GetPlaylistQuery):
|
||||||
|
"""
|
||||||
|
Get playlist by id
|
||||||
|
"""
|
||||||
|
no_tracks = query.no_tracks
|
||||||
|
playlistid = path.playlistid
|
||||||
|
|
||||||
|
custom_playlists = [
|
||||||
|
{"name": "recentlyadded", "handler": get_recently_added_playlist},
|
||||||
|
{"name": "recentlyplayed", "handler": get_recently_played_playlist},
|
||||||
|
]
|
||||||
|
is_custom = playlistid in {p["name"] for p in custom_playlists}
|
||||||
|
|
||||||
|
if is_custom:
|
||||||
|
if query.start != 0:
|
||||||
|
return {
|
||||||
|
"tracks": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
handler = next(
|
||||||
|
p["handler"] for p in custom_playlists if p["name"] == playlistid
|
||||||
|
)
|
||||||
|
playlist, tracks = handler()
|
||||||
|
return format_custom_playlist(playlist, tracks)
|
||||||
|
|
||||||
|
# Try DragonflyDB cache first for regular playlists
|
||||||
|
cache = get_dragonfly_client()
|
||||||
|
cache_key = f"playlists:{playlistid}:{query.start}:{query.limit}"
|
||||||
|
|
||||||
|
if cache.is_available():
|
||||||
|
try:
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
result = json.loads(cached)
|
||||||
|
logger.debug(f"Cache hit for playlist {playlistid}")
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
pass # Cache miss is fine
|
||||||
|
|
||||||
|
playlist = PlaylistTable.get_by_id(int(playlistid))
|
||||||
|
|
||||||
|
if playlist is None:
|
||||||
|
return {"msg": "Playlist not found"}, 404
|
||||||
|
|
||||||
|
if query.limit == -1:
|
||||||
|
query.limit = len(playlist.trackhashes) - 1
|
||||||
|
|
||||||
|
available_trackhashes = get_available_trackhashes(get_current_userid())
|
||||||
|
scoped_trackhashes = [
|
||||||
|
trackhash
|
||||||
|
for trackhash in playlist.trackhashes
|
||||||
|
if trackhash in available_trackhashes
|
||||||
|
]
|
||||||
|
|
||||||
|
tracks = TrackStore.get_tracks_by_trackhashes(
|
||||||
|
scoped_trackhashes[query.start : query.start + query.limit]
|
||||||
|
)
|
||||||
|
duration = sum(t.duration for t in tracks)
|
||||||
|
playlist._last_updated = date_string_to_time_passed(playlist.last_updated)
|
||||||
|
playlist.duration = duration
|
||||||
|
playlist.images = playlistlib.get_first_4_images(tracks)
|
||||||
|
playlist.clear_lists()
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"info": playlist,
|
||||||
|
"tracks": serialize_tracks(tracks) if not no_tracks else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache the result for 5 minutes
|
||||||
|
if cache.is_available():
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
cache.set(cache_key, json.dumps(result, default=str), ex=300)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class FileStorage(_FileStorage):
|
||||||
|
@classmethod
|
||||||
|
def __get_pydantic_core_schema__(
|
||||||
|
cls, _source: Any, handler: GetCoreSchemaHandler
|
||||||
|
) -> core_schema.CoreSchema:
|
||||||
|
return core_schema.with_info_plain_validator_function(cls.validate)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatePlaylistForm(BaseModel):
|
||||||
|
image: FileStorage = Field(description="The image file")
|
||||||
|
name: str = Field(..., description="The name of the playlist")
|
||||||
|
settings: str = Field(
|
||||||
|
...,
|
||||||
|
description="The settings of the playlist",
|
||||||
|
json_schema_extra={
|
||||||
|
"example": '{"has_gif": false, "banner_pos": 50, "square_img": false, "pinned": false}'
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.put("/<playlistid>/update", methods=["PUT"])
|
||||||
|
def update_playlist_info(path: PlaylistIDPath, form: UpdatePlaylistForm):
|
||||||
|
"""
|
||||||
|
Update playlist
|
||||||
|
"""
|
||||||
|
playlistid = path.playlistid
|
||||||
|
db_playlist = PlaylistTable.get_by_id(playlistid)
|
||||||
|
|
||||||
|
if db_playlist is None:
|
||||||
|
return {"error": "Playlist not found"}, 404
|
||||||
|
|
||||||
|
image = form.image
|
||||||
|
|
||||||
|
if form.image:
|
||||||
|
image = form.image
|
||||||
|
|
||||||
|
settings = json.loads(form.settings)
|
||||||
|
settings["has_gif"] = False
|
||||||
|
|
||||||
|
playlist = {
|
||||||
|
"id": int(playlistid),
|
||||||
|
"image": db_playlist.image,
|
||||||
|
"last_updated": create_new_date(),
|
||||||
|
"name": str(form.name).strip(),
|
||||||
|
"settings": settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
if image:
|
||||||
|
try:
|
||||||
|
pil_image = Image.open(image)
|
||||||
|
content_type = image.content_type
|
||||||
|
|
||||||
|
playlist["image"] = playlistlib.save_p_image(
|
||||||
|
pil_image, playlistid, content_type
|
||||||
|
)
|
||||||
|
|
||||||
|
if image.content_type == "image/gif":
|
||||||
|
playlist["settings"]["has_gif"] = True
|
||||||
|
|
||||||
|
except UnidentifiedImageError:
|
||||||
|
return {"error": "Failed: Invalid image"}, 400
|
||||||
|
|
||||||
|
p_tuple = (*playlist.values(),)
|
||||||
|
|
||||||
|
PlaylistTable.update_one(playlistid, playlist)
|
||||||
|
playlistlib.cleanup_playlist_images()
|
||||||
|
|
||||||
|
playlist = models.Playlist(*p_tuple)
|
||||||
|
playlist.last_updated = date_string_to_time_passed(playlist.last_updated)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data": playlist,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/<playlistid>/pin_unpin")
|
||||||
|
def pin_unpin_playlist(path: PlaylistIDPath):
|
||||||
|
"""
|
||||||
|
Pin playlist.
|
||||||
|
"""
|
||||||
|
playlist = PlaylistTable.get_by_id(path.playlistid)
|
||||||
|
|
||||||
|
if playlist is None:
|
||||||
|
return {"error": "Playlist not found"}, 404
|
||||||
|
|
||||||
|
settings = playlist.settings
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings["pinned"] = not settings["pinned"]
|
||||||
|
except KeyError:
|
||||||
|
settings["pinned"] = True
|
||||||
|
|
||||||
|
PlaylistTable.update_settings(path.playlistid, settings)
|
||||||
|
return {"msg": "Done"}, 200
|
||||||
|
|
||||||
|
|
||||||
|
@api.delete("/<playlistid>/remove-img")
|
||||||
|
def remove_playlist_image(path: PlaylistIDPath):
|
||||||
|
"""
|
||||||
|
Clear playlist image.
|
||||||
|
"""
|
||||||
|
playlist = PlaylistTable.get_by_id(path.playlistid)
|
||||||
|
|
||||||
|
if playlist is None:
|
||||||
|
return {"error": "Playlist not found"}, 404
|
||||||
|
|
||||||
|
PlaylistTable.remove_image(path.playlistid)
|
||||||
|
|
||||||
|
playlist.image = None
|
||||||
|
playlist.thumb = None
|
||||||
|
playlist.settings["has_gif"] = False
|
||||||
|
playlist.has_image = False
|
||||||
|
|
||||||
|
available_trackhashes = get_available_trackhashes(get_current_userid())
|
||||||
|
visible_trackhashes = [
|
||||||
|
trackhash
|
||||||
|
for trackhash in playlist.trackhashes
|
||||||
|
if trackhash in available_trackhashes
|
||||||
|
]
|
||||||
|
playlist.images = playlistlib.get_first_4_images(trackhashes=visible_trackhashes)
|
||||||
|
playlist.last_updated = date_string_to_time_passed(playlist.last_updated)
|
||||||
|
|
||||||
|
return {"playlist": playlist}, 200
|
||||||
|
|
||||||
|
|
||||||
|
@api.delete("/<playlistid>/delete", methods=["DELETE"])
|
||||||
|
def remove_playlist(path: PlaylistIDPath):
|
||||||
|
"""
|
||||||
|
Delete playlist
|
||||||
|
"""
|
||||||
|
PlaylistTable.remove_one(path.playlistid)
|
||||||
|
playlistlib.cleanup_playlist_images()
|
||||||
|
return {"msg": "Done"}, 200
|
||||||
|
|
||||||
|
|
||||||
|
class RemoveTracksFromPlaylistBody(BaseModel):
|
||||||
|
tracks: list[dict] = Field(..., description="A list of trackhashes to remove")
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/<playlistid>/remove-tracks")
|
||||||
|
def remove_tracks_from_playlist(
|
||||||
|
path: PlaylistIDPath, body: RemoveTracksFromPlaylistBody
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Remove track from playlist
|
||||||
|
"""
|
||||||
|
# A track looks like this:
|
||||||
|
# {
|
||||||
|
# trackhash: str;
|
||||||
|
# index: int;
|
||||||
|
# }
|
||||||
|
|
||||||
|
PlaylistTable.remove_from_playlist(path.playlistid, body.tracks)
|
||||||
|
|
||||||
|
return {"msg": "Done"}, 200
|
||||||
|
|
||||||
|
|
||||||
|
class SavePlaylistAsItemBody(BaseModel):
|
||||||
|
itemtype: str = Field(..., description="The type of item", example="tracks")
|
||||||
|
playlist_name: str = Field(..., description="The name of the playlist")
|
||||||
|
itemhash: str = Field(..., description="The hash of the item to save")
|
||||||
|
sortoptions: dict = Field(
|
||||||
|
default={},
|
||||||
|
description="The sort options for the tracks",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/save-item")
|
||||||
|
def save_item_as_playlist(body: SavePlaylistAsItemBody):
|
||||||
|
"""
|
||||||
|
Save as playlist
|
||||||
|
|
||||||
|
Saves a track, album, artist or folder as a playlist
|
||||||
|
"""
|
||||||
|
itemtype = body.itemtype
|
||||||
|
playlist_name = body.playlist_name
|
||||||
|
itemhash = body.itemhash
|
||||||
|
sortoptions = body.sortoptions
|
||||||
|
userid = get_current_userid()
|
||||||
|
|
||||||
|
if PlaylistTable.check_exists_by_name(playlist_name):
|
||||||
|
return {"error": "Playlist already exists"}, 409
|
||||||
|
|
||||||
|
if itemtype == "tracks":
|
||||||
|
trackhashes = filter_trackhashes_for_user(itemhash.split(","), userid=userid)
|
||||||
|
elif itemtype == "folder":
|
||||||
|
trackhashes = get_path_trackhashes(
|
||||||
|
itemhash,
|
||||||
|
sortoptions.get("tracksortby") or "default",
|
||||||
|
sortoptions.get("tracksortreverse") or False,
|
||||||
|
userid=userid,
|
||||||
|
)
|
||||||
|
elif itemtype == "album":
|
||||||
|
trackhashes = get_album_trackhashes(itemhash, userid=userid)
|
||||||
|
elif itemtype == "artist":
|
||||||
|
trackhashes = get_artist_trackhashes(itemhash, userid=userid)
|
||||||
|
else:
|
||||||
|
trackhashes = []
|
||||||
|
|
||||||
|
if len(trackhashes) == 0:
|
||||||
|
return {"error": "No tracks founds"}, 404
|
||||||
|
|
||||||
|
image = (
|
||||||
|
itemhash + ".webp" if itemtype != "folder" and itemtype != "tracks" else None
|
||||||
|
)
|
||||||
|
|
||||||
|
playlist = insert_playlist(playlist_name, image)
|
||||||
|
|
||||||
|
if playlist is None:
|
||||||
|
return {"error": "Playlist could not be created"}, 500
|
||||||
|
|
||||||
|
# save image
|
||||||
|
if itemtype != "folder" and itemtype != "tracks":
|
||||||
|
filename = itemhash + ".webp"
|
||||||
|
|
||||||
|
base_path = (
|
||||||
|
Paths().lg_artist_img_path
|
||||||
|
if itemtype == "artist"
|
||||||
|
else Paths().lg_thumb_path()
|
||||||
|
)
|
||||||
|
img_path = pathlib.Path(base_path + "/" + filename)
|
||||||
|
|
||||||
|
if img_path.exists():
|
||||||
|
img = Image.open(img_path)
|
||||||
|
playlistlib.save_p_image(
|
||||||
|
img, str(playlist.id), "image/webp", filename=filename
|
||||||
|
)
|
||||||
|
|
||||||
|
PlaylistTable.append_to_playlist(playlist.id, trackhashes)
|
||||||
|
playlist.count = len(trackhashes)
|
||||||
|
|
||||||
|
images = playlistlib.get_first_4_images(trackhashes=trackhashes)
|
||||||
|
playlist.images = [img["image"] for img in images]
|
||||||
|
|
||||||
|
return {"playlist": playlist}, 201
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from swingmusic.api.auth import admin_required
|
||||||
|
from swingmusic.config import UserConfig
|
||||||
|
from swingmusic.db.userdata import PluginTable
|
||||||
|
from swingmusic.plugins.lastfm import LastFmPlugin
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
|
||||||
|
bp_tag = Tag(name="Plugins", description="Manage plugins")
|
||||||
|
api = APIBlueprint("plugins", __name__, url_prefix="/plugins", abp_tags=[bp_tag])
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/")
|
||||||
|
def get_all_plugins():
|
||||||
|
"""
|
||||||
|
List all plugins
|
||||||
|
"""
|
||||||
|
plugins = PluginTable.get_all()
|
||||||
|
return {"plugins": plugins}
|
||||||
|
|
||||||
|
|
||||||
|
class PluginBody(BaseModel):
|
||||||
|
plugin: str = Field(description="The plugin name", example="lyrics")
|
||||||
|
|
||||||
|
|
||||||
|
class PluginActivateBody(PluginBody):
|
||||||
|
active: bool = Field(
|
||||||
|
description="New plugin active state", example=False, default=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/setactive")
|
||||||
|
@admin_required()
|
||||||
|
def activate_deactivate_plugin(body: PluginActivateBody):
|
||||||
|
"""
|
||||||
|
Activate/Deactivate plugin
|
||||||
|
"""
|
||||||
|
name = body.plugin
|
||||||
|
if name == "lyrics_finder" and not body.active:
|
||||||
|
# Lyrics retrieval is production-required and cannot be disabled.
|
||||||
|
return {"error": "lyrics_finder is always enabled"}, 400
|
||||||
|
|
||||||
|
PluginTable.activate(name, body.active)
|
||||||
|
|
||||||
|
return {"message": "OK"}, 200
|
||||||
|
|
||||||
|
|
||||||
|
class PluginSettingsBody(PluginBody):
|
||||||
|
settings: dict = Field(
|
||||||
|
description="The new plugin settings", example={"key": "value"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/settings")
|
||||||
|
@admin_required()
|
||||||
|
def update_plugin_settings(body: PluginSettingsBody):
|
||||||
|
"""
|
||||||
|
Update plugin settings
|
||||||
|
"""
|
||||||
|
plugin = body.plugin
|
||||||
|
settings = body.settings
|
||||||
|
|
||||||
|
if not plugin or not settings:
|
||||||
|
return {"error": "Missing plugin or settings"}, 400
|
||||||
|
|
||||||
|
if plugin == "lyrics_finder":
|
||||||
|
# Keep lyrics automation on regardless of client payload.
|
||||||
|
settings = {
|
||||||
|
**settings,
|
||||||
|
"auto_download": True,
|
||||||
|
"overide_unsynced": True,
|
||||||
|
}
|
||||||
|
PluginTable.activate(plugin, True)
|
||||||
|
|
||||||
|
PluginTable.update_settings(plugin, settings)
|
||||||
|
plugin = PluginTable.get_by_name(plugin)
|
||||||
|
|
||||||
|
return {"status": "success", "settings": plugin.settings}
|
||||||
|
|
||||||
|
|
||||||
|
class LastFmSessionBody(BaseModel):
|
||||||
|
token: str = Field(description="The token to use to create the session")
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/lastfm/session/create")
|
||||||
|
def create_lastfm_session(body: LastFmSessionBody):
|
||||||
|
"""
|
||||||
|
Create a Last.fm session
|
||||||
|
"""
|
||||||
|
if not body.token:
|
||||||
|
return {"error": "Missing token"}, 400
|
||||||
|
|
||||||
|
lastfm = LastFmPlugin(current_userid=get_current_userid())
|
||||||
|
session_key = lastfm.get_session_key(body.token)
|
||||||
|
|
||||||
|
if session_key:
|
||||||
|
config = UserConfig()
|
||||||
|
current_user = get_current_userid()
|
||||||
|
config.lastfmSessionKeys[str(current_user)] = session_key
|
||||||
|
config.lastfmSessionKeys = config.lastfmSessionKeys
|
||||||
|
|
||||||
|
return {"status": "success", "session_key": session_key}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/lastfm/session/delete")
|
||||||
|
def delete_lastfm_session():
|
||||||
|
"""
|
||||||
|
Delete the Last.fm session
|
||||||
|
"""
|
||||||
|
config = UserConfig()
|
||||||
|
current_user = get_current_userid()
|
||||||
|
config.lastfmSessionKeys[str(current_user)] = ""
|
||||||
|
config.lastfmSessionKeys = config.lastfmSessionKeys
|
||||||
|
|
||||||
|
return {"status": "success"}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from swingmusic.api.apischemas import TrackHashSchema
|
||||||
|
from swingmusic.lib.lyrics import Lyrics as Lyrics_class
|
||||||
|
from swingmusic.plugins.lyrics import Lyrics
|
||||||
|
from swingmusic.settings import Defaults
|
||||||
|
from swingmusic.utils.hashing import create_hash
|
||||||
|
|
||||||
|
bp_tag = Tag(name="Lyrics Plugin", description="Musixmatch lyrics plugin")
|
||||||
|
api = APIBlueprint(
|
||||||
|
"lyricsplugin", __name__, url_prefix="/plugins/lyrics", abp_tags=[bp_tag]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LyricsSearchBody(TrackHashSchema):
|
||||||
|
title: str = Field(description="The track title ", example=Defaults.API_TRACKNAME)
|
||||||
|
artist: str = Field(
|
||||||
|
description="The track artist ", example=Defaults.API_ARTISTNAME
|
||||||
|
)
|
||||||
|
album: str = Field(
|
||||||
|
description="The track track album ", example=Defaults.API_ALBUMNAME
|
||||||
|
)
|
||||||
|
filepath: str = Field(
|
||||||
|
description="Track filepath to save the lyrics file relative to",
|
||||||
|
example="/home/cwilvx/temp/crazy song.mp3",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/search")
|
||||||
|
def search_lyrics(body: LyricsSearchBody):
|
||||||
|
"""
|
||||||
|
Search for lyrics by title and artist
|
||||||
|
"""
|
||||||
|
title = body.title
|
||||||
|
artist = body.artist
|
||||||
|
album = body.album
|
||||||
|
filepath = body.filepath
|
||||||
|
trackhash = body.trackhash
|
||||||
|
|
||||||
|
finder = Lyrics()
|
||||||
|
data = finder.search_lyrics_by_title_and_artist(title, artist)
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return {"trackhash": trackhash, "lyrics": None}
|
||||||
|
|
||||||
|
perfect_match = data[0]
|
||||||
|
|
||||||
|
for track in data:
|
||||||
|
i_title = track["title"]
|
||||||
|
i_album = track["album"]
|
||||||
|
|
||||||
|
if create_hash(i_title) == create_hash(title) and create_hash(
|
||||||
|
i_album
|
||||||
|
) == create_hash(album):
|
||||||
|
perfect_match = track
|
||||||
|
|
||||||
|
track_id = perfect_match["track_id"]
|
||||||
|
lrc = finder.download_lyrics(track_id, filepath)
|
||||||
|
|
||||||
|
if lrc is not None:
|
||||||
|
lyrics = Lyrics_class(lrc)
|
||||||
|
if lyrics.is_synced:
|
||||||
|
formatted_lyrics = lyrics.format_synced_lyrics()
|
||||||
|
else:
|
||||||
|
formatted_lyrics = lyrics.format_unsynced_lyrics()
|
||||||
|
return {
|
||||||
|
"trackhash": trackhash,
|
||||||
|
"lyrics": formatted_lyrics,
|
||||||
|
"synced": lyrics.is_synced,
|
||||||
|
}, 200
|
||||||
|
|
||||||
|
return {"trackhash": trackhash, "lyrics": None, "synced": False}, 200
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from swingmusic.db.userdata import MixTable
|
||||||
|
from swingmusic.plugins.mixes import MixesPlugin
|
||||||
|
from swingmusic.store.homepage import HomepageStore
|
||||||
|
|
||||||
|
bp_tag = Tag(name="Mixes Plugin", description="Mixes plugin hehe")
|
||||||
|
api = APIBlueprint(
|
||||||
|
"mixesplugin", __name__, url_prefix="/plugins/mixes", abp_tags=[bp_tag]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GetMixesBody(BaseModel):
|
||||||
|
mixtype: Literal["artists", "tracks"] = Field(description="The type of mix")
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/<mixtype>")
|
||||||
|
def get_artist_mixes(path: GetMixesBody):
|
||||||
|
srcmixes = MixTable.get_all(with_userid=True)
|
||||||
|
mixes = []
|
||||||
|
|
||||||
|
if path.mixtype == "artists":
|
||||||
|
mixes = [mix.to_dict(convert_timestamp=True) for mix in srcmixes]
|
||||||
|
elif path.mixtype == "tracks":
|
||||||
|
plugin = MixesPlugin()
|
||||||
|
|
||||||
|
for mix in srcmixes:
|
||||||
|
custom_mix = plugin.get_track_mix(mix)
|
||||||
|
if custom_mix:
|
||||||
|
mixes.append(custom_mix.to_dict(convert_timestamp=True))
|
||||||
|
|
||||||
|
seen_mixids = set()
|
||||||
|
|
||||||
|
# filter duplicates by trackshash
|
||||||
|
final_mixes = []
|
||||||
|
for mix in mixes:
|
||||||
|
# INFO: Ignore duplicates for artist mixes
|
||||||
|
if mix["id"] in seen_mixids and path.mixtype == "tracks":
|
||||||
|
continue
|
||||||
|
|
||||||
|
final_mixes.append(mix)
|
||||||
|
seen_mixids.add(mix["id"])
|
||||||
|
|
||||||
|
return final_mixes
|
||||||
|
|
||||||
|
|
||||||
|
class MixQuery(BaseModel):
|
||||||
|
mixid: str = Field(description="The mix id")
|
||||||
|
sourcehash: str = Field(description="The sourcehash of the mix")
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/")
|
||||||
|
def get_mix(query: MixQuery):
|
||||||
|
mixtype = ""
|
||||||
|
|
||||||
|
match query.mixid[0]:
|
||||||
|
case "a":
|
||||||
|
mixtype = "artist_mixes"
|
||||||
|
case "t":
|
||||||
|
mixtype = "custom_mixes"
|
||||||
|
case _:
|
||||||
|
return {"msg": "Invalid mix ID"}, 400
|
||||||
|
|
||||||
|
# INFO: Check if the mix is already in the homepage store
|
||||||
|
mix = HomepageStore.get_mix(mixtype, query.mixid)
|
||||||
|
if mix and mix["sourcehash"] == query.sourcehash:
|
||||||
|
return mix, 200
|
||||||
|
|
||||||
|
# INF0: Get the mix from the db
|
||||||
|
mix = MixTable.get_by_sourcehash(query.sourcehash)
|
||||||
|
|
||||||
|
if not mix:
|
||||||
|
return {"msg": "Mix not found"}, 404
|
||||||
|
|
||||||
|
if mixtype == "custom_mixes":
|
||||||
|
mix = MixesPlugin.get_track_mix(mix)
|
||||||
|
|
||||||
|
if not mix:
|
||||||
|
return {"msg": "Mix not found"}, 404
|
||||||
|
|
||||||
|
return mix.to_full_dict(), 200
|
||||||
|
|
||||||
|
|
||||||
|
class SaveMixRequest(BaseModel):
|
||||||
|
mixid: str = Field(description="The id of the mix")
|
||||||
|
type: str = Field(description="The type of mix")
|
||||||
|
sourcehash: str = Field(description="The sourcehash of the mix")
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/save")
|
||||||
|
def save_mix(body: SaveMixRequest):
|
||||||
|
mix_type = body.type
|
||||||
|
mix_sourcehash = body.sourcehash
|
||||||
|
|
||||||
|
if mix_type == "artist":
|
||||||
|
state = MixTable.save_artist_mix(mix_sourcehash)
|
||||||
|
elif mix_type == "track":
|
||||||
|
state = MixTable.save_track_mix(mix_sourcehash)
|
||||||
|
|
||||||
|
mix = HomepageStore.find_mix(body.mixid)
|
||||||
|
|
||||||
|
if mix:
|
||||||
|
mix.saved = state
|
||||||
|
return {"msg": "Mixes saved"}, 200
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Year-in-review recap endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from swingmusic.services.recap_store import recap_store
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
|
||||||
|
recap_bp = Blueprint("recap", __name__, url_prefix="/api/recap")
|
||||||
|
|
||||||
|
|
||||||
|
def _user_id() -> int:
|
||||||
|
return int(get_current_userid())
|
||||||
|
|
||||||
|
|
||||||
|
def _error(message: str, status: int = 400):
|
||||||
|
return jsonify({"error": message, "message": message}), status
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_year(year: int) -> bool:
|
||||||
|
now_year = dt.datetime.now(dt.UTC).year
|
||||||
|
return 2000 <= int(year) <= now_year + 1
|
||||||
|
|
||||||
|
|
||||||
|
@recap_bp.get("/available-years")
|
||||||
|
def available_years():
|
||||||
|
years = recap_store.get_available_years(_user_id())
|
||||||
|
return jsonify({"available_years": years, "total_recaps": len(years)})
|
||||||
|
|
||||||
|
|
||||||
|
@recap_bp.get("/summary/<int:year>")
|
||||||
|
def summary(year: int):
|
||||||
|
if not _validate_year(year):
|
||||||
|
return _error("Invalid year")
|
||||||
|
|
||||||
|
recap = recap_store.get_summary(_user_id(), year)
|
||||||
|
return jsonify({"year": year, "recap": recap})
|
||||||
|
|
||||||
|
|
||||||
|
@recap_bp.get("/details/<int:year>")
|
||||||
|
def details(year: int):
|
||||||
|
if not _validate_year(year):
|
||||||
|
return _error("Invalid year")
|
||||||
|
|
||||||
|
recap = recap_store.get_recap(_user_id(), year, generate_if_missing=False)
|
||||||
|
return jsonify({"year": year, "recap": recap})
|
||||||
|
|
||||||
|
|
||||||
|
@recap_bp.post("/generate/<int:year>")
|
||||||
|
def generate(year: int):
|
||||||
|
if not _validate_year(year):
|
||||||
|
return _error("Invalid year")
|
||||||
|
|
||||||
|
recap = recap_store.generate_recap(_user_id(), year)
|
||||||
|
if not recap:
|
||||||
|
return _error("No listening data available for this year", 404)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"message": "Recap generated successfully",
|
||||||
|
"year": year,
|
||||||
|
"recap": recap,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@recap_bp.post("/video/<int:year>")
|
||||||
|
def generate_video(year: int):
|
||||||
|
if not _validate_year(year):
|
||||||
|
return _error("Invalid year")
|
||||||
|
|
||||||
|
recap = recap_store.get_recap(_user_id(), year, generate_if_missing=True)
|
||||||
|
if not recap:
|
||||||
|
return _error("No listening data available for this year", 404)
|
||||||
|
|
||||||
|
options = request.get_json(silent=True) or {}
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"message": "Video generation queued",
|
||||||
|
"year": year,
|
||||||
|
"video_status": "queued",
|
||||||
|
"options": options,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@recap_bp.post("/share/<int:year>")
|
||||||
|
def share(year: int):
|
||||||
|
if not _validate_year(year):
|
||||||
|
return _error("Invalid year")
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
include_personal_data = bool(
|
||||||
|
payload.get("includePersonalData", payload.get("include_personal_data", False))
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
expires_in_days = int(
|
||||||
|
payload.get("expiresInDays", payload.get("expires_in_days", 30))
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
expires_in_days = 30
|
||||||
|
|
||||||
|
share_data = recap_store.create_share_link(
|
||||||
|
user_id=_user_id(),
|
||||||
|
year=year,
|
||||||
|
include_personal_data=include_personal_data,
|
||||||
|
expires_in_days=expires_in_days,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not share_data:
|
||||||
|
return _error("Unable to create share link", 404)
|
||||||
|
|
||||||
|
return jsonify(share_data)
|
||||||
|
|
||||||
|
|
||||||
|
@recap_bp.get("/shared/<token>")
|
||||||
|
def shared(token: str):
|
||||||
|
shared_recap = recap_store.get_shared_recap(token)
|
||||||
|
if not shared_recap:
|
||||||
|
return _error("Shared recap not found or expired", 404)
|
||||||
|
|
||||||
|
return jsonify(shared_recap)
|
||||||
|
|
||||||
|
|
||||||
|
@recap_bp.get("/compare/<int:year1>/<int:year2>")
|
||||||
|
def compare(year1: int, year2: int):
|
||||||
|
if not _validate_year(year1) or not _validate_year(year2):
|
||||||
|
return _error("Invalid year")
|
||||||
|
|
||||||
|
if year1 == year2:
|
||||||
|
return _error("Year values must be different")
|
||||||
|
|
||||||
|
comparison = recap_store.compare_years(_user_id(), year1, year2)
|
||||||
|
if not comparison:
|
||||||
|
return _error("Comparison unavailable for selected years", 404)
|
||||||
|
|
||||||
|
return jsonify({"years": [year1, year2], "comparison": comparison})
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""
|
||||||
|
Recently Played API endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
|
||||||
|
from swingmusic.api.apischemas import GenericLimitSchema
|
||||||
|
from swingmusic.services.recently_played_buffer import get_recently_played_buffer
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
|
||||||
|
tag = Tag(name="Recently Played", description="Recently played tracks")
|
||||||
|
api = APIBlueprint(
|
||||||
|
"recently_played", __name__, url_prefix="/recently-played", abp_tags=[tag]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RecentlyPlayedQuery(GenericLimitSchema):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("")
|
||||||
|
def get_recently_played(query: RecentlyPlayedQuery):
|
||||||
|
"""
|
||||||
|
Get recently played tracks for the current user.
|
||||||
|
|
||||||
|
Returns tracks from the DragonflyDB buffer for instant access.
|
||||||
|
"""
|
||||||
|
userid = get_current_userid()
|
||||||
|
limit = query.limit if query.limit > 0 else 20
|
||||||
|
|
||||||
|
buffer = get_recently_played_buffer()
|
||||||
|
tracks = buffer.get_recent_tracks(userid, limit=limit)
|
||||||
|
|
||||||
|
return {"tracks": tracks}
|
||||||
|
|
||||||
|
|
||||||
|
@api.delete("")
|
||||||
|
def clear_recently_played():
|
||||||
|
"""
|
||||||
|
Clear the recently played buffer for the current user.
|
||||||
|
"""
|
||||||
|
userid = get_current_userid()
|
||||||
|
|
||||||
|
buffer = get_recently_played_buffer()
|
||||||
|
success = buffer.clear_buffer(userid)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return {"success": True, "message": "Recently played history cleared"}
|
||||||
|
else:
|
||||||
|
return {"success": False, "message": "Failed to clear history"}, 500
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
import locale
|
||||||
|
import logging
|
||||||
|
from gettext import ngettext
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import pendulum
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from swingmusic.api.apischemas import TrackHashSchema
|
||||||
|
from swingmusic.config import UserConfig
|
||||||
|
|
||||||
|
# DragonflyDB integration for real-time features
|
||||||
|
from swingmusic.db.dragonfly_extended_client import get_realtime_service
|
||||||
|
from swingmusic.db.userdata import FavoritesTable, ScrobbleTable
|
||||||
|
from swingmusic.lib.extras import get_extra_info
|
||||||
|
from swingmusic.lib.recipes.recents import RecentlyPlayed
|
||||||
|
from swingmusic.models.album import Album
|
||||||
|
from swingmusic.models.stats import StatItem
|
||||||
|
from swingmusic.models.track import Track
|
||||||
|
from swingmusic.plugins.lastfm import LastFmPlugin
|
||||||
|
from swingmusic.serializers.album import serialize_for_card as serialize_for_album_card
|
||||||
|
from swingmusic.serializers.artist import serialize_for_card
|
||||||
|
from swingmusic.serializers.track import serialize_track
|
||||||
|
from swingmusic.services.recently_played_buffer import get_recently_played_buffer
|
||||||
|
from swingmusic.services.user_library_scope import get_available_trackhashes
|
||||||
|
from swingmusic.settings import Defaults
|
||||||
|
from swingmusic.store.albums import AlbumStore
|
||||||
|
from swingmusic.store.artists import ArtistStore
|
||||||
|
from swingmusic.store.tracks import TrackStore
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
from swingmusic.utils.dates import (
|
||||||
|
get_date_range,
|
||||||
|
get_duration_in_seconds,
|
||||||
|
seconds_to_time_string,
|
||||||
|
)
|
||||||
|
from swingmusic.utils.stats import (
|
||||||
|
calculate_album_trend,
|
||||||
|
calculate_artist_trend,
|
||||||
|
calculate_new_albums,
|
||||||
|
calculate_new_artists,
|
||||||
|
calculate_scrobble_trend,
|
||||||
|
calculate_track_trend,
|
||||||
|
get_albums_in_period,
|
||||||
|
get_artists_in_period,
|
||||||
|
get_tracks_in_period,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
bp_tag = Tag(name="Logger", description="Log item plays")
|
||||||
|
api = APIBlueprint("logger", __name__, url_prefix="/logger", abp_tags=[bp_tag])
|
||||||
|
|
||||||
|
|
||||||
|
class LogTrackBody(TrackHashSchema):
|
||||||
|
timestamp: int = Field(description="The timestamp of the track")
|
||||||
|
duration: int = Field(description="The duration of the track in seconds")
|
||||||
|
source: str = Field(
|
||||||
|
description="The play source of the track",
|
||||||
|
json_schema_extra={
|
||||||
|
"examples": [
|
||||||
|
f"al:{Defaults.API_ALBUMHASH}",
|
||||||
|
f"tr:{Defaults.API_TRACKHASH}",
|
||||||
|
f"ar:{Defaults.API_ARTISTHASH}",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_date(start: float, end: float):
|
||||||
|
return f"{pendulum.from_timestamp(start).format('MMM D, YYYY')} - {pendulum.from_timestamp(end).format('MMM D, YYYY')}"
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/track/log")
|
||||||
|
def log_track(body: LogTrackBody):
|
||||||
|
"""
|
||||||
|
Log a track play to the database.
|
||||||
|
"""
|
||||||
|
timestamp = body.timestamp
|
||||||
|
duration = body.duration
|
||||||
|
|
||||||
|
if not timestamp or duration < 5:
|
||||||
|
return {"msg": "Invalid entry."}, 400
|
||||||
|
|
||||||
|
trackentry = TrackStore.trackhashmap.get(body.trackhash)
|
||||||
|
if trackentry is None:
|
||||||
|
return {"msg": "Track not found."}, 404
|
||||||
|
|
||||||
|
scrobble_data = dict(body)
|
||||||
|
# REVIEW: Do we need to store the extra info in the database?
|
||||||
|
# OR .... can we just write it to the backup file on demand?
|
||||||
|
scrobble_data["extra"] = get_extra_info(body.trackhash, "track")
|
||||||
|
ScrobbleTable.add(scrobble_data)
|
||||||
|
|
||||||
|
# NOTE: Update the recently played homepage for this userid
|
||||||
|
RecentlyPlayed(userid=scrobble_data["userid"])
|
||||||
|
|
||||||
|
# Update play data on the in-memory stores
|
||||||
|
track = trackentry.tracks[0]
|
||||||
|
album = AlbumStore.albummap.get(track.albumhash)
|
||||||
|
|
||||||
|
if album:
|
||||||
|
album.increment_playcount(duration, timestamp)
|
||||||
|
|
||||||
|
for hash in track.artisthashes:
|
||||||
|
artist = ArtistStore.artistmap.get(hash)
|
||||||
|
|
||||||
|
if artist:
|
||||||
|
artist.increment_playcount(duration, timestamp)
|
||||||
|
|
||||||
|
trackentry.increment_playcount(duration, timestamp)
|
||||||
|
track = trackentry.tracks[0]
|
||||||
|
|
||||||
|
# Update DragonflyDB real-time features (non-blocking, fast)
|
||||||
|
realtime = get_realtime_service()
|
||||||
|
if realtime.playcount_cache.client.is_available():
|
||||||
|
try:
|
||||||
|
userid = get_current_userid()
|
||||||
|
# Increment global playcount for track
|
||||||
|
realtime.increment_playcount(body.trackhash)
|
||||||
|
# Increment user-specific playcount
|
||||||
|
realtime.increment_playcount(body.trackhash, userid=userid)
|
||||||
|
# Add to recently played list
|
||||||
|
realtime.add_to_recently_played(userid, body.trackhash)
|
||||||
|
logger.debug(f"Updated real-time play stats for track {body.trackhash}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to update real-time stats: {e}")
|
||||||
|
|
||||||
|
# Update recently played buffer for instant access
|
||||||
|
recently_played = get_recently_played_buffer()
|
||||||
|
if recently_played.client.is_available():
|
||||||
|
try:
|
||||||
|
userid = get_current_userid()
|
||||||
|
track = trackentry.tracks[0]
|
||||||
|
recently_played.add_track(
|
||||||
|
userid,
|
||||||
|
{
|
||||||
|
"trackhash": track.trackhash,
|
||||||
|
"title": track.title,
|
||||||
|
"artist": track.artists[0] if track.artists else "Unknown Artist",
|
||||||
|
"album": track.album,
|
||||||
|
"albumhash": track.albumhash,
|
||||||
|
"duration": track.duration,
|
||||||
|
"image": track.image,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to update recently played buffer: {e}")
|
||||||
|
|
||||||
|
lastfm = LastFmPlugin(current_userid=get_current_userid())
|
||||||
|
|
||||||
|
if (
|
||||||
|
lastfm.enabled
|
||||||
|
and track.duration > 30
|
||||||
|
and body.duration >= min(track.duration / 2, 240)
|
||||||
|
# SEE: https://www.last.fm/api/scrobbling#when-is-a-scrobble-a-scrobble
|
||||||
|
):
|
||||||
|
lastfm.scrobble(trackentry.tracks[0], timestamp)
|
||||||
|
|
||||||
|
return {"msg": "recorded"}, 201
|
||||||
|
|
||||||
|
|
||||||
|
class ChartItemsQuery(BaseModel):
|
||||||
|
duration: Literal["week", "month", "year", "alltime"] = Field(
|
||||||
|
"year",
|
||||||
|
description="Duration to fetch data for",
|
||||||
|
)
|
||||||
|
limit: int = Field(10, description="Number of top tracks to return")
|
||||||
|
order_by: Literal["playcount", "playduration"] = Field(
|
||||||
|
"playduration", description="Property to order by"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# SECTION: STATS
|
||||||
|
|
||||||
|
|
||||||
|
def get_help_text(
|
||||||
|
playcount: int, playduration: int, order_by: Literal["playcount", "playduration"]
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get the help text given the playcount and playduration.
|
||||||
|
"""
|
||||||
|
if order_by == "playcount":
|
||||||
|
if playcount == 0:
|
||||||
|
return "unplayed"
|
||||||
|
|
||||||
|
return f"{playcount} play{'' if playcount == 1 else 's'}"
|
||||||
|
if order_by == "playduration":
|
||||||
|
return seconds_to_time_string(playduration)
|
||||||
|
|
||||||
|
|
||||||
|
# DISCLAIMER: Code beyond this point was partially written by Claude 3.5 Sonnet in Cursor.
|
||||||
|
# The stats functions are organized by type (tracks, artists, albums) and follow
|
||||||
|
# a consistent pattern for calculating trends and aggregating play data.
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/top-tracks")
|
||||||
|
def get_top_tracks(query: ChartItemsQuery):
|
||||||
|
"""
|
||||||
|
Get the top N tracks played within a given duration.
|
||||||
|
"""
|
||||||
|
start_time, end_time = get_date_range(query.duration)
|
||||||
|
previous_start_time = start_time - get_duration_in_seconds(query.duration)
|
||||||
|
|
||||||
|
current_period_tracks, current_period_scrobbles, duration = get_tracks_in_period(
|
||||||
|
start_time, end_time
|
||||||
|
)
|
||||||
|
previous_period_tracks, previous_period_scrobbles, _ = get_tracks_in_period(
|
||||||
|
previous_start_time, start_time
|
||||||
|
)
|
||||||
|
scrobble_trend = (
|
||||||
|
"rising"
|
||||||
|
if current_period_scrobbles > previous_period_scrobbles
|
||||||
|
else (
|
||||||
|
"falling"
|
||||||
|
if current_period_scrobbles < previous_period_scrobbles
|
||||||
|
else "stable"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
sorted_tracks = sort_tracks(current_period_tracks, query.order_by)
|
||||||
|
top_tracks = sorted_tracks[: query.limit]
|
||||||
|
|
||||||
|
response = []
|
||||||
|
for track in top_tracks:
|
||||||
|
trend = calculate_track_trend(
|
||||||
|
track, current_period_tracks, previous_period_tracks
|
||||||
|
)
|
||||||
|
track = {
|
||||||
|
**serialize_track(track),
|
||||||
|
"trend": trend,
|
||||||
|
"help_text": get_help_text(
|
||||||
|
track.playcount, track.playduration, query.order_by
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
response.append(track)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tracks": response,
|
||||||
|
"scrobbles": {
|
||||||
|
"text": f"{current_period_scrobbles} total play{'' if current_period_scrobbles == 1 else 's'} ({seconds_to_time_string(duration)})",
|
||||||
|
"trend": scrobble_trend,
|
||||||
|
"dates": format_date(start_time, end_time),
|
||||||
|
},
|
||||||
|
}, 200
|
||||||
|
|
||||||
|
|
||||||
|
def sort_tracks(tracks: list[Track], order_by: Literal["playcount", "playduration"]):
|
||||||
|
return sorted(tracks, key=lambda x: getattr(x, order_by), reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/top-artists")
|
||||||
|
def get_top_artists(query: ChartItemsQuery):
|
||||||
|
"""
|
||||||
|
Get the top N artists played within a given duration.
|
||||||
|
"""
|
||||||
|
start_time, end_time = get_date_range(query.duration)
|
||||||
|
previous_start_time = start_time - get_duration_in_seconds(query.duration)
|
||||||
|
|
||||||
|
current_period_artists = get_artists_in_period(start_time, end_time)
|
||||||
|
previous_period_artists = get_artists_in_period(previous_start_time, start_time)
|
||||||
|
|
||||||
|
new_artists = calculate_new_artists(current_period_artists, start_time)
|
||||||
|
scrobble_trend = calculate_scrobble_trend(
|
||||||
|
len(current_period_artists), len(previous_period_artists)
|
||||||
|
)
|
||||||
|
|
||||||
|
sorted_artists = sort_artists(current_period_artists, query.order_by)
|
||||||
|
top_artists = sorted_artists[: query.limit]
|
||||||
|
|
||||||
|
response = []
|
||||||
|
for artist in top_artists:
|
||||||
|
trend = calculate_artist_trend(
|
||||||
|
artist, current_period_artists, previous_period_artists
|
||||||
|
)
|
||||||
|
db_artist = ArtistStore.get_artist_by_hash(artist["artisthash"])
|
||||||
|
|
||||||
|
if db_artist is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
artist = {
|
||||||
|
**serialize_for_card(db_artist),
|
||||||
|
"trend": trend,
|
||||||
|
"help_text": get_help_text(
|
||||||
|
artist["playcount"], artist["playduration"], query.order_by
|
||||||
|
),
|
||||||
|
"extra": {
|
||||||
|
"playcount": artist["playcount"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response.append(artist)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"artists": response,
|
||||||
|
"scrobbles": {
|
||||||
|
"text": f"{new_artists} {'new' if query.duration != 'alltime' else ''} {ngettext('artist', 'artists', new_artists)}",
|
||||||
|
"trend": scrobble_trend,
|
||||||
|
"dates": format_date(start_time, end_time),
|
||||||
|
},
|
||||||
|
}, 200
|
||||||
|
|
||||||
|
|
||||||
|
def sort_artists(artists, order_by):
|
||||||
|
return sorted(artists, key=lambda x: x[order_by], reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/top-albums")
|
||||||
|
def get_top_albums(query: ChartItemsQuery):
|
||||||
|
"""
|
||||||
|
Get the top N albums played within a given duration.
|
||||||
|
"""
|
||||||
|
start_time, end_time = get_date_range(query.duration)
|
||||||
|
previous_start_time = start_time - get_duration_in_seconds(query.duration)
|
||||||
|
|
||||||
|
current_period_albums = get_albums_in_period(start_time, end_time)
|
||||||
|
previous_period_albums = get_albums_in_period(previous_start_time, start_time)
|
||||||
|
|
||||||
|
new_albums = calculate_new_albums(current_period_albums, previous_period_albums)
|
||||||
|
scrobble_trend = calculate_scrobble_trend(
|
||||||
|
len(current_period_albums), len(previous_period_albums)
|
||||||
|
)
|
||||||
|
|
||||||
|
sorted_albums = sort_albums(current_period_albums, query.order_by)
|
||||||
|
top_albums = sorted_albums[: query.limit]
|
||||||
|
|
||||||
|
response = []
|
||||||
|
for album in top_albums:
|
||||||
|
trend = calculate_album_trend(
|
||||||
|
album, current_period_albums, previous_period_albums
|
||||||
|
)
|
||||||
|
album = {
|
||||||
|
**serialize_for_album_card(album),
|
||||||
|
"trend": trend,
|
||||||
|
"help_text": get_help_text(
|
||||||
|
album.playcount, album.playduration, query.order_by
|
||||||
|
),
|
||||||
|
}
|
||||||
|
response.append(album)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"albums": response,
|
||||||
|
"scrobbles": {
|
||||||
|
"text": f"{new_albums} new album{'' if new_albums == 1 else 's'} played",
|
||||||
|
"trend": scrobble_trend,
|
||||||
|
"dates": format_date(start_time, end_time),
|
||||||
|
},
|
||||||
|
}, 200
|
||||||
|
|
||||||
|
|
||||||
|
def sort_albums(albums: list[Album], order_by: Literal["playcount", "playduration"]):
|
||||||
|
return sorted(albums, key=lambda x: getattr(x, order_by), reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/stats")
|
||||||
|
def get_stats():
|
||||||
|
"""
|
||||||
|
Get the stats for the user.
|
||||||
|
"""
|
||||||
|
period = "week"
|
||||||
|
start_time, end_time = get_date_range(period)
|
||||||
|
|
||||||
|
said_period = period
|
||||||
|
match period:
|
||||||
|
case "week":
|
||||||
|
said_period = "this week"
|
||||||
|
case "month":
|
||||||
|
said_period = "this month"
|
||||||
|
case "year":
|
||||||
|
said_period = "this year"
|
||||||
|
case "alltime":
|
||||||
|
said_period = "all time"
|
||||||
|
|
||||||
|
count = len(get_available_trackhashes(get_current_userid()))
|
||||||
|
total_tracks = StatItem(
|
||||||
|
"trackcount",
|
||||||
|
"in your library",
|
||||||
|
locale.format_string("%d", count, grouping=True)
|
||||||
|
+ " "
|
||||||
|
+ ngettext("track", "tracks", count),
|
||||||
|
)
|
||||||
|
|
||||||
|
tracks, playcount, playduration = get_tracks_in_period(start_time, end_time)
|
||||||
|
|
||||||
|
playcount = StatItem(
|
||||||
|
"streams",
|
||||||
|
said_period,
|
||||||
|
f"{playcount} track {ngettext('play', 'plays', playcount)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
playduration = StatItem(
|
||||||
|
"playtime",
|
||||||
|
said_period,
|
||||||
|
f"{seconds_to_time_string(playduration)} listened",
|
||||||
|
)
|
||||||
|
|
||||||
|
tracks = sorted(tracks, key=lambda t: t.playduration, reverse=True)
|
||||||
|
|
||||||
|
# Find the top track from the last 7 days
|
||||||
|
top_track = StatItem(
|
||||||
|
"toptrack",
|
||||||
|
f"Top track {said_period}",
|
||||||
|
(
|
||||||
|
tracks[0].title + " - " + tracks[0].artists[0]["name"]
|
||||||
|
if len(tracks) > 0
|
||||||
|
else "—"
|
||||||
|
),
|
||||||
|
(tracks[0].image if len(tracks) > 0 else None),
|
||||||
|
)
|
||||||
|
|
||||||
|
fav_count = FavoritesTable.count_favs_in_period(start_time, end_time)
|
||||||
|
favorites = StatItem(
|
||||||
|
"favorites",
|
||||||
|
said_period,
|
||||||
|
f"{fav_count} {'new' if period != 'alltime' else ''} favorite{'' if fav_count == 1 else 's'}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"stats": [
|
||||||
|
top_track,
|
||||||
|
playcount,
|
||||||
|
playduration,
|
||||||
|
favorites,
|
||||||
|
total_tracks,
|
||||||
|
],
|
||||||
|
"dates": format_date(start_time, end_time),
|
||||||
|
}, 200
|
||||||
|
|
||||||
|
|
||||||
|
class LastFmConnectBody(BaseModel):
|
||||||
|
token: str = Field(description="Last.fm auth token")
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/lastfm/status")
|
||||||
|
def get_lastfm_status():
|
||||||
|
"""
|
||||||
|
Get user-scoped Last.fm integration status.
|
||||||
|
"""
|
||||||
|
userid = get_current_userid()
|
||||||
|
config = UserConfig()
|
||||||
|
session_key = config.lastfmSessionKeys.get(str(userid), "")
|
||||||
|
plugin = LastFmPlugin(current_userid=userid)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"connected": bool(session_key),
|
||||||
|
"session_key_set": bool(session_key),
|
||||||
|
"enabled": bool(plugin.enabled),
|
||||||
|
"userid": userid,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/lastfm/connect")
|
||||||
|
def connect_lastfm(body: LastFmConnectBody):
|
||||||
|
"""
|
||||||
|
Connect Last.fm for current user.
|
||||||
|
"""
|
||||||
|
if not body.token:
|
||||||
|
return {"error": "Missing token"}, 400
|
||||||
|
|
||||||
|
userid = get_current_userid()
|
||||||
|
lastfm = LastFmPlugin(current_userid=userid)
|
||||||
|
session_key = lastfm.get_session_key(body.token)
|
||||||
|
|
||||||
|
if not session_key:
|
||||||
|
return {"error": "Failed to create Last.fm session"}, 400
|
||||||
|
|
||||||
|
config = UserConfig()
|
||||||
|
config.lastfmSessionKeys[str(userid)] = session_key
|
||||||
|
config.lastfmSessionKeys = config.lastfmSessionKeys
|
||||||
|
|
||||||
|
return {
|
||||||
|
"connected": True,
|
||||||
|
"session_key_set": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/lastfm/disconnect")
|
||||||
|
def disconnect_lastfm():
|
||||||
|
"""
|
||||||
|
Disconnect Last.fm for current user.
|
||||||
|
"""
|
||||||
|
userid = get_current_userid()
|
||||||
|
config = UserConfig()
|
||||||
|
config.lastfmSessionKeys[str(userid)] = ""
|
||||||
|
config.lastfmSessionKeys = config.lastfmSessionKeys
|
||||||
|
|
||||||
|
return {
|
||||||
|
"connected": False,
|
||||||
|
"session_key_set": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/lastfm/sync")
|
||||||
|
def sync_lastfm_status():
|
||||||
|
"""
|
||||||
|
Returns lightweight sync capability status for current user.
|
||||||
|
"""
|
||||||
|
userid = get_current_userid()
|
||||||
|
config = UserConfig()
|
||||||
|
connected = bool(config.lastfmSessionKeys.get(str(userid), ""))
|
||||||
|
scrobbles = list(ScrobbleTable.get_all(0, 1, userid=userid))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"connected": connected,
|
||||||
|
"can_sync": connected and len(scrobbles) > 0,
|
||||||
|
"latest_local_scrobble": scrobbles[0].timestamp if scrobbles else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
"""
|
||||||
|
Contains all the search routes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from pydantic import Field
|
||||||
|
from unidecode import unidecode
|
||||||
|
|
||||||
|
from swingmusic import models
|
||||||
|
from swingmusic.api.apischemas import GenericLimitSchema
|
||||||
|
|
||||||
|
# DragonflyDB integration for search caching
|
||||||
|
from swingmusic.db.dragonfly_extended_client import get_search_cache_service
|
||||||
|
from swingmusic.lib import searchlib
|
||||||
|
from swingmusic.serializers.artist import serialize_for_cards
|
||||||
|
from swingmusic.services.user_library_scope import (
|
||||||
|
get_available_trackhashes,
|
||||||
|
get_visible_albums,
|
||||||
|
get_visible_artists,
|
||||||
|
)
|
||||||
|
from swingmusic.settings import Defaults
|
||||||
|
from swingmusic.store.tracks import TrackStore
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
tag = Tag(name="Search", description="Search for tracks, albums and artists")
|
||||||
|
api = APIBlueprint("search", __name__, url_prefix="/search", abp_tags=[tag])
|
||||||
|
|
||||||
|
SEARCH_COUNT = 30
|
||||||
|
"""
|
||||||
|
The max amount of items to return per request
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class SearchQuery(GenericLimitSchema):
|
||||||
|
q: str = Field(
|
||||||
|
description="The search query",
|
||||||
|
json_schema_extra={"example": "Fleetwood Mac"},
|
||||||
|
)
|
||||||
|
start: int = Field(description="The index to start from", default=0)
|
||||||
|
limit: int = Field(
|
||||||
|
description="The number of items to return", default=SEARCH_COUNT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TopResultsQuery(SearchQuery):
|
||||||
|
limit: int = Field(
|
||||||
|
description="The number of items to return", default=Defaults.API_CARD_LIMIT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchLoadMoreQuery(SearchQuery):
|
||||||
|
itemtype: Literal["tracks", "albums", "artists"] = Field(
|
||||||
|
description="The type of search",
|
||||||
|
json_schema_extra={"example": "tracks"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Search:
|
||||||
|
def __init__(self, query: str) -> None:
|
||||||
|
self.tracks: list[models.Track] = []
|
||||||
|
self.query = unidecode(query)
|
||||||
|
|
||||||
|
def search_tracks(self):
|
||||||
|
"""
|
||||||
|
Calls :class:`SearchTracks` which returns the tracks that fuzzily match
|
||||||
|
the search terms. Then adds them to the `SearchResults` store.
|
||||||
|
"""
|
||||||
|
self.tracks = TrackStore.get_flat_list()
|
||||||
|
return searchlib.TopResults().search(self.query, tracks_only=True)
|
||||||
|
|
||||||
|
def search_artists(self):
|
||||||
|
"""Calls :class:`SearchArtists` which returns the artists that fuzzily match
|
||||||
|
the search term. Then adds them to the `SearchResults` store.
|
||||||
|
"""
|
||||||
|
artists = searchlib.SearchArtists(self.query)()
|
||||||
|
return serialize_for_cards(artists)
|
||||||
|
|
||||||
|
def search_albums(self):
|
||||||
|
"""Calls :class:`SearchAlbums` which returns the albums that fuzzily match
|
||||||
|
the search term. Then adds them to the `SearchResults` store.
|
||||||
|
"""
|
||||||
|
return searchlib.TopResults().search(self.query, albums_only=True)
|
||||||
|
|
||||||
|
def get_top_results(
|
||||||
|
self,
|
||||||
|
limit: int,
|
||||||
|
):
|
||||||
|
finder = searchlib.TopResults()
|
||||||
|
return finder.search(self.query, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_visible_hash_sets(userid: int):
|
||||||
|
return {
|
||||||
|
"tracks": get_available_trackhashes(userid),
|
||||||
|
"albums": {album.albumhash for album in get_visible_albums(userid)},
|
||||||
|
"artists": {artist.artisthash for artist in get_visible_artists(userid)},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_track_items(items: list[dict], allowed_trackhashes: set[str]) -> list[dict]:
|
||||||
|
return [item for item in items if item.get("trackhash") in allowed_trackhashes]
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_album_items(items: list[dict], allowed_albumhashes: set[str]) -> list[dict]:
|
||||||
|
return [item for item in items if item.get("albumhash") in allowed_albumhashes]
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_artist_items(
|
||||||
|
items: list[dict], allowed_artisthashes: set[str]
|
||||||
|
) -> list[dict]:
|
||||||
|
return [item for item in items if item.get("artisthash") in allowed_artisthashes]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_top_result_visible(top_result: dict, visible: dict[str, set[str]]) -> bool:
|
||||||
|
item_type = (top_result.get("type") or "").lower()
|
||||||
|
if item_type == "track":
|
||||||
|
return top_result.get("trackhash") in visible["tracks"]
|
||||||
|
if item_type == "album":
|
||||||
|
return top_result.get("albumhash") in visible["albums"]
|
||||||
|
if item_type == "artist":
|
||||||
|
return top_result.get("artisthash") in visible["artists"]
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_top_result(results: dict) -> dict | None:
|
||||||
|
for key in ("tracks", "albums", "artists"):
|
||||||
|
items = results.get(key) or []
|
||||||
|
if items:
|
||||||
|
top = dict(items[0])
|
||||||
|
if "type" not in top:
|
||||||
|
top["type"] = key[:-1]
|
||||||
|
return top
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cache_key(query: str, item_type: str, userid: int) -> str:
|
||||||
|
"""Generate a cache key for search results"""
|
||||||
|
normalized = unidecode(query).lower().strip()
|
||||||
|
hash_input = f"{normalized}:{item_type}:{userid}"
|
||||||
|
return hashlib.md5(hash_input.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _try_get_cached_results(query: str, item_type: str, userid: int) -> dict | None:
|
||||||
|
"""Try to get cached search results from DragonflyDB"""
|
||||||
|
cache = get_search_cache_service()
|
||||||
|
if not cache.cache.client.is_available():
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache_key = _get_cache_key(query, item_type, userid)
|
||||||
|
cached = cache.get_search_results(cache_key)
|
||||||
|
|
||||||
|
if cached:
|
||||||
|
logger.debug(f"Search cache hit for '{query}' ({item_type})")
|
||||||
|
return cached
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_search_results(
|
||||||
|
query: str, item_type: str, userid: int, results: dict, ttl_hours: int = 1
|
||||||
|
):
|
||||||
|
"""Cache search results in DragonflyDB"""
|
||||||
|
cache = get_search_cache_service()
|
||||||
|
if not cache.cache.client.is_available():
|
||||||
|
return
|
||||||
|
|
||||||
|
cache_key = _get_cache_key(query, item_type, userid)
|
||||||
|
cache.cache_search_results(cache_key, results, ttl_hours=ttl_hours)
|
||||||
|
logger.debug(f"Cached search results for '{query}' ({item_type})")
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/top")
|
||||||
|
def get_top_results(query: TopResultsQuery):
|
||||||
|
"""
|
||||||
|
Get top results
|
||||||
|
|
||||||
|
Returns the top results for the given query.
|
||||||
|
"""
|
||||||
|
if not query.q:
|
||||||
|
return {"error": "No query provided"}, 400
|
||||||
|
|
||||||
|
userid = get_current_userid()
|
||||||
|
|
||||||
|
# Try to get cached results first
|
||||||
|
cached = _try_get_cached_results(query.q, "top", userid)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
visible = _get_visible_hash_sets(userid)
|
||||||
|
results = Search(query.q).get_top_results(limit=query.limit)
|
||||||
|
|
||||||
|
if not isinstance(results, dict):
|
||||||
|
return results
|
||||||
|
|
||||||
|
results["tracks"] = _filter_track_items(
|
||||||
|
results.get("tracks") or [], visible["tracks"]
|
||||||
|
)
|
||||||
|
results["albums"] = _filter_album_items(
|
||||||
|
results.get("albums") or [], visible["albums"]
|
||||||
|
)
|
||||||
|
results["artists"] = _filter_artist_items(
|
||||||
|
results.get("artists") or [], visible["artists"]
|
||||||
|
)
|
||||||
|
|
||||||
|
top_result = results.get("top_result")
|
||||||
|
if (
|
||||||
|
top_result
|
||||||
|
and not _is_top_result_visible(top_result, visible)
|
||||||
|
or top_result is None
|
||||||
|
):
|
||||||
|
results["top_result"] = _fallback_top_result(results)
|
||||||
|
|
||||||
|
# Cache the results for 1 hour (search results change frequently)
|
||||||
|
_cache_search_results(query.q, "top", userid, results, ttl_hours=1)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/")
|
||||||
|
def search_items(query: SearchLoadMoreQuery):
|
||||||
|
"""
|
||||||
|
Find tracks, albums or artists from a search query.
|
||||||
|
"""
|
||||||
|
userid = get_current_userid()
|
||||||
|
|
||||||
|
# Try to get cached results first
|
||||||
|
cached = _try_get_cached_results(query.q, query.itemtype, userid)
|
||||||
|
if cached:
|
||||||
|
# Apply pagination to cached results
|
||||||
|
results = cached.get("results", [])
|
||||||
|
return {
|
||||||
|
"results": results[query.start : query.start + query.limit],
|
||||||
|
"more": len(results) > query.start + query.limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
results: Any = []
|
||||||
|
visible = _get_visible_hash_sets(userid)
|
||||||
|
|
||||||
|
match query.itemtype:
|
||||||
|
case "tracks":
|
||||||
|
results = Search(query.q).search_tracks()
|
||||||
|
results = _filter_track_items(results, visible["tracks"])
|
||||||
|
case "albums":
|
||||||
|
results = Search(query.q).search_albums()
|
||||||
|
results = _filter_album_items(results, visible["albums"])
|
||||||
|
case "artists":
|
||||||
|
results = Search(query.q).search_artists()
|
||||||
|
results = _filter_artist_items(results, visible["artists"])
|
||||||
|
case _:
|
||||||
|
return {
|
||||||
|
"error": "Invalid item type. Valid types are 'tracks', 'albums' and 'artists'"
|
||||||
|
}, 400
|
||||||
|
|
||||||
|
# Cache the full results for 1 hour
|
||||||
|
_cache_search_results(
|
||||||
|
query.q, query.itemtype, userid, {"results": results}, ttl_hours=1
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"results": results[query.start : query.start + query.limit],
|
||||||
|
"more": len(results) > query.start + query.limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Note: Generators are not used here because:
|
||||||
|
# 1. Results are already materialized (loaded from store)
|
||||||
|
# 2. Pagination requires knowing total count for "more" flag
|
||||||
|
# 3. Filtering operations need full list access
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import contextlib
|
||||||
|
from dataclasses import asdict
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from swingmusic.api.auth import admin_required
|
||||||
|
from swingmusic.config import UserConfig
|
||||||
|
from swingmusic.db.userdata import PluginTable
|
||||||
|
from swingmusic.lib.index import index_everything
|
||||||
|
from swingmusic.services.setup_state import trigger_initial_index
|
||||||
|
from swingmusic.settings import Metadata
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
|
||||||
|
bp_tag = Tag(name="Settings", description="Customize stuff")
|
||||||
|
api = APIBlueprint("settings", __name__, url_prefix="/notsettings", abp_tags=[bp_tag])
|
||||||
|
|
||||||
|
|
||||||
|
def get_child_dirs(parent: str, children: list[str]):
|
||||||
|
"""Returns child directories in a list, given a parent directory"""
|
||||||
|
|
||||||
|
return [_dir for _dir in children if _dir.startswith(parent) and _dir != parent]
|
||||||
|
|
||||||
|
|
||||||
|
class AddRootDirsBody(BaseModel):
|
||||||
|
new_dirs: list[str] = Field(
|
||||||
|
description="The new directories to add",
|
||||||
|
example=["/home/user/Music", "/home/user/Downloads"],
|
||||||
|
)
|
||||||
|
removed: list[str] = Field(
|
||||||
|
description="The directories to remove",
|
||||||
|
example=["/home/user/Downloads"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/add-root-dirs")
|
||||||
|
@admin_required()
|
||||||
|
def add_root_dirs(body: AddRootDirsBody):
|
||||||
|
"""
|
||||||
|
Add custom root directories to the database.
|
||||||
|
"""
|
||||||
|
new_dirs = body.new_dirs
|
||||||
|
removed_dirs = body.removed
|
||||||
|
|
||||||
|
config = UserConfig()
|
||||||
|
db_dirs = config.rootDirs
|
||||||
|
home = "$home"
|
||||||
|
|
||||||
|
db_home = any(d == home for d in db_dirs) # if $home is in db
|
||||||
|
incoming_home = any(d == home for d in new_dirs) # if $home is in incoming
|
||||||
|
|
||||||
|
# handle $home case
|
||||||
|
if db_home and incoming_home:
|
||||||
|
return {"msg": "Not changed!"}, 304
|
||||||
|
|
||||||
|
# if $home is the current root dir or the incoming root dir
|
||||||
|
# is $home, remove all root dirs
|
||||||
|
if db_home or incoming_home:
|
||||||
|
config.rootDirs = []
|
||||||
|
|
||||||
|
if incoming_home:
|
||||||
|
config.rootDirs = [home]
|
||||||
|
trigger_initial_index(force=True)
|
||||||
|
return {"root_dirs": [home]}
|
||||||
|
|
||||||
|
# ---
|
||||||
|
|
||||||
|
for _dir in new_dirs:
|
||||||
|
children = get_child_dirs(_dir, db_dirs)
|
||||||
|
removed_dirs.extend(children)
|
||||||
|
|
||||||
|
for _dir in removed_dirs:
|
||||||
|
with contextlib.suppress(ValueError):
|
||||||
|
db_dirs.remove(_dir)
|
||||||
|
|
||||||
|
db_dirs.extend(new_dirs)
|
||||||
|
config.rootDirs = [dir_ for dir_ in db_dirs if dir_ != home]
|
||||||
|
|
||||||
|
trigger_initial_index(force=True)
|
||||||
|
return {"root_dirs": config.rootDirs}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/get-root-dirs")
|
||||||
|
def get_root_dirs():
|
||||||
|
"""
|
||||||
|
Get root directories
|
||||||
|
"""
|
||||||
|
return {"dirs": UserConfig().rootDirs}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("")
|
||||||
|
def get_all_settings():
|
||||||
|
"""
|
||||||
|
Get all settings
|
||||||
|
"""
|
||||||
|
config = asdict(UserConfig())
|
||||||
|
|
||||||
|
# Convert sets to lists for JSON serialization
|
||||||
|
for key, value in config.items():
|
||||||
|
if isinstance(value, set):
|
||||||
|
config[key] = sorted(value)
|
||||||
|
|
||||||
|
config["plugins"] = list(PluginTable.get_all())
|
||||||
|
config["version"] = Metadata.version
|
||||||
|
|
||||||
|
if config["version"] == "0.0.0":
|
||||||
|
# fallback to version.txt (useful for docker builds)
|
||||||
|
config["version"] = open("version.txt").read().strip()
|
||||||
|
|
||||||
|
# only return lastfmSessionKey for the current user
|
||||||
|
current_user = get_current_userid()
|
||||||
|
config["lastfmSessionKey"] = config["lastfmSessionKeys"].get(str(current_user), "")
|
||||||
|
del config["lastfmSessionKeys"]
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
class SetSettingBody(BaseModel):
|
||||||
|
key: str = Field(
|
||||||
|
description="The setting key",
|
||||||
|
example="artist_separators",
|
||||||
|
)
|
||||||
|
value: Any = Field(
|
||||||
|
description="The setting value",
|
||||||
|
example=",",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/trigger-scan")
|
||||||
|
def trigger_scan():
|
||||||
|
"""
|
||||||
|
Triggers scan for new music
|
||||||
|
"""
|
||||||
|
queued = trigger_initial_index(force=True)
|
||||||
|
return {"msg": "Scan triggered!", "queued": queued}
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateConfigBody(BaseModel):
|
||||||
|
key: str = Field(
|
||||||
|
description="The setting key",
|
||||||
|
example="usersOnLogin",
|
||||||
|
)
|
||||||
|
value: Any = Field(
|
||||||
|
description="The setting value",
|
||||||
|
example=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.put("/update")
|
||||||
|
@admin_required()
|
||||||
|
def update_config(body: UpdateConfigBody):
|
||||||
|
"""
|
||||||
|
Update the config file
|
||||||
|
"""
|
||||||
|
config = UserConfig()
|
||||||
|
if body.key == "artistSeparators":
|
||||||
|
body.value = body.value.split(",")
|
||||||
|
|
||||||
|
setattr(config, body.key, body.value)
|
||||||
|
|
||||||
|
# INFO: Rebuild stores when these settings are updated
|
||||||
|
reset_stores_lists = {
|
||||||
|
"artistSeparators",
|
||||||
|
"artistSplitIgnoreList",
|
||||||
|
"removeProdBy",
|
||||||
|
"removeRemasterInfo",
|
||||||
|
"mergeAlbums",
|
||||||
|
"cleanAlbumTitle",
|
||||||
|
"showAlbumsAsSingles",
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.key in reset_stores_lists:
|
||||||
|
index_everything()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"msg": "Config updated!",
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from swingmusic.services.setup_state import (
|
||||||
|
bootstrap_setup,
|
||||||
|
configure_primary_directory,
|
||||||
|
get_setup_status,
|
||||||
|
trigger_initial_index,
|
||||||
|
)
|
||||||
|
from swingmusic.utils.wintools import is_windows
|
||||||
|
|
||||||
|
bp_tag = Tag(name="Setup", description="First-run setup and onboarding state")
|
||||||
|
api = APIBlueprint("setup", __name__, url_prefix="/setup", abp_tags=[bp_tag])
|
||||||
|
|
||||||
|
|
||||||
|
class SetupBootstrapBody(BaseModel):
|
||||||
|
username: str = Field(description="Owner username for first boot")
|
||||||
|
password: str = Field(description="Owner password for first boot")
|
||||||
|
root_dirs: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Initial primary music directories",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SetupDirectoryBody(BaseModel):
|
||||||
|
root_dirs: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Primary music directories to use for indexing",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SetupIndexStartBody(BaseModel):
|
||||||
|
force: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Force queueing a new initial index run",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SetupDirBrowserBody(BaseModel):
|
||||||
|
folder: str = Field(
|
||||||
|
"$root",
|
||||||
|
description="The folder to list directories from during first-run setup",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_root_drives(is_win: bool = False):
|
||||||
|
drives = [Path(d.mountpoint).as_posix() for d in psutil.disk_partitions(all=True)]
|
||||||
|
|
||||||
|
if is_win:
|
||||||
|
return drives
|
||||||
|
|
||||||
|
hidden_roots = (
|
||||||
|
"/boot",
|
||||||
|
"/tmp",
|
||||||
|
"/snap",
|
||||||
|
"/var",
|
||||||
|
"/sys",
|
||||||
|
"/proc",
|
||||||
|
"/etc",
|
||||||
|
"/run",
|
||||||
|
"/dev",
|
||||||
|
)
|
||||||
|
return [drive for drive in drives if not drive.startswith(hidden_roots)]
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/status")
|
||||||
|
def setup_status():
|
||||||
|
return get_setup_status()
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/bootstrap")
|
||||||
|
def setup_bootstrap(body: SetupBootstrapBody):
|
||||||
|
try:
|
||||||
|
owner = bootstrap_setup(
|
||||||
|
username=body.username,
|
||||||
|
password=body.password,
|
||||||
|
root_dirs=body.root_dirs,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"owner": {
|
||||||
|
"id": owner.id,
|
||||||
|
"username": owner.username,
|
||||||
|
},
|
||||||
|
"setup": get_setup_status(),
|
||||||
|
}
|
||||||
|
except ValueError as error:
|
||||||
|
return {"success": False, "error": str(error)}, 400
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/directory")
|
||||||
|
def setup_directory(body: SetupDirectoryBody):
|
||||||
|
status = get_setup_status()
|
||||||
|
if status["setup_completed"]:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Setup is already completed.",
|
||||||
|
"setup": status,
|
||||||
|
}, 400
|
||||||
|
|
||||||
|
if not status["owner_created"]:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Create the owner account before configuring directories.",
|
||||||
|
"setup": status,
|
||||||
|
}, 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
queued = configure_primary_directory(root_dirs=body.root_dirs)
|
||||||
|
except ValueError as error:
|
||||||
|
return {"success": False, "error": str(error)}, 400
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"queued": queued,
|
||||||
|
"setup": get_setup_status(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/index-progress")
|
||||||
|
def setup_index_progress():
|
||||||
|
status = get_setup_status()
|
||||||
|
return {
|
||||||
|
"index_state": status["index_state"],
|
||||||
|
"index_progress": status["index_progress"],
|
||||||
|
"index_message": status["index_message"],
|
||||||
|
"initial_index_completed": status["initial_index_completed"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/index/start")
|
||||||
|
def setup_index_start(body: SetupIndexStartBody):
|
||||||
|
status = get_setup_status()
|
||||||
|
if not status["owner_created"] or not status["directory_configured"]:
|
||||||
|
return {
|
||||||
|
"queued": False,
|
||||||
|
"error": "Owner account and primary music directory are required before indexing.",
|
||||||
|
"setup": status,
|
||||||
|
}, 400
|
||||||
|
|
||||||
|
queued = trigger_initial_index(force=body.force)
|
||||||
|
status = get_setup_status()
|
||||||
|
return {
|
||||||
|
"queued": queued,
|
||||||
|
"setup": status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/dir-browser")
|
||||||
|
def setup_dir_browser(body: SetupDirBrowserBody):
|
||||||
|
status = get_setup_status()
|
||||||
|
if status["setup_completed"]:
|
||||||
|
return {"folders": [], "error": "Setup is already completed."}, 403
|
||||||
|
|
||||||
|
req_dir = body.folder
|
||||||
|
if req_dir == "$root":
|
||||||
|
roots = _setup_root_drives(is_win=is_windows())
|
||||||
|
if "/music" not in roots and Path("/music").exists():
|
||||||
|
roots.insert(0, "/music")
|
||||||
|
|
||||||
|
return {"folders": [{"name": root, "path": root} for root in roots]}
|
||||||
|
|
||||||
|
req_path = pathlib.Path(req_dir).resolve()
|
||||||
|
if not req_path.exists() or not req_path.is_dir():
|
||||||
|
return {"folders": [], "error": "Invalid directory"}, 400
|
||||||
|
|
||||||
|
dirs = []
|
||||||
|
try:
|
||||||
|
with os.scandir(req_path) as entries:
|
||||||
|
for entry in entries:
|
||||||
|
entry_path = pathlib.Path(entry)
|
||||||
|
name = entry_path.name
|
||||||
|
|
||||||
|
if name.startswith("$") or name.startswith("."):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if entry_path.is_dir():
|
||||||
|
dirs.append({"name": name, "path": entry_path.resolve().as_posix()})
|
||||||
|
except PermissionError:
|
||||||
|
return {"folders": []}
|
||||||
|
|
||||||
|
return {"folders": sorted(dirs, key=lambda item: item["name"].lower())}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Spotify downloader API backed by the unified durable download job pipeline."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from flask import jsonify, request
|
||||||
|
from flask_jwt_extended import get_jwt_identity
|
||||||
|
from flask_openapi3 import APIBlueprint
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from swingmusic.services.spotify_downloader import DownloadSource, spotify_downloader
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
|
||||||
|
spotify_bp = APIBlueprint(
|
||||||
|
"spotify",
|
||||||
|
import_name="spotify",
|
||||||
|
url_prefix="/api/spotify",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SpotifyURLRequest(BaseModel):
|
||||||
|
url: str = Field(..., description="Spotify URL (track, album, playlist, artist)")
|
||||||
|
quality: str | None = Field(default="flac", description="Audio quality")
|
||||||
|
output_dir: str | None = Field(default=None, description="Output directory")
|
||||||
|
|
||||||
|
|
||||||
|
def _current_userid() -> int:
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
if isinstance(identity, dict) and identity.get("id") is not None:
|
||||||
|
return int(identity["id"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return get_current_userid()
|
||||||
|
|
||||||
|
|
||||||
|
@spotify_bp.post("/metadata", summary="Get Spotify metadata")
|
||||||
|
def get_metadata(body: SpotifyURLRequest):
|
||||||
|
try:
|
||||||
|
metadata = asyncio.run(spotify_downloader.get_metadata(body.url))
|
||||||
|
|
||||||
|
if not metadata:
|
||||||
|
return jsonify({"error": "Invalid Spotify URL", "success": False}), 400
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"metadata": {
|
||||||
|
"spotify_id": metadata.spotify_id,
|
||||||
|
"item_type": metadata.item_type,
|
||||||
|
"title": metadata.title,
|
||||||
|
"artist": metadata.artist,
|
||||||
|
"album": metadata.album,
|
||||||
|
"duration_ms": metadata.duration_ms,
|
||||||
|
"image_url": metadata.image_url,
|
||||||
|
"release_date": metadata.release_date,
|
||||||
|
"track_number": metadata.track_number,
|
||||||
|
"total_tracks": metadata.total_tracks,
|
||||||
|
"is_explicit": metadata.is_explicit,
|
||||||
|
"preview_url": metadata.preview_url,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
return jsonify({"error": str(error), "success": False}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@spotify_bp.post("/download", summary="Add Spotify URL to queue")
|
||||||
|
def download_from_url(body: SpotifyURLRequest):
|
||||||
|
userid = _current_userid()
|
||||||
|
|
||||||
|
item_id = spotify_downloader.add_download(
|
||||||
|
spotify_url=body.url,
|
||||||
|
output_dir=body.output_dir,
|
||||||
|
quality=body.quality,
|
||||||
|
userid=userid,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not item_id:
|
||||||
|
return jsonify({"error": "Failed to add download", "success": False}), 400
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": "Download added to queue",
|
||||||
|
"item_id": item_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@spotify_bp.get("/queue", summary="Get queue status")
|
||||||
|
def get_queue_status():
|
||||||
|
userid = _current_userid()
|
||||||
|
status = spotify_downloader.get_queue_status(userid)
|
||||||
|
return jsonify({"success": True, "data": status})
|
||||||
|
|
||||||
|
|
||||||
|
@spotify_bp.post("/cancel/<item_id>", summary="Cancel download")
|
||||||
|
def cancel_download(item_id: str):
|
||||||
|
userid = _current_userid()
|
||||||
|
success = spotify_downloader.cancel_download(item_id, userid=userid)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
return jsonify(
|
||||||
|
{"success": False, "message": "Download not found or cannot be cancelled"}
|
||||||
|
), 404
|
||||||
|
|
||||||
|
return jsonify({"success": True, "message": "Download cancelled successfully"})
|
||||||
|
|
||||||
|
|
||||||
|
@spotify_bp.post("/retry/<item_id>", summary="Retry failed download")
|
||||||
|
def retry_download(item_id: str):
|
||||||
|
userid = _current_userid()
|
||||||
|
success = spotify_downloader.retry_download(item_id, userid=userid)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
return jsonify(
|
||||||
|
{"success": False, "message": "Download not found or cannot be retried"}
|
||||||
|
), 404
|
||||||
|
|
||||||
|
return jsonify({"success": True, "message": "Download retry added to queue"})
|
||||||
|
|
||||||
|
|
||||||
|
@spotify_bp.get("/sources", summary="Get download sources")
|
||||||
|
def get_download_sources():
|
||||||
|
sources = [
|
||||||
|
{
|
||||||
|
"name": source.value,
|
||||||
|
"display_name": source.value.replace("_", " ").title(),
|
||||||
|
"enabled": True,
|
||||||
|
"priority": index,
|
||||||
|
}
|
||||||
|
for index, source in enumerate(DownloadSource)
|
||||||
|
]
|
||||||
|
return jsonify({"success": True, "sources": sources})
|
||||||
|
|
||||||
|
|
||||||
|
@spotify_bp.get("/qualities", summary="Get audio qualities")
|
||||||
|
def get_audio_qualities():
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"qualities": [
|
||||||
|
{
|
||||||
|
"id": "flac",
|
||||||
|
"name": "FLAC",
|
||||||
|
"description": "Lossless audio quality",
|
||||||
|
"extension": "flac",
|
||||||
|
"bitrate": "Lossless",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mp3_320",
|
||||||
|
"name": "MP3 320kbps",
|
||||||
|
"description": "High quality MP3",
|
||||||
|
"extension": "mp3",
|
||||||
|
"bitrate": "320 kbps",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mp3_128",
|
||||||
|
"name": "MP3 128kbps",
|
||||||
|
"description": "Standard quality MP3",
|
||||||
|
"extension": "mp3",
|
||||||
|
"bitrate": "128 kbps",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@spotify_bp.get("/history", summary="Get download history")
|
||||||
|
def get_download_history():
|
||||||
|
userid = _current_userid()
|
||||||
|
page = int(request.args.get("page", 1))
|
||||||
|
limit = int(request.args.get("limit", 50))
|
||||||
|
status_filter = request.args.get("status", None)
|
||||||
|
|
||||||
|
status = spotify_downloader.get_queue_status(userid)
|
||||||
|
history = status.get("history", [])
|
||||||
|
|
||||||
|
if status_filter:
|
||||||
|
history = [item for item in history if item.get("state") == status_filter]
|
||||||
|
|
||||||
|
total = len(history)
|
||||||
|
start = max(0, (page - 1) * limit)
|
||||||
|
end = start + limit
|
||||||
|
items = history[start:end]
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"items": items,
|
||||||
|
"pagination": {
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
"total": total,
|
||||||
|
"pages": (total + limit - 1) // limit,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@spotify_bp.delete("/clear-history", summary="Clear download history")
|
||||||
|
def clear_download_history():
|
||||||
|
# Durable history is kept in DB for reliability; expose as no-op success for backward compatibility.
|
||||||
|
return jsonify(
|
||||||
|
{"success": True, "message": "History retention is managed automatically"}
|
||||||
|
)
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
"""
|
||||||
|
Spotify Downloader Settings API endpoints
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import jsonify
|
||||||
|
from flask_jwt_extended import get_jwt_identity
|
||||||
|
from flask_openapi3 import APIBlueprint
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from swingmusic import logger
|
||||||
|
from swingmusic.config import UserConfig
|
||||||
|
from swingmusic.services.download_jobs import download_job_manager
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
|
||||||
|
spotify_settings_bp = APIBlueprint(
|
||||||
|
"spotify_settings",
|
||||||
|
import_name="spotify_settings",
|
||||||
|
url_prefix="/api/settings/spotify",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _current_userid() -> int:
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
if isinstance(identity, dict) and identity.get("id") is not None:
|
||||||
|
return int(identity["id"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return get_current_userid()
|
||||||
|
|
||||||
|
|
||||||
|
class SpotifySettingsRequest(BaseModel):
|
||||||
|
defaultQuality: str = Field("flac", description="Default download quality")
|
||||||
|
downloadFolder: str | None = Field(None, description="Download folder path")
|
||||||
|
autoAddToLibrary: bool = Field(True, description="Auto-add downloads to library")
|
||||||
|
maxConcurrentDownloads: int = Field(3, description="Max concurrent downloads")
|
||||||
|
sources: list | None = Field(None, description="Download sources configuration")
|
||||||
|
maxRetryAttempts: int = Field(3, description="Max retry attempts")
|
||||||
|
cleanupHistoryDays: int = Field(30, description="Auto-cleanup history days")
|
||||||
|
showExplicitWarning: bool = Field(True, description="Show explicit content warning")
|
||||||
|
|
||||||
|
|
||||||
|
class SpotifySettingsResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
settings: dict[str, Any] | None = None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# Default settings
|
||||||
|
DEFAULT_SETTINGS = {
|
||||||
|
"defaultQuality": "flac",
|
||||||
|
"downloadFolder": "",
|
||||||
|
"autoAddToLibrary": True,
|
||||||
|
"maxConcurrentDownloads": 3,
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"name": "tidal",
|
||||||
|
"display_name": "Tidal",
|
||||||
|
"enabled": True,
|
||||||
|
"priority": 1,
|
||||||
|
"config": {
|
||||||
|
"quality_preference": ["lossless", "high", "normal"],
|
||||||
|
"formats": ["flac", "mp3"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "qobuz",
|
||||||
|
"display_name": "Qobuz",
|
||||||
|
"enabled": True,
|
||||||
|
"priority": 2,
|
||||||
|
"config": {
|
||||||
|
"quality_preference": ["lossless", "high", "normal"],
|
||||||
|
"formats": ["flac", "mp3"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "amazon",
|
||||||
|
"display_name": "Amazon Music",
|
||||||
|
"enabled": False,
|
||||||
|
"priority": 3,
|
||||||
|
"config": {
|
||||||
|
"quality_preference": ["high", "normal"],
|
||||||
|
"formats": ["mp3", "aac"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"maxRetryAttempts": 3,
|
||||||
|
"cleanupHistoryDays": 30,
|
||||||
|
"showExplicitWarning": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_spotify_settings():
|
||||||
|
"""Get Spotify downloader settings from config"""
|
||||||
|
try:
|
||||||
|
config = UserConfig()
|
||||||
|
spotify_settings = (
|
||||||
|
config.spotify_downloads if hasattr(config, "spotify_downloads") else {}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Merge with defaults
|
||||||
|
settings = {**DEFAULT_SETTINGS}
|
||||||
|
settings.update(spotify_settings)
|
||||||
|
|
||||||
|
return settings
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading Spotify settings: {e}")
|
||||||
|
return DEFAULT_SETTINGS
|
||||||
|
|
||||||
|
|
||||||
|
def save_spotify_settings(settings_data: dict):
|
||||||
|
"""Save Spotify downloader settings to config"""
|
||||||
|
try:
|
||||||
|
config = UserConfig()
|
||||||
|
|
||||||
|
# Update only provided settings
|
||||||
|
current_settings = get_spotify_settings()
|
||||||
|
current_settings.update(settings_data)
|
||||||
|
|
||||||
|
# Save to config
|
||||||
|
config.spotify_downloads = current_settings
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
logger.info("Spotify settings saved successfully")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving Spotify settings: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@spotify_settings_bp.get("/", summary="Get Spotify downloader settings")
|
||||||
|
def get_settings():
|
||||||
|
"""
|
||||||
|
Get current Spotify downloader settings
|
||||||
|
|
||||||
|
Returns all Spotify downloader configuration including:
|
||||||
|
- Default quality settings
|
||||||
|
- Download folder configuration
|
||||||
|
- Source priorities and enablement
|
||||||
|
- Advanced options
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
settings = get_spotify_settings()
|
||||||
|
|
||||||
|
return jsonify({"success": True, "settings": settings})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting Spotify settings: {e}")
|
||||||
|
return jsonify({"success": False, "message": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@spotify_settings_bp.post("/", summary="Update Spotify downloader settings")
|
||||||
|
def update_settings(body: SpotifySettingsRequest):
|
||||||
|
"""
|
||||||
|
Update Spotify downloader settings
|
||||||
|
|
||||||
|
- **defaultQuality**: Default download quality (flac, mp3_320, mp3_128)
|
||||||
|
- **downloadFolder**: Custom download folder path
|
||||||
|
- **autoAddToLibrary**: Whether to auto-add downloads to library
|
||||||
|
- **maxConcurrentDownloads**: Maximum concurrent downloads (1-10)
|
||||||
|
- **sources**: Download sources configuration
|
||||||
|
- **maxRetryAttempts**: Maximum retry attempts for failed downloads
|
||||||
|
- **cleanupHistoryDays**: Days to keep download history (0 = disabled)
|
||||||
|
- **showExplicitWarning**: Show warning for explicit content
|
||||||
|
|
||||||
|
Updates the Spotify downloader configuration and saves to user settings.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate inputs
|
||||||
|
if body.defaultQuality not in ["flac", "mp3_320", "mp3_128"]:
|
||||||
|
return jsonify(
|
||||||
|
{"success": False, "message": "Invalid quality setting"}
|
||||||
|
), 400
|
||||||
|
|
||||||
|
if not 1 <= body.maxConcurrentDownloads <= 10:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"message": "Max concurrent downloads must be between 1 and 10",
|
||||||
|
}
|
||||||
|
), 400
|
||||||
|
|
||||||
|
if not 0 <= body.maxRetryAttempts <= 10:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"message": "Max retry attempts must be between 0 and 10",
|
||||||
|
}
|
||||||
|
), 400
|
||||||
|
|
||||||
|
if not 0 <= body.cleanupHistoryDays <= 365:
|
||||||
|
return jsonify(
|
||||||
|
{"success": False, "message": "Cleanup days must be between 0 and 365"}
|
||||||
|
), 400
|
||||||
|
|
||||||
|
# Prepare settings data
|
||||||
|
settings_data = {
|
||||||
|
"defaultQuality": body.defaultQuality,
|
||||||
|
"downloadFolder": body.downloadFolder,
|
||||||
|
"autoAddToLibrary": body.autoAddToLibrary,
|
||||||
|
"maxConcurrentDownloads": body.maxConcurrentDownloads,
|
||||||
|
"sources": body.sources,
|
||||||
|
"maxRetryAttempts": body.maxRetryAttempts,
|
||||||
|
"cleanupHistoryDays": body.cleanupHistoryDays,
|
||||||
|
"showExplicitWarning": body.showExplicitWarning,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove None values
|
||||||
|
settings_data = {k: v for k, v in settings_data.items() if v is not None}
|
||||||
|
|
||||||
|
# Save settings
|
||||||
|
if save_spotify_settings(settings_data):
|
||||||
|
return jsonify({"success": True, "message": "Settings saved successfully"})
|
||||||
|
else:
|
||||||
|
return jsonify(
|
||||||
|
{"success": False, "message": "Failed to save settings"}
|
||||||
|
), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating Spotify settings: {e}")
|
||||||
|
return jsonify({"success": False, "message": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@spotify_settings_bp.post("/reset", summary="Reset Spotify settings to defaults")
|
||||||
|
def reset_settings():
|
||||||
|
"""
|
||||||
|
Reset all Spotify downloader settings to default values
|
||||||
|
|
||||||
|
Resets all Spotify downloader configuration to factory defaults.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if save_spotify_settings(DEFAULT_SETTINGS):
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": "Settings reset to defaults",
|
||||||
|
"settings": DEFAULT_SETTINGS,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return jsonify(
|
||||||
|
{"success": False, "message": "Failed to reset settings"}
|
||||||
|
), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error resetting Spotify settings: {e}")
|
||||||
|
return jsonify({"success": False, "message": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@spotify_settings_bp.delete("/queue", summary="Clear download queue")
|
||||||
|
def clear_queue():
|
||||||
|
"""
|
||||||
|
Clear pending/active download jobs for current user.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
userid = _current_userid()
|
||||||
|
cancelled = download_job_manager.clear_queue(userid)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"cancelled_jobs": cancelled,
|
||||||
|
"message": f"Cleared queue ({cancelled} job(s) cancelled)",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error clearing download queue: {e}")
|
||||||
|
return jsonify({"success": False, "message": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@spotify_settings_bp.delete("/history", summary="Clear download history")
|
||||||
|
def clear_history():
|
||||||
|
"""
|
||||||
|
Clear completed/failed/cancelled download history for current user.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
userid = _current_userid()
|
||||||
|
deleted = download_job_manager.clear_history(userid)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"deleted_jobs": deleted,
|
||||||
|
"message": f"Download history cleared ({deleted} job(s) removed)",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error clearing download history: {e}")
|
||||||
|
return jsonify({"success": False, "message": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@spotify_settings_bp.get("/sources", summary="Get available download sources")
|
||||||
|
def get_available_sources():
|
||||||
|
"""
|
||||||
|
Get list of available download sources
|
||||||
|
|
||||||
|
Returns information about supported download sources and their capabilities.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sources = [
|
||||||
|
{
|
||||||
|
"name": "tidal",
|
||||||
|
"display_name": "Tidal",
|
||||||
|
"description": "High-quality FLAC downloads from Tidal",
|
||||||
|
"quality_options": ["lossless", "high", "normal"],
|
||||||
|
"formats": ["flac", "mp3"],
|
||||||
|
"available": True,
|
||||||
|
"requires_auth": False,
|
||||||
|
"max_quality": "lossless",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "qobuz",
|
||||||
|
"display_name": "Qobuz",
|
||||||
|
"description": "Alternative high-quality source with extensive catalog",
|
||||||
|
"quality_options": ["lossless", "high", "normal"],
|
||||||
|
"formats": ["flac", "mp3"],
|
||||||
|
"available": True,
|
||||||
|
"requires_auth": True,
|
||||||
|
"max_quality": "lossless",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "amazon",
|
||||||
|
"display_name": "Amazon Music",
|
||||||
|
"description": "Fallback source with wide availability",
|
||||||
|
"quality_options": ["high", "normal"],
|
||||||
|
"formats": ["mp3", "aac"],
|
||||||
|
"available": False, # Disabled by default
|
||||||
|
"requires_auth": True,
|
||||||
|
"max_quality": "high",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return jsonify({"success": True, "sources": sources})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting available sources: {e}")
|
||||||
|
return jsonify({"success": False, "message": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# Error handlers
|
||||||
|
@spotify_settings_bp.errorhandler(400)
|
||||||
|
def bad_request(error):
|
||||||
|
return jsonify(
|
||||||
|
{"error": "Bad request", "message": str(error), "success": False}
|
||||||
|
), 400
|
||||||
|
|
||||||
|
|
||||||
|
@spotify_settings_bp.errorhandler(500)
|
||||||
|
def internal_error(error):
|
||||||
|
return jsonify(
|
||||||
|
{"error": "Internal server error", "message": str(error), "success": False}
|
||||||
|
), 500
|
||||||
@@ -0,0 +1,613 @@
|
|||||||
|
"""
|
||||||
|
Contains all the track routes with iOS compatibility enhancements.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from flask import Response, request, send_from_directory
|
||||||
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from swingmusic.api.apischemas import TrackHashSchema
|
||||||
|
from swingmusic.lib.trackslib import get_silence_paddings
|
||||||
|
from swingmusic.lib.transcoder import start_transcoding
|
||||||
|
from swingmusic.services.ios_audio_compatibility import ios_audio_manager
|
||||||
|
from swingmusic.services.user_library_scope import (
|
||||||
|
get_available_trackhashes,
|
||||||
|
is_path_within_user_roots,
|
||||||
|
)
|
||||||
|
from swingmusic.store.tracks import TrackStore
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
from swingmusic.utils.files import guess_mime_type
|
||||||
|
|
||||||
|
bp_tag = Tag(name="File", description="Audio files")
|
||||||
|
api = APIBlueprint("track", __name__, url_prefix="/file", abp_tags=[bp_tag])
|
||||||
|
|
||||||
|
|
||||||
|
class TransCodeStore:
|
||||||
|
map: dict[str, str] = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_file(cls, trackhash: str, filepath: str):
|
||||||
|
cls.map[trackhash] = filepath
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def remove_file(cls, trackhash: str):
|
||||||
|
del cls.map[trackhash]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find(cls, trackhash: str):
|
||||||
|
return cls.map.get(trackhash)
|
||||||
|
|
||||||
|
|
||||||
|
class SendTrackFileQuery(BaseModel):
|
||||||
|
filepath: str = Field(description="The filepath to play (if available)")
|
||||||
|
quality: str = Field(
|
||||||
|
"original",
|
||||||
|
description="The quality of the audio file. Options: original, 1411, 1024, 512, 320, 256, 128, 96",
|
||||||
|
)
|
||||||
|
container: Literal["mp3", "aac", "flac", "webm", "ogg"] = Field(
|
||||||
|
"mp3",
|
||||||
|
description="The container format of the audio file. Options: mp3, aac, flac, webm, ogg",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TRANSCODE_CODEC_ARGS = {
|
||||||
|
"mp3": ["-c:a", "libmp3lame"],
|
||||||
|
"aac": ["-c:a", "aac"],
|
||||||
|
"webm": ["-c:a", "libopus"],
|
||||||
|
"ogg": ["-c:a", "libvorbis"],
|
||||||
|
"flac": ["-c:a", "flac"],
|
||||||
|
}
|
||||||
|
TRANSCODE_CACHE_DIR = Path(tempfile.gettempdir()) / "swingmusic_transcodes"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_requested_bitrate(quality: str) -> int | None:
|
||||||
|
normalized = (quality or "").strip().lower()
|
||||||
|
if not normalized or normalized == "original":
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
bitrate = int(normalized)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return max(64, min(1411, bitrate))
|
||||||
|
|
||||||
|
|
||||||
|
def _requested_ios_quality(quality: str) -> str:
|
||||||
|
requested = _parse_requested_bitrate(quality)
|
||||||
|
if requested is None:
|
||||||
|
return "lossless"
|
||||||
|
if requested <= 128:
|
||||||
|
return "low"
|
||||||
|
if requested <= 256:
|
||||||
|
return "medium"
|
||||||
|
if requested <= 512:
|
||||||
|
return "high"
|
||||||
|
return "lossless"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_transcoded_variant(
|
||||||
|
*,
|
||||||
|
source_path: str,
|
||||||
|
quality: str,
|
||||||
|
container: str,
|
||||||
|
) -> str:
|
||||||
|
bitrate = _parse_requested_bitrate(quality)
|
||||||
|
if bitrate is None:
|
||||||
|
return source_path
|
||||||
|
|
||||||
|
output_container = container if container in TRANSCODE_CODEC_ARGS else "mp3"
|
||||||
|
if output_container != "flac":
|
||||||
|
bitrate = min(320, bitrate)
|
||||||
|
|
||||||
|
source = Path(source_path).resolve()
|
||||||
|
TRANSCODE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
source_stamp = source.stat().st_mtime_ns
|
||||||
|
cache_key = f"{source}::{source_stamp}::{output_container}::{bitrate}"
|
||||||
|
out_name = (
|
||||||
|
f"{hashlib.sha1(cache_key.encode('utf-8')).hexdigest()}.{output_container}"
|
||||||
|
)
|
||||||
|
out_path = TRANSCODE_CACHE_DIR / out_name
|
||||||
|
|
||||||
|
if out_path.exists() and out_path.stat().st_size > 0:
|
||||||
|
return str(out_path)
|
||||||
|
|
||||||
|
command = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
str(source),
|
||||||
|
"-vn",
|
||||||
|
"-map_metadata",
|
||||||
|
"0",
|
||||||
|
]
|
||||||
|
command.extend(TRANSCODE_CODEC_ARGS[output_container])
|
||||||
|
if output_container != "flac":
|
||||||
|
command.extend(["-b:a", f"{bitrate}k"])
|
||||||
|
command.append(str(out_path))
|
||||||
|
|
||||||
|
process = subprocess.run(
|
||||||
|
command,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if process.returncode != 0 or not out_path.exists():
|
||||||
|
if out_path.exists():
|
||||||
|
out_path.unlink(missing_ok=True)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Transcoding failed for {source_path} ({quality}/{output_container}): "
|
||||||
|
f"{process.stderr[-400:]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return str(out_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_track_for_user(
|
||||||
|
*,
|
||||||
|
requested_trackhash: str,
|
||||||
|
filepath: str,
|
||||||
|
userid: int,
|
||||||
|
):
|
||||||
|
msg = {"msg": "File Not Found"}
|
||||||
|
available_trackhashes = get_available_trackhashes(userid)
|
||||||
|
|
||||||
|
if requested_trackhash not in available_trackhashes:
|
||||||
|
return None, msg, 404
|
||||||
|
|
||||||
|
if filepath:
|
||||||
|
# prevent path traversal
|
||||||
|
if "/../" in filepath:
|
||||||
|
return (
|
||||||
|
None,
|
||||||
|
{"msg": "Invalid filepath", "error": "Path traversal detected"},
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
|
||||||
|
requested_filepath = Path(filepath).resolve()
|
||||||
|
|
||||||
|
if not is_path_within_user_roots(str(requested_filepath), userid=userid):
|
||||||
|
return (
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"msg": "Invalid filepath",
|
||||||
|
"error": "File not inside root directories",
|
||||||
|
},
|
||||||
|
403,
|
||||||
|
)
|
||||||
|
|
||||||
|
tracks = TrackStore.get_tracks_by_filepaths([filepath])
|
||||||
|
|
||||||
|
if len(tracks) > 0 and os.path.exists(tracks[0].filepath):
|
||||||
|
for track in tracks:
|
||||||
|
if (
|
||||||
|
os.path.exists(track.filepath)
|
||||||
|
and track.trackhash == requested_trackhash
|
||||||
|
):
|
||||||
|
return track, None, None
|
||||||
|
|
||||||
|
group = TrackStore.trackhashmap.get(requested_trackhash)
|
||||||
|
|
||||||
|
if group is not None:
|
||||||
|
tracks = sorted(group.tracks, key=lambda x: x.bitrate, reverse=True)
|
||||||
|
|
||||||
|
for track in tracks:
|
||||||
|
if os.path.exists(track.filepath):
|
||||||
|
return track, None, None
|
||||||
|
|
||||||
|
return None, msg, 404
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/<trackhash>/legacy")
|
||||||
|
def send_track_file_legacy(path: TrackHashSchema, query: SendTrackFileQuery):
|
||||||
|
"""
|
||||||
|
Get a playable audio file without Range support (iOS compatible)
|
||||||
|
|
||||||
|
Returns a playable audio file that corresponds to the given filepath. Falls back to track hash if filepath is not found.
|
||||||
|
Automatically handles iOS compatibility by transcoding to supported formats when needed.
|
||||||
|
|
||||||
|
NOTE: Does not support range requests or transcoding beyond iOS compatibility.
|
||||||
|
"""
|
||||||
|
requested_trackhash = path.trackhash.strip()
|
||||||
|
filepath = query.filepath.strip()
|
||||||
|
userid = get_current_userid()
|
||||||
|
track, error_payload, error_status = _resolve_track_for_user(
|
||||||
|
requested_trackhash=requested_trackhash,
|
||||||
|
filepath=filepath,
|
||||||
|
userid=userid,
|
||||||
|
)
|
||||||
|
|
||||||
|
if track is not None:
|
||||||
|
selected_path = track.filepath
|
||||||
|
selected_quality = (query.quality or "original").strip().lower()
|
||||||
|
selected_container = (query.container or "mp3").strip().lower()
|
||||||
|
|
||||||
|
# Honor requested streaming quality for mobile data saver mode.
|
||||||
|
if selected_quality != "original":
|
||||||
|
try:
|
||||||
|
selected_path = _ensure_transcoded_variant(
|
||||||
|
source_path=track.filepath,
|
||||||
|
quality=selected_quality,
|
||||||
|
container=selected_container,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
selected_path = track.filepath
|
||||||
|
|
||||||
|
# Detect iOS capabilities and handle compatibility
|
||||||
|
user_agent = request.headers.get("User-Agent", "")
|
||||||
|
ios_capabilities = ios_audio_manager.detect_ios_capabilities(user_agent)
|
||||||
|
|
||||||
|
# Create iOS-compatible audio source
|
||||||
|
audio_source = ios_audio_manager.create_ios_audio_source(
|
||||||
|
selected_path,
|
||||||
|
ios_capabilities,
|
||||||
|
quality=_requested_ios_quality(query.quality),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use the potentially transcoded file path
|
||||||
|
final_file_path = audio_source["file_path"]
|
||||||
|
audio_type = audio_source["mime_type"]
|
||||||
|
|
||||||
|
# Add iOS compatibility headers
|
||||||
|
response = send_from_directory(
|
||||||
|
Path(final_file_path).parent,
|
||||||
|
Path(final_file_path).name,
|
||||||
|
mimetype=audio_type,
|
||||||
|
conditional=True,
|
||||||
|
as_attachment=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add iOS-specific headers
|
||||||
|
if ios_capabilities.is_ios:
|
||||||
|
response.headers["Accept-Ranges"] = "bytes"
|
||||||
|
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||||
|
|
||||||
|
# Add transcoding info if applicable
|
||||||
|
if audio_source["needs_transcoding"]:
|
||||||
|
response.headers["X-iOS-Transcoded"] = "true"
|
||||||
|
response.headers["X-iOS-Original-Format"] = guess_mime_type(
|
||||||
|
selected_path
|
||||||
|
)
|
||||||
|
response.headers["X-iOS-Target-Format"] = audio_source["format"]
|
||||||
|
|
||||||
|
response.headers["X-Requested-Quality"] = query.quality
|
||||||
|
response.headers["X-Requested-Container"] = query.container
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
return error_payload, error_status
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/<trackhash>/ios")
|
||||||
|
def send_track_file_ios(path: TrackHashSchema, query: SendTrackFileQuery):
|
||||||
|
"""
|
||||||
|
Get a playable audio file optimized for iOS devices
|
||||||
|
|
||||||
|
Returns a playable audio file optimized for iOS compatibility with automatic transcoding.
|
||||||
|
Supports FLAC to ALAC/AAC conversion and proper MIME types for iOS Safari and other browsers.
|
||||||
|
|
||||||
|
iOS Features:
|
||||||
|
- Automatic FLAC to ALAC/AAC transcoding
|
||||||
|
- Proper MP4 container formatting
|
||||||
|
- iOS-compatible MIME types
|
||||||
|
- Optimized bitrate for mobile streaming
|
||||||
|
- Caching for transcoded files
|
||||||
|
"""
|
||||||
|
requested_trackhash = path.trackhash.strip()
|
||||||
|
filepath = query.filepath.strip()
|
||||||
|
userid = get_current_userid()
|
||||||
|
track, error_payload, error_status = _resolve_track_for_user(
|
||||||
|
requested_trackhash=requested_trackhash,
|
||||||
|
filepath=filepath,
|
||||||
|
userid=userid,
|
||||||
|
)
|
||||||
|
|
||||||
|
if track is not None:
|
||||||
|
# Detect iOS capabilities
|
||||||
|
user_agent = request.headers.get("User-Agent", "")
|
||||||
|
ios_capabilities = ios_audio_manager.detect_ios_capabilities(user_agent)
|
||||||
|
|
||||||
|
# Determine quality based on query parameter or device capabilities
|
||||||
|
quality_map = {
|
||||||
|
"original": "lossless",
|
||||||
|
"1411": "lossless",
|
||||||
|
"1024": "lossless",
|
||||||
|
"512": "high",
|
||||||
|
"320": "high",
|
||||||
|
"256": "high",
|
||||||
|
"128": "medium",
|
||||||
|
"96": "low",
|
||||||
|
}
|
||||||
|
quality = quality_map.get(query.quality, "high")
|
||||||
|
|
||||||
|
# Create iOS-optimized audio source
|
||||||
|
audio_source = ios_audio_manager.create_ios_audio_source(
|
||||||
|
track.filepath, ios_capabilities, quality=quality
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use the potentially transcoded file path
|
||||||
|
final_file_path = audio_source["file_path"]
|
||||||
|
audio_type = audio_source["mime_type"]
|
||||||
|
|
||||||
|
# Create response with iOS-specific optimizations
|
||||||
|
response = send_from_directory(
|
||||||
|
Path(final_file_path).parent,
|
||||||
|
Path(final_file_path).name,
|
||||||
|
mimetype=audio_type,
|
||||||
|
conditional=True,
|
||||||
|
as_attachment=False, # Stream inline for iOS
|
||||||
|
)
|
||||||
|
|
||||||
|
# iOS-specific headers for optimal playback
|
||||||
|
response.headers["Accept-Ranges"] = "bytes"
|
||||||
|
response.headers["Cache-Control"] = "public, max-age=7200" # 2 hours
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
|
||||||
|
# Add iOS compatibility information
|
||||||
|
if ios_capabilities.is_ios:
|
||||||
|
response.headers["X-iOS-Optimized"] = "true"
|
||||||
|
response.headers["X-iOS-Device"] = (
|
||||||
|
"iPhone"
|
||||||
|
if "iPhone" in user_agent
|
||||||
|
else "iPad"
|
||||||
|
if "iPad" in user_agent
|
||||||
|
else "iPod"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add transcoding information
|
||||||
|
if audio_source["needs_transcoding"]:
|
||||||
|
response.headers["X-iOS-Transcoded"] = "true"
|
||||||
|
response.headers["X-iOS-Original-Format"] = guess_mime_type(
|
||||||
|
track.filepath
|
||||||
|
)
|
||||||
|
response.headers["X-iOS-Target-Format"] = audio_source["format"]
|
||||||
|
response.headers["X-iOS-Quality"] = quality
|
||||||
|
else:
|
||||||
|
response.headers["X-iOS-Transcoded"] = "false"
|
||||||
|
response.headers["X-iOS-Native-Format"] = "true"
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
return error_payload, error_status
|
||||||
|
|
||||||
|
|
||||||
|
# @api.get("/<trackhash>")
|
||||||
|
# def send_track_file(path: TrackHashSchema, query: SendTrackFileQuery):
|
||||||
|
# """
|
||||||
|
# Get a playable audio file with Range headers support
|
||||||
|
|
||||||
|
# Returns a playable audio file that corresponds to the given filepath. Falls back to track hash if filepath is not found.
|
||||||
|
|
||||||
|
# Transcoding can be done by sending the quality and container query parameters.
|
||||||
|
|
||||||
|
# **NOTES:**
|
||||||
|
# - Transcoded streams report incorrect duration during playback (idk why! FFMPEG gurus we need your help here).
|
||||||
|
# - The quality parameter is the desired bitrate in kbps.
|
||||||
|
# - The mp3 container is the best container for upto 320kbps (and has better duration reporting). The flac container allows for higher bitrates but it produces dramatically larger files (when transcoding from lossy formats).
|
||||||
|
# - You can get the transcoded bitrate by checking the X-Transcoded-Bitrate header on the first request's response.
|
||||||
|
# """
|
||||||
|
# trackhash = path.trackhash
|
||||||
|
# filepath = query.filepath
|
||||||
|
|
||||||
|
# # If filepath is provided, try to send that
|
||||||
|
# track = None
|
||||||
|
# tracks = TrackStore.get_tracks_by_filepaths([filepath])
|
||||||
|
|
||||||
|
# if len(tracks) > 0 and os.path.exists(filepath):
|
||||||
|
# track = tracks[0]
|
||||||
|
# else:
|
||||||
|
# res = TrackStore.trackhashmap.get(trackhash)
|
||||||
|
|
||||||
|
# # When finding by trackhash, sort by bitrate
|
||||||
|
# # and get the first track that exists
|
||||||
|
# if res is not None:
|
||||||
|
# tracks = sorted(res.tracks, key=lambda x: x.bitrate, reverse=True)
|
||||||
|
|
||||||
|
# for t in tracks:
|
||||||
|
# if os.path.exists(t.filepath):
|
||||||
|
# track = t
|
||||||
|
# break
|
||||||
|
|
||||||
|
# if track is not None:
|
||||||
|
# if query.quality == "original":
|
||||||
|
# return send_file_as_chunks(track.filepath)
|
||||||
|
|
||||||
|
# # prevent requesting over transcoding
|
||||||
|
# max_bitrate = track.bitrate
|
||||||
|
# requested_bitrate = int(query.quality)
|
||||||
|
|
||||||
|
# if query.container != "flac":
|
||||||
|
# # drop to 320 for non-flac containers
|
||||||
|
# requested_bitrate = min(320, requested_bitrate)
|
||||||
|
|
||||||
|
# quality = f"{min(max_bitrate, requested_bitrate)}k"
|
||||||
|
# return transcode_and_stream(trackhash, track.filepath, quality, query.container)
|
||||||
|
|
||||||
|
# return {"msg": "File Not Found"}, 404
|
||||||
|
|
||||||
|
|
||||||
|
def transcode_and_stream(trackhash: str, filepath: str, bitrate: str, container: str):
|
||||||
|
"""
|
||||||
|
Initiates transcoding and returns the first chunk of the transcoded file.
|
||||||
|
|
||||||
|
The other chunks are streamed on subsequent requests and are rerouted to `send_file_as_chunks`.
|
||||||
|
"""
|
||||||
|
temp_file = TransCodeStore.find(trackhash)
|
||||||
|
if temp_file is not None:
|
||||||
|
return send_file_as_chunks(temp_file)
|
||||||
|
|
||||||
|
format_params = {
|
||||||
|
"mp3": ["-c:a", "libmp3lame"],
|
||||||
|
"aac": ["-c:a", "aac"],
|
||||||
|
"webm": ["-c:a", "libopus"],
|
||||||
|
"ogg": ["-c:a", "libvorbis"],
|
||||||
|
"flac": ["-c:a", "flac"],
|
||||||
|
"wav": ["-c:a", "pcm_s16le"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create a temporary file
|
||||||
|
format = f".{container}" if container in format_params else ".flac"
|
||||||
|
container_args = (
|
||||||
|
format_params[container]
|
||||||
|
if container in format_params
|
||||||
|
else format_params["flac"]
|
||||||
|
)
|
||||||
|
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=format)
|
||||||
|
temp_filename = temp_file.name
|
||||||
|
temp_file.close()
|
||||||
|
|
||||||
|
TransCodeStore.add_file(trackhash, temp_filename)
|
||||||
|
start_transcoding(filepath, temp_filename, bitrate, container_args)
|
||||||
|
|
||||||
|
chunk_size = 1024 * 512 # 0.5MB
|
||||||
|
file_size = os.path.getsize(filepath)
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
# Poll for the output file
|
||||||
|
while (
|
||||||
|
not os.path.exists(temp_filename)
|
||||||
|
or os.path.getsize(temp_filename) < chunk_size
|
||||||
|
):
|
||||||
|
print(f"Waiting for transcoding to complete... filename: {temp_filename}")
|
||||||
|
time.sleep(0.1) # Wait for 100ms before checking again
|
||||||
|
|
||||||
|
with open(temp_filename, "rb") as file:
|
||||||
|
file.seek(0)
|
||||||
|
return file.read(chunk_size)
|
||||||
|
|
||||||
|
audio_type = guess_mime_type(temp_filename)
|
||||||
|
response = Response(
|
||||||
|
generate(),
|
||||||
|
206,
|
||||||
|
mimetype=audio_type,
|
||||||
|
content_type=audio_type,
|
||||||
|
direct_passthrough=True,
|
||||||
|
)
|
||||||
|
response.headers.add("Content-Range", f"bytes {0}-{chunk_size}/{file_size}")
|
||||||
|
response.headers.add("Accept-Ranges", "bytes")
|
||||||
|
response.headers.add("X-Transcoded-Bitrate", bitrate)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def send_file_as_chunks(filepath: str) -> Response:
|
||||||
|
"""
|
||||||
|
Returns a Response object that streams the file in chunks.
|
||||||
|
"""
|
||||||
|
# NOTE: +1 makes sure the last byte is included in the range.
|
||||||
|
# NOTE: -1 is used to convert the end index to a 0-based index.
|
||||||
|
chunk_size = 1024 * 512 # 0.5MB
|
||||||
|
|
||||||
|
# Get file size
|
||||||
|
file_size = os.path.getsize(filepath)
|
||||||
|
start = 0
|
||||||
|
end = chunk_size
|
||||||
|
|
||||||
|
# Read range header
|
||||||
|
range_header = request.headers.get("Range")
|
||||||
|
if range_header:
|
||||||
|
start = get_start_range(range_header)
|
||||||
|
|
||||||
|
# If start + chunk_size is greater than file_size,
|
||||||
|
# set end to file_size - 1
|
||||||
|
_end = start + chunk_size - 1
|
||||||
|
|
||||||
|
end = file_size - 1 if _end > file_size else _end
|
||||||
|
|
||||||
|
def generate_chunks():
|
||||||
|
with open(filepath, "rb") as file:
|
||||||
|
file.seek(start)
|
||||||
|
remaining_bytes = end - start + 1
|
||||||
|
|
||||||
|
retry_count = 0
|
||||||
|
max_retries = 10 # 5 * 100ms = 500ms total wait time
|
||||||
|
|
||||||
|
while remaining_bytes > 0 or retry_count < max_retries:
|
||||||
|
if retry_count == max_retries:
|
||||||
|
print("💚 sending final chunk! ...")
|
||||||
|
|
||||||
|
pos = file.tell()
|
||||||
|
chunk = file.read(os.path.getsize(filepath) - pos)
|
||||||
|
|
||||||
|
return chunk, pos, True
|
||||||
|
|
||||||
|
if remaining_bytes < chunk_size:
|
||||||
|
time.sleep(0.25)
|
||||||
|
retry_count += 1
|
||||||
|
remaining_bytes = os.path.getsize(filepath) - file.tell()
|
||||||
|
continue
|
||||||
|
|
||||||
|
chunk = file.read(min(chunk_size, remaining_bytes))
|
||||||
|
if chunk:
|
||||||
|
remaining_bytes -= len(chunk)
|
||||||
|
return chunk, file.tell(), False
|
||||||
|
else:
|
||||||
|
# If no data is read, wait for 100ms before retrying
|
||||||
|
time.sleep(0.25)
|
||||||
|
retry_count += 1
|
||||||
|
|
||||||
|
# update remaining bytes
|
||||||
|
remaining_bytes = os.path.getsize(filepath) - file.tell()
|
||||||
|
print(f"▶ Remaining bytes: {remaining_bytes}")
|
||||||
|
|
||||||
|
return None, 0, True
|
||||||
|
|
||||||
|
data, position, is_final = generate_chunks()
|
||||||
|
|
||||||
|
audio_type = guess_mime_type(filepath)
|
||||||
|
response = Response(
|
||||||
|
response=data,
|
||||||
|
status=206, # Partial Content status code
|
||||||
|
mimetype=audio_type,
|
||||||
|
content_type=audio_type,
|
||||||
|
direct_passthrough=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
bytes_to_add = chunk_size if not is_final else 0
|
||||||
|
response.headers.add(
|
||||||
|
"Content-Range",
|
||||||
|
f"bytes {start}-{position}/{os.path.getsize(filepath) + bytes_to_add}",
|
||||||
|
)
|
||||||
|
response.headers.add("Access-Control-Expose-Headers", "Content-Range")
|
||||||
|
response.headers.add("Accept-Ranges", "bytes")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def get_start_range(range_header: str):
|
||||||
|
try:
|
||||||
|
range_start, range_end = range_header.strip().split("=")[1].split("-")
|
||||||
|
return int(range_start)
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class GetAudioSilenceBody(BaseModel):
|
||||||
|
ending_file: str = Field(description="The ending file's path")
|
||||||
|
starting_file: str = Field(description="The beginning file's path")
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/silence")
|
||||||
|
def get_audio_silence(body: GetAudioSilenceBody):
|
||||||
|
"""
|
||||||
|
Get silence paddings
|
||||||
|
|
||||||
|
Returns the duration of silence at the end of the current ending track and the duration of silence at the beginning of the next track.
|
||||||
|
|
||||||
|
NOTE: Durations are in milliseconds.
|
||||||
|
"""
|
||||||
|
ending_file = body.ending_file # ending file's filepath
|
||||||
|
starting_file = body.starting_file # starting file's filepath
|
||||||
|
|
||||||
|
if ending_file is None or starting_file is None:
|
||||||
|
return {"msg": "No filepath provided"}, 400
|
||||||
|
|
||||||
|
return get_silence_paddings(ending_file, starting_file)
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
"""Unified multi-service downloader API backed by durable download jobs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
from flask_jwt_extended import get_jwt_identity
|
||||||
|
|
||||||
|
from swingmusic.services.download_jobs import download_job_manager
|
||||||
|
from swingmusic.services.spotify_downloader import spotify_downloader
|
||||||
|
from swingmusic.services.universal_url_parser import universal_url_parser
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
from swingmusic.utils.hashing import create_hash
|
||||||
|
|
||||||
|
universal_downloader_bp = Blueprint(
|
||||||
|
"universal_downloader", __name__, url_prefix="/api/universal"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _current_userid() -> int:
|
||||||
|
try:
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
if isinstance(identity, dict) and identity.get("id") is not None:
|
||||||
|
return int(identity["id"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return get_current_userid()
|
||||||
|
|
||||||
|
|
||||||
|
def _quality_to_job(quality: str | None) -> tuple[str, str]:
|
||||||
|
quality = (quality or "high").lower()
|
||||||
|
mapping = {
|
||||||
|
"lossless": ("lossless", "flac"),
|
||||||
|
"high": ("high", "mp3"),
|
||||||
|
"medium": ("medium", "mp3"),
|
||||||
|
"low": ("low", "mp3"),
|
||||||
|
}
|
||||||
|
return mapping.get(quality, ("high", "mp3"))
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_jobs(jobs: list[dict]) -> list[dict]:
|
||||||
|
serialized = []
|
||||||
|
for job in jobs:
|
||||||
|
payload = job.get("payload") or {}
|
||||||
|
serialized.append(
|
||||||
|
{
|
||||||
|
"id": str(job.get("id")),
|
||||||
|
"url": job.get("source_url"),
|
||||||
|
"title": job.get("title") or payload.get("title"),
|
||||||
|
"artist": job.get("artist") or payload.get("artist"),
|
||||||
|
"album": job.get("album") or payload.get("album"),
|
||||||
|
"service": job.get("source") or payload.get("service") or "generic",
|
||||||
|
"item_type": job.get("item_type")
|
||||||
|
or payload.get("item_type")
|
||||||
|
or "track",
|
||||||
|
"quality": job.get("quality") or "high",
|
||||||
|
"status": job.get("state"),
|
||||||
|
"state": job.get("state"),
|
||||||
|
"progress": job.get("progress") or 0,
|
||||||
|
"error_message": job.get("error"),
|
||||||
|
"file_path": job.get("target_path"),
|
||||||
|
"created_at": job.get("created_at"),
|
||||||
|
"started_at": job.get("started_at"),
|
||||||
|
"finished_at": job.get("finished_at"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return serialized
|
||||||
|
|
||||||
|
|
||||||
|
@universal_downloader_bp.route("/download", methods=["POST"])
|
||||||
|
def add_download():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
url = (data.get("url") or "").strip()
|
||||||
|
if not url:
|
||||||
|
return jsonify({"error": "URL is required"}), 400
|
||||||
|
|
||||||
|
parsed = universal_url_parser.parse_url(url)
|
||||||
|
if not parsed:
|
||||||
|
return jsonify({"error": "Unsupported URL format"}), 400
|
||||||
|
|
||||||
|
quality, codec = _quality_to_job(data.get("quality"))
|
||||||
|
output_dir = data.get("output_dir")
|
||||||
|
userid = _current_userid()
|
||||||
|
|
||||||
|
title = None
|
||||||
|
artist = None
|
||||||
|
album = None
|
||||||
|
trackhash = None
|
||||||
|
|
||||||
|
if parsed.service.value == "spotify":
|
||||||
|
metadata = asyncio.run(spotify_downloader.get_metadata(url))
|
||||||
|
if metadata:
|
||||||
|
title = metadata.title
|
||||||
|
artist = metadata.artist
|
||||||
|
album = metadata.album
|
||||||
|
if metadata.item_type == "track" and title and artist:
|
||||||
|
trackhash = create_hash(title, album or "", artist)
|
||||||
|
|
||||||
|
job_id = download_job_manager.enqueue(
|
||||||
|
userid=userid,
|
||||||
|
source_url=url,
|
||||||
|
source=parsed.service.value,
|
||||||
|
quality=quality,
|
||||||
|
codec=codec,
|
||||||
|
trackhash=trackhash,
|
||||||
|
title=title,
|
||||||
|
artist=artist,
|
||||||
|
album=album,
|
||||||
|
item_type=parsed.item_type,
|
||||||
|
target_path=output_dir,
|
||||||
|
payload={
|
||||||
|
"service": parsed.service.value,
|
||||||
|
"item_type": parsed.item_type,
|
||||||
|
"service_id": parsed.id,
|
||||||
|
"metadata": parsed.metadata,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"item_id": str(job_id),
|
||||||
|
"service": parsed.service.value,
|
||||||
|
"item_type": parsed.item_type,
|
||||||
|
"message": f"Added to download queue from {parsed.service.value}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@universal_downloader_bp.route("/metadata", methods=["POST"])
|
||||||
|
def get_metadata():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
url = (data.get("url") or "").strip()
|
||||||
|
if not url:
|
||||||
|
return jsonify({"error": "URL is required"}), 400
|
||||||
|
|
||||||
|
parsed = universal_url_parser.parse_url(url)
|
||||||
|
if not parsed:
|
||||||
|
return jsonify({"error": "Unsupported URL format"}), 400
|
||||||
|
|
||||||
|
if parsed.service.value == "spotify":
|
||||||
|
metadata = asyncio.run(spotify_downloader.get_metadata(url))
|
||||||
|
if metadata:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"service": "spotify",
|
||||||
|
"service_id": metadata.spotify_id,
|
||||||
|
"item_type": metadata.item_type,
|
||||||
|
"title": metadata.title,
|
||||||
|
"artist": metadata.artist,
|
||||||
|
"album": metadata.album,
|
||||||
|
"duration_ms": metadata.duration_ms,
|
||||||
|
"image_url": metadata.image_url,
|
||||||
|
"release_date": metadata.release_date,
|
||||||
|
"explicit": metadata.is_explicit,
|
||||||
|
"preview_url": metadata.preview_url,
|
||||||
|
"original_url": url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"service": parsed.service.value,
|
||||||
|
"service_id": parsed.id,
|
||||||
|
"item_type": parsed.item_type,
|
||||||
|
"title": f"{parsed.service.value.title()} {parsed.item_type.title()}",
|
||||||
|
"artist": "Unknown Artist",
|
||||||
|
"album": "",
|
||||||
|
"duration_ms": None,
|
||||||
|
"image_url": None,
|
||||||
|
"release_date": None,
|
||||||
|
"explicit": False,
|
||||||
|
"preview_url": None,
|
||||||
|
"original_url": url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@universal_downloader_bp.route("/queue", methods=["GET"])
|
||||||
|
def get_queue_status():
|
||||||
|
userid = _current_userid()
|
||||||
|
jobs = download_job_manager.list_jobs(userid, limit=500)
|
||||||
|
|
||||||
|
queued = [job for job in jobs if job["state"] in {"queued", "downloading"}]
|
||||||
|
active = [job for job in jobs if job["state"] == "downloading"]
|
||||||
|
history = [
|
||||||
|
job for job in jobs if job["state"] in {"completed", "failed", "cancelled"}
|
||||||
|
]
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"queue_length": len([job for job in jobs if job["state"] == "queued"]),
|
||||||
|
"active_downloads": len(active),
|
||||||
|
"queue": _serialize_jobs(queued),
|
||||||
|
"pending": _serialize_jobs(
|
||||||
|
[job for job in jobs if job["state"] == "queued"]
|
||||||
|
),
|
||||||
|
"active": _serialize_jobs(active),
|
||||||
|
"history": _serialize_jobs(history),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@universal_downloader_bp.route("/queue/<item_id>/cancel", methods=["POST"])
|
||||||
|
def cancel_download(item_id: str):
|
||||||
|
userid = _current_userid()
|
||||||
|
try:
|
||||||
|
success = download_job_manager.cancel(int(item_id), userid)
|
||||||
|
except ValueError:
|
||||||
|
success = False
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({"success": True, "message": "Download cancelled"})
|
||||||
|
|
||||||
|
return jsonify({"error": "Download not found or cannot be cancelled"}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@universal_downloader_bp.route("/queue/<item_id>/retry", methods=["POST"])
|
||||||
|
def retry_download(item_id: str):
|
||||||
|
userid = _current_userid()
|
||||||
|
try:
|
||||||
|
success = download_job_manager.retry(int(item_id), userid)
|
||||||
|
except ValueError:
|
||||||
|
success = False
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({"success": True, "message": "Download retry added to queue"})
|
||||||
|
|
||||||
|
return jsonify({"error": "Download not found or cannot be retried"}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@universal_downloader_bp.route("/history", methods=["GET"])
|
||||||
|
def get_download_history():
|
||||||
|
limit = min(int(request.args.get("limit", 100)), 500)
|
||||||
|
offset = int(request.args.get("offset", 0))
|
||||||
|
userid = _current_userid()
|
||||||
|
|
||||||
|
jobs = download_job_manager.list_jobs(userid, limit=1000)
|
||||||
|
history = [
|
||||||
|
job for job in jobs if job["state"] in {"completed", "failed", "cancelled"}
|
||||||
|
]
|
||||||
|
sliced = history[offset : offset + limit]
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"downloads": _serialize_jobs(sliced),
|
||||||
|
"total": len(history),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@universal_downloader_bp.route("/services", methods=["GET"])
|
||||||
|
def get_supported_services():
|
||||||
|
services = universal_url_parser.get_supported_services()
|
||||||
|
return jsonify({"services": services, "total": len(services)})
|
||||||
|
|
||||||
|
|
||||||
|
@universal_downloader_bp.route("/services/<service_name>/enable", methods=["POST"])
|
||||||
|
def enable_service(service_name: str):
|
||||||
|
return jsonify({"success": True, "message": f"{service_name} service enabled"})
|
||||||
|
|
||||||
|
|
||||||
|
@universal_downloader_bp.route("/services/<service_name>/disable", methods=["POST"])
|
||||||
|
def disable_service(service_name: str):
|
||||||
|
return jsonify({"success": True, "message": f"{service_name} service disabled"})
|
||||||
|
|
||||||
|
|
||||||
|
@universal_downloader_bp.route(
|
||||||
|
"/services/<service_name>/config", methods=["GET", "POST"]
|
||||||
|
)
|
||||||
|
def service_config(service_name: str):
|
||||||
|
if request.method == "GET":
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"service": service_name,
|
||||||
|
"display_name": service_name.replace("_", " ").title(),
|
||||||
|
"enabled": True,
|
||||||
|
"priority": 0,
|
||||||
|
"supported_types": [],
|
||||||
|
"features": ["metadata", "download"],
|
||||||
|
"config": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({"success": True, "message": "Service configuration updated"})
|
||||||
|
|
||||||
|
|
||||||
|
@universal_downloader_bp.route("/validate-url", methods=["POST"])
|
||||||
|
def validate_url():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
url = (data.get("url") or "").strip()
|
||||||
|
if not url:
|
||||||
|
return jsonify({"error": "URL is required"}), 400
|
||||||
|
|
||||||
|
parsed = universal_url_parser.parse_url(url)
|
||||||
|
if parsed:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"valid": True,
|
||||||
|
"service": parsed.service.value,
|
||||||
|
"item_type": parsed.item_type,
|
||||||
|
"id": parsed.id,
|
||||||
|
"metadata": parsed.metadata,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({"valid": False, "error": "Unsupported URL format"})
|
||||||
|
|
||||||
|
|
||||||
|
@universal_downloader_bp.route("/statistics", methods=["GET"])
|
||||||
|
def get_statistics():
|
||||||
|
userid = _current_userid()
|
||||||
|
jobs = download_job_manager.list_jobs(userid, limit=1000)
|
||||||
|
|
||||||
|
stats: dict[str, dict[str, int]] = defaultdict(dict)
|
||||||
|
grouped = defaultdict(Counter)
|
||||||
|
|
||||||
|
for job in jobs:
|
||||||
|
source = job.get("source") or "generic"
|
||||||
|
state = job.get("state") or "unknown"
|
||||||
|
grouped[source][state] += 1
|
||||||
|
|
||||||
|
for source, counts in grouped.items():
|
||||||
|
stats[source] = dict(counts)
|
||||||
|
|
||||||
|
return jsonify({"statistics": stats})
|
||||||
|
|
||||||
|
|
||||||
|
@universal_downloader_bp.route("/batch", methods=["POST"])
|
||||||
|
def batch_download():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
urls = data.get("urls") or []
|
||||||
|
if not isinstance(urls, list) or len(urls) == 0:
|
||||||
|
return jsonify({"error": "URLs array is required"}), 400
|
||||||
|
|
||||||
|
quality = data.get("quality")
|
||||||
|
output_dir = data.get("output_dir")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for url in urls:
|
||||||
|
value = (url or "").strip()
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parsed = universal_url_parser.parse_url(value)
|
||||||
|
if not parsed:
|
||||||
|
results.append(
|
||||||
|
{"url": value, "success": False, "error": "Unsupported URL format"}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
quality_name, codec = _quality_to_job(quality)
|
||||||
|
userid = _current_userid()
|
||||||
|
|
||||||
|
job_id = download_job_manager.enqueue(
|
||||||
|
userid=userid,
|
||||||
|
source_url=value,
|
||||||
|
source=parsed.service.value,
|
||||||
|
quality=quality_name,
|
||||||
|
codec=codec,
|
||||||
|
item_type=parsed.item_type,
|
||||||
|
target_path=output_dir,
|
||||||
|
payload={
|
||||||
|
"service": parsed.service.value,
|
||||||
|
"item_type": parsed.item_type,
|
||||||
|
"service_id": parsed.id,
|
||||||
|
"metadata": parsed.metadata,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"url": value,
|
||||||
|
"success": True,
|
||||||
|
"item_id": str(job_id),
|
||||||
|
"service": parsed.service.value,
|
||||||
|
"item_type": parsed.item_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
successful = sum(1 for item in results if item["success"])
|
||||||
|
failed = len(results) - successful
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"total": len(results),
|
||||||
|
"successful": successful,
|
||||||
|
"failed": failed,
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_universal_downloader_api(app):
|
||||||
|
app.register_blueprint(universal_downloader_bp)
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""
|
||||||
|
Update Tracking API Endpoints
|
||||||
|
|
||||||
|
Provides stable endpoints for following artists, update preferences,
|
||||||
|
recent release updates, and dashboard statistics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from swingmusic.services.update_tracker import (
|
||||||
|
VALID_CHECK_FREQUENCIES,
|
||||||
|
VALID_FOLLOW_LEVELS,
|
||||||
|
VALID_QUALITY_VALUES,
|
||||||
|
VALID_RELEASE_TYPES,
|
||||||
|
update_tracker,
|
||||||
|
)
|
||||||
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
update_tracking_bp = Blueprint("update_tracking", __name__, url_prefix="/api/updates")
|
||||||
|
|
||||||
|
|
||||||
|
def _error(message: str, status: int = 400):
|
||||||
|
return jsonify({"error": message}), status
|
||||||
|
|
||||||
|
|
||||||
|
def _user_id() -> int:
|
||||||
|
return int(get_current_userid())
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_limit(value: Any, default: int, max_value: int) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
parsed = default
|
||||||
|
|
||||||
|
return max(0, min(parsed, max_value))
|
||||||
|
|
||||||
|
|
||||||
|
@update_tracking_bp.post("/follow-artist")
|
||||||
|
def follow_artist():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
artist_id = str(data.get("artist_id") or "").strip()
|
||||||
|
|
||||||
|
if not artist_id:
|
||||||
|
return _error("artist_id is required")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"user_id": _user_id(),
|
||||||
|
"artist_id": artist_id,
|
||||||
|
"artist_name": str(data.get("artist_name") or artist_id),
|
||||||
|
"follow_level": str(data.get("follow_level") or "followed"),
|
||||||
|
"auto_download": bool(data.get("auto_download", False)),
|
||||||
|
"preferred_quality": str(data.get("preferred_quality") or "flac"),
|
||||||
|
"notification_preferences": data.get("notification_preferences"),
|
||||||
|
"image": data.get("image"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload["follow_level"] not in VALID_FOLLOW_LEVELS:
|
||||||
|
return _error("Invalid follow_level")
|
||||||
|
|
||||||
|
if payload["preferred_quality"] not in VALID_QUALITY_VALUES:
|
||||||
|
return _error("Invalid preferred_quality")
|
||||||
|
|
||||||
|
success = update_tracker.follow_artist(payload)
|
||||||
|
if not success:
|
||||||
|
return _error("Failed to follow artist", 500)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"message": "Artist followed successfully",
|
||||||
|
"artist_id": artist_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@update_tracking_bp.post("/unfollow-artist")
|
||||||
|
def unfollow_artist():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
artist_id = str(data.get("artist_id") or "").strip()
|
||||||
|
|
||||||
|
if not artist_id:
|
||||||
|
return _error("artist_id is required")
|
||||||
|
|
||||||
|
success = update_tracker.unfollow_artist(_user_id(), artist_id)
|
||||||
|
if not success:
|
||||||
|
return _error("Artist not followed", 404)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"message": "Artist unfollowed successfully",
|
||||||
|
"artist_id": artist_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@update_tracking_bp.get("/recent")
|
||||||
|
def get_recent_updates():
|
||||||
|
limit = _safe_limit(request.args.get("limit"), default=20, max_value=100)
|
||||||
|
offset = _safe_limit(request.args.get("offset"), default=0, max_value=100000)
|
||||||
|
release_type = request.args.get("release_type")
|
||||||
|
unread_only = str(request.args.get("unread_only", "false")).lower() == "true"
|
||||||
|
|
||||||
|
if release_type and release_type not in VALID_RELEASE_TYPES:
|
||||||
|
return _error("Invalid release_type")
|
||||||
|
|
||||||
|
updates = update_tracker.get_user_updates(
|
||||||
|
user_id=_user_id(),
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
release_type=release_type,
|
||||||
|
unread_only=unread_only,
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"updates": updates,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"total": len(updates),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@update_tracking_bp.get("/settings")
|
||||||
|
def get_settings():
|
||||||
|
return jsonify(update_tracker.get_user_settings(_user_id()))
|
||||||
|
|
||||||
|
|
||||||
|
@update_tracking_bp.post("/settings")
|
||||||
|
def update_settings():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
check_frequency = data.get("checkFrequency", data.get("check_frequency"))
|
||||||
|
if check_frequency and check_frequency not in VALID_CHECK_FREQUENCIES:
|
||||||
|
return _error("Invalid checkFrequency")
|
||||||
|
|
||||||
|
quality_preference = data.get("qualityPreference", data.get("quality_preference"))
|
||||||
|
if quality_preference and quality_preference not in VALID_QUALITY_VALUES:
|
||||||
|
return _error("Invalid qualityPreference")
|
||||||
|
|
||||||
|
if not update_tracker.update_user_settings(_user_id(), data):
|
||||||
|
return _error("Failed to update settings", 500)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"message": "Settings updated successfully",
|
||||||
|
"settings": update_tracker.get_user_settings(_user_id()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@update_tracking_bp.post("/auto-download/<release_id>")
|
||||||
|
def auto_download_release(release_id: str):
|
||||||
|
if not update_tracker.auto_download_release(_user_id(), release_id):
|
||||||
|
return _error("Release not found", 404)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"message": "Download queued successfully",
|
||||||
|
"release_id": release_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@update_tracking_bp.get("/stats")
|
||||||
|
def get_update_stats():
|
||||||
|
stats = update_tracker.get_user_stats(_user_id())
|
||||||
|
return jsonify({"stats": stats})
|
||||||
|
|
||||||
|
|
||||||
|
@update_tracking_bp.get("/followed-artists")
|
||||||
|
def get_followed_artists():
|
||||||
|
limit = _safe_limit(request.args.get("limit"), default=50, max_value=200)
|
||||||
|
offset = _safe_limit(request.args.get("offset"), default=0, max_value=100000)
|
||||||
|
follow_level = request.args.get("follow_level")
|
||||||
|
|
||||||
|
if follow_level and follow_level not in VALID_FOLLOW_LEVELS:
|
||||||
|
return _error("Invalid follow_level")
|
||||||
|
|
||||||
|
artists = update_tracker.get_followed_artists(
|
||||||
|
user_id=_user_id(),
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
follow_level=follow_level,
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"artists": artists,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"total": len(artists),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@update_tracking_bp.get("/artist/<artist_id>/follow-status")
|
||||||
|
def get_artist_follow_status(artist_id: str):
|
||||||
|
status = update_tracker.get_artist_follow_status(_user_id(), artist_id)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
return jsonify(status)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"is_following": False,
|
||||||
|
"artist_id": artist_id,
|
||||||
|
"follow_level": "followed",
|
||||||
|
"auto_download_new_releases": False,
|
||||||
|
"preferred_quality": "flac",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@update_tracking_bp.route("/artist/<artist_id>", methods=["POST", "PUT"])
|
||||||
|
def update_artist_follow(artist_id: str):
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
follow_level = data.get("follow_level")
|
||||||
|
if follow_level and follow_level not in VALID_FOLLOW_LEVELS:
|
||||||
|
return _error("Invalid follow_level")
|
||||||
|
|
||||||
|
preferred_quality = data.get("preferred_quality")
|
||||||
|
if preferred_quality and preferred_quality not in VALID_QUALITY_VALUES:
|
||||||
|
return _error("Invalid preferred_quality")
|
||||||
|
|
||||||
|
success = update_tracker.update_artist_follow(_user_id(), artist_id, data)
|
||||||
|
if not success:
|
||||||
|
return _error("Failed to update artist", 500)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"message": "Artist follow settings updated",
|
||||||
|
"artist_id": artist_id,
|
||||||
|
"settings": data,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@update_tracking_bp.get("/search/artists")
|
||||||
|
def search_artists():
|
||||||
|
query = str(request.args.get("q") or "").strip()
|
||||||
|
limit = _safe_limit(request.args.get("limit"), default=20, max_value=100)
|
||||||
|
|
||||||
|
artists = update_tracker.search_artists(query, _user_id(), limit=limit)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"artists": artists,
|
||||||
|
"query": query,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@update_tracking_bp.post("/release/<release_id>/mark-read")
|
||||||
|
def mark_release_read(release_id: str):
|
||||||
|
if not update_tracker.mark_release_read(_user_id(), release_id):
|
||||||
|
return _error("Failed to mark release as read", 500)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"message": "Marked release as read",
|
||||||
|
"release_id": release_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@update_tracking_bp.post("/notifications/mark-all-read")
|
||||||
|
def mark_all_read():
|
||||||
|
count = update_tracker.mark_all_notifications_read(_user_id())
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"message": "All notifications marked as read",
|
||||||
|
"updated": count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@update_tracking_bp.get("/export/followed-artists")
|
||||||
|
def export_followed_artists():
|
||||||
|
export_format = str(request.args.get("format") or "json").lower()
|
||||||
|
artists = update_tracker.export_followed_artists(_user_id())
|
||||||
|
|
||||||
|
if export_format == "csv":
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.DictWriter(
|
||||||
|
output,
|
||||||
|
fieldnames=[
|
||||||
|
"artist_id",
|
||||||
|
"artist_name",
|
||||||
|
"follow_level",
|
||||||
|
"auto_download",
|
||||||
|
"preferred_quality",
|
||||||
|
"follow_date",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(artists)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
output.getvalue(),
|
||||||
|
mimetype="text/csv",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": "attachment; filename=followed_artists.csv",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({"followed_artists": artists})
|
||||||
|
|
||||||
|
|
||||||
|
@update_tracking_bp.errorhandler(404)
|
||||||
|
def not_found(_error):
|
||||||
|
return jsonify({"error": "Endpoint not found"}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@update_tracking_bp.errorhandler(500)
|
||||||
|
def internal_error(_error):
|
||||||
|
return jsonify({"error": "Internal server error"}), 500
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user