Compare commits
22 Commits
v1.15.0_21
...
v1.17.0_25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3032f74a4 | ||
|
|
58ec7553ea | ||
|
|
357f7d1c31 | ||
|
|
e6d30d72fa | ||
|
|
355038a91a | ||
|
|
97d9b80baa | ||
|
|
b6814fad57 | ||
|
|
7586c65103 | ||
|
|
633170d743 | ||
|
|
c5be7827c3 | ||
|
|
e84c705e31 | ||
|
|
36162509e0 | ||
|
|
76bf1c0379 | ||
|
|
32b847c26e | ||
|
|
a45d6fdf57 | ||
|
|
c071e64a7e | ||
|
|
663f12851e | ||
|
|
c4ef523564 | ||
|
|
992f792c0a | ||
|
|
97611fa057 | ||
|
|
32240777c3 | ||
|
|
6065ff8caa |
@@ -149,8 +149,8 @@ jobs:
|
|||||||
- name: Build and push immich-proxy release
|
- name: Build and push immich-proxy release
|
||||||
uses: docker/build-push-action@v3.0.0
|
uses: docker/build-push-action@v3.0.0
|
||||||
with:
|
with:
|
||||||
context: ./web
|
context: ./nginx
|
||||||
file: ./web/Dockerfile
|
file: ./nginx/Dockerfile
|
||||||
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: |
|
tags: |
|
||||||
|
|||||||
8
Makefile
8
Makefile
@@ -1,11 +1,11 @@
|
|||||||
dev:
|
dev:
|
||||||
docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
|
rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
|
||||||
|
|
||||||
dev-update:
|
dev-update:
|
||||||
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
||||||
|
|
||||||
dev-scale:
|
dev-scale:
|
||||||
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
|
rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
|
||||||
|
|
||||||
stage:
|
stage:
|
||||||
docker-compose -f ./docker/docker-compose.staging.yml up --build -V --remove-orphans
|
docker-compose -f ./docker/docker-compose.staging.yml up --build -V --remove-orphans
|
||||||
@@ -17,4 +17,4 @@ prod:
|
|||||||
docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans
|
docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans
|
||||||
|
|
||||||
prod-scale:
|
prod-scale:
|
||||||
docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich-server=5 --scale immich-microservices=3 --remove-orphans
|
docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
<a href="https://actions-badge.atrox.dev/alextran1502/immich/goto?ref=main">
|
<a href="https://actions-badge.atrox.dev/alextran1502/immich/goto?ref=main">
|
||||||
<img alt="Build Status" src="https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Falextran1502%2Fimmich%2Fbadge%3Fref%3Dmain&style=for-the-badge&label=Github Action&logo=github&labelColor=ececec&logoColor=000000" />
|
<img alt="Build Status" src="https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Falextran1502%2Fimmich%2Fbadge%3Fref%3Dmain&style=for-the-badge&label=Github Action&logo=github&labelColor=ececec&logoColor=000000" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://discord.gg/rxnyVTXGbM">
|
<a href="https://discord.gg/D8JsnBEuKb">
|
||||||
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Immich%20Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Immich Discord"/>
|
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Immich%20Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Immich Discord"/>
|
||||||
</a>
|
</a>
|
||||||
<br/>
|
<br/>
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ services:
|
|||||||
image: altran1502/immich-proxy:release
|
image: altran1502/immich-proxy:release
|
||||||
ports:
|
ports:
|
||||||
- 2283:80
|
- 2283:80
|
||||||
- 2284:443
|
|
||||||
logging:
|
logging:
|
||||||
driver: none
|
driver: none
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:16-bullseye-slim
|
FROM node:16-bullseye-slim
|
||||||
|
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ COPY package.json package-lock.json ./
|
|||||||
RUN apt-get update
|
RUN apt-get update
|
||||||
RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
|
RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
|
||||||
|
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Logger } from '@nestjs/common';
|
|||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
await app.listen(3001, () => {
|
await app.listen(3003, () => {
|
||||||
if (process.env.NODE_ENV == 'development') {
|
if (process.env.NODE_ENV == 'development') {
|
||||||
Logger.log(
|
Logger.log(
|
||||||
'Running Immich Machine Learning in DEVELOPMENT environment',
|
'Running Immich Machine Learning in DEVELOPMENT environment',
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
* Fixed admin is forced to change password upon logging in on mobile app
|
||||||
|
* Fixed change password form validation
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* Removed thumbnail generation on mobile - the operation now will be on the server to reduce CPU load and battery usage.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* Hot fix: Restore shared album functionality
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* Add information for uploading asset and error indication with error message for each failed upload.
|
||||||
@@ -19,7 +19,7 @@ platform :ios do
|
|||||||
desc "iOS Beta"
|
desc "iOS Beta"
|
||||||
lane :beta do
|
lane :beta do
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "1.14.0"
|
version_number: "1.17.0"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
|
|||||||
@@ -39,14 +39,10 @@ class ImageViewerService {
|
|||||||
entity = await PhotoManager.editor.saveVideo(tempFile, title: fileName);
|
entity = await PhotoManager.editor.saveVideo(tempFile, title: fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entity != null) {
|
return entity != null;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Error saving file $e");
|
debugPrint("Error saving file $e");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,81 +15,72 @@ class ExifBottomSheet extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
_buildMap() {
|
_buildMap() {
|
||||||
return (assetDetail.exifInfo!.latitude != null &&
|
return Padding(
|
||||||
assetDetail.exifInfo!.longitude != null)
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
? Padding(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
height: 150,
|
||||||
child: Container(
|
width: MediaQuery.of(context).size.width,
|
||||||
height: 150,
|
decoration: const BoxDecoration(
|
||||||
width: MediaQuery.of(context).size.width,
|
borderRadius: BorderRadius.all(Radius.circular(15)),
|
||||||
decoration: const BoxDecoration(
|
),
|
||||||
borderRadius: BorderRadius.all(Radius.circular(15)),
|
child: FlutterMap(
|
||||||
),
|
options: MapOptions(
|
||||||
child: FlutterMap(
|
center: LatLng(assetDetail.exifInfo!.latitude!,
|
||||||
options: MapOptions(
|
assetDetail.exifInfo!.longitude!),
|
||||||
center: LatLng(assetDetail.exifInfo!.latitude!,
|
zoom: 16.0,
|
||||||
assetDetail.exifInfo!.longitude!),
|
),
|
||||||
zoom: 16.0,
|
layers: [
|
||||||
),
|
TileLayerOptions(
|
||||||
layers: [
|
urlTemplate:
|
||||||
TileLayerOptions(
|
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
urlTemplate:
|
subdomains: ['a', 'b', 'c'],
|
||||||
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
attributionBuilder: (_) {
|
||||||
subdomains: ['a', 'b', 'c'],
|
return const Text(
|
||||||
attributionBuilder: (_) {
|
"© OpenStreetMap",
|
||||||
return const Text(
|
style: TextStyle(fontSize: 10),
|
||||||
"© OpenStreetMap",
|
);
|
||||||
style: TextStyle(fontSize: 10),
|
},
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
MarkerLayerOptions(
|
|
||||||
markers: [
|
|
||||||
Marker(
|
|
||||||
anchorPos: AnchorPos.align(AnchorAlign.top),
|
|
||||||
point: LatLng(assetDetail.exifInfo!.latitude!,
|
|
||||||
assetDetail.exifInfo!.longitude!),
|
|
||||||
builder: (ctx) => const Image(
|
|
||||||
image: AssetImage('assets/location-pin.png')),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
MarkerLayerOptions(
|
||||||
: Container();
|
markers: [
|
||||||
|
Marker(
|
||||||
|
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||||
|
point: LatLng(assetDetail.exifInfo!.latitude!,
|
||||||
|
assetDetail.exifInfo!.longitude!),
|
||||||
|
builder: (ctx) => const Image(
|
||||||
|
image: AssetImage('assets/location-pin.png')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_buildLocationText() {
|
_buildLocationText() {
|
||||||
return (assetDetail.exifInfo!.city != null &&
|
return Text(
|
||||||
assetDetail.exifInfo!.state != null)
|
"${assetDetail.exifInfo!.city}, ${assetDetail.exifInfo!.state}",
|
||||||
? Text(
|
style: TextStyle(
|
||||||
"${assetDetail.exifInfo!.city}, ${assetDetail.exifInfo!.state}",
|
fontSize: 12, color: Colors.grey[200], fontWeight: FontWeight.bold),
|
||||||
style: TextStyle(
|
);
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.grey[200],
|
|
||||||
fontWeight: FontWeight.bold),
|
|
||||||
)
|
|
||||||
: Container();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8),
|
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
children: [
|
children: [
|
||||||
assetDetail.exifInfo?.dateTimeOriginal != null
|
if (assetDetail.exifInfo?.dateTimeOriginal != null)
|
||||||
? Text(
|
Text(
|
||||||
DateFormat('E, LLL d, y • h:mm a').format(
|
DateFormat('E, LLL d, y • h:mm a').format(
|
||||||
DateTime.parse(assetDetail.exifInfo!.dateTimeOriginal!),
|
DateTime.parse(assetDetail.exifInfo!.dateTimeOriginal!),
|
||||||
),
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.grey[400],
|
color: Colors.grey[400],
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
: Container(),
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 16.0),
|
padding: const EdgeInsets.only(top: 16.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -102,84 +93,83 @@ class ExifBottomSheet extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Location
|
// Location
|
||||||
assetDetail.exifInfo?.latitude != null
|
if (assetDetail.exifInfo?.latitude != null)
|
||||||
? Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 32.0),
|
padding: const EdgeInsets.only(top: 32.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Divider(
|
Divider(
|
||||||
thickness: 1,
|
thickness: 1,
|
||||||
color: Colors.grey[600],
|
color: Colors.grey[600],
|
||||||
),
|
|
||||||
Text(
|
|
||||||
"LOCATION",
|
|
||||||
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
|
|
||||||
),
|
|
||||||
_buildMap(),
|
|
||||||
_buildLocationText(),
|
|
||||||
Text(
|
|
||||||
"${assetDetail.exifInfo!.latitude!.toStringAsFixed(4)}, ${assetDetail.exifInfo!.longitude!.toStringAsFixed(4)}",
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
)
|
Text(
|
||||||
: Container(),
|
"LOCATION",
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
|
||||||
|
),
|
||||||
|
if (assetDetail.exifInfo?.latitude != null &&
|
||||||
|
assetDetail.exifInfo?.longitude != null)
|
||||||
|
_buildMap(),
|
||||||
|
if (assetDetail.exifInfo?.city != null &&
|
||||||
|
assetDetail.exifInfo?.state != null)
|
||||||
|
_buildLocationText(),
|
||||||
|
Text(
|
||||||
|
"${assetDetail.exifInfo?.latitude?.toStringAsFixed(4)}, ${assetDetail.exifInfo?.longitude?.toStringAsFixed(4)}",
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
// Detail
|
// Detail
|
||||||
assetDetail.exifInfo != null
|
if (assetDetail.exifInfo != null)
|
||||||
? Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 32.0),
|
padding: const EdgeInsets.only(top: 32.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Divider(
|
Divider(
|
||||||
thickness: 1,
|
thickness: 1,
|
||||||
color: Colors.grey[600],
|
color: Colors.grey[600],
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 8.0),
|
|
||||||
child: Text(
|
|
||||||
"DETAILS",
|
|
||||||
style:
|
|
||||||
TextStyle(fontSize: 11, color: Colors.grey[400]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
contentPadding: const EdgeInsets.all(0),
|
|
||||||
dense: true,
|
|
||||||
textColor: Colors.grey[300],
|
|
||||||
iconColor: Colors.grey[300],
|
|
||||||
leading: const Icon(Icons.image),
|
|
||||||
title: Text(
|
|
||||||
"${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}",
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
subtitle: assetDetail.exifInfo?.exifImageHeight != null
|
|
||||||
? Text(
|
|
||||||
"${assetDetail.exifInfo?.exifImageHeight} x ${assetDetail.exifInfo?.exifImageWidth} ${assetDetail.exifInfo?.fileSizeInByte!}B ")
|
|
||||||
: Container(),
|
|
||||||
),
|
|
||||||
assetDetail.exifInfo?.make != null
|
|
||||||
? ListTile(
|
|
||||||
contentPadding: const EdgeInsets.all(0),
|
|
||||||
dense: true,
|
|
||||||
textColor: Colors.grey[300],
|
|
||||||
iconColor: Colors.grey[300],
|
|
||||||
leading: const Icon(Icons.camera),
|
|
||||||
title: Text(
|
|
||||||
"${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}",
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
"ƒ/${assetDetail.exifInfo?.fNumber} 1/${(1 / assetDetail.exifInfo!.exposureTime!).toStringAsFixed(0)} ${assetDetail.exifInfo?.focalLength}mm ISO${assetDetail.exifInfo?.iso} "),
|
|
||||||
)
|
|
||||||
: Container()
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
)
|
Padding(
|
||||||
: Container()
|
padding: const EdgeInsets.only(bottom: 8.0),
|
||||||
|
child: Text(
|
||||||
|
"DETAILS",
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: const EdgeInsets.all(0),
|
||||||
|
dense: true,
|
||||||
|
textColor: Colors.grey[300],
|
||||||
|
iconColor: Colors.grey[300],
|
||||||
|
leading: const Icon(Icons.image),
|
||||||
|
title: Text(
|
||||||
|
"${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}",
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: assetDetail.exifInfo?.exifImageHeight != null
|
||||||
|
? Text(
|
||||||
|
"${assetDetail.exifInfo?.exifImageHeight} x ${assetDetail.exifInfo?.exifImageWidth} ${assetDetail.exifInfo?.fileSizeInByte!}B ")
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
if (assetDetail.exifInfo?.make != null)
|
||||||
|
ListTile(
|
||||||
|
contentPadding: const EdgeInsets.all(0),
|
||||||
|
dense: true,
|
||||||
|
textColor: Colors.grey[300],
|
||||||
|
iconColor: Colors.grey[300],
|
||||||
|
leading: const Icon(Icons.camera),
|
||||||
|
title: Text(
|
||||||
|
"${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}",
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
"ƒ/${assetDetail.exifInfo?.fNumber} 1/${(1 / (assetDetail.exifInfo?.exposureTime ?? 1)).toStringAsFixed(0)} ${assetDetail.exifInfo?.focalLength}mm ISO${assetDetail.exifInfo?.iso} "),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -147,8 +147,7 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return chewieController != null &&
|
return chewieController?.videoPlayerController.value.isInitialized == true
|
||||||
chewieController!.videoPlayerController.value.isInitialized
|
|
||||||
? SizedBox(
|
? SizedBox(
|
||||||
child: Chewie(
|
child: Chewie(
|
||||||
controller: chewieController!,
|
controller: chewieController!,
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import 'package:cancellation_token_http/http.dart';
|
import 'package:cancellation_token_http/http.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
|
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/shared/models/server_info.model.dart';
|
import 'package:immich_mobile/shared/models/server_info.model.dart';
|
||||||
|
|
||||||
enum BackUpProgressEnum { idle, inProgress, done }
|
enum BackUpProgressEnum { idle, inProgress, done }
|
||||||
|
|
||||||
class BackUpState extends Equatable {
|
class BackUpState {
|
||||||
// enum
|
// enum
|
||||||
final BackUpProgressEnum backupProgress;
|
final BackUpProgressEnum backupProgress;
|
||||||
final List<String> allAssetsInDatabase;
|
final List<String> allAssetsInDatabase;
|
||||||
@@ -26,6 +27,9 @@ class BackUpState extends Equatable {
|
|||||||
/// All assets from the selected albums that have been backup
|
/// All assets from the selected albums that have been backup
|
||||||
final Set<String> selectedAlbumsBackupAssetsIds;
|
final Set<String> selectedAlbumsBackupAssetsIds;
|
||||||
|
|
||||||
|
// Current Backup Asset
|
||||||
|
final CurrentUploadAsset currentUploadAsset;
|
||||||
|
|
||||||
const BackUpState({
|
const BackUpState({
|
||||||
required this.backupProgress,
|
required this.backupProgress,
|
||||||
required this.allAssetsInDatabase,
|
required this.allAssetsInDatabase,
|
||||||
@@ -37,6 +41,7 @@ class BackUpState extends Equatable {
|
|||||||
required this.excludedBackupAlbums,
|
required this.excludedBackupAlbums,
|
||||||
required this.allUniqueAssets,
|
required this.allUniqueAssets,
|
||||||
required this.selectedAlbumsBackupAssetsIds,
|
required this.selectedAlbumsBackupAssetsIds,
|
||||||
|
required this.currentUploadAsset,
|
||||||
});
|
});
|
||||||
|
|
||||||
BackUpState copyWith({
|
BackUpState copyWith({
|
||||||
@@ -50,6 +55,7 @@ class BackUpState extends Equatable {
|
|||||||
Set<AssetPathEntity>? excludedBackupAlbums,
|
Set<AssetPathEntity>? excludedBackupAlbums,
|
||||||
Set<AssetEntity>? allUniqueAssets,
|
Set<AssetEntity>? allUniqueAssets,
|
||||||
Set<String>? selectedAlbumsBackupAssetsIds,
|
Set<String>? selectedAlbumsBackupAssetsIds,
|
||||||
|
CurrentUploadAsset? currentUploadAsset,
|
||||||
}) {
|
}) {
|
||||||
return BackUpState(
|
return BackUpState(
|
||||||
backupProgress: backupProgress ?? this.backupProgress,
|
backupProgress: backupProgress ?? this.backupProgress,
|
||||||
@@ -63,27 +69,47 @@ class BackUpState extends Equatable {
|
|||||||
allUniqueAssets: allUniqueAssets ?? this.allUniqueAssets,
|
allUniqueAssets: allUniqueAssets ?? this.allUniqueAssets,
|
||||||
selectedAlbumsBackupAssetsIds:
|
selectedAlbumsBackupAssetsIds:
|
||||||
selectedAlbumsBackupAssetsIds ?? this.selectedAlbumsBackupAssetsIds,
|
selectedAlbumsBackupAssetsIds ?? this.selectedAlbumsBackupAssetsIds,
|
||||||
|
currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds)';
|
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props {
|
bool operator ==(Object other) {
|
||||||
return [
|
if (identical(this, other)) return true;
|
||||||
backupProgress,
|
final collectionEquals = const DeepCollectionEquality().equals;
|
||||||
allAssetsInDatabase,
|
|
||||||
progressInPercentage,
|
return other is BackUpState &&
|
||||||
cancelToken,
|
other.backupProgress == backupProgress &&
|
||||||
serverInfo,
|
collectionEquals(other.allAssetsInDatabase, allAssetsInDatabase) &&
|
||||||
availableAlbums,
|
other.progressInPercentage == progressInPercentage &&
|
||||||
selectedBackupAlbums,
|
other.cancelToken == cancelToken &&
|
||||||
excludedBackupAlbums,
|
other.serverInfo == serverInfo &&
|
||||||
allUniqueAssets,
|
collectionEquals(other.availableAlbums, availableAlbums) &&
|
||||||
selectedAlbumsBackupAssetsIds,
|
collectionEquals(other.selectedBackupAlbums, selectedBackupAlbums) &&
|
||||||
];
|
collectionEquals(other.excludedBackupAlbums, excludedBackupAlbums) &&
|
||||||
|
collectionEquals(other.allUniqueAssets, allUniqueAssets) &&
|
||||||
|
collectionEquals(other.selectedAlbumsBackupAssetsIds,
|
||||||
|
selectedAlbumsBackupAssetsIds) &&
|
||||||
|
other.currentUploadAsset == currentUploadAsset;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return backupProgress.hashCode ^
|
||||||
|
allAssetsInDatabase.hashCode ^
|
||||||
|
progressInPercentage.hashCode ^
|
||||||
|
cancelToken.hashCode ^
|
||||||
|
serverInfo.hashCode ^
|
||||||
|
availableAlbums.hashCode ^
|
||||||
|
selectedBackupAlbums.hashCode ^
|
||||||
|
excludedBackupAlbums.hashCode ^
|
||||||
|
allUniqueAssets.hashCode ^
|
||||||
|
selectedAlbumsBackupAssetsIds.hashCode ^
|
||||||
|
currentUploadAsset.hashCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
class CheckDuplicateAssetResponse {
|
||||||
|
final bool isExist;
|
||||||
|
CheckDuplicateAssetResponse({
|
||||||
|
required this.isExist,
|
||||||
|
});
|
||||||
|
|
||||||
|
CheckDuplicateAssetResponse copyWith({
|
||||||
|
bool? isExist,
|
||||||
|
}) {
|
||||||
|
return CheckDuplicateAssetResponse(
|
||||||
|
isExist: isExist ?? this.isExist,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
final result = <String, dynamic>{};
|
||||||
|
|
||||||
|
result.addAll({'isExist': isExist});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory CheckDuplicateAssetResponse.fromMap(Map<String, dynamic> map) {
|
||||||
|
return CheckDuplicateAssetResponse(
|
||||||
|
isExist: map['isExist'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String toJson() => json.encode(toMap());
|
||||||
|
|
||||||
|
factory CheckDuplicateAssetResponse.fromJson(String source) =>
|
||||||
|
CheckDuplicateAssetResponse.fromMap(json.decode(source));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'CheckDuplicateAssetResponse(isExist: $isExist)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is CheckDuplicateAssetResponse && other.isExist == isExist;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => isExist.hashCode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
class CurrentUploadAsset {
|
||||||
|
final String id;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final String fileName;
|
||||||
|
final String fileType;
|
||||||
|
|
||||||
|
CurrentUploadAsset({
|
||||||
|
required this.id,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.fileName,
|
||||||
|
required this.fileType,
|
||||||
|
});
|
||||||
|
|
||||||
|
CurrentUploadAsset copyWith({
|
||||||
|
String? id,
|
||||||
|
DateTime? createdAt,
|
||||||
|
String? fileName,
|
||||||
|
String? fileType,
|
||||||
|
}) {
|
||||||
|
return CurrentUploadAsset(
|
||||||
|
id: id ?? this.id,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
fileName: fileName ?? this.fileName,
|
||||||
|
fileType: fileType ?? this.fileType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
final result = <String, dynamic>{};
|
||||||
|
|
||||||
|
result.addAll({'id': id});
|
||||||
|
result.addAll({'createdAt': createdAt.millisecondsSinceEpoch});
|
||||||
|
result.addAll({'fileName': fileName});
|
||||||
|
result.addAll({'fileType': fileType});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory CurrentUploadAsset.fromMap(Map<String, dynamic> map) {
|
||||||
|
return CurrentUploadAsset(
|
||||||
|
id: map['id'] ?? '',
|
||||||
|
createdAt: DateTime.fromMillisecondsSinceEpoch(map['createdAt']),
|
||||||
|
fileName: map['fileName'] ?? '',
|
||||||
|
fileType: map['fileType'] ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String toJson() => json.encode(toMap());
|
||||||
|
|
||||||
|
factory CurrentUploadAsset.fromJson(String source) =>
|
||||||
|
CurrentUploadAsset.fromMap(json.decode(source));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'CurrentUploadAsset(id: $id, createdAt: $createdAt, fileName: $fileName, fileType: $fileType)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is CurrentUploadAsset &&
|
||||||
|
other.id == id &&
|
||||||
|
other.createdAt == createdAt &&
|
||||||
|
other.fileName == fileName &&
|
||||||
|
other.fileType == fileType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return id.hashCode ^
|
||||||
|
createdAt.hashCode ^
|
||||||
|
fileName.hashCode ^
|
||||||
|
fileType.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
|
class ErrorUploadAsset extends Equatable {
|
||||||
|
final String id;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final String fileName;
|
||||||
|
final String fileType;
|
||||||
|
final AssetEntity asset;
|
||||||
|
final String errorMessage;
|
||||||
|
|
||||||
|
const ErrorUploadAsset({
|
||||||
|
required this.id,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.fileName,
|
||||||
|
required this.fileType,
|
||||||
|
required this.asset,
|
||||||
|
required this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
ErrorUploadAsset copyWith({
|
||||||
|
String? id,
|
||||||
|
DateTime? createdAt,
|
||||||
|
String? fileName,
|
||||||
|
String? fileType,
|
||||||
|
AssetEntity? asset,
|
||||||
|
String? errorMessage,
|
||||||
|
}) {
|
||||||
|
return ErrorUploadAsset(
|
||||||
|
id: id ?? this.id,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
fileName: fileName ?? this.fileName,
|
||||||
|
fileType: fileType ?? this.fileType,
|
||||||
|
asset: asset ?? this.asset,
|
||||||
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'ErrorUploadAsset(id: $id, createdAt: $createdAt, fileName: $fileName, fileType: $fileType, asset: $asset, errorMessage: $errorMessage)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props {
|
||||||
|
return [
|
||||||
|
id,
|
||||||
|
fileName,
|
||||||
|
fileType,
|
||||||
|
errorMessage,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
|
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
|
||||||
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
|
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
|
||||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||||
@@ -14,8 +17,12 @@ import 'package:immich_mobile/shared/services/server_info.service.dart';
|
|||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
class BackupNotifier extends StateNotifier<BackUpState> {
|
class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
BackupNotifier(this._backupService, this._serverInfoService, this._authState)
|
BackupNotifier(
|
||||||
: super(
|
this._backupService,
|
||||||
|
this._serverInfoService,
|
||||||
|
this._authState,
|
||||||
|
this.ref,
|
||||||
|
) : super(
|
||||||
BackUpState(
|
BackUpState(
|
||||||
backupProgress: BackUpProgressEnum.idle,
|
backupProgress: BackUpProgressEnum.idle,
|
||||||
allAssetsInDatabase: const [],
|
allAssetsInDatabase: const [],
|
||||||
@@ -35,6 +42,12 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
excludedBackupAlbums: const {},
|
excludedBackupAlbums: const {},
|
||||||
allUniqueAssets: const {},
|
allUniqueAssets: const {},
|
||||||
selectedAlbumsBackupAssetsIds: const {},
|
selectedAlbumsBackupAssetsIds: const {},
|
||||||
|
currentUploadAsset: CurrentUploadAsset(
|
||||||
|
id: '...',
|
||||||
|
createdAt: DateTime.parse('2020-10-04'),
|
||||||
|
fileName: '...',
|
||||||
|
fileType: '...',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
getBackupInfo();
|
getBackupInfo();
|
||||||
@@ -43,6 +56,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
final BackupService _backupService;
|
final BackupService _backupService;
|
||||||
final ServerInfoService _serverInfoService;
|
final ServerInfoService _serverInfoService;
|
||||||
final AuthenticationState _authState;
|
final AuthenticationState _authState;
|
||||||
|
final Ref ref;
|
||||||
|
|
||||||
///
|
///
|
||||||
/// UI INTERACTION
|
/// UI INTERACTION
|
||||||
@@ -235,8 +249,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
/// and then update the UI according to those information
|
/// and then update the UI according to those information
|
||||||
///
|
///
|
||||||
Future<void> getBackupInfo() async {
|
Future<void> getBackupInfo() async {
|
||||||
await _getBackupAlbumsInfo();
|
await Future.wait([
|
||||||
await _updateServerInfo();
|
_getBackupAlbumsInfo(),
|
||||||
|
_updateServerInfo(),
|
||||||
|
]);
|
||||||
|
|
||||||
await _updateBackupAssetCount();
|
await _updateBackupAssetCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,13 +304,27 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
|
|
||||||
// Perform Backup
|
// Perform Backup
|
||||||
state = state.copyWith(cancelToken: CancellationToken());
|
state = state.copyWith(cancelToken: CancellationToken());
|
||||||
_backupService.backupAsset(assetsWillBeBackup, state.cancelToken,
|
_backupService.backupAsset(
|
||||||
_onAssetUploaded, _onUploadProgress);
|
assetsWillBeBackup,
|
||||||
|
state.cancelToken,
|
||||||
|
_onAssetUploaded,
|
||||||
|
_onUploadProgress,
|
||||||
|
_onSetCurrentBackupAsset,
|
||||||
|
_onBackupError,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
PhotoManager.openSetting();
|
PhotoManager.openSetting();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
|
||||||
|
ref.watch(errorBackupListProvider.notifier).add(errorAssetInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
|
||||||
|
state = state.copyWith(currentUploadAsset: currentUploadAsset);
|
||||||
|
}
|
||||||
|
|
||||||
void cancelBackup() {
|
void cancelBackup() {
|
||||||
state.cancelToken.cancel();
|
state.cancelToken.cancel();
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
@@ -375,5 +406,6 @@ final backupProvider =
|
|||||||
ref.watch(backupServiceProvider),
|
ref.watch(backupServiceProvider),
|
||||||
ref.watch(serverInfoServiceProvider),
|
ref.watch(serverInfoServiceProvider),
|
||||||
ref.watch(authenticationProvider),
|
ref.watch(authenticationProvider),
|
||||||
|
ref,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
|
||||||
|
|
||||||
|
class ErrorBackupListNotifier extends StateNotifier<Set<ErrorUploadAsset>> {
|
||||||
|
ErrorBackupListNotifier() : super({});
|
||||||
|
|
||||||
|
add(ErrorUploadAsset errorAsset) {
|
||||||
|
state = state.union({errorAsset});
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(ErrorUploadAsset errorAsset) {
|
||||||
|
state = state.difference({errorAsset});
|
||||||
|
}
|
||||||
|
|
||||||
|
empty() {
|
||||||
|
state = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final errorBackupListProvider =
|
||||||
|
StateNotifierProvider<ErrorBackupListNotifier, Set<ErrorUploadAsset>>(
|
||||||
|
(ref) => ErrorBackupListNotifier(),
|
||||||
|
);
|
||||||
@@ -7,6 +7,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/models/check_duplicate_asset_response.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/shared/services/network.service.dart';
|
import 'package:immich_mobile/shared/services/network.service.dart';
|
||||||
import 'package:immich_mobile/shared/models/device_info.model.dart';
|
import 'package:immich_mobile/shared/models/device_info.model.dart';
|
||||||
import 'package:immich_mobile/utils/files_helper.dart';
|
import 'package:immich_mobile/utils/files_helper.dart';
|
||||||
@@ -20,6 +23,7 @@ final backupServiceProvider =
|
|||||||
|
|
||||||
class BackupService {
|
class BackupService {
|
||||||
final NetworkService _networkService;
|
final NetworkService _networkService;
|
||||||
|
|
||||||
BackupService(this._networkService);
|
BackupService(this._networkService);
|
||||||
|
|
||||||
Future<List<String>> getDeviceBackupAsset() async {
|
Future<List<String>> getDeviceBackupAsset() async {
|
||||||
@@ -32,17 +36,40 @@ class BackupService {
|
|||||||
return result.cast<String>();
|
return result.cast<String>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> checkDuplicateAsset(String deviceAssetId) async {
|
||||||
|
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Response response =
|
||||||
|
await _networkService.postRequest(url: "asset/check", data: {
|
||||||
|
"deviceId": deviceId,
|
||||||
|
"deviceAssetId": deviceAssetId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
var result = CheckDuplicateAssetResponse.fromJson(response.toString());
|
||||||
|
|
||||||
|
return result.isExist;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
backupAsset(
|
backupAsset(
|
||||||
Set<AssetEntity> assetList,
|
Set<AssetEntity> assetList,
|
||||||
http.CancellationToken cancelToken,
|
http.CancellationToken cancelToken,
|
||||||
Function(String, String) singleAssetDoneCb,
|
Function(String, String) singleAssetDoneCb,
|
||||||
Function(int, int) uploadProgress) async {
|
Function(int, int) uploadProgressCb,
|
||||||
|
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
|
||||||
|
Function(ErrorUploadAsset) errorCb,
|
||||||
|
) async {
|
||||||
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||||
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||||
File? file;
|
File? file;
|
||||||
|
|
||||||
http.MultipartFile? thumbnailUploadData;
|
|
||||||
|
|
||||||
for (var entity in assetList) {
|
for (var entity in assetList) {
|
||||||
try {
|
try {
|
||||||
if (entity.type == AssetType.video) {
|
if (entity.type == AssetType.video) {
|
||||||
@@ -69,27 +96,12 @@ class BackupService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build thumbnail multipart data
|
|
||||||
var thumbnailData = await entity
|
|
||||||
.thumbnailDataWithSize(const ThumbnailSize(1440, 2560));
|
|
||||||
if (thumbnailData != null) {
|
|
||||||
thumbnailUploadData = http.MultipartFile.fromBytes(
|
|
||||||
"thumbnailData",
|
|
||||||
List.from(thumbnailData),
|
|
||||||
filename: fileNameWithoutPath,
|
|
||||||
contentType: MediaType(
|
|
||||||
"image",
|
|
||||||
"jpeg",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
var box = Hive.box(userInfoBox);
|
var box = Hive.box(userInfoBox);
|
||||||
|
|
||||||
var req = MultipartRequest(
|
var req = MultipartRequest(
|
||||||
'POST', Uri.parse('$savedEndpoint/asset/upload'),
|
'POST', Uri.parse('$savedEndpoint/asset/upload'),
|
||||||
onProgress: ((bytes, totalBytes) =>
|
onProgress: ((bytes, totalBytes) =>
|
||||||
uploadProgress(bytes, totalBytes)));
|
uploadProgressCb(bytes, totalBytes)));
|
||||||
req.headers["Authorization"] = "Bearer ${box.get(accessTokenKey)}";
|
req.headers["Authorization"] = "Bearer ${box.get(accessTokenKey)}";
|
||||||
|
|
||||||
req.fields['deviceAssetId'] = entity.id;
|
req.fields['deviceAssetId'] = entity.id;
|
||||||
@@ -101,15 +113,37 @@ class BackupService {
|
|||||||
req.fields['fileExtension'] = fileExtension;
|
req.fields['fileExtension'] = fileExtension;
|
||||||
req.fields['duration'] = entity.videoDuration.toString();
|
req.fields['duration'] = entity.videoDuration.toString();
|
||||||
|
|
||||||
if (thumbnailUploadData != null) {
|
|
||||||
req.files.add(thumbnailUploadData);
|
|
||||||
}
|
|
||||||
req.files.add(assetRawUploadData);
|
req.files.add(assetRawUploadData);
|
||||||
|
|
||||||
var res = await req.send(cancellationToken: cancelToken);
|
setCurrentUploadAssetCb(
|
||||||
|
CurrentUploadAsset(
|
||||||
|
id: entity.id,
|
||||||
|
createdAt: entity.createDateTime,
|
||||||
|
fileName: originalFileName,
|
||||||
|
fileType: _getAssetType(entity.type),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if (res.statusCode == 201) {
|
var response = await req.send(cancellationToken: cancelToken);
|
||||||
|
|
||||||
|
if (response.statusCode == 201) {
|
||||||
singleAssetDoneCb(entity.id, deviceId);
|
singleAssetDoneCb(entity.id, deviceId);
|
||||||
|
} else {
|
||||||
|
var data = await response.stream.bytesToString();
|
||||||
|
var error = jsonDecode(data);
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
"Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}");
|
||||||
|
|
||||||
|
errorCb(ErrorUploadAsset(
|
||||||
|
asset: entity,
|
||||||
|
id: entity.id,
|
||||||
|
createdAt: entity.createDateTime,
|
||||||
|
fileName: originalFileName,
|
||||||
|
fileType: _getAssetType(entity.type),
|
||||||
|
errorMessage: error['error'],
|
||||||
|
));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} on http.CancelledException {
|
} on http.CancelledException {
|
||||||
@@ -126,6 +160,8 @@ class BackupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void sendBackupRequest(AssetEntity entity) {}
|
||||||
|
|
||||||
String _getAssetType(AssetType assetType) {
|
String _getAssetType(AssetType assetType) {
|
||||||
switch (assetType) {
|
switch (assetType) {
|
||||||
case AssetType.audio:
|
case AssetType.audio:
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Container();
|
return const SizedBox();
|
||||||
}
|
}
|
||||||
|
|
||||||
_buildImageFilter() {
|
_buildImageFilter() {
|
||||||
@@ -151,7 +151,11 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: null,
|
child: null,
|
||||||
),
|
),
|
||||||
Positioned(bottom: 10, left: 25, child: _buildSelectedTextBox())
|
Positioned(
|
||||||
|
bottom: 10,
|
||||||
|
left: 25,
|
||||||
|
child: _buildSelectedTextBox(),
|
||||||
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
@@ -176,8 +180,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 2.0),
|
padding: const EdgeInsets.only(top: 2.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
albumInfo.assetCount.toString() +
|
'${albumInfo.assetCount} ${(albumInfo.isAll ? " (ALL)" : "")}',
|
||||||
(albumInfo.isAll ? " (ALL)" : ""),
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12, color: Colors.grey[600]),
|
fontSize: 12, color: Colors.grey[600]),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -188,11 +188,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
|||||||
color: Colors.grey[700]),
|
color: Colors.grey[700]),
|
||||||
),
|
),
|
||||||
trailing: Text(
|
trailing: Text(
|
||||||
ref
|
'${ref.watch(backupProvider).allUniqueAssets.length}',
|
||||||
.watch(backupProvider)
|
|
||||||
.allUniqueAssets
|
|
||||||
.length
|
|
||||||
.toString(),
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -203,7 +199,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
"Albums on device (${availableAlbums.length.toString()})",
|
"Albums on device (${availableAlbums.length})",
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||||
),
|
),
|
||||||
subtitle: Padding(
|
subtitle: Padding(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
|
||||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||||
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
|
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
|
||||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||||
@@ -9,6 +10,7 @@ import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
|||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||||
import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart';
|
import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
import 'package:percent_indicator/linear_percent_indicator.dart';
|
import 'package:percent_indicator/linear_percent_indicator.dart';
|
||||||
|
|
||||||
class BackupControllerPage extends HookConsumerWidget {
|
class BackupControllerPage extends HookConsumerWidget {
|
||||||
@@ -42,7 +44,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
color: Theme.of(context).primaryColor,
|
color: Theme.of(context).primaryColor,
|
||||||
),
|
),
|
||||||
title: const Text(
|
title: const Text(
|
||||||
"Server Storage",
|
"Server storage",
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||||
),
|
),
|
||||||
subtitle: Padding(
|
subtitle: Padding(
|
||||||
@@ -56,7 +58,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
padding:
|
padding:
|
||||||
const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
|
const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
|
||||||
barRadius: const Radius.circular(2),
|
barRadius: const Radius.circular(2),
|
||||||
lineHeight: 6.0,
|
lineHeight: 10.0,
|
||||||
percent: backupState.serverInfo.diskUsagePercentage / 100.0,
|
percent: backupState.serverInfo.diskUsagePercentage / 100.0,
|
||||||
backgroundColor: Colors.grey,
|
backgroundColor: Colors.grey,
|
||||||
progressColor: Theme.of(context).primaryColor,
|
progressColor: Theme.of(context).primaryColor,
|
||||||
@@ -96,12 +98,11 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
!isAutoBackup
|
if (!isAutoBackup)
|
||||||
? const Text(
|
const Text(
|
||||||
"Turn on backup to automatically upload new assets to the server.",
|
"Turn on backup to automatically upload new assets to the server.",
|
||||||
style: TextStyle(fontSize: 14),
|
style: TextStyle(fontSize: 14),
|
||||||
)
|
),
|
||||||
: Container(),
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
@@ -189,7 +190,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return Container();
|
return const SizedBox();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +248,141 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_buildCurrentBackupAssetInfoCard() {
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Icons.info_outline_rounded,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
"Uploading file info",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||||
|
),
|
||||||
|
if (ref.watch(errorBackupListProvider).isNotEmpty)
|
||||||
|
ActionChip(
|
||||||
|
avatar: Icon(
|
||||||
|
Icons.info,
|
||||||
|
size: 24,
|
||||||
|
color: Colors.red[400],
|
||||||
|
),
|
||||||
|
elevation: 1,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
label: Text(
|
||||||
|
"Failed (${ref.watch(errorBackupListProvider).length})",
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.red[400],
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
onPressed: () {
|
||||||
|
AutoRouter.of(context).push(const FailedBackupStatusRoute());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: LinearPercentIndicator(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
|
||||||
|
barRadius: const Radius.circular(2),
|
||||||
|
lineHeight: 10.0,
|
||||||
|
trailing: Text(
|
||||||
|
" ${backupState.progressInPercentage.toStringAsFixed(0)}%",
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
percent: backupState.progressInPercentage / 100.0,
|
||||||
|
backgroundColor: Colors.grey,
|
||||||
|
progressColor: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Table(
|
||||||
|
border: TableBorder.all(
|
||||||
|
color: Colors.black12,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TableRow(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[100],
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TableCell(
|
||||||
|
verticalAlignment: TableCellVerticalAlignment.middle,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(6.0),
|
||||||
|
child: Text(
|
||||||
|
'File name: ${backupState.currentUploadAsset.fileName} [${backupState.currentUploadAsset.fileType.toLowerCase()}]',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold, fontSize: 10.0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
TableRow(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[200],
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TableCell(
|
||||||
|
verticalAlignment: TableCellVerticalAlignment.middle,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(6.0),
|
||||||
|
child: Text(
|
||||||
|
"Created on: ${DateFormat.yMMMMd('en_US').format(
|
||||||
|
DateTime.parse(
|
||||||
|
backupState.currentUploadAsset.createdAt
|
||||||
|
.toString(),
|
||||||
|
),
|
||||||
|
)}",
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold, fontSize: 10.0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
TableRow(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[100],
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TableCell(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(6.0),
|
||||||
|
child: Text(
|
||||||
|
"ID: ${backupState.currentUploadAsset.id}",
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 10.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void startBackup() {
|
||||||
|
ref.watch(errorBackupListProvider.notifier).empty();
|
||||||
|
ref.watch(backupProvider.notifier).startBackupProcess();
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
@@ -265,7 +401,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
body: Padding(
|
body: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 32),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
// crossAxisAlignment: CrossAxisAlignment.start,
|
// crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -298,23 +434,11 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
const Divider(),
|
const Divider(),
|
||||||
_buildStorageInformation(),
|
_buildStorageInformation(),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
_buildCurrentBackupAssetInfoCard(),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.only(
|
||||||
child: Text(
|
top: 24,
|
||||||
"Asset that were being backup: ${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length} [${backupState.progressInPercentage.toStringAsFixed(0)}%]"),
|
),
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 8.0),
|
|
||||||
child: Row(children: [
|
|
||||||
const Text("Backup Progress:"),
|
|
||||||
const Padding(padding: EdgeInsets.symmetric(horizontal: 2)),
|
|
||||||
backupState.backupProgress == BackUpProgressEnum.inProgress
|
|
||||||
? const CircularProgressIndicator.adaptive()
|
|
||||||
: const Text("Done"),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: Container(
|
child: Container(
|
||||||
child:
|
child:
|
||||||
backupState.backupProgress == BackUpProgressEnum.inProgress
|
backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||||
@@ -322,25 +446,33 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
primary: Colors.red[300],
|
primary: Colors.red[300],
|
||||||
onPrimary: Colors.grey[50],
|
onPrimary: Colors.grey[50],
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref.read(backupProvider.notifier).cancelBackup();
|
ref.read(backupProvider.notifier).cancelBackup();
|
||||||
},
|
},
|
||||||
child: const Text("Cancel"),
|
child: const Text(
|
||||||
|
"CANCEL",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: ElevatedButton(
|
: ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
primary: Theme.of(context).primaryColor,
|
primary: Theme.of(context).primaryColor,
|
||||||
onPrimary: Colors.grey[50],
|
onPrimary: Colors.grey[50],
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
),
|
||||||
|
onPressed: shouldBackup ? startBackup : null,
|
||||||
|
child: const Text(
|
||||||
|
"START BACKUP",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onPressed: shouldBackup
|
|
||||||
? () {
|
|
||||||
ref
|
|
||||||
.read(backupProvider.notifier)
|
|
||||||
.startBackupProcess();
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
child: const Text("Start Backup"),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
139
mobile/lib/modules/backup/views/failed_backup_status_page.dart
Normal file
139
mobile/lib/modules/backup/views/failed_backup_status_page.dart
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
|
class FailedBackupStatusPage extends HookConsumerWidget {
|
||||||
|
const FailedBackupStatusPage({Key? key}) : super(key: key);
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final errorBackupList = ref.watch(errorBackupListProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
elevation: 0,
|
||||||
|
title: Text(
|
||||||
|
"Failed Backup (${errorBackupList.length})",
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
leading: IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
AutoRouter.of(context).pop(true);
|
||||||
|
},
|
||||||
|
splashRadius: 24,
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.arrow_back_ios_rounded,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
body: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: errorBackupList.length,
|
||||||
|
itemBuilder: ((context, index) {
|
||||||
|
var errorAsset = errorBackupList.elementAt(index);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12.0,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
child: Card(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(15), // if you need this
|
||||||
|
side: const BorderSide(
|
||||||
|
color: Colors.black12,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
minWidth: 100,
|
||||||
|
minHeight: 150,
|
||||||
|
maxWidth: 100,
|
||||||
|
maxHeight: 200,
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(15),
|
||||||
|
topLeft: Radius.circular(15),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
child: Image(
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
image: AssetEntityImageProvider(
|
||||||
|
errorAsset.asset,
|
||||||
|
isOriginal: false,
|
||||||
|
thumbnailSize: const ThumbnailSize.square(512),
|
||||||
|
thumbnailFormat: ThumbnailFormat.jpeg,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
DateFormat.yMMMMd('en_US').format(
|
||||||
|
DateTime.parse(
|
||||||
|
errorAsset.createdAt.toString(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.grey[700]),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.error,
|
||||||
|
color: Colors.red.withAlpha(200),
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Text(
|
||||||
|
errorAsset.fileName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 12,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
errorAsset.errorMessage,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.grey[800],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ class DisableMultiSelectButton extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
icon: const Icon(Icons.close_rounded),
|
icon: const Icon(Icons.close_rounded),
|
||||||
label: Text(
|
label: Text(
|
||||||
selectedItemCount.toString(),
|
'$selectedItemCount',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.w600, fontSize: 18),
|
fontWeight: FontWeight.w600, fontSize: 18),
|
||||||
)),
|
)),
|
||||||
|
|||||||
@@ -615,7 +615,7 @@ class SlideFadeTransition extends StatelessWidget {
|
|||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: animation,
|
animation: animation,
|
||||||
builder: (context, child) =>
|
builder: (context, child) =>
|
||||||
animation.value == 0.0 ? Container() : child!,
|
animation.value == 0.0 ? const SizedBox() : child!,
|
||||||
child: SlideTransition(
|
child: SlideTransition(
|
||||||
position: Tween(
|
position: Tween(
|
||||||
begin: const Offset(0.3, 0.0),
|
begin: const Offset(0.3, 0.0),
|
||||||
|
|||||||
@@ -25,30 +25,26 @@ class ImageGrid extends ConsumerWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
ThumbnailImage(asset: assetGroup[index]),
|
ThumbnailImage(asset: assetGroup[index]),
|
||||||
assetType == 'IMAGE'
|
if (assetType != 'IMAGE')
|
||||||
? Container()
|
Positioned(
|
||||||
: Positioned(
|
top: 5,
|
||||||
top: 5,
|
right: 5,
|
||||||
right: 5,
|
child: Row(
|
||||||
child: Row(
|
children: [
|
||||||
children: [
|
Text(
|
||||||
Text(
|
assetGroup[index].duration.toString().substring(0, 7),
|
||||||
assetGroup[index]
|
style: const TextStyle(
|
||||||
.duration
|
color: Colors.white,
|
||||||
.toString()
|
fontSize: 10,
|
||||||
.substring(0, 7),
|
),
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 10,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Icon(
|
|
||||||
Icons.play_circle_outline_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
)
|
const Icon(
|
||||||
|
Icons.play_circle_outline_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,30 +49,29 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
serverInfoState.isVersionMismatch
|
if (serverInfoState.isVersionMismatch)
|
||||||
? Positioned(
|
Positioned(
|
||||||
bottom: 12,
|
bottom: 12,
|
||||||
right: 12,
|
right: 12,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => Scaffold.of(context).openDrawer(),
|
onTap: () => Scaffold.of(context).openDrawer(),
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.grey[200],
|
color: Colors.grey[200],
|
||||||
elevation: 1,
|
elevation: 1,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(50.0),
|
borderRadius: BorderRadius.circular(50.0),
|
||||||
),
|
),
|
||||||
child: const Padding(
|
child: const Padding(
|
||||||
padding: EdgeInsets.all(2.0),
|
padding: EdgeInsets.all(2.0),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.info,
|
Icons.info,
|
||||||
color: Color.fromARGB(255, 243, 188, 106),
|
color: Color.fromARGB(255, 243, 188, 106),
|
||||||
size: 15,
|
size: 15,
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
: Container(),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -90,21 +89,20 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
Stack(
|
Stack(
|
||||||
alignment: AlignmentDirectional.center,
|
alignment: AlignmentDirectional.center,
|
||||||
children: [
|
children: [
|
||||||
backupState.backupProgress == BackUpProgressEnum.inProgress
|
if (backupState.backupProgress == BackUpProgressEnum.inProgress)
|
||||||
? Positioned(
|
Positioned(
|
||||||
top: 10,
|
top: 10,
|
||||||
right: 12,
|
right: 12,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 8,
|
height: 8,
|
||||||
width: 8,
|
width: 8,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 1,
|
strokeWidth: 1,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
Theme.of(context).primaryColor),
|
Theme.of(context).primaryColor),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
: Container(),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
splashRadius: 25,
|
splashRadius: 25,
|
||||||
iconSize: 30,
|
iconSize: 30,
|
||||||
@@ -129,18 +127,15 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
backupState.backupProgress == BackUpProgressEnum.inProgress
|
if (backupState.backupProgress == BackUpProgressEnum.inProgress)
|
||||||
? Positioned(
|
Positioned(
|
||||||
bottom: 5,
|
bottom: 5,
|
||||||
child: Text(
|
child: Text(
|
||||||
(backupState.allUniqueAssets.length -
|
'${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}',
|
||||||
backupState.selectedAlbumsBackupAssetsIds.length)
|
style:
|
||||||
.toString(),
|
const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
|
||||||
style: const TextStyle(
|
),
|
||||||
fontSize: 9, fontWeight: FontWeight.bold),
|
),
|
||||||
),
|
|
||||||
)
|
|
||||||
: Container()
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ class ProfileDrawer extends HookConsumerWidget {
|
|||||||
return const ImmichLoadingIndicator();
|
return const ImmichLoadingIndicator();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Container();
|
return const SizedBox();
|
||||||
}
|
}
|
||||||
|
|
||||||
_pickUserProfileImage() async {
|
_pickUserProfileImage() async {
|
||||||
|
|||||||
@@ -122,17 +122,14 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
if (isMultiSelectEnable)
|
||||||
child: isMultiSelectEnable
|
Padding(
|
||||||
? Padding(
|
padding: const EdgeInsets.all(3.0),
|
||||||
padding: const EdgeInsets.all(3.0),
|
child: Align(
|
||||||
child: Align(
|
alignment: Alignment.topLeft,
|
||||||
alignment: Alignment.topLeft,
|
child: _buildSelectionIcon(asset),
|
||||||
child: _buildSelectionIcon(asset),
|
),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
: Container(),
|
|
||||||
),
|
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 10,
|
right: 10,
|
||||||
bottom: 5,
|
bottom: 5,
|
||||||
|
|||||||
@@ -38,17 +38,10 @@ class HomePage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_buildSelectedItemCountIndicator() {
|
_buildSelectedItemCountIndicator() {
|
||||||
return isMultiSelectEnable
|
return DisableMultiSelectButton(
|
||||||
? DisableMultiSelectButton(
|
onPressed: ref.watch(homePageStateProvider.notifier).disableMultiSelect,
|
||||||
onPressed:
|
selectedItemCount: homePageState.selectedItems.length,
|
||||||
ref.watch(homePageStateProvider.notifier).disableMultiSelect,
|
);
|
||||||
selectedItemCount: homePageState.selectedItems.length,
|
|
||||||
)
|
|
||||||
: Container();
|
|
||||||
}
|
|
||||||
|
|
||||||
_buildBottomAppBar() {
|
|
||||||
return isMultiSelectEnable ? const ControlBottomAppBar() : Container();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody() {
|
Widget _buildBody() {
|
||||||
@@ -121,8 +114,10 @@ class HomePage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_buildSelectedItemCountIndicator(),
|
if (isMultiSelectEnable) ...[
|
||||||
_buildBottomAppBar(),
|
_buildSelectedItemCountIndicator(),
|
||||||
|
const ControlBottomAppBar(),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class ChangePasswordForm extends HookConsumerWidget {
|
|||||||
final confirmPasswordController =
|
final confirmPasswordController =
|
||||||
useTextEditingController.fromValue(TextEditingValue.empty);
|
useTextEditingController.fromValue(TextEditingValue.empty);
|
||||||
final authState = ref.watch(authenticationProvider);
|
final authState = ref.watch(authenticationProvider);
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
@@ -47,15 +48,24 @@ class ChangePasswordForm extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PasswordInput(controller: passwordController),
|
Form(
|
||||||
ConfirmPasswordInput(
|
key: formKey,
|
||||||
originalController: passwordController,
|
child: Column(
|
||||||
confirmController: confirmPasswordController,
|
children: [
|
||||||
),
|
PasswordInput(controller: passwordController),
|
||||||
Align(
|
Padding(
|
||||||
alignment: Alignment.center,
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
child: ChangePasswordButton(
|
child: ConfirmPasswordInput(
|
||||||
passwordController: passwordController),
|
originalController: passwordController,
|
||||||
|
confirmController: confirmPasswordController,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ChangePasswordButton(
|
||||||
|
passwordController: passwordController,
|
||||||
|
formKey: formKey,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -119,10 +129,12 @@ class ConfirmPasswordInput extends StatelessWidget {
|
|||||||
|
|
||||||
class ChangePasswordButton extends ConsumerWidget {
|
class ChangePasswordButton extends ConsumerWidget {
|
||||||
final TextEditingController passwordController;
|
final TextEditingController passwordController;
|
||||||
|
final GlobalKey<FormState> formKey;
|
||||||
|
|
||||||
const ChangePasswordButton({
|
const ChangePasswordButton({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.passwordController,
|
required this.passwordController,
|
||||||
|
required this.formKey,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -136,19 +148,21 @@ class ChangePasswordButton extends ConsumerWidget {
|
|||||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25),
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25),
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
var isSuccess = await ref
|
if (formKey.currentState!.validate()) {
|
||||||
.watch(authenticationProvider.notifier)
|
var isSuccess = await ref
|
||||||
.changePassword(passwordController.value.text);
|
.watch(authenticationProvider.notifier)
|
||||||
|
.changePassword(passwordController.value.text);
|
||||||
|
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
bool res =
|
bool res =
|
||||||
await ref.watch(authenticationProvider.notifier).logout();
|
await ref.watch(authenticationProvider.notifier).logout();
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
ref.watch(backupProvider.notifier).cancelBackup();
|
ref.watch(backupProvider.notifier).cancelBackup();
|
||||||
ref.watch(assetProvider.notifier).clearAllAsset();
|
ref.watch(assetProvider.notifier).clearAllAsset();
|
||||||
ref.watch(websocketProvider.notifier).disconnect();
|
ref.watch(websocketProvider.notifier).disconnect();
|
||||||
AutoRouter.of(context).replace(const LoginRoute());
|
AutoRouter.of(context).replace(const LoginRoute());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -107,19 +107,12 @@ class ServerEndpointInput extends StatelessWidget {
|
|||||||
: super(key: key);
|
: super(key: key);
|
||||||
|
|
||||||
String? _validateInput(String? url) {
|
String? _validateInput(String? url) {
|
||||||
if (url == null) {
|
|
||||||
|
if (url?.startsWith(RegExp(r'https?://')) == true) {
|
||||||
return null;
|
return null;
|
||||||
}
|
} else {
|
||||||
|
|
||||||
if (url.isEmpty) {
|
|
||||||
return 'Server endpoint is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!url.startsWith(RegExp(r'https?://'))) {
|
|
||||||
return 'Please specify http:// or https://';
|
return 'Please specify http:// or https://';
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -219,7 +212,8 @@ class LoginButton extends ConsumerWidget {
|
|||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
// Resume backup (if enable) then navigate
|
// Resume backup (if enable) then navigate
|
||||||
|
|
||||||
if (ref.watch(authenticationProvider).shouldChangePassword) {
|
if (ref.watch(authenticationProvider).shouldChangePassword &&
|
||||||
|
!ref.watch(authenticationProvider).isAdmin) {
|
||||||
AutoRouter.of(context).push(const ChangePasswordRoute());
|
AutoRouter.of(context).push(const ChangePasswordRoute());
|
||||||
} else {
|
} else {
|
||||||
ref.watch(backupProvider.notifier).resumeBackup();
|
ref.watch(backupProvider.notifier).resumeBackup();
|
||||||
|
|||||||
@@ -62,11 +62,7 @@ final getCuratedLocationProvider =
|
|||||||
final SearchService searchService = ref.watch(searchServiceProvider);
|
final SearchService searchService = ref.watch(searchServiceProvider);
|
||||||
|
|
||||||
var curatedLocation = await searchService.getCuratedLocation();
|
var curatedLocation = await searchService.getCuratedLocation();
|
||||||
if (curatedLocation != null) {
|
return curatedLocation ?? [];
|
||||||
return curatedLocation;
|
|
||||||
} else {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final getCuratedObjectProvider =
|
final getCuratedObjectProvider =
|
||||||
@@ -74,9 +70,6 @@ final getCuratedObjectProvider =
|
|||||||
final SearchService searchService = ref.watch(searchServiceProvider);
|
final SearchService searchService = ref.watch(searchServiceProvider);
|
||||||
|
|
||||||
var curatedObject = await searchService.getCuratedObjects();
|
var curatedObject = await searchService.getCuratedObjects();
|
||||||
if (curatedObject != null) {
|
|
||||||
return curatedObject;
|
return curatedObject ?? [];
|
||||||
} else {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -176,9 +176,8 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
_buildThings()
|
_buildThings()
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
isSearchEnabled
|
if (isSearchEnabled)
|
||||||
? SearchSuggestionList(onSubmitted: _onSearchSubmitted)
|
SearchSuggestionList(onSubmitted: _onSearchSubmitted),
|
||||||
: Container(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ class SearchResultPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Container();
|
return const SizedBox();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -198,9 +198,8 @@ class SearchResultPage extends HookConsumerWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
_buildSearchResult(),
|
_buildSearchResult(),
|
||||||
isNewSearch.value
|
if (isNewSearch.value)
|
||||||
? SearchSuggestionList(onSubmitted: _onSearchSubmitted)
|
SearchSuggestionList(onSubmitted: _onSearchSubmitted),
|
||||||
: Container(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -39,11 +39,7 @@ class SharedAlbumService {
|
|||||||
"assetIds": assets.map((asset) => asset.id).toList(),
|
"assetIds": assets.map((asset) => asset.id).toList(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res == null) {
|
return res != null;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Error createSharedAlbum ${e.toString()}");
|
debugPrint("Error createSharedAlbum ${e.toString()}");
|
||||||
return false;
|
return false;
|
||||||
@@ -71,11 +67,7 @@ class SharedAlbumService {
|
|||||||
"assetIds": assets.map((asset) => asset.id).toList(),
|
"assetIds": assets.map((asset) => asset.id).toList(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res == null) {
|
return res != null;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
|
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
|
||||||
return false;
|
return false;
|
||||||
@@ -90,11 +82,7 @@ class SharedAlbumService {
|
|||||||
"sharedUserIds": sharedUserIds,
|
"sharedUserIds": sharedUserIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res == null) {
|
return res != null;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Error addAdditionalUserToAlbum ${e.toString()}");
|
debugPrint("Error addAdditionalUserToAlbum ${e.toString()}");
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
|
|||||||
onTap: () => _onRemoveFromAlbumPressed(albumId),
|
onTap: () => _onRemoveFromAlbumPressed(albumId),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return Container();
|
return const SizedBox();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (_albumInfo.asData?.value.ownerId == userId) {
|
if (_albumInfo.asData?.value.ownerId == userId) {
|
||||||
@@ -198,8 +198,8 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
leading: _buildLeadingButton(),
|
leading: _buildLeadingButton(),
|
||||||
title: isMultiSelectionEnable
|
title: isMultiSelectionEnable
|
||||||
? Text(selectedAssetsInAlbum.length.toString())
|
? Text('${selectedAssetsInAlbum.length}')
|
||||||
: Container(),
|
: null,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@@ -71,29 +71,25 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_buildVideoLabel() {
|
_buildVideoLabel() {
|
||||||
if (asset.type == 'IMAGE') {
|
return Positioned(
|
||||||
return Container();
|
top: 5,
|
||||||
} else {
|
right: 5,
|
||||||
return Positioned(
|
child: Row(
|
||||||
top: 5,
|
children: [
|
||||||
right: 5,
|
Text(
|
||||||
child: Row(
|
asset.duration.toString().substring(0, 7),
|
||||||
children: [
|
style: const TextStyle(
|
||||||
Text(
|
|
||||||
asset.duration.toString().substring(0, 7),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 10,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Icon(
|
|
||||||
Icons.play_circle_outline_rounded,
|
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
const Icon(
|
||||||
);
|
Icons.play_circle_outline_rounded,
|
||||||
}
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_buildAssetStoreLocationIcon() {
|
_buildAssetStoreLocationIcon() {
|
||||||
@@ -112,23 +108,20 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
|||||||
|
|
||||||
_buildAssetSelectionIcon() {
|
_buildAssetSelectionIcon() {
|
||||||
bool isSelected = selectedAssetsInAlbumViewer.contains(asset);
|
bool isSelected = selectedAssetsInAlbumViewer.contains(asset);
|
||||||
if (isMultiSelectionEnable) {
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: 10,
|
left: 10,
|
||||||
top: 5,
|
top: 5,
|
||||||
child: isSelected
|
child: isSelected
|
||||||
? Icon(
|
? Icon(
|
||||||
Icons.check_circle_rounded,
|
Icons.check_circle_rounded,
|
||||||
color: Theme.of(context).primaryColor,
|
color: Theme.of(context).primaryColor,
|
||||||
)
|
)
|
||||||
: const Icon(
|
: const Icon(
|
||||||
Icons.check_circle_outline_rounded,
|
Icons.check_circle_outline_rounded,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
return Container();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_buildThumbnailImage() {
|
_buildThumbnailImage() {
|
||||||
@@ -183,8 +176,8 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
_buildThumbnailImage(),
|
_buildThumbnailImage(),
|
||||||
_buildAssetStoreLocationIcon(),
|
_buildAssetStoreLocationIcon(),
|
||||||
_buildVideoLabel(),
|
if (asset.type != 'IMAGE') _buildVideoLabel(),
|
||||||
_buildAssetSelectionIcon(),
|
if (isMultiSelectionEnable) _buildAssetSelectionIcon(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -131,27 +131,26 @@ class SelectionThumbnailImage extends HookConsumerWidget {
|
|||||||
child: _buildSelectionIcon(asset),
|
child: _buildSelectionIcon(asset),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
asset.type == 'IMAGE'
|
if (asset.type != 'IMAGE')
|
||||||
? Container()
|
Positioned(
|
||||||
: Positioned(
|
bottom: 5,
|
||||||
bottom: 5,
|
right: 5,
|
||||||
right: 5,
|
child: Row(
|
||||||
child: Row(
|
children: [
|
||||||
children: [
|
Text(
|
||||||
Text(
|
'${asset.duration?.substring(0, 7)}',
|
||||||
asset.duration.toString().substring(0, 7),
|
style: const TextStyle(
|
||||||
style: const TextStyle(
|
color: Colors.white,
|
||||||
color: Colors.white,
|
fontSize: 10,
|
||||||
fontSize: 10,
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
const Icon(
|
|
||||||
Icons.play_circle_outline_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
)
|
const Icon(
|
||||||
|
Icons.play_circle_outline_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ class SharingSliverAppBar extends StatelessWidget {
|
|||||||
floating: false,
|
floating: false,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
snap: false,
|
snap: false,
|
||||||
leading: Container(),
|
automaticallyImplyLeading: false,
|
||||||
|
// leading: Container(),
|
||||||
// elevation: 0,
|
// elevation: 0,
|
||||||
title: Text(
|
title: Text(
|
||||||
'IMMICH',
|
'IMMICH',
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||||||
/// Find out if the assets in album exist on the device
|
/// Find out if the assets in album exist on the device
|
||||||
/// If they exist, add to selected asset state to show they are already selected.
|
/// If they exist, add to selected asset state to show they are already selected.
|
||||||
void _onAddPhotosPressed(SharedAlbum albumInfo) async {
|
void _onAddPhotosPressed(SharedAlbum albumInfo) async {
|
||||||
if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) {
|
if (albumInfo.assets?.isNotEmpty == true) {
|
||||||
ref
|
ref
|
||||||
.watch(assetSelectionProvider.notifier)
|
.watch(assetSelectionProvider.notifier)
|
||||||
.addNewAssets(albumInfo.assets!.toList());
|
.addNewAssets(albumInfo.assets!.toList());
|
||||||
@@ -109,32 +109,28 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAlbumDateRange(SharedAlbum albumInfo) {
|
Widget _buildAlbumDateRange(SharedAlbum albumInfo) {
|
||||||
if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) {
|
String startDate = "";
|
||||||
String startDate = "";
|
DateTime parsedStartDate =
|
||||||
DateTime parsedStartDate =
|
DateTime.parse(albumInfo.assets!.first.createdAt);
|
||||||
DateTime.parse(albumInfo.assets!.first.createdAt);
|
DateTime parsedEndDate = DateTime.parse(
|
||||||
DateTime parsedEndDate =
|
albumInfo.assets?.last.createdAt ?? '11111111'); //Need default.
|
||||||
DateTime.parse(albumInfo.assets!.last.createdAt);
|
|
||||||
|
|
||||||
if (parsedStartDate.year == parsedEndDate.year) {
|
if (parsedStartDate.year == parsedEndDate.year) {
|
||||||
startDate = DateFormat('LLL d').format(parsedStartDate);
|
startDate = DateFormat('LLL d').format(parsedStartDate);
|
||||||
} else {
|
|
||||||
startDate = DateFormat('LLL d, y').format(parsedStartDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
String endDate = DateFormat('LLL d, y').format(parsedEndDate);
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 16.0, top: 8),
|
|
||||||
child: Text(
|
|
||||||
"$startDate-$endDate",
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14, fontWeight: FontWeight.bold, color: Colors.grey),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
return Container();
|
startDate = DateFormat('LLL d, y').format(parsedStartDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String endDate = DateFormat('LLL d, y').format(parsedEndDate);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16.0, top: 8),
|
||||||
|
child: Text(
|
||||||
|
"$startDate-$endDate",
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14, fontWeight: FontWeight.bold, color: Colors.grey),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHeader(SharedAlbum albumInfo) {
|
Widget _buildHeader(SharedAlbum albumInfo) {
|
||||||
@@ -143,7 +139,8 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildTitle(albumInfo),
|
_buildTitle(albumInfo),
|
||||||
_buildAlbumDateRange(albumInfo),
|
if (albumInfo.assets?.isNotEmpty == true)
|
||||||
|
_buildAlbumDateRange(albumInfo),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 60,
|
height: 60,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
@@ -175,7 +172,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildImageGrid(SharedAlbum albumInfo) {
|
Widget _buildImageGrid(SharedAlbum albumInfo) {
|
||||||
if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) {
|
if (albumInfo.assets?.isNotEmpty == true) {
|
||||||
return SliverPadding(
|
return SliverPadding(
|
||||||
padding: const EdgeInsets.only(top: 10.0),
|
padding: const EdgeInsets.only(top: 10.0),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
@@ -209,13 +206,12 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||||||
onPressed: () => _onAddPhotosPressed(albumInfo),
|
onPressed: () => _onAddPhotosPressed(albumInfo),
|
||||||
labelText: "Add photos",
|
labelText: "Add photos",
|
||||||
),
|
),
|
||||||
userId == albumInfo.ownerId
|
if (userId == albumInfo.ownerId)
|
||||||
? AlbumActionOutlinedButton(
|
AlbumActionOutlinedButton(
|
||||||
iconData: Icons.person_add_alt_rounded,
|
iconData: Icons.person_add_alt_rounded,
|
||||||
onPressed: () => _onAddUsersPressed(albumInfo),
|
onPressed: () => _onAddUsersPressed(albumInfo),
|
||||||
labelText: "Add users",
|
labelText: "Add users",
|
||||||
)
|
),
|
||||||
: Container(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -74,23 +74,22 @@ class AssetSelectionPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
actions: [
|
actions: [
|
||||||
(!isAlbumExist && selectedAssets.isNotEmpty) ||
|
if ((!isAlbumExist && selectedAssets.isNotEmpty) ||
|
||||||
(isAlbumExist && newAssetsForAlbum.isNotEmpty)
|
(isAlbumExist && newAssetsForAlbum.isNotEmpty))
|
||||||
? TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
var payload = AssetSelectionPageResult(
|
var payload = AssetSelectionPageResult(
|
||||||
isAlbumExist: isAlbumExist,
|
isAlbumExist: isAlbumExist,
|
||||||
selectedAdditionalAsset: newAssetsForAlbum,
|
selectedAdditionalAsset: newAssetsForAlbum,
|
||||||
selectedNewAsset: selectedAssets,
|
selectedNewAsset: selectedAssets,
|
||||||
);
|
);
|
||||||
AutoRouter.of(context).pop(payload);
|
AutoRouter.of(context).pop(payload);
|
||||||
},
|
},
|
||||||
child: const Text(
|
child: const Text(
|
||||||
"Add",
|
"Add",
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
: Container()
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: _buildBody(),
|
body: _buildBody(),
|
||||||
|
|||||||
@@ -113,26 +113,22 @@ class CreateSharedAlbumPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_buildControlButton() {
|
_buildControlButton() {
|
||||||
if (selectedAssets.isNotEmpty) {
|
return Padding(
|
||||||
return Padding(
|
padding: const EdgeInsets.only(left: 12.0, top: 16, bottom: 16),
|
||||||
padding: const EdgeInsets.only(left: 12.0, top: 16, bottom: 16),
|
child: SizedBox(
|
||||||
child: SizedBox(
|
height: 30,
|
||||||
height: 30,
|
child: ListView(
|
||||||
child: ListView(
|
scrollDirection: Axis.horizontal,
|
||||||
scrollDirection: Axis.horizontal,
|
children: [
|
||||||
children: [
|
AlbumActionOutlinedButton(
|
||||||
AlbumActionOutlinedButton(
|
iconData: Icons.add_photo_alternate_outlined,
|
||||||
iconData: Icons.add_photo_alternate_outlined,
|
onPressed: _onSelectPhotosButtonPressed,
|
||||||
onPressed: _onSelectPhotosButtonPressed,
|
labelText: "Add photos",
|
||||||
labelText: "Add photos",
|
),
|
||||||
),
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
|
|
||||||
return Container();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_buildSelectedImageGrid() {
|
_buildSelectedImageGrid() {
|
||||||
@@ -196,7 +192,8 @@ class CreateSharedAlbumPage extends HookConsumerWidget {
|
|||||||
slivers: [
|
slivers: [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
elevation: 5,
|
elevation: 5,
|
||||||
leading: Container(),
|
automaticallyImplyLeading: false,
|
||||||
|
// leading: Container(),
|
||||||
pinned: true,
|
pinned: true,
|
||||||
floating: false,
|
floating: false,
|
||||||
bottom: PreferredSize(
|
bottom: PreferredSize(
|
||||||
@@ -204,7 +201,7 @@ class CreateSharedAlbumPage extends HookConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildTitleInputField(),
|
_buildTitleInputField(),
|
||||||
_buildControlButton(),
|
if (selectedAssets.isNotEmpty) _buildControlButton(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
|
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
|
||||||
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
|
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/views/failed_backup_status_page.dart';
|
||||||
import 'package:immich_mobile/modules/login/views/change_password_page.dart';
|
import 'package:immich_mobile/modules/login/views/change_password_page.dart';
|
||||||
import 'package:immich_mobile/modules/login/views/login_page.dart';
|
import 'package:immich_mobile/modules/login/views/login_page.dart';
|
||||||
import 'package:immich_mobile/modules/home/views/home_page.dart';
|
import 'package:immich_mobile/modules/home/views/home_page.dart';
|
||||||
@@ -65,6 +66,11 @@ part 'router.gr.dart';
|
|||||||
),
|
),
|
||||||
AutoRoute(page: BackupAlbumSelectionPage, guards: [AuthGuard]),
|
AutoRoute(page: BackupAlbumSelectionPage, guards: [AuthGuard]),
|
||||||
AutoRoute(page: AlbumPreviewPage, guards: [AuthGuard]),
|
AutoRoute(page: AlbumPreviewPage, guards: [AuthGuard]),
|
||||||
|
CustomRoute(
|
||||||
|
page: FailedBackupStatusPage,
|
||||||
|
guards: [AuthGuard],
|
||||||
|
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppRouter extends _$AppRouter {
|
class AppRouter extends _$AppRouter {
|
||||||
|
|||||||
@@ -115,6 +115,14 @@ class _$AppRouter extends RootStackRouter {
|
|||||||
routeData: routeData,
|
routeData: routeData,
|
||||||
child: AlbumPreviewPage(key: args.key, album: args.album));
|
child: AlbumPreviewPage(key: args.key, album: args.album));
|
||||||
},
|
},
|
||||||
|
FailedBackupStatusRoute.name: (routeData) {
|
||||||
|
return CustomPage<dynamic>(
|
||||||
|
routeData: routeData,
|
||||||
|
child: const FailedBackupStatusPage(),
|
||||||
|
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||||
|
opaque: true,
|
||||||
|
barrierDismissible: false);
|
||||||
|
},
|
||||||
HomeRoute.name: (routeData) {
|
HomeRoute.name: (routeData) {
|
||||||
return MaterialPageX<dynamic>(
|
return MaterialPageX<dynamic>(
|
||||||
routeData: routeData, child: const HomePage());
|
routeData: routeData, child: const HomePage());
|
||||||
@@ -177,7 +185,9 @@ class _$AppRouter extends RootStackRouter {
|
|||||||
RouteConfig(BackupAlbumSelectionRoute.name,
|
RouteConfig(BackupAlbumSelectionRoute.name,
|
||||||
path: '/backup-album-selection-page', guards: [authGuard]),
|
path: '/backup-album-selection-page', guards: [authGuard]),
|
||||||
RouteConfig(AlbumPreviewRoute.name,
|
RouteConfig(AlbumPreviewRoute.name,
|
||||||
path: '/album-preview-page', guards: [authGuard])
|
path: '/album-preview-page', guards: [authGuard]),
|
||||||
|
RouteConfig(FailedBackupStatusRoute.name,
|
||||||
|
path: '/failed-backup-status-page', guards: [authGuard])
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +447,15 @@ class AlbumPreviewRouteArgs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [FailedBackupStatusPage]
|
||||||
|
class FailedBackupStatusRoute extends PageRouteInfo<void> {
|
||||||
|
const FailedBackupStatusRoute()
|
||||||
|
: super(FailedBackupStatusRoute.name, path: '/failed-backup-status-page');
|
||||||
|
|
||||||
|
static const String name = 'FailedBackupStatusRoute';
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [HomePage]
|
/// [HomePage]
|
||||||
class HomeRoute extends PageRouteInfo<void> {
|
class HomeRoute extends PageRouteInfo<void> {
|
||||||
|
|||||||
@@ -104,10 +104,9 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
|
|||||||
disconnect() {
|
disconnect() {
|
||||||
debugPrint("[WEBSOCKET] Attempting to disconnect");
|
debugPrint("[WEBSOCKET] Attempting to disconnect");
|
||||||
var socket = state.socket?.disconnect();
|
var socket = state.socket?.disconnect();
|
||||||
if (socket != null) {
|
|
||||||
if (socket.disconnected) {
|
if (socket?.disconnected == true) {
|
||||||
state = WebscoketState(isConnected: false, socket: null);
|
state = WebscoketState(isConnected: false, socket: null);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class NetworkService {
|
|||||||
|
|
||||||
return res;
|
return res;
|
||||||
} on DioError catch (e) {
|
} on DioError catch (e) {
|
||||||
debugPrint("DioError: ${e.response}");
|
debugPrint("[postRequest] DioError: ${e.response}");
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("ERROR PostRequest: $e");
|
debugPrint("ERROR PostRequest: $e");
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class SplashScreenPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
if (loginInfo != null && loginInfo.isSaveLogin) {
|
if (loginInfo?.isSaveLogin == true) {
|
||||||
performLoggingIn();
|
performLoggingIn();
|
||||||
} else {
|
} else {
|
||||||
AutoRouter.of(context).push(const LoginRoute());
|
AutoRouter.of(context).push(const LoginRoute());
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ class VersionAnnouncementOverlay extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return Container();
|
return const SizedBox();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
|||||||
description: Immich - selfhosted backup media file on mobile phone
|
description: Immich - selfhosted backup media file on mobile phone
|
||||||
|
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 1.14.0+21
|
version: 1.17.0+25
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=2.17.0 <3.0.0"
|
sdk: ">=2.17.0 <3.0.0"
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
FROM nginx:latest
|
FROM nginx:latest
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
EXPOSE 443
|
||||||
@@ -8,7 +8,7 @@ COPY package.json package-lock.json ./
|
|||||||
|
|
||||||
RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
|
RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
|
||||||
|
|
||||||
RUN npm install --legacy-peer-deps
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Logger,
|
Logger,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||||
import { AssetService } from './asset.service';
|
import { AssetService } from './asset.service';
|
||||||
@@ -31,6 +32,10 @@ import { SearchAssetDto } from './dto/search-asset.dto';
|
|||||||
import { CommunicationGateway } from '../communication/communication.gateway';
|
import { CommunicationGateway } from '../communication/communication.gateway';
|
||||||
import { InjectQueue } from '@nestjs/bull';
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
import { Queue } from 'bull';
|
import { Queue } from 'bull';
|
||||||
|
import { IAssetUploadedJob } from '@app/job/index';
|
||||||
|
import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant';
|
||||||
|
import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant';
|
||||||
|
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('asset')
|
@Controller('asset')
|
||||||
@@ -40,8 +45,8 @@ export class AssetController {
|
|||||||
private assetService: AssetService,
|
private assetService: AssetService,
|
||||||
private backgroundTaskService: BackgroundTaskService,
|
private backgroundTaskService: BackgroundTaskService,
|
||||||
|
|
||||||
@InjectQueue('asset-uploaded-queue')
|
@InjectQueue(assetUploadedQueueName)
|
||||||
private assetUploadedQueue: Queue,
|
private assetUploadedQueue: Queue<IAssetUploadedJob>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('upload')
|
@Post('upload')
|
||||||
@@ -56,40 +61,23 @@ export class AssetController {
|
|||||||
)
|
)
|
||||||
async uploadFile(
|
async uploadFile(
|
||||||
@GetAuthUser() authUser: AuthUserDto,
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
@UploadedFiles() uploadFiles: { assetData: Express.Multer.File[]; thumbnailData?: Express.Multer.File[] },
|
@UploadedFiles() uploadFiles: { assetData: Express.Multer.File[] },
|
||||||
@Body(ValidationPipe) assetInfo: CreateAssetDto,
|
@Body(ValidationPipe) assetInfo: CreateAssetDto,
|
||||||
): Promise<'ok' | undefined> {
|
): Promise<'ok' | undefined> {
|
||||||
for (const file of uploadFiles.assetData) {
|
for (const file of uploadFiles.assetData) {
|
||||||
try {
|
try {
|
||||||
const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype);
|
const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype);
|
||||||
|
|
||||||
if (!savedAsset) {
|
if (savedAsset) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (uploadFiles.thumbnailData != null) {
|
|
||||||
const assetWithThumbnail = await this.assetService.updateThumbnailInfo(
|
|
||||||
savedAsset,
|
|
||||||
uploadFiles.thumbnailData[0].path,
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.assetUploadedQueue.add(
|
await this.assetUploadedQueue.add(
|
||||||
'asset-uploaded',
|
assetUploadedProcessorName,
|
||||||
{ asset: assetWithThumbnail, fileName: file.originalname, fileSize: file.size, hasThumbnail: true },
|
{ asset: savedAsset, fileName: file.originalname, fileSize: file.size },
|
||||||
{ jobId: savedAsset.id },
|
|
||||||
);
|
|
||||||
|
|
||||||
this.wsCommunicateionGateway.server
|
|
||||||
.to(savedAsset.userId)
|
|
||||||
.emit('on_upload_success', JSON.stringify(assetWithThumbnail));
|
|
||||||
} else {
|
|
||||||
await this.assetUploadedQueue.add(
|
|
||||||
'asset-uploaded',
|
|
||||||
{ asset: savedAsset, fileName: file.originalname, fileSize: file.size, hasThumbnail: false },
|
|
||||||
{ jobId: savedAsset.id },
|
{ jobId: savedAsset.id },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger.error(`Error receiving upload file ${e}`);
|
Logger.error(`Error uploading file ${e}`);
|
||||||
|
throw new BadRequestException(`Error uploading file`, `${e}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +104,7 @@ export class AssetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('/thumbnail/:assetId')
|
@Get('/thumbnail/:assetId')
|
||||||
async getAssetThumbnail(@Param('assetId') assetId: string): Promise<StreamableFile> {
|
async getAssetThumbnail(@Param('assetId') assetId: string) {
|
||||||
return await this.assetService.getAssetThumbnail(assetId);
|
return await this.assetService.getAssetThumbnail(assetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,9 +173,9 @@ export class AssetController {
|
|||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async checkDuplicateAsset(
|
async checkDuplicateAsset(
|
||||||
@GetAuthUser() authUser: AuthUserDto,
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
@Body(ValidationPipe) { deviceAssetId }: { deviceAssetId: string },
|
@Body(ValidationPipe) checkDuplicateAssetDto: CheckDuplicateAssetDto,
|
||||||
) {
|
) {
|
||||||
const res = await this.assetService.checkDuplicatedAsset(authUser, deviceAssetId);
|
const res = await this.assetService.checkDuplicatedAsset(authUser, checkDuplicateAssetDto);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isExist: res,
|
isExist: res,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { BullModule } from '@nestjs/bull';
|
|||||||
import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
|
import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
|
||||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||||
import { CommunicationModule } from '../communication/communication.module';
|
import { CommunicationModule } from '../communication/communication.module';
|
||||||
|
import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -14,7 +15,7 @@ import { CommunicationModule } from '../communication/communication.module';
|
|||||||
BackgroundTaskModule,
|
BackgroundTaskModule,
|
||||||
TypeOrmModule.forFeature([AssetEntity]),
|
TypeOrmModule.forFeature([AssetEntity]),
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: 'asset-uploaded-queue',
|
name: assetUploadedQueueName,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ import { IsNull, Not, Repository } from 'typeorm';
|
|||||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||||
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
|
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
|
||||||
import { createReadStream, stat } from 'fs';
|
import { constants, createReadStream, ReadStream, stat } from 'fs';
|
||||||
import { ServeFileDto } from './dto/serve-file.dto';
|
import { ServeFileDto } from './dto/serve-file.dto';
|
||||||
import { Response as Res } from 'express';
|
import { Response as Res } from 'express';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
||||||
import { SearchAssetDto } from './dto/search-asset.dto';
|
import { SearchAssetDto } from './dto/search-asset.dto';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
|
||||||
|
|
||||||
const fileInfo = promisify(stat);
|
const fileInfo = promisify(stat);
|
||||||
|
|
||||||
@@ -57,15 +59,11 @@ export class AssetService {
|
|||||||
asset.mimeType = mimeType;
|
asset.mimeType = mimeType;
|
||||||
asset.duration = assetInfo.duration || null;
|
asset.duration = assetInfo.duration || null;
|
||||||
|
|
||||||
try {
|
const createdAsset = await this.assetRepository.save(asset);
|
||||||
const createdAsset = await this.assetRepository.save(asset);
|
if (!createdAsset) {
|
||||||
if (!createdAsset) {
|
throw new Error('Asset not created');
|
||||||
throw new Error('Asset not created');
|
|
||||||
}
|
|
||||||
return createdAsset;
|
|
||||||
} catch (e) {
|
|
||||||
Logger.error(`Error Create New Asset ${e}`, 'createUserAsset');
|
|
||||||
}
|
}
|
||||||
|
return createdAsset;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
|
public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
|
||||||
@@ -123,7 +121,7 @@ export class AssetService {
|
|||||||
|
|
||||||
public async downloadFile(query: ServeFileDto, res: Res) {
|
public async downloadFile(query: ServeFileDto, res: Res) {
|
||||||
try {
|
try {
|
||||||
let file = null;
|
let fileReadStream = null;
|
||||||
const asset = await this.findOne(query.did, query.aid);
|
const asset = await this.findOne(query.did, query.aid);
|
||||||
|
|
||||||
if (query.isThumb === 'false' || !query.isThumb) {
|
if (query.isThumb === 'false' || !query.isThumb) {
|
||||||
@@ -132,76 +130,90 @@ export class AssetService {
|
|||||||
'Content-Type': asset.mimeType,
|
'Content-Type': asset.mimeType,
|
||||||
'Content-Length': size,
|
'Content-Length': size,
|
||||||
});
|
});
|
||||||
file = createReadStream(asset.originalPath);
|
|
||||||
|
await fs.access(asset.originalPath, constants.R_OK | constants.W_OK);
|
||||||
|
fileReadStream = createReadStream(asset.originalPath);
|
||||||
} else {
|
} else {
|
||||||
if (!asset.resizePath) {
|
if (!asset.resizePath) {
|
||||||
throw new Error('resizePath not set');
|
throw new NotFoundException('resizePath not set');
|
||||||
}
|
}
|
||||||
const { size } = await fileInfo(asset.resizePath);
|
const { size } = await fileInfo(asset.resizePath);
|
||||||
res.set({
|
res.set({
|
||||||
'Content-Type': 'image/jpeg',
|
'Content-Type': 'image/jpeg',
|
||||||
'Content-Length': size,
|
'Content-Length': size,
|
||||||
});
|
});
|
||||||
file = createReadStream(asset.resizePath);
|
|
||||||
|
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
|
||||||
|
fileReadStream = createReadStream(asset.resizePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new StreamableFile(file);
|
return new StreamableFile(fileReadStream);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger.error('Error download asset ', e);
|
Logger.error(`Error download asset`, 'downloadFile');
|
||||||
throw new InternalServerErrorException(`Failed to download asset ${e}`, 'DownloadFile');
|
throw new InternalServerErrorException(`Failed to download asset ${e}`, 'DownloadFile');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAssetThumbnail(assetId: string): Promise<StreamableFile> {
|
public async getAssetThumbnail(assetId: string) {
|
||||||
try {
|
let fileReadStream: ReadStream;
|
||||||
const asset = await this.assetRepository.findOne({ where: { id: assetId } });
|
|
||||||
if (!asset) {
|
|
||||||
throw new NotFoundException('Asset not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const asset = await this.assetRepository.findOne({ where: { id: assetId } });
|
||||||
|
|
||||||
|
if (!asset) {
|
||||||
|
throw new NotFoundException('Asset not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
if (asset.webpPath && asset.webpPath.length > 0) {
|
if (asset.webpPath && asset.webpPath.length > 0) {
|
||||||
return new StreamableFile(createReadStream(asset.webpPath));
|
await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
|
||||||
|
fileReadStream = createReadStream(asset.webpPath);
|
||||||
} else {
|
} else {
|
||||||
if (!asset.resizePath) {
|
if (!asset.resizePath) {
|
||||||
throw new Error('resizePath not set');
|
return new NotFoundException('resizePath not set');
|
||||||
}
|
}
|
||||||
return new StreamableFile(createReadStream(asset.resizePath));
|
|
||||||
|
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
|
||||||
|
fileReadStream = createReadStream(asset.resizePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new StreamableFile(fileReadStream);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof NotFoundException) {
|
Logger.error(`Cannot create read stream for asset ${asset.id}`, 'getAssetThumbnail');
|
||||||
throw e;
|
throw new InternalServerErrorException(
|
||||||
}
|
e,
|
||||||
Logger.error('Error serving asset thumbnail ', e);
|
`Cannot read thumbnail file for asset ${asset.id} - contact your administrator`,
|
||||||
throw new InternalServerErrorException('Failed to serve asset thumbnail', 'GetAssetThumbnail');
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async serveFile(authUser: AuthUserDto, query: ServeFileDto, res: Res, headers: any) {
|
public async serveFile(authUser: AuthUserDto, query: ServeFileDto, res: Res, headers: any) {
|
||||||
let file = null;
|
let fileReadStream: ReadStream;
|
||||||
const asset = await this.findOne(query.did, query.aid);
|
const asset = await this.findOne(query.did, query.aid);
|
||||||
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
// TODO: maybe this should be a NotFoundException?
|
throw new NotFoundException('Asset does not exist');
|
||||||
throw new BadRequestException('Asset does not exist');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Sending Images
|
// Handle Sending Images
|
||||||
if (asset.type == AssetType.IMAGE || query.isThumb == 'true') {
|
if (asset.type == AssetType.IMAGE || query.isThumb == 'true') {
|
||||||
/**
|
|
||||||
* Serve file viewer on the web
|
|
||||||
*/
|
|
||||||
if (query.isWeb) {
|
|
||||||
res.set({
|
|
||||||
'Content-Type': 'image/jpeg',
|
|
||||||
});
|
|
||||||
if (!asset.resizePath) {
|
|
||||||
Logger.error('Error serving IMAGE asset for web', 'ServeFile');
|
|
||||||
throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile');
|
|
||||||
}
|
|
||||||
return new StreamableFile(createReadStream(asset.resizePath));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
/**
|
||||||
|
* Serve file viewer on the web
|
||||||
|
*/
|
||||||
|
if (query.isWeb) {
|
||||||
|
res.set({
|
||||||
|
'Content-Type': 'image/jpeg',
|
||||||
|
});
|
||||||
|
if (!asset.resizePath) {
|
||||||
|
Logger.error('Error serving IMAGE asset for web', 'ServeFile');
|
||||||
|
throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile');
|
||||||
|
}
|
||||||
|
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
|
||||||
|
fileReadStream = createReadStream(asset.resizePath);
|
||||||
|
|
||||||
|
return new StreamableFile(fileReadStream);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serve thumbnail image for both web and mobile app
|
* Serve thumbnail image for both web and mobile app
|
||||||
*/
|
*/
|
||||||
@@ -209,34 +221,38 @@ export class AssetService {
|
|||||||
res.set({
|
res.set({
|
||||||
'Content-Type': asset.mimeType,
|
'Content-Type': asset.mimeType,
|
||||||
});
|
});
|
||||||
file = createReadStream(asset.originalPath);
|
|
||||||
|
await fs.access(asset.originalPath, constants.R_OK | constants.W_OK);
|
||||||
|
fileReadStream = createReadStream(asset.originalPath);
|
||||||
} else {
|
} else {
|
||||||
if (asset.webpPath && asset.webpPath.length > 0) {
|
if (asset.webpPath && asset.webpPath.length > 0) {
|
||||||
res.set({
|
res.set({
|
||||||
'Content-Type': 'image/webp',
|
'Content-Type': 'image/webp',
|
||||||
});
|
});
|
||||||
|
|
||||||
file = createReadStream(asset.webpPath);
|
await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
|
||||||
|
fileReadStream = createReadStream(asset.webpPath);
|
||||||
} else {
|
} else {
|
||||||
res.set({
|
res.set({
|
||||||
'Content-Type': 'image/jpeg',
|
'Content-Type': 'image/jpeg',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!asset.resizePath) {
|
if (!asset.resizePath) {
|
||||||
throw new Error('resizePath not set');
|
throw new Error('resizePath not set');
|
||||||
}
|
}
|
||||||
file = createReadStream(asset.resizePath);
|
|
||||||
|
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
|
||||||
|
fileReadStream = createReadStream(asset.resizePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
file.on('error', (error) => {
|
return new StreamableFile(fileReadStream);
|
||||||
Logger.log(`Cannot create read stream ${error}`);
|
|
||||||
return new BadRequestException('Cannot Create Read Stream');
|
|
||||||
});
|
|
||||||
|
|
||||||
return new StreamableFile(file);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger.error('Error serving IMAGE asset ', e);
|
Logger.error(`Cannot create read stream for asset ${asset.id}`, 'serveFile[IMAGE]');
|
||||||
throw new InternalServerErrorException(`Failed to serve image asset ${e}`, 'ServeFile');
|
throw new InternalServerErrorException(
|
||||||
|
e,
|
||||||
|
`Cannot read thumbnail file for asset ${asset.id} - contact your administrator`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (asset.type == AssetType.VIDEO) {
|
} else if (asset.type == AssetType.VIDEO) {
|
||||||
try {
|
try {
|
||||||
@@ -244,6 +260,8 @@ export class AssetService {
|
|||||||
let videoPath = asset.originalPath;
|
let videoPath = asset.originalPath;
|
||||||
let mimeType = asset.mimeType;
|
let mimeType = asset.mimeType;
|
||||||
|
|
||||||
|
await fs.access(videoPath, constants.R_OK | constants.W_OK);
|
||||||
|
|
||||||
if (query.isWeb && asset.mimeType == 'video/quicktime') {
|
if (query.isWeb && asset.mimeType == 'video/quicktime') {
|
||||||
videoPath = asset.encodedVideoPath == '' ? asset.originalPath : asset.encodedVideoPath;
|
videoPath = asset.encodedVideoPath == '' ? asset.originalPath : asset.encodedVideoPath;
|
||||||
mimeType = asset.encodedVideoPath == '' ? asset.mimeType : 'video/mp4';
|
mimeType = asset.encodedVideoPath == '' ? asset.mimeType : 'video/mp4';
|
||||||
@@ -279,7 +297,6 @@ export class AssetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Sending Partial Content With HTTP Code 206 */
|
/** Sending Partial Content With HTTP Code 206 */
|
||||||
console.log('Send Range', range);
|
|
||||||
res.status(206).set({
|
res.status(206).set({
|
||||||
'Content-Range': `bytes ${start}-${end}/${size}`,
|
'Content-Range': `bytes ${start}-${end}/${size}`,
|
||||||
'Accept-Ranges': 'bytes',
|
'Accept-Ranges': 'bytes',
|
||||||
@@ -298,7 +315,7 @@ export class AssetService {
|
|||||||
return new StreamableFile(createReadStream(videoPath));
|
return new StreamableFile(createReadStream(videoPath));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger.error('Error serving VIDEO asset ', e);
|
Logger.error(`Error serving VIDEO asset id ${asset.id}`, 'serveFile[VIDEO]');
|
||||||
throw new InternalServerErrorException(`Failed to serve video asset ${e}`, 'ServeFile');
|
throw new InternalServerErrorException(`Failed to serve video asset ${e}`, 'ServeFile');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,11 +352,11 @@ export class AssetService {
|
|||||||
// TODO: should use query builder
|
// TODO: should use query builder
|
||||||
const rows = await this.assetRepository.query(
|
const rows = await this.assetRepository.query(
|
||||||
`
|
`
|
||||||
select distinct si.tags, si.objects, e.orientation, e."lensModel", e.make, e.model , a.type, e.city, e.state, e.country
|
SELECT DISTINCT si.tags, si.objects, e.orientation, e."lensModel", e.make, e.model , a.type, e.city, e.state, e.country
|
||||||
from assets a
|
FROM assets a
|
||||||
left join exif e on a.id = e."assetId"
|
LEFT JOIN exif e ON a.id = e."assetId"
|
||||||
left join smart_info si on a.id = si."assetId"
|
LEFT JOIN smart_info si ON a.id = si."assetId"
|
||||||
where a."userId" = $1;
|
WHERE a."userId" = $1;
|
||||||
`,
|
`,
|
||||||
[authUser.id],
|
[authUser.id],
|
||||||
);
|
);
|
||||||
@@ -385,7 +402,7 @@ export class AssetService {
|
|||||||
(
|
(
|
||||||
TO_TSVECTOR('english', ARRAY_TO_STRING(si.tags, ',')) @@ PLAINTO_TSQUERY('english', $2) OR
|
TO_TSVECTOR('english', ARRAY_TO_STRING(si.tags, ',')) @@ PLAINTO_TSQUERY('english', $2) OR
|
||||||
TO_TSVECTOR('english', ARRAY_TO_STRING(si.objects, ',')) @@ PLAINTO_TSQUERY('english', $2) OR
|
TO_TSVECTOR('english', ARRAY_TO_STRING(si.objects, ',')) @@ PLAINTO_TSQUERY('english', $2) OR
|
||||||
e.exif_text_searchable_column @@ PLAINTO_TSQUERY('english', $2)
|
e."exifTextSearchableColumn" @@ PLAINTO_TSQUERY('english', $2)
|
||||||
);
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -395,12 +412,12 @@ export class AssetService {
|
|||||||
async getCuratedLocation(authUser: AuthUserDto) {
|
async getCuratedLocation(authUser: AuthUserDto) {
|
||||||
return await this.assetRepository.query(
|
return await this.assetRepository.query(
|
||||||
`
|
`
|
||||||
select distinct on (e.city) a.id, e.city, a."resizePath", a."deviceAssetId", a."deviceId"
|
SELECT DISTINCT ON (e.city) a.id, e.city, a."resizePath", a."deviceAssetId", a."deviceId"
|
||||||
from assets a
|
FROM assets a
|
||||||
left join exif e on a.id = e."assetId"
|
LEFT JOIN exif e ON a.id = e."assetId"
|
||||||
where a."userId" = $1
|
WHERE a."userId" = $1
|
||||||
and e.city is not null
|
AND e.city IS NOT NULL
|
||||||
and a.type = 'IMAGE';
|
AND a.type = 'IMAGE';
|
||||||
`,
|
`,
|
||||||
[authUser.id],
|
[authUser.id],
|
||||||
);
|
);
|
||||||
@@ -409,20 +426,21 @@ export class AssetService {
|
|||||||
async getCuratedObject(authUser: AuthUserDto) {
|
async getCuratedObject(authUser: AuthUserDto) {
|
||||||
return await this.assetRepository.query(
|
return await this.assetRepository.query(
|
||||||
`
|
`
|
||||||
select distinct on (unnest(si.objects)) a.id, unnest(si.objects) as "object", a."resizePath", a."deviceAssetId", a."deviceId"
|
SELECT DISTINCT ON (unnest(si.objects)) a.id, unnest(si.objects) as "object", a."resizePath", a."deviceAssetId", a."deviceId"
|
||||||
from assets a
|
FROM assets a
|
||||||
left join smart_info si on a.id = si."assetId"
|
LEFT JOIN smart_info si ON a.id = si."assetId"
|
||||||
where a."userId" = $1
|
WHERE a."userId" = $1
|
||||||
and si.objects is not null
|
AND si.objects IS NOT NULL
|
||||||
`,
|
`,
|
||||||
[authUser.id],
|
[authUser.id],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkDuplicatedAsset(authUser: AuthUserDto, deviceAssetId: string) {
|
async checkDuplicatedAsset(authUser: AuthUserDto, checkDuplicateAssetDto: CheckDuplicateAssetDto) {
|
||||||
const res = await this.assetRepository.findOne({
|
const res = await this.assetRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
deviceAssetId,
|
deviceAssetId: checkDuplicateAssetDto.deviceAssetId,
|
||||||
|
deviceId: checkDuplicateAssetDto.deviceId,
|
||||||
userId: authUser.id,
|
userId: authUser.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
export class CheckDuplicateAssetDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
deviceAssetId!: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
deviceId!: string;
|
||||||
|
}
|
||||||
@@ -154,7 +154,6 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!user.profileImagePath) {
|
if (!user.profileImagePath) {
|
||||||
// throw new BadRequestException('User does not have a profile image');
|
|
||||||
res.status(404).send('User does not have a profile image');
|
res.status(404).send('User does not have a profile image');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -165,7 +164,7 @@ export class UserService {
|
|||||||
const fileStream = createReadStream(user.profileImagePath);
|
const fileStream = createReadStream(user.profileImagePath);
|
||||||
return new StreamableFile(fileStream);
|
return new StreamableFile(fileStream);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('error getting user profile');
|
res.status(404).send('User does not have a profile image');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { extname } from 'path';
|
|||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant';
|
import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
// import { CreateAssetDto } from '../api-v1/asset/dto/create-asset.dto';
|
|
||||||
|
|
||||||
export const assetUploadOption: MulterOptions = {
|
export const assetUploadOption: MulterOptions = {
|
||||||
fileFilter: (req: Request, file: any, cb: any) => {
|
fileFilter: (req: Request, file: any, cb: any) => {
|
||||||
@@ -30,34 +29,20 @@ export const assetUploadOption: MulterOptions = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.fieldname == 'assetData') {
|
const originalUploadFolder = `${basePath}/${req.user.id}/original/${req.body['deviceId']}`;
|
||||||
const originalUploadFolder = `${basePath}/${req.user.id}/original/${req.body['deviceId']}`;
|
|
||||||
|
|
||||||
if (!existsSync(originalUploadFolder)) {
|
if (!existsSync(originalUploadFolder)) {
|
||||||
mkdirSync(originalUploadFolder, { recursive: true });
|
mkdirSync(originalUploadFolder, { recursive: true });
|
||||||
}
|
|
||||||
|
|
||||||
// Save original to disk
|
|
||||||
cb(null, originalUploadFolder);
|
|
||||||
} else if (file.fieldname == 'thumbnailData') {
|
|
||||||
const thumbnailUploadFolder = `${basePath}/${req.user.id}/thumb/${req.body['deviceId']}`;
|
|
||||||
|
|
||||||
if (!existsSync(thumbnailUploadFolder)) {
|
|
||||||
mkdirSync(thumbnailUploadFolder, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save thumbnail to disk
|
|
||||||
cb(null, thumbnailUploadFolder);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save original to disk
|
||||||
|
cb(null, originalUploadFolder);
|
||||||
},
|
},
|
||||||
|
|
||||||
filename: (req: Request, file: Express.Multer.File, cb: any) => {
|
filename: (req: Request, file: Express.Multer.File, cb: any) => {
|
||||||
const fileNameUUID = randomUUID();
|
const fileNameUUID = randomUUID();
|
||||||
if (file.fieldname == 'assetData') {
|
|
||||||
cb(null, `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`);
|
cb(null, `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`);
|
||||||
} else if (file.fieldname == 'thumbnailData') {
|
|
||||||
cb(null, `${fileNameUUID}.jpeg`);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
export const serverVersion = {
|
export const serverVersion = {
|
||||||
major: 1,
|
major: 1,
|
||||||
minor: 15,
|
minor: 17,
|
||||||
patch: 0,
|
patch: 0,
|
||||||
build: 21,
|
build: 25,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,12 +3,18 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||||
import { ScheduleTasksService } from './schedule-tasks.service';
|
import { ScheduleTasksService } from './schedule-tasks.service';
|
||||||
|
import {
|
||||||
|
metadataExtractionQueueName,
|
||||||
|
thumbnailGeneratorQueueName,
|
||||||
|
videoConversionQueueName,
|
||||||
|
} from '@app/job/constants/queue-name.constant';
|
||||||
|
import { ExifEntity } from '@app/database/entities/exif.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([AssetEntity]),
|
TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: 'video-conversion-queue',
|
name: videoConversionQueueName,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
@@ -16,7 +22,16 @@ import { ScheduleTasksService } from './schedule-tasks.service';
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: 'thumbnail-generator-queue',
|
name: thumbnailGeneratorQueueName,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: metadataExtractionQueueName,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { IsNull, Not, Repository } from 'typeorm';
|
||||||
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
|
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
|
||||||
import { InjectQueue } from '@nestjs/bull';
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
import { Queue } from 'bull';
|
import { Queue } from 'bull';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
import { ExifEntity } from '@app/database/entities/exif.entity';
|
||||||
|
import {
|
||||||
|
IMetadataExtractionJob,
|
||||||
|
IVideoTranscodeJob,
|
||||||
|
metadataExtractionQueueName,
|
||||||
|
thumbnailGeneratorQueueName,
|
||||||
|
videoConversionQueueName,
|
||||||
|
generateWEBPThumbnailProcessorName,
|
||||||
|
mp4ConversionProcessorName,
|
||||||
|
reverseGeocodingProcessorName,
|
||||||
|
} from '@app/job';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ScheduleTasksService {
|
export class ScheduleTasksService {
|
||||||
@@ -13,17 +25,23 @@ export class ScheduleTasksService {
|
|||||||
@InjectRepository(AssetEntity)
|
@InjectRepository(AssetEntity)
|
||||||
private assetRepository: Repository<AssetEntity>,
|
private assetRepository: Repository<AssetEntity>,
|
||||||
|
|
||||||
@InjectQueue('thumbnail-generator-queue')
|
@InjectRepository(ExifEntity)
|
||||||
|
private exifRepository: Repository<ExifEntity>,
|
||||||
|
|
||||||
|
@InjectQueue(thumbnailGeneratorQueueName)
|
||||||
private thumbnailGeneratorQueue: Queue,
|
private thumbnailGeneratorQueue: Queue,
|
||||||
|
|
||||||
@InjectQueue('video-conversion-queue')
|
@InjectQueue(videoConversionQueueName)
|
||||||
private videoConversionQueue: Queue,
|
private videoConversionQueue: Queue<IVideoTranscodeJob>,
|
||||||
|
|
||||||
|
@InjectQueue(metadataExtractionQueueName)
|
||||||
|
private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
|
||||||
|
|
||||||
|
private configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||||
async webpConversion() {
|
async webpConversion() {
|
||||||
Logger.log('Starting Schedule Webp Conversion Tasks', 'CronjobWebpGenerator');
|
|
||||||
|
|
||||||
const assets = await this.assetRepository.find({
|
const assets = await this.assetRepository.find({
|
||||||
where: {
|
where: {
|
||||||
webpPath: '',
|
webpPath: '',
|
||||||
@@ -36,7 +54,11 @@ export class ScheduleTasksService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
await this.thumbnailGeneratorQueue.add('generate-webp-thumbnail', { asset: asset }, { jobId: randomUUID() });
|
await this.thumbnailGeneratorQueue.add(
|
||||||
|
generateWEBPThumbnailProcessorName,
|
||||||
|
{ asset: asset },
|
||||||
|
{ jobId: randomUUID() },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +76,26 @@ export class ScheduleTasksService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
await this.videoConversionQueue.add('mp4-conversion', { asset }, { jobId: randomUUID() });
|
await this.videoConversionQueue.add(mp4ConversionProcessorName, { asset }, { jobId: randomUUID() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_5_SECONDS)
|
||||||
|
async reverseGeocoding() {
|
||||||
|
const isMapboxEnable = this.configService.get('ENABLE_MAPBOX');
|
||||||
|
|
||||||
|
if (isMapboxEnable) {
|
||||||
|
const exifInfo = await this.exifRepository.find({
|
||||||
|
where: {
|
||||||
|
city: IsNull(),
|
||||||
|
longitude: Not(IsNull()),
|
||||||
|
latitude: Not(IsNull()),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const exif of exifInfo) {
|
||||||
|
await this.metadataExtractionQueue.add(reverseGeocodingProcessorName, { exif }, { jobId: randomUUID() });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CanActivate, ExecutionContext } from '@nestjs/common';
|
|||||||
import { TestingModuleBuilder } from '@nestjs/testing';
|
import { TestingModuleBuilder } from '@nestjs/testing';
|
||||||
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
|
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
|
||||||
import { JwtAuthGuard } from '../src/modules/immich-jwt/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../src/modules/immich-jwt/guards/jwt-auth.guard';
|
||||||
import databaseConfig from '@app/database/config/database.config';
|
import { databaseConfig } from '@app/database/config/database.config';
|
||||||
|
|
||||||
type CustomAuthCallback = () => AuthUserDto;
|
type CustomAuthCallback = () => AuthUserDto;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
|
|||||||
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
|
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
|
||||||
import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
|
import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
|
||||||
import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module';
|
import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module';
|
||||||
|
import {
|
||||||
|
assetUploadedQueueName,
|
||||||
|
metadataExtractionQueueName,
|
||||||
|
thumbnailGeneratorQueueName,
|
||||||
|
videoConversionQueueName,
|
||||||
|
} from '@app/job/constants/queue-name.constant';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -26,7 +32,7 @@ import { CommunicationModule } from '../../immich/src/api-v1/communication/commu
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: 'thumbnail-generator-queue',
|
name: thumbnailGeneratorQueueName,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
@@ -34,7 +40,7 @@ import { CommunicationModule } from '../../immich/src/api-v1/communication/commu
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: 'asset-uploaded-queue',
|
name: assetUploadedQueueName,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
@@ -42,7 +48,7 @@ import { CommunicationModule } from '../../immich/src/api-v1/communication/commu
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: 'metadata-extraction-queue',
|
name: metadataExtractionQueueName,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
@@ -50,7 +56,7 @@ import { CommunicationModule } from '../../immich/src/api-v1/communication/commu
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: 'video-conversion-queue',
|
name: videoConversionQueueName,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
|
|||||||
@@ -1,61 +1,58 @@
|
|||||||
import { InjectQueue, Process, Processor } from '@nestjs/bull';
|
import { InjectQueue, Process, Processor } from '@nestjs/bull';
|
||||||
import { Job, Queue } from 'bull';
|
import { Job, Queue } from 'bull';
|
||||||
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
|
import { AssetType } from '@app/database/entities/asset.entity';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
import {
|
||||||
|
IAssetUploadedJob,
|
||||||
|
IMetadataExtractionJob,
|
||||||
|
IThumbnailGenerationJob,
|
||||||
|
IVideoTranscodeJob,
|
||||||
|
assetUploadedQueueName,
|
||||||
|
metadataExtractionQueueName,
|
||||||
|
thumbnailGeneratorQueueName,
|
||||||
|
videoConversionQueueName,
|
||||||
|
assetUploadedProcessorName,
|
||||||
|
exifExtractionProcessorName,
|
||||||
|
generateJPEGThumbnailProcessorName,
|
||||||
|
mp4ConversionProcessorName,
|
||||||
|
videoMetadataExtractionProcessorName,
|
||||||
|
} from '@app/job';
|
||||||
|
|
||||||
@Processor('asset-uploaded-queue')
|
@Processor(assetUploadedQueueName)
|
||||||
export class AssetUploadedProcessor {
|
export class AssetUploadedProcessor {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectQueue('thumbnail-generator-queue')
|
@InjectQueue(thumbnailGeneratorQueueName)
|
||||||
private thumbnailGeneratorQueue: Queue,
|
private thumbnailGeneratorQueue: Queue<IThumbnailGenerationJob>,
|
||||||
|
|
||||||
@InjectQueue('metadata-extraction-queue')
|
@InjectQueue(metadataExtractionQueueName)
|
||||||
private metadataExtractionQueue: Queue,
|
private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
|
||||||
|
|
||||||
@InjectQueue('video-conversion-queue')
|
@InjectQueue(videoConversionQueueName)
|
||||||
private videoConversionQueue: Queue,
|
private videoConversionQueue: Queue<IVideoTranscodeJob>,
|
||||||
|
|
||||||
@InjectRepository(AssetEntity)
|
|
||||||
private assetRepository: Repository<AssetEntity>,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Post processing uploaded asset to perform the following function if missing
|
* Post processing uploaded asset to perform the following function if missing
|
||||||
* 1. Generate JPEG Thumbnail
|
* 1. Generate JPEG Thumbnail
|
||||||
* 2. Generate Webp Thumbnail <-> if JPEG thumbnail exist
|
* 2. Generate Webp Thumbnail
|
||||||
* 3. EXIF extractor
|
* 3. EXIF extractor
|
||||||
* 4. Reverse Geocoding
|
* 4. Reverse Geocoding
|
||||||
*
|
*
|
||||||
* @param job asset-uploaded
|
* @param job asset-uploaded
|
||||||
*/
|
*/
|
||||||
@Process('asset-uploaded')
|
@Process(assetUploadedProcessorName)
|
||||||
async processUploadedVideo(job: Job) {
|
async processUploadedVideo(job: Job<IAssetUploadedJob>) {
|
||||||
const {
|
const { asset, fileName, fileSize } = job.data;
|
||||||
asset,
|
|
||||||
fileName,
|
|
||||||
fileSize,
|
|
||||||
hasThumbnail,
|
|
||||||
}: { asset: AssetEntity; fileName: string; fileSize: number; hasThumbnail: boolean } = job.data;
|
|
||||||
|
|
||||||
if (hasThumbnail) {
|
await this.thumbnailGeneratorQueue.add(generateJPEGThumbnailProcessorName, { asset }, { jobId: randomUUID() });
|
||||||
// The jobs below depends on the existence of jpeg thumbnail
|
|
||||||
await this.thumbnailGeneratorQueue.add('generate-webp-thumbnail', { asset }, { jobId: randomUUID() });
|
|
||||||
await this.metadataExtractionQueue.add('tag-image', { asset }, { jobId: randomUUID() });
|
|
||||||
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
|
// Video Conversion
|
||||||
if (asset.type == AssetType.VIDEO) {
|
if (asset.type == AssetType.VIDEO) {
|
||||||
await this.videoConversionQueue.add('mp4-conversion', { asset }, { jobId: randomUUID() });
|
await this.videoConversionQueue.add(mp4ConversionProcessorName, { asset }, { jobId: randomUUID() });
|
||||||
} else {
|
} else {
|
||||||
// Extract Metadata/Exif for Images - Currently the library cannot extract EXIF for video yet
|
// Extract Metadata/Exif for Images - Currently the EXIF library on the web cannot extract EXIF for video yet
|
||||||
await this.metadataExtractionQueue.add(
|
await this.metadataExtractionQueue.add(
|
||||||
'exif-extraction',
|
exifExtractionProcessorName,
|
||||||
{
|
{
|
||||||
asset,
|
asset,
|
||||||
fileName,
|
fileName,
|
||||||
@@ -65,9 +62,9 @@ export class AssetUploadedProcessor {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract video duration if uploaded from the web
|
// Extract video duration if uploaded from the web & CLI
|
||||||
if (asset.type == AssetType.VIDEO && asset.duration == '0:00:00.000000') {
|
if (asset.type == AssetType.VIDEO && asset.duration == '0:00:00.000000') {
|
||||||
await this.metadataExtractionQueue.add('extract-video-length', { asset }, { jobId: randomUUID() });
|
await this.metadataExtractionQueue.add(videoMetadataExtractionProcessorName, { asset }, { jobId: randomUUID() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,20 @@ import { Logger } from '@nestjs/common';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
|
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
|
||||||
import ffmpeg from 'fluent-ffmpeg';
|
import ffmpeg from 'fluent-ffmpeg';
|
||||||
// import moment from 'moment';
|
import path from 'path';
|
||||||
|
import {
|
||||||
|
IExifExtractionProcessor,
|
||||||
|
IVideoLengthExtractionProcessor,
|
||||||
|
exifExtractionProcessorName,
|
||||||
|
imageTaggingProcessorName,
|
||||||
|
objectDetectionProcessorName,
|
||||||
|
videoMetadataExtractionProcessorName,
|
||||||
|
metadataExtractionQueueName,
|
||||||
|
reverseGeocodingProcessorName,
|
||||||
|
IReverseGeocodingProcessor,
|
||||||
|
} from '@app/job';
|
||||||
|
|
||||||
@Processor('metadata-extraction-queue')
|
@Processor(metadataExtractionQueueName)
|
||||||
export class MetadataExtractionProcessor {
|
export class MetadataExtractionProcessor {
|
||||||
private geocodingClient?: GeocodeService;
|
private geocodingClient?: GeocodeService;
|
||||||
|
|
||||||
@@ -35,8 +46,8 @@ export class MetadataExtractionProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Process('exif-extraction')
|
@Process(exifExtractionProcessorName)
|
||||||
async extractExifInfo(job: Job) {
|
async extractExifInfo(job: Job<IExifExtractionProcessor>) {
|
||||||
try {
|
try {
|
||||||
const { asset, fileName, fileSize }: { asset: AssetEntity; fileName: string; fileSize: number } = job.data;
|
const { asset, fileName, fileSize }: { asset: AssetEntity; fileName: string; fileSize: number } = job.data;
|
||||||
|
|
||||||
@@ -48,7 +59,7 @@ export class MetadataExtractionProcessor {
|
|||||||
newExif.assetId = asset.id;
|
newExif.assetId = asset.id;
|
||||||
newExif.make = exifData['Make'] || null;
|
newExif.make = exifData['Make'] || null;
|
||||||
newExif.model = exifData['Model'] || null;
|
newExif.model = exifData['Model'] || null;
|
||||||
newExif.imageName = fileName || null;
|
newExif.imageName = path.parse(fileName).name || null;
|
||||||
newExif.exifImageHeight = exifData['ExifImageHeight'] || null;
|
newExif.exifImageHeight = exifData['ExifImageHeight'] || null;
|
||||||
newExif.exifImageWidth = exifData['ExifImageWidth'] || null;
|
newExif.exifImageWidth = exifData['ExifImageWidth'] || null;
|
||||||
newExif.fileSizeInByte = fileSize || null;
|
newExif.fileSizeInByte = fileSize || null;
|
||||||
@@ -89,11 +100,33 @@ export class MetadataExtractionProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Process({ name: 'tag-image', concurrency: 2 })
|
@Process({ name: reverseGeocodingProcessorName })
|
||||||
|
async reverseGeocoding(job: Job<IReverseGeocodingProcessor>) {
|
||||||
|
const { exif } = job.data;
|
||||||
|
|
||||||
|
if (this.geocodingClient) {
|
||||||
|
const geoCodeInfo: MapiResponse = await this.geocodingClient
|
||||||
|
.reverseGeocode({
|
||||||
|
query: [Number(exif.longitude), Number(exif.latitude)],
|
||||||
|
types: ['country', 'region', 'place'],
|
||||||
|
})
|
||||||
|
.send();
|
||||||
|
|
||||||
|
const res: [] = geoCodeInfo.body['features'];
|
||||||
|
|
||||||
|
const city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
|
||||||
|
const state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
|
||||||
|
const country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
|
||||||
|
|
||||||
|
await this.exifRepository.update({ id: exif.id }, { city, state, country });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Process({ name: imageTaggingProcessorName, concurrency: 2 })
|
||||||
async tagImage(job: Job) {
|
async tagImage(job: Job) {
|
||||||
const { asset }: { asset: AssetEntity } = job.data;
|
const { asset }: { asset: AssetEntity } = job.data;
|
||||||
|
|
||||||
const res = await axios.post('http://immich-machine-learning:3001/image-classifier/tag-image', {
|
const res = await axios.post('http://immich-machine-learning:3003/image-classifier/tag-image', {
|
||||||
thumbnailPath: asset.resizePath,
|
thumbnailPath: asset.resizePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,12 +141,12 @@ export class MetadataExtractionProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Process({ name: 'detect-object', concurrency: 2 })
|
@Process({ name: objectDetectionProcessorName, concurrency: 2 })
|
||||||
async detectObject(job: Job) {
|
async detectObject(job: Job) {
|
||||||
try {
|
try {
|
||||||
const { asset }: { asset: AssetEntity } = job.data;
|
const { asset }: { asset: AssetEntity } = job.data;
|
||||||
|
|
||||||
const res = await axios.post('http://immich-machine-learning:3001/object-detection/detect-object', {
|
const res = await axios.post('http://immich-machine-learning:3003/object-detection/detect-object', {
|
||||||
thumbnailPath: asset.resizePath,
|
thumbnailPath: asset.resizePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,26 +164,42 @@ export class MetadataExtractionProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Process({ name: 'extract-video-length', concurrency: 2 })
|
@Process({ name: videoMetadataExtractionProcessorName, concurrency: 2 })
|
||||||
async extractVideoLength(job: Job) {
|
async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
|
||||||
const { asset }: { asset: AssetEntity } = job.data;
|
const { asset } = job.data;
|
||||||
|
|
||||||
ffmpeg.ffprobe(asset.originalPath, async (err, data) => {
|
ffmpeg.ffprobe(asset.originalPath, async (err, data) => {
|
||||||
if (!err) {
|
if (!err) {
|
||||||
|
let durationString = asset.duration;
|
||||||
|
let createdAt = asset.createdAt;
|
||||||
|
|
||||||
if (data.format.duration) {
|
if (data.format.duration) {
|
||||||
const videoDurationInSecond = parseInt(data.format.duration.toString(), 0);
|
durationString = this.extractDuration(data.format.duration);
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const videoTags = data.format.tags;
|
||||||
|
if (videoTags) {
|
||||||
|
if (videoTags['com.apple.quicktime.creationdate']) {
|
||||||
|
createdAt = String(videoTags['com.apple.quicktime.creationdate']);
|
||||||
|
} else {
|
||||||
|
createdAt = String(videoTags['creation_time']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.assetRepository.update({ id: asset.id }, { duration: durationString, createdAt: createdAt });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractDuration(duration: number) {
|
||||||
|
const videoDurationInSecond = parseInt(duration.toString(), 0);
|
||||||
|
|
||||||
|
const hours = Math.floor(videoDurationInSecond / 3600);
|
||||||
|
const minutes = Math.floor((videoDurationInSecond - hours * 3600) / 60);
|
||||||
|
const seconds = videoDurationInSecond - hours * 3600 - minutes * 60;
|
||||||
|
|
||||||
|
return `${hours}:${minutes < 10 ? '0' + minutes.toString() : minutes}:${
|
||||||
|
seconds < 10 ? '0' + seconds.toString() : seconds
|
||||||
|
}.000000`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,25 +9,35 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { CommunicationGateway } from '../../../immich/src/api-v1/communication/communication.gateway';
|
import { CommunicationGateway } from '../../../immich/src/api-v1/communication/communication.gateway';
|
||||||
import ffmpeg from 'fluent-ffmpeg';
|
import ffmpeg from 'fluent-ffmpeg';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
WebpGeneratorProcessor,
|
||||||
|
generateJPEGThumbnailProcessorName,
|
||||||
|
generateWEBPThumbnailProcessorName,
|
||||||
|
imageTaggingProcessorName,
|
||||||
|
objectDetectionProcessorName,
|
||||||
|
metadataExtractionQueueName,
|
||||||
|
thumbnailGeneratorQueueName,
|
||||||
|
JpegGeneratorProcessor,
|
||||||
|
} from '@app/job';
|
||||||
|
|
||||||
@Processor('thumbnail-generator-queue')
|
@Processor(thumbnailGeneratorQueueName)
|
||||||
export class ThumbnailGeneratorProcessor {
|
export class ThumbnailGeneratorProcessor {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(AssetEntity)
|
@InjectRepository(AssetEntity)
|
||||||
private assetRepository: Repository<AssetEntity>,
|
private assetRepository: Repository<AssetEntity>,
|
||||||
|
|
||||||
@InjectQueue('thumbnail-generator-queue')
|
@InjectQueue(thumbnailGeneratorQueueName)
|
||||||
private thumbnailGeneratorQueue: Queue,
|
private thumbnailGeneratorQueue: Queue,
|
||||||
|
|
||||||
private wsCommunicateionGateway: CommunicationGateway,
|
private wsCommunicateionGateway: CommunicationGateway,
|
||||||
|
|
||||||
@InjectQueue('metadata-extraction-queue')
|
@InjectQueue(metadataExtractionQueueName)
|
||||||
private metadataExtractionQueue: Queue,
|
private metadataExtractionQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Process('generate-jpeg-thumbnail')
|
@Process({ name: generateJPEGThumbnailProcessorName, concurrency: 3 })
|
||||||
async generateJPEGThumbnail(job: Job) {
|
async generateJPEGThumbnail(job: Job<JpegGeneratorProcessor>) {
|
||||||
const { asset }: { asset: AssetEntity } = job.data;
|
const { asset } = job.data;
|
||||||
|
|
||||||
const resizePath = `upload/${asset.userId}/thumb/${asset.deviceId}/`;
|
const resizePath = `upload/${asset.userId}/thumb/${asset.deviceId}/`;
|
||||||
|
|
||||||
@@ -43,6 +53,7 @@ export class ThumbnailGeneratorProcessor {
|
|||||||
sharp(asset.originalPath)
|
sharp(asset.originalPath)
|
||||||
.resize(1440, 2560, { fit: 'inside' })
|
.resize(1440, 2560, { fit: 'inside' })
|
||||||
.jpeg()
|
.jpeg()
|
||||||
|
.rotate()
|
||||||
.toFile(jpegThumbnailPath, async (err) => {
|
.toFile(jpegThumbnailPath, async (err) => {
|
||||||
if (!err) {
|
if (!err) {
|
||||||
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
|
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
|
||||||
@@ -50,9 +61,13 @@ export class ThumbnailGeneratorProcessor {
|
|||||||
// Update resize path to send to generate webp queue
|
// Update resize path to send to generate webp queue
|
||||||
asset.resizePath = jpegThumbnailPath;
|
asset.resizePath = jpegThumbnailPath;
|
||||||
|
|
||||||
await this.thumbnailGeneratorQueue.add('generate-webp-thumbnail', { asset }, { jobId: randomUUID() });
|
await this.thumbnailGeneratorQueue.add(
|
||||||
await this.metadataExtractionQueue.add('tag-image', { asset }, { jobId: randomUUID() });
|
generateWEBPThumbnailProcessorName,
|
||||||
await this.metadataExtractionQueue.add('detect-object', { asset }, { jobId: randomUUID() });
|
{ asset },
|
||||||
|
{ jobId: randomUUID() },
|
||||||
|
);
|
||||||
|
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
|
||||||
|
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
|
||||||
this.wsCommunicateionGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(asset));
|
this.wsCommunicateionGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(asset));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -60,7 +75,7 @@ export class ThumbnailGeneratorProcessor {
|
|||||||
|
|
||||||
if (asset.type == AssetType.VIDEO) {
|
if (asset.type == AssetType.VIDEO) {
|
||||||
ffmpeg(asset.originalPath)
|
ffmpeg(asset.originalPath)
|
||||||
.outputOptions(['-ss 00:00:01.000', '-frames:v 1'])
|
.outputOptions(['-ss 00:00:00.000', '-frames:v 1'])
|
||||||
.output(jpegThumbnailPath)
|
.output(jpegThumbnailPath)
|
||||||
.on('start', () => {
|
.on('start', () => {
|
||||||
Logger.log('Start Generating Video Thumbnail', 'generateJPEGThumbnail');
|
Logger.log('Start Generating Video Thumbnail', 'generateJPEGThumbnail');
|
||||||
@@ -76,9 +91,13 @@ export class ThumbnailGeneratorProcessor {
|
|||||||
// Update resize path to send to generate webp queue
|
// Update resize path to send to generate webp queue
|
||||||
asset.resizePath = jpegThumbnailPath;
|
asset.resizePath = jpegThumbnailPath;
|
||||||
|
|
||||||
await this.thumbnailGeneratorQueue.add('generate-webp-thumbnail', { asset }, { jobId: randomUUID() });
|
await this.thumbnailGeneratorQueue.add(
|
||||||
await this.metadataExtractionQueue.add('tag-image', { asset }, { jobId: randomUUID() });
|
generateWEBPThumbnailProcessorName,
|
||||||
await this.metadataExtractionQueue.add('detect-object', { asset }, { jobId: randomUUID() });
|
{ asset },
|
||||||
|
{ jobId: randomUUID() },
|
||||||
|
);
|
||||||
|
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
|
||||||
|
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
|
||||||
|
|
||||||
this.wsCommunicateionGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(asset));
|
this.wsCommunicateionGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(asset));
|
||||||
})
|
})
|
||||||
@@ -86,8 +105,8 @@ export class ThumbnailGeneratorProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Process({ name: 'generate-webp-thumbnail', concurrency: 2 })
|
@Process({ name: generateWEBPThumbnailProcessorName, concurrency: 3 })
|
||||||
async generateWepbThumbnail(job: Job<{ asset: AssetEntity }>) {
|
async generateWepbThumbnail(job: Job<WebpGeneratorProcessor>) {
|
||||||
const { asset } = job.data;
|
const { asset } = job.data;
|
||||||
|
|
||||||
if (!asset.resizePath) {
|
if (!asset.resizePath) {
|
||||||
@@ -98,6 +117,7 @@ export class ThumbnailGeneratorProcessor {
|
|||||||
sharp(asset.resizePath)
|
sharp(asset.resizePath)
|
||||||
.resize(250)
|
.resize(250)
|
||||||
.webp()
|
.webp()
|
||||||
|
.rotate()
|
||||||
.toFile(webpPath, (err) => {
|
.toFile(webpPath, (err) => {
|
||||||
if (!err) {
|
if (!err) {
|
||||||
this.assetRepository.update({ id: asset.id }, { webpPath: webpPath });
|
this.assetRepository.update({ id: asset.id }, { webpPath: webpPath });
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { mp4ConversionProcessorName } from '@app/job/constants/job-name.constant';
|
||||||
|
import { videoConversionQueueName } from '@app/job/constants/queue-name.constant';
|
||||||
|
import { IMp4ConversionProcessor } from '@app/job/interfaces/video-transcode.interface';
|
||||||
import { Process, Processor } from '@nestjs/bull';
|
import { Process, Processor } from '@nestjs/bull';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
@@ -8,16 +11,16 @@ import { Repository } from 'typeorm';
|
|||||||
import { AssetEntity } from '../../../../libs/database/src/entities/asset.entity';
|
import { AssetEntity } from '../../../../libs/database/src/entities/asset.entity';
|
||||||
import { APP_UPLOAD_LOCATION } from '../../../immich/src/constants/upload_location.constant';
|
import { APP_UPLOAD_LOCATION } from '../../../immich/src/constants/upload_location.constant';
|
||||||
|
|
||||||
@Processor('video-conversion-queue')
|
@Processor(videoConversionQueueName)
|
||||||
export class VideoTranscodeProcessor {
|
export class VideoTranscodeProcessor {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(AssetEntity)
|
@InjectRepository(AssetEntity)
|
||||||
private assetRepository: Repository<AssetEntity>,
|
private assetRepository: Repository<AssetEntity>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Process({ name: 'mp4-conversion', concurrency: 1 })
|
@Process({ name: mp4ConversionProcessorName, concurrency: 1 })
|
||||||
async mp4Conversion(job: Job) {
|
async mp4Conversion(job: Job<IMp4ConversionProcessor>) {
|
||||||
const { asset }: { asset: AssetEntity } = job.data;
|
const { asset } = job.data;
|
||||||
|
|
||||||
if (asset.mimeType != 'video/mp4') {
|
if (asset.mimeType != 'video/mp4') {
|
||||||
const basePath = APP_UPLOAD_LOCATION;
|
const basePath = APP_UPLOAD_LOCATION;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
|
||||||
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions';
|
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions';
|
||||||
|
import {DataSource} from "typeorm";
|
||||||
|
|
||||||
export const databaseConfig: PostgresConnectionOptions = {
|
export const databaseConfig: PostgresConnectionOptions = {
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
@@ -14,4 +14,4 @@ export const databaseConfig: PostgresConnectionOptions = {
|
|||||||
migrationsRun: true,
|
migrationsRun: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default databaseConfig;
|
export const dataSource = new DataSource(databaseConfig);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
|
import {Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique} from 'typeorm';
|
||||||
import { AlbumEntity } from './album.entity';
|
import { AlbumEntity } from './album.entity';
|
||||||
import { AssetEntity } from './asset.entity';
|
import { AssetEntity } from './asset.entity';
|
||||||
|
|
||||||
@Entity('asset_album')
|
@Entity('asset_album')
|
||||||
@Unique('PK_unique_asset_in_album', ['albumId', 'assetId'])
|
@Unique('UQ_unique_asset_in_album', ['albumId', 'assetId'])
|
||||||
export class AssetAlbumEntity {
|
export class AssetAlbumEntity {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id!: string;
|
id!: string;
|
||||||
@@ -12,6 +12,7 @@ export class AssetAlbumEntity {
|
|||||||
albumId!: string;
|
albumId!: string;
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
|
@OneToOne(() => AssetEntity, (entity) => entity.id)
|
||||||
assetId!: string;
|
assetId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => AlbumEntity, (album) => album.assets, {
|
@ManyToOne(() => AlbumEntity, (album) => album.assets, {
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ export class AssetEntity {
|
|||||||
@Column({ type: 'varchar', nullable: true })
|
@Column({ type: 'varchar', nullable: true })
|
||||||
resizePath!: string | null;
|
resizePath!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
@Column({ type: 'varchar', nullable: true, default: '' })
|
||||||
webpPath!: string | null;
|
webpPath!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
@Column({ type: 'varchar', nullable: true, default: '' })
|
||||||
encodedVideoPath!: string;
|
encodedVideoPath!: string;
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
|
|||||||
@@ -73,4 +73,19 @@ export class ExifEntity {
|
|||||||
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
|
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
|
||||||
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
|
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
|
||||||
asset?: ExifEntity;
|
asset?: ExifEntity;
|
||||||
|
|
||||||
|
@Index("exif_text_searchable", { synchronize: false })
|
||||||
|
@Column({
|
||||||
|
type: 'tsvector',
|
||||||
|
generatedType: 'STORED',
|
||||||
|
asExpression: `TO_TSVECTOR('english',
|
||||||
|
COALESCE(make, '') || ' ' ||
|
||||||
|
COALESCE(model, '') || ' ' ||
|
||||||
|
COALESCE(orientation, '') || ' ' ||
|
||||||
|
COALESCE("lensModel", '') || ' ' ||
|
||||||
|
COALESCE("city", '') || ' ' ||
|
||||||
|
COALESCE("state", '') || ' ' ||
|
||||||
|
COALESCE("country", ''))`
|
||||||
|
})
|
||||||
|
exifTextSearchableColumn!: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ export class UserEntity {
|
|||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@Column()
|
@Column({ default: '' })
|
||||||
firstName!: string;
|
firstName!: string;
|
||||||
|
|
||||||
@Column()
|
@Column({ default: '' })
|
||||||
lastName!: string;
|
lastName!: string;
|
||||||
|
|
||||||
@Column()
|
@Column({ default: false })
|
||||||
isAdmin!: boolean;
|
isAdmin!: boolean;
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
@@ -23,10 +23,10 @@ export class UserEntity {
|
|||||||
@Column({ select: false })
|
@Column({ select: false })
|
||||||
salt?: string;
|
salt?: string;
|
||||||
|
|
||||||
@Column()
|
@Column({ default: '' })
|
||||||
profileImagePath!: string;
|
profileImagePath!: string;
|
||||||
|
|
||||||
@Column()
|
@Column({ default: true })
|
||||||
shouldChangePassword!: boolean;
|
shouldChangePassword!: boolean;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm"
|
||||||
|
|
||||||
|
export class RenameAssetAlbumIdSequence1656888591977 implements MigrationInterface {
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`alter sequence asset_shared_album_id_seq rename to asset_album_id_seq;`);
|
||||||
|
await queryRunner.query(`alter table asset_album alter column id set default nextval('asset_album_id_seq'::regclass);`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`alter sequence asset_album_id_seq rename to asset_shared_album_id_seq;`);
|
||||||
|
await queryRunner.query(`alter table asset_album alter column id set default nextval('asset_shared_album_id_seq'::regclass);`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class DropExifTextSearchableColumns1656888918620 implements MigrationInterface {
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exif_text_searchable_column"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE exif
|
||||||
|
DROP COLUMN IF EXISTS exif_text_searchable_column;
|
||||||
|
|
||||||
|
ALTER TABLE exif
|
||||||
|
ADD COLUMN IF NOT EXISTS exif_text_searchable_column tsvector
|
||||||
|
GENERATED ALWAYS AS (
|
||||||
|
TO_TSVECTOR('english',
|
||||||
|
COALESCE(make, '') || ' ' ||
|
||||||
|
COALESCE(model, '') || ' ' ||
|
||||||
|
COALESCE(orientation, '') || ' ' ||
|
||||||
|
COALESCE("lensModel", '') || ' ' ||
|
||||||
|
COALESCE("city", '') || ' ' ||
|
||||||
|
COALESCE("state", '') || ' ' ||
|
||||||
|
COALESCE("country", '')
|
||||||
|
)
|
||||||
|
) STORED;
|
||||||
|
|
||||||
|
CREATE INDEX exif_text_searchable_idx
|
||||||
|
ON exif
|
||||||
|
USING GIN (exif_text_searchable_column);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class MatchMigrationsWithTypeORMEntities1656889061566 implements MigrationInterface {
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "exif" ADD "exifTextSearchableColumn" tsvector GENERATED ALWAYS AS (TO_TSVECTOR('english',
|
||||||
|
COALESCE(make, '') || ' ' ||
|
||||||
|
COALESCE(model, '') || ' ' ||
|
||||||
|
COALESCE(orientation, '') || ' ' ||
|
||||||
|
COALESCE("lensModel", '') || ' ' ||
|
||||||
|
COALESCE("city", '') || ' ' ||
|
||||||
|
COALESCE("state", '') || ' ' ||
|
||||||
|
COALESCE("country", ''))) STORED`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "exif" ALTER COLUMN "exifTextSearchableColumn" SET NOT NULL`);
|
||||||
|
await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, ["GENERATED_COLUMN","exifTextSearchableColumn","postgres","public","exif"]);
|
||||||
|
await queryRunner.query(`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES ($1, $2, $3, $4, $5, $6)`, ["postgres","public","exif","GENERATED_COLUMN","exifTextSearchableColumn","TO_TSVECTOR('english',\n COALESCE(make, '') || ' ' ||\n COALESCE(model, '') || ' ' ||\n COALESCE(orientation, '') || ' ' ||\n COALESCE(\"lensModel\", '') || ' ' ||\n COALESCE(\"city\", '') || ' ' ||\n COALESCE(\"state\", '') || ' ' ||\n COALESCE(\"country\", ''))"]);
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "firstName" SET NOT NULL`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "lastName" SET NOT NULL`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "isAdmin" SET NOT NULL`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "profileImagePath" SET NOT NULL`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "shouldChangePassword" SET NOT NULL`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_a8b79a84996cef6ba6a3662825d"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_64f2e7d68d1d1d8417acc844a4a"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "UQ_a1e2734a1ce361e7a26f6b28288"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "UQ_unique_asset_in_album" UNIQUE ("albumId", "assetId")`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_256a30a03a4a0aff0394051397d" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_7ae4e03729895bf87e056d7b598" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "shouldChangePassword" DROP NOT NULL`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "profileImagePath" DROP NOT NULL`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "isAdmin" DROP NOT NULL`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "lastName" DROP NOT NULL`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "firstName" DROP NOT NULL`);
|
||||||
|
await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, ["GENERATED_COLUMN","exifTextSearchableColumn","immich","public","exif"]);
|
||||||
|
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exifTextSearchableColumn"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_7ae4e03729895bf87e056d7b598"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_256a30a03a4a0aff0394051397d"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "UQ_unique_asset_in_album"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "UQ_a1e2734a1ce361e7a26f6b28288" UNIQUE ("albumId", "assetId")`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_64f2e7d68d1d1d8417acc844a4a" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_a8b79a84996cef6ba6a3662825d" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
24
server/libs/job/src/constants/job-name.constant.ts
Normal file
24
server/libs/job/src/constants/job-name.constant.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Asset Uploaded Queue Jobs
|
||||||
|
*/
|
||||||
|
export const assetUploadedProcessorName = 'asset-uploaded';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video Conversion Queue Jobs
|
||||||
|
**/
|
||||||
|
export const mp4ConversionProcessorName = 'mp4-conversion';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thumbnail Generator Queue Jobs
|
||||||
|
*/
|
||||||
|
export const generateJPEGThumbnailProcessorName = 'generate-jpeg-thumbnail';
|
||||||
|
export const generateWEBPThumbnailProcessorName = 'generate-webp-thumbnail';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata Extraction Queue Jobs
|
||||||
|
*/
|
||||||
|
export const exifExtractionProcessorName = 'exif-extraction';
|
||||||
|
export const videoMetadataExtractionProcessorName = 'extract-video-metadata';
|
||||||
|
export const reverseGeocodingProcessorName = 'reverse-geocoding';
|
||||||
|
export const objectDetectionProcessorName = 'detect-object';
|
||||||
|
export const imageTaggingProcessorName = 'tag-image';
|
||||||
4
server/libs/job/src/constants/queue-name.constant.ts
Normal file
4
server/libs/job/src/constants/queue-name.constant.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const thumbnailGeneratorQueueName = 'thumbnail-generator-queue';
|
||||||
|
export const assetUploadedQueueName = 'asset-uploaded-queue';
|
||||||
|
export const metadataExtractionQueueName = 'metadata-extraction-queue';
|
||||||
|
export const videoConversionQueueName = 'video-conversion-queue';
|
||||||
7
server/libs/job/src/index.ts
Normal file
7
server/libs/job/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export * from './interfaces/asset-uploaded.interface';
|
||||||
|
export * from './interfaces/metadata-extraction.interface';
|
||||||
|
export * from './interfaces/video-transcode.interface';
|
||||||
|
export * from './interfaces/thumbnail-generation.interface';
|
||||||
|
|
||||||
|
export * from './constants/job-name.constant';
|
||||||
|
export * from './constants/queue-name.constant';
|
||||||
18
server/libs/job/src/interfaces/asset-uploaded.interface.ts
Normal file
18
server/libs/job/src/interfaces/asset-uploaded.interface.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||||
|
|
||||||
|
export interface IAssetUploadedJob {
|
||||||
|
/**
|
||||||
|
* The Asset entity that was saved in the database
|
||||||
|
*/
|
||||||
|
asset: AssetEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Original file name
|
||||||
|
*/
|
||||||
|
fileName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File size in byte
|
||||||
|
*/
|
||||||
|
fileSize: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||||
|
import { ExifEntity } from '@app/database/entities/exif.entity';
|
||||||
|
|
||||||
|
export interface IExifExtractionProcessor {
|
||||||
|
/**
|
||||||
|
* The Asset entity that was saved in the database
|
||||||
|
*/
|
||||||
|
asset: AssetEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Original file name
|
||||||
|
*/
|
||||||
|
fileName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File size in byte
|
||||||
|
*/
|
||||||
|
fileSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IVideoLengthExtractionProcessor {
|
||||||
|
/**
|
||||||
|
* The Asset entity that was saved in the database
|
||||||
|
*/
|
||||||
|
asset: AssetEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReverseGeocodingProcessor {
|
||||||
|
/**
|
||||||
|
* The Asset entity that was saved in the database
|
||||||
|
*/
|
||||||
|
exif: ExifEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IMetadataExtractionJob =
|
||||||
|
| IExifExtractionProcessor
|
||||||
|
| IVideoLengthExtractionProcessor
|
||||||
|
| IReverseGeocodingProcessor;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||||
|
|
||||||
|
export interface JpegGeneratorProcessor {
|
||||||
|
/**
|
||||||
|
* The Asset entity that was saved in the database
|
||||||
|
*/
|
||||||
|
asset: AssetEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebpGeneratorProcessor {
|
||||||
|
/**
|
||||||
|
* The Asset entity that was saved in the database
|
||||||
|
*/
|
||||||
|
asset: AssetEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IThumbnailGenerationJob = JpegGeneratorProcessor | WebpGeneratorProcessor;
|
||||||
10
server/libs/job/src/interfaces/video-transcode.interface.ts
Normal file
10
server/libs/job/src/interfaces/video-transcode.interface.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||||
|
|
||||||
|
export interface IMp4ConversionProcessor {
|
||||||
|
/**
|
||||||
|
* The Asset entity that was saved in the database
|
||||||
|
*/
|
||||||
|
asset: AssetEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IVideoTranscodeJob = IMp4ConversionProcessor;
|
||||||
9
server/libs/job/tsconfig.lib.json
Normal file
9
server/libs/job/tsconfig.lib.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "../../dist/libs/job"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||||
|
}
|
||||||
@@ -34,6 +34,15 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsConfigPath": "libs/database/tsconfig.lib.json"
|
"tsConfigPath": "libs/database/tsconfig.lib.json"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"job": {
|
||||||
|
"type": "library",
|
||||||
|
"root": "libs/job",
|
||||||
|
"entryFile": "index",
|
||||||
|
"sourceRoot": "libs/job/src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsConfigPath": "libs/job/tsconfig.lib.json"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "jest --config ./apps/immich/test/jest-e2e.json",
|
"test:e2e": "jest --config ./apps/immich/test/jest-e2e.json",
|
||||||
"typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js --config libs/database/src/config/database.config.ts"
|
"typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mapbox/mapbox-sdk": "^0.13.3",
|
"@mapbox/mapbox-sdk": "^0.13.3",
|
||||||
@@ -120,7 +120,8 @@
|
|||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
"^@app/database(|/.*)$": "<rootDir>/libs/database/src/$1",
|
"^@app/database(|/.*)$": "<rootDir>/libs/database/src/$1",
|
||||||
"@app/database/config/(.*)": "<rootDir>/libs/database/src/config/$1",
|
"@app/database/config/(.*)": "<rootDir>/libs/database/src/config/$1",
|
||||||
"@app/database/config": "<rootDir>/libs/database/src/config"
|
"@app/database/config": "<rootDir>/libs/database/src/config",
|
||||||
|
"^@app/job(|/.*)$": "<rootDir>/libs/job/src/$1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,12 @@
|
|||||||
],
|
],
|
||||||
"@app/database/*": [
|
"@app/database/*": [
|
||||||
"libs/database/src/*"
|
"libs/database/src/*"
|
||||||
|
],
|
||||||
|
"@app/job": [
|
||||||
|
"libs/job/src"
|
||||||
|
],
|
||||||
|
"@app/job/*": [
|
||||||
|
"libs/job/src/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ RUN chown node:node /usr/src/app
|
|||||||
|
|
||||||
COPY --chown=node:node package*.json ./
|
COPY --chown=node:node package*.json ./
|
||||||
|
|
||||||
RUN apk add --update-cache build-base python3
|
RUN apk add --update-cache build-base python3
|
||||||
|
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
|
|
||||||
COPY --chown=node:node . .
|
COPY --chown=node:node . .
|
||||||
|
|
||||||
@@ -29,9 +29,9 @@ RUN chown node:node /usr/src/app
|
|||||||
COPY --chown=node:node package*.json ./
|
COPY --chown=node:node package*.json ./
|
||||||
COPY --chown=node:node . .
|
COPY --chown=node:node . .
|
||||||
|
|
||||||
RUN apk add --update-cache build-base python3
|
RUN apk add --update-cache build-base python3
|
||||||
|
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,9 @@
|
|||||||
dispatch('close');
|
dispatch('close');
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigateAssetForward = () => {
|
const navigateAssetForward = (e?: Event) => {
|
||||||
|
e?.stopPropagation();
|
||||||
|
|
||||||
const nextAsset = $flattenAssetGroupByDate[selectedIndex + 1];
|
const nextAsset = $flattenAssetGroupByDate[selectedIndex + 1];
|
||||||
viewDeviceId = nextAsset.deviceId;
|
viewDeviceId = nextAsset.deviceId;
|
||||||
viewAssetId = nextAsset.id;
|
viewAssetId = nextAsset.id;
|
||||||
@@ -73,7 +75,9 @@
|
|||||||
pushState(viewAssetId);
|
pushState(viewAssetId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigateAssetBackward = () => {
|
const navigateAssetBackward = (e?: Event) => {
|
||||||
|
e?.stopPropagation();
|
||||||
|
|
||||||
const lastAsset = $flattenAssetGroupByDate[selectedIndex - 1];
|
const lastAsset = $flattenAssetGroupByDate[selectedIndex - 1];
|
||||||
viewDeviceId = lastAsset.deviceId;
|
viewDeviceId = lastAsset.deviceId;
|
||||||
viewAssetId = lastAsset.id;
|
viewAssetId = lastAsset.id;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import type { ImmichAsset } from '../../models/immich-asset';
|
import type { ImmichAsset } from '../../models/immich-asset';
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
import { browser } from '$app/env';
|
import { browser } from '$app/env';
|
||||||
|
import { round } from 'lodash';
|
||||||
|
|
||||||
// Map Property
|
// Map Property
|
||||||
let map: any;
|
let map: any;
|
||||||
@@ -80,6 +81,16 @@
|
|||||||
return `${sizeInByte}B`;
|
return `${sizeInByte}B`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getMegapixel = (width: number, height: number): number | undefined => {
|
||||||
|
const megapixel = Math.round((height * width) / 1_000_000);
|
||||||
|
|
||||||
|
if (megapixel) {
|
||||||
|
return megapixel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="p-2">
|
<section class="p-2">
|
||||||
@@ -129,8 +140,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<p>{`${asset.exifInfo.imageName}.${asset.originalPath.split('.')[1]}` || ''}</p>
|
<p>{`${asset.exifInfo.imageName}.${asset.originalPath.split('.')[1]}` || ''}</p>
|
||||||
<div class="flex text-sm gap-2">
|
<div class="flex text-sm gap-2">
|
||||||
<p>{((asset.exifInfo.exifImageHeight * asset.exifInfo.exifImageWidth) / 1_000_000).toFixed(0)}MP</p>
|
{#if asset.exifInfo.exifImageHeight && asset.exifInfo.exifImageWidth}
|
||||||
<p>{asset.exifInfo.exifImageHeight} x {asset.exifInfo.exifImageWidth}</p>
|
{#if getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}
|
||||||
|
<p>{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}MP</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p>{asset.exifInfo.exifImageHeight} x {asset.exifInfo.exifImageWidth}</p>
|
||||||
|
{/if}
|
||||||
<p>{getHumanReadableString(asset.exifInfo.fileSizeInByte)}</p>
|
<p>{getHumanReadableString(asset.exifInfo.fileSizeInByte)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
import CloudUploadOutline from 'svelte-material-icons/CloudUploadOutline.svelte';
|
import CloudUploadOutline from 'svelte-material-icons/CloudUploadOutline.svelte';
|
||||||
import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
|
import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
|
||||||
import type { UploadAsset } from '$lib/models/upload-asset';
|
import type { UploadAsset } from '$lib/models/upload-asset';
|
||||||
|
import { getAssetsInfo } from '$lib/stores/assets';
|
||||||
|
import { session } from '$app/stores';
|
||||||
|
|
||||||
let showDetail = true;
|
let showDetail = true;
|
||||||
|
|
||||||
@@ -73,8 +75,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let isUploading = false;
|
let isUploading = false;
|
||||||
|
uploadAssetsStore.isUploading.subscribe((value) => {
|
||||||
|
isUploading = value;
|
||||||
|
|
||||||
uploadAssetsStore.isUploading.subscribe((value) => (isUploading = value));
|
if (isUploading == false) {
|
||||||
|
if ($session.user) {
|
||||||
|
getAssetsInfo($session.user.accessToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isUploading}
|
{#if isUploading}
|
||||||
|
|||||||
@@ -2,19 +2,18 @@ import { writable, derived } from 'svelte/store';
|
|||||||
import { getRequest } from '$lib/api';
|
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 lodash from 'lodash-es';
|
||||||
|
import _ from 'lodash';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
|
||||||
export const assets = writable<ImmichAsset[]>([]);
|
export const assets = writable<ImmichAsset[]>([]);
|
||||||
|
|
||||||
export const assetsGroupByDate = derived(assets, ($assets) => {
|
export const assetsGroupByDate = derived(assets, ($assets) => {
|
||||||
try {
|
try {
|
||||||
return lodash
|
return lodash
|
||||||
.chain($assets)
|
.chain($assets)
|
||||||
.groupBy((a) => moment(a.createdAt).format('ddd, MMM DD'))
|
.groupBy((a) => moment(a.createdAt).format('ddd, MMM DD YYYY'))
|
||||||
.sortBy((group) => $assets.indexOf(group[0]))
|
.sortBy((group) => $assets.indexOf(group[0]))
|
||||||
.value();
|
.value();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('error deriving state assets', e);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Socket, io } from 'socket.io-client';
|
import { Socket, io } from 'socket.io-client';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
import { serverEndpoint } from '../constants';
|
import { serverEndpoint } from '../constants';
|
||||||
import type { ImmichAsset } from '../models/immich-asset';
|
import type { ImmichAsset } from '../models/immich-asset';
|
||||||
import { assets } from './assets';
|
import { assets } from './assets';
|
||||||
|
|
||||||
|
let websocket: Socket;
|
||||||
|
|
||||||
export const openWebsocketConnection = (accessToken: string) => {
|
export const openWebsocketConnection = (accessToken: string) => {
|
||||||
const websocketEndpoint = serverEndpoint.replace('/api', '');
|
const websocketEndpoint = serverEndpoint.replace('/api', '');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const websocket = io(websocketEndpoint, {
|
websocket = io(websocketEndpoint, {
|
||||||
path: '/api/socket.io',
|
path: '/api/socket.io',
|
||||||
transports: ['polling'],
|
transports: ['polling'],
|
||||||
reconnection: true,
|
reconnection: true,
|
||||||
@@ -26,11 +30,14 @@ export const openWebsocketConnection = (accessToken: string) => {
|
|||||||
const listenToEvent = (socket: Socket) => {
|
const listenToEvent = (socket: Socket) => {
|
||||||
socket.on('on_upload_success', (data) => {
|
socket.on('on_upload_success', (data) => {
|
||||||
const newUploadedAsset: ImmichAsset = JSON.parse(data);
|
const newUploadedAsset: ImmichAsset = JSON.parse(data);
|
||||||
|
// assets.update((assets) => [...assets, newUploadedAsset]);
|
||||||
assets.update((assets) => [...assets, newUploadedAsset]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('error', (e) => {
|
socket.on('error', (e) => {
|
||||||
console.log('Websocket Error', e);
|
console.log('Websocket Error', e);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const closeWebsocketConnection = () => {
|
||||||
|
websocket?.close();
|
||||||
|
};
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export async function fileUploader(asset: File, accessToken: string) {
|
|||||||
// Check if asset upload on server before performing upload
|
// Check if asset upload on server before performing upload
|
||||||
const res = await fetch(serverEndpoint + '/asset/check', {
|
const res = await fetch(serverEndpoint + '/asset/check', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ deviceAssetId }),
|
body: JSON.stringify({ deviceAssetId, deviceId: 'WEB' }),
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: 'Bearer ' + accessToken,
|
Authorization: 'Bearer ' + accessToken,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -84,7 +84,7 @@ export async function fileUploader(asset: File, accessToken: string) {
|
|||||||
request.upload.onload = () => {
|
request.upload.onload = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
uploadAssetsStore.removeUploadAsset(deviceAssetId);
|
uploadAssetsStore.removeUploadAsset(deviceAssetId);
|
||||||
}, 2500);
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// listen for `error` event
|
// listen for `error` event
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
|
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
|
||||||
import { AppSideBarSelection } from '$lib/models/admin-sidebar-selection';
|
import { AppSideBarSelection } from '$lib/models/admin-sidebar-selection';
|
||||||
import { onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import { session } from '$app/stores';
|
import { session } from '$app/stores';
|
||||||
import { assetsGroupByDate, flattenAssetGroupByDate } from '$lib/stores/assets';
|
import { assetsGroupByDate, flattenAssetGroupByDate } from '$lib/stores/assets';
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
import DownloadPanel from '../../lib/components/asset-viewer/download-panel.svelte';
|
import DownloadPanel from '../../lib/components/asset-viewer/download-panel.svelte';
|
||||||
import StatusBox from '../../lib/components/shared/status-box.svelte';
|
import StatusBox from '../../lib/components/shared/status-box.svelte';
|
||||||
import { fileUploader } from '../../lib/utils/file-uploader';
|
import { fileUploader } from '../../lib/utils/file-uploader';
|
||||||
import { openWebsocketConnection } from '../../lib/stores/websocket';
|
import { openWebsocketConnection, closeWebsocketConnection } from '../../lib/stores/websocket';
|
||||||
|
|
||||||
export let user: ImmichUser;
|
export let user: ImmichUser;
|
||||||
let selectedAction: AppSideBarSelection;
|
let selectedAction: AppSideBarSelection;
|
||||||
@@ -71,6 +71,10 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
closeWebsocketConnection();
|
||||||
|
});
|
||||||
|
|
||||||
const thumbnailMouseEventHandler = (event: CustomEvent) => {
|
const thumbnailMouseEventHandler = (event: CustomEvent) => {
|
||||||
const { selectedGroupIndex }: { selectedGroupIndex: number } = event.detail;
|
const { selectedGroupIndex }: { selectedGroupIndex: number } = event.detail;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user