Schma parity between generated an Grats

This commit is contained in:
Jordan Eldredge 2023-04-01 23:37:19 -07:00
parent 59a1ed88db
commit 7c4e9258f0
16 changed files with 770 additions and 210 deletions

View file

@ -3,7 +3,9 @@ import SkinModel from "../../data/SkinModel";
import { knex } from "../../db";
import ModernSkinResolver from "./resolvers/ModernSkinResolver";
/** @gqlType */
/**
* A collection of "modern" Winamp skins
* @gqlType */
export default class ModernSkinsConnection {
_first: number;
_offset: number;
@ -16,14 +18,18 @@ export default class ModernSkinsConnection {
return query;
}
/** @gqlField */
/**
* The total number of skins matching the filter
* @gqlField */
async count(): Promise<Int> {
const count = await this._getQuery().count("*", { as: "count" });
return Number(count[0].count);
}
/** @gqlField */
async nodes(_args: never, ctx): Promise<ModernSkinResolver[]> {
/**
* The list of skins
* @gqlField */
async nodes(_args: never, ctx): Promise<Array<ModernSkinResolver | null>> {
const skins = await this._getQuery()
.select()
.limit(this._first)

View file

@ -36,7 +36,10 @@ async function getSkinMuseumPageFromCache(first: number, offset: number) {
return skins;
}
/** @gqlType */
/**
* A collection of classic Winamp skins
* @gqlType
*/
export default class SkinsConnection {
_first: number;
_offset: number;
@ -76,7 +79,10 @@ export default class SkinsConnection {
return query;
}
/** @gqlField */
/**
* The total number of skins matching the filter
* @gqlField
*/
async count(): Promise<Int> {
if (this._sort === "MUSEUM") {
// This is the common case, so serve it from cache.
@ -86,8 +92,11 @@ export default class SkinsConnection {
return Number(count[0].count);
}
/** @gqlField */
async nodes(args: never, ctx): Promise<ISkin[]> {
/**
* The list of skins
* @gqlField
*/
async nodes(args: never, ctx): Promise<Array<ISkin | null>> {
if (this._sort === "MUSEUM") {
if (this._filter) {
throw new Error(

View file

@ -6,7 +6,10 @@ import TweetResolver from "./resolvers/TweetResolver";
/** @gqlEnum */
export type TweetsSortOption = "LIKES" | "RETWEETS";
/** @gqlType */
/**
* A collection of tweets made by the @winampskins bot
* @gqlType
*/
export default class TweetsConnection {
_first: number;
_offset: number;
@ -41,7 +44,7 @@ export default class TweetsConnection {
* The list of tweets
* @gqlField
*/
async nodes(args: never, ctx): Promise<TweetResolver[]> {
async nodes(args: never, ctx): Promise<Array<TweetResolver | null>> {
const tweets = await this._getQuery()
.select()
.limit(this._first)

View file

@ -7,89 +7,245 @@ directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITIO
directive @methodName(name: String!) on FIELD_DEFINITION
"""A file found within a Winamp Skin's .wsz archive"""
type ArchiveFile {
"""
The date on the file inside the archive. Given in simplified extended ISO
format (ISO 8601).
"""
date: String
"""The md5 hash of the file within the archive"""
file_md5: String
"""Filename of the file within the archive"""
filename: String
"""Is the file a directory?"""
is_directory: Boolean
"""
The uncompressed size of the file in bytes.
**Note:** Will be `null` for directories
"""
size: Int
"""The skin in which this file was found"""
skin: Skin
"""The content of the file, if it's a text file"""
text_content: String
"""
A URL to download the file. **Note:** This is powered by a little
serverless Cloudflare function which tries to exctact the file on the fly.
It may not work for all files.
"""
url: String
}
"""A classic Winamp skin"""
type ClassicSkin implements Node & Skin {
archive_files: [ArchiveFile!]
"""List of files contained within the skin's .wsz archive"""
archive_files: [ArchiveFile]
"""String representation (rgb usually) of the skin's average color"""
average_color: String
"""URL to download the skin"""
download_url: String
filename(normalize_extension: Boolean = false): 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(
"""
If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used.
"""
normalize_extension: Boolean = false
): String
"""Does the skin include sprite sheets for the media library?"""
has_media_library: Boolean
"""GraphQL ID of the skin"""
id: ID!
"""The skin's "item" at archive.org"""
internet_archive_item: InternetArchiveItem
"""
The date on which this skin was last updated in the Algolia search index.
Given in simplified extended ISO format (ISO 8601).
"""
last_algolia_index_update_date: String
"""MD5 hash of the skin's file"""
md5: String
"""URL of the skin on the Winamp Skin Museum"""
museum_url: String
"""Has the skin been flagged as "not safe for wrok"?"""
nsfw: Boolean
"""Text of the readme file extracted from the skin"""
readme_text: String
reviews: [Review!]
"""
Times that the skin has been reviewed either on the Museum's Tinder-style
reivew page, or via the Discord bot.
"""
reviews: [Review]
"""URL of a screenshot of the skin"""
screenshot_url: String
"""The number of transparent pixels rendered by the skin."""
transparent_pixels: Int
"""Has the skin been tweeted?"""
tweeted: Boolean
tweets: [Tweet!]
"""List of @winampskins tweets that mentioned the skin."""
tweets: [Tweet]
"""URL of webamp.org with the skin loaded"""
webamp_url: String
}
"""Statistics about the contents of the Museum's database."""
type DatabaseStatistics {
"""
The number of skins that have been approved for tweeting. This includes both
tweeted and untweeted skins.
**Note:** Skins can be both approved and rejected by different users.
"""
approved_skins_count: Int
"""
The number of skins that have been marked as NSFW.
**Note:** Skins can be approved and rejected by different users.
**Note:** Generally skins that have been marked NSFW are also marked as rejected.
"""
nsfw_skins_count: Int
"""
The number of skins that have been rejected for tweeting.
**Note:** Skins can be both approved and rejected by different users.
**Note:** Generally skins that have been marked NSFW are also marked as rejected.
"""
rejected_skins_count: Int
"""
The number of skins that have been approved for tweeting, but not yet tweeted.
"""
tweetable_skins_count: Int
"""
The number of skins in the Museum that have been tweeted by @winampskins
"""
tweeted_skins_count: Int
"""The total number of classic skins in the Museum's database"""
unique_classic_skins_count: Int
"""The number of skins that have never been reviewed."""
unreviewed_skins_count: Int
"""Skins uploads that have errored during processing."""
uploads_in_error_state_count: Int
"""
Skins uplaods awaiting processing. This can happen when there are a large
number of skin uplaods at the same time, or when the skin uploading processing
pipeline gets stuck.
"""
uploads_pending_processing_count: Int
"""
Number of skins that have been uploaded to the Museum via the web interface.
"""
web_uploads_count: Int
}
type InternetArchiveItem {
"""The Internet Archive's unique identifier for this item"""
identifier: String
"""
The date and time that we last scraped this item's metadata.
**Note:** This field is temporary and will be removed in the future.
The date format is just what we get from the database, and it's ambiguous.
"""
last_metadata_scrape_date_UNSTABLE: String
"""URL to get the Internet Archive's metadata for this item in JSON form."""
metadata_url: String
"""
Our cached version of the metadata avaliable at \`metadata_url\` (above)
"""
raw_metadata_json: String
"""The skin that this item contains"""
skin: Skin
"""The URL where this item can be viewed on the Internet Archive"""
url: String
}
"""
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 Node & Skin {
archive_files: [ArchiveFile!]
average_color: String
"""List of files contained within the skin's .wsz archive"""
archive_files: [ArchiveFile]
average_color: String @deprecated(reason: "Needed for migration")
"""URL to download the skin"""
download_url: String
filename(normalize_extension: Boolean = false): 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(
"""
If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used.
"""
normalize_extension: Boolean = false
): String
"""GraphQL ID of the skin"""
id: ID!
"""The skin's "item" at archive.org"""
internet_archive_item: InternetArchiveItem
"""MD5 hash of the skin's file"""
md5: String
museum_url: String
nsfw: Boolean
readme_text: String
reviews: [Review!]
screenshot_url: String
museum_url: String @deprecated(reason: "Needed for migration")
nsfw: Boolean @deprecated(reason: "Needed for migration")
readme_text: String @deprecated(reason: "Needed for migration")
"""
Times that the skin has been reviewed either on the Museum's Tinder-style
reivew page, or via the Discord bot.
"""
reviews: [Review]
screenshot_url: String @deprecated(reason: "Needed for migration")
"""Has the skin been tweeted?"""
tweeted: Boolean
tweets: [Tweet!]
webamp_url: String
"""List of @winampskins tweets that mentioned the skin."""
tweets: [Tweet]
webamp_url: String @deprecated(reason: "Needed for migration")
}
"""A collection of "modern" Winamp skins"""
type ModernSkinsConnection {
"""The total number of skins matching the filter"""
count: Int
nodes: [ModernSkin!]
"""The list of skins"""
nodes: [ModernSkin]
}
type Mutation {
"""
Approve skin for tweeting
**Note:** Requires being logged in
"""
approve_skin(md5: String!): Boolean
"""
Mark a skin as NSFW
**Note:** Requires being logged in
"""
mark_skin_nsfw(md5: String!): Boolean
"""
Reject skin for tweeting
**Note:** Requires being logged in
"""
reject_skin(md5: String!): Boolean
"""
Request that an admin check if this skin is NSFW.
Unlike other review mutaiton endpoints, this one does not require being logged
in.
"""
request_nsfw_review_for_skin(md5: String!): Boolean
send_feedback(email: String, message: String, url: String): Boolean
"""
Send a message to the admin of the site. Currently this appears in Discord.
"""
send_feedback(email: String, message: String!, url: String): Boolean
"""Mutations for the upload flow"""
upload: UploadMutations
}
@ -105,11 +261,13 @@ interface Node {
type Query {
"""
Fetch archive file by it's MD5 hash
Get information about a file found within a skin's wsz/wal/zip archive.
"""
fetch_archive_file_by_md5(md5: String!): ArchiveFile
"""
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
@ -119,9 +277,11 @@ type Query {
fetch_tweet_by_url(url: String!): Tweet
"""The currently authenticated user, if any."""
me: User
"""All modern skins in the database"""
modern_skins(first: Int = 10, offset: Int = 0): ModernSkinsConnection
"""
Get a globally unique object by its ID.
https://graphql.org/learn/global-object-identification/
"""
node(id: ID!): Node
@ -130,17 +290,26 @@ type Query {
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]
"""A random skin that needs to be reviewed"""
skin_to_review: Skin
"""
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
"""A namespace for statistics about the database"""
statistics: DatabaseStatistics
"""Tweets tweeted by @winampskins"""
tweets(first: Int = 10, offset: Int = 0, sort: TweetsSortOption): TweetsConnection
upload_statuses(ids: [String!]!): [SkinUpload!]
"""Get the status of a batch of uploads by ids"""
upload_statuses(ids: [String!]!): [SkinUpload]
"""Get the status of a batch of uploads by md5s"""
upload_statuses_by_md5(md5s: [String!]!): [SkinUpload!]
upload_statuses_by_md5(md5s: [String!]!): [SkinUpload] @deprecated(reason: "Prefer `upload_statuses` instead, were we operate on ids.")
}
"""The judgement made about a skin by a moderator"""
enum Rating {
APPROVED
NSFW
@ -155,41 +324,74 @@ type Review {
"""The rating that the user gave the skin"""
rating: Rating
"""
The user who made the review (if known). **Note:** In the early days we
didn't track this, so many will be null.
The user who made the review (if known). **Note:** In the early days we didn't
track this, so many will be null.
"""
reviewer: String
"""The skin that was reviewed"""
skin: Skin
}
"""
A Winamp skin. Could be modern or classic.
**Note**: At some point in the future, this might be renamed to `Skin`.
"""
interface Skin {
archive_files: [ArchiveFile!]
average_color: String @deprecated(reason: "Needed for migration to new skin model")
"""List of files contained within the skin's .wsz archive"""
archive_files: [ArchiveFile]
average_color: String @deprecated(reason: "Needed for migration")
"""URL to download the skin"""
download_url: String
filename(normalize_extension: Boolean = false): 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(
"""
If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used.
"""
normalize_extension: Boolean = false
): String
"""GraphQL ID of the skin"""
id: ID!
"""The skin's "item" at archive.org"""
internet_archive_item: InternetArchiveItem
"""MD5 hash of the skin's file"""
md5: String
museum_url: String @deprecated(reason: "Needed for migration to new skin model")
nsfw: Boolean @deprecated(reason: "Needed for migration to new skin model")
readme_text: String @deprecated(reason: "Needed for migration to new skin model")
reviews: [Review!]
screenshot_url: String @deprecated(reason: "Needed for migration to new skin model")
museum_url: String @deprecated(reason: "Needed for migration")
nsfw: Boolean @deprecated(reason: "Needed for migration")
readme_text: String @deprecated(reason: "Needed for migration")
"""
Times that the skin has been reviewed either on the Museum's Tinder-style
reivew page, or via the Discord bot.
"""
reviews: [Review]
screenshot_url: String @deprecated(reason: "Needed for migration")
"""Has the skin been tweeted?"""
tweeted: Boolean
tweets: [Tweet!]
webamp_url: String @deprecated(reason: "Needed for migration to new skin model")
"""List of @winampskins tweets that mentioned the skin."""
tweets: [Tweet]
webamp_url: String @deprecated(reason: "Needed for migration")
}
"""Information about an attempt to upload a skin to the Museum."""
type SkinUpload {
id: String
"""
Skin that was uploaded. **Note:** This is null if the skin has not yet been
fully processed. (status == ARCHIVED)
"""
skin: Skin
status: SkinUploadStatus
"""Md5 hash given when requesting the upload URL."""
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.
"""
@ -201,9 +403,12 @@ enum SkinUploadStatus {
URL_REQUESTED
}
"""A collection of classic Winamp skins"""
type SkinsConnection {
"""The total number of skins matching the filter"""
count: Int
nodes: [Skin!]
"""The list of skins"""
nodes: [Skin]
}
enum SkinsFilterOption {
@ -217,18 +422,32 @@ enum SkinsSortOption {
MUSEUM
}
"""A tweet made by @winampskins mentioning a Winamp skin"""
type Tweet {
"""
Number of likes the tweet has received. Updated nightly. (Note: Recent likes on older tweets may not be reflected here)
"""
likes: Int
"""
Number of retweets the tweet has received. Updated nightly. (Note: Recent retweets on older tweets may not be reflected here)
"""
retweets: Int
"""The skin featured in this Tweet"""
skin: Skin
"""
URL of the tweet. **Note:** Early on in the bot's life we just recorded
_which_ skins were tweeted, not any info about the actual tweet. This means we
don't always know the URL of the tweet.
"""
url: String
}
"""A collection of tweets made by the @winampskins bot"""
type TweetsConnection {
"""The total number of tweets"""
count: Int
"""The list of tweets"""
nodes: [Tweet!]
nodes: [Tweet]
}
enum TweetsSortOption {
@ -236,17 +455,38 @@ enum TweetsSortOption {
RETWEETS
}
"""
Mutations for the upload flow
1. The user finds the md5 hash of their local files.
2. (`get_upload_urls`) The user requests upload URLs for each of their files.
3. The server returns upload URLs for each of their files which are not already in the collection.
4. The user uploads each of their files to the URLs returned in step 3.
5. (`report_skin_uploaded`) The user notifies the server that they're done uploading.
6. (TODO) The user polls for the status of their uploads.
"""
type UploadMutations {
get_upload_urls(files: [UploadUrlRequest!]!): [UploadUrl!]
"""
Get a (possibly incompelte) list of UploadUrls for each of the files. If an
UploadUrl is not returned for a given hash, it means the file is already in
the collection.
"""
get_upload_urls(files: [UploadUrlRequest!]!): [UploadUrl]
"""Notify the server that the user is done uploading."""
report_skin_uploaded(id: String!, md5: String!): Boolean
}
"""
A URL that the client can use to upload a skin to S3, and then notify the server
when they're done.
"""
type UploadUrl {
id: String
md5: String
url: String
}
"""Input object used for a user to request an UploadUrl"""
input UploadUrlRequest {
filename: String!
md5: String!

View file

@ -4,45 +4,77 @@ import SkinModel from "../../../data/SkinModel";
import { ISkin } from "./CommonSkinResolver";
import SkinResolver from "./SkinResolver";
/** @gqlType ArchiveFile */
/**
* A file found within a Winamp Skin's .wsz archive
* @gqlType ArchiveFile
*/
export default class ArchiveFileResolver {
_model: ArchiveFileModel;
constructor(model: ArchiveFileModel) {
this._model = model;
}
/** @gqlField */
/**
* Filename of the file within the archive
* @gqlField
*/
filename(): string {
return this._model.getFileName();
}
/** @gqlField */
/**
* A URL to download the file. **Note:** This is powered by a little
* serverless Cloudflare function which tries to exctact the file on the fly.
* It may not work for all files.
* @gqlField
*/
url(): string {
return this._model.getUrl();
}
/** @gqlField */
/**
* The md5 hash of the file within the archive
* @gqlField
*/
file_md5(): string {
return this._model.getFileMd5();
}
/** @gqlField */
/**
* The uncompressed size of the file in bytes.
*
* **Note:** Will be `null` for directories
* @gqlField
*/
size(): Promise<Int | null> {
return this._model.getFileSize();
}
/** @gqlField */
/**
* The content of the file, if it's a text file
* @gqlField
*/
text_content(): Promise<string | null> {
return this._model.getTextContent();
}
/** @gqlField */
/**
* Is the file a directory?
* @gqlField
*/
is_directory(): boolean {
return this._model.getIsDirectory();
}
/** @gqlField */
/**
* The skin in which this file was found
* @gqlField
*/
async skin(_: never, { ctx }): Promise<ISkin | null> {
const model = await SkinModel.fromMd5Assert(ctx, this._model.getMd5());
return SkinResolver.fromModel(model);
}
/** @gqlField */
/**
* The date on the file inside the archive. Given in simplified extended ISO
* format (ISO 8601).
* @gqlField
*/
date(): string {
return this._model.getFileDate().toISOString();
}

View file

@ -7,23 +7,31 @@ import TweetResolver from "./TweetResolver";
import ArchiveFileResolver from "./ArchiveFileResolver";
import InternetArchiveItemResolver from "./InternetArchiveItemResolver";
/** @gqlType ClassicSkin */
/**
* A classic Winamp skin
* @gqlType ClassicSkin */
export default class ClassicSkinResolver
extends CommonSkinResolver
implements NodeResolver, ISkin
{
__typename = "ClassicSkin";
/**
* GraphQL ID of the skin
* @gqlField
* @killsParentOnException
*/
id(): ID {
return toId(this.__typename, this.md5());
}
/** @gqlField */
/**
* 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.
* @gqlField */
async filename({
normalize_extension = false,
}: {
/** If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used. */
normalize_extension?: boolean;
}): Promise<string> {
const filename = await this._model.getFileName();
@ -33,67 +41,99 @@ export default class ClassicSkinResolver
return filename;
}
/** @gqlField */
/**
* MD5 hash of the skin's file
* @gqlField */
md5(): string {
return super.md5();
}
/** @gqlField */
/**
* URL to download the skin
* @gqlField */
download_url(): string {
return super.download_url();
}
/** @gqlField */
/**
* Has the skin been tweeted?
* @gqlField */
tweeted(): Promise<boolean> {
return super.tweeted();
}
/** @gqlField */
tweets(): Promise<TweetResolver[]> {
/**
* List of @winampskins tweets that mentioned the skin.
* @gqlField */
tweets(): Promise<Array<TweetResolver | null>> {
return super.tweets();
}
/** @gqlField */
archive_files(): Promise<ArchiveFileResolver[]> {
/**
* List of files contained within the skin's .wsz archive
* @gqlField */
archive_files(): Promise<Array<ArchiveFileResolver | null>> {
return super.archive_files();
}
/** @gqlField */
/**
* The skin's "item" at archive.org
* @gqlField */
internet_archive_item(): Promise<InternetArchiveItemResolver | null> {
return super.internet_archive_item();
}
/** @gqlField */
/**
* URL of the skin on the Winamp Skin Museum
* @gqlField */
museum_url(): string {
return this._model.getMuseumUrl();
}
/** @gqlField */
/**
* URL of webamp.org with the skin loaded
* @gqlField */
webamp_url(): string {
return this._model.getWebampUrl();
}
/** @gqlField */
/**
* URL of a screenshot of the skin
* @gqlField */
screenshot_url(): string {
return this._model.getScreenshotUrl();
}
/** @gqlField */
/**
* Text of the readme file extracted from the skin
* @gqlField */
readme_text(): Promise<string | null> {
return this._model.getReadme();
}
/** @gqlField */
/**
* Has the skin been flagged as "not safe for wrok"?
* @gqlField */
nsfw(): Promise<boolean> {
return this._model.getIsNsfw();
}
/** @gqlField */
/**
* String representation (rgb usually) of the skin's average color
* @gqlField */
average_color(): string {
return this._model.getAverageColor();
}
/** @gqlField */
/**
* Does the skin include sprite sheets for the media library?
* @gqlField */
has_media_library(): Promise<boolean> {
return this._model.hasMediaLibrary();
}
/** @gqlField */
async reviews(): Promise<ReviewResolver[]> {
/**
* Times that the skin has been reviewed either on the Museum's Tinder-style
* reivew page, or via the Discord bot.
* @gqlField */
async reviews(): Promise<Array<ReviewResolver | null>> {
const reviews = await this._model.getReviews();
return reviews.map((row) => new ReviewResolver(row));
}
/** @gqlField */
/**
* The date on which this skin was last updated in the Algolia search index.
* Given in simplified extended ISO format (ISO 8601).
* @gqlField */
async last_algolia_index_update_date(): Promise<string | null> {
const updates = await this._model.getAlgoliaIndexUpdates(1);
if (updates.length < 1) {
@ -102,7 +142,9 @@ export default class ClassicSkinResolver
const update = updates[0];
return new Date(update.update_timestamp * 1000).toISOString();
}
/** @gqlField */
/**
* The number of transparent pixels rendered by the skin.
* @gqlField */
transparent_pixels(): Promise<Int> {
return this._model.transparentPixels();
}

View file

@ -5,77 +5,109 @@ import InternetArchiveItemResolver from "./InternetArchiveItemResolver";
import ReviewResolver from "./ReviewResolver";
import TweetResolver from "./TweetResolver";
/** @gqlInterface Skin */
/**
* A Winamp skin. Could be modern or classic.
*
* **Note**: At some point in the future, this might be renamed to `Skin`.
* @gqlInterface Skin
*/
export interface ISkin {
__typename: string;
/**
* GraphQL ID of the skin
* @gqlField
* @killsParentOnException
*/
id(): ID;
/** @gqlField */
/**
* 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.
* @gqlField
*/
filename({
normalize_extension = false,
}: {
/**
* If true, the the correct file extension (.wsz or .wal) will be .
* Otherwise, the original user-uploaded file extension will be used.
*/
normalize_extension?: boolean;
}): Promise<string>;
/** @gqlField */
/**
* MD5 hash of the skin's file
* @gqlField
*/
md5(): string;
/** @gqlField */
/**
* URL to download the skin
* @gqlField
*/
download_url(): string;
/** @gqlField */
/**
* Has the skin been tweeted?
* @gqlField
*/
tweeted(): Promise<boolean>;
/** @gqlField */
tweets(): Promise<TweetResolver[]>;
/**
* List of @winampskins tweets that mentioned the skin.
* @gqlField
*/
tweets(): Promise<Array<TweetResolver | null>>;
/** @gqlField */
archive_files(): Promise<ArchiveFileResolver[]>;
/**
* List of files contained within the skin's .wsz archive
* @gqlField
*/
archive_files(): Promise<Array<ArchiveFileResolver | null>>;
/** @gqlField */
/**
* The skin's "item" at archive.org
* @gqlField
*/
internet_archive_item(): Promise<InternetArchiveItemResolver | null>;
/** @gqlField */
reviews(): Promise<ReviewResolver[] | null>;
/**
* Times that the skin has been reviewed either on the Museum's Tinder-style
* reivew page, or via the Discord bot.
* @gqlField
*/
reviews(): Promise<Array<ReviewResolver | null>>;
/**
* @gqlField
* @deprecated Needed for migration to new skin model
* @deprecated Needed for migration
*/
museum_url(): string | null;
/**
* @gqlField
* @deprecated Needed for migration to new skin model
* @deprecated Needed for migration
*/
webamp_url(): string | null;
/**
* @gqlField
* @deprecated Needed for migration to new skin model
*/
* @deprecated Needed for migration */
screenshot_url(): string | null;
/**
* @gqlField
* @deprecated Needed for migration to new skin model
*/
* @deprecated Needed for migration */
readme_text(): Promise<string | null>;
/**
* @gqlField
* @deprecated Needed for migration to new skin model
*/
* @deprecated Needed for migration */
nsfw(): Promise<boolean | null>;
/**
* @gqlField
* @deprecated Needed for migration to new skin model
*/
* @deprecated Needed for migration */
average_color(): string | null;
}
@ -94,11 +126,11 @@ export default class CommonSkinResolver {
tweeted(): Promise<boolean> {
return this._model.tweeted();
}
async tweets(): Promise<TweetResolver[]> {
async tweets(): Promise<Array<TweetResolver | null>> {
const tweets = await this._model.getTweets();
return tweets.map((tweetModel) => new TweetResolver(tweetModel));
}
async archive_files(): Promise<ArchiveFileResolver[]> {
async archive_files(): Promise<Array<ArchiveFileResolver | null>> {
const files = await this._model.getArchiveFiles();
return files.map((file) => new ArchiveFileResolver(file));
}
@ -110,7 +142,7 @@ export default class CommonSkinResolver {
return new InternetArchiveItemResolver(item);
}
async reviews(): Promise<ReviewResolver[] | null> {
async reviews(): Promise<Array<ReviewResolver | null>> {
const reviews = await this._model.getReviews();
return reviews.map((row) => new ReviewResolver(row));
}

View file

@ -1,45 +1,89 @@
import { Int } from "grats";
import * as Skins from "../../../data/skins";
/** @gqlType DatabaseStatistics */
/**
* Statistics about the contents of the Museum's database.
* @gqlType DatabaseStatistics
*/
export default class DatabaseStatisticsResolver {
/** @gqlField */
/**
* The total number of classic skins in the Museum's database
* @gqlField
*/
unique_classic_skins_count(): Promise<Int> {
return Skins.getClassicSkinCount();
}
/** @gqlField */
/**
* The number of skins in the Museum that have been tweeted by @winampskins
* @gqlField
*/
tweeted_skins_count(): Promise<Int> {
return Skins.getTweetedSkinCount();
}
/** @gqlField */
/**
* The number of skins that have been approved for tweeting. This includes both
* tweeted and untweeted skins.
*
* **Note:** Skins can be both approved and rejected by different users.
* @gqlField
*/
approved_skins_count(): Promise<Int> {
return Skins.getApprovedSkinCount();
}
/** @gqlField */
/**
* The number of skins that have been rejected for tweeting.
*
* **Note:** Skins can be both approved and rejected by different users.
* **Note:** Generally skins that have been marked NSFW are also marked as rejected.
* @gqlField
*/
rejected_skins_count(): Promise<Int> {
return Skins.getRejectedSkinCount();
}
/** @gqlField */
/**
* The number of skins that have been marked as NSFW.
*
* **Note:** Skins can be approved and rejected by different users.
* **Note:** Generally skins that have been marked NSFW are also marked as rejected.
* @gqlField
*/
nsfw_skins_count(): Promise<Int> {
return Skins.getNsfwSkinCount();
}
/** @gqlField */
/**
* The number of skins that have never been reviewed.
* @gqlField
*/
unreviewed_skins_count(): Promise<Int> {
return Skins.getUnreviewedSkinCount();
}
/** @gqlField */
/**
* The number of skins that have been approved for tweeting, but not yet tweeted.
* @gqlField
*/
tweetable_skins_count(): Promise<Int> {
return Skins.getTweetableSkinCount();
}
/** @gqlField */
/**
* Skins uplaods awaiting processing. This can happen when there are a large
* number of skin uplaods at the same time, or when the skin uploading processing
* pipeline gets stuck.
* @gqlField
*/
uploads_pending_processing_count(): Promise<Int> {
return Skins.getUploadsAwaitingProcessingCount();
}
/** @gqlField */
/**
* Skins uploads that have errored during processing.
* @gqlField
*/
uploads_in_error_state_count(): Promise<Int> {
return Skins.getUploadsErroredCount();
}
/** @gqlField */
/**
* Number of skins that have been uploaded to the Museum via the web interface.
* @gqlField
*/
web_uploads_count(): Promise<Int> {
return Skins.getWebUploadsCount();
}

View file

@ -8,29 +8,49 @@ export default class InternetArchiveItemResolver {
constructor(model: IaItemModel) {
this._model = model;
}
/** @gqlField */
/**
* The Internet Archive's unique identifier for this item
* @gqlField
*/
identifier(): string {
return this._model.getIdentifier();
}
/** @gqlField */
/**
* The URL where this item can be viewed on the Internet Archive
* @gqlField
*/
url(): string {
return this._model.getUrl();
}
/** @gqlField */
/**
* URL to get the Internet Archive's metadata for this item in JSON form.
* @gqlField
*/
metadata_url(): string {
return this._model.getMetadataUrl();
}
/** @gqlField */
/**
* The date and time that we last scraped this item's metadata.
* **Note:** This field is temporary and will be removed in the future.
* The date format is just what we get from the database, and it's ambiguous.
* @gqlField
*/
last_metadata_scrape_date_UNSTABLE(): string | null {
return this._model.getMetadataTimestamp();
}
/** @gqlField */
/**
* Our cached version of the metadata avaliable at \`metadata_url\` (above)
* @gqlField
*/
raw_metadata_json(): string | null {
return this._model.getMetadataJSON();
}
/** @gqlField */
/**
* The skin that this item contains
* @gqlField
*/
async skin(): Promise<ISkin | null> {
const skin = await this._model.getSkin();
if (skin == null) {

View file

@ -8,17 +8,27 @@ import InternetArchiveItemResolver from "./InternetArchiveItemResolver";
import ArchiveFileResolver from "./ArchiveFileResolver";
import TweetResolver from "./TweetResolver";
/** @gqlType ModernSkin */
/**
* 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.
* @gqlType ModernSkin */
export default class ModernSkinResolver
extends CommonSkinResolver
implements NodeResolver, ISkin
{
_model: SkinModel;
__typename = "ModernSkin";
/** @gqlField */
/**
* 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.
* @gqlField */
async filename({
normalize_extension = false,
}: {
/** If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used. */
normalize_extension?: boolean;
}): Promise<string> {
const filename = await this._model.getFileName();
@ -31,68 +41,95 @@ export default class ModernSkinResolver
/* TODO: Get all of these from the parent class/interface */
/**
* GraphQL ID of the skin
* @gqlField
* @killsParentOnException
*/
* @killsParentOnException */
id(): ID {
return toId(this.__typename, this.md5());
}
/** @gqlField */
/**
* MD5 hash of the skin's file
* @gqlField */
md5(): string {
return super.md5();
}
/** @gqlField */
/**
* URL to download the skin
* @gqlField */
download_url(): string {
return super.download_url();
}
/** @gqlField */
/**
* Has the skin been tweeted?
* @gqlField */
tweeted(): Promise<boolean> {
return super.tweeted();
}
/** @gqlField */
async tweets(): Promise<TweetResolver[]> {
/**
* List of @winampskins tweets that mentioned the skin.
* @gqlField */
async tweets(): Promise<Array<TweetResolver | null>> {
return super.tweets();
}
/** @gqlField */
async archive_files(): Promise<ArchiveFileResolver[]> {
/**
* List of files contained within the skin's .wsz archive
* @gqlField */
async archive_files(): Promise<Array<ArchiveFileResolver | null>> {
return super.archive_files();
}
/** @gqlField */
/**
* The skin's "item" at archive.org
* @gqlField */
async internet_archive_item(): Promise<InternetArchiveItemResolver | null> {
return super.internet_archive_item();
}
/** @gqlField */
async reviews(): Promise<ReviewResolver[] | null> {
/**
* Times that the skin has been reviewed either on the Museum's Tinder-style
* reivew page, or via the Discord bot.
* @gqlField */
async reviews(): Promise<Array<ReviewResolver | null>> {
return super.reviews();
}
/** @gqlField */
/**
* @gqlField
* @deprecated Needed for migration */
museum_url(): string | null {
return null;
}
/** @gqlField */
/**
* @gqlField
* @deprecated Needed for migration */
webamp_url(): string | null {
return null;
}
/** @gqlField */
/**
* @gqlField
* @deprecated Needed for migration */
screenshot_url(): string | null {
return null;
}
/** @gqlField */
/**
* @gqlField
* @deprecated Needed for migration */
async readme_text(): Promise<string | null> {
return null;
}
/** @gqlField */
/**
* @gqlField
* @deprecated Needed for migration */
async nsfw(): Promise<boolean | null> {
return null;
}
/** @gqlField */
/**
* @gqlField
* @deprecated Needed for migration */
average_color(): string | null {
return null;
}

View file

@ -5,7 +5,11 @@ import * as Skins from "../../../data/skins";
import { processUserUploads } from "../../processUserUploads";
// We don't use a resolver here, just return the value directly.
/** @gqlType */
/**
* A URL that the client can use to upload a skin to S3, and then notify the server
* when they're done.
* @gqlType
*/
type UploadUrl = {
/** @gqlField */
id: string;
@ -15,16 +19,34 @@ type UploadUrl = {
md5: string;
};
/** @gqlInput */
/**
* Input object used for a user to request an UploadUrl
* @gqlInput
*/
type UploadUrlRequest = { filename: string; md5: string };
/** @gqlType UploadMutations */
/**
* Mutations for the upload flow
*
* 1. The user finds the md5 hash of their local files.
* 2. (`get_upload_urls`) The user requests upload URLs for each of their files.
* 3. The server returns upload URLs for each of their files which are not already in the collection.
* 4. The user uploads each of their files to the URLs returned in step 3.
* 5. (`report_skin_uploaded`) The user notifies the server that they're done uploading.
* 6. (TODO) The user polls for the status of their uploads.
*
* @gqlType UploadMutations */
class UploadMutationResolver {
/** @gqlField */
/**
* Get a (possibly incompelte) list of UploadUrls for each of the files. If an
* UploadUrl is not returned for a given hash, it means the file is already in
* the collection.
* @gqlField
*/
async get_upload_urls(
{ files }: { files: UploadUrlRequest[] },
{ ctx }
): Promise<UploadUrl[]> {
): Promise<Array<UploadUrl | null>> {
const missing: UploadUrl[] = [];
await Parallel.each(
files,
@ -41,7 +63,10 @@ class UploadMutationResolver {
return missing;
}
/** @gqlField */
/**
* Notify the server that the user is done uploading.
* @gqlField
*/
async report_skin_uploaded(
{ id, md5 }: { id: string; md5: string },
req
@ -64,15 +89,21 @@ function requireAuthed(handler) {
};
}
/** @gqlType Mutation */
/**
*
* @gqlType Mutation */
export default class MutationResolver {
/** @gqlField */
/**
* Mutations for the upload flow
* @gqlField */
async upload(): Promise<UploadMutationResolver> {
return new UploadMutationResolver();
}
/** @gqlField */
/**
* Send a message to the admin of the site. Currently this appears in Discord.
* @gqlField */
async send_feedback(
{ message, email, url }: { message?: string; email?: string; url?: string },
{ message, email, url }: { message: string; email?: string; url?: string },
req
): Promise<boolean> {
req.notify({
@ -84,7 +115,11 @@ export default class MutationResolver {
return true;
}
/** @gqlField */
/**
* Reject skin for tweeting
*
* **Note:** Requires being logged in
* @gqlField */
reject_skin(args: { md5: string }, req): Promise<boolean> {
return this._reject_skin(args, req);
}
@ -100,7 +135,11 @@ export default class MutationResolver {
return true;
});
/** @gqlField */
/**
* Approve skin for tweeting
*
* **Note:** Requires being logged in
* @gqlField */
approve_skin(args: { md5: string }, req): Promise<boolean> {
return this._approve_skin(args, req);
}
@ -116,7 +155,11 @@ export default class MutationResolver {
return true;
});
/** @gqlField */
/**
* Mark a skin as NSFW
*
* **Note:** Requires being logged in
* @gqlField */
mark_skin_nsfw(args: { md5: string }, req): Promise<boolean> {
return this._mark_skin_nsfw(args, req);
}
@ -132,7 +175,11 @@ export default class MutationResolver {
return true;
});
/** @gqlField */
/**
* Request that an admin check if this skin is NSFW.
* Unlike other review mutaiton endpoints, this one does not require being logged
* in.
* @gqlField */
async request_nsfw_review_for_skin(
{ md5 }: { md5: string },
req

View file

@ -21,8 +21,8 @@ export default class ReviewResolver {
}
/**
* The user who made the review (if known). **Note:** In the early days we
* didn't track this, so many will be null.
* The user who made the review (if known). **Note:** In the early days we didn't
* track this, so many will be null.
* @gqlField
*/
reviewer(): string {

View file

@ -29,6 +29,7 @@ const index = client.initIndex("Skins");
class RootResolver extends MutationResolver {
/**
* Get a globally unique object by its ID.
*
* https://graphql.org/learn/global-object-identification/
* @gqlField
*/
@ -84,6 +85,7 @@ class RootResolver extends MutationResolver {
/**
* Get an archive.org item by its identifier. You can find this in the URL:
*
* https://archive.org/details/<identifier>/
* @gqlField
*/
@ -100,6 +102,7 @@ class RootResolver extends MutationResolver {
/**
* Fetch archive file by it's MD5 hash
*
* Get information about a file found within a skin's wsz/wal/zip archive.
* @gqlField
*/
@ -127,7 +130,7 @@ class RootResolver extends MutationResolver {
offset = 0,
}: { query: string; first?: Int; offset?: Int },
{ ctx }
): Promise<ISkin[]> {
): Promise<Array<ISkin | null>> {
if (first > 1000) {
throw new Error("Can only query 1000 records via search.");
}
@ -146,7 +149,11 @@ class RootResolver extends MutationResolver {
);
}
/** @gqlField */
/**
* All classic skins in the database
*
* **Note:** We don't currently support combining sorting and filtering.
* @gqlField */
skins({
first = 10,
offset = 0,
@ -164,7 +171,9 @@ class RootResolver extends MutationResolver {
return new SkinsConnection(first, offset, sort, filter);
}
/** @gqlField */
/**
* All modern skins in the database
* @gqlField */
async modern_skins({
first = 10,
offset = 0,
@ -178,7 +187,9 @@ class RootResolver extends MutationResolver {
return new ModernSkinsConnection(first, offset);
}
/** @gqlField */
/**
* A random skin that needs to be reviewed
* @gqlField */
async skin_to_review(_args: never, { ctx }): Promise<ISkin | null> {
if (!ctx.authed()) {
return null;
@ -218,19 +229,22 @@ class RootResolver extends MutationResolver {
/**
* 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<SkinUpload[]> {
): Promise<Array<SkinUpload | null>> {
return this._upload_statuses({ keyName: "skin_md5", keys: md5s }, ctx);
}
/** @gqlField */
/**
* Get the status of a batch of uploads by ids
* @gqlField */
async upload_statuses(
{ ids }: { ids: string[] },
{ ctx }
): Promise<SkinUpload[]> {
): Promise<Array<SkinUpload | null>> {
return this._upload_statuses({ keyName: "id", keys: ids }, ctx);
}
@ -256,27 +270,39 @@ class RootResolver extends MutationResolver {
);
}
/** @gqlField */
/**
* A namespace for statistics about the database
* @gqlField */
statistics(): DatabaseStatisticsResolver {
return new DatabaseStatisticsResolver();
}
}
/** @gqlType */
/**
* Information about an attempt to upload a skin to the Museum.
* @gqlType
*/
type SkinUpload = {
/** @gqlField */
id: string;
/** @gqlField */
status: SkinUploadStatus;
/** @gqlField */
/**
* Skin that was uploaded. **Note:** This is null if the skin has not yet been
* fully processed. (status == ARCHIVED)
* @gqlField
*/
skin: ISkin | null;
/** @gqlField */
/**
* 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:
@ -314,6 +340,7 @@ Only the skins that have been 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

View file

@ -3,26 +3,43 @@ import TweetModel from "../../../data/TweetModel";
import { ISkin } from "./CommonSkinResolver";
import SkinResolver from "./SkinResolver";
/** @gqlType Tweet */
/**
* A tweet made by @winampskins mentioning a Winamp skin
* @gqlType Tweet
*/
export default class TweetResolver {
_model: TweetModel;
constructor(model: TweetModel) {
this._model = model;
}
/** @gqlField */
/**
* URL of the tweet. **Note:** Early on in the bot's life we just recorded
* _which_ skins were tweeted, not any info about the actual tweet. This means we
* don't always know the URL of the tweet.
* @gqlField
*/
url(): string | null {
return this._model.getUrl();
}
/** @gqlField */
/**
* Number of likes the tweet has received. Updated nightly. (Note: Recent likes on older tweets may not be reflected here)
* @gqlField
*/
likes(): Int {
return this._model.getLikes();
}
/** @gqlField */
/**
* Number of retweets the tweet has received. Updated nightly. (Note: Recent retweets on older tweets may not be reflected here)
* @gqlField
*/
retweets(): Int {
return this._model.getRetweets();
}
/** @gqlField */
/**
* The skin featured in this Tweet
* @gqlField
*/
async skin(): Promise<ISkin | null> {
const skin = await this._model.getSkin();
if (skin == null) {

View file

@ -449,40 +449,41 @@ type User {
}
enum SkinsSortOption {
"""
the Museum's (https://skins.webamp.org) special sorting rules.
# TODO: Add this back once Grats supports descriptions
# """
# the Museum's (https://skins.webamp.org) special sorting rules.
Roughly speaking, it's:
# 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
"""
# 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
}
enum SkinsFilterOption {
"""
Only the skins that have been approved for tweeting
"""
# """
# Only the skins that have been approved for tweeting
# """
APPROVED
"""
Only the skins that have been rejected for tweeting
"""
# """
# Only the skins that have been rejected for tweeting
# """
REJECTED
"""
Only the skins that have been marked NSFW
"""
# """
# Only the skins that have been marked NSFW
# """
NSFW
"""
Only the skins that have been tweeted
"""
# """
# Only the skins that have been tweeted
# """
TWEETED
}
@ -493,32 +494,32 @@ The current status of a pending upload.
the status of a pending uploads.
"""
enum SkinUploadStatus {
"""
The user has requested a URL, but the skin has not yet been processed.
"""
# """
# 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.
"""
# """
# 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.
"""
# """
# 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.
"""
# """
# 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.
"""
# """
# The skin has been successfully added to the Museum.
# """
ARCHIVED
}

View file

@ -5,7 +5,10 @@ export type TweetStatus =
| "UNREVIEWED"
| "NSFW";
/** @gqlEnum */
/**
* The judgement made about a skin by a moderator
* @gqlEnum
*/
export type Rating = "APPROVED" | "REJECTED" | "NSFW";
export type SkinType = "MODERN" | "CLASSIC";