mirror of
https://github.com/immich-app/immich.git
synced 2026-05-21 07:06:31 -04:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce9b32a61a | |||
| 4ddc288cd1 | |||
| 94b15b8678 | |||
| ff9ae24219 | |||
| b456f78771 | |||
| 1506776891 | |||
| 0e93aa74cf | |||
| e95ad9d2eb | |||
| b98a227bbd | |||
| 2dd785e3e2 | |||
| 7e754125cd | |||
| e2eb03d3a4 | |||
| bf065a834f | |||
| db79173b5b | |||
| 33666ccd21 | |||
| be93b9040c |
@@ -0,0 +1,143 @@
|
||||
name: Auto-close PRs
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||
types: [opened, edited, labeled]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
parse_template:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.action != 'labeled' && github.event.pull_request.head.repo.fork == true }}
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
uses_template: ${{ steps.check.outputs.uses_template }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
sparse-checkout: .github/pull_request_template.md
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check required sections
|
||||
id: check
|
||||
env:
|
||||
BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
OK=true
|
||||
while IFS= read -r header; do
|
||||
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
|
||||
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
|
||||
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
|
||||
|
||||
close_template:
|
||||
runs-on: ubuntu-latest
|
||||
needs: parse_template
|
||||
if: ${{ needs.parse_template.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Comment and close
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
|
||||
-f query='
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||
addComment(input: {
|
||||
subjectId: $prId,
|
||||
body: $body
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
closePullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
|
||||
- name: Add label
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: gh pr edit "$PR_NUMBER" --add-label "auto-closed:template"
|
||||
|
||||
close_llm:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.action == 'labeled' && github.event.label.name == 'auto-closed:llm' }}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Comment and close
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
|
||||
-f query='
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||
addComment(input: {
|
||||
subjectId: $prId,
|
||||
body: $body
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
closePullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
|
||||
reopen:
|
||||
runs-on: ubuntu-latest
|
||||
needs: parse_template
|
||||
if: >-
|
||||
${{
|
||||
needs.parse_template.outputs.uses_template == 'true'
|
||||
&& github.event.pull_request.state == 'closed'
|
||||
&& contains(github.event.pull_request.labels.*.name, 'auto-closed:template')
|
||||
}}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Remove template label
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: gh pr edit "$PR_NUMBER" --remove-label "auto-closed:template" || true
|
||||
|
||||
- name: Check for remaining auto-closed labels
|
||||
id: check_labels
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
REMAINING=$(gh pr view "$PR_NUMBER" --json labels \
|
||||
--jq '[.labels[].name | select(startswith("auto-closed:"))] | length')
|
||||
echo "remaining=$REMAINING" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Reopen PR
|
||||
if: ${{ steps.check_labels.outputs.remaining == '0' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f query='
|
||||
mutation ReopenPR($prId: ID!) {
|
||||
reopenPullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
@@ -1,97 +0,0 @@
|
||||
name: Check PR Template
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||
types: [opened, edited]
|
||||
|
||||
permissions: {}
|
||||
|
||||
env:
|
||||
LABEL_ID: 'LA_kwDOGyI-8M8AAAACcAeOfg' # auto-closed:template
|
||||
|
||||
jobs:
|
||||
parse:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.pull_request.head.repo.fork == true }}
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
uses_template: ${{ steps.check.outputs.uses_template }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
sparse-checkout: .github/pull_request_template.md
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check required sections
|
||||
id: check
|
||||
env:
|
||||
BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
OK=true
|
||||
while IFS= read -r header; do
|
||||
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
|
||||
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
|
||||
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
|
||||
|
||||
act:
|
||||
runs-on: ubuntu-latest
|
||||
needs: parse
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Close PR
|
||||
if: ${{ needs.parse.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f labelId="$LABEL_ID" \
|
||||
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
|
||||
-f query='
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!, $labelId: ID!) {
|
||||
addComment(input: {
|
||||
subjectId: $prId,
|
||||
body: $body
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
closePullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
addLabelsToLabelable(input: {
|
||||
labelableId: $prId,
|
||||
labelIds: [$labelId]
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
|
||||
- name: Reopen PR (sections now present, PR was auto-closed)
|
||||
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' && contains(github.event.pull_request.labels.*.node_id, env.LABEL_ID) }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f labelId="$LABEL_ID" \
|
||||
-f query='
|
||||
mutation ReopenPR($prId: ID!, $labelId: ID!) {
|
||||
reopenPullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
removeLabelsFromLabelable(input: {
|
||||
labelableId: $prId,
|
||||
labelIds: [$labelId]
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
@@ -1,38 +0,0 @@
|
||||
name: Close LLM-generated PRs
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
comment_and_close:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.label.name == 'llm-generated' }}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Comment and close
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
|
||||
-f query='
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||
addComment(input: {
|
||||
subjectId: $prId,
|
||||
body: $body
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
|
||||
closePullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
+2
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.2",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
@@ -35,8 +35,7 @@
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite-tsconfig-paths": "^6.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.0.0",
|
||||
"vitest-fetch-mock": "^0.4.0",
|
||||
"yaml": "^2.3.1"
|
||||
|
||||
+5
-4
@@ -1,10 +1,12 @@
|
||||
import { defineConfig, UserConfig } from 'vite';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: { alias: { src: '/src' } },
|
||||
resolve: {
|
||||
alias: { src: '/src' },
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
rolldownOptions: {
|
||||
input: 'src/index.ts',
|
||||
output: {
|
||||
dir: 'dist',
|
||||
@@ -16,7 +18,6 @@ export default defineConfig({
|
||||
// bundle everything except for Node built-ins
|
||||
noExternal: /^(?!node:).*$/,
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
test: {
|
||||
name: 'cli:unit',
|
||||
globals: true,
|
||||
|
||||
@@ -8,7 +8,7 @@ Hardware and software requirements for Immich:
|
||||
|
||||
## Hardware
|
||||
|
||||
- **OS**: Recommended Linux or \*nix operating system (Ubuntu, Debian, etc).
|
||||
- **OS**: Recommended Linux or \*nix 64-bit operating system (Ubuntu, Debian, etc).
|
||||
- Non-Linux OSes tend to provide a poor Docker experience and are strongly discouraged.
|
||||
Our ability to assist with setup or troubleshooting on non-Linux OSes will be severely reduced.
|
||||
If you still want to try to use a non-Linux OS, you can set it up as follows:
|
||||
@@ -19,6 +19,10 @@ Hardware and software requirements for Immich:
|
||||
If you have issues, we recommend that you switch to a supported VM deployment.
|
||||
- **RAM**: Minimum 6GB, recommended 8GB.
|
||||
- **CPU**: Minimum 2 cores, recommended 4 cores.
|
||||
- Immich runs on the `amd64` and `arm64` platforms.
|
||||
Since `v2.6`, the machine learning container on `amd64` requires the `>= x86-64-v2` [microarchitecture level](https://en.wikipedia.org/wiki/X86-64#Microarchitecture_levels).
|
||||
Most CPUs released since ~2012 support this microarchitecture.
|
||||
If you are using a virtual machine, ensure you have selected a [supported microarchitecture](https://pve.proxmox.com/pve-docs/chapter-qm.html#_qemu_cpu_types).
|
||||
- **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions.
|
||||
- The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average.
|
||||
|
||||
|
||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"label": "v2.6.1",
|
||||
"url": "https://docs.v2.6.1.archive.immich.app"
|
||||
"label": "v2.6.2",
|
||||
"url": "https://docs.v2.6.2.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.5.6",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.2",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -524,14 +524,19 @@ describe('/albums', () => {
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
|
||||
});
|
||||
|
||||
it('should not be able to update as an editor', async () => {
|
||||
it('should be able to update as an editor', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.patch(`/albums/${user1Albums[0].id}`)
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`)
|
||||
.send({ albumName: 'New album name' });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
id: user1Albums[0].id,
|
||||
albumName: 'New album name',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { AssetMediaResponseDto, LoginResponseDto, updateAssets } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import crypto from 'node:crypto';
|
||||
import { asBearerAuth, utils } from 'src/utils';
|
||||
|
||||
test.describe('Duplicates Utility', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let firstAsset: AssetMediaResponseDto;
|
||||
let secondAsset: AssetMediaResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
[firstAsset, secondAsset] = await Promise.all([
|
||||
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-a' }),
|
||||
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-b' }),
|
||||
]);
|
||||
|
||||
await updateAssets(
|
||||
{
|
||||
assetBulkUpdateDto: {
|
||||
ids: [firstAsset.id, secondAsset.id],
|
||||
duplicateId: crypto.randomUUID(),
|
||||
},
|
||||
},
|
||||
{ headers: asBearerAuth(admin.accessToken) },
|
||||
);
|
||||
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
});
|
||||
|
||||
test('navigates with arrow keys between duplicate preview assets', async ({ page }) => {
|
||||
await page.goto('/utilities/duplicates');
|
||||
await page.getByRole('button', { name: 'View' }).first().click();
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
|
||||
const getViewedAssetId = () => new URL(page.url()).pathname.split('/').at(-1) ?? '';
|
||||
const initialAssetId = getViewedAssetId();
|
||||
expect([firstAsset.id, secondAsset.id]).toContain(initialAssetId);
|
||||
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect.poll(getViewedAssetId).not.toBe(initialAssetId);
|
||||
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect.poll(getViewedAssetId).toBe(initialAssetId);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-i18n",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --cache --check .",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "2.6.1"
|
||||
version = "2.6.2"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.11,<4.0"
|
||||
|
||||
Generated
+1
-1
@@ -898,7 +898,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "immich-ml"
|
||||
version = "2.6.1"
|
||||
version = "2.6.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocache" },
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3039,
|
||||
"android.injected.version.name" => "2.6.1",
|
||||
"android.injected.version.code" => 3040,
|
||||
"android.injected.version.name" => "2.6.2",
|
||||
}
|
||||
)
|
||||
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')
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.6.1</string>
|
||||
<string>2.6.2</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -79,6 +79,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
|
||||
final person = people[index];
|
||||
|
||||
return Column(
|
||||
key: ValueKey(person.id),
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
@@ -88,6 +89,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
|
||||
shape: const CircleBorder(side: BorderSide.none),
|
||||
elevation: 3,
|
||||
child: CircleAvatar(
|
||||
key: ValueKey('avatar-${person.id}'),
|
||||
maxRadius: isTablet ? 100 / 2 : 96 / 2,
|
||||
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
|
||||
),
|
||||
|
||||
@@ -69,6 +69,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
final previousFilter = useState<SearchFilter?>(null);
|
||||
final hasRequestedSearch = useState<bool>(false);
|
||||
final dateInputFilter = useState<DateFilterInputModel?>(null);
|
||||
|
||||
final peopleCurrentFilterWidget = useState<Widget?>(null);
|
||||
@@ -91,9 +92,11 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
|
||||
if (filter.isEmpty) {
|
||||
previousFilter.value = null;
|
||||
hasRequestedSearch.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
hasRequestedSearch.value = true;
|
||||
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter));
|
||||
previousFilter.value = filter;
|
||||
}
|
||||
@@ -107,6 +110,8 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
searchPreFilter() {
|
||||
if (preFilter != null) {
|
||||
Future.delayed(Duration.zero, () {
|
||||
filter.value = preFilter;
|
||||
textSearchController.clear();
|
||||
searchFilter(preFilter);
|
||||
|
||||
if (preFilter.location.city != null) {
|
||||
@@ -719,7 +724,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (filter.value.isEmpty)
|
||||
if (!hasRequestedSearch.value)
|
||||
const _SearchSuggestions()
|
||||
else
|
||||
_SearchResultGrid(onScrollEnd: loadMoreSearchResults),
|
||||
|
||||
+16
-14
@@ -24,20 +24,22 @@ class SimilarPhotosActionButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
ref.invalidate(assetViewerProvider);
|
||||
ref
|
||||
.read(searchPreFilterProvider.notifier)
|
||||
.setFilter(
|
||||
SearchFilter(
|
||||
assetId: assetId,
|
||||
people: {},
|
||||
location: SearchLocationFilter(),
|
||||
camera: SearchCameraFilter(),
|
||||
date: SearchDateFilter(),
|
||||
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
rating: SearchRatingFilter(),
|
||||
mediaType: AssetType.image,
|
||||
),
|
||||
);
|
||||
ref.invalidate(paginatedSearchProvider);
|
||||
|
||||
ref.read(searchPreFilterProvider.notifier)
|
||||
..clear()
|
||||
..setFilter(
|
||||
SearchFilter(
|
||||
assetId: assetId,
|
||||
people: {},
|
||||
location: SearchLocationFilter(),
|
||||
camera: SearchCameraFilter(),
|
||||
date: SearchDateFilter(),
|
||||
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
rating: SearchRatingFilter(),
|
||||
mediaType: AssetType.image,
|
||||
),
|
||||
);
|
||||
|
||||
unawaited(context.navigateTo(const DriftSearchRoute()));
|
||||
}
|
||||
|
||||
@@ -39,6 +39,16 @@ class _RatingBarState extends State<RatingBar> {
|
||||
_currentRating = widget.initialRating;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant RatingBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.initialRating != widget.initialRating && _currentRating != widget.initialRating) {
|
||||
setState(() {
|
||||
_currentRating = widget.initialRating;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _updateRating(Offset localPosition, bool isRTL, {bool isTap = false}) {
|
||||
final totalWidth = widget.itemCount * widget.itemSize + (widget.itemCount - 1) * widget.starPadding;
|
||||
double dx = localPosition.dx;
|
||||
|
||||
@@ -67,6 +67,9 @@ class AuthService {
|
||||
bool isValid = false;
|
||||
|
||||
try {
|
||||
final urls = ApiService.getServerUrls();
|
||||
urls.add(url);
|
||||
await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), urls);
|
||||
final uri = Uri.parse('$url/users/me');
|
||||
final response = await NetworkRepository.client.get(uri);
|
||||
if (response.statusCode == 200) {
|
||||
|
||||
@@ -143,8 +143,7 @@ enum ActionButtonType {
|
||||
!context.isInLockedView && //
|
||||
context.currentAlbum != null,
|
||||
ActionButtonType.setAlbumCover =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
!context.isInLockedView && //
|
||||
context.currentAlbum != null && //
|
||||
context.selectedCount == 1,
|
||||
ActionButtonType.unstack =>
|
||||
|
||||
@@ -16,9 +16,15 @@ class SearchDropdown<T> extends StatelessWidget {
|
||||
final Widget? label;
|
||||
final Widget? leadingIcon;
|
||||
|
||||
static const WidgetStatePropertyAll<EdgeInsetsGeometry> _optionPadding = WidgetStatePropertyAll<EdgeInsetsGeometry>(
|
||||
EdgeInsetsDirectional.fromSTEB(16, 0, 16, 0),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final menuStyle = const MenuStyle(
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final maxMenuHeight = mediaQuery.size.height * 0.5 - mediaQuery.viewPadding.bottom;
|
||||
const menuStyle = MenuStyle(
|
||||
shape: WidgetStatePropertyAll<OutlinedBorder>(
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15))),
|
||||
),
|
||||
@@ -26,11 +32,26 @@ class SearchDropdown<T> extends StatelessWidget {
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final styledEntries = dropdownMenuEntries
|
||||
.map(
|
||||
(entry) => DropdownMenuEntry<T>(
|
||||
value: entry.value,
|
||||
label: entry.label,
|
||||
labelWidget: entry.labelWidget,
|
||||
enabled: entry.enabled,
|
||||
leadingIcon: entry.leadingIcon,
|
||||
trailingIcon: entry.trailingIcon,
|
||||
style: (entry.style ?? const ButtonStyle()).copyWith(padding: _optionPadding),
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
|
||||
return DropdownMenu(
|
||||
controller: controller,
|
||||
leadingIcon: leadingIcon,
|
||||
width: constraints.maxWidth,
|
||||
dropdownMenuEntries: dropdownMenuEntries,
|
||||
menuHeight: maxMenuHeight,
|
||||
dropdownMenuEntries: styledEntries,
|
||||
label: label,
|
||||
menuStyle: menuStyle,
|
||||
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
|
||||
|
||||
Generated
+1
-1
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 2.6.1
|
||||
- API version: 2.6.2
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 2.6.1+3039
|
||||
version: 2.6.2+3040
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
|
||||
@@ -727,7 +727,7 @@ void main() {
|
||||
expect(ActionButtonType.setAlbumCover.shouldShow(context), isTrue);
|
||||
});
|
||||
|
||||
test('should not show when not owner', () {
|
||||
test('should show when not owner', () {
|
||||
final album = createRemoteAlbum();
|
||||
final context = ActionButtonContext(
|
||||
asset: mergedAsset,
|
||||
@@ -742,7 +742,7 @@ void main() {
|
||||
selectedCount: 1,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse);
|
||||
expect(ActionButtonType.setAlbumCover.shouldShow(context), isTrue);
|
||||
});
|
||||
|
||||
test('should not show when in locked view', () {
|
||||
|
||||
@@ -15166,7 +15166,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.2",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.2",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 2.6.1
|
||||
* 2.6.2
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-monorepo",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.2",
|
||||
"description": "Monorepo for Immich",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
|
||||
|
||||
Generated
+529
-252
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.2",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -190,7 +190,13 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
|
||||
}
|
||||
|
||||
case Permission.AlbumUpdate: {
|
||||
return await access.album.checkOwnerAccess(auth.user.id, ids);
|
||||
const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids);
|
||||
const isShared = await access.album.checkSharedAlbumAccess(
|
||||
auth.user.id,
|
||||
setDifference(ids, isOwner),
|
||||
AlbumUserRole.Editor,
|
||||
);
|
||||
return setUnion(isOwner, isShared);
|
||||
}
|
||||
|
||||
case Permission.AlbumDelete: {
|
||||
@@ -198,7 +204,13 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
|
||||
}
|
||||
|
||||
case Permission.AlbumShare: {
|
||||
return await access.album.checkOwnerAccess(auth.user.id, ids);
|
||||
const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids);
|
||||
const isShared = await access.album.checkSharedAlbumAccess(
|
||||
auth.user.id,
|
||||
setDifference(ids, isOwner),
|
||||
AlbumUserRole.Editor,
|
||||
);
|
||||
return setUnion(isOwner, isShared);
|
||||
}
|
||||
|
||||
case Permission.AlbumDownload: {
|
||||
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.2",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -72,10 +72,10 @@
|
||||
"@koddsson/eslint-plugin-tscompat": "^0.2.0",
|
||||
"@socket.io/component-emitter": "^3.1.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/enhanced-img": "^0.10.0",
|
||||
"@sveltejs/enhanced-img": "^0.10.4",
|
||||
"@sveltejs/kit": "^2.27.1",
|
||||
"@sveltejs/vite-plugin-svelte": "6.2.4",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@sveltejs/vite-plugin-svelte": "7.0.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@testing-library/jest-dom": "^6.4.2",
|
||||
"@testing-library/svelte": "^5.2.8",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
@@ -103,10 +103,10 @@
|
||||
"svelte": "5.53.13",
|
||||
"svelte-check": "^4.1.5",
|
||||
"svelte-eslint-parser": "^1.3.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.45.0",
|
||||
"vite": "^7.1.2",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.0.0"
|
||||
},
|
||||
"volta": {
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
onMount(async () => {
|
||||
albums = await getAllAlbums({});
|
||||
// TODO the server should *really* just return all albums (paginated ideally)
|
||||
const ownedAlbums = await getAllAlbums({ shared: false });
|
||||
ownedAlbums.push.apply(ownedAlbums, await getAllAlbums({ shared: true }));
|
||||
albums = ownedAlbums;
|
||||
recentAlbums = albums.sort((a, b) => (new Date(a.updatedAt) > new Date(b.updatedAt) ? -1 : 1)).slice(0, 3);
|
||||
loading = false;
|
||||
});
|
||||
|
||||
@@ -8,17 +8,16 @@ import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { user as authUser, preferences } from '$lib/stores/user.store';
|
||||
import type { AssetControlContext } from '$lib/types';
|
||||
import { getSharedLink, sleep } from '$lib/utils';
|
||||
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
|
||||
import { downloadUrl } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { asQueryString } from '$lib/utils/shared-links';
|
||||
import {
|
||||
AssetJobName,
|
||||
AssetMediaSize,
|
||||
AssetTypeEnum,
|
||||
AssetVisibility,
|
||||
getAssetInfo,
|
||||
getBaseUrl,
|
||||
runAssetJobs,
|
||||
updateAsset,
|
||||
type AssetJobsDto,
|
||||
@@ -308,6 +307,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
|
||||
{
|
||||
filename: asset.originalFileName,
|
||||
id: asset.id,
|
||||
cacheKey: asset.thumbhash,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -321,13 +321,12 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
|
||||
assets.push({
|
||||
filename: motionAsset.originalFileName,
|
||||
id: asset.livePhotoVideoId,
|
||||
cacheKey: motionAsset.thumbhash,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const queryParams = asQueryString(authManager.params);
|
||||
|
||||
for (const [i, { filename, id }] of assets.entries()) {
|
||||
for (const [i, { filename, id, cacheKey }] of assets.entries()) {
|
||||
if (i !== 0) {
|
||||
// play nice with Safari
|
||||
await sleep(500);
|
||||
@@ -335,12 +334,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
|
||||
|
||||
try {
|
||||
toastManager.primary($t('downloading_asset_filename', { values: { filename } }));
|
||||
downloadUrl(
|
||||
getBaseUrl() +
|
||||
`/assets/${id}/original` +
|
||||
(queryParams ? `?${queryParams}&edited=${edited}` : `?edited=${edited}`),
|
||||
filename,
|
||||
);
|
||||
downloadUrl(getAssetMediaUrl({ id, size: AssetMediaSize.Original, edited, cacheKey }), filename);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.error_downloading', { values: { filename } }));
|
||||
}
|
||||
|
||||
@@ -80,7 +80,34 @@ function createUploadStore() {
|
||||
};
|
||||
|
||||
const removeItem = (id: string) => {
|
||||
uploadAssets.update((uploadingAsset) => uploadingAsset.filter((a) => a.id != id));
|
||||
uploadAssets.update((uploadingAsset) => {
|
||||
const assetToRemove = uploadingAsset.find((a) => a.id === id);
|
||||
if (assetToRemove) {
|
||||
stats.update((stats) => {
|
||||
switch (assetToRemove.state) {
|
||||
case UploadState.DONE: {
|
||||
stats.success--;
|
||||
break;
|
||||
}
|
||||
|
||||
case UploadState.DUPLICATED: {
|
||||
stats.duplicates--;
|
||||
break;
|
||||
}
|
||||
|
||||
case UploadState.ERROR: {
|
||||
stats.errors--;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
stats.total--;
|
||||
return stats;
|
||||
});
|
||||
}
|
||||
|
||||
return uploadingAsset.filter((a) => a.id != id);
|
||||
});
|
||||
};
|
||||
|
||||
const dismissErrors = () =>
|
||||
|
||||
+7
-7
@@ -476,13 +476,6 @@
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
{#if assetInteraction.selectedAssets.length === 1}
|
||||
<MenuOption
|
||||
text={$t('set_as_album_cover')}
|
||||
icon={mdiImageOutline}
|
||||
onClick={() => updateThumbnailUsingCurrentSelection()}
|
||||
/>
|
||||
{/if}
|
||||
<ArchiveAction
|
||||
menuItem
|
||||
unarchive={assetInteraction.isAllArchived}
|
||||
@@ -490,6 +483,13 @@
|
||||
/>
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
{/if}
|
||||
{#if assetInteraction.selectedAssets.length === 1}
|
||||
<MenuOption
|
||||
text={$t('set_as_album_cover')}
|
||||
icon={mdiImageOutline}
|
||||
onClick={() => updateThumbnailUsingCurrentSelection()}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||
<TagAction menuItem />
|
||||
|
||||
+6
-16
@@ -178,19 +178,7 @@
|
||||
|
||||
const handleFirst = () => navigateToIndex(0);
|
||||
const handlePrevious = () => navigateToIndex(Math.max(duplicatesIndex - 1, 0));
|
||||
const handlePreviousShortcut = async () => {
|
||||
if ($showAssetViewer) {
|
||||
return;
|
||||
}
|
||||
await handlePrevious();
|
||||
};
|
||||
const handleNext = async () => navigateToIndex(Math.min(duplicatesIndex + 1, duplicates.length - 1));
|
||||
const handleNextShortcut = async () => {
|
||||
if ($showAssetViewer) {
|
||||
return;
|
||||
}
|
||||
await handleNext();
|
||||
};
|
||||
const handleLast = () => navigateToIndex(duplicates.length - 1);
|
||||
|
||||
const navigateToIndex = async (index: number) =>
|
||||
@@ -198,10 +186,12 @@
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
use:shortcuts={[
|
||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePreviousShortcut },
|
||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNextShortcut },
|
||||
]}
|
||||
use:shortcuts={$showAssetViewer
|
||||
? []
|
||||
: [
|
||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePrevious },
|
||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNext },
|
||||
]}
|
||||
/>
|
||||
|
||||
<UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}>
|
||||
|
||||
Reference in New Issue
Block a user