aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/graphql/anime/resolvers.ts30
-rw-r--r--src/graphql/anime/schema.graphql39
-rw-r--r--src/lib/Data/Static/matchExclude.json1
-rw-r--r--src/lib/Data/Static/subtitles.json1
-rw-r--r--src/lib/List/Anime/AnimeListTemplate.svelte10
-rw-r--r--src/lib/List/Anime/DueAnimeList.svelte8
-rw-r--r--src/lib/List/Anime/UpcomingAnimeList.svelte8
-rw-r--r--src/lib/Locale/english.ts10
-rw-r--r--src/lib/Locale/japanese.ts10
-rw-r--r--src/lib/Locale/layout.ts9
-rw-r--r--src/lib/Media/Anime/Airing/Subtitled/match.ts652
-rw-r--r--src/lib/Media/Anime/Airing/Subtitled/subsPlease.ts13
-rw-r--r--src/lib/Media/Anime/Airing/animeSchedule.ts114
-rw-r--r--src/lib/Media/Anime/Airing/classify.test.ts187
-rw-r--r--src/lib/Media/Anime/Airing/match.ts381
-rw-r--r--src/lib/Media/Anime/Airing/time.ts8
-rw-r--r--src/lib/Media/Cover/HoverCover.svelte2
-rw-r--r--src/lib/Media/Cover/hoverCover.ts6
-rw-r--r--src/lib/Schedule/CoverBypass.svelte4
-rw-r--r--src/lib/Schedule/Days.svelte91
-rw-r--r--src/lib/Settings/Categories/Attributions.svelte7
-rw-r--r--src/lib/Settings/Categories/Display.svelte16
-rw-r--r--src/routes/+layout.svelte32
-rw-r--r--src/routes/api/animeschedule/+server.ts25
-rw-r--r--src/routes/api/subsplease/+server.ts18
-rw-r--r--src/routes/schedule/+page.svelte20
-rw-r--r--src/stores/airingSchedule.ts6
-rw-r--r--src/stores/settings.ts29
-rw-r--r--src/stores/subsPlease.ts6
29 files changed, 800 insertions, 943 deletions
diff --git a/src/graphql/anime/resolvers.ts b/src/graphql/anime/resolvers.ts
index 577950f7..8d87eb6c 100644
--- a/src/graphql/anime/resolvers.ts
+++ b/src/graphql/anime/resolvers.ts
@@ -1,31 +1,23 @@
+import { env } from "$env/dynamic/private";
+import { fetchTimetables } from "$lib/Media/Anime/Airing/animeSchedule";
import type { Resolvers as RootResolvers, WithIndex } from "../$types";
type AnimeResolvers = Pick<
RootResolvers,
- "Query" | "Anime" | "Subtitles" | "SubtitleSchedule" | "Subtitle"
+ "Query" | "Anime" | "Airing" | "AiringRelease" | "Stream"
>;
export const resolvers: WithIndex<AnimeResolvers> = {
Query: {
- Anime: async (_, args) => {
- const timezone = args.timezone || "Asia/Tokyo";
+ Anime: async () => {
+ const token = env.ANIMESCHEDULE_CLIENT_TOKEN;
+ const generatedAt = Math.floor(Date.now() / 1000);
- return {
- subtitles: {
- timezone,
- schedule: Object.fromEntries(
- Object.entries(
- (
- await (
- await fetch(
- `https://subsplease.org/api/?f=schedule&tz=${timezone}`,
- )
- ).json()
- ).schedule,
- ).map(([key, value]) => [key.toLowerCase(), value]),
- ),
- },
- };
+ if (!token) return { airing: { generatedAt, sub: [], dub: [] } };
+
+ const { sub, dub } = await fetchTimetables(token);
+
+ return { airing: { generatedAt, sub, dub } };
},
},
};
diff --git a/src/graphql/anime/schema.graphql b/src/graphql/anime/schema.graphql
index d5774966..aad7afec 100644
--- a/src/graphql/anime/schema.graphql
+++ b/src/graphql/anime/schema.graphql
@@ -1,29 +1,32 @@
type Query {
- Anime(timezone: String): Anime!
+ Anime: Anime!
}
type Anime {
- subtitles: Subtitles
+ airing: Airing
}
-type Subtitles {
- timezone: String
- schedule: SubtitleSchedule
+type Airing {
+ generatedAt: Int
+ sub: [AiringRelease]
+ dub: [AiringRelease]
}
-type SubtitleSchedule {
- monday: [Subtitle]
- tuesday: [Subtitle]
- wednesday: [Subtitle]
- thursday: [Subtitle]
- friday: [Subtitle]
- saturday: [Subtitle]
- sunday: [Subtitle]
+type AiringRelease {
+ route: String
+ title: String
+ romaji: String
+ english: String
+ native: String
+ episodeNumber: Int
+ airingAt: Int
+ delayedUntil: Int
+ imageUrl: String
+ streams: [Stream]
}
-type Subtitle {
- title: String
- page: String
- image_url: String
- time: String
+type Stream {
+ platform: String
+ url: String
+ name: String
}
diff --git a/src/lib/Data/Static/matchExclude.json b/src/lib/Data/Static/matchExclude.json
deleted file mode 100644
index a7d74309..00000000
--- a/src/lib/Data/Static/matchExclude.json
+++ /dev/null
@@ -1 +0,0 @@
-[180829]
diff --git a/src/lib/Data/Static/subtitles.json b/src/lib/Data/Static/subtitles.json
deleted file mode 100644
index 0967ef42..00000000
--- a/src/lib/Data/Static/subtitles.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/src/lib/List/Anime/AnimeListTemplate.svelte b/src/lib/List/Anime/AnimeListTemplate.svelte
index 5d769640..86bf2077 100644
--- a/src/lib/List/Anime/AnimeListTemplate.svelte
+++ b/src/lib/List/Anime/AnimeListTemplate.svelte
@@ -5,10 +5,10 @@ import { browser } from "$app/environment";
import type { AniListAuthorisation } from "$lib/Data/AniList/identity";
import type { Media } from "$lib/Data/AniList/media";
import RateLimitedError from "$lib/Error/RateLimited.svelte";
-import type { SubsPlease } from "$lib/Media/Anime/Airing/Subtitled/subsPlease";
+import type { AiringSchedule } from "$lib/Media/Anime/Airing/animeSchedule";
+import airingSchedule from "$stores/airingSchedule";
import identity from "$stores/identity";
import settings from "$stores/settings";
-import subsPlease from "$stores/subsPlease";
import ListTitle from "../ListTitle.svelte";
import type { Title } from "../mediaTitle";
import CleanAnimeList from "./CleanAnimeList.svelte";
@@ -18,7 +18,7 @@ export let endTime: number;
export let cleanMedia: (
media: Media[],
displayUnresolved: boolean,
- subsPlease: SubsPlease | null,
+ schedule: AiringSchedule | null,
plannedOnly?: boolean,
) => Media[];
export let animeLists: Promise<Media[]>;
@@ -57,7 +57,7 @@ onMount(async () => {
});
</script>
-{#if !$subsPlease && !dummy}
+{#if !$airingSchedule && !dummy}
<PlaceholderList count={lastListSize} {title} />
{:else if !animeLists}
<PlaceholderList count={lastListSize} {title} />
@@ -89,7 +89,7 @@ onMount(async () => {
<PlaceholderList count={lastListSize} {title} />
{:else}
<CleanAnimeList
- media={cleanMedia(media, $settings.displayUnresolved, $subsPlease, plannedOnly)}
+ media={cleanMedia(media, $settings.displayUnresolved, $airingSchedule ?? null, plannedOnly)}
{title}
bind:animeLists
{user}
diff --git a/src/lib/List/Anime/DueAnimeList.svelte b/src/lib/List/Anime/DueAnimeList.svelte
index 8ff0539d..563e728d 100644
--- a/src/lib/List/Anime/DueAnimeList.svelte
+++ b/src/lib/List/Anime/DueAnimeList.svelte
@@ -3,12 +3,12 @@ import { onDestroy, onMount } from "svelte";
import { hydrateMediaListCache } from "$lib/Data/AniList/cacheHydration";
import type { AniListAuthorisation } from "$lib/Data/AniList/identity";
import { type Media, mediaListCollection, Type } from "$lib/Data/AniList/media";
+import type { AiringSchedule } from "$lib/Media/Anime/Airing/animeSchedule";
import {
hasDueEpisodes,
hasNoAiredEpisodes,
} from "$lib/Media/Anime/Airing/classify";
-import { injectAiringTime } from "$lib/Media/Anime/Airing/Subtitled/match";
-import type { SubsPlease } from "$lib/Media/Anime/Airing/Subtitled/subsPlease";
+import { injectAiringTime } from "$lib/Media/Anime/Airing/match";
import { addNotification } from "$lib/Notification/store";
import anime from "$stores/anime";
import identity from "$stores/identity";
@@ -85,12 +85,12 @@ onDestroy(() => {
const cleanMedia = (
anime: Media[],
displayUnresolved: boolean,
- subsPlease: SubsPlease | null,
+ schedule: AiringSchedule | null,
) => {
if (anime === undefined) return [];
let dueAnime = anime
- .map((media) => injectAiringTime(media, subsPlease))
+ .map((media) => injectAiringTime(media, schedule))
.filter(
// Releasing media
(media: Media) =>
diff --git a/src/lib/List/Anime/UpcomingAnimeList.svelte b/src/lib/List/Anime/UpcomingAnimeList.svelte
index b88937e9..2e128d8f 100644
--- a/src/lib/List/Anime/UpcomingAnimeList.svelte
+++ b/src/lib/List/Anime/UpcomingAnimeList.svelte
@@ -4,12 +4,12 @@ import { hydrateMediaListCache } from "$lib/Data/AniList/cacheHydration";
import type { AniListAuthorisation } from "$lib/Data/AniList/identity";
import { type Media, mediaListCollection, Type } from "$lib/Data/AniList/media";
import Spacer from "$lib/Layout/Spacer.svelte";
+import type { AiringSchedule } from "$lib/Media/Anime/Airing/animeSchedule";
import {
hasDueEpisodes,
hasNoAiredEpisodes,
} from "$lib/Media/Anime/Airing/classify";
-import { injectAiringTime } from "$lib/Media/Anime/Airing/Subtitled/match";
-import type { SubsPlease } from "$lib/Media/Anime/Airing/Subtitled/subsPlease";
+import { injectAiringTime } from "$lib/Media/Anime/Airing/match";
import { addNotification } from "$lib/Notification/store";
import anime from "$stores/anime";
import identity from "$stores/identity";
@@ -44,7 +44,7 @@ onMount(async () => {
const cleanMedia = (
anime: Media[],
displayUnresolved: boolean,
- subsPlease: SubsPlease | null,
+ schedule: AiringSchedule | null,
plannedOnly = true,
) => {
if (anime === undefined) return [];
@@ -55,7 +55,7 @@ const cleanMedia = (
(media: Media) =>
media.status === status && media.nextAiringEpisode !== null,
)
- .map((media) => injectAiringTime(media, subsPlease))
+ .map((media) => injectAiringTime(media, schedule))
.filter(
(media: Media) =>
// Outdated media
diff --git a/src/lib/Locale/english.ts b/src/lib/Locale/english.ts
index 16eff123..91a3bdb3 100644
--- a/src/lib/Locale/english.ts
+++ b/src/lib/Locale/english.ts
@@ -81,8 +81,14 @@ const English: Locale = {
disableLastActivityWarning: "Disable last activity warning",
rightAlignCountdown: "Align anime episode countdown to the right",
use24HourTime: "Use 24-hour time format for episode countdown",
- nativeEpisodeCountdown:
- "Show episode countdown in native release date & time",
+ countdownSource: {
+ title: "Episode countdown source",
+ options: {
+ native: "Native (original broadcast)",
+ sub: "Subbed release",
+ dub: "Dubbed release",
+ },
+ },
abbreviateCountdown:
"Abbreviate episode countdown date & time units",
lastActivityWarningHint:
diff --git a/src/lib/Locale/japanese.ts b/src/lib/Locale/japanese.ts
index 9dd82ff2..5c905aea 100644
--- a/src/lib/Locale/japanese.ts
+++ b/src/lib/Locale/japanese.ts
@@ -43,8 +43,14 @@ const Japanese: Locale = {
disableLastActivityWarning: "最後のアクティビティ警告を無効にする",
rightAlignCountdown: "エピソードカウントダウンを右に揃える",
use24HourTime: "エピソードカウントダウンに24時間形式を使用する",
- nativeEpisodeCountdown:
- "ネイティブのリリース日時でエピソードのカウントダウンを表示する",
+ countdownSource: {
+ title: "エピソードカウントダウンの基準",
+ options: {
+ native: "ネイティブ(オリジナル放送)",
+ sub: "字幕リリース",
+ dub: "吹き替えリリース",
+ },
+ },
abbreviateCountdown: "エピソードカウントダウンの日時単位を省略する",
},
},
diff --git a/src/lib/Locale/layout.ts b/src/lib/Locale/layout.ts
index de4b7f97..99466aa2 100644
--- a/src/lib/Locale/layout.ts
+++ b/src/lib/Locale/layout.ts
@@ -83,7 +83,14 @@ export interface Locale {
disableLastActivityWarning: LocaleValue;
rightAlignCountdown: LocaleValue;
use24HourTime: LocaleValue;
- nativeEpisodeCountdown: LocaleValue;
+ countdownSource: {
+ title: LocaleValue;
+ options: {
+ native: LocaleValue;
+ sub: LocaleValue;
+ dub: LocaleValue;
+ };
+ };
abbreviateCountdown: LocaleValue;
lastActivityWarningHint?: LocaleValue;
};
diff --git a/src/lib/Media/Anime/Airing/Subtitled/match.ts b/src/lib/Media/Anime/Airing/Subtitled/match.ts
deleted file mode 100644
index a89ac8ac..00000000
--- a/src/lib/Media/Anime/Airing/Subtitled/match.ts
+++ /dev/null
@@ -1,652 +0,0 @@
-import stringSimilarity from "string-similarity";
-import { get } from "svelte/store";
-import excludeMatch from "$lib/Data/Static/matchExclude.json";
-import type { SubsPlease } from "$lib/Media/Anime/Airing/Subtitled/subsPlease";
-import settings from "$stores/settings";
-import type { Media } from "../../../../Data/AniList/media";
-import { season } from "../../season";
-
-export interface Time {
- title: string;
- time: string;
- day: string;
-}
-
-interface IndexedTime {
- time: Time;
- normalizedTitle: string;
- tokens: string[];
-}
-
-interface DayScheduleIndex {
- entries: IndexedTime[];
- exactTitleIndex: Map<string, number[]>;
- tokenIndex: Map<string, number[]>;
-}
-
-interface ScheduleIndex {
- byDay: Map<string, DayScheduleIndex>;
- version: string;
-}
-
-const secondsUntil = (targetTime: string, targetDay: string) => {
- const now = new Date();
- const [targetHour, targetMinute] = targetTime.split(":").map(Number);
- let dayDifference =
- [
- "Sunday",
- "Monday",
- "Tuesday",
- "Wednesday",
- "Thursday",
- "Friday",
- "Saturday",
- ].indexOf(targetDay) - now.getDay();
-
- if (dayDifference < 0) dayDifference += 7;
-
- const targetDate = new Date(now);
-
- targetDate.setDate(now.getDate() + dayDifference);
- targetDate.setHours(targetHour, targetMinute, 0, 0);
-
- const secondsDifference = (Number(targetDate) - Number(now)) / 1000;
-
- return secondsDifference > 0
- ? secondsDifference
- : secondsDifference + 7 * 24 * 60 * 60;
-};
-
-const preprocessTitle = (title: string): string => {
- return title
- .toLowerCase()
- .replace(/\b(season|s|part|cour)(\d+)\b/g, " $2 ")
- .replace(/\b(season|s|part|cour)\b/g, " ")
- .replace(/[^a-z0-9\s]/gi, "")
- .trim()
- .split(/\s+/)
- .join(" ");
-};
-
-const NON_DISTINCTIVE_TOKENS = new Set([
- "a",
- "and",
- "de",
- "e",
- "for",
- "ga",
- "in",
- "na",
- "ni",
- "no",
- "o",
- "of",
- "on",
- "the",
- "to",
- "wa",
- "wo",
-]);
-
-const isMeaningfulToken = (token: string): boolean =>
- /^\d+$/.test(token) ||
- (token.length >= 3 && !NON_DISTINCTIVE_TOKENS.has(token));
-
-const MIN_MATCH_SCORE = 0.3;
-const MIN_TOKEN_OVERLAP = 2;
-const MIN_MATCH_MARGIN = 0.08;
-const FALLBACK_MIN_SCORE = 0.82;
-const FALLBACK_MIN_MARGIN = 0.08;
-const MAX_MATCH_CACHE_ENTRIES = 10_000;
-const MAX_INJECT_CACHE_ENTRIES = 10_000;
-const STALE_AIRING_GRACE_SECONDS = 5 * 60;
-const MAX_EPISODE_SHIFT_WINDOW_SECONDS = 8 * 24 * 60 * 60;
-
-interface SimilarityAnalysis {
- score: number;
- tokenOverlap: number;
- numericTokenOverlap: number;
-}
-
-const calculateWeightedSimilarity = (
- title1: string,
- title2: string,
-): SimilarityAnalysis => {
- const tokens1 = title1.split(" ").filter(isMeaningfulToken);
- const tokens2 = title2.split(" ").filter(isMeaningfulToken);
-
- if (tokens1.length === 0 || tokens2.length === 0)
- return {
- score: 0,
- tokenOverlap: 0,
- numericTokenOverlap: 0,
- };
-
- const set2 = new Set(tokens2);
- let score = 0;
- let tokenOverlap = 0;
- let numericTokenOverlap = 0;
- const numericTokens1 = tokens1.filter((token) => /^\d+$/.test(token));
- const numericTokens2 = tokens2.filter((token) => /^\d+$/.test(token));
-
- tokens1.forEach((token) => {
- if (set2.has(token)) {
- tokenOverlap += 1;
-
- if (/^\d+$/.test(token)) numericTokenOverlap += 1;
-
- score += /^\d+$/.test(token) ? 2 : 1;
- }
- });
-
- let finalScore =
- (score / ((Math.max(tokens1.length, tokens2.length) || 1) * 2)) * 0.7 +
- stringSimilarity.compareTwoStrings(title1, title2) * 0.3;
-
- if (
- numericTokens1.length > 0 &&
- numericTokens2.length > 0 &&
- numericTokenOverlap === 0
- )
- finalScore *= 0.5;
-
- return {
- score: finalScore,
- tokenOverlap,
- numericTokenOverlap,
- };
-};
-
-const indexPush = (
- index: Map<string, number[]>,
- key: string,
- entryIndex: number,
-) => {
- const existing = index.get(key);
-
- if (existing) existing.push(entryIndex);
- else index.set(key, [entryIndex]);
-};
-
-const scheduleIndexCache = new WeakMap<SubsPlease, ScheduleIndex>();
-const closestMatchCache = new Map<string, Time | null>();
-const injectAiringTimeCache = new Map<string, Media>();
-
-const hashString = (input: string): string => {
- let hash = 2166136261;
-
- for (let index = 0; index < input.length; index += 1) {
- hash ^= input.charCodeAt(index);
- hash = Math.imul(hash, 16777619);
- }
-
- return (hash >>> 0).toString(36);
-};
-
-const setBoundedCacheValue = <T>(
- cache: Map<string, T>,
- key: string,
- value: T,
- maxEntries: number,
-) => {
- if (cache.size >= maxEntries) cache.clear();
-
- cache.set(key, value);
-};
-
-const animeTitleFingerprint = (anime: Media) =>
- [anime.title.romaji, anime.title.english, ...anime.synonyms]
- .filter(Boolean)
- .map(preprocessTitle)
- .join("|");
-
-const localTimeZone = () =>
- Intl.DateTimeFormat().resolvedOptions().timeZone || "local";
-
-const airingDayOf = (airingAt: number | undefined) =>
- new Date((airingAt || 0) * 1000).toLocaleString("en-US", { weekday: "long" });
-
-const fallbackClosestMatch = (
- dayIndex: DayScheduleIndex,
- searchTitles: string[],
-): Time | null => {
- let bestMatch: Time | null = null;
- let bestScore = 0;
- let secondBestScore = 0;
-
- for (const searchTitle of searchTitles) {
- if (searchTitle.includes("OVA") || searchTitle.includes("Special"))
- continue;
-
- const normalizedSearchTitle = preprocessTitle(searchTitle);
-
- for (const candidateEntry of dayIndex.entries) {
- const score = stringSimilarity.compareTwoStrings(
- normalizedSearchTitle,
- candidateEntry.normalizedTitle,
- );
-
- if (score > bestScore) {
- secondBestScore = bestScore;
- bestScore = score;
- bestMatch = candidateEntry.time;
- } else if (score > secondBestScore) {
- secondBestScore = score;
- }
- }
- }
-
- if (bestScore < FALLBACK_MIN_SCORE) return null;
- if (bestScore - secondBestScore < FALLBACK_MIN_MARGIN) return null;
-
- return bestMatch;
-};
-
-const buildScheduleIndex = (subsPlease: SubsPlease): ScheduleIndex => {
- const byDay = new Map<string, DayScheduleIndex>();
- const versionParts: string[] = [];
-
- for (const [day, value] of Object.entries(subsPlease.schedule)) {
- const flattenedValue = Array.isArray(value) ? value.flat() : [];
-
- versionParts.push(day);
-
- const dayIndex: DayScheduleIndex = {
- entries: [],
- exactTitleIndex: new Map<string, number[]>(),
- tokenIndex: new Map<string, number[]>(),
- };
-
- for (const scheduleTime of flattenedValue) {
- const time = {
- title: scheduleTime.title,
- time: scheduleTime.time,
- day,
- };
-
- versionParts.push(`${day}\u001f${time.title}\u001f${time.time}`);
-
- const normalizedTitle = preprocessTitle(time.title);
- const tokens = normalizedTitle.split(" ").filter(isMeaningfulToken);
- const entryIndex = dayIndex.entries.length;
-
- dayIndex.entries.push({
- time,
- normalizedTitle,
- tokens,
- });
- indexPush(dayIndex.exactTitleIndex, normalizedTitle, entryIndex);
-
- for (const token of tokens)
- indexPush(dayIndex.tokenIndex, token, entryIndex);
- }
-
- byDay.set(day, dayIndex);
- }
-
- return {
- byDay,
- version: hashString(`${subsPlease.tz}\u001e${versionParts.join("\u001d")}`),
- };
-};
-
-export const findClosestMatch = (
- scheduleIndex: ScheduleIndex,
- anime: Media,
-): Time | null => {
- if (excludeMatch.includes(anime.id)) {
- setBoundedCacheValue(
- closestMatchCache,
- `${anime.id}:excluded`,
- null,
- MAX_MATCH_CACHE_ENTRIES,
- );
-
- return null;
- }
-
- const airingDay = airingDayOf(anime.nextAiringEpisode?.airingAt);
- const cacheKey = `${anime.id}:${anime.nextAiringEpisode?.airingAt || 0}:${animeTitleFingerprint(
- anime,
- )}:${airingDay}:${localTimeZone()}:${scheduleIndex.version}`;
- const cached = closestMatchCache.get(cacheKey);
-
- if (cached !== undefined) return cached;
- const dayIndex = scheduleIndex.byDay.get(airingDay);
-
- if (!dayIndex || dayIndex.entries.length === 0) {
- setBoundedCacheValue(
- closestMatchCache,
- cacheKey,
- null,
- MAX_MATCH_CACHE_ENTRIES,
- );
-
- return null;
- }
-
- let bestMatch: Time | null = null;
- let bestScore = 0;
- let secondBestScore = 0;
- let bestTokenOverlap = 0;
- let bestNumericTokenOverlap = 0;
- const searchTitles = [
- anime.title.romaji,
- anime.title.english,
- ...anime.synonyms,
- ].filter(Boolean);
-
- for (const searchTitle of searchTitles) {
- if (searchTitle.includes("OVA") || searchTitle.includes("Special"))
- continue;
-
- const normalizedSearchTitle = preprocessTitle(searchTitle);
- const exactMatchIndexes = dayIndex.exactTitleIndex.get(
- normalizedSearchTitle,
- );
-
- if (exactMatchIndexes && exactMatchIndexes.length > 0) {
- const exactMatch = dayIndex.entries[exactMatchIndexes[0]];
-
- if (exactMatch) {
- setBoundedCacheValue(
- closestMatchCache,
- cacheKey,
- exactMatch.time,
- MAX_MATCH_CACHE_ENTRIES,
- );
-
- return exactMatch.time;
- }
- }
-
- const searchTokens = normalizedSearchTitle
- .split(" ")
- .filter(isMeaningfulToken);
- const candidateIndexSet = new Set<number>();
-
- for (const token of searchTokens) {
- for (const candidateIndex of dayIndex.tokenIndex.get(token) || [])
- candidateIndexSet.add(candidateIndex);
- }
-
- const candidateIndexes =
- candidateIndexSet.size > 0
- ? [...candidateIndexSet]
- : dayIndex.entries.map((_, entryIndex) => entryIndex);
-
- for (const candidateIndex of candidateIndexes) {
- const candidateEntry = dayIndex.entries[candidateIndex];
- const similarity = calculateWeightedSimilarity(
- normalizedSearchTitle,
- candidateEntry.normalizedTitle,
- );
-
- if (similarity.score > bestScore) {
- secondBestScore = bestScore;
- bestScore = similarity.score;
- bestTokenOverlap = similarity.tokenOverlap;
- bestNumericTokenOverlap = similarity.numericTokenOverlap;
- bestMatch = candidateEntry.time;
- } else if (similarity.score > secondBestScore) {
- secondBestScore = similarity.score;
- }
- }
- }
-
- if (bestScore < MIN_MATCH_SCORE) {
- const fallbackMatch = fallbackClosestMatch(dayIndex, searchTitles);
-
- setBoundedCacheValue(
- closestMatchCache,
- cacheKey,
- fallbackMatch,
- MAX_MATCH_CACHE_ENTRIES,
- );
-
- return fallbackMatch;
- }
-
- if (bestScore - secondBestScore < MIN_MATCH_MARGIN) {
- const fallbackMatch = fallbackClosestMatch(dayIndex, searchTitles);
-
- setBoundedCacheValue(
- closestMatchCache,
- cacheKey,
- fallbackMatch,
- MAX_MATCH_CACHE_ENTRIES,
- );
-
- return fallbackMatch;
- }
-
- if (bestNumericTokenOverlap === 0 && bestTokenOverlap < MIN_TOKEN_OVERLAP) {
- const fallbackMatch = fallbackClosestMatch(dayIndex, searchTitles);
-
- setBoundedCacheValue(
- closestMatchCache,
- cacheKey,
- fallbackMatch,
- MAX_MATCH_CACHE_ENTRIES,
- );
-
- return fallbackMatch;
- }
-
- setBoundedCacheValue(
- closestMatchCache,
- cacheKey,
- bestMatch,
- MAX_MATCH_CACHE_ENTRIES,
- );
-
- return bestMatch;
-};
-
-const normalizeTitle = (title: string | null) =>
- (title || "")
- .toLowerCase()
- .replace(/\b(s|season|part|cour)\s*\d+/g, "")
- .replace(/[\W_]+/g, " ")
- .trim();
-
-const findClosestMediaCache = new Map<string, Media | null>();
-
-export const findClosestMedia = (media: Media[], matchFor: string) => {
- if (!matchFor) return null;
-
- const cached = findClosestMediaCache.get(matchFor);
-
- if (cached !== undefined) return cached;
-
- const normalisedMatchFor = normalizeTitle(matchFor);
- const matchForWords = normalisedMatchFor.split(" ");
- let bestFitMedia: Media | null = null;
- let bestDistance = -Infinity;
-
- for (const m of media) {
- const titles = [m.title.romaji, m.title.english, ...m.synonyms].filter(
- Boolean,
- );
-
- if (
- titles.some(
- (title) =>
- title.toLowerCase().includes("special") ||
- title.toLowerCase().includes("ova"),
- )
- )
- continue;
-
- const normalisedTitles = titles.map(normalizeTitle);
-
- for (const normalisedTitle of normalisedTitles) {
- const distance = stringSimilarity.compareTwoStrings(
- normalisedMatchFor,
- normalisedTitle,
- );
-
- if (distance <= bestDistance) continue;
-
- const wordMatch =
- matchForWords.every((word) =>
- normalisedTitles.some((t) => t.includes(word)),
- ) || normalisedTitles.some((t) => t.includes(normalisedMatchFor));
-
- if (wordMatch) {
- bestDistance = distance;
- bestFitMedia = m;
-
- if (distance === 1) break;
- }
- }
-
- if (bestDistance === 1) break;
- }
-
- findClosestMediaCache.set(matchFor, bestFitMedia);
-
- return bestFitMedia as Media | null;
-};
-
-export const clearClosestMediaCache = () => findClosestMediaCache.clear();
-
-const getScheduleIndex = (subsPlease: SubsPlease): ScheduleIndex => {
- const cached = scheduleIndexCache.get(subsPlease);
-
- if (cached) return cached;
-
- const built = buildScheduleIndex(subsPlease);
-
- scheduleIndexCache.set(subsPlease, built);
-
- return built;
-};
-
-const buildInjectAiringTimeCacheKey = (
- anime: Media,
- scheduleVersion: string,
- displayNativeCountdown: boolean,
-) =>
- [
- anime.id,
- anime.status,
- anime.mediaListEntry?.status || "",
- anime.mediaListEntry?.progress || 0,
- anime.mediaListEntry?.updatedAt || 0,
- anime.nextAiringEpisode?.episode || 0,
- anime.nextAiringEpisode?.airingAt || 0,
- displayNativeCountdown ? 1 : 0,
- scheduleVersion,
- animeTitleFingerprint(anime),
- ].join(":");
-
-const cloneInjectedMedia = (media: Media): Media =>
- ({
- ...media,
- mediaListEntry: media.mediaListEntry
- ? {
- ...media.mediaListEntry,
- startedAt: { ...media.mediaListEntry.startedAt },
- completedAt: { ...media.mediaListEntry.completedAt },
- customLists: { ...media.mediaListEntry.customLists },
- }
- : undefined,
- nextAiringEpisode: media.nextAiringEpisode
- ? { ...media.nextAiringEpisode }
- : undefined,
- }) as Media;
-
-export const injectAiringTime = (
- anime: Media,
- subsPlease: SubsPlease | null,
-) => {
- if (season() !== anime.season) return anime;
-
- const displayNativeCountdown = get(settings).displayNativeCountdown;
- const scheduleVersion = subsPlease
- ? getScheduleIndex(subsPlease).version
- : "native-only";
- const cacheKey = buildInjectAiringTimeCacheKey(
- anime,
- scheduleVersion,
- displayNativeCountdown,
- );
- const cached = injectAiringTimeCache.get(cacheKey);
-
- if (cached) return cloneInjectedMedia(cached);
-
- const airingAt = anime.nextAiringEpisode?.airingAt;
- const now = new Date();
- // const nativeUntilAiring = airingAt
- // ? Math.round((airingAt - Date.now() / 1000) * 100) / 100
- // : undefined;
- const nativeTime = new Date(airingAt ? airingAt * 1000 : 0);
- let untilAiring: number | undefined;
- let time = new Date(airingAt ? airingAt * 1000 : 0);
- let nextEpisode = anime.nextAiringEpisode?.episode || 0;
- let nativeEpisode = nextEpisode;
-
- if (!(displayNativeCountdown || !subsPlease)) {
- const scheduleIndex = getScheduleIndex(subsPlease);
-
- if ((anime.nextAiringEpisode?.episode || 0) > 1) {
- const foundTime: Time | null = findClosestMatch(scheduleIndex, anime);
-
- if (foundTime) {
- untilAiring = secondsUntil(
- (foundTime as Time).time,
- (foundTime as Time).day,
- );
- time = new Date(Date.now() + untilAiring * 1000);
- }
- }
- }
-
- const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
-
- const nowEpochSeconds = Date.now() / 1000;
- const nativeAheadSeconds =
- nativeTime.getTime() / 1000 - time.getTime() / 1000;
-
- if (
- nativeAheadSeconds > 0 &&
- nativeAheadSeconds <= MAX_EPISODE_SHIFT_WINDOW_SECONDS &&
- nativeTime.getTime() / 1000 > nowEpochSeconds + STALE_AIRING_GRACE_SECONDS
- ) {
- nextEpisode -= 1;
- nativeEpisode = nextEpisode;
- }
-
- if (nativeTime.getTime() - now.getTime() > SEVEN_DAYS) {
- const beforeTime = time;
-
- time = nativeTime;
-
- time.setHours(beforeTime.getHours());
- time.setMinutes(beforeTime.getMinutes());
- }
-
- const injected = {
- ...anime,
- nextAiringEpisode: {
- episode: nextEpisode,
- airingAt: time.getTime() / 1000,
- nativeAiringAt: nativeTime.getTime() / 1000,
- nativeEpisode,
- },
- } as Media;
-
- const cachedValue = cloneInjectedMedia(injected);
-
- setBoundedCacheValue(
- injectAiringTimeCache,
- cacheKey,
- cachedValue,
- MAX_INJECT_CACHE_ENTRIES,
- );
-
- return cloneInjectedMedia(cachedValue);
-};
-
-export const clearInjectAiringTimeCache = () => injectAiringTimeCache.clear();
diff --git a/src/lib/Media/Anime/Airing/Subtitled/subsPlease.ts b/src/lib/Media/Anime/Airing/Subtitled/subsPlease.ts
deleted file mode 100644
index 3815259d..00000000
--- a/src/lib/Media/Anime/Airing/Subtitled/subsPlease.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-export interface SubsPlease {
- tz: string;
- schedule: {
- [key in string]: SubsPleaseEpisode;
- }[];
-}
-
-export interface SubsPleaseEpisode {
- title: string;
- page: string;
- image_url: string;
- time: string;
-}
diff --git a/src/lib/Media/Anime/Airing/animeSchedule.ts b/src/lib/Media/Anime/Airing/animeSchedule.ts
new file mode 100644
index 00000000..f3f6f85d
--- /dev/null
+++ b/src/lib/Media/Anime/Airing/animeSchedule.ts
@@ -0,0 +1,114 @@
+// Data model for AnimeSchedule.net's weekly timetable, the source of truth for
+// when subbed and dubbed episodes actually release. Unlike a fansub schedule,
+// every release carries an absolute timestamp, an episode number, delay windows,
+// and the streaming platforms it lands on.
+
+export type AirType = "sub" | "dub";
+
+export interface Stream {
+ platform: string;
+ url: string;
+ name: string;
+}
+
+// A single scheduled episode release for one show.
+export interface AiringEntry {
+ route: string;
+ title: string;
+ romaji: string;
+ english: string;
+ native: string;
+ episodeNumber: number;
+ airingAt: number;
+ delayedUntil?: number;
+ imageUrl: string;
+ streams: Stream[];
+}
+
+// The merged sub + dub schedule for the current week.
+export interface AiringSchedule {
+ generatedAt: number;
+ sub: AiringEntry[];
+ dub: AiringEntry[];
+}
+
+const IMAGE_BASE =
+ "https://img.animeschedule.net/production/assets/public/img/";
+
+const animeScheduleImageUrl = (imageVersionRoute: string): string =>
+ imageVersionRoute ? `${IMAGE_BASE}${imageVersionRoute}` : "";
+
+// AnimeSchedule uses 0001-01-01 as its "no date" sentinel.
+const isZeroDate = (value: string | undefined): boolean =>
+ !value || value.startsWith("0001-01-01");
+
+const toEpochSeconds = (value: string | undefined): number =>
+ isZeroDate(value)
+ ? 0
+ : Math.floor(new Date(value as string).getTime() / 1000);
+
+// Stream URLs come back without a scheme (e.g. "www.hidive.com/...").
+const withScheme = (url: string): string =>
+ !url ? "" : /^https?:\/\//.test(url) ? url : `https://${url}`;
+
+interface RawTimetableEntry {
+ title: string;
+ route: string;
+ romaji: string;
+ english: string;
+ native: string;
+ episodeNumber: number;
+ episodeDate: string;
+ delayedUntil: string;
+ imageVersionRoute: string;
+ streams: { platform: string; url: string; name: string }[];
+}
+
+export const parseTimetable = (raw: unknown): AiringEntry[] => {
+ if (!Array.isArray(raw)) return [];
+
+ return (raw as RawTimetableEntry[]).map((entry) => {
+ const delayedUntil = toEpochSeconds(entry.delayedUntil);
+
+ return {
+ route: entry.route,
+ title: entry.english || entry.romaji || entry.title,
+ romaji: entry.romaji || "",
+ english: entry.english || "",
+ native: entry.native || "",
+ episodeNumber: entry.episodeNumber,
+ airingAt: toEpochSeconds(entry.episodeDate),
+ delayedUntil: delayedUntil || undefined,
+ imageUrl: animeScheduleImageUrl(entry.imageVersionRoute),
+ streams: (entry.streams || []).map((stream) => ({
+ platform: stream.platform,
+ url: withScheme(stream.url),
+ name: stream.name,
+ })),
+ };
+ });
+};
+
+const TIMETABLE_ENDPOINT = "https://animeschedule.net/api/v3/timetables";
+
+// Fetch and parse the current week's sub and dub timetables in one shot. The
+// caller supplies the AnimeSchedule application token.
+export const fetchTimetables = async (
+ token: string,
+): Promise<{ sub: AiringEntry[]; dub: AiringEntry[] }> => {
+ const fetchOne = async (airType: AirType): Promise<AiringEntry[]> => {
+ try {
+ const response = await fetch(`${TIMETABLE_ENDPOINT}?airType=${airType}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+
+ return response.ok ? parseTimetable(await response.json()) : [];
+ } catch {
+ return [];
+ }
+ };
+
+ const [sub, dub] = await Promise.all([fetchOne("sub"), fetchOne("dub")]);
+
+ return { sub, dub };
+};
diff --git a/src/lib/Media/Anime/Airing/classify.test.ts b/src/lib/Media/Anime/Airing/classify.test.ts
index 161787ba..1019b303 100644
--- a/src/lib/Media/Anime/Airing/classify.test.ts
+++ b/src/lib/Media/Anime/Airing/classify.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { Media } from "$lib/Data/AniList/media";
+import type { AiringSchedule } from "$lib/Media/Anime/Airing/animeSchedule";
import {
getAnimeEpisodeState,
hasDueEpisodes,
@@ -7,18 +8,29 @@ import {
import {
clearInjectAiringTimeCache,
injectAiringTime,
-} from "$lib/Media/Anime/Airing/Subtitled/match";
-import type { SubsPlease } from "$lib/Media/Anime/Airing/Subtitled/subsPlease";
+} from "$lib/Media/Anime/Airing/match";
import { season } from "$lib/Media/Anime/season";
import settings from "$stores/settings";
-const toScheduleTime = (epochSeconds: number) => {
- const date = new Date(epochSeconds * 1000);
- const hours = String(date.getHours()).padStart(2, "0");
- const minutes = String(date.getMinutes()).padStart(2, "0");
-
- return `${hours}:${minutes}`;
-};
+// A single-show schedule that joins to the given media by title, releasing the
+// sub at `airingAt`.
+const subScheduleFor = (media: Media, airingAt: number): AiringSchedule => ({
+ generatedAt: Math.floor(Date.now() / 1000),
+ sub: [
+ {
+ route: `fixture-${media.id}`,
+ title: media.title.romaji,
+ romaji: media.title.romaji,
+ english: media.title.english,
+ native: media.title.native,
+ episodeNumber: media.nextAiringEpisode?.episode || 0,
+ airingAt,
+ imageUrl: "",
+ streams: [],
+ },
+ ],
+ dub: [],
+});
const baseMedia = (id: number): Media =>
({
@@ -117,29 +129,12 @@ describe("anime episode classification", () => {
});
});
-describe("native countdown toggle parity", () => {
+describe("countdown source parity", () => {
for (const id of regressionIds) {
- it(`keeps media ${id} due with native countdown on/off`, () => {
+ it(`keeps media ${id} due across native and sub countdown sources`, () => {
const media = baseMedia(id);
const subtitledAiringAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60;
const nativeAiringAt = subtitledAiringAt + 12 * 60 * 60;
- const nativeAiringDate = new Date(nativeAiringAt * 1000);
- const airingDay = nativeAiringDate.toLocaleString("en-US", {
- weekday: "long",
- });
- const subsPlease = {
- tz: "America/Los_Angeles",
- schedule: {
- [airingDay]: [
- {
- title: media.title.romaji,
- page: "",
- image_url: "",
- time: toScheduleTime(subtitledAiringAt),
- },
- ],
- },
- } as unknown as SubsPlease;
media.nextAiringEpisode = {
episode: 8,
@@ -148,13 +143,15 @@ describe("native countdown toggle parity", () => {
if (media.mediaListEntry) media.mediaListEntry.progress = 5;
- settings.setKey("displayNativeCountdown", true);
+ const schedule = subScheduleFor(media, subtitledAiringAt);
- const nativeOnly = injectAiringTime(media, subsPlease);
+ settings.setKey("countdownSource", "native");
- settings.setKey("displayNativeCountdown", false);
+ const nativeOnly = injectAiringTime(media, schedule);
- const subtitled = injectAiringTime(media, subsPlease);
+ settings.setKey("countdownSource", "sub");
+
+ const subtitled = injectAiringTime(media, schedule);
expect(hasDueEpisodes(nativeOnly)).toBe(true);
expect(hasDueEpisodes(subtitled)).toBe(true);
@@ -162,40 +159,75 @@ describe("native countdown toggle parity", () => {
}
});
+describe("countdown source fallback", () => {
+ it("falls back dub → sub when no dub release exists", () => {
+ clearInjectAiringTimeCache();
+
+ const media = baseMedia(310001);
+ const subtitledAiringAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60;
+ const nativeAiringAt = subtitledAiringAt + 12 * 60 * 60;
+
+ media.nextAiringEpisode = {
+ episode: 8,
+ airingAt: nativeAiringAt,
+ };
+
+ // Sub-only schedule (the dub feed is empty).
+ const schedule = subScheduleFor(media, subtitledAiringAt);
+
+ settings.setKey("countdownSource", "dub");
+
+ const injected = injectAiringTime(media, schedule);
+
+ expect(injected.nextAiringEpisode?.airingAt).toBe(subtitledAiringAt);
+ expect(injected.nextAiringEpisode?.nativeAiringAt).toBe(nativeAiringAt);
+ });
+
+ it("falls back to native when neither dub nor sub exists", () => {
+ clearInjectAiringTimeCache();
+
+ const media = baseMedia(310002);
+ const nativeAiringAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60;
+
+ media.nextAiringEpisode = {
+ episode: 8,
+ airingAt: nativeAiringAt,
+ };
+
+ const schedule: AiringSchedule = {
+ generatedAt: Math.floor(Date.now() / 1000),
+ sub: [],
+ dub: [],
+ };
+
+ settings.setKey("countdownSource", "dub");
+
+ const injected = injectAiringTime(media, schedule);
+
+ expect(injected.nextAiringEpisode?.airingAt).toBe(nativeAiringAt);
+ });
+});
+
describe("injectAiringTime cache safety", () => {
it("does not let caller mutation poison cached injected media", () => {
const media = baseMedia(444444);
const subtitledAiringAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60;
const nativeAiringAt = subtitledAiringAt + 12 * 60 * 60;
- const nativeAiringDate = new Date(nativeAiringAt * 1000);
- const airingDay = nativeAiringDate.toLocaleString("en-US", {
- weekday: "long",
- });
- const subsPlease = {
- tz: "America/Los_Angeles",
- schedule: {
- [airingDay]: [
- {
- title: media.title.romaji,
- page: "",
- image_url: "",
- time: toScheduleTime(subtitledAiringAt),
- },
- ],
- },
- } as unknown as SubsPlease;
media.nextAiringEpisode = {
episode: 8,
airingAt: nativeAiringAt,
};
- settings.setKey("displayNativeCountdown", false);
- const first = injectAiringTime(media, subsPlease);
+ const schedule = subScheduleFor(media, subtitledAiringAt);
+
+ settings.setKey("countdownSource", "sub");
+
+ const first = injectAiringTime(media, schedule);
first.nextAiringEpisode = { episode: -1 };
- const second = injectAiringTime(media, subsPlease);
+ const second = injectAiringTime(media, schedule);
expect(second.nextAiringEpisode?.episode).not.toBe(-1);
expect(typeof second.nextAiringEpisode?.airingAt).toBe("number");
@@ -208,30 +240,15 @@ describe("injectAiringTime cache safety", () => {
const media = baseMedia(194028);
const subtitledAiringAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60;
const nativeAiringAt = subtitledAiringAt + 12 * 60 * 60;
- const nativeAiringDate = new Date(nativeAiringAt * 1000);
- const airingDay = nativeAiringDate.toLocaleString("en-US", {
- weekday: "long",
- });
- const subsPlease = {
- tz: "America/Los_Angeles",
- schedule: {
- [airingDay]: [
- {
- title: media.title.romaji,
- page: "",
- image_url: "",
- time: toScheduleTime(subtitledAiringAt),
- },
- ],
- },
- } as unknown as SubsPlease;
media.nextAiringEpisode = {
episode: 10,
airingAt: nativeAiringAt,
};
- settings.setKey("displayNativeCountdown", false);
+ const schedule = subScheduleFor(media, subtitledAiringAt);
+
+ settings.setKey("countdownSource", "sub");
const caughtUp = {
...media,
@@ -247,8 +264,8 @@ describe("injectAiringTime cache safety", () => {
progress: 7,
},
} as Media;
- const cachedCaughtUp = injectAiringTime(caughtUp, subsPlease);
- const updatedBehind = injectAiringTime(behind, subsPlease);
+ const cachedCaughtUp = injectAiringTime(caughtUp, schedule);
+ const updatedBehind = injectAiringTime(behind, schedule);
expect(hasDueEpisodes(cachedCaughtUp)).toBe(false);
expect(hasDueEpisodes(updatedBehind)).toBe(true);
@@ -259,33 +276,17 @@ describe("injectAiringTime cache safety", () => {
const media = baseMedia(194028);
const subtitledAiringAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60;
- const nativeAiringAt = subtitledAiringAt + 12 * 60 * 60;
- const nativeAiringDate = new Date(nativeAiringAt * 1000);
- const airingDay = nativeAiringDate.toLocaleString("en-US", {
- weekday: "long",
- });
- const subsPlease = {
- tz: "America/Los_Angeles",
- schedule: {
- [airingDay]: [
- {
- title: media.title.romaji,
- page: "",
- image_url: "",
- time: toScheduleTime(subtitledAiringAt),
- },
- ],
- },
- } as unknown as SubsPlease;
- settings.setKey("displayNativeCountdown", false);
+ const schedule = subScheduleFor(media, subtitledAiringAt);
+
+ settings.setKey("countdownSource", "sub");
const originalProgress = media.mediaListEntry?.progress || 0;
- const first = injectAiringTime(media, subsPlease);
+ const first = injectAiringTime(media, schedule);
if (first.mediaListEntry) first.mediaListEntry.progress = 999;
- const second = injectAiringTime(media, subsPlease);
+ const second = injectAiringTime(media, schedule);
expect(media.mediaListEntry?.progress).toBe(originalProgress);
expect(second.mediaListEntry?.progress).toBe(originalProgress);
diff --git a/src/lib/Media/Anime/Airing/match.ts b/src/lib/Media/Anime/Airing/match.ts
new file mode 100644
index 00000000..9cfe6102
--- /dev/null
+++ b/src/lib/Media/Anime/Airing/match.ts
@@ -0,0 +1,381 @@
+import stringSimilarity from "string-similarity";
+import { get } from "svelte/store";
+import type { Media } from "$lib/Data/AniList/media";
+import settings from "$stores/settings";
+import { season } from "../season";
+import type { AiringEntry, AiringSchedule, AirType } from "./animeSchedule";
+
+const SEVEN_DAYS_SECONDS = 7 * 24 * 60 * 60;
+const STALE_AIRING_GRACE_SECONDS = 5 * 60;
+const MAX_EPISODE_SHIFT_WINDOW_SECONDS = 8 * 24 * 60 * 60;
+const MAX_INJECT_CACHE_ENTRIES = 10_000;
+const FUZZY_MIN_SCORE = 0.82;
+
+// Strip season/part markers and punctuation so canonical romaji/english titles
+// from AniList and AnimeSchedule normalise to the same key. NFKC folds fullwidth
+// characters down to ASCII first.
+const normalizeLatin = (title: string): string =>
+ (title || "")
+ .normalize("NFKC")
+ .toLowerCase()
+ .replace(/\b(season|s|part|cour)\s*(\d+)\b/g, " $2 ")
+ .replace(/\b(season|s|part|cour)\b/g, " ")
+ .replace(/[^a-z0-9\s]/g, " ")
+ .trim()
+ .split(/\s+/)
+ .join(" ");
+
+// Native titles are CJK (Japanese, Chinese, Korean); collapse whitespace but
+// keep every glyph. NFKC reconciles fullwidth and halfwidth digits (農家2 vs 農家2).
+const normalizeNative = (title: string): string =>
+ (title || "").normalize("NFKC").replace(/\s+/g, "").toLowerCase();
+
+interface ScheduleIndex {
+ byNative: Map<string, AiringEntry>;
+ byLatin: Map<string, AiringEntry>;
+ entries: AiringEntry[];
+}
+
+const indexSet = (
+ index: Map<string, AiringEntry>,
+ key: string,
+ entry: AiringEntry,
+) => {
+ if (key && !index.has(key)) index.set(key, entry);
+};
+
+const buildScheduleIndex = (entries: AiringEntry[]): ScheduleIndex => {
+ const byNative = new Map<string, AiringEntry>();
+ const byLatin = new Map<string, AiringEntry>();
+
+ for (const entry of entries) {
+ indexSet(byNative, normalizeNative(entry.native), entry);
+ indexSet(byLatin, normalizeLatin(entry.romaji), entry);
+ indexSet(byLatin, normalizeLatin(entry.english), entry);
+ indexSet(byLatin, normalizeLatin(entry.title), entry);
+ }
+
+ return { byNative, byLatin, entries };
+};
+
+const indexCache = new WeakMap<
+ AiringSchedule,
+ Partial<Record<AirType, ScheduleIndex>>
+>();
+
+const getScheduleIndex = (
+ schedule: AiringSchedule,
+ source: AirType,
+): ScheduleIndex => {
+ let perSource = indexCache.get(schedule);
+
+ if (!perSource) {
+ perSource = {};
+
+ indexCache.set(schedule, perSource);
+ }
+
+ const cached = perSource[source];
+
+ if (cached) return cached;
+
+ const built = buildScheduleIndex(schedule[source]);
+
+ perSource[source] = built;
+
+ return built;
+};
+
+const fuzzyMatch = (
+ index: ScheduleIndex,
+ searchTitles: string[],
+): AiringEntry | null => {
+ let bestEntry: AiringEntry | null = null;
+ let bestScore = 0;
+
+ for (const searchTitle of searchTitles) {
+ const normalized = normalizeLatin(searchTitle);
+
+ if (!normalized) continue;
+
+ for (const entry of index.entries) {
+ const score = stringSimilarity.compareTwoStrings(
+ normalized,
+ normalizeLatin(entry.english || entry.romaji || entry.title),
+ );
+
+ if (score > bestScore) {
+ bestScore = score;
+ bestEntry = entry;
+ }
+ }
+ }
+
+ return bestScore >= FUZZY_MIN_SCORE ? bestEntry : null;
+};
+
+// Join an AniList show to its AnimeSchedule release. The native title is a
+// near-perfect key; romaji/english cover the rest, with a fuzzy fallback.
+const findScheduleEntry = (
+ schedule: AiringSchedule,
+ source: AirType,
+ anime: Media,
+): AiringEntry | null => {
+ const index = getScheduleIndex(schedule, source);
+
+ const nativeMatch = index.byNative.get(normalizeNative(anime.title.native));
+
+ if (nativeMatch) return nativeMatch;
+
+ const latinTitles = [
+ anime.title.romaji,
+ anime.title.english,
+ ...anime.synonyms,
+ ];
+
+ for (const title of latinTitles) {
+ const match = index.byLatin.get(normalizeLatin(title));
+
+ if (match) return match;
+ }
+
+ return fuzzyMatch(index, latinTitles.filter(Boolean));
+};
+
+// Resolve the next future release time for a matched entry. AnimeSchedule gives
+// the current week's episode; a delay window or a weekly cadence rolls a past
+// release forward to the next occurrence.
+const nextReleaseTime = (
+ entry: AiringEntry,
+ nowEpochSeconds: number,
+): number => {
+ if (entry.delayedUntil && entry.delayedUntil > nowEpochSeconds)
+ return entry.delayedUntil;
+
+ const base = entry.airingAt;
+
+ if (!base) return 0;
+ if (base > nowEpochSeconds - STALE_AIRING_GRACE_SECONDS) return base;
+
+ const weeksElapsed = Math.ceil((nowEpochSeconds - base) / SEVEN_DAYS_SECONDS);
+
+ return base + weeksElapsed * SEVEN_DAYS_SECONDS;
+};
+
+const injectAiringTimeCache = new Map<string, Media>();
+
+const setBoundedCacheValue = (key: string, value: Media) => {
+ if (injectAiringTimeCache.size >= MAX_INJECT_CACHE_ENTRIES)
+ injectAiringTimeCache.clear();
+
+ injectAiringTimeCache.set(key, value);
+};
+
+const animeTitleFingerprint = (anime: Media) =>
+ [
+ anime.title.romaji,
+ anime.title.english,
+ anime.title.native,
+ ...anime.synonyms,
+ ]
+ .filter(Boolean)
+ .join("|");
+
+const buildInjectAiringTimeCacheKey = (
+ anime: Media,
+ scheduleVersion: string,
+ source: string,
+) =>
+ [
+ anime.id,
+ anime.status,
+ anime.mediaListEntry?.status || "",
+ anime.mediaListEntry?.progress || 0,
+ anime.mediaListEntry?.updatedAt || 0,
+ anime.nextAiringEpisode?.episode || 0,
+ anime.nextAiringEpisode?.airingAt || 0,
+ source,
+ scheduleVersion,
+ animeTitleFingerprint(anime),
+ ].join(":");
+
+const cloneInjectedMedia = (media: Media): Media =>
+ ({
+ ...media,
+ mediaListEntry: media.mediaListEntry
+ ? {
+ ...media.mediaListEntry,
+ startedAt: { ...media.mediaListEntry.startedAt },
+ completedAt: { ...media.mediaListEntry.completedAt },
+ customLists: { ...media.mediaListEntry.customLists },
+ }
+ : undefined,
+ nextAiringEpisode: media.nextAiringEpisode
+ ? { ...media.nextAiringEpisode }
+ : undefined,
+ }) as Media;
+
+// Override a show's countdown with its subbed or dubbed release time while
+// preserving the native broadcast time and episode for due classification.
+export const injectAiringTime = (
+ anime: Media,
+ schedule: AiringSchedule | null,
+) => {
+ if (season() !== anime.season) return anime;
+
+ const source = get(settings).countdownSource;
+ const useSchedule = source !== "native" && schedule !== null;
+ const scheduleVersion = useSchedule
+ ? String((schedule as AiringSchedule).generatedAt)
+ : "native-only";
+ const cacheKey = buildInjectAiringTimeCacheKey(
+ anime,
+ scheduleVersion,
+ source,
+ );
+ const cached = injectAiringTimeCache.get(cacheKey);
+
+ if (cached) return cloneInjectedMedia(cached);
+
+ const airingAt = anime.nextAiringEpisode?.airingAt;
+ const now = new Date();
+ const nativeTime = new Date(airingAt ? airingAt * 1000 : 0);
+ let time = new Date(airingAt ? airingAt * 1000 : 0);
+ let nextEpisode = anime.nextAiringEpisode?.episode || 0;
+ let nativeEpisode = nextEpisode;
+
+ // Prefer the selected track, then fall back: dub → sub → native. Sub never
+ // falls back to dub. Native is the initial value of `time`.
+ if (useSchedule && (anime.nextAiringEpisode?.episode || 0) > 1) {
+ const fallbackOrder: AirType[] =
+ source === "dub" ? ["dub", "sub"] : ["sub"];
+
+ for (const candidateSource of fallbackOrder) {
+ const entry = findScheduleEntry(
+ schedule as AiringSchedule,
+ candidateSource,
+ anime,
+ );
+
+ if (!entry) continue;
+
+ const releaseTime = nextReleaseTime(entry, Date.now() / 1000);
+
+ if (releaseTime) {
+ time = new Date(releaseTime * 1000);
+
+ break;
+ }
+ }
+ }
+
+ const nowEpochSeconds = Date.now() / 1000;
+ const nativeAheadSeconds =
+ nativeTime.getTime() / 1000 - time.getTime() / 1000;
+
+ if (
+ nativeAheadSeconds > 0 &&
+ nativeAheadSeconds <= MAX_EPISODE_SHIFT_WINDOW_SECONDS &&
+ nativeTime.getTime() / 1000 > nowEpochSeconds + STALE_AIRING_GRACE_SECONDS
+ ) {
+ nextEpisode -= 1;
+ nativeEpisode = nextEpisode;
+ }
+
+ if (nativeTime.getTime() - now.getTime() > SEVEN_DAYS_SECONDS * 1000) {
+ const beforeTime = time;
+
+ time = new Date(nativeTime.getTime());
+
+ time.setHours(beforeTime.getHours());
+ time.setMinutes(beforeTime.getMinutes());
+ }
+
+ const injected = {
+ ...anime,
+ nextAiringEpisode: {
+ episode: nextEpisode,
+ airingAt: time.getTime() / 1000,
+ nativeAiringAt: nativeTime.getTime() / 1000,
+ nativeEpisode,
+ },
+ } as Media;
+
+ const cachedValue = cloneInjectedMedia(injected);
+
+ setBoundedCacheValue(cacheKey, cachedValue);
+
+ return cloneInjectedMedia(cachedValue);
+};
+
+export const clearInjectAiringTimeCache = () => injectAiringTimeCache.clear();
+
+const normalizeTitle = (title: string | null) =>
+ (title || "")
+ .toLowerCase()
+ .replace(/\b(s|season|part|cour)\s*\d+/g, "")
+ .replace(/[\W_]+/g, " ")
+ .trim();
+
+const findClosestMediaCache = new Map<string, Media | null>();
+
+// Reverse lookup used by the schedule page: pick the AniList show that best
+// matches a given release title.
+export const findClosestMedia = (media: Media[], matchFor: string) => {
+ if (!matchFor) return null;
+
+ const cached = findClosestMediaCache.get(matchFor);
+
+ if (cached !== undefined) return cached;
+
+ const normalizedMatchFor = normalizeTitle(matchFor);
+ const matchForWords = normalizedMatchFor.split(" ");
+ let bestFitMedia: Media | null = null;
+ let bestDistance = -Infinity;
+
+ for (const mediaItem of media) {
+ const titles = [
+ mediaItem.title.romaji,
+ mediaItem.title.english,
+ ...mediaItem.synonyms,
+ ].filter(Boolean);
+
+ if (
+ titles.some(
+ (title) =>
+ title.toLowerCase().includes("special") ||
+ title.toLowerCase().includes("ova"),
+ )
+ )
+ continue;
+
+ const normalizedTitles = titles.map(normalizeTitle);
+
+ for (const normalizedTitle of normalizedTitles) {
+ const distance = stringSimilarity.compareTwoStrings(
+ normalizedMatchFor,
+ normalizedTitle,
+ );
+
+ if (distance <= bestDistance) continue;
+
+ const wordMatch =
+ matchForWords.every((word) =>
+ normalizedTitles.some((t) => t.includes(word)),
+ ) || normalizedTitles.some((t) => t.includes(normalizedMatchFor));
+
+ if (wordMatch) {
+ bestDistance = distance;
+ bestFitMedia = mediaItem;
+
+ if (distance === 1) break;
+ }
+ }
+
+ if (bestDistance === 1) break;
+ }
+
+ findClosestMediaCache.set(matchFor, bestFitMedia);
+
+ return bestFitMedia as Media | null;
+};
diff --git a/src/lib/Media/Anime/Airing/time.ts b/src/lib/Media/Anime/Airing/time.ts
index e2111e86..f45a690f 100644
--- a/src/lib/Media/Anime/Airing/time.ts
+++ b/src/lib/Media/Anime/Airing/time.ts
@@ -1,19 +1,19 @@
import { get } from "svelte/store";
import type { Media } from "$lib/Data/AniList/media";
import type { MediaPrequel } from "$lib/Data/AniList/prequels";
-import type { SubsPlease } from "$lib/Media/Anime/Airing/Subtitled/subsPlease";
+import type { AiringSchedule } from "$lib/Media/Anime/Airing/animeSchedule";
import settings from "$stores/settings";
import { totalEpisodes } from "../episodes";
import { formatCountdown } from "./format";
-import { injectAiringTime } from "./Subtitled/match";
+import { injectAiringTime } from "./match";
export const airingTime = (
originalAnime: Media,
- subsPlease: SubsPlease | null,
+ schedule: AiringSchedule | null,
upcoming = false,
forceDays = false,
) => {
- const anime = injectAiringTime(originalAnime, subsPlease);
+ const anime = injectAiringTime(originalAnime, schedule);
const airingAt = anime.nextAiringEpisode?.airingAt;
const untilAiring = airingAt
? Math.round((airingAt - Date.now() / 1000) * 100) / 100
diff --git a/src/lib/Media/Cover/HoverCover.svelte b/src/lib/Media/Cover/HoverCover.svelte
index c1412fa4..75ee63a0 100644
--- a/src/lib/Media/Cover/HoverCover.svelte
+++ b/src/lib/Media/Cover/HoverCover.svelte
@@ -13,7 +13,7 @@ export let width = 250;
? $settings.displayDataSaver
? options.media.coverImage.medium
: options.media.coverImage.extraLarge
- : `https://subsplease.org${options.item?.image_url}`}
+ : options.item?.imageUrl}
alt="Media Cover"
loading="lazy"
style={`width: ${width}px; ${options.style}`}
diff --git a/src/lib/Media/Cover/hoverCover.ts b/src/lib/Media/Cover/hoverCover.ts
index 9c88bcb1..6e222469 100644
--- a/src/lib/Media/Cover/hoverCover.ts
+++ b/src/lib/Media/Cover/hoverCover.ts
@@ -1,12 +1,12 @@
import { get } from "svelte/store";
import type { Media } from "$lib/Data/AniList/media";
import settings from "$stores/settings";
-import type { SubsPleaseEpisode } from "../Anime/Airing/Subtitled/subsPlease";
+import type { AiringEntry } from "../Anime/Airing/animeSchedule";
export interface HoverCoverResponse {
// OnMouseEnterLeave
hovering?: boolean;
- item?: SubsPleaseEpisode | null;
+ item?: AiringEntry | null;
media?: Media | null;
// OnMouseMove
@@ -16,7 +16,7 @@ export interface HoverCoverResponse {
export const onMouseEnter = (
media: Media | Partial<Media> | null,
- item: SubsPleaseEpisode | null = null,
+ item: AiringEntry | null = null,
) => {
if (!get(settings).displayHoverCover && !item)
return { hovering: false, item: null, media: null } as HoverCoverResponse;
diff --git a/src/lib/Schedule/CoverBypass.svelte b/src/lib/Schedule/CoverBypass.svelte
index 2656a696..af452bf6 100644
--- a/src/lib/Schedule/CoverBypass.svelte
+++ b/src/lib/Schedule/CoverBypass.svelte
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Media } from "$lib/Data/AniList/media";
import MediaTitleDisplay from "$lib/List/MediaTitleDisplay.svelte";
-import type { SubsPleaseEpisode } from "$lib/Media/Anime/Airing/Subtitled/subsPlease";
+import type { AiringEntry } from "$lib/Media/Anime/Airing/animeSchedule";
import { outboundLink } from "$lib/Media/links";
import tooltip from "$lib/Tooltip/tooltip";
@@ -9,7 +9,7 @@ import { abbreviate } from "$lib/Utility/string";
import settings from "$stores/settings";
export let media: Media | null;
-export let entry: SubsPleaseEpisode;
+export let entry: AiringEntry;
export let cover = true;
export let showTooltip = true;
diff --git a/src/lib/Schedule/Days.svelte b/src/lib/Schedule/Days.svelte
index 5a57905a..987bc816 100644
--- a/src/lib/Schedule/Days.svelte
+++ b/src/lib/Schedule/Days.svelte
@@ -3,11 +3,12 @@ import { browser } from "$app/environment";
import { hydrateMediaListCache } from "$lib/Data/AniList/cacheHydration";
import type { AniListAuthorisation } from "$lib/Data/AniList/identity";
import { type Media, mediaListCollection, Type } from "$lib/Data/AniList/media";
-import { findClosestMedia } from "$lib/Media/Anime/Airing/Subtitled/match";
import type {
- SubsPlease,
- SubsPleaseEpisode,
-} from "$lib/Media/Anime/Airing/Subtitled/subsPlease";
+ AiringEntry,
+ AiringSchedule,
+ AirType,
+} from "$lib/Media/Anime/Airing/animeSchedule";
+import { findClosestMedia } from "$lib/Media/Anime/Airing/match";
import { outboundLink } from "$lib/Media/links";
import { parseOrDefault } from "$lib/Utility/parameters";
import settings from "$stores/settings";
@@ -23,7 +24,7 @@ import anime from "$stores/anime";
import identity from "$stores/identity";
import lastPruneTimes from "$stores/lastPruneTimes";
-export let subsPlease: SubsPlease;
+export let schedule: AiringSchedule;
export let scheduledMedia: Partial<Media[]>;
export let forceListMode = false;
export let user: AniListAuthorisation | undefined;
@@ -35,6 +36,8 @@ let day: string | null = parseOrDefault(urlParameters, "day", null);
let mediaListPromise: Promise<Media[]>;
+$: source = ($settings.countdownSource === "dub" ? "dub" : "sub") as AirType;
+
onMount(async () => {
if (user === undefined || $identity.id === -2)
mediaListPromise = Promise.resolve([]);
@@ -54,40 +57,54 @@ onMount(async () => {
}
});
-const shiftSubsPleaseSchedule = (schedule: SubsPlease["schedule"]) => {
- const shiftedSchedule: { [key: string]: SubsPleaseEpisode[] } = {};
+const WEEKDAYS = [
+ "Sunday",
+ "Monday",
+ "Tuesday",
+ "Wednesday",
+ "Thursday",
+ "Friday",
+ "Saturday",
+];
+
+const formatTime = (airingAt: number) =>
+ new Date(airingAt * 1000).toLocaleTimeString([], {
+ hour12: !$settings.display24HourTime,
+ hour: "numeric",
+ minute: "2-digit",
+ });
+
+// Bucket releases by the viewer's local weekday, ordered from today onward.
+const scheduleByDay = (entries: AiringEntry[]) => {
+ const buckets: { [key: string]: AiringEntry[] } = {};
+
+ for (const entry of entries) {
+ if (!entry.airingAt) continue;
+
+ const weekday = new Date(entry.airingAt * 1000).toLocaleString("en-us", {
+ weekday: "long",
+ });
- if (day && Object.keys(schedule).includes(day)) {
- shiftedSchedule[day] = schedule[
- day as keyof typeof schedule
- ] as unknown as SubsPleaseEpisode[];
+ if (!buckets[weekday]) buckets[weekday] = [];
- return shiftedSchedule;
+ buckets[weekday].push(entry);
}
- const days = Object.keys(schedule);
- const currentDayIndex = days.indexOf(
- new Date().toLocaleString("en-us", { weekday: "long" }),
- );
+ for (const weekday in buckets)
+ buckets[weekday].sort((a, b) => a.airingAt - b.airingAt);
- days
- .slice(currentDayIndex)
- .concat(days.slice(0, currentDayIndex))
- .forEach((day) => {
- const scheduleEntry = schedule[day as keyof typeof schedule];
+ if (day && buckets[day]) return { [day]: buckets[day] };
- shiftedSchedule[day] = Array.isArray(scheduleEntry)
- ? scheduleEntry
- : ([scheduleEntry] as unknown as SubsPleaseEpisode[]);
- });
+ const todayIndex = new Date().getDay();
+ const ordered: { [key: string]: AiringEntry[] } = {};
- Object.entries(shiftedSchedule).forEach(([day, scheduleEntry]) => {
- if (scheduleEntry.length === 0) {
- delete shiftedSchedule[day];
- }
- });
+ for (let offset = 0; offset < 7; offset += 1) {
+ const weekday = WEEKDAYS[(todayIndex + offset) % 7];
+
+ if (buckets[weekday]?.length) ordered[weekday] = buckets[weekday];
+ }
- return shiftedSchedule;
+ return ordered;
};
const associateMedia = (
@@ -123,7 +140,7 @@ const episode = (media: Media, weekday: string) => {
<Skeleton grid={true} count={7} height="15em" width="49.5%" />
{:then mediaList}
- {#each Object.entries(shiftSubsPleaseSchedule(subsPlease.schedule)) as [day, scheduleEntry], dayIndex}
+ {#each Object.entries(scheduleByDay(schedule[source])) as [day, scheduleEntry], dayIndex}
<details
open
class="list"
@@ -136,7 +153,7 @@ const episode = (media: Media, weekday: string) => {
class="covers"
style={`grid-template-columns: repeat(auto-fill, minmax(${$settings.displayCoverWidth}px, 1fr))`}
>
- {#each Object.values(scheduleEntry) as entry, entryIndex}
+ {#each scheduleEntry as entry, entryIndex}
{@const media = associateMedia(scheduledMedia, entry.title, mediaList)}
{#if ($settings.displayScheduleFilterList && media) || !$settings.displayScheduleFilterList}
@@ -161,7 +178,7 @@ const episode = (media: Media, weekday: string) => {
? $settings.displayDataSaver
? media.coverImage.medium
: media.coverImage.extraLarge
- : `https://subsplease.org${entry.image_url}`}
+ : entry.imageUrl}
limit={12.5}
alternativeText="Cover"
/>
@@ -179,7 +196,7 @@ const episode = (media: Media, weekday: string) => {
{episode(media, day)}{media.episodes ? `/${media.episodes}` : ''} at
</span>
{/if}
- {entry.time}
+ {formatTime(entry.airingAt)}
</span>
</div>
</LinkedTooltip>
@@ -189,7 +206,7 @@ const episode = (media: Media, weekday: string) => {
</div>
{:else}
<ol>
- {#each Object.values(scheduleEntry) as entry}
+ {#each scheduleEntry as entry}
{@const media = associateMedia(scheduledMedia, entry.title, mediaList)}
{#if ($settings.displayScheduleFilterList && media) || !$settings.displayScheduleFilterList}
@@ -204,7 +221,7 @@ const episode = (media: Media, weekday: string) => {
{episode(media, day)}{media.episodes ? `/${media.episodes}` : ''} at
</span>
{/if}
- {entry.time}
+ {formatTime(entry.airingAt)}
</span>
</li>
{/if}
diff --git a/src/lib/Settings/Categories/Attributions.svelte b/src/lib/Settings/Categories/Attributions.svelte
index 131bd90a..294c6828 100644
--- a/src/lib/Settings/Categories/Attributions.svelte
+++ b/src/lib/Settings/Categories/Attributions.svelte
@@ -18,6 +18,10 @@ import locale from "$stores/locale";
<li>
<a href="https://x.com/YDPFALION" target="_blank">YDPFa</a>: {$locale().settings.attributions.girlsBandCryIcons}
</li>
+ <li>
+ <a href="https://animeschedule.net/" target="_blank">AnimeSchedule.net</a>: subbed &
+ dubbed release schedule data
+ </li>
<!-- <li>
<a href="https://www.animecharactersdatabase.com/index.php" target="_blank">
Anime Characters Database
@@ -33,9 +37,6 @@ import locale from "$stores/locale";
<!-- <li>
<a href="https://www.wlnupdates.com/">WLN Updates</a>: web & light novel release data
</li> -->
- <!-- <li>
- <a href="https://subsplease.org/">SubsPlease</a>: subtitle release data
- </li> -->
</ul>
<Spacer />
diff --git a/src/lib/Settings/Categories/Display.svelte b/src/lib/Settings/Categories/Display.svelte
index f560c65c..fba40a56 100644
--- a/src/lib/Settings/Categories/Display.svelte
+++ b/src/lib/Settings/Categories/Display.svelte
@@ -252,10 +252,18 @@ const onHelperChange = () => {
setting="display24HourTime"
text={$locale().settings.display.categories.dateAndTime.fields.use24HourTime}
/>
-<SettingCheckboxToggle
- setting="displayNativeCountdown"
- text={$locale().settings.display.categories.dateAndTime.fields.nativeEpisodeCountdown}
-/>
+<select bind:value={$settings.countdownSource} onchange={trackSetting('countdownSource')}>
+ <option value="sub">
+ {$locale().settings.display.categories.dateAndTime.fields.countdownSource.options.sub}
+ </option>
+ <option value="dub">
+ {$locale().settings.display.categories.dateAndTime.fields.countdownSource.options.dub}
+ </option>
+ <option value="native">
+ {$locale().settings.display.categories.dateAndTime.fields.countdownSource.options.native}
+ </option>
+</select>
+{$locale().settings.display.categories.dateAndTime.fields.countdownSource.title}<br />
<SettingCheckboxToggle
setting="displayShortCountdown"
text={$locale().settings.display.categories.dateAndTime.fields.abbreviateCountdown}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index f58bff4d..db628da6 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -5,7 +5,6 @@ import { env } from "$env/dynamic/public";
import { userIdentity as getUserIdentity } from "$lib/Data/AniList/identity";
import HeadTitle from "$lib/Home/HeadTitle.svelte";
import Spacer from "$lib/Layout/Spacer.svelte";
-import type { SubsPleaseEpisode } from "$lib/Media/Anime/Airing/Subtitled/subsPlease";
import userIdentity from "$stores/identity";
import settings from "$stores/settings";
import "../app.css";
@@ -25,7 +24,6 @@ import { authActions } from "$lib/CommandPalette/authActions";
import CommandPalette from "$lib/CommandPalette/CommandPalette.svelte";
import { syncActions } from "$lib/CommandPalette/syncActions";
import { toggleActions } from "$lib/CommandPalette/toggleActions";
-import subtitles from "$lib/Data/Static/subtitles.json";
import { database as userDatabase } from "$lib/Database/IDB/user";
import Root from "$lib/Home/Root.svelte";
import Dropdown from "$lib/Layout/Dropdown.svelte";
@@ -38,10 +36,10 @@ import NotificationsProvider from "$lib/Notification/NotificationsProvider.svelt
import { toolsAsCommandPaletteActions } from "$lib/Tools/tools";
import { requestNotifications } from "$lib/Utility/notifications";
import root from "$lib/Utility/root";
+import airingSchedule from "$stores/airingSchedule";
import locale from "$stores/locale";
import settingsSyncPulled from "$stores/settingsSyncPulled";
import settingsSyncTimes from "$stores/settingsSyncTimes";
-import subsPlease from "$stores/subsPlease";
import "lenis/dist/lenis.css";
import lenisStore from "$stores/lenis";
import type { LayoutData } from "./$types";
@@ -286,32 +284,12 @@ $: {
(data.url === "/" ||
data.url === "/completed" ||
data.url === "/schedule") &&
- !$subsPlease
+ !$airingSchedule
)
- fetch(
- root(
- `/api/subsplease?tz=${Intl.DateTimeFormat().resolvedOptions().timeZone}`,
- ),
- )
+ fetch(root("/api/animeschedule"))
.then((r) => r.json())
- .then((r) => {
- for (const day in subtitles) {
- if (!r.schedule[day]) r.schedule[day] = [];
-
- (
- subtitles[day as keyof typeof subtitles] as SubsPleaseEpisode[]
- ).forEach((episode) => {
- r.schedule[day].push({
- title: episode.title,
- page: episode.page || "",
- image_url: episode.image_url || "",
- time: episode.time,
- });
- });
- }
-
- subsPlease.set(r);
- });
+ .then((r) => airingSchedule.set(r))
+ .catch(() => airingSchedule.set({ generatedAt: 0, sub: [], dub: [] }));
}
</script>
diff --git a/src/routes/api/animeschedule/+server.ts b/src/routes/api/animeschedule/+server.ts
new file mode 100644
index 00000000..c596bf41
--- /dev/null
+++ b/src/routes/api/animeschedule/+server.ts
@@ -0,0 +1,25 @@
+import { env } from "$env/dynamic/private";
+import { fetchTimetables } from "$lib/Media/Anime/Airing/animeSchedule";
+import { appOriginHeaders } from "$lib/Utility/appOrigin";
+
+export const GET = async () => {
+ const token = env.ANIMESCHEDULE_CLIENT_TOKEN;
+ const generatedAt = Math.floor(Date.now() / 1000);
+
+ if (!token)
+ return Response.json(
+ { generatedAt, sub: [], dub: [] },
+ { headers: appOriginHeaders({ "Cache-Control": "max-age=60" }) },
+ );
+
+ const { sub, dub } = await fetchTimetables(token);
+
+ return Response.json(
+ { generatedAt, sub, dub },
+ {
+ headers: appOriginHeaders({
+ "Cache-Control": "max-age=86400, s-maxage=86400",
+ }),
+ },
+ );
+};
diff --git a/src/routes/api/subsplease/+server.ts b/src/routes/api/subsplease/+server.ts
deleted file mode 100644
index 1f678d8c..00000000
--- a/src/routes/api/subsplease/+server.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { appOriginHeaders } from "$lib/Utility/appOrigin";
-
-export const GET = async ({ url }) => {
- const timezone = url.searchParams.get("tz") || "America/Los_Angeles";
-
- return Response.json(
- await (
- await fetch(
- `https://subsplease.org/api/?f=schedule&tz=${encodeURIComponent(timezone)}`,
- )
- ).json(),
- {
- headers: appOriginHeaders({
- "Cache-Control": "max-age=86400, s-maxage=86400",
- }),
- },
- );
-};
diff --git a/src/routes/schedule/+page.svelte b/src/routes/schedule/+page.svelte
index d6708a3e..da26e4e3 100644
--- a/src/routes/schedule/+page.svelte
+++ b/src/routes/schedule/+page.svelte
@@ -12,8 +12,8 @@ import "$lib/Schedule/container.css";
import Message from "$lib/Loading/Message.svelte";
import Skeleton from "$lib/Loading/Skeleton.svelte";
import Days from "$lib/Schedule/Days.svelte";
+import airingSchedule from "$stores/airingSchedule";
import locale from "$stores/locale";
-import subsPlease from "$stores/subsPlease";
import type { PageData } from "./$types";
export let data: PageData;
@@ -36,20 +36,6 @@ onMount(async () => {
<HeadTitle routeKey="schedule" path="/schedule" />
-<!-- <blockquote>
- <select
- bind:value={timeZone}
- onchange={() =>
- (subsPleasePromise = fetch(root(`/api/subsplease?tz=${timeZone}`)).then((r) => r.json()))}
- >
- {#each Intl.supportedValuesOf('timeZone') as zone}
- <option value={zone}>
- {zone.split('/').reverse().join(', ').replace(/_/g, ' ')}
- </option>
- {/each}
- </select>
-</blockquote> -->
-
<!-- <details bind:open={crunchyrollExpanded}>
<summary>
Crunchyroll Release Calender (Click to {crunchyrollExpanded ? 'collapse' : 'expand'})
@@ -62,7 +48,7 @@ onMount(async () => {
<Spacer /> -->
-{#if !$subsPlease}
+{#if !$airingSchedule}
<Message message={$locale().schedule?.loadingSubtitle} />
<Skeleton grid={true} count={7} height="15em" width="49.5%" />
@@ -74,7 +60,7 @@ onMount(async () => {
{:then scheduledMedia}
{#if scheduledMedia}
<div class="schedule-container" id="schedule">
- <Days subsPlease={$subsPlease} {scheduledMedia} {forceListMode} user={data.user} />
+ <Days schedule={$airingSchedule} {scheduledMedia} {forceListMode} user={data.user} />
</div>
{:else}
<Message message={$locale().schedule?.loadingSchedule} />
diff --git a/src/stores/airingSchedule.ts b/src/stores/airingSchedule.ts
new file mode 100644
index 00000000..a2b05150
--- /dev/null
+++ b/src/stores/airingSchedule.ts
@@ -0,0 +1,6 @@
+import { writable } from "svelte/store";
+import type { AiringSchedule } from "$lib/Media/Anime/Airing/animeSchedule";
+
+const airingSchedule = writable<AiringSchedule | undefined>(undefined);
+
+export default airingSchedule;
diff --git a/src/stores/settings.ts b/src/stores/settings.ts
index 1edab858..1e77afbd 100644
--- a/src/stores/settings.ts
+++ b/src/stores/settings.ts
@@ -6,7 +6,7 @@ import identity from "./identity";
import settingsSyncPulled from "./settingsSyncPulled";
import settingsSyncTimes from "./settingsSyncTimes";
-const VERSION = "1.0.1";
+const VERSION = "1.0.2";
export interface Settings {
cacheMangaMinutes: number;
@@ -49,7 +49,7 @@ export interface Settings {
disableUpcomingAnime: boolean;
display24HourTime: boolean;
displayCountdownRightAligned: boolean;
- displayNativeCountdown: boolean;
+ countdownSource: "native" | "sub" | "dub";
displayHoverCover: boolean;
displayDisableAnimations: boolean;
displayDisableNotifications: boolean;
@@ -101,7 +101,7 @@ const defaultSettings: Settings = {
disableUpcomingAnime: false,
display24HourTime: false,
displayCountdownRightAligned: false,
- displayNativeCountdown: false,
+ countdownSource: "sub",
displayHoverCover: false,
displayDisableAnimations: false,
displayDisableNotifications: false,
@@ -147,11 +147,28 @@ const defaultSettings: Settings = {
settingsVersion: VERSION,
};
+// Carry the retired `displayNativeCountdown` boolean over to `countdownSource`.
+const migrateSettings = (stored: Settings): Settings => {
+ const legacy = stored as Settings & { displayNativeCountdown?: boolean };
+
+ if (
+ legacy.countdownSource === undefined &&
+ legacy.displayNativeCountdown !== undefined
+ )
+ legacy.countdownSource = legacy.displayNativeCountdown ? "native" : "sub";
+
+ delete legacy.displayNativeCountdown;
+
+ return legacy;
+};
+
const createStore = () => {
const initialValue = browser
- ? parseJsonStringOrDefault(
- localStorage.getItem("settings") || "",
- defaultSettings,
+ ? migrateSettings(
+ parseJsonStringOrDefault(
+ localStorage.getItem("settings") || "",
+ defaultSettings,
+ ),
)
: defaultSettings;
const store = writable<Settings>(initialValue);
diff --git a/src/stores/subsPlease.ts b/src/stores/subsPlease.ts
deleted file mode 100644
index a1276a1c..00000000
--- a/src/stores/subsPlease.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { writable } from "svelte/store";
-import type { SubsPlease } from "$lib/Media/Anime/Airing/Subtitled/subsPlease";
-
-const subsPlease = writable<SubsPlease>(undefined);
-
-export default subsPlease;