mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
272 lines
7.0 KiB
Go
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
|
|
}
|