Refactors and work toward fixing consistency with archive.org

This commit is contained in:
Jordan Eldredge 2022-02-19 14:44:09 -08:00
parent 06243ffffc
commit 3c09e53458
12 changed files with 399 additions and 142 deletions

View file

@ -266,10 +266,8 @@ router.post(
asyncHandler(async (req, res) => {
const { md5 } = req.params;
req.log(`Reporting skin with hash "${md5}"`);
const skin = await SkinModel.fromMd5(req.ctx, md5);
if (skin == null) {
throw new Error(`Cold not locate as skin with md5 ${md5}`);
}
// Blow up if there is no skin with this hash
await SkinModel.fromMd5Assert(req.ctx, md5);
req.notify({ type: "REVIEW_REQUESTED", md5 });
res.send("The skin has been reported and will be reviewed shortly.");
})

View file

@ -11,8 +11,16 @@ import { addSkinFromBuffer } from "./addSkin";
import { scrapeLikeData } from "./tasks/scrapeLikes";
import { followerCount, popularTweets } from "./tasks/tweetMilestones";
import UserContext from "./data/UserContext";
import { integrityCheck } from "./tasks/integrityCheck";
import { ensureWebampLinks, syncWithArchive } from "./tasks/syncWithArchive";
import {
integrityCheck,
checkInternetArchiveMetadata,
} from "./tasks/integrityCheck";
import {
ensureWebampLinks,
findItemsMissingImages,
syncWithArchive,
uploadScreenshotIfSafe,
} from "./tasks/syncToArchive";
import { fillMissingMetadata, syncFromArchive } from "./tasks/syncFromArchive";
import { refreshSkins } from "./tasks/refresh";
import {
@ -110,10 +118,7 @@ program
console.log(await Skins.updateSearchIndex(md5));
}
if (refresh) {
const skin = await SkinModel.fromMd5(ctx, md5);
if (skin == null) {
throw new Error(`Could not find skin ${md5}`);
}
const skin = await SkinModel.fromMd5Assert(ctx, md5);
await refreshSkins([skin]);
}
if (reject) {
@ -172,7 +177,7 @@ program
"and add them."
)
.option(
"--upload",
"--upload-new",
"Find newly uploaded skins, and publish them to the Internet Archive."
)
.action(async ({ metadata, webampLinks, fetchItems, upload }) => {
@ -223,8 +228,16 @@ program
program
.command("integrity-check")
.description("Perfrom a non-exhaustive list of database consistency checks")
.action(async () => {
await integrityCheck();
.option(
"--ia",
"Check the Internet Archive for items that are missing files."
)
.action(async ({ ia }) => {
if (ia) {
await checkInternetArchiveMetadata();
} else {
await integrityCheck();
}
});
/**
@ -265,6 +278,41 @@ program
}
});
/**
* Commands thare are still in development
*/
program
.command("dev")
.description("Grab bag of commands that don't have a place to live yet")
.option(
"--upload-ia-screenshot <md5>",
"Upload a screenshot of a skin to the skin's Internet Archive itme. " +
"[[Warning!]] This might result in multiple screenshots on the item."
)
.option(
"--upload-missing-screenshots",
"Find all IA items that are missing screenshots, and upload the missing ones."
)
.action(async ({ uploadIaScreenshot, uploadMissingScreenshots }) => {
if (uploadIaScreenshot) {
const md5 = uploadIaScreenshot;
if (!(await uploadScreenshotIfSafe(md5))) {
console.log("Did not upload screenshot");
}
}
if (uploadMissingScreenshots) {
const md5s = await findItemsMissingImages();
for (const md5 of md5s) {
if (await uploadScreenshotIfSafe(md5)) {
console.log("Upladed screenshot for ", md5);
} else {
console.log("Did not upload screenshot for ", md5);
}
}
}
});
async function main() {
try {
await program.parseAsync(process.argv);

View file

@ -3,6 +3,7 @@ import { IaItemRow } from "../types";
import SkinModel from "./SkinModel";
import DataLoader from "dataloader";
import { knex } from "../db";
import { exec } from "../utils";
const IA_URL = /^(https:\/\/)?archive.org\/details\/([^/]+)\/?/;
@ -70,6 +71,88 @@ export default class IaItemModel {
return identifier;
}
getAllFiles(): any[] {
if (this.row.metadata == null) {
return [];
}
return JSON.parse(this.row.metadata).files;
}
getUploadedFiles(): any {
return this.getAllFiles().filter(isNotGeneratedFile);
}
// There should be exactly one, but in error cases there can be more or none.
getSkinFiles(): any[] {
return this.getUploadedFiles().filter((file) => file.name.endsWith(".wsz"));
}
async getTasks(): Promise<any[]> {
const result = await exec(`ia tasks ${this.getIdentifier()}`, {
encoding: "utf8",
});
return result.stdout
.trim()
.split("\n")
.map((line) => JSON.parse(line));
}
async hasRunningTasks(): Promise<boolean> {
const tasks = await this.getTasks();
const hasTasks = tasks.some((task) => {
// I'm not really sure what the schema is here. From my limited observations:
// - All completed tasks have a cateory of "history"
// - All completed tasks have a finished timestamp
// - All running tasks have a status of "running"
return (
task.status === "running" ||
task.category !== "history" ||
task.finished == null
);
});
// Just to be sure, let's invalidate the metadata we have.
if (hasTasks) {
await this.invalidateMetadata();
}
return hasTasks;
}
// Fetch new metadata assuming there a no running tasks.
async updateMetadataUnsafe(): Promise<void> {
// TODO: Move some of this into a IA service.
const identifier = this.getIdentifier();
const r = await fetch(`https://archive.org/metadata/${identifier}`);
if (!r.ok) {
console.error(await r.json());
throw new Error(`Could not fetch metadata for ${identifier}`);
}
const response = await r.json();
await knex("ia_items")
.where("identifier", identifier)
.update({ metadata: JSON.stringify(response, null, 2) })
.update("metadata_timestamp", knex.fn.now());
}
// Check if there are any running tasks and, if not, update the metadata.
async updateMetadata(): Promise<boolean> {
if (await this.hasRunningTasks()) {
return false;
}
await this.updateMetadataUnsafe();
return true;
}
// Clear our local cache of metadata. Call this any time you update an IA item
// in such a way that it might change the metadata.
async invalidateMetadata(): Promise<void> {
this.row.metadata = "";
await knex("ia_items")
.where("identifier", this.getIdentifier())
.update({ metadata: "", metadata_timestamp: null });
}
async debug(): Promise<IaItemDebugData> {
return {
row: this.row,
@ -77,6 +160,21 @@ export default class IaItemModel {
}
}
function isNotGeneratedFile(file) {
switch (file.source) {
case "derivative":
case "metadata":
return false;
}
switch (file.format) {
case "Metadata":
case "Item Tile":
case "JPEG Thumb":
return false;
}
return true;
}
const getIaItemLoader = ctxWeakMapMemoize<DataLoader<string, IaItemRow>>(
() =>
new DataLoader(async (md5s) => {

View file

@ -45,11 +45,7 @@ export default class InstagramPostModel {
}
async getSkin(): Promise<SkinModel> {
const skin = await SkinModel.fromMd5(this.ctx, this.getMd5());
if (skin == null) {
throw new Error(`Could not find skin for md5 "${this.getMd5()}"`);
}
return skin;
return SkinModel.fromMd5Assert(this.ctx, this.getMd5());
}
async debug(): Promise<InstagramPostDebugData> {
@ -64,7 +60,9 @@ const getInstagramPostsLoader = ctxWeakMapMemoize<
>(
() =>
new DataLoader(async (md5s) => {
const rows = await knex("instagram_posts").whereIn("skin_md5", md5s).select();
const rows = await knex("instagram_posts")
.whereIn("skin_md5", md5s)
.select();
return md5s.map((md5) => rows.filter((x) => x.skin_md5 === md5));
})
);
@ -74,7 +72,9 @@ const getInstagramPostsByPostIdLoader = ctxWeakMapMemoize<
>(
() =>
new DataLoader(async (ids) => {
const rows = await knex("instagram_posts").whereIn("post_id", ids).select();
const rows = await knex("instagram_posts")
.whereIn("post_id", ids)
.select();
return ids.map((id) => rows.find((x) => x.tweet_id === id));
})
);

View file

@ -10,7 +10,7 @@ import UserContext, { ctxWeakMapMemoize } from "./UserContext";
import TweetModel, { TweetDebugData } from "./TweetModel";
import IaItemModel, { IaItemDebugData } from "./IaItemModel";
import FileModel, { FileDebugData } from "./FileModel";
import { MD5_REGEX } from "../utils";
import { MD5_REGEX, withUrlAsTempFile } from "../utils";
import DataLoader from "dataloader";
import { knex } from "../db";
import UploadModel, { UploadDebugData } from "./UploadModel";
@ -30,6 +30,13 @@ export default class SkinModel {
return row == null ? null : new SkinModel(ctx, row);
}
static async fromMd5Assert(
ctx: UserContext,
md5: string
): Promise<SkinModel> {
return SkinModel.fromMd5Assert(ctx, md5);
}
static async fromAnything(
ctx: UserContext,
anything: string
@ -136,7 +143,16 @@ export default class SkinModel {
if (files.length === 0) {
throw new Error(`Could not find file for skin with md5 ${this.getMd5()}`);
}
return files[0].getFileName();
const filename = files[0].getFileName();
if (!filename.match(/\.(zip)|(wsz)$/i)) {
throw new Error("Expected filename to end with zip or wsz.");
}
return filename;
}
async getScreenshotFileName(): Promise<string> {
const skinFilename = await this.getFileName();
return skinFilename.replace(/\.(wsz|zip)$/, ".png");
}
getMd5(): string {
@ -169,22 +185,41 @@ export default class SkinModel {
return this.row.average_color;
}
getBuffer = mem(
async (): Promise<Buffer> => {
const response = await fetch(this.getSkinUrl());
if (!response.ok) {
throw new Error(`Could not fetch skin at "${this.getSkinUrl()}"`);
}
return response.buffer();
getBuffer = mem(async (): Promise<Buffer> => {
const response = await fetch(this.getSkinUrl());
if (!response.ok) {
throw new Error(`Could not fetch skin at "${this.getSkinUrl()}"`);
}
);
return response.buffer();
});
getZip = mem(
async (): Promise<JSZip> => {
const buffer = await this.getBuffer();
return JSZip.loadAsync(buffer);
getScreenshotBuffer = mem(async (): Promise<Buffer> => {
const response = await fetch(this.getScreenshotUrl());
if (!response.ok) {
throw new Error(
`Could not fetch skin screenshot at "${this.getScreenshotUrl()}"`
);
}
);
return response.buffer();
});
async withTempFile(cb: (file: string) => Promise<void>): Promise<void> {
const filename = await this.getFileName();
return withUrlAsTempFile(this.getSkinUrl(), filename, cb);
}
async withScreenshotTempFile(
cb: (file: string) => Promise<void>
): Promise<void> {
const screenshotFilename = await this.getScreenshotFileName();
return withUrlAsTempFile(this.getScreenshotUrl(), screenshotFilename, cb);
}
getZip = mem(async (): Promise<JSZip> => {
const buffer = await this.getBuffer();
return JSZip.loadAsync(buffer);
});
async debug(): Promise<{
row: SkinRow;

View file

@ -61,11 +61,7 @@ export default class TweetModel {
}
async getSkin(): Promise<SkinModel> {
const skin = await SkinModel.fromMd5(this.ctx, this.getMd5());
if (skin == null) {
throw new Error(`Could not find skin for md5 "${this.getMd5()}"`);
}
return skin;
return SkinModel.fromMd5Assert(this.ctx, this.getMd5());
}
async debug(): Promise<TweetDebugData> {

View file

@ -93,10 +93,7 @@ const UPSCALE = 4;
async function post(md5: string): Promise<string> {
const ctx = new UserContext();
const skin = await SkinModel.fromMd5(ctx, md5);
if (skin == null) {
throw new Error("Could not find skin");
}
const skin = await SkinModel.fromMd5Assert(ctx, md5);
const screenshot = await Skins.getScreenshotBuffer(md5);
const { width, height } = await sharp(screenshot).metadata();

View file

@ -2,6 +2,66 @@ import fs from "fs";
import { knex } from "../db";
import { TWEET_SNOWFLAKE_REGEX, MD5_REGEX } from "../utils";
function isNotGeneratedFile(file) {
switch (file.source) {
case "derivative":
case "metadata":
return false;
}
switch (file.format) {
case "Metadata":
case "Item Tile":
case "JPEG Thumb":
return false;
}
return true;
}
export async function checkInternetArchiveMetadata(): Promise<void> {
const results = await knex.raw(
'SELECT skin_md5, identifier, metadata FROM ia_items WHERE metadata != "";'
);
const tooMany: string[] = [];
const tooFew: string[] = [];
const missingSkin: string[] = [];
for (const item of results) {
const { identifier, metadata, skin_md5 } = item;
try {
const allFiles = JSON.parse(metadata).files;
const files = allFiles.filter(isNotGeneratedFile);
if (files.length > 2) {
tooMany.push(skin_md5);
continue;
console.warn("Too many files", { files, identifier, skin_md5 });
}
const skinFile = files.find((file) => file.md5 === skin_md5);
if (skinFile == null) {
missingSkin.push(skin_md5);
continue;
console.warn("No skin file", { identifier, skin_md5 });
}
if (files.length < 2) {
console.log({skin_md5, identifier, length: files.length});
tooFew.push(skin_md5);
continue;
console.warn("Too few files", { identifier, skin_md5 });
}
} catch (e) {
console.log(metadata);
}
}
console.table({
total: results.length,
tooMany: tooMany.length,
tooFew: tooFew.length,
missingSkin: missingSkin.length,
});
}
export async function integrityCheck(): Promise<void> {
await noDuplicateTweetIds();
await noDuplicateTweetMd5s();

View file

@ -1,20 +1,9 @@
import { knex } from "../db";
import fetch from "node-fetch";
import UserContext from "../data/UserContext";
import SkinModel from "../data/SkinModel";
import child_process from "child_process";
import * as Parallel from "async-parallel";
import { chunk } from "../utils";
import util from "util";
const exec = util.promisify(child_process.exec);
function flatten<T>(matrix: T[][]): T[] {
const flat: T[] = [];
matrix.forEach((arr) => {
flat.push(...arr);
});
return flat;
}
import { chunk, flatten } from "../utils";
import IaItemModel from "../data/IaItemModel";
async function _filterOutKnownIdentifiers(
identifiers: string[]
@ -69,47 +58,8 @@ async function allItems(): Promise<string[]> {
return items.map((item: { identifier: string }) => item.identifier);
}
async function ensureIaRecord(
ctx: UserContext,
identifier: string
): Promise<void> {
const response = await updateMetadata(identifier);
const files = response.files;
const skins = files.filter((file) => file.name.endsWith(".wsz"));
if (skins.length === 0) {
// TODO TODO TODO TODO
// TODO TODO TODO TODO
//
// What if the skin ends in .zip?
//
// TODO TODO TODO TODO
// TODO TODO TODO TODO
console.log(`No skins found in ${identifier}. Deleting... (YOLO)`);
const command = `ia delete ${identifier} --all`;
// await exec(command, { encoding: "utf8" });
console.log(`Deleted ${identifier}`);
return;
}
if (skins.length !== 1) {
console.error(
`Expected to find one skin file for "${identifier}", found ${skins.length}`
);
return;
}
const md5 = skins[0].md5;
const skin = await SkinModel.fromMd5(ctx, md5);
if (skin == null) {
console.error(
`We don't have a record for the skin found in "${identifier}"`
);
return;
}
await knex("ia_items").insert({ skin_md5: md5, identifier });
console.log(`Inserted "${identifier}".`);
}
export async function fillMissingMetadata(count: number) {
const ctx = new UserContext();
const skins = await knex("skins")
.leftJoin("ia_items", "skins.md5", "ia_items.skin_md5")
.where("ia_items.metadata", null)
@ -117,27 +67,19 @@ export async function fillMissingMetadata(count: number) {
.limit(count)
.select("skins.md5", "ia_items.identifier");
for (const { md5, identifier } of skins) {
await updateMetadata(identifier);
const iaItem = await IaItemModel.fromIdentifier(ctx, identifier);
if (iaItem == null) {
console.error(`Could not find IA item for ${identifier}`);
break;
}
await iaItem.updateMetadata();
console.log(`Updated metadata for ${identifier} ${md5}`);
}
console.log("Done updating metadata.");
}
async function updateMetadata(identifier: string): Promise<any> {
const r = await fetch(`https://archive.org/metadata/${identifier}`);
if (!r.ok) {
console.error(await r.json())
throw new Error(`Could not fetch metadata for ${identifier}`);
}
const response = await r.json();
await knex("ia_items")
.where("identifier", identifier)
.update({ metadata: JSON.stringify(response, null, 2) })
.update("metadata_timestamp", knex.fn.now());
return response;
}
export async function syncFromArchive() {
throw new Error("This needs to be rewritten");
const ctx = new UserContext();
// Ensure we know about all items in the `winampskins` collection.
console.log("Going to ensure we know about all archive items");
@ -146,7 +88,8 @@ export async function syncFromArchive() {
await Parallel.each(
unknownItems,
async (identifier) => {
await ensureIaRecord(ctx, identifier);
console.log(identifier, ctx);
// await ensureIaRecord(ctx, identifier);
},
CONCURRENT
);

View file

@ -3,14 +3,70 @@ import path from "path";
import fetch from "node-fetch";
import _temp from "temp";
import fs from "fs";
import child_process from "child_process";
import UserContext from "../data/UserContext";
import SkinModel from "../data/SkinModel";
import util from "util";
import * as Parallel from "async-parallel";
import IaItemModel from "../data/IaItemModel";
import DiscordEventHandler from "../api/DiscordEventHandler";
const exec = util.promisify(child_process.exec);
import { exec } from "../utils";
export async function findItemsMissingImages(): Promise<string[]> {
const ctx = new UserContext();
const results = await knex.raw("SELECT * FROM ia_items;");
const md5s: string[] = [];
for (const row of results) {
const iaItem = new IaItemModel(ctx, row);
if (iaItem == null) {
throw new Error("Expected to find IA item");
}
if (
iaItem.getUploadedFiles().length >= 2 ||
iaItem.getSkinFiles().length !== 1
) {
continue;
}
md5s.push(iaItem.getMd5());
}
return md5s;
}
// Uploads the screenshot to IA if it's safe to do so. In general this should
// not be needed, since we usually upload both files at once.
export async function uploadScreenshotIfSafe(md5: string): Promise<boolean> {
const ctx = new UserContext();
const skin = await SkinModel.fromMd5Assert(ctx, md5);
const iaItem = await skin.getIaItem();
if (iaItem == null) {
throw new Error("Expected ia item to exist");
}
if (!iaItem.row.metadata) {
return false;
}
if (await iaItem.hasRunningTasks()) {
return false;
}
const skinFiles = iaItem.getSkinFiles();
if (skinFiles.length != 1) {
return false;
}
const uploadedFiles = iaItem.getUploadedFiles();
if (uploadedFiles.length !== skinFiles.length) {
return false;
}
await skin.withScreenshotTempFile(async (screenshotFile) => {
const command = `ia upload ${iaItem.getIdentifier()} "${screenshotFile}"`;
await exec(command, { encoding: "utf8" });
});
await iaItem.invalidateMetadata();
return true;
}
/** LEGACY BELOW HERE */
const CONCURRENT = 1;
@ -74,24 +130,8 @@ async function getNewIdentifier(filename: string): Promise<string> {
export async function archive(skin: SkinModel): Promise<string> {
const filename = await skin.getFileName();
if (filename == null) {
throw new Error(
`Couldn't archive skin. Filename not found. ${skin.getMd5()}`
);
}
if (
!(
filename.toLowerCase().endsWith(".wsz") ||
filename.toLowerCase().endsWith(".zip")
)
) {
throw new Error(
`Unexpected file extension for ${skin.getMd5()}: ${filename}`
);
}
const screenshotFilename = filename.replace(/\.(wsz|zip)$/, ".png");
const screenshotFilename = await skin.getScreenshotFileName();
const title = `Winamp Skin: ${filename}`;
const [skinFile, screenshotFile] = await Promise.all([
@ -126,15 +166,11 @@ export async function syncWithArchive(handler: DiscordEventHandler) {
await Parallel.map(
unarchived,
async ({ md5 }) => {
// The internet archive claims this one is corrupt for some reason.
if (md5 === "513fdd06bf39391e52f3ac5b233dd147") {
return;
}
const skin = await SkinModel.fromMd5(ctx, md5);
if (skin == null) {
throw new Error(`Expected to get skin for ${md5}`);
// The internet archive claims this one is corrupt for some reason.
if (md5 === "513fdd06bf39391e52f3ac5b233dd147") {
return;
}
const skin = await SkinModel.fromMd5Assert(ctx, md5);
try {
console.log(`Attempting to upload ${md5}`);
const identifier = await archive(skin);
@ -152,7 +188,9 @@ export async function syncWithArchive(handler: DiscordEventHandler) {
) {
console.log(`Corrupt archvie (encrypted): ${skin.getMd5()}`);
} else if (/case alias may already exist/.test(e.message)) {
console.log(`Invalid name (case alias): ${skin.getMd5()} with ${e.message}`);
console.log(
`Invalid name (case alias): ${skin.getMd5()} with ${e.message}`
);
} else {
console.error(e);
}

View file

@ -50,6 +50,7 @@ export type ArchiveFileRow = {
export type IaItemRow = {
skin_md5: string;
identifier: string;
metadata: string; // JSON from the internet archive
};
export type UploadStatus =

View file

@ -1,3 +1,20 @@
import fs from "fs";
import _temp from "temp";
import fetch from "node-fetch";
import util from "util";
import child_process from "child_process";
import path from "path";
export const exec = util.promisify(child_process.exec);
const temp = _temp.track();
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export function truncate(str: string, len: number): string {
const overflow = str.length - len;
if (overflow < 0) {
@ -19,6 +36,14 @@ export function chunk<T>(items: T[], chunkSize: number): T[][] {
return chunks;
}
export function flatten<T>(matrix: T[][]): T[] {
const flat: T[] = [];
matrix.forEach((arr) => {
flat.push(...arr);
});
return flat;
}
export const MD5_REGEX = /([a-fA-F0-9]{32})/;
export const TWEET_SNOWFLAKE_REGEX = /([0-9]{19})/;
@ -27,3 +52,21 @@ export function throwAfter(message, ms) {
setTimeout(() => reject(new Error(message)), ms);
});
}
export async function withUrlAsTempFile(
url: string,
filename: string,
cb: (file: string) => Promise<void>
): Promise<void> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download ${url}.`);
}
const result = await response.buffer();
const tempDir = temp.mkdirSync();
const tempFile = path.join(tempDir, filename);
fs.writeFileSync(tempFile, result);
await cb(tempFile);
fs.unlinkSync(tempFile);
fs.rmdirSync(tempDir);
}