diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000000..24e38564f8 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,17 @@ +name: Test +on: + pull_request: + push: { branches: master } + +jobs: + test-server-e2e: + name: Run test suite + + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Run Immich Server 2E2 Test + run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich_server_test diff --git a/README.md b/README.md index 5be02110b9..08485affca 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ iOS Build - Build Status + Build Status @@ -25,7 +25,7 @@ # Immich -Self-hosted photo and video backup solution directly from your mobile phone. +**High performance self-hosted photo and video backup solution.** ![](https://media.giphy.com/media/y8ZeaAigGmNvlSoKhU/giphy.gif) @@ -33,7 +33,7 @@ Loading ~4000 images/videos ## Screenshots -### Mobile client +### Mobile

@@ -44,9 +44,10 @@ Loading ~4000 images/videos

-### Web client -

- +### Web +

+ +

# Note @@ -55,26 +56,22 @@ Loading ~4000 images/videos This project is under heavy development, there will be continuous functions, features and api changes. -# Features +# Features + +| | Mobile | Web | +| - | - | - | +| Upload and view videos and photos | Yes | Yes +| Auto backup when app is opened | Yes | N/A +| Selective album(s) for backup | Yes | N/A +| Download photos and videos to local device | Yes | Yes +| Multi-user support | Yes | Yes +| Shared Albums | Yes | No +| Quick navigation with draggable scrollbar | Yes | Yes +| Support RAW (HEIC, HEIF, DNG, Apple ProRaw) | Yes | Yes +| Metadata view (EXIF, map) | Yes | Yes +| Search by metadata, objects and image tags | Yes | No +| Administrative functions (user management) | No | Yes -- Upload and view assets (videos/images). -- Auto Backup. -- Download asset to local device. -- Multi-user supported. -- Quick navigation with drag scroll bar. -- Support HEIC/HEIF Backup. -- Extract and display EXIF info. -- Real-time render from multi-device upload event. -- Image Tagging/Classification based on ImageNet dataset -- Object detection based on COCO SSD. -- Search assets based on tags and exif data (lens, make, model, orientation) -- [Optional] Reverse geocoding using Mapbox (Generous free-tier of 100,000 search/month) -- Show asset's location information on map (OpenStreetMap). -- Show curated places on the search page -- Show curated objects on the search page -- Shared album with users on the same server -- Selective backup - albums can be included and excluded during the backup process. -- Web interface is available for administrative tasks (creating new users) and viewing assets on the server - additional features are coming. # System Requirement @@ -97,7 +94,7 @@ You can use docker compose for development and testing out the application, ther 3. **PostgreSQL** - Main database of the application 4. **Redis** - For sharing websocket instance between docker instances and background tasks message queue. 5. **Nginx** - Load balancing and optimized file uploading. -6. **TensorFlow** - Object Detection and Image Classification. +6. **TensorFlow** - Object Detection (COCO SSD) and Image Classification (ImageNet). ## Step 1: Populate .env file diff --git a/design/dashboard_photos.jpeg b/design/dashboard_photos.jpeg deleted file mode 100644 index c3f47c12de..0000000000 Binary files a/design/dashboard_photos.jpeg and /dev/null differ diff --git a/design/web-admin.jpeg b/design/web-admin.jpeg new file mode 100644 index 0000000000..d5d819ae31 Binary files /dev/null and b/design/web-admin.jpeg differ diff --git a/design/web-detail.jpeg b/design/web-detail.jpeg new file mode 100644 index 0000000000..e6c7c6d2e8 Binary files /dev/null and b/design/web-detail.jpeg differ diff --git a/design/web-home.jpeg b/design/web-home.jpeg new file mode 100644 index 0000000000..f9d16c175f Binary files /dev/null and b/design/web-home.jpeg differ diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 1b763ed6c4..34635f6971 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -60,6 +60,7 @@ services: - NODE_ENV=development depends_on: - database + - immich-server networks: - immich-network diff --git a/server/apps/immich/src/api-v1/asset/asset.controller.ts b/server/apps/immich/src/api-v1/asset/asset.controller.ts index 75331f6d0a..e247d778d8 100644 --- a/server/apps/immich/src/api-v1/asset/asset.controller.ts +++ b/server/apps/immich/src/api-v1/asset/asset.controller.ts @@ -15,6 +15,7 @@ import { Delete, Logger, Patch, + HttpCode, } from '@nestjs/common'; import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; import { AssetService } from './asset.service'; @@ -76,6 +77,10 @@ export class AssetController { { asset: assetWithThumbnail, fileName: file.originalname, fileSize: file.size, hasThumbnail: true }, { jobId: savedAsset.id }, ); + + this.wsCommunicateionGateway.server + .to(savedAsset.userId) + .emit('on_upload_success', JSON.stringify(assetWithThumbnail)); } else { await this.assetUploadedQueue.add( 'asset-uploaded', @@ -83,8 +88,6 @@ export class AssetController { { jobId: savedAsset.id }, ); } - - this.wsCommunicateionGateway.server.to(savedAsset.userId).emit('on_upload_success', JSON.stringify(savedAsset)); } catch (e) { Logger.error(`Error receiving upload file ${e}`); } @@ -171,4 +174,20 @@ export class AssetController { return result; } + + /** + * Check duplicated asset before uploading - for Web upload used + */ + @Post('/check') + @HttpCode(200) + async checkDuplicateAsset( + @GetAuthUser() authUser: AuthUserDto, + @Body(ValidationPipe) { deviceAssetId }: { deviceAssetId: string }, + ) { + const res = await this.assetService.checkDuplicatedAsset(authUser, deviceAssetId); + + return { + isExist: res, + }; + } } diff --git a/server/apps/immich/src/api-v1/asset/asset.module.ts b/server/apps/immich/src/api-v1/asset/asset.module.ts index bb9e0823e0..b8ef03b08d 100644 --- a/server/apps/immich/src/api-v1/asset/asset.module.ts +++ b/server/apps/immich/src/api-v1/asset/asset.module.ts @@ -24,6 +24,6 @@ import { CommunicationModule } from '../communication/communication.module'; ], controllers: [AssetController], providers: [AssetService, BackgroundTaskService], - exports: [], + exports: [AssetService], }) export class AssetModule {} diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index 3221c64bc5..5e7f761738 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { IsNull, Not, Repository } from 'typeorm'; import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { CreateAssetDto } from './dto/create-asset.dto'; import { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; @@ -72,6 +72,7 @@ export class AssetService { return await this.assetRepository.find({ where: { userId: authUser.id, + resizePath: Not(IsNull()), }, relations: ['exifInfo'], order: { @@ -381,4 +382,15 @@ export class AssetService { [authUser.id], ); } + + async checkDuplicatedAsset(authUser: AuthUserDto, deviceAssetId: string) { + const res = await this.assetRepository.findOne({ + where: { + deviceAssetId, + userId: authUser.id, + }, + }); + + return res ? true : false; + } } diff --git a/server/apps/immich/src/api-v1/communication/communication.gateway.ts b/server/apps/immich/src/api-v1/communication/communication.gateway.ts index ae3d4e7031..a7e06bb023 100644 --- a/server/apps/immich/src/api-v1/communication/communication.gateway.ts +++ b/server/apps/immich/src/api-v1/communication/communication.gateway.ts @@ -6,8 +6,9 @@ import { Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { UserEntity } from '@app/database/entities/user.entity'; import { Repository } from 'typeorm'; +import { query } from 'express'; -@WebSocketGateway() +@WebSocketGateway({ cors: true }) export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisconnect { constructor( private immichJwtService: ImmichJwtService, @@ -21,27 +22,33 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco handleDisconnect(client: Socket) { client.leave(client.nsp.name); - Logger.log(`Client ${client.id} disconnected`); + Logger.log(`Client ${client.id} disconnected from Websocket`, 'WebsocketConnectionEvent'); } async handleConnection(client: Socket, ...args: any[]) { - Logger.log(`New websocket connection: ${client.id}`, 'NewWebSocketConnection'); - const accessToken = client.handshake.headers.authorization.split(' ')[1]; - const res = await this.immichJwtService.validateToken(accessToken); + try { + Logger.log(`New websocket connection: ${client.id}`, 'WebsocketConnectionEvent'); - if (!res.status) { - client.emit('error', 'unauthorized'); - client.disconnect(); - return; + const accessToken = client.handshake.headers.authorization.split(' ')[1]; + + const res = await this.immichJwtService.validateToken(accessToken); + + if (!res.status) { + client.emit('error', 'unauthorized'); + client.disconnect(); + return; + } + + const user = await this.userRepository.findOne({ where: { id: res.userId } }); + if (!user) { + client.emit('error', 'unauthorized'); + client.disconnect(); + return; + } + + client.join(user.id); + } catch (e) { + // Logger.error(`Error establish websocket conneciton ${e}`, 'HandleWebscoketConnection'); } - - const user = await this.userRepository.findOne({ where: { id: res.userId } }); - if (!user) { - client.emit('error', 'unauthorized'); - client.disconnect(); - return; - } - - client.join(user.id); } } diff --git a/server/apps/microservices/src/main.ts b/server/apps/microservices/src/main.ts index efd6fdad7b..b921a9ca76 100644 --- a/server/apps/microservices/src/main.ts +++ b/server/apps/microservices/src/main.ts @@ -1,10 +1,13 @@ import { Logger } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import { RedisIoAdapter } from '../../immich/src/middlewares/redis-io.adapter.middleware'; import { MicroservicesModule } from './microservices.module'; async function bootstrap() { const app = await NestFactory.create(MicroservicesModule); + app.useWebSocketAdapter(new RedisIoAdapter(app)); + await app.listen(3000, () => { if (process.env.NODE_ENV == 'development') { Logger.log('Running Immich Microservices in DEVELOPMENT environment', 'ImmichMicroservice'); diff --git a/server/apps/microservices/src/microservices.module.ts b/server/apps/microservices/src/microservices.module.ts index 92c1ce4e75..b7258fa3f5 100644 --- a/server/apps/microservices/src/microservices.module.ts +++ b/server/apps/microservices/src/microservices.module.ts @@ -11,6 +11,9 @@ import { AssetUploadedProcessor } from './processors/asset-uploaded.processor'; import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor'; import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor'; import { VideoTranscodeProcessor } from './processors/video-transcode.processor'; +import { AssetModule } from '../../immich/src/api-v1/asset/asset.module'; +import { CommunicationGateway } from '../../immich/src/api-v1/communication/communication.gateway'; +import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module'; @Module({ imports: [ @@ -56,6 +59,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor' removeOnFail: false, }, }), + CommunicationModule, ], controllers: [], providers: [ diff --git a/server/apps/microservices/src/processors/asset-uploaded.processor.ts b/server/apps/microservices/src/processors/asset-uploaded.processor.ts index c8345c5ca3..f616b83222 100644 --- a/server/apps/microservices/src/processors/asset-uploaded.processor.ts +++ b/server/apps/microservices/src/processors/asset-uploaded.processor.ts @@ -46,6 +46,7 @@ export class AssetUploadedProcessor { await this.metadataExtractionQueue.add('detect-object', { asset }, { jobId: randomUUID() }); } else { // Generate Thumbnail -> Then generate webp, tag image and detect object + await this.thumbnailGeneratorQueue.add('generate-jpeg-thumbnail', { asset }, { jobId: randomUUID() }); } // Video Conversion @@ -63,5 +64,10 @@ export class AssetUploadedProcessor { { jobId: randomUUID() }, ); } + + // Extract video duration if uploaded from the web + if (asset.type == AssetType.VIDEO && asset.duration == '0:00:00.000000') { + await this.metadataExtractionQueue.add('extract-video-length', { asset }, { jobId: randomUUID() }); + } } } diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index 6e649e4e8a..0a8a205d4b 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts @@ -12,6 +12,8 @@ import { Logger } from '@nestjs/common'; import axios from 'axios'; import { SmartInfoEntity } from '@app/database/entities/smart-info.entity'; import { ConfigService } from '@nestjs/config'; +import ffmpeg from 'fluent-ffmpeg'; +// import moment from 'moment'; @Processor('metadata-extraction-queue') export class MetadataExtractionProcessor { @@ -129,4 +131,27 @@ export class MetadataExtractionProcessor { Logger.error(`Failed to trigger object detection pipe line ${error.toString()}`); } } + + @Process({ name: 'extract-video-length', concurrency: 2 }) + async extractVideoLength(job: Job) { + const { asset }: { asset: AssetEntity } = job.data; + + ffmpeg.ffprobe(asset.originalPath, async (err, data) => { + if (!err) { + if (data.format.duration) { + const videoDurationInSecond = parseInt(data.format.duration.toString(), 0); + + const hours = Math.floor(videoDurationInSecond / 3600); + const minutes = Math.floor((videoDurationInSecond - hours * 3600) / 60); + const seconds = videoDurationInSecond - hours * 3600 - minutes * 60; + + const durationString = `${hours}:${minutes < 10 ? '0' + minutes.toString() : minutes}:${ + seconds < 10 ? '0' + seconds.toString() : seconds + }.000000`; + + await this.assetRepository.update({ id: asset.id }, { duration: durationString }); + } + } + }); + } } diff --git a/server/apps/microservices/src/processors/thumbnail.processor.ts b/server/apps/microservices/src/processors/thumbnail.processor.ts index 3f3919daa5..55cc77c470 100644 --- a/server/apps/microservices/src/processors/thumbnail.processor.ts +++ b/server/apps/microservices/src/processors/thumbnail.processor.ts @@ -1,22 +1,82 @@ -import { Process, Processor } from '@nestjs/bull'; -import { Job } from 'bull'; -import { AssetEntity } from '@app/database/entities/asset.entity'; +import { InjectQueue, Process, Processor } from '@nestjs/bull'; +import { Job, Queue } from 'bull'; +import { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; import { Repository } from 'typeorm/repository/Repository'; import { InjectRepository } from '@nestjs/typeorm'; import sharp from 'sharp'; +import { existsSync, mkdirSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { CommunicationGateway } from '../../../immich/src/api-v1/communication/communication.gateway'; +import ffmpeg from 'fluent-ffmpeg'; +import { Logger } from '@nestjs/common'; @Processor('thumbnail-generator-queue') export class ThumbnailGeneratorProcessor { constructor( @InjectRepository(AssetEntity) private assetRepository: Repository, + + @InjectQueue('thumbnail-generator-queue') + private thumbnailGeneratorQueue: Queue, + + private wsCommunicateionGateway: CommunicationGateway, ) {} @Process('generate-jpeg-thumbnail') async generateJPEGThumbnail(job: Job) { const { asset }: { asset: AssetEntity } = job.data; - console.log(asset); + const resizePath = `upload/${asset.userId}/thumb/${asset.deviceId}/`; + + if (!existsSync(resizePath)) { + mkdirSync(resizePath, { recursive: true }); + } + + const temp = asset.originalPath.split('/'); + const originalFilename = temp[temp.length - 1].split('.')[0]; + const jpegThumbnailPath = resizePath + originalFilename + '.jpeg'; + + if (asset.type == AssetType.IMAGE) { + sharp(asset.originalPath) + .resize(1440, 2560, { fit: 'inside' }) + .jpeg() + .toFile(jpegThumbnailPath, async (err, info) => { + if (!err) { + await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath }); + + // Update resize path to send to generate webp queue + asset.resizePath = jpegThumbnailPath; + + await this.thumbnailGeneratorQueue.add('generate-webp-thumbnail', { asset }, { jobId: randomUUID() }); + this.wsCommunicateionGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(asset)); + } + }); + } + + if (asset.type == AssetType.VIDEO) { + ffmpeg(asset.originalPath) + .outputOptions(['-ss 00:00:01.000', '-frames:v 1']) + .output(jpegThumbnailPath) + .on('start', () => { + Logger.log('Start Generating Video Thumbnail', 'generateJPEGThumbnail'); + }) + .on('error', (error, b, c) => { + Logger.error(`Cannot Generate Video Thumbnail ${error}`, 'generateJPEGThumbnail'); + // reject(); + }) + .on('end', async () => { + Logger.log(`Generating Video Thumbnail Success ${asset.id}`, 'generateJPEGThumbnail'); + await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath }); + + // Update resize path to send to generate webp queue + asset.resizePath = jpegThumbnailPath; + + await this.thumbnailGeneratorQueue.add('generate-webp-thumbnail', { asset }, { jobId: randomUUID() }); + + this.wsCommunicateionGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(asset)); + }) + .run(); + } } @Process({ name: 'generate-webp-thumbnail', concurrency: 2 }) diff --git a/server/apps/microservices/src/processors/video-transcode.processor.ts b/server/apps/microservices/src/processors/video-transcode.processor.ts index c7f8f3b3ba..172dfbb7b2 100644 --- a/server/apps/microservices/src/processors/video-transcode.processor.ts +++ b/server/apps/microservices/src/processors/video-transcode.processor.ts @@ -42,7 +42,7 @@ export class VideoTranscodeProcessor { .outputOptions(['-crf 23', '-preset ultrafast', '-vcodec libx264', '-acodec mp3', '-vf scale=1280:-2']) .output(savedEncodedPath) .on('start', () => { - Logger.log('Start Converting', 'mp4Conversion'); + Logger.log('Start Converting Video', 'mp4Conversion'); }) .on('error', (error, b, c) => { Logger.error(`Cannot Convert Video ${error}`, 'mp4Conversion'); diff --git a/server/package-lock.json b/server/package-lock.json index 663d6183d8..fe03142ab6 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -55,6 +55,7 @@ "@types/bull": "^3.15.7", "@types/cron": "^2.0.0", "@types/express": "^4.17.13", + "@types/fluent-ffmpeg": "^2.1.20", "@types/imagemin": "^8.0.0", "@types/jest": "27.0.2", "@types/lodash": "^4.14.178", @@ -2195,6 +2196,15 @@ "@types/range-parser": "*" } }, + "node_modules/@types/fluent-ffmpeg": { + "version": "2.1.20", + "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.20.tgz", + "integrity": "sha512-B+OvhCdJ3LgEq2PhvWNOiB/EfwnXLElfMCgc4Z1K5zXgSfo9I6uGKwR/lqmNPFQuebNnes7re3gqkV77SyypLg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", @@ -12803,6 +12813,15 @@ "@types/range-parser": "*" } }, + "@types/fluent-ffmpeg": { + "version": "2.1.20", + "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.20.tgz", + "integrity": "sha512-B+OvhCdJ3LgEq2PhvWNOiB/EfwnXLElfMCgc4Z1K5zXgSfo9I6uGKwR/lqmNPFQuebNnes7re3gqkV77SyypLg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/graceful-fs": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", diff --git a/server/package.json b/server/package.json index eb2d5b3ef2..6333be06ee 100644 --- a/server/package.json +++ b/server/package.json @@ -68,6 +68,7 @@ "@types/bull": "^3.15.7", "@types/cron": "^2.0.0", "@types/express": "^4.17.13", + "@types/fluent-ffmpeg": "^2.1.20", "@types/imagemin": "^8.0.0", "@types/jest": "27.0.2", "@types/lodash": "^4.14.178", diff --git a/web/package-lock.json b/web/package-lock.json index 89a4d7d3c2..a6869a5bd9 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,11 +10,12 @@ "dependencies": { "axios": "^0.27.2", "cookie": "^0.4.2", + "exifr": "^7.1.3", "leaflet": "^1.8.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", - "markdown-it": "^13.0.1", "moment": "^2.29.3", + "socket.io-client": "^4.5.1", "svelte-material-icons": "^2.0.2" }, "devDependencies": { @@ -28,7 +29,7 @@ "@types/leaflet": "^1.7.10", "@types/lodash": "^4.14.182", "@types/lodash-es": "^4.17.6", - "@types/markdown-it": "^12.2.3", + "@types/socket.io-client": "^3.0.0", "@typescript-eslint/eslint-plugin": "^5.10.1", "@typescript-eslint/parser": "^5.10.1", "autoprefixer": "^10.4.7", @@ -140,6 +141,11 @@ "node": ">= 8.0.0" } }, + "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/@sveltejs/adapter-auto": { "version": "1.0.0-next.40", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-1.0.0-next.40.tgz", @@ -293,12 +299,6 @@ "@types/geojson": "*" } }, - "node_modules/@types/linkify-it": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.2.tgz", - "integrity": "sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==", - "dev": true - }, "node_modules/@types/lodash": { "version": "4.14.182", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz", @@ -314,22 +314,6 @@ "@types/lodash": "*" } }, - "node_modules/@types/markdown-it": { - "version": "12.2.3", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", - "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", - "dev": true, - "dependencies": { - "@types/linkify-it": "*", - "@types/mdurl": "*" - } - }, - "node_modules/@types/mdurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.2.tgz", - "integrity": "sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==", - "dev": true - }, "node_modules/@types/node": { "version": "17.0.32", "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.32.tgz", @@ -351,6 +335,16 @@ "@types/node": "*" } }, + "node_modules/@types/socket.io-client": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/socket.io-client/-/socket.io-client-3.0.0.tgz", + "integrity": "sha512-s+IPvFoEIjKA3RdJz/Z2dGR4gLgysKi8owcnrVwNjgvc01Lk68LJDDsG2GRqegFITcxmvCMYM7bhMpwEMlHmDg==", + "deprecated": "This is a stub types definition. socket.io-client provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "socket.io-client": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.23.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.23.0.tgz", @@ -650,7 +644,8 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, "node_modules/array-union": { "version": "2.1.0", @@ -933,7 +928,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -1043,15 +1037,24 @@ "integrity": "sha512-0Rcpald12O11BUogJagX3HsCN3FE83DSqWjgXoHo5a72KUKMSfI39XBgJpgNNxS9fuGzytaFjE06kZkiVFy2qA==", "dev": true }, - "node_modules/entities": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", - "integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==", + "node_modules/engine.io-client": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.2.2.tgz", + "integrity": "sha512-8ZQmx0LQGRTYkHuogVZuGSpDqYZtCM/nv8zQ68VZ+JkOpazJ7ICdsSpaO6iXwvaU30oFg5QJOJWj8zWqhbKjkQ==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.0.3", + "ws": "~8.2.3", + "xmlhttprequest-ssl": "~2.0.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.4.tgz", + "integrity": "sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg==", "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "node": ">=10.0.0" } }, "node_modules/es6-promise": { @@ -1673,6 +1676,11 @@ "node": ">=0.10.0" } }, + "node_modules/exifr": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", + "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2112,14 +2120,6 @@ "node": ">=10" } }, - "node_modules/linkify-it": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz", - "integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==", - "dependencies": { - "uc.micro": "^1.0.1" - } - }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -2160,26 +2160,6 @@ "node": ">=12" } }, - "node_modules/markdown-it": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.1.tgz", - "integrity": "sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q==", - "dependencies": { - "argparse": "^2.0.1", - "entities": "~3.0.1", - "linkify-it": "^4.0.1", - "mdurl": "^1.0.1", - "uc.micro": "^1.0.5" - }, - "bin": { - "markdown-it": "bin/markdown-it.js" - } - }, - "node_modules/mdurl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", - "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==" - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2289,8 +2269,7 @@ "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==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/nanoid": { "version": "3.3.4", @@ -2820,6 +2799,32 @@ "node": ">=8" } }, + "node_modules/socket.io-client": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.5.1.tgz", + "integrity": "sha512-e6nLVgiRYatS+AHXnOnGi4ocOpubvOUCGhyWw8v+/FxW8saHkinG6Dfhi9TU0Kt/8mwJIAASxvw6eujQmjdZVA==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.2.1", + "socket.io-parser": "~4.2.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.0.tgz", + "integrity": "sha512-tLfmEwcEwnlQTxFB7jibL/q2+q8dlVQzj4JdRLJ/W/G1+Fu9VSxCx1Lo+n1HvXxKnM//dUuD0xgiA7tQf57Vng==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/sorcery": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.10.0.tgz", @@ -3187,11 +3192,6 @@ "node": ">=4.2.0" } }, - "node_modules/uc.micro": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", - "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3293,6 +3293,34 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true }, + "node_modules/ws": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", + "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -3395,6 +3423,11 @@ "picomatch": "^2.2.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==" + }, "@sveltejs/adapter-auto": { "version": "1.0.0-next.40", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-1.0.0-next.40.tgz", @@ -3525,12 +3558,6 @@ "@types/geojson": "*" } }, - "@types/linkify-it": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.2.tgz", - "integrity": "sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==", - "dev": true - }, "@types/lodash": { "version": "4.14.182", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz", @@ -3546,22 +3573,6 @@ "@types/lodash": "*" } }, - "@types/markdown-it": { - "version": "12.2.3", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", - "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", - "dev": true, - "requires": { - "@types/linkify-it": "*", - "@types/mdurl": "*" - } - }, - "@types/mdurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.2.tgz", - "integrity": "sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==", - "dev": true - }, "@types/node": { "version": "17.0.32", "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.32.tgz", @@ -3583,6 +3594,15 @@ "@types/node": "*" } }, + "@types/socket.io-client": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/socket.io-client/-/socket.io-client-3.0.0.tgz", + "integrity": "sha512-s+IPvFoEIjKA3RdJz/Z2dGR4gLgysKi8owcnrVwNjgvc01Lk68LJDDsG2GRqegFITcxmvCMYM7bhMpwEMlHmDg==", + "dev": true, + "requires": { + "socket.io-client": "*" + } + }, "@typescript-eslint/eslint-plugin": { "version": "5.23.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.23.0.tgz", @@ -3762,7 +3782,8 @@ "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, "array-union": { "version": "2.1.0", @@ -3947,7 +3968,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "requires": { "ms": "2.1.2" } @@ -4028,10 +4048,22 @@ "integrity": "sha512-0Rcpald12O11BUogJagX3HsCN3FE83DSqWjgXoHo5a72KUKMSfI39XBgJpgNNxS9fuGzytaFjE06kZkiVFy2qA==", "dev": true }, - "entities": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", - "integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==" + "engine.io-client": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.2.2.tgz", + "integrity": "sha512-8ZQmx0LQGRTYkHuogVZuGSpDqYZtCM/nv8zQ68VZ+JkOpazJ7ICdsSpaO6iXwvaU30oFg5QJOJWj8zWqhbKjkQ==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.0.3", + "ws": "~8.2.3", + "xmlhttprequest-ssl": "~2.0.0" + } + }, + "engine.io-parser": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.4.tgz", + "integrity": "sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg==" }, "es6-promise": { "version": "3.3.1", @@ -4399,6 +4431,11 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "exifr": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", + "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==" + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4729,14 +4766,6 @@ "integrity": "sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==", "dev": true }, - "linkify-it": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz", - "integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==", - "requires": { - "uc.micro": "^1.0.1" - } - }, "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -4771,23 +4800,6 @@ "sourcemap-codec": "^1.4.8" } }, - "markdown-it": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.1.tgz", - "integrity": "sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q==", - "requires": { - "argparse": "^2.0.1", - "entities": "~3.0.1", - "linkify-it": "^4.0.1", - "mdurl": "^1.0.1", - "uc.micro": "^1.0.5" - } - }, - "mdurl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", - "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==" - }, "merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4867,8 +4879,7 @@ "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "nanoid": { "version": "3.3.4", @@ -5199,6 +5210,26 @@ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true }, + "socket.io-client": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.5.1.tgz", + "integrity": "sha512-e6nLVgiRYatS+AHXnOnGi4ocOpubvOUCGhyWw8v+/FxW8saHkinG6Dfhi9TU0Kt/8mwJIAASxvw6eujQmjdZVA==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.2.1", + "socket.io-parser": "~4.2.0" + } + }, + "socket.io-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.0.tgz", + "integrity": "sha512-tLfmEwcEwnlQTxFB7jibL/q2+q8dlVQzj4JdRLJ/W/G1+Fu9VSxCx1Lo+n1HvXxKnM//dUuD0xgiA7tQf57Vng==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + } + }, "sorcery": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.10.0.tgz", @@ -5436,11 +5467,6 @@ "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==", "dev": true }, - "uc.micro": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", - "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" - }, "uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -5506,6 +5532,17 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true }, + "ws": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", + "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==", + "requires": {} + }, + "xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==" + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/web/package.json b/web/package.json index 15030595c8..4a4ea786a0 100644 --- a/web/package.json +++ b/web/package.json @@ -23,6 +23,7 @@ "@types/leaflet": "^1.7.10", "@types/lodash": "^4.14.182", "@types/lodash-es": "^4.17.6", + "@types/socket.io-client": "^3.0.0", "@typescript-eslint/eslint-plugin": "^5.10.1", "@typescript-eslint/parser": "^5.10.1", "autoprefixer": "^10.4.7", @@ -43,10 +44,12 @@ "dependencies": { "axios": "^0.27.2", "cookie": "^0.4.2", + "exifr": "^7.1.3", "leaflet": "^1.8.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "moment": "^2.29.3", + "socket.io-client": "^4.5.1", "svelte-material-icons": "^2.0.2" } } diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index b4b1d24607..95ce78ac98 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -91,7 +91,7 @@ -

Info

+

Info

diff --git a/web/src/lib/components/shared/navigation-bar.svelte b/web/src/lib/components/shared/navigation-bar.svelte index a9fcebf6f7..7333957faa 100644 --- a/web/src/lib/components/shared/navigation-bar.svelte +++ b/web/src/lib/components/shared/navigation-bar.svelte @@ -2,16 +2,19 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import type { ImmichUser } from '$lib/models/immich-user'; - import { onMount } from 'svelte'; - import { fade } from 'svelte/transition'; + import { createEventDispatcher, onMount } from 'svelte'; + import { fade, fly, slide } from 'svelte/transition'; import { postRequest } from '../../api'; import { serverEndpoint } from '../../constants'; + import TrayArrowUp from 'svelte-material-icons/TrayArrowUp.svelte'; import { clickOutside } from './click-outside'; export let user: ImmichUser; let shouldShowAccountInfo = false; let shouldShowProfileImage = false; + + const dispatch = createEventDispatcher(); let shouldShowAccountInfoPanel = false; onMount(async () => { const res = await fetch(`${serverEndpoint}/user/profile-image/${user.id}`, { method: 'GET' }); @@ -41,7 +44,7 @@
-
+
immich logo

IMMICH

@@ -49,13 +52,21 @@
- -
- +
+ {#if $page.url.pathname !== '/admin'} + + {/if} {#if user.isAdmin} + import { quartInOut } from 'svelte/easing'; + import { scale, fade } from 'svelte/transition'; + import { uploadAssetsStore } from '$lib/stores/upload'; + import CloudUploadOutline from 'svelte-material-icons/CloudUploadOutline.svelte'; + import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte'; + import type { UploadAsset } from '$lib/models/upload-asset'; + + let showDetail = true; + + let uploadLength = 0; + + const showUploadImageThumbnail = async (a: UploadAsset) => { + const extension = a.fileExtension.toLowerCase(); + + if (extension == 'jpeg' || extension == 'jpg' || extension == 'png') { + try { + const imgData = await a.file.arrayBuffer(); + const arrayBufferView = new Uint8Array(imgData); + const blob = new Blob([arrayBufferView], { type: 'image/jpeg' }); + const urlCreator = window.URL || window.webkitURL; + const imageUrl = urlCreator.createObjectURL(blob); + const img: any = document.getElementById(`${a.id}`); + img.src = imageUrl; + } catch (e) {} + } + }; + + function getSizeInHumanReadableFormat(sizeInByte: number) { + const pepibyte = 1.126 * Math.pow(10, 15); + const tebibyte = 1.1 * Math.pow(10, 12); + const gibibyte = 1.074 * Math.pow(10, 9); + const mebibyte = 1.049 * Math.pow(10, 6); + const kibibyte = 1024; + // Pebibyte + if (sizeInByte >= pepibyte) { + // Pe + return `${(sizeInByte / pepibyte).toFixed(1)}PB`; + } else if (tebibyte <= sizeInByte && sizeInByte < pepibyte) { + // Te + return `${(sizeInByte / tebibyte).toFixed(1)}TB`; + } else if (gibibyte <= sizeInByte && sizeInByte < tebibyte) { + // Gi + return `${(sizeInByte / gibibyte).toFixed(1)}GB`; + } else if (mebibyte <= sizeInByte && sizeInByte < gibibyte) { + // Mega + return `${(sizeInByte / mebibyte).toFixed(1)}MB`; + } else if (kibibyte <= sizeInByte && sizeInByte < mebibyte) { + // Kibi + return `${(sizeInByte / kibibyte).toFixed(1)}KB`; + } else { + return `${sizeInByte}B`; + } + } + + // Reactive action to get thumbnail image of upload asset whenever there is a new one added to the list + $: { + if ($uploadAssetsStore.length != uploadLength) { + $uploadAssetsStore.map((asset) => { + showUploadImageThumbnail(asset); + }); + + uploadLength = $uploadAssetsStore.length; + } + } + + $: { + if (showDetail) { + $uploadAssetsStore.map((asset) => { + showUploadImageThumbnail(asset); + }); + } + } + + let isUploading = false; + + uploadAssetsStore.isUploading.subscribe((value) => (isUploading = value)); + + +{#if isUploading} +
+ {#if showDetail} +
+
+

UPLOADING {$uploadAssetsStore.length}

+ +
+ +
+ {#each $uploadAssetsStore as uploadAsset} +
+
+ + +
+

+ .{uploadAsset.fileExtension} +

+
+
+ +
+ + +
+
+

{uploadAsset.progress}/100

+
+
+
+ {/each} +
+
+ {:else} +
+ + +
+ {/if} +
+{/if} + + diff --git a/web/src/lib/models/upload-asset.ts b/web/src/lib/models/upload-asset.ts new file mode 100644 index 0000000000..6079251b63 --- /dev/null +++ b/web/src/lib/models/upload-asset.ts @@ -0,0 +1,6 @@ +export type UploadAsset = { + id: string; + file: File; + progress: number; + fileExtension: string; +}; diff --git a/web/src/lib/stores/assets.ts b/web/src/lib/stores/assets.ts index f43295f60d..19dcea16d8 100644 --- a/web/src/lib/stores/assets.ts +++ b/web/src/lib/stores/assets.ts @@ -1,33 +1,29 @@ import { writable, derived } from 'svelte/store'; import { getRequest } from '$lib/api'; -import type { ImmichAsset } from '$lib/models/immich-asset' +import type { ImmichAsset } from '$lib/models/immich-asset'; import lodash from 'lodash-es'; import moment from 'moment'; export const assets = writable([]); export const assetsGroupByDate = derived(assets, ($assets) => { - - try { - return lodash.chain($assets) - .groupBy((a) => moment(a.createdAt).format('ddd, MMM DD')) - .sortBy((group) => $assets.indexOf(group[0])) - .value(); - } catch (e) { - console.log("error deriving state assets", e) - return [] - } - -}) + try { + return lodash + .chain($assets) + .groupBy((a) => moment(a.createdAt).format('ddd, MMM DD')) + .sortBy((group) => $assets.indexOf(group[0])) + .value(); + } catch (e) { + console.log('error deriving state assets', e); + return []; + } +}); export const flattenAssetGroupByDate = derived(assetsGroupByDate, ($assetsGroupByDate) => { - return $assetsGroupByDate.flat(); -}) + return $assetsGroupByDate.flat(); +}); export const getAssetsInfo = async (accessToken: string) => { - const res = await getRequest('asset', accessToken); - - assets.set(res); - -} - + const res = await getRequest('asset', accessToken); + assets.set(res); +}; diff --git a/web/src/lib/stores/upload.ts b/web/src/lib/stores/upload.ts new file mode 100644 index 0000000000..1ed576cd64 --- /dev/null +++ b/web/src/lib/stores/upload.ts @@ -0,0 +1,45 @@ +import { writable, derived } from 'svelte/store'; +import type { UploadAsset } from '../models/upload-asset'; + +function createUploadStore() { + const uploadAssets = writable>([]); + + const { subscribe } = uploadAssets; + + const isUploading = derived(uploadAssets, ($uploadAssets) => { + return $uploadAssets.length > 0 ? true : false; + }); + + const addNewUploadAsset = (newAsset: UploadAsset) => { + uploadAssets.update((currentSet) => [...currentSet, newAsset]); + }; + + const updateProgress = (id: string, progress: number) => { + uploadAssets.update((uploadingAssets) => { + return uploadingAssets.map((asset) => { + if (asset.id == id) { + return { + ...asset, + progress: progress, + }; + } + + return asset; + }); + }); + }; + + const removeUploadAsset = (id: string) => { + uploadAssets.update((uploadingAsset) => uploadingAsset.filter((a) => a.id != id)); + }; + + return { + subscribe, + isUploading, + addNewUploadAsset, + updateProgress, + removeUploadAsset, + }; +} + +export const uploadAssetsStore = createUploadStore(); diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts new file mode 100644 index 0000000000..d37f220922 --- /dev/null +++ b/web/src/lib/stores/websocket.ts @@ -0,0 +1,30 @@ +import { Socket, io } from 'socket.io-client'; +import { serverEndpoint } from '../constants'; +import type { ImmichAsset } from '../models/immich-asset'; +import { assets } from './assets'; + +export const openWebsocketConnection = (accessToken: string) => { + const websocket = io(serverEndpoint, { + transports: ['polling'], + reconnection: true, + forceNew: true, + autoConnect: true, + extraHeaders: { + Authorization: 'Bearer ' + accessToken, + }, + }); + + listenToEvent(websocket); +}; + +const listenToEvent = (socket: Socket) => { + socket.on('on_upload_success', (data) => { + const newUploadedAsset: ImmichAsset = JSON.parse(data); + + assets.update((assets) => [...assets, newUploadedAsset]); + }); + + socket.on('error', (e) => { + console.log('Websocket Error', e); + }); +}; diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts new file mode 100644 index 0000000000..792048adce --- /dev/null +++ b/web/src/lib/utils/file-uploader.ts @@ -0,0 +1,113 @@ +import * as exifr from 'exifr'; +import { serverEndpoint } from '../constants'; +import { uploadAssetsStore } from '$lib/stores/upload'; +import type { UploadAsset } from '../models/upload-asset'; + +export async function fileUploader(asset: File, accessToken: string) { + const assetType = asset.type.split('/')[0].toUpperCase(); + const temp = asset.name.split('.'); + const fileExtension = temp[temp.length - 1]; + const formData = new FormData(); + + try { + let exifData = null; + + if (assetType !== 'VIDEO') { + exifData = await exifr.parse(asset); + } + + const createdAt = + exifData && exifData.DateTimeOriginal != null + ? new Date(exifData.DateTimeOriginal).toISOString() + : new Date(asset.lastModified).toISOString(); + + const deviceAssetId = 'web' + '-' + asset.name + '-' + asset.lastModified; + + // Create and add Unique ID of asset on the device + formData.append('deviceAssetId', deviceAssetId); + + // Get device id - for web -> use WEB + formData.append('deviceId', 'WEB'); + + // Get asset type + formData.append('assetType', assetType); + + // Get Asset Created Date + formData.append('createdAt', createdAt); + + // Get Asset Modified At + formData.append('modifiedAt', new Date(asset.lastModified).toISOString()); + + // Set Asset is Favorite to false + formData.append('isFavorite', 'false'); + + // Get asset duration + formData.append('duration', '0:00:00.000000'); + + // Get asset file extension + formData.append('fileExtension', '.' + fileExtension); + + // Get asset binary data. + formData.append('assetData', asset); + + // Check if asset upload on server before performing upload + const res = await fetch(serverEndpoint + '/asset/check', { + method: 'POST', + body: JSON.stringify({ deviceAssetId }), + headers: { + Authorization: 'Bearer ' + accessToken, + 'Content-Type': 'application/json', + }, + }); + + if (res.status === 200) { + const { isExist } = await res.json(); + + if (isExist) { + return; + } + } + + const request = new XMLHttpRequest(); + + request.upload.onloadstart = () => { + const newUploadAsset: UploadAsset = { + id: deviceAssetId, + file: asset, + progress: 0, + fileExtension: fileExtension, + }; + + uploadAssetsStore.addNewUploadAsset(newUploadAsset); + }; + + request.upload.onload = () => { + setTimeout(() => { + uploadAssetsStore.removeUploadAsset(deviceAssetId); + }, 2500); + }; + + // listen for `error` event + request.upload.onerror = () => { + uploadAssetsStore.removeUploadAsset(deviceAssetId); + }; + + // listen for `abort` event + request.upload.onabort = () => { + uploadAssetsStore.removeUploadAsset(deviceAssetId); + }; + + // listen for `progress` event + request.upload.onprogress = (event) => { + const percentComplete = Math.floor((event.loaded / event.total) * 100); + uploadAssetsStore.updateProgress(deviceAssetId, percentComplete); + }; + + request.open('POST', `${serverEndpoint}/asset/upload`); + request.setRequestHeader('Authorization', `Bearer ${accessToken}`); + + request.send(formData); + } catch (e) { + console.log('error uploading file ', e); + } +} diff --git a/web/src/routes/__layout.svelte b/web/src/routes/__layout.svelte index 5468e49856..3e39e5b294 100644 --- a/web/src/routes/__layout.svelte +++ b/web/src/routes/__layout.svelte @@ -22,8 +22,8 @@ import { blur } from 'svelte/transition'; import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte'; - import FullScreenModal from '../lib/components/shared/full-screen-modal.svelte'; - import AnnouncementBox from '../lib/components/shared/announcement-box.svelte'; + import AnnouncementBox from '$lib/components/shared/announcement-box.svelte'; + import UploadPanel from '$lib/components/shared/upload-panel.svelte'; export let url: string; export let shouldShowAnnouncement: boolean; @@ -36,7 +36,7 @@
- + {#if shouldShowAnnouncement} (shouldShowAnnouncement = false)} /> {/if} diff --git a/web/src/routes/index.svelte b/web/src/routes/index.svelte index 25fd6b01b3..d173d3c4d9 100644 --- a/web/src/routes/index.svelte +++ b/web/src/routes/index.svelte @@ -35,6 +35,7 @@ @@ -88,10 +119,10 @@
- +
-
+