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