mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-09 20:12:10 +10:00
Merge remote-tracking branch 'origin/develop' into develop
This commit is contained in:
@ -14,7 +14,7 @@
|
||||
[
|
||||
](https://translate.droposs.org/engage/drop/)
|
||||
|
||||
Drop is an open-source game distribution platform, like GameVault or Steam. It's designed to distribute and shared DRM-free game quickly, all while being incredibly flexible, beautiful and fast.
|
||||
Drop is an open-source game distribution platform, similar to GameVault or Steam. It's designed to distribute and share DRM-free games quickly, all while being incredibly flexible, beautiful, and fast.
|
||||
|
||||
<div align="center">
|
||||
<img src="https://droposs.org/_ipx/f_webp&q_80/images/carousel/store.png" alt="Drop Screenshot" width="900rem"/>
|
||||
@ -22,9 +22,9 @@ Drop is an open-source game distribution platform, like GameVault or Steam. It's
|
||||
|
||||
## Philosophy
|
||||
|
||||
1. Drop is flexible. While abstractions and interfaces can make the codebase more complicated, the flexibility is worth it.
|
||||
2. Drop is secure. The nature of Drop means an instance can never be accessible without authentication. In line with #1, Drop also supports a huge variety of authentication mechanisms, from a username/password to SSO.
|
||||
3. Drop is user-friendly. The interface is designed to be clean and simple to use, with complexity available to the users who want it.
|
||||
1. Drop is flexible. While abstractions and interfaces can complicate the codebase, the flexibility is worth it.
|
||||
2. Drop is secure. The nature of Drop means an instance can never be accessible without authentication. In line with #1, Drop also supports a huge variety of authentication mechanisms, from username/password to SSO.
|
||||
3. Drop is user-friendly. The interface is designed to be clean and simple to use, with advanced features available to users who want them.
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
42
app.vue
42
app.vue
@ -4,10 +4,52 @@
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
<ModalStack />
|
||||
<div
|
||||
v-if="showExternalUrlWarning"
|
||||
class="fixed flex flex-row gap-x-2 right-0 bottom-0 m-2 px-2 py-2 z-50 text-right bg-red-700/90 rounded-lg"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm text-zinc-200 font-bold font-display">{{
|
||||
$t("errors.externalUrl.title")
|
||||
}}</span>
|
||||
<span class="text-xs text-red-400">{{
|
||||
$t("errors.externalUrl.subtitle")
|
||||
}}</span>
|
||||
</div>
|
||||
<button class="text-red-200" @click="() => hideExternalURL()">
|
||||
<XMarkIcon class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XMarkIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
await updateUser();
|
||||
|
||||
const user = useUser();
|
||||
const apiDetails = await $dropFetch("/api/v1");
|
||||
|
||||
const showExternalUrlWarning = ref(false);
|
||||
function checkExternalUrl() {
|
||||
if (!import.meta.client) return;
|
||||
const realOrigin = window.location.origin.trim();
|
||||
const chosenOrigin = apiDetails.external.trim();
|
||||
const ignore = window.localStorage.getItem("ignoreExternalUrl");
|
||||
if (ignore && ignore == "true") return;
|
||||
showExternalUrlWarning.value = !(realOrigin == chosenOrigin);
|
||||
}
|
||||
|
||||
function hideExternalURL() {
|
||||
window.localStorage.setItem("ignoreExternalUrl", "true");
|
||||
showExternalUrlWarning.value = false;
|
||||
}
|
||||
|
||||
if (user.value?.admin) {
|
||||
onMounted(() => {
|
||||
checkExternalUrl();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -22,21 +22,17 @@
|
||||
<!-- import games button -->
|
||||
|
||||
<NuxtLink
|
||||
:href="
|
||||
unimportedVersions.length > 0
|
||||
? `/admin/library/${game.id}/import`
|
||||
: ''
|
||||
"
|
||||
:href="canImport ? `/admin/library/${game.id}/import` : ''"
|
||||
type="button"
|
||||
:class="[
|
||||
unimportedVersions.length > 0
|
||||
canImport
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
: 'bg-blue-800/50',
|
||||
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
|
||||
]"
|
||||
>
|
||||
{{
|
||||
unimportedVersions.length > 0
|
||||
canImport
|
||||
? $t("library.admin.import.version.import")
|
||||
: $t("library.admin.import.version.noVersions")
|
||||
}}
|
||||
@ -124,10 +120,16 @@ import { ExclamationCircleIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
// TODO implement UI for this page
|
||||
|
||||
defineProps<{ unimportedVersions: string[] }>();
|
||||
const props = defineProps<{ unimportedVersions: string[] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const hasDeleted = ref(false);
|
||||
|
||||
const canImport = computed(
|
||||
() => hasDeleted.value || props.unimportedVersions.length > 0,
|
||||
);
|
||||
|
||||
type GameAndVersions = GameModel & { versions: GameVersionModel[] };
|
||||
const game = defineModel<SerializeObject<GameAndVersions>>() as Ref<
|
||||
SerializeObject<GameAndVersions>
|
||||
@ -176,6 +178,7 @@ async function deleteVersion(versionName: string) {
|
||||
game.value.versions.findIndex((e) => e.versionName === versionName),
|
||||
1,
|
||||
);
|
||||
hasDeleted.value = true;
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
|
||||
@ -18,8 +18,12 @@
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
|
||||
<DevOnly
|
||||
><h1 class="mt-3 text-sm text-gray-500">{{ $t("welcome") }}</h1>
|
||||
<DevOnly>
|
||||
<h1 class="mt-3 text-sm text-gray-500">{{ $t("welcome") }}</h1>
|
||||
</DevOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
|
||||
</script>
|
||||
|
||||
148
components/Modal/CreateCompany.vue
Normal file
148
components/Modal/CreateCompany.vue
Normal file
@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<ModalTemplate v-model="open">
|
||||
<template #default>
|
||||
<div>
|
||||
<h3 class="text-lg font-medium leading-6 text-white">
|
||||
{{ $t("library.admin.metadata.companies.modals.createTitle") }}
|
||||
</h3>
|
||||
<p class="mt-1 text-zinc-400 text-sm">
|
||||
{{ $t("library.admin.metadata.companies.modals.createDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<form class="space-y-4" @submit.prevent="() => createCompany()">
|
||||
<div>
|
||||
<label
|
||||
for="name"
|
||||
class="block text-sm/6 font-medium text-zinc-100"
|
||||
>{{
|
||||
$t("library.admin.metadata.companies.modals.createFieldName")
|
||||
}}</label
|
||||
>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
id="name"
|
||||
v-model="companyName"
|
||||
type="text"
|
||||
name="name"
|
||||
:placeholder="
|
||||
$t(
|
||||
'library.admin.metadata.companies.modals.createFieldNamePlaceholder',
|
||||
)
|
||||
"
|
||||
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="description"
|
||||
class="block text-sm/6 font-medium text-zinc-100"
|
||||
>{{
|
||||
$t(
|
||||
"library.admin.metadata.companies.modals.createFieldDescription",
|
||||
)
|
||||
}}</label
|
||||
>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
id="description"
|
||||
v-model="companyDescription"
|
||||
type="text"
|
||||
name="description"
|
||||
:placeholder="
|
||||
$t(
|
||||
'library.admin.metadata.companies.modals.createFieldDescriptionPlaceholder',
|
||||
)
|
||||
"
|
||||
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="website"
|
||||
class="block text-sm/6 font-medium text-zinc-100"
|
||||
>{{
|
||||
$t("library.admin.metadata.companies.modals.createFieldWebsite")
|
||||
}}</label
|
||||
>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
id="website"
|
||||
v-model="companyWebsite"
|
||||
type="text"
|
||||
name="website"
|
||||
:placeholder="
|
||||
$t(
|
||||
'library.admin.metadata.companies.modals.createFieldWebsitePlaceholder',
|
||||
)
|
||||
"
|
||||
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="hidden" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #buttons="{ close }">
|
||||
<LoadingButton
|
||||
:loading="loading"
|
||||
:disabled="!companyValid"
|
||||
class="w-full sm:w-fit"
|
||||
@click="() => createCompany()"
|
||||
>
|
||||
{{ $t("common.create") }}
|
||||
</LoadingButton>
|
||||
<button
|
||||
ref="cancelButtonRef"
|
||||
type="button"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
|
||||
@click="() => close()"
|
||||
>
|
||||
{{ $t("cancel") }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CompanyModel } from "~/prisma/client/models";
|
||||
|
||||
const open = defineModel<boolean>({ required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [company: CompanyModel];
|
||||
}>();
|
||||
|
||||
const companyName = ref("");
|
||||
const companyDescription = ref("");
|
||||
const companyWebsite = ref("");
|
||||
|
||||
const loading = ref(false);
|
||||
const companyValid = computed(
|
||||
() => companyName.value && companyDescription.value,
|
||||
);
|
||||
async function createCompany() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const newCompany = await $dropFetch("/api/v1/admin/company", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name: companyName.value,
|
||||
description: companyDescription.value,
|
||||
website: companyWebsite.value,
|
||||
},
|
||||
failTitle: "Failed to create new company",
|
||||
});
|
||||
open.value = false;
|
||||
emit("created", newCompany);
|
||||
} finally {
|
||||
/* empty */
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
</script>
|
||||
@ -292,7 +292,7 @@
|
||||
<div
|
||||
v-if="games?.length ?? 0 > 0"
|
||||
ref="product-grid"
|
||||
class="relative lg:col-span-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4"
|
||||
class="relative lg:col-span-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-7 gap-4"
|
||||
>
|
||||
<!-- Your content -->
|
||||
<GamePanel
|
||||
|
||||
@ -212,6 +212,10 @@
|
||||
"desc": "Drop encountered an error while updating the version: {error}",
|
||||
"title": "There an error while updating the version order"
|
||||
}
|
||||
},
|
||||
"externalUrl": {
|
||||
"title": "Accessing over different EXTERNAL_URL. Please check the docs.",
|
||||
"subtitle": "This message is only visible to admins."
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
@ -329,7 +333,9 @@
|
||||
"noDescription": "(no description)",
|
||||
"published": "Published",
|
||||
"uploadBanner": "Upload banner",
|
||||
"uploadIcon": "Upload icon"
|
||||
"uploadIcon": "Upload icon",
|
||||
"descriptionPlaceholder": "{'<'}description{'>'}",
|
||||
"websitePlaceholder": "{'<'}website{'>'}"
|
||||
},
|
||||
"modals": {
|
||||
"nameDescription": "Edit the company's name. Used to match to new game imports.",
|
||||
@ -337,7 +343,16 @@
|
||||
"shortDeckDescription": "Edit the company's description. Doesn't affect long (markdown) description.",
|
||||
"shortDeckTitle": "Edit company description",
|
||||
"websiteDescription": "Edit the company's website. Note: this will be a link, and won't have redirect protection.",
|
||||
"websiteTitle": "Edit company website"
|
||||
"websiteTitle": "Edit company website",
|
||||
|
||||
"createTitle": "Create a company",
|
||||
"createDescription": "Create a company to further organize your games.",
|
||||
"createFieldName": "Company Name",
|
||||
"createFieldNamePlaceholder": "My New Company...",
|
||||
"createFieldDescription": "Company Description",
|
||||
"createFieldDescriptionPlaceholder": "A small indie studio that...",
|
||||
"createFieldWebsite": "Company Website",
|
||||
"createFieldWebsitePlaceholder": "https://example.com/"
|
||||
},
|
||||
"noCompanies": "No companies",
|
||||
"noGames": "No games",
|
||||
@ -499,7 +514,9 @@
|
||||
"images": "Game Images",
|
||||
"lookAt": "Check it out",
|
||||
"noDevelopers": "No developers",
|
||||
"noGame": "no game",
|
||||
"noGame": "NO GAME",
|
||||
"noFeatured": "NO FEATURED GAMES",
|
||||
"openFeatured": "Star games in Admin Library {arrow}",
|
||||
"noImages": "No images",
|
||||
"noPublishers": "No publishers.",
|
||||
"noTags": "No tags",
|
||||
|
||||
@ -138,6 +138,7 @@ export default defineNuxtConfig({
|
||||
|
||||
scheduledTasks: {
|
||||
"0 * * * *": ["dailyTasks"],
|
||||
"*/30 * * * *": ["downloadCleanup"],
|
||||
},
|
||||
|
||||
storage: {
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordapp/twemoji": "^16.0.1",
|
||||
"@drop-oss/droplet": "1.6.0",
|
||||
"@drop-oss/droplet": "2.3.0",
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@heroicons/vue": "^2.1.5",
|
||||
"@lobomfz/prismark": "0.0.3",
|
||||
|
||||
@ -30,7 +30,7 @@
|
||||
{{ company.mName }}
|
||||
<button @click="() => editName()">
|
||||
<PencilIcon
|
||||
class="transition duration-200 opacity-0 group-hover/name:opacity-100 size-8"
|
||||
class="transition duration-200 xl:opacity-0 group-hover/name:opacity-100 size-8"
|
||||
/>
|
||||
</button>
|
||||
</h1>
|
||||
@ -43,17 +43,20 @@
|
||||
}}
|
||||
<button @click="() => editShortDescription()">
|
||||
<PencilIcon
|
||||
class="transition duration-200 opacity-0 group-hover/description:opacity-100 size-5"
|
||||
class="transition duration-200 xl:opacity-0 group-hover/description:opacity-100 size-5"
|
||||
/>
|
||||
</button>
|
||||
</p>
|
||||
<p
|
||||
class="group/website mt-1 text-zinc-500 inline-flex items-center gap-x-3"
|
||||
>
|
||||
{{ company.mWebsite }}
|
||||
{{
|
||||
company.mWebsite ||
|
||||
$t("library.admin.metadata.companies.editor.websitePlaceholder")
|
||||
}}
|
||||
<button @click="() => editWebsite()">
|
||||
<PencilIcon
|
||||
class="transition duration-200 opacity-0 group-hover/website:opacity-100 size-4"
|
||||
class="transition duration-200 xl:opacity-0 group-hover/website:opacity-100 size-4"
|
||||
/>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
@ -10,20 +10,12 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
<NuxtLink
|
||||
to="/admin/library/sources"
|
||||
<button
|
||||
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
@click="() => (createCompanyOpen = true)"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="library.admin.sources.link"
|
||||
tag="span"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
{{ $t("common.create") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 grid grid-cols-1">
|
||||
@ -105,6 +97,10 @@
|
||||
{{ $t("library.admin.metadata.companies.noCompanies") }}
|
||||
</p>
|
||||
</ul>
|
||||
<ModalCreateCompany
|
||||
v-model="createCompanyOpen"
|
||||
@created="(company) => createCompany(company)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -122,9 +118,12 @@ useHead({
|
||||
title: t("library.admin.metadata.companies.title"),
|
||||
});
|
||||
|
||||
const createCompanyOpen = ref(false);
|
||||
|
||||
const searchQuery = ref("");
|
||||
|
||||
const companies = ref(await $dropFetch("/api/v1/admin/company"));
|
||||
const rawCompanies = await $dropFetch("/api/v1/admin/company");
|
||||
const companies = ref(rawCompanies);
|
||||
|
||||
const filteredCompanies = computed(() =>
|
||||
companies.value.filter((e: CompanyModel) => {
|
||||
@ -147,4 +146,8 @@ async function deleteCompany(id: string) {
|
||||
const index = companies.value.findIndex((e) => e.id === id);
|
||||
companies.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function createCompany(company: (typeof companies.value)[number]) {
|
||||
companies.value.push(company);
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -47,7 +47,7 @@
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 truncate text-sm text-zinc-400">
|
||||
{{ parseTaskLog(task.value.log.at(-1) ?? "").message }}
|
||||
{{ parseTaskLog(task.value.log.at(-1)).message }}
|
||||
</p>
|
||||
<NuxtLink
|
||||
type="button"
|
||||
@ -115,7 +115,7 @@
|
||||
{{ task.id }}
|
||||
</p>
|
||||
<p class="mt-1 truncate text-sm text-zinc-400">
|
||||
{{ parseTaskLog(task.log.at(-1) ?? "").message }}
|
||||
{{ parseTaskLog(task.log.at(-1)).message }}
|
||||
</p>
|
||||
<NuxtLink
|
||||
type="button"
|
||||
|
||||
@ -59,13 +59,30 @@
|
||||
</VueCarousel>
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full flex items-center justify-center bg-zinc-950/50 px-6 py-32 sm:px-12 sm:py-40 lg:px-16"
|
||||
class="w-full h-full flex flex-col items-center justify-center bg-zinc-950/50 px-6 py-32 sm:px-12 sm:py-40 lg:px-16 gap-4"
|
||||
>
|
||||
<h2
|
||||
class="uppercase text-xl font-bold tracking-tight text-zinc-700 sm:text-3xl"
|
||||
>
|
||||
{{ $t("store.noGame") }}
|
||||
{{ $t("store.noFeatured") }}
|
||||
</h2>
|
||||
<NuxtLink
|
||||
v-if="user?.admin"
|
||||
to="/admin/library"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 duration-200 hover:scale-105 active:scale-95"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="store.openFeatured"
|
||||
tag="span"
|
||||
scope="global"
|
||||
class="inline-flex items-center gap-x-1"
|
||||
>
|
||||
<template #arrow>
|
||||
<ArrowTopRightOnSquareIcon class="size-4" />
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<StoreView />
|
||||
@ -73,8 +90,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
const recent = await $dropFetch("/api/v1/store/featured");
|
||||
|
||||
const user = useUser();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
useHead({
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `Task` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
|
||||
*/
|
||||
-- DropIndex
|
||||
DROP INDEX "GameTag_name_idx";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Task" DROP CONSTRAINT "Task_pkey",
|
||||
ADD CONSTRAINT "Task_pkey" PRIMARY KEY ("id", "started");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));
|
||||
@ -1,5 +1,5 @@
|
||||
model Task {
|
||||
id String @id
|
||||
id String
|
||||
taskGroup String
|
||||
name String
|
||||
|
||||
@ -12,4 +12,6 @@ model Task {
|
||||
log String[]
|
||||
|
||||
acls String[]
|
||||
|
||||
@@id([id, started])
|
||||
}
|
||||
|
||||
47
server/api/v1/admin/company/index.post.ts
Normal file
47
server/api/v1/admin/company/index.post.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { type } from "arktype";
|
||||
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import * as jdenticon from "jdenticon";
|
||||
import { ObjectTransactionalHandler } from "~/server/internal/objects/transactional";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
import { MetadataSource } from "~/prisma/client/enums";
|
||||
|
||||
const CompanyCreate = type({
|
||||
name: "string",
|
||||
description: "string",
|
||||
website: "string",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["company:create"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const body = await readDropValidatedBody(h3, CompanyCreate);
|
||||
const obj = new ObjectTransactionalHandler();
|
||||
const [register, pull, _] = obj.new({}, ["internal:read"]);
|
||||
|
||||
const icon = jdenticon.toPng(body.name, 512);
|
||||
const logoId = register(icon);
|
||||
|
||||
const banner = jdenticon.toPng(body.description, 1024);
|
||||
const bannerId = register(banner);
|
||||
|
||||
const company = await prisma.company.create({
|
||||
data: {
|
||||
metadataSource: MetadataSource.Manual,
|
||||
metadataId: crypto.randomUUID(),
|
||||
metadataOriginalQuery: "",
|
||||
|
||||
mName: body.name,
|
||||
mShortDescription: body.description,
|
||||
mDescription: "",
|
||||
mLogoObjectId: logoId,
|
||||
mBannerObjectId: bannerId,
|
||||
mWebsite: body.website,
|
||||
},
|
||||
});
|
||||
|
||||
await pull();
|
||||
|
||||
return company;
|
||||
});
|
||||
@ -5,5 +5,6 @@ export default defineEventHandler((_h3) => {
|
||||
appName: "Drop",
|
||||
version: systemConfig.getDropVersion(),
|
||||
gitRef: `#${systemConfig.getGitRef()}`,
|
||||
external: systemConfig.getExternalUrl(),
|
||||
};
|
||||
});
|
||||
|
||||
86
server/api/v2/client/chunk.post.ts
Normal file
86
server/api/v2/client/chunk.post.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { type } from "arktype";
|
||||
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
|
||||
import contextManager from "~/server/internal/downloads/coordinator";
|
||||
import libraryManager from "~/server/internal/library";
|
||||
import { logger } from "~/server/internal/logging";
|
||||
|
||||
const GetChunk = type({
|
||||
context: "string",
|
||||
files: type({
|
||||
filename: "string",
|
||||
chunkIndex: "number",
|
||||
}).array(),
|
||||
}).configure(throwingArktype);
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const body = await readDropValidatedBody(h3, GetChunk);
|
||||
|
||||
const context = await contextManager.fetchContext(body.context);
|
||||
if (!context)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid download context.",
|
||||
});
|
||||
|
||||
const streamFiles = [];
|
||||
|
||||
for (const file of body.files) {
|
||||
const manifestFile = context.manifest[file.filename];
|
||||
if (!manifestFile)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: `Unknown file: ${file.filename}`,
|
||||
});
|
||||
|
||||
const start = manifestFile.lengths
|
||||
.slice(0, file.chunkIndex)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
const end = start + manifestFile.lengths[file.chunkIndex];
|
||||
|
||||
streamFiles.push({ filename: file.filename, start, end });
|
||||
}
|
||||
|
||||
setHeader(
|
||||
h3,
|
||||
"Content-Lengths",
|
||||
streamFiles.map((e) => e.end - e.start).join(","),
|
||||
); // Non-standard header, but we're cool like that 😎
|
||||
|
||||
for (const file of streamFiles) {
|
||||
const gameReadStream = await libraryManager.readFile(
|
||||
context.libraryId,
|
||||
context.libraryPath,
|
||||
context.versionName,
|
||||
file.filename,
|
||||
{ start: file.start, end: file.end },
|
||||
);
|
||||
if (!gameReadStream)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Failed to create read stream",
|
||||
});
|
||||
let length = 0;
|
||||
await gameReadStream.pipeTo(
|
||||
new WritableStream({
|
||||
write(chunk) {
|
||||
h3.node.res.write(chunk);
|
||||
length += chunk.length;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (length != file.end - file.start) {
|
||||
logger.warn(
|
||||
`failed to read enough from ${file.filename}. read ${length}, required: ${file.end - file.start}`,
|
||||
);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Failed to read enough from stream.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await h3.node.res.end();
|
||||
|
||||
return;
|
||||
});
|
||||
22
server/api/v2/client/context.post.ts
Normal file
22
server/api/v2/client/context.post.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { type } from "arktype";
|
||||
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
|
||||
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
|
||||
import contextManager from "~/server/internal/downloads/coordinator";
|
||||
|
||||
const CreateContext = type({
|
||||
game: "string",
|
||||
version: "string",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
export default defineClientEventHandler(async (h3) => {
|
||||
const body = await readDropValidatedBody(h3, CreateContext);
|
||||
|
||||
const context = await contextManager.createContext(body.game, body.version);
|
||||
if (!context)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid game or version",
|
||||
});
|
||||
|
||||
return { context };
|
||||
});
|
||||
@ -1,9 +1,68 @@
|
||||
/*
|
||||
The download co-ordinator's job is to keep track of all the currently online clients.
|
||||
import prisma from "../db/database";
|
||||
import type { DropManifest } from "./manifest";
|
||||
|
||||
When a client signs on and registers itself as a peer
|
||||
const TIMEOUT = 1000 * 60 * 60 * 1; // 1 hour
|
||||
|
||||
*/
|
||||
class DownloadContextManager {
|
||||
private contexts: Map<
|
||||
string,
|
||||
{
|
||||
timeout: Date;
|
||||
manifest: DropManifest;
|
||||
versionName: string;
|
||||
libraryId: string;
|
||||
libraryPath: string;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-extraneous-class, @typescript-eslint/no-unused-vars
|
||||
class DownloadCoordinator {}
|
||||
async createContext(game: string, versionName: string) {
|
||||
const version = await prisma.gameVersion.findUnique({
|
||||
where: {
|
||||
gameId_versionName: {
|
||||
gameId: game,
|
||||
versionName,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
game: {
|
||||
select: {
|
||||
libraryId: true,
|
||||
libraryPath: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!version) return undefined;
|
||||
|
||||
const contextId = crypto.randomUUID();
|
||||
this.contexts.set(contextId, {
|
||||
timeout: new Date(),
|
||||
manifest: JSON.parse(version.dropletManifest as string) as DropManifest,
|
||||
versionName,
|
||||
libraryId: version.game.libraryId!,
|
||||
libraryPath: version.game.libraryPath,
|
||||
});
|
||||
|
||||
return contextId;
|
||||
}
|
||||
|
||||
async fetchContext(contextId: string) {
|
||||
const context = this.contexts.get(contextId);
|
||||
if (!context) return undefined;
|
||||
context.timeout = new Date();
|
||||
this.contexts.set(contextId, context);
|
||||
return context;
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
for (const key of this.contexts.keys()) {
|
||||
const context = this.contexts.get(key)!;
|
||||
if (context.timeout.getDate() + TIMEOUT < Date.now()) {
|
||||
this.contexts.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const contextManager = new DownloadContextManager();
|
||||
export default contextManager;
|
||||
|
||||
@ -5,7 +5,7 @@ export type DropChunk = {
|
||||
permissions: number;
|
||||
ids: string[];
|
||||
checksums: string[];
|
||||
lengths: string[];
|
||||
lengths: number[];
|
||||
};
|
||||
|
||||
export type DropManifest = {
|
||||
|
||||
@ -13,13 +13,19 @@ import { parsePlatform } from "../utils/parseplatform";
|
||||
import notificationSystem from "../notifications";
|
||||
import { GameNotFoundError, type LibraryProvider } from "./provider";
|
||||
import { logger } from "../logging";
|
||||
import type { GameModel } from "~/prisma/client/models";
|
||||
|
||||
export function createGameImportTaskId(libraryId: string, libraryPath: string) {
|
||||
return btoa(`import:${libraryId}:${libraryPath}`);
|
||||
}
|
||||
|
||||
export function createVersionImportTaskId(gameId: string, versionName: string) {
|
||||
return btoa(`import:${gameId}:${versionName}`);
|
||||
}
|
||||
|
||||
class LibraryManager {
|
||||
private libraries: Map<string, LibraryProvider<unknown>> = new Map();
|
||||
|
||||
private gameImportLocks: Map<string, Array<string>> = new Map(); // Library ID to Library Path
|
||||
private versionImportLocks: Map<string, Array<string>> = new Map(); // Game ID to Version Name
|
||||
|
||||
addLibrary(library: LibraryProvider<unknown>) {
|
||||
this.libraries.set(library.id(), library);
|
||||
}
|
||||
@ -37,24 +43,30 @@ class LibraryManager {
|
||||
return libraryWithMetadata;
|
||||
}
|
||||
|
||||
async fetchGamesByLibrary() {
|
||||
const results: { [key: string]: { [key: string]: GameModel } } = {};
|
||||
const games = await prisma.game.findMany({});
|
||||
for (const game of games) {
|
||||
const libraryId = game.libraryId!;
|
||||
const libraryPath = game.libraryPath!;
|
||||
|
||||
results[libraryId] ??= {};
|
||||
results[libraryId][libraryPath] = game;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async fetchUnimportedGames() {
|
||||
const unimportedGames: { [key: string]: string[] } = {};
|
||||
const instanceGames = await this.fetchGamesByLibrary();
|
||||
|
||||
for (const [id, library] of this.libraries.entries()) {
|
||||
const games = await library.listGames();
|
||||
const validGames = await prisma.game.findMany({
|
||||
where: {
|
||||
libraryId: id,
|
||||
libraryPath: { in: games },
|
||||
},
|
||||
select: {
|
||||
libraryPath: true,
|
||||
},
|
||||
});
|
||||
const providerUnimportedGames = games.filter(
|
||||
(e) =>
|
||||
validGames.findIndex((v) => v.libraryPath == e) == -1 &&
|
||||
!(this.gameImportLocks.get(id) ?? []).includes(e),
|
||||
const providerGames = await library.listGames();
|
||||
const providerUnimportedGames = providerGames.filter(
|
||||
(libraryPath) =>
|
||||
!instanceGames[id]?.[libraryPath] &&
|
||||
!taskHandler.hasTask(createGameImportTaskId(id, libraryPath)),
|
||||
);
|
||||
unimportedGames[id] = providerUnimportedGames;
|
||||
}
|
||||
@ -84,7 +96,7 @@ class LibraryManager {
|
||||
const unimportedVersions = versions.filter(
|
||||
(e) =>
|
||||
game.versions.findIndex((v) => v.versionName == e) == -1 &&
|
||||
!(this.versionImportLocks.get(game.id) ?? []).includes(e),
|
||||
!taskHandler.hasTask(createVersionImportTaskId(game.id, e)),
|
||||
);
|
||||
return unimportedVersions;
|
||||
} catch (e) {
|
||||
@ -168,7 +180,8 @@ class LibraryManager {
|
||||
for (const filename of files) {
|
||||
const basename = path.basename(filename);
|
||||
const dotLocation = filename.lastIndexOf(".");
|
||||
const ext = dotLocation == -1 ? "" : filename.slice(dotLocation);
|
||||
const ext =
|
||||
dotLocation == -1 ? "" : filename.slice(dotLocation).toLowerCase();
|
||||
for (const [platform, checkExts] of Object.entries(fileExts)) {
|
||||
for (const checkExt of checkExts) {
|
||||
if (checkExt != ext) continue;
|
||||
@ -206,70 +219,6 @@ class LibraryManager {
|
||||
}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Locks the game so you can't be imported
|
||||
* @param libraryId
|
||||
* @param libraryPath
|
||||
*/
|
||||
async lockGame(libraryId: string, libraryPath: string) {
|
||||
let games = this.gameImportLocks.get(libraryId);
|
||||
if (!games) this.gameImportLocks.set(libraryId, (games = []));
|
||||
|
||||
if (!games.includes(libraryPath)) games.push(libraryPath);
|
||||
|
||||
this.gameImportLocks.set(libraryId, games);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlocks the game, call once imported
|
||||
* @param libraryId
|
||||
* @param libraryPath
|
||||
*/
|
||||
async unlockGame(libraryId: string, libraryPath: string) {
|
||||
let games = this.gameImportLocks.get(libraryId);
|
||||
if (!games) this.gameImportLocks.set(libraryId, (games = []));
|
||||
|
||||
if (games.includes(libraryPath))
|
||||
games.splice(
|
||||
games.findIndex((e) => e === libraryPath),
|
||||
1,
|
||||
);
|
||||
|
||||
this.gameImportLocks.set(libraryId, games);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locks a version so it can't be imported
|
||||
* @param gameId
|
||||
* @param versionName
|
||||
*/
|
||||
async lockVersion(gameId: string, versionName: string) {
|
||||
let versions = this.versionImportLocks.get(gameId);
|
||||
if (!versions) this.versionImportLocks.set(gameId, (versions = []));
|
||||
|
||||
if (!versions.includes(versionName)) versions.push(versionName);
|
||||
|
||||
this.versionImportLocks.set(gameId, versions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlocks the version, call once imported
|
||||
* @param libraryId
|
||||
* @param libraryPath
|
||||
*/
|
||||
async unlockVersion(gameId: string, versionName: string) {
|
||||
let versions = this.versionImportLocks.get(gameId);
|
||||
if (!versions) this.versionImportLocks.set(gameId, (versions = []));
|
||||
|
||||
if (versions.includes(gameId))
|
||||
versions.splice(
|
||||
versions.findIndex((e) => e === versionName),
|
||||
1,
|
||||
);
|
||||
|
||||
this.versionImportLocks.set(gameId, versions);
|
||||
}
|
||||
|
||||
async importVersion(
|
||||
gameId: string,
|
||||
versionName: string,
|
||||
@ -286,7 +235,7 @@ class LibraryManager {
|
||||
umuId: string;
|
||||
},
|
||||
) {
|
||||
const taskId = `import:${gameId}:${versionName}`;
|
||||
const taskId = createVersionImportTaskId(gameId, versionName);
|
||||
|
||||
const platform = parsePlatform(metadata.platform);
|
||||
if (!platform) return undefined;
|
||||
@ -300,8 +249,6 @@ class LibraryManager {
|
||||
const library = this.libraries.get(game.libraryId);
|
||||
if (!library) return undefined;
|
||||
|
||||
await this.lockVersion(gameId, versionName);
|
||||
|
||||
taskHandler.create({
|
||||
id: taskId,
|
||||
taskGroup: "import:game",
|
||||
@ -378,9 +325,6 @@ class LibraryManager {
|
||||
|
||||
progress(100);
|
||||
},
|
||||
async finally() {
|
||||
await libraryManager.unlockVersion(gameId, versionName);
|
||||
},
|
||||
});
|
||||
|
||||
return taskId;
|
||||
@ -394,7 +338,7 @@ class LibraryManager {
|
||||
) {
|
||||
const library = this.libraries.get(libraryId);
|
||||
if (!library) return undefined;
|
||||
return library.peekFile(game, version, filename);
|
||||
return await library.peekFile(game, version, filename);
|
||||
}
|
||||
|
||||
async readFile(
|
||||
@ -406,7 +350,7 @@ class LibraryManager {
|
||||
) {
|
||||
const library = this.libraries.get(libraryId);
|
||||
if (!library) return undefined;
|
||||
return library.readFile(game, version, filename, options);
|
||||
return await library.readFile(game, version, filename, options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -7,12 +7,14 @@ import {
|
||||
import { LibraryBackend } from "~/prisma/client/enums";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import droplet from "@drop-oss/droplet";
|
||||
import droplet, { DropletHandler } from "@drop-oss/droplet";
|
||||
|
||||
export const FilesystemProviderConfig = type({
|
||||
baseDir: "string",
|
||||
});
|
||||
|
||||
export const DROPLET_HANDLER = new DropletHandler();
|
||||
|
||||
export class FilesystemProvider
|
||||
implements LibraryProvider<typeof FilesystemProviderConfig.infer>
|
||||
{
|
||||
@ -57,7 +59,7 @@ export class FilesystemProvider
|
||||
const versionDirs = fs.readdirSync(gameDir);
|
||||
const validVersionDirs = versionDirs.filter((e) => {
|
||||
const fullDir = path.join(this.config.baseDir, game, e);
|
||||
return droplet.hasBackendForPath(fullDir);
|
||||
return DROPLET_HANDLER.hasBackendForPath(fullDir);
|
||||
});
|
||||
return validVersionDirs;
|
||||
}
|
||||
@ -65,7 +67,7 @@ export class FilesystemProvider
|
||||
async versionReaddir(game: string, version: string): Promise<string[]> {
|
||||
const versionDir = path.join(this.config.baseDir, game, version);
|
||||
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
|
||||
return droplet.listFiles(versionDir);
|
||||
return DROPLET_HANDLER.listFiles(versionDir);
|
||||
}
|
||||
|
||||
async generateDropletManifest(
|
||||
@ -77,10 +79,16 @@ export class FilesystemProvider
|
||||
const versionDir = path.join(this.config.baseDir, game, version);
|
||||
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
|
||||
const manifest = await new Promise<string>((r, j) =>
|
||||
droplet.generateManifest(versionDir, progress, log, (err, result) => {
|
||||
if (err) return j(err);
|
||||
r(result);
|
||||
}),
|
||||
droplet.generateManifest(
|
||||
DROPLET_HANDLER,
|
||||
versionDir,
|
||||
progress,
|
||||
log,
|
||||
(err, result) => {
|
||||
if (err) return j(err);
|
||||
r(result);
|
||||
},
|
||||
),
|
||||
);
|
||||
return manifest;
|
||||
}
|
||||
@ -88,7 +96,7 @@ export class FilesystemProvider
|
||||
async peekFile(game: string, version: string, filename: string) {
|
||||
const filepath = path.join(this.config.baseDir, game, version);
|
||||
if (!fs.existsSync(filepath)) return undefined;
|
||||
const stat = droplet.peekFile(filepath, filename);
|
||||
const stat = DROPLET_HANDLER.peekFile(filepath, filename);
|
||||
return { size: Number(stat) };
|
||||
}
|
||||
|
||||
@ -100,13 +108,17 @@ export class FilesystemProvider
|
||||
) {
|
||||
const filepath = path.join(this.config.baseDir, game, version);
|
||||
if (!fs.existsSync(filepath)) return undefined;
|
||||
const stream = droplet.readFile(
|
||||
filepath,
|
||||
filename,
|
||||
options?.start ? BigInt(options.start) : undefined,
|
||||
options?.end ? BigInt(options.end) : undefined,
|
||||
);
|
||||
if (!stream) return undefined;
|
||||
let stream;
|
||||
while (!(stream instanceof ReadableStream)) {
|
||||
const v = DROPLET_HANDLER.readFile(
|
||||
filepath,
|
||||
filename,
|
||||
options?.start ? BigInt(options.start) : undefined,
|
||||
options?.end ? BigInt(options.end) : undefined,
|
||||
);
|
||||
if (!v) return undefined;
|
||||
stream = v.getStream() as ReadableStream<unknown>;
|
||||
}
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import { LibraryBackend } from "~/prisma/client/enums";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import droplet from "@drop-oss/droplet";
|
||||
import { DROPLET_HANDLER } from "./filesystem";
|
||||
|
||||
export const FlatFilesystemProviderConfig = type({
|
||||
baseDir: "string",
|
||||
@ -46,7 +47,7 @@ export class FlatFilesystemProvider
|
||||
const versionDirs = fs.readdirSync(this.config.baseDir);
|
||||
const validVersionDirs = versionDirs.filter((e) => {
|
||||
const fullDir = path.join(this.config.baseDir, e);
|
||||
return droplet.hasBackendForPath(fullDir);
|
||||
return DROPLET_HANDLER.hasBackendForPath(fullDir);
|
||||
});
|
||||
return validVersionDirs;
|
||||
}
|
||||
@ -63,7 +64,7 @@ export class FlatFilesystemProvider
|
||||
async versionReaddir(game: string, _version: string) {
|
||||
const versionDir = path.join(this.config.baseDir, game);
|
||||
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
|
||||
return droplet.listFiles(versionDir);
|
||||
return DROPLET_HANDLER.listFiles(versionDir);
|
||||
}
|
||||
|
||||
async generateDropletManifest(
|
||||
@ -75,17 +76,23 @@ export class FlatFilesystemProvider
|
||||
const versionDir = path.join(this.config.baseDir, game);
|
||||
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
|
||||
const manifest = await new Promise<string>((r, j) =>
|
||||
droplet.generateManifest(versionDir, progress, log, (err, result) => {
|
||||
if (err) return j(err);
|
||||
r(result);
|
||||
}),
|
||||
droplet.generateManifest(
|
||||
DROPLET_HANDLER,
|
||||
versionDir,
|
||||
progress,
|
||||
log,
|
||||
(err, result) => {
|
||||
if (err) return j(err);
|
||||
r(result);
|
||||
},
|
||||
),
|
||||
);
|
||||
return manifest;
|
||||
}
|
||||
async peekFile(game: string, _version: string, filename: string) {
|
||||
const filepath = path.join(this.config.baseDir, game);
|
||||
if (!fs.existsSync(filepath)) return undefined;
|
||||
const stat = droplet.peekFile(filepath, filename);
|
||||
const stat = DROPLET_HANDLER.peekFile(filepath, filename);
|
||||
return { size: Number(stat) };
|
||||
}
|
||||
async readFile(
|
||||
@ -96,7 +103,7 @@ export class FlatFilesystemProvider
|
||||
) {
|
||||
const filepath = path.join(this.config.baseDir, game);
|
||||
if (!fs.existsSync(filepath)) return undefined;
|
||||
const stream = droplet.readFile(
|
||||
const stream = DROPLET_HANDLER.readFile(
|
||||
filepath,
|
||||
filename,
|
||||
options?.start ? BigInt(options.start) : undefined,
|
||||
@ -104,6 +111,6 @@ export class FlatFilesystemProvider
|
||||
);
|
||||
if (!stream) return undefined;
|
||||
|
||||
return stream;
|
||||
return stream.getStream();
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@ import taskHandler, { wrapTaskContext } from "../tasks";
|
||||
import { randomUUID } from "crypto";
|
||||
import { fuzzy } from "fast-fuzzy";
|
||||
import { logger } from "~/server/internal/logging";
|
||||
import libraryManager from "../library";
|
||||
import { createGameImportTaskId } from "../library";
|
||||
import type { GameTagModel } from "~/prisma/client/models";
|
||||
|
||||
export class MissingMetadataProviderConfig extends Error {
|
||||
@ -185,11 +185,9 @@ export class MetadataHandler {
|
||||
});
|
||||
if (existing) return undefined;
|
||||
|
||||
await libraryManager.lockGame(libraryId, libraryPath);
|
||||
|
||||
const gameId = randomUUID();
|
||||
|
||||
const taskId = `import:${gameId}`;
|
||||
const taskId = createGameImportTaskId(libraryId, libraryPath);
|
||||
await taskHandler.create({
|
||||
name: `Import game "${result.name}" (${libraryPath})`,
|
||||
id: taskId,
|
||||
@ -280,9 +278,6 @@ export class MetadataHandler {
|
||||
logger.info(`Finished game import.`);
|
||||
progress(100);
|
||||
},
|
||||
async finally() {
|
||||
await libraryManager.unlockGame(libraryId, libraryPath);
|
||||
},
|
||||
});
|
||||
|
||||
return taskId;
|
||||
|
||||
@ -73,6 +73,8 @@ class TaskHandler {
|
||||
}
|
||||
|
||||
async create(task: Task) {
|
||||
if (this.hasTask(task.id)) throw new Error("Task with ID already exists.");
|
||||
|
||||
let updateCollectTimeout: NodeJS.Timeout | undefined;
|
||||
let updateCollectResolves: Array<(value: unknown) => void> = [];
|
||||
let logOffset: number = 0;
|
||||
@ -206,8 +208,6 @@ class TaskHandler {
|
||||
};
|
||||
}
|
||||
|
||||
if (task.finally) await task.finally();
|
||||
|
||||
taskEntry.endTime = new Date().toISOString();
|
||||
await updateAllClients();
|
||||
|
||||
@ -247,7 +247,10 @@ class TaskHandler {
|
||||
) {
|
||||
const task =
|
||||
this.taskPool.get(taskId) ??
|
||||
(await prisma.task.findUnique({ where: { id: taskId } }));
|
||||
(await prisma.task.findFirst({
|
||||
where: { id: taskId },
|
||||
orderBy: { started: "desc" },
|
||||
}));
|
||||
if (!task) {
|
||||
peer.send(
|
||||
`error/${taskId}/Unknown task/Drop couldn't find the task you're looking for.`,
|
||||
@ -324,6 +327,10 @@ class TaskHandler {
|
||||
.toArray();
|
||||
}
|
||||
|
||||
hasTask(id: string) {
|
||||
return this.taskPool.has(id);
|
||||
}
|
||||
|
||||
dailyTasks() {
|
||||
return this.dailyScheduledTasks;
|
||||
}
|
||||
@ -429,7 +436,6 @@ export interface Task {
|
||||
taskGroup: TaskGroup;
|
||||
name: string;
|
||||
run: (context: TaskRunContext) => Promise<void>;
|
||||
finally?: () => Promise<void> | void;
|
||||
acls: GlobalACL[];
|
||||
}
|
||||
|
||||
|
||||
3
server/middleware/latency.ts
Normal file
3
server/middleware/latency.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export default defineEventHandler(async () => {
|
||||
// await new Promise((r) => setTimeout(r, 700));
|
||||
});
|
||||
11
server/tasks/downloadCleanup.ts
Normal file
11
server/tasks/downloadCleanup.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import contextManager from "../internal/downloads/coordinator";
|
||||
|
||||
export default defineTask({
|
||||
meta: {
|
||||
name: "downloadCleanup",
|
||||
},
|
||||
async run() {
|
||||
await contextManager.cleanup();
|
||||
return { result: true };
|
||||
},
|
||||
});
|
||||
@ -1,6 +1,9 @@
|
||||
import type { TaskLog } from "~/server/internal/tasks";
|
||||
|
||||
export function parseTaskLog(logStr: string): typeof TaskLog.infer {
|
||||
export function parseTaskLog(
|
||||
logStr?: string | undefined,
|
||||
): typeof TaskLog.infer {
|
||||
if (!logStr) return { message: "", timestamp: "" };
|
||||
const log = JSON.parse(logStr);
|
||||
|
||||
return {
|
||||
|
||||
114
yarn.lock
114
yarn.lock
@ -342,71 +342,71 @@
|
||||
jsonfile "^5.0.0"
|
||||
universalify "^0.1.2"
|
||||
|
||||
"@drop-oss/droplet-darwin-arm64@1.6.0":
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-arm64/-/droplet-darwin-arm64-1.6.0.tgz#9697e38c46b02192e8e180b7deaaa20a389a9b0d"
|
||||
integrity sha512-EqTx+Mk5SHP17n19r5coacUDd7lklT4opJ2keNQyGsQjrcf+9FeCX1O5Y+PGIjpQK6UkAVdnBqM+jR7NeFmkAQ==
|
||||
"@drop-oss/droplet-darwin-arm64@2.3.0":
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-arm64/-/droplet-darwin-arm64-2.3.0.tgz#f4f0ded9c9f5b5cac25dd56f59817e1c13e865ab"
|
||||
integrity sha512-5k1VwGZTFc61FvKyL4cvYxFYB7aCY5cWCo0Q7yTkkj+KR+ewH6ucylU8kDG7M+aBLvbC/zbntXUp4RtYZi4AZQ==
|
||||
|
||||
"@drop-oss/droplet-darwin-universal@1.6.0":
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-universal/-/droplet-darwin-universal-1.6.0.tgz#2f780416052ac7d1752b0a7828dc3ef9d1789c92"
|
||||
integrity sha512-TxVpoVDI9aGuBCHA8HktbrIkS/C1gu5laM5+ZbIZkXnIUpTicJIbHRyneXJ4MLnW703gUbW8LTISgm7xKwZJsg==
|
||||
"@drop-oss/droplet-darwin-universal@2.3.0":
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-universal/-/droplet-darwin-universal-2.3.0.tgz#1d8659bc2869e5d30308622bcc6cb230030d738e"
|
||||
integrity sha512-4V/HMnNtmHgn156pTpa3mVTAwTmO9jqtZrDcVko7PdSotEbXiwBpTFzbgb4bPafbPmkSNoRh4G9d3BLQCh4mgw==
|
||||
|
||||
"@drop-oss/droplet-darwin-x64@1.6.0":
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-x64/-/droplet-darwin-x64-1.6.0.tgz#5d6a3c596eca706e40b35cdf49ada65e59c51b8d"
|
||||
integrity sha512-V/1xh4s16AmesDOEHiQ4vj9XQq6AWmXRY5RQf4RKBQqkxsHzmQoa37CTLK25Wf9OUoiJFGpnjViqKOFG4y5Q+g==
|
||||
"@drop-oss/droplet-darwin-x64@2.3.0":
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-x64/-/droplet-darwin-x64-2.3.0.tgz#c7ff5dae8ba520866b7cd49714625ada8fa0a7c2"
|
||||
integrity sha512-PUcNjE09N7qEFsbssKxL8rjmCt9AUYPz1yK34d8N2W9DboS1KI+PShWdd/NOk4GYzTJQuJhMp8wNcUrljfqXmQ==
|
||||
|
||||
"@drop-oss/droplet-linux-arm64-gnu@1.6.0":
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-gnu/-/droplet-linux-arm64-gnu-1.6.0.tgz#265d5e7854c4c61081b8fd74b3e8305ea2c7b5ac"
|
||||
integrity sha512-WjaRl9VW0qE+YkOCaYuNIXzyBbps2lopbpeXELZ9/f/1jBfzfmIe4m6C2hMy4NWUcWnrBbiVTEjnq2cHj/TaBA==
|
||||
"@drop-oss/droplet-linux-arm64-gnu@2.3.0":
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-gnu/-/droplet-linux-arm64-gnu-2.3.0.tgz#8819b34c5ff8bd8182c5cd0c3f1784dc2afd9507"
|
||||
integrity sha512-6VyOwYu9sMrCL82UZOvvjU9G/4wHdA8P6q3+EDIVdABg5jVEYZsxkrT0Kp/5h9Xs0mPFNB/su8ZwB9FRQ63o1w==
|
||||
|
||||
"@drop-oss/droplet-linux-arm64-musl@1.6.0":
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-musl/-/droplet-linux-arm64-musl-1.6.0.tgz#7126e194e5ef9018d61ef7dd0cc3af80734e00e2"
|
||||
integrity sha512-B8KoBYk0YVUZIL+etCcOc99NuoBcTm6KDOIQkN9SHWC4YLRu8um3w8DHzv4VV3arUnEGjyDHuraaOSONfP6NqA==
|
||||
"@drop-oss/droplet-linux-arm64-musl@2.3.0":
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-musl/-/droplet-linux-arm64-musl-2.3.0.tgz#06601aa8af4bffeb26956ff79ed494265e313342"
|
||||
integrity sha512-2BZreAg1XOBxr+iY2hFcX4x6bFC7AKXkIHa9130rmStH/HxnGq6K5H49eJd6ezzNMH/lQ7Sm7uJP2+sH8mjeCw==
|
||||
|
||||
"@drop-oss/droplet-linux-riscv64-gnu@1.6.0":
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-riscv64-gnu/-/droplet-linux-riscv64-gnu-1.6.0.tgz#40d060eafaca08b47a468950d7dc5ec4f1fb2a5a"
|
||||
integrity sha512-nbNr/38EX8Mjj20+paohlOD35apmaNKZan4OO97KOwvq5oZ/pXbkjOGC0zkpsizyxbwKx7Jl4Se7teRVPWWVWw==
|
||||
"@drop-oss/droplet-linux-riscv64-gnu@2.3.0":
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-riscv64-gnu/-/droplet-linux-riscv64-gnu-2.3.0.tgz#6d5629631aeeceadb292998e21b6e2b2cf839bdc"
|
||||
integrity sha512-E7i86Q8IU7rh2FVtXa0NxoGRhB7AZU+AWPumTAuDQS3xPg3sj+c3q/A7YI1Ay4lnvzR/fevP2p/7iSJUEDcchQ==
|
||||
|
||||
"@drop-oss/droplet-linux-x64-gnu@1.6.0":
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-gnu/-/droplet-linux-x64-gnu-1.6.0.tgz#c3a8408644194e59ac2110229e9a99885b3bc533"
|
||||
integrity sha512-n/zA1ftqGey5yQK/1HiCok3MaLA4stVTzQEuRUzyq8BQ1BC6TmKCgdFnI4Q3tuGm3/Mz2CCbfbHY4bYwND9qOQ==
|
||||
"@drop-oss/droplet-linux-x64-gnu@2.3.0":
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-gnu/-/droplet-linux-x64-gnu-2.3.0.tgz#a924aada38dbc54f6967b17435c10bf3b6e6ffb0"
|
||||
integrity sha512-eIHhhoSgpvMAc9tn/K0ldZRXvDe1Xyd9okSSqaclCEKjdVfWU8UMycUz1SzQH9YefiqEB4Qjd3y1iRgaEa8niA==
|
||||
|
||||
"@drop-oss/droplet-linux-x64-musl@1.6.0":
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-musl/-/droplet-linux-x64-musl-1.6.0.tgz#206b5c85b02b7fdf53bc5f0cdf68a9d9a7d501cd"
|
||||
integrity sha512-egZWqKK1+vHoVKNuMle2Kn8WbbJ7Y9WJScUNXjF8hdUDNo9eHwJT/DfnA+BhvFQuJXkU58vwv6MqZ5VLdOsGiA==
|
||||
"@drop-oss/droplet-linux-x64-musl@2.3.0":
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-musl/-/droplet-linux-x64-musl-2.3.0.tgz#4eb71112f7641e1fad3b53f5f8d1b98b9cb84bf0"
|
||||
integrity sha512-0taR945NvK+xNBicSYriKDJgBxpcozzgcALDp/cX2UaYV9cb5PF/xw80DArCyUDvKOfRzeFALx4KRC2ghPr6tw==
|
||||
|
||||
"@drop-oss/droplet-win32-arm64-msvc@1.6.0":
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-arm64-msvc/-/droplet-win32-arm64-msvc-1.6.0.tgz#fbb0387536f5b2a88f03877d730f7f863646ce08"
|
||||
integrity sha512-AwGYHae8ZmQV2QGp+3B0DhsBdYynrZ4AS1xNc+U1tXt5CiMp9wLLM/4a+WySYHX7XrEo8pKmRRa0I8QdAdxk5A==
|
||||
"@drop-oss/droplet-win32-arm64-msvc@2.3.0":
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-arm64-msvc/-/droplet-win32-arm64-msvc-2.3.0.tgz#36568f87024eb48ce7e82d76ea83a2c6ec25a856"
|
||||
integrity sha512-5HkO98h/PboM+/wPulKVGFTklijlqht8w13iW1ipUcRFsOHmS1o8nejjLL7KEr2X8G4JwYOqBeX8tY3OhaU9bw==
|
||||
|
||||
"@drop-oss/droplet-win32-x64-msvc@1.6.0":
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-x64-msvc/-/droplet-win32-x64-msvc-1.6.0.tgz#600058775641b4c5c051291e5a13135aa1ae28bb"
|
||||
integrity sha512-Viz+J87rF7I++nLpPBvdhsjUQAHivA6wSHrBXa+4MwIymUvlQXcvNReFqzObRH4eiuiY4e3s3t9X7+paqd847Q==
|
||||
"@drop-oss/droplet-win32-x64-msvc@2.3.0":
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-x64-msvc/-/droplet-win32-x64-msvc-2.3.0.tgz#e794ea7cfdc0ea148707e4f3e60f2aa547328c03"
|
||||
integrity sha512-6lNXOMyy9sPaO4wbklOIr2jbuvZHIVrd+dXu2UOI2YqFlHdxiDD1sZnqSZmlfCP58yeA+SpTfhxDHwUHJTFI/g==
|
||||
|
||||
"@drop-oss/droplet@1.6.0":
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet/-/droplet-1.6.0.tgz#b6aa382dc5df494c4233a2bd8f19721878edad71"
|
||||
integrity sha512-nTZvLo+GFLlpxgFlObP4zitVctz02bRD3ZSVDiMv7jXxYK0V/GktITJFcKK0J87ZRxneoFHYbLs1lH3MFYoSIw==
|
||||
"@drop-oss/droplet@2.3.0":
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet/-/droplet-2.3.0.tgz#eb2891346cf7fadcc847d5dee37674fc1106d2fc"
|
||||
integrity sha512-ffEoS3LYBfPm0++p7f7F/NkYH5PfauQzuj1gTz7qVWZOSP5VQWYhOc9BEg0fsCCzTB/mct0jwOsK92URmthpxA==
|
||||
optionalDependencies:
|
||||
"@drop-oss/droplet-darwin-arm64" "1.6.0"
|
||||
"@drop-oss/droplet-darwin-universal" "1.6.0"
|
||||
"@drop-oss/droplet-darwin-x64" "1.6.0"
|
||||
"@drop-oss/droplet-linux-arm64-gnu" "1.6.0"
|
||||
"@drop-oss/droplet-linux-arm64-musl" "1.6.0"
|
||||
"@drop-oss/droplet-linux-riscv64-gnu" "1.6.0"
|
||||
"@drop-oss/droplet-linux-x64-gnu" "1.6.0"
|
||||
"@drop-oss/droplet-linux-x64-musl" "1.6.0"
|
||||
"@drop-oss/droplet-win32-arm64-msvc" "1.6.0"
|
||||
"@drop-oss/droplet-win32-x64-msvc" "1.6.0"
|
||||
"@drop-oss/droplet-darwin-arm64" "2.3.0"
|
||||
"@drop-oss/droplet-darwin-universal" "2.3.0"
|
||||
"@drop-oss/droplet-darwin-x64" "2.3.0"
|
||||
"@drop-oss/droplet-linux-arm64-gnu" "2.3.0"
|
||||
"@drop-oss/droplet-linux-arm64-musl" "2.3.0"
|
||||
"@drop-oss/droplet-linux-riscv64-gnu" "2.3.0"
|
||||
"@drop-oss/droplet-linux-x64-gnu" "2.3.0"
|
||||
"@drop-oss/droplet-linux-x64-musl" "2.3.0"
|
||||
"@drop-oss/droplet-win32-arm64-msvc" "2.3.0"
|
||||
"@drop-oss/droplet-win32-x64-msvc" "2.3.0"
|
||||
|
||||
"@emnapi/core@^1.4.3":
|
||||
version "1.4.5"
|
||||
@ -8591,9 +8591,9 @@ tmp-promise@^3.0.2:
|
||||
tmp "^0.2.0"
|
||||
|
||||
tmp@^0.2.0:
|
||||
version "0.2.3"
|
||||
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae"
|
||||
integrity sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==
|
||||
version "0.2.4"
|
||||
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.4.tgz#c6db987a2ccc97f812f17137b36af2b6521b0d13"
|
||||
integrity sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==
|
||||
|
||||
to-regex-range@^5.0.1:
|
||||
version "5.0.1"
|
||||
|
||||
Reference in New Issue
Block a user