Files
romm/frontend/src/services/api/rom.ts

834 lines
21 KiB
TypeScript

import type { AxiosProgressEvent } from "axios";
import Bowser from "bowser";
import type {
Body_delete_roms_api_roms_delete_post as DeleteRomsInput,
Body_update_rom_api_roms__id__put as UpdateRomInput,
BulkOperationResponse,
DetailedRomSchema,
ManualMetadata,
RomUserData,
RomUserSchema,
SearchRomSchema,
SimpleRomSchema,
SoundtrackTrackMetaSchema,
UserNoteSchema,
RomFiltersDict,
} from "@/__generated__";
import { type CustomLimitOffsetPage_SimpleRomSchema_ as GetRomsResponse } from "@/__generated__/models/CustomLimitOffsetPage_SimpleRomSchema_";
import api from "@/services/api";
import socket from "@/services/socket";
import storeUpload from "@/stores/upload";
import { getDownloadPath } from "@/utils";
import { buildFormInput, type FormInputField } from "@/utils/formData";
export const romApi = api;
type DetailedRom = DetailedRomSchema;
type SimpleRom = SimpleRomSchema;
type SearchRom = SearchRomSchema;
const DOWNLOAD_CLEANUP_DELAY = 100;
const UPLOAD_CHUNK_SIZE = 10 * 1024 * 1024; // 10MB per chunk
const MAX_CHUNK_RETRIES = 3;
const browser = Bowser.getParser(window.navigator.userAgent);
const engineName = browser.getEngineName();
const trackChunkUploadProgress = engineName !== "WebKit";
async function uploadRomChunked({
platformId,
file,
}: {
platformId: number;
file: File;
}): Promise<void> {
const uploadStore = storeUpload();
const totalChunks = Math.ceil(file.size / UPLOAD_CHUNK_SIZE);
const { data: startData } = await api.post("/roms/upload/start", null, {
headers: {
"X-Upload-Platform": platformId.toString(),
"X-Upload-Filename": file.name,
"X-Upload-Total-Size": file.size.toString(),
"X-Upload-Total-Chunks": totalChunks.toString(),
},
});
const { upload_id } = startData;
for (let i = 0; i < totalChunks; i++) {
const start = i * UPLOAD_CHUNK_SIZE;
const chunk = file.slice(
start,
Math.min(start + UPLOAD_CHUNK_SIZE, file.size),
);
let lastError: Error | null = null;
for (let attempt = 0; attempt < MAX_CHUNK_RETRIES; attempt++) {
try {
await api.put(`/roms/upload/${upload_id}`, chunk, {
headers: {
"Content-Type": "application/octet-stream",
"X-Chunk-Index": i.toString(),
},
timeout: 120000,
...(trackChunkUploadProgress && {
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
const chunkFraction = progressEvent.progress ?? 0;
const overall = ((i + chunkFraction) / totalChunks) * 100;
uploadStore.updateChunkProgress(
file.name,
overall,
file.size,
progressEvent.rate,
);
},
}),
});
if (!trackChunkUploadProgress) {
uploadStore.updateChunkProgress(
file.name,
((i + 1) / totalChunks) * 100,
file.size,
);
}
lastError = null;
break;
} catch (err) {
lastError = err as Error;
if (attempt < MAX_CHUNK_RETRIES - 1) {
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
}
}
}
if (lastError) {
await api.post(`/roms/upload/${upload_id}/cancel`).catch(() => {});
throw lastError;
}
}
await api.post(`/roms/upload/${upload_id}/complete`, null, {
timeout: 600000, // 10 minutes
});
}
async function uploadRoms({
platformId,
filesToUpload,
}: {
platformId: number;
filesToUpload: File[];
}) {
if (!socket.connected) socket.connect();
const uploadStore = storeUpload();
const promises = filesToUpload.map((file) => {
uploadStore.start(file.name);
return uploadRomChunked({ platformId, file })
.then(() => null as null)
.catch((error) => {
uploadStore.fail(
file.name,
error.response?.data?.detail ?? error.message,
);
return Promise.reject(error);
});
});
return Promise.allSettled(promises);
}
export interface GetRomsParams {
platformIds?: number[] | null;
collectionId?: number | null;
virtualCollectionId?: string | null;
smartCollectionId?: number | null;
searchTerm?: string | null;
limit?: number;
offset?: number;
orderBy?: string | null;
orderDir?: string | null;
filterMatched?: boolean | null;
filterFavorites?: boolean | null;
filterDuplicates?: boolean | null;
filterPlayables?: boolean | null;
filterRA?: boolean | null;
filterMissing?: boolean | null;
filterVerified?: boolean | null;
groupByMetaId?: boolean;
// Multi-value filters
selectedGenres?: string[] | null;
selectedFranchises?: string[] | null;
selectedCollections?: string[] | null;
selectedCompanies?: string[] | null;
selectedAgeRatings?: string[] | null;
selectedRegions?: string[] | null;
selectedLanguages?: string[] | null;
selectedPlayerCounts?: string[] | null;
selectedStatuses?: string[] | null;
// Logic operators for multi-value filters
genresLogic?: string | null;
franchisesLogic?: string | null;
collectionsLogic?: string | null;
companiesLogic?: string | null;
ageRatingsLogic?: string | null;
regionsLogic?: string | null;
languagesLogic?: string | null;
statusesLogic?: string | null;
playerCountsLogic?: string | null;
// Cancellation: pass an AbortSignal to let the caller abort an
// in-flight request (e.g. search-typing → previous query aborted,
// gallery-context switch → previous platform's windows aborted).
signal?: AbortSignal;
}
async function getRoms({
platformIds = null,
collectionId = null,
virtualCollectionId = null,
smartCollectionId = null,
searchTerm = null,
limit = 72,
offset = 0,
orderBy = "name",
orderDir = "asc",
filterMatched = null,
filterFavorites = null,
filterDuplicates = null,
filterPlayables = null,
filterRA = null,
filterMissing = null,
filterVerified = null,
groupByMetaId = false,
selectedGenres = null,
selectedFranchises = null,
selectedCollections = null,
selectedCompanies = null,
selectedAgeRatings = null,
selectedRegions = null,
selectedLanguages = null,
selectedPlayerCounts = null,
selectedStatuses = null,
// Logic operators
genresLogic = null,
franchisesLogic = null,
collectionsLogic = null,
companiesLogic = null,
ageRatingsLogic = null,
regionsLogic = null,
languagesLogic = null,
statusesLogic = null,
playerCountsLogic = null,
signal = undefined,
}: GetRomsParams) {
const params = {
platform_ids:
platformIds && platformIds.length > 0 ? platformIds : undefined,
collection_id: collectionId,
virtual_collection_id: virtualCollectionId,
smart_collection_id: smartCollectionId,
search_term: searchTerm,
limit: limit,
offset: offset,
order_by: orderBy,
order_dir: orderDir,
group_by_meta_id: groupByMetaId,
genres:
selectedGenres && selectedGenres.length > 0 ? selectedGenres : undefined,
franchises:
selectedFranchises && selectedFranchises.length > 0
? selectedFranchises
: undefined,
collections:
selectedCollections && selectedCollections.length > 0
? selectedCollections
: undefined,
companies:
selectedCompanies && selectedCompanies.length > 0
? selectedCompanies
: undefined,
age_ratings:
selectedAgeRatings && selectedAgeRatings.length > 0
? selectedAgeRatings
: undefined,
statuses:
selectedStatuses && selectedStatuses.length > 0
? selectedStatuses
: undefined,
regions:
selectedRegions && selectedRegions.length > 0
? selectedRegions
: undefined,
languages:
selectedLanguages && selectedLanguages.length > 0
? selectedLanguages
: undefined,
player_counts:
selectedPlayerCounts && selectedPlayerCounts.length > 0
? selectedPlayerCounts
: undefined,
// Logic operators
genres_logic:
selectedGenres && selectedGenres.length > 0
? genresLogic || "any"
: undefined,
franchises_logic:
selectedFranchises && selectedFranchises.length > 0
? franchisesLogic || "any"
: undefined,
collections_logic:
selectedCollections && selectedCollections.length > 0
? collectionsLogic || "any"
: undefined,
companies_logic:
selectedCompanies && selectedCompanies.length > 0
? companiesLogic || "any"
: undefined,
age_ratings_logic:
selectedAgeRatings && selectedAgeRatings.length > 0
? ageRatingsLogic || "any"
: undefined,
regions_logic:
selectedRegions && selectedRegions.length > 0
? regionsLogic || "any"
: undefined,
languages_logic:
selectedLanguages && selectedLanguages.length > 0
? languagesLogic || "any"
: undefined,
statuses_logic:
selectedStatuses && selectedStatuses.length > 0
? statusesLogic || "any"
: undefined,
player_counts_logic:
selectedPlayerCounts && selectedPlayerCounts.length > 0
? playerCountsLogic || "any"
: undefined,
...(filterMatched !== null ? { matched: filterMatched } : {}),
...(filterFavorites !== null ? { favorite: filterFavorites } : {}),
...(filterDuplicates !== null ? { duplicate: filterDuplicates } : {}),
...(filterPlayables !== null ? { playable: filterPlayables } : {}),
...(filterMissing !== null ? { missing: filterMissing } : {}),
...(filterRA !== null ? { has_ra: filterRA } : {}),
...(filterVerified !== null ? { verified: filterVerified } : {}),
};
return api.get<GetRomsResponse>(`/roms`, {
params,
signal,
});
}
export const RECENT_ROMS_LIMIT = 15;
export const RECENT_PLAYED_ROMS_LIMIT = 15;
async function getRecentRoms() {
return api.get<GetRomsResponse>("/roms", {
params: {
order_by: "id",
order_dir: "desc",
limit: RECENT_ROMS_LIMIT,
with_char_index: false,
with_filter_values: false,
},
});
}
async function getRecentPlayedRoms() {
return api.get<GetRomsResponse>("/roms", {
params: {
order_by: "last_played",
order_dir: "desc",
limit: RECENT_PLAYED_ROMS_LIMIT,
with_char_index: false,
with_filter_values: false,
last_played: true,
},
});
}
async function getRom({
romId,
signal,
}: {
romId: number;
signal?: AbortSignal;
}) {
return api.get<DetailedRom>(`/roms/${romId}`, { signal });
}
async function getRomSimple({
romId,
signal,
}: {
romId: number;
signal?: AbortSignal;
}) {
// `/roms/{id}/simple` — returns `SimpleRomSchema` with no eager-loaded
// notes / saves / states / screenshots / collections arrays. Designed
// for the v2 gallery card's per-card fetch path. Detail-level data is
// pulled on demand (game details page, quick-note dialog open).
return api.get<SimpleRom>(`/roms/${romId}/simple`, { signal });
}
async function getRomByMetadataProvider({
field,
id,
}: {
field: Partial<keyof DetailedRom>;
id: number;
}) {
return api.get<DetailedRom>(`/roms/by-metadata-provider/`, {
params: { [field]: id },
});
}
async function searchRom({
romId,
searchTerm,
searchBy,
}: {
romId: number;
searchTerm: string;
searchBy: string;
}) {
return api.get<SearchRom[]>("/search/roms", {
params: {
rom_id: romId,
search_term: searchTerm,
search_by: searchBy,
},
});
}
async function downloadRom({
rom,
fileIDs = [],
}: {
rom: SimpleRom;
fileIDs?: number[];
}) {
return new Promise<void>((resolve) => {
const a = document.createElement("a");
a.href = getDownloadPath({ rom, fileIDs });
a.style.display = "none";
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
resolve();
}, DOWNLOAD_CLEANUP_DELAY);
});
}
async function bulkDownloadRoms({
roms,
filename,
}: {
roms: SimpleRom[];
filename?: string;
}) {
return new Promise<void>((resolve) => {
if (roms.length === 0) return resolve();
const romIds = roms.map((rom) => rom.id);
const queryParams = new URLSearchParams();
queryParams.append("rom_ids", romIds.join(","));
if (filename) queryParams.append("filename", filename);
const a = document.createElement("a");
a.href = `/api/roms/download?${queryParams.toString()}`;
a.style.display = "none";
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
resolve();
}, DOWNLOAD_CLEANUP_DELAY);
});
}
export type UpdateRom = SimpleRom & {
artwork?: File;
manual_metadata?: ManualMetadata | null;
raw_metadata?: {
igdb_metadata?: string;
moby_metadata?: string;
ss_metadata?: string;
launchbox_metadata?: string;
hasheous_metadata?: string;
flashpoint_metadata?: string;
hltb_metadata?: string;
};
};
async function updateRom({
rom,
removeCover = false,
unmatch = false,
}: {
rom: UpdateRom;
removeCover?: boolean;
unmatch?: boolean;
}) {
const toFormIdValue = (value: number | string | null | undefined): string =>
value === null || value === undefined ? "" : String(value);
const fields: FormInputField<UpdateRomInput>[] = [
["name", rom.name],
["fs_name", rom.fs_name],
["summary", rom.summary],
["igdb_id", toFormIdValue(rom.igdb_id)],
["sgdb_id", toFormIdValue(rom.sgdb_id)],
["moby_id", toFormIdValue(rom.moby_id)],
["ss_id", toFormIdValue(rom.ss_id)],
["launchbox_id", toFormIdValue(rom.launchbox_id)],
["ra_id", toFormIdValue(rom.ra_id)],
["flashpoint_id", toFormIdValue(rom.flashpoint_id)],
["hasheous_id", toFormIdValue(rom.hasheous_id)],
["tgdb_id", toFormIdValue(rom.tgdb_id)],
["hltb_id", toFormIdValue(rom.hltb_id)],
["libretro_id", toFormIdValue(rom.libretro_id)],
];
if (rom.manual_metadata) {
fields.push(["raw_manual_metadata", JSON.stringify(rom.manual_metadata)]);
}
if (rom.raw_metadata?.igdb_metadata) {
fields.push(["raw_igdb_metadata", rom.raw_metadata.igdb_metadata]);
}
if (rom.raw_metadata?.moby_metadata) {
fields.push(["raw_moby_metadata", rom.raw_metadata.moby_metadata]);
}
if (rom.raw_metadata?.ss_metadata) {
fields.push(["raw_ss_metadata", rom.raw_metadata.ss_metadata]);
}
if (rom.raw_metadata?.launchbox_metadata) {
fields.push([
"raw_launchbox_metadata",
rom.raw_metadata.launchbox_metadata,
]);
}
if (rom.raw_metadata?.hasheous_metadata) {
fields.push(["raw_hasheous_metadata", rom.raw_metadata.hasheous_metadata]);
}
if (rom.raw_metadata?.flashpoint_metadata) {
fields.push([
"raw_flashpoint_metadata",
rom.raw_metadata.flashpoint_metadata,
]);
}
if (rom.raw_metadata?.hltb_metadata) {
fields.push(["raw_hltb_metadata", rom.raw_metadata.hltb_metadata]);
}
// Don't set url_cover on manual artwork upload
if (rom.artwork) {
fields.push(["artwork", rom.artwork]);
} else {
fields.push(["url_cover", rom.url_cover]);
}
const formData = buildFormInput<UpdateRomInput>(fields);
return api.put<DetailedRom>(`/roms/${rom.id}`, formData, {
params: {
remove_cover: removeCover,
unmatch_metadata: unmatch,
},
});
}
async function uploadManuals({
romId,
filesToUpload,
}: {
romId: number;
filesToUpload: File[];
}) {
const uploadStore = storeUpload();
const promises = filesToUpload.map((file) => {
const formData = new FormData();
formData.append(file.name, file);
uploadStore.start(file.name);
return new Promise((resolve, reject) => {
api
.post(`/roms/${romId}/manuals`, formData, {
headers: {
"Content-Type": "multipart/form-data",
"X-Upload-Filename": file.name,
},
params: {},
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
uploadStore.update(file.name, progressEvent);
},
})
.then(resolve)
.catch((error) => {
uploadStore.fail(file.name, error.response?.data?.detail);
reject(error);
});
});
});
return Promise.allSettled(promises);
}
async function removeManual({ romId }: { romId: number }) {
return api.delete<DetailedRom>(`/roms/${romId}/manuals`);
}
async function redownloadManual({ romId }: { romId: number }) {
return api.post(`/roms/${romId}/manuals/redownload`);
}
async function uploadSoundtracks({
romId,
filesToUpload,
}: {
romId: number;
filesToUpload: File[];
}) {
const uploadStore = storeUpload();
const promises = filesToUpload.map((file) => {
const formData = new FormData();
formData.append(file.name, file);
uploadStore.start(file.name);
return new Promise((resolve, reject) => {
api
.post(`/roms/${romId}/soundtracks`, formData, {
headers: {
"Content-Type": "multipart/form-data",
"X-Upload-Filename": file.name,
},
params: {},
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
uploadStore.update(file.name, progressEvent);
},
})
.then(resolve)
.catch((error) => {
uploadStore.fail(file.name, error.response?.data?.detail);
reject(error);
});
});
});
return Promise.allSettled(promises);
}
async function removeSoundtrack({
romId,
fileId,
}: {
romId: number;
fileId: number;
}) {
return api.delete(`/roms/${romId}/soundtracks/${fileId}`);
}
async function getSoundtrackMetadata({
romId,
signal,
}: {
romId: number;
signal?: AbortSignal;
}) {
return api.get<SoundtrackTrackMetaSchema[]>(
`/roms/${romId}/soundtracks/metadata`,
{
signal,
},
);
}
async function uploadManualFiles({
romId,
filesToUpload,
}: {
romId: number;
filesToUpload: File[];
}) {
const uploadStore = storeUpload();
const promises = filesToUpload.map((file) => {
const formData = new FormData();
formData.append(file.name, file);
uploadStore.start(file.name);
return new Promise((resolve, reject) => {
api
.post(`/roms/${romId}/manuals/files`, formData, {
headers: {
"Content-Type": "multipart/form-data",
"X-Upload-Filename": file.name,
},
params: {},
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
uploadStore.update(file.name, progressEvent);
},
})
.then(resolve)
.catch((error) => {
uploadStore.fail(file.name, error.response?.data?.detail);
reject(error);
});
});
});
return Promise.allSettled(promises);
}
async function deleteManualFile({
romId,
fileId,
}: {
romId: number;
fileId: number;
}) {
return api.delete(`/roms/${romId}/manuals/files/${fileId}`);
}
async function updateUserRomProps({
romId,
data,
updateLastPlayed = false,
removeLastPlayed = false,
}: {
romId: number;
data: Partial<RomUserData>;
updateLastPlayed?: boolean;
removeLastPlayed?: boolean;
}) {
const params = new URLSearchParams();
if (updateLastPlayed) {
params.set("update_last_played", "true");
} else if (removeLastPlayed) {
params.set("remove_last_played", "true");
}
const query = params.toString();
return api.put<RomUserSchema>(
`/roms/${romId}/props${query ? `?${query}` : ""}`,
data,
);
}
async function deleteRoms({
roms,
deleteFromFs = [],
}: {
roms: SimpleRom[];
deleteFromFs: number[];
}) {
const payload: DeleteRomsInput = {
roms: roms.map((r) => r.id),
delete_from_fs: deleteFromFs,
};
return api.post<BulkOperationResponse>("/roms/delete", payload);
}
// Multi-note management functions
async function createRomNote({
romId,
noteData,
}: {
romId: number;
noteData: {
title: string;
content?: string;
is_public?: boolean;
tags?: string[];
};
}) {
return api.post<UserNoteSchema>(`/roms/${romId}/notes`, noteData);
}
async function updateRomNote({
romId,
noteId,
noteData,
}: {
romId: number;
noteId: number;
noteData: {
title?: string;
content?: string;
is_public?: boolean;
tags?: string[];
};
}) {
return api.put<UserNoteSchema>(`/roms/${romId}/notes/${noteId}`, noteData);
}
async function deleteRomNote({
romId,
noteId,
}: {
romId: number;
noteId: number;
}) {
return api.delete<UserNoteSchema>(`/roms/${romId}/notes/${noteId}`);
}
async function getRomNotes({
romId,
publicOnly = false,
search,
tags,
}: {
romId: number;
publicOnly?: boolean;
search?: string;
tags?: string[];
}) {
return api.get<UserNoteSchema[]>(`/roms/${romId}/notes`, {
params: {
public_only: publicOnly,
search,
tags: tags?.join(","),
},
});
}
async function getRomFilters() {
return api.get<RomFiltersDict>("/roms/filters");
}
export default {
uploadRoms,
getRoms,
getRecentRoms,
getRecentPlayedRoms,
getRom,
getRomSimple,
getRomByMetadataProvider,
downloadRom,
bulkDownloadRoms,
searchRom,
updateRom,
uploadManuals,
removeManual,
redownloadManual,
uploadManualFiles,
deleteManualFile,
uploadSoundtracks,
removeSoundtrack,
getSoundtrackMetadata,
updateUserRomProps,
deleteRoms,
createRomNote,
updateRomNote,
deleteRomNote,
getRomNotes,
getRomFilters,
};