mirror of
https://github.com/captbaritone/webamp.git
synced 2026-01-23 02:15:01 +00:00
Add bulk download page
This commit is contained in:
parent
d87cb6ffa3
commit
6c732f8e24
8 changed files with 745 additions and 64 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
});
|
||||
}
|
||||
|
|
|
|||
483
packages/skin-database/app/bulk-download/page.tsx
Normal file
483
packages/skin-database/app/bulk-download/page.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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> => {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export type SkinRow = {
|
|||
skin_type: number;
|
||||
emails: string;
|
||||
// readme_text: string;
|
||||
average_color: string;
|
||||
average_color?: string;
|
||||
};
|
||||
|
||||
export type TweetRow = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue