diff --git a/pages/admin/library/sources/index.vue b/pages/admin/library/sources/index.vue
new file mode 100644
index 0000000..2a37797
--- /dev/null
+++ b/pages/admin/library/sources/index.vue
@@ -0,0 +1,379 @@
+
+
+
+
+
Library Sources
+
+ Configure your library sources, where Drop will look for new games and
+ versions to import.
+
+
+
+ (actionSourceOpen = true)"
+ >
+ Create
+
+
+
+
+
+
+
+
+
+
+ Name
+
+
+ Type
+
+
+ Working?
+
+
+ Options
+
+
+ Edit
+
+
+
+
+
+
+ {{ source.name }}
+
+
+ {{ source.backend }}
+
+
+
+
+
+
+ {{ source.options }}
+
+
+ edit(sourceIdx)"
+ >
+ Edit, {{ source.name }}
+
+
+ deleteSource(sourceIdx)"
+ >
+ Delete, {{ source.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Create source
+
+
+ Drop will use this source to access your game library, and make them
+ available.
+
+
+
+
+
+
+
+
+
+
+
+ {{ modalError }}
+
+
+
+
+
+
+
+ performActionSource_wrapper()"
+ >
+ {{ createMode ? "Create" : "Save" }}
+
+ {
+ editIndex = undefined;
+ close();
+ }
+ "
+ >
+ Cancel
+
+
+
+
+
+
+
diff --git a/server/api/v1/admin/library/sources/index.delete.ts b/server/api/v1/admin/library/sources/index.delete.ts
new file mode 100644
index 0000000..eaef699
--- /dev/null
+++ b/server/api/v1/admin/library/sources/index.delete.ts
@@ -0,0 +1,25 @@
+import { type } from "arktype";
+import { throwingArktype } from "~/server/arktype";
+import aclManager from "~/server/internal/acls";
+import prisma from "~/server/internal/db/database";
+
+const DeleteLibrarySource = type({
+ id: "string",
+}).configure(throwingArktype);
+
+export default defineEventHandler<{ body: typeof DeleteLibrarySource.infer }>(
+ async (h3) => {
+ const allowed = await aclManager.allowSystemACL(h3, [
+ "library:sources:delete",
+ ]);
+ if (!allowed) throw createError({ statusCode: 403 });
+
+ const body = await readValidatedBody(h3, DeleteLibrarySource);
+
+ return await prisma.library.delete({
+ where: {
+ id: body.id,
+ },
+ });
+ },
+);
diff --git a/server/api/v1/admin/library/sources/index.get.ts b/server/api/v1/admin/library/sources/index.get.ts
new file mode 100644
index 0000000..b7d5c9d
--- /dev/null
+++ b/server/api/v1/admin/library/sources/index.get.ts
@@ -0,0 +1,16 @@
+import type { Library } from "~/prisma/client";
+import aclManager from "~/server/internal/acls";
+import libraryManager from "~/server/internal/library";
+
+export type WorkingLibrarySource = Library & { working: boolean };
+
+export default defineEventHandler(async (h3) => {
+ const allowed = await aclManager.allowSystemACL(h3, ["library:sources:read"]);
+ if (!allowed) throw createError({ statusCode: 403 });
+
+ const sources = await libraryManager.fetchLibraries();
+
+ // Fetch other library data here
+
+ return sources;
+});
diff --git a/server/api/v1/admin/library/sources/index.patch.ts b/server/api/v1/admin/library/sources/index.patch.ts
new file mode 100644
index 0000000..43dbeb5
--- /dev/null
+++ b/server/api/v1/admin/library/sources/index.patch.ts
@@ -0,0 +1,67 @@
+import { type } from "arktype";
+import { throwingArktype } from "~/server/arktype";
+import aclManager from "~/server/internal/acls";
+import prisma from "~/server/internal/db/database";
+import libraryManager from "~/server/internal/library";
+import { libraryConstructors } from "~/server/plugins/05.library-init";
+import type { WorkingLibrarySource } from "./index.get";
+
+const UpdateLibrarySource = type({
+ id: "string",
+ name: "string",
+ options: "object",
+}).configure(throwingArktype);
+
+export default defineEventHandler<{ body: typeof UpdateLibrarySource.infer }>(
+ async (h3) => {
+ const allowed = await aclManager.allowSystemACL(h3, [
+ "library:sources:update",
+ ]);
+ if (!allowed) throw createError({ statusCode: 403 });
+
+ const body = await readValidatedBody(h3, UpdateLibrarySource);
+
+ const source = await prisma.library.findUnique({ where: { id: body.id } });
+ if (!source)
+ throw createError({
+ statusCode: 400,
+ statusMessage: "Library source not found",
+ });
+
+ const constructor = libraryConstructors[source.backend];
+
+ try {
+ const newLibrary = constructor(body.options, source.id);
+
+ // Test we can actually use it
+ if ((await newLibrary.listGames()) === undefined) {
+ throw "Library failed to fetch games.";
+ }
+
+ const updatedSource = await prisma.library.update({
+ where: {
+ id: source.id,
+ },
+ data: {
+ name: body.name,
+ options: body.options,
+ },
+ });
+
+ await libraryManager.removeLibrary(source.id);
+ await libraryManager.addLibrary(newLibrary);
+
+ const workingSource: WorkingLibrarySource = {
+ ...updatedSource,
+ working: true,
+ };
+
+ return workingSource;
+ } catch (e) {
+ throw createError({
+ statusCode: 400,
+ statusMessage: `Failed to create source: ${e}`,
+ });
+ }
+ },
+);
diff --git a/server/api/v1/admin/library/sources/index.post.ts b/server/api/v1/admin/library/sources/index.post.ts
new file mode 100644
index 0000000..e44c5bd
--- /dev/null
+++ b/server/api/v1/admin/library/sources/index.post.ts
@@ -0,0 +1,69 @@
+import { type } from "arktype";
+import { randomUUID } from "crypto";
+import { LibraryBackend } from "~/prisma/client";
+import { throwingArktype } from "~/server/arktype";
+import aclManager from "~/server/internal/acls";
+import prisma from "~/server/internal/db/database";
+import libraryManager from "~/server/internal/library";
+import { libraryConstructors } from "~/server/plugins/05.library-init";
+import type { WorkingLibrarySource } from "./index.get";
+
+const CreateLibrarySource = type({
+ name: "string",
+ backend: "string",
+ options: "object",
+}).configure(throwingArktype);
+
+export default defineEventHandler<{ body: typeof CreateLibrarySource.infer }>(
+ async (h3) => {
+ const allowed = await aclManager.allowSystemACL(h3, [
+ "library:sources:new",
+ ]);
+ if (!allowed) throw createError({ statusCode: 403 });
+
+ const body = await readValidatedBody(h3, CreateLibrarySource);
+ const backend = Object.values(LibraryBackend).find(
+ (e) => e == body.backend,
+ );
+ if (!backend)
+ throw createError({
+ statusCode: 400,
+ statusMessage: "Invalid source backend.",
+ });
+
+ const constructor = libraryConstructors[backend];
+
+ try {
+ const id = randomUUID();
+ const library = constructor(body.options, id);
+
+ // Test we can actually use it
+ if ((await library.listGames()) === undefined) {
+ throw "Library failed to fetch games.";
+ }
+
+ const source = await prisma.library.create({
+ data: {
+ id,
+ name: body.name,
+ backend,
+ options: body.options,
+ },
+ });
+
+ await libraryManager.addLibrary(library);
+
+ const workingSource: WorkingLibrarySource = {
+ ...source,
+ working: true,
+ };
+
+ return workingSource;
+ } catch (e) {
+ throw createError({
+ statusCode: 400,
+ statusMessage: `Failed to create source: ${e}`,
+ });
+ }
+ },
+);
diff --git a/server/internal/acls/descriptions.ts b/server/internal/acls/descriptions.ts
index d9f21a6..80c1b92 100644
--- a/server/internal/acls/descriptions.ts
+++ b/server/internal/acls/descriptions.ts
@@ -49,6 +49,11 @@ export const systemACLDescriptions: ObjectFromList
= {
"auth:simple:invitation:delete": "Delete a simple auth invitation.",
"library:read": "Fetch a list of all games on this instance.",
+ "library:sources:read":
+ "Fetch a list of all library sources on this instance",
+ "library:sources:new": "Create a new library source.",
+ "library:sources:update": "Update existing library sources.",
+ "library:sources:delete": "Delete library sources.",
"notifications:read": "Read system notifications.",
"notifications:mark": "Mark system notifications as read.",
diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts
index 96c3708..9f96b5b 100644
--- a/server/internal/acls/index.ts
+++ b/server/internal/acls/index.ts
@@ -50,6 +50,11 @@ export const systemACLs = [
"notifications:delete",
"library:read",
+ "library:sources:read",
+ "library:sources:new",
+ "library:sources:update",
+ "library:sources:delete",
+
"game:read",
"game:update",
"game:delete",
diff --git a/server/internal/library/filesystem.ts b/server/internal/library/filesystem.ts
index 73a4a32..2381a18 100644
--- a/server/internal/library/filesystem.ts
+++ b/server/internal/library/filesystem.ts
@@ -30,7 +30,9 @@ export class FilesystemProvider
this.myId = id;
this.config = config;
- fs.mkdirSync(this.config.baseDir, { recursive: true });
+
+ if (!fs.existsSync(this.config.baseDir))
+ throw "Base directory does not exist.";
}
id(): string {
diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts
index 873c0df..a8668af 100644
--- a/server/internal/library/index.ts
+++ b/server/internal/library/index.ts
@@ -20,6 +20,19 @@ class LibraryManager {
this.libraries.set(library.id(), library);
}
+ removeLibrary(id: string) {
+ this.libraries.delete(id);
+ }
+
+ async fetchLibraries() {
+ const libraries = await prisma.library.findMany({});
+ const libraryWithMetadata = libraries.map((e) => ({
+ ...e,
+ working: this.libraries.has(e.id),
+ }));
+ return libraryWithMetadata;
+ }
+
async fetchAllUnimportedGames() {
const unimportedGames: { [key: string]: string[] } = {};
diff --git a/server/plugins/05.library-init.ts b/server/plugins/05.library-init.ts
index 7c40105..dc0bd7c 100644
--- a/server/plugins/05.library-init.ts
+++ b/server/plugins/05.library-init.ts
@@ -7,7 +7,7 @@ import { FilesystemProvider } from "../internal/library/filesystem";
import libraryManager from "../internal/library";
import path from "path";
-const libraryConstructors: {
+export const libraryConstructors: {
[key in LibraryBackend]: (
value: JsonValue,
id: string,