Compare commits

...

13 Commits

Author SHA1 Message Date
Alex Tran
9bef411056 Up server version: 2022-09-13 12:14:36 -05:00
Alex
e79e92c60f Added Log level to background service (#685) 2022-09-13 12:09:57 -05:00
Alex
858ad43d3b fix(server): harden inserting process, self-healing timestamp info on bad timestamp (#682)
* fix(server): harden inserting process, self-healing timestamp info
2022-09-12 23:35:44 -05:00
Alex
5761765ea7 fix(server): remove album thumbnail when the asset is deleted from the database (#681) 2022-09-12 22:06:52 -05:00
Thanh Pham
6abc733763 fix(web): datetime display and add TZ into environment (#618)
* fix(web): timezone

* doc(): update readme.md

* feat(web): keep using UTC timezone in default

* chore(): update doc and remove debug code

* chore(): update readme.md

* Move timezone into to .env.example

* Run prettier check

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-09-12 14:40:18 -05:00
Alex Tran
4271e24e59 Up version for release 2022-09-11 16:05:53 -05:00
Alex
9e4ed2214b fix(web): incorrect shared album count (#677) 2022-09-11 10:07:04 -05:00
Alex
011332e509 fix(mobile) memory leaked causes app to crash when swiping (#673)
* Dispose image provider when swiping away from the asset
2022-09-11 09:56:26 -05:00
Alex
5403ef4d84 Fix(mobile) oversize play button (#672) 2022-09-11 00:25:04 -05:00
Alex Tran
31739aca02 Up version for release 2022-09-10 11:58:59 -05:00
Thanh Pham
8f2e7b6f65 fix(server): loop on checksum generation (#662) 2022-09-10 11:52:39 -05:00
Brett Profitt
4ed647c43d fix(install): Fix checking for docker compose. (#663) 2022-09-10 11:48:50 -05:00
Alex
f88ff4fb5c fix(mobile): background backup not working in release mode (#664) 2022-09-10 11:46:51 -05:00
38 changed files with 389 additions and 121 deletions

View File

@@ -147,6 +147,7 @@ wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.en
* Populate `UPLOAD_LOCATION` as prefered location for storing backup assets. * Populate `UPLOAD_LOCATION` as prefered location for storing backup assets.
* Populate a secret value for `JWT_SECRET`, you can use this command: `openssl rand -base64 128` * Populate a secret value for `JWT_SECRET`, you can use this command: `openssl rand -base64 128`
* [Optional] Populate Mapbox value to use reverse geocoding. * [Optional] Populate Mapbox value to use reverse geocoding.
* [Optional] Populate `TZ` as your timezone, default is `Etc/UTC`.
### Step 3 - Start the containers ### Step 3 - Start the containers

View File

@@ -36,6 +36,11 @@ REDIS_HOSTNAME=immich_redis
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
###################################################################################
# Log message level - [simple|verbose]
###################################################################################
LOG_LEVEL=simple
################################################################################### ###################################################################################
@@ -63,4 +68,12 @@ MAPBOX_KEY=
# Custom message on the login page, should be written in HTML form. # Custom message on the login page, should be written in HTML form.
# For example PUBLIC_LOGIN_PAGE_MESSAGE="This is a demo instance of Immich.<br><br>Email: <i>demo@demo.de</i><br>Password: <i>demo</i>" # For example PUBLIC_LOGIN_PAGE_MESSAGE="This is a demo instance of Immich.<br><br>Email: <i>demo@demo.de</i><br>Password: <i>demo</i>"
PUBLIC_LOGIN_PAGE_MESSAGE= PUBLIC_LOGIN_PAGE_MESSAGE=
# For correctly display your local time zone on the web, you can set the time zone here.
# Should work fine by default value, however, in case of incorrect timezone in EXIF, this value
# should be set to the correct timezone.
# Command to get timezone:
# - Linux: curl -s http://ip-api.com/json/ | grep -oP '(?<=timezone":")(.*?)(?=")'
# TZ=Etc/UTC

View File

@@ -47,6 +47,8 @@ services:
entrypoint: ["/bin/sh", "./entrypoint.sh"] entrypoint: ["/bin/sh", "./entrypoint.sh"]
env_file: env_file:
- .env - .env
environment:
- PUBLIC_TZ=${TZ}
restart: always restart: always
redis: redis:

View File

@@ -6,10 +6,6 @@ RED='\033[0;31m'
GREEN='\032[0;31m' GREEN='\032[0;31m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
machine_has() {
type "$1" >/dev/null 2>&1
}
create_immich_directory() { create_immich_directory() {
echo "Creating Immich directory..." echo "Creating Immich directory..."
mkdir -p ./immich-app/immich-data mkdir -p ./immich-app/immich-data
@@ -45,18 +41,21 @@ populate_upload_location() {
start_docker_compose() { start_docker_compose() {
echo "Starting Immich's docker containers" echo "Starting Immich's docker containers"
if machine_has "docker compose"; then { if docker compose &> /dev/null; then
docker compose up --remove-orphans -d docker_bin="docker compose"
elif docker-compose &> /dev/null; then
show_friendly_message docker_bin="docker-compose"
exit 0 else
}; fi echo 'Cannot find `docker compose` or `docker-compose`.'
exit 1
if machine_has "docker-compose"; then fi
docker-compose up --remove-orphans -d
if $docker_bin up --remove-orphans -d; then
show_friendly_message show_friendly_message
exit 0 exit 0
else
echo "Could not start. Check for errors above."
exit 1
fi fi
} }

View File

@@ -30,8 +30,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 39, "android.injected.version.code" => 41,
"android.injected.version.name" => "1.28.1", "android.injected.version.name" => "1.28.3",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -0,0 +1 @@
* Fix background service cannot run in release build

View File

@@ -0,0 +1,2 @@
* Fixed oversize play button on video
* Fixed app crashing when swipe between assets

View File

@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000222"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.00023">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="55.311329"> <testcase classname="fastlane.lanes" name="1: bundleRelease" time="58.722434">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="30.070842"> <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="36.768014">
</testcase> </testcase>

View File

@@ -360,7 +360,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52; CURRENT_PROJECT_VERSION = 55;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -495,7 +495,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52; CURRENT_PROJECT_VERSION = 55;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -522,7 +522,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52; CURRENT_PROJECT_VERSION = 55;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.27.0</string> <string>1.28.0</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>52</string> <string>55</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true /> <true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key> <key>MGLMapboxMetricsEnabledSettingShownInApp</key>

View File

@@ -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.28.1" version_number: "1.28.3"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,

View File

@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000269"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000199">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.499192"> <testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.594905">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="30.057077"> <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.207648">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.438506"> <testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.391989">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="91.259106"> <testcase classname="fastlane.lanes" name="4: build_app" time="77.835137">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="102.092139"> <testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="70.775758">
</testcase> </testcase>

View File

@@ -12,6 +12,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
bool _zoomedIn = false; bool _zoomedIn = false;
static const int swipeThreshold = 100; static const int swipeThreshold = 100;
late CachedNetworkImageProvider fullProvider;
late CachedNetworkImageProvider previewProvider;
late CachedNetworkImageProvider thumbnailProvider;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -65,7 +68,10 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
} }
CachedNetworkImageProvider _authorizedImageProvider( CachedNetworkImageProvider _authorizedImageProvider(
String url, String cacheKey, BaseCacheManager? cacheManager) { String url,
String cacheKey,
BaseCacheManager? cacheManager,
) {
return CachedNetworkImageProvider( return CachedNetworkImageProvider(
url, url,
headers: {"Authorization": widget.authToken}, headers: {"Authorization": widget.authToken},
@@ -104,7 +110,7 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
} }
void _loadImages() { void _loadImages() {
CachedNetworkImageProvider thumbnailProvider = _authorizedImageProvider( thumbnailProvider = _authorizedImageProvider(
widget.thumbnailUrl, widget.thumbnailUrl,
widget.cacheKey, widget.cacheKey,
widget.thumbnailCacheManager, widget.thumbnailCacheManager,
@@ -121,7 +127,7 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
); );
if (widget.previewUrl != null) { if (widget.previewUrl != null) {
CachedNetworkImageProvider previewProvider = _authorizedImageProvider( previewProvider = _authorizedImageProvider(
widget.previewUrl!, widget.previewUrl!,
"${widget.cacheKey}_previewStage", "${widget.cacheKey}_previewStage",
widget.previewCacheManager, widget.previewCacheManager,
@@ -133,7 +139,7 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
); );
} }
CachedNetworkImageProvider fullProvider = _authorizedImageProvider( fullProvider = _authorizedImageProvider(
widget.imageUrl, widget.imageUrl,
"${widget.cacheKey}_fullStage", "${widget.cacheKey}_fullStage",
widget.fullCacheManager, widget.fullCacheManager,
@@ -150,6 +156,19 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
_loadImages(); _loadImages();
super.initState(); super.initState();
} }
@override
void dispose() async {
super.dispose();
await thumbnailProvider.evict();
await fullProvider.evict();
if (widget.previewUrl != null) {
await previewProvider.evict();
}
_imageProvider.evict();
}
} }
class RemotePhotoView extends StatefulWidget { class RemotePhotoView extends StatefulWidget {

View File

@@ -79,7 +79,7 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
_createChewieController() { _createChewieController() {
chewieController = ChewieController( chewieController = ChewieController(
showOptions: true, showOptions: true,
showControlsOnInitialize: true, showControlsOnInitialize: false,
videoPlayerController: videoPlayerController, videoPlayerController: videoPlayerController,
autoPlay: true, autoPlay: true,
autoInitialize: true, autoInitialize: true,

View File

@@ -173,7 +173,8 @@ class BackgroundService {
} }
} catch (error) { } catch (error) {
debugPrint( debugPrint(
"[_clearErrorNotifications] failed to communicate with plugin"); "[_clearErrorNotifications] failed to communicate with plugin",
);
} }
return false; return false;
} }
@@ -344,7 +345,9 @@ class BackgroundService {
} }
Future<bool> _runBackup( Future<bool> _runBackup(
BackupService backupService, HiveBackupAlbums backupAlbumInfo) async { BackupService backupService,
HiveBackupAlbums backupAlbumInfo,
) async {
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded(); _errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
if (_canceledBySystem) { if (_canceledBySystem) {
@@ -445,6 +448,7 @@ class BackgroundService {
} }
/// entry point called by Kotlin/Java code; needs to be a top-level function /// entry point called by Kotlin/Java code; needs to be a top-level function
@pragma('vm:entry-point')
void _nativeEntry() { void _nativeEntry() {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
BackgroundService backgroundService = BackgroundService(); BackgroundService backgroundService = BackgroundService();

View File

@@ -173,19 +173,19 @@ class BackupControllerPage extends HookConsumerWidget {
).tr(), ).tr(),
), ),
actions: [ actions: [
TextButton( OutlinedButton(
onPressed: () => launchUrl( onPressed: () => launchUrl(
Uri.parse('https://dontkillmyapp.com'), Uri.parse('https://dontkillmyapp.com'),
mode: LaunchMode.externalApplication), mode: LaunchMode.externalApplication,
child: Text( ),
child: const Text(
"backup_controller_page_background_battery_info_link", "backup_controller_page_background_battery_info_link",
style: TextStyle(color: buttonTextColor),
).tr(), ).tr(),
), ),
TextButton( ElevatedButton(
child: Text( child: const Text(
'backup_controller_page_background_battery_info_ok', 'backup_controller_page_background_battery_info_ok',
style: TextStyle(color: buttonTextColor), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
).tr(), ).tr(),
onPressed: () { onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
@@ -636,8 +636,8 @@ class BackupControllerPage extends HookConsumerWidget {
backupState.backupProgress == BackUpProgressEnum.inProgress backupState.backupProgress == BackUpProgressEnum.inProgress
? ElevatedButton( ? ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
primary: Colors.red[300], foregroundColor: Colors.grey[50],
onPrimary: Colors.grey[50], backgroundColor: Colors.red[300],
// padding: const EdgeInsets.all(14), // padding: const EdgeInsets.all(14),
), ),
onPressed: () { onPressed: () {

View File

@@ -182,7 +182,7 @@ packages:
name: chewie name: chewie
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.3.2" version: "1.3.5"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@@ -238,7 +238,7 @@ packages:
name: cupertino_icons name: cupertino_icons
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.4" version: "1.0.5"
dart_style: dart_style:
dependency: transitive dependency: transitive
description: description:
@@ -839,7 +839,7 @@ packages:
name: provider name: provider
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.0.2" version: "6.0.3"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
@@ -1223,27 +1223,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
very_good_analysis:
dependency: transitive
description:
name: very_good_analysis
url: "https://pub.dartlang.org"
source: hosted
version: "2.4.0"
video_player: video_player:
dependency: "direct main" dependency: "direct main"
description: description:
name: video_player name: video_player
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.4.2" version: "2.4.7"
video_player_android: video_player_android:
dependency: transitive dependency: transitive
description: description:
name: video_player_android name: video_player_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.3.3" version: "2.3.9"
video_player_avfoundation: video_player_avfoundation:
dependency: transitive dependency: transitive
description: description:
@@ -1271,7 +1264,7 @@ packages:
name: wakelock name: wakelock
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.6.1+2" version: "0.6.2"
wakelock_macos: wakelock_macos:
dependency: transitive dependency: transitive
description: description:

View File

@@ -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.28.1+39 version: 1.28.3+41
environment: environment:
sdk: ">=2.17.0 <3.0.0" sdk: ">=2.17.0 <3.0.0"
@@ -26,7 +26,7 @@ dependencies:
flutter_launcher_icons: "^0.9.2" flutter_launcher_icons: "^0.9.2"
fluttertoast: ^8.0.8 fluttertoast: ^8.0.8
video_player: ^2.2.18 video_player: ^2.2.18
chewie: ^1.2.2 chewie: ^1.3.5
badges: ^2.0.2 badges: ^2.0.2
photo_view: ^0.14.0 photo_view: ^0.14.0
socket_io_client: ^2.0.0-beta.4-nullsafety.0 socket_io_client: ^2.0.0-beta.4-nullsafety.0

View File

@@ -50,9 +50,14 @@ export class AlbumRepository implements IAlbumRepository {
where: { sharedUserId: userId }, where: { sharedUserId: userId },
}); });
const sharingAlbums = ownedAlbums.map((album) => album.sharedUsers?.length || 0).reduce((a, b) => a + b, 0); let sharedAlbumCount = 0;
ownedAlbums.map((album) => {
if (album.sharedUsers?.length) {
sharedAlbumCount += 1;
}
});
return new AlbumCountResponseDto(ownedAlbums.length, sharedAlbums, sharingAlbums); return new AlbumCountResponseDto(ownedAlbums.length, sharedAlbums, sharedAlbumCount);
} }
async create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity> { async create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity> {

View File

@@ -8,6 +8,7 @@ import { AlbumEntity } from '../../../../../libs/database/src/entities/album.ent
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity'; import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
import { UserAlbumEntity } from '@app/database/entities/user-album.entity'; import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository'; import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository';
import { AssetRepository, ASSET_REPOSITORY } from '../asset/asset-repository';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([AssetEntity, UserEntity, AlbumEntity, AssetAlbumEntity, UserAlbumEntity])], imports: [TypeOrmModule.forFeature([AssetEntity, UserEntity, AlbumEntity, AssetAlbumEntity, UserAlbumEntity])],
@@ -18,6 +19,10 @@ import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository';
provide: ALBUM_REPOSITORY, provide: ALBUM_REPOSITORY,
useClass: AlbumRepository, useClass: AlbumRepository,
}, },
{
provide: ASSET_REPOSITORY,
useClass: AssetRepository,
},
], ],
}) })
export class AlbumModule {} export class AlbumModule {}

View File

@@ -4,10 +4,13 @@ import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { AlbumEntity } from '@app/database/entities/album.entity'; import { AlbumEntity } from '@app/database/entities/album.entity';
import { AlbumResponseDto } from './response-dto/album-response.dto'; import { AlbumResponseDto } from './response-dto/album-response.dto';
import { IAssetRepository } from '../asset/asset-repository';
describe('Album service', () => { describe('Album service', () => {
let sut: AlbumService; let sut: AlbumService;
let albumRepositoryMock: jest.Mocked<IAlbumRepository>; let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
const authUser: AuthUserDto = Object.freeze({ const authUser: AuthUserDto = Object.freeze({
id: '1111', id: '1111',
email: 'auth@test.com', email: 'auth@test.com',
@@ -118,7 +121,22 @@ describe('Album service', () => {
getListByAssetId: jest.fn(), getListByAssetId: jest.fn(),
getCountByUserId: jest.fn(), getCountByUserId: jest.fn(),
}; };
sut = new AlbumService(albumRepositoryMock);
assetRepositoryMock = {
create: jest.fn(),
getAllByUserId: jest.fn(),
getAllByDeviceId: jest.fn(),
getAssetCountByTimeBucket: jest.fn(),
getById: jest.fn(),
getDetectedObjectsByUserId: jest.fn(),
getLocationsByUserId: jest.fn(),
getSearchPropertiesByUserId: jest.fn(),
getAssetByTimeBucket: jest.fn(),
getAssetByChecksum: jest.fn(),
getAssetCountByUserId: jest.fn(),
};
sut = new AlbumService(albumRepositoryMock, assetRepositoryMock);
}); });
it('creates album', async () => { it('creates album', async () => {

View File

@@ -10,10 +10,14 @@ import { GetAlbumsDto } from './dto/get-albums.dto';
import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto'; import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto';
import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository'; import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto'; import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { ASSET_REPOSITORY, IAssetRepository } from '../asset/asset-repository';
@Injectable() @Injectable()
export class AlbumService { export class AlbumService {
constructor(@Inject(ALBUM_REPOSITORY) private _albumRepository: IAlbumRepository) {} constructor(
@Inject(ALBUM_REPOSITORY) private _albumRepository: IAlbumRepository,
@Inject(ASSET_REPOSITORY) private _assetRepository: IAssetRepository,
) {}
private async _getAlbum({ private async _getAlbum({
authUser, authUser,
@@ -54,6 +58,11 @@ export class AlbumService {
return albums.map(mapAlbumExcludeAssetInfo); return albums.map(mapAlbumExcludeAssetInfo);
} }
const albums = await this._albumRepository.getList(authUser.id, getAlbumsDto); const albums = await this._albumRepository.getList(authUser.id, getAlbumsDto);
for (const album of albums) {
await this._checkValidThumbnail(album);
}
return albums.map((album) => mapAlbumExcludeAssetInfo(album)); return albums.map((album) => mapAlbumExcludeAssetInfo(album));
} }
@@ -123,4 +132,18 @@ export class AlbumService {
async getAlbumCountByUserId(authUser: AuthUserDto): Promise<AlbumCountResponseDto> { async getAlbumCountByUserId(authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
return this._albumRepository.getCountByUserId(authUser.id); return this._albumRepository.getCountByUserId(authUser.id);
} }
async _checkValidThumbnail(album: AlbumEntity): Promise<AlbumEntity> {
const assetId = album.albumThumbnailAssetId;
if (assetId) {
try {
await this._assetRepository.getById(assetId);
} catch (e) {
album.albumThumbnailAssetId = null;
return await this._albumRepository.updateAlbum(album, {});
}
}
return album;
}
} }

View File

@@ -36,6 +36,7 @@ import {
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto'; import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto'; import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto'; import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { timeUtils } from '@app/common/utils';
const fileInfo = promisify(stat); const fileInfo = promisify(stat);
@@ -56,6 +57,18 @@ export class AssetService {
mimeType: string, mimeType: string,
checksum: Buffer, checksum: Buffer,
): Promise<AssetEntity> { ): Promise<AssetEntity> {
// Check valid time.
const createdAt = createAssetDto.createdAt;
const modifiedAt = createAssetDto.modifiedAt;
if (!timeUtils.checkValidTimestamp(createdAt)) {
createAssetDto.createdAt = await timeUtils.getTimestampFromExif(originalPath);
}
if (!timeUtils.checkValidTimestamp(modifiedAt)) {
createAssetDto.modifiedAt = await timeUtils.getTimestampFromExif(originalPath);
}
const assetEntity = await this._assetRepository.create( const assetEntity = await this._assetRepository.create(
createAssetDto, createAssetDto,
authUser.id, authUser.id,

View File

@@ -11,6 +11,6 @@ export interface IServerVersion {
export const serverVersion: IServerVersion = { export const serverVersion: IServerVersion = {
major: 1, major: 1,
minor: 28, minor: 28,
patch: 1, patch: 4,
build: 39, build: 41,
}; };

View File

@@ -6,6 +6,7 @@ import cookieParser from 'cookie-parser';
import { writeFileSync } from 'fs'; import { writeFileSync } from 'fs';
import path from 'path'; import path from 'path';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { serverVersion } from './constants/server_version.constant';
import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware'; import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
async function bootstrap() { async function bootstrap() {
@@ -52,11 +53,17 @@ async function bootstrap() {
// Generate API Documentation only in development mode // Generate API Documentation only in development mode
const outputPath = path.resolve(process.cwd(), 'immich-openapi-specs.json'); const outputPath = path.resolve(process.cwd(), 'immich-openapi-specs.json');
writeFileSync(outputPath, JSON.stringify(apiDocument), { encoding: 'utf8' }); writeFileSync(outputPath, JSON.stringify(apiDocument), { encoding: 'utf8' });
Logger.log('Running Immich Server in DEVELOPMENT environment', 'ImmichServer'); Logger.log(
`Running Immich Server in DEVELOPMENT environment - version ${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`,
'ImmichServer',
);
} }
if (process.env.NODE_ENV == 'production') { if (process.env.NODE_ENV == 'production') {
Logger.log('Running Immich Server in PRODUCTION environment', 'ImmichServer'); Logger.log(
`Running Immich Server in PRODUCTION environment - version ${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`,
'ImmichServer',
);
} }
}); });
} }

View File

@@ -1,5 +1,6 @@
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { serverVersion } from 'apps/immich/src/constants/server_version.constant';
import { RedisIoAdapter } from '../../immich/src/middlewares/redis-io.adapter.middleware'; import { RedisIoAdapter } from '../../immich/src/middlewares/redis-io.adapter.middleware';
import { MicroservicesModule } from './microservices.module'; import { MicroservicesModule } from './microservices.module';
@@ -10,11 +11,17 @@ async function bootstrap() {
await app.listen(3002, () => { await app.listen(3002, () => {
if (process.env.NODE_ENV == 'development') { if (process.env.NODE_ENV == 'development') {
Logger.log('Running Immich Microservices in DEVELOPMENT environment', 'ImmichMicroservice'); Logger.log(
`Running Immich Microservices in DEVELOPMENT environment - version ${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`,
'ImmichMicroservice',
);
} }
if (process.env.NODE_ENV == 'production') { if (process.env.NODE_ENV == 'production') {
Logger.log('Running Immich Microservices in PRODUCTION environment', 'ImmichMicroservice'); Logger.log(
`Running Immich Microservices in PRODUCTION environment - version ${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`,
'ImmichMicroservice',
);
} }
}); });
} }

View File

@@ -13,7 +13,7 @@ import {
} from '@app/job/constants/queue-name.constant'; } from '@app/job/constants/queue-name.constant';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module'; import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module';
import { MicroservicesService } from './microservices.service'; import { MicroservicesService } from './microservices.service';
@@ -40,42 +40,48 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
}, },
}), }),
}), }),
BullModule.registerQueue({ BullModule.registerQueue(
name: thumbnailGeneratorQueueName, {
defaultJobOptions: { name: thumbnailGeneratorQueueName,
attempts: 3, defaultJobOptions: {
removeOnComplete: true, attempts: 3,
removeOnFail: false, removeOnComplete: true,
removeOnFail: false,
},
}, },
}, { {
name: assetUploadedQueueName, name: assetUploadedQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
removeOnFail: false, removeOnFail: false,
},
}, },
}, { {
name: metadataExtractionQueueName, name: metadataExtractionQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
removeOnFail: false, removeOnFail: false,
},
}, },
}, { {
name: videoConversionQueueName, name: videoConversionQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
removeOnFail: false, removeOnFail: false,
},
}, },
}, { {
name: generateChecksumQueueName, name: generateChecksumQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
removeOnFail: false, removeOnFail: false,
},
}, },
}), ),
CommunicationModule, CommunicationModule,
], ],
controllers: [], controllers: [],
@@ -86,6 +92,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
MetadataExtractionProcessor, MetadataExtractionProcessor,
VideoTranscodeProcessor, VideoTranscodeProcessor,
GenerateChecksumProcessor, GenerateChecksumProcessor,
ConfigService,
], ],
exports: [], exports: [],
}) })

View File

@@ -5,7 +5,7 @@ import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import { IsNull, Repository } from 'typeorm'; import { FindOptionsWhere, IsNull, MoreThan, QueryFailedError, Repository } from 'typeorm';
// TODO: just temporary task to generate previous uploaded assets. // TODO: just temporary task to generate previous uploaded assets.
@Processor(generateChecksumQueueName) @Processor(generateChecksumQueueName)
@@ -17,15 +17,23 @@ export class GenerateChecksumProcessor {
@Process() @Process()
async generateChecksum() { async generateChecksum() {
let hasNext = true;
const pageSize = 200; const pageSize = 200;
let hasNext = true;
let lastErrAssetId: string | undefined = undefined;
while (hasNext) { while (hasNext) {
const whereStat: FindOptionsWhere<AssetEntity> = {
checksum: IsNull(),
};
if (lastErrAssetId) {
whereStat.id = MoreThan(lastErrAssetId);
}
const assets = await this.assetRepository.find({ const assets = await this.assetRepository.find({
where: { where: whereStat,
checksum: IsNull()
},
take: pageSize, take: pageSize,
order: { id: 'ASC' }
}); });
if (!assets?.length) { if (!assets?.length) {
@@ -35,15 +43,24 @@ export class GenerateChecksumProcessor {
try { try {
await this.generateAssetChecksum(asset); await this.generateAssetChecksum(asset);
} catch (err: any) { } catch (err: any) {
Logger.error(`Error generate checksum ${err}`); lastErrAssetId = asset.id;
if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') {
Logger.error(`${asset.originalPath} duplicated`);
} else {
Logger.error(`checksum generation ${err}`);
}
} }
} }
// break when reach to the last page
if (assets.length < pageSize) { if (assets.length < pageSize) {
hasNext = false; hasNext = false;
} }
} }
} }
Logger.log(`checksum generation done!`);
} }
private async generateAssetChecksum(asset: AssetEntity) { private async generateAssetChecksum(asset: AssetEntity) {

View File

@@ -1,3 +1,4 @@
import { ImmichLogLevel } from '@app/common/constants/log-level.constant';
import { AssetEntity } from '@app/database/entities/asset.entity'; import { AssetEntity } from '@app/database/entities/asset.entity';
import { ExifEntity } from '@app/database/entities/exif.entity'; import { ExifEntity } from '@app/database/entities/exif.entity';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity'; import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
@@ -16,6 +17,7 @@ import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding'; import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding';
import { Process, Processor } from '@nestjs/bull'; import { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import axios from 'axios'; import axios from 'axios';
import { Job } from 'bull'; import { Job } from 'bull';
@@ -28,6 +30,7 @@ import { Repository } from 'typeorm/repository/Repository';
@Processor(metadataExtractionQueueName) @Processor(metadataExtractionQueueName)
export class MetadataExtractionProcessor { export class MetadataExtractionProcessor {
private geocodingClient?: GeocodeService; private geocodingClient?: GeocodeService;
private logLevel: ImmichLogLevel;
constructor( constructor(
@InjectRepository(AssetEntity) @InjectRepository(AssetEntity)
@@ -38,12 +41,16 @@ export class MetadataExtractionProcessor {
@InjectRepository(SmartInfoEntity) @InjectRepository(SmartInfoEntity)
private smartInfoRepository: Repository<SmartInfoEntity>, private smartInfoRepository: Repository<SmartInfoEntity>,
private configService: ConfigService,
) { ) {
if (process.env.ENABLE_MAPBOX == 'true' && process.env.MAPBOX_KEY) { if (process.env.ENABLE_MAPBOX == 'true' && process.env.MAPBOX_KEY) {
this.geocodingClient = mapboxGeocoding({ this.geocodingClient = mapboxGeocoding({
accessToken: process.env.MAPBOX_KEY, accessToken: process.env.MAPBOX_KEY,
}); });
} }
this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE;
} }
@Process(exifExtractionProcessorName) @Process(exifExtractionProcessorName)
@@ -139,6 +146,10 @@ export class MetadataExtractionProcessor {
await this.exifRepository.save(newExif); await this.exifRepository.save(newExif);
} catch (e) { } catch (e) {
Logger.error(`Error extracting EXIF ${String(e)}`, 'extractExif'); Logger.error(`Error extracting EXIF ${String(e)}`, 'extractExif');
if (this.logLevel === ImmichLogLevel.VERBOSE) {
console.trace('Error extracting EXIF', e);
}
} }
} }

View File

@@ -1,3 +1,4 @@
import { ImmichLogLevel } from '@app/common/constants/log-level.constant';
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
import { import {
WebpGeneratorProcessor, WebpGeneratorProcessor,
@@ -11,6 +12,7 @@ import {
} from '@app/job'; } from '@app/job';
import { InjectQueue, Process, Processor } from '@nestjs/bull'; import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { mapAsset } from 'apps/immich/src/api-v1/asset/response-dto/asset-response.dto'; import { mapAsset } from 'apps/immich/src/api-v1/asset/response-dto/asset-response.dto';
import { Job, Queue } from 'bull'; import { Job, Queue } from 'bull';
@@ -23,6 +25,8 @@ import { CommunicationGateway } from '../../../immich/src/api-v1/communication/c
@Processor(thumbnailGeneratorQueueName) @Processor(thumbnailGeneratorQueueName)
export class ThumbnailGeneratorProcessor { export class ThumbnailGeneratorProcessor {
private logLevel: ImmichLogLevel;
constructor( constructor(
@InjectRepository(AssetEntity) @InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>, private assetRepository: Repository<AssetEntity>,
@@ -34,7 +38,11 @@ export class ThumbnailGeneratorProcessor {
@InjectQueue(metadataExtractionQueueName) @InjectQueue(metadataExtractionQueueName)
private metadataExtractionQueue: Queue, private metadataExtractionQueue: Queue,
) {}
private configService: ConfigService,
) {
this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE;
}
@Process({ name: generateJPEGThumbnailProcessorName, concurrency: 3 }) @Process({ name: generateJPEGThumbnailProcessorName, concurrency: 3 })
async generateJPEGThumbnail(job: Job<JpegGeneratorProcessor>) { async generateJPEGThumbnail(job: Job<JpegGeneratorProcessor>) {
@@ -51,8 +59,16 @@ export class ThumbnailGeneratorProcessor {
const jpegThumbnailPath = resizePath + originalFilename + '.jpeg'; const jpegThumbnailPath = resizePath + originalFilename + '.jpeg';
if (asset.type == AssetType.IMAGE) { if (asset.type == AssetType.IMAGE) {
await sharp(asset.originalPath).resize(1440, 2560, { fit: 'inside' }).jpeg().rotate().toFile(jpegThumbnailPath); try {
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath }); await sharp(asset.originalPath).resize(1440, 2560, { fit: 'inside' }).jpeg().rotate().toFile(jpegThumbnailPath);
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
} catch (error) {
Logger.error('Failed to generate jpeg thumbnail for asset: ' + asset.id);
if (this.logLevel == ImmichLogLevel.VERBOSE) {
console.trace('Failed to generate jpeg thumbnail for asset', error);
}
}
// Update resize path to send to generate webp queue // Update resize path to send to generate webp queue
asset.resizePath = jpegThumbnailPath; asset.resizePath = jpegThumbnailPath;
@@ -105,7 +121,15 @@ export class ThumbnailGeneratorProcessor {
const webpPath = asset.resizePath.replace('jpeg', 'webp'); const webpPath = asset.resizePath.replace('jpeg', 'webp');
await sharp(asset.resizePath).resize(250).webp().rotate().toFile(webpPath); try {
await this.assetRepository.update({ id: asset.id }, { webpPath: webpPath }); await sharp(asset.resizePath).resize(250).webp().rotate().toFile(webpPath);
await this.assetRepository.update({ id: asset.id }, { webpPath: webpPath });
} catch (error) {
Logger.error('Failed to generate webp thumbnail for asset: ' + asset.id);
if (this.logLevel == ImmichLogLevel.VERBOSE) {
console.trace('Failed to generate webp thumbnail for asset', error);
}
}
} }
} }

View File

@@ -16,5 +16,6 @@ export const immichAppConfig: ConfigModuleOptions = {
then: Joi.string().optional().allow(null, ''), then: Joi.string().optional().allow(null, ''),
otherwise: Joi.string().required(), otherwise: Joi.string().required(),
}), }),
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
}), }),
}; };

View File

@@ -0,0 +1,4 @@
export enum ImmichLogLevel {
SIMPLE = 'simple',
VERBOSE = 'verbose',
}

View File

@@ -1,2 +1,3 @@
export * from './config'; export * from './config';
export * from './constants'; export * from './constants';
export * from './utils';

View File

@@ -0,0 +1 @@
export * from './time-utils';

View File

@@ -0,0 +1,37 @@
// create unit test for time utils
import { timeUtils } from './time-utils';
describe('Time Utilities', () => {
describe('checkValidTimestamp', () => {
it('check for year 0000', () => {
const result = timeUtils.checkValidTimestamp('0000-00-00T00:00:00.000Z');
expect(result).toBeFalsy();
});
it('check for 6-digits year with plus sign', () => {
const result = timeUtils.checkValidTimestamp('+12345-00-00T00:00:00.000Z');
expect(result).toBeFalsy();
});
it('check for 6-digits year with negative sign', () => {
const result = timeUtils.checkValidTimestamp('-12345-00-00T00:00:00.000Z');
expect(result).toBeFalsy();
});
it('check for current date', () => {
const result = timeUtils.checkValidTimestamp(new Date().toISOString());
expect(result).toBeTruthy();
});
it('check for year before 1583', () => {
const result = timeUtils.checkValidTimestamp('1582-12-31T23:59:59.999Z');
expect(result).toBeFalsy();
});
it('check for year after 9999', () => {
const result = timeUtils.checkValidTimestamp('10000-00-00T00:00:00.000Z');
expect(result).toBeFalsy();
});
});
});

View File

@@ -0,0 +1,48 @@
import exifr from 'exifr';
function createTimeUtils() {
const checkValidTimestamp = (timestamp: string): boolean => {
const parsedTimestamp = Date.parse(timestamp);
if (isNaN(parsedTimestamp)) {
return false;
}
const date = new Date(parsedTimestamp);
if (date.getFullYear() < 1583 || date.getFullYear() > 9999) {
return false;
}
return date.getFullYear() > 0;
};
const getTimestampFromExif = async (originalPath: string): Promise<string> => {
try {
const exifData = await exifr.parse(originalPath, {
tiff: true,
ifd0: true as any,
ifd1: true,
exif: true,
gps: true,
interop: true,
xmp: true,
icc: true,
iptc: true,
jfif: true,
ihdr: true,
});
if (exifData && exifData['DateTimeOriginal']) {
return exifData['DateTimeOriginal'];
} else {
return new Date().toISOString();
}
} catch (error) {
return new Date().toISOString();
}
};
return { checkValidTimestamp, getTimestampFromExif };
}
export const timeUtils = createTimeUtils();

View File

@@ -129,6 +129,7 @@
"^@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/common": "<rootDir>/libs/common/src",
"^@app/job(|/.*)$": "<rootDir>/libs/job/src/$1" "^@app/job(|/.*)$": "<rootDir>/libs/job/src/$1"
} }
} }

View File

@@ -7,6 +7,7 @@
import moment from 'moment'; import moment from 'moment';
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
import { browser } from '$app/env'; import { browser } from '$app/env';
import { env } from '$env/dynamic/public';
import { AssetResponseDto, AlbumResponseDto } from '@api'; import { AssetResponseDto, AlbumResponseDto } from '@api';
type Leaflet = typeof import('leaflet'); type Leaflet = typeof import('leaflet');
@@ -30,6 +31,13 @@
if (asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null) { if (asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null) {
await drawMap(asset.exifInfo.latitude, asset.exifInfo.longitude); await drawMap(asset.exifInfo.latitude, asset.exifInfo.longitude);
} }
// remove timezone when user not config PUBLIC_TZ var. Etc/UTC is used in default.
if (asset.exifInfo?.dateTimeOriginal && !env.PUBLIC_TZ) {
const dateTimeOriginal = asset.exifInfo.dateTimeOriginal;
asset.exifInfo.dateTimeOriginal = dateTimeOriginal.slice(0, dateTimeOriginal.length - 1);
}
} }
}); });
@@ -126,11 +134,7 @@
<p>{moment(asset.exifInfo.dateTimeOriginal).format('MMM DD, YYYY')}</p> <p>{moment(asset.exifInfo.dateTimeOriginal).format('MMM DD, YYYY')}</p>
<div class="flex gap-2 text-sm"> <div class="flex gap-2 text-sm">
<p> <p>
{moment( {moment(asset.exifInfo.dateTimeOriginal).format('ddd, hh:mm A')}
asset.exifInfo.dateTimeOriginal
.toString()
.slice(0, asset.exifInfo.dateTimeOriginal.toString().length - 1)
).format('ddd, hh:mm A')}
</p> </p>
<p>GMT{moment(asset.exifInfo.dateTimeOriginal).format('Z')}</p> <p>GMT{moment(asset.exifInfo.dateTimeOriginal).format('Z')}</p>
</div> </div>