mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-12 00:06:37 +08:00
Add TMDb-backed Emby people metadata and image caching
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
tmdbPersonIDPrefix = "person~tmdb~"
|
||||
tmdbProfileTagPrefix = "tmdb:"
|
||||
tmdbPeopleMax = 80
|
||||
)
|
||||
|
||||
var tmdbProfilePathRE = regexp.MustCompile(`^/[A-Za-z0-9._-]+$`)
|
||||
|
||||
type tmdbCreditCast struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Character string `json:"character"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
Order int `json:"order"`
|
||||
Roles []struct {
|
||||
Character string `json:"character"`
|
||||
} `json:"roles"`
|
||||
}
|
||||
|
||||
type tmdbCreditCrew struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Job string `json:"job"`
|
||||
Department string `json:"department"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
Jobs []struct {
|
||||
Job string `json:"job"`
|
||||
} `json:"jobs"`
|
||||
}
|
||||
|
||||
type tmdbCreditsResponse struct {
|
||||
Cast []tmdbCreditCast `json:"cast"`
|
||||
Crew []tmdbCreditCrew `json:"crew"`
|
||||
}
|
||||
|
||||
// GetPeople fetches cast/crew for a movie or series from TMDb. Nothing is
|
||||
// persisted; callers should keep the returned entries in a short-lived cache.
|
||||
func (t *TMDbProvider) GetPeople(ctx context.Context, tmdbID int, mediaType string) ([]map[string]any, error) {
|
||||
if t == nil || t.cfg == nil || tmdbID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
apiKey := t.resolveAPIKey(ctx)
|
||||
if strings.TrimSpace(apiKey) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
mediaType = strings.ToLower(strings.TrimSpace(mediaType))
|
||||
endpoint := "/movie/" + strconv.Itoa(tmdbID) + "/credits"
|
||||
if mediaType == "tv" {
|
||||
endpoint = "/tv/" + strconv.Itoa(tmdbID) + "/aggregate_credits"
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("api_key", apiKey)
|
||||
q.Set("language", "zh-CN")
|
||||
var out tmdbCreditsResponse
|
||||
if err := t.getJSON(ctx, t.resolveBaseURL(ctx)+endpoint+"?"+q.Encode(), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
people := make([]map[string]any, 0, 32)
|
||||
seen := map[string]int{}
|
||||
for _, cast := range out.Cast {
|
||||
name := strings.TrimSpace(cast.Name)
|
||||
if name == "" || cast.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
role := strings.TrimSpace(cast.Character)
|
||||
if role == "" {
|
||||
for _, item := range cast.Roles {
|
||||
if role = strings.TrimSpace(item.Character); role != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if role == "" {
|
||||
role = "Actor"
|
||||
}
|
||||
person := newTMDbPerson(cast.ID, name, "Actor", role, cast.ProfilePath)
|
||||
key := personIdentity(person)
|
||||
if idx, ok := seen[key]; ok {
|
||||
mergePersonRole(people[idx], role)
|
||||
continue
|
||||
}
|
||||
seen[key] = len(people)
|
||||
people = append(people, person)
|
||||
if len(people) >= tmdbPeopleMax {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, crew := range out.Crew {
|
||||
name := strings.TrimSpace(crew.Name)
|
||||
if name == "" || crew.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
jobs := make([]string, 0, 1+len(crew.Jobs))
|
||||
if strings.TrimSpace(crew.Job) != "" {
|
||||
jobs = append(jobs, strings.TrimSpace(crew.Job))
|
||||
}
|
||||
for _, item := range crew.Jobs {
|
||||
if job := strings.TrimSpace(item.Job); job != "" {
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
}
|
||||
jobs = deduplicateNonEmpty(jobs)
|
||||
added := false
|
||||
for _, job := range jobs {
|
||||
personType := embyPersonTypeForJob(job)
|
||||
if personType == "" {
|
||||
continue
|
||||
}
|
||||
person := newTMDbPerson(crew.ID, name, personType, job, crew.ProfilePath)
|
||||
key := personIdentity(person)
|
||||
if idx, ok := seen[key]; ok {
|
||||
mergePersonRole(people[idx], job)
|
||||
} else if len(people) < tmdbPeopleMax {
|
||||
seen[key] = len(people)
|
||||
people = append(people, person)
|
||||
}
|
||||
added = true
|
||||
}
|
||||
if !added && len(people) >= tmdbPeopleMax {
|
||||
break
|
||||
}
|
||||
}
|
||||
return people, nil
|
||||
}
|
||||
|
||||
func newTMDbPerson(personID int, name, personType, role, profilePath string) map[string]any {
|
||||
person := map[string]any{
|
||||
"Id": tmdbPersonID(personID),
|
||||
"Name": name,
|
||||
"Type": personType,
|
||||
"Role": role,
|
||||
}
|
||||
if tag := tmdbProfileTag(profilePath); tag != "" {
|
||||
person["PrimaryImageTag"] = tag
|
||||
}
|
||||
return person
|
||||
}
|
||||
|
||||
func personIdentity(person map[string]any) string {
|
||||
return fmt.Sprint(person["Id"]) + ":" + fmt.Sprint(person["Type"])
|
||||
}
|
||||
|
||||
func mergePersonRole(person map[string]any, role string) {
|
||||
role = strings.TrimSpace(role)
|
||||
if role == "" {
|
||||
return
|
||||
}
|
||||
existing := strings.TrimSpace(fmt.Sprint(person["Role"]))
|
||||
if existing == "" {
|
||||
person["Role"] = role
|
||||
return
|
||||
}
|
||||
for _, part := range strings.Split(existing, " / ") {
|
||||
if strings.EqualFold(strings.TrimSpace(part), role) {
|
||||
return
|
||||
}
|
||||
}
|
||||
person["Role"] = existing + " / " + role
|
||||
}
|
||||
|
||||
func deduplicateNonEmpty(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
key := strings.ToLower(value)
|
||||
if value == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func embyPersonTypeForJob(job string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(job)) {
|
||||
case "director":
|
||||
return "Director"
|
||||
case "writer", "screenplay", "story", "teleplay", "author":
|
||||
return "Writer"
|
||||
case "producer", "executive producer", "co-producer", "associate producer":
|
||||
return "Producer"
|
||||
case "composer", "original music composer", "music":
|
||||
return "Composer"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func tmdbPersonID(personID int) string {
|
||||
return tmdbPersonIDPrefix + strconv.Itoa(personID)
|
||||
}
|
||||
|
||||
func parseTMDbPersonID(id string) (int, bool) {
|
||||
id = strings.TrimSpace(id)
|
||||
if !strings.HasPrefix(id, tmdbPersonIDPrefix) {
|
||||
return 0, false
|
||||
}
|
||||
raw := strings.TrimPrefix(id, tmdbPersonIDPrefix)
|
||||
personID, err := strconv.Atoi(raw)
|
||||
return personID, err == nil && personID > 0
|
||||
}
|
||||
|
||||
func tmdbProfileTag(profilePath string) string {
|
||||
profilePath = strings.TrimSpace(profilePath)
|
||||
if !tmdbProfilePathRE.MatchString(profilePath) {
|
||||
return ""
|
||||
}
|
||||
return tmdbProfileTagPrefix + profilePath
|
||||
}
|
||||
|
||||
func tmdbProfilePathFromTag(tag string) string {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if !strings.HasPrefix(tag, tmdbProfileTagPrefix) {
|
||||
return ""
|
||||
}
|
||||
profilePath := strings.TrimPrefix(tag, tmdbProfileTagPrefix)
|
||||
if !tmdbProfilePathRE.MatchString(profilePath) {
|
||||
return ""
|
||||
}
|
||||
return profilePath
|
||||
}
|
||||
|
||||
// ProfileImageURL builds the public TMDb image CDN URL for a profile path.
|
||||
func (t *TMDbProvider) ProfileImageURL(profilePath string) string {
|
||||
if t == nil || !tmdbProfilePathRE.MatchString(strings.TrimSpace(profilePath)) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(t.imgCDN, "/") + "/w300" + strings.TrimSpace(profilePath)
|
||||
}
|
||||
|
||||
// PersonProfilePathByID resolves a person's current profile path directly
|
||||
// from TMDb. Used when the client does not return the PrimaryImageTag.
|
||||
func (t *TMDbProvider) PersonProfilePathByID(ctx context.Context, personID int) (string, error) {
|
||||
if t == nil || t.cfg == nil || personID <= 0 {
|
||||
return "", nil
|
||||
}
|
||||
apiKey := t.resolveAPIKey(ctx)
|
||||
if strings.TrimSpace(apiKey) == "" {
|
||||
return "", nil
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("api_key", apiKey)
|
||||
q.Set("language", "zh-CN")
|
||||
endpoint := t.resolveBaseURL(ctx) + "/person/" + strconv.Itoa(personID) + "?" + q.Encode()
|
||||
var out struct {
|
||||
ProfilePath string `json:"profile_path"`
|
||||
}
|
||||
if err := t.getJSON(ctx, endpoint, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(out.ProfilePath), nil
|
||||
}
|
||||
Reference in New Issue
Block a user