Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
}
})
}
230 changes: 230 additions & 0 deletions websites/A/AniTilky/functions/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
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 inflight = new Map<string, Promise<Blob | undefined>>()
const CACHE_TTL = 60_000

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 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 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:'))
return src
}

return undefined
}

export function getOgImage(): string | undefined {
return document.querySelector<HTMLMetaElement>('meta[property="og:image"]')?.content
|| document.querySelector<HTMLMetaElement>('meta[name="twitter:image"]')?.content
}

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)
}

/**
* BunnyCDN needs a site Referer (curl -e anitilky.com). Browser fetch from the page works.
* Return raw Blob — GIF stays GIF, everything else kept as fetched (or JPEG if mistyped).
*/
async function curlImageBlob(url: string): Promise<Blob | undefined> {
try {
const controller = new AbortController()
const timer = window.setTimeout(() => controller.abort(), 5000)

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)
return 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'
const type = wantGif
? 'image/gif'
: (headerType.startsWith('image/') ? headerType : 'image/jpeg')

return new Blob([buffer], { type })
}
catch {
return undefined
}
}

function ensureBlobFetch(url: string): void {
if (blobCache.has(url) || inflight.has(url))
return

const job = curlImageBlob(url).then((blob) => {
if (blob)
blobCache.set(url, blob)
inflight.delete(url)
return blob
}).catch(() => {
inflight.delete(url)
return undefined
})

inflight.set(url, job)
}

/**
* Non-blocking: return cached Blob immediately, otherwise Logo and prefetch in background.
* Next UpdateData tick will show the BunnyCDN image / GIF.
*/
export function resolvePresenceImage(
...candidates: Array<string | undefined>
): 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
}

for (const url of urls)
ensureBlobFetch(url)

return ActivityAssets.Logo
}

/** Optional short wait used once when we already have an inflight fetch. */
export async function resolvePresenceImageAsync(
...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
}

for (const url of urls)
ensureBlobFetch(url)

// Wait briefly for the first candidate only (does not hang presence forever)
const first = urls[0]
if (first) {
const pending = inflight.get(first)
if (pending) {
const blob = await Promise.race([
pending,
new Promise<undefined>(resolve => window.setTimeout(() => resolve(undefined), 1200)),

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
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
])
if (blob)
return blob
}
}

return ActivityAssets.Logo
}
116 changes: 116 additions & 0 deletions websites/A/AniTilky/functions/strings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
export interface PresenceStrings {
play: string
pause: string
browse: string
search: string
viewPage: string
viewSeries: string
watchEpisode: string
viewProfile: string
watchingSeries: string
seasonEpisode: string
home: string
animeList: string
schedule: string
users: string
notifications: string
settings: string
donation: string
login: string
register: string
admin: string
viewingAnime: string
viewingProfile: string
ownProfile: string
tabPosts: string
tabMedia: string
tabWatchlist: string
tabFavorites: string
tabHistory: string
privacyWatching: string
privacyBrowsing: string
}

const SITE_STRINGS: Record<'en' | 'tr', Omit<PresenceStrings,
'play' | 'pause' | 'browse' | 'search' | 'viewPage' | 'viewSeries' | 'watchEpisode' | 'watchingSeries'
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
>> = {
en: {

Check failure

Code scanning / ESLint

Having line breaks styles to object, array and named imports Error

Should not have line breaks between items, in node TSTypeParameterInstantiation

Check failure

Code scanning / ESLint

Having line breaks styles to object, array and named imports Error

Should not have line breaks between items, in node TSTypeParameterInstantiation
viewProfile: 'View profile',
seasonEpisode: 'Season {0} · Episode {1}',
home: 'Home',
animeList: 'Anime catalog',
schedule: 'Release schedule',
users: 'Community',
notifications: 'Notifications',
settings: 'Account settings',
donation: 'Support AniTilky',
login: 'Signing in',
register: 'Creating account',
admin: 'Admin panel',
viewingAnime: 'Viewing anime',
viewingProfile: 'Viewing profile',
ownProfile: 'My profile',
tabPosts: 'Posts',
tabMedia: 'Media',
tabWatchlist: 'Watchlist',
tabFavorites: 'Favorites',
tabHistory: 'Watch history',
privacyWatching: 'Watching content',
privacyBrowsing: 'Browsing AniTilky',
},
tr: {
viewProfile: 'Profile git',
seasonEpisode: 'Sezon {0} · Bölüm {1}',
home: 'Ana sayfa',
animeList: 'Anime kataloğu',
schedule: 'Yayın takvimi',
users: 'Topluluk',
notifications: 'Bildirimler',
settings: 'Hesap ayarları',
donation: 'AniTilky\'ye destek',
login: 'Giriş yapılıyor',
register: 'Hesap oluşturuluyor',
admin: 'Yönetim paneli',
viewingAnime: 'Anime inceleniyor',
viewingProfile: 'Profil görüntüleniyor',
ownProfile: 'Kendi profili',
tabPosts: 'Gönderiler',
tabMedia: 'Medya',
tabWatchlist: 'İzleme listesi',
tabFavorites: 'Favoriler',
tabHistory: 'İzleme geçmişi',
privacyWatching: 'İçerik izleniyor',
privacyBrowsing: 'AniTilky\'de geziniyor',
},
}

export async function getStrings(presence: Presence, lang: string): Promise<PresenceStrings> {
const normalized = lang.startsWith('tr') ? 'tr' : 'en'
const general = await presence.getStrings(

Check failure

Code scanning / ESLint

Disallow using code marked as `@deprecated` Error

getStrings is deprecated. 2.5 - Passing language is deprecated
Get translations from the extension
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
{
play: 'general.playing',
pause: 'general.paused',
browse: 'general.browsing',
search: 'general.search',
viewPage: 'general.viewPage',
viewSeries: 'general.buttonViewSeries',
watchEpisode: 'general.buttonViewEpisode',
watchingSeries: 'general.watchingSeries',
},
lang,
)

return { ...general, ...SITE_STRINGS[normalized] }
}

export type ProfileTabKey = keyof Pick<PresenceStrings,
'tabPosts' | 'tabMedia' | 'tabWatchlist' | 'tabFavorites' | 'tabHistory'
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
>

Check failure

Code scanning / ESLint

Having line breaks styles to object, array and named imports Error

Should not have line breaks between items, in node TSTypeParameterInstantiation

Check failure

Code scanning / ESLint

Having line breaks styles to object, array and named imports Error

Should not have line breaks between items, in node TSTypeParameterInstantiation
export const PROFILE_TABS: Record<string, ProfileTabKey> = {
posts: 'tabPosts',
media: 'tabMedia',
watchlist: 'tabWatchlist',
favorites: 'tabFavorites',
history: 'tabHistory',
}
Loading
Loading