Files
pipu-music/services/api/cmd/api/main.go
T
mrleo1nid 39bf7d4b70
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
feat(uploads): match release groups with MusicBrainz and AcoustID
2026-10-29 20:44:59 +03:00

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,
)
}