feat: timezone-aware schedule with browser tz and JST client conversion

This commit is contained in:
2026-06-05 15:42:23 +02:00
committed by Milas Holsting
parent 7ebfe4807b
commit c13895b7cd
6 changed files with 255 additions and 48 deletions

View File

@@ -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,
})

View File

@@ -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(`
<h3 class="time-bar">
<span class="show-episode">Ep 9</span>
<time datetime="2026-06-05T16:00+01:00" class="show-air-time">04:00 PM</time>
<span>SUB</span>
</h3>
`))
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(`
<h3 class="time-bar">
<span class="show-episode">Ep 10</span>
<time datetime="2026-06-05T15:30+01:00" class="show-air-time">03:30 PM</time>
<span>SUB</span>
</h3>
`))
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)
}
}