mirror of
https://github.com/immich-app/immich.git
synced 2025-12-09 01:10:08 +03:00
Compare commits
12 Commits
v1.28.1_39
...
v1.28.4_42
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e79e92c60f | ||
|
|
858ad43d3b | ||
|
|
5761765ea7 | ||
|
|
6abc733763 | ||
|
|
4271e24e59 | ||
|
|
9e4ed2214b | ||
|
|
011332e509 | ||
|
|
5403ef4d84 | ||
|
|
31739aca02 | ||
|
|
8f2e7b6f65 | ||
|
|
4ed647c43d | ||
|
|
f88ff4fb5c |
@@ -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 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 `TZ` as your timezone, default is `Etc/UTC`.
|
||||
|
||||
### Step 3 - Start the containers
|
||||
|
||||
|
||||
@@ -36,6 +36,11 @@ REDIS_HOSTNAME=immich_redis
|
||||
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.
|
||||
# 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
|
||||
@@ -47,6 +47,8 @@ services:
|
||||
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- PUBLIC_TZ=${TZ}
|
||||
restart: always
|
||||
|
||||
redis:
|
||||
|
||||
25
install.sh
25
install.sh
@@ -6,10 +6,6 @@ RED='\033[0;31m'
|
||||
GREEN='\032[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
machine_has() {
|
||||
type "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
create_immich_directory() {
|
||||
echo "Creating Immich directory..."
|
||||
mkdir -p ./immich-app/immich-data
|
||||
@@ -45,18 +41,21 @@ populate_upload_location() {
|
||||
start_docker_compose() {
|
||||
echo "Starting Immich's docker containers"
|
||||
|
||||
if machine_has "docker compose"; then {
|
||||
docker compose up --remove-orphans -d
|
||||
|
||||
show_friendly_message
|
||||
exit 0
|
||||
}; fi
|
||||
|
||||
if machine_has "docker-compose"; then
|
||||
docker-compose up --remove-orphans -d
|
||||
if docker compose &> /dev/null; then
|
||||
docker_bin="docker compose"
|
||||
elif docker-compose &> /dev/null; then
|
||||
docker_bin="docker-compose"
|
||||
else
|
||||
echo 'Cannot find `docker compose` or `docker-compose`.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if $docker_bin up --remove-orphans -d; then
|
||||
show_friendly_message
|
||||
exit 0
|
||||
else
|
||||
echo "Could not start. Check for errors above."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 39,
|
||||
"android.injected.version.name" => "1.28.1",
|
||||
"android.injected.version.code" => 41,
|
||||
"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')
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
* Fix background service cannot run in release build
|
||||
@@ -0,0 +1,2 @@
|
||||
* Fixed oversize play button on video
|
||||
* Fixed app crashing when swipe between assets
|
||||
@@ -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 classname="fastlane.lanes" name="1: bundleRelease" time="55.311329">
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="58.722434">
|
||||
|
||||
</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>
|
||||
|
||||
|
||||
@@ -360,7 +360,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -495,7 +495,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -522,7 +522,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.27.0</string>
|
||||
<string>1.28.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>52</string>
|
||||
<string>55</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true />
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.28.1"
|
||||
version_number: "1.28.3"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -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 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 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 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 classname="fastlane.lanes" name="4: build_app" time="91.259106">
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="77.835137">
|
||||
|
||||
</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>
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
bool _zoomedIn = false;
|
||||
|
||||
static const int swipeThreshold = 100;
|
||||
late CachedNetworkImageProvider fullProvider;
|
||||
late CachedNetworkImageProvider previewProvider;
|
||||
late CachedNetworkImageProvider thumbnailProvider;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -65,7 +68,10 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
}
|
||||
|
||||
CachedNetworkImageProvider _authorizedImageProvider(
|
||||
String url, String cacheKey, BaseCacheManager? cacheManager) {
|
||||
String url,
|
||||
String cacheKey,
|
||||
BaseCacheManager? cacheManager,
|
||||
) {
|
||||
return CachedNetworkImageProvider(
|
||||
url,
|
||||
headers: {"Authorization": widget.authToken},
|
||||
@@ -104,7 +110,7 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
}
|
||||
|
||||
void _loadImages() {
|
||||
CachedNetworkImageProvider thumbnailProvider = _authorizedImageProvider(
|
||||
thumbnailProvider = _authorizedImageProvider(
|
||||
widget.thumbnailUrl,
|
||||
widget.cacheKey,
|
||||
widget.thumbnailCacheManager,
|
||||
@@ -121,7 +127,7 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
);
|
||||
|
||||
if (widget.previewUrl != null) {
|
||||
CachedNetworkImageProvider previewProvider = _authorizedImageProvider(
|
||||
previewProvider = _authorizedImageProvider(
|
||||
widget.previewUrl!,
|
||||
"${widget.cacheKey}_previewStage",
|
||||
widget.previewCacheManager,
|
||||
@@ -133,7 +139,7 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
);
|
||||
}
|
||||
|
||||
CachedNetworkImageProvider fullProvider = _authorizedImageProvider(
|
||||
fullProvider = _authorizedImageProvider(
|
||||
widget.imageUrl,
|
||||
"${widget.cacheKey}_fullStage",
|
||||
widget.fullCacheManager,
|
||||
@@ -150,6 +156,19 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
_loadImages();
|
||||
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 {
|
||||
|
||||
@@ -79,7 +79,7 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
|
||||
_createChewieController() {
|
||||
chewieController = ChewieController(
|
||||
showOptions: true,
|
||||
showControlsOnInitialize: true,
|
||||
showControlsOnInitialize: false,
|
||||
videoPlayerController: videoPlayerController,
|
||||
autoPlay: true,
|
||||
autoInitialize: true,
|
||||
|
||||
@@ -173,7 +173,8 @@ class BackgroundService {
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
"[_clearErrorNotifications] failed to communicate with plugin");
|
||||
"[_clearErrorNotifications] failed to communicate with plugin",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -344,7 +345,9 @@ class BackgroundService {
|
||||
}
|
||||
|
||||
Future<bool> _runBackup(
|
||||
BackupService backupService, HiveBackupAlbums backupAlbumInfo) async {
|
||||
BackupService backupService,
|
||||
HiveBackupAlbums backupAlbumInfo,
|
||||
) async {
|
||||
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
|
||||
|
||||
if (_canceledBySystem) {
|
||||
@@ -445,6 +448,7 @@ class BackgroundService {
|
||||
}
|
||||
|
||||
/// entry point called by Kotlin/Java code; needs to be a top-level function
|
||||
@pragma('vm:entry-point')
|
||||
void _nativeEntry() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
BackgroundService backgroundService = BackgroundService();
|
||||
|
||||
@@ -173,19 +173,19 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
).tr(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
OutlinedButton(
|
||||
onPressed: () => launchUrl(
|
||||
Uri.parse('https://dontkillmyapp.com'),
|
||||
mode: LaunchMode.externalApplication),
|
||||
child: Text(
|
||||
Uri.parse('https://dontkillmyapp.com'),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
child: const Text(
|
||||
"backup_controller_page_background_battery_info_link",
|
||||
style: TextStyle(color: buttonTextColor),
|
||||
).tr(),
|
||||
),
|
||||
TextButton(
|
||||
child: Text(
|
||||
ElevatedButton(
|
||||
child: const Text(
|
||||
'backup_controller_page_background_battery_info_ok',
|
||||
style: TextStyle(color: buttonTextColor),
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
).tr(),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
@@ -636,8 +636,8 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||
? ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: Colors.red[300],
|
||||
onPrimary: Colors.grey[50],
|
||||
foregroundColor: Colors.grey[50],
|
||||
backgroundColor: Colors.red[300],
|
||||
// padding: const EdgeInsets.all(14),
|
||||
),
|
||||
onPressed: () {
|
||||
|
||||
@@ -182,7 +182,7 @@ packages:
|
||||
name: chewie
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.3.2"
|
||||
version: "1.3.5"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -238,7 +238,7 @@ packages:
|
||||
name: cupertino_icons
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
version: "1.0.5"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -839,7 +839,7 @@ packages:
|
||||
name: provider
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.0.2"
|
||||
version: "6.0.3"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1223,27 +1223,20 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: video_player
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "2.4.7"
|
||||
video_player_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.3.3"
|
||||
version: "2.3.9"
|
||||
video_player_avfoundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1271,7 +1264,7 @@ packages:
|
||||
name: wakelock
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.6.1+2"
|
||||
version: "0.6.2"
|
||||
wakelock_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: "none"
|
||||
version: 1.28.1+39
|
||||
version: 1.28.3+41
|
||||
|
||||
environment:
|
||||
sdk: ">=2.17.0 <3.0.0"
|
||||
@@ -26,7 +26,7 @@ dependencies:
|
||||
flutter_launcher_icons: "^0.9.2"
|
||||
fluttertoast: ^8.0.8
|
||||
video_player: ^2.2.18
|
||||
chewie: ^1.2.2
|
||||
chewie: ^1.3.5
|
||||
badges: ^2.0.2
|
||||
photo_view: ^0.14.0
|
||||
socket_io_client: ^2.0.0-beta.4-nullsafety.0
|
||||
|
||||
@@ -50,9 +50,14 @@ export class AlbumRepository implements IAlbumRepository {
|
||||
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> {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AlbumEntity } from '../../../../../libs/database/src/entities/album.ent
|
||||
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
|
||||
import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
|
||||
import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository';
|
||||
import { AssetRepository, ASSET_REPOSITORY } from '../asset/asset-repository';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AssetEntity, UserEntity, AlbumEntity, AssetAlbumEntity, UserAlbumEntity])],
|
||||
@@ -18,6 +19,10 @@ import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository';
|
||||
provide: ALBUM_REPOSITORY,
|
||||
useClass: AlbumRepository,
|
||||
},
|
||||
{
|
||||
provide: ASSET_REPOSITORY,
|
||||
useClass: AssetRepository,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AlbumModule {}
|
||||
|
||||
@@ -4,10 +4,13 @@ import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { AlbumEntity } from '@app/database/entities/album.entity';
|
||||
import { AlbumResponseDto } from './response-dto/album-response.dto';
|
||||
import { IAssetRepository } from '../asset/asset-repository';
|
||||
|
||||
describe('Album service', () => {
|
||||
let sut: AlbumService;
|
||||
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
|
||||
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
|
||||
|
||||
const authUser: AuthUserDto = Object.freeze({
|
||||
id: '1111',
|
||||
email: 'auth@test.com',
|
||||
@@ -118,7 +121,22 @@ describe('Album service', () => {
|
||||
getListByAssetId: 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 () => {
|
||||
|
||||
@@ -10,10 +10,14 @@ import { GetAlbumsDto } from './dto/get-albums.dto';
|
||||
import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto';
|
||||
import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository';
|
||||
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
|
||||
import { ASSET_REPOSITORY, IAssetRepository } from '../asset/asset-repository';
|
||||
|
||||
@Injectable()
|
||||
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({
|
||||
authUser,
|
||||
@@ -54,6 +58,11 @@ export class AlbumService {
|
||||
return albums.map(mapAlbumExcludeAssetInfo);
|
||||
}
|
||||
const albums = await this._albumRepository.getList(authUser.id, getAlbumsDto);
|
||||
|
||||
for (const album of albums) {
|
||||
await this._checkValidThumbnail(album);
|
||||
}
|
||||
|
||||
return albums.map((album) => mapAlbumExcludeAssetInfo(album));
|
||||
}
|
||||
|
||||
@@ -123,4 +132,18 @@ export class AlbumService {
|
||||
async getAlbumCountByUserId(authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-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 { timeUtils } from '@app/common/utils';
|
||||
|
||||
const fileInfo = promisify(stat);
|
||||
|
||||
@@ -56,6 +57,18 @@ export class AssetService {
|
||||
mimeType: string,
|
||||
checksum: Buffer,
|
||||
): 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(
|
||||
createAssetDto,
|
||||
authUser.id,
|
||||
|
||||
@@ -11,6 +11,6 @@ export interface IServerVersion {
|
||||
export const serverVersion: IServerVersion = {
|
||||
major: 1,
|
||||
minor: 28,
|
||||
patch: 1,
|
||||
build: 39,
|
||||
patch: 3,
|
||||
build: 41,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import cookieParser from 'cookie-parser';
|
||||
import { writeFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
import { serverVersion } from './constants/server_version.constant';
|
||||
import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
|
||||
|
||||
async function bootstrap() {
|
||||
@@ -52,11 +53,17 @@ async function bootstrap() {
|
||||
// Generate API Documentation only in development mode
|
||||
const outputPath = path.resolve(process.cwd(), 'immich-openapi-specs.json');
|
||||
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') {
|
||||
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',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
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 { MicroservicesModule } from './microservices.module';
|
||||
|
||||
@@ -10,11 +11,17 @@ async function bootstrap() {
|
||||
|
||||
await app.listen(3002, () => {
|
||||
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') {
|
||||
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',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@app/job/constants/queue-name.constant';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module';
|
||||
import { MicroservicesService } from './microservices.service';
|
||||
@@ -40,42 +40,48 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
|
||||
},
|
||||
}),
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: thumbnailGeneratorQueueName,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
BullModule.registerQueue(
|
||||
{
|
||||
name: thumbnailGeneratorQueueName,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: assetUploadedQueueName,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
{
|
||||
name: assetUploadedQueueName,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: metadataExtractionQueueName,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
{
|
||||
name: metadataExtractionQueueName,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: videoConversionQueueName,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
{
|
||||
name: videoConversionQueueName,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: generateChecksumQueueName,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
{
|
||||
name: generateChecksumQueueName,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
CommunicationModule,
|
||||
],
|
||||
controllers: [],
|
||||
@@ -86,6 +92,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
|
||||
MetadataExtractionProcessor,
|
||||
VideoTranscodeProcessor,
|
||||
GenerateChecksumProcessor,
|
||||
ConfigService,
|
||||
],
|
||||
exports: [],
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { createHash } from 'node:crypto';
|
||||
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.
|
||||
@Processor(generateChecksumQueueName)
|
||||
@@ -17,15 +17,23 @@ export class GenerateChecksumProcessor {
|
||||
|
||||
@Process()
|
||||
async generateChecksum() {
|
||||
let hasNext = true;
|
||||
const pageSize = 200;
|
||||
let hasNext = true;
|
||||
let lastErrAssetId: string | undefined = undefined;
|
||||
|
||||
while (hasNext) {
|
||||
const whereStat: FindOptionsWhere<AssetEntity> = {
|
||||
checksum: IsNull(),
|
||||
};
|
||||
|
||||
if (lastErrAssetId) {
|
||||
whereStat.id = MoreThan(lastErrAssetId);
|
||||
}
|
||||
|
||||
const assets = await this.assetRepository.find({
|
||||
where: {
|
||||
checksum: IsNull()
|
||||
},
|
||||
where: whereStat,
|
||||
take: pageSize,
|
||||
order: { id: 'ASC' }
|
||||
});
|
||||
|
||||
if (!assets?.length) {
|
||||
@@ -35,15 +43,24 @@ export class GenerateChecksumProcessor {
|
||||
try {
|
||||
await this.generateAssetChecksum(asset);
|
||||
} 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) {
|
||||
hasNext = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Logger.log(`checksum generation done!`);
|
||||
}
|
||||
|
||||
private async generateAssetChecksum(asset: AssetEntity) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ImmichLogLevel } from '@app/common/constants/log-level.constant';
|
||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||
import { ExifEntity } from '@app/database/entities/exif.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 { Process, Processor } from '@nestjs/bull';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import axios from 'axios';
|
||||
import { Job } from 'bull';
|
||||
@@ -28,6 +30,7 @@ import { Repository } from 'typeorm/repository/Repository';
|
||||
@Processor(metadataExtractionQueueName)
|
||||
export class MetadataExtractionProcessor {
|
||||
private geocodingClient?: GeocodeService;
|
||||
private logLevel: ImmichLogLevel;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity)
|
||||
@@ -38,12 +41,16 @@ export class MetadataExtractionProcessor {
|
||||
|
||||
@InjectRepository(SmartInfoEntity)
|
||||
private smartInfoRepository: Repository<SmartInfoEntity>,
|
||||
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
if (process.env.ENABLE_MAPBOX == 'true' && process.env.MAPBOX_KEY) {
|
||||
this.geocodingClient = mapboxGeocoding({
|
||||
accessToken: process.env.MAPBOX_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE;
|
||||
}
|
||||
|
||||
@Process(exifExtractionProcessorName)
|
||||
@@ -139,6 +146,10 @@ export class MetadataExtractionProcessor {
|
||||
await this.exifRepository.save(newExif);
|
||||
} catch (e) {
|
||||
Logger.error(`Error extracting EXIF ${String(e)}`, 'extractExif');
|
||||
|
||||
if (this.logLevel === ImmichLogLevel.VERBOSE) {
|
||||
console.trace('Error extracting EXIF', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ImmichLogLevel } from '@app/common/constants/log-level.constant';
|
||||
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
|
||||
import {
|
||||
WebpGeneratorProcessor,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
} from '@app/job';
|
||||
import { InjectQueue, Process, Processor } from '@nestjs/bull';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { mapAsset } from 'apps/immich/src/api-v1/asset/response-dto/asset-response.dto';
|
||||
import { Job, Queue } from 'bull';
|
||||
@@ -23,6 +25,8 @@ import { CommunicationGateway } from '../../../immich/src/api-v1/communication/c
|
||||
|
||||
@Processor(thumbnailGeneratorQueueName)
|
||||
export class ThumbnailGeneratorProcessor {
|
||||
private logLevel: ImmichLogLevel;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity)
|
||||
private assetRepository: Repository<AssetEntity>,
|
||||
@@ -34,7 +38,11 @@ export class ThumbnailGeneratorProcessor {
|
||||
|
||||
@InjectQueue(metadataExtractionQueueName)
|
||||
private metadataExtractionQueue: Queue,
|
||||
) {}
|
||||
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE;
|
||||
}
|
||||
|
||||
@Process({ name: generateJPEGThumbnailProcessorName, concurrency: 3 })
|
||||
async generateJPEGThumbnail(job: Job<JpegGeneratorProcessor>) {
|
||||
@@ -51,8 +59,16 @@ export class ThumbnailGeneratorProcessor {
|
||||
const jpegThumbnailPath = resizePath + originalFilename + '.jpeg';
|
||||
|
||||
if (asset.type == AssetType.IMAGE) {
|
||||
await sharp(asset.originalPath).resize(1440, 2560, { fit: 'inside' }).jpeg().rotate().toFile(jpegThumbnailPath);
|
||||
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
|
||||
try {
|
||||
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
|
||||
asset.resizePath = jpegThumbnailPath;
|
||||
@@ -105,7 +121,15 @@ export class ThumbnailGeneratorProcessor {
|
||||
|
||||
const webpPath = asset.resizePath.replace('jpeg', 'webp');
|
||||
|
||||
await sharp(asset.resizePath).resize(250).webp().rotate().toFile(webpPath);
|
||||
await this.assetRepository.update({ id: asset.id }, { webpPath: webpPath });
|
||||
try {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,6 @@ export const immichAppConfig: ConfigModuleOptions = {
|
||||
then: Joi.string().optional().allow(null, ''),
|
||||
otherwise: Joi.string().required(),
|
||||
}),
|
||||
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
|
||||
}),
|
||||
};
|
||||
|
||||
4
server/libs/common/src/constants/log-level.constant.ts
Normal file
4
server/libs/common/src/constants/log-level.constant.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum ImmichLogLevel {
|
||||
SIMPLE = 'simple',
|
||||
VERBOSE = 'verbose',
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './config';
|
||||
export * from './constants';
|
||||
export * from './utils';
|
||||
|
||||
1
server/libs/common/src/utils/index.ts
Normal file
1
server/libs/common/src/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './time-utils';
|
||||
37
server/libs/common/src/utils/time-utils.spec.ts
Normal file
37
server/libs/common/src/utils/time-utils.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
48
server/libs/common/src/utils/time-utils.ts
Normal file
48
server/libs/common/src/utils/time-utils.ts
Normal 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();
|
||||
@@ -129,6 +129,7 @@
|
||||
"^@app/database(|/.*)$": "<rootDir>/libs/database/src/$1",
|
||||
"@app/database/config/(.*)": "<rootDir>/libs/database/src/config/$1",
|
||||
"@app/database/config": "<rootDir>/libs/database/src/config",
|
||||
"@app/common": "<rootDir>/libs/common/src",
|
||||
"^@app/job(|/.*)$": "<rootDir>/libs/job/src/$1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import moment from 'moment';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { browser } from '$app/env';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { AssetResponseDto, AlbumResponseDto } from '@api';
|
||||
|
||||
type Leaflet = typeof import('leaflet');
|
||||
@@ -30,6 +31,13 @@
|
||||
if (asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null) {
|
||||
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>
|
||||
<div class="flex gap-2 text-sm">
|
||||
<p>
|
||||
{moment(
|
||||
asset.exifInfo.dateTimeOriginal
|
||||
.toString()
|
||||
.slice(0, asset.exifInfo.dateTimeOriginal.toString().length - 1)
|
||||
).format('ddd, hh:mm A')}
|
||||
{moment(asset.exifInfo.dateTimeOriginal).format('ddd, hh:mm A')}
|
||||
</p>
|
||||
<p>GMT{moment(asset.exifInfo.dateTimeOriginal).format('Z')}</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user