ci / module boundaries / boundaries (push) Successful in 10s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m30s
ci / api / api (push) Successful in 58s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Failing after 25s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m36s
ci / ts / ts (packages + web) (push) Failing after 6m3s
243 lines
8.1 KiB
Go
243 lines
8.1 KiB
Go
// Команда api — публичный REST API Pipu Music.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/redis/go-redis/v9"
|
|
|
|
"pipu/api/internal/acoustid"
|
|
"pipu/api/internal/appupdate"
|
|
"pipu/api/internal/auth"
|
|
"pipu/api/internal/buildinfo"
|
|
"pipu/api/internal/genius"
|
|
"pipu/api/internal/library"
|
|
"pipu/api/internal/management"
|
|
"pipu/api/internal/musicbrainz"
|
|
"pipu/api/internal/platform/config"
|
|
"pipu/api/internal/platform/db"
|
|
"pipu/api/internal/platform/httpx"
|
|
"pipu/api/internal/platform/sign"
|
|
"pipu/api/internal/playback"
|
|
"pipu/api/internal/search"
|
|
"pipu/api/internal/server"
|
|
"pipu/api/internal/social"
|
|
"pipu/api/internal/system"
|
|
"pipu/api/internal/uploads"
|
|
"pipu/api/internal/wave"
|
|
)
|
|
|
|
func main() {
|
|
if err := run(); err != nil {
|
|
slog.Error("api stopped", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run() error {
|
|
cfg, err := config.Load[config.API]()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log := httpx.NewLogger(cfg.LogLevel)
|
|
slog.SetDefault(log)
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
pool, err := db.Connect(ctx, cfg.DatabaseURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer pool.Close()
|
|
|
|
if cfg.MigrateOnStart {
|
|
if err := db.Migrate(ctx, pool); err != nil {
|
|
return err
|
|
}
|
|
log.Info("migrations applied")
|
|
}
|
|
|
|
redisOpts, err := redis.ParseURL(cfg.RedisURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rdb := redis.NewClient(redisOpts)
|
|
defer func() { _ = rdb.Close() }()
|
|
|
|
handler, err := newApp(ctx, cfg, pool, rdb, log, library.FFprobe{Bin: cfg.FFprobePath, Timeout: time.Minute})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
srv := httpx.NewServer(cfg.HTTPAddr, handler)
|
|
log.Info("starting", "version", buildinfo.Version)
|
|
return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
|
|
}
|
|
|
|
// newApp собирает сервис: хранилища, сканер, плеер, волну, очередь и HTTP-роутер. Фоновые задачи живут до отмены ctx.
|
|
// prober подменяется в сквозном тесте (там нет ffprobe).
|
|
func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.Client, log *slog.Logger,
|
|
prober library.Prober) (http.Handler, error) {
|
|
authSvc, err := newAuthService(cfg, pool, rdb, log)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := authSvc.Bootstrap(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sys := system.NewHandler(system.Info{
|
|
Version: buildinfo.Version,
|
|
SupportedLocales: cfg.SupportedLocales,
|
|
MinAndroidVersionCode: cfg.MinAndroidVersionCode,
|
|
BootstrapRequired: authSvc.BootstrapRequired,
|
|
}, log, map[string]system.Checker{
|
|
"postgres": system.CheckerFunc(pool.Ping),
|
|
"redis": system.CheckerFunc(func(ctx context.Context) error { return rdb.Ping(ctx).Err() }),
|
|
"search": system.CheckerFunc(search.NewMeili(cfg.MeiliURL, cfg.MeiliMasterKey).Healthy),
|
|
})
|
|
|
|
libStore := library.NewStore(pool)
|
|
geniusSvc := genius.NewService(pool, cfg.GeniusAccessToken, cfg.GeniusRequestInterval, log)
|
|
go geniusSvc.Run(ctx)
|
|
board := library.NewProgressBoard()
|
|
scanner := library.NewScanner(libStore, prober, board, log)
|
|
agent := library.NewHTTPAgent(cfg.StorageAgentURL, cfg.InternalToken)
|
|
libSvc := library.NewService(libStore, scanner, board, agent,
|
|
prober, library.Config{LibraryRoot: cfg.LibraryRoot, HostRoot: cfg.LibraryHostRoot}, log)
|
|
meili := search.NewMeili(cfg.MeiliURL, cfg.MeiliMasterKey)
|
|
indexer := search.NewIndexer(pool, meili, log)
|
|
signer, err := sign.New(cfg.SigningKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg.SigningKey == "" {
|
|
log.Warn("STREAM_SIGNING_KEY is not set: cover and stream links will break on restart")
|
|
}
|
|
covers := library.NewCovers(libStore, libSvc, cfg.FFmpegPath, filepath.Join(cfg.CacheDir, "covers"), log)
|
|
fingerprints := library.NewFingerprinter(libStore, library.FPCalc{Bin: cfg.FPCalcPath, Timeout: time.Minute},
|
|
func(ctx context.Context, sourceID uuid.UUID) (string, error) {
|
|
src, err := libStore.GetSource(ctx, sourceID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return libSvc.Root(src)
|
|
}, log)
|
|
scanner.AfterScan = func(ctx context.Context) {
|
|
covers.Run(ctx)
|
|
indexer.Trigger()
|
|
fingerprints.Trigger(ctx)
|
|
geniusSvc.Wake()
|
|
}
|
|
go covers.Run(ctx) // альбомы, оставшиеся без обложки с прошлого запуска
|
|
fingerprints.Trigger(ctx)
|
|
|
|
concurrency := cfg.TranscodeConcurrency
|
|
if concurrency <= 0 {
|
|
concurrency = max(1, runtime.NumCPU()/2)
|
|
}
|
|
transcoder := playback.NewTranscoder(cfg.FFmpegPath, filepath.Join(cfg.CacheDir, "transcode"), concurrency,
|
|
cfg.TranscodeCacheLimitGB, log)
|
|
go transcoder.RunCleanup(ctx)
|
|
rootFor := func(ctx context.Context, sourceID uuid.UUID) (string, error) {
|
|
src, err := libStore.GetSource(ctx, sourceID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return libSvc.Root(src)
|
|
}
|
|
pbStore := playback.NewStore(pool)
|
|
dto := library.DTO{Signer: signer, Personal: playback.NewPersonalizer(pbStore, signer)}
|
|
pb := playback.NewHandler(pbStore, libStore, dto, signer, transcoder,
|
|
rootFor, cfg.XAccel, log)
|
|
libSvc.OnCatalogChanged = indexer.Trigger
|
|
indexer.Trigger() // индекс мог отстать (новая версия, сбой Meilisearch) — пересобираем при старте
|
|
go libSvc.Restore(ctx)
|
|
go libSvc.RunSchedule(ctx)
|
|
|
|
upStore := uploads.NewStore(pool)
|
|
upSvc := uploads.NewService(upStore, libStore, libSvc, scanner, agent, prober,
|
|
library.FPCalc{Bin: cfg.FPCalcPath, Timeout: time.Minute},
|
|
uploads.Config{StagingDir: cfg.UploadsDir, FFmpeg: cfg.FFmpegPath}, log)
|
|
if cfg.MusicBrainzEnabled {
|
|
upSvc.SetReleaseCatalogue(musicbrainz.New(upStore, "PipuMusic/"+buildinfo.Version+" (https://gitea.hsrv.site/mrleo1nid/pipu-music)"))
|
|
if strings.TrimSpace(cfg.AcoustIDAPIKey) != "" {
|
|
upSvc.SetAudioIdentifier(acoustid.New(strings.TrimSpace(cfg.AcoustIDAPIKey), upStore))
|
|
}
|
|
}
|
|
upSvc.Run(ctx)
|
|
|
|
wh := wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken,
|
|
Client: &http.Client{}}, libStore, dto, log)
|
|
wh.Warm = pb.Prewarm
|
|
return server.NewRouter(server.Deps{
|
|
Social: social.NewHandler(pool, pb),
|
|
Management: management.NewHandler(management.Mailbox{Dir: cfg.ManagementDir}),
|
|
Genius: genius.NewHandler(geniusSvc),
|
|
Log: log,
|
|
System: sys,
|
|
Auth: auth.NewHandler(authSvc, cfg.CookieSecure),
|
|
AuthService: authSvc,
|
|
Library: library.NewHandler(libSvc, libStore, dto, covers, cfg.XAccel),
|
|
Search: search.NewHandler(meili, libStore, indexer, search.NewAffinity(pool), dto, log),
|
|
Playback: pb,
|
|
Wave: wh,
|
|
App: appupdate.NewHandler(appupdate.Config{GiteaURL: cfg.GiteaURL, Repo: cfg.GiteaRepo, Token: cfg.GiteaToken,
|
|
Dir: cfg.UpdatesDir, MinSupported: cfg.MinAndroidVersionCode}, log),
|
|
Uploads: uploads.NewHandler(upSvc, signer),
|
|
TUS: uploads.NewTUS(uploads.NewStore(pool), cfg.UploadsDir, upSvc.Complete, log),
|
|
}), nil
|
|
}
|
|
|
|
func newAuthService(cfg config.API, pool *pgxpool.Pool, rdb *redis.Client, log *slog.Logger) (*auth.Service, error) {
|
|
var (
|
|
key ed25519.PrivateKey
|
|
err error
|
|
)
|
|
if cfg.JWTPrivateKey == "" {
|
|
log.Warn("JWT_PRIVATE_KEY is not set: using a random key, access tokens will not survive a restart")
|
|
key, err = auth.GenerateKey()
|
|
} else {
|
|
key, err = auth.ParsePrivateKey(cfg.JWTPrivateKey)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var previous []ed25519.PrivateKey
|
|
for _, s := range cfg.JWTPreviousKeys {
|
|
if s == "" {
|
|
continue
|
|
}
|
|
k, err := auth.ParsePrivateKey(s)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("JWT_PREVIOUS_KEYS: %w", err)
|
|
}
|
|
previous = append(previous, k)
|
|
}
|
|
clock := auth.SystemClock{}
|
|
acfg := auth.DefaultConfig()
|
|
acfg.BootstrapUsername = cfg.BootstrapAdminUsername
|
|
acfg.BootstrapPassword = cfg.BootstrapAdminPassword
|
|
acfg.SupportedLocales = cfg.SupportedLocales
|
|
return auth.NewService(
|
|
auth.NewPgStore(pool),
|
|
auth.NewHasher(auth.DefaultArgon2Params),
|
|
auth.NewTokenIssuer(key, previous, cfg.AccessTokenTTL, clock.Now),
|
|
auth.NewRedisLimiter(rdb),
|
|
clock, auth.UUIDv7{}, log, acfg,
|
|
)
|
|
}
|