mirror of
https://github.com/captbaritone/webamp.git
synced 2026-07-26 19:47:30 +00:00
Disaggregate root query
This commit is contained in:
parent
c34bffc44a
commit
4dbc326b03
14 changed files with 402 additions and 373 deletions
|
|
@ -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<ModernSkinsConnection> {
|
||||
if (first > 1000) {
|
||||
throw new Error("Maximum limit is 1000");
|
||||
}
|
||||
return new ModernSkinsConnection(first, offset);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<TweetsConnection> {
|
||||
if (first > 1000) {
|
||||
throw new Error("Maximum limit is 1000");
|
||||
}
|
||||
return new TweetsConnection(first, offset, sort);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ArchiveFileResolver | null> {
|
||||
const archiveFile = await ArchiveFileModel.fromFileMd5(ctx, md5);
|
||||
if (archiveFile == null) {
|
||||
return null;
|
||||
}
|
||||
return new ArchiveFileResolver(archiveFile);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/<identifier>/
|
||||
* @gqlField
|
||||
*/
|
||||
export async function fetch_internet_archive_item_by_identifier(
|
||||
_: RootResolver,
|
||||
{ identifier }: { identifier: string },
|
||||
{ ctx }
|
||||
): Promise<InternetArchiveItemResolver | null> {
|
||||
const iaItem = await IaItemModel.fromIdentifier(ctx, identifier);
|
||||
if (iaItem == null) {
|
||||
return null;
|
||||
}
|
||||
return new InternetArchiveItemResolver(iaItem);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ISkin | null> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<NodeResolver | null> {
|
||||
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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<NodeResolver | null> {
|
||||
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<ISkin | null> {
|
||||
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<TweetResolver | null> {
|
||||
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/<identifier>/
|
||||
* @gqlField
|
||||
*/
|
||||
async fetch_internet_archive_item_by_identifier(
|
||||
{ identifier }: { identifier: string },
|
||||
{ ctx }
|
||||
): Promise<InternetArchiveItemResolver | null> {
|
||||
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<ArchiveFileResolver | null> {
|
||||
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<Array<ISkin | null>> {
|
||||
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<ModernSkinsConnection> {
|
||||
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<ISkin | null> {
|
||||
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<TweetsConnection> {
|
||||
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<Array<SkinUpload | null>> {
|
||||
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<Array<SkinUpload | null>> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<Array<ISkin | null>> {
|
||||
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<ISkin | null> {
|
||||
if (!ctx.authed()) {
|
||||
return null;
|
||||
}
|
||||
const { md5 } = await Skins.getSkinToReview();
|
||||
const model = await SkinModel.fromMd5Assert(ctx, md5);
|
||||
return SkinResolver.fromModel(model);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Array<SkinUpload | null>> {
|
||||
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<Array<SkinUpload | null>> {
|
||||
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 };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -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<TweetResolver | null> {
|
||||
const tweet = await TweetModel.fromAnything(ctx, url);
|
||||
if (tweet == null) {
|
||||
return null;
|
||||
}
|
||||
return new TweetResolver(tweet);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/<identifier>/
|
||||
"""
|
||||
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"""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue