From cf7fd315b6a96d3a75f3a5eb89c5a72230f83768 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 4 Jul 2023 18:14:44 -0500 Subject: [PATCH] Init sqlite take 2 --- client/pages/config/email.vue | 2 +- client/players/PlayerHandler.js | 1 + package-lock.json | 2378 ++++++++++++++++- package.json | 4 +- server/Auth.js | 63 +- server/Database.js | 456 ++++ server/Db.js | 49 - server/Server.js | 105 +- server/SocketAuthority.js | 11 +- server/controllers/AuthorController.js | 34 +- server/controllers/CollectionController.js | 86 +- server/controllers/EmailController.js | 21 +- server/controllers/FileSystemController.js | 3 +- server/controllers/LibraryController.js | 72 +- server/controllers/LibraryItemController.js | 55 +- server/controllers/MeController.js | 80 +- server/controllers/MiscController.js | 29 +- server/controllers/NotificationController.js | 29 +- server/controllers/PlaylistController.js | 72 +- server/controllers/PodcastController.js | 18 +- server/controllers/RSSFeedController.js | 12 +- server/controllers/SeriesController.js | 9 +- server/controllers/SessionController.js | 9 +- server/controllers/ToolsController.js | 5 +- server/controllers/UserController.js | 88 +- server/controllers2/libraryItem.controller.js | 16 + server/db/libraryItem.db.js | 80 + server/managers/AbMergeManager.js | 3 +- server/managers/AudioMetadataManager.js | 3 +- server/managers/CoverManager.js | 5 +- server/managers/CronManager.js | 10 +- server/managers/EmailManager.js | 13 +- server/managers/LogManager.js | 10 +- server/managers/NotificationManager.js | 25 +- server/managers/PlaybackSessionManager.js | 58 +- server/managers/PodcastManager.js | 18 +- server/managers/RssFeedManager.js | 49 +- server/models/Author.js | 78 + server/models/Book.js | 121 + server/models/BookAuthor.js | 40 + server/models/BookSeries.js | 41 + server/models/Collection.js | 117 + server/models/CollectionBook.js | 42 + server/models/Device.js | 112 + server/models/Feed.js | 165 ++ server/models/FeedEpisode.js | 60 + server/models/Library.js | 137 + server/models/LibraryFolder.js | 23 + server/models/LibraryItem.js | 393 +++ server/models/MediaProgress.js | 141 + server/models/PlaybackSession.js | 198 ++ server/models/Playlist.js | 171 ++ server/models/PlaylistMediaItem.js | 82 + server/models/Podcast.js | 98 + server/models/PodcastEpisode.js | 95 + server/models/Series.js | 72 + server/models/Setting.js | 45 + server/models/User.js | 136 + server/objects/Collection.js | 10 +- server/objects/DeviceInfo.js | 59 +- server/objects/Feed.js | 7 +- server/objects/FeedEpisode.js | 1 - server/objects/Folder.js | 4 +- server/objects/Library.js | 4 +- server/objects/LibraryItem.js | 6 +- server/objects/Notification.js | 4 +- server/objects/PlaybackSession.js | 25 +- server/objects/Playlist.js | 5 +- server/objects/PodcastEpisodeDownload.js | 4 +- server/objects/Task.js | 4 +- server/objects/entities/Author.js | 4 +- server/objects/entities/PodcastEpisode.js | 11 +- server/objects/entities/Series.js | 4 +- server/objects/mediaTypes/Book.js | 5 + server/objects/mediaTypes/Podcast.js | 8 +- server/objects/metadata/BookMetadata.js | 2 +- server/objects/user/MediaProgress.js | 24 +- server/objects/user/User.js | 13 +- server/routers/ApiRouter.js | 50 +- server/routers/HlsRouter.js | 3 +- server/routes/index.js | 8 + server/routes/libraries.js | 7 + server/scanner/LibraryScan.js | 5 +- server/scanner/Scanner.js | 61 +- server/utils/areEquivalent.js | 66 +- server/utils/libraryHelpers.js | 5 +- server/utils/migrations/dbMigration.js | 763 ++++++ server/utils/migrations/oldDbFiles.js | 189 ++ 88 files changed, 7017 insertions(+), 692 deletions(-) create mode 100644 server/Database.js create mode 100644 server/controllers2/libraryItem.controller.js create mode 100644 server/db/libraryItem.db.js create mode 100644 server/models/Author.js create mode 100644 server/models/Book.js create mode 100644 server/models/BookAuthor.js create mode 100644 server/models/BookSeries.js create mode 100644 server/models/Collection.js create mode 100644 server/models/CollectionBook.js create mode 100644 server/models/Device.js create mode 100644 server/models/Feed.js create mode 100644 server/models/FeedEpisode.js create mode 100644 server/models/Library.js create mode 100644 server/models/LibraryFolder.js create mode 100644 server/models/LibraryItem.js create mode 100644 server/models/MediaProgress.js create mode 100644 server/models/PlaybackSession.js create mode 100644 server/models/Playlist.js create mode 100644 server/models/PlaylistMediaItem.js create mode 100644 server/models/Podcast.js create mode 100644 server/models/PodcastEpisode.js create mode 100644 server/models/Series.js create mode 100644 server/models/Setting.js create mode 100644 server/models/User.js create mode 100644 server/routes/index.js create mode 100644 server/routes/libraries.js create mode 100644 server/utils/migrations/dbMigration.js create mode 100644 server/utils/migrations/oldDbFiles.js diff --git a/client/pages/config/email.vue b/client/pages/config/email.vue index 52b83ac8..5ae659fa 100644 --- a/client/pages/config/email.vue +++ b/client/pages/config/email.vue @@ -154,7 +154,7 @@ export default { } this.deletingDeviceName = device.name this.$axios - .$patch(`/emails/ereader-devices`, payload) + .$post(`/api/emails/ereader-devices`, payload) .then((data) => { this.ereaderDevicesUpdated(data.ereaderDevices) this.$toast.success('Device deleted') diff --git a/client/players/PlayerHandler.js b/client/players/PlayerHandler.js index 5821aee1..23bdc9d7 100644 --- a/client/players/PlayerHandler.js +++ b/client/players/PlayerHandler.js @@ -191,6 +191,7 @@ export default class PlayerHandler { const payload = { deviceInfo: { + clientName: 'Abs Web', deviceId: this.getDeviceId() }, supportedMimeTypes: this.player.playableMimeTypes, diff --git a/package-lock.json b/package-lock.json index 392fa794..32fca372 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,9 @@ "htmlparser2": "^8.0.1", "node-tone": "^1.0.1", "nodemailer": "^6.9.2", + "sequelize": "^6.32.1", "socket.io": "^4.5.4", + "sqlite3": "^5.1.6", "xml2js": "^0.5.0" }, "bin": { @@ -25,11 +27,112 @@ "nodemon": "^2.0.20" } }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "optional": true + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", + "integrity": "sha512-4ySo4CjzStuprMwk35H5pPbkymjv1SF3jGLj6rAHp/xT/RF7TL7bd9CTm1xDY49K2qF7jmR/g7k+SkLETP6opA==", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/fs/node_modules/semver": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "optional": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "optional": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/@types/cookie": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", @@ -43,16 +146,33 @@ "@types/node": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.8.tgz", + "integrity": "sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/ms": { + "version": "0.7.31", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", + "integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==" + }, "node_modules/@types/node": { "version": "18.11.18", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", "integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==" }, + "node_modules/@types/validator": { + "version": "13.7.17", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.17.tgz", + "integrity": "sha512-aqayTNmeWrZcvnG2MG9eGYI6b7S5fl+yKgPs6bAjOTwPS316R5SxBGKvtSExfyoJU7pIeHJfsHI0Ji41RVMkvQ==" + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, "node_modules/accepts": { "version": "1.3.8", @@ -66,6 +186,96 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/agentkeepalive": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.3.0.tgz", + "integrity": "sha512-7Epl1Blf4Sy37j4v9f9FjICCh4+KAQOyXgHEwlyBiAQLbhKdq/i2QQU3amQalS/wPhdPzDXPL5DMR5bkn+YeWg==", + "optional": true, + "dependencies": { + "debug": "^4.1.0", + "depd": "^2.0.0", + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/agentkeepalive/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "optional": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agentkeepalive/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "optional": true + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -79,6 +289,23 @@ "node": ">= 8" } }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -101,8 +328,7 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/base64id": { "version": "2.0.0", @@ -148,7 +374,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -174,6 +399,35 @@ "node": ">= 0.8" } }, + "node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -213,6 +467,31 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "bin": { + "color-support": "bin.js" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -227,8 +506,12 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" }, "node_modules/content-disposition": { "version": "0.5.4", @@ -290,6 +573,11 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -307,6 +595,14 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", + "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==", + "engines": { + "node": ">=8" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -358,11 +654,21 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dottie": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz", + "integrity": "sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, "node_modules/encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", @@ -371,6 +677,27 @@ "node": ">= 0.8" } }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/engine.io": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.2.1.tgz", @@ -439,6 +766,21 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "optional": true + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -570,6 +912,22 @@ "node": ">= 0.6" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -589,6 +947,25 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/get-intrinsic": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", @@ -602,6 +979,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -650,6 +1046,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" + }, "node_modules/htmlparser2": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz", @@ -668,6 +1069,12 @@ "entities": "^4.3.0" } }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "optional": true + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -683,6 +1090,85 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "optional": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "optional": true + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -700,11 +1186,58 @@ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", "dev": true }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "optional": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "optional": true + }, + "node_modules/inflection": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", + "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", + "engines": [ + "node >= 0.4.0" + ] + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", + "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", + "optional": true + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -734,6 +1267,14 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -746,6 +1287,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "optional": true + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -755,6 +1302,77 @@ "node": ">=0.12.0" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "optional": true + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -810,7 +1428,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -818,6 +1435,124 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.43", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.43.tgz", + "integrity": "sha512-72j3aNyuIsDxdF1i7CEgV2FfxM1r6aaqJyLB2vwb33mXYyoyLly+F1zbWqhA3/bVIoJ4szlUoMbUnVdid32NUQ==", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -831,6 +1566,131 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==" + }, + "node_modules/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/node-gyp/node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/node-gyp/node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/node-gyp/node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/node-gyp/node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "optional": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-tone": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz", @@ -911,6 +1771,17 @@ "node": ">=0.10.0" } }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -938,6 +1809,29 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -946,11 +1840,24 @@ "node": ">= 0.8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" }, + "node_modules/pg-connection-string": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", + "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -963,6 +1870,25 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1017,6 +1943,19 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1029,6 +1968,34 @@ "node": ">=8.10.0" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-as-promised": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.0.4.tgz", + "integrity": "sha512-XgmCoxKWkDofwH8WddD0w85ZfqYz+ZHlr5yo+3YUCfycWawU56T5ckWXsScsj5B8tqUcIG67DxXByo3VUgiAdA==" + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1095,6 +2062,110 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/sequelize": { + "version": "6.32.1", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.32.1.tgz", + "integrity": "sha512-3Iv0jruv57Y0YvcxQW7BE56O7DC1BojcfIrqh6my+IQwde+9u/YnuYHzK+8kmZLhLvaziRT1eWu38nh9yVwn/g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/sequelize" + } + ], + "dependencies": { + "@types/debug": "^4.1.8", + "@types/validator": "^13.7.17", + "debug": "^4.3.4", + "dottie": "^2.0.4", + "inflection": "^1.13.4", + "lodash": "^4.17.21", + "moment": "^2.29.4", + "moment-timezone": "^0.5.43", + "pg-connection-string": "^2.6.0", + "retry-as-promised": "^7.0.4", + "semver": "^7.5.1", + "sequelize-pool": "^7.1.0", + "toposort-class": "^1.0.1", + "uuid": "^8.3.2", + "validator": "^13.9.0", + "wkx": "^0.5.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependenciesMeta": { + "ibm_db": { + "optional": true + }, + "mariadb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-hstore": { + "optional": true + }, + "snowflake-sdk": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, + "node_modules/sequelize-pool": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", + "integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/sequelize/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/sequelize/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/sequelize/node_modules/semver": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/serve-static": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", @@ -1109,6 +2180,11 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -1127,6 +2203,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, "node_modules/simple-update-notifier": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", @@ -1148,6 +2229,16 @@ "semver": "bin/semver.js" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/socket.io": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.5.4.tgz", @@ -1223,6 +2314,91 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/socks": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", + "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", + "optional": true, + "dependencies": { + "ip": "^2.0.0", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.13.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "optional": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "optional": true + }, + "node_modules/sqlite3": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.6.tgz", + "integrity": "sha512-olYkWoKFVNSSSQNvxVUfjiVbz3YtBwTJj+mfV5zpHmqW3sELx2Cf4QCdirMelhM5Zh+KDVaKgQHqCxrqiWHybw==", + "hasInstallScript": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.0", + "node-addon-api": "^4.2.0", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -1231,6 +2407,38 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -1243,6 +2451,30 @@ "node": ">=4" } }, + "node_modules/tar": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz", + "integrity": "sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A==", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1263,6 +2495,11 @@ "node": ">=0.6" } }, + "node_modules/toposort-class": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", + "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==" + }, "node_modules/touch": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", @@ -1275,6 +2512,11 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -1293,6 +2535,24 @@ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", "dev": true }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1301,6 +2561,11 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -1309,6 +2574,22 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validator": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.9.0.tgz", + "integrity": "sha512-B+dGG8U3fdtM0/aNK4/X8CXq/EcxU2WPrPEkJGslb47qyHsxmbggTWK0yEA4qnYVNF+nxNlN88o14hIcPmSIEA==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -1317,6 +2598,56 @@ "node": ">= 0.8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "optional": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wkx": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", + "integrity": "sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, "node_modules/ws": { "version": "8.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", @@ -1356,14 +2687,96 @@ "engines": { "node": ">=4.0" } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } }, "dependencies": { + "@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "optional": true + }, + "@mapbox/node-pre-gyp": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", + "integrity": "sha512-4ySo4CjzStuprMwk35H5pPbkymjv1SF3jGLj6rAHp/xT/RF7TL7bd9CTm1xDY49K2qF7jmR/g7k+SkLETP6opA==", + "requires": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "dependencies": { + "nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "requires": { + "abbrev": "1" + } + }, + "semver": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "optional": true, + "requires": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + }, + "dependencies": { + "semver": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "optional": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "optional": true, + "requires": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + } + }, "@socket.io/component-emitter": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" }, + "@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "optional": true + }, "@types/cookie": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", @@ -1377,16 +2790,33 @@ "@types/node": "*" } }, + "@types/debug": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.8.tgz", + "integrity": "sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==", + "requires": { + "@types/ms": "*" + } + }, + "@types/ms": { + "version": "0.7.31", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", + "integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==" + }, "@types/node": { "version": "18.11.18", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", "integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==" }, + "@types/validator": { + "version": "13.7.17", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.17.tgz", + "integrity": "sha512-aqayTNmeWrZcvnG2MG9eGYI6b7S5fl+yKgPs6bAjOTwPS316R5SxBGKvtSExfyoJU7pIeHJfsHI0Ji41RVMkvQ==" + }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, "accepts": { "version": "1.3.8", @@ -1397,6 +2827,72 @@ "negotiator": "0.6.3" } }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "requires": { + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "agentkeepalive": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.3.0.tgz", + "integrity": "sha512-7Epl1Blf4Sy37j4v9f9FjICCh4+KAQOyXgHEwlyBiAQLbhKdq/i2QQU3amQalS/wPhdPzDXPL5DMR5bkn+YeWg==", + "optional": true, + "requires": { + "debug": "^4.1.0", + "depd": "^2.0.0", + "humanize-ms": "^1.2.1" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "optional": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "optional": true + } + } + }, + "aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "optional": true, + "requires": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + } + }, + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, "anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -1407,6 +2903,20 @@ "picomatch": "^2.0.4" } }, + "aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" + }, + "are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + } + }, "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -1429,8 +2939,7 @@ "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "base64id": { "version": "2.0.0", @@ -1466,7 +2975,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1486,6 +2994,32 @@ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" }, + "cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "optional": true, + "requires": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + } + }, "call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -1511,6 +3045,22 @@ "readdirp": "~3.6.0" } }, + "chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" + }, + "clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "optional": true + }, + "color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==" + }, "combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1522,8 +3072,12 @@ "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" }, "content-disposition": { "version": "0.5.4", @@ -1570,6 +3124,11 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" + }, "depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1580,6 +3139,11 @@ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" }, + "detect-libc": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", + "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==" + }, "dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -1613,16 +3177,46 @@ "domhandler": "^5.0.1" } }, + "dottie": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz", + "integrity": "sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==" + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, "encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" }, + "encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "optional": true, + "requires": { + "iconv-lite": "^0.6.2" + }, + "dependencies": { + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "optional": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, "engine.io": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.2.1.tgz", @@ -1670,6 +3264,18 @@ "resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz", "integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==" }, + "env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "optional": true + }, + "err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "optional": true + }, "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1766,6 +3372,19 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" }, + "fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "requires": { + "minipass": "^3.0.0" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, "fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -1778,6 +3397,22 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, + "gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "requires": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + } + }, "get-intrinsic": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", @@ -1788,6 +3423,19 @@ "has-symbols": "^1.0.3" } }, + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, "glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -1821,6 +3469,11 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" }, + "has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" + }, "htmlparser2": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz", @@ -1832,6 +3485,12 @@ "entities": "^4.3.0" } }, + "http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "optional": true + }, "http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -1844,6 +3503,67 @@ "toidentifier": "1.0.1" } }, + "http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "optional": true, + "requires": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "optional": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "optional": true + } + } + }, + "https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "requires": { + "agent-base": "6", + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "optional": true, + "requires": { + "ms": "^2.0.0" + } + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -1858,11 +3578,49 @@ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", "dev": true }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "optional": true + }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "optional": true + }, + "infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "optional": true + }, + "inflection": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", + "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==" + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "ip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", + "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", + "optional": true + }, "ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1883,6 +3641,11 @@ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, "is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1892,12 +3655,76 @@ "is-extglob": "^2.1.1" } }, + "is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "optional": true + }, "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "optional": true + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "requires": { + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, + "make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "optional": true, + "requires": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + } + }, "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -1935,11 +3762,93 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "requires": { "brace-expansion": "^1.1.7" } }, + "minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "requires": { + "yallist": "^4.0.0" + } + }, + "minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "optional": true, + "requires": { + "minipass": "^3.0.0" + } + }, + "minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "optional": true, + "requires": { + "encoding": "^0.1.12", + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + } + }, + "minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "optional": true, + "requires": { + "minipass": "^3.0.0" + } + }, + "minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "optional": true, + "requires": { + "minipass": "^3.0.0" + } + }, + "minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "optional": true, + "requires": { + "minipass": "^3.0.0" + } + }, + "minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "requires": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" + }, + "moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" + }, + "moment-timezone": { + "version": "0.5.43", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.43.tgz", + "integrity": "sha512-72j3aNyuIsDxdF1i7CEgV2FfxM1r6aaqJyLB2vwb33mXYyoyLly+F1zbWqhA3/bVIoJ4szlUoMbUnVdid32NUQ==", + "requires": { + "moment": "^2.29.4" + } + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -1950,6 +3859,95 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" }, + "node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==" + }, + "node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", + "requires": { + "whatwg-url": "^5.0.0" + } + }, + "node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "optional": true, + "requires": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "dependencies": { + "are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "optional": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + } + }, + "gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "optional": true, + "requires": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + } + }, + "nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "optional": true, + "requires": { + "abbrev": "1" + } + }, + "npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "optional": true, + "requires": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + } + }, + "semver": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "optional": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, "node-tone": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz", @@ -2010,6 +4008,17 @@ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true }, + "npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "requires": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2028,22 +4037,65 @@ "ee-first": "1.1.1" } }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "requires": { + "wrappy": "1" + } + }, + "p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "optional": true, + "requires": { + "aggregate-error": "^3.0.0" + } + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" + }, "path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" }, + "pg-connection-string": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", + "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==" + }, "picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true }, + "promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "optional": true + }, + "promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "optional": true, + "requires": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + } + }, "proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2083,6 +4135,16 @@ "unpipe": "1.0.0" } }, + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, "readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -2092,6 +4154,25 @@ "picomatch": "^2.2.1" } }, + "retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "optional": true + }, + "retry-as-promised": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.0.4.tgz", + "integrity": "sha512-XgmCoxKWkDofwH8WddD0w85ZfqYz+ZHlr5yo+3YUCfycWawU56T5ckWXsScsj5B8tqUcIG67DxXByo3VUgiAdA==" + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "requires": { + "glob": "^7.1.3" + } + }, "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2140,6 +4221,57 @@ } } }, + "sequelize": { + "version": "6.32.1", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.32.1.tgz", + "integrity": "sha512-3Iv0jruv57Y0YvcxQW7BE56O7DC1BojcfIrqh6my+IQwde+9u/YnuYHzK+8kmZLhLvaziRT1eWu38nh9yVwn/g==", + "requires": { + "@types/debug": "^4.1.8", + "@types/validator": "^13.7.17", + "debug": "^4.3.4", + "dottie": "^2.0.4", + "inflection": "^1.13.4", + "lodash": "^4.17.21", + "moment": "^2.29.4", + "moment-timezone": "^0.5.43", + "pg-connection-string": "^2.6.0", + "retry-as-promised": "^7.0.4", + "semver": "^7.5.1", + "sequelize-pool": "^7.1.0", + "toposort-class": "^1.0.1", + "uuid": "^8.3.2", + "validator": "^13.9.0", + "wkx": "^0.5.0" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "semver": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "sequelize-pool": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", + "integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==" + }, "serve-static": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", @@ -2151,6 +4283,11 @@ "send": "0.18.0" } }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, "setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -2166,6 +4303,11 @@ "object-inspect": "^1.9.0" } }, + "signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, "simple-update-notifier": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", @@ -2183,6 +4325,12 @@ } } }, + "smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "optional": true + }, "socket.io": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.5.4.tgz", @@ -2240,11 +4388,95 @@ } } }, + "socks": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", + "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", + "optional": true, + "requires": { + "ip": "^2.0.0", + "smart-buffer": "^4.2.0" + } + }, + "socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "optional": true, + "requires": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "optional": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "optional": true + } + } + }, + "sqlite3": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.6.tgz", + "integrity": "sha512-olYkWoKFVNSSSQNvxVUfjiVbz3YtBwTJj+mfV5zpHmqW3sELx2Cf4QCdirMelhM5Zh+KDVaKgQHqCxrqiWHybw==", + "requires": { + "@mapbox/node-pre-gyp": "^1.0.0", + "node-addon-api": "^4.2.0", + "node-gyp": "8.x", + "tar": "^6.1.11" + } + }, + "ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "optional": true, + "requires": { + "minipass": "^3.1.1" + } + }, "statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "requires": { + "safe-buffer": "~5.2.0" + } + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "requires": { + "ansi-regex": "^5.0.1" + } + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -2254,6 +4486,26 @@ "has-flag": "^3.0.0" } }, + "tar": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz", + "integrity": "sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A==", + "requires": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "dependencies": { + "minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==" + } + } + }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2268,6 +4520,11 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, + "toposort-class": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", + "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==" + }, "touch": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", @@ -2277,6 +4534,11 @@ "nopt": "~1.0.10" } }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -2292,21 +4554,98 @@ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", "dev": true }, + "unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "optional": true, + "requires": { + "unique-slug": "^2.0.0" + } + }, + "unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "optional": true, + "requires": { + "imurmurhash": "^0.1.4" + } + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + }, + "validator": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.9.0.tgz", + "integrity": "sha512-B+dGG8U3fdtM0/aNK4/X8CXq/EcxU2WPrPEkJGslb47qyHsxmbggTWK0yEA4qnYVNF+nxNlN88o14hIcPmSIEA==" + }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "optional": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "requires": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "wkx": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", + "integrity": "sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==", + "requires": { + "@types/node": "*" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, "ws": { "version": "8.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", @@ -2326,6 +4665,11 @@ "version": "11.0.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 49eafa0c..22b604e5 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,12 @@ "htmlparser2": "^8.0.1", "node-tone": "^1.0.1", "nodemailer": "^6.9.2", + "sequelize": "^6.32.1", "socket.io": "^4.5.4", + "sqlite3": "^5.1.6", "xml2js": "^0.5.0" }, "devDependencies": { "nodemon": "^2.0.20" } -} \ No newline at end of file +} diff --git a/server/Auth.js b/server/Auth.js index c8e03606..c9d67b20 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -2,21 +2,10 @@ const bcrypt = require('./libs/bcryptjs') const jwt = require('./libs/jsonwebtoken') const requestIp = require('./libs/requestIp') const Logger = require('./Logger') +const Database = require('./Database') class Auth { - constructor(db) { - this.db = db - - this.user = null - } - - get username() { - return this.user ? this.user.username : 'nobody' - } - - get users() { - return this.db.users - } + constructor() { } cors(req, res, next) { res.header('Access-Control-Allow-Origin', '*') @@ -35,20 +24,20 @@ class Auth { async initTokenSecret() { if (process.env.TOKEN_SECRET) { // User can supply their own token secret Logger.debug(`[Auth] Setting token secret - using user passed in TOKEN_SECRET env var`) - this.db.serverSettings.tokenSecret = process.env.TOKEN_SECRET + Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET } else { Logger.debug(`[Auth] Setting token secret - using random bytes`) - this.db.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64') + Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64') } - await this.db.updateServerSettings() + await Database.updateServerSettings() // New token secret creation added in v2.1.0 so generate new API tokens for each user - if (this.db.users.length) { - for (const user of this.db.users) { + if (Database.users.length) { + for (const user of Database.users) { user.token = await this.generateAccessToken({ userId: user.id, username: user.username }) Logger.warn(`[Auth] User ${user.username} api token has been updated using new token secret`) } - await this.db.updateEntities('user', this.db.users) + await Database.updateBulkUsers(Database.users) } } @@ -68,7 +57,7 @@ class Auth { return res.sendStatus(401) } - var user = await this.verifyToken(token) + const user = await this.verifyToken(token) if (!user) { Logger.error('Verify Token User Not Found', token) return res.sendStatus(404) @@ -95,7 +84,7 @@ class Auth { } generateAccessToken(payload) { - return jwt.sign(payload, global.ServerSettings.tokenSecret); + return jwt.sign(payload, Database.serverSettings.tokenSecret) } authenticateUser(token) { @@ -104,12 +93,12 @@ class Auth { verifyToken(token) { return new Promise((resolve) => { - jwt.verify(token, global.ServerSettings.tokenSecret, (err, payload) => { + jwt.verify(token, Database.serverSettings.tokenSecret, (err, payload) => { if (!payload || err) { Logger.error('JWT Verify Token Failed', err) return resolve(null) } - const user = this.users.find(u => u.id === payload.userId && u.username === payload.username) + const user = Database.users.find(u => (u.id === payload.userId || u.oldUserId === payload.userId) && u.username === payload.username) resolve(user || null) }) }) @@ -118,9 +107,9 @@ class Auth { getUserLoginResponsePayload(user) { return { user: user.toJSONForBrowser(), - userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries), - serverSettings: this.db.serverSettings.toJSONForBrowser(), - ereaderDevices: this.db.emailSettings.getEReaderDevices(user), + userDefaultLibraryId: user.getDefaultLibraryId(Database.libraries), + serverSettings: Database.serverSettings.toJSONForBrowser(), + ereaderDevices: Database.emailSettings.getEReaderDevices(user), Source: global.Source } } @@ -130,7 +119,7 @@ class Auth { const username = (req.body.username || '').toLowerCase() const password = req.body.password || '' - const user = this.users.find(u => u.username.toLowerCase() === username) + const user = Database.users.find(u => u.username.toLowerCase() === username) if (!user?.isActive) { Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`) @@ -142,7 +131,7 @@ class Auth { } // Check passwordless root user - if (user.id === 'root' && (!user.pash || user.pash === '')) { + if (user.type === 'root' && (!user.pash || user.pash === '')) { if (password) { return res.status(401).send('Invalid root password (hint: there is none)') } else { @@ -166,15 +155,6 @@ class Auth { } } - // Not in use now - lockUser(user) { - user.isLocked = true - return this.db.updateEntity('user', user).catch((error) => { - Logger.error('[Auth] Failed to lock user', user.username, error) - return false - }) - } - comparePassword(password, user) { if (user.type === 'root' && !password && !user.pash) return true if (!password || !user.pash) return false @@ -184,7 +164,7 @@ class Auth { async userChangePassword(req, res) { var { password, newPassword } = req.body newPassword = newPassword || '' - var matchingUser = this.users.find(u => u.id === req.user.id) + const matchingUser = Database.users.find(u => u.id === req.user.id) // Only root can have an empty password if (matchingUser.type !== 'root' && !newPassword) { @@ -193,14 +173,14 @@ class Auth { }) } - var compare = await this.comparePassword(password, matchingUser) + const compare = await this.comparePassword(password, matchingUser) if (!compare) { return res.json({ error: 'Invalid password' }) } - var pw = '' + let pw = '' if (newPassword) { pw = await this.hashPass(newPassword) if (!pw) { @@ -211,7 +191,8 @@ class Auth { } matchingUser.pash = pw - var success = await this.db.updateEntity('user', matchingUser) + + const success = await Database.updateUser(matchingUser) if (success) { res.json({ success: true diff --git a/server/Database.js b/server/Database.js new file mode 100644 index 00000000..332e0518 --- /dev/null +++ b/server/Database.js @@ -0,0 +1,456 @@ +const Path = require('path') +const { Sequelize } = require('sequelize') + +const packageJson = require('../package.json') +const fs = require('./libs/fsExtra') +const Logger = require('./Logger') + +const dbMigration = require('./utils/migrations/dbMigration') + +class Database { + constructor() { + this.sequelize = null + this.dbPath = null + this.isNew = false // New database.sqlite created + + // Temporarily using format of old DB + // below data should be loaded from the DB as needed + this.libraryItems = [] + this.users = [] + this.libraries = [] + this.settings = [] + this.collections = [] + this.playlists = [] + this.authors = [] + this.series = [] + this.feeds = [] + + this.serverSettings = null + this.notificationSettings = null + this.emailSettings = null + } + + get models() { + return this.sequelize?.models || {} + } + + get hasRootUser() { + return this.users.some(u => u.type === 'root') + } + + async checkHasDb() { + if (!await fs.pathExists(this.dbPath)) { + Logger.info(`[Database] database.sqlite not found at ${this.dbPath}`) + return false + } + return true + } + + async init(force = false) { + this.dbPath = Path.join(global.ConfigPath, 'database.sqlite') + + // First check if this is a new database + this.isNew = !(await this.checkHasDb()) || force + + if (!await this.connect()) { + throw new Error('Database connection failed') + } + + await this.buildModels(force) + Logger.info(`[Database] Db initialized`, Object.keys(this.sequelize.models)) + + await this.loadData(force) + } + + async connect() { + Logger.info(`[Database] Initializing db at "${this.dbPath}"`) + this.sequelize = new Sequelize({ + dialect: 'sqlite', + storage: this.dbPath, + logging: false + }) + + // Helper function + this.sequelize.uppercaseFirst = str => str ? `${str[0].toUpperCase()}${str.substr(1)}` : '' + + try { + await this.sequelize.authenticate() + Logger.info(`[Database] Db connection was successful`) + return true + } catch (error) { + Logger.error(`[Database] Failed to connect to db`, error) + return false + } + } + + buildModels(force = false) { + require('./models/User')(this.sequelize) + require('./models/Library')(this.sequelize) + require('./models/LibraryFolder')(this.sequelize) + require('./models/Book')(this.sequelize) + require('./models/Podcast')(this.sequelize) + require('./models/PodcastEpisode')(this.sequelize) + require('./models/LibraryItem')(this.sequelize) + require('./models/MediaProgress')(this.sequelize) + require('./models/Series')(this.sequelize) + require('./models/BookSeries')(this.sequelize) + require('./models/Author')(this.sequelize) + require('./models/BookAuthor')(this.sequelize) + require('./models/Collection')(this.sequelize) + require('./models/CollectionBook')(this.sequelize) + require('./models/Playlist')(this.sequelize) + require('./models/PlaylistMediaItem')(this.sequelize) + require('./models/Device')(this.sequelize) + require('./models/PlaybackSession')(this.sequelize) + require('./models/Feed')(this.sequelize) + require('./models/FeedEpisode')(this.sequelize) + require('./models/Setting')(this.sequelize) + + return this.sequelize.sync({ force }) + } + + async loadData(force = false) { + if (this.isNew && await dbMigration.checkShouldMigrate(force)) { + Logger.info(`[Database] New database was created and old database was detected - migrating old to new`) + await dbMigration.migrate(this.models) + } + + const startTime = Date.now() + + this.libraryItems = await this.models.libraryItem.getAllOldLibraryItems() + this.users = await this.models.user.getOldUsers() + this.libraries = await this.models.library.getAllOldLibraries() + this.collections = await this.models.collection.getOldCollections() + this.playlists = await this.models.playlist.getOldPlaylists() + this.authors = await this.models.author.getOldAuthors() + this.series = await this.models.series.getAllOldSeries() + this.feeds = await this.models.feed.getOldFeeds() + + const settingsData = await this.models.setting.getOldSettings() + this.settings = settingsData.settings + this.emailSettings = settingsData.emailSettings + this.serverSettings = settingsData.serverSettings + this.notificationSettings = settingsData.notificationSettings + global.ServerSettings = this.serverSettings.toJSON() + + Logger.info(`[Database] Db data loaded in ${Date.now() - startTime}ms`) + + if (packageJson.version !== this.serverSettings.version) { + Logger.info(`[Database] Server upgrade detected from ${this.serverSettings.version} to ${packageJson.version}`) + this.serverSettings.version = packageJson.version + await this.updateServerSettings() + } + } + + async createRootUser(username, pash, token) { + const newUser = await this.models.user.createRootUser(username, pash, token) + if (newUser) { + this.users.push(newUser) + return true + } + return false + } + + updateServerSettings() { + global.ServerSettings = this.serverSettings.toJSON() + return this.updateSetting(this.serverSettings) + } + + updateSetting(settings) { + return this.models.setting.updateSettingObj(settings.toJSON()) + } + + async createUser(oldUser) { + await this.models.user.createFromOld(oldUser) + this.users.push(oldUser) + return true + } + + updateUser(oldUser) { + return this.models.user.updateFromOld(oldUser) + } + + updateBulkUsers(oldUsers) { + return Promise.all(oldUsers.map(u => this.updateUser(u))) + } + + async removeUser(userId) { + await this.models.user.removeById(userId) + this.users = this.users.filter(u => u.id !== userId) + } + + upsertMediaProgress(oldMediaProgress) { + return this.models.mediaProgress.upsertFromOld(oldMediaProgress) + } + + removeMediaProgress(mediaProgressId) { + return this.models.mediaProgress.removeById(mediaProgressId) + } + + updateBulkBooks(oldBooks) { + return Promise.all(oldBooks.map(oldBook => this.models.book.saveFromOld(oldBook))) + } + + async createLibrary(oldLibrary) { + await this.models.library.createFromOld(oldLibrary) + this.libraries.push(oldLibrary) + } + + updateLibrary(oldLibrary) { + return this.models.library.updateFromOld(oldLibrary) + } + + async removeLibrary(libraryId) { + await this.models.library.removeById(libraryId) + this.libraries = this.libraries.filter(lib => lib.id !== libraryId) + } + + async createCollection(oldCollection) { + const newCollection = await this.models.collection.createFromOld(oldCollection) + // Create CollectionBooks + if (newCollection) { + const collectionBooks = [] + oldCollection.books.forEach((libraryItemId) => { + const libraryItem = this.libraryItems.filter(li => li.id === libraryItemId) + if (libraryItem) { + collectionBooks.push({ + collectionId: newCollection.id, + bookId: libraryItem.media.id + }) + } + }) + if (collectionBooks.length) { + await this.createBulkCollectionBooks(collectionBooks) + } + } + this.collections.push(oldCollection) + } + + updateCollection(oldCollection) { + const collectionBooks = [] + let order = 1 + oldCollection.books.forEach((libraryItemId) => { + const libraryItem = this.getLibraryItem(libraryItemId) + if (!libraryItem) return + collectionBooks.push({ + collectionId: oldCollection.id, + bookId: libraryItem.media.id, + order: order++ + }) + }) + return this.models.collection.fullUpdateFromOld(oldCollection, collectionBooks) + } + + async removeCollection(collectionId) { + await this.models.collection.removeById(collectionId) + this.collections = this.collections.filter(c => c.id !== collectionId) + } + + createCollectionBook(collectionBook) { + return this.models.collectionBook.create(collectionBook) + } + + createBulkCollectionBooks(collectionBooks) { + return this.models.collectionBook.bulkCreate(collectionBooks) + } + + removeCollectionBook(collectionId, bookId) { + return this.models.collectionBook.removeByIds(collectionId, bookId) + } + + async createPlaylist(oldPlaylist) { + const newPlaylist = await this.models.playlist.createFromOld(oldPlaylist) + if (newPlaylist) { + const playlistMediaItems = [] + let order = 1 + for (const mediaItemObj of oldPlaylist.items) { + const libraryItem = this.libraryItems.find(li => li.id === mediaItemObj.libraryItemId) + if (!libraryItem) continue + + let mediaItemId = libraryItem.media.id // bookId + let mediaItemType = 'book' + if (mediaItemObj.episodeId) { + mediaItemType = 'podcastEpisode' + mediaItemId = mediaItemObj.episodeId + } + playlistMediaItems.push({ + playlistId: newPlaylist.id, + mediaItemId, + mediaItemType, + order: order++ + }) + } + if (playlistMediaItems.length) { + await this.createBulkPlaylistMediaItems(playlistMediaItems) + } + } + this.playlists.push(oldPlaylist) + } + + updatePlaylist(oldPlaylist) { + const playlistMediaItems = [] + let order = 1 + oldPlaylist.items.forEach((item) => { + const libraryItem = this.getLibraryItem(item.libraryItemId) + if (!libraryItem) return + playlistMediaItems.push({ + playlistId: oldPlaylist.id, + mediaItemId: item.episodeId || libraryItem.media.id, + mediaItemType: item.episodeId ? 'podcastEpisode' : 'book', + order: order++ + }) + }) + return this.models.playlist.fullUpdateFromOld(oldPlaylist, playlistMediaItems) + } + + async removePlaylist(playlistId) { + await this.models.playlist.removeById(playlistId) + this.playlists = this.playlists.filter(p => p.id !== playlistId) + } + + createPlaylistMediaItem(playlistMediaItem) { + return this.models.playlistMediaItem.create(playlistMediaItem) + } + + createBulkPlaylistMediaItems(playlistMediaItems) { + return this.models.playlistMediaItem.bulkCreate(playlistMediaItems) + } + + removePlaylistMediaItem(playlistId, mediaItemId) { + return this.models.playlistMediaItem.removeByIds(playlistId, mediaItemId) + } + + getLibraryItem(libraryItemId) { + return this.libraryItems.find(li => li.id === libraryItemId) + } + + async createLibraryItem(oldLibraryItem) { + await this.models.libraryItem.fullCreateFromOld(oldLibraryItem) + this.libraryItems.push(oldLibraryItem) + } + + updateLibraryItem(oldLibraryItem) { + return this.models.libraryItem.fullUpdateFromOld(oldLibraryItem) + } + + async updateBulkLibraryItems(oldLibraryItems) { + let updatesMade = 0 + for (const oldLibraryItem of oldLibraryItems) { + const hasUpdates = await this.models.libraryItem.fullUpdateFromOld(oldLibraryItem) + if (hasUpdates) updatesMade++ + } + return updatesMade + } + + async createBulkLibraryItems(oldLibraryItems) { + for (const oldLibraryItem of oldLibraryItems) { + await this.models.libraryItem.fullCreateFromOld(oldLibraryItem) + this.libraryItems.push(oldLibraryItem) + } + } + + async removeLibraryItem(libraryItemId) { + await this.models.libraryItem.removeById(libraryItemId) + this.libraryItems = this.libraryItems.filter(li => li.id !== libraryItemId) + } + + createFeed(oldFeed) { + // TODO: Implement + } + + updateFeed(oldFeed) { + // TODO: Implement + } + + async removeFeed(feedId) { + await this.models.feed.removeById(feedId) + this.feeds = this.feeds.filter(f => f.id !== feedId) + } + + updateSeries(oldSeries) { + return this.models.series.updateFromOld(oldSeries) + } + + async createSeries(oldSeries) { + await this.models.series.createFromOld(oldSeries) + this.series.push(oldSeries) + } + + async createBulkSeries(oldSeriesObjs) { + await this.models.series.createBulkFromOld(oldSeriesObjs) + this.series.push(...oldSeriesObjs) + } + + async removeSeries(seriesId) { + await this.models.series.removeById(seriesId) + this.series = this.series.filter(se => se.id !== seriesId) + } + + async createAuthor(oldAuthor) { + await this.models.createFromOld(oldAuthor) + this.authors.push(oldAuthor) + } + + async createBulkAuthors(oldAuthors) { + await this.models.author.createBulkFromOld(oldAuthors) + this.authors.push(...oldAuthors) + } + + updateAuthor(oldAuthor) { + return this.models.author.updateFromOld(oldAuthor) + } + + async removeAuthor(authorId) { + await this.models.author.removeById(authorId) + this.authors = this.authors.filter(au => au.id !== authorId) + } + + async createBulkBookAuthors(bookAuthors) { + await this.models.bookAuthor.bulkCreate(bookAuthors) + this.authors.push(...bookAuthors) + } + + async removeBulkBookAuthors(authorId = null, bookId = null) { + if (!authorId && !bookId) return + await this.models.bookAuthor.removeByIds(authorId, bookId) + this.authors = this.authors.filter(au => { + if (authorId && au.authorId !== authorId) return true + if (bookId && au.bookId !== bookId) return true + return false + }) + } + + getPlaybackSessions(where = null) { + return this.models.playbackSession.getOldPlaybackSessions(where) + } + + getPlaybackSession(sessionId) { + return this.models.playbackSession.getById(sessionId) + } + + createPlaybackSession(oldSession) { + return this.models.playbackSession.createFromOld(oldSession) + } + + updatePlaybackSession(oldSession) { + return this.models.playbackSession.updateFromOld(oldSession) + } + + removePlaybackSession(sessionId) { + return this.models.playbackSession.removeById(sessionId) + } + + getDeviceByDeviceId(deviceId) { + return this.models.device.getOldDeviceByDeviceId(deviceId) + } + + updateDevice(oldDevice) { + return this.models.device.updateFromOld(oldDevice) + } + + createDevice(oldDevice) { + return this.models.device.createFromOld(oldDevice) + } +} + +module.exports = new Database() \ No newline at end of file diff --git a/server/Db.js b/server/Db.js index 185ce131..bf27314a 100644 --- a/server/Db.js +++ b/server/Db.js @@ -243,9 +243,6 @@ class Db { getLibraryItem(id) { return this.libraryItems.find(li => li.id === id) } - getLibraryItemsInLibrary(libraryId) { - return this.libraryItems.filter(li => li.libraryId === libraryId) - } async updateLibraryItem(libraryItem) { return this.updateLibraryItems([libraryItem]) @@ -269,26 +266,6 @@ class Db { }) } - async insertLibraryItem(libraryItem) { - return this.insertLibraryItems([libraryItem]) - } - - async insertLibraryItems(libraryItems) { - await Promise.all(libraryItems.map(async (li) => { - if (li && li.saveMetadata) return li.saveMetadata() - return null - })) - - return this.libraryItemsDb.insert(libraryItems).then((results) => { - Logger.debug(`[DB] Library Items inserted ${results.inserted}`) - this.libraryItems = this.libraryItems.concat(libraryItems) - return true - }).catch((error) => { - Logger.error(`[DB] Library Items insert failed ${error}`) - return false - }) - } - removeLibraryItem(id) { return this.libraryItemsDb.delete((record) => record.id === id).then((results) => { Logger.debug(`[DB] Deleted Library Items: ${results.deleted}`) @@ -303,14 +280,6 @@ class Db { return this.updateEntity('settings', this.serverSettings) } - getAllEntities(entityName) { - const entityDb = this.getEntityDb(entityName) - return entityDb.select(() => true).then((results) => results.data).catch((error) => { - Logger.error(`[DB] Failed to get all ${entityName}`, error) - return null - }) - } - insertEntities(entityName, entities) { var entityDb = this.getEntityDb(entityName) return entityDb.insert(entities).then((results) => { @@ -463,15 +432,6 @@ class Db { }) } - getAllSessions(selectFunc = () => true) { - return this.sessionsDb.select(selectFunc).then((results) => { - return results.data || [] - }).catch((error) => { - Logger.error('[Db] Failed to select sessions', error) - return [] - }) - } - getPlaybackSession(id) { return this.sessionsDb.select((pb) => pb.id == id).then((results) => { if (results.data.length) { @@ -484,15 +444,6 @@ class Db { }) } - selectUserSessions(userId) { - return this.sessionsDb.select((session) => session.userId === userId).then((results) => { - return results.data || [] - }).catch((error) => { - Logger.error(`[Db] Failed to select user sessions "${userId}"`, error) - return [] - }) - } - // Check if server was updated and previous version was earlier than param checkPreviousVersionIsBefore(version) { if (!this.previousVersion) return false diff --git a/server/Server.js b/server/Server.js index be5a8067..30f34d4b 100644 --- a/server/Server.js +++ b/server/Server.js @@ -8,18 +8,18 @@ const rateLimit = require('./libs/expressRateLimit') const { version } = require('../package.json') // Utils -const dbMigration = require('./utils/dbMigration') const filePerms = require('./utils/filePerms') const fileUtils = require('./utils/fileUtils') -const globals = require('./utils/globals') const Logger = require('./Logger') const Auth = require('./Auth') const Watcher = require('./Watcher') const Scanner = require('./scanner/Scanner') -const Db = require('./Db') +const Database = require('./Database') const SocketAuthority = require('./SocketAuthority') +const routes = require('./routes/index') + const ApiRouter = require('./routers/ApiRouter') const HlsRouter = require('./routers/HlsRouter') @@ -29,7 +29,7 @@ const CoverManager = require('./managers/CoverManager') const AbMergeManager = require('./managers/AbMergeManager') const CacheManager = require('./managers/CacheManager') const LogManager = require('./managers/LogManager') -const BackupManager = require('./managers/BackupManager') +// const BackupManager = require('./managers/BackupManager') // TODO const PlaybackSessionManager = require('./managers/PlaybackSessionManager') const PodcastManager = require('./managers/PodcastManager') const AudioMetadataMangaer = require('./managers/AudioMetadataManager') @@ -59,30 +59,30 @@ class Server { filePerms.setDefaultDirSync(global.MetadataPath, false) } - this.db = new Db() + // this.db = new Db() this.watcher = new Watcher() - this.auth = new Auth(this.db) + this.auth = new Auth() // Managers this.taskManager = new TaskManager() - this.notificationManager = new NotificationManager(this.db) - this.emailManager = new EmailManager(this.db) - this.backupManager = new BackupManager(this.db) - this.logManager = new LogManager(this.db) + this.notificationManager = new NotificationManager() + this.emailManager = new EmailManager() + // this.backupManager = new BackupManager(this.db) + this.logManager = new LogManager() this.cacheManager = new CacheManager() - this.abMergeManager = new AbMergeManager(this.db, this.taskManager) - this.playbackSessionManager = new PlaybackSessionManager(this.db) - this.coverManager = new CoverManager(this.db, this.cacheManager) - this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager, this.taskManager) - this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager) - this.rssFeedManager = new RssFeedManager(this.db) + this.abMergeManager = new AbMergeManager(this.taskManager) + this.playbackSessionManager = new PlaybackSessionManager() + this.coverManager = new CoverManager(this.cacheManager) + this.podcastManager = new PodcastManager(this.watcher, this.notificationManager, this.taskManager) + this.audioMetadataManager = new AudioMetadataMangaer(this.taskManager) + this.rssFeedManager = new RssFeedManager() - this.scanner = new Scanner(this.db, this.coverManager, this.taskManager) - this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager) + this.scanner = new Scanner(this.coverManager, this.taskManager) + this.cronManager = new CronManager(this.scanner, this.podcastManager) // Routers this.apiRouter = new ApiRouter(this) - this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager) + this.hlsRouter = new HlsRouter(this.auth, this.playbackSessionManager) Logger.logManager = this.logManager @@ -98,38 +98,28 @@ class Server { Logger.info('[Server] Init v' + version) await this.playbackSessionManager.removeOrphanStreams() - const previousVersion = await this.db.checkPreviousVersion() // Returns null if same server version - if (previousVersion) { - Logger.debug(`[Server] Upgraded from previous version ${previousVersion}`) - } - if (previousVersion && previousVersion.localeCompare('2.0.0') < 0) { // Old version data model migration - Logger.debug(`[Server] Previous version was < 2.0.0 - migration required`) - await dbMigration.migrate(this.db) - } else { - await this.db.init() - } + await Database.init(false) // Create token secret if does not exist (Added v2.1.0) - if (!this.db.serverSettings.tokenSecret) { + if (!Database.serverSettings.tokenSecret) { await this.auth.initTokenSecret() } await this.cleanUserData() // Remove invalid user item progress await this.purgeMetadata() // Remove metadata folders without library item - await this.playbackSessionManager.removeInvalidSessions() await this.cacheManager.ensureCachePaths() - await this.backupManager.init() + // await this.backupManager.init() // TODO: Implement backups await this.logManager.init() - await this.apiRouter.checkRemoveEmptySeries(this.db.series) // Remove empty series + await this.apiRouter.checkRemoveEmptySeries(Database.series) // Remove empty series await this.rssFeedManager.init() this.cronManager.init() - if (this.db.serverSettings.scannerDisableWatcher) { + if (Database.serverSettings.scannerDisableWatcher) { Logger.info(`[Server] Watcher is disabled`) this.watcher.disabled = true } else { - this.watcher.initWatcher(this.db.libraries) + this.watcher.initWatcher(Database.libraries) this.watcher.on('files', this.filesChanged.bind(this)) } } @@ -162,6 +152,7 @@ class Server { // Static folder router.use(express.static(Path.join(global.appRoot, 'static'))) + // router.use('/api/v1', routes) // TODO: New routes router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router) router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router) @@ -203,7 +194,7 @@ class Server { router.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res)) router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this)) router.post('/init', (req, res) => { - if (this.db.hasRootUser) { + if (Database.hasRootUser) { Logger.error(`[Server] attempt to init server when server already has a root user`) return res.sendStatus(500) } @@ -213,8 +204,8 @@ class Server { // status check for client to see if server has been initialized // server has been initialized if a root user exists const payload = { - isInit: this.db.hasRootUser, - language: this.db.serverSettings.language + isInit: Database.hasRootUser, + language: Database.serverSettings.language } if (!payload.isInit) { payload.ConfigPath = global.ConfigPath @@ -243,7 +234,7 @@ class Server { let rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : '' if (!rootPash) Logger.warn(`[Server] Creating root user with no password`) let rootToken = await this.auth.generateAccessToken({ userId: 'root', username: newRoot.username }) - await this.db.createRootUser(newRoot.username, rootPash, rootToken) + await Database.createRootUser(newRoot.username, rootPash, rootToken) res.sendStatus(200) } @@ -261,7 +252,7 @@ class Server { let purged = 0 await Promise.all(foldersInItemsMetadata.map(async foldername => { - const hasMatchingItem = this.db.libraryItems.find(ab => ab.id === foldername) + const hasMatchingItem = Database.libraryItems.find(ab => ab.id === foldername) if (!hasMatchingItem) { const folderPath = Path.join(itemsMetadata, foldername) Logger.debug(`[Server] Purging unused metadata ${folderPath}`) @@ -281,26 +272,26 @@ class Server { // Remove user media progress with items that no longer exist & remove seriesHideFrom that no longer exist async cleanUserData() { - for (let i = 0; i < this.db.users.length; i++) { - const _user = this.db.users[i] - let hasUpdated = false + for (const _user of Database.users) { if (_user.mediaProgress.length) { - const lengthBefore = _user.mediaProgress.length - _user.mediaProgress = _user.mediaProgress.filter(mp => { - const libraryItem = this.db.libraryItems.find(li => li.id === mp.libraryItemId) - if (!libraryItem) return false - if (mp.episodeId && (libraryItem.mediaType !== 'podcast' || !libraryItem.media.checkHasEpisode(mp.episodeId))) return false // Episode not found - return true - }) + for (const mediaProgress of _user.mediaProgress) { + const libraryItem = Database.libraryItems.find(li => li.id === mediaProgress.libraryItemId) + if (libraryItem && mediaProgress.episodeId) { + const episode = libraryItem.media.checkHasEpisode?.(mediaProgress.episodeId) + if (episode) continue + } else { + continue + } - if (lengthBefore > _user.mediaProgress.length) { - Logger.debug(`[Server] Removing ${_user.mediaProgress.length - lengthBefore} media progress data from user ${_user.username}`) - hasUpdated = true + Logger.debug(`[Server] Removing media progress ${mediaProgress.id} data from user ${_user.username}`) + await Database.removeMediaProgress(mediaProgress.id) } } + + let hasUpdated = false if (_user.seriesHideFromContinueListening.length) { _user.seriesHideFromContinueListening = _user.seriesHideFromContinueListening.filter(seriesId => { - if (!this.db.series.some(se => se.id === seriesId)) { // Series removed + if (!Database.series.some(se => se.id === seriesId)) { // Series removed hasUpdated = true return false } @@ -308,7 +299,7 @@ class Server { }) } if (hasUpdated) { - await this.db.updateEntity('user', _user) + await Database.updateUser(_user) } } } @@ -321,8 +312,8 @@ class Server { getLoginRateLimiter() { return rateLimit({ - windowMs: this.db.serverSettings.rateLimitLoginWindow, // 5 minutes - max: this.db.serverSettings.rateLimitLoginRequests, + windowMs: Database.serverSettings.rateLimitLoginWindow, // 5 minutes + max: Database.serverSettings.rateLimitLoginRequests, skipSuccessfulRequests: true, onLimitReached: this.loginLimitReached }) diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index bf6ea6f7..92d7e0f1 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -1,5 +1,6 @@ const SocketIO = require('socket.io') const Logger = require('./Logger') +const Database = require('./Database') class SocketAuthority { constructor() { @@ -18,7 +19,7 @@ class SocketAuthority { onlineUsersMap[client.user.id].connections++ } else { onlineUsersMap[client.user.id] = { - ...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, this.Server.db.libraryItems), + ...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems), connections: 1 } } @@ -107,7 +108,7 @@ class SocketAuthority { delete this.clients[socket.id] } else { Logger.debug('[Server] User Offline ' + _client.user.username) - this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, this.Server.db.libraryItems)) + this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems)) const disconnectTime = Date.now() - _client.connected_at Logger.info(`[Server] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`) @@ -160,11 +161,11 @@ class SocketAuthority { Logger.debug(`[Server] User Online ${client.user.username}`) - this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, this.Server.db.libraryItems)) + this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems)) // Update user lastSeen user.lastSeen = Date.now() - await this.Server.db.updateEntity('user', user) + await Database.updateUser(user) const initialPayload = { userId: client.user.id, @@ -186,7 +187,7 @@ class SocketAuthority { if (client.user) { Logger.debug('[Server] User Offline ' + client.user.username) - this.adminEmitter('user_offline', client.user.toJSONForPublic(null, this.Server.db.libraryItems)) + this.adminEmitter('user_offline', client.user.toJSONForPublic(null, Database.libraryItems)) } delete this.clients[socketId].user diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js index 106734a4..c217dab0 100644 --- a/server/controllers/AuthorController.js +++ b/server/controllers/AuthorController.js @@ -4,6 +4,7 @@ const { createNewSortInstance } = require('../libs/fastSort') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') +const Database = require('../Database') const { reqSupportsWebp } = require('../utils/index') @@ -21,7 +22,7 @@ class AuthorController { // Used on author landing page to include library items and items grouped in series if (include.includes('items')) { - authorJson.libraryItems = this.db.libraryItems.filter(li => { + authorJson.libraryItems = Database.libraryItems.filter(li => { if (libraryId && li.libraryId !== libraryId) return false if (!req.user.checkCanAccessLibraryItem(li)) return false // filter out library items user cannot access return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id) @@ -97,23 +98,29 @@ class AuthorController { const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name // Check if author name matches another author and merge the authors - const existingAuthor = authorNameUpdate ? this.db.authors.find(au => au.id !== req.author.id && payload.name === au.name) : false + const existingAuthor = authorNameUpdate ? Database.authors.find(au => au.id !== req.author.id && payload.name === au.name) : false if (existingAuthor) { - const itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id)) + const bookAuthorsToCreate = [] + const itemsWithAuthor = Database.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id)) itemsWithAuthor.forEach(libraryItem => { // Replace old author with merging author for each book libraryItem.media.metadata.replaceAuthor(req.author, existingAuthor) + bookAuthorsToCreate.push({ + bookId: libraryItem.media.id, + authorId: existingAuthor.id + }) }) if (itemsWithAuthor.length) { - await this.db.updateLibraryItems(itemsWithAuthor) + await Database.removeBulkBookAuthors(req.author.id) // Remove all old BookAuthor + await Database.createBulkBookAuthors(bookAuthorsToCreate) // Create all new BookAuthor SocketAuthority.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded())) } // Remove old author - await this.db.removeEntity('author', req.author.id) + await Database.removeAuthor(req.author.id) SocketAuthority.emitter('author_removed', req.author.toJSON()) // Send updated num books for merged author - const numBooks = this.db.libraryItems.filter(li => { + const numBooks = Database.libraryItems.filter(li => { return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(existingAuthor.id) }).length SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks)) @@ -131,18 +138,17 @@ class AuthorController { req.author.updatedAt = Date.now() if (authorNameUpdate) { // Update author name on all books - const itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id)) + const itemsWithAuthor = Database.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id)) itemsWithAuthor.forEach(libraryItem => { libraryItem.media.metadata.updateAuthor(req.author) }) if (itemsWithAuthor.length) { - await this.db.updateLibraryItems(itemsWithAuthor) SocketAuthority.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded())) } } - await this.db.updateEntity('author', req.author) - const numBooks = this.db.libraryItems.filter(li => { + await Database.updateAuthor(req.author) + const numBooks = Database.libraryItems.filter(li => { return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id) }).length SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks)) @@ -159,7 +165,7 @@ class AuthorController { var q = (req.query.q || '').toLowerCase() if (!q) return res.json([]) var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25 - var authors = this.db.authors.filter(au => au.name.toLowerCase().includes(q)) + var authors = Database.authors.filter(au => au.name.toLowerCase().includes(q)) authors = authors.slice(0, limit) res.json({ results: authors @@ -204,8 +210,8 @@ class AuthorController { if (hasUpdates) { req.author.updatedAt = Date.now() - await this.db.updateEntity('author', req.author) - const numBooks = this.db.libraryItems.filter(li => { + await Database.updateAuthor(req.author) + const numBooks = Database.libraryItems.filter(li => { return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id) }).length SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks)) @@ -238,7 +244,7 @@ class AuthorController { } middleware(req, res, next) { - var author = this.db.authors.find(au => au.id === req.params.id) + var author = Database.authors.find(au => au.id === req.params.id) if (!author) return res.sendStatus(404) if (req.method == 'DELETE' && !req.user.canDelete) { diff --git a/server/controllers/CollectionController.js b/server/controllers/CollectionController.js index 5557fdb7..f4702d16 100644 --- a/server/controllers/CollectionController.js +++ b/server/controllers/CollectionController.js @@ -1,5 +1,6 @@ const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') +const Database = require('../Database') const Collection = require('../objects/Collection') @@ -13,22 +14,22 @@ class CollectionController { if (!success) { return res.status(500).send('Invalid collection data') } - var jsonExpanded = newCollection.toJSONExpanded(this.db.libraryItems) - await this.db.insertEntity('collection', newCollection) + var jsonExpanded = newCollection.toJSONExpanded(Database.libraryItems) + await Database.createCollection(newCollection) SocketAuthority.emitter('collection_added', jsonExpanded) res.json(jsonExpanded) } findAll(req, res) { res.json({ - collections: this.db.collections.map(c => c.toJSONExpanded(this.db.libraryItems)) + collections: Database.collections.map(c => c.toJSONExpanded(Database.libraryItems)) }) } findOne(req, res) { const includeEntities = (req.query.include || '').split(',') - const collectionExpanded = req.collection.toJSONExpanded(this.db.libraryItems) + const collectionExpanded = req.collection.toJSONExpanded(Database.libraryItems) if (includeEntities.includes('rssfeed')) { const feedData = this.rssFeedManager.findFeedForEntityId(collectionExpanded.id) @@ -41,9 +42,9 @@ class CollectionController { async update(req, res) { const collection = req.collection const wasUpdated = collection.update(req.body) - const jsonExpanded = collection.toJSONExpanded(this.db.libraryItems) + const jsonExpanded = collection.toJSONExpanded(Database.libraryItems) if (wasUpdated) { - await this.db.updateEntity('collection', collection) + await Database.updateCollection(collection) SocketAuthority.emitter('collection_updated', jsonExpanded) } res.json(jsonExpanded) @@ -51,19 +52,19 @@ class CollectionController { async delete(req, res) { const collection = req.collection - const jsonExpanded = collection.toJSONExpanded(this.db.libraryItems) + const jsonExpanded = collection.toJSONExpanded(Database.libraryItems) // Close rss feed - remove from db and emit socket event await this.rssFeedManager.closeFeedForEntityId(collection.id) - await this.db.removeEntity('collection', collection.id) + await Database.removeCollection(collection.id) SocketAuthority.emitter('collection_removed', jsonExpanded) res.sendStatus(200) } async addBook(req, res) { const collection = req.collection - const libraryItem = this.db.libraryItems.find(li => li.id === req.body.id) + const libraryItem = Database.libraryItems.find(li => li.id === req.body.id) if (!libraryItem) { return res.status(500).send('Book not found') } @@ -74,8 +75,14 @@ class CollectionController { return res.status(500).send('Book already in collection') } collection.addBook(req.body.id) - const jsonExpanded = collection.toJSONExpanded(this.db.libraryItems) - await this.db.updateEntity('collection', collection) + const jsonExpanded = collection.toJSONExpanded(Database.libraryItems) + + const collectionBook = { + collectionId: collection.id, + bookId: libraryItem.media.id, + order: collection.books.length + } + await Database.createCollectionBook(collectionBook) SocketAuthority.emitter('collection_updated', jsonExpanded) res.json(jsonExpanded) } @@ -83,13 +90,18 @@ class CollectionController { // DELETE: api/collections/:id/book/:bookId async removeBook(req, res) { const collection = req.collection + const libraryItem = Database.libraryItems.find(li => li.id === req.params.bookId) + if (!libraryItem) { + return res.sendStatus(404) + } + if (collection.books.includes(req.params.bookId)) { collection.removeBook(req.params.bookId) - var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems) - await this.db.updateEntity('collection', collection) + const jsonExpanded = collection.toJSONExpanded(Database.libraryItems) SocketAuthority.emitter('collection_updated', jsonExpanded) + await Database.updateCollection(collection) } - res.json(collection.toJSONExpanded(this.db.libraryItems)) + res.json(collection.toJSONExpanded(Database.libraryItems)) } // POST: api/collections/:id/batch/add @@ -98,19 +110,30 @@ class CollectionController { if (!req.body.books || !req.body.books.length) { return res.status(500).send('Invalid request body') } - var bookIdsToAdd = req.body.books - var hasUpdated = false - for (let i = 0; i < bookIdsToAdd.length; i++) { - if (!collection.books.includes(bookIdsToAdd[i])) { - collection.addBook(bookIdsToAdd[i]) + const bookIdsToAdd = req.body.books + const collectionBooksToAdd = [] + let hasUpdated = false + + let order = collection.books.length + for (const libraryItemId of bookIdsToAdd) { + const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId) + if (!libraryItem) continue + if (!collection.books.includes(libraryItemId)) { + collection.addBook(libraryItemId) + collectionBooksToAdd.push({ + collectionId: collection.id, + bookId: libraryItem.media.id, + order: order++ + }) hasUpdated = true } } + if (hasUpdated) { - await this.db.updateEntity('collection', collection) - SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(this.db.libraryItems)) + await Database.createBulkCollectionBooks(collectionBooksToAdd) + SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems)) } - res.json(collection.toJSONExpanded(this.db.libraryItems)) + res.json(collection.toJSONExpanded(Database.libraryItems)) } // POST: api/collections/:id/batch/remove @@ -120,23 +143,26 @@ class CollectionController { return res.status(500).send('Invalid request body') } var bookIdsToRemove = req.body.books - var hasUpdated = false - for (let i = 0; i < bookIdsToRemove.length; i++) { - if (collection.books.includes(bookIdsToRemove[i])) { - collection.removeBook(bookIdsToRemove[i]) + let hasUpdated = false + for (const libraryItemId of bookIdsToRemove) { + const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId) + if (!libraryItem) continue + + if (collection.books.includes(libraryItemId)) { + collection.removeBook(libraryItemId) hasUpdated = true } } if (hasUpdated) { - await this.db.updateEntity('collection', collection) - SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(this.db.libraryItems)) + await Database.updateCollection(collection) + SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems)) } - res.json(collection.toJSONExpanded(this.db.libraryItems)) + res.json(collection.toJSONExpanded(Database.libraryItems)) } middleware(req, res, next) { if (req.params.id) { - const collection = this.db.collections.find(c => c.id === req.params.id) + const collection = Database.collections.find(c => c.id === req.params.id) if (!collection) { return res.status(404).send('Collection not found') } diff --git a/server/controllers/EmailController.js b/server/controllers/EmailController.js index c9dd6879..ada0f5df 100644 --- a/server/controllers/EmailController.js +++ b/server/controllers/EmailController.js @@ -1,22 +1,23 @@ const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') +const Database = require('../Database') class EmailController { constructor() { } getSettings(req, res) { res.json({ - settings: this.db.emailSettings + settings: Database.emailSettings }) } async updateSettings(req, res) { - const updated = this.db.emailSettings.update(req.body) + const updated = Database.emailSettings.update(req.body) if (updated) { - await this.db.updateEntity('settings', this.db.emailSettings) + await Database.updateSetting(Database.emailSettings) } res.json({ - settings: this.db.emailSettings + settings: Database.emailSettings }) } @@ -36,24 +37,24 @@ class EmailController { } } - const updated = this.db.emailSettings.update({ + const updated = Database.emailSettings.update({ ereaderDevices }) if (updated) { - await this.db.updateEntity('settings', this.db.emailSettings) + await Database.updateSetting(Database.emailSettings) SocketAuthority.adminEmitter('ereader-devices-updated', { - ereaderDevices: this.db.emailSettings.ereaderDevices + ereaderDevices: Database.emailSettings.ereaderDevices }) } res.json({ - ereaderDevices: this.db.emailSettings.ereaderDevices + ereaderDevices: Database.emailSettings.ereaderDevices }) } async sendEBookToDevice(req, res) { Logger.debug(`[EmailController] Send ebook to device request for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`) - const libraryItem = this.db.getLibraryItem(req.body.libraryItemId) + const libraryItem = Database.getLibraryItem(req.body.libraryItemId) if (!libraryItem) { return res.status(404).send('Library item not found') } @@ -67,7 +68,7 @@ class EmailController { return res.status(404).send('EBook file not found') } - const device = this.db.emailSettings.getEReaderDevice(req.body.deviceName) + const device = Database.emailSettings.getEReaderDevice(req.body.deviceName) if (!device) { return res.status(404).send('E-reader device not found') } diff --git a/server/controllers/FileSystemController.js b/server/controllers/FileSystemController.js index 8a51e02c..edf9b736 100644 --- a/server/controllers/FileSystemController.js +++ b/server/controllers/FileSystemController.js @@ -1,5 +1,6 @@ const Path = require('path') const Logger = require('../Logger') +const Database = require('../Database') const fs = require('../libs/fsExtra') class FileSystemController { @@ -16,7 +17,7 @@ class FileSystemController { }) // Do not include existing mapped library paths in response - this.db.libraries.forEach(lib => { + Database.libraries.forEach(lib => { lib.folders.forEach((folder) => { let dir = folder.fullPath if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '') diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 55f2c79f..62057b71 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -9,6 +9,9 @@ const { sort, createNewSortInstance } = require('../libs/fastSort') const naturalSort = createNewSortInstance({ comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare }) + +const Database = require('../Database') + class LibraryController { constructor() { } @@ -40,13 +43,13 @@ class LibraryController { } const library = new Library() - newLibraryPayload.displayOrder = this.db.libraries.length + 1 + newLibraryPayload.displayOrder = Database.libraries.length + 1 library.setData(newLibraryPayload) - await this.db.insertEntity('library', library) + await Database.createLibrary(library) // Only emit to users with access to library const userFilter = (user) => { - return user.checkCanAccessLibrary && user.checkCanAccessLibrary(library.id) + return user.checkCanAccessLibrary?.(library.id) } SocketAuthority.emitter('library_added', library.toJSON(), userFilter) @@ -58,14 +61,15 @@ class LibraryController { findAll(req, res) { const librariesAccessible = req.user.librariesAccessible || [] - if (librariesAccessible && librariesAccessible.length) { + if (librariesAccessible.length) { return res.json({ - libraries: this.db.libraries.filter(lib => librariesAccessible.includes(lib.id)).map(lib => lib.toJSON()) + libraries: Database.libraries.filter(lib => librariesAccessible.includes(lib.id)).map(lib => lib.toJSON()) }) } res.json({ - libraries: this.db.libraries.map(lib => lib.toJSON()) + libraries: Database.libraries.map(lib => lib.toJSON()) + // libraries: Database.libraries.map(lib => lib.toJSON()) }) } @@ -75,7 +79,7 @@ class LibraryController { return res.json({ filterdata: libraryHelpers.getDistinctFilterDataNew(req.libraryItems), issues: req.libraryItems.filter(li => li.hasIssues).length, - numUserPlaylists: this.db.playlists.filter(p => p.userId === req.user.id && p.libraryId === req.library.id).length, + numUserPlaylists: Database.playlists.filter(p => p.userId === req.user.id && p.libraryId === req.library.id).length, library: req.library }) } @@ -128,14 +132,14 @@ class LibraryController { this.cronManager.updateLibraryScanCron(library) // Remove libraryItems no longer in library - const itemsToRemove = this.db.libraryItems.filter(li => li.libraryId === library.id && !library.checkFullPathInLibrary(li.path)) + const itemsToRemove = Database.libraryItems.filter(li => li.libraryId === library.id && !library.checkFullPathInLibrary(li.path)) if (itemsToRemove.length) { Logger.info(`[Scanner] Updating library, removing ${itemsToRemove.length} items`) for (let i = 0; i < itemsToRemove.length; i++) { await this.handleDeleteLibraryItem(itemsToRemove[i]) } } - await this.db.updateEntity('library', library) + await Database.updateLibrary(library) // Only emit to users with access to library const userFilter = (user) => { @@ -153,21 +157,21 @@ class LibraryController { this.watcher.removeLibrary(library) // Remove collections for library - const collections = this.db.collections.filter(c => c.libraryId === library.id) + const collections = Database.collections.filter(c => c.libraryId === library.id) for (const collection of collections) { Logger.info(`[Server] deleting collection "${collection.name}" for library "${library.name}"`) - await this.db.removeEntity('collection', collection.id) + await Database.removeCollection(collection.id) } // Remove items in this library - const libraryItems = this.db.libraryItems.filter(li => li.libraryId === library.id) + const libraryItems = Database.libraryItems.filter(li => li.libraryId === library.id) Logger.info(`[Server] deleting library "${library.name}" with ${libraryItems.length} items"`) for (let i = 0; i < libraryItems.length; i++) { await this.handleDeleteLibraryItem(libraryItems[i]) } const libraryJson = library.toJSON() - await this.db.removeEntity('library', library.id) + await Database.removeLibrary(library.id) SocketAuthority.emitter('library_removed', libraryJson) return res.json(libraryJson) } @@ -209,7 +213,7 @@ class LibraryController { // If also filtering by series, will not collapse the filtered series as this would lead // to series having a collapsed series that is just that series. if (payload.collapseseries) { - let collapsedItems = libraryHelpers.collapseBookSeries(libraryItems, this.db.series, filterSeries, req.library.settings.hideSingleBookSeries) + let collapsedItems = libraryHelpers.collapseBookSeries(libraryItems, Database.series, filterSeries, req.library.settings.hideSingleBookSeries) if (!(collapsedItems.length == 1 && collapsedItems[0].collapsedSeries)) { libraryItems = collapsedItems @@ -237,7 +241,7 @@ class LibraryController { // If no series sequence then fallback to sorting by title (or collapsed series name for sub-series) sortArray.push({ asc: (li) => { - if (this.db.serverSettings.sortingIgnorePrefix) { + if (Database.serverSettings.sortingIgnorePrefix) { return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix } else { return li.collapsedSeries?.name || li.media.metadata.title @@ -255,7 +259,7 @@ class LibraryController { // Handle server setting sortingIgnorePrefix const sortByTitle = sortKey === 'media.metadata.title' - if (sortByTitle && this.db.serverSettings.sortingIgnorePrefix) { + if (sortByTitle && Database.serverSettings.sortingIgnorePrefix) { // BookMetadata.js has titleIgnorePrefix getter sortKey += 'IgnorePrefix' } @@ -267,7 +271,7 @@ class LibraryController { sortArray.push({ asc: (li) => { if (li.collapsedSeries) { - return this.db.serverSettings.sortingIgnorePrefix ? + return Database.serverSettings.sortingIgnorePrefix ? li.collapsedSeries.nameIgnorePrefix : li.collapsedSeries.name } else { @@ -284,7 +288,7 @@ class LibraryController { if (mediaIsBook && sortBySequence) { return li.media.metadata.getSeries(filterSeries).sequence } else if (mediaIsBook && sortByTitle && li.collapsedSeries) { - return this.db.serverSettings.sortingIgnorePrefix ? + return Database.serverSettings.sortingIgnorePrefix ? li.collapsedSeries.nameIgnorePrefix : li.collapsedSeries.name } else { @@ -405,7 +409,7 @@ class LibraryController { include: include.join(',') } - let series = libraryHelpers.getSeriesFromBooks(libraryItems, this.db.series, null, payload.filterBy, req.user, payload.minified, req.library.settings.hideSingleBookSeries) + let series = libraryHelpers.getSeriesFromBooks(libraryItems, Database.series, null, payload.filterBy, req.user, payload.minified, req.library.settings.hideSingleBookSeries) const direction = payload.sortDesc ? 'desc' : 'asc' series = naturalSort(series).by([ @@ -422,7 +426,7 @@ class LibraryController { } else if (payload.sortBy === 'lastBookAdded') { return Math.max(...(se.books).map(x => x.addedAt), 0) } else { // sort by name - return this.db.serverSettings.sortingIgnorePrefix ? se.nameIgnorePrefixSort : se.name + return Database.serverSettings.sortingIgnorePrefix ? se.nameIgnorePrefixSort : se.name } } } @@ -466,7 +470,7 @@ class LibraryController { include: include.join(',') } - let collections = this.db.collections.filter(c => c.libraryId === req.library.id).map(c => { + let collections = Database.collections.filter(c => c.libraryId === req.library.id).map(c => { const expanded = c.toJSONExpanded(libraryItems, payload.minified) // If all books restricted to user in this collection then hide this collection @@ -493,7 +497,7 @@ class LibraryController { // api/libraries/:id/playlists async getUserPlaylistsForLibrary(req, res) { - let playlistsForUser = this.db.playlists.filter(p => p.userId === req.user.id && p.libraryId === req.library.id).map(p => p.toJSONExpanded(this.db.libraryItems)) + let playlistsForUser = Database.playlists.filter(p => p.userId === req.user.id && p.libraryId === req.library.id).map(p => p.toJSONExpanded(Database.libraryItems)) const payload = { results: [], @@ -517,7 +521,7 @@ class LibraryController { return res.status(400).send('Invalid library media type') } - let libraryItems = this.db.libraryItems.filter(li => li.libraryId === req.library.id) + let libraryItems = Database.libraryItems.filter(li => li.libraryId === req.library.id) let albums = libraryHelpers.groupMusicLibraryItemsIntoAlbums(libraryItems) albums = naturalSort(albums).asc(a => a.title) // Alphabetical by album title @@ -561,26 +565,26 @@ class LibraryController { var orderdata = req.body var hasUpdates = false for (let i = 0; i < orderdata.length; i++) { - var library = this.db.libraries.find(lib => lib.id === orderdata[i].id) + var library = Database.libraries.find(lib => lib.id === orderdata[i].id) if (!library) { Logger.error(`[LibraryController] Invalid library not found in reorder ${orderdata[i].id}`) return res.sendStatus(500) } if (library.update({ displayOrder: orderdata[i].newOrder })) { hasUpdates = true - await this.db.updateEntity('library', library) + await Database.updateLibrary(library) } } if (hasUpdates) { - this.db.libraries.sort((a, b) => a.displayOrder - b.displayOrder) + Database.libraries.sort((a, b) => a.displayOrder - b.displayOrder) Logger.debug(`[LibraryController] Updated library display orders`) } else { Logger.debug(`[LibraryController] Library orders were up to date`) } res.json({ - libraries: this.db.libraries.map(lib => lib.toJSON()) + libraries: Database.libraries.map(lib => lib.toJSON()) }) } @@ -610,7 +614,7 @@ class LibraryController { if (queryResult.series?.length) { queryResult.series.forEach((se) => { if (!seriesMatches[se.id]) { - const _series = this.db.series.find(_se => _se.id === se.id) + const _series = Database.series.find(_se => _se.id === se.id) if (_series) seriesMatches[se.id] = { series: _series.toJSON(), books: [li.toJSON()] } } else { seriesMatches[se.id].books.push(li.toJSON()) @@ -620,7 +624,7 @@ class LibraryController { if (queryResult.authors?.length) { queryResult.authors.forEach((au) => { if (!authorMatches[au.id]) { - const _author = this.db.authors.find(_au => _au.id === au.id) + const _author = Database.authors.find(_au => _au.id === au.id) if (_author) { authorMatches[au.id] = _author.toJSON() authorMatches[au.id].numBooks = 1 @@ -687,7 +691,7 @@ class LibraryController { if (li.media.metadata.authors && li.media.metadata.authors.length) { li.media.metadata.authors.forEach((au) => { if (!authors[au.id]) { - const _author = this.db.authors.find(_au => _au.id === au.id) + const _author = Database.authors.find(_au => _au.id === au.id) if (_author) { authors[au.id] = _author.toJSON() authors[au.id].numBooks = 1 @@ -749,7 +753,7 @@ class LibraryController { } if (itemsUpdated.length) { - await this.db.updateLibraryItems(itemsUpdated) + await Database.updateBulkBooks(itemsUpdated.map(i => i.media)) SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded())) } @@ -774,7 +778,7 @@ class LibraryController { } if (itemsUpdated.length) { - await this.db.updateLibraryItems(itemsUpdated) + await Database.updateBulkBooks(itemsUpdated.map(i => i.media)) SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded())) } @@ -858,12 +862,12 @@ class LibraryController { return res.sendStatus(404) } - const library = this.db.libraries.find(lib => lib.id === req.params.id) + const library = Database.libraries.find(lib => lib.id === req.params.id) if (!library) { return res.status(404).send('Library not found') } req.library = library - req.libraryItems = this.db.libraryItems.filter(li => { + req.libraryItems = Database.libraryItems.filter(li => { return li.libraryId === library.id && req.user.checkCanAccessLibraryItem(li) }) next() diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 085ff03f..67c19052 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -2,9 +2,10 @@ const Path = require('path') const fs = require('../libs/fsExtra') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') +const Database = require('../Database') const zipHelpers = require('../utils/zipHelpers') -const { reqSupportsWebp, isNullOrNaN } = require('../utils/index') +const { reqSupportsWebp } = require('../utils/index') const { ScanResult } = require('../utils/constants') const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils') @@ -31,7 +32,7 @@ class LibraryItemController { if (item.mediaType == 'book') { if (includeEntities.includes('authors')) { item.media.metadata.authors = item.media.metadata.authors.map(au => { - var author = this.db.authors.find(_au => _au.id === au.id) + var author = Database.authors.find(_au => _au.id === au.id) if (!author) return null return { ...author @@ -61,7 +62,7 @@ class LibraryItemController { const hasUpdates = libraryItem.update(req.body) if (hasUpdates) { Logger.debug(`[LibraryItemController] Updated now saving`) - await this.db.updateLibraryItem(libraryItem) + await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) } res.json(libraryItem.toJSON()) @@ -139,7 +140,7 @@ class LibraryItemController { } Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) - await this.db.updateLibraryItem(libraryItem) + await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) } res.json({ @@ -174,7 +175,7 @@ class LibraryItemController { return res.status(500).send('Unknown error occurred') } - await this.db.updateLibraryItem(libraryItem) + await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) res.json({ success: true, @@ -194,7 +195,7 @@ class LibraryItemController { return res.status(500).send(validationResult.error) } if (validationResult.updated) { - await this.db.updateLibraryItem(libraryItem) + await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) } res.json({ @@ -210,7 +211,7 @@ class LibraryItemController { if (libraryItem.media.coverPath) { libraryItem.updateMediaCover('') await this.cacheManager.purgeCoverCache(libraryItem.id) - await this.db.updateLibraryItem(libraryItem) + await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) } @@ -282,7 +283,7 @@ class LibraryItemController { return res.sendStatus(500) } libraryItem.media.updateAudioTracks(orderedFileData) - await this.db.updateLibraryItem(libraryItem) + await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) res.json(libraryItem.toJSON()) } @@ -309,7 +310,7 @@ class LibraryItemController { return res.sendStatus(500) } - const itemsToDelete = this.db.libraryItems.filter(li => libraryItemIds.includes(li.id)) + const itemsToDelete = Database.libraryItems.filter(li => libraryItemIds.includes(li.id)) if (!itemsToDelete.length) { return res.sendStatus(404) } @@ -338,7 +339,7 @@ class LibraryItemController { for (let i = 0; i < updatePayloads.length; i++) { var mediaPayload = updatePayloads[i].mediaPayload - var libraryItem = this.db.libraryItems.find(_li => _li.id === updatePayloads[i].id) + var libraryItem = Database.libraryItems.find(_li => _li.id === updatePayloads[i].id) if (!libraryItem) return null await this.createAuthorsAndSeriesForItemUpdate(mediaPayload) @@ -346,7 +347,7 @@ class LibraryItemController { var hasUpdates = libraryItem.media.update(mediaPayload) if (hasUpdates) { Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) - await this.db.updateLibraryItem(libraryItem) + await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) itemsUpdated++ } @@ -366,7 +367,7 @@ class LibraryItemController { } const libraryItems = [] libraryItemIds.forEach((lid) => { - const li = this.db.libraryItems.find(_li => _li.id === lid) + const li = Database.libraryItems.find(_li => _li.id === lid) if (li) libraryItems.push(li.toJSONExpanded()) }) res.json({ @@ -389,7 +390,7 @@ class LibraryItemController { return res.sendStatus(400) } - const libraryItems = req.body.libraryItemIds.map(lid => this.db.getLibraryItem(lid)).filter(li => li) + const libraryItems = req.body.libraryItemIds.map(lid => Database.getLibraryItem(lid)).filter(li => li) if (!libraryItems?.length) { return res.sendStatus(400) } @@ -424,7 +425,7 @@ class LibraryItemController { return res.sendStatus(400) } - const libraryItems = req.body.libraryItemIds.map(lid => this.db.getLibraryItem(lid)).filter(li => li) + const libraryItems = req.body.libraryItemIds.map(lid => Database.getLibraryItem(lid)).filter(li => li) if (!libraryItems?.length) { return res.sendStatus(400) } @@ -441,15 +442,17 @@ class LibraryItemController { } // DELETE: api/items/all + // TODO: Remove async deleteAll(req, res) { - if (!req.user.isAdminOrUp) { - Logger.warn('User other than admin attempted to delete all library items', req.user) - return res.sendStatus(403) - } - Logger.info('Removing all Library Items') - var success = await this.db.recreateLibraryItemsDb() - if (success) res.sendStatus(200) - else res.sendStatus(500) + return res.sendStatus(404) + // if (!req.user.isAdminOrUp) { + // Logger.warn('User other than admin attempted to delete all library items', req.user) + // return res.sendStatus(403) + // } + // Logger.info('Removing all Library Items') + // var success = await this.db.recreateLibraryItemsDb() + // if (success) res.sendStatus(200) + // else res.sendStatus(500) } // POST: api/items/:id/scan (admin) @@ -504,7 +507,7 @@ class LibraryItemController { const chapters = req.body.chapters || [] const wasUpdated = req.libraryItem.media.updateChapters(chapters) if (wasUpdated) { - await this.db.updateLibraryItem(req.libraryItem) + await Database.updateLibraryItem(req.libraryItem) SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) } @@ -586,7 +589,7 @@ class LibraryItemController { } } req.libraryItem.updatedAt = Date.now() - await this.db.updateLibraryItem(req.libraryItem) + await Database.updateLibraryItem(req.libraryItem) SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) res.sendStatus(200) } @@ -682,13 +685,13 @@ class LibraryItemController { } req.libraryItem.updatedAt = Date.now() - await this.db.updateLibraryItem(req.libraryItem) + await Database.updateLibraryItem(req.libraryItem) SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) res.sendStatus(200) } middleware(req, res, next) { - req.libraryItem = this.db.libraryItems.find(li => li.id === req.params.id) + req.libraryItem = Database.libraryItems.find(li => li.id === req.params.id) if (!req.libraryItem?.media) return res.sendStatus(404) // Check user can access this library item diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index 7b8d4348..9899c742 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -1,7 +1,8 @@ const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') +const Database = require('../Database') const { sort } = require('../libs/fastSort') -const { isObject, toNumber } = require('../utils/index') +const { toNumber } = require('../utils/index') class MeController { constructor() { } @@ -33,7 +34,7 @@ class MeController { // GET: api/me/listening-stats async getListeningStats(req, res) { - var listeningStats = await this.getUserListeningStatsHelpers(req.user.id) + const listeningStats = await this.getUserListeningStatsHelpers(req.user.id) res.json(listeningStats) } @@ -51,21 +52,21 @@ class MeController { if (!req.user.removeMediaProgress(req.params.id)) { return res.sendStatus(200) } - await this.db.updateEntity('user', req.user) + await Database.removeMediaProgress(req.params.id) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) res.sendStatus(200) } // PATCH: api/me/progress/:id async createUpdateMediaProgress(req, res) { - var libraryItem = this.db.libraryItems.find(ab => ab.id === req.params.id) + const libraryItem = Database.libraryItems.find(ab => ab.id === req.params.id) if (!libraryItem) { return res.status(404).send('Item not found') } - var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body) - if (wasUpdated) { - await this.db.updateEntity('user', req.user) + if (req.user.createUpdateMediaProgress(libraryItem, req.body)) { + const mediaProgress = req.user.getMediaProgress(libraryItem.id) + if (mediaProgress) await Database.upsertMediaProgress(mediaProgress) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) } res.sendStatus(200) @@ -73,8 +74,8 @@ class MeController { // PATCH: api/me/progress/:id/:episodeId async createUpdateEpisodeMediaProgress(req, res) { - var episodeId = req.params.episodeId - var libraryItem = this.db.libraryItems.find(ab => ab.id === req.params.id) + const episodeId = req.params.episodeId + const libraryItem = Database.libraryItems.find(ab => ab.id === req.params.id) if (!libraryItem) { return res.status(404).send('Item not found') } @@ -83,9 +84,9 @@ class MeController { return res.status(404).send('Episode not found') } - var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body, episodeId) - if (wasUpdated) { - await this.db.updateEntity('user', req.user) + if (req.user.createUpdateMediaProgress(libraryItem, req.body, episodeId)) { + const mediaProgress = req.user.getMediaProgress(libraryItem.id, episodeId) + if (mediaProgress) await Database.upsertMediaProgress(mediaProgress) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) } res.sendStatus(200) @@ -93,24 +94,26 @@ class MeController { // PATCH: api/me/progress/batch/update async batchUpdateMediaProgress(req, res) { - var itemProgressPayloads = req.body - if (!itemProgressPayloads || !itemProgressPayloads.length) { + const itemProgressPayloads = req.body + if (!itemProgressPayloads?.length) { return res.status(400).send('Missing request payload') } - var shouldUpdate = false - itemProgressPayloads.forEach((itemProgress) => { - var libraryItem = this.db.libraryItems.find(li => li.id === itemProgress.libraryItemId) // Make sure this library item exists + let shouldUpdate = false + for (const itemProgress of itemProgressPayloads) { + const libraryItem = Database.libraryItems.find(li => li.id === itemProgress.libraryItemId) // Make sure this library item exists if (libraryItem) { - var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, itemProgress, itemProgress.episodeId) - if (wasUpdated) shouldUpdate = true + if (req.user.createUpdateMediaProgress(libraryItem, itemProgress, itemProgress.episodeId)) { + const mediaProgress = req.user.getMediaProgress(libraryItem.id, itemProgress.episodeId) + if (mediaProgress) await Database.upsertMediaProgress(mediaProgress) + shouldUpdate = true + } } else { Logger.error(`[MeController] batchUpdateMediaProgress: Library Item does not exist ${itemProgress.id}`) } - }) + } if (shouldUpdate) { - await this.db.updateEntity('user', req.user) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) } @@ -119,18 +122,18 @@ class MeController { // POST: api/me/item/:id/bookmark async createBookmark(req, res) { - var libraryItem = this.db.libraryItems.find(li => li.id === req.params.id) + var libraryItem = Database.libraryItems.find(li => li.id === req.params.id) if (!libraryItem) return res.sendStatus(404) const { time, title } = req.body var bookmark = req.user.createBookmark(libraryItem.id, time, title) - await this.db.updateEntity('user', req.user) + await Database.updateUser(req.user) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) res.json(bookmark) } // PATCH: api/me/item/:id/bookmark async updateBookmark(req, res) { - var libraryItem = this.db.libraryItems.find(li => li.id === req.params.id) + var libraryItem = Database.libraryItems.find(li => li.id === req.params.id) if (!libraryItem) return res.sendStatus(404) const { time, title } = req.body if (!req.user.findBookmark(libraryItem.id, time)) { @@ -139,14 +142,14 @@ class MeController { } var bookmark = req.user.updateBookmark(libraryItem.id, time, title) if (!bookmark) return res.sendStatus(500) - await this.db.updateEntity('user', req.user) + await Database.updateUser(req.user) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) res.json(bookmark) } // DELETE: api/me/item/:id/bookmark/:time async removeBookmark(req, res) { - var libraryItem = this.db.libraryItems.find(li => li.id === req.params.id) + var libraryItem = Database.libraryItems.find(li => li.id === req.params.id) if (!libraryItem) return res.sendStatus(404) var time = Number(req.params.time) if (isNaN(time)) return res.sendStatus(500) @@ -156,7 +159,7 @@ class MeController { return res.sendStatus(404) } req.user.removeBookmark(libraryItem.id, time) - await this.db.updateEntity('user', req.user) + await Database.updateUser(req.user) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) res.sendStatus(200) } @@ -178,16 +181,16 @@ class MeController { return res.sendStatus(500) } const updatedLocalMediaProgress = [] - var numServerProgressUpdates = 0 + let numServerProgressUpdates = 0 const updatedServerMediaProgress = [] const localMediaProgress = req.body.localMediaProgress || [] - localMediaProgress.forEach(localProgress => { + for (const localProgress of localMediaProgress) { if (!localProgress.libraryItemId) { Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object`, localProgress) return } - var libraryItem = this.db.getLibraryItem(localProgress.libraryItemId) + const libraryItem = Database.getLibraryItem(localProgress.libraryItemId) if (!libraryItem) { Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object no library item`, localProgress) return @@ -199,12 +202,14 @@ class MeController { Logger.debug(`[MeController] syncLocalMediaProgress local progress is new - creating ${localProgress.id}`) req.user.createUpdateMediaProgress(libraryItem, localProgress, localProgress.episodeId) mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId) + if (mediaProgress) await Database.upsertMediaProgress(mediaProgress) updatedServerMediaProgress.push(mediaProgress) numServerProgressUpdates++ } else if (mediaProgress.lastUpdate < localProgress.lastUpdate) { Logger.debug(`[MeController] syncLocalMediaProgress local progress is more recent - updating ${mediaProgress.id}`) req.user.createUpdateMediaProgress(libraryItem, localProgress, localProgress.episodeId) mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId) + if (mediaProgress) await Database.upsertMediaProgress(mediaProgress) updatedServerMediaProgress.push(mediaProgress) numServerProgressUpdates++ } else if (mediaProgress.lastUpdate > localProgress.lastUpdate) { @@ -222,11 +227,10 @@ class MeController { } else { Logger.debug(`[MeController] syncLocalMediaProgress server and local are in sync - ${mediaProgress.id}`) } - }) + } Logger.debug(`[MeController] syncLocalMediaProgress server updates = ${numServerProgressUpdates}, local updates = ${updatedLocalMediaProgress.length}`) if (numServerProgressUpdates > 0) { - await this.db.updateEntity('user', req.user) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) } @@ -244,7 +248,7 @@ class MeController { let itemsInProgress = [] for (const mediaProgress of req.user.mediaProgress) { if (!mediaProgress.isFinished && (mediaProgress.progress > 0 || mediaProgress.ebookProgress > 0)) { - const libraryItem = this.db.getLibraryItem(mediaProgress.libraryItemId) + const libraryItem = Database.getLibraryItem(mediaProgress.libraryItemId) if (libraryItem) { if (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') { const episode = libraryItem.media.episodes.find(ep => ep.id === mediaProgress.episodeId) @@ -274,7 +278,7 @@ class MeController { // GET: api/me/series/:id/remove-from-continue-listening async removeSeriesFromContinueListening(req, res) { - const series = this.db.series.find(se => se.id === req.params.id) + const series = Database.series.find(se => se.id === req.params.id) if (!series) { Logger.error(`[MeController] removeSeriesFromContinueListening: Series ${req.params.id} not found`) return res.sendStatus(404) @@ -282,7 +286,7 @@ class MeController { const hasUpdated = req.user.addSeriesToHideFromContinueListening(req.params.id) if (hasUpdated) { - await this.db.updateEntity('user', req.user) + await Database.updateUser(req.user) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) } res.json(req.user.toJSONForBrowser()) @@ -290,7 +294,7 @@ class MeController { // GET: api/me/series/:id/readd-to-continue-listening async readdSeriesFromContinueListening(req, res) { - const series = this.db.series.find(se => se.id === req.params.id) + const series = Database.series.find(se => se.id === req.params.id) if (!series) { Logger.error(`[MeController] readdSeriesFromContinueListening: Series ${req.params.id} not found`) return res.sendStatus(404) @@ -298,7 +302,7 @@ class MeController { const hasUpdated = req.user.removeSeriesFromHideFromContinueListening(req.params.id) if (hasUpdated) { - await this.db.updateEntity('user', req.user) + await Database.updateUser(req.user) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) } res.json(req.user.toJSONForBrowser()) @@ -308,7 +312,7 @@ class MeController { async removeItemFromContinueListening(req, res) { const hasUpdated = req.user.removeProgressFromContinueListening(req.params.id) if (hasUpdated) { - await this.db.updateEntity('user', req.user) + await Database.updateUser(req.user) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) } res.json(req.user.toJSONForBrowser()) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index ec0cc447..cd7f12d5 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -2,6 +2,7 @@ const Path = require('path') const fs = require('../libs/fsExtra') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') +const Database = require('../Database') const filePerms = require('../utils/filePerms') const patternValidation = require('../libs/nodeCron/pattern-validation') @@ -30,7 +31,7 @@ class MiscController { var libraryId = req.body.library var folderId = req.body.folder - var library = this.db.libraries.find(lib => lib.id === libraryId) + var library = Database.libraries.find(lib => lib.id === libraryId) if (!library) { return res.status(404).send(`Library not found with id ${libraryId}`) } @@ -116,18 +117,18 @@ class MiscController { return res.status(500).send('Invalid settings update object') } - var madeUpdates = this.db.serverSettings.update(settingsUpdate) + var madeUpdates = Database.serverSettings.update(settingsUpdate) if (madeUpdates) { // If backup schedule is updated - update backup manager if (settingsUpdate.backupSchedule !== undefined) { this.backupManager.updateCronSchedule() } - await this.db.updateServerSettings() + await Database.updateServerSettings() } return res.json({ success: true, - serverSettings: this.db.serverSettings.toJSONForBrowser() + serverSettings: Database.serverSettings.toJSONForBrowser() }) } @@ -147,7 +148,7 @@ class MiscController { return res.sendStatus(404) } const tags = [] - this.db.libraryItems.forEach((li) => { + Database.libraryItems.forEach((li) => { if (li.media.tags && li.media.tags.length) { li.media.tags.forEach((tag) => { if (!tags.includes(tag)) tags.push(tag) @@ -176,7 +177,7 @@ class MiscController { let tagMerged = false let numItemsUpdated = 0 - for (const li of this.db.libraryItems) { + for (const li of Database.libraryItems) { if (!li.media.tags || !li.media.tags.length) continue if (li.media.tags.includes(newTag)) tagMerged = true // new tag is an existing tag so this is a merge @@ -187,7 +188,7 @@ class MiscController { li.media.tags.push(newTag) // Add new tag } Logger.debug(`[MiscController] Rename tag "${tag}" to "${newTag}" for item "${li.media.metadata.title}"`) - await this.db.updateLibraryItem(li) + await Database.updateLibraryItem(li) SocketAuthority.emitter('item_updated', li.toJSONExpanded()) numItemsUpdated++ } @@ -209,13 +210,13 @@ class MiscController { const tag = Buffer.from(decodeURIComponent(req.params.tag), 'base64').toString() let numItemsUpdated = 0 - for (const li of this.db.libraryItems) { + for (const li of Database.libraryItems) { if (!li.media.tags || !li.media.tags.length) continue if (li.media.tags.includes(tag)) { li.media.tags = li.media.tags.filter(t => t !== tag) Logger.debug(`[MiscController] Remove tag "${tag}" from item "${li.media.metadata.title}"`) - await this.db.updateLibraryItem(li) + await Database.updateLibraryItem(li) SocketAuthority.emitter('item_updated', li.toJSONExpanded()) numItemsUpdated++ } @@ -233,7 +234,7 @@ class MiscController { return res.sendStatus(404) } const genres = [] - this.db.libraryItems.forEach((li) => { + Database.libraryItems.forEach((li) => { if (li.media.metadata.genres && li.media.metadata.genres.length) { li.media.metadata.genres.forEach((genre) => { if (!genres.includes(genre)) genres.push(genre) @@ -262,7 +263,7 @@ class MiscController { let genreMerged = false let numItemsUpdated = 0 - for (const li of this.db.libraryItems) { + for (const li of Database.libraryItems) { if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue if (li.media.metadata.genres.includes(newGenre)) genreMerged = true // new genre is an existing genre so this is a merge @@ -273,7 +274,7 @@ class MiscController { li.media.metadata.genres.push(newGenre) // Add new genre } Logger.debug(`[MiscController] Rename genre "${genre}" to "${newGenre}" for item "${li.media.metadata.title}"`) - await this.db.updateLibraryItem(li) + await Database.updateLibraryItem(li) SocketAuthority.emitter('item_updated', li.toJSONExpanded()) numItemsUpdated++ } @@ -295,13 +296,13 @@ class MiscController { const genre = Buffer.from(decodeURIComponent(req.params.genre), 'base64').toString() let numItemsUpdated = 0 - for (const li of this.db.libraryItems) { + for (const li of Database.libraryItems) { if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue if (li.media.metadata.genres.includes(genre)) { li.media.metadata.genres = li.media.metadata.genres.filter(t => t !== genre) Logger.debug(`[MiscController] Remove genre "${genre}" from item "${li.media.metadata.title}"`) - await this.db.updateLibraryItem(li) + await Database.updateLibraryItem(li) SocketAuthority.emitter('item_updated', li.toJSONExpanded()) numItemsUpdated++ } diff --git a/server/controllers/NotificationController.js b/server/controllers/NotificationController.js index 5714a816..8b94a9bb 100644 --- a/server/controllers/NotificationController.js +++ b/server/controllers/NotificationController.js @@ -1,4 +1,5 @@ const Logger = require('../Logger') +const Database = require('../Database') const { version } = require('../../package.json') class NotificationController { @@ -7,14 +8,14 @@ class NotificationController { get(req, res) { res.json({ data: this.notificationManager.getData(), - settings: this.db.notificationSettings + settings: Database.notificationSettings }) } async update(req, res) { - const updated = this.db.notificationSettings.update(req.body) + const updated = Database.notificationSettings.update(req.body) if (updated) { - await this.db.updateEntity('settings', this.db.notificationSettings) + await Database.updateSetting(Database.notificationSettings) } res.sendStatus(200) } @@ -29,31 +30,31 @@ class NotificationController { } async createNotification(req, res) { - const success = this.db.notificationSettings.createNotification(req.body) + const success = Database.notificationSettings.createNotification(req.body) if (success) { - await this.db.updateEntity('settings', this.db.notificationSettings) + await Database.updateSetting(Database.notificationSettings) } - res.json(this.db.notificationSettings) + res.json(Database.notificationSettings) } async deleteNotification(req, res) { - if (this.db.notificationSettings.removeNotification(req.notification.id)) { - await this.db.updateEntity('settings', this.db.notificationSettings) + if (Database.notificationSettings.removeNotification(req.notification.id)) { + await Database.updateSetting(Database.notificationSettings) } - res.json(this.db.notificationSettings) + res.json(Database.notificationSettings) } async updateNotification(req, res) { - const success = this.db.notificationSettings.updateNotification(req.body) + const success = Database.notificationSettings.updateNotification(req.body) if (success) { - await this.db.updateEntity('settings', this.db.notificationSettings) + await Database.updateSetting(Database.notificationSettings) } - res.json(this.db.notificationSettings) + res.json(Database.notificationSettings) } async sendNotificationTest(req, res) { - if (!this.db.notificationSettings.isUseable) return res.status(500).send('Apprise is not configured') + if (!Database.notificationSettings.isUseable) return res.status(500).send('Apprise is not configured') const success = await this.notificationManager.sendTestNotification(req.notification) if (success) res.sendStatus(200) @@ -66,7 +67,7 @@ class NotificationController { } if (req.params.id) { - const notification = this.db.notificationSettings.getNotification(req.params.id) + const notification = Database.notificationSettings.getNotification(req.params.id) if (!notification) { return res.sendStatus(404) } diff --git a/server/controllers/PlaylistController.js b/server/controllers/PlaylistController.js index e923cf82..92eb4f37 100644 --- a/server/controllers/PlaylistController.js +++ b/server/controllers/PlaylistController.js @@ -1,5 +1,6 @@ const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') +const Database = require('../Database') const Playlist = require('../objects/Playlist') @@ -14,8 +15,8 @@ class PlaylistController { if (!success) { return res.status(400).send('Invalid playlist request data') } - const jsonExpanded = newPlaylist.toJSONExpanded(this.db.libraryItems) - await this.db.insertEntity('playlist', newPlaylist) + const jsonExpanded = newPlaylist.toJSONExpanded(Database.libraryItems) + await Database.createPlaylist(newPlaylist) SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded) res.json(jsonExpanded) } @@ -23,22 +24,22 @@ class PlaylistController { // GET: api/playlists findAllForUser(req, res) { res.json({ - playlists: this.db.playlists.filter(p => p.userId === req.user.id).map(p => p.toJSONExpanded(this.db.libraryItems)) + playlists: Database.playlists.filter(p => p.userId === req.user.id).map(p => p.toJSONExpanded(Database.libraryItems)) }) } // GET: api/playlists/:id findOne(req, res) { - res.json(req.playlist.toJSONExpanded(this.db.libraryItems)) + res.json(req.playlist.toJSONExpanded(Database.libraryItems)) } // PATCH: api/playlists/:id async update(req, res) { const playlist = req.playlist let wasUpdated = playlist.update(req.body) - const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems) + const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems) if (wasUpdated) { - await this.db.updateEntity('playlist', playlist) + await Database.updatePlaylist(playlist) SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded) } res.json(jsonExpanded) @@ -47,8 +48,8 @@ class PlaylistController { // DELETE: api/playlists/:id async delete(req, res) { const playlist = req.playlist - const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems) - await this.db.removeEntity('playlist', playlist.id) + const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems) + await Database.removePlaylist(playlist.id) SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded) res.sendStatus(200) } @@ -62,7 +63,7 @@ class PlaylistController { return res.status(400).send('Request body has no libraryItemId') } - const libraryItem = this.db.libraryItems.find(li => li.id === itemToAdd.libraryItemId) + const libraryItem = Database.libraryItems.find(li => li.id === itemToAdd.libraryItemId) if (!libraryItem) { return res.status(400).send('Library item not found') } @@ -80,8 +81,16 @@ class PlaylistController { } playlist.addItem(itemToAdd.libraryItemId, itemToAdd.episodeId) - const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems) - await this.db.updateEntity('playlist', playlist) + + const playlistMediaItem = { + playlistId: playlist.id, + mediaItemId: itemToAdd.episodeId || libraryItem.media.id, + mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book', + order: playlist.items.length + } + + const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems) + await Database.createPlaylistMediaItem(playlistMediaItem) SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded) res.json(jsonExpanded) } @@ -99,15 +108,15 @@ class PlaylistController { playlist.removeItem(itemToRemove.libraryItemId, itemToRemove.episodeId) - const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems) + const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems) // Playlist is removed when there are no items if (!playlist.items.length) { Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`) - await this.db.removeEntity('playlist', playlist.id) + await Database.removePlaylist(playlist.id) SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded) } else { - await this.db.updateEntity('playlist', playlist) + await Database.updatePlaylist(playlist) SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded) } @@ -122,20 +131,34 @@ class PlaylistController { } const itemsToAdd = req.body.items let hasUpdated = false + + let order = playlist.items.length + const playlistMediaItems = [] for (const item of itemsToAdd) { if (!item.libraryItemId) { return res.status(400).send('Item does not have libraryItemId') } + const libraryItem = Database.getLibraryItem(item.libraryItemId) + if (!libraryItem) { + return res.status(400).send('Item not found with id ' + item.libraryItemId) + } + if (!playlist.containsItem(item)) { + playlistMediaItems.push({ + playlistId: playlist.id, + mediaItemId: item.episodeId || libraryItem.media.id, // podcastEpisodeId or bookId + mediaItemType: item.episodeId ? 'podcastEpisode' : 'book', + order: order++ + }) playlist.addItem(item.libraryItemId, item.episodeId) hasUpdated = true } } - const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems) + const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems) if (hasUpdated) { - await this.db.updateEntity('playlist', playlist) + await Database.createBulkPlaylistMediaItems(playlistMediaItems) SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded) } res.json(jsonExpanded) @@ -153,21 +176,22 @@ class PlaylistController { if (!item.libraryItemId) { return res.status(400).send('Item does not have libraryItemId') } + if (playlist.containsItem(item)) { playlist.removeItem(item.libraryItemId, item.episodeId) hasUpdated = true } } - const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems) + const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems) if (hasUpdated) { // Playlist is removed when there are no items if (!playlist.items.length) { Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`) - await this.db.removeEntity('playlist', playlist.id) + await Database.removePlaylist(playlist.id) SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded) } else { - await this.db.updateEntity('playlist', playlist) + await Database.updatePlaylist(playlist) SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded) } } @@ -176,12 +200,12 @@ class PlaylistController { // POST: api/playlists/collection/:collectionId async createFromCollection(req, res) { - let collection = this.db.collections.find(c => c.id === req.params.collectionId) + let collection = Database.collections.find(c => c.id === req.params.collectionId) if (!collection) { return res.status(404).send('Collection not found') } // Expand collection to get library items - collection = collection.toJSONExpanded(this.db.libraryItems) + collection = collection.toJSONExpanded(Database.libraryItems) // Filter out library items not accessible to user const libraryItems = collection.books.filter(item => req.user.checkCanAccessLibraryItem(item)) @@ -201,15 +225,15 @@ class PlaylistController { } newPlaylist.setData(newPlaylistData) - const jsonExpanded = newPlaylist.toJSONExpanded(this.db.libraryItems) - await this.db.insertEntity('playlist', newPlaylist) + const jsonExpanded = newPlaylist.toJSONExpanded(Database.libraryItems) + await Database.createPlaylist(newPlaylist) SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded) res.json(jsonExpanded) } middleware(req, res, next) { if (req.params.id) { - const playlist = this.db.playlists.find(p => p.id === req.params.id) + const playlist = Database.playlists.find(p => p.id === req.params.id) if (!playlist) { return res.status(404).send('Playlist not found') } diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index 9bde330d..fbcf007f 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -1,5 +1,6 @@ const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') +const Database = require('../Database') const fs = require('../libs/fsExtra') @@ -18,7 +19,7 @@ class PodcastController { } const payload = req.body - const library = this.db.libraries.find(lib => lib.id === payload.libraryId) + const library = Database.libraries.find(lib => lib.id === payload.libraryId) if (!library) { Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`) return res.status(404).send('Library not found') @@ -33,7 +34,7 @@ class PodcastController { const podcastPath = filePathToPOSIX(payload.path) // Check if a library item with this podcast folder exists already - const existingLibraryItem = this.db.libraryItems.find(li => li.path === podcastPath && li.libraryId === library.id) + const existingLibraryItem = Database.libraryItems.find(li => li.path === podcastPath && li.libraryId === library.id) if (existingLibraryItem) { Logger.error(`[PodcastController] Podcast already exists with name "${existingLibraryItem.media.metadata.title}" at path "${podcastPath}"`) return res.status(400).send('Podcast already exists') @@ -80,7 +81,7 @@ class PodcastController { } } - await this.db.insertLibraryItem(libraryItem) + await Database.createLibraryItem(libraryItem) SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded()) res.json(libraryItem.toJSONExpanded()) @@ -199,7 +200,7 @@ class PodcastController { const overrideDetails = req.query.override === '1' const episodesUpdated = await this.scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails }) if (episodesUpdated) { - await this.db.updateLibraryItem(req.libraryItem) + await Database.updateLibraryItem(req.libraryItem) SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) } @@ -216,9 +217,8 @@ class PodcastController { return res.status(404).send('Episode not found') } - var wasUpdated = libraryItem.media.updateEpisode(episodeId, req.body) - if (wasUpdated) { - await this.db.updateLibraryItem(libraryItem) + if (libraryItem.media.updateEpisode(episodeId, req.body)) { + await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) } @@ -267,13 +267,13 @@ class PodcastController { libraryItem.removeLibraryFile(episodeRemoved.audioFile.ino) } - await this.db.updateLibraryItem(libraryItem) + await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) res.json(libraryItem.toJSON()) } middleware(req, res, next) { - const item = this.db.libraryItems.find(li => li.id === req.params.id) + const item = Database.libraryItems.find(li => li.id === req.params.id) if (!item || !item.media) return res.sendStatus(404) if (!item.isPodcast) { diff --git a/server/controllers/RSSFeedController.js b/server/controllers/RSSFeedController.js index b9a08e11..aa5e5dda 100644 --- a/server/controllers/RSSFeedController.js +++ b/server/controllers/RSSFeedController.js @@ -1,5 +1,5 @@ const Logger = require('../Logger') -const SocketAuthority = require('../SocketAuthority') +const Database = require('../Database') class RSSFeedController { constructor() { } @@ -8,7 +8,7 @@ class RSSFeedController { async openRSSFeedForItem(req, res) { const options = req.body || {} - const item = this.db.libraryItems.find(li => li.id === req.params.itemId) + const item = Database.libraryItems.find(li => li.id === req.params.itemId) if (!item) return res.sendStatus(404) // Check user can access this library item @@ -45,7 +45,7 @@ class RSSFeedController { async openRSSFeedForCollection(req, res) { const options = req.body || {} - const collection = this.db.collections.find(li => li.id === req.params.collectionId) + const collection = Database.collections.find(li => li.id === req.params.collectionId) if (!collection) return res.sendStatus(404) // Check request body options exist @@ -60,7 +60,7 @@ class RSSFeedController { return res.status(400).send('Slug already in use') } - const collectionExpanded = collection.toJSONExpanded(this.db.libraryItems) + const collectionExpanded = collection.toJSONExpanded(Database.libraryItems) const collectionItemsWithTracks = collectionExpanded.books.filter(li => li.media.tracks.length) // Check collection has audio tracks @@ -79,7 +79,7 @@ class RSSFeedController { async openRSSFeedForSeries(req, res) { const options = req.body || {} - const series = this.db.series.find(se => se.id === req.params.seriesId) + const series = Database.series.find(se => se.id === req.params.seriesId) if (!series) return res.sendStatus(404) // Check request body options exist @@ -96,7 +96,7 @@ class RSSFeedController { const seriesJson = series.toJSON() // Get books in series that have audio tracks - seriesJson.books = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length) + seriesJson.books = Database.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length) // Check series has audio tracks if (!seriesJson.books.length) { diff --git a/server/controllers/SeriesController.js b/server/controllers/SeriesController.js index 970191e7..9b05d406 100644 --- a/server/controllers/SeriesController.js +++ b/server/controllers/SeriesController.js @@ -1,5 +1,6 @@ const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') +const Database = require('../Database') class SeriesController { constructor() { } @@ -35,7 +36,7 @@ class SeriesController { var q = (req.query.q || '').toLowerCase() if (!q) return res.json([]) var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25 - var series = this.db.series.filter(se => se.name.toLowerCase().includes(q)) + var series = Database.series.filter(se => se.name.toLowerCase().includes(q)) series = series.slice(0, limit) res.json({ results: series @@ -45,17 +46,17 @@ class SeriesController { async update(req, res) { const hasUpdated = req.series.update(req.body) if (hasUpdated) { - await this.db.updateEntity('series', req.series) + await Database.updateSeries(req.series) SocketAuthority.emitter('series_updated', req.series.toJSON()) } res.json(req.series.toJSON()) } middleware(req, res, next) { - const series = this.db.series.find(se => se.id === req.params.id) + const series = Database.series.find(se => se.id === req.params.id) if (!series) return res.sendStatus(404) - const libraryItemsInSeries = this.db.libraryItems.filter(li => li.media.metadata.hasSeries?.(series.id)) + const libraryItemsInSeries = Database.libraryItems.filter(li => li.media.metadata.hasSeries?.(series.id)) if (libraryItemsInSeries.some(li => !req.user.checkCanAccessLibrary(li.libraryId))) { Logger.warn(`[SeriesController] User attempted to access series "${series.id}" without access to the library`, req.user) return res.sendStatus(403) diff --git a/server/controllers/SessionController.js b/server/controllers/SessionController.js index 4d0166a7..fe707607 100644 --- a/server/controllers/SessionController.js +++ b/server/controllers/SessionController.js @@ -1,4 +1,5 @@ const Logger = require('../Logger') +const Database = require('../Database') const { toNumber } = require('../utils/index') class SessionController { @@ -49,7 +50,7 @@ class SessionController { } const openSessions = this.playbackSessionManager.sessions.map(se => { - const user = this.db.users.find(u => u.id === se.userId) || null + const user = Database.users.find(u => u.id === se.userId) || null return { ...se.toJSON(), user: user ? { id: user.id, username: user.username } : null @@ -62,7 +63,7 @@ class SessionController { } getOpenSession(req, res) { - var libraryItem = this.db.getLibraryItem(req.session.libraryItemId) + var libraryItem = Database.getLibraryItem(req.session.libraryItemId) var sessionForClient = req.session.toJSONForClient(libraryItem) res.json(sessionForClient) } @@ -87,7 +88,7 @@ class SessionController { await this.playbackSessionManager.removeSession(req.session.id) } - await this.db.removeEntity('session', req.session.id) + await Database.removePlaybackSession(req.session.id) res.sendStatus(200) } @@ -115,7 +116,7 @@ class SessionController { } async middleware(req, res, next) { - const playbackSession = await this.db.getPlaybackSession(req.params.id) + const playbackSession = await Database.getPlaybackSession(req.params.id) if (!playbackSession) { Logger.error(`[SessionController] Unable to find playback session with id=${req.params.id}`) return res.sendStatus(404) diff --git a/server/controllers/ToolsController.js b/server/controllers/ToolsController.js index 3f21c5dd..6215dd43 100644 --- a/server/controllers/ToolsController.js +++ b/server/controllers/ToolsController.js @@ -1,4 +1,5 @@ const Logger = require('../Logger') +const Database = require('../Database') class ToolsController { constructor() { } @@ -65,7 +66,7 @@ class ToolsController { const libraryItems = [] for (const libraryItemId of libraryItemIds) { - const libraryItem = this.db.getLibraryItem(libraryItemId) + const libraryItem = Database.getLibraryItem(libraryItemId) if (!libraryItem) { Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`) return res.sendStatus(404) @@ -105,7 +106,7 @@ class ToolsController { } if (req.params.id) { - const item = this.db.libraryItems.find(li => li.id === req.params.id) + const item = Database.libraryItems.find(li => li.id === req.params.id) if (!item || !item.media) return res.sendStatus(404) // Check user can access this library item diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index 97c08cea..b542b122 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -1,9 +1,11 @@ +const uuidv4 = require("uuid").v4 const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') +const Database = require('../Database') const User = require('../objects/user/User') -const { getId, toNumber } = require('../utils/index') +const { toNumber } = require('../utils/index') class UserController { constructor() { } @@ -15,11 +17,11 @@ class UserController { const includes = (req.query.include || '').split(',').map(i => i.trim()) // Minimal toJSONForBrowser does not include mediaProgress and bookmarks - const users = this.db.users.map(u => u.toJSONForBrowser(hideRootToken, true)) + const users = Database.users.map(u => u.toJSONForBrowser(hideRootToken, true)) if (includes.includes('latestSession')) { for (const user of users) { - const userSessions = await this.db.selectUserSessions(user.id) + const userSessions = await Database.getPlaybackSessions({ userId: user.id }) user.latestSession = userSessions.sort((a, b) => b.updatedAt - a.updatedAt).shift() || null } } @@ -35,7 +37,7 @@ class UserController { return res.sendStatus(403) } - const user = this.db.users.find(u => u.id === req.params.id) + const user = Database.users.find(u => u.id === req.params.id) if (!user) { return res.sendStatus(404) } @@ -47,18 +49,19 @@ class UserController { var account = req.body var username = account.username - var usernameExists = this.db.users.find(u => u.username.toLowerCase() === username.toLowerCase()) + var usernameExists = Database.users.find(u => u.username.toLowerCase() === username.toLowerCase()) if (usernameExists) { return res.status(500).send('Username already taken') } - account.id = getId('usr') + account.id = uuidv4() account.pash = await this.auth.hashPass(account.password) delete account.password account.token = await this.auth.generateAccessToken({ userId: account.id, username }) account.createdAt = Date.now() - var newUser = new User(account) - var success = await this.db.insertEntity('user', newUser) + const newUser = new User(account) + + const success = await Database.createUser(newUser) if (success) { SocketAuthority.adminEmitter('user_added', newUser.toJSONForBrowser()) res.json({ @@ -81,7 +84,7 @@ class UserController { var shouldUpdateToken = false if (account.username !== undefined && account.username !== user.username) { - var usernameExists = this.db.users.find(u => u.username.toLowerCase() === account.username.toLowerCase()) + var usernameExists = Database.users.find(u => u.username.toLowerCase() === account.username.toLowerCase()) if (usernameExists) { return res.status(500).send('Username already taken') } @@ -94,13 +97,12 @@ class UserController { delete account.password } - var hasUpdated = user.update(account) - if (hasUpdated) { + if (user.update(account)) { if (shouldUpdateToken) { user.token = await this.auth.generateAccessToken({ userId: user.id, username: user.username }) Logger.info(`[UserController] User ${user.username} was generated a new api token`) } - await this.db.updateEntity('user', user) + await Database.updateUser(user) SocketAuthority.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser()) } @@ -124,13 +126,13 @@ class UserController { // Todo: check if user is logged in and cancel streams // Remove user playlists - const userPlaylists = this.db.playlists.filter(p => p.userId === user.id) + const userPlaylists = Database.playlists.filter(p => p.userId === user.id) for (const playlist of userPlaylists) { - await this.db.removeEntity('playlist', playlist.id) + await Database.removePlaylist(playlist.id) } const userJson = user.toJSONForBrowser() - await this.db.removeEntity('user', user.id) + await Database.removeUser(user.id) SocketAuthority.adminEmitter('user_removed', userJson) res.json({ success: true @@ -165,37 +167,39 @@ class UserController { } // POST: api/users/:id/purge-media-progress + // TODO: Remove async purgeMediaProgress(req, res) { - const user = req.reqUser + return res.sendStatus(404) + // const user = req.reqUser - if (user.type === 'root' && !req.user.isRoot) { - Logger.error(`[UserController] Admin user attempted to purge media progress of root user`, req.user.username) - return res.sendStatus(403) - } + // if (user.type === 'root' && !req.user.isRoot) { + // Logger.error(`[UserController] Admin user attempted to purge media progress of root user`, req.user.username) + // return res.sendStatus(403) + // } - var progressPurged = 0 - user.mediaProgress = user.mediaProgress.filter(mp => { - const libraryItem = this.db.libraryItems.find(li => li.id === mp.libraryItemId) - if (!libraryItem) { - progressPurged++ - return false - } else if (mp.episodeId) { - const episode = libraryItem.mediaType === 'podcast' ? libraryItem.media.getEpisode(mp.episodeId) : null - if (!episode) { // Episode not found - progressPurged++ - return false - } - } - return true - }) + // var progressPurged = 0 + // user.mediaProgress = user.mediaProgress.filter(mp => { + // const libraryItem = Database.libraryItems.find(li => li.id === mp.libraryItemId) + // if (!libraryItem) { + // progressPurged++ + // return false + // } else if (mp.episodeId) { + // const episode = libraryItem.mediaType === 'podcast' ? libraryItem.media.getEpisode(mp.episodeId) : null + // if (!episode) { // Episode not found + // progressPurged++ + // return false + // } + // } + // return true + // }) - if (progressPurged) { - Logger.info(`[UserController] Purged ${progressPurged} media progress for user ${user.username}`) - await this.db.updateEntity('user', user) - SocketAuthority.adminEmitter('user_updated', user.toJSONForBrowser()) - } + // if (progressPurged) { + // Logger.info(`[UserController] Purged ${progressPurged} media progress for user ${user.username}`) + // await this.db.updateEntity('user', user) + // SocketAuthority.adminEmitter('user_updated', user.toJSONForBrowser()) + // } - res.json(this.userJsonWithItemProgressDetails(user, !req.user.isRoot)) + // res.json(this.userJsonWithItemProgressDetails(user, !req.user.isRoot)) } // POST: api/users/online (admin) @@ -218,7 +222,7 @@ class UserController { } if (req.params.id) { - req.reqUser = this.db.users.find(u => u.id === req.params.id) + req.reqUser = Database.users.find(u => u.id === req.params.id) if (!req.reqUser) { return res.sendStatus(404) } diff --git a/server/controllers2/libraryItem.controller.js b/server/controllers2/libraryItem.controller.js new file mode 100644 index 00000000..83b1776e --- /dev/null +++ b/server/controllers2/libraryItem.controller.js @@ -0,0 +1,16 @@ +const itemDb = require('../db/item.db') + +const getLibraryItem = async (req, res) => { + let libraryItem = null + if (req.query.expanded == 1) { + libraryItem = await itemDb.getLibraryItemExpanded(req.params.id) + } else { + libraryItem = await itemDb.getLibraryItemMinified(req.params.id) + } + + res.json(libraryItem) +} + +module.exports = { + getLibraryItem +} \ No newline at end of file diff --git a/server/db/libraryItem.db.js b/server/db/libraryItem.db.js new file mode 100644 index 00000000..3f08bf06 --- /dev/null +++ b/server/db/libraryItem.db.js @@ -0,0 +1,80 @@ +/** + * TODO: Unused for testing + */ +const { Sequelize } = require('sequelize') +const Database = require('../Database') + +const getLibraryItemMinified = (libraryItemId) => { + return Database.models.libraryItem.findByPk(libraryItemId, { + include: [ + { + model: Database.models.book, + attributes: [ + 'id', 'title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language', 'explicit', 'narrators', 'coverPath', 'genres', 'tags' + ], + include: [ + { + model: Database.models.author, + attributes: ['id', 'name'], + through: { + attributes: [] + } + }, + { + model: Database.models.series, + attributes: ['id', 'name'], + through: { + attributes: ['sequence'] + } + } + ] + }, + { + model: Database.models.podcast, + attributes: [ + 'id', 'title', 'author', 'releaseDate', 'feedURL', 'imageURL', 'description', 'itunesPageURL', 'itunesId', 'itunesArtistId', 'language', 'podcastType', 'explicit', 'autoDownloadEpisodes', 'genres', 'tags', + [Sequelize.literal('(SELECT COUNT(*) FROM "podcastEpisodes" WHERE "podcastEpisodes"."podcastId" = podcast.id)'), 'numPodcastEpisodes'] + ] + } + ] + }) +} + +const getLibraryItemExpanded = (libraryItemId) => { + return Database.models.libraryItem.findByPk(libraryItemId, { + include: [ + { + model: Database.models.book, + include: [ + { + model: Database.models.author, + through: { + attributes: [] + } + }, + { + model: Database.models.series, + through: { + attributes: ['sequence'] + } + } + ] + }, + { + model: Database.models.podcast, + include: [ + { + model: Database.models.podcastEpisode + } + ] + }, + 'libraryFolder', + 'library' + ] + }) +} + +module.exports = { + getLibraryItemMinified, + getLibraryItemExpanded +} \ No newline at end of file diff --git a/server/managers/AbMergeManager.js b/server/managers/AbMergeManager.js index 9fc48e29..1b0a3604 100644 --- a/server/managers/AbMergeManager.js +++ b/server/managers/AbMergeManager.js @@ -10,8 +10,7 @@ const { writeConcatFile } = require('../utils/ffmpegHelpers') const toneHelpers = require('../utils/toneHelpers') class AbMergeManager { - constructor(db, taskManager) { - this.db = db + constructor(taskManager) { this.taskManager = taskManager this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items') diff --git a/server/managers/AudioMetadataManager.js b/server/managers/AudioMetadataManager.js index 5d760d75..2f74bcbe 100644 --- a/server/managers/AudioMetadataManager.js +++ b/server/managers/AudioMetadataManager.js @@ -10,8 +10,7 @@ const toneHelpers = require('../utils/toneHelpers') const Task = require('../objects/Task') class AudioMetadataMangaer { - constructor(db, taskManager) { - this.db = db + constructor(taskManager) { this.taskManager = taskManager this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items') diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js index 08f34305..eba4f44f 100644 --- a/server/managers/CoverManager.js +++ b/server/managers/CoverManager.js @@ -10,15 +10,14 @@ const { downloadFile, filePathToPOSIX } = require('../utils/fileUtils') const { extractCoverArt } = require('../utils/ffmpegHelpers') class CoverManager { - constructor(db, cacheManager) { - this.db = db + constructor(cacheManager) { this.cacheManager = cacheManager this.ItemMetadataPath = Path.posix.join(global.MetadataPath, 'items') } getCoverDirectory(libraryItem) { - if (this.db.serverSettings.storeCoverWithItem && !libraryItem.isFile && !libraryItem.isMusic) { + if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile && !libraryItem.isMusic) { return libraryItem.path } else { return Path.posix.join(this.ItemMetadataPath, libraryItem.id) diff --git a/server/managers/CronManager.js b/server/managers/CronManager.js index 92fa4772..adbf87a5 100644 --- a/server/managers/CronManager.js +++ b/server/managers/CronManager.js @@ -1,9 +1,9 @@ const cron = require('../libs/nodeCron') const Logger = require('../Logger') +const Database = require('../Database') class CronManager { - constructor(db, scanner, podcastManager) { - this.db = db + constructor(scanner, podcastManager) { this.scanner = scanner this.podcastManager = podcastManager @@ -19,7 +19,7 @@ class CronManager { } initLibraryScanCrons() { - for (const library of this.db.libraries) { + for (const library of Database.libraries) { if (library.settings.autoScanCronExpression) { this.startCronForLibrary(library) } @@ -64,7 +64,7 @@ class CronManager { initPodcastCrons() { const cronExpressionMap = {} - this.db.libraryItems.forEach((li) => { + Database.libraryItems.forEach((li) => { if (li.mediaType === 'podcast' && li.media.autoDownloadEpisodes) { if (!li.media.autoDownloadSchedule) { Logger.error(`[CronManager] Podcast auto download schedule is not set for ${li.media.metadata.title}`) @@ -119,7 +119,7 @@ class CronManager { // Get podcast library items to check const libraryItems = [] for (const libraryItemId of libraryItemIds) { - const libraryItem = this.db.libraryItems.find(li => li.id === libraryItemId) + const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId) if (!libraryItem) { Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`) podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItemId) // Filter it out diff --git a/server/managers/EmailManager.js b/server/managers/EmailManager.js index cf0e4b9c..14106d72 100644 --- a/server/managers/EmailManager.js +++ b/server/managers/EmailManager.js @@ -1,14 +1,11 @@ const nodemailer = require('nodemailer') const Logger = require("../Logger") -const SocketAuthority = require('../SocketAuthority') class EmailManager { - constructor(db) { - this.db = db - } + constructor() { } getTransporter() { - return nodemailer.createTransport(this.db.emailSettings.getTransportObject()) + return nodemailer.createTransport(Database.emailSettings.getTransportObject()) } async sendTest(res) { @@ -25,8 +22,8 @@ class EmailManager { } transporter.sendMail({ - from: this.db.emailSettings.fromAddress, - to: this.db.emailSettings.testAddress || this.db.emailSettings.fromAddress, + from: Database.emailSettings.fromAddress, + to: Database.emailSettings.testAddress || Database.emailSettings.fromAddress, subject: 'Test email from Audiobookshelf', text: 'Success!' }).then((result) => { @@ -52,7 +49,7 @@ class EmailManager { } transporter.sendMail({ - from: this.db.emailSettings.fromAddress, + from: Database.emailSettings.fromAddress, to: device.email, subject: "Here is your Ebook!", html: '
', diff --git a/server/managers/LogManager.js b/server/managers/LogManager.js index 3f4907bf..789cd877 100644 --- a/server/managers/LogManager.js +++ b/server/managers/LogManager.js @@ -9,9 +9,7 @@ const Logger = require('../Logger') const TAG = '[LogManager]' class LogManager { - constructor(db) { - this.db = db - + constructor() { this.DailyLogPath = Path.posix.join(global.MetadataPath, 'logs', 'daily') this.ScanLogPath = Path.posix.join(global.MetadataPath, 'logs', 'scans') @@ -20,12 +18,8 @@ class LogManager { this.dailyLogFiles = [] } - get serverSettings() { - return this.db.serverSettings || {} - } - get loggerDailyLogsToKeep() { - return this.serverSettings.loggerDailyLogsToKeep || 7 + return global.ServerSettings.loggerDailyLogsToKeep || 7 } async ensureLogDirs() { diff --git a/server/managers/NotificationManager.js b/server/managers/NotificationManager.js index 3a70ce8e..bd62b880 100644 --- a/server/managers/NotificationManager.js +++ b/server/managers/NotificationManager.js @@ -1,12 +1,11 @@ const axios = require('axios') const Logger = require("../Logger") const SocketAuthority = require('../SocketAuthority') +const Database = require('../Database') const { notificationData } = require('../utils/notifications') class NotificationManager { - constructor(db) { - this.db = db - + constructor() { this.sendingNotification = false this.notificationQueue = [] } @@ -16,10 +15,10 @@ class NotificationManager { } onPodcastEpisodeDownloaded(libraryItem, episode) { - if (!this.db.notificationSettings.isUseable) return + if (!Database.notificationSettings.isUseable) return Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`) - const library = this.db.libraries.find(lib => lib.id === libraryItem.libraryId) + const library = Database.libraries.find(lib => lib.id === libraryItem.libraryId) const eventData = { libraryItemId: libraryItem.id, libraryId: libraryItem.libraryId, @@ -42,19 +41,19 @@ class NotificationManager { } async triggerNotification(eventName, eventData, intentionallyFail = false) { - if (!this.db.notificationSettings.isUseable) return + if (!Database.notificationSettings.isUseable) return // Will queue the notification if sendingNotification and queue is not full if (!this.checkTriggerNotification(eventName, eventData)) return - const notifications = this.db.notificationSettings.getActiveNotificationsForEvent(eventName) + const notifications = Database.notificationSettings.getActiveNotificationsForEvent(eventName) for (const notification of notifications) { Logger.debug(`[NotificationManager] triggerNotification: Sending ${eventName} notification ${notification.id}`) const success = intentionallyFail ? false : await this.sendNotification(notification, eventData) notification.updateNotificationFired(success) if (!success) { // Failed notification - if (notification.numConsecutiveFailedAttempts >= this.db.notificationSettings.maxFailedAttempts) { + if (notification.numConsecutiveFailedAttempts >= Database.notificationSettings.maxFailedAttempts) { Logger.error(`[NotificationManager] triggerNotification: ${notification.eventName}/${notification.id} reached max failed attempts`) notification.enabled = false } else { @@ -63,8 +62,8 @@ class NotificationManager { } } - await this.db.updateEntity('settings', this.db.notificationSettings) - SocketAuthority.emitter('notifications_updated', this.db.notificationSettings.toJSON()) + await Database.updateSetting(Database.notificationSettings) + SocketAuthority.emitter('notifications_updated', Database.notificationSettings.toJSON()) this.notificationFinished() } @@ -72,7 +71,7 @@ class NotificationManager { // Return TRUE if notification should be triggered now checkTriggerNotification(eventName, eventData) { if (this.sendingNotification) { - if (this.notificationQueue.length >= this.db.notificationSettings.maxNotificationQueue) { + if (this.notificationQueue.length >= Database.notificationSettings.maxNotificationQueue) { Logger.warn(`[NotificationManager] Notification queue is full - ignoring event ${eventName}`) } else { Logger.debug(`[NotificationManager] Queueing notification ${eventName} (Queue size: ${this.notificationQueue.length})`) @@ -92,7 +91,7 @@ class NotificationManager { const nextNotificationEvent = this.notificationQueue.shift() this.triggerNotification(nextNotificationEvent.eventName, nextNotificationEvent.eventData) } - }, this.db.notificationSettings.notificationDelay) + }, Database.notificationSettings.notificationDelay) } sendTestNotification(notification) { @@ -107,7 +106,7 @@ class NotificationManager { sendNotification(notification, eventData) { const payload = notification.getApprisePayload(eventData) - return axios.post(this.db.notificationSettings.appriseApiUrl, payload, { timeout: 6000 }).then((response) => { + return axios.post(Database.notificationSettings.appriseApiUrl, payload, { timeout: 6000 }).then((response) => { Logger.debug(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} response=`, response.data) return true }).catch((error) => { diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index d63d6249..0794d508 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -2,6 +2,7 @@ const Path = require('path') const serverVersion = require('../../package.json').version const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') +const Database = require('../Database') const date = require('../libs/dateAndTime') const fs = require('../libs/fsExtra') @@ -15,8 +16,7 @@ const DeviceInfo = require('../objects/DeviceInfo') const Stream = require('../objects/Stream') class PlaybackSessionManager { - constructor(db) { - this.db = db + constructor() { this.StreamsPath = Path.join(global.MetadataPath, 'streams') this.sessions = [] @@ -33,19 +33,31 @@ class PlaybackSessionManager { return session?.stream || null } - getDeviceInfo(req) { + async getDeviceInfo(req) { const ua = uaParserJs(req.headers['user-agent']) const ip = requestIp.getClientIp(req) const clientDeviceInfo = req.body?.deviceInfo || null const deviceInfo = new DeviceInfo() - deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion) + deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion, req.user.id) + + if (clientDeviceInfo?.deviceId) { + const existingDevice = await Database.getDeviceByDeviceId(clientDeviceInfo.deviceId) + if (existingDevice) { + if (existingDevice.update(deviceInfo)) { + await Database.updateDevice(existingDevice) + } + return existingDevice + } + } + + return deviceInfo } async startSessionRequest(req, res, episodeId) { - const deviceInfo = this.getDeviceInfo(req) + const deviceInfo = await this.getDeviceInfo(req) Logger.debug(`[PlaybackSessionManager] startSessionRequest for device ${deviceInfo.deviceDescription}`) const { user, libraryItem, body: options } = req const session = await this.startSession(user, deviceInfo, libraryItem, episodeId, options) @@ -77,7 +89,7 @@ class PlaybackSessionManager { } async syncLocalSession(user, sessionJson) { - const libraryItem = this.db.getLibraryItem(sessionJson.libraryItemId) + const libraryItem = Database.getLibraryItem(sessionJson.libraryItemId) const episode = (sessionJson.episodeId && libraryItem && libraryItem.isPodcast) ? libraryItem.media.getEpisode(sessionJson.episodeId) : null if (!libraryItem || (libraryItem.isPodcast && !episode)) { Logger.error(`[PlaybackSessionManager] syncLocalSession: Media item not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`) @@ -88,12 +100,12 @@ class PlaybackSessionManager { } } - let session = await this.db.getPlaybackSession(sessionJson.id) + let session = await Database.getPlaybackSession(sessionJson.id) if (!session) { // New session from local session = new PlaybackSession(sessionJson) Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`) - await this.db.insertEntity('session', session) + await Database.createPlaybackSession(session) } else { session.currentTime = sessionJson.currentTime session.timeListening = sessionJson.timeListening @@ -102,7 +114,7 @@ class PlaybackSessionManager { session.dayOfWeek = date.format(new Date(), 'dddd') Logger.debug(`[PlaybackSessionManager] Updated session for "${session.displayTitle}" (${session.id})`) - await this.db.updateEntity('session', session) + await Database.updatePlaybackSession(session) } const result = { @@ -126,8 +138,8 @@ class PlaybackSessionManager { // Update user and emit socket event if (result.progressSynced) { - await this.db.updateEntity('user', user) const itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId) + if (itemProgress) await Database.upsertMediaProgress(itemProgress) SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', { id: itemProgress.id, sessionId: session.id, @@ -155,7 +167,7 @@ class PlaybackSessionManager { async startSession(user, deviceInfo, libraryItem, episodeId, options) { // Close any sessions already open for user and device - const userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id && playbackSession.deviceId === deviceInfo.deviceId) + const userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id && playbackSession.deviceId === deviceInfo.id) for (const session of userSessions) { Logger.info(`[PlaybackSessionManager] startSession: Closing open session "${session.displayTitle}" for user "${user.username}" (Device: ${session.deviceDescription})`) await this.closeSession(user, session, null) @@ -213,13 +225,13 @@ class PlaybackSessionManager { user.currentSessionId = newPlaybackSession.id this.sessions.push(newPlaybackSession) - SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, this.db.libraryItems)) + SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, Database.libraryItems)) return newPlaybackSession } async syncSession(user, session, syncData) { - const libraryItem = this.db.libraryItems.find(li => li.id === session.libraryItemId) + const libraryItem = Database.libraryItems.find(li => li.id === session.libraryItemId) if (!libraryItem) { Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`) return null @@ -236,9 +248,8 @@ class PlaybackSessionManager { } const wasUpdated = user.createUpdateMediaProgress(libraryItem, itemProgressUpdate, session.episodeId) if (wasUpdated) { - - await this.db.updateEntity('user', user) const itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId) + if (itemProgress) await Database.upsertMediaProgress(itemProgress) SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', { id: itemProgress.id, sessionId: session.id, @@ -259,7 +270,7 @@ class PlaybackSessionManager { await this.saveSession(session) } Logger.debug(`[PlaybackSessionManager] closeSession "${session.id}"`) - SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, this.db.libraryItems)) + SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, Database.libraryItems)) SocketAuthority.clientEmitter(session.userId, 'user_session_closed', session.id) return this.removeSession(session.id) } @@ -268,10 +279,10 @@ class PlaybackSessionManager { if (!session.timeListening) return // Do not save a session with no listening time if (session.lastSave) { - return this.db.updateEntity('session', session) + return Database.updatePlaybackSession(session) } else { session.lastSave = Date.now() - return this.db.insertEntity('session', session) + return Database.createPlaybackSession(session) } } @@ -305,16 +316,5 @@ class PlaybackSessionManager { Logger.error(`[PlaybackSessionManager] cleanOrphanStreams failed`, error) } } - - // Android app v0.9.54 and below had a bug where listening time was sending unix timestamp - // See https://github.com/advplyr/audiobookshelf/issues/868 - // Remove playback sessions with listening time too high - async removeInvalidSessions() { - const selectFunc = (session) => isNaN(session.timeListening) || Number(session.timeListening) > 36000000 - const numSessionsRemoved = await this.db.removeEntities('session', selectFunc, true) - if (numSessionsRemoved) { - Logger.info(`[PlaybackSessionManager] Removed ${numSessionsRemoved} invalid playback sessions`) - } - } } module.exports = PlaybackSessionManager diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 28fe37f1..ce16c2d3 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -1,5 +1,6 @@ const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') +const Database = require('../Database') const fs = require('../libs/fsExtra') @@ -19,8 +20,7 @@ const AudioFile = require('../objects/files/AudioFile') const Task = require("../objects/Task") class PodcastManager { - constructor(db, watcher, notificationManager, taskManager) { - this.db = db + constructor(watcher, notificationManager, taskManager) { this.watcher = watcher this.notificationManager = notificationManager this.taskManager = taskManager @@ -32,10 +32,6 @@ class PodcastManager { this.MaxFailedEpisodeChecks = 24 } - get serverSettings() { - return this.db.serverSettings || {} - } - getEpisodeDownloadsInQueue(libraryItemId) { return this.downloadQueue.filter(d => d.libraryItemId === libraryItemId) } @@ -59,6 +55,7 @@ class PodcastManager { const newPe = new PodcastEpisode() newPe.setData(ep, index++) newPe.libraryItemId = libraryItem.id + newPe.podcastId = libraryItem.media.id const newPeDl = new PodcastEpisodeDownload() newPeDl.setData(newPe, libraryItem, isAutoDownload, libraryItem.libraryId) this.startPodcastEpisodeDownload(newPeDl) @@ -153,7 +150,7 @@ class PodcastManager { return false } - const libraryItem = this.db.libraryItems.find(li => li.id === this.currentDownload.libraryItem.id) + const libraryItem = Database.libraryItems.find(li => li.id === this.currentDownload.libraryItem.id) if (!libraryItem) { Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`) return false @@ -182,7 +179,7 @@ class PodcastManager { } libraryItem.updatedAt = Date.now() - await this.db.updateLibraryItem(libraryItem) + await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) const podcastEpisodeExpanded = podcastEpisode.toJSONExpanded() podcastEpisodeExpanded.libraryItem = libraryItem.toJSONExpanded() @@ -235,6 +232,7 @@ class PodcastManager { } const newAudioFile = new AudioFile() newAudioFile.setDataFromProbe(libraryFile, mediaProbeData) + newAudioFile.index = 1 return newAudioFile } @@ -274,7 +272,7 @@ class PodcastManager { libraryItem.media.lastEpisodeCheck = Date.now() libraryItem.updatedAt = Date.now() - await this.db.updateLibraryItem(libraryItem) + await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) return libraryItem.media.autoDownloadEpisodes } @@ -313,7 +311,7 @@ class PodcastManager { libraryItem.media.lastEpisodeCheck = Date.now() libraryItem.updatedAt = Date.now() - await this.db.updateLibraryItem(libraryItem) + await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) return newEpisodes diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index 9325a7a2..485433b2 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -2,14 +2,13 @@ const Path = require('path') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') +const Database = require('../Database') const fs = require('../libs/fsExtra') const Feed = require('../objects/Feed') class RssFeedManager { - constructor(db) { - this.db = db - + constructor() { this.feeds = {} } @@ -19,18 +18,18 @@ class RssFeedManager { validateFeedEntity(feedObj) { if (feedObj.entityType === 'collection') { - if (!this.db.collections.some(li => li.id === feedObj.entityId)) { + if (!Database.collections.some(li => li.id === feedObj.entityId)) { Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Collection "${feedObj.entityId}" not found`) return false } } else if (feedObj.entityType === 'libraryItem') { - if (!this.db.libraryItems.some(li => li.id === feedObj.entityId)) { + if (!Database.libraryItems.some(li => li.id === feedObj.entityId)) { Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Library item "${feedObj.entityId}" not found`) return false } } else if (feedObj.entityType === 'series') { - const series = this.db.series.find(s => s.id === feedObj.entityId) - const hasSeriesBook = this.db.libraryItems.some(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length) + const series = Database.series.find(s => s.id === feedObj.entityId) + const hasSeriesBook = Database.libraryItems.some(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length) if (!hasSeriesBook) { Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found or has no audio tracks`) return false @@ -43,19 +42,13 @@ class RssFeedManager { } async init() { - const feedObjects = await this.db.getAllEntities('feed') - if (!feedObjects || !feedObjects.length) return + const feedObjects = Database.feeds + if (!feedObjects?.length) return for (const feedObj of feedObjects) { - // Migration: In v2.2.12 entityType "item" was updated to "libraryItem" - if (feedObj.entityType === 'item') { - feedObj.entityType = 'libraryItem' - await this.db.updateEntity('feed', feedObj) - } - // Remove invalid feeds if (!this.validateFeedEntity(feedObj)) { - await this.db.removeEntity('feed', feedObj.id) + await Database.removeFeed(feedObj.id) } const feed = new Feed(feedObj) @@ -82,7 +75,7 @@ class RssFeedManager { // Check if feed needs to be updated if (feed.entityType === 'libraryItem') { - const libraryItem = this.db.getLibraryItem(feed.entityId) + const libraryItem = Database.getLibraryItem(feed.entityId) let mostRecentlyUpdatedAt = libraryItem.updatedAt if (libraryItem.isPodcast) { @@ -94,12 +87,12 @@ class RssFeedManager { if (libraryItem && (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt)) { Logger.debug(`[RssFeedManager] Updating RSS feed for item ${libraryItem.id} "${libraryItem.media.metadata.title}"`) feed.updateFromItem(libraryItem) - await this.db.updateEntity('feed', feed) + await Database.updateFeed(feed) } } else if (feed.entityType === 'collection') { - const collection = this.db.collections.find(c => c.id === feed.entityId) + const collection = Database.collections.find(c => c.id === feed.entityId) if (collection) { - const collectionExpanded = collection.toJSONExpanded(this.db.libraryItems) + const collectionExpanded = collection.toJSONExpanded(Database.libraryItems) // Find most recently updated item in collection let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate @@ -113,15 +106,15 @@ class RssFeedManager { Logger.debug(`[RssFeedManager] Updating RSS feed for collection "${collection.name}"`) feed.updateFromCollection(collectionExpanded) - await this.db.updateEntity('feed', feed) + await Database.updateFeed(feed) } } } else if (feed.entityType === 'series') { - const series = this.db.series.find(s => s.id === feed.entityId) + const series = Database.series.find(s => s.id === feed.entityId) if (series) { const seriesJson = series.toJSON() // Get books in series that have audio tracks - seriesJson.books = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length) + seriesJson.books = Database.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length) // Find most recently updated item in series let mostRecentlyUpdatedAt = seriesJson.updatedAt @@ -140,7 +133,7 @@ class RssFeedManager { Logger.debug(`[RssFeedManager] Updating RSS feed for series "${seriesJson.name}"`) feed.updateFromSeries(seriesJson) - await this.db.updateEntity('feed', feed) + await Database.updateFeed(feed) } } } @@ -197,7 +190,7 @@ class RssFeedManager { this.feeds[feed.id] = feed Logger.debug(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`) - await this.db.insertEntity('feed', feed) + await Database.createFeed(feed) SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified()) return feed } @@ -214,7 +207,7 @@ class RssFeedManager { this.feeds[feed.id] = feed Logger.debug(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`) - await this.db.insertEntity('feed', feed) + await Database.createFeed(feed) SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified()) return feed } @@ -231,14 +224,14 @@ class RssFeedManager { this.feeds[feed.id] = feed Logger.debug(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`) - await this.db.insertEntity('feed', feed) + await Database.createFeed(feed) SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified()) return feed } async handleCloseFeed(feed) { if (!feed) return - await this.db.removeEntity('feed', feed.id) + await Database.removeFeed(feed.id) SocketAuthority.emitter('rss_feed_closed', feed.toJSONMinified()) delete this.feeds[feed.id] Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedUrl}"`) diff --git a/server/models/Author.js b/server/models/Author.js new file mode 100644 index 00000000..d0ae6d9f --- /dev/null +++ b/server/models/Author.js @@ -0,0 +1,78 @@ +const { DataTypes, Model } = require('sequelize') + +const oldAuthor = require('../objects/entities/Author') + +module.exports = (sequelize) => { + class Author extends Model { + static async getOldAuthors() { + const authors = await this.findAll() + return authors.map(au => au.getOldAuthor()) + } + + getOldAuthor() { + return new oldAuthor({ + id: this.id, + asin: this.asin, + name: this.name, + description: this.description, + imagePath: this.imagePath, + addedAt: this.createdAt.valueOf(), + updatedAt: this.updatedAt.valueOf() + }) + } + + static updateFromOld(oldAuthor) { + const author = this.getFromOld(oldAuthor) + return this.update(author, { + where: { + id: author.id + } + }) + } + + static createFromOld(oldAuthor) { + const author = this.getFromOld(oldAuthor) + return this.create(author) + } + + static createBulkFromOld(oldAuthors) { + const authors = oldAuthors.map(this.getFromOld) + return this.bulkCreate(authors) + } + + static getFromOld(oldAuthor) { + return { + id: oldAuthor.id, + name: oldAuthor.name, + asin: oldAuthor.asin, + description: oldAuthor.description, + imagePath: oldAuthor.imagePath + } + } + + static removeById(authorId) { + return this.destroy({ + where: { + id: authorId + } + }) + } + } + + Author.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: DataTypes.STRING, + asin: DataTypes.STRING, + description: DataTypes.TEXT, + imagePath: DataTypes.STRING + }, { + sequelize, + modelName: 'author' + }) + + return Author +} \ No newline at end of file diff --git a/server/models/Book.js b/server/models/Book.js new file mode 100644 index 00000000..f2bfdd1c --- /dev/null +++ b/server/models/Book.js @@ -0,0 +1,121 @@ +const { DataTypes, Model } = require('sequelize') +const Logger = require('../Logger') + +module.exports = (sequelize) => { + class Book extends Model { + static getOldBook(libraryItemExpanded) { + const bookExpanded = libraryItemExpanded.media + const authors = bookExpanded.authors.map(au => { + return { + id: au.id, + name: au.name + } + }) + const series = bookExpanded.series.map(se => { + return { + id: se.id, + name: se.name, + sequence: se.bookSeries.sequence + } + }) + return { + id: bookExpanded.id, + libraryItemId: libraryItemExpanded.id, + coverPath: bookExpanded.coverPath, + tags: bookExpanded.tags, + audioFiles: bookExpanded.audioFiles, + chapters: bookExpanded.chapters, + ebookFile: bookExpanded.ebookFile, + metadata: { + title: bookExpanded.title, + subtitle: bookExpanded.subtitle, + authors: authors, + narrators: bookExpanded.narrators, + series: series, + genres: bookExpanded.genres, + publishedYear: bookExpanded.publishedYear, + publishedDate: bookExpanded.publishedDate, + publisher: bookExpanded.publisher, + description: bookExpanded.description, + isbn: bookExpanded.isbn, + asin: bookExpanded.asin, + language: bookExpanded.language, + explicit: bookExpanded.explicit, + abridged: bookExpanded.abridged + } + } + } + + /** + * @param {object} oldBook + * @returns {boolean} true if updated + */ + static saveFromOld(oldBook) { + const book = this.getFromOld(oldBook) + return this.update(book, { + where: { + id: book.id + } + }).then(result => result[0] > 0).catch((error) => { + Logger.error(`[Book] Failed to save book ${book.id}`, error) + return false + }) + } + + static getFromOld(oldBook) { + return { + id: oldBook.id, + title: oldBook.metadata.title, + subtitle: oldBook.metadata.subtitle, + publishedYear: oldBook.metadata.publishedYear, + publishedDate: oldBook.metadata.publishedDate, + publisher: oldBook.metadata.publisher, + description: oldBook.metadata.description, + isbn: oldBook.metadata.isbn, + asin: oldBook.metadata.asin, + language: oldBook.metadata.language, + explicit: !!oldBook.metadata.explicit, + abridged: !!oldBook.metadata.abridged, + narrators: oldBook.metadata.narrators, + ebookFile: oldBook.ebookFile?.toJSON() || null, + coverPath: oldBook.coverPath, + audioFiles: oldBook.audioFiles?.map(af => af.toJSON()) || [], + chapters: oldBook.chapters, + tags: oldBook.tags, + genres: oldBook.metadata.genres + } + } + } + + Book.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + title: DataTypes.STRING, + subtitle: DataTypes.STRING, + publishedYear: DataTypes.STRING, + publishedDate: DataTypes.STRING, + publisher: DataTypes.STRING, + description: DataTypes.TEXT, + isbn: DataTypes.STRING, + asin: DataTypes.STRING, + language: DataTypes.STRING, + explicit: DataTypes.BOOLEAN, + abridged: DataTypes.BOOLEAN, + coverPath: DataTypes.STRING, + + narrators: DataTypes.JSON, + audioFiles: DataTypes.JSON, + ebookFile: DataTypes.JSON, + chapters: DataTypes.JSON, + tags: DataTypes.JSON, + genres: DataTypes.JSON + }, { + sequelize, + modelName: 'book' + }) + + return Book +} \ No newline at end of file diff --git a/server/models/BookAuthor.js b/server/models/BookAuthor.js new file mode 100644 index 00000000..7ee596d3 --- /dev/null +++ b/server/models/BookAuthor.js @@ -0,0 +1,40 @@ +const { DataTypes, Model } = require('sequelize') + +module.exports = (sequelize) => { + class BookAuthor extends Model { + static removeByIds(authorId = null, bookId = null) { + const where = {} + if (authorId) where.authorId = authorId + if (bookId) where.bookId = bookId + return this.destroy({ + where + }) + } + } + + BookAuthor.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + } + }, { + sequelize, + modelName: 'bookAuthor', + timestamps: false + }) + + // Super Many-to-Many + // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship + const { book, author } = sequelize.models + book.belongsToMany(author, { through: BookAuthor }) + author.belongsToMany(book, { through: BookAuthor }) + + book.hasMany(BookAuthor) + BookAuthor.belongsTo(book) + + author.hasMany(BookAuthor) + BookAuthor.belongsTo(author) + + return BookAuthor +} \ No newline at end of file diff --git a/server/models/BookSeries.js b/server/models/BookSeries.js new file mode 100644 index 00000000..5406d2c1 --- /dev/null +++ b/server/models/BookSeries.js @@ -0,0 +1,41 @@ +const { DataTypes, Model } = require('sequelize') + +module.exports = (sequelize) => { + class BookSeries extends Model { + static removeByIds(seriesId = null, bookId = null) { + const where = {} + if (seriesId) where.seriesId = seriesId + if (bookId) where.bookId = bookId + return this.destroy({ + where + }) + } + } + + BookSeries.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + sequence: DataTypes.STRING + }, { + sequelize, + modelName: 'bookSeries', + timestamps: false + }) + + // Super Many-to-Many + // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship + const { book, series } = sequelize.models + book.belongsToMany(series, { through: BookSeries }) + series.belongsToMany(book, { through: BookSeries }) + + book.hasMany(BookSeries) + BookSeries.belongsTo(book) + + series.hasMany(BookSeries) + BookSeries.belongsTo(series) + + return BookSeries +} \ No newline at end of file diff --git a/server/models/Collection.js b/server/models/Collection.js new file mode 100644 index 00000000..8a96cc6d --- /dev/null +++ b/server/models/Collection.js @@ -0,0 +1,117 @@ +const { DataTypes, Model } = require('sequelize') + +const oldCollection = require('../objects/Collection') +const { areEquivalent } = require('../utils/index') + +module.exports = (sequelize) => { + class Collection extends Model { + static async getOldCollections() { + const collections = await this.findAll({ + include: { + model: sequelize.models.book, + include: sequelize.models.libraryItem + } + }) + return collections.map(c => this.getOldCollection(c)) + } + + static getOldCollection(collectionExpanded) { + const libraryItemIds = collectionExpanded.books?.map(b => b.libraryItem?.id || null).filter(lid => lid) || [] + return new oldCollection({ + id: collectionExpanded.id, + libraryId: collectionExpanded.libraryId, + name: collectionExpanded.name, + description: collectionExpanded.description, + books: libraryItemIds, + lastUpdate: collectionExpanded.updatedAt.valueOf(), + createdAt: collectionExpanded.createdAt.valueOf() + }) + } + + static createFromOld(oldCollection) { + const collection = this.getFromOld(oldCollection) + return this.create(collection) + } + + static async fullUpdateFromOld(oldCollection, collectionBooks) { + const existingCollection = await this.findByPk(oldCollection.id, { + include: sequelize.models.collectionBook + }) + if (!existingCollection) return false + + let hasUpdates = false + const collection = this.getFromOld(oldCollection) + + for (const cb of collectionBooks) { + const existingCb = existingCollection.collectionBooks.find(i => i.bookId === cb.bookId) + if (!existingCb) { + await sequelize.models.collectionBook.create(cb) + hasUpdates = true + } else if (existingCb.order != cb.order) { + await existingCb.update({ order: cb.order }) + hasUpdates = true + } + } + for (const cb of existingCollection.collectionBooks) { + // collectionBook was removed + if (!collectionBooks.some(i => i.bookId === cb.bookId)) { + await cb.destroy() + hasUpdates = true + } + } + + let hasCollectionUpdates = false + for (const key in collection) { + let existingValue = existingCollection[key] + if (existingValue instanceof Date) existingValue = existingValue.valueOf() + if (!areEquivalent(collection[key], existingValue)) { + hasCollectionUpdates = true + } + } + if (hasCollectionUpdates) { + existingCollection.update(collection) + hasUpdates = true + } + return hasUpdates + } + + static getFromOld(oldCollection) { + return { + id: oldCollection.id, + name: oldCollection.name, + description: oldCollection.description, + createdAt: oldCollection.createdAt, + updatedAt: oldCollection.lastUpdate, + libraryId: oldCollection.libraryId + } + } + + static removeById(collectionId) { + return this.destroy({ + where: { + id: collectionId + } + }) + } + } + + Collection.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: DataTypes.STRING, + description: DataTypes.TEXT + }, { + sequelize, + modelName: 'collection' + }) + + const { library } = sequelize.models + + library.hasMany(Collection) + Collection.belongsTo(library) + + return Collection +} \ No newline at end of file diff --git a/server/models/CollectionBook.js b/server/models/CollectionBook.js new file mode 100644 index 00000000..0312e0be --- /dev/null +++ b/server/models/CollectionBook.js @@ -0,0 +1,42 @@ +const { DataTypes, Model } = require('sequelize') + +module.exports = (sequelize) => { + class CollectionBook extends Model { + static removeByIds(collectionId, bookId) { + return this.destroy({ + where: { + bookId, + collectionId + } + }) + } + } + + CollectionBook.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + order: DataTypes.INTEGER + }, { + sequelize, + timestamps: true, + updatedAt: false, + modelName: 'collectionBook' + }) + + // Super Many-to-Many + // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship + const { book, collection } = sequelize.models + book.belongsToMany(collection, { through: CollectionBook }) + collection.belongsToMany(book, { through: CollectionBook }) + + book.hasMany(CollectionBook) + CollectionBook.belongsTo(book) + + collection.hasMany(CollectionBook) + CollectionBook.belongsTo(collection) + + return CollectionBook +} \ No newline at end of file diff --git a/server/models/Device.js b/server/models/Device.js new file mode 100644 index 00000000..cde4ce7f --- /dev/null +++ b/server/models/Device.js @@ -0,0 +1,112 @@ +const { DataTypes, Model } = require('sequelize') +const oldDevice = require('../objects/DeviceInfo') + +module.exports = (sequelize) => { + class Device extends Model { + getOldDevice() { + let browserVersion = null + let sdkVersion = null + if (this.clientName === 'Abs Android') { + sdkVersion = this.deviceVersion || null + } else { + browserVersion = this.deviceVersion || null + } + + return new oldDevice({ + id: this.id, + deviceId: this.deviceId, + userId: this.userId, + ipAddress: this.ipAddress, + browserName: this.extraData.browserName || null, + browserVersion, + osName: this.extraData.osName || null, + osVersion: this.extraData.osVersion || null, + clientVersion: this.clientVersion || null, + manufacturer: this.extraData.manufacturer || null, + model: this.extraData.model || null, + sdkVersion, + deviceName: this.deviceName, + clientName: this.clientName + }) + } + + static async getOldDeviceByDeviceId(deviceId) { + const device = await this.findOne({ + deviceId + }) + if (!device) return null + return device.getOldDevice() + } + + static createFromOld(oldDevice) { + const device = this.getFromOld(oldDevice) + return this.create(device) + } + + static updateFromOld(oldDevice) { + const device = this.getFromOld(oldDevice) + return this.update(device, { + where: { + id: device.id + } + }) + } + + static getFromOld(oldDeviceInfo) { + let extraData = {} + + if (oldDeviceInfo.manufacturer) { + extraData.manufacturer = oldDeviceInfo.manufacturer + } + if (oldDeviceInfo.model) { + extraData.model = oldDeviceInfo.model + } + if (oldDeviceInfo.osName) { + extraData.osName = oldDeviceInfo.osName + } + if (oldDeviceInfo.osVersion) { + extraData.osVersion = oldDeviceInfo.osVersion + } + if (oldDeviceInfo.browserName) { + extraData.browserName = oldDeviceInfo.browserName + } + + return { + id: oldDeviceInfo.id, + deviceId: oldDeviceInfo.deviceId, + clientName: oldDeviceInfo.clientName || null, + clientVersion: oldDeviceInfo.clientVersion || null, + ipAddress: oldDeviceInfo.ipAddress, + deviceName: oldDeviceInfo.deviceName || null, + deviceVersion: oldDeviceInfo.sdkVersion || oldDeviceInfo.browserVersion || null, + userId: oldDeviceInfo.userId, + extraData + } + } + } + + Device.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + deviceId: DataTypes.STRING, + clientName: DataTypes.STRING, // e.g. Abs Web, Abs Android + clientVersion: DataTypes.STRING, // e.g. Server version or mobile version + ipAddress: DataTypes.STRING, + deviceName: DataTypes.STRING, // e.g. Windows 10 Chrome, Google Pixel 6, Apple iPhone 10,3 + deviceVersion: DataTypes.STRING, // e.g. Browser version or Android SDK + extraData: DataTypes.JSON + }, { + sequelize, + modelName: 'device' + }) + + const { user } = sequelize.models + + user.hasMany(Device) + Device.belongsTo(user) + + return Device +} \ No newline at end of file diff --git a/server/models/Feed.js b/server/models/Feed.js new file mode 100644 index 00000000..de97271f --- /dev/null +++ b/server/models/Feed.js @@ -0,0 +1,165 @@ +const { DataTypes, Model } = require('sequelize') +const oldFeed = require('../objects/Feed') +/* + * Polymorphic association: https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/ + * Feeds can be created from LibraryItem, Collection, Playlist or Series + */ +module.exports = (sequelize) => { + class Feed extends Model { + static async getOldFeeds() { + const feeds = await this.findAll({ + include: { + model: sequelize.models.feedEpisode + } + }) + return feeds.map(f => this.getOldFeed(f)) + } + + static getOldFeed(feedExpanded) { + const episodes = feedExpanded.feedEpisodes.map((feedEpisode) => feedEpisode.getOldEpisode()) + + return new oldFeed({ + id: feedExpanded.id, + slug: feedExpanded.slug, + userId: feedExpanded.userId, + entityType: feedExpanded.entityType, + entityId: feedExpanded.entityId, + meta: { + title: feedExpanded.title, + description: feedExpanded.description, + author: feedExpanded.author, + imageUrl: feedExpanded.imageURL, + feedUrl: feedExpanded.feedURL, + link: feedExpanded.siteURL, + explicit: feedExpanded.explicit, + type: feedExpanded.podcastType, + language: feedExpanded.language, + preventIndexing: feedExpanded.preventIndexing, + ownerName: feedExpanded.ownerName, + ownerEmail: feedExpanded.ownerEmail + }, + serverAddress: feedExpanded.serverAddress, + feedUrl: feedExpanded.feedURL, + episodes, + createdAt: feedExpanded.createdAt.valueOf(), + updatedAt: feedExpanded.updatedAt.valueOf() + }) + } + + static removeById(feedId) { + return this.destroy({ + where: { + id: feedId + } + }) + } + + getEntity(options) { + if (!this.entityType) return Promise.resolve(null) + const mixinMethodName = `get${sequelize.uppercaseFirst(this.entityType)}` + return this[mixinMethodName](options) + } + } + + Feed.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + slug: DataTypes.STRING, + entityType: DataTypes.STRING, + entityId: DataTypes.UUIDV4, + entityUpdatedAt: DataTypes.DATE, + serverAddress: DataTypes.STRING, + feedURL: DataTypes.STRING, + imageURL: DataTypes.STRING, + siteURL: DataTypes.STRING, + title: DataTypes.STRING, + description: DataTypes.TEXT, + author: DataTypes.STRING, + podcastType: DataTypes.STRING, + language: DataTypes.STRING, + ownerName: DataTypes.STRING, + ownerEmail: DataTypes.STRING, + explicit: DataTypes.BOOLEAN, + preventIndexing: DataTypes.BOOLEAN + }, { + sequelize, + modelName: 'feed' + }) + + const { user, libraryItem, collection, series, playlist } = sequelize.models + + user.hasMany(Feed) + Feed.belongsTo(user) + + libraryItem.hasMany(Feed, { + foreignKey: 'entityId', + constraints: false, + scope: { + entityType: 'libraryItem' + } + }) + Feed.belongsTo(libraryItem, { foreignKey: 'entityId', constraints: false }) + + collection.hasMany(Feed, { + foreignKey: 'entityId', + constraints: false, + scope: { + entityType: 'collection' + } + }) + Feed.belongsTo(collection, { foreignKey: 'entityId', constraints: false }) + + series.hasMany(Feed, { + foreignKey: 'entityId', + constraints: false, + scope: { + entityType: 'series' + } + }) + Feed.belongsTo(series, { foreignKey: 'entityId', constraints: false }) + + playlist.hasMany(Feed, { + foreignKey: 'entityId', + constraints: false, + scope: { + entityType: 'playlist' + } + }) + Feed.belongsTo(playlist, { foreignKey: 'entityId', constraints: false }) + + Feed.addHook('afterFind', findResult => { + if (!findResult) return + + if (!Array.isArray(findResult)) findResult = [findResult] + for (const instance of findResult) { + if (instance.entityType === 'libraryItem' && instance.libraryItem !== undefined) { + instance.entity = instance.libraryItem + instance.dataValues.entity = instance.dataValues.libraryItem + } else if (instance.entityType === 'collection' && instance.collection !== undefined) { + instance.entity = instance.collection + instance.dataValues.entity = instance.dataValues.collection + } else if (instance.entityType === 'series' && instance.series !== undefined) { + instance.entity = instance.series + instance.dataValues.entity = instance.dataValues.series + } else if (instance.entityType === 'playlist' && instance.playlist !== undefined) { + instance.entity = instance.playlist + instance.dataValues.entity = instance.dataValues.playlist + } + + // To prevent mistakes: + delete instance.libraryItem + delete instance.dataValues.libraryItem + delete instance.collection + delete instance.dataValues.collection + delete instance.series + delete instance.dataValues.series + delete instance.playlist + delete instance.dataValues.playlist + } + }) + + return Feed +} \ No newline at end of file diff --git a/server/models/FeedEpisode.js b/server/models/FeedEpisode.js new file mode 100644 index 00000000..2ed6e7fa --- /dev/null +++ b/server/models/FeedEpisode.js @@ -0,0 +1,60 @@ +const { DataTypes, Model } = require('sequelize') + +module.exports = (sequelize) => { + class FeedEpisode extends Model { + getOldEpisode() { + const enclosure = { + url: this.enclosureURL, + size: this.enclosureSize, + type: this.enclosureType + } + return { + id: this.id, + title: this.title, + description: this.description, + enclosure, + pubDate: this.pubDate, + link: this.siteURL, + author: this.author, + explicit: this.explicit, + duration: this.duration, + season: this.season, + episode: this.episode, + episodeType: this.episodeType, + fullPath: this.filePath + } + } + } + + FeedEpisode.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + title: DataTypes.STRING, + author: DataTypes.STRING, + description: DataTypes.TEXT, + siteURL: DataTypes.STRING, + enclosureURL: DataTypes.STRING, + enclosureType: DataTypes.STRING, + enclosureSize: DataTypes.BIGINT, + pubDate: DataTypes.STRING, + season: DataTypes.STRING, + episode: DataTypes.STRING, + episodeType: DataTypes.STRING, + duration: DataTypes.FLOAT, + filePath: DataTypes.STRING, + explicit: DataTypes.BOOLEAN + }, { + sequelize, + modelName: 'feedEpisode' + }) + + const { feed } = sequelize.models + + feed.hasMany(FeedEpisode) + FeedEpisode.belongsTo(feed) + + return FeedEpisode +} \ No newline at end of file diff --git a/server/models/Library.js b/server/models/Library.js new file mode 100644 index 00000000..6c8e8261 --- /dev/null +++ b/server/models/Library.js @@ -0,0 +1,137 @@ +const Logger = require('../Logger') +const { DataTypes, Model } = require('sequelize') + +const oldLibrary = require('../objects/Library') + +module.exports = (sequelize) => { + class Library extends Model { + static async getAllOldLibraries() { + const libraries = await this.findAll({ + include: sequelize.models.libraryFolder + }) + return libraries.map(lib => this.getOldLibrary(lib)) + } + + static getOldLibrary(libraryExpanded) { + const folders = libraryExpanded.libraryFolders.map(folder => { + return { + id: folder.id, + fullPath: folder.path, + libraryId: folder.libraryId, + addedAt: folder.createdAt.valueOf() + } + }) + return new oldLibrary({ + id: libraryExpanded.id, + name: libraryExpanded.name, + folders, + displayOrder: libraryExpanded.displayOrder, + icon: libraryExpanded.icon, + mediaType: libraryExpanded.mediaType, + provider: libraryExpanded.provider, + settings: libraryExpanded.settings, + createdAt: libraryExpanded.createdAt.valueOf(), + lastUpdate: libraryExpanded.updatedAt.valueOf() + }) + } + + /** + * @param {object} oldLibrary + * @returns {Library|null} + */ + static createFromOld(oldLibrary) { + const library = this.getFromOld(oldLibrary) + + library.libraryFolders = oldLibrary.folders.map(folder => { + return { + id: folder.id, + path: folder.fullPath, + libraryId: library.id + } + }) + + return this.create(library).catch((error) => { + Logger.error(`[Library] Failed to create library ${library.id}`, error) + return null + }) + } + + static async updateFromOld(oldLibrary) { + const existingLibrary = await this.findByPk(oldLibrary.id, { + include: sequelize.models.libraryFolder + }) + if (!existingLibrary) { + Logger.error(`[Library] Failed to update library ${oldLibrary.id} - not found`) + return null + } + + const library = this.getFromOld(oldLibrary) + + const libraryFolders = oldLibrary.folders.map(folder => { + return { + id: folder.id, + path: folder.fullPath, + libraryId: library.id + } + }) + for (const libraryFolder of libraryFolders) { + const existingLibraryFolder = existingLibrary.libraryFolders.find(lf => lf.id === libraryFolder.id) + if (!existingLibraryFolder) { + await sequelize.models.libraryFolder.create(libraryFolder) + } else if (existingLibraryFolder.path !== libraryFolder.path) { + await existingLibraryFolder.update({ path: libraryFolder.path }) + } + } + + const libraryFoldersRemoved = existingLibrary.libraryFolders.filter(lf => !libraryFolders.some(_lf => _lf.id === lf.id)) + for (const existingLibraryFolder of libraryFoldersRemoved) { + await existingLibraryFolder.destroy() + } + + return existingLibrary.update(library) + } + + static getFromOld(oldLibrary) { + return { + id: oldLibrary.id, + name: oldLibrary.name, + displayOrder: oldLibrary.displayOrder, + icon: oldLibrary.icon || null, + mediaType: oldLibrary.mediaType || null, + provider: oldLibrary.provider, + settings: oldLibrary.settings?.toJSON() || {}, + createdAt: oldLibrary.createdAt, + updatedAt: oldLibrary.lastUpdate + } + } + + static removeById(libraryId) { + return this.destroy({ + where: { + id: libraryId + } + }) + } + } + + Library.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: DataTypes.STRING, + displayOrder: DataTypes.INTEGER, + icon: DataTypes.STRING, + mediaType: DataTypes.STRING, + provider: DataTypes.STRING, + lastScan: DataTypes.DATE, + lastScanVersion: DataTypes.STRING, + settings: DataTypes.JSON + }, { + sequelize, + modelName: 'library' + }) + + return Library +} \ No newline at end of file diff --git a/server/models/LibraryFolder.js b/server/models/LibraryFolder.js new file mode 100644 index 00000000..537947f3 --- /dev/null +++ b/server/models/LibraryFolder.js @@ -0,0 +1,23 @@ +const { DataTypes, Model } = require('sequelize') + +module.exports = (sequelize) => { + class LibraryFolder extends Model { } + + LibraryFolder.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + path: DataTypes.STRING + }, { + sequelize, + modelName: 'libraryFolder' + }) + + const { library } = sequelize.models + library.hasMany(LibraryFolder) + LibraryFolder.belongsTo(library) + + return LibraryFolder +} \ No newline at end of file diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js new file mode 100644 index 00000000..3cf6d87c --- /dev/null +++ b/server/models/LibraryItem.js @@ -0,0 +1,393 @@ +const { DataTypes, Model } = require('sequelize') +const Logger = require('../Logger') +const oldLibraryItem = require('../objects/LibraryItem') +const { areEquivalent } = require('../utils/index') + +module.exports = (sequelize) => { + class LibraryItem extends Model { + static async getAllOldLibraryItems() { + let libraryItems = await this.findAll({ + include: [ + { + model: sequelize.models.book, + include: [ + { + model: sequelize.models.author, + through: { + attributes: [] + } + }, + { + model: sequelize.models.series, + through: { + attributes: ['sequence'] + } + } + ] + }, + { + model: sequelize.models.podcast, + include: [ + { + model: sequelize.models.podcastEpisode + } + ] + } + ] + }) + return libraryItems.map(ti => this.getOldLibraryItem(ti)) + } + + static getOldLibraryItem(libraryItemExpanded) { + let media = null + if (libraryItemExpanded.mediaType === 'book') { + media = sequelize.models.book.getOldBook(libraryItemExpanded) + } else if (libraryItemExpanded.mediaType === 'podcast') { + media = sequelize.models.podcast.getOldPodcast(libraryItemExpanded) + } + + return new oldLibraryItem({ + id: libraryItemExpanded.id, + ino: libraryItemExpanded.ino, + libraryId: libraryItemExpanded.libraryId, + folderId: libraryItemExpanded.libraryFolderId, + path: libraryItemExpanded.path, + relPath: libraryItemExpanded.relPath, + isFile: libraryItemExpanded.isFile, + mtimeMs: libraryItemExpanded.mtime?.valueOf(), + ctimeMs: libraryItemExpanded.ctime?.valueOf(), + birthtimeMs: libraryItemExpanded.birthtime?.valueOf(), + addedAt: libraryItemExpanded.createdAt.valueOf(), + updatedAt: libraryItemExpanded.updatedAt.valueOf(), + lastScan: libraryItemExpanded.lastScan?.valueOf(), + scanVersion: libraryItemExpanded.lastScanVersion, + isMissing: !!libraryItemExpanded.isMissing, + isInvalid: !!libraryItemExpanded.isInvalid, + mediaType: libraryItemExpanded.mediaType, + media, + libraryFiles: libraryItemExpanded.libraryFiles + }) + } + + static async fullCreateFromOld(oldLibraryItem) { + const newLibraryItem = await this.create(this.getFromOld(oldLibraryItem)) + + if (oldLibraryItem.mediaType === 'book') { + const bookObj = sequelize.models.book.getFromOld(oldLibraryItem.media) + bookObj.libraryItemId = newLibraryItem.id + const newBook = await sequelize.models.book.create(bookObj) + + const oldBookAuthors = oldLibraryItem.media.metadata.authors || [] + const oldBookSeriesAll = oldLibraryItem.media.metadata.series || [] + + for (const oldBookAuthor of oldBookAuthors) { + await sequelize.models.bookAuthor.create({ authorId: oldBookAuthor.id, bookId: newBook.id }) + } + for (const oldSeries of oldBookSeriesAll) { + await sequelize.models.bookSeries.create({ seriesId: oldSeries.id, bookId: newBook.id, sequence: oldSeries.sequence }) + } + } else if (oldLibraryItem.mediaType === 'podcast') { + const podcastObj = sequelize.models.podcast.getFromOld(oldLibraryItem.media) + podcastObj.libraryItemId = newLibraryItem.id + const newPodcast = await sequelize.models.podcast.create(podcastObj) + + const oldEpisodes = oldLibraryItem.media.episodes || [] + for (const oldEpisode of oldEpisodes) { + const episodeObj = sequelize.models.podcastEpisode.getFromOld(oldEpisode) + episodeObj.libraryItemId = newLibraryItem.id + episodeObj.podcastId = newPodcast.id + await sequelize.models.podcastEpisode.create(episodeObj) + } + } + + return newLibraryItem + } + + static async fullUpdateFromOld(oldLibraryItem) { + const libraryItemExpanded = await this.findByPk(oldLibraryItem.id, { + include: [ + { + model: sequelize.models.book, + include: [ + { + model: sequelize.models.author, + through: { + attributes: [] + } + }, + { + model: sequelize.models.series, + through: { + attributes: ['sequence'] + } + } + ] + }, + { + model: sequelize.models.podcast, + include: [ + { + model: sequelize.models.podcastEpisode + } + ] + } + ] + }) + if (!libraryItemExpanded) return false + + let hasUpdates = false + + // Check update Book/Podcast + if (libraryItemExpanded.media) { + let updatedMedia = null + if (libraryItemExpanded.mediaType === 'podcast') { + updatedMedia = sequelize.models.podcast.getFromOld(oldLibraryItem.media) + + const existingPodcastEpisodes = libraryItemExpanded.media.podcastEpisodes || [] + const updatedPodcastEpisodes = oldLibraryItem.media.episodes || [] + + for (const existingPodcastEpisode of existingPodcastEpisodes) { + // Episode was removed + if (!updatedPodcastEpisodes.some(ep => ep.id === existingPodcastEpisode.id)) { + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingPodcastEpisode.title}" was removed`) + await existingPodcastEpisode.destroy() + hasUpdates = true + } + } + for (const updatedPodcastEpisode of updatedPodcastEpisodes) { + const existingEpisodeMatch = existingPodcastEpisodes.find(ep => ep.id === updatedPodcastEpisode.id) + if (!existingEpisodeMatch) { + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${updatedPodcastEpisode.title}" was added`) + await sequelize.models.podcastEpisode.createFromOld(updatedPodcastEpisode) + hasUpdates = true + } else { + const updatedEpisodeCleaned = sequelize.models.podcastEpisode.getFromOld(updatedPodcastEpisode) + let episodeHasUpdates = false + for (const key in updatedEpisodeCleaned) { + let existingValue = existingEpisodeMatch[key] + if (existingValue instanceof Date) existingValue = existingValue.valueOf() + + if (!areEquivalent(updatedEpisodeCleaned[key], existingValue, true)) { + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingEpisodeMatch.title}" ${key} was updated from "${existingValue}" to "${updatedEpisodeCleaned[key]}"`) + episodeHasUpdates = true + } + } + if (episodeHasUpdates) { + await existingEpisodeMatch.update(updatedEpisodeCleaned) + hasUpdates = true + } + } + } + } else if (libraryItemExpanded.mediaType === 'book') { + updatedMedia = sequelize.models.book.getFromOld(oldLibraryItem.media) + + const existingAuthors = libraryItemExpanded.media.authors || [] + const existingSeriesAll = libraryItemExpanded.media.series || [] + const updatedAuthors = oldLibraryItem.media.metadata.authors || [] + const updatedSeriesAll = oldLibraryItem.media.metadata.series || [] + + for (const existingAuthor of existingAuthors) { + // Author was removed from Book + if (!updatedAuthors.some(au => au.id === existingAuthor.id)) { + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${existingAuthor.name}" was removed`) + await sequelize.models.bookAuthor.removeByIds(existingAuthor.id, libraryItemExpanded.media.id) + hasUpdates = true + } + } + for (const updatedAuthor of updatedAuthors) { + // Author was added + if (!existingAuthors.some(au => au.id === updatedAuthor.id)) { + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${updatedAuthor.name}" was added`) + await sequelize.models.bookAuthor.create({ authorId: updatedAuthor.id, bookId: libraryItemExpanded.media.id }) + hasUpdates = true + } + } + for (const existingSeries of existingSeriesAll) { + // Series was removed + if (!updatedSeriesAll.some(se => se.id === existingSeries.id)) { + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${existingSeries.name}" was removed`) + await sequelize.models.bookSeries.removeByIds(existingSeries.id, libraryItemExpanded.media.id) + hasUpdates = true + } + } + for (const updatedSeries of updatedSeriesAll) { + // Series was added/updated + const existingSeriesMatch = existingSeriesAll.find(se => se.id === updatedSeries.id) + if (!existingSeriesMatch) { + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" was added`) + await sequelize.models.bookSeries.create({ seriesId: updatedSeries.id, bookId: libraryItemExpanded.media.id, sequence: updatedSeries.sequence }) + hasUpdates = true + } else if (existingSeriesMatch.bookSeries.sequence !== updatedSeries.sequence) { + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" sequence was updated from "${existingSeriesMatch.bookSeries.sequence}" to "${updatedSeries.sequence}"`) + await existingSeriesMatch.bookSeries.update({ sequence: updatedSeries.sequence }) + hasUpdates = true + } + } + } + + let hasMediaUpdates = false + for (const key in updatedMedia) { + let existingValue = libraryItemExpanded.media[key] + if (existingValue instanceof Date) existingValue = existingValue.valueOf() + + if (!areEquivalent(updatedMedia[key], existingValue, true)) { + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from ${existingValue} to ${updatedMedia[key]}`) + hasMediaUpdates = true + } + } + if (hasMediaUpdates && updatedMedia) { + await libraryItemExpanded.media.update(updatedMedia) + hasUpdates = true + } + } + + const updatedLibraryItem = this.getFromOld(oldLibraryItem) + let hasLibraryItemUpdates = false + for (const key in updatedLibraryItem) { + let existingValue = libraryItemExpanded[key] + if (existingValue instanceof Date) existingValue = existingValue.valueOf() + + if (!areEquivalent(updatedLibraryItem[key], existingValue, true)) { + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" ${key} updated from ${existingValue} to ${updatedLibraryItem[key]}`) + hasLibraryItemUpdates = true + } + } + if (hasLibraryItemUpdates) { + await libraryItemExpanded.update(updatedLibraryItem) + hasUpdates = true + } + return hasUpdates + } + + static updateFromOld(oldLibraryItem) { + const libraryItem = this.getFromOld(oldLibraryItem) + return this.update(libraryItem, { + where: { + id: libraryItem.id + } + }).then((result) => result[0] > 0).catch((error) => { + Logger.error(`[LibraryItem] Failed to update libraryItem ${libraryItem.id}`, error) + return false + }) + } + + static getFromOld(oldLibraryItem) { + return { + id: oldLibraryItem.id, + ino: oldLibraryItem.ino, + path: oldLibraryItem.path, + relPath: oldLibraryItem.relPath, + mediaId: oldLibraryItem.media.id, + mediaType: oldLibraryItem.mediaType, + isFile: !!oldLibraryItem.isFile, + isMissing: !!oldLibraryItem.isMissing, + isInvalid: !!oldLibraryItem.isInvalid, + mtime: oldLibraryItem.mtimeMs, + ctime: oldLibraryItem.ctimeMs, + birthtime: oldLibraryItem.birthtimeMs, + lastScan: oldLibraryItem.lastScan, + lastScanVersion: oldLibraryItem.scanVersion, + createdAt: oldLibraryItem.addedAt, + updatedAt: oldLibraryItem.updatedAt, + libraryId: oldLibraryItem.libraryId, + libraryFolderId: oldLibraryItem.folderId, + libraryFiles: oldLibraryItem.libraryFiles?.map(lf => lf.toJSON()) || [] + } + } + + static removeById(libraryItemId) { + return this.destroy({ + where: { + id: libraryItemId + }, + individualHooks: true + }) + } + + getMedia(options) { + if (!this.mediaType) return Promise.resolve(null) + const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaType)}` + return this[mixinMethodName](options) + } + } + + LibraryItem.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + ino: DataTypes.STRING, + path: DataTypes.STRING, + relPath: DataTypes.STRING, + mediaId: DataTypes.UUIDV4, + mediaType: DataTypes.STRING, + isFile: DataTypes.BOOLEAN, + isMissing: DataTypes.BOOLEAN, + isInvalid: DataTypes.BOOLEAN, + mtime: DataTypes.DATE(6), + ctime: DataTypes.DATE(6), + birthtime: DataTypes.DATE(6), + lastScan: DataTypes.DATE, + lastScanVersion: DataTypes.STRING, + libraryFiles: DataTypes.JSON + }, { + sequelize, + modelName: 'libraryItem' + }) + + const { library, libraryFolder, book, podcast } = sequelize.models + library.hasMany(LibraryItem) + LibraryItem.belongsTo(library) + + libraryFolder.hasMany(LibraryItem) + LibraryItem.belongsTo(libraryFolder) + + book.hasOne(LibraryItem, { + foreignKey: 'mediaId', + constraints: false, + scope: { + mediaType: 'book' + } + }) + LibraryItem.belongsTo(book, { foreignKey: 'mediaId', constraints: false }) + + podcast.hasOne(LibraryItem, { + foreignKey: 'mediaId', + constraints: false, + scope: { + mediaType: 'podcast' + } + }) + LibraryItem.belongsTo(podcast, { foreignKey: 'mediaId', constraints: false }) + + LibraryItem.addHook('afterFind', findResult => { + if (!findResult) return + + if (!Array.isArray(findResult)) findResult = [findResult] + for (const instance of findResult) { + if (instance.mediaType === 'book' && instance.book !== undefined) { + instance.media = instance.book + instance.dataValues.media = instance.dataValues.book + } else if (instance.mediaType === 'podcast' && instance.podcast !== undefined) { + instance.media = instance.podcast + instance.dataValues.media = instance.dataValues.podcast + } + // To prevent mistakes: + delete instance.book + delete instance.dataValues.book + delete instance.podcast + delete instance.dataValues.podcast + } + }) + + LibraryItem.addHook('afterDestroy', async instance => { + if (!instance) return + const media = await instance.getMedia() + if (media) { + media.destroy() + } + }) + + return LibraryItem +} \ No newline at end of file diff --git a/server/models/MediaProgress.js b/server/models/MediaProgress.js new file mode 100644 index 00000000..913f46ec --- /dev/null +++ b/server/models/MediaProgress.js @@ -0,0 +1,141 @@ +const { DataTypes, Model } = require('sequelize') + +/* + * Polymorphic association: https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/ + * Book has many MediaProgress. PodcastEpisode has many MediaProgress. + */ +module.exports = (sequelize) => { + class MediaProgress extends Model { + getOldMediaProgress() { + const isPodcastEpisode = this.mediaItemType === 'podcastEpisode' + + return { + id: this.id, + userId: this.userId, + libraryItemId: this.extraData?.libraryItemId || null, + episodeId: isPodcastEpisode ? this.mediaItemId : null, + mediaItemId: this.mediaItemId, + mediaItemType: this.mediaItemType, + duration: this.duration, + progress: this.extraData?.progress || null, + currentTime: this.currentTime, + isFinished: !!this.isFinished, + hideFromContinueListening: !!this.hideFromContinueListening, + ebookLocation: this.ebookLocation, + ebookProgress: this.ebookProgress, + lastUpdate: this.updatedAt.valueOf(), + startedAt: this.createdAt.valueOf(), + finishedAt: this.finishedAt?.valueOf() || null + } + } + + static upsertFromOld(oldMediaProgress) { + const mediaProgress = this.getFromOld(oldMediaProgress) + return this.upsert(mediaProgress) + } + + static getFromOld(oldMediaProgress) { + return { + id: oldMediaProgress.id, + userId: oldMediaProgress.userId, + mediaItemId: oldMediaProgress.mediaItemId, + mediaItemType: oldMediaProgress.mediaItemType, + duration: oldMediaProgress.duration, + currentTime: oldMediaProgress.currentTime, + ebookLocation: oldMediaProgress.ebookLocation || null, + ebookProgress: oldMediaProgress.ebookProgress || null, + isFinished: !!oldMediaProgress.isFinished, + hideFromContinueListening: !!oldMediaProgress.hideFromContinueListening, + finishedAt: oldMediaProgress.finishedAt, + createdAt: oldMediaProgress.startedAt || oldMediaProgress.lastUpdate, + updatedAt: oldMediaProgress.lastUpdate, + extraData: { + libraryItemId: oldMediaProgress.libraryItemId, + progress: oldMediaProgress.progress + } + } + } + + static removeById(mediaProgressId) { + return this.destroy({ + where: { + id: mediaProgressId + } + }) + } + + getMediaItem(options) { + if (!this.mediaItemType) return Promise.resolve(null) + const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}` + return this[mixinMethodName](options) + } + } + + + MediaProgress.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + mediaItemId: DataTypes.UUIDV4, + mediaItemType: DataTypes.STRING, + duration: DataTypes.FLOAT, + currentTime: DataTypes.FLOAT, + isFinished: DataTypes.BOOLEAN, + hideFromContinueListening: DataTypes.BOOLEAN, + ebookLocation: DataTypes.STRING, + ebookProgress: DataTypes.FLOAT, + finishedAt: DataTypes.DATE, + extraData: DataTypes.JSON + }, { + sequelize, + modelName: 'mediaProgress' + }) + + const { book, podcastEpisode, user } = sequelize.models + + book.hasMany(MediaProgress, { + foreignKey: 'mediaItemId', + constraints: false, + scope: { + mediaItemType: 'book' + } + }) + MediaProgress.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false }) + + podcastEpisode.hasMany(MediaProgress, { + foreignKey: 'mediaItemId', + constraints: false, + scope: { + mediaItemType: 'podcastEpisode' + } + }) + MediaProgress.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false }) + + MediaProgress.addHook('afterFind', findResult => { + if (!findResult) return + + if (!Array.isArray(findResult)) findResult = [findResult] + + for (const instance of findResult) { + if (instance.mediaItemType === 'book' && instance.book !== undefined) { + instance.mediaItem = instance.book + instance.dataValues.mediaItem = instance.dataValues.book + } else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) { + instance.mediaItem = instance.podcastEpisode + instance.dataValues.mediaItem = instance.dataValues.podcastEpisode + } + // To prevent mistakes: + delete instance.book + delete instance.dataValues.book + delete instance.podcastEpisode + delete instance.dataValues.podcastEpisode + } + }) + + user.hasMany(MediaProgress) + MediaProgress.belongsTo(user) + + return MediaProgress +} \ No newline at end of file diff --git a/server/models/PlaybackSession.js b/server/models/PlaybackSession.js new file mode 100644 index 00000000..0e0e04b5 --- /dev/null +++ b/server/models/PlaybackSession.js @@ -0,0 +1,198 @@ +const { DataTypes, Model } = require('sequelize') + +const oldPlaybackSession = require('../objects/PlaybackSession') + +module.exports = (sequelize) => { + class PlaybackSession extends Model { + static async getOldPlaybackSessions(where = null) { + const playbackSessions = await this.findAll({ + where, + include: [ + { + model: sequelize.models.device + } + ] + }) + return playbackSessions.map(session => this.getOldPlaybackSession(session)) + } + + static async getById(sessionId) { + const playbackSession = await this.findByPk(sessionId, { + include: [ + { + model: sequelize.models.device + } + ] + }) + if (!playbackSession) return null + return this.getOldPlaybackSession(playbackSession) + } + + static getOldPlaybackSession(playbackSessionExpanded) { + const isPodcastEpisode = playbackSessionExpanded.mediaItemType === 'podcastEpisode' + + return new oldPlaybackSession({ + id: playbackSessionExpanded.id, + userId: playbackSessionExpanded.userId, + libraryId: playbackSessionExpanded.libraryId, + libraryItemId: playbackSessionExpanded.extraData?.libraryItemId || null, + bookId: isPodcastEpisode ? null : playbackSessionExpanded.mediaItemId, + episodeId: isPodcastEpisode ? playbackSessionExpanded.mediaItemId : null, + mediaType: isPodcastEpisode ? 'podcast' : 'book', + mediaMetadata: playbackSessionExpanded.mediaMetadata, + chapters: null, + displayTitle: playbackSessionExpanded.displayTitle, + displayAuthor: playbackSessionExpanded.displayAuthor, + coverPath: playbackSessionExpanded.coverPath, + duration: playbackSessionExpanded.duration, + playMethod: playbackSessionExpanded.playMethod, + mediaPlayer: playbackSessionExpanded.mediaPlayer, + deviceInfo: playbackSessionExpanded.device?.getOldDevice() || null, + serverVersion: playbackSessionExpanded.serverVersion, + date: playbackSessionExpanded.date, + dayOfWeek: playbackSessionExpanded.dayOfWeek, + timeListening: playbackSessionExpanded.timeListening, + startTime: playbackSessionExpanded.startTime, + currentTime: playbackSessionExpanded.currentTime, + startedAt: playbackSessionExpanded.createdAt.valueOf(), + updatedAt: playbackSessionExpanded.updatedAt.valueOf() + }) + } + + static removeById(sessionId) { + return this.destroy({ + where: { + id: sessionId + } + }) + } + + static createFromOld(oldPlaybackSession) { + const playbackSession = this.getFromOld(oldPlaybackSession) + return this.create(playbackSession) + } + + static updateFromOld(oldPlaybackSession) { + const playbackSession = this.getFromOld(oldPlaybackSession) + return this.update(playbackSession, { + where: { + id: playbackSession.id + } + }) + } + + static getFromOld(oldPlaybackSession) { + return { + id: oldPlaybackSession.id, + mediaItemId: oldPlaybackSession.episodeId || oldPlaybackSession.bookId, + mediaItemType: oldPlaybackSession.episodeId ? 'podcastEpisode' : 'book', + libraryId: oldPlaybackSession.libraryId, + displayTitle: oldPlaybackSession.displayTitle, + displayAuthor: oldPlaybackSession.displayAuthor, + duration: oldPlaybackSession.duration, + playMethod: oldPlaybackSession.playMethod, + mediaPlayer: oldPlaybackSession.mediaPlayer, + startTime: oldPlaybackSession.startTime, + currentTime: oldPlaybackSession.currentTime, + serverVersion: oldPlaybackSession.serverVersion || null, + createdAt: oldPlaybackSession.startedAt, + updatedAt: oldPlaybackSession.updatedAt, + userId: oldPlaybackSession.userId, + deviceId: oldPlaybackSession.deviceInfo?.id || null, + timeListening: oldPlaybackSession.timeListening, + coverPath: oldPlaybackSession.coverPath, + mediaMetadata: oldPlaybackSession.mediaMetadata, + date: oldPlaybackSession.date, + dayOfWeek: oldPlaybackSession.dayOfWeek, + extraData: { + libraryItemId: oldPlaybackSession.libraryItemId + } + } + } + + getMediaItem(options) { + if (!this.mediaItemType) return Promise.resolve(null) + const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}` + return this[mixinMethodName](options) + } + } + + PlaybackSession.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + mediaItemId: DataTypes.UUIDV4, + mediaItemType: DataTypes.STRING, + displayTitle: DataTypes.STRING, + displayAuthor: DataTypes.STRING, + duration: DataTypes.FLOAT, + playMethod: DataTypes.INTEGER, + mediaPlayer: DataTypes.STRING, + startTime: DataTypes.FLOAT, + currentTime: DataTypes.FLOAT, + serverVersion: DataTypes.STRING, + coverPath: DataTypes.STRING, + timeListening: DataTypes.INTEGER, + mediaMetadata: DataTypes.JSON, + date: DataTypes.STRING, + dayOfWeek: DataTypes.STRING, + extraData: DataTypes.JSON + }, { + sequelize, + modelName: 'playbackSession' + }) + + const { book, podcastEpisode, user, device, library } = sequelize.models + + user.hasMany(PlaybackSession) + PlaybackSession.belongsTo(user) + + device.hasMany(PlaybackSession) + PlaybackSession.belongsTo(device) + + library.hasMany(PlaybackSession) + PlaybackSession.belongsTo(library) + + book.hasMany(PlaybackSession, { + foreignKey: 'mediaItemId', + constraints: false, + scope: { + mediaItemType: 'book' + } + }) + PlaybackSession.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false }) + + podcastEpisode.hasOne(PlaybackSession, { + foreignKey: 'mediaItemId', + constraints: false, + scope: { + mediaItemType: 'podcastEpisode' + } + }) + PlaybackSession.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false }) + + PlaybackSession.addHook('afterFind', findResult => { + if (!findResult) return + + if (!Array.isArray(findResult)) findResult = [findResult] + + for (const instance of findResult) { + if (instance.mediaItemType === 'book' && instance.book !== undefined) { + instance.mediaItem = instance.book + instance.dataValues.mediaItem = instance.dataValues.book + } else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) { + instance.mediaItem = instance.podcastEpisode + instance.dataValues.mediaItem = instance.dataValues.podcastEpisode + } + // To prevent mistakes: + delete instance.book + delete instance.dataValues.book + delete instance.podcastEpisode + delete instance.dataValues.podcastEpisode + } + }) + + return PlaybackSession +} \ No newline at end of file diff --git a/server/models/Playlist.js b/server/models/Playlist.js new file mode 100644 index 00000000..b018ee73 --- /dev/null +++ b/server/models/Playlist.js @@ -0,0 +1,171 @@ +const { DataTypes, Model } = require('sequelize') + +const oldPlaylist = require('../objects/Playlist') +const { areEquivalent } = require('../utils/index') + +module.exports = (sequelize) => { + class Playlist extends Model { + static async getOldPlaylists() { + const playlists = await this.findAll({ + include: { + model: sequelize.models.playlistMediaItem, + include: [ + { + model: sequelize.models.book, + include: sequelize.models.libraryItem + }, + { + model: sequelize.models.podcastEpisode, + include: { + model: sequelize.models.podcast, + include: sequelize.models.libraryItem + } + } + ] + } + }) + return playlists.map(p => this.getOldPlaylist(p)) + } + + static getOldPlaylist(playlistExpanded) { + const items = playlistExpanded.playlistMediaItems.map(pmi => { + const libraryItemId = pmi.mediaItem?.podcast?.libraryItem?.id || pmi.mediaItem?.libraryItem?.id || null + if (!libraryItemId) { + console.log(JSON.stringify(pmi, null, 2)) + throw new Error('No library item id') + } + return { + episodeId: pmi.mediaItemType === 'podcastEpisode' ? pmi.mediaItemId : '', + libraryItemId: libraryItemId + } + }) + return new oldPlaylist({ + id: playlistExpanded.id, + libraryId: playlistExpanded.libraryId, + userId: playlistExpanded.userId, + name: playlistExpanded.name, + description: playlistExpanded.description, + items, + lastUpdate: playlistExpanded.updatedAt.valueOf(), + createdAt: playlistExpanded.createdAt.valueOf() + }) + } + + static createFromOld(oldPlaylist) { + const playlist = this.getFromOld(oldPlaylist) + return this.create(playlist) + } + + static async fullUpdateFromOld(oldPlaylist, playlistMediaItems) { + const existingPlaylist = await this.findByPk(oldPlaylist.id, { + include: sequelize.models.playlistMediaItem + }) + if (!existingPlaylist) return false + + let hasUpdates = false + const playlist = this.getFromOld(oldPlaylist) + + for (const pmi of playlistMediaItems) { + const existingPmi = existingPlaylist.playlistMediaItems.find(i => i.mediaItemId === pmi.mediaItemId) + if (!existingPmi) { + await sequelize.models.playlistMediaItem.create(pmi) + hasUpdates = true + } else if (existingPmi.order != pmi.order) { + await existingPmi.update({ order: pmi.order }) + hasUpdates = true + } + } + for (const pmi of existingPlaylist.playlistMediaItems) { + // Pmi was removed + if (!playlistMediaItems.some(i => i.mediaItemId === pmi.mediaItemId)) { + await pmi.destroy() + hasUpdates = true + } + } + + let hasPlaylistUpdates = false + for (const key in playlist) { + let existingValue = existingPlaylist[key] + if (existingValue instanceof Date) existingValue = existingValue.valueOf() + + if (!areEquivalent(playlist[key], existingValue)) { + hasPlaylistUpdates = true + } + } + if (hasPlaylistUpdates) { + existingPlaylist.update(playlist) + hasUpdates = true + } + return hasUpdates + } + + static getFromOld(oldPlaylist) { + return { + id: oldPlaylist.id, + name: oldPlaylist.name, + description: oldPlaylist.description, + createdAt: oldPlaylist.createdAt, + updatedAt: oldPlaylist.lastUpdate, + userId: oldPlaylist.userId, + libraryId: oldPlaylist.libraryId + } + } + + static removeById(playlistId) { + return this.destroy({ + where: { + id: playlistId + } + }) + } + } + + Playlist.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: DataTypes.STRING, + description: DataTypes.TEXT + }, { + sequelize, + modelName: 'playlist' + }) + + const { library, user } = sequelize.models + library.hasMany(Playlist) + Playlist.belongsTo(library) + + user.hasMany(Playlist) + Playlist.belongsTo(user) + + Playlist.addHook('afterFind', findResult => { + if (!findResult) return + + if (!Array.isArray(findResult)) findResult = [findResult] + + for (const instance of findResult) { + if (instance.playlistMediaItems?.length) { + instance.playlistMediaItems = instance.playlistMediaItems.map(pmi => { + if (pmi.mediaItemType === 'book' && pmi.book !== undefined) { + pmi.mediaItem = pmi.book + pmi.dataValues.mediaItem = pmi.dataValues.book + } else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) { + pmi.mediaItem = pmi.podcastEpisode + pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode + } + // To prevent mistakes: + delete pmi.book + delete pmi.dataValues.book + delete pmi.podcastEpisode + delete pmi.dataValues.podcastEpisode + return pmi + }) + } + + } + }) + + return Playlist +} \ No newline at end of file diff --git a/server/models/PlaylistMediaItem.js b/server/models/PlaylistMediaItem.js new file mode 100644 index 00000000..7d2960e8 --- /dev/null +++ b/server/models/PlaylistMediaItem.js @@ -0,0 +1,82 @@ +const { DataTypes, Model } = require('sequelize') + +module.exports = (sequelize) => { + class PlaylistMediaItem extends Model { + static removeByIds(playlistId, mediaItemId) { + return this.destroy({ + where: { + playlistId, + mediaItemId + } + }) + } + + getMediaItem(options) { + if (!this.mediaItemType) return Promise.resolve(null) + const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}` + return this[mixinMethodName](options) + } + } + + PlaylistMediaItem.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + mediaItemId: DataTypes.UUIDV4, + mediaItemType: DataTypes.STRING, + order: DataTypes.INTEGER + }, { + sequelize, + timestamps: true, + updatedAt: false, + modelName: 'playlistMediaItem' + }) + + const { book, podcastEpisode, playlist } = sequelize.models + + book.hasMany(PlaylistMediaItem, { + foreignKey: 'mediaItemId', + constraints: false, + scope: { + mediaItemType: 'book' + } + }) + PlaylistMediaItem.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false }) + + podcastEpisode.hasOne(PlaylistMediaItem, { + foreignKey: 'mediaItemId', + constraints: false, + scope: { + mediaItemType: 'podcastEpisode' + } + }) + PlaylistMediaItem.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false }) + + PlaylistMediaItem.addHook('afterFind', findResult => { + if (!findResult) return + + if (!Array.isArray(findResult)) findResult = [findResult] + + for (const instance of findResult) { + if (instance.mediaItemType === 'book' && instance.book !== undefined) { + instance.mediaItem = instance.book + instance.dataValues.mediaItem = instance.dataValues.book + } else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) { + instance.mediaItem = instance.podcastEpisode + instance.dataValues.mediaItem = instance.dataValues.podcastEpisode + } + // To prevent mistakes: + delete instance.book + delete instance.dataValues.book + delete instance.podcastEpisode + delete instance.dataValues.podcastEpisode + } + }) + + playlist.hasMany(PlaylistMediaItem) + PlaylistMediaItem.belongsTo(playlist) + + return PlaylistMediaItem +} \ No newline at end of file diff --git a/server/models/Podcast.js b/server/models/Podcast.js new file mode 100644 index 00000000..0d68bd5e --- /dev/null +++ b/server/models/Podcast.js @@ -0,0 +1,98 @@ +const { DataTypes, Model } = require('sequelize') + +module.exports = (sequelize) => { + class Podcast extends Model { + static getOldPodcast(libraryItemExpanded) { + const podcastExpanded = libraryItemExpanded.media + const podcastEpisodes = podcastExpanded.podcastEpisodes.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id)).sort((a, b) => a.index - b.index) + return { + id: podcastExpanded.id, + libraryItemId: libraryItemExpanded.id, + metadata: { + title: podcastExpanded.title, + author: podcastExpanded.author, + description: podcastExpanded.description, + releaseDate: podcastExpanded.releaseDate, + genres: podcastExpanded.genres, + feedUrl: podcastExpanded.feedURL, + imageUrl: podcastExpanded.imageURL, + itunesPageUrl: podcastExpanded.itunesPageURL, + itunesId: podcastExpanded.itunesId, + itunesArtistId: podcastExpanded.itunesArtistId, + explicit: podcastExpanded.explicit, + language: podcastExpanded.language, + type: podcastExpanded.podcastType + }, + coverPath: podcastExpanded.coverPath, + tags: podcastExpanded.tags, + episodes: podcastEpisodes, + autoDownloadEpisodes: podcastExpanded.autoDownloadEpisodes, + autoDownloadSchedule: podcastExpanded.autoDownloadSchedule, + lastEpisodeCheck: podcastExpanded.lastEpisodeCheck?.valueOf() || null, + maxEpisodesToKeep: podcastExpanded.maxEpisodesToKeep, + maxNewEpisodesToDownload: podcastExpanded.maxNewEpisodesToDownload + } + } + + static getFromOld(oldPodcast) { + const oldPodcastMetadata = oldPodcast.metadata + return { + id: oldPodcast.id, + title: oldPodcastMetadata.title, + author: oldPodcastMetadata.author, + releaseDate: oldPodcastMetadata.releaseDate, + feedURL: oldPodcastMetadata.feedUrl, + imageURL: oldPodcastMetadata.imageUrl, + description: oldPodcastMetadata.description, + itunesPageURL: oldPodcastMetadata.itunesPageUrl, + itunesId: oldPodcastMetadata.itunesId, + itunesArtistId: oldPodcastMetadata.itunesArtistId, + language: oldPodcastMetadata.language, + podcastType: oldPodcastMetadata.type, + explicit: !!oldPodcastMetadata.explicit, + autoDownloadEpisodes: !!oldPodcast.autoDownloadEpisodes, + autoDownloadSchedule: oldPodcast.autoDownloadSchedule, + lastEpisodeCheck: oldPodcast.lastEpisodeCheck, + maxEpisodesToKeep: oldPodcast.maxEpisodesToKeep, + maxNewEpisodesToDownload: oldPodcast.maxNewEpisodesToDownload, + coverPath: oldPodcast.coverPath, + tags: oldPodcast.tags, + genres: oldPodcastMetadata.genres + } + } + } + + Podcast.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + title: DataTypes.STRING, + author: DataTypes.STRING, + releaseDate: DataTypes.STRING, + feedURL: DataTypes.STRING, + imageURL: DataTypes.STRING, + description: DataTypes.TEXT, + itunesPageURL: DataTypes.STRING, + itunesId: DataTypes.STRING, + itunesArtistId: DataTypes.STRING, + language: DataTypes.STRING, + podcastType: DataTypes.STRING, + explicit: DataTypes.BOOLEAN, + + autoDownloadEpisodes: DataTypes.BOOLEAN, + autoDownloadSchedule: DataTypes.STRING, + lastEpisodeCheck: DataTypes.DATE, + maxEpisodesToKeep: DataTypes.INTEGER, + maxNewEpisodesToDownload: DataTypes.INTEGER, + coverPath: DataTypes.STRING, + tags: DataTypes.JSON, + genres: DataTypes.JSON + }, { + sequelize, + modelName: 'podcast' + }) + + return Podcast +} \ No newline at end of file diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js new file mode 100644 index 00000000..d289dd50 --- /dev/null +++ b/server/models/PodcastEpisode.js @@ -0,0 +1,95 @@ +const { DataTypes, Model } = require('sequelize') + +module.exports = (sequelize) => { + class PodcastEpisode extends Model { + getOldPodcastEpisode(libraryItemId = null) { + let enclosure = null + if (this.enclosureURL) { + enclosure = { + url: this.enclosureURL, + type: this.enclosureType, + length: this.enclosureSize !== null ? String(this.enclosureSize) : null + } + } + return { + libraryItemId: libraryItemId || null, + podcastId: this.podcastId, + id: this.id, + index: this.index, + season: this.season, + episode: this.episode, + episodeType: this.episodeType, + title: this.title, + subtitle: this.subtitle, + description: this.description, + enclosure, + pubDate: this.pubDate, + chapters: this.chapters, + audioFile: this.audioFile, + publishedAt: this.publishedAt?.valueOf() || null, + addedAt: this.createdAt.valueOf(), + updatedAt: this.updatedAt.valueOf() + } + } + + static createFromOld(oldEpisode) { + const podcastEpisode = this.getFromOld(oldEpisode) + return this.create(podcastEpisode) + } + + static getFromOld(oldEpisode) { + return { + id: oldEpisode.id, + index: oldEpisode.index, + season: oldEpisode.season, + episode: oldEpisode.episode, + episodeType: oldEpisode.episodeType, + title: oldEpisode.title, + subtitle: oldEpisode.subtitle, + description: oldEpisode.description, + pubDate: oldEpisode.pubDate, + enclosureURL: oldEpisode.enclosure?.url || null, + enclosureSize: oldEpisode.enclosure?.length || null, + enclosureType: oldEpisode.enclosure?.type || null, + publishedAt: oldEpisode.publishedAt, + createdAt: oldEpisode.addedAt, + updatedAt: oldEpisode.updatedAt, + podcastId: oldEpisode.podcastId, + audioFile: oldEpisode.audioFile?.toJSON() || null, + chapters: oldEpisode.chapters + } + } + } + + PodcastEpisode.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + index: DataTypes.INTEGER, + season: DataTypes.STRING, + episode: DataTypes.STRING, + episodeType: DataTypes.STRING, + title: DataTypes.STRING, + subtitle: DataTypes.STRING(1000), + description: DataTypes.TEXT, + pubDate: DataTypes.STRING, + enclosureURL: DataTypes.STRING, + enclosureSize: DataTypes.BIGINT, + enclosureType: DataTypes.STRING, + publishedAt: DataTypes.DATE, + + audioFile: DataTypes.JSON, + chapters: DataTypes.JSON + }, { + sequelize, + modelName: 'podcastEpisode' + }) + + const { podcast } = sequelize.models + podcast.hasMany(PodcastEpisode) + PodcastEpisode.belongsTo(podcast) + + return PodcastEpisode +} \ No newline at end of file diff --git a/server/models/Series.js b/server/models/Series.js new file mode 100644 index 00000000..ef0e5709 --- /dev/null +++ b/server/models/Series.js @@ -0,0 +1,72 @@ +const { DataTypes, Model } = require('sequelize') + +const oldSeries = require('../objects/entities/Series') + +module.exports = (sequelize) => { + class Series extends Model { + static async getAllOldSeries() { + const series = await this.findAll() + return series.map(se => se.getOldSeries()) + } + + getOldSeries() { + return new oldSeries({ + id: this.id, + name: this.name, + description: this.description, + addedAt: this.createdAt.valueOf(), + updatedAt: this.updatedAt.valueOf() + }) + } + + static updateFromOld(oldSeries) { + const series = this.getFromOld(oldSeries) + return this.update(series, { + where: { + id: series.id + } + }) + } + + static createFromOld(oldSeries) { + const series = this.getFromOld(oldSeries) + return this.create(series) + } + + static createBulkFromOld(oldSeriesObjs) { + const series = oldSeriesObjs.map(this.getFromOld) + return this.bulkCreate(series) + } + + static getFromOld(oldSeries) { + return { + id: oldSeries.id, + name: oldSeries.name, + description: oldSeries.description + } + } + + static removeById(seriesId) { + return this.destroy({ + where: { + id: seriesId + } + }) + } + } + + Series.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: DataTypes.STRING, + description: DataTypes.TEXT + }, { + sequelize, + modelName: 'series' + }) + + return Series +} \ No newline at end of file diff --git a/server/models/Setting.js b/server/models/Setting.js new file mode 100644 index 00000000..9b47c227 --- /dev/null +++ b/server/models/Setting.js @@ -0,0 +1,45 @@ +const { DataTypes, Model } = require('sequelize') + +const oldEmailSettings = require('../objects/settings/EmailSettings') +const oldServerSettings = require('../objects/settings/ServerSettings') +const oldNotificationSettings = require('../objects/settings/NotificationSettings') + +module.exports = (sequelize) => { + class Setting extends Model { + static async getOldSettings() { + const settings = (await this.findAll()).map(se => se.value) + + + const emailSettingsJson = settings.find(se => se.id === 'email-settings') + const serverSettingsJson = settings.find(se => se.id === 'server-settings') + const notificationSettingsJson = settings.find(se => se.id === 'notification-settings') + + return { + settings, + emailSettings: new oldEmailSettings(emailSettingsJson), + serverSettings: new oldServerSettings(serverSettingsJson), + notificationSettings: new oldNotificationSettings(notificationSettingsJson) + } + } + + static updateSettingObj(setting) { + return this.upsert({ + key: setting.id, + value: setting + }) + } + } + + Setting.init({ + key: { + type: DataTypes.STRING, + primaryKey: true + }, + value: DataTypes.JSON + }, { + sequelize, + modelName: 'setting' + }) + + return Setting +} \ No newline at end of file diff --git a/server/models/User.js b/server/models/User.js new file mode 100644 index 00000000..e7656310 --- /dev/null +++ b/server/models/User.js @@ -0,0 +1,136 @@ +const uuidv4 = require("uuid").v4 +const { DataTypes, Model } = require('sequelize') +const Logger = require('../Logger') +const oldUser = require('../objects/user/User') + +module.exports = (sequelize) => { + class User extends Model { + static async getOldUsers() { + const users = await this.findAll({ + include: sequelize.models.mediaProgress + }) + return users.map(u => this.getOldUser(u)) + } + + static getOldUser(userExpanded) { + const mediaProgress = userExpanded.mediaProgresses.map(mp => mp.getOldMediaProgress()) + + const librariesAccessible = userExpanded.permissions?.librariesAccessible || [] + const itemTagsSelected = userExpanded.permissions?.itemTagsSelected || [] + const permissions = userExpanded.permissions || {} + delete permissions.librariesAccessible + delete permissions.itemTagsSelected + + return new oldUser({ + id: userExpanded.id, + oldUserId: userExpanded.extraData?.oldUserId || null, + username: userExpanded.username, + pash: userExpanded.pash, + type: userExpanded.type, + token: userExpanded.token, + mediaProgress, + seriesHideFromContinueListening: userExpanded.extraData?.seriesHideFromContinueListening || [], + bookmarks: userExpanded.bookmarks, + isActive: userExpanded.isActive, + isLocked: userExpanded.isLocked, + lastSeen: userExpanded.lastSeen?.valueOf() || null, + createdAt: userExpanded.createdAt.valueOf(), + permissions, + librariesAccessible, + itemTagsSelected + }) + } + + static createFromOld(oldUser) { + const user = this.getFromOld(oldUser) + return this.create(user) + } + + static updateFromOld(oldUser) { + const user = this.getFromOld(oldUser) + return this.update(user, { + where: { + id: user.id + } + }).then((result) => result[0] > 0).catch((error) => { + Logger.error(`[User] Failed to save user ${oldUser.id}`, error) + return false + }) + } + + static getFromOld(oldUser) { + return { + id: oldUser.id, + username: oldUser.username, + pash: oldUser.pash || null, + type: oldUser.type || null, + token: oldUser.token || null, + isActive: !!oldUser.isActive, + lastSeen: oldUser.lastSeen || null, + extraData: { + seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [], + oldUserId: oldUser.oldUserId + }, + createdAt: oldUser.createdAt || Date.now(), + permissions: { + ...oldUser.permissions, + librariesAccessible: oldUser.librariesAccessible || [], + itemTagsSelected: oldUser.itemTagsSelected || [] + }, + bookmarks: oldUser.bookmarks + } + } + + static removeById(userId) { + return this.destroy({ + where: { + id: userId + } + }) + } + + static async createRootUser(username, pash, token) { + const newRoot = new oldUser({ + id: uuidv4(), + type: 'root', + username, + pash, + token, + isActive: true, + createdAt: Date.now() + }) + await this.createFromOld(newRoot) + return newRoot + } + } + + User.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + username: DataTypes.STRING, + email: DataTypes.STRING, + pash: DataTypes.STRING, + type: DataTypes.STRING, + token: DataTypes.STRING, + isActive: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + isLocked: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + lastSeen: DataTypes.DATE, + permissions: DataTypes.JSON, + bookmarks: DataTypes.JSON, + extraData: DataTypes.JSON + }, { + sequelize, + modelName: 'user' + }) + + return User +} \ No newline at end of file diff --git a/server/objects/Collection.js b/server/objects/Collection.js index 4f97a802..970d714b 100644 --- a/server/objects/Collection.js +++ b/server/objects/Collection.js @@ -1,10 +1,9 @@ -const { getId } = require('../utils/index') +const uuidv4 = require("uuid").v4 class Collection { constructor(collection) { this.id = null this.libraryId = null - this.userId = null this.name = null this.description = null @@ -25,7 +24,6 @@ class Collection { return { id: this.id, libraryId: this.libraryId, - userId: this.userId, name: this.name, description: this.description, cover: this.cover, @@ -60,7 +58,6 @@ class Collection { construct(collection) { this.id = collection.id this.libraryId = collection.libraryId - this.userId = collection.userId this.name = collection.name this.description = collection.description || null this.cover = collection.cover || null @@ -71,11 +68,10 @@ class Collection { } setData(data) { - if (!data.userId || !data.libraryId || !data.name) { + if (!data.libraryId || !data.name) { return false } - this.id = getId('col') - this.userId = data.userId + this.id = uuidv4() this.libraryId = data.libraryId this.name = data.name this.description = data.description || null diff --git a/server/objects/DeviceInfo.js b/server/objects/DeviceInfo.js index 4d7cf0d6..2f7438cc 100644 --- a/server/objects/DeviceInfo.js +++ b/server/objects/DeviceInfo.js @@ -1,5 +1,9 @@ +const uuidv4 = require("uuid").v4 + class DeviceInfo { constructor(deviceInfo = null) { + this.id = null + this.userId = null this.deviceId = null this.ipAddress = null @@ -16,7 +20,8 @@ class DeviceInfo { this.model = null this.sdkVersion = null // Android Only - this.serverVersion = null + this.clientName = null + this.deviceName = null if (deviceInfo) { this.construct(deviceInfo) @@ -33,6 +38,8 @@ class DeviceInfo { toJSON() { const obj = { + id: this.id, + userId: this.userId, deviceId: this.deviceId, ipAddress: this.ipAddress, browserName: this.browserName, @@ -44,7 +51,8 @@ class DeviceInfo { manufacturer: this.manufacturer, model: this.model, sdkVersion: this.sdkVersion, - serverVersion: this.serverVersion + clientName: this.clientName, + deviceName: this.deviceName } for (const key in obj) { if (obj[key] === null || obj[key] === undefined) { @@ -65,6 +73,7 @@ class DeviceInfo { // When client doesn't send a device id getTempDeviceId() { const keys = [ + this.userId, this.browserName, this.browserVersion, this.osName, @@ -78,7 +87,9 @@ class DeviceInfo { return 'temp-' + Buffer.from(keys.join('-'), 'utf-8').toString('base64') } - setData(ip, ua, clientDeviceInfo, serverVersion) { + setData(ip, ua, clientDeviceInfo, serverVersion, userId) { + this.id = uuidv4() + this.userId = userId this.deviceId = clientDeviceInfo?.deviceId || null this.ipAddress = ip || null @@ -88,16 +99,54 @@ class DeviceInfo { this.osVersion = ua?.os.version || null this.deviceType = ua?.device.type || null - this.clientVersion = clientDeviceInfo?.clientVersion || null + this.clientVersion = clientDeviceInfo?.clientVersion || serverVersion this.manufacturer = clientDeviceInfo?.manufacturer || null this.model = clientDeviceInfo?.model || null this.sdkVersion = clientDeviceInfo?.sdkVersion || null - this.serverVersion = serverVersion || null + this.clientName = clientDeviceInfo?.clientName || null + if (this.sdkVersion) { + if (!this.clientName) this.clientName = 'Abs Android' + this.deviceName = `${this.manufacturer || 'Unknown'} ${this.model || ''}` + } else if (this.model) { + if (!this.clientName) this.clientName = 'Abs iOS' + this.deviceName = `${this.manufacturer || 'Unknown'} ${this.model || ''}` + } else if (this.osName && this.browserName) { + if (!this.clientName) this.clientName = 'Abs Web' + this.deviceName = `${this.osName} ${this.osVersion || 'N/A'} ${this.browserName}` + } else if (!this.clientName) { + this.clientName = 'Unknown' + } if (!this.deviceId) { this.deviceId = this.getTempDeviceId() } } + + update(deviceInfo) { + const deviceInfoJson = deviceInfo.toJSON ? deviceInfo.toJSON() : deviceInfo + const existingDeviceInfoJson = this.toJSON() + + let hasUpdates = false + for (const key in deviceInfoJson) { + if (['id', 'deviceId'].includes(key)) continue + + if (deviceInfoJson[key] !== existingDeviceInfoJson[key]) { + this[key] = deviceInfoJson[key] + hasUpdates = true + } + } + + for (const key in existingDeviceInfoJson) { + if (['id', 'deviceId'].includes(key)) continue + + if (existingDeviceInfoJson[key] && !deviceInfoJson[key]) { + this[key] = null + hasUpdates = true + } + } + + return hasUpdates + } } module.exports = DeviceInfo \ No newline at end of file diff --git a/server/objects/Feed.js b/server/objects/Feed.js index 10d6d282..adad91d0 100644 --- a/server/objects/Feed.js +++ b/server/objects/Feed.js @@ -1,3 +1,4 @@ +const uuidv4 = require("uuid").v4 const FeedMeta = require('./FeedMeta') const FeedEpisode = require('./FeedEpisode') const RSS = require('../libs/rss') @@ -90,7 +91,7 @@ class Feed { const feedUrl = `${serverAddress}/feed/${slug}` const author = isPodcast ? mediaMetadata.author : mediaMetadata.authorName - this.id = slug + this.id = uuidv4() this.slug = slug this.userId = userId this.entityType = 'libraryItem' @@ -179,7 +180,7 @@ class Feed { const itemsWithTracks = collectionExpanded.books.filter(libraryItem => libraryItem.media.tracks.length) const firstItemWithCover = itemsWithTracks.find(item => item.media.coverPath) - this.id = slug + this.id = uuidv4() this.slug = slug this.userId = userId this.entityType = 'collection' @@ -253,7 +254,7 @@ class Feed { const libraryId = itemsWithTracks[0].libraryId const firstItemWithCover = itemsWithTracks.find(li => li.media.coverPath) - this.id = slug + this.id = uuidv4() this.slug = slug this.userId = userId this.entityType = 'series' diff --git a/server/objects/FeedEpisode.js b/server/objects/FeedEpisode.js index 0f5ac28c..3ef9f918 100644 --- a/server/objects/FeedEpisode.js +++ b/server/objects/FeedEpisode.js @@ -1,4 +1,3 @@ -const Path = require('path') const date = require('../libs/dateAndTime') const { secondsToTimestamp } = require('../utils/index') diff --git a/server/objects/Folder.js b/server/objects/Folder.js index f06f9ed4..9ca6b214 100644 --- a/server/objects/Folder.js +++ b/server/objects/Folder.js @@ -1,4 +1,4 @@ -const { getId } = require("../utils") +const uuidv4 = require("uuid").v4 class Folder { constructor(folder = null) { @@ -29,7 +29,7 @@ class Folder { } setData(data) { - this.id = data.id ? data.id : getId('fol') + this.id = data.id || uuidv4() this.fullPath = data.fullPath this.libraryId = data.libraryId this.addedAt = Date.now() diff --git a/server/objects/Library.js b/server/objects/Library.js index 86e9f9de..557268f3 100644 --- a/server/objects/Library.js +++ b/server/objects/Library.js @@ -1,6 +1,6 @@ +const uuidv4 = require("uuid").v4 const Folder = require('./Folder') const LibrarySettings = require('./settings/LibrarySettings') -const { getId } = require('../utils/index') const { filePathToPOSIX } = require('../utils/fileUtils') class Library { @@ -87,7 +87,7 @@ class Library { } setData(data) { - this.id = data.id ? data.id : getId('lib') + this.id = data.id || uuidv4() this.name = data.name if (data.folder) { this.folders = [ diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index 9c0db11f..d4aacfee 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -1,3 +1,4 @@ +const uuidv4 = require("uuid").v4 const fs = require('../libs/fsExtra') const Path = require('path') const { version } = require('../../package.json') @@ -8,7 +9,7 @@ const Book = require('./mediaTypes/Book') const Podcast = require('./mediaTypes/Podcast') const Video = require('./mediaTypes/Video') const Music = require('./mediaTypes/Music') -const { areEquivalent, copyValue, getId, cleanStringForSearch } = require('../utils/index') +const { areEquivalent, copyValue, cleanStringForSearch } = require('../utils/index') const { filePathToPOSIX } = require('../utils/fileUtils') class LibraryItem { @@ -191,7 +192,7 @@ class LibraryItem { // Data comes from scandir library item data setData(libraryMediaType, payload) { - this.id = getId('li') + this.id = uuidv4() this.mediaType = libraryMediaType if (libraryMediaType === 'video') { this.media = new Video() @@ -202,6 +203,7 @@ class LibraryItem { } else if (libraryMediaType === 'music') { this.media = new Music() } + this.media.id = uuidv4() this.media.libraryItemId = this.id for (const key in payload) { diff --git a/server/objects/Notification.js b/server/objects/Notification.js index 4dffe040..d075e101 100644 --- a/server/objects/Notification.js +++ b/server/objects/Notification.js @@ -1,4 +1,4 @@ -const { getId } = require('../utils/index') +const uuidv4 = require("uuid").v4 class Notification { constructor(notification = null) { @@ -57,7 +57,7 @@ class Notification { } setData(payload) { - this.id = getId('noti') + this.id = uuidv4() this.libraryId = payload.libraryId || null this.eventName = payload.eventName this.urls = payload.urls diff --git a/server/objects/PlaybackSession.js b/server/objects/PlaybackSession.js index db4ebb61..6b7340f3 100644 --- a/server/objects/PlaybackSession.js +++ b/server/objects/PlaybackSession.js @@ -1,5 +1,6 @@ const date = require('../libs/dateAndTime') -const { getId } = require('../utils/index') +const uuidv4 = require("uuid").v4 +const serverVersion = require('../../package.json').version const BookMetadata = require('./metadata/BookMetadata') const PodcastMetadata = require('./metadata/PodcastMetadata') const DeviceInfo = require('./DeviceInfo') @@ -11,6 +12,7 @@ class PlaybackSession { this.userId = null this.libraryId = null this.libraryItemId = null + this.bookId = null this.episodeId = null this.mediaType = null @@ -24,6 +26,7 @@ class PlaybackSession { this.playMethod = null this.mediaPlayer = null this.deviceInfo = null + this.serverVersion = null this.date = null this.dayOfWeek = null @@ -52,6 +55,7 @@ class PlaybackSession { userId: this.userId, libraryId: this.libraryId, libraryItemId: this.libraryItemId, + bookId: this.bookId, episodeId: this.episodeId, mediaType: this.mediaType, mediaMetadata: this.mediaMetadata?.toJSON() || null, @@ -63,6 +67,7 @@ class PlaybackSession { playMethod: this.playMethod, mediaPlayer: this.mediaPlayer, deviceInfo: this.deviceInfo?.toJSON() || null, + serverVersion: this.serverVersion, date: this.date, dayOfWeek: this.dayOfWeek, timeListening: this.timeListening, @@ -79,6 +84,7 @@ class PlaybackSession { userId: this.userId, libraryId: this.libraryId, libraryItemId: this.libraryItemId, + bookId: this.bookId, episodeId: this.episodeId, mediaType: this.mediaType, mediaMetadata: this.mediaMetadata?.toJSON() || null, @@ -90,6 +96,7 @@ class PlaybackSession { playMethod: this.playMethod, mediaPlayer: this.mediaPlayer, deviceInfo: this.deviceInfo?.toJSON() || null, + serverVersion: this.serverVersion, date: this.date, dayOfWeek: this.dayOfWeek, timeListening: this.timeListening, @@ -108,12 +115,20 @@ class PlaybackSession { this.userId = session.userId this.libraryId = session.libraryId || null this.libraryItemId = session.libraryItemId + this.bookId = session.bookId this.episodeId = session.episodeId this.mediaType = session.mediaType this.duration = session.duration this.playMethod = session.playMethod this.mediaPlayer = session.mediaPlayer || null - this.deviceInfo = new DeviceInfo(session.deviceInfo) + + if (session.deviceInfo instanceof DeviceInfo) { + this.deviceInfo = new DeviceInfo(session.deviceInfo.toJSON()) + } else { + this.deviceInfo = new DeviceInfo(session.deviceInfo) + } + + this.serverVersion = session.serverVersion this.chapters = session.chapters || [] this.mediaMetadata = null @@ -151,7 +166,7 @@ class PlaybackSession { } get deviceId() { - return this.deviceInfo?.deviceId + return this.deviceInfo?.id } get deviceDescription() { @@ -169,10 +184,11 @@ class PlaybackSession { } setData(libraryItem, user, mediaPlayer, deviceInfo, startTime, episodeId = null) { - this.id = getId('play') + this.id = uuidv4() this.userId = user.id this.libraryId = libraryItem.libraryId this.libraryItemId = libraryItem.id + this.bookId = episodeId ? null : libraryItem.media.id this.episodeId = episodeId this.mediaType = libraryItem.mediaType this.mediaMetadata = libraryItem.media.metadata.clone() @@ -189,6 +205,7 @@ class PlaybackSession { this.mediaPlayer = mediaPlayer this.deviceInfo = deviceInfo || new DeviceInfo() + this.serverVersion = serverVersion this.timeListening = 0 this.startTime = startTime diff --git a/server/objects/Playlist.js b/server/objects/Playlist.js index 21dcee8a..c4b3357b 100644 --- a/server/objects/Playlist.js +++ b/server/objects/Playlist.js @@ -1,5 +1,4 @@ -const Logger = require('../Logger') -const { getId } = require('../utils/index') +const uuidv4 = require("uuid").v4 class Playlist { constructor(playlist) { @@ -88,7 +87,7 @@ class Playlist { if (!data.userId || !data.libraryId || !data.name) { return false } - this.id = getId('pl') + this.id = uuidv4() this.userId = data.userId this.libraryId = data.libraryId this.name = data.name diff --git a/server/objects/PodcastEpisodeDownload.js b/server/objects/PodcastEpisodeDownload.js index 6c4b343e..2dfdc52e 100644 --- a/server/objects/PodcastEpisodeDownload.js +++ b/server/objects/PodcastEpisodeDownload.js @@ -1,5 +1,5 @@ const Path = require('path') -const { getId } = require('../utils/index') +const uuidv4 = require("uuid").v4 const { sanitizeFilename } = require('../utils/fileUtils') const globals = require('../utils/globals') @@ -70,7 +70,7 @@ class PodcastEpisodeDownload { } setData(podcastEpisode, libraryItem, isAutoDownload, libraryId) { - this.id = getId('epdl') + this.id = uuidv4() this.podcastEpisode = podcastEpisode const url = podcastEpisode.enclosure.url diff --git a/server/objects/Task.js b/server/objects/Task.js index edcfe40f..04c83d17 100644 --- a/server/objects/Task.js +++ b/server/objects/Task.js @@ -1,4 +1,4 @@ -const { getId } = require('../utils/index') +const uuidv4 = require("uuid").v4 class Task { constructor() { @@ -35,7 +35,7 @@ class Task { } setData(action, title, description, showSuccess, data = {}) { - this.id = getId(action) + this.id = uuidv4() this.action = action this.data = { ...data } this.title = title diff --git a/server/objects/entities/Author.js b/server/objects/entities/Author.js index 9684eebb..7a274b92 100644 --- a/server/objects/entities/Author.js +++ b/server/objects/entities/Author.js @@ -1,5 +1,5 @@ const Logger = require('../../Logger') -const { getId } = require('../../utils/index') +const uuidv4 = require("uuid").v4 const { checkNamesAreEqual } = require('../../utils/parsers/parseNameString') class Author { @@ -53,7 +53,7 @@ class Author { } setData(data) { - this.id = getId('aut') + this.id = uuidv4() this.name = data.name this.description = data.description || null this.asin = data.asin || null diff --git a/server/objects/entities/PodcastEpisode.js b/server/objects/entities/PodcastEpisode.js index a9f39fbf..37e53fba 100644 --- a/server/objects/entities/PodcastEpisode.js +++ b/server/objects/entities/PodcastEpisode.js @@ -1,12 +1,14 @@ +const uuidv4 = require("uuid").v4 const Path = require('path') const Logger = require('../../Logger') -const { getId, cleanStringForSearch, areEquivalent, copyValue } = require('../../utils/index') +const { cleanStringForSearch, areEquivalent, copyValue } = require('../../utils/index') const AudioFile = require('../files/AudioFile') const AudioTrack = require('../files/AudioTrack') class PodcastEpisode { constructor(episode) { this.libraryItemId = null + this.podcastId = null this.id = null this.index = null @@ -32,6 +34,7 @@ class PodcastEpisode { construct(episode) { this.libraryItemId = episode.libraryItemId + this.podcastId = episode.podcastId this.id = episode.id this.index = episode.index this.season = episode.season @@ -54,6 +57,7 @@ class PodcastEpisode { toJSON() { return { libraryItemId: this.libraryItemId, + podcastId: this.podcastId, id: this.id, index: this.index, season: this.season, @@ -75,6 +79,7 @@ class PodcastEpisode { toJSONExpanded() { return { libraryItemId: this.libraryItemId, + podcastId: this.podcastId, id: this.id, index: this.index, season: this.season, @@ -117,7 +122,7 @@ class PodcastEpisode { } setData(data, index = 1) { - this.id = getId('ep') + this.id = uuidv4() this.index = index this.title = data.title this.subtitle = data.subtitle || '' @@ -133,7 +138,7 @@ class PodcastEpisode { } setDataFromAudioFile(audioFile, index) { - this.id = getId('ep') + this.id = uuidv4() this.audioFile = audioFile this.title = Path.basename(audioFile.metadata.filename, Path.extname(audioFile.metadata.filename)) this.index = index diff --git a/server/objects/entities/Series.js b/server/objects/entities/Series.js index 702f41f5..4c7d8f31 100644 --- a/server/objects/entities/Series.js +++ b/server/objects/entities/Series.js @@ -1,4 +1,4 @@ -const { getId } = require('../../utils/index') +const uuidv4 = require("uuid").v4 class Series { constructor(series) { @@ -40,7 +40,7 @@ class Series { } setData(data) { - this.id = getId('ser') + this.id = uuidv4() this.name = data.name this.description = data.description || null this.addedAt = Date.now() diff --git a/server/objects/mediaTypes/Book.js b/server/objects/mediaTypes/Book.js index f94a240c..e9eec451 100644 --- a/server/objects/mediaTypes/Book.js +++ b/server/objects/mediaTypes/Book.js @@ -12,6 +12,7 @@ const EBookFile = require('../files/EBookFile') class Book { constructor(book) { + this.id = null this.libraryItemId = null this.metadata = null @@ -32,6 +33,7 @@ class Book { } construct(book) { + this.id = book.id this.libraryItemId = book.libraryItemId this.metadata = new BookMetadata(book.metadata) this.coverPath = book.coverPath @@ -46,6 +48,7 @@ class Book { toJSON() { return { + id: this.id, libraryItemId: this.libraryItemId, metadata: this.metadata.toJSON(), coverPath: this.coverPath, @@ -59,6 +62,7 @@ class Book { toJSONMinified() { return { + id: this.id, metadata: this.metadata.toJSONMinified(), coverPath: this.coverPath, tags: [...this.tags], @@ -75,6 +79,7 @@ class Book { toJSONExpanded() { return { + id: this.id, libraryItemId: this.libraryItemId, metadata: this.metadata.toJSONExpanded(), coverPath: this.coverPath, diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index e1fb9d80..a3baef46 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -11,6 +11,7 @@ const naturalSort = createNewSortInstance({ class Podcast { constructor(podcast) { + this.id = null this.libraryItemId = null this.metadata = null this.coverPath = null @@ -32,6 +33,7 @@ class Podcast { } construct(podcast) { + this.id = podcast.id this.libraryItemId = podcast.libraryItemId this.metadata = new PodcastMetadata(podcast.metadata) this.coverPath = podcast.coverPath @@ -50,6 +52,7 @@ class Podcast { toJSON() { return { + id: this.id, libraryItemId: this.libraryItemId, metadata: this.metadata.toJSON(), coverPath: this.coverPath, @@ -65,6 +68,7 @@ class Podcast { toJSONMinified() { return { + id: this.id, metadata: this.metadata.toJSONMinified(), coverPath: this.coverPath, tags: [...this.tags], @@ -80,6 +84,7 @@ class Podcast { toJSONExpanded() { return { + id: this.id, libraryItemId: this.libraryItemId, metadata: this.metadata.toJSONExpanded(), coverPath: this.coverPath, @@ -284,8 +289,9 @@ class Podcast { } addNewEpisodeFromAudioFile(audioFile, index) { - var pe = new PodcastEpisode() + const pe = new PodcastEpisode() pe.libraryItemId = this.libraryItemId + pe.podcastId = this.id audioFile.index = 1 // Only 1 audio file per episode pe.setDataFromAudioFile(audioFile, index) this.episodes.push(pe) diff --git a/server/objects/metadata/BookMetadata.js b/server/objects/metadata/BookMetadata.js index 7cdbdcff..2740d25f 100644 --- a/server/objects/metadata/BookMetadata.js +++ b/server/objects/metadata/BookMetadata.js @@ -218,7 +218,7 @@ class BookMetadata { // Updates author name updateAuthor(updatedAuthor) { - var author = this.authors.find(au => au.id === updatedAuthor.id) + const author = this.authors.find(au => au.id === updatedAuthor.id) if (!author || author.name == updatedAuthor.name) return false author.name = updatedAuthor.name return true diff --git a/server/objects/user/MediaProgress.js b/server/objects/user/MediaProgress.js index 8c813a3b..6a6271c6 100644 --- a/server/objects/user/MediaProgress.js +++ b/server/objects/user/MediaProgress.js @@ -1,9 +1,15 @@ +const uuidv4 = require("uuid").v4 + class MediaProgress { constructor(progress) { this.id = null + this.userId = null this.libraryItemId = null this.episodeId = null // For podcasts + this.mediaItemId = null // For use in new data model + this.mediaItemType = null // For use in new data model + this.duration = null this.progress = null // 0 to 1 this.currentTime = null // seconds @@ -25,8 +31,11 @@ class MediaProgress { toJSON() { return { id: this.id, + userId: this.userId, libraryItemId: this.libraryItemId, episodeId: this.episodeId, + mediaItemId: this.mediaItemId, + mediaItemType: this.mediaItemType, duration: this.duration, progress: this.progress, currentTime: this.currentTime, @@ -42,8 +51,11 @@ class MediaProgress { construct(progress) { this.id = progress.id + this.userId = progress.userId this.libraryItemId = progress.libraryItemId this.episodeId = progress.episodeId + this.mediaItemId = progress.mediaItemId + this.mediaItemType = progress.mediaItemType this.duration = progress.duration || 0 this.progress = progress.progress this.currentTime = progress.currentTime || 0 @@ -60,10 +72,16 @@ class MediaProgress { return !this.isFinished && (this.progress > 0 || (this.ebookLocation != null && this.ebookProgress > 0)) } - setData(libraryItemId, progress, episodeId = null) { - this.id = episodeId ? `${libraryItemId}-${episodeId}` : libraryItemId - this.libraryItemId = libraryItemId + setData(libraryItem, progress, episodeId, userId) { + this.id = uuidv4() + this.userId = userId + this.libraryItemId = libraryItem.id this.episodeId = episodeId + + // PodcastEpisodeId or BookId + this.mediaItemId = episodeId || libraryItem.media.id + this.mediaItemType = episodeId ? 'podcastEpisode' : 'book' + this.duration = progress.duration || 0 this.progress = Math.min(1, (progress.progress || 0)) this.currentTime = progress.currentTime || 0 diff --git a/server/objects/user/User.js b/server/objects/user/User.js index b6ee71bc..ae665e3b 100644 --- a/server/objects/user/User.js +++ b/server/objects/user/User.js @@ -5,6 +5,7 @@ const MediaProgress = require('./MediaProgress') class User { constructor(user) { this.id = null + this.oldUserId = null // TODO: Temp for keeping old access tokens this.username = null this.pash = null this.type = null @@ -73,6 +74,7 @@ class User { toJSON() { return { id: this.id, + oldUserId: this.oldUserId, username: this.username, pash: this.pash, type: this.type, @@ -93,6 +95,7 @@ class User { toJSONForBrowser(hideRootToken = false, minimal = false) { const json = { id: this.id, + oldUserId: this.oldUserId, username: this.username, type: this.type, token: (this.type === 'root' && hideRootToken) ? '' : this.token, @@ -126,6 +129,7 @@ class User { } return { id: this.id, + oldUserId: this.oldUserId, username: this.username, type: this.type, session, @@ -137,6 +141,7 @@ class User { construct(user) { this.id = user.id + this.oldUserId = user.oldUserId this.username = user.username this.pash = user.pash this.type = user.type @@ -320,7 +325,7 @@ class User { if (!itemProgress) { const newItemProgress = new MediaProgress() - newItemProgress.setData(libraryItem.id, updatePayload, episodeId) + newItemProgress.setData(libraryItem, updatePayload, episodeId, this.id) this.mediaProgress.push(newItemProgress) return true } @@ -336,12 +341,6 @@ class User { return true } - removeMediaProgressForLibraryItem(libraryItemId) { - if (!this.mediaProgress.some(lip => lip.libraryItemId == libraryItemId)) return false - this.mediaProgress = this.mediaProgress.filter(lip => lip.libraryItemId != libraryItemId) - return true - } - checkCanAccessLibrary(libraryId) { if (this.permissions.accessAllLibraries) return true if (!this.librariesAccessible) return false diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 9277f4c3..1df7ff02 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -2,6 +2,7 @@ const express = require('express') const Path = require('path') const Logger = require('../Logger') +const Database = require('../Database') const SocketAuthority = require('../SocketAuthority') const fs = require('../libs/fsExtra') @@ -37,7 +38,6 @@ const Series = require('../objects/entities/Series') class ApiRouter { constructor(Server) { - this.db = Server.db this.auth = Server.auth this.scanner = Server.scanner this.playbackSessionManager = Server.playbackSessionManager @@ -356,7 +356,7 @@ class ApiRouter { const json = user.toJSONForBrowser(hideRootToken) json.mediaProgress = json.mediaProgress.map(lip => { - const libraryItem = this.db.libraryItems.find(li => li.id === lip.libraryItemId) + const libraryItem = Database.libraryItems.find(li => li.id === lip.libraryItemId) if (!libraryItem) { Logger.warn('[ApiRouter] Library item not found for users progress ' + lip.libraryItemId) lip.media = null @@ -381,11 +381,10 @@ class ApiRouter { } async handleDeleteLibraryItem(libraryItem) { - // Remove libraryItem from users - for (let i = 0; i < this.db.users.length; i++) { - const user = this.db.users[i] - if (user.removeMediaProgressForLibraryItem(libraryItem.id)) { - await this.db.updateEntity('user', user) + // Remove media progress for this library item from all users + for (const user of Database.users) { + for (const mediaProgress of user.getAllMediaProgressForLibraryItem(libraryItem.id)) { + await Database.removeMediaProgress(mediaProgress.id) } } @@ -393,12 +392,12 @@ class ApiRouter { if (libraryItem.isBook) { // remove book from collections - const collectionsWithBook = this.db.collections.filter(c => c.books.includes(libraryItem.id)) + const collectionsWithBook = Database.collections.filter(c => c.books.includes(libraryItem.id)) for (let i = 0; i < collectionsWithBook.length; i++) { const collection = collectionsWithBook[i] collection.removeBook(libraryItem.id) - await this.db.updateEntity('collection', collection) - SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(this.db.libraryItems)) + await Database.removeCollectionBook(collection.id, libraryItem.media.id) + SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems)) } // Check remove empty series @@ -406,7 +405,7 @@ class ApiRouter { } // remove item from playlists - const playlistsWithItem = this.db.playlists.filter(p => p.hasItemsForLibraryItem(libraryItem.id)) + const playlistsWithItem = Database.playlists.filter(p => p.hasItemsForLibraryItem(libraryItem.id)) for (let i = 0; i < playlistsWithItem.length; i++) { const playlist = playlistsWithItem[i] playlist.removeItemsForLibraryItem(libraryItem.id) @@ -414,11 +413,12 @@ class ApiRouter { // If playlist is now empty then remove it if (!playlist.items.length) { Logger.info(`[ApiRouter] Playlist "${playlist.name}" has no more items - removing it`) - await this.db.removeEntity('playlist', playlist.id) - SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', playlist.toJSONExpanded(this.db.libraryItems)) + await Database.removePlaylist(playlist.id) + SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', playlist.toJSONExpanded(Database.libraryItems)) } else { - await this.db.updateEntity('playlist', playlist) - SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', playlist.toJSONExpanded(this.db.libraryItems)) + await Database.updatePlaylist(playlist) + + SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', playlist.toJSONExpanded(Database.libraryItems)) } } @@ -436,7 +436,7 @@ class ApiRouter { await fs.remove(itemMetadataPath) } - await this.db.removeLibraryItem(libraryItem.id) + await Database.removeLibraryItem(libraryItem.id) SocketAuthority.emitter('item_removed', libraryItem.toJSONExpanded()) } @@ -444,27 +444,27 @@ class ApiRouter { if (!seriesToCheck || !seriesToCheck.length) return for (const series of seriesToCheck) { - const otherLibraryItemsInSeries = this.db.libraryItems.filter(li => li.id !== excludeLibraryItemId && li.isBook && li.media.metadata.hasSeries(series.id)) + const otherLibraryItemsInSeries = Database.libraryItems.filter(li => li.id !== excludeLibraryItemId && li.isBook && li.media.metadata.hasSeries(series.id)) if (!otherLibraryItemsInSeries.length) { // Close open RSS feed for series await this.rssFeedManager.closeFeedForEntityId(series.id) Logger.debug(`[ApiRouter] Series "${series.name}" is now empty. Removing series`) - await this.db.removeEntity('series', series.id) + await Database.removeSeries(series.id) // TODO: Socket events for series? } } } async getUserListeningSessionsHelper(userId) { - const userSessions = await this.db.selectUserSessions(userId) + const userSessions = await Database.getPlaybackSessions({ userId }) return userSessions.sort((a, b) => b.updatedAt - a.updatedAt) } async getAllSessionsWithUserData() { - const sessions = await this.db.getAllSessions() + const sessions = await Database.getPlaybackSessions() sessions.sort((a, b) => b.updatedAt - a.updatedAt) return sessions.map(se => { - const user = this.db.users.find(u => u.id === se.userId) + const user = Database.users.find(u => u.id === se.userId) return { ...se, user: user ? { id: user.id, username: user.username } : null @@ -533,7 +533,7 @@ class ApiRouter { } if (!mediaMetadata.authors[i].id || mediaMetadata.authors[i].id.startsWith('new')) { - let author = this.db.authors.find(au => au.checkNameEquals(authorName)) + let author = Database.authors.find(au => au.checkNameEquals(authorName)) if (!author) { author = new Author() author.setData(mediaMetadata.authors[i]) @@ -546,7 +546,7 @@ class ApiRouter { } } if (newAuthors.length) { - await this.db.insertEntities('author', newAuthors) + await Database.createBulkAuthors(newAuthors) SocketAuthority.emitter('authors_added', newAuthors.map(au => au.toJSON())) } } @@ -562,7 +562,7 @@ class ApiRouter { } if (!mediaMetadata.series[i].id || mediaMetadata.series[i].id.startsWith('new')) { - let seriesItem = this.db.series.find(se => se.checkNameEquals(seriesName)) + let seriesItem = Database.series.find(se => se.checkNameEquals(seriesName)) if (!seriesItem) { seriesItem = new Series() seriesItem.setData(mediaMetadata.series[i]) @@ -575,7 +575,7 @@ class ApiRouter { } } if (newSeries.length) { - await this.db.insertEntities('series', newSeries) + await Database.createBulkSeries(newSeries) SocketAuthority.emitter('multiple_series_added', newSeries.map(se => se.toJSON())) } } diff --git a/server/routers/HlsRouter.js b/server/routers/HlsRouter.js index bcb3c74b..d4f1bc60 100644 --- a/server/routers/HlsRouter.js +++ b/server/routers/HlsRouter.js @@ -8,8 +8,7 @@ const fs = require('../libs/fsExtra') class HlsRouter { - constructor(db, auth, playbackSessionManager) { - this.db = db + constructor(auth, playbackSessionManager) { this.auth = auth this.playbackSessionManager = playbackSessionManager diff --git a/server/routes/index.js b/server/routes/index.js new file mode 100644 index 00000000..e638a9c5 --- /dev/null +++ b/server/routes/index.js @@ -0,0 +1,8 @@ +const express = require('express') +const libraries = require('./libraries') + +const router = express.Router() + +router.use('/libraries', libraries) + +module.exports = router \ No newline at end of file diff --git a/server/routes/libraries.js b/server/routes/libraries.js new file mode 100644 index 00000000..07ab5ccd --- /dev/null +++ b/server/routes/libraries.js @@ -0,0 +1,7 @@ +const express = require('express') + +const router = express.Router() + +// TODO: Add library routes + +module.exports = router \ No newline at end of file diff --git a/server/scanner/LibraryScan.js b/server/scanner/LibraryScan.js index 6c9caa50..8e97a41a 100644 --- a/server/scanner/LibraryScan.js +++ b/server/scanner/LibraryScan.js @@ -1,4 +1,5 @@ const Path = require('path') +const uuidv4 = require("uuid").v4 const fs = require('../libs/fsExtra') const date = require('../libs/dateAndTime') @@ -6,7 +7,7 @@ const Logger = require('../Logger') const Library = require('../objects/Library') const { LogLevel } = require('../utils/constants') const filePerms = require('../utils/filePerms') -const { getId, secondsToTimestamp } = require('../utils/index') +const { secondsToTimestamp } = require('../utils/index') class LibraryScan { constructor() { @@ -84,7 +85,7 @@ class LibraryScan { } setData(library, scanOptions, type = 'scan') { - this.id = getId('lscan') + this.id = uuidv4() this.type = type this.library = new Library(library.toJSON()) // clone library diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index 28874b8c..1fb8016d 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -2,6 +2,7 @@ const fs = require('../libs/fsExtra') const Path = require('path') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') +const Database = require('../Database') // Utils const { groupFilesIntoLibraryItemPaths, getLibraryItemFileData, scanFolder, checkFilepathIsAudioFile } = require('../utils/scandir') @@ -22,8 +23,7 @@ const Series = require('../objects/entities/Series') const Task = require('../objects/Task') class Scanner { - constructor(db, coverManager, taskManager) { - this.db = db + constructor(coverManager, taskManager) { this.coverManager = coverManager this.taskManager = taskManager @@ -66,7 +66,7 @@ class Scanner { } async scanLibraryItemByRequest(libraryItem) { - const library = this.db.libraries.find(lib => lib.id === libraryItem.libraryId) + const library = Database.libraries.find(lib => lib.id === libraryItem.libraryId) if (!library) { Logger.error(`[Scanner] Scan libraryItem by id library not found "${libraryItem.libraryId}"`) return ScanResult.NOTHING @@ -108,7 +108,7 @@ class Scanner { if (checkRes.updated) hasUpdated = true // Sync other files first so that local images are used as cover art - if (await libraryItem.syncFiles(this.db.serverSettings.scannerPreferOpfMetadata, library.settings)) { + if (await libraryItem.syncFiles(Database.serverSettings.scannerPreferOpfMetadata, library.settings)) { hasUpdated = true } @@ -141,7 +141,7 @@ class Scanner { } if (hasUpdated) { - await this.db.updateLibraryItem(libraryItem) + await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) return ScanResult.UPDATED } @@ -160,7 +160,7 @@ class Scanner { } const scanOptions = new ScanOptions() - scanOptions.setData(options, this.db.serverSettings) + scanOptions.setData(options, Database.serverSettings) const libraryScan = new LibraryScan() libraryScan.setData(library, scanOptions) @@ -212,7 +212,7 @@ class Scanner { // Remove items with no inode libraryItemDataFound = libraryItemDataFound.filter(lid => lid.ino) - const libraryItemsInLibrary = this.db.libraryItems.filter(li => li.libraryId === libraryScan.libraryId) + const libraryItemsInLibrary = Database.libraryItems.filter(li => li.libraryId === libraryScan.libraryId) const MaxSizePerChunk = 2.5e9 const itemDataToRescanChunks = [] @@ -333,7 +333,7 @@ class Scanner { } async updateLibraryItemChunk(itemsToUpdate) { - await this.db.updateLibraryItems(itemsToUpdate) + await Database.updateBulkLibraryItems(itemsToUpdate) SocketAuthority.emitter('items_updated', itemsToUpdate.map(li => li.toJSONExpanded())) } @@ -351,7 +351,7 @@ class Scanner { if (itemsUpdated.length) { libraryScan.resultsUpdated += itemsUpdated.length - await this.db.updateLibraryItems(itemsUpdated) + await Database.updateBulkLibraryItems(itemsUpdated) SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded())) } } @@ -368,7 +368,7 @@ class Scanner { } libraryScan.resultsAdded += newLibraryItems.length - await this.db.insertLibraryItems(newLibraryItems) + await Database.createBulkLibraryItems(newLibraryItems) SocketAuthority.emitter('items_added', newLibraryItems.map(li => li.toJSONExpanded())) } @@ -436,6 +436,7 @@ class Scanner { const libraryItem = new LibraryItem() libraryItem.setData(library.mediaType, libraryItemData) + libraryItem.setLastScan() const mediaFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video') if (mediaFiles.length) { @@ -478,7 +479,7 @@ class Scanner { if (libraryItem.media.metadata.authors.some(au => au.id.startsWith('new'))) { var newAuthors = [] libraryItem.media.metadata.authors = libraryItem.media.metadata.authors.map((tempMinAuthor) => { - var _author = this.db.authors.find(au => au.checkNameEquals(tempMinAuthor.name)) + var _author = Database.authors.find(au => au.checkNameEquals(tempMinAuthor.name)) if (!_author) _author = newAuthors.find(au => au.checkNameEquals(tempMinAuthor.name)) // Check new unsaved authors if (!_author) { // Must create new author _author = new Author() @@ -492,14 +493,14 @@ class Scanner { } }) if (newAuthors.length) { - await this.db.insertEntities('author', newAuthors) + await Database.createBulkAuthors(newAuthors) SocketAuthority.emitter('authors_added', newAuthors.map(au => au.toJSON())) } } if (libraryItem.media.metadata.series.some(se => se.id.startsWith('new'))) { var newSeries = [] libraryItem.media.metadata.series = libraryItem.media.metadata.series.map((tempMinSeries) => { - var _series = this.db.series.find(se => se.checkNameEquals(tempMinSeries.name)) + var _series = Database.series.find(se => se.checkNameEquals(tempMinSeries.name)) if (!_series) _series = newSeries.find(se => se.checkNameEquals(tempMinSeries.name)) // Check new unsaved series if (!_series) { // Must create new series _series = new Series() @@ -513,7 +514,7 @@ class Scanner { } }) if (newSeries.length) { - await this.db.insertEntities('series', newSeries) + await Database.createBulkSeries(newSeries) SocketAuthority.emitter('multiple_series_added', newSeries.map(se => se.toJSON())) } } @@ -551,7 +552,7 @@ class Scanner { for (const folderId in folderGroups) { const libraryId = folderGroups[folderId].libraryId - const library = this.db.libraries.find(lib => lib.id === libraryId) + const library = Database.libraries.find(lib => lib.id === libraryId) if (!library) { Logger.error(`[Scanner] Library not found in files changed ${libraryId}`) continue; @@ -597,12 +598,12 @@ class Scanner { const altDir = `${itemDir}/${firstNest}` const fullPath = Path.posix.join(filePathToPOSIX(folder.fullPath), itemDir) - const childLibraryItem = this.db.libraryItems.find(li => li.path !== fullPath && li.path.startsWith(fullPath)) + const childLibraryItem = Database.libraryItems.find(li => li.path !== fullPath && li.path.startsWith(fullPath)) if (!childLibraryItem) { continue } const altFullPath = Path.posix.join(filePathToPOSIX(folder.fullPath), altDir) - const altChildLibraryItem = this.db.libraryItems.find(li => li.path !== altFullPath && li.path.startsWith(altFullPath)) + const altChildLibraryItem = Database.libraryItems.find(li => li.path !== altFullPath && li.path.startsWith(altFullPath)) if (altChildLibraryItem) { continue } @@ -619,9 +620,9 @@ class Scanner { const dirIno = await getIno(fullPath) // Check if book dir group is already an item - let existingLibraryItem = this.db.libraryItems.find(li => fullPath.startsWith(li.path)) + let existingLibraryItem = Database.libraryItems.find(li => fullPath.startsWith(li.path)) if (!existingLibraryItem) { - existingLibraryItem = this.db.libraryItems.find(li => li.ino === dirIno) + existingLibraryItem = Database.libraryItems.find(li => li.ino === dirIno) if (existingLibraryItem) { Logger.debug(`[Scanner] scanFolderUpdates: Library item found by inode value=${dirIno}. "${existingLibraryItem.relPath} => ${itemDir}"`) // Update library item paths for scan and all library item paths will get updated in LibraryItem.checkScanData @@ -636,7 +637,7 @@ class Scanner { if (!exists) { Logger.info(`[Scanner] Scanning file update group and library item was deleted "${existingLibraryItem.media.metadata.title}" - marking as missing`) existingLibraryItem.setMissing() - await this.db.updateLibraryItem(existingLibraryItem) + await Database.updateLibraryItem(existingLibraryItem) SocketAuthority.emitter('item_updated', existingLibraryItem.toJSONExpanded()) itemGroupingResults[itemDir] = ScanResult.REMOVED @@ -654,7 +655,7 @@ class Scanner { } // Check if a library item is a subdirectory of this dir - var childItem = this.db.libraryItems.find(li => (li.path + '/').startsWith(fullPath + '/')) + var childItem = Database.libraryItems.find(li => (li.path + '/').startsWith(fullPath + '/')) if (childItem) { Logger.warn(`[Scanner] Files were modified in a parent directory of a library item "${childItem.media.metadata.title}" - ignoring`) itemGroupingResults[itemDir] = ScanResult.NOTHING @@ -666,7 +667,7 @@ class Scanner { var newLibraryItem = await this.scanPotentialNewLibraryItem(library, folder, fullPath, isSingleMediaItem) if (newLibraryItem) { await this.createNewAuthorsAndSeries(newLibraryItem) - await this.db.insertLibraryItem(newLibraryItem) + await Database.createLibraryItem(newLibraryItem) SocketAuthority.emitter('item_added', newLibraryItem.toJSONExpanded()) } itemGroupingResults[itemDir] = newLibraryItem ? ScanResult.ADDED : ScanResult.NOTHING @@ -686,7 +687,7 @@ class Scanner { titleDistance: 2, authorDistance: 2 } - const scannerCoverProvider = this.db.serverSettings.scannerCoverProvider + const scannerCoverProvider = Database.serverSettings.scannerCoverProvider const results = await this.bookFinder.findCovers(scannerCoverProvider, libraryItem.media.metadata.title, libraryItem.media.metadata.authorName, options) if (results.length) { if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Found best cover for "${libraryItem.media.metadata.title}"`) @@ -716,7 +717,7 @@ class Scanner { // Set to override existing metadata if scannerPreferMatchedMetadata setting is true and // the overrideDefaults option is not set or set to false. - if ((overrideDefaults == false) && (this.db.serverSettings.scannerPreferMatchedMetadata)) { + if ((overrideDefaults == false) && (Database.serverSettings.scannerPreferMatchedMetadata)) { options.overrideCover = true options.overrideDetails = true } @@ -783,7 +784,7 @@ class Scanner { await this.quickMatchPodcastEpisodes(libraryItem, options) } - await this.db.updateLibraryItem(libraryItem) + await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) } @@ -878,11 +879,11 @@ class Scanner { const authorPayload = [] for (let index = 0; index < matchData.author.length; index++) { const authorName = matchData.author[index] - var author = this.db.authors.find(au => au.checkNameEquals(authorName)) + var author = Database.authors.find(au => au.checkNameEquals(authorName)) if (!author) { author = new Author() author.setData({ name: authorName }) - await this.db.insertEntity('author', author) + await Database.createAuthor(author) SocketAuthority.emitter('author_added', author.toJSON()) } authorPayload.push(author.toJSONMinimal()) @@ -896,11 +897,11 @@ class Scanner { const seriesPayload = [] for (let index = 0; index < matchData.series.length; index++) { const seriesMatchItem = matchData.series[index] - var seriesItem = this.db.series.find(au => au.checkNameEquals(seriesMatchItem.series)) + var seriesItem = Database.series.find(au => au.checkNameEquals(seriesMatchItem.series)) if (!seriesItem) { seriesItem = new Series() seriesItem.setData({ name: seriesMatchItem.series }) - await this.db.insertEntity('series', seriesItem) + await Database.createSeries(seriesItem) SocketAuthority.emitter('series_added', seriesItem.toJSON()) } seriesPayload.push(seriesItem.toJSONMinimal(seriesMatchItem.sequence)) @@ -981,7 +982,7 @@ class Scanner { return } - var itemsInLibrary = this.db.getLibraryItemsInLibrary(library.id) + const itemsInLibrary = Database.libraryItems.filter(li => li.libraryId === library.id) if (!itemsInLibrary.length) { Logger.error(`[Scanner] matchLibraryItems: Library has no items ${library.id}`) return diff --git a/server/utils/areEquivalent.js b/server/utils/areEquivalent.js index 94a1901e..4e3d66a0 100644 --- a/server/utils/areEquivalent.js +++ b/server/utils/areEquivalent.js @@ -17,24 +17,31 @@ @param value2 Other item to compare @param stack Used internally to track circular refs - don't set it */ -module.exports = function areEquivalent(value1, value2, stack = []) { +module.exports = function areEquivalent(value1, value2, numToString = false, stack = []) { + if (numToString) { + if (value1 !== null && !isNaN(value1)) value1 = String(value1) + if (value2 !== null && !isNaN(value2)) value2 = String(value2) + } + // Numbers, strings, null, undefined, symbols, functions, booleans. // Also: objects (incl. arrays) that are actually the same instance if (value1 === value2) { // Fast and done - return true; + return true } // Truthy check to handle value1=null, value2=Object if ((value1 && !value2) || (!value1 && value2)) { + console.log('value1/value2 falsy mismatch', value1, value2) return false } - const type1 = typeof value1; + const type1 = typeof value1 // Ensure types match if (type1 !== typeof value2) { - return false; + console.log('type diff', type1, typeof value2) + return false } // Special case for number: check for NaN on both sides @@ -49,26 +56,27 @@ module.exports = function areEquivalent(value1, value2, stack = []) { // Failed initial equals test, but could still have equivalent // implementations - note, will match on functions that have same name // and are native code: `function abc() { [native code] }` - return value1.toString() === value2.toString(); + return value1.toString() === value2.toString() } // For these types, cannot still be equal at this point, so fast-fail if (type1 === 'bigint' || type1 === 'boolean' || type1 === 'function' || type1 === 'string' || type1 === 'symbol') { - return false; + console.log('no match for values', value1, value2) + return false } // For dates, cast to number and ensure equal or both NaN (note, if same // exact instance then we're not here - that was checked above) if (value1 instanceof Date) { if (!(value2 instanceof Date)) { - return false; + return false } // Convert to number to compare - const asNum1 = +value1, asNum2 = +value2; + const asNum1 = +value1, asNum2 = +value2 // Check if both invalid (NaN) or are same value - return asNum1 === asNum2 || (isNaN(asNum1) && isNaN(asNum2)); + return asNum1 === asNum2 || (isNaN(asNum1) && isNaN(asNum2)) } // At this point, it's a reference type and could be circular, so @@ -80,61 +88,67 @@ module.exports = function areEquivalent(value1, value2, stack = []) { } // breadcrumb - stack.push(value1); + stack.push(value1) // Handle arrays if (Array.isArray(value1)) { if (!Array.isArray(value2)) { - return false; + console.log('value2 is not array but value1 is', value1, value2) + return false } - const length = value1.length; + const length = value1.length if (length !== value2.length) { - return false; + console.log('array length diff', length) + return false } for (let i = 0; i < length; i++) { - if (!areEquivalent(value1[i], value2[i], stack)) { - return false; + if (!areEquivalent(value1[i], value2[i], numToString, stack)) { + console.log('2 array items are not equiv', value1[i], value2[i]) + return false } } - return true; + return true } // Final case: object // get both key lists and check length - const keys1 = Object.keys(value1); - const keys2 = Object.keys(value2); - const numKeys = keys1.length; + const keys1 = Object.keys(value1) + const keys2 = Object.keys(value2) + const numKeys = keys1.length if (keys2.length !== numKeys) { - return false; + console.log('Key length is diff', keys2.length, numKeys) + return false } // Empty object on both sides? if (numKeys === 0) { - return true; + return true } // sort is a native call so it's very fast - much faster than comparing the // values at each key if it can be avoided, so do the sort and then // ensure every key matches at every index - keys1.sort(); - keys2.sort(); + keys1.sort() + keys2.sort() // Ensure perfect match across all keys for (let i = 0; i < numKeys; i++) { if (keys1[i] !== keys2[i]) { - return false; + console.log('object key is not equiv', keys1[i], keys2[i]) + return false } } // Ensure perfect match across all values for (let i = 0; i < numKeys; i++) { - if (!areEquivalent(value1[keys1[i]], value2[keys1[i]], stack)) { - return false; + if (!areEquivalent(value1[keys1[i]], value2[keys1[i]], numToString, stack)) { + console.log('2 subobjects not equiv', keys1[i], value1[keys1[i]], value2[keys1[i]]) + return false } } diff --git a/server/utils/libraryHelpers.js b/server/utils/libraryHelpers.js index 899e43bb..9ebd3323 100644 --- a/server/utils/libraryHelpers.js +++ b/server/utils/libraryHelpers.js @@ -1,5 +1,6 @@ const { sort, createNewSortInstance } = require('../libs/fastSort') const Logger = require('../Logger') +const Database = require('../Database') const { getTitlePrefixAtEnd, isNullOrNaN, getTitleIgnorePrefix } = require('../utils/index') const naturalSort = createNewSortInstance({ comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare @@ -574,7 +575,7 @@ module.exports = { const hideFromContinueListening = user.checkShouldHideSeriesFromContinueListening(librarySeries.id) if (!seriesMap[librarySeries.id]) { - const seriesObj = ctx.db.series.find(se => se.id === librarySeries.id) + const seriesObj = Database.series.find(se => se.id === librarySeries.id) if (seriesObj) { const series = { ...seriesObj.toJSON(), @@ -626,7 +627,7 @@ module.exports = { if (libraryItem.media.metadata.authors.length) { for (const libraryAuthor of libraryItem.media.metadata.authors) { if (!authorMap[libraryAuthor.id]) { - const authorObj = ctx.db.authors.find(au => au.id === libraryAuthor.id) + const authorObj = Database.authors.find(au => au.id === libraryAuthor.id) if (authorObj) { const author = { ...authorObj.toJSON(), diff --git a/server/utils/migrations/dbMigration.js b/server/utils/migrations/dbMigration.js new file mode 100644 index 00000000..28fb5907 --- /dev/null +++ b/server/utils/migrations/dbMigration.js @@ -0,0 +1,763 @@ +const Path = require('path') +const uuidv4 = require("uuid").v4 +const Logger = require('../../Logger') +const fs = require('../../libs/fsExtra') +const oldDbFiles = require('./oldDbFiles') + +const oldDbIdMap = { + users: {}, + libraries: {}, + libraryFolders: {}, + libraryItems: {}, + authors: {}, + series: {}, + collections: {}, + podcastEpisodes: {}, + books: {}, // key is library item id + podcasts: {}, // key is library item id + devices: {} // key is a json stringify of the old DeviceInfo data OR deviceId if it exists +} +const newRecords = { + user: [], + library: [], + libraryFolder: [], + author: [], + book: [], + podcast: [], + libraryItem: [], + bookAuthor: [], + series: [], + bookSeries: [], + podcastEpisode: [], + mediaProgress: [], + device: [], + playbackSession: [], + collection: [], + collectionBook: [], + playlist: [], + playlistMediaItem: [], + feed: [], + feedEpisode: [], + setting: [] +} + +function getDeviceInfoString(deviceInfo, UserId) { + if (!deviceInfo) return null + if (deviceInfo.deviceId) return deviceInfo.deviceId + + const keys = [ + UserId, + deviceInfo.browserName || null, + deviceInfo.browserVersion || null, + deviceInfo.osName || null, + deviceInfo.osVersion || null, + deviceInfo.clientVersion || null, + deviceInfo.manufacturer || null, + deviceInfo.model || null, + deviceInfo.sdkVersion || null, + deviceInfo.ipAddress || null + ].map(k => k || '') + return 'temp-' + Buffer.from(keys.join('-'), 'utf-8').toString('base64') +} + +function migrateBook(oldLibraryItem, LibraryItem) { + const oldBook = oldLibraryItem.media + + // + // Migrate Book + // + const Book = { + id: uuidv4(), + title: oldBook.metadata.title, + subtitle: oldBook.metadata.subtitle, + publishedYear: oldBook.metadata.publishedYear, + publishedDate: oldBook.metadata.publishedDate, + publisher: oldBook.metadata.publisher, + description: oldBook.metadata.description, + isbn: oldBook.metadata.isbn, + asin: oldBook.metadata.asin, + language: oldBook.metadata.language, + explicit: !!oldBook.metadata.explicit, + abridged: !!oldBook.metadata.abridged, + lastCoverSearchQuery: oldBook.lastCoverSearchQuery, + lastCoverSearch: oldBook.lastCoverSearch, + createdAt: LibraryItem.createdAt, + updatedAt: LibraryItem.updatedAt, + narrators: oldBook.metadata.narrators, + ebookFile: oldBook.ebookFile, + coverPath: oldBook.coverPath, + audioFiles: oldBook.audioFiles, + chapters: oldBook.chapters, + tags: oldBook.tags, + genres: oldBook.metadata.genres + } + newRecords.book.push(Book) + oldDbIdMap.books[oldLibraryItem.id] = Book.id + + // + // Migrate BookAuthors + // + for (const oldBookAuthor of oldBook.metadata.authors) { + if (oldDbIdMap.authors[oldBookAuthor.id]) { + newRecords.bookAuthor.push({ + id: uuidv4(), + authorId: oldDbIdMap.authors[oldBookAuthor.id], + bookId: Book.id + }) + } else { + Logger.warn(`[dbMigration] migrateBook: Book author not found "${oldBookAuthor.name}"`) + } + } + + // + // Migrate BookSeries + // + for (const oldBookSeries of oldBook.metadata.series) { + if (oldDbIdMap.series[oldBookSeries.id]) { + const BookSeries = { + id: uuidv4(), + sequence: oldBookSeries.sequence, + seriesId: oldDbIdMap.series[oldBookSeries.id], + bookId: Book.id + } + newRecords.bookSeries.push(BookSeries) + } else { + Logger.warn(`[dbMigration] migrateBook: Series not found "${oldBookSeries.name}"`) + } + } +} + +function migratePodcast(oldLibraryItem, LibraryItem) { + const oldPodcast = oldLibraryItem.media + const oldPodcastMetadata = oldPodcast.metadata + + // + // Migrate Podcast + // + const Podcast = { + id: uuidv4(), + title: oldPodcastMetadata.title, + author: oldPodcastMetadata.author, + releaseDate: oldPodcastMetadata.releaseDate, + feedURL: oldPodcastMetadata.feedUrl, + imageURL: oldPodcastMetadata.imageUrl, + description: oldPodcastMetadata.description, + itunesPageURL: oldPodcastMetadata.itunesPageUrl, + itunesId: oldPodcastMetadata.itunesId, + itunesArtistId: oldPodcastMetadata.itunesArtistId, + language: oldPodcastMetadata.language, + podcastType: oldPodcastMetadata.type, + explicit: !!oldPodcastMetadata.explicit, + autoDownloadEpisodes: !!oldPodcast.autoDownloadEpisodes, + autoDownloadSchedule: oldPodcast.autoDownloadSchedule, + lastEpisodeCheck: oldPodcast.lastEpisodeCheck, + maxEpisodesToKeep: oldPodcast.maxEpisodesToKeep, + maxNewEpisodesToDownload: oldPodcast.maxNewEpisodesToDownload, + lastCoverSearchQuery: oldPodcast.lastCoverSearchQuery, + lastCoverSearch: oldPodcast.lastCoverSearch, + createdAt: LibraryItem.createdAt, + updatedAt: LibraryItem.updatedAt, + coverPath: oldPodcast.coverPath, + tags: oldPodcast.tags, + genres: oldPodcastMetadata.genres + } + newRecords.podcast.push(Podcast) + oldDbIdMap.podcasts[oldLibraryItem.id] = Podcast.id + + // + // Migrate PodcastEpisodes + // + const oldEpisodes = oldPodcast.episodes || [] + for (const oldEpisode of oldEpisodes) { + const PodcastEpisode = { + id: uuidv4(), + index: oldEpisode.index, + season: oldEpisode.season, + episode: oldEpisode.episode, + episodeType: oldEpisode.episodeType, + title: oldEpisode.title, + subtitle: oldEpisode.subtitle, + description: oldEpisode.description, + pubDate: oldEpisode.pubDate, + enclosureURL: oldEpisode.enclosure?.url || null, + enclosureSize: oldEpisode.enclosure?.length || null, + enclosureType: oldEpisode.enclosure?.type || null, + publishedAt: oldEpisode.publishedAt, + createdAt: oldEpisode.addedAt, + updatedAt: oldEpisode.updatedAt, + podcastId: Podcast.id, + audioFile: oldEpisode.audioFile, + chapters: oldEpisode.chapters + } + newRecords.podcastEpisode.push(PodcastEpisode) + oldDbIdMap.podcastEpisodes[oldEpisode.id] = PodcastEpisode.id + } +} + +function migrateLibraryItems(oldLibraryItems) { + for (const oldLibraryItem of oldLibraryItems) { + const libraryFolderId = oldDbIdMap.libraryFolders[oldLibraryItem.folderId] + if (!libraryFolderId) { + Logger.error(`[dbMigration] migrateLibraryItems: Old library folder id not found "${oldLibraryItem.folderId}"`) + continue + } + const libraryId = oldDbIdMap.libraries[oldLibraryItem.libraryId] + if (!libraryId) { + Logger.error(`[dbMigration] migrateLibraryItems: Old library id not found "${oldLibraryItem.libraryId}"`) + continue + } + if (!['book', 'podcast'].includes(oldLibraryItem.mediaType)) { + Logger.error(`[dbMigration] migrateLibraryItems: Not migrating library item with mediaType=${oldLibraryItem.mediaType}`) + continue + } + + // + // Migrate LibraryItem + // + const LibraryItem = { + id: uuidv4(), + ino: oldLibraryItem.ino, + path: oldLibraryItem.path, + relPath: oldLibraryItem.relPath, + mediaId: null, // set below + mediaType: oldLibraryItem.mediaType, + isFile: !!oldLibraryItem.isFile, + isMissing: !!oldLibraryItem.isMissing, + isInvalid: !!oldLibraryItem.isInvalid, + mtime: oldLibraryItem.mtimeMs, + ctime: oldLibraryItem.ctimeMs, + birthtime: oldLibraryItem.birthtimeMs, + lastScan: oldLibraryItem.lastScan, + lastScanVersion: oldLibraryItem.scanVersion, + createdAt: oldLibraryItem.addedAt, + updatedAt: oldLibraryItem.updatedAt, + libraryId, + libraryFolderId, + libraryFiles: oldLibraryItem.libraryFiles.map(lf => { + if (lf.isSupplementary === undefined) lf.isSupplementary = null + return lf + }) + } + oldDbIdMap.libraryItems[oldLibraryItem.id] = LibraryItem.id + newRecords.libraryItem.push(LibraryItem) + + // + // Migrate Book/Podcast + // + if (oldLibraryItem.mediaType === 'book') { + migrateBook(oldLibraryItem, LibraryItem) + LibraryItem.mediaId = oldDbIdMap.books[oldLibraryItem.id] + } else if (oldLibraryItem.mediaType === 'podcast') { + migratePodcast(oldLibraryItem, LibraryItem) + LibraryItem.mediaId = oldDbIdMap.podcasts[oldLibraryItem.id] + } + } +} + +function migrateLibraries(oldLibraries) { + for (const oldLibrary of oldLibraries) { + if (!['book', 'podcast'].includes(oldLibrary.mediaType)) { + Logger.error(`[dbMigration] migrateLibraries: Not migrating library with mediaType=${oldLibrary.mediaType}`) + continue + } + + // + // Migrate Library + // + const Library = { + id: uuidv4(), + name: oldLibrary.name, + displayOrder: oldLibrary.displayOrder, + icon: oldLibrary.icon || null, + mediaType: oldLibrary.mediaType || null, + provider: oldLibrary.provider, + settings: oldLibrary.settings || {}, + createdAt: oldLibrary.createdAt, + updatedAt: oldLibrary.lastUpdate + } + oldDbIdMap.libraries[oldLibrary.id] = Library.id + newRecords.library.push(Library) + + // + // Migrate LibraryFolders + // + for (const oldFolder of oldLibrary.folders) { + const LibraryFolder = { + id: uuidv4(), + path: oldFolder.fullPath, + createdAt: oldFolder.addedAt, + updatedAt: oldLibrary.lastUpdate, + libraryId: Library.id + } + oldDbIdMap.libraryFolders[oldFolder.id] = LibraryFolder.id + newRecords.libraryFolder.push(LibraryFolder) + } + } +} + +function migrateAuthors(oldAuthors) { + for (const oldAuthor of oldAuthors) { + const Author = { + id: uuidv4(), + name: oldAuthor.name, + asin: oldAuthor.asin || null, + description: oldAuthor.description, + imagePath: oldAuthor.imagePath, + createdAt: oldAuthor.addedAt || Date.now(), + updatedAt: oldAuthor.updatedAt || Date.now() + } + oldDbIdMap.authors[oldAuthor.id] = Author.id + newRecords.author.push(Author) + } +} + +function migrateSeries(oldSerieses) { + for (const oldSeries of oldSerieses) { + const Series = { + id: uuidv4(), + name: oldSeries.name, + description: oldSeries.description || null, + createdAt: oldSeries.addedAt || Date.now(), + updatedAt: oldSeries.updatedAt || Date.now() + } + oldDbIdMap.series[oldSeries.id] = Series.id + newRecords.series.push(Series) + } +} + +function migrateUsers(oldUsers) { + for (const oldUser of oldUsers) { + // + // Migrate User + // + const User = { + id: uuidv4(), + username: oldUser.username, + pash: oldUser.pash || null, + type: oldUser.type || null, + token: oldUser.token || null, + isActive: !!oldUser.isActive, + lastSeen: oldUser.lastSeen || null, + extraData: { + seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [], + oldUserId: oldUser.id // Used to keep old tokens + }, + createdAt: oldUser.createdAt || Date.now(), + permissions: { + ...oldUser.permissions, + librariesAccessible: oldUser.librariesAccessible || [], + itemTagsSelected: oldUser.itemTagsSelected || [] + }, + bookmarks: oldUser.bookmarks + } + oldDbIdMap.users[oldUser.id] = User.id + newRecords.user.push(User) + + // + // Migrate MediaProgress + // + for (const oldMediaProgress of oldUser.mediaProgress) { + let mediaItemType = 'book' + let mediaItemId = null + if (oldMediaProgress.episodeId) { + mediaItemType = 'podcastEpisode' + mediaItemId = oldDbIdMap.podcastEpisodes[oldMediaProgress.episodeId] + } else { + mediaItemId = oldDbIdMap.books[oldMediaProgress.libraryItemId] + } + + if (!mediaItemId) { + Logger.warn(`[dbMigration] migrateUsers: Unable to find media item for media progress "${oldMediaProgress.id}"`) + continue + } + + const MediaProgress = { + id: uuidv4(), + mediaItemId, + mediaItemType, + duration: oldMediaProgress.duration, + currentTime: oldMediaProgress.currentTime, + ebookLocation: oldMediaProgress.ebookLocation || null, + ebookProgress: oldMediaProgress.ebookProgress || null, + isFinished: !!oldMediaProgress.isFinished, + hideFromContinueListening: !!oldMediaProgress.hideFromContinueListening, + finishedAt: oldMediaProgress.finishedAt, + createdAt: oldMediaProgress.startedAt || oldMediaProgress.lastUpdate, + updatedAt: oldMediaProgress.lastUpdate, + userId: User.id, + extraData: { + libraryItemId: oldDbIdMap.libraryItems[oldMediaProgress.libraryItemId], + progress: oldMediaProgress.progress + } + } + newRecords.mediaProgress.push(MediaProgress) + } + } +} + +function migrateSessions(oldSessions) { + for (const oldSession of oldSessions) { + const userId = oldDbIdMap.users[oldSession.userId] || null // Can be null + + // + // Migrate Device + // + let deviceId = null + if (oldSession.deviceInfo) { + const oldDeviceInfo = oldSession.deviceInfo + const deviceDeviceId = getDeviceInfoString(oldDeviceInfo, userId) + deviceId = oldDbIdMap.devices[deviceDeviceId] + if (!deviceId) { + let clientName = 'Unknown' + let clientVersion = null + let deviceName = null + let deviceVersion = oldDeviceInfo.browserVersion || null + let extraData = {} + if (oldDeviceInfo.sdkVersion) { + clientName = 'Abs Android' + clientVersion = oldDeviceInfo.clientVersion || null + deviceName = `${oldDeviceInfo.manufacturer} ${oldDeviceInfo.model}` + deviceVersion = oldDeviceInfo.sdkVersion + } else if (oldDeviceInfo.model) { + clientName = 'Abs iOS' + clientVersion = oldDeviceInfo.clientVersion || null + deviceName = `${oldDeviceInfo.manufacturer} ${oldDeviceInfo.model}` + } else if (oldDeviceInfo.osName && oldDeviceInfo.browserName) { + clientName = 'Abs Web' + clientVersion = oldDeviceInfo.serverVersion || null + deviceName = `${oldDeviceInfo.osName} ${oldDeviceInfo.osVersion || 'N/A'} ${oldDeviceInfo.browserName}` + } + + if (oldDeviceInfo.manufacturer) { + extraData.manufacturer = oldDeviceInfo.manufacturer + } + if (oldDeviceInfo.model) { + extraData.model = oldDeviceInfo.model + } + if (oldDeviceInfo.osName) { + extraData.osName = oldDeviceInfo.osName + } + if (oldDeviceInfo.osVersion) { + extraData.osVersion = oldDeviceInfo.osVersion + } + if (oldDeviceInfo.browserName) { + extraData.browserName = oldDeviceInfo.browserName + } + + const id = uuidv4() + const Device = { + id, + deviceId: deviceDeviceId, + clientName, + clientVersion, + ipAddress: oldDeviceInfo.ipAddress, + deviceName, // e.g. Windows 10 Chrome, Google Pixel 6, Apple iPhone 10,3 + deviceVersion, + userId, + extraData + } + newRecords.device.push(Device) + oldDbIdMap.devices[deviceDeviceId] = Device.id + } + } + + + // + // Migrate PlaybackSession + // + let mediaItemId = null + let mediaItemType = 'book' + if (oldSession.mediaType === 'podcast') { + mediaItemId = oldDbIdMap.podcastEpisodes[oldSession.episodeId] || null + mediaItemType = 'podcastEpisode' + } else { + mediaItemId = oldDbIdMap.books[oldSession.libraryItemId] || null + } + + const PlaybackSession = { + id: uuidv4(), + mediaItemId, // Can be null + mediaItemType, + libraryId: oldDbIdMap.libraries[oldSession.libraryId] || null, + displayTitle: oldSession.displayTitle, + displayAuthor: oldSession.displayAuthor, + duration: oldSession.duration, + playMethod: oldSession.playMethod, + mediaPlayer: oldSession.mediaPlayer, + startTime: oldSession.startTime, + currentTime: oldSession.currentTime, + serverVersion: oldSession.deviceInfo?.serverVersion || null, + createdAt: oldSession.startedAt, + updatedAt: oldSession.updatedAt, + userId, // Can be null + deviceId, + timeListening: oldSession.timeListening, + coverPath: oldSession.coverPath, + mediaMetadata: oldSession.mediaMetadata, + date: oldSession.date, + dayOfWeek: oldSession.dayOfWeek, + extraData: { + libraryItemId: oldDbIdMap.libraryItems[oldSession.libraryItemId] + } + } + newRecords.playbackSession.push(PlaybackSession) + } +} + +function migrateCollections(oldCollections) { + for (const oldCollection of oldCollections) { + const libraryId = oldDbIdMap.libraries[oldCollection.libraryId] + if (!libraryId) { + Logger.warn(`[dbMigration] migrateCollections: Library not found for collection "${oldCollection.name}" (id:${oldCollection.libraryId})`) + continue + } + + const BookIds = oldCollection.books.map(lid => oldDbIdMap.books[lid]).filter(bid => bid) + if (!BookIds.length) { + Logger.warn(`[dbMigration] migrateCollections: Collection "${oldCollection.name}" has no books`) + continue + } + + const Collection = { + id: uuidv4(), + name: oldCollection.name, + description: oldCollection.description, + createdAt: oldCollection.createdAt, + updatedAt: oldCollection.lastUpdate, + libraryId + } + oldDbIdMap.collections[oldCollection.id] = Collection.id + newRecords.collection.push(Collection) + + let order = 1 + BookIds.forEach((bookId) => { + const CollectionBook = { + id: uuidv4(), + createdAt: Collection.createdAt, + bookId, + collectionId: Collection.id, + order: order++ + } + newRecords.collectionBook.push(CollectionBook) + }) + } +} + +function migratePlaylists(oldPlaylists) { + for (const oldPlaylist of oldPlaylists) { + const libraryId = oldDbIdMap.libraries[oldPlaylist.libraryId] + if (!libraryId) { + Logger.warn(`[dbMigration] migratePlaylists: Library not found for playlist "${oldPlaylist.name}" (id:${oldPlaylist.libraryId})`) + continue + } + + const userId = oldDbIdMap.users[oldPlaylist.userId] + if (!userId) { + Logger.warn(`[dbMigration] migratePlaylists: User not found for playlist "${oldPlaylist.name}" (id:${oldPlaylist.userId})`) + continue + } + + let mediaItemType = 'book' + let MediaItemIds = [] + oldPlaylist.items.forEach((itemObj) => { + if (itemObj.episodeId) { + mediaItemType = 'podcastEpisode' + if (oldDbIdMap.podcastEpisodes[itemObj.episodeId]) { + MediaItemIds.push(oldDbIdMap.podcastEpisodes[itemObj.episodeId]) + } + } else if (oldDbIdMap.books[itemObj.libraryItemId]) { + MediaItemIds.push(oldDbIdMap.books[itemObj.libraryItemId]) + } + }) + if (!MediaItemIds.length) { + Logger.warn(`[dbMigration] migratePlaylists: Playlist "${oldPlaylist.name}" has no items`) + continue + } + + const Playlist = { + id: uuidv4(), + name: oldPlaylist.name, + description: oldPlaylist.description, + createdAt: oldPlaylist.createdAt, + updatedAt: oldPlaylist.lastUpdate, + userId, + libraryId + } + newRecords.playlist.push(Playlist) + + let order = 1 + MediaItemIds.forEach((mediaItemId) => { + const PlaylistMediaItem = { + id: uuidv4(), + mediaItemId, + mediaItemType, + createdAt: Playlist.createdAt, + playlistId: Playlist.id, + order: order++ + } + newRecords.playlistMediaItem.push(PlaylistMediaItem) + }) + } +} + +function migrateFeeds(oldFeeds) { + for (const oldFeed of oldFeeds) { + if (!oldFeed.episodes?.length) { + continue + } + + let entityId = null + + if (oldFeed.entityType === 'collection') { + entityId = oldDbIdMap.collections[oldFeed.entityId] + } else if (oldFeed.entityType === 'libraryItem') { + entityId = oldDbIdMap.libraryItems[oldFeed.entityId] + } else if (oldFeed.entityType === 'series') { + entityId = oldDbIdMap.series[oldFeed.entityId] + } + + if (!entityId) { + Logger.warn(`[dbMigration] migrateFeeds: Entity not found for feed "${oldFeed.entityType}" (id:${oldFeed.entityId})`) + continue + } + + const userId = oldDbIdMap.users[oldFeed.userId] + if (!userId) { + Logger.warn(`[dbMigration] migrateFeeds: User not found for feed (id:${oldFeed.userId})`) + continue + } + + const oldFeedMeta = oldFeed.meta + + const Feed = { + id: uuidv4(), + slug: oldFeed.slug, + entityType: oldFeed.entityType, + entityId, + entityUpdatedAt: oldFeed.entityUpdatedAt, + serverAddress: oldFeed.serverAddress, + feedURL: oldFeed.feedUrl, + imageURL: oldFeedMeta.imageUrl, + siteURL: oldFeedMeta.link, + title: oldFeedMeta.title, + description: oldFeedMeta.description, + author: oldFeedMeta.author, + podcastType: oldFeedMeta.type || null, + language: oldFeedMeta.language || null, + ownerName: oldFeedMeta.ownerName || null, + ownerEmail: oldFeedMeta.ownerEmail || null, + explicit: !!oldFeedMeta.explicit, + preventIndexing: !!oldFeedMeta.preventIndexing, + createdAt: oldFeed.createdAt, + updatedAt: oldFeed.updatedAt, + userId + } + newRecords.feed.push(Feed) + + // + // Migrate FeedEpisodes + // + for (const oldFeedEpisode of oldFeed.episodes) { + const FeedEpisode = { + id: uuidv4(), + title: oldFeedEpisode.title, + author: oldFeedEpisode.author, + description: oldFeedEpisode.description, + siteURL: oldFeedEpisode.link, + enclosureURL: oldFeedEpisode.enclosure?.url || null, + enclosureType: oldFeedEpisode.enclosure?.type || null, + enclosureSize: oldFeedEpisode.enclosure?.size || null, + pubDate: oldFeedEpisode.pubDate, + season: oldFeedEpisode.season || null, + episode: oldFeedEpisode.episode || null, + episodeType: oldFeedEpisode.episodeType || null, + duration: oldFeedEpisode.duration, + filePath: oldFeedEpisode.fullPath, + explicit: !!oldFeedEpisode.explicit, + createdAt: oldFeed.createdAt, + updatedAt: oldFeed.updatedAt, + feedId: Feed.id + } + newRecords.feedEpisode.push(FeedEpisode) + } + } +} + +function migrateSettings(oldSettings) { + const serverSettings = oldSettings.find(s => s.id === 'server-settings') + const notificationSettings = oldSettings.find(s => s.id === 'notification-settings') + const emailSettings = oldSettings.find(s => s.id === 'email-settings') + + if (serverSettings) { + newRecords.setting.push({ + key: 'server-settings', + value: serverSettings + }) + } + + if (notificationSettings) { + newRecords.setting.push({ + key: 'notification-settings', + value: notificationSettings + }) + } + + if (emailSettings) { + newRecords.setting.push({ + key: 'email-settings', + value: emailSettings + }) + } +} + +module.exports.migrate = async (DatabaseModels) => { + Logger.info(`[dbMigration] Starting migration`) + + const data = await oldDbFiles.init() + + const start = Date.now() + migrateSettings(data.settings) + migrateAuthors(data.authors) + migrateSeries(data.series) + migrateLibraries(data.libraries) + migrateLibraryItems(data.libraryItems) + migrateUsers(data.users) + migrateSessions(data.sessions) + migrateCollections(data.collections) + migratePlaylists(data.playlists) + migrateFeeds(data.feeds) + + let totalRecords = 0 + for (const model in newRecords) { + Logger.info(`[dbMigration] Inserting ${newRecords[model].length} ${model} rows`) + if (newRecords[model].length) { + await DatabaseModels[model].bulkCreate(newRecords[model]) + totalRecords += newRecords[model].length + } + } + + const elapsed = Date.now() - start + + // Purge author images and cover images from cache + try { + const CachePath = Path.join(global.MetadataPath, 'cache') + await fs.emptyDir(Path.join(CachePath, 'covers')) + await fs.emptyDir(Path.join(CachePath, 'images')) + } catch (error) { + Logger.error(`[dbMigration] Failed to purge author/cover image cache`, error) + } + + // Put all old db folders into a zipfile oldDb.zip + await oldDbFiles.zipWrapOldDb() + + Logger.info(`[dbMigration] Migration complete. ${totalRecords} rows. Elapsed ${(elapsed / 1000).toFixed(2)}s`) +} + +/** + * @returns {boolean} true if old database exists + */ +module.exports.checkShouldMigrate = async (force = false) => { + if (await oldDbFiles.checkHasOldDb()) return true + if (!force) return false + return oldDbFiles.checkHasOldDbZip() +} \ No newline at end of file diff --git a/server/utils/migrations/oldDbFiles.js b/server/utils/migrations/oldDbFiles.js new file mode 100644 index 00000000..1ff9ffe0 --- /dev/null +++ b/server/utils/migrations/oldDbFiles.js @@ -0,0 +1,189 @@ +const { once } = require('events') +const { createInterface } = require('readline') +const Path = require('path') +const Logger = require('../../Logger') +const fs = require('../../libs/fsExtra') +const archiver = require('../../libs/archiver') +const StreamZip = require('../../libs/nodeStreamZip') + +async function processDbFile(filepath) { + if (!fs.pathExistsSync(filepath)) { + Logger.error(`[oldDbFiles] Db file does not exist at "${filepath}"`) + return [] + } + + const entities = [] + + try { + const fileStream = fs.createReadStream(filepath) + + const rl = createInterface({ + input: fileStream, + crlfDelay: Infinity, + }) + + rl.on('line', (line) => { + if (line && line.trim()) { + try { + const entity = JSON.parse(line) + if (entity && Object.keys(entity).length) entities.push(entity) + } catch (jsonParseError) { + Logger.error(`[oldDbFiles] Failed to parse line "${line}" in db file "${filepath}"`, jsonParseError) + } + } + }) + + await once(rl, 'close') + + console.log(`[oldDbFiles] Db file "${filepath}" processed`) + + return entities + } catch (error) { + Logger.error(`[oldDbFiles] Failed to read db file "${filepath}"`, error) + return [] + } +} + +async function loadDbData(dbpath) { + try { + Logger.info(`[oldDbFiles] Loading db data at "${dbpath}"`) + const files = await fs.readdir(dbpath) + + const entities = [] + for (const filename of files) { + if (Path.extname(filename).toLowerCase() !== '.json') { + Logger.warn(`[oldDbFiles] Ignoring filename "${filename}" in db folder "${dbpath}"`) + continue + } + + const filepath = Path.join(dbpath, filename) + Logger.info(`[oldDbFiles] Loading db data file "${filepath}"`) + const someEntities = await processDbFile(filepath) + Logger.info(`[oldDbFiles] Processed db data file with ${someEntities.length} entities`) + entities.push(...someEntities) + } + + Logger.info(`[oldDbFiles] Finished loading db data with ${entities.length} entities`) + return entities + } catch (error) { + Logger.error(`[oldDbFiles] Failed to load db data "${dbpath}"`, error) + return null + } +} + +module.exports.init = async () => { + const dbs = { + libraryItems: Path.join(global.ConfigPath, 'libraryItems', 'data'), + users: Path.join(global.ConfigPath, 'users', 'data'), + sessions: Path.join(global.ConfigPath, 'sessions', 'data'), + libraries: Path.join(global.ConfigPath, 'libraries', 'data'), + settings: Path.join(global.ConfigPath, 'settings', 'data'), + collections: Path.join(global.ConfigPath, 'collections', 'data'), + playlists: Path.join(global.ConfigPath, 'playlists', 'data'), + authors: Path.join(global.ConfigPath, 'authors', 'data'), + series: Path.join(global.ConfigPath, 'series', 'data'), + feeds: Path.join(global.ConfigPath, 'feeds', 'data') + } + + const data = {} + for (const key in dbs) { + data[key] = await loadDbData(dbs[key]) + Logger.info(`[oldDbFiles] ${data[key].length} ${key} loaded`) + } + + return data +} + +module.exports.zipWrapOldDb = async () => { + const dbs = { + libraryItems: Path.join(global.ConfigPath, 'libraryItems'), + users: Path.join(global.ConfigPath, 'users'), + sessions: Path.join(global.ConfigPath, 'sessions'), + libraries: Path.join(global.ConfigPath, 'libraries'), + settings: Path.join(global.ConfigPath, 'settings'), + collections: Path.join(global.ConfigPath, 'collections'), + playlists: Path.join(global.ConfigPath, 'playlists'), + authors: Path.join(global.ConfigPath, 'authors'), + series: Path.join(global.ConfigPath, 'series'), + feeds: Path.join(global.ConfigPath, 'feeds') + } + + return new Promise((resolve) => { + const oldDbPath = Path.join(global.ConfigPath, 'oldDb.zip') + const output = fs.createWriteStream(oldDbPath) + const archive = archiver('zip', { + zlib: { level: 9 } // Sets the compression level. + }) + + // listen for all archive data to be written + // 'close' event is fired only when a file descriptor is involved + output.on('close', async () => { + Logger.info(`[oldDbFiles] Old db files have been zipped in ${oldDbPath}. ${archive.pointer()} total bytes`) + + // Remove old db folders have successful zip + for (const db in dbs) { + await fs.remove(dbs[db]) + } + + resolve(true) + }) + + // This event is fired when the data source is drained no matter what was the data source. + // It is not part of this library but rather from the NodeJS Stream API. + // @see: https://nodejs.org/api/stream.html#stream_event_end + output.on('end', () => { + Logger.debug('[oldDbFiles] Data has been drained') + }) + + // good practice to catch this error explicitly + archive.on('error', (err) => { + Logger.error(`[oldDbFiles] Failed to zip old db folders`, err) + resolve(false) + }) + + // pipe archive data to the file + archive.pipe(output) + + for (const db in dbs) { + archive.directory(dbs[db], db) + } + + // finalize the archive (ie we are done appending files but streams have to finish yet) + // 'close', 'end' or 'finish' may be fired right after calling this method so register to them beforehand + archive.finalize() + }) +} + +module.exports.checkHasOldDb = async () => { + const dbs = { + libraryItems: Path.join(global.ConfigPath, 'libraryItems'), + users: Path.join(global.ConfigPath, 'users'), + sessions: Path.join(global.ConfigPath, 'sessions'), + libraries: Path.join(global.ConfigPath, 'libraries'), + settings: Path.join(global.ConfigPath, 'settings'), + collections: Path.join(global.ConfigPath, 'collections'), + playlists: Path.join(global.ConfigPath, 'playlists'), + authors: Path.join(global.ConfigPath, 'authors'), + series: Path.join(global.ConfigPath, 'series'), + feeds: Path.join(global.ConfigPath, 'feeds') + } + for (const db in dbs) { + if (await fs.pathExists(dbs[db])) { + return true + } + } + return false +} + +module.exports.checkHasOldDbZip = async () => { + const oldDbPath = Path.join(global.ConfigPath, 'oldDb.zip') + if (!await fs.pathExists(oldDbPath)) { + return false + } + + // Extract oldDb.zip + const zip = new StreamZip.async({ file: oldDbPath }) + await zip.extract(null, global.ConfigPath) + + return this.checkHasOldDb() +} \ No newline at end of file