Files
silo-server/internal/catalog/person_repo.go
T

703 lines
22 KiB
Go

package catalog
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/idgen"
"github.com/Silo-Server/silo-server/internal/models"
)
// PersonRepository manages the people table.
type PersonRepository struct {
pool *pgxpool.Pool
}
// NewPersonRepository creates a new PersonRepository.
func NewPersonRepository(pool *pgxpool.Pool) *PersonRepository {
return &PersonRepository{pool: pool}
}
// FindOrCreate looks up a person by tmdb_id, imdb_id, or case-insensitive name.
// If found, it enriches empty fields with new data and returns the existing ID.
// If not found, it creates a new person and returns the new ID.
func (r *PersonRepository) FindOrCreate(ctx context.Context, p models.Person) (int64, error) {
var existingID int64
if p.TmdbID != "" {
err := r.pool.QueryRow(ctx, "SELECT id FROM people WHERE tmdb_id = $1", p.TmdbID).Scan(&existingID)
if err == nil {
return r.enrichExisting(ctx, existingID, p)
}
if err != pgx.ErrNoRows {
return 0, fmt.Errorf("lookup by tmdb_id: %w", err)
}
}
if p.ImdbID != "" {
err := r.pool.QueryRow(ctx, "SELECT id FROM people WHERE imdb_id = $1", p.ImdbID).Scan(&existingID)
if err == nil {
return r.enrichExisting(ctx, existingID, p)
}
if err != pgx.ErrNoRows {
return 0, fmt.Errorf("lookup by imdb_id: %w", err)
}
}
if p.Name != "" {
err := r.pool.QueryRow(ctx, "SELECT id FROM people WHERE LOWER(name) = LOWER($1)", p.Name).Scan(&existingID)
if err == nil {
return r.enrichExisting(ctx, existingID, p)
}
if err != pgx.ErrNoRows {
return 0, fmt.Errorf("lookup by name: %w", err)
}
}
// Not found — create new person
idStr, err := idgen.NextID()
if err != nil {
return 0, fmt.Errorf("generate person id: %w", err)
}
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
return 0, fmt.Errorf("parse generated id: %w", err)
}
_, err = r.pool.Exec(ctx, `
INSERT INTO people (id, name, sort_name, bio, birth_date, death_date, birthplace, homepage,
photo_path, photo_thumbhash, tmdb_id, imdb_id, tvdb_id, plex_guid)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
id, p.Name, p.SortName, p.Bio, p.BirthDate, p.DeathDate, p.Birthplace, p.Homepage,
p.PhotoPath, p.PhotoThumbhash, p.TmdbID, p.ImdbID, p.TvdbID, p.PlexGUID,
)
if err != nil {
return 0, fmt.Errorf("insert person: %w", err)
}
return id, nil
}
// enrichExisting updates empty fields on an existing person with non-empty values from p.
func (r *PersonRepository) enrichExisting(ctx context.Context, id int64, p models.Person) (int64, error) {
var setClauses []string
var args []interface{}
argIdx := 1
// fillEmpty only sets the column when the existing DB value is empty.
fillEmpty := func(column, value string) {
if value == "" {
return
}
setClauses = append(setClauses, fmt.Sprintf("%s = CASE WHEN %s = '' THEN $%d ELSE %s END", column, column, argIdx, column))
args = append(args, value)
argIdx++
}
// overwriteIfReal sets the column when the new value is real. The "-"
// sentinel ("no photo, but we tried") is only written when the existing
// column is empty, so it cannot clobber a real provider path.
overwriteIfReal := func(column, value string) {
if value == "" {
return
}
if value == "-" {
setClauses = append(setClauses, fmt.Sprintf("%s = CASE WHEN %s = '' THEN $%d ELSE %s END", column, column, argIdx, column))
} else {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", column, argIdx))
}
args = append(args, value)
argIdx++
}
fillEmpty("tmdb_id", p.TmdbID)
fillEmpty("imdb_id", p.ImdbID)
fillEmpty("tvdb_id", p.TvdbID)
fillEmpty("plex_guid", p.PlexGUID)
overwriteIfReal("photo_path", p.PhotoPath)
overwriteIfReal("photo_thumbhash", p.PhotoThumbhash)
fillEmpty("bio", p.Bio)
fillEmpty("birthplace", p.Birthplace)
fillEmpty("homepage", p.Homepage)
if len(setClauses) == 0 {
return id, nil
}
setClauses = append(setClauses, "updated_at = now()")
query := fmt.Sprintf("UPDATE people SET %s WHERE id = $%d", strings.Join(setClauses, ", "), argIdx)
args = append(args, id)
if _, err := r.pool.Exec(ctx, query, args...); err != nil {
return 0, fmt.Errorf("enrich person %d: %w", id, err)
}
return id, nil
}
// BatchFindOrCreate resolves a batch of people in 5 phases: lookup by tmdb_id,
// imdb_id, and name; enrich found people; insert new people. Returns a slice
// of IDs positionally matching the input. Zero values indicate failures.
func (r *PersonRepository) BatchFindOrCreate(ctx context.Context, people []models.Person) ([]int64, error) {
if len(people) == 0 {
return nil, nil
}
ids := make([]int64, len(people))
// Deduplicate input by (TmdbID, ImdbID, Name) so each unique person is
// looked up once. Track which input indices map to each unique person.
type dedupKey struct{ tmdb, imdb, name string }
uniqueMap := make(map[dedupKey]int) // key → index in uniquePeople
indexMap := make([]int, len(people)) // input index → unique index
var uniquePeople []models.Person
for i, p := range people {
key := dedupKey{p.TmdbID, p.ImdbID, strings.ToLower(p.Name)}
if ui, ok := uniqueMap[key]; ok {
indexMap[i] = ui
} else {
ui := len(uniquePeople)
uniqueMap[key] = ui
indexMap[i] = ui
uniquePeople = append(uniquePeople, p)
}
}
uniqueIDs := make([]int64, len(uniquePeople))
resolved := make([]bool, len(uniquePeople))
// Track which unique people need enrichment.
type enrichEntry struct {
id int64
person models.Person
}
var toEnrich []enrichEntry
// Phase 1: Batch lookup by tmdb_id.
tmdbLookup := make(map[string][]int) // tmdb_id → unique indices
var tmdbIDs []string
for i, p := range uniquePeople {
if p.TmdbID != "" {
tmdbLookup[p.TmdbID] = append(tmdbLookup[p.TmdbID], i)
tmdbIDs = append(tmdbIDs, p.TmdbID)
}
}
if len(tmdbIDs) > 0 {
rows, err := r.pool.Query(ctx,
"SELECT id, tmdb_id FROM people WHERE tmdb_id = ANY($1::text[]) AND tmdb_id <> ''",
tmdbIDs)
if err != nil {
return nil, fmt.Errorf("batch lookup by tmdb_id: %w", err)
}
for rows.Next() {
var id int64
var tid string
if err := rows.Scan(&id, &tid); err != nil {
rows.Close()
return nil, fmt.Errorf("scanning tmdb_id result: %w", err)
}
for _, ui := range tmdbLookup[tid] {
if !resolved[ui] {
uniqueIDs[ui] = id
resolved[ui] = true
toEnrich = append(toEnrich, enrichEntry{id, uniquePeople[ui]})
}
}
}
rows.Close()
}
// Phase 2: Batch lookup by imdb_id (remaining).
var imdbIDs []string
imdbLookup := make(map[string][]int)
for i, p := range uniquePeople {
if !resolved[i] && p.ImdbID != "" {
imdbLookup[p.ImdbID] = append(imdbLookup[p.ImdbID], i)
imdbIDs = append(imdbIDs, p.ImdbID)
}
}
if len(imdbIDs) > 0 {
rows, err := r.pool.Query(ctx,
"SELECT id, imdb_id FROM people WHERE imdb_id = ANY($1::text[]) AND imdb_id <> ''",
imdbIDs)
if err != nil {
return nil, fmt.Errorf("batch lookup by imdb_id: %w", err)
}
for rows.Next() {
var id int64
var iid string
if err := rows.Scan(&id, &iid); err != nil {
rows.Close()
return nil, fmt.Errorf("scanning imdb_id result: %w", err)
}
for _, ui := range imdbLookup[iid] {
if !resolved[ui] {
uniqueIDs[ui] = id
resolved[ui] = true
toEnrich = append(toEnrich, enrichEntry{id, uniquePeople[ui]})
}
}
}
rows.Close()
}
// Phase 3: Batch lookup by name (remaining).
var nameValues []string
nameLookup := make(map[string][]int) // LOWER(name) → unique indices
for i, p := range uniquePeople {
if !resolved[i] && p.Name != "" {
lower := strings.ToLower(p.Name)
nameLookup[lower] = append(nameLookup[lower], i)
nameValues = append(nameValues, lower)
}
}
if len(nameValues) > 0 {
rows, err := r.pool.Query(ctx,
"SELECT id, LOWER(name) FROM people WHERE LOWER(name) = ANY($1::text[])",
nameValues)
if err != nil {
return nil, fmt.Errorf("batch lookup by name: %w", err)
}
for rows.Next() {
var id int64
var name string
if err := rows.Scan(&id, &name); err != nil {
rows.Close()
return nil, fmt.Errorf("scanning name result: %w", err)
}
for _, ui := range nameLookup[name] {
if !resolved[ui] {
uniqueIDs[ui] = id
resolved[ui] = true
toEnrich = append(toEnrich, enrichEntry{id, uniquePeople[ui]})
}
}
}
rows.Close()
}
// Phase 4: Batch enrich found people (same fillEmpty/overwrite semantics).
if len(toEnrich) > 0 {
enrichIDs := make([]int64, len(toEnrich))
eTmdbIDs := make([]string, len(toEnrich))
eImdbIDs := make([]string, len(toEnrich))
eTvdbIDs := make([]string, len(toEnrich))
ePlexGUIDs := make([]string, len(toEnrich))
ePhotoPaths := make([]string, len(toEnrich))
ePhotoThumbs := make([]string, len(toEnrich))
eBios := make([]string, len(toEnrich))
eBirthplaces := make([]string, len(toEnrich))
eHomepages := make([]string, len(toEnrich))
for i, e := range toEnrich {
enrichIDs[i] = e.id
eTmdbIDs[i] = e.person.TmdbID
eImdbIDs[i] = e.person.ImdbID
eTvdbIDs[i] = e.person.TvdbID
ePlexGUIDs[i] = e.person.PlexGUID
ePhotoPaths[i] = e.person.PhotoPath
ePhotoThumbs[i] = e.person.PhotoThumbhash
eBios[i] = e.person.Bio
eBirthplaces[i] = e.person.Birthplace
eHomepages[i] = e.person.Homepage
}
_, err := r.pool.Exec(ctx, `
UPDATE people SET
tmdb_id = CASE WHEN people.tmdb_id = '' AND t.tmdb_id <> '' THEN t.tmdb_id ELSE people.tmdb_id END,
imdb_id = CASE WHEN people.imdb_id = '' AND t.imdb_id <> '' THEN t.imdb_id ELSE people.imdb_id END,
tvdb_id = CASE WHEN people.tvdb_id = '' AND t.tvdb_id <> '' THEN t.tvdb_id ELSE people.tvdb_id END,
plex_guid = CASE WHEN people.plex_guid = '' AND t.plex_guid <> '' THEN t.plex_guid ELSE people.plex_guid END,
photo_path = CASE
WHEN t.photo_path NOT IN ('', '-') THEN t.photo_path
WHEN people.photo_path = '' THEN t.photo_path
ELSE people.photo_path
END,
photo_thumbhash = CASE
WHEN t.photo_thumbhash NOT IN ('', '-') THEN t.photo_thumbhash
WHEN people.photo_thumbhash = '' THEN t.photo_thumbhash
ELSE people.photo_thumbhash
END,
bio = CASE WHEN people.bio = '' AND t.bio <> '' THEN t.bio ELSE people.bio END,
birthplace = CASE WHEN people.birthplace = '' AND t.birthplace <> '' THEN t.birthplace ELSE people.birthplace END,
homepage = CASE WHEN people.homepage = '' AND t.homepage <> '' THEN t.homepage ELSE people.homepage END,
updated_at = NOW()
FROM UNNEST($1::bigint[], $2::text[], $3::text[], $4::text[], $5::text[],
$6::text[], $7::text[], $8::text[], $9::text[], $10::text[])
AS t(id, tmdb_id, imdb_id, tvdb_id, plex_guid,
photo_path, photo_thumbhash, bio, birthplace, homepage)
WHERE people.id = t.id`,
enrichIDs, eTmdbIDs, eImdbIDs, eTvdbIDs, ePlexGUIDs,
ePhotoPaths, ePhotoThumbs, eBios, eBirthplaces, eHomepages,
)
if err != nil {
return nil, fmt.Errorf("batch enrich people: %w", err)
}
}
// Phase 5: Batch insert new people.
var newIndices []int
for i := range uniquePeople {
if !resolved[i] {
newIndices = append(newIndices, i)
}
}
if len(newIndices) > 0 {
newIDs := make([]int64, len(newIndices))
names := make([]string, len(newIndices))
sortNames := make([]string, len(newIndices))
bios := make([]string, len(newIndices))
birthDates := make([]*time.Time, len(newIndices))
deathDates := make([]*time.Time, len(newIndices))
birthplaces := make([]string, len(newIndices))
homepages := make([]string, len(newIndices))
photoPaths := make([]string, len(newIndices))
photoThumbs := make([]string, len(newIndices))
nTmdbIDs := make([]string, len(newIndices))
nImdbIDs := make([]string, len(newIndices))
nTvdbIDs := make([]string, len(newIndices))
nPlexGUIDs := make([]string, len(newIndices))
for j, ui := range newIndices {
idStr, err := idgen.NextID()
if err != nil {
continue
}
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
continue
}
p := uniquePeople[ui]
newIDs[j] = id
names[j] = p.Name
sortNames[j] = p.SortName
bios[j] = p.Bio
birthDates[j] = p.BirthDate
deathDates[j] = p.DeathDate
birthplaces[j] = p.Birthplace
homepages[j] = p.Homepage
photoPaths[j] = p.PhotoPath
photoThumbs[j] = p.PhotoThumbhash
nTmdbIDs[j] = p.TmdbID
nImdbIDs[j] = p.ImdbID
nTvdbIDs[j] = p.TvdbID
nPlexGUIDs[j] = p.PlexGUID
}
rows, err := r.pool.Query(ctx, `
INSERT INTO people (id, name, sort_name, bio, birth_date, death_date, birthplace, homepage,
photo_path, photo_thumbhash, tmdb_id, imdb_id, tvdb_id, plex_guid)
SELECT * FROM UNNEST(
$1::bigint[], $2::text[], $3::text[], $4::text[], $5::date[], $6::date[],
$7::text[], $8::text[], $9::text[], $10::text[], $11::text[], $12::text[],
$13::text[], $14::text[]
)
ON CONFLICT DO NOTHING
RETURNING id`,
newIDs, names, sortNames, bios, birthDates, deathDates,
birthplaces, homepages, photoPaths, photoThumbs, nTmdbIDs, nImdbIDs,
nTvdbIDs, nPlexGUIDs,
)
if err != nil {
return nil, fmt.Errorf("batch insert people: %w", err)
}
// Collect inserted IDs.
insertedSet := make(map[int64]bool)
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return nil, fmt.Errorf("scanning inserted person id: %w", err)
}
insertedSet[id] = true
}
rows.Close()
// Assign IDs: either from successful insert or via re-query for race losers.
for j, ui := range newIndices {
if newIDs[j] == 0 {
continue
}
if insertedSet[newIDs[j]] {
uniqueIDs[ui] = newIDs[j]
resolved[ui] = true
}
}
// Re-query for any people that lost the insert race (ON CONFLICT DO NOTHING
// skipped them). Fall back to single-row FindOrCreate for these rare cases.
for j, ui := range newIndices {
if resolved[ui] || newIDs[j] == 0 {
continue
}
p := uniquePeople[ui]
id, err := r.FindOrCreate(ctx, p)
if err == nil {
uniqueIDs[ui] = id
resolved[ui] = true
}
}
}
// Map unique IDs back to the original input positions.
for i := range people {
ids[i] = uniqueIDs[indexMap[i]]
}
return ids, nil
}
// Get retrieves a person by ID.
func (r *PersonRepository) Get(ctx context.Context, id int64) (*models.Person, error) {
var p models.Person
err := r.pool.QueryRow(ctx, `
SELECT id, name, sort_name, bio, birth_date, death_date, birthplace, homepage,
photo_path, photo_thumbhash, tmdb_id, imdb_id, tvdb_id, plex_guid, created_at, updated_at
FROM people WHERE id = $1`, id,
).Scan(&p.ID, &p.Name, &p.SortName, &p.Bio, &p.BirthDate, &p.DeathDate, &p.Birthplace, &p.Homepage,
&p.PhotoPath, &p.PhotoThumbhash, &p.TmdbID, &p.ImdbID, &p.TvdbID, &p.PlexGUID, &p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("get person %d: %w", id, err)
}
return &p, nil
}
// GetByName retrieves a person by exact name (case-insensitive).
func (r *PersonRepository) GetByName(ctx context.Context, name string) (*models.Person, error) {
var p models.Person
err := r.pool.QueryRow(ctx, `
SELECT id, name, sort_name, bio, birth_date, death_date, birthplace, homepage,
photo_path, photo_thumbhash, tmdb_id, imdb_id, tvdb_id, plex_guid, created_at, updated_at
FROM people WHERE LOWER(name) = LOWER($1)`, name,
).Scan(&p.ID, &p.Name, &p.SortName, &p.Bio, &p.BirthDate, &p.DeathDate, &p.Birthplace, &p.Homepage,
&p.PhotoPath, &p.PhotoThumbhash, &p.TmdbID, &p.ImdbID, &p.TvdbID, &p.PlexGUID, &p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("get person by name %q: %w", name, err)
}
return &p, nil
}
// Search finds persons by name substring (case-insensitive), ordered by name.
func (r *PersonRepository) Search(ctx context.Context, query string, limit int) ([]models.Person, error) {
if limit <= 0 {
limit = 20
}
rows, err := r.pool.Query(ctx, `
SELECT id, name, sort_name, bio, birth_date, death_date, birthplace, homepage,
photo_path, photo_thumbhash, tmdb_id, imdb_id, tvdb_id, plex_guid, created_at, updated_at
FROM people WHERE LOWER(name) LIKE '%' || LOWER($1) || '%'
ORDER BY name LIMIT $2`, query, limit,
)
if err != nil {
return nil, fmt.Errorf("search people: %w", err)
}
defer rows.Close()
var people []models.Person
for rows.Next() {
var p models.Person
if err := rows.Scan(&p.ID, &p.Name, &p.SortName, &p.Bio, &p.BirthDate, &p.DeathDate, &p.Birthplace, &p.Homepage,
&p.PhotoPath, &p.PhotoThumbhash, &p.TmdbID, &p.ImdbID, &p.TvdbID, &p.PlexGUID, &p.CreatedAt, &p.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan person: %w", err)
}
people = append(people, p)
}
return people, rows.Err()
}
// Update updates all non-key fields on a person.
func (r *PersonRepository) Update(ctx context.Context, p models.Person) error {
_, err := r.pool.Exec(ctx, `
UPDATE people SET name=$2, sort_name=$3, bio=$4, birth_date=$5, death_date=$6,
birthplace=$7, homepage=$8, photo_path=$9, photo_thumbhash=$10,
tmdb_id=$11, imdb_id=$12, tvdb_id=$13, plex_guid=$14, updated_at=now()
WHERE id = $1`,
p.ID, p.Name, p.SortName, p.Bio, p.BirthDate, p.DeathDate,
p.Birthplace, p.Homepage, p.PhotoPath, p.PhotoThumbhash,
p.TmdbID, p.ImdbID, p.TvdbID, p.PlexGUID,
)
return err
}
// FindRefreshCandidates returns people with external IDs that are incomplete or stale.
func (r *PersonRepository) FindRefreshCandidates(
ctx context.Context,
staleAfter time.Duration,
limit int,
) ([]int64, error) {
if limit <= 0 {
return []int64{}, nil
}
args := []any{limit}
stalePredicate := ""
if staleAfter > 0 {
args = append(args, time.Now().Add(-staleAfter))
stalePredicate = " OR updated_at < $2"
}
rows, err := r.pool.Query(ctx, `
SELECT id
FROM people
WHERE
(tmdb_id <> '' OR imdb_id <> '' OR tvdb_id <> '')
AND (
COALESCE(bio, '') = ''
OR COALESCE(photo_path, '') = ''
OR birth_date IS NULL`+stalePredicate+`
)
ORDER BY updated_at ASC
LIMIT $1`,
args...,
)
if err != nil {
return nil, fmt.Errorf("query refresh candidates: %w", err)
}
defer rows.Close()
ids := make([]int64, 0, limit)
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan refresh candidate: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate refresh candidates: %w", err)
}
return ids, nil
}
// ListForItem returns all people credited on a media item, ordered by sort_order.
func (r *PersonRepository) ListForItem(ctx context.Context, contentID string) ([]models.ItemPerson, error) {
rows, err := r.pool.Query(ctx, `
SELECT p.id, p.name, p.sort_name, p.bio, p.birth_date, p.death_date, p.birthplace, p.homepage,
p.photo_path, p.photo_thumbhash, p.tmdb_id, p.imdb_id, p.tvdb_id, p.plex_guid,
p.created_at, p.updated_at,
ip.kind, ip.character, ip.sort_order
FROM item_people ip
JOIN people p ON p.id = ip.person_id
WHERE ip.content_id = $1
ORDER BY ip.kind, ip.sort_order`, contentID,
)
if err != nil {
return nil, fmt.Errorf("list people for item %s: %w", contentID, err)
}
defer rows.Close()
return scanItemPeople(rows)
}
// ListForItems returns people credited on any of the given media items, grouped
// by content ID. This is the batch equivalent of ListForItem.
func (r *PersonRepository) ListForItems(ctx context.Context, contentIDs []string) (map[string][]models.ItemPerson, error) {
if len(contentIDs) == 0 {
return map[string][]models.ItemPerson{}, nil
}
rows, err := r.pool.Query(ctx, `
SELECT ip.content_id,
p.id, p.name, p.sort_name, p.bio, p.birth_date, p.death_date, p.birthplace, p.homepage,
p.photo_path, p.photo_thumbhash, p.tmdb_id, p.imdb_id, p.tvdb_id, p.plex_guid,
p.created_at, p.updated_at,
ip.kind, ip.character, ip.sort_order
FROM item_people ip
JOIN people p ON p.id = ip.person_id
WHERE ip.content_id = ANY($1)
ORDER BY ip.content_id, ip.kind, ip.sort_order`, contentIDs,
)
if err != nil {
return nil, fmt.Errorf("list people for items: %w", err)
}
defer rows.Close()
result := make(map[string][]models.ItemPerson)
for rows.Next() {
var contentID string
var p models.ItemPerson
if err := rows.Scan(
&contentID,
&p.ID, &p.Name, &p.SortName, &p.Bio, &p.BirthDate, &p.DeathDate,
&p.Birthplace, &p.Homepage, &p.PhotoPath, &p.PhotoThumbhash,
&p.TmdbID, &p.ImdbID, &p.TvdbID, &p.PlexGUID,
&p.CreatedAt, &p.UpdatedAt,
&p.Kind, &p.Character, &p.SortOrder,
); err != nil {
return nil, fmt.Errorf("scan item person: %w", err)
}
result[contentID] = append(result[contentID], p)
}
return result, rows.Err()
}
// ListByKind returns people of a specific kind credited on a media item.
func (r *PersonRepository) ListByKind(ctx context.Context, contentID string, kind models.PersonKind) ([]models.ItemPerson, error) {
rows, err := r.pool.Query(ctx, `
SELECT p.id, p.name, p.sort_name, p.bio, p.birth_date, p.death_date, p.birthplace, p.homepage,
p.photo_path, p.photo_thumbhash, p.tmdb_id, p.imdb_id, p.tvdb_id, p.plex_guid,
p.created_at, p.updated_at,
ip.kind, ip.character, ip.sort_order
FROM item_people ip
JOIN people p ON p.id = ip.person_id
WHERE ip.content_id = $1 AND ip.kind = $2
ORDER BY ip.sort_order`, contentID, kind,
)
if err != nil {
return nil, fmt.Errorf("list people for item %s kind %d: %w", contentID, kind, err)
}
defer rows.Close()
return scanItemPeople(rows)
}
// CountItemsByType returns the number of media items a person appears in,
// grouped by item type (e.g. "movie", "series", "episode").
func (r *PersonRepository) CountItemsByType(ctx context.Context, personID int64) (map[string]int, error) {
rows, err := r.pool.Query(ctx, `
SELECT mi.type, COUNT(DISTINCT mi.content_id)
FROM item_people ip
JOIN media_items mi ON mi.content_id = ip.content_id
WHERE ip.person_id = $1
GROUP BY mi.type`, personID,
)
if err != nil {
return nil, fmt.Errorf("count items by type for person %d: %w", personID, err)
}
defer rows.Close()
counts := make(map[string]int)
for rows.Next() {
var itemType string
var count int
if err := rows.Scan(&itemType, &count); err != nil {
return nil, fmt.Errorf("scan item type count: %w", err)
}
counts[itemType] = count
}
return counts, rows.Err()
}
func scanItemPeople(rows pgx.Rows) ([]models.ItemPerson, error) {
var people []models.ItemPerson
for rows.Next() {
var ip models.ItemPerson
if err := rows.Scan(
&ip.ID, &ip.Name, &ip.SortName, &ip.Bio, &ip.BirthDate, &ip.DeathDate, &ip.Birthplace, &ip.Homepage,
&ip.PhotoPath, &ip.PhotoThumbhash, &ip.TmdbID, &ip.ImdbID, &ip.TvdbID, &ip.PlexGUID,
&ip.CreatedAt, &ip.UpdatedAt,
&ip.Kind, &ip.Character, &ip.SortOrder,
); err != nil {
return nil, fmt.Errorf("scan item_person: %w", err)
}
people = append(people, ip)
}
return people, rows.Err()
}