diff --git a/integrations/animeschedule/animeschedule.go b/integrations/animeschedule/animeschedule.go
index 9febb58..e21c181 100644
--- a/integrations/animeschedule/animeschedule.go
+++ b/integrations/animeschedule/animeschedule.go
@@ -33,6 +33,7 @@ type Entry struct {
ImageURL string
EpisodeText string
AirType AirType
+ AirsAt time.Time
LocalTime string
DateLabel string
Weekday time.Weekday
@@ -58,16 +59,28 @@ func (e *HTTPStatusError) Error() string {
var reWeek = regexp.MustCompile(`(?i)[?&]week=(\d+)`)
var reYear = regexp.MustCompile(`(?i)[?&]year=(\d+)`)
-func scheduleLocation() *time.Location {
- // Use the host's local timezone (e.g. CEST) so the schedule matches the user's environment.
- return time.Local
+func scheduleLocation(timezone string) (*time.Location, error) {
+ timezone = strings.TrimSpace(timezone)
+ if timezone == "" {
+ timezone = "UTC"
+ }
+ location, err := time.LoadLocation(timezone)
+ if err != nil {
+ return nil, fmt.Errorf("load schedule timezone %q: %w", timezone, err)
+ }
+ return location, nil
}
-func FetchWeek(ctx context.Context, httpClient *http.Client, year int, week int) (WeekSchedule, error) {
+func FetchWeek(ctx context.Context, httpClient *http.Client, year int, week int, timezone string) (WeekSchedule, error) {
apiToken := strings.TrimSpace(os.Getenv("ANIMESCHEDULE_API_TOKEN"))
if apiToken != "" {
- return fetchWeekAPI(ctx, httpClient, apiToken, year, week)
+ return fetchWeekAPI(ctx, httpClient, apiToken, year, week, timezone)
+ }
+
+ location, err := scheduleLocation(timezone)
+ if err != nil {
+ return WeekSchedule{}, err
}
u, _ := url.Parse("https://animeschedule.net/")
@@ -147,8 +160,8 @@ func FetchWeek(ctx context.Context, httpClient *http.Client, year int, week int)
metaText := strings.Join(strings.Fields(strings.TrimSpace(meta.Text())), " ")
epText, _, airType := parseMeta(metaText)
- localTime, _, _ := parseLocalTime(meta)
- if title == "" || animeURL == "" || localTime == "" || airType == "" {
+ localTime, airsAt, _, _ := parseLocalTime(meta, location)
+ if title == "" || animeURL == "" || localTime == "" || airType != AirTypeSUB {
return
}
@@ -158,6 +171,7 @@ func FetchWeek(ctx context.Context, httpClient *http.Client, year int, week int)
ImageURL: imageURL,
EpisodeText: epText,
AirType: airType,
+ AirsAt: airsAt,
LocalTime: localTime,
DateLabel: rawHeader,
Weekday: *weekday,
@@ -168,7 +182,7 @@ func FetchWeek(ctx context.Context, httpClient *http.Client, year int, week int)
return
}
- out.Days[*weekday] = append(out.Days[*weekday], dayEntries...)
+ out.Days[*weekday] = append(out.Days[*weekday], preferredReleaseEntries(dayEntries)...)
})
return out, nil
@@ -241,7 +255,50 @@ func parseMeta(meta string) (episodeText string, localTime string, airType AirTy
return episodeText, localTime, airType
}
-func parseLocalTime(meta *goquery.Selection) (localTime string, rawDatetime string, rawRenderedTime string) {
+func preferredReleaseEntries(entries []Entry) []Entry {
+ type keyedEntry struct {
+ index int
+ entry Entry
+ }
+
+ selected := map[string]keyedEntry{}
+ for i, entry := range entries {
+ key := entry.AnimeURL + "\x00" + entry.EpisodeText
+ current, ok := selected[key]
+ if !ok || airTypePriority(entry.AirType) > airTypePriority(current.entry.AirType) {
+ selected[key] = keyedEntry{index: i, entry: entry}
+ }
+ }
+
+ out := make([]keyedEntry, 0, len(selected))
+ for _, entry := range selected {
+ out = append(out, entry)
+ }
+ slices.SortFunc(out, func(a keyedEntry, b keyedEntry) int {
+ return a.index - b.index
+ })
+
+ preferred := make([]Entry, 0, len(out))
+ for _, entry := range out {
+ preferred = append(preferred, entry.entry)
+ }
+ return preferred
+}
+
+func airTypePriority(airType AirType) int {
+ switch airType {
+ case AirTypeSUB:
+ return 3
+ case AirTypeDUB:
+ return 2
+ case AirTypeJPN:
+ return 1
+ default:
+ return 0
+ }
+}
+
+func parseLocalTime(meta *goquery.Selection, location *time.Location) (localTime string, airsAt time.Time, rawDatetime string, rawRenderedTime string) {
// AnimeSchedule updates rendered time client-side based on the viewer's timezone.
// The server-rendered HTML can show a different time string, so we prefer the `datetime`
// attribute when available.
@@ -250,16 +307,27 @@ func parseLocalTime(meta *goquery.Selection) (localTime string, rawDatetime stri
rawRenderedTime = strings.Join(strings.Fields(strings.TrimSpace(t.Text())), " ")
if raw, ok := t.Attr("datetime"); ok {
rawDatetime = raw
- if parsed, err := time.Parse(time.RFC3339, rawDatetime); err == nil {
- localTime = parsed.In(scheduleLocation()).Format("03:04 PM")
- return localTime, rawDatetime, rawRenderedTime
+ if parsed, err := parseScheduleDatetime(rawDatetime); err == nil {
+ airsAt = parsed.In(location)
+ localTime = airsAt.Format("15:04")
+ return localTime, airsAt, rawDatetime, rawRenderedTime
}
}
}
fallback := strings.Join(strings.Fields(strings.TrimSpace(meta.Text())), " ")
_, parsedTime, _ := parseMeta(fallback)
- return parsedTime, "", ""
+ return parsedTime, time.Time{}, "", ""
+}
+
+func parseScheduleDatetime(value string) (time.Time, error) {
+ for _, layout := range []string{time.RFC3339, "2006-01-02T15:04Z07:00"} {
+ parsed, err := time.Parse(layout, strings.TrimSpace(value))
+ if err == nil {
+ return parsed, nil
+ }
+ }
+ return time.Time{}, fmt.Errorf("parse schedule datetime %q", value)
}
func absolutizeURL(base string, href string) string {
@@ -304,23 +372,24 @@ type timetableAnimeAPI struct {
ImageVersionRoute string `json:"imageVersionRoute"`
}
-func fetchWeekAPI(ctx context.Context, httpClient *http.Client, token string, year int, week int) (WeekSchedule, error) {
+func fetchWeekAPI(ctx context.Context, httpClient *http.Client, token string, year int, week int, timezone string) (WeekSchedule, error) {
client := httpClient
if client == nil {
client = http.DefaultClient
}
+ location, err := scheduleLocation(timezone)
+ if err != nil {
+ return WeekSchedule{}, err
+ }
+
u, _ := url.Parse("https://animeschedule.net/api/v3/timetables/sub")
q := u.Query()
if year > 0 && week > 0 {
q.Set("year", strconv.Itoa(year))
q.Set("week", strconv.Itoa(week))
}
- tz := strings.TrimSpace(os.Getenv("ANIMESCHEDULE_TZ"))
- if tz == "" {
- tz = "Europe/Copenhagen"
- }
- q.Set("tz", tz)
+ q.Set("tz", location.String())
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
@@ -386,13 +455,13 @@ func fetchWeekAPI(ctx context.Context, httpClient *http.Client, token string, ye
}
airType := AirType(strings.ToUpper(strings.TrimSpace(item.AirType)))
- if airType != AirTypeJPN && airType != AirTypeSUB && airType != AirTypeDUB {
+ if airType != AirTypeSUB {
continue
}
- episodeTime := item.EpisodeDate.In(time.Local)
+ episodeTime := item.EpisodeDate.In(location)
weekday := episodeTime.Weekday()
- localTime := episodeTime.Format("03:04 PM")
+ localTime := episodeTime.Format("15:04")
imageURL := ""
if strings.TrimSpace(item.ImageVersionRoute) != "" {
@@ -410,6 +479,7 @@ func fetchWeekAPI(ctx context.Context, httpClient *http.Client, token string, ye
ImageURL: imageURL,
EpisodeText: episodeText,
AirType: airType,
+ AirsAt: episodeTime,
LocalTime: localTime,
Weekday: weekday,
})
diff --git a/integrations/animeschedule/animeschedule_test.go b/integrations/animeschedule/animeschedule_test.go
new file mode 100644
index 0000000..178a72f
--- /dev/null
+++ b/integrations/animeschedule/animeschedule_test.go
@@ -0,0 +1,101 @@
+package animeschedule
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/PuerkitoBio/goquery"
+)
+
+func TestParseLocalTimeUsesRequestedTimezone(t *testing.T) {
+ doc, err := goquery.NewDocumentFromReader(strings.NewReader(`
+
+ Ep 9
+
+ SUB
+
+ `))
+ if err != nil {
+ t.Fatalf("parse document: %v", err)
+ }
+
+ location, err := time.LoadLocation("Europe/Copenhagen")
+ if err != nil {
+ t.Fatalf("load location: %v", err)
+ }
+
+ localTime, airsAt, _, rendered := parseLocalTime(doc.Find(".time-bar").First(), location)
+
+ if localTime != "17:00" {
+ t.Fatalf("localTime = %q, want %q", localTime, "17:00")
+ }
+ if rendered != "04:00 PM" {
+ t.Fatalf("rendered = %q, want %q", rendered, "04:00 PM")
+ }
+ if airsAt.Location().String() != "Europe/Copenhagen" {
+ t.Fatalf("airsAt location = %q, want Europe/Copenhagen", airsAt.Location().String())
+ }
+}
+
+func TestParseLocalTimeUsesExactAngelNextDoorSubRelease(t *testing.T) {
+ doc, err := goquery.NewDocumentFromReader(strings.NewReader(`
+
+ Ep 10
+
+ SUB
+
+ `))
+ if err != nil {
+ t.Fatalf("parse document: %v", err)
+ }
+
+ location, err := time.LoadLocation("Europe/Copenhagen")
+ if err != nil {
+ t.Fatalf("load location: %v", err)
+ }
+
+ localTime, _, _, _ := parseLocalTime(doc.Find(".time-bar").First(), location)
+
+ if localTime != "16:30" {
+ t.Fatalf("localTime = %q, want %q", localTime, "16:30")
+ }
+}
+
+func TestPreferredReleaseEntriesPrefersSubForSameEpisode(t *testing.T) {
+ entries := []Entry{
+ {
+ Title: "Tensei shitara Slime Datta Ken 4th Season",
+ AnimeURL: "https://animeschedule.net/anime/tensei-shitara-slime-datta-ken-4th-season",
+ EpisodeText: "Ep 9",
+ AirType: AirTypeJPN,
+ LocalTime: "16:00",
+ },
+ {
+ Title: "Tensei shitara Slime Datta Ken 4th Season",
+ AnimeURL: "https://animeschedule.net/anime/tensei-shitara-slime-datta-ken-4th-season",
+ EpisodeText: "Ep 9",
+ AirType: AirTypeSUB,
+ LocalTime: "17:00",
+ },
+ {
+ Title: "Tensei shitara Slime Datta Ken 4th Season",
+ AnimeURL: "https://animeschedule.net/anime/tensei-shitara-slime-datta-ken-4th-season",
+ EpisodeText: "Ep 6",
+ AirType: AirTypeDUB,
+ LocalTime: "17:00",
+ },
+ }
+
+ got := preferredReleaseEntries(entries)
+
+ if len(got) != 2 {
+ t.Fatalf("len(got) = %d, want 2", len(got))
+ }
+ if got[0].AirType != AirTypeSUB {
+ t.Fatalf("first air type = %q, want %q", got[0].AirType, AirTypeSUB)
+ }
+ if got[1].AirType != AirTypeDUB {
+ t.Fatalf("second air type = %q, want %q", got[1].AirType, AirTypeDUB)
+ }
+}
diff --git a/internal/anime/handler.go b/internal/anime/handler.go
index d1d9c48..849af81 100644
--- a/internal/anime/handler.go
+++ b/internal/anime/handler.go
@@ -357,8 +357,9 @@ func (h *AnimeHandler) HandleSchedule(c *gin.Context) {
func (h *AnimeHandler) HandleScheduleSection(c *gin.Context) {
year, week := parseYearWeek(c)
+ timezone := scheduleTimezone(c)
- schedule, err := h.getCachedAnimeScheduleWeek(c.Request.Context(), year, week)
+ schedule, err := h.getCachedAnimeScheduleWeek(c.Request.Context(), year, week, timezone)
if err != nil {
prevYear, prevWeek := adjacentISOWeek(year, week, -1)
nextYear, nextWeek := adjacentISOWeek(year, week, 1)
@@ -367,8 +368,9 @@ func (h *AnimeHandler) HandleScheduleSection(c *gin.Context) {
"anime",
"",
map[string]any{
- "year": year,
- "week": week,
+ "year": year,
+ "week": week,
+ "timezone": timezone,
},
err,
)
diff --git a/internal/anime/schedule.go b/internal/anime/schedule.go
index a716c31..415fccb 100644
--- a/internal/anime/schedule.go
+++ b/internal/anime/schedule.go
@@ -33,8 +33,16 @@ func parseYearWeek(c *gin.Context) (int, int) {
return year, week
}
-func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int, week int) (animeschedule.WeekSchedule, error) {
- cacheKey := fmt.Sprintf("%d-%02d", year, week)
+func scheduleTimezone(c *gin.Context) string {
+ timezone := strings.TrimSpace(c.Query("timezone"))
+ if timezone == "" {
+ return "UTC"
+ }
+ return timezone
+}
+
+func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int, week int, timezone string) (animeschedule.WeekSchedule, error) {
+ cacheKey := fmt.Sprintf("%d-%02d-%s", year, week, timezone)
const ttl = 10 * time.Minute
h.scheduleCacheMu.Lock()
@@ -45,7 +53,7 @@ func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int,
return cached.value, nil
}
- value, err := animeschedule.FetchWeek(ctx, nil, year, week)
+ value, err := animeschedule.FetchWeek(ctx, nil, year, week, timezone)
if err != nil {
return animeschedule.WeekSchedule{}, err
}
@@ -71,6 +79,9 @@ func buildScheduleDays(schedule animeschedule.WeekSchedule, year int, week int)
date := start.AddDate(0, 0, i)
entries := schedule.Days[wd]
sort.SliceStable(entries, func(i, j int) bool {
+ if !entries[i].AirsAt.IsZero() && !entries[j].AirsAt.IsZero() {
+ return entries[i].AirsAt.Before(entries[j].AirsAt)
+ }
return localTimeMinutes(entries[i].LocalTime) < localTimeMinutes(entries[j].LocalTime)
})
out = append(out, scheduleDayView{
@@ -83,11 +94,13 @@ func buildScheduleDays(schedule animeschedule.WeekSchedule, year int, week int)
}
func localTimeMinutes(localTime string) int {
- t, err := time.Parse("03:04 PM", localTime)
- if err != nil {
- return 0
+ for _, layout := range []string{"15:04", "03:04 PM"} {
+ t, err := time.Parse(layout, localTime)
+ if err == nil {
+ return t.Hour()*60 + t.Minute()
+ }
}
- return t.Hour()*60 + t.Minute()
+ return 0
}
func isoWeekStartMonday(year int, week int) time.Time {
diff --git a/static/schedule_board.ts b/static/schedule_board.ts
index 400966e..8b70dd5 100644
--- a/static/schedule_board.ts
+++ b/static/schedule_board.ts
@@ -1,5 +1,4 @@
import {
- convertJstToLocalTime,
isJstTimezone,
normalizeWeekday,
parseHHMM,
@@ -73,10 +72,8 @@ interface LocalSlot {
timeLabel: string;
}
-const formatLocalTime = (hour: number, minute: number, format: TimeFormat): string => {
+const formatLocalTime = (date: Date, format: TimeFormat): string => {
const use12h = format === "12";
- const date = new Date();
- date.setHours(hour, minute, 0, 0);
const formatter = new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
@@ -85,10 +82,33 @@ const formatLocalTime = (hour: number, minute: number, format: TimeFormat): stri
return formatter.format(date);
};
+const dateForDayIndex = (weekStartDate: Date, weekStart: WeekStart, dayIndex: number): Date => {
+ const dayOrder = orderedDayIndexes(weekStart);
+ const startIndex = dayOrder[0] ?? 1;
+ const date = new Date(weekStartDate);
+ date.setDate(date.getDate() + ((dayIndex - startIndex + 7) % 7));
+ return date;
+};
+
+const jstBroadcastInstant = (sourceDate: Date, hour: number, minute: number): Date =>
+ new Date(
+ Date.UTC(
+ sourceDate.getFullYear(),
+ sourceDate.getMonth(),
+ sourceDate.getDate(),
+ hour - 9,
+ minute,
+ 0,
+ 0,
+ ),
+ );
+
const getLocalSlot = (
broadcastDay: string | null,
broadcastTime: string | null,
broadcastTimezone: string | null,
+ weekStartDate: Date,
+ weekStart: WeekStart,
timeFormat: TimeFormat,
): LocalSlot | null => {
const sourceDayIndex = normalizeWeekday(broadcastDay);
@@ -96,18 +116,12 @@ const getLocalSlot = (
if (sourceDayIndex === null || !parsed) return null;
if (!isJstTimezone(broadcastTimezone)) return null;
- const localOffsetMinutes = -new Date().getTimezoneOffset();
-
- const localTime = convertJstToLocalTime(
- sourceDayIndex,
- parsed.hour,
- parsed.minute,
- localOffsetMinutes,
- );
- const timeLabel = formatLocalTime(localTime.hour, localTime.minute, timeFormat);
+ const sourceDate = dateForDayIndex(weekStartDate, weekStart, sourceDayIndex);
+ const localDate = jstBroadcastInstant(sourceDate, parsed.hour, parsed.minute);
+ const timeLabel = formatLocalTime(localDate, timeFormat);
return {
- localDayIndex: localTime.dayIndex,
- localMinutes: localTime.totalMinutes,
+ localDayIndex: localDate.getDay(),
+ localMinutes: localDate.getHours() * 60 + localDate.getMinutes(),
timeLabel,
};
};
@@ -268,6 +282,8 @@ const buildBoard = (section: HTMLElement): void => {
node.getAttribute("data-broadcast-day"),
node.getAttribute("data-broadcast-time"),
node.getAttribute("data-broadcast-timezone"),
+ weekStartDate,
+ settings.weekStart,
settings.timeFormat,
);
if (!slot) continue;
diff --git a/templates/schedule.gohtml b/templates/schedule.gohtml
index 6f86b9b..e8f58d2 100644
--- a/templates/schedule.gohtml
+++ b/templates/schedule.gohtml
@@ -7,7 +7,12 @@
-
+
{{template "schedule_skeleton"}}