mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-10 04:22:09 +10:00
completed game importing; partial work on version importing
This commit is contained in:
9
server/api/v1/admin/import/game/index.get.ts
Normal file
9
server/api/v1/admin/import/game/index.get.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import libraryManager from "~/server/internal/library";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const user = await h3.context.session.getAdminUser(h3);
|
||||
if (!user) throw createError({ statusCode: 403 });
|
||||
|
||||
const unimportedGames = await libraryManager.fetchAllUnimportedGames();
|
||||
return { unimportedGames };
|
||||
});
|
||||
36
server/api/v1/admin/import/game/index.post.ts
Normal file
36
server/api/v1/admin/import/game/index.post.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import libraryManager from "~/server/internal/library";
|
||||
import {
|
||||
GameMetadataSearchResult,
|
||||
GameMetadataSource,
|
||||
} from "~/server/internal/metadata/types";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const user = await h3.context.session.getAdminUser(h3);
|
||||
if (!user) throw createError({ statusCode: 403 });
|
||||
|
||||
const body = await readBody(h3);
|
||||
|
||||
const path = body.path;
|
||||
const metadata = body.metadata as GameMetadataSearchResult &
|
||||
GameMetadataSource;
|
||||
if (!path)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Path missing from body",
|
||||
});
|
||||
if (!metadata.id || !metadata.sourceId)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Metadata IDs missing from body",
|
||||
});
|
||||
|
||||
const validPath = await libraryManager.checkUnimportedGamePath(path);
|
||||
if (!validPath)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid unimported game path",
|
||||
});
|
||||
|
||||
const game = await h3.context.metadataHandler.createGame(metadata, path);
|
||||
return game;
|
||||
});
|
||||
13
server/api/v1/admin/import/game/search.get.ts
Normal file
13
server/api/v1/admin/import/game/search.get.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import libraryManager from "~/server/internal/library";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const user = await h3.context.session.getAdminUser(h3);
|
||||
if (!user) throw createError({ statusCode: 403 });
|
||||
|
||||
const query = getQuery(h3);
|
||||
const search = query.q?.toString();
|
||||
if (!search)
|
||||
throw createError({ statusCode: 400, statusMessage: "Invalid search" });
|
||||
|
||||
return await h3.context.metadataHandler.search(search);
|
||||
});
|
||||
22
server/api/v1/admin/import/version/index.get.ts
Normal file
22
server/api/v1/admin/import/version/index.get.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import libraryManager from "~/server/internal/library";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const user = await h3.context.session.getAdminUser(h3);
|
||||
if (!user) throw createError({ statusCode: 403 });
|
||||
|
||||
const query = await getQuery(h3);
|
||||
const gameId = query.id?.toString();
|
||||
if (!gameId)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Missing id in request params",
|
||||
});
|
||||
|
||||
const unimportedVersions = await libraryManager.fetchUnimportedVersions(
|
||||
gameId
|
||||
);
|
||||
if (!unimportedVersions)
|
||||
throw createError({ statusCode: 400, statusMessage: "Invalid game ID" });
|
||||
|
||||
return unimportedVersions;
|
||||
});
|
||||
27
server/api/v1/admin/import/version/preload.get.ts
Normal file
27
server/api/v1/admin/import/version/preload.get.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import libraryManager from "~/server/internal/library";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const user = await h3.context.session.getAdminUser(h3);
|
||||
if (!user) throw createError({ statusCode: 403 });
|
||||
|
||||
const query = await getQuery(h3);
|
||||
const gameId = query.id?.toString();
|
||||
const versionName = query.version?.toString();
|
||||
if (!gameId || !versionName)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Missing id or version in request params",
|
||||
});
|
||||
|
||||
const preload = await libraryManager.fetchUnimportedVersionInformation(
|
||||
gameId,
|
||||
versionName
|
||||
);
|
||||
if (!preload)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid game or version id/name",
|
||||
});
|
||||
|
||||
return preload;
|
||||
});
|
||||
6
server/api/v1/admin/index.get.ts
Normal file
6
server/api/v1/admin/index.get.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const user = await h3.context.session.getUser(h3);
|
||||
if (!user)
|
||||
throw createError({ statusCode: 403, statusMessage: "Not authenticated" });
|
||||
return { admin: user.admin };
|
||||
});
|
||||
13
server/api/v1/admin/library/index.get.ts
Normal file
13
server/api/v1/admin/library/index.get.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import libraryManager from "~/server/internal/library";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const user = await h3.context.session.getAdminUser(h3);
|
||||
if (!user) throw createError({ statusCode: 403 });
|
||||
|
||||
const unimportedGames = await libraryManager.fetchAllUnimportedGames();
|
||||
const games = await libraryManager.fetchGamesWithStatus();
|
||||
|
||||
// Fetch other library data here
|
||||
|
||||
return { unimportedGames, games };
|
||||
});
|
||||
@ -24,7 +24,9 @@ export default defineEventHandler(async (h3) => {
|
||||
|
||||
const userId = uuidv4();
|
||||
|
||||
const profilePictureObject = await h3.context.objects.createFromSource(
|
||||
const profilePictureId = uuidv4();
|
||||
await h3.context.objects.createFromSource(
|
||||
profilePictureId,
|
||||
() =>
|
||||
$fetch<Readable>("https://avatars.githubusercontent.com/u/64579723?v=4", {
|
||||
responseType: "stream",
|
||||
@ -32,18 +34,12 @@ export default defineEventHandler(async (h3) => {
|
||||
{},
|
||||
[`anonymous:read`, `${userId}:write`]
|
||||
);
|
||||
if (!profilePictureObject)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Unable to import profile picture",
|
||||
});
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
displayName: "DecDuck",
|
||||
email: "",
|
||||
profilePicture: profilePictureObject,
|
||||
profilePicture: profilePictureId,
|
||||
admin: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,17 +1,25 @@
|
||||
import clientHandler from "~/server/internal/clients/handler";
|
||||
import { parsePlatform } from "~/server/internal/utils/parseplatform";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const body = await readBody(h3);
|
||||
|
||||
const name = body.name;
|
||||
const platform = body.platform;
|
||||
const platformRaw = body.platform;
|
||||
|
||||
if (!name || !platform)
|
||||
if (!name || !platformRaw)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Missing name or platform in body",
|
||||
});
|
||||
|
||||
const platform = parsePlatform(platformRaw);
|
||||
if (!platform)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid or unsupported platform",
|
||||
});
|
||||
|
||||
const clientId = await clientHandler.initiate({ name, platform });
|
||||
|
||||
return `/client/${clientId}/callback`;
|
||||
|
||||
36
server/api/v1/games/front.get.ts
Normal file
36
server/api/v1/games/front.get.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const userId = await h3.context.session.getUserId(h3);
|
||||
if (!userId) throw createError({ statusCode: 403 });
|
||||
|
||||
const rawGames = await prisma.game.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
mName: true,
|
||||
mShortDescription: true,
|
||||
mBannerId: true,
|
||||
mDevelopers: {
|
||||
select: {
|
||||
id: true,
|
||||
mName: true,
|
||||
},
|
||||
},
|
||||
mPublishers: {
|
||||
select: {
|
||||
id: true,
|
||||
mName: true,
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
select: {
|
||||
platform: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const games = rawGames.map((e) => ({...e, platforms: e.versions.map((e) => e.platform).filter((e, _, r) => !r.includes(e))}))
|
||||
|
||||
return games;
|
||||
});
|
||||
@ -1,10 +1,11 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { CertificateBundle } from "./ca";
|
||||
import prisma from "../db/database";
|
||||
import { Platform } from "@prisma/client";
|
||||
|
||||
export interface ClientMetadata {
|
||||
name: string;
|
||||
platform: string;
|
||||
platform: Platform;
|
||||
}
|
||||
|
||||
export class ClientHandler {
|
||||
|
||||
@ -10,6 +10,6 @@ declare const globalThis: {
|
||||
|
||||
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
|
||||
|
||||
export default prisma
|
||||
export default prisma;
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma
|
||||
194
server/internal/library/index.ts
Normal file
194
server/internal/library/index.ts
Normal file
@ -0,0 +1,194 @@
|
||||
/**
|
||||
* The Library Manager keeps track of games in Drop's library and their various states.
|
||||
* It uses path relative to the library, so it can moved without issue
|
||||
*
|
||||
* It also provides the endpoints with information about unmatched games
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import prisma from "../db/database";
|
||||
import { GameVersion, Platform } from "@prisma/client";
|
||||
import { fuzzy } from "fast-fuzzy";
|
||||
import { recursivelyReaddir } from "../utils/recursivedirs";
|
||||
|
||||
class LibraryManager {
|
||||
private basePath: string;
|
||||
|
||||
constructor() {
|
||||
this.basePath = process.env.LIBRARY ?? "./.data/library";
|
||||
}
|
||||
|
||||
async fetchAllUnimportedGames() {
|
||||
const dirs = fs.readdirSync(this.basePath).filter((e) => {
|
||||
const fullDir = path.join(this.basePath, e);
|
||||
return fs.lstatSync(fullDir).isDirectory();
|
||||
});
|
||||
|
||||
const validGames = await prisma.game.findMany({
|
||||
where: {
|
||||
libraryBasePath: { in: dirs },
|
||||
},
|
||||
select: {
|
||||
libraryBasePath: true,
|
||||
},
|
||||
});
|
||||
const validGameDirs = validGames.map((e) => e.libraryBasePath);
|
||||
|
||||
const unregisteredGames = dirs.filter((e) => !validGameDirs.includes(e));
|
||||
|
||||
return unregisteredGames;
|
||||
}
|
||||
|
||||
async fetchUnimportedGameVersions(
|
||||
libraryBasePath: string,
|
||||
versions: Array<GameVersion>
|
||||
) {
|
||||
const gameDir = path.join(this.basePath, libraryBasePath);
|
||||
const versionsDirs = fs.readdirSync(gameDir);
|
||||
const importedVersionDirs = versions.map((e) => e.versionName);
|
||||
const unimportedVersions = versionsDirs.filter(
|
||||
(e) => !importedVersionDirs.includes(e)
|
||||
);
|
||||
|
||||
return unimportedVersions;
|
||||
}
|
||||
|
||||
async fetchGamesWithStatus() {
|
||||
const games = await prisma.game.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
versions: true,
|
||||
mName: true,
|
||||
mShortDescription: true,
|
||||
metadataSource: true,
|
||||
mDevelopers: true,
|
||||
mPublishers: true,
|
||||
mIconId: true,
|
||||
libraryBasePath: true,
|
||||
},
|
||||
});
|
||||
|
||||
return await Promise.all(
|
||||
games.map(async (e) => ({
|
||||
game: e,
|
||||
status: {
|
||||
noVersions: e.versions.length == 0,
|
||||
unimportedVersions: await this.fetchUnimportedGameVersions(
|
||||
e.libraryBasePath,
|
||||
e.versions
|
||||
),
|
||||
},
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
async fetchUnimportedVersions(gameId: string) {
|
||||
const game = await prisma.game.findUnique({
|
||||
where: { id: gameId },
|
||||
select: {
|
||||
versions: {
|
||||
select: {
|
||||
versionName: true,
|
||||
},
|
||||
},
|
||||
libraryBasePath: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!game) return undefined;
|
||||
const targetDir = path.join(this.basePath, game.libraryBasePath);
|
||||
if (!fs.existsSync(targetDir))
|
||||
throw new Error(
|
||||
"Game in database, but no physical directory? Something is very very wrong..."
|
||||
);
|
||||
const versions = fs.readdirSync(targetDir);
|
||||
const currentVersions = game.versions.map((e) => e.versionName);
|
||||
|
||||
const unimportedVersions = versions.filter(
|
||||
(e) => !currentVersions.includes(e)
|
||||
);
|
||||
return unimportedVersions;
|
||||
}
|
||||
|
||||
async fetchUnimportedVersionInformation(gameId: string, versionName: string) {
|
||||
const game = await prisma.game.findUnique({
|
||||
where: { id: gameId },
|
||||
select: { libraryBasePath: true, mName: true },
|
||||
});
|
||||
if (!game) return undefined;
|
||||
const targetDir = path.join(
|
||||
this.basePath,
|
||||
game.libraryBasePath,
|
||||
versionName
|
||||
);
|
||||
if (!fs.existsSync(targetDir)) return undefined;
|
||||
|
||||
const fileExts: { [key: string]: string[] } = {
|
||||
Linux: [
|
||||
// Ext for Unity games
|
||||
".x86_64",
|
||||
// No extension is common for Linux binaries
|
||||
"",
|
||||
],
|
||||
Windows: [
|
||||
// Pretty much the only one
|
||||
".exe",
|
||||
],
|
||||
};
|
||||
|
||||
const options: Array<{
|
||||
filename: string;
|
||||
platform: string;
|
||||
match: number;
|
||||
}> = [];
|
||||
|
||||
const files = recursivelyReaddir(targetDir);
|
||||
for (const file of files) {
|
||||
const filename = path.basename(file);
|
||||
const dotLocation = file.lastIndexOf(".");
|
||||
const ext = dotLocation == -1 ? "" : file.slice(dotLocation);
|
||||
for (const [platform, checkExts] of Object.entries(fileExts)) {
|
||||
for (const checkExt of checkExts) {
|
||||
if (checkExt != ext) continue;
|
||||
const fuzzyValue = fuzzy(filename, game.mName);
|
||||
options.push({
|
||||
filename: file,
|
||||
platform: platform,
|
||||
match: fuzzyValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sortedOptions = options.sort((a, b) => b.match - a.match);
|
||||
let startupGuess = "";
|
||||
let platformGuess = "";
|
||||
if (sortedOptions.length > 0) {
|
||||
const finalChoice = sortedOptions[0];
|
||||
const finalChoiceRelativePath = path.relative(
|
||||
targetDir,
|
||||
finalChoice.filename
|
||||
);
|
||||
startupGuess = finalChoiceRelativePath;
|
||||
platformGuess = finalChoice.platform;
|
||||
}
|
||||
|
||||
return { startupGuess, platformGuess };
|
||||
}
|
||||
|
||||
// Checks are done in least to most expensive order
|
||||
async checkUnimportedGamePath(targetPath: string) {
|
||||
const targetDir = path.join(this.basePath, targetPath);
|
||||
if (!fs.existsSync(targetDir)) return false;
|
||||
|
||||
const hasGame =
|
||||
(await prisma.game.count({ where: { libraryBasePath: targetPath } })) > 0;
|
||||
if (hasGame) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const libraryManager = new LibraryManager();
|
||||
export default libraryManager;
|
||||
@ -50,9 +50,10 @@ interface GameResult {
|
||||
|
||||
interface CompanySearchResult {
|
||||
guid: string,
|
||||
deck: string,
|
||||
description: string,
|
||||
deck: string | null,
|
||||
description: string | null,
|
||||
name: string,
|
||||
website: string | null,
|
||||
|
||||
image: {
|
||||
icon_url: string,
|
||||
@ -191,8 +192,9 @@ export class GiantBombProvider implements MetadataProvider {
|
||||
const metadata: PublisherMetadata = {
|
||||
id: company.guid,
|
||||
name: company.name,
|
||||
shortDescription: company.deck,
|
||||
description: longDescription,
|
||||
shortDescription: company.deck ?? "",
|
||||
description: longDescription ?? "",
|
||||
website: company.website ?? "",
|
||||
|
||||
logo: createObject(company.image.icon_url),
|
||||
banner: createObject(company.image.screen_large_url),
|
||||
|
||||
@ -1,162 +1,215 @@
|
||||
import { Developer, MetadataSource, PrismaClient, Publisher } from "@prisma/client";
|
||||
import {
|
||||
Developer,
|
||||
MetadataSource,
|
||||
PrismaClient,
|
||||
Publisher,
|
||||
} from "@prisma/client";
|
||||
import prisma from "../db/database";
|
||||
import { _FetchDeveloperMetadataParams, _FetchGameMetadataParams, _FetchPublisherMetadataParams, DeveloperMetadata, GameMetadata, GameMetadataSearchResult, InternalGameMetadataResult, PublisherMetadata } from "./types";
|
||||
import {
|
||||
_FetchDeveloperMetadataParams,
|
||||
_FetchGameMetadataParams,
|
||||
_FetchPublisherMetadataParams,
|
||||
DeveloperMetadata,
|
||||
GameMetadata,
|
||||
GameMetadataSearchResult,
|
||||
InternalGameMetadataResult,
|
||||
PublisherMetadata,
|
||||
} from "./types";
|
||||
import { ObjectTransactionalHandler } from "../objects/transactional";
|
||||
import { PriorityList, PriorityListIndexed } from "../utils/prioritylist";
|
||||
|
||||
export abstract class MetadataProvider {
|
||||
abstract id(): string;
|
||||
abstract name(): string;
|
||||
abstract source(): MetadataSource;
|
||||
abstract id(): string;
|
||||
abstract name(): string;
|
||||
abstract source(): MetadataSource;
|
||||
|
||||
abstract search(query: string): Promise<GameMetadataSearchResult[]>;
|
||||
abstract fetchGame(params: _FetchGameMetadataParams): Promise<GameMetadata>;
|
||||
abstract fetchPublisher(params: _FetchPublisherMetadataParams): Promise<PublisherMetadata>;
|
||||
abstract fetchDeveloper(params: _FetchDeveloperMetadataParams): Promise<DeveloperMetadata>;
|
||||
abstract search(query: string): Promise<GameMetadataSearchResult[]>;
|
||||
abstract fetchGame(params: _FetchGameMetadataParams): Promise<GameMetadata>;
|
||||
abstract fetchPublisher(
|
||||
params: _FetchPublisherMetadataParams
|
||||
): Promise<PublisherMetadata>;
|
||||
abstract fetchDeveloper(
|
||||
params: _FetchDeveloperMetadataParams
|
||||
): Promise<DeveloperMetadata>;
|
||||
}
|
||||
|
||||
export class MetadataHandler {
|
||||
// Ordered by priority
|
||||
private providers: PriorityListIndexed<MetadataProvider> = new PriorityListIndexed("id");
|
||||
private objectHandler: ObjectTransactionalHandler = new ObjectTransactionalHandler();
|
||||
// Ordered by priority
|
||||
private providers: PriorityListIndexed<MetadataProvider> =
|
||||
new PriorityListIndexed("id");
|
||||
private objectHandler: ObjectTransactionalHandler =
|
||||
new ObjectTransactionalHandler();
|
||||
|
||||
addProvider(provider: MetadataProvider, priority: number = 0) {
|
||||
this.providers.push(provider, priority);
|
||||
}
|
||||
addProvider(provider: MetadataProvider, priority: number = 0) {
|
||||
this.providers.push(provider, priority);
|
||||
}
|
||||
|
||||
async search(query: string) {
|
||||
const promises: Promise<InternalGameMetadataResult[]>[] = [];
|
||||
for (const provider of this.providers.values()) {
|
||||
const queryTransformationPromise = new Promise<InternalGameMetadataResult[]>(async (resolve, reject) => {
|
||||
const results = await provider.search(query);
|
||||
const mappedResults: InternalGameMetadataResult[] = results.map((result) => Object.assign(
|
||||
{},
|
||||
result,
|
||||
{
|
||||
sourceId: provider.id(),
|
||||
sourceName: provider.name()
|
||||
}
|
||||
));
|
||||
resolve(mappedResults);
|
||||
});
|
||||
promises.push(queryTransformationPromise);
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
const successfulResults = results.filter((result) => result.status === 'fulfilled').map((result) => result.value).flat();
|
||||
|
||||
return successfulResults;
|
||||
}
|
||||
|
||||
async fetchGame(result: InternalGameMetadataResult) {
|
||||
const provider = this.providers.get(result.sourceId);
|
||||
if (!provider) throw new Error(`Invalid metadata provider for ID "${result.sourceId}"`);
|
||||
|
||||
const existing = await prisma.game.findUnique({
|
||||
where: {
|
||||
metadataKey: {
|
||||
metadataSource: provider.source(),
|
||||
metadataId: provider.id(),
|
||||
}
|
||||
}
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const [createObject, pullObjects, dumpObjects] = this.objectHandler.new();
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await provider.fetchGame({
|
||||
id: result.id,
|
||||
publisher: this.fetchPublisher,
|
||||
developer: this.fetchDeveloper,
|
||||
createObject,
|
||||
async search(query: string) {
|
||||
const promises: Promise<InternalGameMetadataResult[]>[] = [];
|
||||
for (const provider of this.providers.values()) {
|
||||
const queryTransformationPromise = new Promise<
|
||||
InternalGameMetadataResult[]
|
||||
>(async (resolve, reject) => {
|
||||
const results = await provider.search(query);
|
||||
const mappedResults: InternalGameMetadataResult[] = results.map(
|
||||
(result) =>
|
||||
Object.assign({}, result, {
|
||||
sourceId: provider.id(),
|
||||
sourceName: provider.name(),
|
||||
})
|
||||
} catch (e) {
|
||||
dumpObjects();
|
||||
throw e;
|
||||
}
|
||||
|
||||
await pullObjects();
|
||||
const game = await prisma.game.create({
|
||||
data: {
|
||||
metadataSource: provider.source(),
|
||||
metadataId: metadata.id,
|
||||
|
||||
mName: metadata.name,
|
||||
mShortDescription: metadata.shortDescription,
|
||||
mDescription: metadata.description,
|
||||
mDevelopers: {
|
||||
connect: metadata.developers
|
||||
},
|
||||
mPublishers: {
|
||||
connect: metadata.publishers,
|
||||
},
|
||||
|
||||
mReviewCount: metadata.reviewCount,
|
||||
mReviewRating: metadata.reviewRating,
|
||||
|
||||
mIconId: metadata.icon,
|
||||
mBannerId: metadata.banner,
|
||||
mArt: metadata.art,
|
||||
mScreenshots: metadata.screenshots,
|
||||
},
|
||||
});
|
||||
|
||||
return game;
|
||||
);
|
||||
resolve(mappedResults);
|
||||
});
|
||||
promises.push(queryTransformationPromise);
|
||||
}
|
||||
|
||||
async fetchDeveloper(query: string) {
|
||||
return await this.fetchDeveloperPublisher(query, "fetchDeveloper", "developer") as Developer;
|
||||
const results = await Promise.allSettled(promises);
|
||||
const successfulResults = results
|
||||
.filter((result) => result.status === "fulfilled")
|
||||
.map((result) => result.value)
|
||||
.flat();
|
||||
|
||||
return successfulResults;
|
||||
}
|
||||
|
||||
async createGame(
|
||||
result: InternalGameMetadataResult,
|
||||
libraryBasePath: string
|
||||
) {
|
||||
const provider = this.providers.get(result.sourceId);
|
||||
if (!provider)
|
||||
throw new Error(`Invalid metadata provider for ID "${result.sourceId}"`);
|
||||
|
||||
const existing = await prisma.game.findUnique({
|
||||
where: {
|
||||
metadataKey: {
|
||||
metadataSource: provider.source(),
|
||||
metadataId: provider.id(),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const [createObject, pullObjects, dumpObjects] = this.objectHandler.new(
|
||||
{},
|
||||
["internal:read"]
|
||||
);
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await provider.fetchGame({
|
||||
id: result.id,
|
||||
// wrap in anonymous functions to keep references to this
|
||||
publisher: (name: string) => this.fetchPublisher(name),
|
||||
developer: (name: string) => this.fetchDeveloper(name),
|
||||
createObject,
|
||||
});
|
||||
} catch (e) {
|
||||
dumpObjects();
|
||||
throw e;
|
||||
}
|
||||
|
||||
async fetchPublisher(query: string) {
|
||||
return await this.fetchDeveloperPublisher(query, "fetchPublisher", "publisher") as Publisher;
|
||||
await pullObjects();
|
||||
const game = await prisma.game.create({
|
||||
data: {
|
||||
metadataSource: provider.source(),
|
||||
metadataId: metadata.id,
|
||||
|
||||
mName: metadata.name,
|
||||
mShortDescription: metadata.shortDescription,
|
||||
mDescription: metadata.description,
|
||||
mDevelopers: {
|
||||
connect: metadata.developers,
|
||||
},
|
||||
mPublishers: {
|
||||
connect: metadata.publishers,
|
||||
},
|
||||
|
||||
mReviewCount: metadata.reviewCount,
|
||||
mReviewRating: metadata.reviewRating,
|
||||
|
||||
mIconId: metadata.icon,
|
||||
mBannerId: metadata.banner,
|
||||
mArt: metadata.art,
|
||||
mScreenshots: metadata.screenshots,
|
||||
|
||||
versionOrder: [],
|
||||
libraryBasePath,
|
||||
},
|
||||
});
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
async fetchDeveloper(query: string) {
|
||||
return (await this.fetchDeveloperPublisher(
|
||||
query,
|
||||
"fetchDeveloper",
|
||||
"developer"
|
||||
)) as Developer;
|
||||
}
|
||||
|
||||
async fetchPublisher(query: string) {
|
||||
return (await this.fetchDeveloperPublisher(
|
||||
query,
|
||||
"fetchPublisher",
|
||||
"publisher"
|
||||
)) as Publisher;
|
||||
}
|
||||
|
||||
// Careful with this function, it has no typechecking
|
||||
// TODO: fix typechecking
|
||||
private async fetchDeveloperPublisher(
|
||||
query: string,
|
||||
functionName: any,
|
||||
databaseName: any
|
||||
) {
|
||||
const existing = await (prisma as any)[databaseName].findFirst({
|
||||
where: {
|
||||
metadataOriginalQuery: query,
|
||||
},
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
for (const provider of this.providers.values() as any) {
|
||||
const [createObject, pullObjects, dumpObjects] = this.objectHandler.new(
|
||||
{},
|
||||
["internal:read"]
|
||||
);
|
||||
let result;
|
||||
try {
|
||||
result = await provider[functionName]({ query, createObject });
|
||||
} catch(e) {
|
||||
console.warn(e);
|
||||
dumpObjects();
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we're successful
|
||||
await pullObjects();
|
||||
|
||||
const object = await (prisma as any)[databaseName].create({
|
||||
data: {
|
||||
metadataSource: provider.source(),
|
||||
metadataId: provider.id(),
|
||||
metadataOriginalQuery: query,
|
||||
|
||||
mName: result.name,
|
||||
mShortDescription: result.shortDescription,
|
||||
mDescription: result.description,
|
||||
mLogo: result.logo,
|
||||
mBanner: result.banner,
|
||||
mWebsite: result.website,
|
||||
},
|
||||
});
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
// Careful with this function, it has no typechecking
|
||||
// TODO: fix typechecking
|
||||
private async fetchDeveloperPublisher(query: string, functionName: any, databaseName: any) {
|
||||
const existing = await (prisma as any)[databaseName].findFirst({
|
||||
where: {
|
||||
mName: query,
|
||||
}
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
for (const provider of this.providers.values() as any) {
|
||||
const [createObject, pullObjects, dumpObjects] = this.objectHandler.new();
|
||||
let result;
|
||||
try {
|
||||
result = await provider[functionName]({ query, createObject });
|
||||
} catch {
|
||||
dumpObjects();
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we're successful
|
||||
await pullObjects();
|
||||
|
||||
const object = await (prisma as any)[databaseName].create({
|
||||
data: {
|
||||
metadataSource: provider.source(),
|
||||
metadataId: provider.id(),
|
||||
|
||||
mName: result.name,
|
||||
mShortDescription: result.shortDescription,
|
||||
mDescription: result.description,
|
||||
mLogo: result.logo,
|
||||
mBanner: result.banner,
|
||||
},
|
||||
})
|
||||
|
||||
return object;
|
||||
|
||||
}
|
||||
|
||||
throw new Error(`No metadata provider found a ${databaseName} for "${query}"`);
|
||||
|
||||
}
|
||||
throw new Error(
|
||||
`No metadata provider found a ${databaseName} for "${query}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new MetadataHandler();
|
||||
export default new MetadataHandler();
|
||||
|
||||
1
server/internal/metadata/types.d.ts
vendored
1
server/internal/metadata/types.d.ts
vendored
@ -45,6 +45,7 @@ export interface PublisherMetadata {
|
||||
|
||||
logo: ObjectReference;
|
||||
banner: ObjectReference;
|
||||
website: String;
|
||||
}
|
||||
|
||||
export type DeveloperMetadata = PublisherMetadata;
|
||||
|
||||
@ -45,10 +45,10 @@ export class FsObjectBackend extends ObjectBackend {
|
||||
return false;
|
||||
}
|
||||
async create(
|
||||
id: string,
|
||||
source: Source,
|
||||
metadata: ObjectMetadata
|
||||
): Promise<ObjectReference | undefined> {
|
||||
const id = uuidv4();
|
||||
const objectPath = path.join(this.baseObjectPath, sanitize(id));
|
||||
const metadataPath = path.join(
|
||||
this.baseMetadataPath,
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
import { parse as getMimeTypeBuffer } from "file-type-mime";
|
||||
import { Readable } from "stream";
|
||||
import { getMimeType as getMimeTypeStream } from "stream-mime-type";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export type ObjectReference = string;
|
||||
export type ObjectMetadata = {
|
||||
@ -46,6 +47,7 @@ export abstract class ObjectBackend {
|
||||
abstract fetch(id: ObjectReference): Promise<Source | undefined>;
|
||||
abstract write(id: ObjectReference, source: Source): Promise<boolean>;
|
||||
abstract create(
|
||||
id: string,
|
||||
source: Source,
|
||||
metadata: ObjectMetadata
|
||||
): Promise<ObjectReference | undefined>;
|
||||
@ -59,6 +61,7 @@ export abstract class ObjectBackend {
|
||||
): Promise<boolean>;
|
||||
|
||||
async createFromSource(
|
||||
id: string,
|
||||
sourceFetcher: () => Promise<Source>,
|
||||
metadata: { [key: string]: string },
|
||||
permissions: Array<string>
|
||||
@ -83,13 +86,11 @@ export abstract class ObjectBackend {
|
||||
if (!mime)
|
||||
throw new Error("Unable to calculate MIME type - is the source empty?");
|
||||
|
||||
const objectId = this.create(source, {
|
||||
await this.create(id, source, {
|
||||
permissions,
|
||||
userMetadata: metadata,
|
||||
mime,
|
||||
});
|
||||
|
||||
return objectId;
|
||||
}
|
||||
|
||||
async fetchWithPermissions(id: ObjectReference, userId?: string) {
|
||||
|
||||
@ -2,7 +2,9 @@
|
||||
The purpose of this class is to hold references to remote objects (like images) until they're actually needed
|
||||
This is used as a utility in metadata handling, so we only fetch the objects if we're actually creating a database record.
|
||||
*/
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Readable } from "stream";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { GlobalObjectHandler } from "~/server/plugins/objects";
|
||||
|
||||
type TransactionTable = { [key: string]: string }; // ID to URL
|
||||
type GlobalTransactionRecord = { [key: string]: TransactionTable }; // Transaction ID to table
|
||||
@ -12,27 +14,38 @@ type Pull = () => Promise<void>;
|
||||
type Dump = () => void;
|
||||
|
||||
export class ObjectTransactionalHandler {
|
||||
private record: GlobalTransactionRecord = {};
|
||||
private record: GlobalTransactionRecord = {};
|
||||
|
||||
new(): [Register, Pull, Dump] {
|
||||
const transactionId = uuidv4();
|
||||
new(
|
||||
metadata: { [key: string]: string },
|
||||
permissions: Array<string>
|
||||
): [Register, Pull, Dump] {
|
||||
const transactionId = uuidv4();
|
||||
|
||||
const register = (url: string) => {
|
||||
const objectId = uuidv4();
|
||||
this.record[transactionId][objectId] = url;
|
||||
this.record[transactionId] ??= {};
|
||||
|
||||
return objectId;
|
||||
}
|
||||
const register = (url: string) => {
|
||||
const objectId = uuidv4();
|
||||
this.record[transactionId][objectId] = url;
|
||||
|
||||
const pull = async () => {
|
||||
// Dummy function
|
||||
dump();
|
||||
}
|
||||
return objectId;
|
||||
};
|
||||
|
||||
const dump = () => {
|
||||
delete this.record[transactionId];
|
||||
}
|
||||
const pull = async () => {
|
||||
for (const [id, url] of Object.entries(this.record[transactionId])) {
|
||||
await GlobalObjectHandler.createFromSource(
|
||||
id,
|
||||
() => $fetch<Readable>(url, { responseType: "stream" }),
|
||||
metadata,
|
||||
permissions
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return [register, pull, dump];
|
||||
}
|
||||
}
|
||||
const dump = () => {
|
||||
delete this.record[transactionId];
|
||||
};
|
||||
|
||||
return [register, pull, dump];
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,52 +13,71 @@ const userSessionKey = "_userSession";
|
||||
const userIdKey = "_userId";
|
||||
|
||||
export class SessionHandler {
|
||||
private sessionProvider: SessionProvider;
|
||||
private sessionProvider: SessionProvider;
|
||||
|
||||
constructor() {
|
||||
// Create a new provider
|
||||
this.sessionProvider = createMemorySessionProvider();
|
||||
constructor() {
|
||||
// Create a new provider
|
||||
this.sessionProvider = createMemorySessionProvider();
|
||||
}
|
||||
|
||||
async getSession<T extends Session>(h3: H3Event) {
|
||||
const data = await this.sessionProvider.getSession<{ [userSessionKey]: T }>(
|
||||
h3
|
||||
);
|
||||
if (!data) return undefined;
|
||||
|
||||
return data[userSessionKey];
|
||||
}
|
||||
async setSession(h3: H3Event, data: any, expend = false) {
|
||||
const result = await this.sessionProvider.updateSession(
|
||||
h3,
|
||||
userSessionKey,
|
||||
data
|
||||
);
|
||||
if (!result) {
|
||||
const toCreate = { [userSessionKey]: data };
|
||||
await this.sessionProvider.setSession(h3, toCreate, expend);
|
||||
}
|
||||
}
|
||||
async clearSession(h3: H3Event) {
|
||||
await this.sessionProvider.clearSession(h3);
|
||||
}
|
||||
|
||||
async getSession<T extends Session>(h3: H3Event) {
|
||||
const data = await this.sessionProvider.getSession<{ [userSessionKey]: T }>(h3);
|
||||
if (!data) return undefined;
|
||||
async getUserId(h3: H3Event) {
|
||||
const session = await this.sessionProvider.getSession<{
|
||||
[userIdKey]: string | undefined;
|
||||
}>(h3);
|
||||
if (!session) return undefined;
|
||||
|
||||
return data[userSessionKey];
|
||||
}
|
||||
async setSession(h3: H3Event, data: any, expend = false) {
|
||||
const result = await this.sessionProvider.updateSession(h3, userSessionKey, data);
|
||||
if (!result) {
|
||||
const toCreate = { [userSessionKey]: data };
|
||||
await this.sessionProvider.setSession(h3, toCreate, expend);
|
||||
}
|
||||
}
|
||||
async clearSession(h3: H3Event) {
|
||||
await this.sessionProvider.clearSession(h3);
|
||||
return session[userIdKey];
|
||||
}
|
||||
|
||||
async getUser(h3: H3Event) {
|
||||
const userId = await this.getUserId(h3);
|
||||
if (!userId) return undefined;
|
||||
|
||||
const user = await prisma.user.findFirst({ where: { id: userId } });
|
||||
return user;
|
||||
}
|
||||
|
||||
async setUserId(h3: H3Event, userId: string, extend = false) {
|
||||
const result = await this.sessionProvider.updateSession(
|
||||
h3,
|
||||
userIdKey,
|
||||
userId
|
||||
);
|
||||
if (!result) {
|
||||
const toCreate = { [userIdKey]: userId };
|
||||
await this.sessionProvider.setSession(h3, toCreate, extend);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserId(h3: H3Event) {
|
||||
const session = await this.sessionProvider.getSession<{ [userIdKey]: string | undefined }>(h3);
|
||||
if (!session) return undefined;
|
||||
|
||||
return session[userIdKey];
|
||||
}
|
||||
|
||||
async getUser(h3: H3Event) {
|
||||
const userId = await this.getUserId(h3);
|
||||
if (!userId) return undefined;
|
||||
|
||||
const user = await prisma.user.findFirst({ where: { id: userId } });
|
||||
return user;
|
||||
}
|
||||
|
||||
async setUserId(h3: H3Event, userId: string, extend = false) {
|
||||
const result = await this.sessionProvider.updateSession(h3, userIdKey, userId);
|
||||
if (!result) {
|
||||
const toCreate = { [userIdKey]: userId };
|
||||
await this.sessionProvider.setSession(h3, toCreate, extend);
|
||||
}
|
||||
}
|
||||
async getAdminUser(h3: H3Event) {
|
||||
const user = await this.getUser(h3);
|
||||
if (!user) return undefined;
|
||||
if (!user.admin) return undefined;
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
export default new SessionHandler();
|
||||
export default new SessionHandler();
|
||||
|
||||
14
server/internal/utils/parseplatform.ts
Normal file
14
server/internal/utils/parseplatform.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Platform } from "@prisma/client";
|
||||
|
||||
export function parsePlatform(platform: string) {
|
||||
switch (platform) {
|
||||
case "linux":
|
||||
case "Linux":
|
||||
return Platform.Linux;
|
||||
case "windows":
|
||||
case "Windows":
|
||||
return Platform.Windows;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
20
server/internal/utils/recursivedirs.ts
Normal file
20
server/internal/utils/recursivedirs.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export function recursivelyReaddir(dir: string) {
|
||||
const result: Array<string> = [];
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const targetDir = path.join(dir, file);
|
||||
const stat = fs.lstatSync(targetDir);
|
||||
if (stat.isDirectory()) {
|
||||
const subdirs = recursivelyReaddir(targetDir);
|
||||
const subdirsWithBase = subdirs.map((e) => path.join(dir, e));
|
||||
result.push(...subdirsWithBase);
|
||||
continue;
|
||||
}
|
||||
result.push(targetDir);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -1,11 +1,10 @@
|
||||
import { FsObjectBackend } from "../internal/objects/fsBackend";
|
||||
|
||||
// To-do insert logic surrounding deciding what object backend to use
|
||||
export const GlobalObjectHandler = new FsObjectBackend();
|
||||
|
||||
export default defineNitroPlugin((nitro) => {
|
||||
const currentObjectHandler = new FsObjectBackend();
|
||||
|
||||
// To-do insert logic surrounding deciding what object backend to use
|
||||
|
||||
nitro.hooks.hook("request", (h3) => {
|
||||
h3.context.objects = currentObjectHandler;
|
||||
h3.context.objects = GlobalObjectHandler;
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user