feat(calendar): show local episode airtimes

This commit is contained in:
Quick
2026-05-27 21:32:18 -04:00
parent 0f3f548952
commit 808f265fab
28 changed files with 535 additions and 60 deletions
+10 -1
View File
@@ -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,
+77 -10
View File
@@ -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,
+56
View File
@@ -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()
+114
View File
@@ -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))
}
+71
View File
@@ -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())
}
}
+8 -7
View File
@@ -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
+2
View File
@@ -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,
+13 -8
View File
@@ -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)
+1
View File
@@ -28,6 +28,7 @@ type MetadataUpdate struct {
FirstAirDate *string
LastAirDate *string
AirTime *string
AirTimezone *string
AirDate *string
Status *string
ShowStatus *string
+4
View File
@@ -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,
+22 -18
View File
@@ -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)
+2
View File
@@ -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"`
+40 -4
View File
@@ -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{}
+8 -2
View File
@@ -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)
+9
View File
@@ -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
}
+2
View File
@@ -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.
+1
View File
@@ -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;
+4
View File
@@ -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;
+33 -2
View File
@@ -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">
+8 -3
View File
@@ -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`,
);
});
});
+11 -1
View File
@@ -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";
}
+1
View File
@@ -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;
+2 -2
View File
@@ -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);
});
});
+11 -1
View File
@@ -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;
}