Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
5ab05e5
fix(mobile): inconsistent asset details background (#26634)
uhthomas Mar 5, 2026
7b0deb1
fix: playback style migration (#26718)
alextran1502 Mar 5, 2026
9597f8c
feat(mobile): SyncAssetEditV1 (#26518)
bwees Mar 5, 2026
ec7246b
refactor(web): add --font-sans CSS variable for primary font (#26730)
midzelis Mar 6, 2026
abfcffb
feat(web): toggle zoom on double-click in photo viewer (#26732)
midzelis Mar 6, 2026
6012d22
fix(mobile): incorrect asset dimensions in search (#26725)
uhthomas Mar 6, 2026
6e9a425
fix(web): asset viewer showing wrong viewer type when hovering on sta…
Snowknight26 Mar 6, 2026
e73686b
feat(android): enhance playback style detection using MIME type, redu…
LeLunZ Mar 7, 2026
dd72ec2
fix(mobile): correct local asset dimensions (#26677)
uhthomas Mar 7, 2026
4a384bc
fix(server): opus handling as accepted audio codec in transcode polic…
skatsubo Mar 7, 2026
aaf34fa
feat(ml): enable openvino for cpu (#22948)
apejcic Mar 7, 2026
7a83baa
feat: responsive video duration in thumbnail (#26770)
midzelis Mar 8, 2026
422111d
test(e2e): fix flakiness: optimize resetDatabase with TRUNCATE and fi…
midzelis Mar 8, 2026
df0c869
fix(mobile): restrict trashed asset migration to Android platform (#2…
LeLunZ Mar 9, 2026
a47b232
fix(web): refresh recent albums sidebar after album changes (#26757)
michelheusschen Mar 9, 2026
4791d9c
fix(web): show the correct cursor at crop bounds when editing an asse…
Snowknight26 Mar 9, 2026
0edbca2
fix(web): recalculate face bounding boxes (#26737)
cratoo Mar 9, 2026
f272660
fix(web): context menu overflow (#26760)
SevereCloud Mar 9, 2026
d325231
chore: refactor test factories (#26804)
danieldietzler Mar 9, 2026
08c4594
chore: remove release-pr workflow (#26742)
bo0tzz Mar 9, 2026
8222781
fix(web): correct tag rounding in search options (#26814)
michelheusschen Mar 10, 2026
8e50d25
feat(web): animate zoom toggle with cubicOut easing (#26731)
midzelis Mar 10, 2026
f79c8cf
feat(mobile): consolidate video controls (#26673)
uhthomas Mar 10, 2026
56b8e1b
chore(deps): update docker.io/valkey/valkey:9 docker digest to 3eeb09…
renovate[bot] Mar 10, 2026
45eff1c
fix(web): prevent unrelated assets from appearing in tag view (#26816)
michelheusschen Mar 10, 2026
22b43bf
chore(deps): update dependency @types/node to ^24.11.0 (#26808)
renovate[bot] Mar 10, 2026
1a4c5d7
feat(web): add shortcut "p" to open/close the face tag box (#26826)
cratoo Mar 10, 2026
1ceb6d2
fix(mobile): use tabular figures in backup page (#26830)
uhthomas Mar 11, 2026
4571940
fix(mobile): wrap backup error message text (#26834)
uhthomas Mar 11, 2026
9fc32b6
feat(mobile): use material design 3 slider (#26829)
uhthomas Mar 11, 2026
9fc6fbc
fix(web): restore asset update events in asset viewer (#26845)
michelheusschen Mar 11, 2026
27f69b3
fix(server): use correct day ordering in timeline buckets (#26821)
michelheusschen Mar 11, 2026
8764a18
feat: adaptive progressive image loading for photo viewer (#26636)
midzelis Mar 11, 2026
34ce680
chore: upgrade to kysely 0.28.11 (#26744)
danieldietzler Mar 11, 2026
0f2fe65
fix(deps): update typescript-projects (#26812)
renovate[bot] Mar 11, 2026
28d5c16
chore: use pokedex-large runner for rocm (#26823)
bo0tzz Mar 11, 2026
e7db3b2
feat(mobile): show animated images in asset viewer (#26614)
LeLunZ Mar 11, 2026
c403e03
fix(mobile): logout on upgrade (#26827)
mertalev Mar 11, 2026
e45308b
fix(web): exclude emoji from translation string (#26852)
meesfrensel Mar 11, 2026
0a79dd1
fix(server): extract make/model from sony video files (#26833)
brendanngo Mar 11, 2026
9996ee1
refactor(web): crop area tool (#26843)
meesfrensel Mar 11, 2026
0ac3d6a
fix(web): face selection box position resetting on browser resize (#2…
Snowknight26 Mar 11, 2026
d49d995
chore(deps): update dependency exiftool-vendored to v35.13.1 (#26813)
renovate[bot] Mar 11, 2026
4773788
chore: more unused release workflow cleanup (#26817)
bo0tzz Mar 11, 2026
471c27c
chore(mobile): remove background from asset viewer back button (#26851)
uhthomas Mar 11, 2026
6c531e0
chore: add shadow to video play/pause icon shadow (#26836)
alextran1502 Mar 11, 2026
5c3777a
fix(web): fix zoom touch event handling (#26866)
midzelis Mar 12, 2026
3bd37eb
refactor: clean class (#26879)
jrasm91 Mar 12, 2026
d4605b2
refactor: external links (#26880)
jrasm91 Mar 12, 2026
6bb8f4f
refactor: clean class (#26885)
jrasm91 Mar 12, 2026
3fd24e2
fix(server): restrict individual shared link asset removal to owners …
michelheusschen Mar 12, 2026
001d7d0
refactor: small test factories (#26862)
danieldietzler Mar 12, 2026
990aff4
fix: add to shared link (#26886)
jrasm91 Mar 12, 2026
f3b7cd6
refactor: move encoded video to asset files table (#26863)
bwees Mar 12, 2026
c91d874
fix: use correct original URL for 360 video panorama playback (#26831)
luis15pt Mar 12, 2026
40a0d9a
Merge remote-tracking branch 'upstream/main' into worktree-upstream-m…
Deeds67 Mar 12, 2026
3ef3cbb
fix: resolve type errors after upstream merge (Kysely 0.28.11, asset …
Deeds67 Mar 12, 2026
d77051e
fix: remove unused sharedLinkResponseStub import
Deeds67 Mar 12, 2026
bac16d1
fix: format metadata.service, storage-migration spec, and small.factory
Deeds67 Mar 12, 2026
fa9a409
fix: remove unused LoadingSpinner import from photo-viewer
Deeds67 Mar 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 0 additions & 149 deletions .github/workflows/release.yml

This file was deleted.

10 changes: 10 additions & 0 deletions e2e/src/specs/server/api/shared-link.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,16 @@ describe('/shared-links', () => {
expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
});

it('should reject guests removing assets from an individual shared link', async () => {
const { status, body } = await request(app)
.delete(`/shared-links/${linkWithAssets.id}/assets`)
.query({ key: linkWithAssets.key })
.send({ assetIds: [asset1.id] });

expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});

it('should remove assets from a shared link (individual)', async () => {
const { status, body } = await request(app)
.delete(`/shared-links/${linkWithAssets.id}/assets`)
Expand Down
62 changes: 43 additions & 19 deletions e2e/src/specs/web/photo-viewer.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { Page, expect, test } from '@playwright/test';
import { expect, test } from '@playwright/test';
import type { Socket } from 'socket.io-client';
import { utils } from 'src/utils';

function imageLocator(page: Page) {
return page.getByAltText('Image taken').locator('visible=true');
}
test.describe('Photo Viewer', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
let rawAsset: AssetMediaResponseDto;
let websocket: Socket;

test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
asset = await utils.createAsset(admin.accessToken);
rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } });
websocket = await utils.connectWebsocket(admin.accessToken);
});

test.afterAll(() => {
utils.disconnectWebsocket(websocket);
});

test.beforeEach(async ({ context, page }) => {
Expand All @@ -26,31 +30,51 @@ test.describe('Photo Viewer', () => {

test('loads original photo when zoomed', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox();
expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2);

const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).toHaveAttribute('src', /.+/);

const originalResponse = page.waitForResponse((response) => response.url().includes('/original'));

const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');

await originalResponse;

const original = page.getByTestId('original').filter({ visible: true });
await expect(original).toHaveAttribute('src', /original/);
});

test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
await page.goto(`/photos/${rawAsset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox();
expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2);

const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).toHaveAttribute('src', /.+/);

const fullsizeResponse = page.waitForResponse((response) => response.url().includes('fullsize'));

const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');

await fullsizeResponse;

const original = page.getByTestId('original').filter({ visible: true });
await expect(original).toHaveAttribute('src', /fullsize/);
});

test('reloads photo when checksum changes', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const initialSrc = await imageLocator(page).getAttribute('src');

const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).toHaveAttribute('src', /.+/);
const initialSrc = await preview.getAttribute('src');

const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
await utils.replaceAsset(admin.accessToken, asset.id);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
await websocketEvent;

await expect(preview).not.toHaveAttribute('src', initialSrc!);
});
});
24 changes: 24 additions & 0 deletions e2e/src/specs/web/shared-link.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,18 @@ import { asBearerAuth, utils } from 'src/utils';
test.describe('Shared Links', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
let asset2: AssetMediaResponseDto;
let album: AlbumResponseDto;
let sharedLink: SharedLinkResponseDto;
let sharedLinkPassword: SharedLinkResponseDto;
let individualSharedLink: SharedLinkResponseDto;

test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
asset = await utils.createAsset(admin.accessToken);
asset2 = await utils.createAsset(admin.accessToken);
album = await createAlbum(
{
createAlbumDto: {
Expand All @@ -39,6 +42,10 @@ test.describe('Shared Links', () => {
albumId: album.id,
password: 'test-password',
});
individualSharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset.id, asset2.id],
});
});

test('download from a shared link', async ({ page }) => {
Expand Down Expand Up @@ -109,4 +116,21 @@ test.describe('Shared Links', () => {
await page.waitForURL('/photos');
await page.locator(`[data-asset-id="${asset.id}"]`).waitFor();
});

test('owner can remove assets from an individual shared link', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);

await page.goto(`/share/${individualSharedLink.key}`);
await page.locator(`[data-asset="${asset.id}"]`).waitFor();
await expect(page.locator(`[data-asset]`)).toHaveCount(2);

await page.locator(`[data-asset="${asset.id}"]`).hover();
await page.locator(`[data-asset="${asset.id}"] [role="checkbox"]`).click();

await page.getByRole('button', { name: 'Remove from shared link' }).click();
await page.getByRole('button', { name: 'Remove', exact: true }).click();

await expect(page.locator(`[data-asset="${asset.id}"]`)).toHaveCount(0);
await expect(page.locator(`[data-asset="${asset2.id}"]`)).toHaveCount(1);
});
});
6 changes: 4 additions & 2 deletions e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ test.describe('broken-asset responsiveness', () => {

test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
await context.route(
(url) => url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`),
(url) =>
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`) ||
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/original`),
async (route) => {
return route.fulfill({ status: 404 });
},
Expand All @@ -73,7 +75,7 @@ test.describe('broken-asset responsiveness', () => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await page.waitForSelector('#immich-asset-viewer');

const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]');
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]').first();
await expect(viewerBrokenAsset).toBeVisible();

await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
Expand Down
4 changes: 3 additions & 1 deletion i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1020,6 +1020,8 @@
"editor_edits_applied_success": "Edits applied successfully",
"editor_flip_horizontal": "Flip horizontal",
"editor_flip_vertical": "Flip vertical",
"editor_handle_corner": "{corner, select, top_left {Top-left} top_right {Top-right} bottom_left {Bottom-left} bottom_right {Bottom-right} other {A}} corner handle",
"editor_handle_edge": "{edge, select, top {Top} bottom {Bottom} left {Left} right {Right} other {An}} edge handle",
"editor_orientation": "Orientation",
"editor_reset_all_changes": "Reset changes",
"editor_rotate_left": "Rotate 90° counterclockwise",
Expand Down Expand Up @@ -1089,7 +1091,7 @@
"failed_to_update_notification_status": "Failed to update notification status",
"incorrect_email_or_password": "Incorrect email or password",
"library_folder_already_exists": "This import path already exists.",
"page_not_found": "Page not found :/",
"page_not_found": "Page not found",
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
"quota_higher_than_disk_size": "You set a quota higher than the disk size",
Expand Down
1 change: 1 addition & 0 deletions mobile/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ plugins {
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
id 'com.google.devtools.ksp'
id 'org.jetbrains.kotlin.plugin.serialization'
id 'org.jetbrains.kotlin.plugin.compose' version '2.0.20' // this version matches your Kotlin version

}
Expand Down
Loading
Loading