-
-
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 11 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,330 @@ | ||
| 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', | ||
| } | ||
|
|
||
| /** GIFs above this are flattened to JPEG — PreMiD silently drops multi‑MB GIFs. */ | ||
| const MAX_GIF_BYTES = 400_000 | ||
| const MAX_IMAGE_EDGE = 512 | ||
| const dataCache = new Map<string, { data: unknown, expires: number }>() | ||
| const blobCache = new Map<string, Blob>() | ||
| const inflight = new Set<string>() | ||
| const failedUrls = new Set<string>() | ||
| const CACHE_TTL = 120_000 | ||
| const API_TIMEOUT_MS = 1200 | ||
|
|
||
| let logoBlob: Blob | undefined | ||
| let logoPrefetchStarted = false | ||
|
|
||
| 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 | ||
|
|
||
| try { | ||
| const data = await Promise.race([ | ||
| fetcher(), | ||
| new Promise<null>(resolve => window.setTimeout(() => resolve(null), API_TIMEOUT_MS)), | ||
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
|
||
| ]) | ||
| if (data) | ||
| dataCache.set(key, { data, expires: Date.now() + CACHE_TTL }) | ||
| return data | ||
| } | ||
| catch { | ||
| return null | ||
| } | ||
| } | ||
|
|
||
| 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 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 getProfileImageUrl(): string | undefined { | ||
| const images = Array.from(document.querySelectorAll<HTMLImageElement>( | ||
| '.MuiAvatar-img, img[src*="profile-images"], img[src*="b-cdn.net"][src*="profile"]', | ||
| )) | ||
|
|
||
| images.sort((a, b) => (b.naturalWidth * b.naturalHeight) - (a.naturalWidth * a.naturalHeight)) | ||
|
|
||
| for (const img of images) { | ||
| const src = img.currentSrc || img.src | ||
| if (src && !src.startsWith('data:') && !/logo|placeholder/i.test(src)) | ||
| 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 isGifUrl(url: string): boolean { | ||
| return /\.gif(?:$|\?)/i.test(url) | ||
| } | ||
|
|
||
| function loadImage(url: string): Promise<HTMLImageElement> { | ||
| return new Promise((resolve, reject) => { | ||
| const img = new Image() | ||
| img.decoding = 'async' | ||
| img.crossOrigin = 'anonymous' | ||
| img.referrerPolicy = 'origin' | ||
| img.onload = () => resolve(img) | ||
| img.onerror = () => reject(new Error('img failed')) | ||
| img.src = url | ||
| }) | ||
| } | ||
|
|
||
| async function imageFromBlobOrBuffer(source: Blob | ArrayBuffer): Promise<HTMLImageElement | undefined> { | ||
| const blob = source instanceof Blob ? source : new Blob([source]) | ||
| const objectUrl = URL.createObjectURL(blob) | ||
| try { | ||
| return await loadImage(objectUrl) | ||
| } | ||
| catch { | ||
| return undefined | ||
| } | ||
| finally { | ||
| URL.revokeObjectURL(objectUrl) | ||
| } | ||
| } | ||
|
|
||
| async function toJpegBlob( | ||
| source: HTMLImageElement | Blob | ArrayBuffer, | ||
| fallbackUrl?: string, | ||
| ): Promise<Blob | undefined> { | ||
| try { | ||
| let img: HTMLImageElement | undefined | ||
|
|
||
| if (source instanceof HTMLImageElement) { | ||
| img = source | ||
| } | ||
| else { | ||
| img = await imageFromBlobOrBuffer(source) | ||
| if (!img && fallbackUrl) | ||
| img = await loadImage(fallbackUrl).catch(() => undefined) | ||
| } | ||
|
|
||
| if (!img?.naturalWidth && fallbackUrl) | ||
| img = await loadImage(fallbackUrl).catch(() => undefined) | ||
|
|
||
| if (!img?.naturalWidth) | ||
| return undefined | ||
|
|
||
| const scale = Math.min(1, MAX_IMAGE_EDGE / 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 = '#111' | ||
| ctx.fillRect(0, 0, width, height) | ||
| ctx.drawImage(img, 0, 0, width, height) | ||
|
|
||
| return await new Promise(resolve => | ||
| canvas.toBlob(b => resolve(b || undefined), 'image/jpeg', 0.9), | ||
| ) | ||
| } | ||
| catch { | ||
| return undefined | ||
| } | ||
| } | ||
|
|
||
| async function ensureLogoBlob(): Promise<Blob | undefined> { | ||
| if (logoBlob) | ||
| return logoBlob | ||
|
|
||
| if (!logoPrefetchStarted) { | ||
| logoPrefetchStarted = true | ||
| void (async () => { | ||
| try { | ||
| const response = await fetch(ActivityAssets.Logo, { mode: 'cors', credentials: 'omit' }) | ||
| if (response.ok) { | ||
| const blob = await response.blob() | ||
| if (blob.size > 100) { | ||
| logoBlob = blob.type.startsWith('image/') ? blob : await toJpegBlob(blob) | ||
| return | ||
| } | ||
| } | ||
| } | ||
| catch { | ||
| // ignore — CDN string still works as fallback | ||
| } | ||
|
|
||
| try { | ||
| logoBlob = await toJpegBlob(await loadImage(ActivityAssets.Logo)) | ||
| } | ||
| catch { | ||
| // keep ActivityAssets.Logo string | ||
| } | ||
| })() | ||
| } | ||
|
|
||
| return logoBlob | ||
| } | ||
|
|
||
| void ensureLogoBlob() | ||
|
|
||
| /** curl-style BunnyCDN fetch (needs site referer). GIFs → JPEG. */ | ||
| async function curlImageBlob(url: string): Promise<Blob | undefined> { | ||
| try { | ||
| const controller = new AbortController() | ||
| const timer = window.setTimeout(() => controller.abort(), 10000) | ||
|
|
||
| const response = await fetch(url, { | ||
| method: 'GET', | ||
| mode: 'cors', | ||
| credentials: 'omit', | ||
| referrer: `${BASE_URL}/`, | ||
| referrerPolicy: 'origin', | ||
| signal: controller.signal, | ||
| headers: { | ||
| Accept: 'image/gif,image/webp,image/avif,image/apng,image/*,*/*;q=0.8', | ||
| }, | ||
| }) | ||
|
|
||
| window.clearTimeout(timer) | ||
|
|
||
| if (!response.ok) { | ||
| const img = await loadImage(url).catch(() => undefined) | ||
| return img ? toJpegBlob(img) : undefined | ||
| } | ||
|
|
||
| const buffer = await response.arrayBuffer() | ||
| if (buffer.byteLength < 100) | ||
| return undefined | ||
|
|
||
| const headerType = (response.headers.get('content-type') || '').split(';')[0]!.trim().toLowerCase() | ||
| const wantGif = isGifUrl(url) || headerType === 'image/gif' | ||
|
|
||
| // GIFs (esp. large ones) fail PreMiD upload → always prefer JPEG | ||
| if (wantGif) { | ||
| const jpeg = await toJpegBlob(buffer, url) | ||
| if (jpeg) | ||
| return jpeg | ||
| } | ||
|
|
||
| const type = headerType.startsWith('image/') ? headerType : 'image/jpeg' | ||
| const blob = new Blob([buffer], { type }) | ||
|
|
||
| if (buffer.byteLength > MAX_GIF_BYTES || buffer.byteLength > 1_500_000) { | ||
| const jpeg = await toJpegBlob(blob, url) | ||
| if (jpeg) | ||
| return jpeg | ||
| } | ||
|
|
||
| return blob | ||
| } | ||
| catch { | ||
| const img = await loadImage(url).catch(() => undefined) | ||
| return img ? toJpegBlob(img) : undefined | ||
| } | ||
| } | ||
|
|
||
| function prefetchBlob(url: string): void { | ||
| if (blobCache.has(url) || inflight.has(url) || failedUrls.has(url)) | ||
| return | ||
|
|
||
| inflight.add(url) | ||
| void curlImageBlob(url).then((blob) => { | ||
| inflight.delete(url) | ||
| if (blob) | ||
| blobCache.set(url, blob) | ||
| else | ||
| failedUrls.add(url) | ||
| }).catch(() => { | ||
| inflight.delete(url) | ||
| failedUrls.add(url) | ||
| }) | ||
| } | ||
|
|
||
| function fallbackLogo(): string | Blob { | ||
| return logoBlob || ActivityAssets.Logo | ||
| } | ||
|
|
||
| /** | ||
| * Sync — never blocks UpdateData. | ||
| * Cached BunnyCDN blob, otherwise logo (blob if ready, else CDN URL). | ||
| */ | ||
| export function resolvePresenceImage( | ||
| ...candidates: Array<string | undefined> | ||
| ): string | Blob { | ||
| void ensureLogoBlob() | ||
|
|
||
| const urls = candidates | ||
| .map(normalizeUrl) | ||
| .filter((url): url is string => Boolean(url)) | ||
|
|
||
| for (const url of urls) { | ||
| const cached = blobCache.get(url) | ||
| if (cached) | ||
| return cached | ||
| } | ||
|
|
||
| for (const url of urls) | ||
| prefetchBlob(url) | ||
|
|
||
| return fallbackLogo() | ||
| } | ||
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.