Add bulk download page

This commit is contained in:
Jordan Eldredge 2025-12-29 11:35:45 -08:00
parent d87cb6ffa3
commit 6c732f8e24
8 changed files with 745 additions and 64 deletions

View file

@ -0,0 +1,93 @@
import { Int } from "grats";
import { knex } from "../../../db";
import SkinModel from "../../../data/SkinModel";
import ClassicSkinResolver from "./ClassicSkinResolver";
import ModernSkinResolver from "./ModernSkinResolver";
import UserContext from "../../../data/UserContext";
import { ISkin } from "./CommonSkinResolver";
import { SkinRow } from "../../../types";
/**
* Connection for bulk download skin metadata
* @gqlType
*/
export class BulkDownloadConnection {
_offset: number;
_first: number;
constructor(first: number, offset: number) {
this._first = first;
this._offset = offset;
}
/**
* Total number of skins available for download
* @gqlField
*/
async totalCount(): Promise<Int> {
// Get count of both classic and modern skins
const [classicResult, modernResult] = await Promise.all([
knex("skins").where({ skin_type: 1 }).count("* as count"),
knex("skins").where({ skin_type: 2 }).count("* as count"),
]);
const classicCount = Number(classicResult[0].count);
const modernCount = Number(modernResult[0].count);
return classicCount + modernCount;
}
/**
* Estimated total size in bytes (approximation for progress indication)
* @gqlField
*/
async estimatedSizeBytes(): Promise<string> {
const totalCount = await this.totalCount();
// Rough estimate: average skin is ~56KB
return (totalCount * 56 * 1024).toString();
}
/**
* List of skin metadata for bulk download
* @gqlField
*/
async nodes(ctx: UserContext): Promise<Array<ISkin>> {
// Get skins ordered by skin_type (classic first, then modern) and id for consistency
const skins = await knex<SkinRow>("skins")
.select(["id", "md5", "skin_type", "emails"])
.orderBy([{ column: "skins.id", order: "asc" }])
.where({ skin_type: 1 })
.orWhere({ skin_type: 2 })
.limit(this._first)
.offset(this._offset);
return skins.map((skinRow) => {
const skinModel = new SkinModel(ctx, skinRow);
if (skinRow.skin_type === 1) {
return new ClassicSkinResolver(skinModel);
} else if (skinRow.skin_type === 2) {
return new ModernSkinResolver(skinModel);
} else {
throw new Error("Expected classic or modern skin");
}
});
}
}
/**
* Get metadata for bulk downloading all skins in the museum
* @gqlQueryField
*/
export function bulkDownload({
first = 1000,
offset = 0,
}: {
first?: Int;
offset?: Int;
}): BulkDownloadConnection {
if (first > 10000) {
throw new Error("Maximum limit is 10000 for bulk download");
}
return new BulkDownloadConnection(first, offset);
}

View file

@ -41,7 +41,7 @@ export default class ClassicSkinResolver implements NodeResolver, ISkin {
nsfw(): Promise<boolean> {
return this._model.getIsNsfw();
}
average_color(): string {
average_color(): string | null {
return this._model.getAverageColor();
}
/**

View file

@ -69,19 +69,34 @@ export function id(skin: ISkin): ID {
* has been uploaded under multiple names. Here we just pick one.
* @gqlField
*/
export function filename(
export async function filename(
skin: ISkin,
{
normalize_extension = false,
include_museum_id = 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;
/**
* If true, the museum ID will be appended to the filename to ensure filenames are globally unique.
*/
include_museum_id?: boolean;
}
): Promise<string> {
return skin.filename(normalize_extension);
const baseFilename = await skin.filename(normalize_extension);
if (!include_museum_id) {
return baseFilename;
}
const museumId = skin._model.getId();
const segments = baseFilename.split(".");
const fileExtension = segments.pop();
return `${segments.join(".")}_[S${museumId}].${fileExtension}`;
}
/**

View file

@ -93,6 +93,10 @@ interface Skin {
has been uploaded under multiple names. Here we just pick one.
"""
filename(
"""
If true, the museum ID will be appended to the filename to ensure filenames are globally unique.
"""
include_museum_id: Boolean! = false
"""
If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used.
@ -165,6 +169,16 @@ type ArchiveFile {
url: String
}
"""Connection for bulk download skin metadata"""
type BulkDownloadConnection {
"""Estimated total size in bytes (approximation for progress indication)"""
estimatedSizeBytes: String @semanticNonNull
"""List of skin metadata for bulk download"""
nodes: [Skin!] @semanticNonNull
"""Total number of skins available for download"""
totalCount: Int @semanticNonNull
}
"""A classic Winamp skin"""
type ClassicSkin implements Node & Skin {
"""List of files contained within the skin's .wsz archive"""
@ -178,6 +192,10 @@ type ClassicSkin implements Node & Skin {
has been uploaded under multiple names. Here we just pick one.
"""
filename(
"""
If true, the museum ID will be appended to the filename to ensure filenames are globally unique.
"""
include_museum_id: Boolean! = false
"""
If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used.
@ -308,6 +326,10 @@ type ModernSkin implements Node & Skin {
has been uploaded under multiple names. Here we just pick one.
"""
filename(
"""
If true, the museum ID will be appended to the filename to ensure filenames are globally unique.
"""
include_museum_id: Boolean! = false
"""
If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used.
@ -385,6 +407,8 @@ type Mutation {
}
type Query {
"""Get metadata for bulk downloading all skins in the museum"""
bulkDownload(first: Int! = 1000, offset: Int! = 0): BulkDownloadConnection @semanticNonNull
"""
Fetch archive file by it's MD5 hash

View file

@ -3,8 +3,9 @@
* Do not manually edit. Regenerate by running `npx grats`.
*/
import { GraphQLSchema, GraphQLDirective, DirectiveLocation, GraphQLList, GraphQLInt, specifiedDirectives, GraphQLObjectType, GraphQLString, GraphQLBoolean, GraphQLInterfaceType, GraphQLNonNull, GraphQLID, GraphQLEnumType, defaultFieldResolver, GraphQLInputObjectType } from "graphql";
import { GraphQLSchema, GraphQLDirective, DirectiveLocation, GraphQLList, GraphQLInt, specifiedDirectives, GraphQLObjectType, GraphQLString, defaultFieldResolver, GraphQLNonNull, GraphQLInterfaceType, GraphQLBoolean, GraphQLID, GraphQLEnumType, GraphQLInputObjectType } from "graphql";
import { getUserContext } from "./index";
import { bulkDownload as queryBulkDownloadResolver } from "./resolvers/BulkDownloadConnection";
import { fetch_archive_file_by_md5 as queryFetch_archive_file_by_md5Resolver } from "./../../data/ArchiveFileModel";
import { fetch_internet_archive_item_by_identifier as queryFetch_internet_archive_item_by_identifierResolver } from "./../../data/IaItemModel";
import { fetch_skin_by_md5 as queryFetch_skin_by_md5Resolver, search_classic_skins as querySearch_classic_skinsResolver, search_skins as querySearch_skinsResolver, skin_to_review as querySkin_to_reviewResolver } from "./resolvers/SkinResolver";
@ -27,6 +28,75 @@ async function assertNonNull<T>(value: T | Promise<T>): Promise<T> {
return awaited;
}
export function getSchema(): GraphQLSchema {
const ArchiveFileType: GraphQLObjectType = new GraphQLObjectType({
name: "ArchiveFile",
description: "A file found within a Winamp Skin's .wsz archive",
fields() {
return {
date: {
description: "The date on the file inside the archive. Given in simplified extended ISO\nformat (ISO 8601).",
name: "date",
type: GraphQLString,
resolve(source) {
return assertNonNull(source.getIsoDate());
}
},
file_md5: {
description: "The md5 hash of the file within the archive",
name: "file_md5",
type: GraphQLString,
resolve(source) {
return assertNonNull(source.getFileMd5());
}
},
filename: {
description: "Filename of the file within the archive",
name: "filename",
type: GraphQLString,
resolve(source) {
return assertNonNull(source.getFileName());
}
},
is_directory: {
description: "Is the file a directory?",
name: "is_directory",
type: GraphQLBoolean,
resolve(source) {
return assertNonNull(source.getIsDirectory());
}
},
size: {
description: "The uncompressed size of the file in bytes.\n\n**Note:** Will be `null` for directories",
name: "size",
type: GraphQLInt,
resolve(source) {
return source.getFileSize();
}
},
skin: {
description: "The skin in which this file was found",
name: "skin",
type: SkinType
},
text_content: {
description: "The content of the file, if it's a text file",
name: "text_content",
type: GraphQLString,
resolve(source) {
return source.getTextContent();
}
},
url: {
description: "A URL to download the file. **Note:** This is powered by a little\nserverless Cloudflare function which tries to exctact the file on the fly.\nIt may not work for all files.",
name: "url",
type: GraphQLString,
resolve(source) {
return source.getUrl();
}
}
};
}
});
const InternetArchiveItemType: GraphQLObjectType = new GraphQLObjectType({
name: "InternetArchiveItem",
fields() {
@ -188,6 +258,11 @@ export function getSchema(): GraphQLSchema {
name: "filename",
type: GraphQLString,
args: {
include_museum_id: {
description: "If true, the museum ID will be appended to the filename to ensure filenames are globally unique.",
type: new GraphQLNonNull(GraphQLBoolean),
defaultValue: false
},
normalize_extension: {
description: "If true, the the correct file extension (.wsz or .wal) will be .\nOtherwise, the original user-uploaded file extension will be used.",
type: new GraphQLNonNull(GraphQLBoolean),
@ -253,70 +328,33 @@ export function getSchema(): GraphQLSchema {
};
}
});
const ArchiveFileType: GraphQLObjectType = new GraphQLObjectType({
name: "ArchiveFile",
description: "A file found within a Winamp Skin's .wsz archive",
const BulkDownloadConnectionType: GraphQLObjectType = new GraphQLObjectType({
name: "BulkDownloadConnection",
description: "Connection for bulk download skin metadata",
fields() {
return {
date: {
description: "The date on the file inside the archive. Given in simplified extended ISO\nformat (ISO 8601).",
name: "date",
estimatedSizeBytes: {
description: "Estimated total size in bytes (approximation for progress indication)",
name: "estimatedSizeBytes",
type: GraphQLString,
resolve(source) {
return assertNonNull(source.getIsoDate());
resolve(source, args, context, info) {
return assertNonNull(defaultFieldResolver(source, args, context, info));
}
},
file_md5: {
description: "The md5 hash of the file within the archive",
name: "file_md5",
type: GraphQLString,
resolve(source) {
return assertNonNull(source.getFileMd5());
nodes: {
description: "List of skin metadata for bulk download",
name: "nodes",
type: new GraphQLList(new GraphQLNonNull(SkinType)),
resolve(source, _args, context) {
return assertNonNull(source.nodes(getUserContext(context)));
}
},
filename: {
description: "Filename of the file within the archive",
name: "filename",
type: GraphQLString,
resolve(source) {
return assertNonNull(source.getFileName());
}
},
is_directory: {
description: "Is the file a directory?",
name: "is_directory",
type: GraphQLBoolean,
resolve(source) {
return assertNonNull(source.getIsDirectory());
}
},
size: {
description: "The uncompressed size of the file in bytes.\n\n**Note:** Will be `null` for directories",
name: "size",
totalCount: {
description: "Total number of skins available for download",
name: "totalCount",
type: GraphQLInt,
resolve(source) {
return source.getFileSize();
}
},
skin: {
description: "The skin in which this file was found",
name: "skin",
type: SkinType
},
text_content: {
description: "The content of the file, if it's a text file",
name: "text_content",
type: GraphQLString,
resolve(source) {
return source.getTextContent();
}
},
url: {
description: "A URL to download the file. **Note:** This is powered by a little\nserverless Cloudflare function which tries to exctact the file on the fly.\nIt may not work for all files.",
name: "url",
type: GraphQLString,
resolve(source) {
return source.getUrl();
resolve(source, args, context, info) {
return assertNonNull(defaultFieldResolver(source, args, context, info));
}
}
};
@ -382,6 +420,11 @@ export function getSchema(): GraphQLSchema {
name: "filename",
type: GraphQLString,
args: {
include_museum_id: {
description: "If true, the museum ID will be appended to the filename to ensure filenames are globally unique.",
type: new GraphQLNonNull(GraphQLBoolean),
defaultValue: false
},
normalize_extension: {
description: "If true, the the correct file extension (.wsz or .wal) will be .\nOtherwise, the original user-uploaded file extension will be used.",
type: new GraphQLNonNull(GraphQLBoolean),
@ -544,6 +587,11 @@ export function getSchema(): GraphQLSchema {
name: "filename",
type: GraphQLString,
args: {
include_museum_id: {
description: "If true, the museum ID will be appended to the filename to ensure filenames are globally unique.",
type: new GraphQLNonNull(GraphQLBoolean),
defaultValue: false
},
normalize_extension: {
description: "If true, the the correct file extension (.wsz or .wal) will be .\nOtherwise, the original user-uploaded file extension will be used.",
type: new GraphQLNonNull(GraphQLBoolean),
@ -901,6 +949,24 @@ export function getSchema(): GraphQLSchema {
name: "Query",
fields() {
return {
bulkDownload: {
description: "Get metadata for bulk downloading all skins in the museum",
name: "bulkDownload",
type: BulkDownloadConnectionType,
args: {
first: {
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 1000
},
offset: {
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 0
}
},
resolve(_source, args) {
return assertNonNull(queryBulkDownloadResolver(args));
}
},
fetch_archive_file_by_md5: {
description: "Fetch archive file by it's MD5 hash\n\nGet information about a file found within a skin's wsz/wal/zip archive.",
name: "fetch_archive_file_by_md5",
@ -1307,6 +1373,6 @@ export function getSchema(): GraphQLSchema {
})],
query: QueryType,
mutation: MutationType,
types: [RatingType, SkinUploadStatusType, SkinsFilterOptionType, SkinsSortOptionType, TweetsSortOptionType, NodeType, SkinType, UploadUrlRequestType, ArchiveFileType, ClassicSkinType, DatabaseStatisticsType, InternetArchiveItemType, ModernSkinType, ModernSkinsConnectionType, MutationType, QueryType, ReviewType, SkinUploadType, SkinsConnectionType, TweetType, TweetsConnectionType, UploadMutationsType, UploadUrlType, UserType]
types: [RatingType, SkinUploadStatusType, SkinsFilterOptionType, SkinsSortOptionType, TweetsSortOptionType, NodeType, SkinType, UploadUrlRequestType, ArchiveFileType, BulkDownloadConnectionType, ClassicSkinType, DatabaseStatisticsType, InternetArchiveItemType, ModernSkinType, ModernSkinsConnectionType, MutationType, QueryType, ReviewType, SkinUploadType, SkinsConnectionType, TweetType, TweetsConnectionType, UploadMutationsType, UploadUrlType, UserType]
});
}

View file

@ -0,0 +1,483 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { fetchGraphql, gql } from "../../legacy-client/src/utils";
interface BulkDownloadSkin {
md5: string;
filename: string;
download_url: string;
__typename: string;
}
interface DownloadProgress {
totalSkins: number;
completedSkins: number;
failedSkins: number;
estimatedSizeBytes: string;
activeDownloads: Array<{
filename: string;
md5: string;
status: "downloading" | "failed";
error?: string;
}>;
}
interface DirectoryHandle {
name: string;
getDirectoryHandle: (
name: string,
options?: { create?: boolean }
) => Promise<DirectoryHandle>;
getFileHandle: (
name: string,
options?: { create?: boolean }
) => Promise<FileSystemFileHandle>;
}
declare global {
interface Window {
showDirectoryPicker?: () => Promise<DirectoryHandle>;
}
}
const BULK_DOWNLOAD_QUERY = gql`
query BulkDownload($offset: Int!, $first: Int!) {
bulkDownload(offset: $offset, first: $first) {
totalCount
estimatedSizeBytes
nodes {
__typename
md5
filename(normalize_extension: true, include_museum_id: true)
download_url
}
}
}
`;
const MAX_CONCURRENT_DOWNLOADS = 6;
const CHUNK_SIZE = 1000;
export default function BulkDownloadPage() {
const [directoryHandle, setDirectoryHandle] =
useState<DirectoryHandle | null>(null);
const [progress, setProgress] = useState<DownloadProgress>({
totalSkins: 0,
completedSkins: 0,
failedSkins: 0,
estimatedSizeBytes: "0",
activeDownloads: [],
});
const [isDownloading, setIsDownloading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSupported] = useState(
typeof window !== "undefined" && "showDirectoryPicker" in window
);
const abortController = useRef<AbortController | null>(null);
const downloadSkin = useCallback(
async (
skin: BulkDownloadSkin,
directoryHandle: DirectoryHandle,
signal: AbortSignal
): Promise<void> => {
const { filename, download_url, md5 } = skin;
// Get the target directory and file path
const targetDirectory = await getDirectoryForSkin(
filename,
directoryHandle
);
// Check if file already exists
try {
await targetDirectory.getFileHandle(filename);
// File exists, skip download
console.log(`Skipping ${filename} - already exists`);
setProgress((prev) => ({
...prev,
completedSkins: prev.completedSkins + 1,
}));
return;
} catch (error) {
// File doesn't exist, continue with download
}
// Add to active downloads
setProgress((prev) => ({
...prev,
activeDownloads: [
...prev.activeDownloads,
{
filename,
md5,
status: "downloading" as const,
},
],
}));
try {
const response = await fetch(download_url, { signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// We don't need individual progress tracking anymore
// const contentLength = parseInt(response.headers.get("content-length") || "0", 10);
const reader = response.body?.getReader();
if (!reader) {
throw new Error("No response body");
}
// Use the targetDirectory and finalFilename we calculated earlier
const fileHandle = await targetDirectory.getFileHandle(filename, {
create: true,
});
const writable = await fileHandle.createWritable();
// Track total bytes for this file (not needed for individual progress)
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
await writable.write(value);
}
await writable.close();
// Mark as completed and immediately remove from active downloads
setProgress((prev) => ({
...prev,
completedSkins: prev.completedSkins + 1,
activeDownloads: prev.activeDownloads.filter((d) => d.md5 !== md5),
}));
} catch (writeError) {
await writable.abort("Failed to write file");
throw writeError;
}
} catch (error: any) {
if (error.name === "AbortError") {
console.log(`Download aborted: ${filename}`);
throw error; // Re-throw abort errors
}
// Mark as failed and schedule removal
setProgress((prev) => ({
...prev,
failedSkins: prev.failedSkins + 1,
activeDownloads: prev.activeDownloads.map((d) =>
d.md5 === md5
? {
...d,
status: "failed" as const,
error: error.message,
}
: d
),
}));
// Remove failed download after 3 seconds
setTimeout(() => {
setProgress((prev) => ({
...prev,
activeDownloads: prev.activeDownloads.filter((d) => d.md5 !== md5),
}));
}, 3000);
console.error(`Failed to download ${filename}:`, error);
}
},
[getDirectoryForSkin]
);
// Load initial metadata when component mounts
useEffect(() => {
async function loadInitialData() {
try {
const { totalCount, estimatedSizeBytes } = await fetchSkins(0, 1);
setProgress((prev) => ({
...prev,
totalSkins: totalCount,
estimatedSizeBytes,
}));
} catch (error: any) {
console.error("Failed to load initial data:", error);
setError("Failed to load skin count information");
}
}
loadInitialData();
}, [fetchSkins]);
const selectDirectoryAndStart = useCallback(async () => {
// First, select directory if not already selected
if (!directoryHandle) {
if (!window.showDirectoryPicker) {
setError(
"File System Access API is not supported in this browser. Please use Chrome or Edge."
);
return;
}
try {
const handle = await window.showDirectoryPicker();
setDirectoryHandle(handle);
setError(null);
// Now start the download with the new directory
await startDownloadWithDirectory(handle as FileSystemDirectoryHandle);
} catch (err: any) {
if (err.name !== "AbortError") {
setError(`Failed to select directory: ${err.message}`);
}
}
} else {
// Directory already selected, just start download
await startDownloadWithDirectory(
directoryHandle as FileSystemDirectoryHandle
);
}
}, [directoryHandle]);
const startDownloadWithDirectory = useCallback(
async (handle: FileSystemDirectoryHandle) => {
setIsDownloading(true);
setError(null);
// setStartTime(Date.now());
abortController.current = new AbortController();
try {
// Get initial metadata
const { totalCount, estimatedSizeBytes } = await fetchSkins(0, 1);
setProgress({
totalSkins: totalCount,
completedSkins: 0,
failedSkins: 0,
estimatedSizeBytes,
activeDownloads: [],
});
let offset = 0;
const activePromises = new Set<Promise<void>>();
while (offset < totalCount && !abortController.current.signal.aborted) {
console.log(`Fetching batch: offset=${offset}, chunk=${CHUNK_SIZE}`);
try {
const { skins } = await fetchSkins(offset, CHUNK_SIZE);
console.log(`Retrieved ${skins.length} skins in this batch`);
if (skins.length === 0) {
console.log("No more skins to fetch, breaking");
break;
}
for (const skin of skins) {
if (abortController.current.signal.aborted) break;
await waitForAvailableSlot(
activePromises,
abortController.current.signal
);
if (abortController.current.signal.aborted) break;
const downloadPromise = downloadSkin(
skin,
handle,
abortController.current.signal
).finally(() => {
activePromises.delete(downloadPromise);
});
activePromises.add(downloadPromise);
}
offset += skins.length;
console.log(`Completed batch, new offset: ${offset}/${totalCount}`);
} catch (error: any) {
console.error(`Failed to fetch batch at offset ${offset}:`, error);
setError(`Failed to fetch skins: ${error.message}`);
break;
}
}
// Wait for all remaining downloads to complete
await Promise.allSettled(activePromises);
console.log("All downloads completed!");
} catch (error: any) {
if (error.name !== "AbortError") {
setError(`Download failed: ${error.message}`);
}
} finally {
setIsDownloading(false);
}
},
[fetchSkins, downloadSkin]
);
const stopDownload = useCallback(() => {
if (abortController.current) {
abortController.current.abort("User Canceled");
}
setIsDownloading(false);
// setStartTime(null);
}, []);
const progressPercent =
progress.totalSkins > 0
? ((progress.completedSkins + progress.failedSkins) /
progress.totalSkins) *
100
: 0;
if (!isSupported) {
return <h1>Your browser does not support filesystem access.</h1>;
}
const gb = Math.round(
parseInt(progress.estimatedSizeBytes || "0", 10) / (1024 * 1024 * 1024)
);
return (
<div>
<div>
<div>
<div>
<h1>Bulk Download All Skins</h1>
<p>
Download the entire Winamp Skin Museum collection.
<ul>
<li>
Will download {progress.totalSkins.toLocaleString()} files (~
{gb}
GB) into the selected directory
</li>
<li>
Files will be organized into directories (aa-zz, 0-9) based on
filename prefix
</li>
<li>
Supports resuming from previously interrupted bulk download
</li>
</ul>
</p>
</div>
</div>
{error && (
<div>
<div>{error}</div>
</div>
)}
{/* Download Controls */}
<div>
{isDownloading ? (
<button onClick={stopDownload}>Stop Download</button>
) : (
<button onClick={selectDirectoryAndStart}>
{directoryHandle
? "Start Download"
: "Select Directory & Start Download"}
</button>
)}
</div>
{/* Progress Section */}
{(isDownloading || progress.completedSkins > 0) && (
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span>
Downloaded{" "}
{(
progress.completedSkins + progress.failedSkins
).toLocaleString()}{" "}
of {progress.totalSkins.toLocaleString()} skins
</span>
<span>{Math.round(progressPercent)}% complete</span>
</div>
<div
style={{
border: "1px solid black",
}}
>
<div
style={{
background: "black",
transition: "all 300ms",
height: "18px",
width: `${progressPercent}%`,
}}
/>
</div>
</div>
)}
</div>
</div>
);
}
async function getDirectoryForSkin(
filename: string,
rootHandle: DirectoryHandle
) {
// Create directory based on first two characters of filename (case insensitive)
const firstChar = filename.charAt(0).toLowerCase();
const secondChar =
filename.length > 1 ? filename.charAt(1).toLowerCase() : "";
let dirName: string;
if (/[a-z]/.test(firstChar)) {
// For letters, use two-character prefix if second char is alphanumeric
if (/[a-z0-9]/.test(secondChar)) {
dirName = firstChar + secondChar;
} else {
// Fallback to single letter + 'x' for special characters
dirName = firstChar + "x";
}
} else {
// For numbers/symbols, use "0-9"
dirName = "0-9";
}
try {
return await rootHandle.getDirectoryHandle(dirName, { create: true });
} catch (err) {
console.warn(`Failed to create directory ${dirName}, using root:`, err);
return rootHandle;
}
}
async function fetchSkins(
offset: number,
first: number
): Promise<{
skins: BulkDownloadSkin[];
totalCount: number;
estimatedSizeBytes: string;
}> {
const { bulkDownload } = await fetchGraphql(BULK_DOWNLOAD_QUERY, {
offset,
first,
});
return {
skins: bulkDownload.nodes,
totalCount: bulkDownload.totalCount,
estimatedSizeBytes: bulkDownload.estimatedSizeBytes,
};
}
// Helper function to wait for an available download slot
async function waitForAvailableSlot(
activePromises: Set<Promise<void>>,
signal: AbortSignal
) {
while (activePromises.size >= MAX_CONCURRENT_DOWNLOADS && !signal.aborted) {
await Promise.race(activePromises);
}
}

View file

@ -232,8 +232,8 @@ export default class SkinModel {
}
}
getAverageColor(): string {
return this.row.average_color;
getAverageColor(): string | null {
return this.row.average_color ?? null;
}
getBuffer = mem(async (): Promise<Buffer> => {

View file

@ -19,7 +19,7 @@ export type SkinRow = {
skin_type: number;
emails: string;
// readme_text: string;
average_color: string;
average_color?: string;
};
export type TweetRow = {