diff --git a/packages/skin-database/api/graphql/ModernSkinsConnection.ts b/packages/skin-database/api/graphql/ModernSkinsConnection.ts index 22cd3878..e5894c2d 100644 --- a/packages/skin-database/api/graphql/ModernSkinsConnection.ts +++ b/packages/skin-database/api/graphql/ModernSkinsConnection.ts @@ -2,6 +2,8 @@ import { Int } from "grats"; import SkinModel from "../../data/SkinModel"; import { knex } from "../../db"; import ModernSkinResolver from "./resolvers/ModernSkinResolver"; +import { Root } from "aws-sdk/clients/organizations"; +import RootResolver from "./resolvers/RootResolver"; /** * A collection of "modern" Winamp skins @@ -39,3 +41,22 @@ export default class ModernSkinsConnection { }); } } + +/** + * All modern skins in the database + * @gqlField */ +export async function modern_skins( + _: RootResolver, + { + first = 10, + offset = 0, + }: { + first?: Int; + offset?: Int; + } +): Promise { + if (first > 1000) { + throw new Error("Maximum limit is 1000"); + } + return new ModernSkinsConnection(first, offset); +} diff --git a/packages/skin-database/api/graphql/SkinsConnection.ts b/packages/skin-database/api/graphql/SkinsConnection.ts index 10658b4d..7febf767 100644 --- a/packages/skin-database/api/graphql/SkinsConnection.ts +++ b/packages/skin-database/api/graphql/SkinsConnection.ts @@ -5,6 +5,7 @@ import SkinResolver from "./resolvers/SkinResolver"; import LRU from "lru-cache"; import { Int } from "grats"; import { ISkin } from "./resolvers/CommonSkinResolver"; +import RootResolver from "./resolvers/RootResolver"; const options = { max: 100, @@ -121,3 +122,66 @@ export default class SkinsConnection { }); } } + +/** + * All classic skins in the database + * + * **Note:** We don't currently support combining sorting and filtering. + * @gqlField */ +export function skins( + _: RootResolver, + { + first = 10, + offset = 0, + sort, + filter, + }: { + first?: Int; + offset?: Int; + sort?: SkinsSortOption; + filter?: SkinsFilterOption; + } +): SkinsConnection { + if (first > 1000) { + throw new Error("Maximum limit is 1000"); + } + return new SkinsConnection(first, offset, sort, filter); +} + +/** @gqlEnum */ +type SkinsSortOption = + /** +the Museum's (https://skins.webamp.org) special sorting rules. + +Roughly speaking, it's: + +1. The four classic default skins +2. Tweeted skins first (sorted by the number of likes/retweets) +3. Approved, but not tweeted yet, skins +4. Unreviwed skins +5. Rejected skins +6. NSFW skins +*/ + "MUSEUM"; + +/** @gqlEnum */ +type SkinsFilterOption = + /* +Only the skins that have been approved for tweeting +*/ + | "APPROVED" + + /* +Only the skins that have been rejected for tweeting +*/ + | "REJECTED" + + /* +Only the skins that have been marked NSFW +*/ + | "NSFW" + + /* +Only the skins that have been tweeted +*/ + | "TWEETED"; diff --git a/packages/skin-database/api/graphql/TweetsConnection.ts b/packages/skin-database/api/graphql/TweetsConnection.ts index 11993b3a..c48b859e 100644 --- a/packages/skin-database/api/graphql/TweetsConnection.ts +++ b/packages/skin-database/api/graphql/TweetsConnection.ts @@ -2,6 +2,7 @@ import { Int } from "grats"; import TweetModel from "../../data/TweetModel"; import { knex } from "../../db"; import TweetResolver from "./resolvers/TweetResolver"; +import RootResolver from "./resolvers/RootResolver"; /** @gqlEnum */ export type TweetsSortOption = "LIKES" | "RETWEETS"; @@ -54,3 +55,25 @@ export default class TweetsConnection { }); } } + +/** + * Tweets tweeted by @winampskins + * @gqlField + */ +export async function tweets( + _: RootResolver, + { + first = 10, + offset = 0, + sort, + }: { + first?: Int; + offset?: Int; + sort?: TweetsSortOption; + } +): Promise { + if (first > 1000) { + throw new Error("Maximum limit is 1000"); + } + return new TweetsConnection(first, offset, sort); +} diff --git a/packages/skin-database/api/graphql/resolvers/ArchiveFileResolver.ts b/packages/skin-database/api/graphql/resolvers/ArchiveFileResolver.ts index 41d5e812..67e06c7b 100644 --- a/packages/skin-database/api/graphql/resolvers/ArchiveFileResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/ArchiveFileResolver.ts @@ -3,6 +3,7 @@ import ArchiveFileModel from "../../../data/ArchiveFileModel"; import SkinModel from "../../../data/SkinModel"; import { ISkin } from "./CommonSkinResolver"; import SkinResolver from "./SkinResolver"; +import RootResolver from "./RootResolver"; /** * A file found within a Winamp Skin's .wsz archive @@ -79,3 +80,21 @@ export default class ArchiveFileResolver { return this._model.getFileDate().toISOString(); } } + +/** + * Fetch archive file by it's MD5 hash + * + * Get information about a file found within a skin's wsz/wal/zip archive. + * @gqlField + */ +export async function fetch_archive_file_by_md5( + _: RootResolver, + { md5 }: { md5: string }, + { ctx } +): Promise { + const archiveFile = await ArchiveFileModel.fromFileMd5(ctx, md5); + if (archiveFile == null) { + return null; + } + return new ArchiveFileResolver(archiveFile); +} diff --git a/packages/skin-database/api/graphql/resolvers/DatabaseStatisticsResolver.ts b/packages/skin-database/api/graphql/resolvers/DatabaseStatisticsResolver.ts index d377e381..2205a727 100644 --- a/packages/skin-database/api/graphql/resolvers/DatabaseStatisticsResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/DatabaseStatisticsResolver.ts @@ -1,5 +1,6 @@ import { Int } from "grats"; import * as Skins from "../../../data/skins"; +import RootResolver from "./RootResolver"; /** * Statistics about the contents of the Museum's database. @@ -88,3 +89,10 @@ export default class DatabaseStatisticsResolver { return Skins.getWebUploadsCount(); } } + +/** + * A namespace for statistics about the database + * @gqlField */ +export function statistics(_: RootResolver): DatabaseStatisticsResolver { + return new DatabaseStatisticsResolver(); +} diff --git a/packages/skin-database/api/graphql/resolvers/InternetArchiveItemResolver.ts b/packages/skin-database/api/graphql/resolvers/InternetArchiveItemResolver.ts index 650d0cd1..804b4a5f 100644 --- a/packages/skin-database/api/graphql/resolvers/InternetArchiveItemResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/InternetArchiveItemResolver.ts @@ -1,5 +1,6 @@ import IaItemModel from "../../../data/IaItemModel"; import { ISkin } from "./CommonSkinResolver"; +import RootResolver from "./RootResolver"; import SkinResolver from "./SkinResolver"; /** @gqlType InternetArchiveItem */ @@ -59,3 +60,21 @@ export default class InternetArchiveItemResolver { return SkinResolver.fromModel(skin); } } + +/** + * Get an archive.org item by its identifier. You can find this in the URL: + * + * https://archive.org/details// + * @gqlField + */ +export async function fetch_internet_archive_item_by_identifier( + _: RootResolver, + { identifier }: { identifier: string }, + { ctx } +): Promise { + const iaItem = await IaItemModel.fromIdentifier(ctx, identifier); + if (iaItem == null) { + return null; + } + return new InternetArchiveItemResolver(iaItem); +} diff --git a/packages/skin-database/api/graphql/resolvers/ModernSkinResolver.ts b/packages/skin-database/api/graphql/resolvers/ModernSkinResolver.ts index cfd40967..c3e43b3c 100644 --- a/packages/skin-database/api/graphql/resolvers/ModernSkinResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/ModernSkinResolver.ts @@ -1,3 +1,4 @@ +import SkinResolver from "./SkinResolver"; import SkinModel from "../../../data/SkinModel"; import CommonSkinResolver, { ISkin } from "./CommonSkinResolver"; import { NodeResolver, toId } from "./NodeResolver"; @@ -8,6 +9,7 @@ import InternetArchiveItemResolver from "./InternetArchiveItemResolver"; import ArchiveFileResolver from "./ArchiveFileResolver"; import TweetResolver from "./TweetResolver"; import { XMLParser } from "fast-xml-parser"; +import RootResolver from "./RootResolver"; /** * A "modern" Winamp skin. These skins use the `.wal` file extension and are free-form. @@ -169,3 +171,23 @@ export default class ModernSkinResolver return null; } } + +/** + * Get a skin by its MD5 hash + * @gqlField + */ +export async function fetch_skin_by_md5( + _: RootResolver, + { md5 }: { md5: string }, + { ctx } +): Promise { + const skin = await SkinModel.fromMd5(ctx, md5); + if (skin == null) { + return null; + } + if (skin.getSkinType() === "MODERN") { + return new ModernSkinResolver(skin); + } else { + return SkinResolver.fromModel(skin); + } +} diff --git a/packages/skin-database/api/graphql/resolvers/NodeResolver.ts b/packages/skin-database/api/graphql/resolvers/NodeResolver.ts index 859d1508..75350d56 100644 --- a/packages/skin-database/api/graphql/resolvers/NodeResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/NodeResolver.ts @@ -1,5 +1,9 @@ import { ID } from "grats"; +import SkinModel from "../../../data/SkinModel"; +import SkinResolver from "../resolvers/SkinResolver"; +import RootResolver from "./RootResolver"; + /** * A globally unique object. The `id` here is intended only for use within * GraphQL. @@ -16,6 +20,32 @@ export interface NodeResolver { __typename: string; } +/** + * Get a globally unique object by its ID. + * + * https://graphql.org/learn/global-object-identification/ + * @gqlField + */ +export async function node( + _: RootResolver, + { id }: { id: ID }, + { ctx } +): Promise { + const { graphqlType, id: localId } = fromId(id); + // TODO Use typeResolver + switch (graphqlType) { + case "ClassicSkin": + case "ModernSkin": { + const skin = await SkinModel.fromMd5(ctx, localId); + if (skin == null) { + return null; + } + return SkinResolver.fromModel(skin); + } + } + return null; +} + export function toId(graphqlType: string, id: string) { return Buffer.from(`${graphqlType}__${id}`).toString("base64"); } diff --git a/packages/skin-database/api/graphql/resolvers/RootResolver.ts b/packages/skin-database/api/graphql/resolvers/RootResolver.ts index 06bf9c50..90dc10f1 100644 --- a/packages/skin-database/api/graphql/resolvers/RootResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/RootResolver.ts @@ -1,365 +1,7 @@ -import IaItemModel from "../../../data/IaItemModel"; -import SkinModel from "../../../data/SkinModel"; -import TweetModel from "../../../data/TweetModel"; -import SkinResolver from "../resolvers/SkinResolver"; -import TweetResolver from "../resolvers/TweetResolver"; -import UserResolver from "../resolvers/UserResolver"; -import SkinsConnection from "../SkinsConnection"; -import TweetsConnection, { TweetsSortOption } from "../TweetsConnection"; -import InternetArchiveItemResolver from "./InternetArchiveItemResolver"; -import * as Skins from "../../../data/skins"; -import { ID, Int } from "grats"; - -import algoliasearch from "algoliasearch"; import MutationResolver from "./MutationResolver"; -import { knex } from "../../../db"; -import ArchiveFileModel from "../../../data/ArchiveFileModel"; -import ArchiveFileResolver from "./ArchiveFileResolver"; -import DatabaseStatisticsResolver from "./DatabaseStatisticsResolver"; -import { fromId, NodeResolver } from "./NodeResolver"; -import ModernSkinsConnection from "../ModernSkinsConnection"; -import ModernSkinResolver from "./ModernSkinResolver"; import { ISkin } from "./CommonSkinResolver"; -// These keys are already in the web client, so they are not secret at all. -const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51"); -const index = client.initIndex("Skins"); - /** @gqlType Query */ -class RootResolver extends MutationResolver { - /** - * Get a globally unique object by its ID. - * - * https://graphql.org/learn/global-object-identification/ - * @gqlField - */ - async node({ id }: { id: ID }, { ctx }): Promise { - const { graphqlType, id: localId } = fromId(id); - // TODO Use typeResolver - switch (graphqlType) { - case "ClassicSkin": - case "ModernSkin": { - const skin = await SkinModel.fromMd5(ctx, localId); - if (skin == null) { - return null; - } - return SkinResolver.fromModel(skin); - } - } - return null; - } - - /** - * Get a skin by its MD5 hash - * @gqlField - */ - async fetch_skin_by_md5( - { md5 }: { md5: string }, - { ctx } - ): Promise { - const skin = await SkinModel.fromMd5(ctx, md5); - if (skin == null) { - return null; - } - if (skin.getSkinType() === "MODERN") { - return new ModernSkinResolver(skin); - } else { - return SkinResolver.fromModel(skin); - } - } - - /** - * Get a tweet by its URL - * @gqlField - */ - async fetch_tweet_by_url( - { url }: { url: string }, - { ctx } - ): Promise { - const tweet = await TweetModel.fromAnything(ctx, url); - if (tweet == null) { - return null; - } - return new TweetResolver(tweet); - } - - /** - * Get an archive.org item by its identifier. You can find this in the URL: - * - * https://archive.org/details// - * @gqlField - */ - async fetch_internet_archive_item_by_identifier( - { identifier }: { identifier: string }, - { ctx } - ): Promise { - const iaItem = await IaItemModel.fromIdentifier(ctx, identifier); - if (iaItem == null) { - return null; - } - return new InternetArchiveItemResolver(iaItem); - } - - /** - * Fetch archive file by it's MD5 hash - * - * Get information about a file found within a skin's wsz/wal/zip archive. - * @gqlField - */ - async fetch_archive_file_by_md5( - { md5 }: { md5: string }, - { ctx } - ): Promise { - const archiveFile = await ArchiveFileModel.fromFileMd5(ctx, md5); - if (archiveFile == null) { - return null; - } - return new ArchiveFileResolver(archiveFile); - } - - /** - * Search the database using the Algolia search index used by the Museum. - * - * Useful for locating a particular skin. - * @gqlField - */ - async search_skins( - { - query, - first = 10, - offset = 0, - }: { query: string; first?: Int; offset?: Int }, - { ctx } - ): Promise> { - if (first > 1000) { - throw new Error("Can only query 1000 records via search."); - } - - const results: { hits: { md5: string }[] } = await index.search(query, { - attributesToRetrieve: ["md5"], - length: first, - offset, - }); - - return Promise.all( - results.hits.map(async (hit) => { - const model = await SkinModel.fromMd5Assert(ctx, hit.md5); - return SkinResolver.fromModel(model); - }) - ); - } - - /** - * All classic skins in the database - * - * **Note:** We don't currently support combining sorting and filtering. - * @gqlField */ - skins({ - first = 10, - offset = 0, - sort, - filter, - }: { - first?: Int; - offset?: Int; - sort?: SkinsSortOption; - filter?: SkinsFilterOption; - }): SkinsConnection { - if (first > 1000) { - throw new Error("Maximum limit is 1000"); - } - return new SkinsConnection(first, offset, sort, filter); - } - - /** - * All modern skins in the database - * @gqlField */ - async modern_skins({ - first = 10, - offset = 0, - }: { - first?: Int; - offset?: Int; - }): Promise { - if (first > 1000) { - throw new Error("Maximum limit is 1000"); - } - return new ModernSkinsConnection(first, offset); - } - - /** - * A random skin that needs to be reviewed - * @gqlField */ - async skin_to_review(_args: never, { ctx }): Promise { - if (!ctx.authed()) { - return null; - } - const { md5 } = await Skins.getSkinToReview(); - const model = await SkinModel.fromMd5Assert(ctx, md5); - return SkinResolver.fromModel(model); - } - - /** - * Tweets tweeted by @winampskins - * @gqlField - */ - async tweets({ - first = 10, - offset = 0, - sort, - }: { - first?: Int; - offset?: Int; - sort?: TweetsSortOption; - }): Promise { - if (first > 1000) { - throw new Error("Maximum limit is 1000"); - } - return new TweetsConnection(first, offset, sort); - } - - /** - * The currently authenticated user, if any. - * @gqlField - */ - me(): UserResolver | null { - return new UserResolver(); - } - - /** - * Get the status of a batch of uploads by md5s - * @gqlField - * @deprecated Prefer `upload_statuses` instead, were we operate on ids. - */ - async upload_statuses_by_md5( - { md5s }: { md5s: string[] }, - { ctx } - ): Promise> { - return this._upload_statuses({ keyName: "skin_md5", keys: md5s }, ctx); - } - - /** - * Get the status of a batch of uploads by ids - * @gqlField */ - async upload_statuses( - { ids }: { ids: string[] }, - { ctx } - ): Promise> { - return this._upload_statuses({ keyName: "id", keys: ids }, ctx); - } - - // Shared implementation for upload_statuses and upload_statuses_by_md5 - async _upload_statuses({ keyName, keys }, ctx) { - const skins = await knex("skin_uploads") - .whereIn(keyName, keys) - .orderBy("id", "desc") - .select("id", "skin_md5", "status"); - - return Promise.all( - skins.map(async ({ id, skin_md5, status }) => { - // TODO: Could we avoid fetching the skin if it's not read? - const skinModel = await SkinModel.fromMd5(ctx, skin_md5); - const skin = - skinModel == null ? null : SkinResolver.fromModel(skinModel); - // Most of the time when a skin fails to process, it's due to some infa - // issue on our side, and we can recover. For now, we'll always tell the user - // That processing is just delayed. - status = status === "ERRORED" ? "DELAYED" : status; - return { id, skin, status, upload_md5: skin_md5 }; - }) - ); - } - - /** - * A namespace for statistics about the database - * @gqlField */ - statistics(): DatabaseStatisticsResolver { - return new DatabaseStatisticsResolver(); - } -} - -/** - * Information about an attempt to upload a skin to the Museum. - * @gqlType - */ -type SkinUpload = { - /** @gqlField */ - id: string; - /** @gqlField */ - status: SkinUploadStatus; - /** - * Skin that was uploaded. **Note:** This is null if the skin has not yet been - * fully processed. (status == ARCHIVED) - * @gqlField - */ - skin: ISkin | null; - /** - * Md5 hash given when requesting the upload URL. - * @gqlField - */ - upload_md5: string; -}; - -/** @gqlEnum */ -type SkinsSortOption = - /** -the Museum's (https://skins.webamp.org) special sorting rules. - -Roughly speaking, it's: - -1. The four classic default skins -2. Tweeted skins first (sorted by the number of likes/retweets) -3. Approved, but not tweeted yet, skins -4. Unreviwed skins -5. Rejected skins -6. NSFW skins -*/ - "MUSEUM"; - -/** @gqlEnum */ -type SkinsFilterOption = - /* -Only the skins that have been approved for tweeting -*/ - | "APPROVED" - - /* -Only the skins that have been rejected for tweeting -*/ - | "REJECTED" - - /* -Only the skins that have been marked NSFW -*/ - | "NSFW" - - /* -Only the skins that have been tweeted -*/ - | "TWEETED"; - -/** - * The current status of a pending upload. - * - * **Note:** Expect more values here as we try to be more transparent about - * the status of a pending uploads. - * @gqlEnum - */ -type SkinUploadStatus = - /** The user has requested a URL, but the skin has not yet been processed. */ - | "URL_REQUESTED" - /** The user has notified us that the skin has been uploaded, but we haven't yet - processed it. */ - | "UPLOAD_REPORTED" - /** An error occured processing the skin. Usually this is a transient error, and - the skin will be retried at a later time. */ - | "ERRORED" - /** An error occured processing the skin, but it was the fault of the server. It - will be processed at a later date. */ - | "DELAYED" - /** The skin has been successfully added to the Museum. - * @deprecated - */ - | "ARCHIVED"; +class RootResolver extends MutationResolver {} export default RootResolver; diff --git a/packages/skin-database/api/graphql/resolvers/SkinResolver.ts b/packages/skin-database/api/graphql/resolvers/SkinResolver.ts index 60f82c7d..ab929464 100644 --- a/packages/skin-database/api/graphql/resolvers/SkinResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/SkinResolver.ts @@ -1,8 +1,17 @@ +import * as Skins from "../../../data/skins"; import SkinModel from "../../../data/SkinModel"; import UserContext from "../../../data/UserContext"; import ClassicSkinResolver from "./ClassicSkinResolver"; import { ISkin } from "./CommonSkinResolver"; import ModernSkinResolver from "./ModernSkinResolver"; +import RootResolver from "./RootResolver"; +import { Int } from "grats"; + +import algoliasearch from "algoliasearch"; + +// These keys are already in the web client, so they are not secret at all. +const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51"); +const index = client.initIndex("Skins"); export default class SkinResolver { constructor() { @@ -23,3 +32,52 @@ export default class SkinResolver { } } } + +/** + * Search the database using the Algolia search index used by the Museum. + * + * Useful for locating a particular skin. + * @gqlField + */ +export async function search_skins( + _: RootResolver, + { + query, + first = 10, + offset = 0, + }: { query: string; first?: Int; offset?: Int }, + { ctx } +): Promise> { + if (first > 1000) { + throw new Error("Can only query 1000 records via search."); + } + + const results: { hits: { md5: string }[] } = await index.search(query, { + attributesToRetrieve: ["md5"], + length: first, + offset, + }); + + return Promise.all( + results.hits.map(async (hit) => { + const model = await SkinModel.fromMd5Assert(ctx, hit.md5); + return SkinResolver.fromModel(model); + }) + ); +} + +/** + * A random skin that needs to be reviewed + * @gqlField */ +export async function skin_to_review( + _: RootResolver, + _args: never, + { ctx } +): Promise { + if (!ctx.authed()) { + return null; + } + const { md5 } = await Skins.getSkinToReview(); + const model = await SkinModel.fromMd5Assert(ctx, md5); + return SkinResolver.fromModel(model); +} diff --git a/packages/skin-database/api/graphql/resolvers/SkinUploadResolver.ts b/packages/skin-database/api/graphql/resolvers/SkinUploadResolver.ts new file mode 100644 index 00000000..7da07446 --- /dev/null +++ b/packages/skin-database/api/graphql/resolvers/SkinUploadResolver.ts @@ -0,0 +1,96 @@ +import SkinModel from "../../../data/SkinModel"; +import SkinResolver from "../resolvers/SkinResolver"; +import { knex } from "../../../db"; +import { ISkin } from "./CommonSkinResolver"; +import RootResolver from "./RootResolver"; + +/** + * Information about an attempt to upload a skin to the Museum. + * @gqlType + */ +type SkinUpload = { + /** @gqlField */ + id: string; + /** @gqlField */ + status: SkinUploadStatus; + /** + * Skin that was uploaded. **Note:** This is null if the skin has not yet been + * fully processed. (status == ARCHIVED) + * @gqlField + */ + skin: ISkin | null; + /** + * Md5 hash given when requesting the upload URL. + * @gqlField + */ + upload_md5: string; +}; + +/** + * The current status of a pending upload. + * + * **Note:** Expect more values here as we try to be more transparent about + * the status of a pending uploads. + * @gqlEnum + */ +type SkinUploadStatus = + /** The user has requested a URL, but the skin has not yet been processed. */ + | "URL_REQUESTED" + /** The user has notified us that the skin has been uploaded, but we haven't yet + processed it. */ + | "UPLOAD_REPORTED" + /** An error occured processing the skin. Usually this is a transient error, and + the skin will be retried at a later time. */ + | "ERRORED" + /** An error occured processing the skin, but it was the fault of the server. It + will be processed at a later date. */ + | "DELAYED" + /** The skin has been successfully added to the Museum. + * @deprecated + */ + | "ARCHIVED"; + +/** + * Get the status of a batch of uploads by md5s + * @gqlField + * @deprecated Prefer `upload_statuses` instead, were we operate on ids. + */ +export async function upload_statuses_by_md5( + _: RootResolver, + { md5s }: { md5s: string[] }, + { ctx } +): Promise> { + return _upload_statuses({ keyName: "skin_md5", keys: md5s }, ctx); +} + +/** + * Get the status of a batch of uploads by ids + * @gqlField */ +export async function upload_statuses( + _: RootResolver, + { ids }: { ids: string[] }, + { ctx } +): Promise> { + return _upload_statuses({ keyName: "id", keys: ids }, ctx); +} + +// Shared implementation for upload_statuses and upload_statuses_by_md5 +async function _upload_statuses({ keyName, keys }, ctx) { + const skins = await knex("skin_uploads") + .whereIn(keyName, keys) + .orderBy("id", "desc") + .select("id", "skin_md5", "status"); + + return Promise.all( + skins.map(async ({ id, skin_md5, status }) => { + // TODO: Could we avoid fetching the skin if it's not read? + const skinModel = await SkinModel.fromMd5(ctx, skin_md5); + const skin = skinModel == null ? null : SkinResolver.fromModel(skinModel); + // Most of the time when a skin fails to process, it's due to some infa + // issue on our side, and we can recover. For now, we'll always tell the user + // That processing is just delayed. + status = status === "ERRORED" ? "DELAYED" : status; + return { id, skin, status, upload_md5: skin_md5 }; + }) + ); +} diff --git a/packages/skin-database/api/graphql/resolvers/TweetResolver.ts b/packages/skin-database/api/graphql/resolvers/TweetResolver.ts index ef440562..e59ecab5 100644 --- a/packages/skin-database/api/graphql/resolvers/TweetResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/TweetResolver.ts @@ -2,6 +2,7 @@ import { Int } from "grats"; import TweetModel from "../../../data/TweetModel"; import { ISkin } from "./CommonSkinResolver"; import SkinResolver from "./SkinResolver"; +import RootResolver from "./RootResolver"; /** * A tweet made by @winampskins mentioning a Winamp skin @@ -48,3 +49,19 @@ export default class TweetResolver { return SkinResolver.fromModel(skin); } } + +/** + * Get a tweet by its URL + * @gqlField + */ +export async function fetch_tweet_by_url( + _: RootResolver, + { url }: { url: string }, + { ctx } +): Promise { + const tweet = await TweetModel.fromAnything(ctx, url); + if (tweet == null) { + return null; + } + return new TweetResolver(tweet); +} diff --git a/packages/skin-database/api/graphql/resolvers/UserResolver.ts b/packages/skin-database/api/graphql/resolvers/UserResolver.ts index b8eb894e..f3f50bad 100644 --- a/packages/skin-database/api/graphql/resolvers/UserResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/UserResolver.ts @@ -1,3 +1,5 @@ +import RootResolver from "./RootResolver"; + /** @gqlType User */ export default class UserResolver { /** @gqlField */ @@ -5,3 +7,11 @@ export default class UserResolver { return ctx.username; } } + +/** + * The currently authenticated user, if any. + * @gqlField + */ +export function me(_: RootResolver): UserResolver | null { + return new UserResolver(); +} diff --git a/packages/skin-database/api/graphql/schema.graphql b/packages/skin-database/api/graphql/schema.graphql index d0ee903a..a2d63337 100644 --- a/packages/skin-database/api/graphql/schema.graphql +++ b/packages/skin-database/api/graphql/schema.graphql @@ -264,49 +264,49 @@ type Query { Get information about a file found within a skin's wsz/wal/zip archive. """ - fetch_archive_file_by_md5(md5: String!): ArchiveFile + fetch_archive_file_by_md5(md5: String!): ArchiveFile @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/ArchiveFileResolver.js", functionName: "fetch_archive_file_by_md5") """ Get an archive.org item by its identifier. You can find this in the URL: https://archive.org/details// """ - fetch_internet_archive_item_by_identifier(identifier: String!): InternetArchiveItem + fetch_internet_archive_item_by_identifier(identifier: String!): InternetArchiveItem @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/InternetArchiveItemResolver.js", functionName: "fetch_internet_archive_item_by_identifier") """Get a skin by its MD5 hash""" - fetch_skin_by_md5(md5: String!): Skin + fetch_skin_by_md5(md5: String!): Skin @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/ModernSkinResolver.js", functionName: "fetch_skin_by_md5") """Get a tweet by its URL""" - fetch_tweet_by_url(url: String!): Tweet + fetch_tweet_by_url(url: String!): Tweet @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/TweetResolver.js", functionName: "fetch_tweet_by_url") """The currently authenticated user, if any.""" - me: User + me: User @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/UserResolver.js", functionName: "me") """All modern skins in the database""" - modern_skins(first: Int = 10, offset: Int = 0): ModernSkinsConnection + modern_skins(first: Int = 10, offset: Int = 0): ModernSkinsConnection @exported(filename: "../../../../packages/skin-database/dist/api/graphql/ModernSkinsConnection.js", functionName: "modern_skins") """ Get a globally unique object by its ID. https://graphql.org/learn/global-object-identification/ """ - node(id: ID!): Node + node(id: ID!): Node @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/NodeResolver.js", functionName: "node") """ Search the database using the Algolia search index used by the Museum. Useful for locating a particular skin. """ - search_skins(first: Int = 10, offset: Int = 0, query: String!): [Skin] + search_skins(first: Int = 10, offset: Int = 0, query: String!): [Skin] @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/SkinResolver.js", functionName: "search_skins") """A random skin that needs to be reviewed""" - skin_to_review: Skin + skin_to_review: Skin @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/SkinResolver.js", functionName: "skin_to_review") """ All classic skins in the database **Note:** We don't currently support combining sorting and filtering. """ - skins(filter: SkinsFilterOption, first: Int = 10, offset: Int = 0, sort: SkinsSortOption): SkinsConnection + skins(filter: SkinsFilterOption, first: Int = 10, offset: Int = 0, sort: SkinsSortOption): SkinsConnection @exported(filename: "../../../../packages/skin-database/dist/api/graphql/SkinsConnection.js", functionName: "skins") """A namespace for statistics about the database""" - statistics: DatabaseStatistics + statistics: DatabaseStatistics @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/DatabaseStatisticsResolver.js", functionName: "statistics") """Tweets tweeted by @winampskins""" - tweets(first: Int = 10, offset: Int = 0, sort: TweetsSortOption): TweetsConnection + tweets(first: Int = 10, offset: Int = 0, sort: TweetsSortOption): TweetsConnection @exported(filename: "../../../../packages/skin-database/dist/api/graphql/TweetsConnection.js", functionName: "tweets") """Get the status of a batch of uploads by ids""" - upload_statuses(ids: [String!]!): [SkinUpload] + upload_statuses(ids: [String!]!): [SkinUpload] @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/SkinUploadResolver.js", functionName: "upload_statuses") """Get the status of a batch of uploads by md5s""" - upload_statuses_by_md5(md5s: [String!]!): [SkinUpload] @deprecated(reason: "Prefer `upload_statuses` instead, were we operate on ids.") + upload_statuses_by_md5(md5s: [String!]!): [SkinUpload] @deprecated(reason: "Prefer `upload_statuses` instead, were we operate on ids.") @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/SkinUploadResolver.js", functionName: "upload_statuses_by_md5") } """The judgement made about a skin by a moderator"""