diff --git a/components/AddLibraryButton.vue b/components/AddLibraryButton.vue new file mode 100644 index 0000000..3b822f8 --- /dev/null +++ b/components/AddLibraryButton.vue @@ -0,0 +1,95 @@ + + + diff --git a/composables/collection.ts b/composables/collection.ts new file mode 100644 index 0000000..e69de29 diff --git a/prisma/migrations/20250103202348_add_collections/migration.sql b/prisma/migrations/20250103202348_add_collections/migration.sql new file mode 100644 index 0000000..3dd7f22 --- /dev/null +++ b/prisma/migrations/20250103202348_add_collections/migration.sql @@ -0,0 +1,29 @@ +-- CreateTable +CREATE TABLE "Collection" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "isDefault" BOOLEAN NOT NULL DEFAULT false, + "userId" TEXT NOT NULL, + + CONSTRAINT "Collection_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_CollectionToGame" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_CollectionToGame_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE INDEX "_CollectionToGame_B_index" ON "_CollectionToGame"("B"); + +-- AddForeignKey +ALTER TABLE "Collection" ADD CONSTRAINT "Collection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_CollectionToGame" ADD CONSTRAINT "_CollectionToGame_A_fkey" FOREIGN KEY ("A") REFERENCES "Collection"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_CollectionToGame" ADD CONSTRAINT "_CollectionToGame_B_fkey" FOREIGN KEY ("B") REFERENCES "Game"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20250109005948_use_collection_entry_to_ensure_unique_games/migration.sql b/prisma/migrations/20250109005948_use_collection_entry_to_ensure_unique_games/migration.sql new file mode 100644 index 0000000..0e4a76f --- /dev/null +++ b/prisma/migrations/20250109005948_use_collection_entry_to_ensure_unique_games/migration.sql @@ -0,0 +1,28 @@ +/* + Warnings: + + - You are about to drop the `_CollectionToGame` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "_CollectionToGame" DROP CONSTRAINT "_CollectionToGame_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_CollectionToGame" DROP CONSTRAINT "_CollectionToGame_B_fkey"; + +-- DropTable +DROP TABLE "_CollectionToGame"; + +-- CreateTable +CREATE TABLE "CollectionEntry" ( + "collectionId" TEXT NOT NULL, + "gameId" TEXT NOT NULL, + + CONSTRAINT "CollectionEntry_pkey" PRIMARY KEY ("collectionId","gameId") +); + +-- AddForeignKey +ALTER TABLE "CollectionEntry" ADD CONSTRAINT "CollectionEntry_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CollectionEntry" ADD CONSTRAINT "CollectionEntry_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema/collection.prisma b/prisma/schema/collection.prisma new file mode 100644 index 0000000..704ccd3 --- /dev/null +++ b/prisma/schema/collection.prisma @@ -0,0 +1,20 @@ +model Collection { + id String @id @default(uuid()) + name String + + isDefault Boolean @default(false) + userId String + user User @relation(fields: [userId], references: [id]) + + entries CollectionEntry[] +} + +model CollectionEntry { + collectionId String + collection Collection @relation(fields: [collectionId], references: [id]) + + gameId String + game Game @relation(fields: [gameId], references: [id]) + + @@id([collectionId, gameId]) +} diff --git a/server/api/v1/client/game/manifest.get.ts b/server/api/v1/client/game/manifest.get.ts new file mode 100644 index 0000000..80535e5 --- /dev/null +++ b/server/api/v1/client/game/manifest.get.ts @@ -0,0 +1,21 @@ +import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; +import manifestGenerator from "~/server/internal/downloads/manifest"; + +export default defineClientEventHandler(async (h3) => { + const query = getQuery(h3); + const id = query.id?.toString(); + const version = query.version?.toString(); + if (!id || !version) + throw createError({ + statusCode: 400, + statusMessage: "Missing id or version in query", + }); + + const manifest = await manifestGenerator.generateManifest(id, version); + if (!manifest) + throw createError({ + statusCode: 400, + statusMessage: "Invalid game or version, or no versions added.", + }); + return manifest; +}); diff --git a/server/api/v1/client/game/version.get.ts b/server/api/v1/client/game/version.get.ts new file mode 100644 index 0000000..e9cf38e --- /dev/null +++ b/server/api/v1/client/game/version.get.ts @@ -0,0 +1,30 @@ +import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; +import prisma from "~/server/internal/db/database"; + +export default defineClientEventHandler(async (h3) => { + const query = getQuery(h3); + const id = query.id?.toString(); + const version = query.version?.toString(); + if (!id || !version) + throw createError({ + statusCode: 400, + statusMessage: "Missing id or version in query", + }); + + const gameVersion = await prisma.gameVersion.findUnique({ + where: { + gameId_versionName: { + gameId: id, + versionName: version, + }, + }, + }); + + if (!gameVersion) + throw createError({ + statusCode: 404, + statusMessage: "Game version not found", + }); + + return gameVersion; +}); diff --git a/server/api/v1/client/game/versions.get.ts b/server/api/v1/client/game/versions.get.ts new file mode 100644 index 0000000..40437e3 --- /dev/null +++ b/server/api/v1/client/game/versions.get.ts @@ -0,0 +1,46 @@ +import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; +import prisma from "~/server/internal/db/database"; +import { DropManifest } from "~/server/internal/downloads/manifest"; + +export default defineClientEventHandler(async (h3, {}) => { + const query = getQuery(h3); + const id = query.id?.toString(); + if (!id) + throw createError({ + statusCode: 400, + statusMessage: "No ID in request query", + }); + + const versions = await prisma.gameVersion.findMany({ + where: { + gameId: id, + }, + orderBy: { + versionIndex: "desc", // Latest one first + }, + }); + + const mappedVersions = versions + .map((version) => { + if (!version.dropletManifest) return undefined; + const manifest = JSON.parse( + version.dropletManifest.toString() + ) as DropManifest; + + /* + TODO: size estimates + They are a little complicated because of delta versions + Manifests need to be generated with the manifest generator and then + added up. I'm a little busy right now to implement this, though. + */ + + const newVersion = { ...version, dropletManifest: undefined }; + delete newVersion.dropletManifest; + return { + ...newVersion, + }; + }) + .filter((e) => e); + + return mappedVersions; +}); diff --git a/server/api/v1/collection/[id]/entry.delete.ts b/server/api/v1/collection/[id]/entry.delete.ts new file mode 100644 index 0000000..4feeb53 --- /dev/null +++ b/server/api/v1/collection/[id]/entry.delete.ts @@ -0,0 +1,26 @@ +import userLibraryManager from "~/server/internal/userlibrary"; + +export default defineEventHandler(async (h3) => { + const userId = await h3.context.session.getUserId(h3); + if (!userId) + throw createError({ + statusCode: 403, + statusMessage: "Requires authentication", + }); + + const id = getRouterParam(h3, "id"); + if (!id) + throw createError({ + statusCode: 400, + statusMessage: "ID required in route params", + }); + + const body = await readBody(h3); + + const gameId = body.id; + if (!gameId) + throw createError({ statusCode: 400, statusMessage: "Game ID required" }); + + await userLibraryManager.collectionRemove(id, gameId); + return {}; +}); diff --git a/server/api/v1/collection/[id]/entry.post.ts b/server/api/v1/collection/[id]/entry.post.ts new file mode 100644 index 0000000..d2e0a06 --- /dev/null +++ b/server/api/v1/collection/[id]/entry.post.ts @@ -0,0 +1,26 @@ +import userLibraryManager from "~/server/internal/userlibrary"; + +export default defineEventHandler(async (h3) => { + const userId = await h3.context.session.getUserId(h3); + if (!userId) + throw createError({ + statusCode: 403, + statusMessage: "Requires authentication", + }); + + const id = getRouterParam(h3, "id"); + if (!id) + throw createError({ + statusCode: 400, + statusMessage: "ID required in route params", + }); + + const body = await readBody(h3); + + const gameId = body.id; + if (!gameId) + throw createError({ statusCode: 400, statusMessage: "Game ID required" }); + + await userLibraryManager.collectionAdd(id, gameId); + return {}; +}); diff --git a/server/api/v1/collection/[id]/index.delete.ts b/server/api/v1/collection/[id]/index.delete.ts new file mode 100644 index 0000000..025d1d8 --- /dev/null +++ b/server/api/v1/collection/[id]/index.delete.ts @@ -0,0 +1,20 @@ +import userLibraryManager from "~/server/internal/userlibrary"; + +export default defineEventHandler(async (h3) => { + const userId = await h3.context.session.getUserId(h3); + if (!userId) + throw createError({ + statusCode: 403, + statusMessage: "Requires authentication", + }); + + const id = getRouterParam(h3, "id"); + if (!id) + throw createError({ + statusCode: 400, + statusMessage: "ID required in route params", + }); + + const collection = await userLibraryManager.deleteCollection(id); + return collection; +}); diff --git a/server/api/v1/collection/[id]/index.get.ts b/server/api/v1/collection/[id]/index.get.ts new file mode 100644 index 0000000..22c15c1 --- /dev/null +++ b/server/api/v1/collection/[id]/index.get.ts @@ -0,0 +1,20 @@ +import userLibraryManager from "~/server/internal/userlibrary"; + +export default defineEventHandler(async (h3) => { + const userId = await h3.context.session.getUserId(h3); + if (!userId) + throw createError({ + statusCode: 403, + statusMessage: "Requires authentication", + }); + + const id = getRouterParam(h3, "id"); + if (!id) + throw createError({ + statusCode: 400, + statusMessage: "ID required in route params", + }); + + const collection = await userLibraryManager.fetchCollection(id); + return collection; +}); diff --git a/server/api/v1/collection/default/entry.delete.ts b/server/api/v1/collection/default/entry.delete.ts new file mode 100644 index 0000000..d72dd74 --- /dev/null +++ b/server/api/v1/collection/default/entry.delete.ts @@ -0,0 +1,19 @@ +import userLibraryManager from "~/server/internal/userlibrary"; + +export default defineEventHandler(async (h3) => { + const userId = await h3.context.session.getUserId(h3); + if (!userId) + throw createError({ + statusCode: 403, + statusMessage: "Requires authentication", + }); + + const body = await readBody(h3); + + const gameId = body.id; + if (!gameId) + throw createError({ statusCode: 400, statusMessage: "Game ID required" }); + + await userLibraryManager.libraryRemove(gameId, userId); + return {}; +}); diff --git a/server/api/v1/collection/default/entry.post.ts b/server/api/v1/collection/default/entry.post.ts new file mode 100644 index 0000000..d72dd74 --- /dev/null +++ b/server/api/v1/collection/default/entry.post.ts @@ -0,0 +1,19 @@ +import userLibraryManager from "~/server/internal/userlibrary"; + +export default defineEventHandler(async (h3) => { + const userId = await h3.context.session.getUserId(h3); + if (!userId) + throw createError({ + statusCode: 403, + statusMessage: "Requires authentication", + }); + + const body = await readBody(h3); + + const gameId = body.id; + if (!gameId) + throw createError({ statusCode: 400, statusMessage: "Game ID required" }); + + await userLibraryManager.libraryRemove(gameId, userId); + return {}; +}); diff --git a/server/api/v1/collection/index.get.ts b/server/api/v1/collection/index.get.ts new file mode 100644 index 0000000..76a3f51 --- /dev/null +++ b/server/api/v1/collection/index.get.ts @@ -0,0 +1,13 @@ +import userLibraryManager from "~/server/internal/userlibrary"; + +export default defineEventHandler(async (h3) => { + const userId = await h3.context.session.getUserId(h3); + if (!userId) + throw createError({ + statusCode: 403, + statusMessage: "Requires authentication", + }); + + const collections = await userLibraryManager.fetchCollections(userId); + return collections; +}); diff --git a/server/api/v1/collection/index.post.ts b/server/api/v1/collection/index.post.ts new file mode 100644 index 0000000..a67cf3e --- /dev/null +++ b/server/api/v1/collection/index.post.ts @@ -0,0 +1,19 @@ +import userLibraryManager from "~/server/internal/userlibrary"; + +export default defineEventHandler(async (h3) => { + const userId = await h3.context.session.getUserId(h3); + if (!userId) + throw createError({ + statusCode: 403, + statusMessage: "Requires authentication", + }); + + const body = await readBody(h3); + + const name = body.name; + if (!name) + throw createError({ statusCode: 400, statusMessage: "Requires name" }); + + const collections = await userLibraryManager.fetchCollections(userId); + return collections; +}); diff --git a/server/internal/applibrary/README.md b/server/internal/applibrary/README.md new file mode 100644 index 0000000..e33b8ad --- /dev/null +++ b/server/internal/applibrary/README.md @@ -0,0 +1,11 @@ +# Library Format + +Drop uses a filesystem-based library format, as it targets homelabs and not enterprise-grade solutions. The format works as follows: + +## /{game name} + +The game name is only used for initial matching, and doesn't affect actual metadata. Metadata is linked to the game's database entry, which is linked to it's filesystem name (they, however, can be completely different). + +## /{game name}/{version name} + +The version name can be anything. Versions have to manually imported within the web UI. There, you can change the order of the updates and mark them as deltas. Delta updates apply files over the previous versions. \ No newline at end of file diff --git a/server/internal/applibrary/index.ts b/server/internal/applibrary/index.ts new file mode 100644 index 0000000..308c90d --- /dev/null +++ b/server/internal/applibrary/index.ts @@ -0,0 +1,309 @@ +/** + * 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"; +import taskHandler from "../tasks"; +import { parsePlatform } from "../utils/parseplatform"; +import droplet from "@drop/droplet"; + +class AppLibraryManager { + private basePath: string; + + constructor() { + this.basePath = process.env.LIBRARY ?? "./.data/library"; + fs.mkdirSync(this.basePath, { recursive: true }); + } + + fetchLibraryPath() { + return this.basePath; + } + + 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 + ) { + 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, + }, + orderBy: { + mName: "asc", + }, + }); + + 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 validVersions = versions.filter((versionDir) => { + const versionPath = path.join(targetDir, versionDir); + const stat = fs.statSync(versionPath); + return stat.isDirectory(); + }); + const currentVersions = game.versions.map((e) => e.versionName); + + const unimportedVersions = validVersions.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", + // Shell scripts + ".sh", + // 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, 2); + 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); + const relative = path.relative(targetDir, file); + options.push({ + filename: relative, + platform: platform, + match: fuzzyValue, + }); + } + } + } + + const sortedOptions = options.sort((a, b) => b.match - a.match); + + return sortedOptions; + } + + // 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; + } + + async importVersion( + gameId: string, + versionName: string, + metadata: { + platform: string; + onlySetup: boolean; + + setup: string; + setupArgs: string; + launch: string; + launchArgs: string; + delta: boolean; + + umuId: string; + } + ) { + const taskId = `import:${gameId}:${versionName}`; + + const platform = parsePlatform(metadata.platform); + if (!platform) return undefined; + + const game = await prisma.game.findUnique({ + where: { id: gameId }, + select: { mName: true, libraryBasePath: true }, + }); + if (!game) return undefined; + + const baseDir = path.join(this.basePath, game.libraryBasePath, versionName); + if (!fs.existsSync(baseDir)) return undefined; + + taskHandler.create({ + id: taskId, + name: `Importing version ${versionName} for ${game.mName}`, + requireAdmin: true, + async run({ progress, log }) { + // First, create the manifest via droplet. + // This takes up 90% of our progress, so we wrap it in a *0.9 + const manifest = await new Promise((resolve, reject) => { + droplet.generateManifest( + baseDir, + (err, value) => { + if (err) return reject(err); + progress(value * 0.9); + }, + (err, line) => { + if (err) return reject(err); + log(line); + }, + (err, manifest) => { + if (err) return reject(err); + resolve(manifest); + } + ); + }); + + log("Created manifest successfully!"); + + const currentIndex = await prisma.gameVersion.count({ + where: { gameId: gameId }, + }); + + // Then, create the database object + if (metadata.onlySetup) { + await prisma.gameVersion.create({ + data: { + gameId: gameId, + versionName: versionName, + dropletManifest: manifest, + versionIndex: currentIndex, + delta: metadata.delta, + umuIdOverride: metadata.umuId, + platform: platform, + + onlySetup: true, + setupCommand: metadata.setup, + setupArgs: metadata.setupArgs.split(" "), + }, + }); + } else { + await prisma.gameVersion.create({ + data: { + gameId: gameId, + versionName: versionName, + dropletManifest: manifest, + versionIndex: currentIndex, + delta: metadata.delta, + umuIdOverride: metadata.umuId, + platform: platform, + + onlySetup: false, + setupCommand: metadata.setup, + setupArgs: metadata.setupArgs.split(" "), + launchCommand: metadata.launch, + launchArgs: metadata.launchArgs.split(" "), + }, + }); + } + + log("Successfully created version!"); + + progress(100); + }, + }); + + return taskId; + } +} + +export const appLibraryManager = new AppLibraryManager(); +export default appLibraryManager; diff --git a/server/internal/userlibrary/index.ts b/server/internal/userlibrary/index.ts new file mode 100644 index 0000000..fe30dfe --- /dev/null +++ b/server/internal/userlibrary/index.ts @@ -0,0 +1,119 @@ +/* +Handles managing collections +*/ + +import prisma from "../db/database"; + +class UserLibraryManager { + // Caches the user's core library + private userCoreLibraryCache: { [key: string]: string } = {}; + + constructor() {} + + private async fetchUserLibrary(userId: string) { + if (this.userCoreLibraryCache[userId]) + return this.userCoreLibraryCache[userId]; + + let collection = await prisma.collection.findFirst({ + where: { + userId, + isDefault: true, + }, + }); + + if (!collection) + collection = await prisma.collection.create({ + data: { + name: "Library", + userId, + isDefault: true, + }, + }); + + this.userCoreLibraryCache[userId] = collection.id; + + return collection.id; + } + + async libraryAdd(gameId: string, userId: string) { + const userLibraryId = await this.fetchUserLibrary(userId); + await this.collectionAdd(gameId, userLibraryId); + } + + async libraryRemove(gameId: string, userId: string) { + const userLibraryId = await this.fetchUserLibrary(userId); + await this.collectionRemove(gameId, userLibraryId); + } + + async fetchLibrary(userId: string) { + const userLibraryId = await this.fetchUserLibrary(userId); + const userLibrary = await prisma.collection.findUnique({ + where: { id: userLibraryId }, + include: { entries: { include: { game: true } } }, + }); + if (!userLibrary) throw new Error("Failed to load user library"); + return userLibrary; + } + + async fetchCollection(collectionId: string) { + return await prisma.collection.findUnique({ + where: { id: collectionId }, + include: { entries: { include: { game: true } } }, + }); + } + + async fetchCollections(userId: string) { + await this.fetchUserLibrary(userId); // Ensures user library exists, doesn't have much performance impact due to caching + return await prisma.collection.findMany({ where: { userId } }); + } + + async collectionAdd(gameId: string, collectionId: string) { + await prisma.collectionEntry.upsert({ + where: { + collectionId_gameId: { + collectionId, + gameId, + }, + }, + create: { + collectionId, + gameId, + }, + update: {}, + }); + } + + async collectionRemove(gameId: string, collectionId: string) { + // Delete if exists + return ( + ( + await prisma.collectionEntry.deleteMany({ + where: { + collectionId, + gameId, + }, + }) + ).count > 0 + ); + } + + async collectionCreate(name: string, userId: string) { + return await prisma.collection.create({ + data: { + name, + userId: userId, + }, + }); + } + + async deleteCollection(collectionId: string) { + await prisma.collection.delete({ + where: { + id: collectionId, + }, + }); + } +} + +export const userLibraryManager = new UserLibraryManager(); +export default userLibraryManager;