diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index e22d1970..473e0dd2 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -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, diff --git a/internal/api/handlers/calendar.go b/internal/api/handlers/calendar.go index 47419ce7..3812c668 100644 --- a/internal/api/handlers/calendar.go +++ b/internal/api/handlers/calendar.go @@ -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, diff --git a/internal/api/handlers/calendar_test.go b/internal/api/handlers/calendar_test.go index e9813b31..f9d2acd8 100644 --- a/internal/api/handlers/calendar_test.go +++ b/internal/api/handlers/calendar_test.go @@ -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() diff --git a/internal/catalog/air_schedule.go b/internal/catalog/air_schedule.go new file mode 100644 index 00000000..b4a9bade --- /dev/null +++ b/internal/catalog/air_schedule.go @@ -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)) +} diff --git a/internal/catalog/air_schedule_test.go b/internal/catalog/air_schedule_test.go new file mode 100644 index 00000000..65fa139b --- /dev/null +++ b/internal/catalog/air_schedule_test.go @@ -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()) + } +} diff --git a/internal/catalog/calendar_repo.go b/internal/catalog/calendar_repo.go index 2247920a..46dd7069 100644 --- a/internal/catalog/calendar_repo.go +++ b/internal/catalog/calendar_repo.go @@ -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 diff --git a/internal/catalog/detail.go b/internal/catalog/detail.go index 99661385..c4f3c49d 100644 --- a/internal/catalog/detail.go +++ b/internal/catalog/detail.go @@ -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, diff --git a/internal/catalog/item_repo.go b/internal/catalog/item_repo.go index 9788a91c..2b917837 100644 --- a/internal/catalog/item_repo.go +++ b/internal/catalog/item_repo.go @@ -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) diff --git a/internal/catalog/update.go b/internal/catalog/update.go index 46cea8d6..b0057327 100644 --- a/internal/catalog/update.go +++ b/internal/catalog/update.go @@ -28,6 +28,7 @@ type MetadataUpdate struct { FirstAirDate *string LastAirDate *string AirTime *string + AirTimezone *string AirDate *string Status *string ShowStatus *string diff --git a/internal/catalogseed/export_stream.go b/internal/catalogseed/export_stream.go index f02734b3..7a4a9e88 100644 --- a/internal/catalogseed/export_stream.go +++ b/internal/catalogseed/export_stream.go @@ -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, diff --git a/internal/catalogseed/service.go b/internal/catalogseed/service.go index 0206895f..e3838dad 100644 --- a/internal/catalogseed/service.go +++ b/internal/catalogseed/service.go @@ -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) diff --git a/internal/catalogseed/types.go b/internal/catalogseed/types.go index d44b2a96..ff899a56 100644 --- a/internal/catalogseed/types.go +++ b/internal/catalogseed/types.go @@ -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"` diff --git a/internal/metadata/air_time_test.go b/internal/metadata/air_time_test.go index 89332a54..5348f0d8 100644 --- a/internal/metadata/air_time_test.go +++ b/internal/metadata/air_time_test.go @@ -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{} diff --git a/internal/metadata/merge.go b/internal/metadata/merge.go index f33c0f0b..fbeb65bc 100644 --- a/internal/metadata/merge.go +++ b/internal/metadata/merge.go @@ -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) diff --git a/internal/metadata/service.go b/internal/metadata/service.go index 33c963a7..f90fb81a 100644 --- a/internal/metadata/service.go +++ b/internal/metadata/service.go @@ -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 } diff --git a/internal/metadata/types.go b/internal/metadata/types.go index b40f4f69..cb7e433b 100644 --- a/internal/metadata/types.go +++ b/internal/metadata/types.go @@ -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. diff --git a/internal/models/media.go b/internal/models/media.go index 54c64a82..30459504 100644 --- a/internal/models/media.go +++ b/internal/models/media.go @@ -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 diff --git a/migrations/162_media_items_air_timezone.down.sql b/migrations/162_media_items_air_timezone.down.sql new file mode 100644 index 00000000..be2d1a6e --- /dev/null +++ b/migrations/162_media_items_air_timezone.down.sql @@ -0,0 +1 @@ +ALTER TABLE media_items DROP COLUMN IF EXISTS air_timezone; diff --git a/migrations/162_media_items_air_timezone.up.sql b/migrations/162_media_items_air_timezone.up.sql new file mode 100644 index 00000000..af9cf2aa --- /dev/null +++ b/migrations/162_media_items_air_timezone.up.sql @@ -0,0 +1 @@ +ALTER TABLE media_items ADD COLUMN air_timezone text; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 55c3a06e..e81ba3b0 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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; diff --git a/web/src/components/EditMetadataDialog.tsx b/web/src/components/EditMetadataDialog.tsx index 1574470c..9d54429d 100644 --- a/web/src/components/EditMetadataDialog.tsx +++ b/web/src/components/EditMetadataDialog.tsx @@ -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 = { 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" && ( -
+
setField("last_air_date", e.target.value)} /> - + setField("air_time", e.target.value)} /> + + setField("air_timezone", e.target.value)} + /> + + {AIR_TIMEZONES.map((timezone) => ( + +
)} diff --git a/web/src/components/calendar/CalendarEventCard.tsx b/web/src/components/calendar/CalendarEventCard.tsx index d505b811..4f670b8d 100644 --- a/web/src/components/calendar/CalendarEventCard.tsx +++ b/web/src/components/calendar/CalendarEventCard.tsx @@ -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 (
diff --git a/web/src/hooks/queries/calendar.test.ts b/web/src/hooks/queries/calendar.test.ts index 75a0ddf7..7cccfd4e 100644 --- a/web/src/hooks/queries/calendar.test.ts +++ b/web/src/hooks/queries/calendar.test.ts @@ -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`, ); }); }); diff --git a/web/src/hooks/queries/calendar.ts b/web/src/hooks/queries/calendar.ts index 0277eed5..ccbd1104 100644 --- a/web/src/hooks/queries/calendar.ts +++ b/web/src/hooks/queries/calendar.ts @@ -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(`/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"; +} diff --git a/web/src/hooks/queries/items.ts b/web/src/hooks/queries/items.ts index cbbb257a..aaa4d070 100644 --- a/web/src/hooks/queries/items.ts +++ b/web/src/hooks/queries/items.ts @@ -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; diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index b0e8272c..be61fbef 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -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 = { diff --git a/web/src/lib/upcomingEventPresentation.test.ts b/web/src/lib/upcomingEventPresentation.test.ts new file mode 100644 index 00000000..7c73345f --- /dev/null +++ b/web/src/lib/upcomingEventPresentation.test.ts @@ -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); + }); +}); diff --git a/web/src/lib/upcomingEventPresentation.ts b/web/src/lib/upcomingEventPresentation.ts index b3987aaa..416e5a32 100644 --- a/web/src/lib/upcomingEventPresentation.ts +++ b/web/src/lib/upcomingEventPresentation.ts @@ -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; }