diff options
| author | Fuwn <[email protected]> | 2023-12-16 18:50:51 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2023-12-16 18:50:51 -0800 |
| commit | b018bd62213b1a72114ecf47c6676204e33bd429 (patch) | |
| tree | 8b8ece187917e99fc5591fe2f5fa271861e8b260 | |
| parent | feat(api): subsplease endpoint (diff) | |
| download | due.moe-b018bd62213b1a72114ecf47c6676204e33bd429.tar.xz due.moe-b018bd62213b1a72114ecf47c6676204e33bd429.zip | |
feat(anime): subtitle countdown
| -rwxr-xr-x | bun.lockb | bin | 124086 -> 125230 bytes | |||
| -rw-r--r-- | package.json | 90 | ||||
| -rw-r--r-- | src/lib/AniList/media.ts | 3 | ||||
| -rw-r--r-- | src/lib/List/Anime/CleanAnimeList.svelte | 67 | ||||
| -rw-r--r-- | src/lib/Media/anime.ts | 88 | ||||
| -rw-r--r-- | src/lib/Tools/SequelSpy.svelte | 10 | ||||
| -rw-r--r-- | src/lib/subsPlease.ts | 11 | ||||
| -rw-r--r-- | src/routes/api/subsplease/+server.ts | 7 | ||||
| -rw-r--r-- | src/routes/settings/+page.svelte | 6 | ||||
| -rw-r--r-- | src/stores/settings.ts | 2 |
10 files changed, 202 insertions, 82 deletions
| Binary files differ diff --git a/package.json b/package.json index ce3c6f54..1939447d 100644 --- a/package.json +++ b/package.json @@ -1,46 +1,48 @@ { - "name": "due.moe", - "version": "0.0.0", - "private": true, - "scripts": { - "dev": "vite dev", - "build": "vite build", - "preview": "vite preview", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "lint": "prettier --plugin-search-dir . --check . && eslint .", - "format": "prettier --plugin-search-dir . --write ." - }, - "devDependencies": { - "@sveltejs/adapter-auto": "^2.0.0", - "@sveltejs/kit": "^1.20.4", - "@types/dom-to-image": "^2.6.5", - "@types/file-saver": "^2.0.5", - "@types/jsdom": "^21.1.6", - "@typescript-eslint/eslint-plugin": "^5.45.0", - "@typescript-eslint/parser": "^5.45.0", - "eslint": "^8.28.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-svelte": "^2.30.0", - "prettier": "^2.8.0", - "prettier-plugin-svelte": "^2.10.1", - "svelte": "^4.0.5", - "svelte-adapter-bun": "^0.5.0", - "svelte-check": "^3.4.3", - "tslib": "^2.4.1", - "typescript": "^5.0.0", - "vite": "^4.4.2" - }, - "type": "module", - "dependencies": { - "bun-types": "^1.0.15", - "dexie": "^4.0.1-alpha.25", - "html2canvas": "^1.4.1", - "jsdom": "^23.0.1", - "modern-screenshot": "^4.4.33", - "rss-parser": "^3.13.0", - "socket.io": "^4.7.2", - "socket.io-client": "^4.7.2", - "wanakana": "^5.3.1" - } + "name": "due.moe", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --plugin-search-dir . --check . && eslint .", + "format": "prettier --plugin-search-dir . --write ." + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^2.0.0", + "@sveltejs/kit": "^1.20.4", + "@types/dom-to-image": "^2.6.5", + "@types/fast-levenshtein": "^0.0.4", + "@types/file-saver": "^2.0.5", + "@types/jsdom": "^21.1.6", + "@typescript-eslint/eslint-plugin": "^5.45.0", + "@typescript-eslint/parser": "^5.45.0", + "eslint": "^8.28.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-svelte": "^2.30.0", + "prettier": "^2.8.0", + "prettier-plugin-svelte": "^2.10.1", + "svelte": "^4.0.5", + "svelte-adapter-bun": "^0.5.0", + "svelte-check": "^3.4.3", + "tslib": "^2.4.1", + "typescript": "^5.0.0", + "vite": "^4.4.2" + }, + "type": "module", + "dependencies": { + "bun-types": "^1.0.15", + "dexie": "^4.0.1-alpha.25", + "fast-levenshtein": "^3.0.0", + "html2canvas": "^1.4.1", + "jsdom": "^23.0.1", + "modern-screenshot": "^4.4.33", + "rss-parser": "^3.13.0", + "socket.io": "^4.7.2", + "socket.io-client": "^4.7.2", + "wanakana": "^5.3.1" + } } diff --git a/src/lib/AniList/media.ts b/src/lib/AniList/media.ts index ff43732e..3338dd9a 100644 --- a/src/lib/AniList/media.ts +++ b/src/lib/AniList/media.ts @@ -41,6 +41,7 @@ export interface Media { episode: number; airingAt?: number; }; + synonyms: string[]; mediaListEntry?: { progress: number; progressVolumes: number; @@ -105,7 +106,7 @@ const collectionQueryTemplate = (type: Type, userId: number, includeCompleted: b lists { name entries { media { - id idMal status type episodes chapters format duration + id idMal status type episodes chapters format duration synonyms title { romaji english native } nextAiringEpisode { episode airingAt } mediaListEntry { diff --git a/src/lib/List/Anime/CleanAnimeList.svelte b/src/lib/List/Anime/CleanAnimeList.svelte index 0b130d15..fd31963c 100644 --- a/src/lib/List/Anime/CleanAnimeList.svelte +++ b/src/lib/List/Anime/CleanAnimeList.svelte @@ -8,7 +8,8 @@ import ListTitle from '../ListTitle.svelte'; import MediaTitle from '../MediaTitleDisplay.svelte'; import { outboundLink } from '$lib/Media/media'; - import { onDestroy } from 'svelte'; + import { onDestroy, onMount } from 'svelte'; + import type { SubsPlease } from '$lib/subsPlease'; export let media: Media[]; export let title: string; @@ -73,42 +74,46 @@ {#if title !== 'Upcoming Episodes' || !$settings.displayCountdownRightAligned} <span style="opacity: 50%;">|</span> {/if} - {#if title !== 'Upcoming Episodes'} - <!-- {anime.mediaListEntry?.progress || 0}{@html totalEpisodes(anime)} --> - {pendingUpdate === anime.id ? progress + 1 : progress}{@html totalEpisodes(anime)} - <a - href={'#'} - style={pendingUpdate === anime.id ? 'pointer-events: none; opacity: 50%;' : ''} - on:click={() => { - if (pendingUpdate !== anime.id) { - lastUpdatedMedia = anime.id; - pendingUpdate = anime.id; + {#await fetch(`/api/subsplease?tz=${Intl.DateTimeFormat().resolvedOptions().timeZone}`).then( (r) => r.json() )} + Loading ... + {:then subsPlease} + {#if title !== 'Upcoming Episodes'} + <!-- {anime.mediaListEntry?.progress || 0}{@html totalEpisodes(anime)} --> + {pendingUpdate === anime.id ? progress + 1 : progress}{@html totalEpisodes(anime)} + <a + href={'#'} + style={pendingUpdate === anime.id ? 'pointer-events: none; opacity: 50%;' : ''} + on:click={() => { + if (pendingUpdate !== anime.id) { + lastUpdatedMedia = anime.id; + pendingUpdate = anime.id; - updateMedia(anime.id, anime.mediaListEntry?.progress, () => { - const mediaListEntry = media.find((m) => m.id === anime.id)?.mediaListEntry; + updateMedia(anime.id, anime.mediaListEntry?.progress, () => { + const mediaListEntry = media.find((m) => m.id === anime.id)?.mediaListEntry; - if (mediaListEntry) mediaListEntry.progress = progress + 1; + if (mediaListEntry) mediaListEntry.progress = progress + 1; - previousAnimeList = media; - animeLists = cleanCache(user, identity); - pendingUpdate = null; - }); - } - }}>+</a - > - {#if !completed} - [{anime.nextAiringEpisode?.episode === -1 - ? '?' - : (anime.nextAiringEpisode?.episode || 1) - 1}] + previousAnimeList = media; + animeLists = cleanCache(user, identity); + pendingUpdate = null; + }); + } + }}>+</a + > + {#if !completed} + [{anime.nextAiringEpisode?.episode === -1 + ? '?' + : (anime.nextAiringEpisode?.episode || 1) - 1}] + <span class:countdown={$settings.displayCountdownRightAligned}> + {@html airingTime(anime, subsPlease)} + </span> + {/if} + {:else} <span class:countdown={$settings.displayCountdownRightAligned}> - {@html airingTime(anime)} + {@html airingTime(anime, subsPlease, true)} </span> {/if} - {:else} - <span class:countdown={$settings.displayCountdownRightAligned}> - {@html airingTime(anime, true)} - </span> - {/if} + {/await} </span> </li> {/if} diff --git a/src/lib/Media/anime.ts b/src/lib/Media/anime.ts index 927da31b..b9227562 100644 --- a/src/lib/Media/anime.ts +++ b/src/lib/Media/anime.ts @@ -5,6 +5,14 @@ import lastPruneTimes from '../../stores/lastPruneTimes'; import type { AniListAuthorisation, UserIdentity } from '../AniList/identity'; import settings from '../../stores/settings'; import type { MediaPrequel } from '$lib/AniList/prequels'; +import type { SubsPlease } from '$lib/subsPlease'; +import levenshtein from 'fast-levenshtein'; + +interface Time { + title: string; + time: string; + day: string; +} export const cleanCache = (user: AniListAuthorisation, identity: UserIdentity) => mediaListCollection(user, identity, Type.Anime, get(anime), get(lastPruneTimes).anime, true); @@ -16,9 +24,85 @@ export const updateMedia = (id: number, progress: number | undefined, callback: export const totalEpisodes = (anime: Media) => anime.episodes === null ? '' : `<span style="opacity: 50%">/${anime.episodes}</span>`; -export const airingTime = (anime: Media, upcoming = false) => { +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 normalizeTitle = (title: string) => + (title || '') + .toLowerCase() + .replace(/season \d+|s\d+/g, '') + .trim(); + +const findClosestMatch = (times: Time[], titles: string[]) => { + let closestMatch = null; + let smallestDistance = Infinity; + + titles.forEach((animeTitle) => { + const normalizedAnimeTitle = normalizeTitle(animeTitle); + + times.forEach((item) => { + const normalizedItemTitle = normalizeTitle(item.title); + const distance = levenshtein.get(normalizedAnimeTitle, normalizedItemTitle); + + if (distance < smallestDistance) { + smallestDistance = distance; + closestMatch = item; + } + }); + }); + + return closestMatch; +}; + +export const airingTime = (anime: Media, subsPlease: SubsPlease, upcoming = false) => { const airingAt = anime.nextAiringEpisode?.airingAt; - const untilAiring = airingAt ? Math.round((airingAt - Date.now() / 1000) * 100) / 100 : undefined; + let untilAiring; + + if (get(settings).displayNativeCountdown) { + untilAiring = airingAt ? Math.round((airingAt - Date.now() / 1000) * 100) / 100 : undefined; + } else { + const times: Time[] = []; + + for (const [key, value] of Object.entries(subsPlease.schedule)) { + const flattenedValue = Array.isArray(value) ? value.flat() : []; + + for (const time of flattenedValue) { + times.push({ + title: time.title, + time: time.time, + day: key + }); + } + } + + const time: Time | null = findClosestMatch(times, [ + anime.title.romaji, + anime.title.english, + ...anime.synonyms + ]); + + if (time) untilAiring = secondsUntil((time as Time).time, (time as Time).day); + else + untilAiring = airingAt ? Math.round((airingAt - Date.now() / 1000) * 100) / 100 : undefined; + } + let timeFrame; const time = new Date(airingAt ? airingAt * 1000 : 0).toLocaleTimeString([], { hour12: !settings.get().display24HourTime, diff --git a/src/lib/Tools/SequelSpy.svelte b/src/lib/Tools/SequelSpy.svelte index 56f40956..e8cc98bf 100644 --- a/src/lib/Tools/SequelSpy.svelte +++ b/src/lib/Tools/SequelSpy.svelte @@ -8,6 +8,7 @@ import type { Media } from '$lib/AniList/media'; import { page } from '$app/stores'; import { browser } from '$app/environment'; + import type { SubsPlease } from '$lib/subsPlease'; export let user: AniListAuthorisation; @@ -42,7 +43,8 @@ onMount(() => clearAllParameters(['year', 'season'])); - const prequelAiringTime = (prequel: MediaPrequel) => airingTime(prequel as unknown as Media); + const prequelAiringTime = (prequel: MediaPrequel, subsPlease: SubsPlease) => + airingTime(prequel as unknown as Media, subsPlease); </script> <p> @@ -66,7 +68,11 @@ </a> <span style="opacity: 50%;">|</span> {prequel.seen}<span style="opacity: 50%;">/{prequel.episodes}</span> - {@html prequelAiringTime(prequel)} + {#await fetch(`/api/subsplease?tz=${Intl.DateTimeFormat().resolvedOptions().timeZone}`).then( (r) => r.json() )} + Loading ... + {:then subsPlease} + {@html prequelAiringTime(prequel, subsPlease)} + {/await} </li> {/each} </ul> diff --git a/src/lib/subsPlease.ts b/src/lib/subsPlease.ts new file mode 100644 index 00000000..b2bcb30e --- /dev/null +++ b/src/lib/subsPlease.ts @@ -0,0 +1,11 @@ +export interface SubsPlease { + tz: string; + schedule: { + [key in string]: { + title: string; + page: string; + image_url: string; + time: string; + }[]; + }[]; +} diff --git a/src/routes/api/subsplease/+server.ts b/src/routes/api/subsplease/+server.ts index d3e6bf99..91d0e665 100644 --- a/src/routes/api/subsplease/+server.ts +++ b/src/routes/api/subsplease/+server.ts @@ -6,5 +6,10 @@ export const GET = async ({ url }) => url.searchParams.get('tz') || 'America/Los_Angeles' }` ) - ).json() + ).json(), + { + headers: { + 'Cache-Control': 'max-age=86400' + } + } ); diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index e47af157..c4af4cf5 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -114,7 +114,11 @@ setting="displayCountdownRightAligned" text="Display countdown right aligned" /> - <SettingCheckboxToggle setting="display24HourTime" text="Display time in 24-hour format" /> + <SettingCheckboxToggle setting="display24HourTime" text="Display countdown in 24-hour format" /> + <SettingCheckboxToggle + setting="displayNativeCountdown" + text="Display countdown as native release" + /> <SettingCheckboxToggle setting="displayNativeTitles" text="Display titles in native language" diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 57d94c6b..b06fe58b 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -27,6 +27,7 @@ export interface Settings { disableUpcomingAnime: boolean; display24HourTime: boolean; displayCountdownRightAligned: boolean; + displayNativeCountdown: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: any; } @@ -52,6 +53,7 @@ const defaultSettings: Settings = { disableUpcomingAnime: false, display24HourTime: false, displayCountdownRightAligned: false, + displayNativeCountdown: false, // Calculation roundDownChapters: true, |