import crypto from "crypto"; import axios from "axios"; import getStorage from "./storage"; const storage = getStorage(); /** * Postavy Umamusume pro konfety kola štěstí (styl Umamusume): chibi obrázky (případně * portrét s `round: true`) a typická hláška. * Zdroj: Umamusume Wiki (umamusume.fandom.com) — obrázky se do repozitáře neukládají, * server je při prvním použití stáhne a nacachuje ve storage (viz {@link getConfettiImage}). */ export const UMA_CHARACTERS: { name: string, quote?: string, round?: boolean, images: string[], icon?: string }[] = [ { name: "Air Groove", quote: "Just as my esteemed mother once did, I must train to perfection and lead others along my path!", images: [ 'https://static.wikia.nocookie.net/umamusume/images/c/c3/Air_Groove_Original_Chibi_2.png', 'https://static.wikia.nocookie.net/umamusume/images/f/f9/Air_Groove_Original_Chibi_1.png', ], }, { name: "Bamboo Memory", quote: "Let's keep the pecking order nice and neat. We don't want anything bad to happen, right?", images: [ 'https://static.wikia.nocookie.net/umamusume/images/c/c5/Bamboo_Memory_Original_Chibi_1.png', 'https://static.wikia.nocookie.net/umamusume/images/e/e7/Bamboo_Memory_Original_Chibi_2.png', ], }, { name: "Cesario", quote: "I will not give up this Tiara. It's time for me to show the way.", images: [ 'https://static.wikia.nocookie.net/umamusume/images/7/74/Cesario_Original_Chibi_2.png', 'https://static.wikia.nocookie.net/umamusume/images/a/ad/Cesario_Original_Chibi_1.png', ], }, { name: "El Condor Pasa", quote: "Soaring towards victory with the heart of a wrestler! El's the world's strooongest!", images: [ 'https://static.wikia.nocookie.net/umamusume/images/7/72/El_Condor_Pasa_Original_Chibi_1.png', 'https://static.wikia.nocookie.net/umamusume/images/c/c0/El_Condor_Pasa_Original_Chibi_2.png', ], }, { name: "Gold Ship", quote: "So long as it's fun, right? Right?!", images: [ 'https://static.wikia.nocookie.net/umamusume/images/8/84/Gold_Ship_Original_Chibi_2.png', 'https://static.wikia.nocookie.net/umamusume/images/d/db/Gold_Ship_Original_Chibi_1.png', ], }, { name: "Manhattan Cafe", quote: "I'm... right here, with someone you can't see.", images: [ 'https://static.wikia.nocookie.net/umamusume/images/8/8f/Manhattan_Cafe_Original_Chibi_1.png', 'https://static.wikia.nocookie.net/umamusume/images/d/de/Manhattan_Cafe_Original_Chibi_2.png', ], }, { name: "Matikanetannhauser", quote: "Y'know, I kinda get the feeling I can really do it this time!", images: [ 'https://static.wikia.nocookie.net/umamusume/images/6/6b/Matikanetannhauser_Original_Chibi_2.png', 'https://static.wikia.nocookie.net/umamusume/images/9/90/Matikanetannhauser_Original_Chibi_1.png', ], }, { name: "Mejiro Bright", quote: "Oh, is it time for my race already? Well then, I suppose I shall get ready.", images: [ 'https://static.wikia.nocookie.net/umamusume/images/3/3d/Mejiro_Bright_Original_Chibi_2.png', 'https://static.wikia.nocookie.net/umamusume/images/c/cc/Mejiro_Bright_Original_Chibi_1.png', ], }, { name: "Nakayama Festa", quote: "Cold… is that so? When my passion heats up it'll be hot enough to burn you.", images: [ 'https://static.wikia.nocookie.net/umamusume/images/1/16/Nakayama_Festa_Original_Chibi_1.png', 'https://static.wikia.nocookie.net/umamusume/images/5/57/Nakayama_Festa_Original_Chibi_2.png', ], }, { name: "Neo Universe", quote: "Neo Universe would like to request a friendly \"ENCOUNTER\". In other words, I'm in your hands.", images: [ 'https://static.wikia.nocookie.net/umamusume/images/3/34/Neo_Universe_Original_Chibi_1.png', 'https://static.wikia.nocookie.net/umamusume/images/4/40/Neo_Universe_Original_Chibi_2.png', ], }, { name: "Phalaenopsis", quote: "Frail health, poor form—what are they to me? Legs that dull at such trifles have no business claiming victory.", images: [ 'https://static.wikia.nocookie.net/umamusume/images/6/65/Phalaenopsis_Original_Chibi_2.png', 'https://static.wikia.nocookie.net/umamusume/images/9/95/Phalaenopsis_Original_Chibi_1.png', ], }, { name: "Satono Diamond", quote: "Everyone believes in my ability to win. I shall shine on like a brilliant diamond for them!", images: [ 'https://static.wikia.nocookie.net/umamusume/images/3/3b/Satono_Diamond_Original_Chibi_1.png', 'https://static.wikia.nocookie.net/umamusume/images/9/9d/Satono_Diamond_Original_Chibi_2.png', ], }, { name: "Silence Suzuka", quote: "I just want to see... that scenery from the lead.", images: [ 'https://static.wikia.nocookie.net/umamusume/images/1/1f/Silence_Suzuka_Original_Chibi_1.png', 'https://static.wikia.nocookie.net/umamusume/images/d/df/Silence_Suzuka_Original_Chibi_2.png', ], }, { name: "Special Week", images: [ 'https://static.wikia.nocookie.net/umamusume/images/4/4f/Special_Week_Original_Chibi_2.png', 'https://static.wikia.nocookie.net/umamusume/images/6/6c/Special_Week_Original_Chibi_1.png', ], // Portrét pro odznak „jedlíka" (chibi je v malé velikosti nečitelný) icon: 'https://static.wikia.nocookie.net/umamusume/images/3/3a/Special_Week_%28icon%29.png/revision/latest/scale-to-width-down/128', }, { name: "Tokai Teio", quote: "Here begins the legend of the invincible Teio!", images: [ 'https://static.wikia.nocookie.net/umamusume/images/3/36/Tokai_Teio_Original_Chibi_1.png', 'https://static.wikia.nocookie.net/umamusume/images/9/9e/Tokai_Teio_Original_Chibi_2.png', ], }, { name: "Tosen Jordan", quote: "My dream is... actually, serious topics are NG~☆ Anyways, look at my nails! Aren't they cool?", images: [ 'https://static.wikia.nocookie.net/umamusume/images/3/3e/Tosen_Jordan_Original_Chibi_1.png', 'https://static.wikia.nocookie.net/umamusume/images/7/7b/Tosen_Jordan_Original_Chibi_2.png', ], }, { name: "Yaeno Muteki", images: [ 'https://static.wikia.nocookie.net/umamusume/images/9/9d/Yaeno_Muteki_Original_Chibi_2.png', 'https://static.wikia.nocookie.net/umamusume/images/d/d7/Yaeno_Muteki_Original_Chibi_1.png', ], }, // Postavy bez chibi obrázku na wiki — čtvercový portrét (ikona), klient ho zobrazí jako kulatou samolepku { name: "Kitasan Black", quote: "I'll give it a go! You'll never know unless you try, right?", round: true, images: [ 'https://static.wikia.nocookie.net/umamusume/images/c/c8/Kitasan_Black_%28icon%29.png/revision/latest/scale-to-width-down/256', ], }, { name: "Oguri Cap", quote: "I'm going to win this for the folks back home... But first, sustenance.", round: true, images: [ 'https://static.wikia.nocookie.net/umamusume/images/5/57/Oguri_Cap_%28icon%29.png/revision/latest/scale-to-width-down/256', ], }, { name: "Haru Urara", quote: "I won't let losing get me down! Urara, la la la! ♪", round: true, images: [ 'https://static.wikia.nocookie.net/umamusume/images/7/7b/Haru_Urara_%28icon%29.png/revision/latest/scale-to-width-down/256', ], }, { name: "Mejiro McQueen", quote: "I'll bring honor and glory to the Mejiro family!", round: true, images: [ 'https://static.wikia.nocookie.net/umamusume/images/1/15/Mejiro_McQueen_%28icon%29.png/revision/latest/scale-to-width-down/256', ], }, { name: "Rice Shower", quote: "Rice only brings misfortune to everyone... I really... really hope I can change that!", round: true, images: [ 'https://static.wikia.nocookie.net/umamusume/images/0/07/Rice_Shower_%28icon%29.png/revision/latest/scale-to-width-down/256', ], }, { name: "Daiwa Scarlet", quote: "Second-best won't cut it. Everything I do is for the sake of being number one!", round: true, images: [ 'https://static.wikia.nocookie.net/umamusume/images/d/d7/Daiwa_Scarlet_%28icon%29.png/revision/latest/scale-to-width-down/256', ], }, { name: "Vodka", quote: "I've just gotta be the coolest to ever do it!", round: true, images: [ 'https://static.wikia.nocookie.net/umamusume/images/0/0d/Vodka_%28icon%29.png/revision/latest/scale-to-width-down/256', ], }, { name: "Agnes Tachyon", quote: "Life is an experiment, and we its guinea pigs. You, me, the other Umamusume—everyone!", round: true, images: [ 'https://static.wikia.nocookie.net/umamusume/images/9/9d/Agnes_Tachyon_%28icon%29.png/revision/latest/scale-to-width-down/256', ], }, { name: "Twin Turbo", quote: "Turbo's my name, and speed's my game! Let's race, right now! Hey! You listenin' to me?!", round: true, images: [ 'https://static.wikia.nocookie.net/umamusume/images/9/97/Twin_Turbo_%28icon%29.png/revision/latest/scale-to-width-down/256', ], }, { name: "Symboli Rudolf", quote: "Fortune smiles upon the brave. Let us create a world where all Umamusume can live in bliss.", round: true, images: [ 'https://static.wikia.nocookie.net/umamusume/images/5/56/Symboli_Rudolf_%28icon%29.png/revision/latest/scale-to-width-down/256', ], }, { name: "Nice Nature", quote: "Yup, I'm happy with a third place finish—it's all an extra like me can ask for.", round: true, images: [ 'https://static.wikia.nocookie.net/umamusume/images/7/77/Nice_Nature_%28icon%29.png/revision/latest/scale-to-width-down/256', ], }, { name: "Maruzensky", quote: "The track's like a dance floor, and I'm feelin' groovy today!♪", round: true, images: [ 'https://static.wikia.nocookie.net/umamusume/images/b/b4/Maruzensky_%28icon%29.png/revision/latest/scale-to-width-down/256', ], }, { name: "Grass Wonder", quote: "Noble and gallant, I march on... towards my ambitions.", round: true, images: [ 'https://static.wikia.nocookie.net/umamusume/images/d/d0/Grass_Wonder_%28icon%29.png/revision/latest/scale-to-width-down/256', ], }, { name: "Mihono Bourbon", quote: "Target acquired. Commencing Operation: Triple Crown.", round: true, images: [ 'https://static.wikia.nocookie.net/umamusume/images/3/37/Mihono_Bourbon_%28icon%29.png/revision/latest/scale-to-width-down/256', ], }, { name: "Curren Chan", quote: "New day, same adorable Curren! ☆ Spread the word with #CurrenChan!", round: true, images: [ 'https://static.wikia.nocookie.net/umamusume/images/9/91/Curren_Chan_%28icon%29.png/revision/latest/scale-to-width-down/256', ], }, { name: "Super Creek", quote: "I can spoil you as much as you'd like. Aww, there, there. ♪", round: true, images: [ 'https://static.wikia.nocookie.net/umamusume/images/5/5e/Super_Creek_%28icon%29.png/revision/latest/scale-to-width-down/256', ], }, ]; /** Po neúspěšném stažení obrázku se další pokus smí provést až po této době. */ const RETRY_AFTER_MS = 10 * 60 * 1000; const MAX_IMAGE_BYTES = 2 * 1024 * 1024; type CachedImage = { contentType: string, data: string }; /** Stabilní ID obrázku z jeho URL — nemění se při úpravách seznamu (prohlížeč obrázky cachuje natrvalo). */ const imageId = (url: string) => crypto.createHash('sha1').update(url).digest('hex').slice(0, 16); const imageKey = (url: string) => `confetti_img_${imageId(url)}`; const imageUrl = (url: string) => `/api/confetti/image/${imageId(url)}`; /** Povolené obrázky: ID → zdrojová URL (nic jiného server nestáhne). */ const ALLOWED_IMAGES = new Map(UMA_CHARACTERS.flatMap(c => c.icon ? [...c.images, c.icon] : c.images).map(url => [imageId(url), url])); const failedAt = new Map(); const inFlight = new Map>(); /** Seznam postav pro klienta — obrázky vedou na cachující proxy tohoto serveru. */ export function getConfettiCharacters() { return UMA_CHARACTERS.map(c => ({ name: c.name, ...(c.quote && { quote: c.quote }), ...(c.round && { round: true }), images: c.images.map(imageUrl), ...(c.icon && { icon: imageUrl(c.icon) }), })); } /** * Vrátí obrázek konfet dle jeho ID. Poprvé ho stáhne a uloží do storage, další * požadavky (i při výpadku internetu) obslouží z cache. Stahují se jen URL ze seznamu výše. */ export async function getConfettiImage(id: string): Promise<{ contentType: string, data: Buffer } | undefined> { const url = ALLOWED_IMAGES.get(id); if (!url) return undefined; const key = imageKey(url); const cached = await storage.getData(key); if (cached?.data) { return { contentType: cached.contentType, data: Buffer.from(cached.data, 'base64') }; } const lastFailure = failedAt.get(id); if (lastFailure && Date.now() - lastFailure < RETRY_AFTER_MS) return undefined; let pending = inFlight.get(id); if (!pending) { pending = downloadImage(url, key) .catch(e => { console.warn(`Konfety: obrázek ${url} se nepodařilo stáhnout — ${e?.message ?? e}`); failedAt.set(id, Date.now()); return undefined; }) .finally(() => inFlight.delete(id)); inFlight.set(id, pending); } const image = await pending; return image && { contentType: image.contentType, data: Buffer.from(image.data, 'base64') }; } async function downloadImage(url: string, key: string): Promise { const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10_000, maxContentLength: MAX_IMAGE_BYTES }); const contentType = String(res.headers['content-type'] ?? ''); if (!contentType.startsWith('image/')) { throw new Error(`Neočekávaný typ obsahu ${contentType}`); } const image = { contentType, data: Buffer.from(res.data).toString('base64') }; await storage.setData(key, image); return image; }