mirror of
https://github.com/immich-app/immich.git
synced 2025-12-15 14:34:30 +03:00
Compare commits
195 Commits
refactor/m
...
feat/datab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e958516318 | ||
|
|
0d05c0d4ae | ||
|
|
4e2187acf9 | ||
|
|
adc2d5d1e5 | ||
|
|
6b9cc855a5 | ||
|
|
02265ba224 | ||
|
|
cf3686a509 | ||
|
|
3019091733 | ||
|
|
4296211c61 | ||
|
|
207a8bc55a | ||
|
|
a63b418507 | ||
|
|
fe8eb85e37 | ||
|
|
4659ceb425 | ||
|
|
17dfcedad6 | ||
|
|
20d1e610ce | ||
|
|
305bf60f97 | ||
|
|
f9d2a9707d | ||
|
|
ef944c29d3 | ||
|
|
274775d876 | ||
|
|
0945e18564 | ||
|
|
e0428b565a | ||
|
|
4f93eda8d8 | ||
|
|
f5df5fa98d | ||
|
|
f07d1441ea | ||
|
|
1bcf28c062 | ||
|
|
9b955508e9 | ||
|
|
62628dfcfa | ||
|
|
a79b4bdc47 | ||
|
|
94af1bba4d | ||
|
|
b11aecd184 | ||
|
|
116012f6f8 | ||
|
|
b5ff460a55 | ||
|
|
7594136050 | ||
|
|
bb341cc774 | ||
|
|
af1d4afb95 | ||
|
|
75b1ef2c57 | ||
|
|
1e37f7c8c8 | ||
|
|
a32f450059 | ||
|
|
b452ab463b | ||
|
|
79bed80226 | ||
|
|
6249996cdb | ||
|
|
a3f281caa3 | ||
|
|
7c19b0591f | ||
|
|
95c29a8aea | ||
|
|
d8ca210641 | ||
|
|
ab35afd3b1 | ||
|
|
65e4fdf98d | ||
|
|
8b1ba11e0b | ||
|
|
a7fd19db52 | ||
|
|
fa43fae2a5 | ||
|
|
46afd6a101 | ||
|
|
46e1967760 | ||
|
|
922282b2b4 | ||
|
|
e3ab16a5bd | ||
|
|
08f320c801 | ||
|
|
e36261b552 | ||
|
|
c0a3b58bba | ||
|
|
f12f609038 | ||
|
|
1f6eb662e5 | ||
|
|
0c1fe35f2f | ||
|
|
e98a33cf9d | ||
|
|
d38305360c | ||
|
|
db7169ea01 | ||
|
|
cede65f2dd | ||
|
|
e355dccc48 | ||
|
|
3e3ca4c104 | ||
|
|
81edf0749f | ||
|
|
01f83ae964 | ||
|
|
5eec0dc981 | ||
|
|
ca4fd07656 | ||
|
|
7ce43b3824 | ||
|
|
ce00119926 | ||
|
|
fffee80e2f | ||
|
|
64cd4e96e3 | ||
|
|
955a3bfaa6 | ||
|
|
e699d8f170 | ||
|
|
8dd865d054 | ||
|
|
e3f350ea60 | ||
|
|
13104d49cd | ||
|
|
2d5ec528d5 | ||
|
|
5226898184 | ||
|
|
dd4169876c | ||
|
|
8321c275b8 | ||
|
|
3d6c26350a | ||
|
|
db15e5e423 | ||
|
|
35d18da14a | ||
|
|
cb56a11f0b | ||
|
|
6ec10a5f15 | ||
|
|
104fa09f69 | ||
|
|
66ae07ee39 | ||
|
|
939d2c8b27 | ||
|
|
2801a6e672 | ||
|
|
4742360469 | ||
|
|
9cb968116b | ||
|
|
b56fa62b32 | ||
|
|
52edcdee60 | ||
|
|
96426fec7e | ||
|
|
47f5232a5f | ||
|
|
a091ca76e7 | ||
|
|
c8fea45731 | ||
|
|
390f0b2817 | ||
|
|
ddbe485074 | ||
|
|
01310c6d86 | ||
|
|
1cdffeb3be | ||
|
|
87f34ba505 | ||
|
|
ca116caafb | ||
|
|
86b7b1c44d | ||
|
|
512327ef69 | ||
|
|
8755cd59fd | ||
|
|
7694b342ed | ||
|
|
95d9bcb3f1 | ||
|
|
0f145a5b52 | ||
|
|
481ec02edb | ||
|
|
78553a0258 | ||
|
|
c1198b99b7 | ||
|
|
8b7b9ee394 | ||
|
|
d6b39a464d | ||
|
|
75d23fe135 | ||
|
|
c860809aa1 | ||
|
|
9f5f90b2ff | ||
|
|
0498f6cb9d | ||
|
|
24e5dabb51 | ||
|
|
aecf064ec9 | ||
|
|
1ad2282166 | ||
|
|
b99d92961c | ||
|
|
45b5752cbf | ||
|
|
220d63e035 | ||
|
|
3be039b953 | ||
|
|
e2ca0c6f67 | ||
|
|
f84bdc14d5 | ||
|
|
fd6f043aa4 | ||
|
|
5a6083f53c | ||
|
|
a61f9d7a26 | ||
|
|
57be3ff8c7 | ||
|
|
3863ff73ef | ||
|
|
99505f987e | ||
|
|
534a9f50b6 | ||
|
|
b46d6cda65 | ||
|
|
86d8e1a092 | ||
|
|
0940c313ac | ||
|
|
f6316ca0c8 | ||
|
|
539167eb88 | ||
|
|
5bca8808a1 | ||
|
|
e93652a4a5 | ||
|
|
ac9a587063 | ||
|
|
f7b59f50ed | ||
|
|
53ef26a5e4 | ||
|
|
6cefb9ca95 | ||
|
|
fdacf0ec57 | ||
|
|
cbf3a2c3cb | ||
|
|
d2a4dd67d8 | ||
|
|
874782edf0 | ||
|
|
a7245627fc | ||
|
|
174670a1b7 | ||
|
|
a3c6d71a58 | ||
|
|
19ba23056c | ||
|
|
3d2d7fa64c | ||
|
|
fccb31d1d8 | ||
|
|
8405a9bf0c | ||
|
|
3933b23e2c | ||
|
|
824f6e5b05 | ||
|
|
270d7e3cdc | ||
|
|
8463968712 | ||
|
|
5be08274ff | ||
|
|
161918e9ca | ||
|
|
d6e3d26cfc | ||
|
|
d5351de26f | ||
|
|
ed4a850a01 | ||
|
|
9d4ad11cff | ||
|
|
b887d4f557 | ||
|
|
2e15012257 | ||
|
|
56a4159295 | ||
|
|
f69c49a60f | ||
|
|
f778a4260b | ||
|
|
31f4665d35 | ||
|
|
53a74a7279 | ||
|
|
dd1cf12aaa | ||
|
|
31410c3c20 | ||
|
|
26587dd690 | ||
|
|
442fe6e3d0 | ||
|
|
af741a4761 | ||
|
|
7c2e8b1d62 | ||
|
|
56c93a71c0 | ||
|
|
c090a1a9d9 | ||
|
|
d040de2d52 | ||
|
|
73ae766d9f | ||
|
|
edc1333db1 | ||
|
|
b01b63b25a | ||
|
|
7e7d6af66b | ||
|
|
0ae03f68cf | ||
|
|
0419539c08 | ||
|
|
f67153e44b | ||
|
|
cc7895244d | ||
|
|
a6fb942fca | ||
|
|
9cea3d7b2f |
2
.github/.nvmrc
vendored
2
.github/.nvmrc
vendored
@@ -1 +1 @@
|
||||
24.11.0
|
||||
24.11.1
|
||||
|
||||
2
.github/workflows/cli.yml
vendored
2
.github/workflows/cli.yml
vendored
@@ -105,7 +105,7 @@ jobs:
|
||||
|
||||
- name: Generate docker image tags
|
||||
id: metadata
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
2
.github/workflows/close-duplicates.yml
vendored
2
.github/workflows/close-duplicates.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
needs: [get_body, should_run]
|
||||
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
||||
container:
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:9c905a4ff69f00c4b2f98b40b6090ab3ab18d1a15ed1379733b8691aa1fcb271
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:237cdae7783609c96f18037a513d38088713cf4a2e493a3aa136d0c45490749a
|
||||
outputs:
|
||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||
steps:
|
||||
|
||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
uses: github/codeql-action/autobuild@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -83,6 +83,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
@@ -132,7 +132,7 @@ jobs:
|
||||
suffixes: '-rocm'
|
||||
platforms: linux/amd64
|
||||
runner-mapping: '{"linux/amd64": "mich"}'
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
name: Build and Push Server
|
||||
needs: pre-job
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
2
.github/workflows/fix-format.yml
vendored
2
.github/workflows/fix-format.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
2
.github/workflows/merge-translations.yml
vendored
2
.github/workflows/merge-translations.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Generate a token
|
||||
id: generate_token
|
||||
if: ${{ inputs.skip != true }}
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
6
.github/workflows/prepare-release.yml
vendored
6
.github/workflows/prepare-release.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
ref: main
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
6
.github/workflows/release-pr.yml
vendored
6
.github/workflows/release-pr.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
ref: main
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
|
||||
- name: Create PR
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download APK
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: release-apk-signed
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -571,8 +571,8 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
|
||||
# with:
|
||||
# python-version: 3.11
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,7 +7,7 @@
|
||||
.idea
|
||||
|
||||
docker/upload
|
||||
docker/library
|
||||
docker/library*
|
||||
uploads
|
||||
coverage
|
||||
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -52,7 +52,7 @@
|
||||
},
|
||||
"cSpell.words": ["immich"],
|
||||
"editor.formatOnSave": true,
|
||||
"eslint.validate": ["javascript", "svelte"],
|
||||
"eslint.validate": ["javascript", "typescript", "svelte"],
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.11.0
|
||||
24.11.1
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e AS core
|
||||
FROM node:24.1.0-alpine3.20@sha256:8fe019e0d57dbdce5f5c27c0b63d2775cf34b00e3755a7dea969802d7e0c2b25 AS core
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY package* pnpm* .pnpmfile.cjs ./
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"byte-size": "^9.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
@@ -28,7 +28,7 @@
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^60.0.0",
|
||||
"eslint-plugin-unicorn": "^62.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"mock-fs": "^5.2.0",
|
||||
"prettier": "^3.2.5",
|
||||
@@ -69,6 +69,6 @@
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.11.0"
|
||||
"node": "24.11.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@ describe('crawl', () => {
|
||||
.map(([file]) => file);
|
||||
|
||||
// Compare file's content instead of path since a file can be represent in multiple ways.
|
||||
expect(actual.map((path) => readContent(path)).sort()).toEqual(expected.sort());
|
||||
expect(actual.map((path) => readContent(path)).toSorted()).toEqual(expected.toSorted());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -160,7 +160,7 @@ export const crawl = async (options: CrawlOptions): Promise<string[]> => {
|
||||
ignore: [`**/${exclusionPattern}`],
|
||||
});
|
||||
globbedFiles.push(...crawledFiles);
|
||||
return globbedFiles.sort();
|
||||
return globbedFiles.toSorted();
|
||||
};
|
||||
|
||||
export const sha1 = (filepath: string) => {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"target": "es2022",
|
||||
"target": "es2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"incremental": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tools]
|
||||
terragrunt = "0.91.2"
|
||||
opentofu = "1.10.6"
|
||||
terragrunt = "0.93.10"
|
||||
opentofu = "1.10.7"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
run = "terragrunt hclfmt"
|
||||
|
||||
@@ -135,7 +135,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
|
||||
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
|
||||
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
@@ -95,7 +95,7 @@ services:
|
||||
command: ['./run.sh', '-disable-reporting']
|
||||
ports:
|
||||
- 3000:3000
|
||||
image: grafana/grafana:12.2.1-ubuntu@sha256:797530c642f7b41ba7848c44cfda5e361ef1f3391a98bed1e5d448c472b6826a
|
||||
image: grafana/grafana:12.3.0-ubuntu@sha256:cee936306135e1925ab21dffa16f8a411535d16ab086bef2309339a8e74d62df
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
|
||||
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.11.0
|
||||
24.11.1
|
||||
|
||||
@@ -133,9 +133,9 @@ There are a few different scenarios that can lead to this situation. The solutio
|
||||
The job is only automatically run once per asset after upload. If metadata extraction originally failed, the jobs were cleared/canceled, etc.,
|
||||
the job may not have run automatically the first time.
|
||||
|
||||
### How can I hide photos from the timeline?
|
||||
### How can I hide a photo or video from the timeline?
|
||||
|
||||
You can _archive_ them.
|
||||
You can _archive_ them. This will hide the asset from the main timeline and folder view, but it will still show up in searches. All archived assets can be found in the _Archive_ view
|
||||
|
||||
### How can I backup data from Immich?
|
||||
|
||||
|
||||
@@ -14,15 +14,15 @@ When contributing code through a pull request, please check the following:
|
||||
- [ ] `pnpm run check:typescript` (check typescript)
|
||||
- [ ] `pnpm test` (unit tests)
|
||||
|
||||
:::tip AIO
|
||||
Run all web checks with `pnpm run check:all`
|
||||
:::
|
||||
|
||||
## Documentation
|
||||
|
||||
- [ ] `pnpm run format` (formatting via Prettier)
|
||||
- [ ] Update the `_redirects` file if you have renamed a page or removed it from the documentation.
|
||||
|
||||
:::tip AIO
|
||||
Run all web checks with `pnpm run check:all`
|
||||
:::
|
||||
|
||||
## Server Checks
|
||||
|
||||
- [ ] `pnpm run lint` (linting via ESLint)
|
||||
|
||||
@@ -18,6 +18,7 @@ make e2e
|
||||
Before you can run the tests, you need to run the following commands _once_:
|
||||
|
||||
- `pnpm install` (in `e2e/`)
|
||||
- `pnpm run build` (in `cli/`)
|
||||
- `make open-api` (in the project root `/`)
|
||||
|
||||
Once the test environment is running, the e2e tests can be run via:
|
||||
|
||||
@@ -62,10 +62,10 @@ Information on the current workers can be found [here](/administration/jobs-work
|
||||
|
||||
## Ports
|
||||
|
||||
| Variable | Description | Default |
|
||||
| :------------ | :------------- | :----------------------------------------: |
|
||||
| `IMMICH_HOST` | Listening host | `0.0.0.0` |
|
||||
| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) |
|
||||
| Variable | Description | Default | Containers |
|
||||
| :------------ | :------------- | :----------------------------------------: | :----------------------- |
|
||||
| `IMMICH_HOST` | Listening host | `0.0.0.0` | server, machine learning |
|
||||
| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) | server, machine learning |
|
||||
|
||||
## Database
|
||||
|
||||
@@ -80,7 +80,7 @@ Information on the current workers can be found [here](/administration/jobs-work
|
||||
| `DB_SSL_MODE` | Database SSL mode | | server |
|
||||
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
|
||||
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
|
||||
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | server |
|
||||
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | database |
|
||||
|
||||
\*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`.
|
||||
|
||||
@@ -93,7 +93,7 @@ Information on the current workers can be found [here](/administration/jobs-work
|
||||
All `DB_` variables must be provided to all Immich workers, including `api` and `microservices`.
|
||||
|
||||
`DB_URL` must be in the format `postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename`.
|
||||
You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&sslmode=no-verify`.
|
||||
You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&uselibpqcompat=true`. This allows both immich and `pg_dumpall` (the utility used for database backups) to [properly connect](https://github.com/brianc/node-postgres/tree/master/packages/pg-connection-string#tcp-connections) to your database.
|
||||
|
||||
When `DB_URL` is defined, the `DB_HOSTNAME`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD` and `DB_DATABASE_NAME` database variables are ignored.
|
||||
|
||||
|
||||
@@ -57,6 +57,6 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.11.0"
|
||||
"node": "24.11.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.11.0
|
||||
24.11.1
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/oidc-provider": "^9.0.0",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
@@ -35,8 +35,8 @@
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^60.0.0",
|
||||
"exiftool-vendored": "^31.1.0",
|
||||
"eslint-plugin-unicorn": "^62.0.0",
|
||||
"exiftool-vendored": "^33.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"jose": "^5.6.3",
|
||||
"luxon": "^3.4.4",
|
||||
@@ -45,7 +45,7 @@
|
||||
"pngjs": "^7.0.0",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"sharp": "^0.34.4",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"supertest": "^7.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
@@ -54,6 +54,6 @@
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.11.0"
|
||||
"node": "24.11.1"
|
||||
}
|
||||
}
|
||||
|
||||
267
e2e/src/api/specs/database-backups.e2e-spec.ts
Normal file
267
e2e/src/api/specs/database-backups.e2e-spec.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { LoginResponseDto, ManualJobName } from '@immich/sdk';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/admin/database-backups', () => {
|
||||
let cookie: string | undefined;
|
||||
let admin: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
});
|
||||
|
||||
describe('GET /', async () => {
|
||||
it('should succeed and be empty', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/database-backups')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
backups: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should contain a created backup', async () => {
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.BackupDatabase,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, 'backupDatabase');
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/database-backups')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
return body;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toEqual(
|
||||
expect.objectContaining({
|
||||
backups: [expect.stringMatching(/immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/)],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /', async () => {
|
||||
it('should delete backup', async () => {
|
||||
const filename = await utils.createBackup(admin.accessToken);
|
||||
|
||||
const { status } = await request(app)
|
||||
.delete(`/admin/database-backups`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ backups: [filename] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
||||
const { status: listStatus, body } = await request(app)
|
||||
.get('/admin/database-backups')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(listStatus).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
backups: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// => action: restore database flow
|
||||
|
||||
describe.sequential('POST /start-restore', () => {
|
||||
afterAll(async () => {
|
||||
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({ action: 'end' });
|
||||
await utils.poll(
|
||||
() => request(app).get('/server/config'),
|
||||
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||
);
|
||||
|
||||
admin = await utils.adminSetup();
|
||||
});
|
||||
|
||||
it.sequential('should not work when the server is configured', async () => {
|
||||
const { status, body } = await request(app).post('/admin/database-backups/start-restore').send();
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('The server already has an admin'));
|
||||
});
|
||||
|
||||
it.sequential('should enter maintenance mode in "database restore mode"', async () => {
|
||||
await utils.resetDatabase(); // reset database before running this test
|
||||
|
||||
const { status, headers } = await request(app).post('/admin/database-backups/start-restore').send();
|
||||
|
||||
expect(status).toBe(201);
|
||||
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status2).toBe(200);
|
||||
expect(body).toEqual({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// => action: restore database
|
||||
|
||||
describe.sequential('POST /backups/restore', () => {
|
||||
beforeAll(async () => {
|
||||
await utils.disconnectDatabase();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await utils.connectDatabase();
|
||||
});
|
||||
|
||||
it.sequential('should restore a backup', { timeout: 60_000 }, async () => {
|
||||
const filename = await utils.createBackup(admin.accessToken);
|
||||
|
||||
const { status } = await request(app)
|
||||
.post('/admin/maintenance')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
action: 'restore_database',
|
||||
restoreBackupFilename: filename,
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status2).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 60_000,
|
||||
},
|
||||
)
|
||||
.toBeFalsy();
|
||||
});
|
||||
|
||||
it.sequential('fail to restore a corrupted backup', { timeout: 60_000 }, async () => {
|
||||
await utils.prepareTestBackup('corrupted');
|
||||
|
||||
const { status, headers } = await request(app)
|
||||
.post('/admin/maintenance')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
action: 'restore_database',
|
||||
restoreBackupFilename: 'development-corrupted.sql.gz',
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status).toBe(200);
|
||||
return body;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toEqual(
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
error: 'Something went wrong, see logs!',
|
||||
}),
|
||||
);
|
||||
|
||||
const { status: status2, body: body2 } = await request(app)
|
||||
.get('/admin/maintenance/status')
|
||||
.set('cookie', cookie!)
|
||||
.send({ token: 'token' });
|
||||
expect(status2).toBe(200);
|
||||
expect(body2).toEqual(
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
error: expect.stringContaining('IM CORRUPTED'),
|
||||
}),
|
||||
);
|
||||
|
||||
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
|
||||
action: 'end',
|
||||
});
|
||||
|
||||
await utils.poll(
|
||||
() => request(app).get('/server/config'),
|
||||
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1006,7 +1006,7 @@ describe('/libraries', () => {
|
||||
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => {
|
||||
it('should switch from using file metadata to file.ext.xmp metadata when asset refreshes', async () => {
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
importPaths: [`${testAssetDirInternal}/temp/xmp`],
|
||||
|
||||
@@ -14,6 +14,7 @@ describe('/admin/maintenance', () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
});
|
||||
|
||||
// => outside of maintenance mode
|
||||
@@ -26,6 +27,17 @@ describe('/admin/maintenance', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /status', async () => {
|
||||
it('to always indicate we are not in maintenance mode', async () => {
|
||||
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
active: false,
|
||||
action: 'end',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /login', async () => {
|
||||
it('should not work out of maintenance mode', async () => {
|
||||
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
|
||||
@@ -39,6 +51,7 @@ describe('/admin/maintenance', () => {
|
||||
describe.sequential('POST /', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post('/admin/maintenance').send({
|
||||
active: false,
|
||||
action: 'end',
|
||||
});
|
||||
expect(status).toBe(401);
|
||||
@@ -69,6 +82,7 @@ describe('/admin/maintenance', () => {
|
||||
.send({
|
||||
action: 'start',
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
@@ -79,12 +93,13 @@ describe('/admin/maintenance', () => {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { body } = await request(app).get('/server/config');
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 5e2,
|
||||
timeout: 1e4,
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
@@ -102,6 +117,17 @@ describe('/admin/maintenance', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /status', async () => {
|
||||
it('to indicate we are in maintenance mode', async () => {
|
||||
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
active: true,
|
||||
action: 'start',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /login', async () => {
|
||||
it('should fail without cookie or token in body', async () => {
|
||||
const { status, body } = await request(app).post('/admin/maintenance/login').send({});
|
||||
@@ -158,12 +184,13 @@ describe('/admin/maintenance', () => {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { body } = await request(app).get('/server/config');
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 5e2,
|
||||
timeout: 1e4,
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeFalsy();
|
||||
|
||||
@@ -61,7 +61,7 @@ export function selectRandomDays(daysInMonth: number, numDays: number, rng: Seed
|
||||
}
|
||||
}
|
||||
|
||||
return [...selectedDays].sort((a, b) => b - a);
|
||||
return [...selectedDays].toSorted((a, b) => b - a);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,50 +62,60 @@ export const setupTimelineMockApiRoutes = async (
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/**', async (route, request) => {
|
||||
await context.route('**/api/assets/*', async (route, request) => {
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
const assetId = basename(pathname);
|
||||
const asset = getAsset(timelineRestData, assetId);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: asset,
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*/ocr', async (route) => {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', json: [] });
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
|
||||
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
|
||||
const match = request.url().match(pattern);
|
||||
if (!match) {
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
const assetId = basename(pathname);
|
||||
const asset = getAsset(timelineRestData, assetId);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: asset,
|
||||
});
|
||||
if (!match?.groups) {
|
||||
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
|
||||
}
|
||||
if (match.groups?.size === 'preview') {
|
||||
|
||||
if (match.groups.size === 'preview') {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
const asset = getAsset(timelineRestData, match.groups?.assetId);
|
||||
const asset = getAsset(timelineRestData, match.groups.assetId);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg', ETag: 'abc123', 'Cache-Control': 'public, max-age=3600' },
|
||||
body: await randomPreview(
|
||||
match.groups?.assetId,
|
||||
match.groups.assetId,
|
||||
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (match.groups?.size === 'thumbnail') {
|
||||
if (match.groups.size === 'thumbnail') {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
const asset = getAsset(timelineRestData, match.groups?.assetId);
|
||||
const asset = getAsset(timelineRestData, match.groups.assetId);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: await randomThumbnail(
|
||||
match.groups?.assetId,
|
||||
match.groups.assetId,
|
||||
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
|
||||
),
|
||||
});
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await context.route('**/api/albums/**', async (route, request) => {
|
||||
const pattern = /\/api\/albums\/(?<albumId>[^/?]+)/;
|
||||
const match = request.url().match(pattern);
|
||||
|
||||
@@ -6,13 +6,15 @@ import {
|
||||
CheckExistingAssetsDto,
|
||||
CreateAlbumDto,
|
||||
CreateLibraryDto,
|
||||
JobCreateDto,
|
||||
MaintenanceAction,
|
||||
ManualJobName,
|
||||
MetadataSearchDto,
|
||||
Permission,
|
||||
PersonCreateDto,
|
||||
QueueCommandDto,
|
||||
QueueName,
|
||||
QueuesResponseDto,
|
||||
QueuesResponseLegacyDto,
|
||||
SharedLinkCreateDto,
|
||||
UpdateLibraryDto,
|
||||
UserAdminCreateDto,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
checkExistingAssets,
|
||||
createAlbum,
|
||||
createApiKey,
|
||||
createJob,
|
||||
createLibrary,
|
||||
createPartner,
|
||||
createPerson,
|
||||
@@ -28,10 +31,12 @@ import {
|
||||
createStack,
|
||||
createUserAdmin,
|
||||
deleteAssets,
|
||||
deleteDatabaseBackup,
|
||||
getAssetInfo,
|
||||
getConfig,
|
||||
getConfigDefaults,
|
||||
getQueuesLegacy,
|
||||
listDatabaseBackups,
|
||||
login,
|
||||
runQueueCommandLegacy,
|
||||
scanLibrary,
|
||||
@@ -52,11 +57,15 @@ import {
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { exec, spawn } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
|
||||
import { promisify } from 'node:util';
|
||||
import { createGzip } from 'node:zlib';
|
||||
import pg from 'pg';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { loginDto, signupDto } from 'src/fixtures';
|
||||
@@ -84,8 +93,9 @@ export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer $
|
||||
export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
|
||||
export const immichCli = (args: string[]) =>
|
||||
executeCommand('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).promise;
|
||||
export const immichAdmin = (args: string[]) =>
|
||||
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
|
||||
export const dockerExec = (args: string[]) =>
|
||||
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', args.join(' ')]);
|
||||
export const immichAdmin = (args: string[]) => dockerExec([`immich-admin ${args.join(' ')}`]);
|
||||
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
|
||||
export const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
|
||||
@@ -149,12 +159,26 @@ const onEvent = ({ event, id }: { event: EventType; id: string }) => {
|
||||
};
|
||||
|
||||
export const utils = {
|
||||
connectDatabase: async () => {
|
||||
if (!client) {
|
||||
client = new pg.Client(dbUrl);
|
||||
client.on('end', () => (client = null));
|
||||
client.on('error', () => (client = null));
|
||||
await client.connect();
|
||||
}
|
||||
|
||||
return client;
|
||||
},
|
||||
|
||||
disconnectDatabase: async () => {
|
||||
if (client) {
|
||||
await client.end();
|
||||
}
|
||||
},
|
||||
|
||||
resetDatabase: async (tables?: string[]) => {
|
||||
try {
|
||||
if (!client) {
|
||||
client = new pg.Client(dbUrl);
|
||||
await client.connect();
|
||||
}
|
||||
client = await utils.connectDatabase();
|
||||
|
||||
tables = tables || [
|
||||
// TODO e2e test for deleting a stack, since it is quite complex
|
||||
@@ -481,6 +505,9 @@ export const utils = {
|
||||
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
|
||||
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
createJob: async (accessToken: string, jobCreateDto: JobCreateDto) =>
|
||||
createJob({ jobCreateDto }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
|
||||
runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
@@ -559,18 +586,48 @@ export const utils = {
|
||||
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
|
||||
},
|
||||
|
||||
createBackup: async (accessToken: string) => {
|
||||
await utils.createJob(accessToken, {
|
||||
name: ManualJobName.BackupDatabase,
|
||||
});
|
||||
|
||||
return await utils.poll(
|
||||
() => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`),
|
||||
({ status, body }) => status === 200 && body.backups.length === 1,
|
||||
({ body }) => body.backups[0],
|
||||
);
|
||||
},
|
||||
|
||||
resetBackups: async (accessToken: string) => {
|
||||
const { backups } = await listDatabaseBackups({ headers: asBearerAuth(accessToken) });
|
||||
await deleteDatabaseBackup({ databaseBackupDeleteDto: { backups } }, { headers: asBearerAuth(accessToken) });
|
||||
},
|
||||
|
||||
prepareTestBackup: async (generate: 'corrupted') => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'test-'));
|
||||
const fn = join(dir, 'file');
|
||||
|
||||
const sql = Readable.from('IM CORRUPTED;');
|
||||
const gzip = createGzip();
|
||||
const writeStream = createWriteStream(fn);
|
||||
await pipeline(sql, gzip, writeStream);
|
||||
|
||||
await executeCommand('docker', ['cp', fn, `immich-e2e-server:/data/backups/development-${generate}.sql.gz`])
|
||||
.promise;
|
||||
},
|
||||
|
||||
resetAdminConfig: async (accessToken: string) => {
|
||||
const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
|
||||
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
||||
},
|
||||
|
||||
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseDto) => {
|
||||
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => {
|
||||
const queues = await getQueuesLegacy({ headers: asBearerAuth(accessToken) });
|
||||
const jobCounts = queues[queue].jobCounts;
|
||||
return !jobCounts.active && !jobCounts.waiting;
|
||||
},
|
||||
|
||||
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseDto, ms?: number) => {
|
||||
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms?: number) => {
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise<void>(async (resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 10_000);
|
||||
@@ -601,6 +658,25 @@ export const utils = {
|
||||
await utils.waitForQueueFinish(accessToken, 'sidecar');
|
||||
await utils.waitForQueueFinish(accessToken, 'metadataExtraction');
|
||||
},
|
||||
|
||||
async poll<T>(cb: () => Promise<T>, validate: (value: T) => boolean, map?: (value: T) => any) {
|
||||
let timeout = 0;
|
||||
while (true) {
|
||||
try {
|
||||
const data = await cb();
|
||||
if (validate(data)) {
|
||||
return map ? map(data) : data;
|
||||
}
|
||||
timeout++;
|
||||
if (timeout >= 10) {
|
||||
throw 'Could not clean up test.';
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5e2));
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
utils.initSdk();
|
||||
|
||||
75
e2e/src/web/specs/database-backups.e2e-spec.ts
Normal file
75
e2e/src/web/specs/database-backups.e2e-spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { LoginResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Database Backups', () => {
|
||||
let admin: LoginResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
});
|
||||
|
||||
test('restore a backup from settings', async ({ context, page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
await utils.createBackup(admin.accessToken);
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto('/admin/maintenance?isOpen=backups');
|
||||
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await page.locator('#bits-c2').getByRole('button', { name: 'Restore' }).click();
|
||||
|
||||
await page.waitForURL('/maintenance?**');
|
||||
await page.waitForURL('/admin/maintenance**', { timeout: 60_000 });
|
||||
});
|
||||
|
||||
test('handle backup restore failure', async ({ context, page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
await utils.prepareTestBackup('corrupted');
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto('/admin/maintenance?isOpen=backups');
|
||||
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await page.locator('#bits-c2').getByRole('button', { name: 'Restore' }).click();
|
||||
|
||||
await page.waitForURL('/maintenance?**');
|
||||
await expect(page.getByText('IM CORRUPTED')).toBeVisible({ timeout: 60_000 });
|
||||
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||
await page.waitForURL('/admin/maintenance**');
|
||||
});
|
||||
|
||||
test('restore a backup from onboarding', async ({ context, page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
await utils.createBackup(admin.accessToken);
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await utils.resetDatabase();
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Restore from backup' }).click();
|
||||
|
||||
try {
|
||||
await page.waitForURL('/maintenance**');
|
||||
} catch {
|
||||
// when chained with the rest of the tests
|
||||
// this navigation may fail..? not sure why...
|
||||
await page.goto('/maintenance');
|
||||
await page.waitForURL('/maintenance**');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await page.locator('#bits-c2').getByRole('button', { name: 'Restore' }).click();
|
||||
|
||||
await page.waitForURL('/maintenance?**');
|
||||
await page.waitForURL('/photos', { timeout: 60_000 });
|
||||
});
|
||||
});
|
||||
@@ -16,12 +16,12 @@ test.describe('Maintenance', () => {
|
||||
test('enter and exit maintenance mode', async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto('/admin/system-settings?isOpen=maintenance');
|
||||
await page.getByRole('button', { name: 'Start maintenance mode' }).click();
|
||||
await page.goto('/admin/maintenance');
|
||||
await page.getByRole('button', { name: 'Switch to maintenance mode' }).click();
|
||||
|
||||
await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||
await page.waitForURL('**/admin/system-settings*', { timeout: 10_000 });
|
||||
await page.waitForURL('**/admin/maintenance*', { timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
|
||||
|
||||
@@ -611,6 +611,53 @@ test.describe('Timeline', () => {
|
||||
await page.getByText('Photos', { exact: true }).click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
});
|
||||
test('open /archive, favorite photo, unfavorite', async ({ page }) => {
|
||||
const assetToFavorite = assets[0];
|
||||
changes.assetArchivals.push(assetToFavorite.id);
|
||||
await pageUtils.openArchivePage(page);
|
||||
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
const isFavorite = requestJson.isFavorite;
|
||||
if (isFavorite) {
|
||||
changes.assetFavorites.push(...requestJson.ids);
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
await page.getByLabel('Favorite').click();
|
||||
await expect(favorite).resolves.toEqual({
|
||||
isFavorite: true,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
|
||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Remove from favorites').click();
|
||||
await expect(unFavoriteRequest).resolves.toEqual({
|
||||
isFavorite: false,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
|
||||
await thumbnailUtils.expectThumbnailIsNotFavorite(page, assetToFavorite.id);
|
||||
});
|
||||
test('open album, archive photo, open album, unarchive', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
@@ -633,8 +680,7 @@ test.describe('Timeline', () => {
|
||||
visibility: 'archive',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
console.log('Skipping assertion - TODO - fix that archiving in album doesnt add icon');
|
||||
// await thumbnail.expectThumbnailIsArchive(page, assetToArchive.id);
|
||||
await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
|
||||
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
||||
await page.getByRole('link').getByText('Archive').click();
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
@@ -656,8 +702,7 @@ test.describe('Timeline', () => {
|
||||
visibility: 'timeline',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
console.log('Skipping assertion - TODO - fix bug with not removing asset from timeline-manager after unarchive');
|
||||
// await expect(thumbnail.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
});
|
||||
@@ -712,6 +757,50 @@ test.describe('Timeline', () => {
|
||||
await page.getByText('Photos', { exact: true }).click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||
});
|
||||
test('open /favorites, archive photo, unarchive photo', async ({ page }) => {
|
||||
await pageUtils.openFavorites(page);
|
||||
const assetToArchive = getAsset(timelineRestData, 'ad31e29f-2069-4574-b9a9-ad86523c92cb')!;
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
await page.getByLabel('Menu').click();
|
||||
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'archive') {
|
||||
return await route.continue();
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
changes.assetArchivals.push(...requestJson.ids);
|
||||
});
|
||||
await page.getByRole('menuitem').getByText('Archive').click();
|
||||
await expect(archive).resolves.toEqual({
|
||||
visibility: 'archive',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await page.getByRole('link').getByText('Archive').click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'timeline') {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Unarchive').click();
|
||||
await expect(unarchiveRequest).resolves.toEqual({
|
||||
visibility: 'timeline',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||
await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
|
||||
});
|
||||
test('Open album, favorite photo, open /favorites, remove favorite, Open album', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
|
||||
@@ -105,20 +105,16 @@ export const thumbnailUtils = {
|
||||
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, collector));
|
||||
},
|
||||
async expectThumbnailIsFavorite(page: Page, assetId: string) {
|
||||
await expect(
|
||||
thumbnailUtils
|
||||
.withAssetId(page, assetId)
|
||||
.locator(
|
||||
'path[d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z"]',
|
||||
),
|
||||
).toHaveCount(1);
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(1);
|
||||
},
|
||||
async expectThumbnailIsNotFavorite(page: Page, assetId: string) {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(0);
|
||||
},
|
||||
async expectThumbnailIsArchive(page: Page, assetId: string) {
|
||||
await expect(
|
||||
thumbnailUtils
|
||||
.withAssetId(page, assetId)
|
||||
.locator('path[d="M20 21H4V10H6V19H18V10H20V21M3 3H21V9H3V3M5 5V7H19V5M10.5 11V14H8L12 18L16 14H13.5V11"]'),
|
||||
).toHaveCount(1);
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(1);
|
||||
},
|
||||
async expectThumbnailIsNotArchive(page: Page, assetId: string) {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
|
||||
},
|
||||
async expectSelectedReadonly(page: Page, assetId: string) {
|
||||
// todo - need a data attribute for selected
|
||||
@@ -208,10 +204,18 @@ export const pageUtils = {
|
||||
await page.goto(`/photos`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async openFavorites(page: Page) {
|
||||
await page.goto(`/favorites`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async openAlbumPage(page: Page, albumId: string) {
|
||||
await page.goto(`/albums/${albumId}`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async openArchivePage(page: Page) {
|
||||
await page.goto(`/archive`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async deepLinkAlbumPage(page: Page, albumId: string, assetId: string) {
|
||||
await page.goto(`/albums/${albumId}?at=${assetId}`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
|
||||
@@ -54,7 +54,7 @@ test.describe('User Administration', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||
await page.getByText('Admin User').click();
|
||||
await page.getByLabel('Admin User').click();
|
||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
|
||||
@@ -83,7 +83,7 @@ test.describe('User Administration', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||
await page.getByText('Admin User').click();
|
||||
await page.getByLabel('Admin User').click();
|
||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"target": "es2022",
|
||||
"target": "es2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"incremental": true,
|
||||
|
||||
47
i18n/en.json
47
i18n/en.json
@@ -67,6 +67,7 @@
|
||||
"confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.",
|
||||
"confirm_user_password_reset": "Are you sure you want to reset {user}'s password?",
|
||||
"confirm_user_pin_code_reset": "Are you sure you want to reset {user}'s PIN code?",
|
||||
"copy_config_to_clipboard_description": "Copy the current system config as a JSON object to the clipboard",
|
||||
"create_job": "Create job",
|
||||
"cron_expression": "Cron expression",
|
||||
"cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>",
|
||||
@@ -74,6 +75,8 @@
|
||||
"disable_login": "Disable login",
|
||||
"duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search",
|
||||
"exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.",
|
||||
"export_config_as_json_description": "Download the current system config as a JSON file",
|
||||
"external_libraries_page_description": "Admin external library page",
|
||||
"external_library_management": "External Library Management",
|
||||
"face_detection": "Face detection",
|
||||
"face_detection_description": "Detect the faces in assets using machine learning. For videos, only the thumbnail is considered. \"Refresh\" (re-)processes all assets. \"Reset\" additionally clears all current face data. \"Missing\" queues assets that haven't been processed yet. Detected faces will be queued for Facial Recognition after Face Detection is complete, grouping them into existing or new people.",
|
||||
@@ -102,6 +105,7 @@
|
||||
"image_thumbnail_description": "Small thumbnail with stripped metadata, used when viewing groups of photos like the main timeline",
|
||||
"image_thumbnail_quality_description": "Thumbnail quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness.",
|
||||
"image_thumbnail_title": "Thumbnail Settings",
|
||||
"import_config_from_json_description": "Import system config by uploading a JSON config file",
|
||||
"job_concurrency": "{job} concurrency",
|
||||
"job_created": "Job created",
|
||||
"job_not_concurrency_safe": "This job is not concurrency-safe.",
|
||||
@@ -110,6 +114,7 @@
|
||||
"job_status": "Job Status",
|
||||
"jobs_delayed": "{jobCount, plural, other {# delayed}}",
|
||||
"jobs_failed": "{jobCount, plural, other {# failed}}",
|
||||
"jobs_page_description": "Admin jobs page",
|
||||
"library_created": "Created library: {library}",
|
||||
"library_deleted": "Library deleted",
|
||||
"library_details": "Library details",
|
||||
@@ -177,11 +182,21 @@
|
||||
"machine_learning_smart_search_enabled": "Enable smart search",
|
||||
"machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.",
|
||||
"machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.",
|
||||
"maintenance_delete_backup": "Delete Backup",
|
||||
"maintenance_delete_backup_description": "This file will be irrevocably deleted.",
|
||||
"maintenance_delete_error": "Failed to delete backup.",
|
||||
"maintenance_restore_backup": "Restore Backup",
|
||||
"maintenance_restore_backup_description": "Immich will be wiped and restored from the chosen backup. A backup will be created before continuing.",
|
||||
"maintenance_restore_database_backup": "Restore database backup",
|
||||
"maintenance_restore_database_backup_description": "Rollback to an earlier database state using a backup file",
|
||||
"maintenance_settings": "Maintenance",
|
||||
"maintenance_settings_description": "Put Immich into maintenance mode.",
|
||||
"maintenance_start": "Start maintenance mode",
|
||||
"maintenance_start": "Switch to maintenance mode",
|
||||
"maintenance_start_error": "Failed to start maintenance mode.",
|
||||
"maintenance_upload_backup": "Upload database backup file",
|
||||
"maintenance_upload_backup_error": "Could not upload backup, is it an .sql/.sql.gz file?",
|
||||
"manage_concurrency": "Manage Concurrency",
|
||||
"manage_concurrency_description": "Navigate to the jobs page to manage job concurrency",
|
||||
"manage_log_settings": "Manage log settings",
|
||||
"map_dark_style": "Dark style",
|
||||
"map_enable_description": "Enable map features",
|
||||
@@ -287,8 +302,10 @@
|
||||
"server_public_users_description": "All users (name and email) are listed when adding a user to shared albums. When disabled, the user list will only be available to admin users.",
|
||||
"server_settings": "Server Settings",
|
||||
"server_settings_description": "Manage server settings",
|
||||
"server_stats_page_description": "Admin server statistics page",
|
||||
"server_welcome_message": "Welcome message",
|
||||
"server_welcome_message_description": "A message that is displayed on the login page.",
|
||||
"settings_page_description": "Admin settings page",
|
||||
"sidecar_job": "Sidecar metadata",
|
||||
"sidecar_job_description": "Discover or synchronize sidecar metadata from the filesystem",
|
||||
"slideshow_duration_description": "Number of seconds to display each image",
|
||||
@@ -407,6 +424,8 @@
|
||||
"user_restore_scheduled_removal": "Restore user - scheduled removal on {date, date, long}",
|
||||
"user_settings": "User Settings",
|
||||
"user_settings_description": "Manage user settings",
|
||||
"user_successfully_removed": "User {email} has been successfully removed.",
|
||||
"users_page_description": "Admin users page",
|
||||
"version_check_enabled_description": "Enable version check",
|
||||
"version_check_implications": "The version check feature relies on periodic communication with github.com",
|
||||
"version_check_settings": "Version Check",
|
||||
@@ -727,6 +746,7 @@
|
||||
"collapse_all": "Collapse all",
|
||||
"color": "Color",
|
||||
"color_theme": "Color theme",
|
||||
"command": "Command",
|
||||
"comment_deleted": "Comment deleted",
|
||||
"comment_options": "Comment options",
|
||||
"comments_and_likes": "Comments & likes",
|
||||
@@ -787,6 +807,12 @@
|
||||
"create_user": "Create user",
|
||||
"created": "Created",
|
||||
"created_at": "Created",
|
||||
"created_day_ago": "Created 1 day ago",
|
||||
"created_days_ago": "Created {count} days ago",
|
||||
"created_hour_ago": "Created 1 hour ago",
|
||||
"created_hours_ago": "Created {count} hours ago",
|
||||
"created_minute_ago": "Created 1 minute ago",
|
||||
"created_minutes_ago": "Created {count} minutes ago",
|
||||
"creating_linked_albums": "Creating linked albums...",
|
||||
"crop": "Crop",
|
||||
"curated_object_page_title": "Things",
|
||||
@@ -1324,10 +1350,26 @@
|
||||
"loop_videos_description": "Enable to automatically loop a video in the detail viewer.",
|
||||
"main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
|
||||
"main_menu": "Main menu",
|
||||
"maintenance_action_restore": "Restoring Database",
|
||||
"maintenance_description": "Immich has been put into <link>maintenance mode</link>.",
|
||||
"maintenance_end": "End maintenance mode",
|
||||
"maintenance_end_error": "Failed to end maintenance mode.",
|
||||
"maintenance_logged_in_as": "Currently logged in as {user}",
|
||||
"maintenance_restore_from_backup": "Restore From Backup",
|
||||
"maintenance_restore_library": "Restore Your Library",
|
||||
"maintenance_restore_library_confirm": "If this looks correct, continue to restoring a backup!",
|
||||
"maintenance_restore_library_description": "Restoring Database",
|
||||
"maintenance_restore_library_folder_has_files": "{folder} has {count} folder(s)",
|
||||
"maintenance_restore_library_folder_no_files": "{folder} is missing files!",
|
||||
"maintenance_restore_library_folder_pass": "readable and writable",
|
||||
"maintenance_restore_library_folder_read_fail": "not readable",
|
||||
"maintenance_restore_library_folder_write_fail": "not writable",
|
||||
"maintenance_restore_library_hint_missing_files": "You may be missing important files",
|
||||
"maintenance_restore_library_hint_regenerate_later": "You can regenerate these later in settings",
|
||||
"maintenance_restore_library_hint_storage_template_missing_files": "Using storage template? You may be missing files",
|
||||
"maintenance_restore_library_loading": "Loading integrity checks and heuristics…",
|
||||
"maintenance_task_backup": "Creating a backup of the existing database…",
|
||||
"maintenance_task_restore": "Restoring the chosen backup…",
|
||||
"maintenance_title": "Temporarily Unavailable",
|
||||
"make": "Make",
|
||||
"manage_geolocation": "Manage location",
|
||||
@@ -1511,6 +1553,7 @@
|
||||
"other_variables": "Other variables",
|
||||
"owned": "Owned",
|
||||
"owner": "Owner",
|
||||
"page": "Page",
|
||||
"partner": "Partner",
|
||||
"partner_can_access": "{partner} can access",
|
||||
"partner_can_access_assets": "All your photos and videos except those in Archived and Deleted",
|
||||
@@ -2071,6 +2114,7 @@
|
||||
"to_select": "to select",
|
||||
"to_trash": "Trash",
|
||||
"toggle_settings": "Toggle settings",
|
||||
"toggle_theme_description": "Toggle theme",
|
||||
"total": "Total",
|
||||
"total_usage": "Total usage",
|
||||
"trash": "Trash",
|
||||
@@ -2179,6 +2223,7 @@
|
||||
"view_album": "View Album",
|
||||
"view_all": "View All",
|
||||
"view_all_users": "View all users",
|
||||
"view_asset_owners": "View asset owners",
|
||||
"view_details": "View Details",
|
||||
"view_in_timeline": "View in timeline",
|
||||
"view_link": "View link",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
ARG DEVICE=cpu
|
||||
|
||||
FROM python:3.11-bookworm@sha256:fc1f2e357c307c4044133952b203e66a47e7726821a664f603a180a0c5823844 AS builder-cpu
|
||||
FROM python:3.11-bookworm@sha256:e39286476f84ffedf7c3564b0b74e32c9e1193ec9ca32ee8a11f8c09dbf6aafe AS builder-cpu
|
||||
|
||||
FROM builder-cpu AS builder-openvino
|
||||
|
||||
@@ -22,10 +22,10 @@ FROM builder-cpu AS builder-rknn
|
||||
|
||||
# Warning: 25GiB+ disk space required to pull this image
|
||||
# TODO: find a way to reduce the image size
|
||||
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS builder-rocm
|
||||
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS builder-rocm
|
||||
|
||||
# renovate: datasource=github-releases depName=Microsoft/onnxruntime
|
||||
ARG ONNXRUNTIME_VERSION="v1.20.1"
|
||||
ARG ONNXRUNTIME_VERSION="v1.22.1"
|
||||
WORKDIR /code
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wget git python3.10-venv
|
||||
@@ -68,12 +68,12 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
|
||||
uv pip install /opt/onnxruntime_rocm-*.whl; \
|
||||
fi
|
||||
|
||||
FROM python:3.11-slim-bookworm@sha256:873f91540d53b36327ed4fb018c9669107a4e2a676719720edb4209c4b15d029 AS prod-cpu
|
||||
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-cpu
|
||||
|
||||
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
|
||||
MACHINE_LEARNING_MODEL_ARENA=false
|
||||
|
||||
FROM python:3.11-slim-bookworm@sha256:873f91540d53b36327ed4fb018c9669107a4e2a676719720edb4209c4b15d029 AS prod-openvino
|
||||
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-openvino
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||
@@ -102,7 +102,7 @@ COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3
|
||||
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
|
||||
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so
|
||||
|
||||
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS prod-rocm
|
||||
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS prod-rocm
|
||||
|
||||
FROM prod-cpu AS prod-armnn
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ class TextDetector(InferenceModel):
|
||||
ratio = float(self.max_resolution) / img.height
|
||||
else:
|
||||
ratio = float(self.max_resolution) / img.width
|
||||
ratio = min(ratio, 1.0)
|
||||
|
||||
resize_h = int(img.height * ratio)
|
||||
resize_w = int(img.width * ratio)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt
|
||||
index d90a2a355..bb1a7de12 100644
|
||||
index 2714e6f59..a69da76b4 100644
|
||||
--- a/cmake/CMakeLists.txt
|
||||
+++ b/cmake/CMakeLists.txt
|
||||
@@ -295,7 +295,7 @@ if (onnxruntime_USE_ROCM)
|
||||
@@ -338,7 +338,7 @@ if (onnxruntime_USE_ROCM)
|
||||
if (ROCM_VERSION_DEV VERSION_LESS "6.2")
|
||||
message(FATAL_ERROR "CMAKE_HIP_ARCHITECTURES is not set when ROCm version < 6.2")
|
||||
else()
|
||||
- set(CMAKE_HIP_ARCHITECTURES "gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx940;gfx941;gfx942;gfx1200;gfx1201")
|
||||
+ set(CMAKE_HIP_ARCHITECTURES "gfx900;gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx1102;gfx940;gfx941;gfx942;gfx1200;gfx1201")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if (NOT CMAKE_HIP_ARCHITECTURES)
|
||||
- set(CMAKE_HIP_ARCHITECTURES "gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx940;gfx941;gfx942;gfx1200;gfx1201")
|
||||
+ set(CMAKE_HIP_ARCHITECTURES "gfx900;gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx1102;gfx940;gfx941;gfx942;gfx1200;gfx1201")
|
||||
endif()
|
||||
|
||||
file(GLOB rocm_cmake_components ${onnxruntime_ROCM_HOME}/lib/cmake/*)
|
||||
|
||||
|
||||
3598
machine-learning/uv.lock
generated
3598
machine-learning/uv.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
||||
experimental_monorepo_root = true
|
||||
|
||||
[tools]
|
||||
node = "24.11.0"
|
||||
node = "24.11.1"
|
||||
flutter = "3.35.7"
|
||||
pnpm = "10.20.0"
|
||||
terragrunt = "0.91.2"
|
||||
opentofu = "1.10.6"
|
||||
pnpm = "10.24.0"
|
||||
terragrunt = "0.93.10"
|
||||
opentofu = "1.10.7"
|
||||
java = "25.0.1"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
version = "1.30.0"
|
||||
|
||||
32
mobile/lib/domain/models/events.model.dart
Normal file
32
mobile/lib/domain/models/events.model.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
|
||||
// Timeline Events
|
||||
class TimelineReloadEvent extends Event {
|
||||
const TimelineReloadEvent();
|
||||
}
|
||||
|
||||
class ScrollToTopEvent extends Event {
|
||||
const ScrollToTopEvent();
|
||||
}
|
||||
|
||||
class ScrollToDateEvent extends Event {
|
||||
final DateTime date;
|
||||
|
||||
const ScrollToDateEvent(this.date);
|
||||
}
|
||||
|
||||
// Asset Viewer Events
|
||||
class ViewerOpenBottomSheetEvent extends Event {
|
||||
final bool activitiesMode;
|
||||
const ViewerOpenBottomSheetEvent({this.activitiesMode = false});
|
||||
}
|
||||
|
||||
class ViewerReloadAssetEvent extends Event {
|
||||
const ViewerReloadAssetEvent();
|
||||
}
|
||||
|
||||
// Multi-Select Events
|
||||
class MultiSelectToggleEvent extends Event {
|
||||
final bool isEnabled;
|
||||
const MultiSelectToggleEvent(this.isEnabled);
|
||||
}
|
||||
@@ -71,6 +71,7 @@ enum StoreKey<T> {
|
||||
readonlyModeEnabled<bool>._(138),
|
||||
|
||||
autoPlayVideo<bool>._(139),
|
||||
albumGridView<bool>._(140),
|
||||
|
||||
// Experimental stuff
|
||||
photoManagerCustomFilter<bool>._(1000),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
|
||||
enum GroupAssetsBy { day, month, auto, none }
|
||||
|
||||
enum HeaderType { none, month, day, monthAndDay }
|
||||
@@ -31,17 +29,3 @@ class TimeBucket extends Bucket {
|
||||
@override
|
||||
int get hashCode => super.hashCode ^ date.hashCode;
|
||||
}
|
||||
|
||||
class TimelineReloadEvent extends Event {
|
||||
const TimelineReloadEvent();
|
||||
}
|
||||
|
||||
class ScrollToTopEvent extends Event {
|
||||
const ScrollToTopEvent();
|
||||
}
|
||||
|
||||
class ScrollToDateEvent extends Event {
|
||||
final DateTime date;
|
||||
|
||||
const ScrollToDateEvent(this.date);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,20 @@ class AssetService {
|
||||
isFlipped = false;
|
||||
}
|
||||
|
||||
if (width == null || height == null) {
|
||||
if (asset.hasRemote) {
|
||||
final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id;
|
||||
final remoteAsset = await _remoteAssetRepository.get(id);
|
||||
width = remoteAsset?.width?.toDouble();
|
||||
height = remoteAsset?.height?.toDouble();
|
||||
} else {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||
final localAsset = await _localAssetRepository.get(id);
|
||||
width = localAsset?.width?.toDouble();
|
||||
height = localAsset?.height?.toDouble();
|
||||
}
|
||||
}
|
||||
|
||||
final orientedWidth = isFlipped ? height : width;
|
||||
final orientedHeight = isFlipped ? width : height;
|
||||
if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) {
|
||||
|
||||
@@ -363,14 +363,14 @@ extension on Iterable<PlatformAsset> {
|
||||
}
|
||||
}
|
||||
|
||||
extension on PlatformAsset {
|
||||
extension PlatformToLocalAsset on PlatformAsset {
|
||||
LocalAsset toLocalAsset() => LocalAsset(
|
||||
id: id,
|
||||
name: name,
|
||||
checksum: null,
|
||||
type: AssetType.values.elementAtOrNull(type) ?? AssetType.other,
|
||||
createdAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
|
||||
updatedAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
|
||||
updatedAt: tryFromSecondsSinceEpoch(updatedAt, isUtc: true) ?? DateTime.timestamp(),
|
||||
width: width,
|
||||
height: height,
|
||||
durationInSeconds: durationInSeconds,
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
class RemoteAlbumService {
|
||||
final DriftRemoteAlbumRepository _repository;
|
||||
@@ -32,16 +33,16 @@ class RemoteAlbumService {
|
||||
|
||||
Future<List<RemoteAlbum>> sortAlbums(
|
||||
List<RemoteAlbum> albums,
|
||||
RemoteAlbumSortMode sortMode, {
|
||||
AlbumSortMode sortMode, {
|
||||
bool isReverse = false,
|
||||
}) async {
|
||||
final List<RemoteAlbum> sorted = switch (sortMode) {
|
||||
RemoteAlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
|
||||
RemoteAlbumSortMode.title => albums.sortedBy((album) => album.name),
|
||||
RemoteAlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
|
||||
RemoteAlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
|
||||
RemoteAlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
|
||||
RemoteAlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
|
||||
AlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
|
||||
AlbumSortMode.title => albums.sortedBy((album) => album.name),
|
||||
AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
|
||||
AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
|
||||
AlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
|
||||
AlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
|
||||
};
|
||||
|
||||
return (isReverse ? sorted.reversed : sorted).toList();
|
||||
@@ -211,16 +212,3 @@ class RemoteAlbumService {
|
||||
return sorted.reversed.toList();
|
||||
}
|
||||
}
|
||||
|
||||
enum RemoteAlbumSortMode {
|
||||
title("library_page_sort_title"),
|
||||
assetCount("library_page_sort_asset_count"),
|
||||
lastModified("library_page_sort_last_modified"),
|
||||
created("library_page_sort_created"),
|
||||
mostRecent("sort_newest"),
|
||||
mostOldest("sort_oldest");
|
||||
|
||||
final String key;
|
||||
|
||||
const RemoteAlbumSortMode(this.key);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:math' as math;
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:timezone/timezone.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
|
||||
extension TZExtension on Asset {
|
||||
/// Returns the created time of the asset from the exif info (if available) or from
|
||||
@@ -7,24 +7,11 @@ extension TZExtension on Asset {
|
||||
/// the timezone offset in [Duration]
|
||||
(DateTime, Duration) getTZAdjustedTimeAndOffset() {
|
||||
DateTime dt = fileCreatedAt.toLocal();
|
||||
|
||||
if (exifInfo?.dateTimeOriginal != null) {
|
||||
dt = exifInfo!.dateTimeOriginal!;
|
||||
if (exifInfo?.timeZone != null) {
|
||||
dt = dt.toUtc();
|
||||
try {
|
||||
final location = getLocation(exifInfo!.timeZone!);
|
||||
dt = TZDateTime.from(dt, location);
|
||||
} on LocationNotFoundException {
|
||||
RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false);
|
||||
final m = re.firstMatch(exifInfo!.timeZone!);
|
||||
if (m != null) {
|
||||
final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0'));
|
||||
dt = dt.add(duration);
|
||||
return (dt, duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
return applyTimezoneOffset(dateTime: exifInfo!.dateTimeOriginal!, timeZone: exifInfo?.timeZone);
|
||||
}
|
||||
|
||||
return (dt, dt.timeZoneOffset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
|
||||
@@ -58,7 +58,7 @@ class SettingsPage extends StatelessWidget {
|
||||
context.locale;
|
||||
return Scaffold(
|
||||
appBar: AppBar(centerTitle: false, title: const Text('settings').tr()),
|
||||
body: context.isMobile ? const SafeArea(child: _MobileLayout()) : const SafeArea(child: _TabletLayout()),
|
||||
body: context.isMobile ? const _MobileLayout() : const _TabletLayout(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -89,11 +89,7 @@ class _MobileLayout extends StatelessWidget {
|
||||
],
|
||||
)
|
||||
.toList();
|
||||
return ListView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
padding: const EdgeInsets.only(top: 10.0, bottom: 16),
|
||||
children: [...settings],
|
||||
);
|
||||
return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 16), children: [...settings]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
|
||||
@@ -16,7 +16,6 @@ import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
|
||||
import 'package:immich_mobile/providers/tab.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
@RoutePage()
|
||||
|
||||
@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
@@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
@@ -9,8 +9,8 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
|
||||
// used to allow performing unarchive action from different sources (without duplicating code)
|
||||
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
@@ -17,6 +16,9 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/album_filter.utils.dart';
|
||||
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||
@@ -45,14 +47,28 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
List<RemoteAlbum> shownAlbums = [];
|
||||
|
||||
AlbumFilter filter = AlbumFilter(query: "", mode: QuickFilterMode.all);
|
||||
AlbumSort sort = AlbumSort(mode: RemoteAlbumSortMode.lastModified, isReverse: true);
|
||||
AlbumSort sort = AlbumSort(mode: AlbumSortMode.lastModified, isReverse: true);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Load albums when component mounts
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final appSettings = ref.read(appSettingsServiceProvider);
|
||||
final savedSortMode = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortOrder);
|
||||
final savedIsReverse = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortReverse);
|
||||
final savedIsGrid = appSettings.getSetting(AppSettingsEnum.albumGridView);
|
||||
|
||||
final albumSortMode = AlbumSortMode.values.firstWhere(
|
||||
(e) => e.storeIndex == savedSortMode,
|
||||
orElse: () => AlbumSortMode.lastModified,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
sort = AlbumSort(mode: albumSortMode, isReverse: savedIsReverse);
|
||||
isGrid = savedIsGrid;
|
||||
});
|
||||
|
||||
ref.read(remoteAlbumProvider.notifier).refresh();
|
||||
});
|
||||
|
||||
@@ -82,6 +98,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
setState(() {
|
||||
isGrid = !isGrid;
|
||||
});
|
||||
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.albumGridView, isGrid);
|
||||
}
|
||||
|
||||
void changeFilter(QuickFilterMode mode) {
|
||||
@@ -97,6 +114,10 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
this.sort = sort;
|
||||
});
|
||||
|
||||
final appSettings = ref.read(appSettingsServiceProvider);
|
||||
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortOrder, sort.mode.storeIndex);
|
||||
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortReverse, sort.isReverse);
|
||||
|
||||
await sortAlbums();
|
||||
}
|
||||
|
||||
@@ -181,6 +202,8 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
onToggleViewMode: toggleViewMode,
|
||||
onSortChanged: changeSort,
|
||||
controller: menuController,
|
||||
currentSortMode: sort.mode,
|
||||
currentIsReverse: sort.isReverse,
|
||||
),
|
||||
isGrid
|
||||
? _AlbumGrid(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected)
|
||||
@@ -192,21 +215,46 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
}
|
||||
|
||||
class _SortButton extends ConsumerStatefulWidget {
|
||||
const _SortButton(this.onSortChanged, {this.controller});
|
||||
const _SortButton(
|
||||
this.onSortChanged, {
|
||||
required this.initialSortMode,
|
||||
required this.initialIsReverse,
|
||||
this.controller,
|
||||
});
|
||||
|
||||
final Future<void> Function(AlbumSort) onSortChanged;
|
||||
final MenuController? controller;
|
||||
final AlbumSortMode initialSortMode;
|
||||
final bool initialIsReverse;
|
||||
|
||||
@override
|
||||
ConsumerState<_SortButton> createState() => _SortButtonState();
|
||||
}
|
||||
|
||||
class _SortButtonState extends ConsumerState<_SortButton> {
|
||||
RemoteAlbumSortMode albumSortOption = RemoteAlbumSortMode.lastModified;
|
||||
bool albumSortIsReverse = true;
|
||||
late AlbumSortMode albumSortOption;
|
||||
late bool albumSortIsReverse;
|
||||
bool isSorting = false;
|
||||
|
||||
Future<void> onMenuTapped(RemoteAlbumSortMode sortMode) async {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
albumSortOption = widget.initialSortMode;
|
||||
albumSortIsReverse = widget.initialIsReverse;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_SortButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.initialSortMode != widget.initialSortMode || oldWidget.initialIsReverse != widget.initialIsReverse) {
|
||||
setState(() {
|
||||
albumSortOption = widget.initialSortMode;
|
||||
albumSortIsReverse = widget.initialIsReverse;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onMenuTapped(AlbumSortMode sortMode) async {
|
||||
final selected = albumSortOption == sortMode;
|
||||
// Switch direction
|
||||
if (selected) {
|
||||
@@ -240,7 +288,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
|
||||
padding: const WidgetStatePropertyAll(EdgeInsets.all(4)),
|
||||
),
|
||||
consumeOutsideTap: true,
|
||||
menuChildren: RemoteAlbumSortMode.values
|
||||
menuChildren: AlbumSortMode.values
|
||||
.map(
|
||||
(sortMode) => MenuItemButton(
|
||||
leadingIcon: albumSortOption == sortMode
|
||||
@@ -269,7 +317,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
sortMode.key.t(context: context),
|
||||
sortMode.label.t(context: context),
|
||||
style: context.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: albumSortOption == sortMode
|
||||
@@ -298,7 +346,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
|
||||
: const Icon(Icons.keyboard_arrow_up_rounded),
|
||||
),
|
||||
Text(
|
||||
albumSortOption.key.t(context: context),
|
||||
albumSortOption.label.t(context: context),
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: context.colorScheme.onSurface.withAlpha(225),
|
||||
@@ -465,6 +513,8 @@ class _QuickSortAndViewMode extends StatelessWidget {
|
||||
required this.isGrid,
|
||||
required this.onToggleViewMode,
|
||||
required this.onSortChanged,
|
||||
required this.currentSortMode,
|
||||
required this.currentIsReverse,
|
||||
this.controller,
|
||||
});
|
||||
|
||||
@@ -472,6 +522,8 @@ class _QuickSortAndViewMode extends StatelessWidget {
|
||||
final VoidCallback onToggleViewMode;
|
||||
final MenuController? controller;
|
||||
final Future<void> Function(AlbumSort) onSortChanged;
|
||||
final AlbumSortMode currentSortMode;
|
||||
final bool currentIsReverse;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -481,7 +533,12 @@ class _QuickSortAndViewMode extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_SortButton(onSortChanged, controller: controller),
|
||||
_SortButton(
|
||||
onSortChanged,
|
||||
controller: controller,
|
||||
initialSortMode: currentSortMode,
|
||||
initialIsReverse: currentIsReverse,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24),
|
||||
onPressed: onToggleViewMode,
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
class ViewerOpenBottomSheetEvent extends Event {
|
||||
final bool activitiesMode;
|
||||
const ViewerOpenBottomSheetEvent({this.activitiesMode = false});
|
||||
}
|
||||
|
||||
class ViewerReloadAssetEvent extends Event {
|
||||
const ViewerReloadAssetEvent();
|
||||
}
|
||||
|
||||
class AssetViewerState {
|
||||
final int backgroundOpacity;
|
||||
final bool showingBottomSheet;
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
@@ -29,6 +30,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/action_button.utils.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
const _kSeparator = ' • ';
|
||||
@@ -85,13 +87,21 @@ class AssetDetailBottomSheet extends ConsumerWidget {
|
||||
class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
const _AssetDetailBottomSheet();
|
||||
|
||||
String _getDateTime(BuildContext ctx, BaseAsset asset) {
|
||||
final dateTime = asset.createdAt.toLocal();
|
||||
String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) {
|
||||
DateTime dateTime = asset.createdAt.toLocal();
|
||||
Duration timeZoneOffset = dateTime.timeZoneOffset;
|
||||
|
||||
// Use EXIF timezone information if available (matching web app behavior)
|
||||
if (exifInfo?.dateTimeOriginal != null) {
|
||||
(dateTime, timeZoneOffset) = applyTimezoneOffset(
|
||||
dateTime: exifInfo!.dateTimeOriginal!,
|
||||
timeZone: exifInfo.timeZone,
|
||||
);
|
||||
}
|
||||
|
||||
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
|
||||
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
|
||||
final timezone = dateTime.timeZoneOffset.isNegative
|
||||
? 'UTC-${dateTime.timeZoneOffset.inHours.abs().toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}'
|
||||
: 'UTC+${dateTime.timeZoneOffset.inHours.toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}';
|
||||
final timezone = 'GMT${timeZoneOffset.formatAsOffset()}';
|
||||
return '$date$_kSeparator$time $timezone';
|
||||
}
|
||||
|
||||
@@ -269,7 +279,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
children: [
|
||||
// Asset Date and Time
|
||||
SheetTile(
|
||||
title: _getDateTime(context, asset),
|
||||
title: _getDateTime(context, asset, exifInfo),
|
||||
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
|
||||
onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
@@ -143,11 +143,13 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"enable_backup".t(context: context),
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.primaryColor,
|
||||
Flexible(
|
||||
child: Text(
|
||||
"enable_backup".t(context: context),
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
@@ -70,7 +71,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
|
||||
Future<List<RemoteAlbum>> sortAlbums(
|
||||
List<RemoteAlbum> albums,
|
||||
RemoteAlbumSortMode sortMode, {
|
||||
AlbumSortMode sortMode, {
|
||||
bool isReverse = false,
|
||||
}) async {
|
||||
return await _remoteAlbumService.sortAlbums(albums, sortMode, isReverse: isReverse);
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
final multiSelectProvider = NotifierProvider<MultiSelectNotifier, MultiSelectState>(
|
||||
@@ -10,11 +9,6 @@ final multiSelectProvider = NotifierProvider<MultiSelectNotifier, MultiSelectSta
|
||||
dependencies: [timelineServiceProvider],
|
||||
);
|
||||
|
||||
class MultiSelectToggleEvent extends Event {
|
||||
final bool isEnabled;
|
||||
const MultiSelectToggleEvent(this.isEnabled);
|
||||
}
|
||||
|
||||
class MultiSelectState {
|
||||
final Set<BaseAsset> selectedAssets;
|
||||
final Set<BaseAsset> lockedSelectionAssets;
|
||||
|
||||
@@ -245,23 +245,15 @@ class AppRouter extends RootStackRouter {
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
transitionsBuilder: TransitionsBuilders.slideLeft,
|
||||
),
|
||||
CustomRoute(page: FolderRoute.page, guards: [_authGuard], transitionsBuilder: TransitionsBuilders.fadeIn),
|
||||
AutoRoute(page: FolderRoute.page, guards: [_authGuard]),
|
||||
AutoRoute(page: PartnerDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: PersonResultRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: AllPeopleRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: MemoryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: MapRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: AlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
CustomRoute(
|
||||
page: TrashRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
transitionsBuilder: TransitionsBuilders.slideLeft,
|
||||
),
|
||||
CustomRoute(
|
||||
page: SharedLinkRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
transitionsBuilder: TransitionsBuilders.slideLeft,
|
||||
),
|
||||
AutoRoute(page: TrashRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: SharedLinkRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: SharedLinkEditRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
CustomRoute(
|
||||
page: ActivitiesRoute.page,
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/download.repository.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
|
||||
import 'package:immich_mobile/widgets/common/location_picker.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
|
||||
@@ -175,9 +176,17 @@ class ActionService {
|
||||
}
|
||||
|
||||
final exifData = await _remoteAssetRepository.getExif(assetId);
|
||||
initialDate = asset.createdAt.toLocal();
|
||||
offset = initialDate.timeZoneOffset;
|
||||
timeZone = exifData?.timeZone;
|
||||
|
||||
// Use EXIF timezone information if available (matching web app and display behavior)
|
||||
DateTime dt = asset.createdAt.toLocal();
|
||||
offset = dt.timeZoneOffset;
|
||||
|
||||
if (exifData?.dateTimeOriginal != null) {
|
||||
timeZone = exifData!.timeZone;
|
||||
(dt, offset) = applyTimezoneOffset(dateTime: exifData.dateTimeOriginal!, timeZone: exifData.timeZone);
|
||||
}
|
||||
|
||||
initialDate = dt;
|
||||
}
|
||||
|
||||
final dateTime = await showDateTimePicker(
|
||||
|
||||
@@ -51,9 +51,10 @@ enum AppSettingsEnum<T> {
|
||||
enableBackup<bool>(StoreKey.enableBackup, null, false),
|
||||
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
|
||||
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
|
||||
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
|
||||
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false),
|
||||
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
|
||||
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30),
|
||||
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
|
||||
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30);
|
||||
|
||||
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
class AlbumFilter {
|
||||
String? userId;
|
||||
@@ -14,12 +14,12 @@ class AlbumFilter {
|
||||
}
|
||||
|
||||
class AlbumSort {
|
||||
RemoteAlbumSortMode mode;
|
||||
AlbumSortMode mode;
|
||||
bool isReverse;
|
||||
|
||||
AlbumSort({required this.mode, this.isReverse = false});
|
||||
|
||||
AlbumSort copyWith({RemoteAlbumSortMode? mode, bool? isReverse}) {
|
||||
AlbumSort copyWith({AlbumSortMode? mode, bool? isReverse}) {
|
||||
return AlbumSort(mode: mode ?? this.mode, isReverse: isReverse ?? this.isReverse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,14 +22,16 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
// ignore: import_rule_photo_manager
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
const int targetVersion = 18;
|
||||
const int targetVersion = 19;
|
||||
|
||||
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
final hasVersion = Store.tryGet(StoreKey.version) != null;
|
||||
@@ -78,6 +80,12 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
await Store.put(StoreKey.shouldResetSync, true);
|
||||
}
|
||||
|
||||
if (version < 19 && Store.isBetaTimelineEnabled) {
|
||||
if (!await _populateUpdatedAtTime(drift)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetVersion >= 12) {
|
||||
await Store.put(StoreKey.version, targetVersion);
|
||||
return;
|
||||
@@ -221,6 +229,32 @@ Future<void> _migrateDeviceAsset(Isar db) async {
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _populateUpdatedAtTime(Drift db) async {
|
||||
try {
|
||||
final nativeApi = NativeSyncApi();
|
||||
final albums = await nativeApi.getAlbums();
|
||||
for (final album in albums) {
|
||||
final assets = await nativeApi.getAssetsForAlbum(album.id);
|
||||
await db.batch((batch) async {
|
||||
for (final asset in assets) {
|
||||
batch.update(
|
||||
db.localAssetEntity,
|
||||
LocalAssetEntityCompanion(
|
||||
updatedAt: Value(tryFromSecondsSinceEpoch(asset.updatedAt, isUtc: true) ?? DateTime.timestamp()),
|
||||
),
|
||||
where: (t) => t.id.equals(asset.id),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
dPrint(() => "[MIGRATION] Error while populating updatedAt time: $error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> migrateDeviceAssetToSqlite(Isar db, Drift drift) async {
|
||||
try {
|
||||
final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll();
|
||||
|
||||
35
mobile/lib/utils/timezone.dart
Normal file
35
mobile/lib/utils/timezone.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:timezone/timezone.dart';
|
||||
|
||||
/// Applies timezone conversion to a DateTime using EXIF timezone information.
|
||||
///
|
||||
/// This function handles two timezone formats:
|
||||
/// 1. Named timezone locations (e.g., "Asia/Hong_Kong")
|
||||
/// 2. UTC offset format (e.g., "UTC+08:00", "UTC-05:00")
|
||||
///
|
||||
/// Returns a tuple of (adjusted DateTime, timezone offset Duration)
|
||||
(DateTime, Duration) applyTimezoneOffset({required DateTime dateTime, required String? timeZone}) {
|
||||
DateTime dt = dateTime.toUtc();
|
||||
|
||||
if (timeZone == null) {
|
||||
return (dt, dt.timeZoneOffset);
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get timezone location from database
|
||||
final location = getLocation(timeZone);
|
||||
dt = TZDateTime.from(dt, location);
|
||||
return (dt, dt.timeZoneOffset);
|
||||
} on LocationNotFoundException {
|
||||
// Handle UTC offset format (e.g., "UTC+08:00")
|
||||
RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false);
|
||||
final m = re.firstMatch(timeZone);
|
||||
if (m != null) {
|
||||
final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0'));
|
||||
dt = dt.add(duration);
|
||||
return (dt, duration);
|
||||
}
|
||||
}
|
||||
|
||||
// If timezone is invalid, return UTC
|
||||
return (dt, dt.timeZoneOffset);
|
||||
}
|
||||
@@ -193,7 +193,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
||||
InkWell(
|
||||
onTap: () {
|
||||
context.pop();
|
||||
launchUrl(Uri.parse('https://immich.app'), mode: LaunchMode.externalApplication);
|
||||
launchUrl(Uri.parse('https://docs.immich.app'), mode: LaunchMode.externalApplication);
|
||||
},
|
||||
child: Text("documentation", style: context.textTheme.bodySmall).tr(),
|
||||
),
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
@@ -6,8 +6,8 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/models/person.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
@@ -108,82 +108,80 @@ class SyncStatusAndActions extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 32),
|
||||
child: ListView(
|
||||
children: [
|
||||
const _SyncStatsCounts(),
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
const SizedBox(height: 24),
|
||||
_SectionHeaderText(text: "jobs".t(context: context)),
|
||||
ListTile(
|
||||
title: Text(
|
||||
"sync_local".t(context: context),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text("tap_to_run_job".t(context: context)),
|
||||
leading: const Icon(Icons.sync),
|
||||
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).localSyncStatus),
|
||||
onTap: () {
|
||||
ref.read(backgroundSyncProvider).syncLocal(full: true);
|
||||
},
|
||||
return ListView(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 96),
|
||||
children: [
|
||||
const _SyncStatsCounts(),
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
const SizedBox(height: 24),
|
||||
_SectionHeaderText(text: "jobs".t(context: context)),
|
||||
ListTile(
|
||||
title: Text(
|
||||
"sync_local".t(context: context),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(
|
||||
"sync_remote".t(context: context),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text("tap_to_run_job".t(context: context)),
|
||||
leading: const Icon(Icons.cloud_sync),
|
||||
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).remoteSyncStatus),
|
||||
onTap: () {
|
||||
ref.read(backgroundSyncProvider).syncRemote();
|
||||
},
|
||||
subtitle: Text("tap_to_run_job".t(context: context)),
|
||||
leading: const Icon(Icons.sync),
|
||||
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).localSyncStatus),
|
||||
onTap: () {
|
||||
ref.read(backgroundSyncProvider).syncLocal(full: true);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text(
|
||||
"sync_remote".t(context: context),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(
|
||||
"hash_asset".t(context: context),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
leading: const Icon(Icons.tag),
|
||||
subtitle: Text("tap_to_run_job".t(context: context)),
|
||||
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).hashJobStatus),
|
||||
onTap: () {
|
||||
ref.read(backgroundSyncProvider).hashAssets();
|
||||
},
|
||||
subtitle: Text("tap_to_run_job".t(context: context)),
|
||||
leading: const Icon(Icons.cloud_sync),
|
||||
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).remoteSyncStatus),
|
||||
onTap: () {
|
||||
ref.read(backgroundSyncProvider).syncRemote();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text(
|
||||
"hash_asset".t(context: context),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
const SizedBox(height: 24),
|
||||
_SectionHeaderText(text: "actions".t(context: context)),
|
||||
ListTile(
|
||||
title: Text(
|
||||
"clear_file_cache".t(context: context),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
leading: const Icon(Icons.playlist_remove_rounded),
|
||||
onTap: clearFileCache,
|
||||
leading: const Icon(Icons.tag),
|
||||
subtitle: Text("tap_to_run_job".t(context: context)),
|
||||
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).hashJobStatus),
|
||||
onTap: () {
|
||||
ref.read(backgroundSyncProvider).hashAssets();
|
||||
},
|
||||
),
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
const SizedBox(height: 24),
|
||||
_SectionHeaderText(text: "actions".t(context: context)),
|
||||
ListTile(
|
||||
title: Text(
|
||||
"clear_file_cache".t(context: context),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(
|
||||
"export_database".t(context: context),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text("export_database_description".t(context: context)),
|
||||
leading: const Icon(Icons.download),
|
||||
onTap: exportDatabase,
|
||||
leading: const Icon(Icons.playlist_remove_rounded),
|
||||
onTap: clearFileCache,
|
||||
),
|
||||
ListTile(
|
||||
title: Text(
|
||||
"export_database".t(context: context),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(
|
||||
"reset_sqlite".t(context: context),
|
||||
style: TextStyle(color: context.colorScheme.error, fontWeight: FontWeight.w500),
|
||||
),
|
||||
leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error),
|
||||
onTap: () async {
|
||||
await resetSqliteDb(context);
|
||||
},
|
||||
subtitle: Text("export_database_description".t(context: context)),
|
||||
leading: const Icon(Icons.download),
|
||||
onTap: exportDatabase,
|
||||
),
|
||||
ListTile(
|
||||
title: Text(
|
||||
"reset_sqlite".t(context: context),
|
||||
style: TextStyle(color: context.colorScheme.error, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error),
|
||||
onTap: () async {
|
||||
await resetSqliteDb(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,6 @@ class NetworkingSettings extends HookConsumerWidget {
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.only(bottom: 96),
|
||||
physics: const ClampingScrollPhysics(),
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, left: 16, bottom: 8),
|
||||
|
||||
30
mobile/openapi/README.md
generated
30
mobile/openapi/README.md
generated
@@ -133,12 +133,19 @@ Class | Method | HTTP request | Description
|
||||
*AuthenticationApi* | [**unlockAuthSession**](doc//AuthenticationApi.md#unlockauthsession) | **POST** /auth/session/unlock | Unlock auth session
|
||||
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | Validate access token
|
||||
*AuthenticationAdminApi* | [**unlinkAllOAuthAccountsAdmin**](doc//AuthenticationAdminApi.md#unlinkalloauthaccountsadmin) | **POST** /admin/auth/unlink-all | Unlink all OAuth accounts
|
||||
*DatabaseBackupsAdminApi* | [**deleteDatabaseBackup**](doc//DatabaseBackupsAdminApi.md#deletedatabasebackup) | **DELETE** /admin/database-backups | Delete database backup
|
||||
*DatabaseBackupsAdminApi* | [**downloadDatabaseBackup**](doc//DatabaseBackupsAdminApi.md#downloaddatabasebackup) | **GET** /admin/database-backups/{filename} | Download database backup
|
||||
*DatabaseBackupsAdminApi* | [**listDatabaseBackups**](doc//DatabaseBackupsAdminApi.md#listdatabasebackups) | **GET** /admin/database-backups | List database backups
|
||||
*DatabaseBackupsAdminApi* | [**startDatabaseRestoreFlow**](doc//DatabaseBackupsAdminApi.md#startdatabaserestoreflow) | **POST** /admin/database-backups/start-restore | Start database backup restore flow
|
||||
*DatabaseBackupsAdminApi* | [**uploadDatabaseBackup**](doc//DatabaseBackupsAdminApi.md#uploaddatabasebackup) | **POST** /admin/database-backups/upload | Upload database backup
|
||||
*DeprecatedApi* | [**createPartnerDeprecated**](doc//DeprecatedApi.md#createpartnerdeprecated) | **POST** /partners/{id} | Create a partner
|
||||
*DeprecatedApi* | [**getAllUserAssetsByDeviceId**](doc//DeprecatedApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | Retrieve assets by device ID
|
||||
*DeprecatedApi* | [**getDeltaSync**](doc//DeprecatedApi.md#getdeltasync) | **POST** /sync/delta-sync | Get delta sync for user
|
||||
*DeprecatedApi* | [**getFullSyncForUser**](doc//DeprecatedApi.md#getfullsyncforuser) | **POST** /sync/full-sync | Get full sync for user
|
||||
*DeprecatedApi* | [**getQueuesLegacy**](doc//DeprecatedApi.md#getqueueslegacy) | **GET** /jobs | Retrieve queue counts and status
|
||||
*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | Get random assets
|
||||
*DeprecatedApi* | [**replaceAsset**](doc//DeprecatedApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace asset
|
||||
*DeprecatedApi* | [**runQueueCommandLegacy**](doc//DeprecatedApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs
|
||||
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | Download asset archive
|
||||
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | Retrieve download information
|
||||
*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Delete a duplicate
|
||||
@@ -159,6 +166,8 @@ Class | Method | HTTP request | Description
|
||||
*LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | Scan a library
|
||||
*LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | Update a library
|
||||
*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | Validate library settings
|
||||
*MaintenanceAdminApi* | [**detectPriorInstall**](doc//MaintenanceAdminApi.md#detectpriorinstall) | **GET** /admin/maintenance/detect-install | Detect existing install
|
||||
*MaintenanceAdminApi* | [**getMaintenanceStatus**](doc//MaintenanceAdminApi.md#getmaintenancestatus) | **GET** /admin/maintenance/status | Get maintenance mode status
|
||||
*MaintenanceAdminApi* | [**maintenanceLogin**](doc//MaintenanceAdminApi.md#maintenancelogin) | **POST** /admin/maintenance/login | Log into maintenance mode
|
||||
*MaintenanceAdminApi* | [**setMaintenanceMode**](doc//MaintenanceAdminApi.md#setmaintenancemode) | **POST** /admin/maintenance | Set maintenance mode
|
||||
*MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | Retrieve map markers
|
||||
@@ -198,6 +207,11 @@ Class | Method | HTTP request | Description
|
||||
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
|
||||
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
|
||||
*PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins
|
||||
*QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue
|
||||
*QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue
|
||||
*QueuesApi* | [**getQueueJobs**](doc//QueuesApi.md#getqueuejobs) | **GET** /queues/{name}/jobs | Retrieve queue jobs
|
||||
*QueuesApi* | [**getQueues**](doc//QueuesApi.md#getqueues) | **GET** /queues | List all queues
|
||||
*QueuesApi* | [**updateQueue**](doc//QueuesApi.md#updatequeue) | **PUT** /queues/{name} | Update a queue
|
||||
*SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities | Retrieve assets by city
|
||||
*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | Retrieve explore data
|
||||
*SearchApi* | [**getSearchSuggestions**](doc//SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions | Retrieve search suggestions
|
||||
@@ -380,6 +394,8 @@ Class | Method | HTTP request | Description
|
||||
- [CreateLibraryDto](doc//CreateLibraryDto.md)
|
||||
- [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
|
||||
- [DatabaseBackupConfig](doc//DatabaseBackupConfig.md)
|
||||
- [DatabaseBackupDeleteDto](doc//DatabaseBackupDeleteDto.md)
|
||||
- [DatabaseBackupListResponseDto](doc//DatabaseBackupListResponseDto.md)
|
||||
- [DownloadArchiveInfo](doc//DownloadArchiveInfo.md)
|
||||
- [DownloadInfoDto](doc//DownloadInfoDto.md)
|
||||
- [DownloadResponse](doc//DownloadResponse.md)
|
||||
@@ -396,6 +412,7 @@ Class | Method | HTTP request | Description
|
||||
- [FoldersUpdate](doc//FoldersUpdate.md)
|
||||
- [ImageFormat](doc//ImageFormat.md)
|
||||
- [JobCreateDto](doc//JobCreateDto.md)
|
||||
- [JobName](doc//JobName.md)
|
||||
- [JobSettingsDto](doc//JobSettingsDto.md)
|
||||
- [LibraryResponseDto](doc//LibraryResponseDto.md)
|
||||
- [LibraryStatsResponseDto](doc//LibraryStatsResponseDto.md)
|
||||
@@ -408,7 +425,10 @@ Class | Method | HTTP request | Description
|
||||
- [MachineLearningAvailabilityChecksDto](doc//MachineLearningAvailabilityChecksDto.md)
|
||||
- [MaintenanceAction](doc//MaintenanceAction.md)
|
||||
- [MaintenanceAuthDto](doc//MaintenanceAuthDto.md)
|
||||
- [MaintenanceDetectInstallResponseDto](doc//MaintenanceDetectInstallResponseDto.md)
|
||||
- [MaintenanceDetectInstallStorageFolderDto](doc//MaintenanceDetectInstallStorageFolderDto.md)
|
||||
- [MaintenanceLoginDto](doc//MaintenanceLoginDto.md)
|
||||
- [MaintenanceStatusResponseDto](doc//MaintenanceStatusResponseDto.md)
|
||||
- [ManualJobName](doc//ManualJobName.md)
|
||||
- [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
|
||||
- [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md)
|
||||
@@ -465,11 +485,16 @@ Class | Method | HTTP request | Description
|
||||
- [PurchaseUpdate](doc//PurchaseUpdate.md)
|
||||
- [QueueCommand](doc//QueueCommand.md)
|
||||
- [QueueCommandDto](doc//QueueCommandDto.md)
|
||||
- [QueueDeleteDto](doc//QueueDeleteDto.md)
|
||||
- [QueueJobResponseDto](doc//QueueJobResponseDto.md)
|
||||
- [QueueJobStatus](doc//QueueJobStatus.md)
|
||||
- [QueueName](doc//QueueName.md)
|
||||
- [QueueResponseDto](doc//QueueResponseDto.md)
|
||||
- [QueueResponseLegacyDto](doc//QueueResponseLegacyDto.md)
|
||||
- [QueueStatisticsDto](doc//QueueStatisticsDto.md)
|
||||
- [QueueStatusDto](doc//QueueStatusDto.md)
|
||||
- [QueuesResponseDto](doc//QueuesResponseDto.md)
|
||||
- [QueueStatusLegacyDto](doc//QueueStatusLegacyDto.md)
|
||||
- [QueueUpdateDto](doc//QueueUpdateDto.md)
|
||||
- [QueuesResponseLegacyDto](doc//QueuesResponseLegacyDto.md)
|
||||
- [RandomSearchDto](doc//RandomSearchDto.md)
|
||||
- [RatingsResponse](doc//RatingsResponse.md)
|
||||
- [RatingsUpdate](doc//RatingsUpdate.md)
|
||||
@@ -515,6 +540,7 @@ Class | Method | HTTP request | Description
|
||||
- [StackResponseDto](doc//StackResponseDto.md)
|
||||
- [StackUpdateDto](doc//StackUpdateDto.md)
|
||||
- [StatisticsSearchDto](doc//StatisticsSearchDto.md)
|
||||
- [StorageFolder](doc//StorageFolder.md)
|
||||
- [SyncAckDeleteDto](doc//SyncAckDeleteDto.md)
|
||||
- [SyncAckDto](doc//SyncAckDto.md)
|
||||
- [SyncAckSetDto](doc//SyncAckSetDto.md)
|
||||
|
||||
18
mobile/openapi/lib/api.dart
generated
18
mobile/openapi/lib/api.dart
generated
@@ -36,6 +36,7 @@ part 'api/albums_api.dart';
|
||||
part 'api/assets_api.dart';
|
||||
part 'api/authentication_api.dart';
|
||||
part 'api/authentication_admin_api.dart';
|
||||
part 'api/database_backups_admin_api.dart';
|
||||
part 'api/deprecated_api.dart';
|
||||
part 'api/download_api.dart';
|
||||
part 'api/duplicates_api.dart';
|
||||
@@ -50,6 +51,7 @@ part 'api/notifications_admin_api.dart';
|
||||
part 'api/partners_api.dart';
|
||||
part 'api/people_api.dart';
|
||||
part 'api/plugins_api.dart';
|
||||
part 'api/queues_api.dart';
|
||||
part 'api/search_api.dart';
|
||||
part 'api/server_api.dart';
|
||||
part 'api/sessions_api.dart';
|
||||
@@ -138,6 +140,8 @@ part 'model/create_album_dto.dart';
|
||||
part 'model/create_library_dto.dart';
|
||||
part 'model/create_profile_image_response_dto.dart';
|
||||
part 'model/database_backup_config.dart';
|
||||
part 'model/database_backup_delete_dto.dart';
|
||||
part 'model/database_backup_list_response_dto.dart';
|
||||
part 'model/download_archive_info.dart';
|
||||
part 'model/download_info_dto.dart';
|
||||
part 'model/download_response.dart';
|
||||
@@ -154,6 +158,7 @@ part 'model/folders_response.dart';
|
||||
part 'model/folders_update.dart';
|
||||
part 'model/image_format.dart';
|
||||
part 'model/job_create_dto.dart';
|
||||
part 'model/job_name.dart';
|
||||
part 'model/job_settings_dto.dart';
|
||||
part 'model/library_response_dto.dart';
|
||||
part 'model/library_stats_response_dto.dart';
|
||||
@@ -166,7 +171,10 @@ part 'model/logout_response_dto.dart';
|
||||
part 'model/machine_learning_availability_checks_dto.dart';
|
||||
part 'model/maintenance_action.dart';
|
||||
part 'model/maintenance_auth_dto.dart';
|
||||
part 'model/maintenance_detect_install_response_dto.dart';
|
||||
part 'model/maintenance_detect_install_storage_folder_dto.dart';
|
||||
part 'model/maintenance_login_dto.dart';
|
||||
part 'model/maintenance_status_response_dto.dart';
|
||||
part 'model/manual_job_name.dart';
|
||||
part 'model/map_marker_response_dto.dart';
|
||||
part 'model/map_reverse_geocode_response_dto.dart';
|
||||
@@ -223,11 +231,16 @@ part 'model/purchase_response.dart';
|
||||
part 'model/purchase_update.dart';
|
||||
part 'model/queue_command.dart';
|
||||
part 'model/queue_command_dto.dart';
|
||||
part 'model/queue_delete_dto.dart';
|
||||
part 'model/queue_job_response_dto.dart';
|
||||
part 'model/queue_job_status.dart';
|
||||
part 'model/queue_name.dart';
|
||||
part 'model/queue_response_dto.dart';
|
||||
part 'model/queue_response_legacy_dto.dart';
|
||||
part 'model/queue_statistics_dto.dart';
|
||||
part 'model/queue_status_dto.dart';
|
||||
part 'model/queues_response_dto.dart';
|
||||
part 'model/queue_status_legacy_dto.dart';
|
||||
part 'model/queue_update_dto.dart';
|
||||
part 'model/queues_response_legacy_dto.dart';
|
||||
part 'model/random_search_dto.dart';
|
||||
part 'model/ratings_response.dart';
|
||||
part 'model/ratings_update.dart';
|
||||
@@ -273,6 +286,7 @@ part 'model/stack_create_dto.dart';
|
||||
part 'model/stack_response_dto.dart';
|
||||
part 'model/stack_update_dto.dart';
|
||||
part 'model/statistics_search_dto.dart';
|
||||
part 'model/storage_folder.dart';
|
||||
part 'model/sync_ack_delete_dto.dart';
|
||||
part 'model/sync_ack_dto.dart';
|
||||
part 'model/sync_ack_set_dto.dart';
|
||||
|
||||
269
mobile/openapi/lib/api/database_backups_admin_api.dart
generated
Normal file
269
mobile/openapi/lib/api/database_backups_admin_api.dart
generated
Normal file
@@ -0,0 +1,269 @@
|
||||
//
|
||||
// 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 DatabaseBackupsAdminApi {
|
||||
DatabaseBackupsAdminApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// Delete database backup
|
||||
///
|
||||
/// Delete a backup by its filename
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [DatabaseBackupDeleteDto] databaseBackupDeleteDto (required):
|
||||
Future<Response> deleteDatabaseBackupWithHttpInfo(DatabaseBackupDeleteDto databaseBackupDeleteDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/database-backups';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = databaseBackupDeleteDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'DELETE',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Delete database backup
|
||||
///
|
||||
/// Delete a backup by its filename
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [DatabaseBackupDeleteDto] databaseBackupDeleteDto (required):
|
||||
Future<void> deleteDatabaseBackup(DatabaseBackupDeleteDto databaseBackupDeleteDto,) async {
|
||||
final response = await deleteDatabaseBackupWithHttpInfo(databaseBackupDeleteDto,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
}
|
||||
|
||||
/// Download database backup
|
||||
///
|
||||
/// Downloads the database backup file
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] filename (required):
|
||||
Future<Response> downloadDatabaseBackupWithHttpInfo(String filename,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/database-backups/{filename}'
|
||||
.replaceAll('{filename}', filename);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Download database backup
|
||||
///
|
||||
/// Downloads the database backup file
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] filename (required):
|
||||
Future<MultipartFile?> downloadDatabaseBackup(String filename,) async {
|
||||
final response = await downloadDatabaseBackupWithHttpInfo(filename,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// List database backups
|
||||
///
|
||||
/// Get the list of the successful and failed backups
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> listDatabaseBackupsWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/database-backups';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// List database backups
|
||||
///
|
||||
/// Get the list of the successful and failed backups
|
||||
Future<DatabaseBackupListResponseDto?> listDatabaseBackups() async {
|
||||
final response = await listDatabaseBackupsWithHttpInfo();
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'DatabaseBackupListResponseDto',) as DatabaseBackupListResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Start database backup restore flow
|
||||
///
|
||||
/// Put Immich into maintenance mode to restore a backup (Immich must not be configured)
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> startDatabaseRestoreFlowWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/database-backups/start-restore';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Start database backup restore flow
|
||||
///
|
||||
/// Put Immich into maintenance mode to restore a backup (Immich must not be configured)
|
||||
Future<void> startDatabaseRestoreFlow() async {
|
||||
final response = await startDatabaseRestoreFlowWithHttpInfo();
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload database backup
|
||||
///
|
||||
/// Uploads .sql/.sql.gz file to restore backup from
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [MultipartFile] file:
|
||||
Future<Response> uploadDatabaseBackupWithHttpInfo({ MultipartFile? file, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/database-backups/upload';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['multipart/form-data'];
|
||||
|
||||
bool hasFields = false;
|
||||
final mp = MultipartRequest('POST', Uri.parse(apiPath));
|
||||
if (file != null) {
|
||||
hasFields = true;
|
||||
mp.fields[r'file'] = file.field;
|
||||
mp.files.add(file);
|
||||
}
|
||||
if (hasFields) {
|
||||
postBody = mp;
|
||||
}
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Upload database backup
|
||||
///
|
||||
/// Uploads .sql/.sql.gz file to restore backup from
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [MultipartFile] file:
|
||||
Future<void> uploadDatabaseBackup({ MultipartFile? file, }) async {
|
||||
final response = await uploadDatabaseBackupWithHttpInfo( file: file, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
}
|
||||
}
|
||||
109
mobile/openapi/lib/api/deprecated_api.dart
generated
109
mobile/openapi/lib/api/deprecated_api.dart
generated
@@ -248,6 +248,54 @@ class DeprecatedApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Retrieve queue counts and status
|
||||
///
|
||||
/// Retrieve the counts of the current queue, as well as the current status.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> getQueuesLegacyWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/jobs';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Retrieve queue counts and status
|
||||
///
|
||||
/// Retrieve the counts of the current queue, as well as the current status.
|
||||
Future<QueuesResponseLegacyDto?> getQueuesLegacy() async {
|
||||
final response = await getQueuesLegacyWithHttpInfo();
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueuesResponseLegacyDto',) as QueuesResponseLegacyDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get random assets
|
||||
///
|
||||
/// Retrieve a specified number of random assets for the authenticated user.
|
||||
@@ -444,4 +492,65 @@ class DeprecatedApi {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Run jobs
|
||||
///
|
||||
/// Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [QueueName] name (required):
|
||||
///
|
||||
/// * [QueueCommandDto] queueCommandDto (required):
|
||||
Future<Response> runQueueCommandLegacyWithHttpInfo(QueueName name, QueueCommandDto queueCommandDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/jobs/{name}'
|
||||
.replaceAll('{name}', name.toString());
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = queueCommandDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PUT',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Run jobs
|
||||
///
|
||||
/// Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [QueueName] name (required):
|
||||
///
|
||||
/// * [QueueCommandDto] queueCommandDto (required):
|
||||
Future<QueueResponseLegacyDto?> runQueueCommandLegacy(QueueName name, QueueCommandDto queueCommandDto,) async {
|
||||
final response = await runQueueCommandLegacyWithHttpInfo(name, queueCommandDto,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueueResponseLegacyDto',) as QueueResponseLegacyDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
8
mobile/openapi/lib/api/jobs_api.dart
generated
8
mobile/openapi/lib/api/jobs_api.dart
generated
@@ -97,7 +97,7 @@ class JobsApi {
|
||||
/// Retrieve queue counts and status
|
||||
///
|
||||
/// Retrieve the counts of the current queue, as well as the current status.
|
||||
Future<QueuesResponseDto?> getQueuesLegacy() async {
|
||||
Future<QueuesResponseLegacyDto?> getQueuesLegacy() async {
|
||||
final response = await getQueuesLegacyWithHttpInfo();
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
@@ -106,7 +106,7 @@ class JobsApi {
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueuesResponseDto',) as QueuesResponseDto;
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueuesResponseLegacyDto',) as QueuesResponseLegacyDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
@@ -158,7 +158,7 @@ class JobsApi {
|
||||
/// * [QueueName] name (required):
|
||||
///
|
||||
/// * [QueueCommandDto] queueCommandDto (required):
|
||||
Future<QueueResponseDto?> runQueueCommandLegacy(QueueName name, QueueCommandDto queueCommandDto,) async {
|
||||
Future<QueueResponseLegacyDto?> runQueueCommandLegacy(QueueName name, QueueCommandDto queueCommandDto,) async {
|
||||
final response = await runQueueCommandLegacyWithHttpInfo(name, queueCommandDto,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
@@ -167,7 +167,7 @@ class JobsApi {
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueueResponseDto',) as QueueResponseDto;
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueueResponseLegacyDto',) as QueueResponseLegacyDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
|
||||
96
mobile/openapi/lib/api/maintenance_admin_api.dart
generated
96
mobile/openapi/lib/api/maintenance_admin_api.dart
generated
@@ -16,6 +16,102 @@ class MaintenanceAdminApi {
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// Detect existing install
|
||||
///
|
||||
/// Collect integrity checks and other heuristics about local data.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> detectPriorInstallWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/maintenance/detect-install';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Detect existing install
|
||||
///
|
||||
/// Collect integrity checks and other heuristics about local data.
|
||||
Future<MaintenanceDetectInstallResponseDto?> detectPriorInstall() async {
|
||||
final response = await detectPriorInstallWithHttpInfo();
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MaintenanceDetectInstallResponseDto',) as MaintenanceDetectInstallResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get maintenance mode status
|
||||
///
|
||||
/// Fetch information about the currently running maintenance action.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> getMaintenanceStatusWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/maintenance/status';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get maintenance mode status
|
||||
///
|
||||
/// Fetch information about the currently running maintenance action.
|
||||
Future<MaintenanceStatusResponseDto?> getMaintenanceStatus() async {
|
||||
final response = await getMaintenanceStatusWithHttpInfo();
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MaintenanceStatusResponseDto',) as MaintenanceStatusResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Log into maintenance mode
|
||||
///
|
||||
/// Login with maintenance token or cookie to receive current information and perform further actions.
|
||||
|
||||
308
mobile/openapi/lib/api/queues_api.dart
generated
Normal file
308
mobile/openapi/lib/api/queues_api.dart
generated
Normal file
@@ -0,0 +1,308 @@
|
||||
//
|
||||
// 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 QueuesApi {
|
||||
QueuesApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// Empty a queue
|
||||
///
|
||||
/// Removes all jobs from the specified queue.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [QueueName] name (required):
|
||||
///
|
||||
/// * [QueueDeleteDto] queueDeleteDto (required):
|
||||
Future<Response> emptyQueueWithHttpInfo(QueueName name, QueueDeleteDto queueDeleteDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/queues/{name}/jobs'
|
||||
.replaceAll('{name}', name.toString());
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = queueDeleteDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'DELETE',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Empty a queue
|
||||
///
|
||||
/// Removes all jobs from the specified queue.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [QueueName] name (required):
|
||||
///
|
||||
/// * [QueueDeleteDto] queueDeleteDto (required):
|
||||
Future<void> emptyQueue(QueueName name, QueueDeleteDto queueDeleteDto,) async {
|
||||
final response = await emptyQueueWithHttpInfo(name, queueDeleteDto,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve a queue
|
||||
///
|
||||
/// Retrieves a specific queue by its name.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [QueueName] name (required):
|
||||
Future<Response> getQueueWithHttpInfo(QueueName name,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/queues/{name}'
|
||||
.replaceAll('{name}', name.toString());
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Retrieve a queue
|
||||
///
|
||||
/// Retrieves a specific queue by its name.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [QueueName] name (required):
|
||||
Future<QueueResponseDto?> getQueue(QueueName name,) async {
|
||||
final response = await getQueueWithHttpInfo(name,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueueResponseDto',) as QueueResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Retrieve queue jobs
|
||||
///
|
||||
/// Retrieves a list of queue jobs from the specified queue.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [QueueName] name (required):
|
||||
///
|
||||
/// * [List<QueueJobStatus>] status:
|
||||
Future<Response> getQueueJobsWithHttpInfo(QueueName name, { List<QueueJobStatus>? status, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/queues/{name}/jobs'
|
||||
.replaceAll('{name}', name.toString());
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (status != null) {
|
||||
queryParams.addAll(_queryParams('multi', 'status', status));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Retrieve queue jobs
|
||||
///
|
||||
/// Retrieves a list of queue jobs from the specified queue.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [QueueName] name (required):
|
||||
///
|
||||
/// * [List<QueueJobStatus>] status:
|
||||
Future<List<QueueJobResponseDto>?> getQueueJobs(QueueName name, { List<QueueJobStatus>? status, }) async {
|
||||
final response = await getQueueJobsWithHttpInfo(name, status: status, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
final responseBody = await _decodeBodyBytes(response);
|
||||
return (await apiClient.deserializeAsync(responseBody, 'List<QueueJobResponseDto>') as List)
|
||||
.cast<QueueJobResponseDto>()
|
||||
.toList(growable: false);
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// List all queues
|
||||
///
|
||||
/// Retrieves a list of queues.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> getQueuesWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/queues';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// List all queues
|
||||
///
|
||||
/// Retrieves a list of queues.
|
||||
Future<List<QueueResponseDto>?> getQueues() async {
|
||||
final response = await getQueuesWithHttpInfo();
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
final responseBody = await _decodeBodyBytes(response);
|
||||
return (await apiClient.deserializeAsync(responseBody, 'List<QueueResponseDto>') as List)
|
||||
.cast<QueueResponseDto>()
|
||||
.toList(growable: false);
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Update a queue
|
||||
///
|
||||
/// Change the paused status of a specific queue.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [QueueName] name (required):
|
||||
///
|
||||
/// * [QueueUpdateDto] queueUpdateDto (required):
|
||||
Future<Response> updateQueueWithHttpInfo(QueueName name, QueueUpdateDto queueUpdateDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/queues/{name}'
|
||||
.replaceAll('{name}', name.toString());
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = queueUpdateDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PUT',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update a queue
|
||||
///
|
||||
/// Change the paused status of a specific queue.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [QueueName] name (required):
|
||||
///
|
||||
/// * [QueueUpdateDto] queueUpdateDto (required):
|
||||
Future<QueueResponseDto?> updateQueue(QueueName name, QueueUpdateDto queueUpdateDto,) async {
|
||||
final response = await updateQueueWithHttpInfo(name, queueUpdateDto,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueueResponseDto',) as QueueResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
32
mobile/openapi/lib/api_client.dart
generated
32
mobile/openapi/lib/api_client.dart
generated
@@ -326,6 +326,10 @@ class ApiClient {
|
||||
return CreateProfileImageResponseDto.fromJson(value);
|
||||
case 'DatabaseBackupConfig':
|
||||
return DatabaseBackupConfig.fromJson(value);
|
||||
case 'DatabaseBackupDeleteDto':
|
||||
return DatabaseBackupDeleteDto.fromJson(value);
|
||||
case 'DatabaseBackupListResponseDto':
|
||||
return DatabaseBackupListResponseDto.fromJson(value);
|
||||
case 'DownloadArchiveInfo':
|
||||
return DownloadArchiveInfo.fromJson(value);
|
||||
case 'DownloadInfoDto':
|
||||
@@ -358,6 +362,8 @@ class ApiClient {
|
||||
return ImageFormatTypeTransformer().decode(value);
|
||||
case 'JobCreateDto':
|
||||
return JobCreateDto.fromJson(value);
|
||||
case 'JobName':
|
||||
return JobNameTypeTransformer().decode(value);
|
||||
case 'JobSettingsDto':
|
||||
return JobSettingsDto.fromJson(value);
|
||||
case 'LibraryResponseDto':
|
||||
@@ -382,8 +388,14 @@ class ApiClient {
|
||||
return MaintenanceActionTypeTransformer().decode(value);
|
||||
case 'MaintenanceAuthDto':
|
||||
return MaintenanceAuthDto.fromJson(value);
|
||||
case 'MaintenanceDetectInstallResponseDto':
|
||||
return MaintenanceDetectInstallResponseDto.fromJson(value);
|
||||
case 'MaintenanceDetectInstallStorageFolderDto':
|
||||
return MaintenanceDetectInstallStorageFolderDto.fromJson(value);
|
||||
case 'MaintenanceLoginDto':
|
||||
return MaintenanceLoginDto.fromJson(value);
|
||||
case 'MaintenanceStatusResponseDto':
|
||||
return MaintenanceStatusResponseDto.fromJson(value);
|
||||
case 'ManualJobName':
|
||||
return ManualJobNameTypeTransformer().decode(value);
|
||||
case 'MapMarkerResponseDto':
|
||||
@@ -496,16 +508,26 @@ class ApiClient {
|
||||
return QueueCommandTypeTransformer().decode(value);
|
||||
case 'QueueCommandDto':
|
||||
return QueueCommandDto.fromJson(value);
|
||||
case 'QueueDeleteDto':
|
||||
return QueueDeleteDto.fromJson(value);
|
||||
case 'QueueJobResponseDto':
|
||||
return QueueJobResponseDto.fromJson(value);
|
||||
case 'QueueJobStatus':
|
||||
return QueueJobStatusTypeTransformer().decode(value);
|
||||
case 'QueueName':
|
||||
return QueueNameTypeTransformer().decode(value);
|
||||
case 'QueueResponseDto':
|
||||
return QueueResponseDto.fromJson(value);
|
||||
case 'QueueResponseLegacyDto':
|
||||
return QueueResponseLegacyDto.fromJson(value);
|
||||
case 'QueueStatisticsDto':
|
||||
return QueueStatisticsDto.fromJson(value);
|
||||
case 'QueueStatusDto':
|
||||
return QueueStatusDto.fromJson(value);
|
||||
case 'QueuesResponseDto':
|
||||
return QueuesResponseDto.fromJson(value);
|
||||
case 'QueueStatusLegacyDto':
|
||||
return QueueStatusLegacyDto.fromJson(value);
|
||||
case 'QueueUpdateDto':
|
||||
return QueueUpdateDto.fromJson(value);
|
||||
case 'QueuesResponseLegacyDto':
|
||||
return QueuesResponseLegacyDto.fromJson(value);
|
||||
case 'RandomSearchDto':
|
||||
return RandomSearchDto.fromJson(value);
|
||||
case 'RatingsResponse':
|
||||
@@ -596,6 +618,8 @@ class ApiClient {
|
||||
return StackUpdateDto.fromJson(value);
|
||||
case 'StatisticsSearchDto':
|
||||
return StatisticsSearchDto.fromJson(value);
|
||||
case 'StorageFolder':
|
||||
return StorageFolderTypeTransformer().decode(value);
|
||||
case 'SyncAckDeleteDto':
|
||||
return SyncAckDeleteDto.fromJson(value);
|
||||
case 'SyncAckDto':
|
||||
|
||||
9
mobile/openapi/lib/api_helper.dart
generated
9
mobile/openapi/lib/api_helper.dart
generated
@@ -94,6 +94,9 @@ String parameterToString(dynamic value) {
|
||||
if (value is ImageFormat) {
|
||||
return ImageFormatTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is JobName) {
|
||||
return JobNameTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is LogLevel) {
|
||||
return LogLevelTypeTransformer().encode(value).toString();
|
||||
}
|
||||
@@ -133,6 +136,9 @@ String parameterToString(dynamic value) {
|
||||
if (value is QueueCommand) {
|
||||
return QueueCommandTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is QueueJobStatus) {
|
||||
return QueueJobStatusTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is QueueName) {
|
||||
return QueueNameTypeTransformer().encode(value).toString();
|
||||
}
|
||||
@@ -151,6 +157,9 @@ String parameterToString(dynamic value) {
|
||||
if (value is SourceType) {
|
||||
return SourceTypeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is StorageFolder) {
|
||||
return StorageFolderTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is SyncEntityType) {
|
||||
return SyncEntityTypeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
|
||||
101
mobile/openapi/lib/model/database_backup_delete_dto.dart
generated
Normal file
101
mobile/openapi/lib/model/database_backup_delete_dto.dart
generated
Normal file
@@ -0,0 +1,101 @@
|
||||
//
|
||||
// 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 DatabaseBackupDeleteDto {
|
||||
/// Returns a new [DatabaseBackupDeleteDto] instance.
|
||||
DatabaseBackupDeleteDto({
|
||||
this.backups = const [],
|
||||
});
|
||||
|
||||
List<String> backups;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupDeleteDto &&
|
||||
_deepEquality.equals(other.backups, backups);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(backups.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'DatabaseBackupDeleteDto[backups=$backups]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'backups'] = this.backups;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [DatabaseBackupDeleteDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static DatabaseBackupDeleteDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "DatabaseBackupDeleteDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return DatabaseBackupDeleteDto(
|
||||
backups: json[r'backups'] is Iterable
|
||||
? (json[r'backups'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<DatabaseBackupDeleteDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <DatabaseBackupDeleteDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = DatabaseBackupDeleteDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, DatabaseBackupDeleteDto> mapFromJson(dynamic json) {
|
||||
final map = <String, DatabaseBackupDeleteDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = DatabaseBackupDeleteDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of DatabaseBackupDeleteDto-objects as value to a dart map
|
||||
static Map<String, List<DatabaseBackupDeleteDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<DatabaseBackupDeleteDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = DatabaseBackupDeleteDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'backups',
|
||||
};
|
||||
}
|
||||
|
||||
101
mobile/openapi/lib/model/database_backup_list_response_dto.dart
generated
Normal file
101
mobile/openapi/lib/model/database_backup_list_response_dto.dart
generated
Normal file
@@ -0,0 +1,101 @@
|
||||
//
|
||||
// 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 DatabaseBackupListResponseDto {
|
||||
/// Returns a new [DatabaseBackupListResponseDto] instance.
|
||||
DatabaseBackupListResponseDto({
|
||||
this.backups = const [],
|
||||
});
|
||||
|
||||
List<String> backups;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupListResponseDto &&
|
||||
_deepEquality.equals(other.backups, backups);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(backups.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'DatabaseBackupListResponseDto[backups=$backups]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'backups'] = this.backups;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [DatabaseBackupListResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static DatabaseBackupListResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "DatabaseBackupListResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return DatabaseBackupListResponseDto(
|
||||
backups: json[r'backups'] is Iterable
|
||||
? (json[r'backups'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<DatabaseBackupListResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <DatabaseBackupListResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = DatabaseBackupListResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, DatabaseBackupListResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, DatabaseBackupListResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = DatabaseBackupListResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of DatabaseBackupListResponseDto-objects as value to a dart map
|
||||
static Map<String, List<DatabaseBackupListResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<DatabaseBackupListResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = DatabaseBackupListResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'backups',
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user