From 72bd7a2a535494583d7ff750dc5d3cdacc7e97de Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Sun, 20 Mar 2022 22:20:15 -0700 Subject: [PATCH] Sart to expose modern skins via graphql --- .../api/graphql/ModernSkinsConnection.ts | 32 +++++++ .../graphql/resolvers/ModernSkinResolver.ts | 27 ++++++ .../api/graphql/resolvers/RootResolver.ts | 23 ++++- .../skin-database/api/graphql/schema.graphql | 93 ++++++++++++++++++- packages/skin-database/data/SkinModel.ts | 38 ++++++-- packages/skin-database/data/skins.ts | 4 + 6 files changed, 206 insertions(+), 11 deletions(-) create mode 100644 packages/skin-database/api/graphql/ModernSkinsConnection.ts create mode 100644 packages/skin-database/api/graphql/resolvers/ModernSkinResolver.ts diff --git a/packages/skin-database/api/graphql/ModernSkinsConnection.ts b/packages/skin-database/api/graphql/ModernSkinsConnection.ts new file mode 100644 index 00000000..d7dd40a2 --- /dev/null +++ b/packages/skin-database/api/graphql/ModernSkinsConnection.ts @@ -0,0 +1,32 @@ +import SkinModel from "../../data/SkinModel"; +import { knex } from "../../db"; +import ModernSkinResolver from "./resolvers/ModernSkinResolver"; + + +export default class ModernSkinsConnection { + _first: number; + _offset: number; + constructor(first: number, offset: number) { + this._first = first; + this._offset = offset; + } + _getQuery() { + let query = knex("skins").where({ skin_type: 2 }); + return query; + } + + async count() { + const count = await this._getQuery().count("*", { as: "count" }); + return count[0].count; + } + + async nodes(_args, ctx) { + const skins = await this._getQuery() + .select() + .limit(this._first) + .offset(this._offset); + return skins.map((skin) => { + return new ModernSkinResolver(new SkinModel(ctx, skin)); + }); + } +} diff --git a/packages/skin-database/api/graphql/resolvers/ModernSkinResolver.ts b/packages/skin-database/api/graphql/resolvers/ModernSkinResolver.ts new file mode 100644 index 00000000..6c5ad69f --- /dev/null +++ b/packages/skin-database/api/graphql/resolvers/ModernSkinResolver.ts @@ -0,0 +1,27 @@ +import SkinModel from "../../../data/SkinModel"; +import ArchiveFileResolver from "./ArchiveFileResolver"; +import { NodeResolver, toId } from "./NodeResolver"; + +export default class ModernSkinResolver implements NodeResolver { + _model: SkinModel; + constructor(model: SkinModel) { + this._model = model; + } + __typename = "ModernSkin"; + async id() { + return toId(this.__typename, this.md5()); + } + md5() { + return this._model.getMd5(); + } + filename() { + return this._model.getFileName(); + } + download_url() { + return this._model.getSkinUrl(); + } + async archive_files() { + const files = await this._model.getArchiveFiles(); + return files.map((file) => new ArchiveFileResolver(file)); + } +} diff --git a/packages/skin-database/api/graphql/resolvers/RootResolver.ts b/packages/skin-database/api/graphql/resolvers/RootResolver.ts index d212f966..c96ec0fd 100644 --- a/packages/skin-database/api/graphql/resolvers/RootResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/RootResolver.ts @@ -16,6 +16,8 @@ import ArchiveFileModel from "../../../data/ArchiveFileModel"; import ArchiveFileResolver from "./ArchiveFileResolver"; import DatabaseStatisticsResolver from "./DatabaseStatisticsResolver"; import { fromId } from "./NodeResolver"; +import ModernSkinsConnection from "../ModernSkinsConnection"; +import ModernSkinResolver from "./ModernSkinResolver"; // These keys are already in the web client, so they are not secret at all. const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51"); @@ -33,6 +35,13 @@ class RootResolver extends MutationResolver { } return new SkinResolver(skin); } + case "ModernSkin": { + const skin = await SkinModel.fromMd5(ctx, localId); + if (skin == null) { + return null; + } + return new ModernSkinResolver(skin); + } } return null; } @@ -41,7 +50,11 @@ class RootResolver extends MutationResolver { if (skin == null) { return null; } - return new SkinResolver(skin); + if (skin.getSkinType() === "MODERN") { + return new ModernSkinResolver(skin); + } else { + return new SkinResolver(skin); + } } async fetch_tweet_by_url({ url }, { ctx }) { const tweet = await TweetModel.fromAnything(ctx, url); @@ -90,6 +103,14 @@ class RootResolver extends MutationResolver { } return new SkinsConnection(first, offset, sort, filter); } + + async modern_skins({ first, offset }) { + if (first > 1000) { + throw new Error("Maximum limit is 1000"); + } + return new ModernSkinsConnection(first, offset); + } + async skin_to_review(_args, { ctx }) { if (!ctx.authed()) { return null; diff --git a/packages/skin-database/api/graphql/schema.graphql b/packages/skin-database/api/graphql/schema.graphql index cd77934b..2c970c5a 100644 --- a/packages/skin-database/api/graphql/schema.graphql +++ b/packages/skin-database/api/graphql/schema.graphql @@ -7,10 +7,77 @@ interface Node { id: ID! } +""" +A Winamp skin. Could be modern or classic. + +**Note**: At some point in the future, this might be renamed to `Skin`. +""" +interface GenericSkin { + """ + GraphQL ID of the skin + """ + id: ID! + + """ + MD5 hash of the skin's file + """ + md5: String + + """ + URL to download the skin + """ + download_url: String + + """ + Filename of skin when uploaded to the Museum. Note: In some cases a skin + has been uploaded under multiple names. Here we just pick one. + """ + filename: String + + """ + List of files contained within the skin's .wsz/.wal archive + """ + archive_files: [ArchiveFile] +} + +""" +A "modern" Winamp skin. These skins use the `.wal` file extension and are free-form. + +Most functionality in the Winamp Skin Museum is centered around "classic" skins, +which are currently called just `Skin` in this schema. +""" +type ModernSkin implements GenericSkin & Node { + """ + GraphQL ID of the skin + """ + id: ID! + + """ + MD5 hash of the skin's file + """ + md5: String + + """ + URL to download the skin + """ + download_url: String + + """ + Filename of skin when uploaded to the Museum. Note: In some cases a skin + has been uploaded under multiple names. Here we just pick one. + """ + filename: String + + """ + List of files contained within the skin's .wsz archive + """ + archive_files: [ArchiveFile] +} + """ A classic Winamp skin """ -type Skin implements Node { +type Skin implements GenericSkin & Node { """ GraphQL ID of the skin """ @@ -89,6 +156,21 @@ type Skin implements Node { reviews: [Review] } +""" +A collection of "modern" Winamp skins +""" +type ModernSkinsConnection { + """ + The total number of skins matching the filter + """ + count: Int + + """ + The list of skins + """ + nodes: [ModernSkin] +} + """ The judgement made about a skin by a moderator """ @@ -523,7 +605,7 @@ type Query { """ Get a skin by its MD5 hash """ - fetch_skin_by_md5(md5: String!): Skin + fetch_skin_by_md5(md5: String!): GenericSkin """ Get a tweet by its URL @@ -552,7 +634,7 @@ type Query { ): InternetArchiveItem """ - All skins in the database + All classic skins in the database **Note:** We don't currently support combining sorting and filtering. """ @@ -563,6 +645,11 @@ type Query { filter: SkinsFilterOption ): SkinsConnection + """ + All modern skins in the database + """ + modern_skins(first: Int = 10, offset: Int = 0): ModernSkinsConnection + """ Search the database using the Algolia search index used by the Museum. diff --git a/packages/skin-database/data/SkinModel.ts b/packages/skin-database/data/SkinModel.ts index 2b20d6f4..28c4f37c 100644 --- a/packages/skin-database/data/SkinModel.ts +++ b/packages/skin-database/data/SkinModel.ts @@ -1,4 +1,3 @@ -import { getScreenshotUrl, getSkinUrl } from "./skins"; import { TweetStatus, SkinRow, @@ -27,7 +26,7 @@ export const IS_NOT_README = /(genex\.txt)|(genexinfo\.txt)|(gen_gslyrics\.txt)|(region\.txt)|(pledit\.txt)|(viscolor\.txt)|(winampmb\.txt)|("gen_ex help\.txt)|(mbinner\.txt)$/i; export default class SkinModel { - constructor(readonly ctx: UserContext, readonly row: SkinRow) {} + constructor(readonly ctx: UserContext, readonly row: SkinRow) { } static async fromMd5( ctx: UserContext, @@ -163,13 +162,16 @@ export default class SkinModel { throw new Error(`Could not find file for skin with md5 ${this.getMd5()}`); } const filename = files[0].getFileName(); - if (!filename.match(/\.(zip)|(wsz)$/i)) { - throw new Error("Expected filename to end with zip or wsz."); + if (!filename.match(/\.(zip)|(wsz)|(wal)$/i)) { + throw new Error("Expected filename to end with zip, wsz or wal."); } 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"); } @@ -197,16 +199,31 @@ export default class SkinModel { } 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 { - return getScreenshotUrl(this.row.md5); + if (this.getSkinType() === "MODERN") { + throw new Error("Modern skins do not have screenshots."); + } + return Skins.getScreenshotUrl(this.row.md5); } getSkinUrl(): string { - return getSkinUrl(this.row.md5); + switch (this.getSkinType()) { + case "CLASSIC": + return Skins.getSkinUrl(this.row.md5); + case "MODERN": + return Skins.getModernSkinUrl(this.row.md5); + + } } getAverageColor(): string { @@ -215,10 +232,11 @@ export default class SkinModel { 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() + ".wsz" + this.getMd5() + ext ); return fs.readFile(skinPath); } else { @@ -231,6 +249,9 @@ export default class SkinModel { }); 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( @@ -249,6 +270,9 @@ export default class SkinModel { 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); } diff --git a/packages/skin-database/data/skins.ts b/packages/skin-database/data/skins.ts index f88f830f..8df3bb4d 100644 --- a/packages/skin-database/data/skins.ts +++ b/packages/skin-database/data/skins.ts @@ -21,6 +21,10 @@ export function getSkinUrl(md5: string): string { return `https://cdn.webampskins.org/skins/${md5}.wsz`; } +export function getModernSkinUrl(md5: string): string { + return `https://cdn.webampskins.org/skins/${md5}.wal`; +} + export function getScreenshotUrl(md5: string): string { return `https://cdn.webampskins.org/screenshots/${md5}.png`; }