-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(AniTilky): update activity for anitilky.com #11001
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
hyqanxd
wants to merge
24
commits into
PreMiD:main
Choose a base branch
from
hyqanxd:update/anitilky-v2
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 7 commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
76d2e8f
feat(AniTilky): modern rewrite for anitilky.com
hyqanxd 0a5e50e
fix(AniTilky): pass pmd validation and update assets
hyqanxd d5b7b0b
fix(AniTilky): resolve covers blocked by BunnyCDN
hyqanxd f5904c3
fix(AniTilky): fetch cover blobs with referer
hyqanxd ae8c3e1
fix(AniTilky): blob covers only, drop broken small icons
hyqanxd 9d297bf
fix(AniTilky): unstick watch presence and restore covers/buttons
hyqanxd 115bfcd
fix(AniTilky): blob covers via fetch and reliable pause detection
hyqanxd df10eb9
fix(AniTilky): keep animated GIF profile avatars
hyqanxd 1d33446
revert(AniTilky): drop broken blob pipeline, restore stable covers
hyqanxd bdd77bc
fix(AniTilky): fetch BunnyCDN images as blobs including GIFs
hyqanxd ff45ea6
fix(AniTilky): convert large GIFs to JPEG with reliable logo fallback
hyqanxd 52e0975
fix(AniTilky): resolve CI lint and set version to 2.0.0
hyqanxd 8fa4e60
feat(AniTilky): switch to new brand logo
hyqanxd d7de6a6
feat(AniTilky): use imgur logo for CDN asset migration
hyqanxd 8c0a327
feat(AniTilky): update logo asset
hyqanxd f98b794
Merge branch 'main' into update/anitilky-v2
hyqanxd f0cb030
Merge branch 'main' into update/anitilky-v2
hyqanxd b64f313
Update websites/A/AniTilky/metadata.json
hyqanxd 94bac12
Merge branch 'main' into update/anitilky-v2
hyqanxd 3281f13
feat(AniTilky): use landscape thumbnail
hyqanxd c5ceb32
Merge branch 'main' into update/anitilky-v2
hyqanxd a1fe1b5
Merge branch 'main' into update/anitilky-v2
hyqanxd b610889
Merge branch 'main' into update/anitilky-v2
hyqanxd 51ba7d6
Merge branch 'main' into update/anitilky-v2
hyqanxd File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import type { Anime } from '../types.js' | ||
| import { API_URL, fetchCached } from './helpers.js' | ||
|
|
||
| export async function fetchAnime(id: string): Promise<Anime | null> { | ||
| return fetchCached(`anime:${id}`, async () => { | ||
| try { | ||
| const response = await fetch(`${API_URL}/anime/${encodeURIComponent(id)}`) | ||
| if (!response.ok) | ||
| return null | ||
| return await response.json() as Anime | ||
| } | ||
| catch { | ||
| return null | ||
| } | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import type { UserProfile } from '../types.js' | ||
| import { API_URL, fetchCached } from './helpers.js' | ||
|
|
||
| export async function fetchUser(username: string): Promise<UserProfile | null> { | ||
| return fetchCached(`user:${username.toLowerCase()}`, async () => { | ||
| try { | ||
| const response = await fetch(`${API_URL}/user/profile/${encodeURIComponent(username)}`) | ||
| if (!response.ok) | ||
| return null | ||
| return await response.json() as UserProfile | ||
| } | ||
| catch { | ||
| return null | ||
| } | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,251 @@ | ||
| import type { Anime, AnimeEpisode, AnimeSeason } from '../types.js' | ||
|
|
||
| export const BASE_URL = 'https://anitilky.com' | ||
| export const API_URL = 'https://api.anitilky.com/api' | ||
|
|
||
| export enum ActivityAssets { | ||
| Logo = 'https://cdn.rcd.gg/PreMiD/websites/A/AniTilky/assets/logo.png', | ||
| } | ||
|
|
||
| const dataCache = new Map<string, { data: unknown, expires: number }>() | ||
| const blobCache = new Map<string, Blob>() | ||
| const pendingBlob = new Map<string, Promise<Blob | undefined>>() | ||
| const CACHE_TTL = 60_000 | ||
| const FETCH_TIMEOUT_MS = 4000 | ||
|
|
||
| export async function fetchCached<T>(key: string, fetcher: () => Promise<T | null>): Promise<T | null> { | ||
| const cached = dataCache.get(key) | ||
| if (cached && cached.expires > Date.now()) | ||
| return cached.data as T | ||
|
|
||
| const data = await fetcher() | ||
| if (data) | ||
| dataCache.set(key, { data, expires: Date.now() + CACHE_TTL }) | ||
|
|
||
| return data | ||
| } | ||
|
|
||
| export function getAnimeTitle(anime?: Anime | null): string | undefined { | ||
| if (!anime?.title) | ||
| return undefined | ||
| return anime.title.romaji || anime.title.english || anime.title.native | ||
| } | ||
|
|
||
| export function findSeason(anime: Anime | null | undefined, seasonNumber: number): AnimeSeason | undefined { | ||
| return anime?.seasons?.find(s => Number(s.seasonNumber) === Number(seasonNumber)) | ||
| } | ||
|
|
||
| export function findEpisode(season: AnimeSeason | undefined, episodeNumber: number): AnimeEpisode | undefined { | ||
| return season?.episodes?.find(e => Number(e.episodeNumber) === Number(episodeNumber)) | ||
| } | ||
|
|
||
| export function getOgImage(): string | undefined { | ||
| return document.querySelector<HTMLMetaElement>('meta[property="og:image"]')?.content | ||
| || document.querySelector<HTMLMetaElement>('meta[name="twitter:image"]')?.content | ||
| } | ||
|
|
||
| export function getPageTitle(): string { | ||
| return document.title | ||
| .replace(/\s*\|\s*ANITILKY.*$/i, '') | ||
| .replace(/\s*Türkçe.*$/i, '') | ||
| .trim() | ||
| } | ||
|
|
||
| export function getStoredUsername(): string | undefined { | ||
| try { | ||
| const raw = localStorage.getItem('userData') || sessionStorage.getItem('userData') | ||
| if (!raw) | ||
| return undefined | ||
| return (JSON.parse(raw) as { username?: string }).username | ||
| } | ||
| catch { | ||
| return undefined | ||
| } | ||
| } | ||
|
|
||
| export function getProfileUsernameFromDom(): string | undefined { | ||
| return document.querySelector<HTMLElement>('main h4, [class*="MuiTypography-h4"]')?.textContent?.trim() | ||
| || getStoredUsername() | ||
| } | ||
|
|
||
| export function getProfileImageFromDom(): string | undefined { | ||
| const selectors = [ | ||
| 'img[src*="profile-images"]', | ||
| 'img[src*="b-cdn.net"][src*="profile"]', | ||
| '[class*="MuiAvatar"] img', | ||
| ] | ||
|
|
||
| for (const selector of selectors) { | ||
| const img = document.querySelector<HTMLImageElement>(selector) | ||
| const src = img?.currentSrc || img?.src | ||
| if (src && !src.startsWith('data:') && img && img.naturalWidth > 0) | ||
| return src | ||
| } | ||
|
|
||
| return undefined | ||
| } | ||
|
|
||
| export function parsePathSegments(pathname: string): string[] { | ||
| return pathname.split('/').filter(Boolean) | ||
| } | ||
|
|
||
| export function formatEpisodeState( | ||
| season: number, | ||
| episode: number, | ||
| episodeTitle?: string, | ||
| ): string { | ||
| const base = `S${season} E${episode}` | ||
| return episodeTitle ? `${base} — ${episodeTitle}` : base | ||
| } | ||
|
|
||
| function normalizeUrl(url?: string | null): string | undefined { | ||
| if (!url) | ||
| return undefined | ||
| try { | ||
| return new URL(url, document.location.origin).href | ||
| } | ||
| catch { | ||
| return undefined | ||
| } | ||
| } | ||
|
|
||
| function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | undefined> { | ||
| return new Promise((resolve) => { | ||
| const timer = window.setTimeout(() => resolve(undefined), ms) | ||
| promise | ||
| .then((value) => { | ||
| window.clearTimeout(timer) | ||
| resolve(value) | ||
| }) | ||
| .catch(() => { | ||
| window.clearTimeout(timer) | ||
| resolve(undefined) | ||
| }) | ||
| }) | ||
| } | ||
|
|
||
| function loadImage(url: string): Promise<HTMLImageElement> { | ||
| return new Promise((resolve, reject) => { | ||
| const img = new Image() | ||
| img.decoding = 'async' | ||
| img.referrerPolicy = 'strict-origin-when-cross-origin' | ||
| img.onload = () => resolve(img) | ||
| img.onerror = () => reject(new Error('image load failed')) | ||
Check failureCode scanning / ESLint Prefer passing function and arguments directly to setTimeout/setInterval instead of wrapping in an arrow function or using bind Error
Pass function and arguments directly to timer function to avoid allocating an extra function
|
||
| img.src = url | ||
| }) | ||
| } | ||
|
|
||
| /** Resize / flatten (incl. GIF) to a Discord-friendly JPEG blob. */ | ||
| async function toJpegBlob(source: Blob | HTMLImageElement, maxSize = 512): Promise<Blob | undefined> { | ||
| try { | ||
| let img: HTMLImageElement | ||
| if (source instanceof HTMLImageElement) { | ||
| img = source | ||
| } | ||
| else { | ||
| const objectUrl = URL.createObjectURL(source) | ||
| try { | ||
| img = await loadImage(objectUrl) | ||
| } | ||
| finally { | ||
| URL.revokeObjectURL(objectUrl) | ||
| } | ||
| } | ||
|
|
||
| if (!img.naturalWidth || !img.naturalHeight) | ||
| return undefined | ||
|
|
||
| const scale = Math.min(1, maxSize / Math.max(img.naturalWidth, img.naturalHeight)) | ||
| const width = Math.max(1, Math.round(img.naturalWidth * scale)) | ||
| const height = Math.max(1, Math.round(img.naturalHeight * scale)) | ||
| const canvas = document.createElement('canvas') | ||
| canvas.width = width | ||
| canvas.height = height | ||
| const ctx = canvas.getContext('2d') | ||
| if (!ctx) | ||
| return undefined | ||
|
|
||
| ctx.fillStyle = '#000' | ||
| ctx.fillRect(0, 0, width, height) | ||
| ctx.drawImage(img, 0, 0, width, height) | ||
|
|
||
| return await new Promise((resolve) => { | ||
| canvas.toBlob(blob => resolve(blob || undefined), 'image/jpeg', 0.86) | ||
| }) | ||
| } | ||
| catch { | ||
| return undefined | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Curl-equivalent: fetch image bytes in-page (site referer), return JPEG Blob. | ||
| * Discord cannot fetch BunnyCDN URLs directly (403). | ||
| */ | ||
| export async function fetchCoverBlob(url: string): Promise<Blob | undefined> { | ||
| try { | ||
| const response = await fetch(url, { | ||
| method: 'GET', | ||
| mode: 'cors', | ||
| credentials: 'omit', | ||
| referrerPolicy: 'strict-origin-when-cross-origin', | ||
| headers: { | ||
| Accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8', | ||
| }, | ||
| }) | ||
|
|
||
| if (response.ok) { | ||
| const blob = await response.blob() | ||
| if (blob.size > 100) { | ||
| const jpeg = await toJpegBlob(blob) | ||
| if (jpeg) | ||
| return jpeg | ||
| } | ||
| } | ||
| } | ||
| catch { | ||
| // fall through to <img> path | ||
| } | ||
|
|
||
| try { | ||
| const img = await loadImage(url) | ||
| return await toJpegBlob(img) | ||
| } | ||
| catch { | ||
| return undefined | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Always returns a Blob (or logo URL). Uses cache so UpdateData is not blocked twice. | ||
| */ | ||
| export async function resolveCoverImage( | ||
| _title?: string, | ||
| ...candidates: Array<string | undefined> | ||
| ): Promise<string | Blob> { | ||
| const urls = [...candidates, getOgImage()] | ||
| .map(normalizeUrl) | ||
| .filter((url): url is string => Boolean(url)) | ||
|
|
||
| for (const url of urls) { | ||
| const cached = blobCache.get(url) | ||
| if (cached) | ||
| return cached | ||
|
|
||
| let pending = pendingBlob.get(url) | ||
| if (!pending) { | ||
| pending = fetchCoverBlob(url).then((blob) => { | ||
| if (blob) | ||
| blobCache.set(url, blob) | ||
| return blob | ||
| }) | ||
| pendingBlob.set(url, pending) | ||
| } | ||
|
|
||
| const blob = await withTimeout(pending, FETCH_TIMEOUT_MS) | ||
| if (blob) | ||
| return blob | ||
| } | ||
|
|
||
| return ActivityAssets.Logo | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.