Search
- Albums and songs search Deezer and MusicBrainz at once and merge the
results: duplicates keep the Deezer copy, best word matches first.
Deezer's search misses albums it carries (Death Magnetic) and most game
soundtracks; MusicBrainz finds them
- MusicBrainz secondary types are shown ("Soundtrack", "Live")
- Settings: Deezer + MusicBrainz (default), Deezer only, MusicBrainz only
- Search results show which service they came from
Previews
- Songs and albums get a play button (cards, rows, detail view, editor,
and Deezer search results) for a 30-second Deezer clip; albums play
their most popular track
- Clips are looked up fresh (Deezer URLs expire), cached 10 minutes, and
streamed through Canon with Range support; hidden lists stay private
- Entries from MusicBrainz or added by hand are matched to Deezer on save
so they can preview too
- "Play previews" toggle in Settings → Profile
Tests cover merging and title/artist matching; `-tags live` runs checks
against the real services.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
147 lines
3.5 KiB
Go
147 lines
3.5 KiB
Go
package providers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// tmdb searches The Movie Database. It accepts either a v3 API key or a v4
|
|
// "API Read Access Token".
|
|
type tmdb struct {
|
|
r *Registry
|
|
key string
|
|
kind string // "movie" or "tv"
|
|
}
|
|
|
|
func (t *tmdb) Name() string { return "tmdb" }
|
|
func (t *tmdb) Label() string { return "TMDB" }
|
|
|
|
func (t *tmdb) get(ctx context.Context, path string, q url.Values, out any) error {
|
|
if q == nil {
|
|
q = url.Values{}
|
|
}
|
|
header := map[string]string{}
|
|
if strings.HasPrefix(t.key, "eyJ") {
|
|
header["Authorization"] = "Bearer " + t.key
|
|
} else {
|
|
q.Set("api_key", t.key)
|
|
}
|
|
err := t.r.getJSON(ctx, "https://api.themoviedb.org/3"+path+"?"+q.Encode(), header, out)
|
|
if isStatus(err, 401) {
|
|
return fmt.Errorf("TMDB rejected the API key; check it under Settings → Integrations")
|
|
}
|
|
return err
|
|
}
|
|
|
|
func tmdbImage(path string) string {
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
return "https://image.tmdb.org/t/p/w780" + path
|
|
}
|
|
|
|
type tmdbHit struct {
|
|
ID int64 `json:"id"`
|
|
Title string `json:"title"`
|
|
Name string `json:"name"`
|
|
ReleaseDate string `json:"release_date"`
|
|
FirstAirDate string `json:"first_air_date"`
|
|
PosterPath string `json:"poster_path"`
|
|
Overview string `json:"overview"`
|
|
}
|
|
|
|
func (t *tmdb) Search(ctx context.Context, query string) ([]Result, error) {
|
|
var resp struct {
|
|
Results []tmdbHit `json:"results"`
|
|
}
|
|
q := url.Values{"query": {query}, "include_adult": {"false"}}
|
|
if err := t.get(ctx, "/search/"+t.kind, q, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]Result, 0, len(resp.Results))
|
|
for _, h := range resp.Results {
|
|
title, date := h.Title, h.ReleaseDate
|
|
if t.kind == "tv" {
|
|
title, date = h.Name, h.FirstAirDate
|
|
}
|
|
id := strconv.FormatInt(h.ID, 10)
|
|
out = append(out, Result{
|
|
Provider: "tmdb",
|
|
ProviderID: t.kind + ":" + id,
|
|
Title: title,
|
|
Year: yearOf(date),
|
|
ImageURL: tmdbImage(h.PosterPath),
|
|
ExternalURL: "https://www.themoviedb.org/" + t.kind + "/" + id,
|
|
Overview: truncate(h.Overview, maxDescription),
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (t *tmdb) Details(ctx context.Context, res Result) (Result, error) {
|
|
if res.Provider != "tmdb" {
|
|
return res, nil
|
|
}
|
|
_, id, ok := strings.Cut(res.ProviderID, ":")
|
|
if !ok {
|
|
return res, nil
|
|
}
|
|
var d struct {
|
|
Genres []struct {
|
|
Name string `json:"name"`
|
|
} `json:"genres"`
|
|
Runtime int `json:"runtime"`
|
|
Networks []struct {
|
|
Name string `json:"name"`
|
|
} `json:"networks"`
|
|
CreatedBy []struct {
|
|
Name string `json:"name"`
|
|
} `json:"created_by"`
|
|
Credits struct {
|
|
Crew []struct {
|
|
Name string `json:"name"`
|
|
Job string `json:"job"`
|
|
} `json:"crew"`
|
|
} `json:"credits"`
|
|
}
|
|
q := url.Values{}
|
|
if t.kind == "movie" {
|
|
q.Set("append_to_response", "credits")
|
|
}
|
|
if err := t.get(ctx, "/"+t.kind+"/"+id, q, &d); err != nil {
|
|
return res, err
|
|
}
|
|
var names []string
|
|
if t.kind == "movie" {
|
|
for _, c := range d.Credits.Crew {
|
|
if c.Job == "Director" {
|
|
names = append(names, c.Name)
|
|
}
|
|
}
|
|
} else {
|
|
for _, n := range d.Networks {
|
|
names = append(names, n.Name)
|
|
}
|
|
}
|
|
if len(names) > 0 {
|
|
res.Subtitle = strings.Join(names, ", ")
|
|
}
|
|
var genres []string
|
|
for _, g := range d.Genres {
|
|
genres = append(genres, g.Name)
|
|
}
|
|
if res.Extra == nil {
|
|
res.Extra = map[string]string{}
|
|
}
|
|
if len(genres) > 0 {
|
|
res.Extra["genres"] = strings.Join(genres, ", ")
|
|
}
|
|
if d.Runtime > 0 {
|
|
res.Extra["runtime"] = fmt.Sprintf("%dh %02dm", d.Runtime/60, d.Runtime%60)
|
|
}
|
|
return res, nil
|
|
}
|