completed game importing; partial work on version importing

This commit is contained in:
DecDuck
2024-10-11 00:37:08 +11:00
parent 718f5ba514
commit a7c33e7d43
42 changed files with 1499 additions and 281 deletions

View File

@ -0,0 +1,24 @@
<template>
<div class="flex flex-row items-center gap-x-2">
<img :src="game.icon" class="w-12 h-12 rounded-sm object-cover" />
<div class="flex flex-col items-left">
<h1 class="font-semibold font-display text-lg text-zinc-100">
{{ game.name }}
<span
v-if="game.sourceName"
class="inline-flex items-center rounded-md bg-blue-600/50 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-700/10"
>{{ game.sourceName }}</span
>
</h1>
<p class="text-sm text-zinc-400">{{ game.description }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
const props = defineProps<{
game: GameMetadataSearchResult & { sourceName?: string };
}>();
</script>

View File

@ -20,7 +20,6 @@ function initWs() {
ws = new WebSocket(url); ws = new WebSocket(url);
ws.onmessage = (e) => { ws.onmessage = (e) => {
const msg = JSON.parse(e.data) as TaskMessage; const msg = JSON.parse(e.data) as TaskMessage;
console.log(msg);
const taskStates = useTaskStates(); const taskStates = useTaskStates();
const state = taskStates.value[msg.id]; const state = taskStates.value[msg.id];
if (!state) return; if (!state) return;

95
error.vue Normal file
View File

@ -0,0 +1,95 @@
<script setup lang="ts">
import type { NuxtError } from "#app";
const props = defineProps({
error: Object as () => NuxtError,
});
const route = useRoute();
const user = useUser();
async function signIn() {
clearError({
redirect: `/signin?redirect=${encodeURIComponent(route.fullPath)}`,
});
}
useHead({
title: `${props.error?.statusCode ?? "An unknown error occurred"} | Drop`,
});
console.log(props.error);
</script>
<template>
<div
class="grid min-h-screen grid-cols-1 grid-rows-[1fr,auto,1fr] bg-zinc-950 lg:grid-cols-[max(50%,36rem),1fr]"
>
<header
class="mx-auto w-full max-w-7xl px-6 pt-6 sm:pt-10 lg:col-span-2 lg:col-start-1 lg:row-start-1 lg:px-8"
>
<Logo class="h-10 w-auto sm:h-12" />
</header>
<main
class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8"
>
<div class="max-w-lg">
<p class="text-base font-semibold leading-8 text-blue-600">
{{ error?.statusCode }}
</p>
<h1
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
>
Oh no!
</h1>
<p class="mt-6 text-base leading-7 text-zinc-400">
An error occurred while responding to your request. If you believe
this to be a bug, please report it.
</p>
<div class="mt-10">
<!-- full app reload to fix errors -->
<a
v-if="user"
href="/"
class="text-sm font-semibold leading-7 text-blue-600"
><span aria-hidden="true">&larr;</span> Back to home</a
>
<button
v-else
@click="signIn"
class="text-sm font-semibold leading-7 text-blue-600"
>
Sign in <span aria-hidden="true">&rarr;</span>
</button>
</div>
</div>
</main>
<footer class="self-end lg:col-span-2 lg:col-start-1 lg:row-start-3">
<div class="border-t border-zinc-700 bg-zinc-900 py-10">
<nav
class="mx-auto flex w-full max-w-7xl items-center gap-x-4 px-6 text-sm leading-7 text-zinc-400 lg:px-8"
>
<NuxtLink href="/docs">Documentation</NuxtLink>
<svg
viewBox="0 0 2 2"
aria-hidden="true"
class="h-0.5 w-0.5 fill-zinc-600"
>
<circle cx="1" cy="1" r="1" />
</svg>
<a href="https://discord.gg/NHx46XKJWA" target="_blank"
>Support Discord</a
>
</nav>
</div>
</footer>
<div
class="hidden lg:relative lg:col-start-2 lg:row-start-1 lg:row-end-4 lg:block"
>
<img
src="/wallpapers/error-wallpaper.jpg"
alt=""
class="absolute inset-0 h-full w-full object-cover"
/>
</div>
</div>
</template>

View File

@ -49,11 +49,9 @@
</div> </div>
<main class="lg:pl-20 min-h-screen bg-zinc-900"> <main class="lg:pl-20 min-h-screen bg-zinc-900">
<div class="xl:pl-96"> <div class="px-4 py-10 sm:px-6 lg:px-8 lg:py-6">
<div class="px-4 py-10 sm:px-6 lg:px-8 lg:py-6"> <!-- Main area -->
<!-- Main area --> <NuxtPage />
<NuxtPage />
</div>
</div> </div>
</main> </main>
</div> </div>
@ -71,13 +69,14 @@ import {
} from "@heroicons/vue/24/outline"; } from "@heroicons/vue/24/outline";
import type { NavigationItem } from "~/composables/types"; import type { NavigationItem } from "~/composables/types";
import { useCurrentNavigationIndex } from "~/composables/current-page-engine"; import { useCurrentNavigationIndex } from "~/composables/current-page-engine";
import { ArrowLeftIcon } from "@heroicons/vue/16/solid";
const navigation: Array<NavigationItem & { icon: Component }> = [ const navigation: Array<NavigationItem & { icon: Component }> = [
{ label: "Home", route: "/admin", prefix: "/admin", icon: HomeIcon }, { label: "Home", route: "/admin", prefix: "/admin", icon: HomeIcon },
{ {
label: "Libraries", label: "Library",
route: "/admin/libraries", route: "/admin/library",
prefix: "/admin/libraries", prefix: "/admin/library",
icon: ServerStackIcon, icon: ServerStackIcon,
}, },
{ {
@ -90,13 +89,19 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
label: "Feature Flags", label: "Feature Flags",
route: "/admin/features", route: "/admin/features",
prefix: "/admin/features", prefix: "/admin/features",
icon: FlagIcon icon: FlagIcon,
}, },
{ {
label: "Settings", label: "Settings",
route: "/admin/settings", route: "/admin/settings",
prefix: "/admin/settings", prefix: "/admin/settings",
icon: Cog6ToothIcon icon: Cog6ToothIcon,
},
{
label: "Back",
route: "/",
prefix: ".",
icon: ArrowLeftIcon
} }
]; ];

View File

@ -3,7 +3,6 @@
<UserHeader /> <UserHeader />
<div class="grow flex"> <div class="grow flex">
<NuxtPage /> <NuxtPage />
{{ test }}
</div> </div>
<UserFooter /> <UserFooter />
</content> </content>
@ -16,6 +15,4 @@ useHead({
return `Drop`; return `Drop`;
}, },
}); });
const test = useTask("test");
</script> </script>

View File

@ -1,7 +1,10 @@
const whitelistedPrefixes = ["/signin", "/register", "/api"]; const whitelistedPrefixes = ["/signin", "/register", "/api"];
const requireAdmin = ["/admin"];
export default defineNuxtRouteMiddleware(async (to, from) => { export default defineNuxtRouteMiddleware(async (to, from) => {
if (import.meta.server) return; if (import.meta.server) return;
const error = useError();
if (error.value !== undefined) return;
if (whitelistedPrefixes.findIndex((e) => to.fullPath.startsWith(e)) != -1) if (whitelistedPrefixes.findIndex((e) => to.fullPath.startsWith(e)) != -1)
return; return;
@ -12,4 +15,10 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
if (!user.value) { if (!user.value) {
return navigateTo({ path: "/signin", query: { redirect: to.fullPath } }); return navigateTo({ path: "/signin", query: { redirect: to.fullPath } });
} }
if (
requireAdmin.findIndex((e) => to.fullPath.startsWith(e)) != -1 &&
!user.value.admin
) {
return navigateTo({ path: "/" });
}
}); });

View File

@ -10,15 +10,16 @@
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
}, },
"dependencies": { "dependencies": {
"@drop/droplet": "^0.3.2", "@drop/droplet": "^0.4.1",
"@headlessui/vue": "^1.7.23", "@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5", "@heroicons/vue": "^2.1.5",
"@prisma/client": "5.20.0", "@prisma/client": "5.20.0",
"axios": "^1.7.7", "axios": "^1.7.7",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"fast-fuzzy": "^1.12.0",
"file-type-mime": "^0.4.3", "file-type-mime": "^0.4.3",
"moment": "^2.30.1", "moment": "^2.30.1",
"nuxt": "^3.13.0", "nuxt": "^3.13.2",
"prisma": "^5.20.0", "prisma": "^5.20.0",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"stream": "^0.0.3", "stream": "^0.0.3",
@ -39,5 +40,9 @@
"postcss": "^8.4.47", "postcss": "^8.4.47",
"sass": "^1.79.4", "sass": "^1.79.4",
"tailwindcss": "^3.4.13" "tailwindcss": "^3.4.13"
},
"optionalDependencies": {
"@drop/droplet-linux-x64-gnu": "^0.4.1",
"@drop/droplet-win32-x64-msvc": "^0.4.1"
} }
} }

View File

@ -1,11 +0,0 @@
<template></template>
<script setup lang="ts">
definePageMeta({
layout: "admin",
});
useHead({
title: "Libraries"
})
</script>

View File

@ -0,0 +1,109 @@
<template>
<Listbox
as="div"
class="max-w-md"
v-on:update:model-value="(value) => updateCurrentlySelectedVersion(value)"
:model-value="currentlySelectedVersion"
>
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
>Select version to import</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
>
<span v-if="currentlySelectedVersion != -1" class="block truncate">{{
versions[currentlySelectedVersion]
}}</span>
<span v-else class="block truncate text-zinc-600"
>Please select a directory...</span
>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-800 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="(version, versionIdx) in versions"
:key="version"
:value="versionIdx"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'block truncate',
]"
>{{ version }}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
definePageMeta({
layout: "admin",
});
const route = useRoute();
const headers = useRequestHeaders(["cookie"]);
const gameId = route.params.id.toString();
const versions = await $fetch(
`/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}`,
{
headers,
}
);
const currentlySelectedVersion = ref(-1);
async function updateCurrentlySelectedVersion(value: number) {
if (currentlySelectedVersion.value == value) return;
currentlySelectedVersion.value = value;
const version = versions[currentlySelectedVersion.value];
const results = await $fetch(
`/api/v1/admin/import/version/preload?id=${encodeURIComponent(
gameId
)}&version=${encodeURIComponent(version)}`
);
}
</script>

View File

@ -0,0 +1,254 @@
<template>
<div class="flex flex-col gap-y-4">
<Listbox
as="div"
class="max-w-md"
v-on:update:model-value="(value) => updateSelectedGame(value)"
:model="currentlySelectedGame"
>
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
>Select game to import</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
>
<span v-if="currentlySelectedGame != -1" class="block truncate">{{
games.unimportedGames[currentlySelectedGame]
}}</span>
<span v-else class="block truncate text-zinc-600"
>Please select a directory...</span
>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-800 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="(game, gameIdx) in games.unimportedGames"
:key="game"
:value="gameIdx"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'block truncate',
]"
>{{ game }}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
<Listbox
as="div"
class="max-w-md"
v-if="metadataResults"
v-model="currentlySelectedMetadata"
>
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
>Select game</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
>
<GameSearchResultWidget
v-if="currentlySelectedMetadata != -1"
:game="metadataResults[currentlySelectedMetadata]"
/>
<span v-else class="block truncate text-zinc-600"
>Please select a game...</span
>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="(result, resultIdx) in metadataResults"
:key="result.id"
:value="resultIdx"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<GameSearchResultWidget :game="result" />
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
<div
v-else-if="currentlySelectedGame != -1"
role="status"
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4"
>
Loading game results...
<svg
aria-hidden="true"
class="w-6 h-6 text-transparent animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
<div v-if="currentlySelectedGame !== -1 && currentlySelectedMetadata !== -1">
<LoadingButton
@click="() => importGame_wrapper()"
class="w-fit"
:loading="importLoading"
>Import</LoadingButton
>
<div v-if="importError" class="mt-4 w-fit rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ importError }}
</h3>
</div>
</div>
</div>
</div>
{{ metadataResults }}
</div>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { XCircleIcon } from "@heroicons/vue/16/solid";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
definePageMeta({
layout: "admin",
});
const headers = useRequestHeaders(["cookie"]);
const games = await $fetch("/api/v1/admin/library/game/import", { headers });
const currentlySelectedGame = ref(-1);
async function updateSelectedGame(value: number) {
if (currentlySelectedGame.value == value) return;
currentlySelectedGame.value = value;
if (currentlySelectedGame.value == -1) return;
const game = games.unimportedGames[currentlySelectedGame.value];
if (!game) return;
metadataResults.value = undefined;
currentlySelectedMetadata.value = -1;
const results = await $fetch(
`/api/v1/admin/import/game/search?q=${encodeURIComponent(game)}`
);
metadataResults.value = results;
}
const metadataResults = ref<Array<GameMetadataSearchResult> | undefined>();
const currentlySelectedMetadata = ref(-1);
const router = useRouter();
const importLoading = ref(false);
const importError = ref<string | undefined>();
async function importGame() {
if (!metadataResults.value) return;
const game = await $fetch("/api/v1/admin/import/game", {
method: "POST",
body: {
path: games.unimportedGames[currentlySelectedGame.value],
metadata: metadataResults.value[currentlySelectedMetadata.value],
},
});
router.push(`/admin/library/${game.id}`);
}
function importGame_wrapper() {
importLoading.value = true;
importError.value = undefined;
importGame()
.catch((error) => {
importError.value = error?.statusMessage || "An unknown error occurred.";
})
.finally(() => {
importLoading.value = false;
});
}
</script>

View File

@ -0,0 +1,122 @@
<template>
<div
v-if="libraryState.unimportedGames.length > 0"
class="rounded-md bg-blue-600/10 p-4 mb-4"
>
<div class="flex">
<div class="flex-shrink-0">
<InformationCircleIcon
class="h-5 w-5 text-blue-400"
aria-hidden="true"
/>
</div>
<div class="ml-3 flex-1 md:flex md:justify-between">
<p class="text-sm text-blue-400">
Drop has detected you have new games to import.
</p>
<p class="mt-3 text-sm md:ml-6 md:mt-0">
<NuxtLink
href="/admin/library/import"
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
>
Import
<span aria-hidden="true"> &rarr;</span>
</NuxtLink>
</p>
</div>
</div>
</div>
<ul
role="list"
class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
>
<li
v-for="{ game, status } in libraryState.games"
:key="game.id"
class="col-span-1 flex flex-col justify-center divide-y divide-zinc-700 rounded-lg bg-zinc-950/20 text-left shadow"
>
<div class="flex flex-1 flex-row p-4 gap-x-4">
<img
class="mx-auto h-16 w-16 flex-shrink-0 rounded-md"
:src="useObject(game.mIconId)"
alt=""
/>
<div class="flex-1 flex-col">
<h3 class="text-sm font-medium text-zinc-100 font-display">
{{ game.mName }}
</h3>
<dl class="mt-1 flex flex-grow flex-col justify-between">
<dt class="sr-only">Short Description</dt>
<dd class="text-sm text-zinc-400">{{ game.mShortDescription }}</dd>
<dt class="sr-only">Metadata provider</dt>
<dd class="mt-3">
<span
class="inline-flex items-center rounded-full bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-600 ring-1 ring-inset ring-blue-600/20"
>{{ game.metadataSource }}</span
>
</dd>
</dl>
</div>
</div>
<div class="flex flex-col gap-y-2 p-2">
<div
v-if="status.unimportedVersions"
class="rounded-md bg-blue-600/10 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<InformationCircleIcon
class="h-5 w-5 text-blue-400"
aria-hidden="true"
/>
</div>
<div class="ml-3 flex-1 md:flex md:justify-between">
<p class="text-sm text-blue-400">
Drop has detected you have new verions of this game to import.
</p>
<p class="mt-3 text-sm md:ml-6 md:mt-0">
<NuxtLink
:href="`/admin/library/${game.id}/import`"
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
>
Import
<span aria-hidden="true"> &rarr;</span>
</NuxtLink>
</p>
</div>
</div>
</div>
<div v-if="status.noVersions" class="rounded-md bg-yellow-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<ExclamationTriangleIcon
class="h-5 w-5 text-yellow-600"
aria-hidden="true"
/>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-600">
You have no versions of this game available.
</h3>
</div>
</div>
</div>
</div>
</li>
</ul>
</template>
<script setup lang="ts">
import { ExclamationTriangleIcon } from "@heroicons/vue/16/solid";
import { InformationCircleIcon } from "@heroicons/vue/20/solid";
definePageMeta({
layout: "admin",
});
useHead({
title: "Libraries",
});
const headers = useRequestHeaders(["cookie"]);
const libraryState = await $fetch("/api/v1/admin/library", { headers });
</script>

View File

@ -1,5 +1,5 @@
<template> <template>
{{ user ?? "no user" }} {{ games }}
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -7,5 +7,5 @@ useHead({
title: "Home", title: "Home",
}); });
const user = useUser(); const games = await $fetch("/api/v1/games/front");
</script> </script>

View File

@ -0,0 +1,25 @@
/*
Warnings:
- A unique constraint covering the columns `[libraryBasePath]` on the table `Game` will be added. If there are existing duplicate values, this will fail.
- Added the required column `libraryBasePath` to the `Game` table without a default value. This is not possible if the table is not empty.
- Added the required column `versionOrder` to the `Game` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Game" ADD COLUMN "libraryBasePath" TEXT NOT NULL,
ADD COLUMN "versionOrder" TEXT NOT NULL;
-- CreateTable
CREATE TABLE "GameVersion" (
"gameId" TEXT NOT NULL,
"versionName" TEXT NOT NULL,
CONSTRAINT "GameVersion_pkey" PRIMARY KEY ("gameId","versionName")
);
-- CreateIndex
CREATE UNIQUE INDEX "Game_libraryBasePath_key" ON "Game"("libraryBasePath");
-- AddForeignKey
ALTER TABLE "GameVersion" ADD CONSTRAINT "GameVersion_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,25 @@
/*
Warnings:
- The `versionOrder` column on the `Game` table would be dropped and recreated. This will lead to data loss if there is data in the column.
- Changed the type of `platform` on the `Client` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
- Added the required column `launchCommand` to the `GameVersion` table without a default value. This is not possible if the table is not empty.
- Added the required column `platform` to the `GameVersion` table without a default value. This is not possible if the table is not empty.
- Added the required column `setupCommand` to the `GameVersion` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "Platform" AS ENUM ('windows', 'linux');
-- AlterTable
ALTER TABLE "Client" DROP COLUMN "platform",
ADD COLUMN "platform" "Platform" NOT NULL;
-- AlterTable
ALTER TABLE "Game" DROP COLUMN "versionOrder",
ADD COLUMN "versionOrder" TEXT[];
-- AlterTable
ALTER TABLE "GameVersion" ADD COLUMN "launchCommand" TEXT NOT NULL,
ADD COLUMN "platform" "Platform" NOT NULL,
ADD COLUMN "setupCommand" TEXT NOT NULL;

View File

@ -0,0 +1,12 @@
/*
Warnings:
- Added the required column `metadataOriginalQuery` to the `Developer` table without a default value. This is not possible if the table is not empty.
- Added the required column `metadataOriginalQuery` to the `Publisher` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Developer" ADD COLUMN "metadataOriginalQuery" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "Publisher" ADD COLUMN "metadataOriginalQuery" TEXT NOT NULL;

View File

@ -0,0 +1,18 @@
/*
Warnings:
- A unique constraint covering the columns `[metadataSource,metadataId,metadataOriginalQuery]` on the table `Developer` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[metadataSource,metadataId,metadataOriginalQuery]` on the table `Publisher` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "Developer_metadataSource_metadataId_key";
-- DropIndex
DROP INDEX "Publisher_metadataSource_metadataId_key";
-- CreateIndex
CREATE UNIQUE INDEX "Developer_metadataSource_metadataId_metadataOriginalQuery_key" ON "Developer"("metadataSource", "metadataId", "metadataOriginalQuery");
-- CreateIndex
CREATE UNIQUE INDEX "Publisher_metadataSource_metadataId_metadataOriginalQuery_key" ON "Publisher"("metadataSource", "metadataId", "metadataOriginalQuery");

View File

@ -45,6 +45,11 @@ enum ClientCapabilities {
DownloadAggregation DownloadAggregation
} }
enum Platform {
Windows @map("windows")
Linux @map("linux")
}
// References a device // References a device
model Client { model Client {
id String @id @default(uuid()) id String @id @default(uuid())
@ -55,7 +60,7 @@ model Client {
capabilities ClientCapabilities[] capabilities ClientCapabilities[]
name String name String
platform String platform Platform
lastConnected DateTime lastConnected DateTime
} }
@ -86,9 +91,9 @@ model Game {
mArt String[] // linked to objects in s3 mArt String[] // linked to objects in s3
mScreenshots String[] // linked to objects in s3 mScreenshots String[] // linked to objects in s3
versionOrder String versionOrder String[]
versions GameVersion[] versions GameVersion[]
libraryBasePath String // Base dir for all the game versions libraryBasePath String @unique // Base dir for all the game versions
@@unique([metadataSource, metadataId], name: "metadataKey") @@unique([metadataSource, metadataId], name: "metadataKey")
} }
@ -99,7 +104,9 @@ model GameVersion {
game Game @relation(fields: [gameId], references: [id]) game Game @relation(fields: [gameId], references: [id])
versionName String // Sub directory for the game files versionName String // Sub directory for the game files
platform Platform
launchCommand String // Command to run to start. Platform-specific. Windows games on Linux will wrap this command in Proton/Wine
setupCommand String // Command to setup game (dependencies and such)
@@id([gameId, versionName]) @@id([gameId, versionName])
} }
@ -107,8 +114,9 @@ model GameVersion {
model Developer { model Developer {
id String @id @default(uuid()) id String @id @default(uuid())
metadataSource MetadataSource metadataSource MetadataSource
metadataId String metadataId String
metadataOriginalQuery String
mName String mName String
mShortDescription String mShortDescription String
@ -119,14 +127,15 @@ model Developer {
games Game[] games Game[]
@@unique([metadataSource, metadataId], name: "metadataKey") @@unique([metadataSource, metadataId, metadataOriginalQuery], name: "metadataKey")
} }
model Publisher { model Publisher {
id String @id @default(uuid()) id String @id @default(uuid())
metadataSource MetadataSource metadataSource MetadataSource
metadataId String metadataId String
metadataOriginalQuery String
mName String mName String
mShortDescription String mShortDescription String
@ -137,5 +146,5 @@ model Publisher {
games Game[] games Game[]
@@unique([metadataSource, metadataId], name: "metadataKey") @@unique([metadataSource, metadataId, metadataOriginalQuery], name: "metadataKey")
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

View 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 };
});

View 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;
});

View 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);
});

View 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;
});

View 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;
});

View 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 };
});

View 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 };
});

View File

@ -24,7 +24,9 @@ export default defineEventHandler(async (h3) => {
const userId = uuidv4(); 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", { $fetch<Readable>("https://avatars.githubusercontent.com/u/64579723?v=4", {
responseType: "stream", responseType: "stream",
@ -32,18 +34,12 @@ export default defineEventHandler(async (h3) => {
{}, {},
[`anonymous:read`, `${userId}:write`] [`anonymous:read`, `${userId}:write`]
); );
if (!profilePictureObject)
throw createError({
statusCode: 500,
statusMessage: "Unable to import profile picture",
});
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { data: {
username, username,
displayName: "DecDuck", displayName: "DecDuck",
email: "", email: "",
profilePicture: profilePictureObject, profilePicture: profilePictureId,
admin: true, admin: true,
}, },
}); });

View File

@ -1,17 +1,25 @@
import clientHandler from "~/server/internal/clients/handler"; import clientHandler from "~/server/internal/clients/handler";
import { parsePlatform } from "~/server/internal/utils/parseplatform";
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const body = await readBody(h3); const body = await readBody(h3);
const name = body.name; const name = body.name;
const platform = body.platform; const platformRaw = body.platform;
if (!name || !platform) if (!name || !platformRaw)
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
statusMessage: "Missing name or platform in body", 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 }); const clientId = await clientHandler.initiate({ name, platform });
return `/client/${clientId}/callback`; return `/client/${clientId}/callback`;

View 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;
});

View File

@ -1,10 +1,11 @@
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { CertificateBundle } from "./ca"; import { CertificateBundle } from "./ca";
import prisma from "../db/database"; import prisma from "../db/database";
import { Platform } from "@prisma/client";
export interface ClientMetadata { export interface ClientMetadata {
name: string; name: string;
platform: string; platform: Platform;
} }
export class ClientHandler { export class ClientHandler {

View File

@ -10,6 +10,6 @@ declare const globalThis: {
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton() const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
export default prisma export default prisma;
if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma

View 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;

View File

@ -50,9 +50,10 @@ interface GameResult {
interface CompanySearchResult { interface CompanySearchResult {
guid: string, guid: string,
deck: string, deck: string | null,
description: string, description: string | null,
name: string, name: string,
website: string | null,
image: { image: {
icon_url: string, icon_url: string,
@ -191,8 +192,9 @@ export class GiantBombProvider implements MetadataProvider {
const metadata: PublisherMetadata = { const metadata: PublisherMetadata = {
id: company.guid, id: company.guid,
name: company.name, name: company.name,
shortDescription: company.deck, shortDescription: company.deck ?? "",
description: longDescription, description: longDescription ?? "",
website: company.website ?? "",
logo: createObject(company.image.icon_url), logo: createObject(company.image.icon_url),
banner: createObject(company.image.screen_large_url), banner: createObject(company.image.screen_large_url),

View File

@ -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 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 { ObjectTransactionalHandler } from "../objects/transactional";
import { PriorityList, PriorityListIndexed } from "../utils/prioritylist"; import { PriorityList, PriorityListIndexed } from "../utils/prioritylist";
export abstract class MetadataProvider { export abstract class MetadataProvider {
abstract id(): string; abstract id(): string;
abstract name(): string; abstract name(): string;
abstract source(): MetadataSource; abstract source(): MetadataSource;
abstract search(query: string): Promise<GameMetadataSearchResult[]>; abstract search(query: string): Promise<GameMetadataSearchResult[]>;
abstract fetchGame(params: _FetchGameMetadataParams): Promise<GameMetadata>; abstract fetchGame(params: _FetchGameMetadataParams): Promise<GameMetadata>;
abstract fetchPublisher(params: _FetchPublisherMetadataParams): Promise<PublisherMetadata>; abstract fetchPublisher(
abstract fetchDeveloper(params: _FetchDeveloperMetadataParams): Promise<DeveloperMetadata>; params: _FetchPublisherMetadataParams
): Promise<PublisherMetadata>;
abstract fetchDeveloper(
params: _FetchDeveloperMetadataParams
): Promise<DeveloperMetadata>;
} }
export class MetadataHandler { export class MetadataHandler {
// Ordered by priority // Ordered by priority
private providers: PriorityListIndexed<MetadataProvider> = new PriorityListIndexed("id"); private providers: PriorityListIndexed<MetadataProvider> =
private objectHandler: ObjectTransactionalHandler = new ObjectTransactionalHandler(); new PriorityListIndexed("id");
private objectHandler: ObjectTransactionalHandler =
new ObjectTransactionalHandler();
addProvider(provider: MetadataProvider, priority: number = 0) { addProvider(provider: MetadataProvider, priority: number = 0) {
this.providers.push(provider, priority); this.providers.push(provider, priority);
} }
async search(query: string) { async search(query: string) {
const promises: Promise<InternalGameMetadataResult[]>[] = []; const promises: Promise<InternalGameMetadataResult[]>[] = [];
for (const provider of this.providers.values()) { for (const provider of this.providers.values()) {
const queryTransformationPromise = new Promise<InternalGameMetadataResult[]>(async (resolve, reject) => { const queryTransformationPromise = new Promise<
const results = await provider.search(query); InternalGameMetadataResult[]
const mappedResults: InternalGameMetadataResult[] = results.map((result) => Object.assign( >(async (resolve, reject) => {
{}, const results = await provider.search(query);
result, const mappedResults: InternalGameMetadataResult[] = results.map(
{ (result) =>
sourceId: provider.id(), Object.assign({}, result, {
sourceName: provider.name() 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,
}) })
} catch (e) { );
dumpObjects(); resolve(mappedResults);
throw e; });
} promises.push(queryTransformationPromise);
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;
} }
async fetchDeveloper(query: string) { const results = await Promise.allSettled(promises);
return await this.fetchDeveloperPublisher(query, "fetchDeveloper", "developer") as Developer; 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) { await pullObjects();
return await this.fetchDeveloperPublisher(query, "fetchPublisher", "publisher") as Publisher; 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 throw new Error(
// TODO: fix typechecking `No metadata provider found a ${databaseName} for "${query}"`
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}"`);
}
} }
export default new MetadataHandler(); export default new MetadataHandler();

View File

@ -45,6 +45,7 @@ export interface PublisherMetadata {
logo: ObjectReference; logo: ObjectReference;
banner: ObjectReference; banner: ObjectReference;
website: String;
} }
export type DeveloperMetadata = PublisherMetadata; export type DeveloperMetadata = PublisherMetadata;

View File

@ -45,10 +45,10 @@ export class FsObjectBackend extends ObjectBackend {
return false; return false;
} }
async create( async create(
id: string,
source: Source, source: Source,
metadata: ObjectMetadata metadata: ObjectMetadata
): Promise<ObjectReference | undefined> { ): Promise<ObjectReference | undefined> {
const id = uuidv4();
const objectPath = path.join(this.baseObjectPath, sanitize(id)); const objectPath = path.join(this.baseObjectPath, sanitize(id));
const metadataPath = path.join( const metadataPath = path.join(
this.baseMetadataPath, this.baseMetadataPath,

View File

@ -17,6 +17,7 @@
import { parse as getMimeTypeBuffer } from "file-type-mime"; import { parse as getMimeTypeBuffer } from "file-type-mime";
import { Readable } from "stream"; import { Readable } from "stream";
import { getMimeType as getMimeTypeStream } from "stream-mime-type"; import { getMimeType as getMimeTypeStream } from "stream-mime-type";
import { v4 as uuidv4 } from "uuid";
export type ObjectReference = string; export type ObjectReference = string;
export type ObjectMetadata = { export type ObjectMetadata = {
@ -46,6 +47,7 @@ export abstract class ObjectBackend {
abstract fetch(id: ObjectReference): Promise<Source | undefined>; abstract fetch(id: ObjectReference): Promise<Source | undefined>;
abstract write(id: ObjectReference, source: Source): Promise<boolean>; abstract write(id: ObjectReference, source: Source): Promise<boolean>;
abstract create( abstract create(
id: string,
source: Source, source: Source,
metadata: ObjectMetadata metadata: ObjectMetadata
): Promise<ObjectReference | undefined>; ): Promise<ObjectReference | undefined>;
@ -59,6 +61,7 @@ export abstract class ObjectBackend {
): Promise<boolean>; ): Promise<boolean>;
async createFromSource( async createFromSource(
id: string,
sourceFetcher: () => Promise<Source>, sourceFetcher: () => Promise<Source>,
metadata: { [key: string]: string }, metadata: { [key: string]: string },
permissions: Array<string> permissions: Array<string>
@ -83,13 +86,11 @@ export abstract class ObjectBackend {
if (!mime) if (!mime)
throw new Error("Unable to calculate MIME type - is the source empty?"); throw new Error("Unable to calculate MIME type - is the source empty?");
const objectId = this.create(source, { await this.create(id, source, {
permissions, permissions,
userMetadata: metadata, userMetadata: metadata,
mime, mime,
}); });
return objectId;
} }
async fetchWithPermissions(id: ObjectReference, userId?: string) { async fetchWithPermissions(id: ObjectReference, userId?: string) {

View File

@ -2,7 +2,9 @@
The purpose of this class is to hold references to remote objects (like images) until they're actually needed 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. 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 TransactionTable = { [key: string]: string }; // ID to URL
type GlobalTransactionRecord = { [key: string]: TransactionTable }; // Transaction ID to table type GlobalTransactionRecord = { [key: string]: TransactionTable }; // Transaction ID to table
@ -12,27 +14,38 @@ type Pull = () => Promise<void>;
type Dump = () => void; type Dump = () => void;
export class ObjectTransactionalHandler { export class ObjectTransactionalHandler {
private record: GlobalTransactionRecord = {}; private record: GlobalTransactionRecord = {};
new(): [Register, Pull, Dump] { new(
const transactionId = uuidv4(); metadata: { [key: string]: string },
permissions: Array<string>
): [Register, Pull, Dump] {
const transactionId = uuidv4();
const register = (url: string) => { this.record[transactionId] ??= {};
const objectId = uuidv4();
this.record[transactionId][objectId] = url;
return objectId; const register = (url: string) => {
} const objectId = uuidv4();
this.record[transactionId][objectId] = url;
const pull = async () => { return objectId;
// Dummy function };
dump();
}
const dump = () => { const pull = async () => {
delete this.record[transactionId]; 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];
}
}

View File

@ -13,52 +13,71 @@ const userSessionKey = "_userSession";
const userIdKey = "_userId"; const userIdKey = "_userId";
export class SessionHandler { export class SessionHandler {
private sessionProvider: SessionProvider; private sessionProvider: SessionProvider;
constructor() { constructor() {
// Create a new provider // Create a new provider
this.sessionProvider = createMemorySessionProvider(); 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) { async getUserId(h3: H3Event) {
const data = await this.sessionProvider.getSession<{ [userSessionKey]: T }>(h3); const session = await this.sessionProvider.getSession<{
if (!data) return undefined; [userIdKey]: string | undefined;
}>(h3);
if (!session) return undefined;
return data[userSessionKey]; return session[userIdKey];
} }
async setSession(h3: H3Event, data: any, expend = false) {
const result = await this.sessionProvider.updateSession(h3, userSessionKey, data); async getUser(h3: H3Event) {
if (!result) { const userId = await this.getUserId(h3);
const toCreate = { [userSessionKey]: data }; if (!userId) return undefined;
await this.sessionProvider.setSession(h3, toCreate, expend);
} const user = await prisma.user.findFirst({ where: { id: userId } });
} return user;
async clearSession(h3: H3Event) { }
await this.sessionProvider.clearSession(h3);
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) { async getAdminUser(h3: H3Event) {
const session = await this.sessionProvider.getSession<{ [userIdKey]: string | undefined }>(h3); const user = await this.getUser(h3);
if (!session) return undefined; if (!user) return undefined;
if (!user.admin) return undefined;
return session[userIdKey]; return user;
} }
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);
}
}
} }
export default new SessionHandler(); export default new SessionHandler();

View 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;
}

View 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;
}

View File

@ -1,11 +1,10 @@
import { FsObjectBackend } from "../internal/objects/fsBackend"; 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) => { export default defineNitroPlugin((nitro) => {
const currentObjectHandler = new FsObjectBackend();
// To-do insert logic surrounding deciding what object backend to use
nitro.hooks.hook("request", (h3) => { nitro.hooks.hook("request", (h3) => {
h3.context.objects = currentObjectHandler; h3.context.objects = GlobalObjectHandler;
}); });
}); });

View File

@ -296,23 +296,23 @@
dependencies: dependencies:
mime "^3.0.0" mime "^3.0.0"
"@drop/droplet-linux-x64-gnu@0.3.2": "@drop/droplet-linux-x64-gnu@0.4.1", "@drop/droplet-linux-x64-gnu@^0.4.1":
version "0.3.2" version "0.4.1"
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-linux-x64-gnu/-/@drop/droplet-linux-x64-gnu-0.3.2.tgz#9be12f1e61df67837bb225ec67cc98f5af8f703b" resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-linux-x64-gnu/-/@drop/droplet-linux-x64-gnu-0.4.1.tgz#24f9ccebf7349bec450b855571b300284fb3731f"
integrity sha1-m+EvHmHfZ4N7siXsZ8yY9a+PcDs= integrity sha1-JPnM6/c0m+xFC4VVcbMAKE+zcx8=
"@drop/droplet-win32-x64-msvc@0.3.2": "@drop/droplet-win32-x64-msvc@0.4.1", "@drop/droplet-win32-x64-msvc@^0.4.1":
version "0.3.2" version "0.4.1"
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-win32-x64-msvc/-/@drop/droplet-win32-x64-msvc-0.3.2.tgz#dc5d0fa8334bf211666e99ca365c900d363f7823" resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-win32-x64-msvc/-/@drop/droplet-win32-x64-msvc-0.4.1.tgz#58238faca15b36abb02162354c2f39526bc213a1"
integrity sha1-3F0PqDNL8hFmbpnKNlyQDTY/eCM= integrity sha1-WCOPrKFbNquwIWI1TC85UmvCE6E=
"@drop/droplet@^0.3.2": "@drop/droplet@^0.4.1":
version "0.3.2" version "0.4.1"
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet/-/@drop/droplet-0.3.2.tgz#f57cf35e50dfd448b7837f9b4712543ff160e769" resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet/-/@drop/droplet-0.4.1.tgz#d4f3a7950fad2a95487ce4c014e1c782c2fcc3c7"
integrity sha1-9XzzXlDf1Ei3g3+bRxJUP/Fg52k= integrity sha1-1POnlQ+tKpVIfOTAFOHHgsL8w8c=
optionalDependencies: optionalDependencies:
"@drop/droplet-linux-x64-gnu" "0.3.2" "@drop/droplet-linux-x64-gnu" "0.4.1"
"@drop/droplet-win32-x64-msvc" "0.3.2" "@drop/droplet-win32-x64-msvc" "0.4.1"
"@esbuild/aix-ppc64@0.20.2": "@esbuild/aix-ppc64@0.20.2":
version "0.20.2" version "0.20.2"
@ -2709,6 +2709,13 @@ fast-fifo@^1.2.0, fast-fifo@^1.3.2:
resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c" resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c"
integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==
fast-fuzzy@^1.12.0:
version "1.12.0"
resolved "https://registry.yarnpkg.com/fast-fuzzy/-/fast-fuzzy-1.12.0.tgz#f900a8165bffbb7dd5c013a1bb96ee22179ba406"
integrity sha512-sXxGgHS+ubYpsdLnvOvJ9w5GYYZrtL9mkosG3nfuD446ahvoWEsSKBP7ieGmWIKVLnaxRDgUJkZMdxRgA2Ni+Q==
dependencies:
graphemesplit "^2.4.1"
fast-glob@^3.2.7, fast-glob@^3.3.0, fast-glob@^3.3.2: fast-glob@^3.2.7, fast-glob@^3.3.0, fast-glob@^3.3.2:
version "3.3.2" version "3.3.2"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129"
@ -2983,6 +2990,14 @@ graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.9:
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
graphemesplit@^2.4.1:
version "2.4.4"
resolved "https://registry.yarnpkg.com/graphemesplit/-/graphemesplit-2.4.4.tgz#6d325c61e928efdaec2189f54a9b87babf89b75a"
integrity sha512-lKrpp1mk1NH26USxC/Asw4OHbhSQf5XfrWZ+CDv/dFVvd1j17kFgMotdJvOesmHkbFX9P9sBfpH8VogxOWLg8w==
dependencies:
js-base64 "^3.6.0"
unicode-trie "^2.0.0"
gzip-size@^7.0.0: gzip-size@^7.0.0:
version "7.0.0" version "7.0.0"
resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-7.0.0.tgz#9f9644251f15bc78460fccef4055ae5a5562ac60" resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-7.0.0.tgz#9f9644251f15bc78460fccef4055ae5a5562ac60"
@ -3306,6 +3321,11 @@ jiti@^2.0.0:
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.0.0.tgz#ccaab6ce73a73cbf04e187645c614b3a3d41b653" resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.0.0.tgz#ccaab6ce73a73cbf04e187645c614b3a3d41b653"
integrity sha512-CJ7e7Abb779OTRv3lomfp7Mns/Sy1+U4pcAx5VbjxCZD5ZM/VJaXPpPjNKjtSvWQy/H86E49REXR34dl1JEz9w== integrity sha512-CJ7e7Abb779OTRv3lomfp7Mns/Sy1+U4pcAx5VbjxCZD5ZM/VJaXPpPjNKjtSvWQy/H86E49REXR34dl1JEz9w==
js-base64@^3.6.0:
version "3.7.7"
resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.7.tgz#e51b84bf78fbf5702b9541e2cb7bfcb893b43e79"
integrity sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==
js-tokens@^4.0.0: js-tokens@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@ -3838,7 +3858,7 @@ nuxi@^3.13.2:
resolved "https://registry.yarnpkg.com/nuxi/-/nuxi-3.14.0.tgz#697a1e8b4f0d92fb8b30aa355af9295fa8c2cb4e" resolved "https://registry.yarnpkg.com/nuxi/-/nuxi-3.14.0.tgz#697a1e8b4f0d92fb8b30aa355af9295fa8c2cb4e"
integrity sha512-MhG4QR6D95jQxhnwKfdKXulZ8Yqy1nbpwbotbxY5IcabOzpEeTB8hYn2BFkmYdMUB0no81qpv2ldZmVCT9UsnQ== integrity sha512-MhG4QR6D95jQxhnwKfdKXulZ8Yqy1nbpwbotbxY5IcabOzpEeTB8hYn2BFkmYdMUB0no81qpv2ldZmVCT9UsnQ==
nuxt@^3.13.0: nuxt@^3.13.2:
version "3.13.2" version "3.13.2"
resolved "https://registry.yarnpkg.com/nuxt/-/nuxt-3.13.2.tgz#af43a1fb5ccaaf98be0aaeca1bee504eeee24135" resolved "https://registry.yarnpkg.com/nuxt/-/nuxt-3.13.2.tgz#af43a1fb5ccaaf98be0aaeca1bee504eeee24135"
integrity sha512-Bjc2qRsipfBhjXsBEJCN+EUAukhdgFv/KoIR5HFB2hZOYRSqXBod3oWQs78k3ja1nlIhAEdBG533898KJxUtJw== integrity sha512-Bjc2qRsipfBhjXsBEJCN+EUAukhdgFv/KoIR5HFB2hZOYRSqXBod3oWQs78k3ja1nlIhAEdBG533898KJxUtJw==
@ -4006,6 +4026,11 @@ package-manager-detector@^0.2.0:
resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-0.2.0.tgz#160395cd5809181f5a047222319262b8c2d8aaea" resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-0.2.0.tgz#160395cd5809181f5a047222319262b8c2d8aaea"
integrity sha512-E385OSk9qDcXhcM9LNSe4sdhx8a9mAPrZ4sMLW+tmxl5ZuGtPUcdFu+MPP2jbgiWAZ6Pfe5soGFMd+0Db5Vrog== integrity sha512-E385OSk9qDcXhcM9LNSe4sdhx8a9mAPrZ4sMLW+tmxl5ZuGtPUcdFu+MPP2jbgiWAZ6Pfe5soGFMd+0Db5Vrog==
pako@^0.2.5:
version "0.2.9"
resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75"
integrity sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==
parse-git-config@^3.0.0: parse-git-config@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/parse-git-config/-/parse-git-config-3.0.0.tgz#4a2de08c7b74a2555efa5ae94d40cd44302a6132" resolved "https://registry.yarnpkg.com/parse-git-config/-/parse-git-config-3.0.0.tgz#4a2de08c7b74a2555efa5ae94d40cd44302a6132"
@ -5085,6 +5110,11 @@ through2@4.0.2:
dependencies: dependencies:
readable-stream "3" readable-stream "3"
tiny-inflate@^1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4"
integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==
tiny-invariant@^1.1.0: tiny-invariant@^1.1.0:
version "1.3.3" version "1.3.3"
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
@ -5220,6 +5250,14 @@ unhead@1.11.6, unhead@^1.11.5:
"@unhead/shared" "1.11.6" "@unhead/shared" "1.11.6"
hookable "^5.5.3" hookable "^5.5.3"
unicode-trie@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/unicode-trie/-/unicode-trie-2.0.0.tgz#8fd8845696e2e14a8b67d78fa9e0dd2cad62fec8"
integrity sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==
dependencies:
pako "^0.2.5"
tiny-inflate "^1.0.0"
unicorn-magic@^0.1.0: unicorn-magic@^0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.1.0.tgz#1bb9a51c823aaf9d73a8bfcd3d1a23dde94b0ce4" resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.1.0.tgz#1bb9a51c823aaf9d73a8bfcd3d1a23dde94b0ce4"