diff --git a/Dockerfile b/Dockerfile index c2a077c..6de118d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ RUN npm ci --only=production && npm cache clean --force FROM node:current-alpine -RUN apk add pipx ffmpeg git +RUN apk add pipx ffmpeg git exiftool RUN pipx install git+https://github.com/juanmcasillas/gopro2gpx RUN pipx install git+https://github.com/tjhorner/mapillary_tools RUN pipx install apprise diff --git a/backend/package-lock.json b/backend/package-lock.json index b5a25a1..90a5024 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,7 +11,6 @@ "dependencies": { "@bull-board/express": "^5.21.5", "@bull-board/nestjs": "^5.21.5", - "@hapi/accept": "^6.0.3", "@nestjs/bullmq": "^10.2.1", "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", @@ -24,6 +23,7 @@ "chokidar": "^3.6.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "exiftool-vendored": "^35.15.1", "fast-xml-parser": "^4.5.0", "immer": "^10.1.1", "pg": "^8.12.0", @@ -241,6 +241,7 @@ "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", @@ -840,6 +841,7 @@ "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-5.21.5.tgz", "integrity": "sha512-EhAM+VtA9vnIivLQaAJpJ8jhEErtKndL+Ka8hPmRnEM9HLDXpdDcA+CdEtfE3tYIT2Zex/WmtxiINkOd6JxzZQ==", "license": "MIT", + "peer": true, "dependencies": { "redis-info": "^3.0.8" }, @@ -852,6 +854,7 @@ "resolved": "https://registry.npmjs.org/@bull-board/express/-/express-5.21.5.tgz", "integrity": "sha512-4kiF34BJh7awoBvf3v370N+1Croa8zGdwJsA3Tb5HZADfWdWL/90/f+df8m5PfhD0vAOOCYmUWh1u5wtjTmcMg==", "license": "MIT", + "peer": true, "dependencies": { "@bull-board/api": "5.21.5", "@bull-board/ui": "5.21.5", @@ -881,6 +884,7 @@ "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-5.21.5.tgz", "integrity": "sha512-b5TGE/8jhyFEWyNPivhNG0qNZE4mdNbnVSwaKkTCcnSXPSxqjdGoTSAFkugJ3+I8sFka+Uc3nh/suWbRuryQkA==", "license": "MIT", + "peer": true, "dependencies": { "@bull-board/api": "5.21.5" } @@ -1028,31 +1032,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@hapi/accept": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@hapi/accept/-/accept-6.0.3.tgz", - "integrity": "sha512-p72f9k56EuF0n3MwlBNThyVE5PXX40g+aQh+C/xbKrfzahM2Oispv3AXmOIU51t3j77zay1qrX7IIziZXspMlw==", - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/boom": "^10.0.1", - "@hapi/hoek": "^11.0.2" - } - }, - "node_modules/@hapi/boom": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz", - "integrity": "sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==", - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^11.0.2" - } - }, - "node_modules/@hapi/hoek": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.4.tgz", - "integrity": "sha512-PnsP5d4q7289pS2T2EgGz147BFJ2Jpb4yrEdkpz2IhgEUzos1S7HTl7ezWh1yfYzYlj89KzLdCRkqsP6SIryeQ==", - "license": "BSD-3-Clause" - }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -1935,6 +1914,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.1.tgz", "integrity": "sha512-4CkrDx0s4XuWqFjX8WvOFV7Y6RGJd0P2OBblkhZS7nwoctoSuW5pyEa8SWak6YHNGrHRpFb6ymm5Ai4LncwRVA==", "license": "MIT", + "peer": true, "dependencies": { "iterare": "1.2.1", "tslib": "2.6.3", @@ -1965,6 +1945,7 @@ "integrity": "sha512-9I1WdfOBCCHdUm+ClBJupOuZQS6UxzIWHIq6Vp1brAA5ZKl/Wq6BVwSsbnUJGBy3J3PM2XHmR0EQ4fwX3nR7lA==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -2035,6 +2016,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.1.tgz", "integrity": "sha512-ccfqIDAq/bg1ShLI5KGtaLaYGykuAdvCi57ohewH7eKJSIpWY1DQjbgKlFfXokALYUq1YOMGqjeZ244OWHfDQg==", "license": "MIT", + "peer": true, "dependencies": { "body-parser": "1.20.2", "cors": "2.8.5", @@ -2208,6 +2190,12 @@ "npm": ">=5.0.0" } }, + "node_modules/@photostructure/tz-lookup": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-11.5.0.tgz", + "integrity": "sha512-0DVFriinZ7TeOnm9ytXeSL3NMFU87ZqMjgbPNkd8LgHFLcPg1BDyM1eewFYs+pPM+62S4fSP9Mtgijmn+6y95w==", + "license": "CC0-1.0" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2485,6 +2473,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", + "license": "MIT" + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -2505,6 +2499,7 @@ "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.19.2" } @@ -2640,6 +2635,7 @@ "integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.5.0", "@typescript-eslint/types": "8.5.0", @@ -2991,6 +2987,7 @@ "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3373,6 +3370,15 @@ ], "license": "MIT" }, + "node_modules/batch-cluster": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-17.3.1.tgz", + "integrity": "sha512-/aWEgZKXgvEseV3WEIRyjDoFka9FTrpt5+FYCxn+giUgveGBKxWjz3cl26V3aD+1kvOBP3nmANZZfcXDmKzcAA==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3492,6 +3498,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001646", "electron-to-chromium": "^1.5.4", @@ -3564,6 +3571,7 @@ "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.13.0.tgz", "integrity": "sha512-rE7v3jMZZGsEhfMhLZwADwuHdqJPTTGHBM8C+SpxF9GzyZ+7pvC80EP5bOZJPPRzbmyhvIPJCVd0bchUZiQF+w==", "license": "MIT", + "peer": true, "dependencies": { "cron-parser": "^4.6.0", "ioredis": "^5.4.1", @@ -3748,13 +3756,15 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/class-validator": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.1.tgz", "integrity": "sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/validator": "^13.11.8", "libphonenumber-js": "^1.10.53", @@ -4550,6 +4560,7 @@ "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4606,6 +4617,7 @@ "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -4869,6 +4881,49 @@ "dev": true, "license": "ISC" }, + "node_modules/exiftool-vendored": { + "version": "35.15.1", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-35.15.1.tgz", + "integrity": "sha512-ox+pcW9m52MGeXMMuZjbdaKgeha9WmWPE7HhVw6GNZ607a9Hx2HyiAUDQm+XdAzv6Y34sahLReCeJRmS9F70Ww==", + "license": "MIT", + "dependencies": { + "@photostructure/tz-lookup": "^11.5.0", + "@types/luxon": "^3.7.1", + "batch-cluster": "^17.3.1", + "he": "^1.2.0", + "luxon": "^3.7.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "optionalDependencies": { + "exiftool-vendored.exe": "13.53.0", + "exiftool-vendored.pl": "13.53.0" + } + }, + "node_modules/exiftool-vendored.exe": { + "version": "13.53.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-13.53.0.tgz", + "integrity": "sha512-CX8w1iVDOdt6iitqoOmUCWLYVmfBVmd59htXGpns/+CItu8LBAT9qVHdBP+Jl0abZyCcDrZf0eaLsfXb9mZOcQ==", + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/exiftool-vendored.pl": { + "version": "13.53.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-13.53.0.tgz", + "integrity": "sha512-D/3yJymCPeMQPtQA9Q8ou/+vvEeQcTjrNt2jT7GS2A9tE0s0NiMNVc62HaKdwm5reQXQRbPrnp56sNxWpNCHKA==", + "license": "MIT", + "optional": true, + "os": [ + "!win32" + ], + "bin": { + "exiftool": "bin/exiftool" + } + }, "node_modules/exit": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", @@ -5591,6 +5646,15 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/hexoid": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", @@ -6113,6 +6177,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -7052,9 +7117,9 @@ } }, "node_modules/luxon": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", - "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", "license": "MIT", "engines": { "node": ">=12" @@ -7767,6 +7832,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.6.4", "pg-pool": "^3.6.2", @@ -8015,6 +8081,7 @@ "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -8282,7 +8349,8 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/repeat-string": { "version": "1.6.1", @@ -8508,6 +8576,7 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -8563,6 +8632,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -9478,6 +9548,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -9622,6 +9693,7 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.20.tgz", "integrity": "sha512-sJ0T08dV5eoZroaq9uPKBoNcGslHBR4E4y+EBHs//SiGbblGe7IeduP/IH4ddCcj0qp3PHwDwGnuvqEAnKlq/Q==", "license": "MIT", + "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "app-root-path": "^3.1.0", @@ -9768,6 +9840,7 @@ "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9969,6 +10042,7 @@ "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", diff --git a/backend/package.json b/backend/package.json index 810c987..2af81f8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -34,6 +34,7 @@ "chokidar": "^3.6.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "exiftool-vendored": "^35.15.1", "fast-xml-parser": "^4.5.0", "immer": "^10.1.1", "pg": "^8.12.0", diff --git a/backend/src/track-watcher.service.ts b/backend/src/track-watcher.service.ts index ad4ada1..f5f24cf 100644 --- a/backend/src/track-watcher.service.ts +++ b/backend/src/track-watcher.service.ts @@ -15,19 +15,19 @@ export class TrackWatcherService { @OnEvent("file.added") async onFileAdded(filePath: string) { - if (!filePath.endsWith(".360")) { - return - } - - this.logger.log(`New file detected; import queued for ${filePath}`) + this.logger.log(`New file detected; considering ${filePath}`) + const path_lower = filePath.toLowerCase() + if (path_lower.endsWith(".360") || path_lower.endsWith('.jpg')) { + this.logger.log(`New file detected; import queued for ${filePath}`) - const fileName = path.basename(filePath) - this.notificationsService.sendNotification( - "Import Queued", - `New file detected; import queued for ${fileName}`, - "info", - ) + const fileName = path.basename(filePath) + this.notificationsService.sendNotification( + "Import Queued", + `New file detected; import queued for ${fileName}`, + "info", + ) - await this.tracksService.startImport(filePath) + await this.tracksService.startImport(filePath) + } } } diff --git a/backend/src/tracks/queues.constants.ts b/backend/src/tracks/queues.constants.ts index ab08eb4..d63e715 100644 --- a/backend/src/tracks/queues.constants.ts +++ b/backend/src/tracks/queues.constants.ts @@ -1,2 +1,3 @@ export const TRACK_IMPORT_QUEUE = "track-import" +export const VIDEO_IMPORT_QUEUE = "video-import" export const IMAGE_IMPORT_QUEUE = "image-import" diff --git a/backend/src/tracks/track-images/import/image-import.processor.ts b/backend/src/tracks/track-images/import/image-import.processor.ts index cf4da92..b9ed515 100644 --- a/backend/src/tracks/track-images/import/image-import.processor.ts +++ b/backend/src/tracks/track-images/import/image-import.processor.ts @@ -1,93 +1,287 @@ -import { Processor, WorkerHost } from "@nestjs/bullmq" -import { Job } from "bullmq" -import { runCmd } from "src/util/run-command" -import { MapillaryImageDescription } from "./mapillary-metadata" -import path from "path" -import * as fs from "fs/promises" +import { OnWorkerEvent, Processor, WorkerHost } from "@nestjs/bullmq" import { TracksService } from "../../tracks.service" -import { TrackImage } from "../track-image.entity" -import { Inject, forwardRef } from "@nestjs/common" +import { Job, UnrecoverableError } from "bullmq" +import { Feature, LineString } from "typeorm" +import { createReadStream } from "fs" +import * as crypto from "crypto" +import * as fs from "fs/promises" +import * as path from "path" +import { forwardRef, Inject } from "@nestjs/common" import { EventEmitter2 } from "@nestjs/event-emitter" +import { runCmd } from "src/util/run-command" import { IMAGE_IMPORT_QUEUE } from "src/tracks/queues.constants" +import { Track } from "src/tracks/track.entity" +import { TrackPoint, TrackSegment, Track as GPXTrack, GPXFile } from "src/vendor/gpx" +import { smoothTrackSegment } from "src/tracks/import/gpx-smooth" +import { parseGPX, ramerDouglasPeucker } from "src/vendor/gpx" +import { ExifTool, ExifDateTime } from "exiftool-vendored"; + export interface ImageImportPayload { - trackId: number + filePath: string + force?: boolean +} + +export interface GpxPoint { + lat: number + lon: number + time: Date } @Processor(IMAGE_IMPORT_QUEUE) export class ImageImportProcessor extends WorkerHost { + exiftool: ExifTool; constructor( @Inject(forwardRef(() => TracksService)) private readonly tracksService: TracksService, private readonly eventEmitter: EventEmitter2, ) { super() + this.exiftool = new ExifTool(); + process.on("SIGINT", () => this.exiftool.end()); + process.on("SIGTERM", () => this.exiftool.end()); + } + + @OnWorkerEvent("failed") + onFailure(job: Job, error: Error) { + this.eventEmitter.emit("image.importFailure", { + filePath: job.data.filePath, + error: error.message, + }) } async process(job: Job): Promise { - const trackId = job.data.trackId - const track = await this.tracksService.get(trackId) - const videoPath = track.filePath + console.log(`Processing for ${job.data.filePath}`) + await job.updateProgress({ + status: "Obtaining file hash", + }) - const outDir = path.join( - path.dirname(videoPath), - path.basename(videoPath, path.extname(videoPath)), - ) + const hash = await this.getFileHash(job.data.filePath) - const imageDescriptions = await this.extractFrames(videoPath, outDir) - const images = imageDescriptions.map( - (imageDescription, index): Partial => ({ - sequenceNumber: index, - captureDate: this.parseMapillaryDate(imageDescription.MAPCaptureTime), - filePath: imageDescription.filename, - location: { - type: "Point", - coordinates: [ - imageDescription.MAPLongitude, - imageDescription.MAPLatitude, - ], - }, - heading: imageDescription.MAPCompassHeading?.TrueHeading, - track, - }), - ) + if (!job.data.force) { + const alreadyExists = await this.tracksService.existsByFileHash(hash) + if (alreadyExists) { + throw new UnrecoverableError("File has already been processed") + } + } + + const parts = path.basename(job.data.filePath).split('.') + const date = parts[1] + const hash_path = parts[1] + '_' + parts[3].substring(0, 4) + const index = parseInt(parts[3].substring(4)) - await this.tracksService.createImages(images) + var track = await this.tracksService.getByPath(hash_path) + if (!track) { + const empty_array: LineString = { + type: "LineString", + coordinates: [], + } + track = await this.tracksService.create({ + name: hash_path, + captureDate: date, + filePath: hash_path, + fileHash: hash, + geometry: empty_array, + }) + } - this.eventEmitter.emit("track.imagesImported", { + await job.updateProgress({ + status: "Extracting GPX data", + }) + + const gpxPoint: GpxPoint = await this.convertToGpx(job.data.filePath, job) + + this.tracksService.createImages([{ + sequenceNumber: index, + captureDate: gpxPoint.time, + filePath: job.data.filePath, + location: { + type: "Point", + coordinates: [ + gpxPoint.lon, + gpxPoint.lat, + ], + }, + heading: 0, + track, + }]) + + await job.updateProgress({ + status: "Finalizing import", + }) + + const gpxFeature: Feature = await this.processGpxData(track) + + await this.tracksService.upsert({ id: track.id, name: track.name, - imageCount: images.length, + captureDate: gpxPoint.time, + filePath: track.filePath, + fileHash: hash, + geometry: gpxFeature.geometry as LineString, }) + + // this.eventEmitter.emit("image.imported", { + // id: track.id, + // name: track.name, + // }) + + return { + id: job.data.filePath, + } + } + + private parseGPXDate(date: string): Date { + return new Date(date) + } + + private toNumber(string_or_number: string | number): number { + return Number(string_or_number) } - private parseMapillaryDate(date: string): Date { - const iso = date.replace( - /(\d{4})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d{3})/, - "$1-$2-$3T$4:$5:$6.$7", + private toDate(string_or_exif_date_time: string | ExifDateTime): Date { + const gps_date = ExifDateTime.from(string_or_exif_date_time) + return new Date(gps_date.toISOString()) + } + + private async convertToGpx(filePath: string, job: Job): Promise { + const tags = await this.exiftool.read(filePath) + + const d: GpxPoint = { 'lat': this.toNumber(tags.GPSLatitude), 'lon': this.toNumber(tags.GPSLongitude), 'time': this.toDate(tags.GPSDateTime) } + + if (d['lat'] === null || d['lon'] === null || d['time'] === null) { + throw new Error(`Could not convert to GPX`) + } + + return d + } + + private async processGpxData(track: Track): Promise { + + const track_images = await this.tracksService.getImages(track.id) + const allPoints: number[][] = track_images.map((image) => ( + image.location.coordinates + )) + + if (allPoints.length > 1) { + const headings: number[] = await this.computeHeadings(allPoints) + const track_images_to_update = [] + for (let i = 0; i < headings.length; i++) { + // Decimals get returned from DB as strings, so cast to Number + const heading = Number(track_images[i].heading) + if (heading !== headings[i]) { + console.log(`Updating heading for image ${track_images[i].sequenceNumber} from ${heading} to ${headings[i]}`) + track_images[i].heading = headings[i] + track_images_to_update.push(track_images[i]) + } + } + if (track_images_to_update.length > 0) { + await Promise.all(track_images_to_update.map(async (image) => { + await this.tracksService.upsertImage(image) + })) + } + } + const trackpoints = allPoints.map((point, index): TrackPoint => (new TrackPoint( + { + attributes: {lon: point[0], lat: point[1]}, + time: track_images[index].captureDate, + } + ))) + const segment = new TrackSegment({trkpt: trackpoints}) + //const gpx_track = new GPXTrack({trkseg: [segment]}) + //const gpx_file = new GPXFile({trk: [gpx_track], rte: [], wpt: [], attributes: null, metadata: null}) + + const smoothedSegment = smoothTrackSegment(segment) + + const simplifiedPoints = ramerDouglasPeucker(smoothedSegment.trkpt, 1) + const points = simplifiedPoints.filter( + (point) => point.distance === undefined || point.distance >= 1, ) - return new Date(iso) + var finalPoints: number[][] = points.map((point) => [ + point.point.getLongitude(), + point.point.getLatitude(), + ] + ) + if (finalPoints.length < 5) { + console.log("Cleaned GPX yielded insignificant data, reverting to original") + finalPoints = allPoints + } + + console.log(`Processed ${allPoints.length} points for track ${track.id}`) + + return { + type: "Feature", + properties: {}, + geometry: { + type: "LineString", + coordinates: finalPoints, + }, + } } - private async extractFrames( - videoPath: string, - outDir: string, - ): Promise { - await runCmd("mapillary_tools", [ - "video_process", - "--video_sample_distance", - "10", - videoPath, - outDir, + private async computeHeadings(points: number[][]): Promise { + const headings: number[] = [] + for (let i = 0; i < points.length - 1; i++) { + const [lon1, lat1] = points[i] + const [lon2, lat2] = points[i + 1] + + const heading = this.calculateBearing(lat1, lon1, lat2, lon2) + headings.push(heading) + } + headings.push(headings[headings.length - 1]) // Repeat last heading + return headings + } + + private calculateBearing(lat1: number, lon1: number, lat2: number, lon2: number): number { + const toRadians = (deg: number) => (deg * Math.PI) / 180 + const toDegrees = (rad: number) => (rad * 180) / Math.PI + + const dLon = toRadians(lon2 - lon1) + const y = Math.sin(dLon) * Math.cos(toRadians(lat2)) + const x = + Math.cos(toRadians(lat1)) * Math.sin(toRadians(lat2)) - + Math.sin(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.cos(dLon) + let bearing = toDegrees(Math.atan2(y, x)) + bearing = (bearing + 360) % 360 // Normalize to 0-360 + return bearing + } + + private async getCaptureDate(filePath: string): Promise { + const { stdout } = await runCmd("ffprobe", [ + "-v", + "quiet", + "-print_format", + "json", + "-show_format", + filePath, ]) - const imageDescriptionPath = path.join( - outDir, - "mapillary_image_description.json", - ) + const data = JSON.parse(stdout) + if (data.format?.tags?.creation_time) { + const creationTime = data.format.tags.creation_time as string + return new Date(creationTime.replace("Z", "")) + } + + const { birthtime } = await fs.stat(filePath) + return birthtime + } + + private getFileHash(filePath: string): Promise { + const fd = createReadStream(filePath) + const hash = crypto.createHash("sha256") + hash.setEncoding("hex") + + const promise = new Promise((resolve, reject) => { + fd.once("end", () => { + hash.end() + resolve(hash.read()) + }) + + fd.once("error", reject) + }) - const imageDescriptions = await fs.readFile(imageDescriptionPath, "utf-8") - return JSON.parse(imageDescriptions) + fd.pipe(hash) + return promise } } diff --git a/backend/src/tracks/track-images/import/video-import.processor.ts b/backend/src/tracks/track-images/import/video-import.processor.ts new file mode 100644 index 0000000..7f6b777 --- /dev/null +++ b/backend/src/tracks/track-images/import/video-import.processor.ts @@ -0,0 +1,93 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq" +import { Job } from "bullmq" +import { runCmd } from "src/util/run-command" +import { MapillaryImageDescription } from "./mapillary-metadata" +import path from "path" +import * as fs from "fs/promises" +import { TracksService } from "../../tracks.service" +import { TrackImage } from "../track-image.entity" +import { Inject, forwardRef } from "@nestjs/common" +import { EventEmitter2 } from "@nestjs/event-emitter" +import { VIDEO_IMPORT_QUEUE } from "src/tracks/queues.constants" + +export interface VideoImportPayload { + trackId: number +} + +@Processor(VIDEO_IMPORT_QUEUE) +export class VideoImportProcessor extends WorkerHost { + constructor( + @Inject(forwardRef(() => TracksService)) + private readonly tracksService: TracksService, + private readonly eventEmitter: EventEmitter2, + ) { + super() + } + + async process(job: Job): Promise { + const trackId = job.data.trackId + const track = await this.tracksService.get(trackId) + const videoPath = track.filePath + + const outDir = path.join( + path.dirname(videoPath), + path.basename(videoPath, path.extname(videoPath)), + ) + + const imageDescriptions = await this.extractFrames(videoPath, outDir) + const images = imageDescriptions.map( + (imageDescription, index): Partial => ({ + sequenceNumber: index, + captureDate: this.parseMapillaryDate(imageDescription.MAPCaptureTime), + filePath: imageDescription.filename, + location: { + type: "Point", + coordinates: [ + imageDescription.MAPLongitude, + imageDescription.MAPLatitude, + ], + }, + heading: imageDescription.MAPCompassHeading?.TrueHeading, + track, + }), + ) + + await this.tracksService.createImages(images) + + this.eventEmitter.emit("track.imagesImported", { + id: track.id, + name: track.name, + imageCount: images.length, + }) + } + + private parseMapillaryDate(date: string): Date { + const iso = date.replace( + /(\d{4})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d{3})/, + "$1-$2-$3T$4:$5:$6.$7", + ) + + return new Date(iso) + } + + private async extractFrames( + videoPath: string, + outDir: string, + ): Promise { + await runCmd("mapillary_tools", [ + "video_process", + "--video_sample_distance", + "10", + videoPath, + outDir, + ]) + + const imageDescriptionPath = path.join( + outDir, + "mapillary_image_description.json", + ) + + const imageDescriptions = await fs.readFile(imageDescriptionPath, "utf-8") + return JSON.parse(imageDescriptions) + } +} diff --git a/backend/src/tracks/tracks.module.ts b/backend/src/tracks/tracks.module.ts index 531639c..2baaaa7 100644 --- a/backend/src/tracks/tracks.module.ts +++ b/backend/src/tracks/tracks.module.ts @@ -6,11 +6,13 @@ import { TypeOrmModule } from "@nestjs/typeorm" import { TrackImportProcessor } from "./import/track-import.processor" import { ImagesController } from "./track-images/images.controller" import { ImageImportProcessor } from "./track-images/import/image-import.processor" +import { VideoImportProcessor } from "./track-images/import/video-import.processor" import { TrackImage } from "./track-images/track-image.entity" import { Track } from "./track.entity" import { TracksController } from "./tracks.controller" import { TracksService } from "./tracks.service" -import { IMAGE_IMPORT_QUEUE, TRACK_IMPORT_QUEUE } from "./queues.constants" +import { IMAGE_IMPORT_QUEUE, VIDEO_IMPORT_QUEUE, TRACK_IMPORT_QUEUE } from "./queues.constants" + @Module({ imports: [ @@ -20,8 +22,11 @@ import { IMAGE_IMPORT_QUEUE, TRACK_IMPORT_QUEUE } from "./queues.constants" name: TRACK_IMPORT_QUEUE, }, { - name: IMAGE_IMPORT_QUEUE, + name: VIDEO_IMPORT_QUEUE, }, + { + name: IMAGE_IMPORT_QUEUE, + } ), BullBoardModule.forFeature( { @@ -29,12 +34,16 @@ import { IMAGE_IMPORT_QUEUE, TRACK_IMPORT_QUEUE } from "./queues.constants" adapter: BullMQAdapter, }, { - name: IMAGE_IMPORT_QUEUE, + name: VIDEO_IMPORT_QUEUE, adapter: BullMQAdapter, }, + { + name: IMAGE_IMPORT_QUEUE, + adapter: BullMQAdapter, + } ), ], - providers: [TracksService, TrackImportProcessor, ImageImportProcessor], + providers: [TracksService, TrackImportProcessor, VideoImportProcessor, ImageImportProcessor], controllers: [TracksController, ImagesController], exports: [TracksService], }) diff --git a/backend/src/tracks/tracks.service.ts b/backend/src/tracks/tracks.service.ts index 2a98596..20996af 100644 --- a/backend/src/tracks/tracks.service.ts +++ b/backend/src/tracks/tracks.service.ts @@ -10,9 +10,10 @@ import * as path from "node:path" import { DeepPartial, Repository } from "typeorm" import { TrackImportPayload } from "./import/track-import.processor" import { ImageImportPayload } from "./track-images/import/image-import.processor" +import { VideoImportPayload } from "./track-images/import/video-import.processor" import { TrackImage } from "./track-images/track-image.entity" import { Track } from "./track.entity" -import { IMAGE_IMPORT_QUEUE, TRACK_IMPORT_QUEUE } from "./queues.constants" +import { IMAGE_IMPORT_QUEUE, TRACK_IMPORT_QUEUE, VIDEO_IMPORT_QUEUE } from "./queues.constants" export interface TrackFilters { start?: Date @@ -33,6 +34,9 @@ export class TracksService { @InjectQueue(TRACK_IMPORT_QUEUE) private trackImportQueue: Queue, + @InjectQueue(VIDEO_IMPORT_QUEUE) + private videoImportQueue: Queue, + @InjectQueue(IMAGE_IMPORT_QUEUE) private imageImportQueue: Queue, ) {} @@ -87,6 +91,12 @@ export class TracksService { return this.tracksRepository.findOneBy({ id }) } + async getByPath(filePath: string): Promise { + return this.tracksRepository.findOne({ + where: { filePath: filePath } + }) + } + async create(track: DeepPartial): Promise { return this.tracksRepository.save(track) } @@ -112,6 +122,23 @@ export class TracksService { return this.trackImagesRepository.save(images) } + async upsertImage(image: DeepPartial): Promise { + const existing = await this.trackImagesRepository.findOne({ + where: { + id: image.id, + } + }) + + if (existing) { + return this.trackImagesRepository.save({ + ...existing, + ...image, + }) + } + + return this.trackImagesRepository.save(image) + } + async upsert(track: DeepPartial) { const existing = await this.tracksRepository.findOne({ where: { filePath: track.filePath }, @@ -138,10 +165,16 @@ export class TracksService { throw new Error(`File not found: ${filePath}`) } - return this.trackImportQueue.add(path.basename(filePath), { - filePath, - force, - }) + if (filePath.endsWith('.360')) { + return this.trackImportQueue.add(path.basename(filePath), { + filePath, + force, + }) + } else { + return this.imageImportQueue.add(path.basename(filePath), { + filePath, force + }) + } } async reprocessAll() { @@ -156,11 +189,11 @@ export class TracksService { @OnEvent("track.imported") handleTrackImported({ id }: { id: number; name: string }) { - this.imageImportQueue.add(id.toString(), { trackId: id }) + this.videoImportQueue.add(id.toString(), { trackId: id }) } - async startImageImport(trackId: number) { - return this.imageImportQueue.add(trackId.toString(), { trackId }) + async startVideoImport(trackId: number) { + return this.videoImportQueue.add(trackId.toString(), { trackId }) } async processMissingImages() { @@ -176,7 +209,7 @@ export class TracksService { data: { trackId: track.id }, })) - return this.imageImportQueue.addBulk(jobs) + return this.videoImportQueue.addBulk(jobs) } toGeoJSON(tracks: { toGeoJSON(): T }[]) { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e1a5097..d052ee0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -544,6 +544,7 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz", "integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==", "license": "MIT", + "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" }, @@ -1072,6 +1073,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", @@ -1104,6 +1106,7 @@ "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", "debug": "^4.3.4", @@ -1420,6 +1423,7 @@ "integrity": "sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.19.2" } @@ -1686,6 +1690,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001646", "electron-to-chromium": "^1.5.4", @@ -2890,21 +2895,6 @@ "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", "license": "ISC" }, - "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", @@ -2952,6 +2942,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.0", @@ -3582,6 +3573,7 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.19.tgz", "integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==", "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", @@ -3725,6 +3717,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.12.tgz", "integrity": "sha512-Htf/gHj2+soPb9UayUNci/Ja3d8pTmu9ONTfh4QY8r3MATTZOzmv6UYWF7ZwikEIC8okpfqmGqrmDehua8mF8w==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -3860,6 +3853,7 @@ "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3948,6 +3942,7 @@ "integrity": "sha512-pXqR0qtb2bTwLkev4SE3r4abCNioP3GkjvIDLlzziPpXtHgiJIjuKl+1GN6ESOT3wMjG3JTeARopj2SwYaHTOA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/package-lock.json b/package-lock.json index e1af592..143cb74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "chokidar": "^3.6.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "exiftool-vendored": "^35.15.1", "fast-xml-parser": "^4.5.0", "immer": "^10.1.1", "pg": "^8.12.0", @@ -225,6 +226,7 @@ "version": "7.25.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", @@ -736,6 +738,7 @@ "backend/node_modules/@bull-board/api": { "version": "5.21.5", "license": "MIT", + "peer": true, "dependencies": { "redis-info": "^3.0.8" }, @@ -746,6 +749,7 @@ "backend/node_modules/@bull-board/express": { "version": "5.21.5", "license": "MIT", + "peer": true, "dependencies": { "@bull-board/api": "5.21.5", "@bull-board/ui": "5.21.5", @@ -771,6 +775,7 @@ "backend/node_modules/@bull-board/ui": { "version": "5.21.5", "license": "MIT", + "peer": true, "dependencies": { "@bull-board/api": "5.21.5" } @@ -1508,15 +1513,15 @@ "version": "0.15.0", "license": "MIT" }, - "backend/node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "backend/node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { "version": "3.0.3", "cpu": [ - "arm64" + "x64" ], "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ] }, "backend/node_modules/@nestjs/bull-shared": { @@ -1602,6 +1607,7 @@ "backend/node_modules/@nestjs/common": { "version": "10.4.1", "license": "MIT", + "peer": true, "dependencies": { "iterare": "1.2.1", "tslib": "2.6.3", @@ -1630,6 +1636,7 @@ "version": "10.4.1", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -1694,6 +1701,7 @@ "backend/node_modules/@nestjs/platform-express": { "version": "10.4.1", "license": "MIT", + "peer": true, "dependencies": { "body-parser": "1.20.2", "cors": "2.8.5", @@ -1849,6 +1857,10 @@ "npm": ">=5.0.0" } }, + "backend/node_modules/@photostructure/tz-lookup": { + "version": "11.5.0", + "license": "CC0-1.0" + }, "backend/node_modules/@pkgjs/parseargs": { "version": "0.11.0", "license": "MIT", @@ -2066,6 +2078,10 @@ "dev": true, "license": "MIT" }, + "backend/node_modules/@types/luxon": { + "version": "3.7.1", + "license": "MIT" + }, "backend/node_modules/@types/methods": { "version": "1.1.4", "dev": true, @@ -2080,6 +2096,7 @@ "version": "20.16.5", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.19.2" } @@ -2191,6 +2208,7 @@ "version": "8.5.0", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.5.0", "@typescript-eslint/types": "8.5.0", @@ -2490,6 +2508,7 @@ "version": "8.12.1", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2790,6 +2809,13 @@ ], "license": "MIT" }, + "backend/node_modules/batch-cluster": { + "version": "17.3.1", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "backend/node_modules/binary-extensions": { "version": "2.3.0", "license": "MIT", @@ -2891,6 +2917,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001646", "electron-to-chromium": "^1.5.4", @@ -2953,6 +2980,7 @@ "backend/node_modules/bullmq": { "version": "5.13.0", "license": "MIT", + "peer": true, "dependencies": { "cron-parser": "^4.6.0", "ioredis": "^5.4.1", @@ -3095,11 +3123,13 @@ }, "backend/node_modules/class-transformer": { "version": "0.5.1", - "license": "MIT" + "license": "MIT", + "peer": true }, "backend/node_modules/class-validator": { "version": "0.14.1", "license": "MIT", + "peer": true, "dependencies": { "@types/validator": "^13.11.8", "libphonenumber-js": "^1.10.53", @@ -3701,6 +3731,7 @@ "version": "8.57.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3755,6 +3786,7 @@ "version": "9.1.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -3980,6 +4012,35 @@ "dev": true, "license": "ISC" }, + "backend/node_modules/exiftool-vendored": { + "version": "35.15.1", + "license": "MIT", + "dependencies": { + "@photostructure/tz-lookup": "^11.5.0", + "@types/luxon": "^3.7.1", + "batch-cluster": "^17.3.1", + "he": "^1.2.0", + "luxon": "^3.7.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "optionalDependencies": { + "exiftool-vendored.exe": "13.53.0", + "exiftool-vendored.pl": "13.53.0" + } + }, + "backend/node_modules/exiftool-vendored.pl": { + "version": "13.53.0", + "license": "MIT", + "optional": true, + "os": [ + "!win32" + ], + "bin": { + "exiftool": "bin/exiftool" + } + }, "backend/node_modules/exit": { "version": "0.1.2", "dev": true, @@ -4389,17 +4450,6 @@ "dev": true, "license": "ISC" }, - "backend/node_modules/fsevents": { - "version": "2.3.3", - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "backend/node_modules/function-bind": { "version": "1.1.2", "license": "MIT", @@ -4569,6 +4619,13 @@ "node": ">= 0.4" } }, + "backend/node_modules/he": { + "version": "1.2.0", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "backend/node_modules/hexoid": { "version": "1.0.0", "dev": true, @@ -4997,6 +5054,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -5766,6 +5824,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "backend/node_modules/lodash": { + "version": "4.17.21", + "license": "MIT" + }, "backend/node_modules/lodash.defaults": { "version": "4.2.0", "license": "MIT" @@ -5808,7 +5870,7 @@ } }, "backend/node_modules/luxon": { - "version": "3.5.0", + "version": "3.7.2", "license": "MIT", "engines": { "node": ">=12" @@ -6391,6 +6453,7 @@ "backend/node_modules/pg": { "version": "8.12.0", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.6.4", "pg-pool": "^3.6.2", @@ -6595,6 +6658,7 @@ "version": "3.3.3", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -6818,7 +6882,8 @@ }, "backend/node_modules/reflect-metadata": { "version": "0.2.2", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "backend/node_modules/repeat-string": { "version": "1.6.1", @@ -6996,6 +7061,14 @@ "queue-microtask": "^1.2.2" } }, + "backend/node_modules/rxjs": { + "version": "7.8.1", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, "backend/node_modules/safe-buffer": { "version": "5.2.1", "funding": [ @@ -7039,6 +7112,7 @@ "version": "6.12.6", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -7776,6 +7850,7 @@ "version": "10.9.2", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -7900,6 +7975,7 @@ "backend/node_modules/typeorm": { "version": "0.3.20", "license": "MIT", + "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "app-root-path": "^3.1.0", @@ -8040,6 +8116,7 @@ "version": "5.6.2", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8205,6 +8282,7 @@ "version": "5.94.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", @@ -8489,16 +8567,16 @@ "gl-matrix": "^3.4.3" } }, - "frontend/node_modules/@esbuild/darwin-arm64": { + "frontend/node_modules/@esbuild/linux-x64": { "version": "0.21.5", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { "node": ">=12" @@ -8533,6 +8611,7 @@ "frontend/node_modules/@fortawesome/fontawesome-svg-core": { "version": "6.6.0", "license": "MIT", + "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" }, @@ -8757,16 +8836,28 @@ "dev": true, "license": "MIT" }, - "frontend/node_modules/@rollup/rollup-darwin-arm64": { + "frontend/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.21.3", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "frontend/node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.21.3", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ] }, "frontend/node_modules/@sveltejs/adapter-auto": { @@ -8793,6 +8884,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", @@ -8823,6 +8915,7 @@ "version": "3.1.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", "debug": "^4.3.4", @@ -9091,6 +9184,7 @@ "version": "22.5.5", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.19.2" } @@ -9313,6 +9407,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001646", "electron-to-chromium": "^1.5.4", @@ -9692,17 +9787,6 @@ "url": "https://github.com/sponsors/rawify" } }, - "frontend/node_modules/fsevents": { - "version": "2.3.3", - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "frontend/node_modules/function-bind": { "version": "1.1.2", "license": "MIT", @@ -10263,19 +10347,6 @@ "version": "1.1.0", "license": "ISC" }, - "frontend/node_modules/picomatch": { - "version": "4.0.2", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "frontend/node_modules/pify": { "version": "2.3.0", "license": "MIT", @@ -10315,6 +10386,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.0", @@ -10851,6 +10923,7 @@ "frontend/node_modules/svelte": { "version": "4.2.19", "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", @@ -10976,6 +11049,7 @@ "frontend/node_modules/tailwindcss": { "version": "3.4.12", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -11085,6 +11159,7 @@ "version": "5.6.2", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11159,6 +11234,7 @@ "version": "5.4.5", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -11420,19 +11496,18 @@ "license": "MIT" }, "node_modules/concurrently": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.0.1.tgz", - "integrity": "sha512-wYKvCd/f54sTXJMSfV6Ln/B8UrfLBKOYa+lzc6CHay3Qek+LorVSBdMVfyewFhRbH0Rbabsk4D+3PL/VjQ5gzg==", + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.1.2", - "lodash": "^4.17.21", - "rxjs": "^7.8.1", - "shell-quote": "^1.8.1", - "supports-color": "^8.1.1", - "tree-kill": "^1.2.2", - "yargs": "^17.7.2" + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", @@ -11487,12 +11562,6 @@ "node": ">=8" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -11503,20 +11572,24 @@ } }, "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" } }, "node_modules/shell-quote": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", - "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "dev": true, "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -11582,9 +11655,9 @@ } }, "node_modules/tslib": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", - "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, "node_modules/wrap-ansi": {