package backend import ( "cmp" "context" "errors" "fmt" "io" "net/http" "os" "path/filepath" "regexp" "strings" "sync" "time" "music-server/internal/db/repository" "music-server/internal/logging" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "go.uber.org/zap" ) const ( // MaxCoverBytes is the largest cover image that is stored. MaxCoverBytes = 10 << 20 // coverRecheckAfter is how long a soundtrack without a match waits before the job // searches for it again. Changing its alternative names makes it search right away. coverRecheckAfter = 30 * 24 * time.Hour // coverRequestInterval spaces out requests to the external APIs, to stay well inside // their rate limits. coverRequestInterval = time.Second ) // SourceUpload is the source of a cover that was uploaded. const SourceUpload = "upload" // coverTypes are the image types stored as covers, by MIME type, with their file extension. var coverTypes = map[string]string{ "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", } // CoverService stores cover images for soundtracks in a folder and finds them in external // databases in the background. type CoverService struct { q repository.Querier dir string sources []coverSource client *http.Client // interval spaces out external requests; tests set it to 0. interval time.Duration trigger chan struct{} // writeMu keeps the job from replacing a cover that is being set by hand. writeMu sync.Mutex } // CoverOptions configures where covers are searched. type CoverOptions struct { // SteamGridDBKey turns on SteamGridDB and the background job. SteamGridDBKey string // SteamGridDBURL and AniListURL replace the real APIs, for tests. SteamGridDBURL string AniListURL string } // NewCoverService stores covers in dir. With a SteamGridDB API key the background job // searches SteamGridDB, then AniList; without one there is no background job, but covers // can still be uploaded or picked by ID. func NewCoverService(q repository.Querier, dir string, opts CoverOptions) (*CoverService, error) { if err := os.MkdirAll(dir, 0o755); err != nil { return nil, err } client := &http.Client{Timeout: 30 * time.Second} c := &CoverService{ q: q, dir: dir, client: client, interval: coverRequestInterval, trigger: make(chan struct{}, 1), } if opts.SteamGridDBKey != "" { c.sources = append(c.sources, &steamGridDB{ baseURL: cmp.Or(opts.SteamGridDBURL, steamGridDBURL), key: opts.SteamGridDBKey, client: client, }) } c.sources = append(c.sources, &aniList{url: cmp.Or(opts.AniListURL, aniListURL), client: client}) return c, nil } // SearchesAutomatically reports whether the background job runs. func (c *CoverService) SearchesAutomatically() bool { return c.source("steamgriddb") != nil } func (c *CoverService) source(name string) coverSource { for _, s := range c.sources { if s.Name() == name { return s } } return nil } // Path returns the cover image of an active soundtrack, or ErrNotFound. func (c *CoverService) Path(ctx context.Context, id uuid.UUID) (string, error) { st, err := c.q.GetSoundtrackByUUID(ctx, id) if err != nil { return "", notFound(err) } if st.CoverFile == nil { return "", ErrNotFound } return filepath.Join(c.dir, *st.CoverFile), nil } // Upload stores data as the soundtrack's cover. It is never replaced by the background job. func (c *CoverService) Upload(ctx context.Context, id uuid.UUID, data []byte) error { if _, err := c.activeSoundtrack(ctx, id); err != nil { return err } c.writeMu.Lock() defer c.writeMu.Unlock() return c.store(ctx, id, data, SourceUpload, nil, true) } // FromSource stores the cover of entry externalID in source (steamgriddb or anilist) as // the soundtrack's cover. It is never replaced by the background job. func (c *CoverService) FromSource(ctx context.Context, id uuid.UUID, source, externalID string) error { s := c.source(source) if s == nil { return fmt.Errorf("%w: unknown or disabled cover source %q", ErrInvalidName, source) } if _, err := c.activeSoundtrack(ctx, id); err != nil { return err } match, err := s.ByID(ctx, externalID) if err != nil { return err } data, err := c.download(ctx, match.ImageURL) if err != nil { return err } c.writeMu.Lock() defer c.writeMu.Unlock() return c.store(ctx, id, data, source, &match.ExternalID, true) } // Remove deletes the soundtrack's cover, so the background job searches for a new one. func (c *CoverService) Remove(ctx context.Context, id uuid.UUID) error { st, err := c.activeSoundtrack(ctx, id) if err != nil { return err } c.writeMu.Lock() defer c.writeMu.Unlock() if err := c.q.ClearCover(ctx, id); err != nil { return err } if st.CoverFile != nil { c.removeFile(*st.CoverFile) } c.Trigger() return nil } func (c *CoverService) activeSoundtrack(ctx context.Context, id uuid.UUID) (repository.Soundtrack, error) { st, err := c.q.GetSoundtrackByUUID(ctx, id) return st, notFound(err) } // store writes data as the soundtrack's cover file and records it. Call with writeMu held. func (c *CoverService) store(ctx context.Context, id uuid.UUID, data []byte, source string, externalID *string, manual bool) error { ext, err := coverExtension(data) if err != nil { return err } name := id.String() + ext tmp, err := os.CreateTemp(c.dir, ".upload-*") if err != nil { return err } defer os.Remove(tmp.Name()) if _, err := tmp.Write(data); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } if err := os.Rename(tmp.Name(), filepath.Join(c.dir, name)); err != nil { return err } // A cover of another image type leaves a file with another extension behind. for _, other := range coverTypes { if other != ext { c.removeFile(id.String() + other) } } return c.q.SetCover(ctx, repository.SetCoverParams{ Uuid: id, File: &name, Source: &source, ExternalID: externalID, Manual: manual, }) } func (c *CoverService) removeFile(name string) { if err := os.Remove(filepath.Join(c.dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) { logging.GetLogger().Warn("Failed to remove cover", zap.String("file", name), zap.Error(err)) } } // coverExtension checks that data is a supported image and returns its file extension. func coverExtension(data []byte) (string, error) { if len(data) > MaxCoverBytes { return "", fmt.Errorf("%w: the image is larger than %d MB", ErrInvalidImage, MaxCoverBytes>>20) } ext, ok := coverTypes[http.DetectContentType(data)] if !ok { return "", fmt.Errorf("%w: only JPEG, PNG and WebP images are supported", ErrInvalidImage) } return ext, nil } // download fetches a cover image. func (c *CoverService) download(ctx context.Context, imageURL string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) if err != nil { return nil, fmt.Errorf("%w: invalid image URL: %v", ErrUpstream, err) } resp, err := c.client.Do(req) if err != nil { return nil, fmt.Errorf("%w: %v", ErrUpstream, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("%w: image download answered %s", ErrUpstream, resp.Status) } data, err := io.ReadAll(io.LimitReader(resp.Body, MaxCoverBytes+1)) if err != nil { return nil, fmt.Errorf("%w: %v", ErrUpstream, err) } if _, err := coverExtension(data); err != nil { return nil, fmt.Errorf("%w: %v", ErrUpstream, err) } return data, nil } // Trigger asks the background job to look for missing covers. It never blocks. func (c *CoverService) Trigger() { select { case c.trigger <- struct{}{}: default: // a pass is already waiting } } // Run searches for missing covers now and each time Trigger is called, until ctx ends. // It does nothing when automatic search is off. func (c *CoverService) Run(ctx context.Context) { if !c.SearchesAutomatically() { return } c.Trigger() for { select { case <-ctx.Done(): return case <-c.trigger: if err := c.searchMissing(ctx); err != nil && ctx.Err() == nil { logging.GetLogger().Warn("Cover search stopped, trying again after the next sync", zap.Error(err)) } } } } // searchMissing finds covers for active soundtracks without one. It stops at the first // error from an external API, so an unreachable API is not asked again for every soundtrack. func (c *CoverService) searchMissing(ctx context.Context) error { c.removeOrphans(ctx) rows, err := c.q.ListSoundtracksNeedingCover(ctx, pgtype.Timestamptz{Time: time.Now().Add(-coverRecheckAfter), Valid: true}) if err != nil { return err } logger := logging.GetLogger() found := 0 for _, row := range rows { match, source, err := c.search(ctx, coverSearchNames(row.SoundtrackName, row.AlternativeNames)) if err != nil { return err } if match == nil { if err := c.q.MarkCoverChecked(ctx, row.Uuid); err != nil { return err } continue } if err := c.wait(ctx); err != nil { return err } data, err := c.download(ctx, match.ImageURL) if err != nil { return err } stored, err := c.storeFound(ctx, row.Uuid, data, source, match.ExternalID) if err != nil { return err } if stored { found++ logger.Info("Cover found", zap.String("soundtrack", row.SoundtrackName), zap.String("source", source), zap.String("external_id", match.ExternalID)) } } if len(rows) > 0 { logger.Info("Cover search finished", zap.Int("searched", len(rows)), zap.Int("found", found)) } return nil } // storeFound stores a cover found by the job, unless one was set by hand meanwhile. func (c *CoverService) storeFound(ctx context.Context, id uuid.UUID, data []byte, source, externalID string) (bool, error) { c.writeMu.Lock() defer c.writeMu.Unlock() st, err := c.q.GetSoundtrackByUUID(ctx, id) if errors.Is(err, pgx.ErrNoRows) { return false, nil // removed meanwhile } if err != nil { return false, err } if st.CoverManual || st.CoverFile != nil { return false, nil } return true, c.store(ctx, id, data, source, &externalID, false) } // search asks the sources in order and returns the first match. func (c *CoverService) search(ctx context.Context, names []string) (*coverMatch, string, error) { for _, s := range c.sources { if err := c.wait(ctx); err != nil { return nil, "", err } match, err := s.Search(ctx, names) if err != nil { return nil, "", fmt.Errorf("%s: %w", s.Name(), err) } if match != nil { return match, s.Name(), nil } } return nil, "", nil } func (c *CoverService) wait(ctx context.Context) error { if c.interval <= 0 { return ctx.Err() } select { case <-ctx.Done(): return ctx.Err() case <-time.After(c.interval): return nil } } // removeOrphans deletes cover files that no soundtrack uses any more, for example after // the library was reset. func (c *CoverService) removeOrphans(ctx context.Context) { files, err := c.q.ListCoverFiles(ctx) if err != nil { logging.GetLogger().Warn("Failed to list cover files", zap.Error(err)) return } used := make(map[string]bool, len(files)) for _, f := range files { used[f] = true } entries, err := os.ReadDir(c.dir) if err != nil { logging.GetLogger().Warn("Failed to read the covers folder", zap.Error(err)) return } for _, entry := range entries { if !entry.IsDir() && !used[entry.Name()] { c.removeFile(entry.Name()) } } } var bracketed = regexp.MustCompile(`\s*[(\[][^)\]]*[)\]]`) // coverSearchNames returns the names to search for: the soundtrack's name, its alternative // names, and the name without parts in brackets ("Lemmings (SNES + Mega Drive)" → // "Lemmings"), without duplicates. func coverSearchNames(name string, alternatives []string) []string { var names []string seen := map[string]bool{} for _, n := range append(append([]string{name}, alternatives...), bracketed.ReplaceAllString(name, "")) { n = strings.TrimSpace(n) if n != "" && !seen[strings.ToLower(n)] { seen[strings.ToLower(n)] = true names = append(names, n) } } return names }