webamp/packages/skin-database/data/skins.ts
2025-11-07 15:23:02 -08:00

865 lines
24 KiB
TypeScript

import { ALGOLIA_INDEX } from "../config";
import { knex } from "../db";
import path from "path";
import md5Hash from "md5";
import { client } from "../algolia";
import { truncate } from "../utils";
import fetch from "node-fetch";
import * as S3 from "../s3";
import * as CloudFlare from "../CloudFlare";
import SkinModel from "./SkinModel";
import UserContext from "./UserContext";
import TweetModel from "./TweetModel";
import { TweetStatus } from "../types";
import fs from "fs";
// const CDN_URL = "https://cdn.webampskins.org";
const CDN_URL = "https://r2.webampskins.org";
export const SKIN_TYPE = {
CLASSIC: 1,
MODERN: 2,
PACK: 3,
INVALID: 4,
};
export function getSkinUrl(md5: string): string {
return `${CDN_URL}/skins/${md5}.wsz`;
}
export function getModernSkinUrl(md5: string): string {
return `${CDN_URL}/skins/${md5}.wal`;
}
export function getScreenshotUrl(md5: string): string {
return `${CDN_URL}/screenshots/${md5}.png`;
}
export async function addSkin({
ctx,
md5,
filePath,
uploader,
modern,
}: {
ctx: UserContext;
md5: string;
filePath: string;
uploader: string;
modern: boolean;
}) {
await knex("skins").insert(
{
md5,
skin_type: modern ? SKIN_TYPE.MODERN : SKIN_TYPE.CLASSIC,
},
[]
);
await knex("files").insert(
{
skin_md5: md5,
file_path: filePath,
source_attribution: uploader,
},
[]
);
SkinModel.clearMd5(ctx, md5);
}
const CRUFT_FILENAME = /winampskins\.info\.(html)|(txt)$/;
export async function setContentHash(md5: string): Promise<string | null> {
const files = await knex("archive_files").where("skin_md5", md5).select();
if (files.length === 0) {
return null;
}
const contentHash = md5Hash(
files
.filter(({ file_name }) => !CRUFT_FILENAME.test(file_name))
.map(({ file_name, file_md5 }) => `${file_name}:${file_md5}`)
.sort()
.join("|")
);
await knex("skins").update({ content_hash: contentHash }).where("md5", md5);
return contentHash;
}
export async function getTweetableSkinCount(): Promise<number> {
const tweetable = await knex("skins")
.leftJoin("skin_reviews", "skin_reviews.skin_md5", "=", "skins.md5")
.leftJoin("tweets", "tweets.skin_md5", "=", "skins.md5")
.where({
"tweets.id": null,
skin_type: 1,
"skin_reviews.review": "APPROVED",
})
.count("skins.id", { as: "count" })
.first();
return Number(tweetable?.count ?? 0);
}
export async function getClassicSkinCount(): Promise<number> {
const rows = await knex("skins")
.where({ skin_type: 1 })
.count({ count: "*" });
const row = rows[0];
if (row == null || row.count == null) {
throw new Error("Could not find count row");
}
const { count } = row;
if (count == null) {
throw new Error("Could not find count");
}
return Number(count);
}
export async function markAsTweeted(
md5: string,
tweetId: string
): Promise<void> {
await knex("tweets").insert({ skin_md5: md5, tweet_id: tweetId }, []);
}
export async function markAsPostedToInstagram(
md5: string,
postId: string,
url: string
): Promise<void> {
await knex("instagram_posts").insert(
{ skin_md5: md5, post_id: postId, url },
[]
);
}
export async function markAsPostedToMastodon(
md5: string,
postId: string,
url: string
): Promise<void> {
await knex("mastodon_posts").insert(
{ skin_md5: md5, post_id: postId, url },
[]
);
}
export async function markAsPostedToBlueSky(
md5: string,
postId: string,
url: string
): Promise<void> {
await knex("bluesky_posts").insert(
{ skin_md5: md5, post_id: postId, url },
[]
);
}
// TODO: Also path actor
export async function markAsNSFW(ctx: UserContext, md5: string): Promise<void> {
const index = { objectID: md5, nsfw: true };
// TODO: Await here, but for some reason this never completes
await client.partialUpdateObjects({
indexName: ALGOLIA_INDEX,
objects: [index],
});
await recordSearchIndexUpdates(md5, Object.keys(index));
await knex("skin_reviews").insert(
{ skin_md5: md5, review: "NSFW", reviewer: ctx.username || "UNKNOWN" },
[]
);
}
export async function getUploadStatuses(
md5s: string[]
): Promise<{ [md5: string]: string }> {
const skins = await knex("skin_uploads")
.whereIn("skin_md5", md5s)
.orderBy("id", "desc")
.select("skin_md5", "status");
const statuses: { [md5: string]: string } = {};
skins.forEach(({ skin_md5, status }) => {
statuses[skin_md5] = status;
});
return statuses;
}
type SearchIndex = {
objectID: string;
md5: string;
// TODO: Can be deprecated in favor of tweetStatus
nsfw: boolean;
tweetStatus: TweetStatus;
fileName: string;
twitterLikes: number;
equalizer: boolean;
playlist: boolean;
browser: boolean;
media: boolean;
general: boolean;
video: boolean;
cur: boolean;
ani: boolean;
transparentPixels: number;
mikro: boolean;
vidamp: boolean;
avs: boolean;
};
async function getSearchIndexes(
ctx: UserContext,
md5s: string[]
): Promise<SearchIndex[]> {
const skins = await Promise.all(
md5s.map((md5) => {
return SkinModel.fromMd5Assert(ctx, md5);
})
);
return Promise.all(
skins.map(async (skin) => {
if (skin.getSkinType() !== "CLASSIC") {
throw new Error("Only classic skins are supported");
}
const readmeText = await skin.getReadme();
const tweets = await skin.getTweets();
const likes = tweets.reduce((acc: number, tweet: TweetModel) => {
return Math.max(acc, tweet.getLikes());
}, 0);
return {
objectID: skin.getMd5(),
md5: skin.getMd5(),
nsfw: await skin.getIsNsfw(),
tweetStatus: await skin.getTweetStatus(),
readmeText: readmeText ? truncate(readmeText, 4000) : null,
fileName: await skin.getFileName(),
twitterLikes: likes,
equalizer: await skin.hasEqualizer(),
media: await skin.hasMediaLibrary(),
playlist: await skin.hasPlaylist(),
browser: await skin.hasBrowser(),
general: await skin.hasGeneral(),
video: await skin.hasVideo(),
cur: await skin.hasCur(),
ani: await skin.hasAni(),
transparentPixels: await skin.transparentPixels(),
mikro: await skin.hasMikro(),
vidamp: await skin.hasVidamp(),
avs: await skin.hasAVS(),
};
})
);
}
export async function updateSearchIndexs(
ctx: UserContext,
md5s: string[]
): Promise<any> {
const skinIndexes = await getSearchIndexes(ctx, md5s);
const results = await client.partialUpdateObjects({
indexName: ALGOLIA_INDEX,
createIfNotExists: true,
objects: skinIndexes,
});
for (const index of skinIndexes) {
await recordSearchIndexUpdates(index.md5, Object.keys(index));
}
return results;
}
export async function updateSearchIndex(
ctx: UserContext,
md5: string
): Promise<any | null> {
return updateSearchIndexs(ctx, [md5]);
}
export async function hideSkin(md5: string): Promise<void> {
await knex("museum_sort_overrides").insert({ skin_md5: md5, score: -1 });
}
// Note: This might leave behind some files in file_info.
export async function deleteSkin(md5: string): Promise<void> {
await deleteLocalSkin(md5);
console.log(`Deleting skin ${md5} from external sources...`);
console.log(`... removing from Algolia index`);
await client.deleteObjects({
indexName: ALGOLIA_INDEX,
objectIDs: [md5],
});
console.log(`... removing skin from S3`);
await S3.deleteSkin(md5);
console.log(`... removing screenshot from S3`);
await S3.deleteScreenshot(md5);
console.log(`... purging screenshot and skin from CloudFlare`);
await CloudFlare.purgeFiles([getScreenshotUrl(md5), getSkinUrl(md5)]);
console.log(`Done deleting skin ${md5} from external sources.`);
// TODO: Delete from Internet Archive
}
export async function deleteLocalSkin(md5: string): Promise<void> {
console.log(`Deleting skin ${md5} locally...`);
console.log(`... sqlite "skins"`);
await knex("skins").where({ md5 }).limit(1).delete();
console.log(`... sqlite "refreshes"`);
await knex("refreshes").where({ skin_md5: md5 }).delete();
console.log(`... sqlite "files"`);
await knex("files").where({ skin_md5: md5 }).delete();
console.log(`... sqlite "skin_reviews"`);
await knex("skin_reviews").where({ skin_md5: md5 }).delete();
console.log(`... sqlite "ia_items"`);
await knex("ia_items").where({ skin_md5: md5 }).delete();
console.log(`... sqlite "archive_files"`);
await knex("archive_files").where({ skin_md5: md5 }).delete();
console.log(`... sqlite "tweets"`);
await knex("tweets").where({ skin_md5: md5 }).delete();
console.log(`... sqlite "algolia_field_updates"`);
await knex("algolia_field_updates").where({ skin_md5: md5 }).delete();
console.log(`... sqlite "skin_uploads"`);
await knex("skin_uploads").where({ skin_md5: md5 }).delete();
console.log(`... sqlite "screenshot_updates"`);
await knex("screenshot_updates").where({ skin_md5: md5 }).delete();
console.log(`Done deleting skin ${md5} locally.`);
}
export async function recordScreenshotUpdate(
md5: string,
errorMessage?: string
) {
// TODO: Could this invalidate the CloudFlare cache?
const update_timestamp = Math.floor(Date.now() / 1000);
await knex("screenshot_updates").insert([
{
skin_md5: md5,
update_timestamp,
success: errorMessage == null,
error_message: errorMessage,
},
]);
}
export async function getSkinsToShoot(limit: number): Promise<string[]> {
// TODO: Once return an ordered list here of skins that have no record,
// followed by skins that have not been shot in a long time.
const results = await knex("skins")
.leftJoin(
"screenshot_updates",
"skins.md5",
"=",
"screenshot_updates.skin_md5"
)
.where("screenshot_updates.id", null)
.limit(limit)
.select(["md5"]);
return results.map((row) => row.md5);
}
export async function searchIndexUpdatesForSkin(
md5: string,
limit?: number
): Promise<
Array<{ skin_md5: string; update_timestamp: number; field: string }>
> {
let query = knex("algolia_field_updates")
.where({ skin_md5: md5 })
.orderBy("update_timestamp", "desc");
if (limit != null) {
query = query.limit(limit);
}
return query.select();
}
export async function recordSearchIndexUpdates(
md5: string,
fields: string[]
): Promise<void> {
const update_timestamp = Math.floor(Date.now() / 1000);
await knex("algolia_field_updates").insert(
fields.map((field) => ({
skin_md5: md5,
update_timestamp,
field,
}))
);
}
export async function getReportedUpload(): Promise<{
skin_md5: string;
id: string;
filename: string;
} | null> {
const found = await knex("skin_uploads")
.where("status", "UPLOAD_REPORTED")
.first(["skin_md5", "id", "filename"]);
return found || null;
}
export async function getErroredUpload(): Promise<{
skin_md5: string;
id: string;
filename: string;
} | null> {
const found = await knex("skin_uploads")
.where("status", "ERRORED")
.where("skin_md5", "!=", "c7df44bde6eb3671bde5a03e6d03ce1e")
.where("skin_md5", "!=", "fedc564eb2ce0a4ec5518b93983240ef")
.first(["skin_md5", "id", "filename"]);
return found || null;
}
export async function recordUserUploadComplete(
md5: string,
id: string
): Promise<void> {
await knex("skin_uploads")
.where({ skin_md5: md5, id, status: "URL_REQUESTED" })
.update({ status: "UPLOAD_REPORTED" }, [id])
.limit(1);
}
export async function recordUserUploadArchived(id: string): Promise<void> {
await knex("skin_uploads")
.where({ id })
.update({ status: "ARCHIVED" }, [id])
.limit(1);
}
export async function recordUserUploadErrored(id: string): Promise<void> {
await knex("skin_uploads")
.where({ id })
.update({ status: "ERRORED" }, [id])
.limit(1);
}
export async function recordUserUploadRequest(
md5: string,
filename: string
): Promise<string> {
const record = await knex("skin_uploads").insert(
{ skin_md5: md5, status: "URL_REQUESTED", filename },
["id"]
);
return record[0];
}
// TODO: Also path actor
export async function approve(ctx: UserContext, md5: string): Promise<void> {
await knex("skin_reviews").insert(
{ skin_md5: md5, review: "APPROVED", reviewer: ctx.username || "UNKNOWN" },
[]
);
}
// TODO: Also path actor
export async function reject(ctx: UserContext, md5: string): Promise<void> {
await knex("skin_reviews").insert(
{ skin_md5: md5, review: "REJECTED", reviewer: ctx.username || "UNKNOWN" },
[]
);
}
export async function getSkinToReview(): Promise<{
filename: string | null;
md5: string;
}> {
const skins = await knex("skins")
.leftJoin("skin_reviews", "skin_reviews.skin_md5", "=", "skins.md5")
.leftJoin("tweets", "tweets.skin_md5", "=", "skins.md5")
.innerJoin("files", "files.skin_md5", "=", "skins.md5")
.select("skins.md5", "files.file_path")
.where({ "skin_reviews.id": null, "tweets.id": null, "skins.skin_type": 1 })
// TODO: Remove this once we run out of skins that don't have it.
.whereNot("emails", "like", "%Dr.Algebra@gmx.de%")
.orderByRaw("random()")
.limit(1);
if (!skins.length) {
throw new Error("Could not find any skins to review");
}
const skin = skins[0];
return { filename: path.basename(skin.file_path), md5: skin.md5 };
}
export async function getSkinToTweet(): Promise<{
md5: string;
canonicalFilename: string;
} | null> {
// TODO: This does not account for skins that have been both approved and rejected
const tweetables = await knex("skins")
.leftJoin("skin_reviews", "skin_reviews.skin_md5", "=", "skins.md5")
.leftJoin("tweets", "tweets.skin_md5", "=", "skins.md5")
.leftJoin("files", "files.skin_md5", "=", "skins.md5")
.leftJoin("refreshes", "refreshes.skin_md5", "=", "skins.md5")
.where({
"tweets.id": null,
skin_type: 1,
"skin_reviews.review": "APPROVED",
"refreshes.error": null,
})
.groupBy("skins.md5")
.select(["skins.md5", "files.file_path"])
.orderByRaw("random()")
.limit(1);
const skin = tweetables[0];
if (skin == null) {
return null;
}
return { md5: skin.md5, canonicalFilename: path.basename(skin.file_path) };
}
export async function getSkinToPostToInstagram(): Promise<string | null> {
// TODO: This does not account for skins that have been both approved and rejected
const tweetables = await knex("skins")
.leftJoin("skin_reviews", "skin_reviews.skin_md5", "=", "skins.md5")
.leftJoin("instagram_posts", "instagram_posts.skin_md5", "=", "skins.md5")
.leftJoin("refreshes", "refreshes.skin_md5", "=", "skins.md5")
.where({
"instagram_posts.id": null,
skin_type: 1,
"skin_reviews.review": "APPROVED",
"refreshes.error": null,
})
.groupBy("skins.md5")
.orderByRaw("random()")
.limit(1);
const skin = tweetables[0];
if (skin == null) {
return null;
}
return skin.md5;
}
export async function getSkinToPostToMastodon(): Promise<string | null> {
// TODO: This does not account for skins that have been both approved and rejected
const postables = await knex("skins")
.leftJoin("skin_reviews", "skin_reviews.skin_md5", "=", "skins.md5")
.leftJoin("mastodon_posts", "mastodon_posts.skin_md5", "=", "skins.md5")
.leftJoin("tweets", "tweets.skin_md5", "=", "skins.md5")
.leftJoin("refreshes", "refreshes.skin_md5", "=", "skins.md5")
.where({
"mastodon_posts.id": null,
skin_type: 1,
"skin_reviews.review": "APPROVED",
"refreshes.error": null,
})
.where("likes", ">", 10)
.groupBy("skins.md5")
.orderByRaw("random()")
.limit(1);
const skin = postables[0];
if (skin == null) {
return null;
}
return skin.md5;
}
export async function getSkinToPostToBluesky(): Promise<string | null> {
// TODO: This does not account for skins that have been both approved and rejected
const postables = await knex("skins")
.leftJoin("skin_reviews", "skin_reviews.skin_md5", "=", "skins.md5")
.leftJoin("bluesky_posts", "bluesky_posts.skin_md5", "=", "skins.md5")
.leftJoin("tweets", "tweets.skin_md5", "=", "skins.md5")
.leftJoin("refreshes", "refreshes.skin_md5", "=", "skins.md5")
.where({
"bluesky_posts.id": null,
skin_type: 1,
"skin_reviews.review": "APPROVED",
"refreshes.error": null,
})
.where("likes", ">", 10)
.groupBy("skins.md5")
.orderByRaw("random()")
.limit(1);
const skin = postables[0];
if (skin == null) {
return null;
}
return skin.md5;
}
export async function getUnreviewedSkinCount(): Promise<number> {
const rows = await knex("skins")
.where({ skin_type: 1 })
.whereNotIn("md5", knex("skin_reviews").select("skin_md5"))
.count("*", { as: "unreviewed" });
return Number(rows[0].unreviewed);
}
export async function getApprovedSkinCount(): Promise<number> {
const row = await knex("skin_reviews")
.first(knex.raw(`COUNT(DISTINCT skin_md5) AS "approved_count"`))
.where({ review: "APPROVED" });
return Number(row.approved_count);
}
export async function getRejectedSkinCount(): Promise<number> {
const row = await knex("skin_reviews")
.first(knex.raw(`COUNT(DISTINCT skin_md5) AS "rejected_count"`))
.where({ review: "REJECTED" });
return Number(row.rejected_count);
}
export async function getNsfwSkinCount(): Promise<number> {
const row = await knex("skin_reviews")
.first(knex.raw(`COUNT(DISTINCT skin_md5) AS "nsfw_count"`))
.where({ review: "NSFW" });
return Number(row.nsfw_count);
}
export async function getTweetedSkinCount(): Promise<number> {
const rows = await knex("tweets").count("*", { as: "tweeted" });
return Number(rows[0].tweeted);
}
export async function getWebUploadsCount(): Promise<number> {
const rows = await knex("files")
.where("source_attribution", "Web API")
.count("*", { as: "uploads" });
return Number(rows[0].uploads);
}
export async function getUploadsAwaitingProcessingCount(): Promise<number> {
const rows = await knex("skin_uploads")
.where("status", "UPLOAD_REPORTED")
.count("*", { as: "uploads" });
return Number(rows[0].uploads);
}
export async function getUploadsErroredCount(): Promise<number> {
const rows = await knex("skin_uploads")
.where("status", "ERRORED")
.count("*", { as: "uploads" });
return Number(rows[0].uploads);
}
export async function getStats(): Promise<{
approved: number;
rejected: number;
nsfw: number;
tweeted: number;
tweetable: number;
webUploads: number;
uploadsAwaitingProcessing: number;
uploadsErrored: number;
}> {
return {
approved: await getApprovedSkinCount(),
rejected: await getRejectedSkinCount(),
nsfw: await getNsfwSkinCount(),
tweeted: await getTweetedSkinCount(),
tweetable: await getTweetableSkinCount(),
webUploads: await getWebUploadsCount(),
uploadsAwaitingProcessing: await getUploadsAwaitingProcessingCount(),
uploadsErrored: await getUploadsErroredCount(),
};
}
export async function getScreenshotBuffer(md5: string): Promise<Buffer> {
const ctx = new UserContext();
const exists = await SkinModel.exists(ctx, md5);
if (!exists) {
throw new Error(`Could not find skin with hash ${md5}`);
}
const screenshotUrl = getScreenshotUrl(md5);
const screenshotResponse = await fetch(screenshotUrl);
if (!screenshotResponse.ok) {
throw new Error(`Could not get screenshot at ${screenshotUrl}`);
}
return screenshotResponse.buffer();
}
export async function setTweetInfo(
md5: string | null,
likes: number,
retweets: number,
tweetId: string
): Promise<boolean> {
const first = await knex("tweets")
.where({ tweet_id: tweetId })
.first(["likes", "retweets"]);
if (first == null) {
if (md5 == null) {
return false;
}
await knex("tweets").insert(
{
skin_md5: md5,
tweet_id: tweetId,
likes,
retweets,
},
[]
);
return true;
}
if (first.likes !== likes || first.retweets !== retweets) {
await knex("tweets")
.where({ tweet_id: tweetId })
.update({ tweet_id: tweetId, likes, retweets }, []);
}
return true;
}
export type MuseumPage = Array<{
color: string;
fileName: string;
md5: string;
nsfw: boolean;
}>;
export type ScrollPage = Array<{
md5: string;
}>;
const PAGE_SIZE = 50;
const FRESHNESS_PERCENTAGE = 0.2; // 20% random skins
const randomCount = Math.floor(PAGE_SIZE * FRESHNESS_PERCENTAGE);
const curatedCount = PAGE_SIZE - randomCount;
export async function getScrollPage(sessionId: string): Promise<ScrollPage> {
const skins = await knex.raw(
`
SELECT
museum_sort_order.skin_md5
FROM
museum_sort_order
WHERE museum_sort_order.skin_md5 NOT IN (SELECT skin_md5 from session_skin WHERE session_id = ?)
LIMIT ?`,
[sessionId, curatedCount]
);
const randomSkins = await knex.raw(
`
SELECT
skins.md5 as skin_md5
FROM
skins
LEFT JOIN session_skin ON session_skin.skin_md5 = skins.md5 AND session_skin.session_id = ?
LEFT JOIN skin_reviews ON skin_reviews.skin_md5 = skins.md5 AND skin_reviews.review = 'NSFW'
WHERE
skins.skin_type = 1 AND session_skin.skin_md5 IS NULL AND skin_reviews.skin_md5 IS NULL
ORDER BY RANDOM()
LIMIT ?`,
[sessionId, randomCount]
);
// Note: Technically we could get duplicates if a random skin is also in museum_sort_order,
// but in practice this is rare and acceptable for the use case.
const allSkins = skins.concat(randomSkins);
// Shuffle the results
allSkins.sort(() => Math.random() - 0.5);
return allSkins.map(({ skin_md5 }) => {
return { md5: skin_md5 };
});
}
export async function getMuseumPage({
offset,
first,
}: {
offset: number;
first: number;
}): Promise<MuseumPage> {
const skins = await knex.raw(
`
SELECT
skins.md5,
(SELECT file_path from files WHERE files.skin_md5 = skins.md5 LIMIT 1) as file_path,
(skins.md5 IN (SELECT skin_reviews.skin_md5 from skin_reviews WHERE skin_reviews.review = 'NSFW')) as nsfw
FROM
museum_sort_order
LEFT JOIN skins ON skins.md5 = museum_sort_order.skin_md5
LIMIT ? OFFSET ?`,
[first, offset]
);
return skins.map(({ md5, nsfw, file_path }) => {
return {
fileName: path.basename(file_path),
md5,
nsfw: Boolean(nsfw),
};
});
}
export async function computeMuseumOrder() {
await knex.transaction(async (trx) => {
await trx("museum_sort_order").del();
const sql = fs.readFileSync(path.join(__dirname, "../museumOrder.sql"), {
encoding: "utf8",
});
await trx.raw(sql);
});
}
export async function getAllClassicSkins(): Promise<
Array<{ fileName: string; md5: string }>
> {
const skins = await knex.raw(
`
SELECT skins.md5,
files.file_path
FROM skins
LEFT JOIN files ON files.skin_md5 = skins.md5
WHERE skin_type = 1
GROUP BY skins.md5`,
[]
);
return skins.map(({ md5, file_path }) => {
if (file_path == null) {
throw new Error(`Could not find a file path for skin with md5 "${md5}"`);
}
return {
fileName: path.basename(file_path),
md5: md5,
};
});
}
export async function getAllApproved(): Promise<Array<string>> {
const skins = await knex("skins")
.leftJoin("skin_reviews", "skin_reviews.skin_md5", "=", "skins.md5")
.where("review", "APPROVED")
.select("md5");
return skins.map(({ md5 }) => md5);
}
export async function getAllClassicScreenshotUrls(): Promise<
Array<{ fileName: string; url: string }>
> {
const skins = await knex.raw(
`
SELECT skins.md5,
files.file_path,
skin_reviews.review
FROM skins
LEFT JOIN files ON files.skin_md5 = skins.md5
LEFT JOIN skin_reviews ON skin_reviews.skin_md5 = skins.md5 AND skin_reviews.review = "NSFW"
WHERE skin_type = 1
GROUP BY skins.md5`,
[]
);
return skins.map(({ md5, file_path, review }) => {
return {
md5,
nsfw: review != null,
fileName: path.basename(file_path),
url: getScreenshotUrl(md5),
};
});
}