Compare commits

...

7 Commits

Author SHA1 Message Date
timonrieger c10ba15610 refactor(server): album user route params 2026-07-03 11:22:56 +02:00
github-actions f77c8a4699 chore: version v3.0.1 2026-07-02 19:24:13 +00:00
Daniel Dietzler bf4b020856 fix: setting input field description spacing for description snippet (#29433) 2026-07-02 14:06:03 -05:00
shenlong f73796597c fix: albums broken on v2 mobile clients (#29446)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-02 14:04:37 -05:00
Ben Beckford 237734bb26 feat(web): recently added link in sidebar (#29039)
* feat(web): recently added link in sidebar

* chore(mobile): update openapi patches
2026-07-01 23:12:50 +00:00
Weblate (bot) 4b54fef82e chore(web): update translations (#29410)
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ar/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/be/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/bg/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ca/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/cs/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/da/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/es/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/fr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ga/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/gl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/gu/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/he/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/it/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ja/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/pt/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ro/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sq/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sv/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/vi/
Translation: Immich/immich

Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Charles IdB <charles.issert2braux@gmail.com>
Co-authored-by: Dmitry Banny <dj.icecore@gmail.com>
Co-authored-by: Enric Pagès i Gassull <enricpages@hotmail.com>
Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: HackingAll <hacking.all.YT@gmail.com>
Co-authored-by: Harsh Kevadia <kevadiyaharsh@gmail.com>
Co-authored-by: Hosted Weblate user 156232 <53017937+parol100@users.noreply.github.com>
Co-authored-by: Hurricane_32 <rodrigorimo@hotmail.com>
Co-authored-by: Hồ Nhất Duy <axicenia@gmail.com>
Co-authored-by: Ivan Dimitrov <idimitrov08@gmail.com>
Co-authored-by: Jeppe Nellemann <jepnel@proton.me>
Co-authored-by: Mahmoud Dwidar <modydodo2055@gmail.com>
Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Co-authored-by: Pavel Miniutka <pavel.miniutka@gmail.com>
Co-authored-by: Yago Raña Gayoso <yago.rana.gayoso@gmail.com>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
Co-authored-by: ikeno-web <ikeno@nextcore-consulting.com>
Co-authored-by: rubes <mail@armd.one>
2026-07-01 11:05:25 +00:00
Daniel Dietzler 0050332391 fix: e2e version test (#29412) 2026-07-01 12:41:45 +02:00
39 changed files with 426 additions and 48 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
[
{
"label": "v3.0.0",
"url": "https://docs.v3.0.0.archive.immich.app"
"label": "v3.0.1",
"url": "https://docs.v3.0.1.archive.immich.app"
},
{
"label": "v2.7.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "3.0.0",
"version": "3.0.1",
"description": "",
"main": "index.js",
"type": "module",
+8 -6
View File
@@ -91,12 +91,14 @@ describe('/server', () => {
it('should respond with the server version', async () => {
const { status, body } = await request(app).get('/server/version');
expect(status).toBe(200);
expect(body).toEqual({
major: expect.any(Number),
minor: expect.any(Number),
patch: expect.any(Number),
prerelease: expect.anything(),
});
expect(body).toEqual(
expect.objectContaining({
major: expect.any(Number),
minor: expect.any(Number),
patch: expect.any(Number),
}),
);
expect(Object.keys(body)).toEqual(expect.arrayContaining(['major', 'minor', 'patch', 'prerelease']));
});
});
+3
View File
@@ -82,6 +82,9 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
cast: {
gCastEnabled: false,
},
recentlyAdded: {
sidebarWeb: false,
},
},
});
});
+1
View File
@@ -110,6 +110,7 @@ export async function enableTagsPreference(context: BrowserContext) {
download: { archiveSize: 4_294_967_296, includeEmbeddedVideos: false },
purchase: { showSupportBadge: true, hideBuyButtonUntil: '2100-02-12T00:00:00.000Z' },
cast: { gCastEnabled: false },
recentlyAdded: { sidebarWeb: false },
},
});
});
+1
View File
@@ -1718,6 +1718,7 @@
"recent_searches": "Recent searches",
"recently_added": "Recently added",
"recently_added_body": "Jump straight to everything you've added lately on a dedicated page.",
"recently_added_description": "Browse your assets sorted by when they were uploaded to Immich",
"recently_added_page_title": "Recently Added",
"recently_added_title": "Recently added",
"recently_taken": "Recently taken",
+29 -1
View File
@@ -6,7 +6,7 @@
"action": "Aksion",
"action_common_update": "Përditëso",
"action_description": "Një grup veprimesh për t'u kryer në asetet e filtruara",
"actions": "Aksione",
"actions": "Veprime",
"active": "Aktiv",
"active_count": "Aktive: {count}",
"activity": "Aktivitet",
@@ -536,6 +536,11 @@
"api_keys": "Çelësat API",
"app_architecture_variant": "Varianta (Arkitektura)",
"app_bar_signout_dialog_content": "A je i sigurt që dëshiron të dalësh?",
"back": "Mbrapa",
"close": "Mbyll",
"copy_image": "Kopjo imazhin",
"dark": "E errët",
"disabled": "I çaktivizuar",
"download_original": "Shkarko origjinalin",
"download_paused": "Shkarkimi u pezullua",
"download_settings": "Shkarko",
@@ -544,6 +549,29 @@
"downloading_asset_filename": "Duke shkarkuar asetin {filename}",
"downloading_from_icloud": "Duke shkarkuar nga iCloud",
"downloading_media": "Duke shkarkuar median",
"enable": "Aktivizo",
"error": "Gabim",
"expired": "Skaduar",
"image": "Imazhi",
"info": "Info",
"model": "Modeli",
"name": "Emri",
"none": "Asnjë",
"offline": "Jashtë linje",
"ok": "Në rregull",
"online": "Online",
"path": "Shtegu",
"refresh": "Rifresko",
"rename": "Riemërto",
"search": "Kërko",
"settings": "Cilësimet",
"size": "Madhësia",
"status": "Statusi",
"type": "Lloji",
"unknown": "E panjohur",
"upload_finished": "Ngarkimi përfundoi",
"username": "Emri i përdoruesit",
"version": "Versioni",
"you_dont_have_any_shared_links": "Nuk keni asnjë link të shpërndarë",
"your_wifi_name": "Emri i Wi-Fi tuaj",
"zoom_image": "Zmadho imazhin"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "3.0.0"
version = "3.0.1"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0"
+1 -1
View File
@@ -974,7 +974,7 @@ wheels = [
[[package]]
name = "immich-ml"
version = "3.0.0"
version = "3.0.1"
source = { editable = "." }
dependencies = [
{ name = "aiocache" },
+4 -4
View File
@@ -22,8 +22,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3053,
"android.injected.version.name" => "3.0.0",
"android.injected.version.code" => 3054,
"android.injected.version.name" => "3.0.1",
}
)
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', track: 'beta')
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3053,
"android.injected.version.name" => "3.0.0",
"android.injected.version.code" => 3054,
"android.injected.version.name" => "3.0.1",
}
)
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')
+1 -1
View File
@@ -78,7 +78,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>3.0.0</string>
<string>3.0.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -25,6 +25,7 @@ enum SyncMigrationTask {
v20260128_CopyExifWidthHeightToAsset, // Asset table has incorrect width and height for video ratio calculations.
v20260128_ResetAssetV1, // Asset v2.5.0 has width and height information that were edited assets.
v20260597_ResetAssetV1AssetV2, // Assets didn't include the uploadedAt column.
v20260701_ResetAlbumsV1, // Album user migration dropped the owner. Sync fresh albums from the server to re-populate them.
}
class SyncStreamService {
@@ -103,6 +104,12 @@ class SyncStreamService {
}
Future<void> _runPreSyncTasks(List<String> migrations, SemVer semVer) async {
if (!migrations.contains(SyncMigrationTask.v20260701_ResetAlbumsV1.name)) {
_logger.info("Running pre-sync task: v20260701_ResetAlbumsV1");
await _syncApiRepository.deleteSyncAck([SyncEntityType.albumV1]);
migrations.add(SyncMigrationTask.v20260701_ResetAlbumsV1.name);
}
if (!migrations.contains(SyncMigrationTask.v20260128_ResetExifV1.name)) {
_logger.info("Running pre-sync task: v20260128_ResetExifV1");
await _syncApiRepository.deleteSyncAck([
+1
View File
@@ -26,6 +26,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
'sharedLinks': SharedLinksResponse(enabled: true, sidebarWeb: false).toJson(),
'cast': CastResponse(gCastEnabled: false).toJson(),
'albums': {'defaultAssetOrder': 'desc'},
'recentlyAdded': RecentlyAddedResponse(sidebarWeb: false).toJson(),
},
'ServerConfigDto': {
'mapLightStyleUrl': 'https://tiles.immich.cloud/v1/style/light.json',
+3 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 3.0.0
- API version: 3.0.1
- Generator version: 7.22.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
@@ -545,6 +545,8 @@ Class | Method | HTTP request | Description
- [RatingsUpdate](doc//RatingsUpdate.md)
- [ReactionLevel](doc//ReactionLevel.md)
- [ReactionType](doc//ReactionType.md)
- [RecentlyAddedResponse](doc//RecentlyAddedResponse.md)
- [RecentlyAddedUpdate](doc//RecentlyAddedUpdate.md)
- [ReleaseChannel](doc//ReleaseChannel.md)
- [ReleaseEventV1](doc//ReleaseEventV1.md)
- [ReleaseType](doc//ReleaseType.md)
+2
View File
@@ -266,6 +266,8 @@ part 'model/ratings_response.dart';
part 'model/ratings_update.dart';
part 'model/reaction_level.dart';
part 'model/reaction_type.dart';
part 'model/recently_added_response.dart';
part 'model/recently_added_update.dart';
part 'model/release_channel.dart';
part 'model/release_event_v1.dart';
part 'model/release_type.dart';
+8
View File
@@ -681,8 +681,10 @@ class AlbumsApi {
/// Parameters:
///
/// * [String] id (required):
/// Album ID
///
/// * [String] userId (required):
/// Album user ID, or \"me\" to reference the current user
Future<Response> removeUserFromAlbumWithHttpInfo(String id, String userId, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/albums/{id}/user/{userId}'
@@ -718,8 +720,10 @@ class AlbumsApi {
/// Parameters:
///
/// * [String] id (required):
/// Album ID
///
/// * [String] userId (required):
/// Album user ID, or \"me\" to reference the current user
Future<void> removeUserFromAlbum(String id, String userId, { Future<void>? abortTrigger, }) async {
final response = await removeUserFromAlbumWithHttpInfo(id, userId, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
@@ -798,8 +802,10 @@ class AlbumsApi {
/// Parameters:
///
/// * [String] id (required):
/// Album ID
///
/// * [String] userId (required):
/// Album user ID, or \"me\" to reference the current user
///
/// * [UpdateAlbumUserDto] updateAlbumUserDto (required):
Future<Response> updateAlbumUserWithHttpInfo(String id, String userId, UpdateAlbumUserDto updateAlbumUserDto, { Future<void>? abortTrigger, }) async {
@@ -837,8 +843,10 @@ class AlbumsApi {
/// Parameters:
///
/// * [String] id (required):
/// Album ID
///
/// * [String] userId (required):
/// Album user ID, or \"me\" to reference the current user
///
/// * [UpdateAlbumUserDto] updateAlbumUserDto (required):
Future<void> updateAlbumUser(String id, String userId, UpdateAlbumUserDto updateAlbumUserDto, { Future<void>? abortTrigger, }) async {
+4
View File
@@ -577,6 +577,10 @@ class ApiClient {
return ReactionLevelTypeTransformer().decode(value);
case 'ReactionType':
return ReactionTypeTypeTransformer().decode(value);
case 'RecentlyAddedResponse':
return RecentlyAddedResponse.fromJson(value);
case 'RecentlyAddedUpdate':
return RecentlyAddedUpdate.fromJson(value);
case 'ReleaseChannel':
return ReleaseChannelTypeTransformer().decode(value);
case 'ReleaseEventV1':
+100
View File
@@ -0,0 +1,100 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class RecentlyAddedResponse {
/// Returns a new [RecentlyAddedResponse] instance.
RecentlyAddedResponse({
required this.sidebarWeb,
});
/// Whether the recently added page appears in the web sidebar
bool sidebarWeb;
@override
bool operator ==(Object other) => identical(this, other) || other is RecentlyAddedResponse &&
other.sidebarWeb == sidebarWeb;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(sidebarWeb.hashCode);
@override
String toString() => 'RecentlyAddedResponse[sidebarWeb=$sidebarWeb]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'sidebarWeb'] = this.sidebarWeb;
return json;
}
/// Returns a new [RecentlyAddedResponse] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static RecentlyAddedResponse? fromJson(dynamic value) {
upgradeDto(value, "RecentlyAddedResponse");
if (value is Map) {
final json = value.cast<String, dynamic>();
return RecentlyAddedResponse(
sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb')!,
);
}
return null;
}
static List<RecentlyAddedResponse> listFromJson(dynamic json, {bool growable = false,}) {
final result = <RecentlyAddedResponse>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = RecentlyAddedResponse.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, RecentlyAddedResponse> mapFromJson(dynamic json) {
final map = <String, RecentlyAddedResponse>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = RecentlyAddedResponse.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of RecentlyAddedResponse-objects as value to a dart map
static Map<String, List<RecentlyAddedResponse>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<RecentlyAddedResponse>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = RecentlyAddedResponse.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'sidebarWeb',
};
}
+108
View File
@@ -0,0 +1,108 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class RecentlyAddedUpdate {
/// Returns a new [RecentlyAddedUpdate] instance.
RecentlyAddedUpdate({
this.sidebarWeb = const Optional.absent(),
});
/// Whether the recently added page appears in the web sidebar
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<bool?> sidebarWeb;
@override
bool operator ==(Object other) => identical(this, other) || other is RecentlyAddedUpdate &&
other.sidebarWeb == sidebarWeb;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(sidebarWeb == null ? 0 : sidebarWeb!.hashCode);
@override
String toString() => 'RecentlyAddedUpdate[sidebarWeb=$sidebarWeb]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.sidebarWeb.isPresent) {
final value = this.sidebarWeb.value;
json[r'sidebarWeb'] = value;
}
return json;
}
/// Returns a new [RecentlyAddedUpdate] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static RecentlyAddedUpdate? fromJson(dynamic value) {
upgradeDto(value, "RecentlyAddedUpdate");
if (value is Map) {
final json = value.cast<String, dynamic>();
return RecentlyAddedUpdate(
sidebarWeb: json.containsKey(r'sidebarWeb') ? Optional.present(mapValueOfType<bool>(json, r'sidebarWeb')) : const Optional.absent(),
);
}
return null;
}
static List<RecentlyAddedUpdate> listFromJson(dynamic json, {bool growable = false,}) {
final result = <RecentlyAddedUpdate>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = RecentlyAddedUpdate.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, RecentlyAddedUpdate> mapFromJson(dynamic json) {
final map = <String, RecentlyAddedUpdate>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = RecentlyAddedUpdate.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of RecentlyAddedUpdate-objects as value to a dart map
static Map<String, List<RecentlyAddedUpdate>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<RecentlyAddedUpdate>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = RecentlyAddedUpdate.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}
+9 -1
View File
@@ -22,6 +22,7 @@ class UserPreferencesResponseDto {
required this.people,
required this.purchase,
required this.ratings,
required this.recentlyAdded,
required this.sharedLinks,
required this.tags,
});
@@ -44,6 +45,8 @@ class UserPreferencesResponseDto {
RatingsResponse ratings;
RecentlyAddedResponse recentlyAdded;
SharedLinksResponse sharedLinks;
TagsResponse tags;
@@ -59,6 +62,7 @@ class UserPreferencesResponseDto {
other.people == people &&
other.purchase == purchase &&
other.ratings == ratings &&
other.recentlyAdded == recentlyAdded &&
other.sharedLinks == sharedLinks &&
other.tags == tags;
@@ -74,11 +78,12 @@ class UserPreferencesResponseDto {
(people.hashCode) +
(purchase.hashCode) +
(ratings.hashCode) +
(recentlyAdded.hashCode) +
(sharedLinks.hashCode) +
(tags.hashCode);
@override
String toString() => 'UserPreferencesResponseDto[albums=$albums, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
String toString() => 'UserPreferencesResponseDto[albums=$albums, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, recentlyAdded=$recentlyAdded, sharedLinks=$sharedLinks, tags=$tags]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -91,6 +96,7 @@ class UserPreferencesResponseDto {
json[r'people'] = this.people;
json[r'purchase'] = this.purchase;
json[r'ratings'] = this.ratings;
json[r'recentlyAdded'] = this.recentlyAdded;
json[r'sharedLinks'] = this.sharedLinks;
json[r'tags'] = this.tags;
return json;
@@ -114,6 +120,7 @@ class UserPreferencesResponseDto {
people: PeopleResponse.fromJson(json[r'people'])!,
purchase: PurchaseResponse.fromJson(json[r'purchase'])!,
ratings: RatingsResponse.fromJson(json[r'ratings'])!,
recentlyAdded: RecentlyAddedResponse.fromJson(json[r'recentlyAdded'])!,
sharedLinks: SharedLinksResponse.fromJson(json[r'sharedLinks'])!,
tags: TagsResponse.fromJson(json[r'tags'])!,
);
@@ -172,6 +179,7 @@ class UserPreferencesResponseDto {
'people',
'purchase',
'ratings',
'recentlyAdded',
'sharedLinks',
'tags',
};
+17 -1
View File
@@ -23,6 +23,7 @@ class UserPreferencesUpdateDto {
this.people = const Optional.absent(),
this.purchase = const Optional.absent(),
this.ratings = const Optional.absent(),
this.recentlyAdded = const Optional.absent(),
this.sharedLinks = const Optional.absent(),
this.tags = const Optional.absent(),
});
@@ -107,6 +108,14 @@ class UserPreferencesUpdateDto {
///
Optional<RatingsUpdate?> ratings;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<RecentlyAddedUpdate?> recentlyAdded;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
@@ -135,6 +144,7 @@ class UserPreferencesUpdateDto {
other.people == people &&
other.purchase == purchase &&
other.ratings == ratings &&
other.recentlyAdded == recentlyAdded &&
other.sharedLinks == sharedLinks &&
other.tags == tags;
@@ -151,11 +161,12 @@ class UserPreferencesUpdateDto {
(people == null ? 0 : people!.hashCode) +
(purchase == null ? 0 : purchase!.hashCode) +
(ratings == null ? 0 : ratings!.hashCode) +
(recentlyAdded == null ? 0 : recentlyAdded!.hashCode) +
(sharedLinks == null ? 0 : sharedLinks!.hashCode) +
(tags == null ? 0 : tags!.hashCode);
@override
String toString() => 'UserPreferencesUpdateDto[albums=$albums, avatar=$avatar, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
String toString() => 'UserPreferencesUpdateDto[albums=$albums, avatar=$avatar, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, recentlyAdded=$recentlyAdded, sharedLinks=$sharedLinks, tags=$tags]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -199,6 +210,10 @@ class UserPreferencesUpdateDto {
final value = this.ratings.value;
json[r'ratings'] = value;
}
if (this.recentlyAdded.isPresent) {
final value = this.recentlyAdded.value;
json[r'recentlyAdded'] = value;
}
if (this.sharedLinks.isPresent) {
final value = this.sharedLinks.value;
json[r'sharedLinks'] = value;
@@ -229,6 +244,7 @@ class UserPreferencesUpdateDto {
people: json.containsKey(r'people') ? Optional.present(PeopleUpdate.fromJson(json[r'people'])) : const Optional.absent(),
purchase: json.containsKey(r'purchase') ? Optional.present(PurchaseUpdate.fromJson(json[r'purchase'])) : const Optional.absent(),
ratings: json.containsKey(r'ratings') ? Optional.present(RatingsUpdate.fromJson(json[r'ratings'])) : const Optional.absent(),
recentlyAdded: json.containsKey(r'recentlyAdded') ? Optional.present(RecentlyAddedUpdate.fromJson(json[r'recentlyAdded'])) : const Optional.absent(),
sharedLinks: json.containsKey(r'sharedLinks') ? Optional.present(SharedLinksUpdate.fromJson(json[r'sharedLinks'])) : const Optional.absent(),
tags: json.containsKey(r'tags') ? Optional.present(TagsUpdate.fromJson(json[r'tags'])) : const Optional.absent(),
);
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 3.0.0+3053
version: 3.0.1+3054
environment:
sdk: '>=3.12.0 <4.0.0'
+33 -1
View File
@@ -2702,6 +2702,7 @@
"name": "id",
"required": true,
"in": "path",
"description": "Album ID",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -2712,6 +2713,7 @@
"name": "userId",
"required": true,
"in": "path",
"description": "Album user ID, or \"me\" to reference the current user",
"schema": {
"type": "string"
}
@@ -2762,6 +2764,7 @@
"name": "id",
"required": true,
"in": "path",
"description": "Album ID",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -2772,6 +2775,7 @@
"name": "userId",
"required": true,
"in": "path",
"description": "Album user ID, or \"me\" to reference the current user",
"schema": {
"type": "string"
}
@@ -16206,7 +16210,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "3.0.0",
"version": "3.0.1",
"contact": {}
},
"tags": [
@@ -21991,6 +21995,27 @@
],
"type": "string"
},
"RecentlyAddedResponse": {
"properties": {
"sidebarWeb": {
"description": "Whether the recently added page appears in the web sidebar",
"type": "boolean"
}
},
"required": [
"sidebarWeb"
],
"type": "object"
},
"RecentlyAddedUpdate": {
"properties": {
"sidebarWeb": {
"description": "Whether the recently added page appears in the web sidebar",
"type": "boolean"
}
},
"type": "object"
},
"ReleaseChannel": {
"description": "Release channel",
"enum": [
@@ -27525,6 +27550,9 @@
"ratings": {
"$ref": "#/components/schemas/RatingsResponse"
},
"recentlyAdded": {
"$ref": "#/components/schemas/RecentlyAddedResponse"
},
"sharedLinks": {
"$ref": "#/components/schemas/SharedLinksResponse"
},
@@ -27542,6 +27570,7 @@
"people",
"purchase",
"ratings",
"recentlyAdded",
"sharedLinks",
"tags"
],
@@ -27579,6 +27608,9 @@
"ratings": {
"$ref": "#/components/schemas/RatingsUpdate"
},
"recentlyAdded": {
"$ref": "#/components/schemas/RecentlyAddedUpdate"
},
"sharedLinks": {
"$ref": "#/components/schemas/SharedLinksUpdate"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-monorepo",
"version": "3.0.0",
"version": "3.0.1",
"description": "Monorepo for Immich",
"type": "module",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "3.0.0",
"version": "3.0.1",
"description": "Command Line Interface (CLI) for Immich",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "3.0.0",
"version": "3.0.1",
"description": "Auto-generated TypeScript SDK for the Immich API",
"repository": {
"type": "git",
+11 -1
View File
@@ -1,6 +1,6 @@
/**
* Immich
* 3.0.0
* 3.0.1
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -342,6 +342,10 @@ export type RatingsResponse = {
/** Whether ratings are enabled */
enabled: boolean;
};
export type RecentlyAddedResponse = {
/** Whether the recently added page appears in the web sidebar */
sidebarWeb: boolean;
};
export type SharedLinksResponse = {
/** Whether shared links are enabled */
enabled: boolean;
@@ -364,6 +368,7 @@ export type UserPreferencesResponseDto = {
people: PeopleResponse;
purchase: PurchaseResponse;
ratings: RatingsResponse;
recentlyAdded: RecentlyAddedResponse;
sharedLinks: SharedLinksResponse;
tags: TagsResponse;
};
@@ -421,6 +426,10 @@ export type RatingsUpdate = {
/** Whether ratings are enabled */
enabled?: boolean;
};
export type RecentlyAddedUpdate = {
/** Whether the recently added page appears in the web sidebar */
sidebarWeb?: boolean;
};
export type SharedLinksUpdate = {
/** Whether shared links are enabled */
enabled?: boolean;
@@ -444,6 +453,7 @@ export type UserPreferencesUpdateDto = {
people?: PeopleUpdate;
purchase?: PurchaseUpdate;
ratings?: RatingsUpdate;
recentlyAdded?: RecentlyAddedUpdate;
sharedLinks?: SharedLinksUpdate;
tags?: TagsUpdate;
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "3.0.0",
"version": "3.0.1",
"description": "",
"author": "",
"private": true,
+4 -8
View File
@@ -7,6 +7,7 @@ import {
AlbumsAddAssetsDto,
AlbumsAddAssetsResponseDto,
AlbumStatisticsResponseDto,
AlbumUserParamDto,
CreateAlbumDto,
GetAlbumsDto,
UpdateAlbumDto,
@@ -18,7 +19,7 @@ import { MapMarkerResponseDto } from 'src/dtos/map.dto';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AlbumService } from 'src/services/album.service';
import { ParseMeUUIDPipe, UUIDParamDto } from 'src/validation';
import { UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Albums)
@Controller('albums')
@@ -175,8 +176,7 @@ export class AlbumController {
})
updateAlbumUser(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
@Param() { id, userId }: AlbumUserParamDto,
@Body() dto: UpdateAlbumUserDto,
): Promise<void> {
return this.service.updateUser(auth, id, userId, dto);
@@ -190,11 +190,7 @@ export class AlbumController {
description: 'Remove a user from an album. Use an ID of "me" to leave a shared album.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
removeUserFromAlbum(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
): Promise<void> {
removeUserFromAlbum(@Auth() auth: AuthDto, @Param() { id, userId }: AlbumUserParamDto): Promise<void> {
return this.service.removeUser(auth, id, userId);
}
}
+12
View File
@@ -140,6 +140,18 @@ export const AlbumResponseSchema = z
})
.meta({ id: 'AlbumResponseDto' });
const AlbumUserParamSchema = z.object({
id: z.uuidv4().describe('Album ID'),
// TODO: disallow 'me' as a shortcut in v4 and type userId as uuidv4
userId: z
.string()
.refine((value) => value === 'me' || z.uuidv4().safeParse(value).success, {
error: 'Must be a UUID v4 or "me"',
})
.describe('Album user ID, or "me" to reference the current user'),
});
export class AlbumUserParamDto extends createZodDto(AlbumUserParamSchema) {}
export class AddUsersDto extends createZodDto(AddUsersSchema) {}
export class AlbumUserCreateDto extends createZodDto(AlbumUserCreateSchema) {}
export class CreateAlbumDto extends createZodDto(CreateAlbumSchema) {}
+15
View File
@@ -98,6 +98,13 @@ const CastUpdateSchema = z
.optional()
.meta({ id: 'CastUpdate' });
const RecentlyAddedUpdateSchema = z
.object({
sidebarWeb: z.boolean().optional().describe('Whether the recently added page appears in the web sidebar'),
})
.optional()
.meta({ id: 'RecentlyAddedUpdate' });
const UserPreferencesUpdateSchema = z
.object({
albums: AlbumsUpdateSchema,
@@ -112,6 +119,7 @@ const UserPreferencesUpdateSchema = z
ratings: RatingsUpdateSchema,
sharedLinks: SharedLinksUpdateSchema,
tags: TagsUpdateSchema,
recentlyAdded: RecentlyAddedUpdateSchema,
})
.meta({ id: 'UserPreferencesUpdateDto' });
@@ -191,6 +199,12 @@ const CastResponseSchema = z
})
.meta({ id: 'CastResponse' });
const RecentlyAddedResponseSchema = z
.object({
sidebarWeb: z.boolean().describe('Whether the recently added page appears in the web sidebar'),
})
.meta({ id: 'RecentlyAddedResponse' });
const UserPreferencesResponseSchema = z
.object({
albums: AlbumsResponseSchema,
@@ -204,6 +218,7 @@ const UserPreferencesResponseSchema = z
download: DownloadResponseSchema,
purchase: PurchaseResponseSchema,
cast: CastResponseSchema,
recentlyAdded: RecentlyAddedResponseSchema,
})
.meta({ id: 'UserPreferencesResponseDto' });
+3
View File
@@ -619,6 +619,9 @@ export type UserPreferences = {
cast: {
gCastEnabled: boolean;
};
recentlyAdded: {
sidebarWeb: boolean;
};
};
export type UserMetadataItem<T extends keyof UserMetadata = UserMetadataKey> = {
+3
View File
@@ -50,6 +50,9 @@ const getDefaultPreferences = (): UserPreferences => {
cast: {
gCastEnabled: false,
},
recentlyAdded: {
sidebarWeb: false,
},
};
};
+1 -11
View File
@@ -1,4 +1,4 @@
import { ArgumentMetadata, FileValidator, Injectable, ParseUUIDPipe } from '@nestjs/common';
import { FileValidator, Injectable } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import sanitize from 'sanitize-filename';
import { isIP, isIPRange } from 'validator';
@@ -74,16 +74,6 @@ export function IsNotSiblingOf<
);
}
@Injectable()
export class ParseMeUUIDPipe extends ParseUUIDPipe {
async transform(value: string, metadata: ArgumentMetadata) {
if (value == 'me') {
return value;
}
return super.transform(value, metadata);
}
}
@Injectable()
export class FileNotEmptyValidator extends FileValidator {
constructor(private requiredFields: string[]) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "3.0.0",
"version": "3.0.1",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {
@@ -101,7 +101,9 @@
{description}
</p>
{:else}
{@render descriptionSnippet?.()}
<div class="pb-2">
{@render descriptionSnippet?.()}
</div>
{/if}
{#if inputType !== SettingInputFieldType.PASSWORD}
@@ -31,6 +31,7 @@
mdiToolboxOutline,
mdiTrashCan,
mdiTrashCanOutline,
mdiUploadOutline,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
@@ -83,6 +84,14 @@
<NavbarItem title={$t('tags')} href={Route.tags()} icon={{ icon: mdiTagMultipleOutline, flipped: true }} />
{/if}
{#if authManager.preferences.recentlyAdded.sidebarWeb}
<NavbarItem
title={$t('recently_added')}
href={Route.recentlyAdded()}
icon={{ icon: mdiUploadOutline, flipped: true }}
/>
{/if}
{#if authManager.preferences.folders.enabled && authManager.preferences.folders.sidebarWeb}
<NavbarItem title={$t('folders')} href={Route.folders()} icon={{ icon: mdiFolderOutline, flipped: true }} />
{/if}
@@ -38,6 +38,9 @@
// Cast
let gCastEnabled = $state(authManager.preferences.cast?.gCastEnabled ?? false);
// Recently added
let recentlyAddedSidebar = $state(authManager.preferences.recentlyAdded?.sidebarWeb ?? false);
const handleSave = async () => {
try {
const response = await updateMyPreferences({
@@ -50,6 +53,7 @@
sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar },
tags: { enabled: tagsEnabled, sidebarWeb: tagsSidebar },
cast: { gCastEnabled },
recentlyAdded: { sidebarWeb: recentlyAddedSidebar },
},
});
@@ -170,6 +174,14 @@
</div>
</SettingAccordion>
<SettingAccordion key="recentlyAdded" title={$t('recently_added')} subtitle={$t('recently_added_description')}>
<div class="mt-4 flex flex-col gap-4 sm:ms-4">
<Field label={$t('sidebar')} description={$t('sidebar_display_description')}>
<Switch bind:checked={recentlyAddedSidebar} />
</Field>
</div>
</SettingAccordion>
<div class="mt-4 flex justify-end">
<Button shape="round" type="submit" size="small" onclick={() => handleSave()}>{$t('save')}</Button>
</div>
@@ -44,4 +44,7 @@ export const preferencesFactory = Sync.makeFactory<UserPreferencesResponseDto>({
enabled: false,
sidebarWeb: false,
},
recentlyAdded: {
sidebarWeb: false,
},
});