Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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 Jul 14, 2026
0a5e50e
fix(AniTilky): pass pmd validation and update assets
hyqanxd Jul 14, 2026
d5b7b0b
fix(AniTilky): resolve covers blocked by BunnyCDN
hyqanxd Jul 14, 2026
f5904c3
fix(AniTilky): fetch cover blobs with referer
hyqanxd Jul 14, 2026
ae8c3e1
fix(AniTilky): blob covers only, drop broken small icons
hyqanxd Jul 14, 2026
9d297bf
fix(AniTilky): unstick watch presence and restore covers/buttons
hyqanxd Jul 14, 2026
115bfcd
fix(AniTilky): blob covers via fetch and reliable pause detection
hyqanxd Jul 14, 2026
df10eb9
fix(AniTilky): keep animated GIF profile avatars
hyqanxd Jul 14, 2026
1d33446
revert(AniTilky): drop broken blob pipeline, restore stable covers
hyqanxd Jul 14, 2026
bdd77bc
fix(AniTilky): fetch BunnyCDN images as blobs including GIFs
hyqanxd Jul 14, 2026
ff45ea6
fix(AniTilky): convert large GIFs to JPEG with reliable logo fallback
hyqanxd Jul 14, 2026
52e0975
fix(AniTilky): resolve CI lint and set version to 2.0.0
hyqanxd Jul 14, 2026
8fa4e60
feat(AniTilky): switch to new brand logo
hyqanxd Jul 14, 2026
d7de6a6
feat(AniTilky): use imgur logo for CDN asset migration
hyqanxd Jul 14, 2026
8c0a327
feat(AniTilky): update logo asset
hyqanxd Jul 14, 2026
f98b794
Merge branch 'main' into update/anitilky-v2
hyqanxd Jul 16, 2026
f0cb030
Merge branch 'main' into update/anitilky-v2
hyqanxd Jul 16, 2026
b64f313
Update websites/A/AniTilky/metadata.json
hyqanxd Jul 16, 2026
94bac12
Merge branch 'main' into update/anitilky-v2
hyqanxd Jul 16, 2026
3281f13
feat(AniTilky): use landscape thumbnail
hyqanxd Jul 16, 2026
c5ceb32
Merge branch 'main' into update/anitilky-v2
hyqanxd Jul 17, 2026
a1fe1b5
Merge branch 'main' into update/anitilky-v2
hyqanxd Jul 20, 2026
b610889
Merge branch 'main' into update/anitilky-v2
hyqanxd Jul 21, 2026
51ba7d6
Merge branch 'main' into update/anitilky-v2
hyqanxd Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions websites/A/AniTilky/functions/fetchAnime.ts
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
}
})
}
16 changes: 16 additions & 0 deletions websites/A/AniTilky/functions/fetchUser.ts
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
}
})
}
286 changes: 286 additions & 0 deletions websites/A/AniTilky/functions/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
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)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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 failure

Code 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).
* GIFs stay animated when preserveGif=true; otherwise flatten to JPEG for Discord.
*/
export async function fetchCoverBlob(
url: string,
options: { preserveGif?: boolean } = {},
): Promise<Blob | undefined> {
const preserveGif = options.preserveGif === true
|| /\.gif(?:$|\?)/i.test(url)

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/gif,image/*,*/*;q=0.8',
},
})

if (response.ok) {
const blob = await response.blob()
if (blob.size > 100) {
const isGif = blob.type === 'image/gif'
|| (preserveGif && /\.gif(?:$|\?)/i.test(url))

if (isGif) {
return blob.type === 'image/gif'
? blob
: new Blob([await blob.arrayBuffer()], { type: 'image/gif' })
}

const jpeg = await toJpegBlob(blob)
if (jpeg)
return jpeg
}
}
}
catch {
// fall through
}

// Animated GIF cannot be rebuilt from <img> draw — only static fallback
if (preserveGif)
return undefined

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> {
return resolveImageBlob(candidates, false)
}

/** Profile avatars: keep animated GIFs intact. */
export async function resolveProfileImage(
...candidates: Array<string | undefined>
): Promise<string | Blob> {
return resolveImageBlob(candidates, true)
}

async function resolveImageBlob(
candidates: Array<string | undefined>,
preserveGif: boolean,
): Promise<string | Blob> {
const cachePrefix = preserveGif ? 'gif:' : 'still:'
const urls = [...candidates, preserveGif ? undefined : getOgImage()]
.map(normalizeUrl)
.filter((url): url is string => Boolean(url))

for (const url of urls) {
const cacheKey = cachePrefix + url
const cached = blobCache.get(cacheKey)
if (cached)
return cached

let pending = pendingBlob.get(cacheKey)
if (!pending) {
pending = fetchCoverBlob(url, { preserveGif }).then((blob) => {
if (blob)
blobCache.set(cacheKey, blob)
return blob
})
pendingBlob.set(cacheKey, pending)
}

const blob = await withTimeout(pending, FETCH_TIMEOUT_MS)
if (blob)
return blob
}

return ActivityAssets.Logo
}
Loading
Loading