Files
MeBox/internal/service/tmdb_people.go
T

272 lines
7.0 KiB
Go

package service
import (
"context"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
)
const (
tmdbPersonIDPrefix = "person~tmdb~"
tmdbProfileTagPrefix = "tmdb:"
tmdbProfileTagVersion = "p2"
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 + "?" + tmdbProfileTagVersion
}
func tmdbProfilePathFromTag(tag string) string {
tag = strings.TrimSpace(tag)
if !strings.HasPrefix(tag, tmdbProfileTagPrefix) {
return ""
}
profilePath := strings.TrimPrefix(tag, tmdbProfileTagPrefix)
if idx := strings.IndexByte(profilePath, '?'); idx >= 0 {
profilePath = profilePath[:idx]
}
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
}