feat(calendar): show local episode airtimes
This commit is contained in:
@@ -966,6 +966,7 @@ type UpdateItemMetadataRequest struct {
|
||||
FirstAirDate *string `json:"first_air_date"`
|
||||
LastAirDate *string `json:"last_air_date"`
|
||||
AirTime *string `json:"air_time"`
|
||||
AirTimezone *string `json:"air_timezone"`
|
||||
AirDate *string `json:"air_date"`
|
||||
Status *string `json:"status"`
|
||||
RatingIMDB *float64 `json:"rating_imdb"`
|
||||
@@ -993,6 +994,14 @@ func (h *AdminHandler) HandleUpdateItemMetadata(w http.ResponseWriter, r *http.R
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.AirTimezone != nil {
|
||||
trimmed := strings.TrimSpace(*req.AirTimezone)
|
||||
req.AirTimezone = &trimmed
|
||||
if !catalog.ValidateAirTimezone(trimmed) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "air_timezone must be a valid IANA timezone")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
upd := catalog.MetadataUpdate{
|
||||
Title: req.Title, SortTitle: req.SortTitle, OriginalTitle: req.OriginalTitle,
|
||||
@@ -1000,7 +1009,7 @@ func (h *AdminHandler) HandleUpdateItemMetadata(w http.ResponseWriter, r *http.R
|
||||
Year: req.Year, Runtime: req.Runtime,
|
||||
Genres: req.Genres, Studios: req.Studios, Networks: req.Networks, Countries: req.Countries,
|
||||
ReleaseDate: req.ReleaseDate, FirstAirDate: req.FirstAirDate, LastAirDate: req.LastAirDate,
|
||||
AirTime: req.AirTime,
|
||||
AirTime: req.AirTime, AirTimezone: req.AirTimezone,
|
||||
AirDate: req.AirDate, Status: req.Status,
|
||||
RatingIMDB: req.RatingIMDB, RatingTMDB: req.RatingTMDB,
|
||||
RatingRTCritic: req.RatingRTCritic, RatingRTAudience: req.RatingRTAudience,
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -36,6 +37,9 @@ type calendarEventResponse struct {
|
||||
EpisodeNumber *int `json:"episode_number,omitempty"`
|
||||
AirDate string `json:"air_date"`
|
||||
AirTime *string `json:"air_time,omitempty"`
|
||||
AirAt *string `json:"air_at,omitempty"`
|
||||
AirTimezone *string `json:"air_timezone,omitempty"`
|
||||
LocalAirDate string `json:"local_air_date"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
PosterThumbhash string `json:"poster_thumbhash,omitempty"`
|
||||
Badges []string `json:"badges"`
|
||||
@@ -91,9 +95,11 @@ func (h *CalendarHandler) HandleGetCalendar(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
viewerLocation := catalog.CalendarLocation(q.Get("timezone"))
|
||||
|
||||
cf := catalog.CalendarFilter{
|
||||
Start: start,
|
||||
End: end,
|
||||
Start: start.AddDate(0, 0, -2),
|
||||
End: end.AddDate(0, 0, 2),
|
||||
Filter: filter,
|
||||
AllowedLibraryIDs: af.AllowedLibraryIDs,
|
||||
DisabledLibraryIDs: af.DisabledLibraryIDs,
|
||||
@@ -118,11 +124,11 @@ func (h *CalendarHandler) HandleGetCalendar(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
// Group events by date and build response.
|
||||
days := groupEventsByDate(events, r, h.detailSvc)
|
||||
days := groupEventsByDate(events, r, h.detailSvc, start, end, viewerLocation)
|
||||
writeJSON(w, http.StatusOK, calendarResponse{Events: days})
|
||||
}
|
||||
|
||||
func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSvc *catalog.DetailService) []calendarDayResponse {
|
||||
func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSvc *catalog.DetailService, start, end time.Time, viewerLocation *time.Location) []calendarDayResponse {
|
||||
if len(events) == 0 {
|
||||
return []calendarDayResponse{}
|
||||
}
|
||||
@@ -144,17 +150,75 @@ func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSv
|
||||
posterURLs = detailSvc.PresignImageURLs(r.Context(), posterPaths, "poster", "small")
|
||||
}
|
||||
|
||||
type preparedCalendarEvent struct {
|
||||
event catalog.CalendarEvent
|
||||
localDate string
|
||||
sourceDate string
|
||||
airAt *time.Time
|
||||
airAtString *string
|
||||
}
|
||||
|
||||
prepared := make([]preparedCalendarEvent, 0, len(events))
|
||||
for _, ev := range events {
|
||||
airAt := catalog.CalendarEventAirAt(ev.AirDate, ev.AirTime, ev.AirTimezone)
|
||||
localDateTime := ev.AirDate
|
||||
if airAt != nil {
|
||||
localDateTime = airAt.In(viewerLocation)
|
||||
}
|
||||
localDate := localDateTime.Format("2006-01-02")
|
||||
if localDate < start.Format("2006-01-02") || localDate > end.Format("2006-01-02") {
|
||||
continue
|
||||
}
|
||||
var airAtString *string
|
||||
if airAt != nil {
|
||||
formatted := airAt.Format(time.RFC3339)
|
||||
airAtString = &formatted
|
||||
}
|
||||
prepared = append(prepared, preparedCalendarEvent{
|
||||
event: ev,
|
||||
localDate: localDate,
|
||||
sourceDate: ev.AirDate.Format("2006-01-02"),
|
||||
airAt: airAt,
|
||||
airAtString: airAtString,
|
||||
})
|
||||
}
|
||||
if len(prepared) == 0 {
|
||||
return []calendarDayResponse{}
|
||||
}
|
||||
|
||||
sort.SliceStable(prepared, func(i, j int) bool {
|
||||
left, right := prepared[i], prepared[j]
|
||||
if left.localDate != right.localDate {
|
||||
return left.localDate < right.localDate
|
||||
}
|
||||
if left.airAt != nil && right.airAt != nil && !left.airAt.Equal(*right.airAt) {
|
||||
return left.airAt.Before(*right.airAt)
|
||||
}
|
||||
if (left.airAt != nil) != (right.airAt != nil) {
|
||||
return left.airAt != nil
|
||||
}
|
||||
if left.event.Title != right.event.Title {
|
||||
return left.event.Title < right.event.Title
|
||||
}
|
||||
if left.event.SeasonNumber != nil && right.event.SeasonNumber != nil && *left.event.SeasonNumber != *right.event.SeasonNumber {
|
||||
return *left.event.SeasonNumber < *right.event.SeasonNumber
|
||||
}
|
||||
if left.event.EpisodeNumber != nil && right.event.EpisodeNumber != nil && *left.event.EpisodeNumber != *right.event.EpisodeNumber {
|
||||
return *left.event.EpisodeNumber < *right.event.EpisodeNumber
|
||||
}
|
||||
return left.event.ContentID < right.event.ContentID
|
||||
})
|
||||
|
||||
var days []calendarDayResponse
|
||||
var currentDay *calendarDayResponse
|
||||
|
||||
for _, ev := range events {
|
||||
dateStr := ev.AirDate.Format("2006-01-02")
|
||||
|
||||
if currentDay == nil || currentDay.Date != dateStr {
|
||||
for _, item := range prepared {
|
||||
ev := item.event
|
||||
if currentDay == nil || currentDay.Date != item.localDate {
|
||||
if currentDay != nil {
|
||||
days = append(days, *currentDay)
|
||||
}
|
||||
currentDay = &calendarDayResponse{Date: dateStr}
|
||||
currentDay = &calendarDayResponse{Date: item.localDate}
|
||||
}
|
||||
|
||||
badges := buildBadges(ev)
|
||||
@@ -167,8 +231,11 @@ func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSv
|
||||
SeriesID: ev.SeriesID,
|
||||
SeasonNumber: ev.SeasonNumber,
|
||||
EpisodeNumber: ev.EpisodeNumber,
|
||||
AirDate: dateStr,
|
||||
AirDate: item.sourceDate,
|
||||
AirTime: ev.AirTime,
|
||||
AirAt: item.airAtString,
|
||||
AirTimezone: ev.AirTimezone,
|
||||
LocalAirDate: item.localDate,
|
||||
PosterURL: posterURLs[ev.PosterPath],
|
||||
PosterThumbhash: ev.PosterThumbhash,
|
||||
Badges: badges,
|
||||
|
||||
@@ -110,6 +110,62 @@ func TestHandleGetCalendar_ReturnsEmptyEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_ExpandsRepoRangeAndGroupsByViewerLocalDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
airTime := "00:30"
|
||||
airTimezone := "Asia/Tokyo"
|
||||
repo := &stubCalendarRepo{
|
||||
events: []catalog.CalendarEvent{
|
||||
{
|
||||
ContentID: "episode-1",
|
||||
Type: "episode",
|
||||
Title: "Series",
|
||||
SeriesID: ptrString("series-1"),
|
||||
AirDate: time.Date(2026, time.January, 2, 0, 0, 0, 0, time.UTC),
|
||||
AirTime: &airTime,
|
||||
AirTimezone: &airTimezone,
|
||||
},
|
||||
},
|
||||
}
|
||||
handler := &CalendarHandler{repo: repo}
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-01-01&end=2026-01-01&timezone=America/New_York", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetCalendar(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if got := repo.last.Start.Format("2006-01-02"); got != "2025-12-30" {
|
||||
t.Fatalf("repo start = %s, want expanded 2025-12-30", got)
|
||||
}
|
||||
if got := repo.last.End.Format("2006-01-02"); got != "2026-01-03" {
|
||||
t.Fatalf("repo end = %s, want expanded 2026-01-03", got)
|
||||
}
|
||||
|
||||
var resp calendarResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(resp.Events) != 1 {
|
||||
t.Fatalf("days len = %d, want 1", len(resp.Events))
|
||||
}
|
||||
if resp.Events[0].Date != "2026-01-01" {
|
||||
t.Fatalf("day = %q, want viewer-local 2026-01-01", resp.Events[0].Date)
|
||||
}
|
||||
item := resp.Events[0].Items[0]
|
||||
if item.AirDate != "2026-01-02" {
|
||||
t.Fatalf("air_date = %q, want source date 2026-01-02", item.AirDate)
|
||||
}
|
||||
if item.LocalAirDate != "2026-01-01" {
|
||||
t.Fatalf("local_air_date = %q, want 2026-01-01", item.LocalAirDate)
|
||||
}
|
||||
if item.AirAt == nil || *item.AirAt != "2026-01-01T15:30:00Z" {
|
||||
t.Fatalf("air_at = %v, want 2026-01-01T15:30:00Z", item.AirAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_GroupsEventsAndBatchResolvesCardPosters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var networkAirTimezones = map[string]string{
|
||||
"abc": "America/New_York",
|
||||
"cbs": "America/New_York",
|
||||
"nbc": "America/New_York",
|
||||
"fox": "America/New_York",
|
||||
"the cw": "America/New_York",
|
||||
"cw": "America/New_York",
|
||||
"hbo": "America/New_York",
|
||||
"showtime": "America/New_York",
|
||||
"fx": "America/New_York",
|
||||
"amc": "America/New_York",
|
||||
"bbc": "Europe/London",
|
||||
"bbc one": "Europe/London",
|
||||
"bbc two": "Europe/London",
|
||||
"itv": "Europe/London",
|
||||
"channel 4": "Europe/London",
|
||||
}
|
||||
|
||||
var countryAirTimezones = map[string]string{
|
||||
"france": "Europe/Paris",
|
||||
"japan": "Asia/Tokyo",
|
||||
"south korea": "Asia/Seoul",
|
||||
"korea": "Asia/Seoul",
|
||||
"united kingdom": "Europe/London",
|
||||
"uk": "Europe/London",
|
||||
}
|
||||
|
||||
// ValidateAirTimezone reports whether tz is empty or a valid IANA timezone.
|
||||
func ValidateAirTimezone(tz string) bool {
|
||||
tz = strings.TrimSpace(tz)
|
||||
if tz == "" {
|
||||
return true
|
||||
}
|
||||
_, err := time.LoadLocation(tz)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// InferAirTimezone returns a conservative source airing timezone for series metadata.
|
||||
func InferAirTimezone(networks, countries []string) string {
|
||||
for _, network := range networks {
|
||||
if tz := networkAirTimezones[normalizeScheduleLookupKey(network)]; tz != "" {
|
||||
return tz
|
||||
}
|
||||
}
|
||||
for _, country := range countries {
|
||||
if tz := countryAirTimezones[normalizeScheduleLookupKey(country)]; tz != "" {
|
||||
return tz
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CalendarEventAirAt combines a source date, source wall-clock time, and source timezone.
|
||||
func CalendarEventAirAt(airDate time.Time, airTime, airTimezone *string) *time.Time {
|
||||
if airTime == nil || strings.TrimSpace(*airTime) == "" {
|
||||
return nil
|
||||
}
|
||||
if airTimezone == nil || strings.TrimSpace(*airTimezone) == "" {
|
||||
return nil
|
||||
}
|
||||
loc, err := time.LoadLocation(strings.TrimSpace(*airTimezone))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
parsed, ok := parseAirTime(*airTime)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
local := time.Date(
|
||||
airDate.Year(),
|
||||
airDate.Month(),
|
||||
airDate.Day(),
|
||||
parsed.Hour(),
|
||||
parsed.Minute(),
|
||||
parsed.Second(),
|
||||
0,
|
||||
loc,
|
||||
)
|
||||
utc := local.UTC()
|
||||
return &utc
|
||||
}
|
||||
|
||||
func CalendarLocation(name string) *time.Location {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return time.UTC
|
||||
}
|
||||
loc, err := time.LoadLocation(strings.TrimSpace(name))
|
||||
if err != nil {
|
||||
return time.UTC
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
func parseAirTime(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
for _, layout := range []string{"15:04:05", "15:04"} {
|
||||
parsed, err := time.Parse(layout, value)
|
||||
if err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func normalizeScheduleLookupKey(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInferAirTimezone_UsesNetworkBeforeCountry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := InferAirTimezone([]string{"BBC One"}, []string{"Japan"})
|
||||
if got != "Europe/London" {
|
||||
t.Fatalf("timezone = %q, want Europe/London", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferAirTimezone_UsesSingleTimezoneCountry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := InferAirTimezone(nil, []string{"South Korea"})
|
||||
if got != "Asia/Seoul" {
|
||||
t.Fatalf("timezone = %q, want Asia/Seoul", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAirTimezone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !ValidateAirTimezone("America/New_York") {
|
||||
t.Fatal("expected America/New_York to be valid")
|
||||
}
|
||||
if ValidateAirTimezone("Eastern") {
|
||||
t.Fatal("expected Eastern to be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventAirAt_ConvertsSourceTimezoneToUTC(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
airDate := time.Date(2026, time.May, 28, 0, 0, 0, 0, time.UTC)
|
||||
airTime := "23:30"
|
||||
airTimezone := "Asia/Tokyo"
|
||||
|
||||
got := CalendarEventAirAt(airDate, &airTime, &airTimezone)
|
||||
if got == nil {
|
||||
t.Fatal("expected air_at")
|
||||
}
|
||||
|
||||
want := time.Date(2026, time.May, 28, 14, 30, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("air_at = %s, want %s", got.Format(time.RFC3339), want.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventAirAt_UsesDSTForSourceDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
airTime := "20:00"
|
||||
airTimezone := "America/New_York"
|
||||
winter := CalendarEventAirAt(time.Date(2026, time.January, 15, 0, 0, 0, 0, time.UTC), &airTime, &airTimezone)
|
||||
summer := CalendarEventAirAt(time.Date(2026, time.July, 15, 0, 0, 0, 0, time.UTC), &airTime, &airTimezone)
|
||||
if winter == nil || summer == nil {
|
||||
t.Fatal("expected air_at values")
|
||||
}
|
||||
if winter.Hour() != 1 {
|
||||
t.Fatalf("winter UTC hour = %d, want 1", winter.Hour())
|
||||
}
|
||||
if summer.Hour() != 0 {
|
||||
t.Fatalf("summer UTC hour = %d, want 0", summer.Hour())
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ type CalendarEvent struct {
|
||||
EpisodeNumber *int
|
||||
AirDate time.Time
|
||||
AirTime *string
|
||||
AirTimezone *string
|
||||
PosterPath string
|
||||
PosterThumbhash string
|
||||
IsPremiere bool
|
||||
@@ -70,7 +71,7 @@ func (r *CalendarRepository) ListEvents(ctx context.Context, f CalendarFilter) (
|
||||
var seasonNum, episodeNum *int
|
||||
if err := rows.Scan(
|
||||
&ev.ContentID, &ev.Type, &ev.Title, &episodeTitle, &seriesID,
|
||||
&seasonNum, &episodeNum, &ev.AirDate, &ev.AirTime,
|
||||
&seasonNum, &episodeNum, &ev.AirDate, &ev.AirTime, &ev.AirTimezone,
|
||||
&ev.PosterPath, &ev.PosterThumbhash,
|
||||
&ev.IsPremiere, &ev.IsFinale,
|
||||
); err != nil {
|
||||
@@ -126,7 +127,7 @@ func (r *CalendarRepository) buildListEventsQuery(f CalendarFilter) (string, []a
|
||||
WHERE e.episode_number = 1 AND e.air_date IS NOT NULL
|
||||
)
|
||||
SELECT content_id, type, title, episode_title, series_id,
|
||||
season_number, episode_number, air_date, air_time,
|
||||
season_number, episode_number, air_date, air_time, air_timezone,
|
||||
poster_path, poster_thumbhash,
|
||||
is_premiere, is_finale
|
||||
FROM (
|
||||
@@ -150,7 +151,7 @@ func (r *CalendarRepository) buildMovieBranch(startArg, endArg int, f CalendarFi
|
||||
return fmt.Sprintf(`SELECT mi.content_id, 'movie'::text AS type,
|
||||
mi.title, NULL::text AS episode_title, NULL::text AS series_id,
|
||||
NULL::int AS season_number, NULL::int AS episode_number,
|
||||
mi.release_date AS air_date, NULL::text AS air_time,
|
||||
mi.release_date AS air_date, NULL::text AS air_time, NULL::text AS air_timezone,
|
||||
mi.poster_path, mi.poster_thumbhash,
|
||||
FALSE AS is_premiere, FALSE AS is_finale
|
||||
FROM media_items mi
|
||||
@@ -168,7 +169,7 @@ func (r *CalendarRepository) buildFilteredEpisodesCTE(startArg, endArg int, f Ca
|
||||
|
||||
return fmt.Sprintf(`SELECT e.content_id, e.series_id, e.season_number,
|
||||
e.episode_number, e.title AS episode_title, e.air_date,
|
||||
mi.title AS title, mi.air_time,
|
||||
mi.title AS title, mi.air_time, mi.air_timezone,
|
||||
mi.poster_path, mi.poster_thumbhash
|
||||
FROM episodes e
|
||||
JOIN media_items mi ON mi.content_id = e.series_id
|
||||
@@ -179,7 +180,7 @@ func (r *CalendarRepository) buildEpisodeBranch() string {
|
||||
return `SELECT fe.content_id, 'episode'::text AS type,
|
||||
fe.title, fe.episode_title, fe.series_id,
|
||||
fe.season_number, fe.episode_number,
|
||||
fe.air_date, fe.air_time,
|
||||
fe.air_date, fe.air_time, fe.air_timezone,
|
||||
fe.poster_path, fe.poster_thumbhash,
|
||||
(fe.episode_number = 1) AS is_premiere,
|
||||
(fe.episode_number = sf.max_episode_number) AS is_finale
|
||||
@@ -197,7 +198,7 @@ func (r *CalendarRepository) buildFilteredSeasonsCTE(startArg, endArg int, f Cal
|
||||
r.appendPersonalFilterClause("s.series_id", f, &conditions, args, argIdx)
|
||||
|
||||
return fmt.Sprintf(`SELECT s.content_id, s.series_id, s.season_number,
|
||||
s.title AS episode_title, s.air_date, mi.title AS title, mi.air_time,
|
||||
s.title AS episode_title, s.air_date, mi.title AS title, mi.air_time, mi.air_timezone,
|
||||
COALESCE(NULLIF(s.poster_path, ''), mi.poster_path) AS poster_path,
|
||||
COALESCE(NULLIF(s.poster_thumbhash, ''), mi.poster_thumbhash) AS poster_thumbhash
|
||||
FROM seasons s
|
||||
@@ -209,7 +210,7 @@ func (r *CalendarRepository) buildSeasonBranch() string {
|
||||
return `SELECT fs.content_id, 'season_premiere'::text AS type,
|
||||
fs.title, fs.episode_title, fs.series_id,
|
||||
fs.season_number, NULL::int AS episode_number,
|
||||
fs.air_date, fs.air_time,
|
||||
fs.air_date, fs.air_time, fs.air_timezone,
|
||||
fs.poster_path, fs.poster_thumbhash,
|
||||
TRUE AS is_premiere, FALSE AS is_finale
|
||||
FROM filtered_seasons fs
|
||||
|
||||
@@ -94,6 +94,7 @@ type ItemDetail struct {
|
||||
LastAirDate *string `json:"last_air_date,omitempty"`
|
||||
ReleaseDate *string `json:"release_date,omitempty"`
|
||||
AirTime *string `json:"air_time,omitempty"`
|
||||
AirTimezone *string `json:"air_timezone,omitempty"`
|
||||
ShowStatus string `json:"show_status,omitempty"`
|
||||
|
||||
// Presigned image URLs.
|
||||
@@ -806,6 +807,7 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M
|
||||
LastAirDate: item.LastAirDate,
|
||||
ReleaseDate: item.ReleaseDate,
|
||||
AirTime: item.AirTime,
|
||||
AirTimezone: item.AirTimezone,
|
||||
ShowStatus: item.ShowStatus,
|
||||
PosterThumbhash: item.PosterThumbhash,
|
||||
BackdropThumbhash: item.BackdropThumbhash,
|
||||
|
||||
@@ -52,7 +52,7 @@ const itemColumns = `content_id, type, title, sort_title, default_metadata_langu
|
||||
imdb_id, tmdb_id, tvdb_id,
|
||||
poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path,
|
||||
metadata_s3_path, metadata_etag, season_count,
|
||||
studios, networks, countries, keywords, original_language, release_date::text, first_air_date, last_air_date, air_time,
|
||||
studios, networks, countries, keywords, original_language, release_date::text, first_air_date, last_air_date, air_time, air_timezone,
|
||||
show_status,
|
||||
matched_at, last_refreshed, refresh_failures,
|
||||
episode_metadata_incomplete, episode_metadata_last_checked_at, locked_fields, status, created_at, updated_at`
|
||||
@@ -65,7 +65,7 @@ func qualifiedItemColumns(alias string) string {
|
||||
"imdb_id", "tmdb_id", "tvdb_id",
|
||||
"poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path",
|
||||
"metadata_s3_path", "metadata_etag", "season_count",
|
||||
"studios", "networks", "countries", "keywords", "original_language", "release_date::text", "first_air_date", "last_air_date", "air_time",
|
||||
"studios", "networks", "countries", "keywords", "original_language", "release_date::text", "first_air_date", "last_air_date", "air_time", "air_timezone",
|
||||
"show_status",
|
||||
"matched_at", "last_refreshed", "refresh_failures",
|
||||
"episode_metadata_incomplete", "episode_metadata_last_checked_at", "locked_fields", "status", "created_at", "updated_at",
|
||||
@@ -85,7 +85,7 @@ func qualifiedListItemColumns(alias string) string {
|
||||
"imdb_id", "tmdb_id", "tvdb_id",
|
||||
"poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path",
|
||||
"metadata_s3_path", "metadata_etag", "season_count",
|
||||
"studios", "networks", "countries", "keywords", "original_language", "release_date::text", "first_air_date", "last_air_date", "air_time",
|
||||
"studios", "networks", "countries", "keywords", "original_language", "release_date::text", "first_air_date", "last_air_date", "air_time", "air_timezone",
|
||||
"show_status",
|
||||
"matched_at", "last_refreshed", "refresh_failures",
|
||||
"episode_metadata_incomplete", "episode_metadata_last_checked_at", "locked_fields", "status", "created_at", "updated_at",
|
||||
@@ -141,6 +141,7 @@ func scanItem(row pgx.Row) (*models.MediaItem, error) {
|
||||
&item.FirstAirDate,
|
||||
&item.LastAirDate,
|
||||
&item.AirTime,
|
||||
&item.AirTimezone,
|
||||
&item.ShowStatus,
|
||||
&item.MatchedAt,
|
||||
&item.LastRefreshed,
|
||||
@@ -203,6 +204,7 @@ func scanItems(rows pgx.Rows) ([]*models.MediaItem, error) {
|
||||
&item.FirstAirDate,
|
||||
&item.LastAirDate,
|
||||
&item.AirTime,
|
||||
&item.AirTimezone,
|
||||
&item.ShowStatus,
|
||||
&item.MatchedAt,
|
||||
&item.LastRefreshed,
|
||||
@@ -326,7 +328,7 @@ func (r *ItemRepository) upsert(ctx context.Context, execer itemExecer, item *mo
|
||||
imdb_id, tmdb_id, tvdb_id,
|
||||
poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path,
|
||||
metadata_s3_path, metadata_etag, season_count,
|
||||
studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date, air_time,
|
||||
studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date, air_time, air_timezone,
|
||||
show_status,
|
||||
matched_at, last_refreshed, refresh_failures,
|
||||
episode_metadata_incomplete, episode_metadata_last_checked_at, status
|
||||
@@ -337,10 +339,10 @@ func (r *ItemRepository) upsert(ctx context.Context, execer itemExecer, item *mo
|
||||
$17, $18, $19,
|
||||
$20, $21, $22, $23, $24,
|
||||
$25, $26, $27,
|
||||
$28, $29, $30, $31, $32, $33, $34, $35, $36,
|
||||
$37,
|
||||
$38, $39, $40,
|
||||
$41, $42, $43
|
||||
$28, $29, $30, $31, $32, $33, $34, $35, $36, $37,
|
||||
$38,
|
||||
$39, $40, $41,
|
||||
$42, $43, $44
|
||||
)
|
||||
ON CONFLICT (content_id) DO UPDATE SET
|
||||
type = EXCLUDED.type,
|
||||
@@ -378,6 +380,7 @@ func (r *ItemRepository) upsert(ctx context.Context, execer itemExecer, item *mo
|
||||
first_air_date = EXCLUDED.first_air_date,
|
||||
last_air_date = EXCLUDED.last_air_date,
|
||||
air_time = EXCLUDED.air_time,
|
||||
air_timezone = EXCLUDED.air_timezone,
|
||||
show_status = EXCLUDED.show_status,
|
||||
matched_at = EXCLUDED.matched_at,
|
||||
last_refreshed = EXCLUDED.last_refreshed,
|
||||
@@ -424,6 +427,7 @@ func (r *ItemRepository) upsert(ctx context.Context, execer itemExecer, item *mo
|
||||
item.FirstAirDate,
|
||||
item.LastAirDate,
|
||||
item.AirTime,
|
||||
item.AirTimezone,
|
||||
item.ShowStatus,
|
||||
item.MatchedAt,
|
||||
item.LastRefreshed,
|
||||
@@ -1285,6 +1289,7 @@ func (r *ItemRepository) UpdateMetadata(ctx context.Context, contentID string, u
|
||||
addString("first_air_date", upd.FirstAirDate)
|
||||
addString("last_air_date", upd.LastAirDate)
|
||||
addString("air_time", upd.AirTime)
|
||||
addString("air_timezone", upd.AirTimezone)
|
||||
addString("status", upd.Status)
|
||||
addString("show_status", upd.ShowStatus)
|
||||
addString("imdb_id", upd.ImdbID)
|
||||
|
||||
@@ -28,6 +28,7 @@ type MetadataUpdate struct {
|
||||
FirstAirDate *string
|
||||
LastAirDate *string
|
||||
AirTime *string
|
||||
AirTimezone *string
|
||||
AirDate *string
|
||||
Status *string
|
||||
ShowStatus *string
|
||||
|
||||
@@ -372,6 +372,8 @@ func (s *Service) streamItemRecords(ctx context.Context, folderIDs []int, fn fun
|
||||
mi.release_date::text,
|
||||
mi.first_air_date,
|
||||
mi.last_air_date,
|
||||
mi.air_time,
|
||||
mi.air_timezone,
|
||||
mi.matched_at,
|
||||
mi.last_refreshed,
|
||||
COALESCE(mi.refresh_failures, 0),
|
||||
@@ -426,6 +428,8 @@ func (s *Service) streamItemRecords(ctx context.Context, folderIDs []int, fn fun
|
||||
&record.ReleaseDate,
|
||||
&record.FirstAirDate,
|
||||
&record.LastAirDate,
|
||||
&record.AirTime,
|
||||
&record.AirTimezone,
|
||||
&record.MatchedAt,
|
||||
&record.LastRefreshed,
|
||||
&record.RefreshFailures,
|
||||
|
||||
@@ -166,7 +166,7 @@ func (s *Service) ImportWithProgress(ctx context.Context, data []byte, opts Impo
|
||||
item.ImdbID, item.TmdbID, item.TvdbID,
|
||||
item.PosterPath, item.PosterThumbhash, item.BackdropPath, item.BackdropThumbhash, item.LogoPath,
|
||||
item.MetadataS3Path, item.MetadataEtag, item.SeasonCount,
|
||||
studios, networks, countries, keywords, item.OriginalLanguage, item.ReleaseDate, item.FirstAirDate, item.LastAirDate,
|
||||
studios, networks, countries, keywords, item.OriginalLanguage, item.ReleaseDate, item.FirstAirDate, item.LastAirDate, item.AirTime, item.AirTimezone,
|
||||
item.MatchedAt, item.LastRefreshed, item.RefreshFailures, item.LockedFields, item.Status,
|
||||
item.CreatedAt, item.UpdatedAt,
|
||||
})
|
||||
@@ -179,7 +179,7 @@ func (s *Service) ImportWithProgress(ctx context.Context, data []byte, opts Impo
|
||||
"imdb_id", "tmdb_id", "tvdb_id",
|
||||
"poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path",
|
||||
"metadata_s3_path", "metadata_etag", "season_count",
|
||||
"studios", "networks", "countries", "keywords", "original_language", "release_date", "first_air_date", "last_air_date",
|
||||
"studios", "networks", "countries", "keywords", "original_language", "release_date", "first_air_date", "last_air_date", "air_time", "air_timezone",
|
||||
"matched_at", "last_refreshed", "refresh_failures", "locked_fields", "status",
|
||||
"created_at", "updated_at",
|
||||
},
|
||||
@@ -1044,7 +1044,7 @@ func bulkInsertItems(ctx context.Context, tx pgx.Tx, items []ItemRecord, onBatch
|
||||
item.ImdbID, item.TmdbID, item.TvdbID,
|
||||
item.PosterPath, item.PosterThumbhash, item.BackdropPath, item.BackdropThumbhash, item.LogoPath,
|
||||
item.MetadataS3Path, item.MetadataEtag, item.SeasonCount,
|
||||
item.Studios, item.Networks, item.Countries, item.Keywords, item.OriginalLanguage, item.ReleaseDate, item.FirstAirDate, item.LastAirDate,
|
||||
item.Studios, item.Networks, item.Countries, item.Keywords, item.OriginalLanguage, item.ReleaseDate, item.FirstAirDate, item.LastAirDate, item.AirTime, item.AirTimezone,
|
||||
item.MatchedAt, item.LastRefreshed, item.RefreshFailures, item.LockedFields, item.Status,
|
||||
item.CreatedAt, item.UpdatedAt,
|
||||
})
|
||||
@@ -1058,12 +1058,12 @@ func bulkInsertItems(ctx context.Context, tx pgx.Tx, items []ItemRecord, onBatch
|
||||
imdb_id, tmdb_id, tvdb_id,
|
||||
poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path,
|
||||
metadata_s3_path, metadata_etag, season_count,
|
||||
studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date,
|
||||
studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date, air_time, air_timezone,
|
||||
matched_at, last_refreshed, refresh_failures, locked_fields, status,
|
||||
created_at, updated_at
|
||||
) VALUES `,
|
||||
rows,
|
||||
41,
|
||||
43,
|
||||
nil,
|
||||
"",
|
||||
onBatch,
|
||||
@@ -1904,13 +1904,13 @@ func batchImportItems(ctx context.Context, tx pgx.Tx, items []ItemRecord, mode C
|
||||
item.ImdbID, item.TmdbID, item.TvdbID,
|
||||
item.PosterPath, item.PosterThumbhash, item.BackdropPath, item.BackdropThumbhash, item.LogoPath,
|
||||
item.MetadataS3Path, item.MetadataEtag, item.SeasonCount,
|
||||
studios, networks, countries, keywords, item.OriginalLanguage, item.ReleaseDate, item.FirstAirDate, item.LastAirDate,
|
||||
studios, networks, countries, keywords, item.OriginalLanguage, item.ReleaseDate, item.FirstAirDate, item.LastAirDate, item.AirTime, item.AirTimezone,
|
||||
item.MatchedAt, item.LastRefreshed, item.RefreshFailures, item.LockedFields, item.Status,
|
||||
item.CreatedAt, item.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
const colCount = 41
|
||||
const colCount = 43
|
||||
prefix := `
|
||||
INSERT INTO media_items (
|
||||
content_id, type, title, sort_title, original_title, year, genres,
|
||||
@@ -1919,7 +1919,7 @@ func batchImportItems(ctx context.Context, tx pgx.Tx, items []ItemRecord, mode C
|
||||
imdb_id, tmdb_id, tvdb_id,
|
||||
poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path,
|
||||
metadata_s3_path, metadata_etag, season_count,
|
||||
studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date,
|
||||
studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date, air_time, air_timezone,
|
||||
matched_at, last_refreshed, refresh_failures, locked_fields, status,
|
||||
created_at, updated_at
|
||||
) VALUES `
|
||||
@@ -1963,6 +1963,8 @@ func batchImportItems(ctx context.Context, tx pgx.Tx, items []ItemRecord, mode C
|
||||
release_date = EXCLUDED.release_date,
|
||||
first_air_date = EXCLUDED.first_air_date,
|
||||
last_air_date = EXCLUDED.last_air_date,
|
||||
air_time = EXCLUDED.air_time,
|
||||
air_timezone = EXCLUDED.air_timezone,
|
||||
matched_at = EXCLUDED.matched_at,
|
||||
last_refreshed = EXCLUDED.last_refreshed,
|
||||
refresh_failures = EXCLUDED.refresh_failures,
|
||||
@@ -2055,7 +2057,7 @@ func importItem(ctx context.Context, tx pgx.Tx, item ItemRecord, mode ConflictMo
|
||||
imdb_id, tmdb_id, tvdb_id,
|
||||
poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path,
|
||||
metadata_s3_path, metadata_etag, season_count,
|
||||
studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date,
|
||||
studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date, air_time, air_timezone,
|
||||
matched_at, last_refreshed, refresh_failures, locked_fields, status,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
@@ -2065,9 +2067,9 @@ func importItem(ctx context.Context, tx pgx.Tx, item ItemRecord, mode ConflictMo
|
||||
$16, $17, $18,
|
||||
$19, $20, $21, $22, $23,
|
||||
$24, $25, $26,
|
||||
$27, $28, $29, $30, $31, $32, $33, $34,
|
||||
$35, $36, $37, $38, $39,
|
||||
$40, $41
|
||||
$27, $28, $29, $30, $31, $32, $33, $34, $35, $36,
|
||||
$37, $38, $39, $40, $41,
|
||||
$42, $43
|
||||
)
|
||||
ON CONFLICT (content_id) DO NOTHING`,
|
||||
item.ContentID, item.Type, item.Title, item.SortTitle, item.OriginalTitle, item.Year, item.Genres,
|
||||
@@ -2076,7 +2078,7 @@ func importItem(ctx context.Context, tx pgx.Tx, item ItemRecord, mode ConflictMo
|
||||
item.ImdbID, item.TmdbID, item.TvdbID,
|
||||
item.PosterPath, item.PosterThumbhash, item.BackdropPath, item.BackdropThumbhash, item.LogoPath,
|
||||
item.MetadataS3Path, item.MetadataEtag, item.SeasonCount,
|
||||
item.Studios, item.Networks, item.Countries, item.Keywords, item.OriginalLanguage, item.ReleaseDate, item.FirstAirDate, item.LastAirDate,
|
||||
item.Studios, item.Networks, item.Countries, item.Keywords, item.OriginalLanguage, item.ReleaseDate, item.FirstAirDate, item.LastAirDate, item.AirTime, item.AirTimezone,
|
||||
item.MatchedAt, item.LastRefreshed, item.RefreshFailures, item.LockedFields, item.Status,
|
||||
item.CreatedAt, item.UpdatedAt,
|
||||
)
|
||||
@@ -2094,7 +2096,7 @@ func importItem(ctx context.Context, tx pgx.Tx, item ItemRecord, mode ConflictMo
|
||||
imdb_id, tmdb_id, tvdb_id,
|
||||
poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path,
|
||||
metadata_s3_path, metadata_etag, season_count,
|
||||
studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date,
|
||||
studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date, air_time, air_timezone,
|
||||
matched_at, last_refreshed, refresh_failures, locked_fields, status,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
@@ -2104,9 +2106,9 @@ func importItem(ctx context.Context, tx pgx.Tx, item ItemRecord, mode ConflictMo
|
||||
$16, $17, $18,
|
||||
$19, $20, $21, $22, $23,
|
||||
$24, $25, $26,
|
||||
$27, $28, $29, $30, $31, $32, $33, $34,
|
||||
$35, $36, $37, $38, $39,
|
||||
$40, $41
|
||||
$27, $28, $29, $30, $31, $32, $33, $34, $35, $36,
|
||||
$37, $38, $39, $40, $41,
|
||||
$42, $43
|
||||
)
|
||||
ON CONFLICT (content_id) DO UPDATE SET
|
||||
type = EXCLUDED.type,
|
||||
@@ -2142,6 +2144,8 @@ func importItem(ctx context.Context, tx pgx.Tx, item ItemRecord, mode ConflictMo
|
||||
release_date = EXCLUDED.release_date,
|
||||
first_air_date = EXCLUDED.first_air_date,
|
||||
last_air_date = EXCLUDED.last_air_date,
|
||||
air_time = EXCLUDED.air_time,
|
||||
air_timezone = EXCLUDED.air_timezone,
|
||||
matched_at = EXCLUDED.matched_at,
|
||||
last_refreshed = EXCLUDED.last_refreshed,
|
||||
refresh_failures = EXCLUDED.refresh_failures,
|
||||
@@ -2155,7 +2159,7 @@ func importItem(ctx context.Context, tx pgx.Tx, item ItemRecord, mode ConflictMo
|
||||
item.ImdbID, item.TmdbID, item.TvdbID,
|
||||
item.PosterPath, item.PosterThumbhash, item.BackdropPath, item.BackdropThumbhash, item.LogoPath,
|
||||
item.MetadataS3Path, item.MetadataEtag, item.SeasonCount,
|
||||
item.Studios, item.Networks, item.Countries, item.Keywords, item.OriginalLanguage, item.ReleaseDate, item.FirstAirDate, item.LastAirDate,
|
||||
item.Studios, item.Networks, item.Countries, item.Keywords, item.OriginalLanguage, item.ReleaseDate, item.FirstAirDate, item.LastAirDate, item.AirTime, item.AirTimezone,
|
||||
item.MatchedAt, item.LastRefreshed, item.RefreshFailures, item.LockedFields, item.Status,
|
||||
item.CreatedAt, item.UpdatedAt,
|
||||
).Scan(&created)
|
||||
|
||||
@@ -70,6 +70,8 @@ type ItemRecord struct {
|
||||
ReleaseDate *string `json:"release_date,omitempty"`
|
||||
FirstAirDate *string `json:"first_air_date,omitempty"`
|
||||
LastAirDate *string `json:"last_air_date,omitempty"`
|
||||
AirTime *string `json:"air_time,omitempty"`
|
||||
AirTimezone *string `json:"air_timezone,omitempty"`
|
||||
MatchedAt *time.Time `json:"matched_at,omitempty"`
|
||||
LastRefreshed *time.Time `json:"last_refreshed,omitempty"`
|
||||
RefreshFailures int `json:"refresh_failures"`
|
||||
|
||||
@@ -49,18 +49,40 @@ func TestMetadataResultToItem_CarriesAirTime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataResultToItem_InfersAirTimezone(t *testing.T) {
|
||||
result := &MetadataResult{
|
||||
HasMetadata: true,
|
||||
Title: "Series",
|
||||
Networks: []string{"BBC One"},
|
||||
}
|
||||
setMetadataAirTime(t, result, "20:00")
|
||||
|
||||
item := metadataResultToItem(result, "series")
|
||||
if item.AirTimezone == nil {
|
||||
t.Fatal("expected item air_timezone to be inferred")
|
||||
}
|
||||
if got := *item.AirTimezone; got != "Europe/London" {
|
||||
t.Fatalf("expected inferred air_timezone Europe/London, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemToMetadataResult_CarriesAirTime(t *testing.T) {
|
||||
airTime := "20:00"
|
||||
airTimezone := "America/New_York"
|
||||
result := itemToMetadataResult(&models.MediaItem{
|
||||
ContentID: "series-1",
|
||||
Type: "series",
|
||||
Title: "Series",
|
||||
AirTime: &airTime,
|
||||
ContentID: "series-1",
|
||||
Type: "series",
|
||||
Title: "Series",
|
||||
AirTime: &airTime,
|
||||
AirTimezone: &airTimezone,
|
||||
})
|
||||
|
||||
if got := getMetadataAirTime(t, result); got != airTime {
|
||||
t.Fatalf("expected metadata air_time %q, got %q", airTime, got)
|
||||
}
|
||||
if result.AirTimezone != airTimezone {
|
||||
t.Fatalf("expected metadata air_timezone %q, got %q", airTimezone, result.AirTimezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeMetadata_CarriesAirTime(t *testing.T) {
|
||||
@@ -75,6 +97,20 @@ func TestMergeMetadata_CarriesAirTime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeMetadata_RespectsAirScheduleLock(t *testing.T) {
|
||||
source := &MetadataResult{AirTime: "20:00", AirTimezone: "Europe/London"}
|
||||
target := &MetadataResult{AirTime: "21:00", AirTimezone: "America/New_York"}
|
||||
|
||||
MergeMetadata(source, target, []MetadataField{FieldAirSchedule}, MergeReplaceUnlocked)
|
||||
|
||||
if target.AirTime != "21:00" {
|
||||
t.Fatalf("expected locked air_time to remain 21:00, got %q", target.AirTime)
|
||||
}
|
||||
if target.AirTimezone != "America/New_York" {
|
||||
t.Fatalf("expected locked air_timezone to remain America/New_York, got %q", target.AirTimezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeGlobalMetadata_CarriesAirTime(t *testing.T) {
|
||||
source := &MetadataResult{}
|
||||
target := &MetadataResult{}
|
||||
|
||||
@@ -49,7 +49,10 @@ func MergeMetadata(source, target *MetadataResult, locked []MetadataField, mode
|
||||
mergeInt(&target.SeasonCount, source.SeasonCount, mode)
|
||||
mergeScalar(&target.FirstAirDate, source.FirstAirDate, mode)
|
||||
mergeScalar(&target.LastAirDate, source.LastAirDate, mode)
|
||||
mergeScalar(&target.AirTime, source.AirTime, mode)
|
||||
if !isLocked(FieldAirSchedule) {
|
||||
mergeScalar(&target.AirTime, source.AirTime, mode)
|
||||
mergeScalar(&target.AirTimezone, source.AirTimezone, mode)
|
||||
}
|
||||
|
||||
// Genres follow provider priority during FillEmpty instead of unioning tags
|
||||
// from later providers, which can create noisy hybrid classifications.
|
||||
@@ -114,7 +117,10 @@ func MergeGlobalMetadata(source, target *MetadataResult, locked []MetadataField,
|
||||
mergeInt(&target.SeasonCount, source.SeasonCount, mode)
|
||||
mergeScalar(&target.FirstAirDate, source.FirstAirDate, mode)
|
||||
mergeScalar(&target.LastAirDate, source.LastAirDate, mode)
|
||||
mergeScalar(&target.AirTime, source.AirTime, mode)
|
||||
if !isLocked(FieldAirSchedule) {
|
||||
mergeScalar(&target.AirTime, source.AirTime, mode)
|
||||
mergeScalar(&target.AirTimezone, source.AirTimezone, mode)
|
||||
}
|
||||
|
||||
if !isLocked(FieldGenres) {
|
||||
mergePrioritizedStringSlice(&target.Genres, source.Genres, mode)
|
||||
|
||||
@@ -4746,6 +4746,9 @@ func itemToMetadataResult(item *models.MediaItem) *MetadataResult {
|
||||
if item.AirTime != nil {
|
||||
result.AirTime = *item.AirTime
|
||||
}
|
||||
if item.AirTimezone != nil {
|
||||
result.AirTimezone = *item.AirTimezone
|
||||
}
|
||||
if item.ReleaseDate != nil {
|
||||
result.ReleaseDate = *item.ReleaseDate
|
||||
}
|
||||
@@ -4810,6 +4813,12 @@ func metadataResultToItem(r *MetadataResult, contentType string) *models.MediaIt
|
||||
if r.AirTime != "" {
|
||||
item.AirTime = &r.AirTime
|
||||
}
|
||||
if r.AirTime != "" && r.AirTimezone == "" {
|
||||
r.AirTimezone = catalog.InferAirTimezone(r.Networks, r.Countries)
|
||||
}
|
||||
if r.AirTimezone != "" {
|
||||
item.AirTimezone = &r.AirTimezone
|
||||
}
|
||||
if r.ReleaseDate != "" {
|
||||
item.ReleaseDate = &r.ReleaseDate
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ const (
|
||||
FieldTags
|
||||
FieldContentRating
|
||||
FieldImages
|
||||
FieldAirSchedule
|
||||
)
|
||||
|
||||
// RefreshPriority controls queue ordering.
|
||||
@@ -178,6 +179,7 @@ type MetadataResult struct {
|
||||
FirstAirDate string
|
||||
LastAirDate string
|
||||
AirTime string
|
||||
AirTimezone string
|
||||
}
|
||||
|
||||
// Ratings holds ratings from multiple sources.
|
||||
|
||||
@@ -314,6 +314,7 @@ type MediaItem struct {
|
||||
FirstAirDate *string // ISO date (series only), nullable
|
||||
LastAirDate *string // ISO date (series only), nullable
|
||||
AirTime *string // Series broadcast time (e.g. "20:00"), nullable
|
||||
AirTimezone *string // Series broadcast timezone (IANA name, e.g. "America/New_York"), nullable
|
||||
ShowStatus string // Series lifecycle: "returning", "ended", "cancelled", "in_production", or "" if unknown (series only)
|
||||
People []ItemPerson
|
||||
MatchedAt *time.Time
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE media_items DROP COLUMN IF EXISTS air_timezone;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE media_items ADD COLUMN air_timezone text;
|
||||
@@ -831,6 +831,7 @@ export interface ItemDetail {
|
||||
first_air_date: string | null;
|
||||
last_air_date: string | null;
|
||||
air_time?: string | null;
|
||||
air_timezone?: string | null;
|
||||
|
||||
// Presigned image URLs.
|
||||
poster_url: string;
|
||||
@@ -2505,6 +2506,9 @@ export interface SectionItemUpcomingEvent {
|
||||
type: "movie" | "episode" | "season_premiere";
|
||||
air_date: string;
|
||||
air_time?: string;
|
||||
air_at?: string | null;
|
||||
air_timezone?: string | null;
|
||||
local_air_date?: string;
|
||||
episode_title?: string | null;
|
||||
season_number?: number | null;
|
||||
episode_number?: number | null;
|
||||
|
||||
@@ -24,6 +24,19 @@ const FIELD_STUDIOS = 3;
|
||||
const FIELD_RATING = 6;
|
||||
const FIELD_RUNTIME = 7;
|
||||
const FIELD_CONTENT_RATING = 9;
|
||||
const FIELD_AIR_SCHEDULE = 11;
|
||||
|
||||
const AIR_TIMEZONES = [
|
||||
"America/New_York",
|
||||
"America/Chicago",
|
||||
"America/Denver",
|
||||
"America/Los_Angeles",
|
||||
"Europe/London",
|
||||
"Europe/Paris",
|
||||
"Asia/Tokyo",
|
||||
"Asia/Seoul",
|
||||
"Australia/Sydney",
|
||||
];
|
||||
|
||||
type Section = "general" | "dates" | "tags" | "ids" | "images";
|
||||
|
||||
@@ -52,6 +65,8 @@ const FIELD_LOCK_MAP: Record<string, number> = {
|
||||
rating_rt_audience: FIELD_RATING,
|
||||
runtime: FIELD_RUNTIME,
|
||||
content_rating: FIELD_CONTENT_RATING,
|
||||
air_time: FIELD_AIR_SCHEDULE,
|
||||
air_timezone: FIELD_AIR_SCHEDULE,
|
||||
};
|
||||
|
||||
interface EditMetadataDialogProps {
|
||||
@@ -78,6 +93,7 @@ function initFormState(item: ItemDetail) {
|
||||
first_air_date: item.first_air_date ?? "",
|
||||
last_air_date: item.last_air_date ?? "",
|
||||
air_time: item.air_time ?? "",
|
||||
air_timezone: item.air_timezone ?? "",
|
||||
air_date: item.air_date ?? "",
|
||||
status: item.status ?? "",
|
||||
rating_imdb: item.rating_imdb,
|
||||
@@ -168,6 +184,8 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
|
||||
if (form.last_air_date !== originalForm.last_air_date)
|
||||
data.last_air_date = form.last_air_date || null;
|
||||
if (form.air_time !== originalForm.air_time) data.air_time = form.air_time || null;
|
||||
if (form.air_timezone !== originalForm.air_timezone)
|
||||
data.air_timezone = form.air_timezone || null;
|
||||
if (form.air_date !== originalForm.air_date) data.air_date = form.air_date || null;
|
||||
if (form.status !== originalForm.status) data.status = form.status;
|
||||
if (form.rating_imdb !== originalForm.rating_imdb) data.rating_imdb = form.rating_imdb;
|
||||
@@ -432,7 +450,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
|
||||
)}
|
||||
|
||||
{item.type === "series" && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<FieldRow label="Last Air Date">
|
||||
<Input
|
||||
type="date"
|
||||
@@ -440,13 +458,26 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
|
||||
onChange={(e) => setField("last_air_date", e.target.value)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label="Air Time">
|
||||
<FieldRow label="Air Time" lockIcon={renderLockIcon("air_time")}>
|
||||
<Input
|
||||
type="time"
|
||||
value={form.air_time}
|
||||
onChange={(e) => setField("air_time", e.target.value)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label="Air Timezone" lockIcon={renderLockIcon("air_timezone")}>
|
||||
<Input
|
||||
list="air-timezone-options"
|
||||
value={form.air_timezone}
|
||||
placeholder="America/New_York"
|
||||
onChange={(e) => setField("air_timezone", e.target.value)}
|
||||
/>
|
||||
<datalist id="air-timezone-options">
|
||||
{AIR_TIMEZONES.map((timezone) => (
|
||||
<option key={timezone} value={timezone} />
|
||||
))}
|
||||
</datalist>
|
||||
</FieldRow>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function CalendarEventCard({ event }: { event: CalendarEvent }) {
|
||||
: `/item/${event.series_id ?? event.content_id}`;
|
||||
|
||||
const subtitle = formatUpcomingSubtitle(event);
|
||||
const airTime = formatUpcomingTime(event.air_time);
|
||||
const airTime = formatUpcomingTime(event.air_time, event.air_at);
|
||||
|
||||
return (
|
||||
<div className="media-card group/card">
|
||||
|
||||
@@ -14,6 +14,9 @@ vi.mock("@/api/client", () => ({
|
||||
import { useCalendarWeek } from "./calendar";
|
||||
|
||||
describe("useCalendarWeek", () => {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
const encodedTimezone = encodeURIComponent(timezone);
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseQuery.mockReset();
|
||||
mockApi.mockReset();
|
||||
@@ -29,11 +32,13 @@ describe("useCalendarWeek", () => {
|
||||
|
||||
expect(mockUseQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: ["calendar", "week", "2026-04-06", "all", "all"],
|
||||
queryKey: ["calendar", "week", "2026-04-06", "all", "all", timezone],
|
||||
staleTime: 10 * 60 * 1000,
|
||||
}),
|
||||
);
|
||||
expect(mockApi).toHaveBeenCalledWith("/calendar?start=2026-04-06&end=2026-04-12&filter=all");
|
||||
expect(mockApi).toHaveBeenCalledWith(
|
||||
`/calendar?start=2026-04-06&end=2026-04-12&filter=all&timezone=${encodedTimezone}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("includes the selected library in the request", async () => {
|
||||
@@ -43,7 +48,7 @@ describe("useCalendarWeek", () => {
|
||||
await queryOptions.queryFn();
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith(
|
||||
"/calendar?start=2026-04-06&end=2026-04-12&filter=favorites&library_id=7",
|
||||
`/calendar?start=2026-04-06&end=2026-04-12&filter=favorites&timezone=${encodedTimezone}&library_id=7`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,9 @@ export interface CalendarEvent {
|
||||
episode_number?: number;
|
||||
air_date: string;
|
||||
air_time?: string;
|
||||
air_at?: string | null;
|
||||
air_timezone?: string | null;
|
||||
local_air_date: string;
|
||||
poster_url?: string;
|
||||
poster_thumbhash?: string;
|
||||
badges: string[];
|
||||
@@ -28,13 +31,16 @@ interface CalendarResponse {
|
||||
}
|
||||
|
||||
export function useCalendarWeek(weekStart: string, params: { filter: string; libraryId?: number }) {
|
||||
const timezone = getViewerTimezone();
|
||||
|
||||
return useQuery({
|
||||
queryKey: calendarKeys.week(weekStart, params.filter, params.libraryId),
|
||||
queryKey: calendarKeys.week(weekStart, params.filter, params.libraryId, timezone),
|
||||
queryFn: () => {
|
||||
const sp = new URLSearchParams({
|
||||
start: weekStart,
|
||||
end: addDays(weekStart, 6),
|
||||
filter: params.filter,
|
||||
timezone,
|
||||
});
|
||||
if (params.libraryId) sp.set("library_id", String(params.libraryId));
|
||||
return api<CalendarResponse>(`/calendar?${sp}`).then((d) => d.events ?? []);
|
||||
@@ -42,3 +48,7 @@ export function useCalendarWeek(weekStart: string, params: { filter: string; lib
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
function getViewerTimezone(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
}
|
||||
|
||||
@@ -207,6 +207,7 @@ export interface UpdateItemMetadataRequest {
|
||||
first_air_date?: string | null;
|
||||
last_air_date?: string | null;
|
||||
air_time?: string | null;
|
||||
air_timezone?: string | null;
|
||||
air_date?: string | null;
|
||||
status?: string;
|
||||
rating_imdb?: number | null;
|
||||
|
||||
@@ -291,8 +291,8 @@ export const recKeys = {
|
||||
|
||||
export const calendarKeys = {
|
||||
all: ["calendar"] as const,
|
||||
week: (weekStart: string, filter: string, libraryId?: number) =>
|
||||
["calendar", "week", weekStart, filter, libraryId ?? "all"] as const,
|
||||
week: (weekStart: string, filter: string, libraryId?: number, timezone?: string) =>
|
||||
["calendar", "week", weekStart, filter, libraryId ?? "all", timezone ?? "UTC"] as const,
|
||||
};
|
||||
|
||||
export const downloadKeys = {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatUpcomingTime } from "./upcomingEventPresentation";
|
||||
|
||||
describe("formatUpcomingTime", () => {
|
||||
it("formats air_at before falling back to source air_time", () => {
|
||||
const expected = new Date("2026-01-01T15:30:00Z").toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
expect(formatUpcomingTime("00:30", "2026-01-01T15:30:00Z")).toBe(expected);
|
||||
});
|
||||
|
||||
it("falls back to source air_time when air_at is missing", () => {
|
||||
const expected = new Date("2000-01-01T20:00").toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
expect(formatUpcomingTime("20:00", null)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ interface UpcomingPresentationEvent {
|
||||
type: "movie" | "episode" | "season_premiere";
|
||||
air_date: string;
|
||||
air_time?: string | null;
|
||||
air_at?: string | null;
|
||||
episode_title?: string | null;
|
||||
season_number?: number | null;
|
||||
episode_number?: number | null;
|
||||
@@ -61,7 +62,16 @@ export function formatUpcomingDate(airDate: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
export function formatUpcomingTime(airTime?: string | null): string | null {
|
||||
export function formatUpcomingTime(airTime?: string | null, airAt?: string | null): string | null {
|
||||
if (airAt) {
|
||||
const date = new Date(airAt);
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!airTime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user