import { TweetStatus, SkinRow, ReviewRow, UploadStatus, SkinType, } from "../types"; import UserContext, { ctxWeakMapMemoize } from "./UserContext"; import TweetModel, { TweetDebugData } from "./TweetModel"; import IaItemModel, { IaItemDebugData } from "./IaItemModel"; import FileModel, { FileDebugData } from "./FileModel"; import { MD5_REGEX, withUrlAsTempFile } from "../utils"; import DataLoader from "dataloader"; import { knex } from "../db"; import UploadModel, { UploadDebugData } from "./UploadModel"; import ArchiveFileModel, { ArchiveFileDebugData } from "./ArchiveFileModel"; import * as Skins from "./skins"; import fetch from "node-fetch"; import JSZip from "jszip"; import fs from "fs/promises"; import path from "path"; import { getTransparentAreaSize } from "../transparency"; import KeyValue from "./KeyValue"; export const IS_README = /(file_id\.diz)|(\.txt)$/i; // Skinning Updates.txt ? export const IS_NOT_README = /(dialogs\.txt)|(genex\.txt)|(genexinfo\.txt)|(gen_gslyrics\.txt)|(region\.txt)|(pledit\.txt)|(viscolor\.txt)|(winampmb\.txt)|("gen_ex help\.txt)|(mbinner\.txt)|(winampskins\.info\.txt)|(albumlist\.txt)|(covertag\.txt)|(1001winampskins\.com\.txt)$/i; export default class SkinModel { constructor(readonly ctx: UserContext, readonly row: SkinRow) {} static async fromMd5( ctx: UserContext, md5: string ): Promise { const row = await getSkinLoader(ctx).load(md5); return row == null ? null : new SkinModel(ctx, row); } static clearMd5(ctx: UserContext, md5: string): void { getSkinLoader(ctx).clear(md5); } static async fromMd5Assert( ctx: UserContext, md5: string ): Promise { const skin = await SkinModel.fromMd5(ctx, md5); if (skin == null) { throw new Error(`Expected to find skin with md5 "${md5}".`); } return skin; } static async fromAnything( ctx: UserContext, anything: string ): Promise { const md5Match = anything.match(MD5_REGEX); if (md5Match != null) { const md5 = md5Match[1]; const found = await SkinModel.fromMd5(ctx, md5); if (found != null) { return found; } } const iaItem = await IaItemModel.fromAnything(ctx, anything); if (iaItem != null) { return iaItem.getSkin(); } const tweet = await TweetModel.fromAnything(ctx, anything); if (tweet != null) { return tweet.getSkin(); } return null; } static async exists(ctx: UserContext, md5: string): Promise { const row = await getSkinLoader(ctx).load(md5); return row != null; } getSkinType(): SkinType { switch (this.row.skin_type) { case 1: return "CLASSIC"; case 2: return "MODERN"; } throw new Error(`Unknown skin_type ${this.row.skin_type}`); } getId(): number { return this.row.id; } async tweeted(): Promise { return (await this.getTweet()) != null; } async getTweet(): Promise { const tweets = await this.getTweets(); return tweets[0] || null; } async getTweets(): Promise { return TweetModel.fromMd5(this.ctx, this.row.md5); } getIaItem(): Promise { return IaItemModel.fromMd5(this.ctx, this.row.md5); } getReviews(): Promise { return getReviewsLoader(this.ctx).load(this.row.md5); } getFiles(): Promise { return FileModel.fromMd5(this.ctx, this.row.md5); } getArchiveFiles(): Promise { return ArchiveFileModel.fromMd5(this.ctx, this.row.md5); } getUploadStatuses(): Promise { return UploadModel.fromMd5(this.ctx, this.row.md5); } async getUploadStatus(): Promise { const status = await Skins.getUploadStatuses([this.row.md5]); return (status[this.getMd5()] as UploadStatus) || null; } async getIsNsfw(): Promise { const reviews = await this.getReviews(); return reviews.some((review) => review.review === "NSFW"); } async getTweetStatus(): Promise { const tweet = await this.getTweet(); if (tweet != null) { return "TWEETED"; } const reviewRows = await this.getReviews(); const reviews = new Set(reviewRows.map((row) => row.review)); if (reviews.has("NSFW")) { return "NSFW"; } if (reviews.has("REJECTED")) { return "REJECTED"; } if (reviews.has("APPROVED")) { return "APPROVED"; } return "UNREVIEWED"; } async getFileName(normalizeExtension?: boolean): Promise { const files = await this.getFiles(); if (files.length === 0) { throw new Error(`Could not find file for skin with md5 ${this.getMd5()}`); } const filename = files[0].getFileName(); if (!filename.match(/\.(zip)|(wsz)|(wal)$/i)) { throw new Error("Expected filename to end with zip, wsz or wal."); } if (normalizeExtension) { return path.parse(filename).name + ".wsz"; } return filename; } async getScreenshotFileName(): Promise { if (this.getSkinType() === "MODERN") { throw new Error("Modern skins do not have screenshots yet."); } const skinFilename = await this.getFileName(); return skinFilename.replace(/\.(wsz|zip)$/, ".png"); } getMd5(): string { return this.row.md5; } getEmails(): string[] { const { emails } = this.row; return emails ? emails.split(" ") : []; } async getReadme(): Promise { const files = await this.getArchiveFiles(); const readme = files.find((file) => { const filename = file.getFileName(); const isReadme = IS_README.test(filename); const isNotReadme = IS_NOT_README.test(filename); return isReadme && !isNotReadme; }); if (readme == null) { return null; } return readme.getTextContent(); } getMuseumUrl(): string { if (this.getSkinType() === "MODERN") { throw new Error("Modern skins do not render in the museum."); } return `https://skins.webamp.org/skin/${this.row.md5}`; } getWebampUrl(): string { if (this.getSkinType() === "MODERN") { throw new Error("Modern skins do not render in Webamp."); } return `https://webamp.org?skinUrl=${this.getSkinUrl()}`; } getScreenshotUrl(): string { if (this.getSkinType() === "MODERN") { throw new Error("Modern skins do not have screenshots."); } return Skins.getScreenshotUrl(this.row.md5); } getSkinUrl(): string { switch (this.getSkinType()) { case "CLASSIC": return Skins.getSkinUrl(this.row.md5); case "MODERN": return Skins.getModernSkinUrl(this.row.md5); } } getAverageColor(): string | null { return this.row.average_color ?? null; } getBuffer = mem(async (): Promise => { if (process.env.LOCAL_FILE_CACHE) { const ext = this.getSkinType() === "CLASSIC" ? ".wsz" : ".wal"; const skinPath = path.join( process.env.LOCAL_FILE_CACHE, "skins", this.getMd5() + ext ); return fs.readFile(skinPath); } else { const response = await fetch(this.getSkinUrl()); if (!response.ok) { const missingModernSkins = (await KeyValue.get("missingModernSkins")) ?? []; const missingModernSkinsSet = new Set(missingModernSkins); missingModernSkinsSet.add(this.getMd5()); await KeyValue.set( "missingModernSkins", Array.from(missingModernSkinsSet) ); throw new Error( `Could not fetch skin at "${this.getSkinUrl()}" (Marked in missingModernSkins in the KeyValue store)` ); } return response.buffer(); } }); getScreenshotBuffer = mem(async (): Promise => { if (this.getSkinType() === "MODERN") { throw new Error("Modern skins do not have screenshots."); } const response = await fetch(this.getScreenshotUrl()); if (!response.ok) { throw new Error( `Could not fetch skin screenshot at "${this.getScreenshotUrl()}"` ); } return response.buffer(); }); async withTempFile(cb: (file: string) => Promise): Promise { const filename = await this.getFileName(); return withUrlAsTempFile(this.getSkinUrl(), filename, cb); } async _hasSpriteSheet(base: string): Promise { const ext = "(bmp)|(png)"; return this._hasFile(base, ext); } async _hasFile(base: string, ext: string): Promise { // TODO: Pre-compile regexp const matcher = new RegExp(`^(.*[/\\\\])?${base}.(${ext})$`, "i"); const archiveFiles = await this.getArchiveFiles(); return archiveFiles.some((file) => { return matcher.test(file.getFileName()); }); } async _getFile(base: string, ext: string): Promise { // TODO: Pre-compile regexp const matcher = new RegExp(`^(.*[/\\\\])?${base}.(${ext})$`, "i"); const archiveFiles = await this.getArchiveFiles(); const row = archiveFiles.find((file) => { return matcher.test(file.getFileName()); }); return row || null; } async hasEqualizer(): Promise { return this._hasSpriteSheet("EQMAIN"); } async hasPlaylist(): Promise { return this._hasSpriteSheet("PLEDIT"); } async hasMediaLibrary(): Promise { return this.hasGeneral(); } async hasBrowser(): Promise { return this._hasSpriteSheet("MB"); } async hasAVS(): Promise { return this._hasSpriteSheet("AVS"); } async hasVideo(): Promise { return this._hasSpriteSheet("VIDEO"); } // Has built-in support for the MikroAMP plugin. async hasMikro(): Promise { // Could also check for `WINAMPMB.TXT`. return this._hasSpriteSheet("MIKRO"); } // Has built-in support of the Amarok plugin. async hasAmarok(): Promise { return this._hasSpriteSheet("AMAROK"); } // Has built-in support of the vidamp async hasVidamp(): Promise { return this._hasSpriteSheet("VIDAMP"); } // Includes custom cursors async hasCur(): Promise { const matcher = new RegExp(`.(cur)$`, "i"); const archiveFiles = await this.getArchiveFiles(); return archiveFiles.some((file) => { return matcher.test(file.getFileName()); }); } // Has transparency async hasTransparency(): Promise { const size = await this.transparentPixels(); return size > 0; } async transparentPixels(): Promise { const region = await this._getFile("region", "txt"); if (region == null) { return 0; } const text = await region.getTextContent(); if (text == null) { return 0; } try { return getTransparentAreaSize(text); } catch (_e) { console.error(`Failed: ${this.getMd5()}`); return 0; } } async hasAni(): Promise { // Note: This should be expanded to check for animated cursors that use the // .cur extension (but are actually .ani under the hood). const matcher = new RegExp(`.(ani)$`, "i"); const archiveFiles = await this.getArchiveFiles(); return archiveFiles.some((file) => { return matcher.test(file.getFileName()); }); } async hasGeneral(): Promise { return ( (await this._hasSpriteSheet("GEN")) && (await this._hasSpriteSheet("GENEX")) ); } async getAlgoliaIndexUpdates(limit?: number): Promise { return Skins.searchIndexUpdatesForSkin(this.getMd5(), limit); } async withScreenshotTempFile( cb: (file: string) => Promise ): Promise { if (this.getSkinType() === "MODERN") { throw new Error("Modern skins do not have screenshots."); } const screenshotFilename = await this.getScreenshotFileName(); return withUrlAsTempFile(this.getScreenshotUrl(), screenshotFilename, cb); } getZip = mem(async (): Promise => { const buffer = await this.getBuffer(); // @ts-ignore TODO: Is this typing correct? return JSZip.loadAsync(buffer); }); async debug(): Promise<{ row: SkinRow; reviews: ReviewRow[]; tweets: TweetDebugData[]; files: FileDebugData[]; archiveFiles: ArchiveFileDebugData[]; iaItem: IaItemDebugData | null; uploadStatuses: UploadDebugData[]; }> { const tweets = await this.getTweets(); const files = await this.getFiles(); const archiveFiles = await this.getArchiveFiles(); const iaItem = await this.getIaItem(); const uploadStatuses = await this.getUploadStatuses(); return { row: this.row, reviews: await this.getReviews(), tweets: await Promise.all(tweets.map((tweet) => tweet.debug())), files: await Promise.all(files.map((file) => file.debug())), archiveFiles: await Promise.all(archiveFiles.map((file) => file.debug())), uploadStatuses: await Promise.all( uploadStatuses.map((upload) => upload.debug()) ), iaItem: iaItem == null ? null : await iaItem.debug(), }; } } const getSkinLoader = ctxWeakMapMemoize>( () => new DataLoader(async (md5s) => { const rows = await knex("skins").whereIn("md5", md5s).select(); return md5s.map((md5) => rows.find((x) => x.md5 === md5)); }) ); const getReviewsLoader = ctxWeakMapMemoize>( () => new DataLoader(async (md5s) => { const rows = await knex("skin_reviews") .whereIn("skin_md5", md5s) .select(); return md5s.map((md5) => rows.filter((x) => x.skin_md5 === md5)); }) ); function mem(fn: () => T): () => T { let cached: T | null = null; return () => { if (cached == null) { cached = fn(); } return cached; }; }