aboutsummaryrefslogtreecommitdiff
path: root/src/lib/Media
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/Media')
-rw-r--r--src/lib/Media/Anime/Airing/AiringTime.svelte204
-rw-r--r--src/lib/Media/Anime/Airing/Subtitled/match.ts949
-rw-r--r--src/lib/Media/Anime/Airing/Subtitled/subsPlease.ts16
-rw-r--r--src/lib/Media/Anime/Airing/classify.test.ts273
-rw-r--r--src/lib/Media/Anime/Airing/classify.ts87
-rw-r--r--src/lib/Media/Anime/Airing/time.ts239
-rw-r--r--src/lib/Media/Anime/cache.ts65
-rw-r--r--src/lib/Media/Anime/episodes.ts6
-rw-r--r--src/lib/Media/Anime/season.ts18
-rw-r--r--src/lib/Media/Cover/HoverCover.svelte4
-rw-r--r--src/lib/Media/Cover/hoverCover.ts85
-rw-r--r--src/lib/Media/Manga/cache.ts12
-rw-r--r--src/lib/Media/Manga/chapters.ts428
-rw-r--r--src/lib/Media/Manga/volumes.ts6
-rw-r--r--src/lib/Media/links.ts98
15 files changed, 1331 insertions, 1159 deletions
diff --git a/src/lib/Media/Anime/Airing/AiringTime.svelte b/src/lib/Media/Anime/Airing/AiringTime.svelte
index 0278de1c..be5f40d9 100644
--- a/src/lib/Media/Anime/Airing/AiringTime.svelte
+++ b/src/lib/Media/Anime/Airing/AiringTime.svelte
@@ -1,117 +1,121 @@
<script lang="ts">
-import type { Media } from '$lib/Data/AniList/media';
-import settings from '$stores/settings';
-import type { MediaPrequel } from '$lib/Data/AniList/prequels';
-import tooltip from '$lib/Tooltip/tooltip';
-import locale from '$stores/locale';
-import airingNow from '$stores/airingNow';
+import type { Media } from "$lib/Data/AniList/media";
+import settings from "$stores/settings";
+import type { MediaPrequel } from "$lib/Data/AniList/prequels";
+import tooltip from "$lib/Tooltip/tooltip";
+import locale from "$stores/locale";
+import airingNow from "$stores/airingNow";
export let originalAnime: Media;
export let upcoming = false;
const anime = originalAnime;
let opacity = 100;
-let timeFrame = '';
-let time = '';
+let timeFrame = "";
+let time = "";
let nextEpisode = anime.nextAiringEpisode?.episode || 0;
let few = true;
-let dateString = '';
+let dateString = "";
const setAiringTime = () => {
- time = '';
- timeFrame = '';
- dateString = '';
-
- const airingAt = anime.nextAiringEpisode?.airingAt;
- const untilAiring = airingAt ? Math.round((airingAt - $airingNow / 1000) * 100) / 100 : undefined;
- let hours = null;
- const shortenCountdown = $settings.displayShortCountdown;
-
- time = new Date(airingAt ? airingAt * 1000 : 0).toLocaleTimeString([], {
- hour12: !$settings.display24HourTime,
- hour: 'numeric',
- minute: '2-digit'
- });
-
- if (
- (anime as unknown as MediaPrequel).startDate &&
- new Date(
- anime.startDate.year,
- (anime as unknown as MediaPrequel).startDate.month,
- (anime as unknown as MediaPrequel).startDate.day
- ) < new Date()
- )
- return `<span class="opaque">on ${new Date(
- anime.startDate.year,
- (anime as unknown as MediaPrequel).startDate.month,
- (anime as unknown as MediaPrequel).startDate.day
- ).toLocaleDateString()}</span>`;
-
- if (untilAiring !== undefined) {
- let minutes = Math.round(untilAiring / 60);
-
- few = true;
-
- if (minutes > 60) {
- hours = minutes / 60;
-
- if (hours > 24) {
- const days = Math.floor(hours / 24);
- const weeks = Math.floor(days / 7);
-
- few = false;
-
- if (weeks >= 1.5) {
- timeFrame = `${weeks}${shortenCountdown ? 'w' : ' week'}${
- weeks === 1 || shortenCountdown ? '' : 's'
- }`;
-
- const residualDays = days % 7;
-
- if (residualDays > 0)
- timeFrame += `${shortenCountdown ? '' : ' '}${residualDays}${
- shortenCountdown ? 'd' : ' day'
- }${residualDays === 1 || shortenCountdown ? '' : 's'}`;
- } else {
- timeFrame += `${days}${shortenCountdown ? 'd' : ' day'}${
- days === 1 || shortenCountdown ? '' : 's'
- }`;
- }
-
- const residualHours = Math.floor(hours - days * 24);
-
- if (residualHours > 0)
- timeFrame += `${shortenCountdown ? '' : ' '}${residualHours}${
- shortenCountdown ? 'h' : ' hour'
- }${residualHours === 1 || shortenCountdown ? '' : 's'}`;
- } else {
- const residualMinutes = Math.round(minutes - Math.floor(hours) * 60);
-
- timeFrame += `${Math.floor(hours).toFixed(0)}${shortenCountdown ? 'h' : ' hour'}${
- Math.floor(hours) === 1 || shortenCountdown ? '' : 's'
- }`;
-
- if (residualMinutes > 0)
- timeFrame += `${shortenCountdown ? '' : ' '}${residualMinutes}${
- shortenCountdown ? 'm' : ' minute'
- }${residualMinutes === 1 || shortenCountdown ? '' : 's'}`;
- }
- } else {
- minutes = Math.round(minutes);
-
- timeFrame += `${minutes}${shortenCountdown ? 'm' : ' minute'}${
- minutes === 1 || shortenCountdown ? '' : 's'
- }`;
- }
-
- opacity = Math.max(50, 100 - (untilAiring / 60 / 60 / 24 / 7) * 50);
- dateString = $locale().dateFormatter(new Date(airingAt ? airingAt * 1000 : 0));
- }
+ time = "";
+ timeFrame = "";
+ dateString = "";
+
+ const airingAt = anime.nextAiringEpisode?.airingAt;
+ const untilAiring = airingAt
+ ? Math.round((airingAt - $airingNow / 1000) * 100) / 100
+ : undefined;
+ let hours = null;
+ const shortenCountdown = $settings.displayShortCountdown;
+
+ time = new Date(airingAt ? airingAt * 1000 : 0).toLocaleTimeString([], {
+ hour12: !$settings.display24HourTime,
+ hour: "numeric",
+ minute: "2-digit",
+ });
+
+ if (
+ (anime as unknown as MediaPrequel).startDate &&
+ new Date(
+ anime.startDate.year,
+ (anime as unknown as MediaPrequel).startDate.month,
+ (anime as unknown as MediaPrequel).startDate.day,
+ ) < new Date()
+ )
+ return `<span class="opaque">on ${new Date(
+ anime.startDate.year,
+ (anime as unknown as MediaPrequel).startDate.month,
+ (anime as unknown as MediaPrequel).startDate.day,
+ ).toLocaleDateString()}</span>`;
+
+ if (untilAiring !== undefined) {
+ let minutes = Math.round(untilAiring / 60);
+
+ few = true;
+
+ if (minutes > 60) {
+ hours = minutes / 60;
+
+ if (hours > 24) {
+ const days = Math.floor(hours / 24);
+ const weeks = Math.floor(days / 7);
+
+ few = false;
+
+ if (weeks >= 1.5) {
+ timeFrame = `${weeks}${shortenCountdown ? "w" : " week"}${
+ weeks === 1 || shortenCountdown ? "" : "s"
+ }`;
+
+ const residualDays = days % 7;
+
+ if (residualDays > 0)
+ timeFrame += `${shortenCountdown ? "" : " "}${residualDays}${
+ shortenCountdown ? "d" : " day"
+ }${residualDays === 1 || shortenCountdown ? "" : "s"}`;
+ } else {
+ timeFrame += `${days}${shortenCountdown ? "d" : " day"}${
+ days === 1 || shortenCountdown ? "" : "s"
+ }`;
+ }
+
+ const residualHours = Math.floor(hours - days * 24);
+
+ if (residualHours > 0)
+ timeFrame += `${shortenCountdown ? "" : " "}${residualHours}${
+ shortenCountdown ? "h" : " hour"
+ }${residualHours === 1 || shortenCountdown ? "" : "s"}`;
+ } else {
+ const residualMinutes = Math.round(minutes - Math.floor(hours) * 60);
+
+ timeFrame += `${Math.floor(hours).toFixed(0)}${shortenCountdown ? "h" : " hour"}${
+ Math.floor(hours) === 1 || shortenCountdown ? "" : "s"
+ }`;
+
+ if (residualMinutes > 0)
+ timeFrame += `${shortenCountdown ? "" : " "}${residualMinutes}${
+ shortenCountdown ? "m" : " minute"
+ }${residualMinutes === 1 || shortenCountdown ? "" : "s"}`;
+ }
+ } else {
+ minutes = Math.round(minutes);
+
+ timeFrame += `${minutes}${shortenCountdown ? "m" : " minute"}${
+ minutes === 1 || shortenCountdown ? "" : "s"
+ }`;
+ }
+
+ opacity = Math.max(50, 100 - (untilAiring / 60 / 60 / 24 / 7) * 50);
+ dateString = $locale().dateFormatter(
+ new Date(airingAt ? airingAt * 1000 : 0),
+ );
+ }
};
$: {
- $airingNow;
+ $airingNow;
- setAiringTime();
+ setAiringTime();
}
</script>
diff --git a/src/lib/Media/Anime/Airing/Subtitled/match.ts b/src/lib/Media/Anime/Airing/Subtitled/match.ts
index 0570b7f9..d3950948 100644
--- a/src/lib/Media/Anime/Airing/Subtitled/match.ts
+++ b/src/lib/Media/Anime/Airing/Subtitled/match.ts
@@ -1,86 +1,95 @@
-import { get } from 'svelte/store';
-import type { Media } from '../../../../Data/AniList/media';
-import settings from '$stores/settings';
-import type { SubsPlease } from '$lib/Media/Anime/Airing/Subtitled/subsPlease';
-import stringSimilarity from 'string-similarity';
-import excludeMatch from '$lib/Data/Static/matchExclude.json';
-import { season } from '../../season';
+import { get } from "svelte/store";
+import type { Media } from "../../../../Data/AniList/media";
+import settings from "$stores/settings";
+import type { SubsPlease } from "$lib/Media/Anime/Airing/Subtitled/subsPlease";
+import stringSimilarity from "string-similarity";
+import excludeMatch from "$lib/Data/Static/matchExclude.json";
+import { season } from "../../season";
export interface Time {
- title: string;
- time: string;
- day: string;
+ title: string;
+ time: string;
+ day: string;
}
interface IndexedTime {
- time: Time;
- normalizedTitle: string;
- tokens: string[];
+ time: Time;
+ normalizedTitle: string;
+ tokens: string[];
}
interface DayScheduleIndex {
- entries: IndexedTime[];
- exactTitleIndex: Map<string, number[]>;
- tokenIndex: Map<string, number[]>;
+ entries: IndexedTime[];
+ exactTitleIndex: Map<string, number[]>;
+ tokenIndex: Map<string, number[]>;
}
interface ScheduleIndex {
- byDay: Map<string, DayScheduleIndex>;
- version: string;
+ 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 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)\b/g, ' ')
- .replace(/[^a-z0-9\s]/gi, '')
- .trim()
- .split(/\s+/)
- .join(' ');
+ return title
+ .toLowerCase()
+ .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'
+ "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));
+ /^\d+$/.test(token) ||
+ (token.length >= 3 && !NON_DISTINCTIVE_TOKENS.has(token));
const MIN_MATCH_SCORE = 0.3;
const MIN_TOKEN_OVERLAP = 2;
@@ -93,58 +102,69 @@ const STALE_AIRING_GRACE_SECONDS = 5 * 60;
const MAX_EPISODE_SHIFT_WINDOW_SECONDS = 36 * 60 * 60;
interface SimilarityAnalysis {
- score: number;
- tokenOverlap: number;
- numericTokenOverlap: number;
+ 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 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);
+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]);
+ if (existing) existing.push(entryIndex);
+ else index.set(key, [entryIndex]);
};
const scheduleIndexCache = new WeakMap<SubsPlease, ScheduleIndex>();
@@ -152,367 +172,444 @@ const closestMatchCache = new Map<string, Time | null>();
const injectAiringTimeCache = new Map<string, Media>();
const setBoundedCacheValue = <T>(
- cache: Map<string, T>,
- key: string,
- value: T,
- maxEntries: number
+ cache: Map<string, T>,
+ key: string,
+ value: T,
+ maxEntries: number,
) => {
- if (cache.size >= maxEntries) cache.clear();
+ if (cache.size >= maxEntries) cache.clear();
- cache.set(key, value);
+ cache.set(key, value);
};
const animeTitleFingerprint = (anime: Media) =>
- [anime.title.romaji, anime.title.english, ...anime.synonyms]
- .filter(Boolean)
- .map(preprocessTitle)
- .join('|');
-
-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;
+ [anime.title.romaji, anime.title.english, ...anime.synonyms]
+ .filter(Boolean)
+ .map(preprocessTitle)
+ .join("|");
+
+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() : [];
- const firstEntry = flattenedValue[0];
- const lastEntry = flattenedValue[flattenedValue.length - 1];
-
- versionParts.push(
- `${day}:${flattenedValue.length}:${firstEntry?.title || ''}:${firstEntry?.time || ''}:${
- lastEntry?.title || ''
- }:${lastEntry?.time || ''}`
- );
-
- 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
- };
- 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: `${subsPlease.tz}:${versionParts.join('|')}`
- };
+ 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() : [];
+ const firstEntry = flattenedValue[0];
+ const lastEntry = flattenedValue[flattenedValue.length - 1];
+
+ versionParts.push(
+ `${day}:${flattenedValue.length}:${firstEntry?.title || ""}:${firstEntry?.time || ""}:${
+ lastEntry?.title || ""
+ }:${lastEntry?.time || ""}`,
+ );
+
+ 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,
+ };
+ 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: `${subsPlease.tz}:${versionParts.join("|")}`,
+ };
};
-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 cacheKey = `${anime.id}:${anime.nextAiringEpisode?.airingAt || 0}:${animeTitleFingerprint(
- anime
- )}:${scheduleIndex.version}`;
- const cached = closestMatchCache.get(cacheKey);
-
- if (cached !== undefined) return cached;
-
- const airingDay = new Date((anime.nextAiringEpisode?.airingAt || 0) * 1000).toLocaleString(
- 'en-US',
- { weekday: 'long' }
- );
- 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;
+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 cacheKey = `${anime.id}:${anime.nextAiringEpisode?.airingAt || 0}:${animeTitleFingerprint(
+ anime,
+ )}:${scheduleIndex.version}`;
+ const cached = closestMatchCache.get(cacheKey);
+
+ if (cached !== undefined) return cached;
+
+ const airingDay = new Date(
+ (anime.nextAiringEpisode?.airingAt || 0) * 1000,
+ ).toLocaleString("en-US", { weekday: "long" });
+ 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();
+ (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;
+ if (!matchFor) return null;
- const cached = findClosestMediaCache.get(matchFor);
+ const cached = findClosestMediaCache.get(matchFor);
- if (cached !== undefined) return cached;
+ if (cached !== undefined) return cached;
- const normalisedMatchFor = normalizeTitle(matchFor);
- const matchForWords = normalisedMatchFor.split(' ');
- let bestFitMedia: Media | null = null;
- let bestDistance = -Infinity;
+ 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);
+ 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;
+ if (
+ titles.some(
+ (title) =>
+ title.toLowerCase().includes("special") ||
+ title.toLowerCase().includes("ova"),
+ )
+ )
+ continue;
- const normalisedTitles = titles.map(normalizeTitle);
+ const normalisedTitles = titles.map(normalizeTitle);
- for (const normalisedTitle of normalisedTitles) {
- const distance = stringSimilarity.compareTwoStrings(normalisedMatchFor, normalisedTitle);
+ for (const normalisedTitle of normalisedTitles) {
+ const distance = stringSimilarity.compareTwoStrings(
+ normalisedMatchFor,
+ normalisedTitle,
+ );
- if (distance <= bestDistance) continue;
+ if (distance <= bestDistance) continue;
- const wordMatch =
- matchForWords.every((word) => normalisedTitles.some((t) => t.includes(word))) ||
- normalisedTitles.some((t) => t.includes(normalisedMatchFor));
+ const wordMatch =
+ matchForWords.every((word) =>
+ normalisedTitles.some((t) => t.includes(word)),
+ ) || normalisedTitles.some((t) => t.includes(normalisedMatchFor));
- if (wordMatch) {
- bestDistance = distance;
- bestFitMedia = m;
+ if (wordMatch) {
+ bestDistance = distance;
+ bestFitMedia = m;
- if (distance === 1) break;
- }
- }
+ if (distance === 1) break;
+ }
+ }
- if (bestDistance === 1) break;
- }
+ if (bestDistance === 1) break;
+ }
- findClosestMediaCache.set(matchFor, bestFitMedia);
+ findClosestMediaCache.set(matchFor, bestFitMedia);
- return bestFitMedia as Media | null;
+ return bestFitMedia as Media | null;
};
export const clearClosestMediaCache = () => findClosestMediaCache.clear();
const getScheduleIndex = (subsPlease: SubsPlease): ScheduleIndex => {
- const cached = scheduleIndexCache.get(subsPlease);
+ const cached = scheduleIndexCache.get(subsPlease);
- if (cached) return cached;
+ if (cached) return cached;
- const built = buildScheduleIndex(subsPlease);
+ const built = buildScheduleIndex(subsPlease);
- scheduleIndexCache.set(subsPlease, built);
+ scheduleIndexCache.set(subsPlease, built);
- return built;
+ return built;
};
const buildInjectAiringTimeCacheKey = (
- anime: Media,
- scheduleVersion: string,
- displayNativeCountdown: boolean
+ anime: Media,
+ scheduleVersion: string,
+ displayNativeCountdown: boolean,
) =>
- [
- anime.id,
- anime.status,
- anime.nextAiringEpisode?.episode || 0,
- anime.nextAiringEpisode?.airingAt || 0,
- displayNativeCountdown ? 1 : 0,
- scheduleVersion,
- animeTitleFingerprint(anime)
- ].join(':');
-
-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 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;
-
- 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;
- }
-
- 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: anime.nextAiringEpisode?.episode || 0
- }
- } as Media;
-
- setBoundedCacheValue(injectAiringTimeCache, cacheKey, injected, MAX_INJECT_CACHE_ENTRIES);
-
- return injected;
+ [
+ anime.id,
+ anime.status,
+ anime.nextAiringEpisode?.episode || 0,
+ anime.nextAiringEpisode?.airingAt || 0,
+ displayNativeCountdown ? 1 : 0,
+ scheduleVersion,
+ animeTitleFingerprint(anime),
+ ].join(":");
+
+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 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;
+
+ 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;
+ }
+
+ 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: anime.nextAiringEpisode?.episode || 0,
+ },
+ } as Media;
+
+ setBoundedCacheValue(
+ injectAiringTimeCache,
+ cacheKey,
+ injected,
+ MAX_INJECT_CACHE_ENTRIES,
+ );
+
+ return injected;
};
diff --git a/src/lib/Media/Anime/Airing/Subtitled/subsPlease.ts b/src/lib/Media/Anime/Airing/Subtitled/subsPlease.ts
index 69f56286..3815259d 100644
--- a/src/lib/Media/Anime/Airing/Subtitled/subsPlease.ts
+++ b/src/lib/Media/Anime/Airing/Subtitled/subsPlease.ts
@@ -1,13 +1,13 @@
export interface SubsPlease {
- tz: string;
- schedule: {
- [key in string]: SubsPleaseEpisode;
- }[];
+ tz: string;
+ schedule: {
+ [key in string]: SubsPleaseEpisode;
+ }[];
}
export interface SubsPleaseEpisode {
- title: string;
- page: string;
- image_url: string;
- time: string;
+ title: string;
+ page: string;
+ image_url: string;
+ time: string;
}
diff --git a/src/lib/Media/Anime/Airing/classify.test.ts b/src/lib/Media/Anime/Airing/classify.test.ts
index cad08b43..6af36b77 100644
--- a/src/lib/Media/Anime/Airing/classify.test.ts
+++ b/src/lib/Media/Anime/Airing/classify.test.ts
@@ -1,153 +1,158 @@
-import { describe, expect, it } from 'vitest';
-import settings from '$stores/settings';
-import type { Media } from '$lib/Data/AniList/media';
-import { season } from '$lib/Media/Anime/season';
-import { hasDueEpisodes, getAnimeEpisodeState } 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 { describe, expect, it } from "vitest";
+import settings from "$stores/settings";
+import type { Media } from "$lib/Data/AniList/media";
+import { season } from "$lib/Media/Anime/season";
+import {
+ hasDueEpisodes,
+ getAnimeEpisodeState,
+} 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";
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');
+ 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}`;
+ return `${hours}:${minutes}`;
};
const baseMedia = (id: number): Media =>
- ({
- id,
- idMal: id,
- status: 'RELEASING',
- type: 'ANIME',
- episodes: 12,
- chapters: 0,
- volumes: 0,
- duration: 24,
- format: 'TV',
- title: {
- romaji: `Fixture Show ${id}`,
- english: `Fixture Show ${id}`,
- native: `Fixture Show ${id}`
- },
- nextAiringEpisode: {
- episode: 8,
- airingAt: Math.floor(Date.now() / 1000) + 24 * 60 * 60
- },
- synonyms: [],
- mediaListEntry: {
- progress: 6,
- progressVolumes: 0,
- status: 'CURRENT',
- score: 0,
- repeat: 0,
- startedAt: {
- year: 2025,
- month: 1,
- day: 1
- },
- completedAt: {
- year: 0,
- month: 0,
- day: 0
- },
- createdAt: 0,
- updatedAt: 0,
- customLists: {}
- },
- startDate: {
- year: 2025,
- month: 1
- },
- endDate: {
- year: 2025,
- month: 12
- },
- coverImage: {
- extraLarge: 'https://example.com/cover-xl.jpg',
- medium: 'https://example.com/cover-md.jpg'
- },
- tags: [],
- genres: [],
- season: season(),
- isAdult: false,
- relations: {
- edges: []
- }
- }) as Media;
+ ({
+ id,
+ idMal: id,
+ status: "RELEASING",
+ type: "ANIME",
+ episodes: 12,
+ chapters: 0,
+ volumes: 0,
+ duration: 24,
+ format: "TV",
+ title: {
+ romaji: `Fixture Show ${id}`,
+ english: `Fixture Show ${id}`,
+ native: `Fixture Show ${id}`,
+ },
+ nextAiringEpisode: {
+ episode: 8,
+ airingAt: Math.floor(Date.now() / 1000) + 24 * 60 * 60,
+ },
+ synonyms: [],
+ mediaListEntry: {
+ progress: 6,
+ progressVolumes: 0,
+ status: "CURRENT",
+ score: 0,
+ repeat: 0,
+ startedAt: {
+ year: 2025,
+ month: 1,
+ day: 1,
+ },
+ completedAt: {
+ year: 0,
+ month: 0,
+ day: 0,
+ },
+ createdAt: 0,
+ updatedAt: 0,
+ customLists: {},
+ },
+ startDate: {
+ year: 2025,
+ month: 1,
+ },
+ endDate: {
+ year: 2025,
+ month: 12,
+ },
+ coverImage: {
+ extraLarge: "https://example.com/cover-xl.jpg",
+ medium: "https://example.com/cover-md.jpg",
+ },
+ tags: [],
+ genres: [],
+ season: season(),
+ isAdult: false,
+ relations: {
+ edges: [],
+ },
+ }) as Media;
const regressionIds = [192507, 189259, 198767];
-describe('anime episode classification', () => {
- it('prefers nativeEpisode for due/upcoming classification', () => {
- const media = baseMedia(192507);
+describe("anime episode classification", () => {
+ it("prefers nativeEpisode for due/upcoming classification", () => {
+ const media = baseMedia(192507);
- media.nextAiringEpisode = {
- episode: 7,
- nativeEpisode: 8,
- airingAt: Math.floor(Date.now() / 1000) + 6 * 60 * 60,
- nativeAiringAt: Math.floor(Date.now() / 1000) + 18 * 60 * 60
- };
+ media.nextAiringEpisode = {
+ episode: 7,
+ nativeEpisode: 8,
+ airingAt: Math.floor(Date.now() / 1000) + 6 * 60 * 60,
+ nativeAiringAt: Math.floor(Date.now() / 1000) + 18 * 60 * 60,
+ };
- const state = getAnimeEpisodeState(media);
+ const state = getAnimeEpisodeState(media);
- expect(state.airedEpisodes).toBe(7);
- expect(hasDueEpisodes(media)).toBe(true);
- });
+ expect(state.airedEpisodes).toBe(7);
+ expect(hasDueEpisodes(media)).toBe(true);
+ });
- it('treats stale native release data as aired for due detection', () => {
- const media = baseMedia(189259);
+ it("treats stale native release data as aired for due detection", () => {
+ const media = baseMedia(189259);
- media.nextAiringEpisode = {
- episode: 9,
- airingAt: Math.floor(Date.now() / 1000) + 72 * 60 * 60,
- nativeAiringAt: Math.floor(Date.now() / 1000) - 60 * 60
- };
+ media.nextAiringEpisode = {
+ episode: 9,
+ airingAt: Math.floor(Date.now() / 1000) + 72 * 60 * 60,
+ nativeAiringAt: Math.floor(Date.now() / 1000) - 60 * 60,
+ };
- const state = getAnimeEpisodeState(media);
+ const state = getAnimeEpisodeState(media);
- expect(state.airedEpisodes).toBe(9);
- expect(hasDueEpisodes(media)).toBe(true);
- });
+ expect(state.airedEpisodes).toBe(9);
+ expect(hasDueEpisodes(media)).toBe(true);
+ });
});
-describe('native countdown toggle parity', () => {
- for (const id of regressionIds) {
- it(`keeps media ${id} due with native countdown on/off`, () => {
- 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,
- airingAt: nativeAiringAt
- };
-
- settings.setKey('displayNativeCountdown', true);
-
- const nativeOnly = injectAiringTime(media, subsPlease);
-
- settings.setKey('displayNativeCountdown', false);
-
- const subtitled = injectAiringTime(media, subsPlease);
-
- expect(hasDueEpisodes(nativeOnly)).toBe(true);
- expect(hasDueEpisodes(subtitled)).toBe(true);
- });
- }
+describe("native countdown toggle parity", () => {
+ for (const id of regressionIds) {
+ it(`keeps media ${id} due with native countdown on/off`, () => {
+ 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,
+ airingAt: nativeAiringAt,
+ };
+
+ settings.setKey("displayNativeCountdown", true);
+
+ const nativeOnly = injectAiringTime(media, subsPlease);
+
+ settings.setKey("displayNativeCountdown", false);
+
+ const subtitled = injectAiringTime(media, subsPlease);
+
+ expect(hasDueEpisodes(nativeOnly)).toBe(true);
+ expect(hasDueEpisodes(subtitled)).toBe(true);
+ });
+ }
});
diff --git a/src/lib/Media/Anime/Airing/classify.ts b/src/lib/Media/Anime/Airing/classify.ts
index 9b7487d2..9585fae9 100644
--- a/src/lib/Media/Anime/Airing/classify.ts
+++ b/src/lib/Media/Anime/Airing/classify.ts
@@ -1,53 +1,64 @@
-import type { Media } from '$lib/Data/AniList/media';
+import type { Media } from "$lib/Data/AniList/media";
export interface AnimeEpisodeState {
- progress: number;
- nextEpisode: number;
- airedEpisodes: number;
+ progress: number;
+ nextEpisode: number;
+ airedEpisodes: number;
}
const hasAired = (airingAt: number | undefined, nowEpochSeconds: number) =>
- typeof airingAt === 'number' && airingAt <= nowEpochSeconds;
+ typeof airingAt === "number" && airingAt <= nowEpochSeconds;
export const getAnimeEpisodeState = (
- media: Media,
- nowEpochSeconds = Date.now() / 1000
+ media: Media,
+ nowEpochSeconds = Date.now() / 1000,
): AnimeEpisodeState => {
- const progress = media.mediaListEntry?.progress || 0;
- const nextEpisode =
- media.nextAiringEpisode?.nativeEpisode || media.nextAiringEpisode?.episode || 0;
-
- if (nextEpisode <= 0) {
- return {
- progress,
- nextEpisode,
- airedEpisodes: 0
- };
- }
-
- let airedEpisodes = Math.max(0, nextEpisode - 1);
- const airingAt = media.nextAiringEpisode?.airingAt;
- const nativeAiringAt = media.nextAiringEpisode?.nativeAiringAt;
-
- // If either source says the "next" episode already aired, treat it as released.
- if (hasAired(airingAt, nowEpochSeconds) || hasAired(nativeAiringAt, nowEpochSeconds))
- airedEpisodes = Math.max(airedEpisodes, nextEpisode);
-
- return {
- progress,
- nextEpisode,
- airedEpisodes
- };
+ const progress = media.mediaListEntry?.progress || 0;
+ const nextEpisode =
+ media.nextAiringEpisode?.nativeEpisode ||
+ media.nextAiringEpisode?.episode ||
+ 0;
+
+ if (nextEpisode <= 0) {
+ return {
+ progress,
+ nextEpisode,
+ airedEpisodes: 0,
+ };
+ }
+
+ let airedEpisodes = Math.max(0, nextEpisode - 1);
+ const airingAt = media.nextAiringEpisode?.airingAt;
+ const nativeAiringAt = media.nextAiringEpisode?.nativeAiringAt;
+
+ // If either source says the "next" episode already aired, treat it as released.
+ if (
+ hasAired(airingAt, nowEpochSeconds) ||
+ hasAired(nativeAiringAt, nowEpochSeconds)
+ )
+ airedEpisodes = Math.max(airedEpisodes, nextEpisode);
+
+ return {
+ progress,
+ nextEpisode,
+ airedEpisodes,
+ };
};
-export const hasDueEpisodes = (media: Media, nowEpochSeconds = Date.now() / 1000) => {
- const episodeState = getAnimeEpisodeState(media, nowEpochSeconds);
+export const hasDueEpisodes = (
+ media: Media,
+ nowEpochSeconds = Date.now() / 1000,
+) => {
+ const episodeState = getAnimeEpisodeState(media, nowEpochSeconds);
- return episodeState.airedEpisodes > episodeState.progress;
+ return episodeState.airedEpisodes > episodeState.progress;
};
-export const hasNoAiredEpisodes = (media: Media, nowEpochSeconds = Date.now() / 1000) => {
- const episodeState = getAnimeEpisodeState(media, nowEpochSeconds);
+export const hasNoAiredEpisodes = (
+ media: Media,
+ nowEpochSeconds = Date.now() / 1000,
+) => {
+ const episodeState = getAnimeEpisodeState(media, nowEpochSeconds);
- return episodeState.airedEpisodes <= 0;
+ return episodeState.airedEpisodes <= 0;
};
diff --git a/src/lib/Media/Anime/Airing/time.ts b/src/lib/Media/Anime/Airing/time.ts
index 76d51668..dadcd6f1 100644
--- a/src/lib/Media/Anime/Airing/time.ts
+++ b/src/lib/Media/Anime/Airing/time.ts
@@ -1,122 +1,125 @@
-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 settings from '$stores/settings';
-import { injectAiringTime } from './Subtitled/match';
-import { totalEpisodes } from '../episodes';
-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 settings from "$stores/settings";
+import { injectAiringTime } from "./Subtitled/match";
+import { totalEpisodes } from "../episodes";
+import { get } from "svelte/store";
export const airingTime = (
- originalAnime: Media,
- subsPlease: SubsPlease | null,
- upcoming = false,
- forceDays = false
+ originalAnime: Media,
+ subsPlease: SubsPlease | null,
+ upcoming = false,
+ forceDays = false,
) => {
- const anime = injectAiringTime(originalAnime, subsPlease);
- const airingAt = anime.nextAiringEpisode?.airingAt;
- const untilAiring = airingAt ? Math.round((airingAt - Date.now() / 1000) * 100) / 100 : undefined;
- const time = new Date(airingAt ? airingAt * 1000 : 0).toLocaleTimeString([], {
- hour12: !settings.get().display24HourTime,
- hour: 'numeric',
- minute: '2-digit'
- });
- let timeFrame = '';
- let hours = null;
- const shortenCountdown = get(settings).displayShortCountdown;
-
- if (
- (anime as unknown as MediaPrequel).startDate &&
- new Date(
- anime.startDate.year,
- (anime as unknown as MediaPrequel).startDate.month,
- (anime as unknown as MediaPrequel).startDate.day
- ) < new Date()
- )
- return `<span class="opaque">on ${new Date(
- anime.startDate.year,
- (anime as unknown as MediaPrequel).startDate.month,
- (anime as unknown as MediaPrequel).startDate.day
- ).toLocaleDateString()}</span>`;
-
- if (untilAiring !== undefined) {
- let minutes = untilAiring / 60;
- let few = true;
-
- if (minutes > 60) {
- hours = minutes / 60;
-
- if (hours > 24) {
- let weeks = Math.floor(hours / 24) / 7;
-
- few = false;
-
- if (weeks >= 1.5 && !forceDays) {
- weeks = Math.round(weeks);
-
- timeFrame = `${weeks}${shortenCountdown ? 'w' : ' week'}${
- weeks === 1 || shortenCountdown ? '' : 's'
- }`;
- } else {
- const days = Math.round(Math.floor(hours / 24));
- const residualHours = Math.floor(hours - days * 24);
-
- timeFrame += `${days.toFixed(0)}${shortenCountdown ? 'd' : ' day'}${
- days === 1 || shortenCountdown ? '' : 's'
- }`;
-
- if (residualHours > 0)
- timeFrame += `${shortenCountdown ? '' : ' '}${residualHours}${
- shortenCountdown ? 'h' : ' hour'
- }${residualHours === 1 || shortenCountdown ? '' : 's'}`;
- }
- } else {
- const residualMinutes = Math.round(minutes - Math.floor(hours) * 60);
-
- timeFrame += `${hours.toFixed(0)}${shortenCountdown ? 'h' : ' hour'}${
- hours === 1 || shortenCountdown ? '' : 's'
- }`;
-
- if (residualMinutes > 0)
- timeFrame += `${shortenCountdown ? '' : ' '}${residualMinutes}${
- shortenCountdown ? 'm' : ' minute'
- }${residualMinutes === 1 || shortenCountdown ? '' : 's'}`;
- }
- } else {
- minutes = Math.round(minutes);
-
- timeFrame += `${minutes}${shortenCountdown ? 'm' : ' minute'}${
- minutes === 1 || shortenCountdown ? '' : 's'
- }`;
- }
-
- const opacity = Math.max(50, 100 - (untilAiring / 60 / 60 / 24 / 7) * 50);
- const nextEpisode =
- anime.nextAiringEpisode?.nativeAiringAt &&
- !upcoming &&
- anime.nextAiringEpisode.nativeAiringAt < Date.now() / 1000 + 1 * 24 * 60 * 60
- ? anime.nextAiringEpisode.episode - 1
- : anime.nextAiringEpisode?.episode || 0;
- const dateString =
- new Date(airingAt ? airingAt * 1000 : 0).toLocaleDateString([], {
- weekday: 'long',
- year: 'numeric',
- month: 'long',
- day: 'numeric'
- }) +
- ' ' +
- time;
-
- if (upcoming)
- return `<span title="${dateString}" style="opacity: ${opacity}%;">${nextEpisode}${totalEpisodes(
- anime
- )} in ${timeFrame} <span class="opaque">${
- few && get(settings).displayCoverModeAnime ? '<br>' : ''
- }${few ? `(${time})` : ''}</span></span>`;
- else
- return `<span title="${dateString}" style="opacity: ${opacity}%;">${nextEpisode} in ${
- few && get(settings).displayCoverModeAnime ? '<br>' : ''
- }${few ? '<b>' : ''}${timeFrame}${few ? '</b>' : ''} ${few ? `(${time})` : ''}</span>`;
- }
-
- return '';
+ const anime = injectAiringTime(originalAnime, subsPlease);
+ const airingAt = anime.nextAiringEpisode?.airingAt;
+ const untilAiring = airingAt
+ ? Math.round((airingAt - Date.now() / 1000) * 100) / 100
+ : undefined;
+ const time = new Date(airingAt ? airingAt * 1000 : 0).toLocaleTimeString([], {
+ hour12: !settings.get().display24HourTime,
+ hour: "numeric",
+ minute: "2-digit",
+ });
+ let timeFrame = "";
+ let hours = null;
+ const shortenCountdown = get(settings).displayShortCountdown;
+
+ if (
+ (anime as unknown as MediaPrequel).startDate &&
+ new Date(
+ anime.startDate.year,
+ (anime as unknown as MediaPrequel).startDate.month,
+ (anime as unknown as MediaPrequel).startDate.day,
+ ) < new Date()
+ )
+ return `<span class="opaque">on ${new Date(
+ anime.startDate.year,
+ (anime as unknown as MediaPrequel).startDate.month,
+ (anime as unknown as MediaPrequel).startDate.day,
+ ).toLocaleDateString()}</span>`;
+
+ if (untilAiring !== undefined) {
+ let minutes = untilAiring / 60;
+ let few = true;
+
+ if (minutes > 60) {
+ hours = minutes / 60;
+
+ if (hours > 24) {
+ let weeks = Math.floor(hours / 24) / 7;
+
+ few = false;
+
+ if (weeks >= 1.5 && !forceDays) {
+ weeks = Math.round(weeks);
+
+ timeFrame = `${weeks}${shortenCountdown ? "w" : " week"}${
+ weeks === 1 || shortenCountdown ? "" : "s"
+ }`;
+ } else {
+ const days = Math.round(Math.floor(hours / 24));
+ const residualHours = Math.floor(hours - days * 24);
+
+ timeFrame += `${days.toFixed(0)}${shortenCountdown ? "d" : " day"}${
+ days === 1 || shortenCountdown ? "" : "s"
+ }`;
+
+ if (residualHours > 0)
+ timeFrame += `${shortenCountdown ? "" : " "}${residualHours}${
+ shortenCountdown ? "h" : " hour"
+ }${residualHours === 1 || shortenCountdown ? "" : "s"}`;
+ }
+ } else {
+ const residualMinutes = Math.round(minutes - Math.floor(hours) * 60);
+
+ timeFrame += `${hours.toFixed(0)}${shortenCountdown ? "h" : " hour"}${
+ hours === 1 || shortenCountdown ? "" : "s"
+ }`;
+
+ if (residualMinutes > 0)
+ timeFrame += `${shortenCountdown ? "" : " "}${residualMinutes}${
+ shortenCountdown ? "m" : " minute"
+ }${residualMinutes === 1 || shortenCountdown ? "" : "s"}`;
+ }
+ } else {
+ minutes = Math.round(minutes);
+
+ timeFrame += `${minutes}${shortenCountdown ? "m" : " minute"}${
+ minutes === 1 || shortenCountdown ? "" : "s"
+ }`;
+ }
+
+ const opacity = Math.max(50, 100 - (untilAiring / 60 / 60 / 24 / 7) * 50);
+ const nextEpisode =
+ anime.nextAiringEpisode?.nativeAiringAt &&
+ !upcoming &&
+ anime.nextAiringEpisode.nativeAiringAt <
+ Date.now() / 1000 + 1 * 24 * 60 * 60
+ ? anime.nextAiringEpisode.episode - 1
+ : anime.nextAiringEpisode?.episode || 0;
+ const dateString =
+ new Date(airingAt ? airingAt * 1000 : 0).toLocaleDateString([], {
+ weekday: "long",
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }) +
+ " " +
+ time;
+
+ if (upcoming)
+ return `<span title="${dateString}" style="opacity: ${opacity}%;">${nextEpisode}${totalEpisodes(
+ anime,
+ )} in ${timeFrame} <span class="opaque">${
+ few && get(settings).displayCoverModeAnime ? "<br>" : ""
+ }${few ? `(${time})` : ""}</span></span>`;
+ else
+ return `<span title="${dateString}" style="opacity: ${opacity}%;">${nextEpisode} in ${
+ few && get(settings).displayCoverModeAnime ? "<br>" : ""
+ }${few ? "<b>" : ""}${timeFrame}${few ? "</b>" : ""} ${few ? `(${time})` : ""}</span>`;
+ }
+
+ return "";
};
diff --git a/src/lib/Media/Anime/cache.ts b/src/lib/Media/Anime/cache.ts
index 9aabb2ab..e988f255 100644
--- a/src/lib/Media/Anime/cache.ts
+++ b/src/lib/Media/Anime/cache.ts
@@ -1,31 +1,44 @@
-import { get } from 'svelte/store';
-import anime from '$stores/anime';
-import { mediaListCollection, Type } from '../../Data/AniList/media';
-import lastPruneTimes from '$stores/lastPruneTimes';
-import type { AniListAuthorisation, UserIdentity } from '../../Data/AniList/identity';
+import { get } from "svelte/store";
+import anime from "$stores/anime";
+import { mediaListCollection, Type } from "../../Data/AniList/media";
+import lastPruneTimes from "$stores/lastPruneTimes";
+import type {
+ AniListAuthorisation,
+ UserIdentity,
+} from "../../Data/AniList/identity";
-export const cleanCache = (user: AniListAuthorisation, identity: UserIdentity) =>
- mediaListCollection(user, identity, Type.Anime, get(anime), get(lastPruneTimes).anime, {
- forcePrune: true
- });
+export const cleanCache = (
+ user: AniListAuthorisation,
+ identity: UserIdentity,
+) =>
+ mediaListCollection(
+ user,
+ identity,
+ Type.Anime,
+ get(anime),
+ get(lastPruneTimes).anime,
+ {
+ forcePrune: true,
+ },
+ );
export const incrementMediaProgress = (
- id: number,
- progress: number | undefined,
- user: AniListAuthorisation,
- callback: () => void
+ id: number,
+ progress: number | undefined,
+ user: AniListAuthorisation,
+ callback: () => void,
) => {
- fetch('https://graphql.anilist.co', {
- method: 'POST',
- headers: {
- Authorization: `${user.tokenType} ${user.accessToken}`,
- 'Content-Type': 'application/json',
- Accept: 'application/json'
- },
- body: JSON.stringify({
- query: `mutation { SaveMediaListEntry(mediaId: ${id}, progress: ${
- (progress || 0) + 1
- }) { id } }`
- })
- }).then(callback);
+ fetch("https://graphql.anilist.co", {
+ method: "POST",
+ headers: {
+ Authorization: `${user.tokenType} ${user.accessToken}`,
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ body: JSON.stringify({
+ query: `mutation { SaveMediaListEntry(mediaId: ${id}, progress: ${
+ (progress || 0) + 1
+ }) { id } }`,
+ }),
+ }).then(callback);
};
diff --git a/src/lib/Media/Anime/episodes.ts b/src/lib/Media/Anime/episodes.ts
index f4994f83..75281c1e 100644
--- a/src/lib/Media/Anime/episodes.ts
+++ b/src/lib/Media/Anime/episodes.ts
@@ -1,4 +1,6 @@
-import type { Media } from '$lib/Data/AniList/media';
+import type { Media } from "$lib/Data/AniList/media";
export const totalEpisodes = (anime: Media) =>
- anime.episodes === null ? '' : `<span class="opaque">/${anime.episodes}</span>`;
+ anime.episodes === null
+ ? ""
+ : `<span class="opaque">/${anime.episodes}</span>`;
diff --git a/src/lib/Media/Anime/season.ts b/src/lib/Media/Anime/season.ts
index d0cd6c25..cfd96856 100644
--- a/src/lib/Media/Anime/season.ts
+++ b/src/lib/Media/Anime/season.ts
@@ -1,11 +1,11 @@
export const season = () => {
- if (new Date().getMonth() >= 0 && new Date().getMonth() <= 2)
- return 'WINTER' as 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL';
- else if (new Date().getMonth() >= 3 && new Date().getMonth() <= 5)
- return 'SPRING' as 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL';
- else if (new Date().getMonth() >= 6 && new Date().getMonth() <= 8)
- return 'SUMMER' as 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL';
- else if (new Date().getMonth() >= 9 && new Date().getMonth() <= 11)
- return 'FALL' as 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL';
- else return 'WINTER' as 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL';
+ if (new Date().getMonth() >= 0 && new Date().getMonth() <= 2)
+ return "WINTER" as "WINTER" | "SPRING" | "SUMMER" | "FALL";
+ else if (new Date().getMonth() >= 3 && new Date().getMonth() <= 5)
+ return "SPRING" as "WINTER" | "SPRING" | "SUMMER" | "FALL";
+ else if (new Date().getMonth() >= 6 && new Date().getMonth() <= 8)
+ return "SUMMER" as "WINTER" | "SPRING" | "SUMMER" | "FALL";
+ else if (new Date().getMonth() >= 9 && new Date().getMonth() <= 11)
+ return "FALL" as "WINTER" | "SPRING" | "SUMMER" | "FALL";
+ else return "WINTER" as "WINTER" | "SPRING" | "SUMMER" | "FALL";
};
diff --git a/src/lib/Media/Cover/HoverCover.svelte b/src/lib/Media/Cover/HoverCover.svelte
index 48027e44..1909449f 100644
--- a/src/lib/Media/Cover/HoverCover.svelte
+++ b/src/lib/Media/Cover/HoverCover.svelte
@@ -1,6 +1,6 @@
<script lang="ts">
-import settings from '$stores/settings';
-import type { HoverCoverResponse } from './hoverCover';
+import settings from "$stores/settings";
+import type { HoverCoverResponse } from "./hoverCover";
export let options: HoverCoverResponse;
export let width = 250;
diff --git a/src/lib/Media/Cover/hoverCover.ts b/src/lib/Media/Cover/hoverCover.ts
index 3b364349..933f35c3 100644
--- a/src/lib/Media/Cover/hoverCover.ts
+++ b/src/lib/Media/Cover/hoverCover.ts
@@ -1,56 +1,57 @@
-import type { Media } from '$lib/Data/AniList/media';
-import { get } from 'svelte/store';
-import type { SubsPleaseEpisode } from '../Anime/Airing/Subtitled/subsPlease';
-import settings from '$stores/settings';
+import type { Media } from "$lib/Data/AniList/media";
+import { get } from "svelte/store";
+import type { SubsPleaseEpisode } from "../Anime/Airing/Subtitled/subsPlease";
+import settings from "$stores/settings";
export interface HoverCoverResponse {
- // OnMouseEnterLeave
- hovering?: boolean;
- item?: SubsPleaseEpisode | null;
- media?: Media | null;
-
- // OnMouseMove
- height?: number;
- style?: string;
+ // OnMouseEnterLeave
+ hovering?: boolean;
+ item?: SubsPleaseEpisode | null;
+ media?: Media | null;
+
+ // OnMouseMove
+ height?: number;
+ style?: string;
}
export const onMouseEnter = (
- media: Media | Partial<Media> | null,
- item: SubsPleaseEpisode | null = null
+ media: Media | Partial<Media> | null,
+ item: SubsPleaseEpisode | null = null,
) => {
- if (!get(settings).displayHoverCover && !item)
- return { hovering: false, item: null, media: null } as HoverCoverResponse;
+ if (!get(settings).displayHoverCover && !item)
+ return { hovering: false, item: null, media: null } as HoverCoverResponse;
- return { hovering: true, item, media } as HoverCoverResponse;
+ return { hovering: true, item, media } as HoverCoverResponse;
};
export const onMouseLeave = () => {
- return { hovering: false, item: null, media: null } as HoverCoverResponse;
+ return { hovering: false, item: null, media: null } as HoverCoverResponse;
};
export const onMouseMove = (event: MouseEvent, imageWidth = 250) => {
- const offset = 10;
- let imageLeft = 0;
- let imageTop = 0;
- const elements = document.getElementsByClassName('hover-image');
-
- if (elements.length === 0) return { height: 0, style: '' } as HoverCoverResponse;
-
- const response: HoverCoverResponse = {
- height: (elements[0] as HTMLImageElement).height,
- style: ''
- };
- const height = response.height || 0;
-
- imageLeft =
- event.pageX + height + offset > window.innerWidth
- ? event.pageX - imageWidth - offset
- : event.pageX + offset;
- imageTop =
- event.pageY - window.scrollY + height + offset > window.innerHeight
- ? event.pageY - window.scrollY - height - offset
- : event.pageY - window.scrollY + offset;
- response.style = `top: ${imageTop}px; left: ${imageLeft}px;`;
-
- return response;
+ const offset = 10;
+ let imageLeft = 0;
+ let imageTop = 0;
+ const elements = document.getElementsByClassName("hover-image");
+
+ if (elements.length === 0)
+ return { height: 0, style: "" } as HoverCoverResponse;
+
+ const response: HoverCoverResponse = {
+ height: (elements[0] as HTMLImageElement).height,
+ style: "",
+ };
+ const height = response.height || 0;
+
+ imageLeft =
+ event.pageX + height + offset > window.innerWidth
+ ? event.pageX - imageWidth - offset
+ : event.pageX + offset;
+ imageTop =
+ event.pageY - window.scrollY + height + offset > window.innerHeight
+ ? event.pageY - window.scrollY - height - offset
+ : event.pageY - window.scrollY + offset;
+ response.style = `top: ${imageTop}px; left: ${imageLeft}px;`;
+
+ return response;
};
diff --git a/src/lib/Media/Manga/cache.ts b/src/lib/Media/Manga/cache.ts
index 6bd248dc..68d52299 100644
--- a/src/lib/Media/Manga/cache.ts
+++ b/src/lib/Media/Manga/cache.ts
@@ -1,10 +1,10 @@
-import { database } from '../../Database/IDB/chapters';
-import manga from '$stores/manga';
+import { database } from "../../Database/IDB/chapters";
+import manga from "$stores/manga";
export const pruneAllManga = async () => {
- const all = await database.chapters.toArray();
- const ids = all.map((m) => m.id);
+ const all = await database.chapters.toArray();
+ const ids = all.map((m) => m.id);
- manga.set('');
- await database.chapters.bulkDelete(ids);
+ manga.set("");
+ await database.chapters.bulkDelete(ids);
};
diff --git a/src/lib/Media/Manga/chapters.ts b/src/lib/Media/Manga/chapters.ts
index caac1fbd..e4ad9429 100644
--- a/src/lib/Media/Manga/chapters.ts
+++ b/src/lib/Media/Manga/chapters.ts
@@ -1,207 +1,237 @@
-import { recentMediaActivities, type Media } from '$lib/Data/AniList/media';
-import { getChapterCount } from '$lib/Data/Manga/raw';
-import proxy from '$lib/Utility/proxy';
-import settings from '$stores/settings';
-import type { UserIdentity } from '../../Data/AniList/identity';
-import { database } from '../../Database/IDB/chapters';
+import { recentMediaActivities, type Media } from "$lib/Data/AniList/media";
+import { getChapterCount } from "$lib/Data/Manga/raw";
+import proxy from "$lib/Utility/proxy";
+import settings from "$stores/settings";
+import type { UserIdentity } from "../../Data/AniList/identity";
+import { database } from "../../Database/IDB/chapters";
const getManga = async (
- statusIn: string,
- year: number,
- native: string | null,
- english: string | null,
- romaji: string | null
+ statusIn: string,
+ year: number,
+ native: string | null,
+ english: string | null,
+ romaji: string | null,
) => {
- let status = '';
- let error = false;
-
- switch (statusIn) {
- case 'FINISHED':
- {
- status = 'completed';
- }
- break;
- case 'RELEASING':
- {
- status = 'ongoing';
- }
- break;
- case 'HIATUS':
- {
- status = 'hiatus';
- }
- break;
- case 'CANCELLED':
- {
- status = 'cancelled';
- }
- break;
- }
-
- const nullIfNullString = (s: string | null) => (s === 'null' ? null : s);
- const get = async (title: string) => {
- try {
- return await (
- await fetch(
- proxy(
- `https://api.mangadex.org/manga?title=${encodeURIComponent(
- title
- )}&year=${year}&status[]=${status}`
- )
- )
- ).json();
- } catch {
- error = true;
- }
- };
-
- let mangadexData = await get(
- nullIfNullString(native) || nullIfNullString(english) || nullIfNullString(romaji) || ''
- );
-
- if (error) return new Response('rate-limited');
-
- if (mangadexData['data'] === undefined || mangadexData['data'].length === 0) {
- mangadexData = await get(nullIfNullString(english) || '');
-
- if (mangadexData['data'] === undefined || mangadexData['data'].length === 0) {
- mangadexData = await get(nullIfNullString(romaji) || '');
- }
- }
-
- return Response.json(mangadexData, {
- headers: {
- 'Cache-Control': 'max-age=300'
- }
- });
+ let status = "";
+ let error = false;
+
+ switch (statusIn) {
+ case "FINISHED":
+ {
+ status = "completed";
+ }
+ break;
+ case "RELEASING":
+ {
+ status = "ongoing";
+ }
+ break;
+ case "HIATUS":
+ {
+ status = "hiatus";
+ }
+ break;
+ case "CANCELLED":
+ {
+ status = "cancelled";
+ }
+ break;
+ }
+
+ const nullIfNullString = (s: string | null) => (s === "null" ? null : s);
+ const get = async (title: string) => {
+ try {
+ return await (
+ await fetch(
+ proxy(
+ `https://api.mangadex.org/manga?title=${encodeURIComponent(
+ title,
+ )}&year=${year}&status[]=${status}`,
+ ),
+ )
+ ).json();
+ } catch {
+ error = true;
+ }
+ };
+
+ let mangadexData = await get(
+ nullIfNullString(native) ||
+ nullIfNullString(english) ||
+ nullIfNullString(romaji) ||
+ "",
+ );
+
+ if (error) return new Response("rate-limited");
+
+ if (mangadexData["data"] === undefined || mangadexData["data"].length === 0) {
+ mangadexData = await get(nullIfNullString(english) || "");
+
+ if (
+ mangadexData["data"] === undefined ||
+ mangadexData["data"].length === 0
+ ) {
+ mangadexData = await get(nullIfNullString(romaji) || "");
+ }
+ }
+
+ return Response.json(mangadexData, {
+ headers: {
+ "Cache-Control": "max-age=300",
+ },
+ });
};
export const chapterCount = async (
- identity: UserIdentity,
- manga: Media,
- disableGuessing: boolean
- // preferActivity = false
+ identity: UserIdentity,
+ manga: Media,
+ disableGuessing: boolean,
+ // preferActivity = false
): Promise<number | null> => {
- const chapters = await database.chapters.get(manga.id);
-
- if (chapters !== undefined) return chapters.chapters === -1 ? null : chapters.chapters;
-
- // if (preferActivity) {
- // return await recentMediaActivities(identity, manga);
- // }
-
- const tryRecentMediaActivities = async () => {
- if (disableGuessing) {
- await database.chapters.put({
- id: manga.id,
- chapters: -1,
- volumes: null
- });
-
- return null;
- }
-
- const anilistData = await recentMediaActivities(
- identity,
- manga,
- settings.get().calculateGuessMethod
- );
-
- await database.chapters.put({
- id: manga.id,
- chapters: anilistData ? anilistData : -1,
- volumes: null
- });
-
- return anilistData;
- };
-
- if (manga.format === 'NOVEL') return await tryRecentMediaActivities();
-
- let lastChapter = 0;
- let completedVolumes = null;
-
- if (!settings.get().calculatePreferNativeChapterCount) {
- const mangadexData = await getManga(
- manga.status,
- manga.startDate.year,
- manga.title.native,
- manga.title.english,
- manga.title.romaji
- );
-
- if ((await mangadexData.clone().text()) === 'rate-limited') return -22;
-
- const mangadexDataJson = await mangadexData.json();
-
- if (mangadexDataJson['data'] === undefined || mangadexDataJson['data'].length === 0)
- return await tryRecentMediaActivities();
-
- const mangadexId = mangadexDataJson['data'][0]['id'];
- const lastChapterDataJson = await (
- await fetch(
- proxy(
- `https://api.mangadex.org/manga/${mangadexId}/feed?order[chapter]=desc&translatedLanguage[]=en&limit=1&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic`
- )
- )
- ).json();
-
- if (lastChapterDataJson['data'] === undefined || lastChapterDataJson['data'].length === 0)
- return await tryRecentMediaActivities();
-
- lastChapter = lastChapterDataJson['data'][0]['attributes']['chapter'];
- completedVolumes = null;
-
- if ((manga.mediaListEntry || { progress: 0 }).progress > lastChapter && !disableGuessing) {
- const anilistData = await recentMediaActivities(
- identity,
- manga,
- settings.get().calculateGuessMethod
- );
-
- if (anilistData !== null && anilistData > lastChapter) lastChapter = anilistData;
- }
-
- if (!settings.get().calculateDisableOutOfDateVolumeWarning) {
- const volumeOfChapterData = await (
- await fetch(
- proxy(
- `https://api.mangadex.org/chapter?manga=${mangadexId}&chapter=${manga.mediaListEntry?.progress}&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic&limit=1`
- )
- )
- ).json();
- let lastAvailableVolume = lastChapterDataJson['data'][0]['attributes']['volume'];
-
- if (lastAvailableVolume === null) {
- let chapterIndex = 0;
-
- while (chapterIndex < lastChapterDataJson['data'].length && lastAvailableVolume === null) {
- if (lastChapterDataJson['data'][chapterIndex]['attributes']['volume'] !== null)
- lastAvailableVolume = lastChapterDataJson['data'][chapterIndex]['attributes']['volume'];
-
- chapterIndex += 1;
- }
- }
-
- if (volumeOfChapterData['data'] !== undefined && volumeOfChapterData['data'].length > 0) {
- const volumeOfChapter = volumeOfChapterData['data'][0]['attributes']['volume'];
-
- if (volumeOfChapter !== null) completedVolumes = volumeOfChapter;
-
- if (completedVolumes === volumeOfChapter) completedVolumes -= 1;
- }
- }
- } else {
- lastChapter = (await getChapterCount(manga.title.native)) || 0;
- }
-
- if (lastChapter === 0) lastChapter = -1;
-
- await database.chapters.put({
- id: manga.id,
- chapters: Number(lastChapter),
- volumes: completedVolumes
- });
-
- return Number(lastChapter);
+ const chapters = await database.chapters.get(manga.id);
+
+ if (chapters !== undefined)
+ return chapters.chapters === -1 ? null : chapters.chapters;
+
+ // if (preferActivity) {
+ // return await recentMediaActivities(identity, manga);
+ // }
+
+ const tryRecentMediaActivities = async () => {
+ if (disableGuessing) {
+ await database.chapters.put({
+ id: manga.id,
+ chapters: -1,
+ volumes: null,
+ });
+
+ return null;
+ }
+
+ const anilistData = await recentMediaActivities(
+ identity,
+ manga,
+ settings.get().calculateGuessMethod,
+ );
+
+ await database.chapters.put({
+ id: manga.id,
+ chapters: anilistData ? anilistData : -1,
+ volumes: null,
+ });
+
+ return anilistData;
+ };
+
+ if (manga.format === "NOVEL") return await tryRecentMediaActivities();
+
+ let lastChapter = 0;
+ let completedVolumes = null;
+
+ if (!settings.get().calculatePreferNativeChapterCount) {
+ const mangadexData = await getManga(
+ manga.status,
+ manga.startDate.year,
+ manga.title.native,
+ manga.title.english,
+ manga.title.romaji,
+ );
+
+ if ((await mangadexData.clone().text()) === "rate-limited") return -22;
+
+ const mangadexDataJson = await mangadexData.json();
+
+ if (
+ mangadexDataJson["data"] === undefined ||
+ mangadexDataJson["data"].length === 0
+ )
+ return await tryRecentMediaActivities();
+
+ const mangadexId = mangadexDataJson["data"][0]["id"];
+ const lastChapterDataJson = await (
+ await fetch(
+ proxy(
+ `https://api.mangadex.org/manga/${mangadexId}/feed?order[chapter]=desc&translatedLanguage[]=en&limit=1&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic`,
+ ),
+ )
+ ).json();
+
+ if (
+ lastChapterDataJson["data"] === undefined ||
+ lastChapterDataJson["data"].length === 0
+ )
+ return await tryRecentMediaActivities();
+
+ lastChapter = lastChapterDataJson["data"][0]["attributes"]["chapter"];
+ completedVolumes = null;
+
+ if (
+ (manga.mediaListEntry || { progress: 0 }).progress > lastChapter &&
+ !disableGuessing
+ ) {
+ const anilistData = await recentMediaActivities(
+ identity,
+ manga,
+ settings.get().calculateGuessMethod,
+ );
+
+ if (anilistData !== null && anilistData > lastChapter)
+ lastChapter = anilistData;
+ }
+
+ if (!settings.get().calculateDisableOutOfDateVolumeWarning) {
+ const volumeOfChapterData = await (
+ await fetch(
+ proxy(
+ `https://api.mangadex.org/chapter?manga=${mangadexId}&chapter=${manga.mediaListEntry?.progress}&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic&limit=1`,
+ ),
+ )
+ ).json();
+ let lastAvailableVolume =
+ lastChapterDataJson["data"][0]["attributes"]["volume"];
+
+ if (lastAvailableVolume === null) {
+ let chapterIndex = 0;
+
+ while (
+ chapterIndex < lastChapterDataJson["data"].length &&
+ lastAvailableVolume === null
+ ) {
+ if (
+ lastChapterDataJson["data"][chapterIndex]["attributes"][
+ "volume"
+ ] !== null
+ )
+ lastAvailableVolume =
+ lastChapterDataJson["data"][chapterIndex]["attributes"]["volume"];
+
+ chapterIndex += 1;
+ }
+ }
+
+ if (
+ volumeOfChapterData["data"] !== undefined &&
+ volumeOfChapterData["data"].length > 0
+ ) {
+ const volumeOfChapter =
+ volumeOfChapterData["data"][0]["attributes"]["volume"];
+
+ if (volumeOfChapter !== null) completedVolumes = volumeOfChapter;
+
+ if (completedVolumes === volumeOfChapter) completedVolumes -= 1;
+ }
+ }
+ } else {
+ lastChapter = (await getChapterCount(manga.title.native)) || 0;
+ }
+
+ if (lastChapter === 0) lastChapter = -1;
+
+ await database.chapters.put({
+ id: manga.id,
+ chapters: Number(lastChapter),
+ volumes: completedVolumes,
+ });
+
+ return Number(lastChapter);
};
diff --git a/src/lib/Media/Manga/volumes.ts b/src/lib/Media/Manga/volumes.ts
index 05ae571d..73871acf 100644
--- a/src/lib/Media/Manga/volumes.ts
+++ b/src/lib/Media/Manga/volumes.ts
@@ -1,5 +1,5 @@
-import type { Media } from '$lib/Data/AniList/media';
-import { database } from '../../Database/IDB/chapters';
+import type { Media } from "$lib/Data/AniList/media";
+import { database } from "../../Database/IDB/chapters";
export const volumeCount = async (manga: Media): Promise<number | null> =>
- (await database.chapters.get(manga.id))?.volumes as number | null;
+ (await database.chapters.get(manga.id))?.volumes as number | null;
diff --git a/src/lib/Media/links.ts b/src/lib/Media/links.ts
index 209afb28..bc9dde5b 100644
--- a/src/lib/Media/links.ts
+++ b/src/lib/Media/links.ts
@@ -1,51 +1,57 @@
-import type { Media } from '$lib/Data/AniList/media';
-import type { PrequelRelationNode } from '$lib/Data/AniList/prequels';
+import type { Media } from "$lib/Data/AniList/media";
+import type { PrequelRelationNode } from "$lib/Data/AniList/prequels";
export const outboundLink = (
- media: Media | PrequelRelationNode | null,
- type: 'anime' | 'manga',
- setting: 'anilist' | 'livechartme' | 'animeschedule' | 'myanimelist',
- search = false,
- title: string | null = null
+ media: Media | PrequelRelationNode | null,
+ type: "anime" | "manga",
+ setting: "anilist" | "livechartme" | "animeschedule" | "myanimelist",
+ search = false,
+ title: string | null = null,
) => {
- media = media as Media;
+ media = media as Media;
- if (type === 'manga')
- switch (setting) {
- case 'livechartme':
- case 'animeschedule':
- return `https://anilist.co/${type}/${media.id}/`;
- case 'myanimelist':
- return media.idMal
- ? `https://myanimelist.net/manga/${media.idMal}`
- : `https://myanimelist.net/manga.php?q=${encodeURIComponent(
- media.title.native || media.title.english || media.title.romaji
- )}&cat=manga`;
- default:
- return `https://anilist.co/${type}/${media.id}/`;
- }
- else
- switch (setting) {
- case 'anilist':
- return search
- ? `https://anilist.co/search?search=${encodeURIComponent(title || '')}`
- : `https://anilist.co/${type}/${media.id}/`;
- case 'livechartme':
- return `https://www.livechart.me/search?q=${encodeURIComponent(
- title || media.title.native || media.title.english || media.title.romaji
- )}`;
- case 'animeschedule':
- return `https://animeschedule.net/shows?q=${encodeURIComponent(
- title || media.title.native || media.title.english || media.title.romaji
- )}`;
- case 'myanimelist': {
- return search
- ? `https://myanimelist.net/anime.php?q=${title}&cat=anime`
- : media.idMal
- ? `https://myanimelist.net/anime/${media.idMal}`
- : `https://myanimelist.net/anime.php?q=${encodeURIComponent(
- media.title.native || media.title.english || media.title.romaji
- )}&cat=anime`;
- }
- }
+ if (type === "manga")
+ switch (setting) {
+ case "livechartme":
+ case "animeschedule":
+ return `https://anilist.co/${type}/${media.id}/`;
+ case "myanimelist":
+ return media.idMal
+ ? `https://myanimelist.net/manga/${media.idMal}`
+ : `https://myanimelist.net/manga.php?q=${encodeURIComponent(
+ media.title.native || media.title.english || media.title.romaji,
+ )}&cat=manga`;
+ default:
+ return `https://anilist.co/${type}/${media.id}/`;
+ }
+ else
+ switch (setting) {
+ case "anilist":
+ return search
+ ? `https://anilist.co/search?search=${encodeURIComponent(title || "")}`
+ : `https://anilist.co/${type}/${media.id}/`;
+ case "livechartme":
+ return `https://www.livechart.me/search?q=${encodeURIComponent(
+ title ||
+ media.title.native ||
+ media.title.english ||
+ media.title.romaji,
+ )}`;
+ case "animeschedule":
+ return `https://animeschedule.net/shows?q=${encodeURIComponent(
+ title ||
+ media.title.native ||
+ media.title.english ||
+ media.title.romaji,
+ )}`;
+ case "myanimelist": {
+ return search
+ ? `https://myanimelist.net/anime.php?q=${title}&cat=anime`
+ : media.idMal
+ ? `https://myanimelist.net/anime/${media.idMal}`
+ : `https://myanimelist.net/anime.php?q=${encodeURIComponent(
+ media.title.native || media.title.english || media.title.romaji,
+ )}&cat=anime`;
+ }
+ }
};