mirror of
https://github.com/immich-app/immich.git
synced 2026-03-18 08:08:42 -07:00
Compare commits
30 Commits
feat/mobil
...
renovate/n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c989e53451 | ||
|
|
9dafc8e8e9 | ||
|
|
4e44fb9cf7 | ||
|
|
82db581cc5 | ||
|
|
b66c97b785 | ||
|
|
ff936f901d | ||
|
|
48fe111daa | ||
|
|
0581b49750 | ||
|
|
2c6d4f3fe1 | ||
|
|
55513cd59f | ||
|
|
10fa928abe | ||
|
|
e322d44f95 | ||
|
|
c2a279e49e | ||
|
|
226b9390db | ||
|
|
754f072ef9 | ||
|
|
c91d8745b4 | ||
|
|
f3b7cd6198 | ||
|
|
990aff441b | ||
|
|
001d7d083f | ||
|
|
3fd24e2083 | ||
|
|
6bb8f4fcc4 | ||
|
|
d4605b21d9 | ||
|
|
3bd37ebbfb | ||
|
|
5c3777ab46 | ||
|
|
6c531e0a5a | ||
|
|
471c27cd33 | ||
|
|
4773788a88 | ||
|
|
d49d995611 | ||
|
|
0ac3d6a83a | ||
|
|
9996ee12d0 |
80
.github/workflows/check-pr-template.yml
vendored
Normal file
80
.github/workflows/check-pr-template.yml
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
name: Check PR Template
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||
types: [opened, edited]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
parse:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.pull_request.head.repo.fork == true }}
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
uses_template: ${{ steps.check.outputs.uses_template }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
sparse-checkout: .github/pull_request_template.md
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check required sections
|
||||
id: check
|
||||
env:
|
||||
BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
OK=true
|
||||
while IFS= read -r header; do
|
||||
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
|
||||
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
|
||||
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
|
||||
|
||||
act:
|
||||
runs-on: ubuntu-latest
|
||||
needs: parse
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Close PR
|
||||
if: ${{ needs.parse.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
|
||||
-f query='
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||
addComment(input: {
|
||||
subjectId: $prId,
|
||||
body: $body
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
closePullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
|
||||
- name: Reopen PR (sections now present, PR closed)
|
||||
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f query='
|
||||
mutation ReopenPR($prId: ID!) {
|
||||
reopenPullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
149
.github/workflows/release.yml
vendored
149
.github/workflows/release.yml
vendored
@@ -1,149 +0,0 @@
|
||||
name: release.yml
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
paths:
|
||||
- CHANGELOG.md
|
||||
|
||||
jobs:
|
||||
# Maybe double check PR source branch?
|
||||
|
||||
merge_translations:
|
||||
uses: ./.github/workflows/merge-translations.yml
|
||||
permissions:
|
||||
pull-requests: write
|
||||
secrets:
|
||||
PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
|
||||
|
||||
build_mobile:
|
||||
uses: ./.github/workflows/build-mobile.yml
|
||||
needs: merge_translations
|
||||
permissions:
|
||||
contents: read
|
||||
secrets:
|
||||
KEY_JKS: ${{ secrets.KEY_JKS }}
|
||||
ALIAS: ${{ secrets.ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||
# iOS secrets
|
||||
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
|
||||
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
||||
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
|
||||
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl
|
||||
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
|
||||
with:
|
||||
ref: main
|
||||
environment: production
|
||||
|
||||
prepare_release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build_mobile
|
||||
permissions:
|
||||
actions: read # To download the app artifact
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: false
|
||||
ref: main
|
||||
|
||||
- name: Extract changelog
|
||||
id: changelog
|
||||
run: |
|
||||
CHANGELOG_PATH=$RUNNER_TEMP/changelog.md
|
||||
sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH
|
||||
echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT
|
||||
VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download APK
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: release-apk-signed
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Create draft release
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.result }}
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
body_path: ${{ steps.changelog.outputs.path }}
|
||||
draft: true
|
||||
files: |
|
||||
docker/docker-compose.yml
|
||||
docker/docker-compose.rootless.yml
|
||||
docker/example.env
|
||||
docker/hwaccel.ml.yml
|
||||
docker/hwaccel.transcoding.yml
|
||||
docker/prometheus.yml
|
||||
*.apk
|
||||
|
||||
- name: Rename Outline document
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
continue-on-error: true
|
||||
env:
|
||||
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
|
||||
VERSION: ${{ steps.changelog.outputs.version }}
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
const outlineKey = process.env.OUTLINE_API_KEY;
|
||||
const version = process.env.VERSION;
|
||||
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9';
|
||||
const baseUrl = 'https://outline.immich.cloud';
|
||||
|
||||
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${outlineKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ parentDocumentId })
|
||||
});
|
||||
|
||||
if (!listResponse.ok) {
|
||||
throw new Error(`Outline list failed: ${listResponse.statusText}`);
|
||||
}
|
||||
|
||||
const listData = await listResponse.json();
|
||||
const allDocuments = listData.data || [];
|
||||
const document = allDocuments.find(doc => doc.title === 'next');
|
||||
|
||||
if (document) {
|
||||
console.log(`Found document 'next', renaming to '${version}'...`);
|
||||
|
||||
const updateResponse = await fetch(`${baseUrl}/api/documents.update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${outlineKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: document.id,
|
||||
title: version
|
||||
})
|
||||
});
|
||||
|
||||
if (!updateResponse.ok) {
|
||||
throw new Error(`Failed to rename document: ${updateResponse.statusText}`);
|
||||
}
|
||||
} else {
|
||||
console.log('No document titled "next" found to rename');
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^24.11.0",
|
||||
"@types/node": "^24.12.0",
|
||||
"@vitest/coverage-v8": "^4.0.0",
|
||||
"byte-size": "^9.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
|
||||
@@ -10,6 +10,7 @@ export enum OAuthClient {
|
||||
export enum OAuthUser {
|
||||
NO_EMAIL = 'no-email',
|
||||
NO_NAME = 'no-name',
|
||||
ID_TOKEN_CLAIMS = 'id-token-claims',
|
||||
WITH_QUOTA = 'with-quota',
|
||||
WITH_USERNAME = 'with-username',
|
||||
WITH_ROLE = 'with-role',
|
||||
@@ -52,12 +53,25 @@ const withDefaultClaims = (sub: string) => ({
|
||||
email_verified: true,
|
||||
});
|
||||
|
||||
const getClaims = (sub: string) => claims.find((user) => user.sub === sub) || withDefaultClaims(sub);
|
||||
const getClaims = (sub: string, use?: string) => {
|
||||
if (sub === OAuthUser.ID_TOKEN_CLAIMS) {
|
||||
return {
|
||||
sub,
|
||||
email: `oauth-${sub}@immich.app`,
|
||||
email_verified: true,
|
||||
name: use === 'id_token' ? 'ID Token User' : 'Userinfo User',
|
||||
};
|
||||
}
|
||||
return claims.find((user) => user.sub === sub) || withDefaultClaims(sub);
|
||||
};
|
||||
|
||||
const setup = async () => {
|
||||
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
||||
|
||||
const redirectUris = ['http://127.0.0.1:2285/auth/login', 'https://photos.immich.app/oauth/mobile-redirect'];
|
||||
const redirectUris = [
|
||||
'http://127.0.0.1:2285/auth/login',
|
||||
'https://photos.immich.app/oauth/mobile-redirect',
|
||||
];
|
||||
const port = 2286;
|
||||
const host = '0.0.0.0';
|
||||
const oidc = new Provider(`http://${host}:${port}`, {
|
||||
@@ -66,7 +80,10 @@ const setup = async () => {
|
||||
console.error(error);
|
||||
ctx.body = 'Internal Server Error';
|
||||
},
|
||||
findAccount: (ctx, sub) => ({ accountId: sub, claims: () => getClaims(sub) }),
|
||||
findAccount: (ctx, sub) => ({
|
||||
accountId: sub,
|
||||
claims: (use) => getClaims(sub, use),
|
||||
}),
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
claims: {
|
||||
openid: ['sub'],
|
||||
@@ -94,6 +111,7 @@ const setup = async () => {
|
||||
state: 'oidc.state',
|
||||
},
|
||||
},
|
||||
conformIdTokenClaims: false,
|
||||
pkce: {
|
||||
required: () => false,
|
||||
},
|
||||
@@ -125,7 +143,10 @@ const setup = async () => {
|
||||
],
|
||||
});
|
||||
|
||||
const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`);
|
||||
const onStart = () =>
|
||||
console.log(
|
||||
`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`,
|
||||
);
|
||||
const app = oidc.listen(port, host, onStart);
|
||||
return () => app.close();
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^24.11.0",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@types/supertest": "^6.0.2",
|
||||
|
||||
@@ -380,4 +380,23 @@ describe(`/oauth`, () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('idTokenClaims', () => {
|
||||
it('should use claims from the ID token if IDP includes them', async () => {
|
||||
await setupOAuth(admin.accessToken, {
|
||||
enabled: true,
|
||||
clientId: OAuthClient.DEFAULT,
|
||||
clientSecret: OAuthClient.DEFAULT,
|
||||
});
|
||||
const callbackParams = await loginWithOAuth(OAuthUser.ID_TOKEN_CLAIMS);
|
||||
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
||||
expect(status).toBe(201);
|
||||
expect(body).toMatchObject({
|
||||
accessToken: expect.any(String),
|
||||
name: 'ID Token User',
|
||||
userEmail: 'oauth-id-token-claims@immich.app',
|
||||
userId: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -438,6 +438,16 @@ describe('/shared-links', () => {
|
||||
expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
|
||||
});
|
||||
|
||||
it('should reject guests removing assets from an individual shared link', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/shared-links/${linkWithAssets.id}/assets`)
|
||||
.query({ key: linkWithAssets.key })
|
||||
.send({ assetIds: [asset1.id] });
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.forbidden);
|
||||
});
|
||||
|
||||
it('should remove assets from a shared link (individual)', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/shared-links/${linkWithAssets.id}/assets`)
|
||||
|
||||
@@ -12,15 +12,18 @@ import { asBearerAuth, utils } from 'src/utils';
|
||||
test.describe('Shared Links', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
let asset2: AssetMediaResponseDto;
|
||||
let album: AlbumResponseDto;
|
||||
let sharedLink: SharedLinkResponseDto;
|
||||
let sharedLinkPassword: SharedLinkResponseDto;
|
||||
let individualSharedLink: SharedLinkResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
asset = await utils.createAsset(admin.accessToken);
|
||||
asset2 = await utils.createAsset(admin.accessToken);
|
||||
album = await createAlbum(
|
||||
{
|
||||
createAlbumDto: {
|
||||
@@ -39,6 +42,10 @@ test.describe('Shared Links', () => {
|
||||
albumId: album.id,
|
||||
password: 'test-password',
|
||||
});
|
||||
individualSharedLink = await utils.createSharedLink(admin.accessToken, {
|
||||
type: SharedLinkType.Individual,
|
||||
assetIds: [asset.id, asset2.id],
|
||||
});
|
||||
});
|
||||
|
||||
test('download from a shared link', async ({ page }) => {
|
||||
@@ -109,4 +116,21 @@ test.describe('Shared Links', () => {
|
||||
await page.waitForURL('/photos');
|
||||
await page.locator(`[data-asset-id="${asset.id}"]`).waitFor();
|
||||
});
|
||||
|
||||
test('owner can remove assets from an individual shared link', async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto(`/share/${individualSharedLink.key}`);
|
||||
await page.locator(`[data-asset="${asset.id}"]`).waitFor();
|
||||
await expect(page.locator(`[data-asset]`)).toHaveCount(2);
|
||||
|
||||
await page.locator(`[data-asset="${asset.id}"]`).hover();
|
||||
await page.locator(`[data-asset="${asset.id}"] [role="checkbox"]`).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Remove from shared link' }).click();
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click();
|
||||
|
||||
await expect(page.locator(`[data-asset="${asset.id}"]`)).toHaveCount(0);
|
||||
await expect(page.locator(`[data-asset="${asset2.id}"]`)).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1007,6 +1007,8 @@
|
||||
"editor_edits_applied_success": "Edits applied successfully",
|
||||
"editor_flip_horizontal": "Flip horizontal",
|
||||
"editor_flip_vertical": "Flip vertical",
|
||||
"editor_handle_corner": "{corner, select, top_left {Top-left} top_right {Top-right} bottom_left {Bottom-left} bottom_right {Bottom-right} other {A}} corner handle",
|
||||
"editor_handle_edge": "{edge, select, top {Top} bottom {Bottom} left {Left} right {Right} other {An}} edge handle",
|
||||
"editor_orientation": "Orientation",
|
||||
"editor_reset_all_changes": "Reset changes",
|
||||
"editor_rotate_left": "Rotate 90° counterclockwise",
|
||||
@@ -1649,6 +1651,7 @@
|
||||
"only_favorites": "Only favorites",
|
||||
"open": "Open",
|
||||
"open_calendar": "Open calendar",
|
||||
"open_in_browser": "Open in browser",
|
||||
"open_in_map_view": "Open in map view",
|
||||
"open_in_openstreetmap": "Open in OpenStreetMap",
|
||||
"open_the_search_filters": "Open the search filters",
|
||||
|
||||
@@ -113,6 +113,8 @@ dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
|
||||
implementation 'org.chromium.net:cronet-embedded:143.7445.0'
|
||||
implementation("androidx.media3:media3-datasource-okhttp:1.9.2")
|
||||
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
||||
implementation "androidx.work:work-runtime-ktx:$work_version"
|
||||
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
|
||||
|
||||
@@ -12,6 +12,7 @@ import app.alextran.immich.connectivity.ConnectivityApiImpl
|
||||
import app.alextran.immich.core.HttpClientManager
|
||||
import app.alextran.immich.core.ImmichPlugin
|
||||
import app.alextran.immich.core.NetworkApiPlugin
|
||||
import me.albemala.native_video_player.NativeVideoPlayerPlugin
|
||||
import app.alextran.immich.images.LocalImageApi
|
||||
import app.alextran.immich.images.LocalImagesImpl
|
||||
import app.alextran.immich.images.RemoteImageApi
|
||||
@@ -31,6 +32,7 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
companion object {
|
||||
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
||||
HttpClientManager.initialize(ctx)
|
||||
NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory
|
||||
flutterEngine.plugins.add(NetworkApiPlugin())
|
||||
|
||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||
|
||||
@@ -3,7 +3,13 @@ package app.alextran.immich.core
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.security.KeyChain
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.content.edit
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.ResolvingDataSource
|
||||
import androidx.media3.datasource.cronet.CronetDataSource
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import app.alextran.immich.BuildConfig
|
||||
import app.alextran.immich.NativeBuffer
|
||||
import okhttp3.Cache
|
||||
@@ -16,15 +22,22 @@ import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import org.chromium.net.CronetEngine
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.net.Authenticator
|
||||
import java.net.CookieHandler
|
||||
import java.net.PasswordAuthentication
|
||||
import java.net.Socket
|
||||
import java.net.URI
|
||||
import java.security.KeyStore
|
||||
import java.security.Principal
|
||||
import java.security.PrivateKey
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.SSLContext
|
||||
@@ -56,6 +69,7 @@ private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) {
|
||||
*/
|
||||
object HttpClientManager {
|
||||
private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB
|
||||
const val MEDIA_CACHE_SIZE_BYTES = 1024L * 1024 * 1024 // 1GiB
|
||||
private const val KEEP_ALIVE_CONNECTIONS = 10
|
||||
private const val KEEP_ALIVE_DURATION_MINUTES = 5L
|
||||
private const val MAX_REQUESTS_PER_HOST = 64
|
||||
@@ -63,12 +77,15 @@ object HttpClientManager {
|
||||
private var initialized = false
|
||||
private val clientChangedListeners = mutableListOf<() -> Unit>()
|
||||
|
||||
@JvmStatic
|
||||
lateinit var client: OkHttpClient
|
||||
private set
|
||||
private lateinit var client: OkHttpClient
|
||||
private lateinit var appContext: Context
|
||||
private lateinit var prefs: SharedPreferences
|
||||
|
||||
var cronetEngine: CronetEngine? = null
|
||||
private set
|
||||
private lateinit var cronetStorageDir: File
|
||||
val cronetExecutor: ExecutorService = Executors.newFixedThreadPool(4)
|
||||
|
||||
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
|
||||
var keyChainAlias: String? = null
|
||||
@@ -81,9 +98,6 @@ object HttpClientManager {
|
||||
|
||||
val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS)
|
||||
|
||||
val serverUrl: String? get() = if (initialized) prefs.getString(PREFS_SERVER_URLS, null)
|
||||
?.let { Json.decodeFromString<List<String>>(it).firstOrNull() } else null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
if (initialized) return
|
||||
synchronized(this) {
|
||||
@@ -94,6 +108,25 @@ object HttpClientManager {
|
||||
keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null)
|
||||
|
||||
cookieJar.init(prefs)
|
||||
System.setProperty("http.agent", USER_AGENT)
|
||||
Authenticator.setDefault(object : Authenticator() {
|
||||
override fun getPasswordAuthentication(): PasswordAuthentication? {
|
||||
val url = requestingURL ?: return null
|
||||
if (url.userInfo.isNullOrEmpty()) return null
|
||||
val parts = url.userInfo.split(":", limit = 2)
|
||||
return PasswordAuthentication(parts[0], parts.getOrElse(1) { "" }.toCharArray())
|
||||
}
|
||||
})
|
||||
CookieHandler.setDefault(object : CookieHandler() {
|
||||
override fun get(uri: URI, requestHeaders: Map<String, List<String>>): Map<String, List<String>> {
|
||||
val httpUrl = uri.toString().toHttpUrlOrNull() ?: return emptyMap()
|
||||
val cookies = cookieJar.loadForRequest(httpUrl)
|
||||
if (cookies.isEmpty()) return emptyMap()
|
||||
return mapOf("Cookie" to listOf(cookies.joinToString("; ") { "${it.name}=${it.value}" }))
|
||||
}
|
||||
|
||||
override fun put(uri: URI, responseHeaders: Map<String, List<String>>) {}
|
||||
})
|
||||
|
||||
val savedHeaders = prefs.getString(PREFS_HEADERS, null)
|
||||
if (savedHeaders != null) {
|
||||
@@ -112,6 +145,10 @@ object HttpClientManager {
|
||||
|
||||
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
|
||||
client = build(cacheDir)
|
||||
|
||||
cronetStorageDir = File(context.cacheDir, "cronet").apply { mkdirs() }
|
||||
cronetEngine = buildCronetEngine()
|
||||
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
@@ -168,6 +205,11 @@ object HttpClientManager {
|
||||
|
||||
private var clientGlobalRef: Long = 0L
|
||||
|
||||
@JvmStatic
|
||||
fun getClient(): OkHttpClient {
|
||||
return client
|
||||
}
|
||||
|
||||
fun getClientPointer(): Long {
|
||||
if (clientGlobalRef == 0L) {
|
||||
clientGlobalRef = NativeBuffer.createGlobalRef(client)
|
||||
@@ -223,6 +265,53 @@ object HttpClientManager {
|
||||
?.joinToString("; ") { "${it.name}=${it.value}" }
|
||||
}
|
||||
|
||||
fun getAuthHeaders(url: String): Map<String, String> {
|
||||
val result = mutableMapOf<String, String>()
|
||||
headers.forEach { (key, value) -> result[key] = value }
|
||||
loadCookieHeader(url)?.let { result["Cookie"] = it }
|
||||
url.toHttpUrlOrNull()?.let { httpUrl ->
|
||||
if (httpUrl.username.isNotEmpty()) {
|
||||
result["Authorization"] = Credentials.basic(httpUrl.username, httpUrl.password)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun rebuildCronetEngine(): CronetEngine {
|
||||
val old = cronetEngine!!
|
||||
cronetEngine = buildCronetEngine()
|
||||
return old
|
||||
}
|
||||
|
||||
val cronetStoragePath: File get() = cronetStorageDir
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun createDataSourceFactory(headers: Map<String, String>): DataSource.Factory {
|
||||
return if (isMtls) {
|
||||
OkHttpDataSource.Factory(client.newBuilder().cache(null).build())
|
||||
} else {
|
||||
ResolvingDataSource.Factory(
|
||||
CronetDataSource.Factory(cronetEngine!!, cronetExecutor)
|
||||
) { dataSpec ->
|
||||
val newHeaders = dataSpec.httpRequestHeaders.toMutableMap()
|
||||
newHeaders.putAll(getAuthHeaders(dataSpec.uri.toString()))
|
||||
newHeaders["Cache-Control"] = "no-store"
|
||||
dataSpec.buildUpon().setHttpRequestHeaders(newHeaders).build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCronetEngine(): CronetEngine {
|
||||
return CronetEngine.Builder(appContext)
|
||||
.enableHttp2(true)
|
||||
.enableQuic(true)
|
||||
.enableBrotli(true)
|
||||
.setStoragePath(cronetStorageDir.absolutePath)
|
||||
.setUserAgent(USER_AGENT)
|
||||
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, MEDIA_CACHE_SIZE_BYTES)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun build(cacheDir: File): OkHttpClient {
|
||||
val connectionPool = ConnectionPool(
|
||||
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
|
||||
|
||||
@@ -32,18 +32,14 @@ data class Request(
|
||||
)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
fun ImageDecoder.Source.decodeBitmap(
|
||||
target: Size = Size(0, 0),
|
||||
allocator: Int = ImageDecoder.ALLOCATOR_DEFAULT,
|
||||
colorspace: ColorSpace? = null
|
||||
): Bitmap {
|
||||
inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
|
||||
return ImageDecoder.decodeBitmap(this) { decoder, info, _ ->
|
||||
if (target.width > 0 && target.height > 0) {
|
||||
val sample = max(1, min(info.size.width / target.width, info.size.height / target.height))
|
||||
decoder.setTargetSampleSize(sample)
|
||||
}
|
||||
decoder.allocator = allocator
|
||||
decoder.setTargetColorSpace(colorspace)
|
||||
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,11 +228,7 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
|
||||
private fun decodeSource(uri: Uri, target: Size, signal: CancellationSignal): Bitmap {
|
||||
signal.throwIfCanceled()
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ImageDecoder.createSource(resolver, uri).decodeBitmap(
|
||||
target,
|
||||
ImageDecoder.ALLOCATOR_SOFTWARE,
|
||||
ColorSpace.get(ColorSpace.Named.SRGB)
|
||||
)
|
||||
ImageDecoder.createSource(resolver, uri).decodeBitmap(target)
|
||||
} else {
|
||||
val ref =
|
||||
Glide.with(ctx).asBitmap().priority(Priority.IMMEDIATE).load(uri).disallowHardwareConfig()
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
package app.alextran.immich.images
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageDecoder
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.os.OperationCanceledException
|
||||
import app.alextran.immich.INITIAL_BUFFER_SIZE
|
||||
import app.alextran.immich.NativeBuffer
|
||||
import app.alextran.immich.NativeByteBuffer
|
||||
import app.alextran.immich.core.HttpClientManager
|
||||
import app.alextran.immich.core.USER_AGENT
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.Cache
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.chromium.net.CronetEngine
|
||||
import org.chromium.net.CronetException
|
||||
import org.chromium.net.UrlRequest
|
||||
import org.chromium.net.UrlResponseInfo
|
||||
@@ -35,25 +27,6 @@ import java.nio.file.Path
|
||||
import java.nio.file.SimpleFileVisitor
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
fun NativeByteBuffer.decodeBitmap(target: android.util.Size = android.util.Size(0, 0)): Bitmap {
|
||||
try {
|
||||
val byteBuffer = NativeBuffer.wrap(pointer, offset)
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ImageDecoder.createSource(byteBuffer).decodeBitmap(target = target)
|
||||
} else {
|
||||
val bytes = ByteArray(offset)
|
||||
byteBuffer.get(bytes)
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
?: throw IOException("Failed to decode image")
|
||||
}
|
||||
} finally {
|
||||
free()
|
||||
}
|
||||
}
|
||||
|
||||
private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024
|
||||
|
||||
private class RemoteRequest(val cancellationSignal: CancellationSignal)
|
||||
|
||||
@@ -71,7 +44,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
override fun requestImage(
|
||||
url: String,
|
||||
requestId: Long,
|
||||
preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
|
||||
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
|
||||
callback: (Result<Map<String, Long>?>) -> Unit
|
||||
) {
|
||||
val signal = CancellationSignal()
|
||||
@@ -119,8 +92,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
}
|
||||
}
|
||||
|
||||
object ImageFetcherManager {
|
||||
private lateinit var appContext: Context
|
||||
private object ImageFetcherManager {
|
||||
private lateinit var cacheDir: File
|
||||
private lateinit var fetcher: ImageFetcher
|
||||
private var initialized = false
|
||||
@@ -129,7 +101,6 @@ object ImageFetcherManager {
|
||||
if (initialized) return
|
||||
synchronized(this) {
|
||||
if (initialized) return
|
||||
appContext = context.applicationContext
|
||||
cacheDir = context.cacheDir
|
||||
fetcher = build()
|
||||
HttpClientManager.addClientChangedListener(::invalidate)
|
||||
@@ -162,12 +133,12 @@ object ImageFetcherManager {
|
||||
return if (HttpClientManager.isMtls) {
|
||||
OkHttpImageFetcher.create(cacheDir)
|
||||
} else {
|
||||
CronetImageFetcher(appContext, cacheDir)
|
||||
CronetImageFetcher()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed interface ImageFetcher {
|
||||
private sealed interface ImageFetcher {
|
||||
fun fetch(
|
||||
url: String,
|
||||
signal: CancellationSignal,
|
||||
@@ -180,19 +151,11 @@ internal sealed interface ImageFetcher {
|
||||
fun clearCache(onCleared: (Result<Long>) -> Unit)
|
||||
}
|
||||
|
||||
internal class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
|
||||
private val ctx = context
|
||||
private var engine: CronetEngine
|
||||
private val executor = Executors.newFixedThreadPool(4)
|
||||
private class CronetImageFetcher : ImageFetcher {
|
||||
private val stateLock = Any()
|
||||
private var activeCount = 0
|
||||
private var draining = false
|
||||
private var onCacheCleared: ((Result<Long>) -> Unit)? = null
|
||||
private val storageDir = File(cacheDir, "cronet").apply { mkdirs() }
|
||||
|
||||
init {
|
||||
engine = build(context)
|
||||
}
|
||||
|
||||
override fun fetch(
|
||||
url: String,
|
||||
@@ -209,30 +172,16 @@ internal class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetch
|
||||
}
|
||||
|
||||
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
|
||||
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
|
||||
HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
|
||||
HttpClientManager.loadCookieHeader(url)?.let { requestBuilder.addHeader("Cookie", it) }
|
||||
url.toHttpUrlOrNull()?.let { httpUrl ->
|
||||
if (httpUrl.username.isNotEmpty()) {
|
||||
requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password))
|
||||
}
|
||||
val requestBuilder = HttpClientManager.cronetEngine!!
|
||||
.newUrlRequestBuilder(url, callback, HttpClientManager.cronetExecutor)
|
||||
HttpClientManager.getAuthHeaders(url).forEach { (key, value) ->
|
||||
requestBuilder.addHeader(key, value)
|
||||
}
|
||||
val request = requestBuilder.build()
|
||||
signal.setOnCancelListener(request::cancel)
|
||||
request.start()
|
||||
}
|
||||
|
||||
private fun build(ctx: Context): CronetEngine {
|
||||
return CronetEngine.Builder(ctx)
|
||||
.enableHttp2(true)
|
||||
.enableQuic(true)
|
||||
.enableBrotli(true)
|
||||
.setStoragePath(storageDir.absolutePath)
|
||||
.setUserAgent(USER_AGENT)
|
||||
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun onComplete() {
|
||||
val didDrain = synchronized(stateLock) {
|
||||
activeCount--
|
||||
@@ -255,19 +204,16 @@ internal class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetch
|
||||
}
|
||||
|
||||
private fun onDrained() {
|
||||
engine.shutdown()
|
||||
val onCacheCleared = synchronized(stateLock) {
|
||||
val onCacheCleared = onCacheCleared
|
||||
this.onCacheCleared = null
|
||||
onCacheCleared
|
||||
}
|
||||
if (onCacheCleared == null) {
|
||||
executor.shutdown()
|
||||
} else {
|
||||
if (onCacheCleared != null) {
|
||||
val oldEngine = HttpClientManager.rebuildCronetEngine()
|
||||
oldEngine.shutdown()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val result = runCatching { deleteFolderAndGetSize(storageDir.toPath()) }
|
||||
// Cronet is very good at self-repair, so it shouldn't fail here regardless of clear result
|
||||
engine = build(ctx)
|
||||
val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) }
|
||||
synchronized(stateLock) { draining = false }
|
||||
onCacheCleared(result)
|
||||
}
|
||||
@@ -360,7 +306,7 @@ internal class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetch
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
|
||||
suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
|
||||
var totalSize = 0L
|
||||
|
||||
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
|
||||
@@ -382,7 +328,7 @@ internal class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetch
|
||||
}
|
||||
}
|
||||
|
||||
internal class OkHttpImageFetcher private constructor(
|
||||
private class OkHttpImageFetcher private constructor(
|
||||
private val client: OkHttpClient,
|
||||
) : ImageFetcher {
|
||||
private val stateLock = Any()
|
||||
@@ -393,8 +339,8 @@ internal class OkHttpImageFetcher private constructor(
|
||||
fun create(cacheDir: File): OkHttpImageFetcher {
|
||||
val dir = File(cacheDir, "okhttp")
|
||||
|
||||
val client = HttpClientManager.client.newBuilder()
|
||||
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
|
||||
val client = HttpClientManager.getClient().newBuilder()
|
||||
.cache(Cache(File(dir, "thumbnails"), HttpClientManager.MEDIA_CACHE_SIZE_BYTES))
|
||||
.build()
|
||||
|
||||
return OkHttpImageFetcher(client)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package app.alextran.immich.widget
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import java.io.File
|
||||
|
||||
fun loadScaledBitmap(file: File, reqWidth: Int, reqHeight: Int): Bitmap? {
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
BitmapFactory.decodeFile(file.absolutePath, options)
|
||||
|
||||
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
|
||||
options.inJustDecodeBounds = false
|
||||
|
||||
return BitmapFactory.decodeFile(file.absolutePath, options)
|
||||
}
|
||||
|
||||
fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
|
||||
val (height: Int, width: Int) = options.run { outHeight to outWidth }
|
||||
var inSampleSize = 1
|
||||
|
||||
if (height > reqHeight || width > reqWidth) {
|
||||
val halfHeight: Int = height / 2
|
||||
val halfWidth: Int = width / 2
|
||||
|
||||
while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
|
||||
inSampleSize *= 2
|
||||
}
|
||||
}
|
||||
|
||||
return inSampleSize
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
package app.alextran.immich.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Log
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.glance.*
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.work.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
@@ -69,8 +75,18 @@ class ImageDownloadWorker(
|
||||
)
|
||||
}
|
||||
|
||||
fun cancel(context: Context, appWidgetId: Int) {
|
||||
suspend fun cancel(context: Context, appWidgetId: Int) {
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag("$uniqueWorkName-$appWidgetId")
|
||||
|
||||
// delete cached image
|
||||
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
|
||||
val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
|
||||
val currentImgUUID = widgetConfig[kImageUUID]
|
||||
|
||||
if (!currentImgUUID.isNullOrEmpty()) {
|
||||
val file = File(context.cacheDir, imageFilename(currentImgUUID))
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,22 +96,43 @@ class ImageDownloadWorker(
|
||||
val widgetId = inputData.getInt(kWorkerWidgetID, -1)
|
||||
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(widgetId)
|
||||
val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
|
||||
// clear state and go to "login" if no credentials
|
||||
if (!ImmichAPI.isLoggedIn(context)) {
|
||||
val currentAssetId = widgetConfig[kAssetId]
|
||||
if (!currentAssetId.isNullOrEmpty()) {
|
||||
updateWidget(glanceId, "", "", "immich://", WidgetState.LOG_IN)
|
||||
val currentImgUUID = widgetConfig[kImageUUID]
|
||||
|
||||
val serverConfig = ImmichAPI.getServerConfig(context)
|
||||
|
||||
// clear any image caches and go to "login" state if no credentials
|
||||
if (serverConfig == null) {
|
||||
if (!currentImgUUID.isNullOrEmpty()) {
|
||||
deleteImage(currentImgUUID)
|
||||
updateWidget(
|
||||
glanceId,
|
||||
"",
|
||||
"",
|
||||
"immich://",
|
||||
WidgetState.LOG_IN
|
||||
)
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
// fetch new image
|
||||
val entry = when (widgetType) {
|
||||
WidgetType.RANDOM -> fetchRandom(widgetConfig)
|
||||
WidgetType.MEMORIES -> fetchMemory()
|
||||
WidgetType.RANDOM -> fetchRandom(serverConfig, widgetConfig)
|
||||
WidgetType.MEMORIES -> fetchMemory(serverConfig)
|
||||
}
|
||||
|
||||
updateWidget(glanceId, entry.assetId, entry.subtitle, entry.deeplink)
|
||||
// clear current image if it exists
|
||||
if (!currentImgUUID.isNullOrEmpty()) {
|
||||
deleteImage(currentImgUUID)
|
||||
}
|
||||
|
||||
// save a new image
|
||||
val imgUUID = UUID.randomUUID().toString()
|
||||
saveImage(entry.image, imgUUID)
|
||||
|
||||
// trigger the update routine with new image uuid
|
||||
updateWidget(glanceId, imgUUID, entry.subtitle, entry.deeplink)
|
||||
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
@@ -110,25 +147,28 @@ class ImageDownloadWorker(
|
||||
|
||||
private suspend fun updateWidget(
|
||||
glanceId: GlanceId,
|
||||
assetId: String,
|
||||
imageUUID: String,
|
||||
subtitle: String?,
|
||||
deeplink: String?,
|
||||
widgetState: WidgetState = WidgetState.SUCCESS
|
||||
) {
|
||||
updateAppWidgetState(context, glanceId) { prefs ->
|
||||
prefs[kNow] = System.currentTimeMillis()
|
||||
prefs[kAssetId] = assetId
|
||||
prefs[kImageUUID] = imageUUID
|
||||
prefs[kWidgetState] = widgetState.toString()
|
||||
prefs[kSubtitleText] = subtitle ?: ""
|
||||
prefs[kDeeplinkURL] = deeplink ?: ""
|
||||
}
|
||||
|
||||
PhotoWidget().update(context, glanceId)
|
||||
PhotoWidget().update(context,glanceId)
|
||||
}
|
||||
|
||||
private suspend fun fetchRandom(
|
||||
serverConfig: ServerConfig,
|
||||
widgetConfig: Preferences
|
||||
): WidgetEntry {
|
||||
val api = ImmichAPI(serverConfig)
|
||||
|
||||
val filters = SearchFilters()
|
||||
val albumId = widgetConfig[kSelectedAlbum]
|
||||
val showSubtitle = widgetConfig[kShowAlbumName]
|
||||
@@ -142,27 +182,31 @@ class ImageDownloadWorker(
|
||||
filters.albumIds = listOf(albumId)
|
||||
}
|
||||
|
||||
var randomSearch = ImmichAPI.fetchSearchResults(filters)
|
||||
var randomSearch = api.fetchSearchResults(filters)
|
||||
|
||||
// handle an empty album, fallback to random
|
||||
if (randomSearch.isEmpty() && albumId != null) {
|
||||
randomSearch = ImmichAPI.fetchSearchResults(SearchFilters())
|
||||
randomSearch = api.fetchSearchResults(SearchFilters())
|
||||
subtitle = ""
|
||||
}
|
||||
|
||||
val random = randomSearch.first()
|
||||
ImmichAPI.fetchImage(random).free() // warm the HTTP disk cache
|
||||
val image = api.fetchImage(random)
|
||||
|
||||
return WidgetEntry(
|
||||
random.id,
|
||||
image,
|
||||
subtitle,
|
||||
assetDeeplink(random)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun fetchMemory(): WidgetEntry {
|
||||
private suspend fun fetchMemory(
|
||||
serverConfig: ServerConfig
|
||||
): WidgetEntry {
|
||||
val api = ImmichAPI(serverConfig)
|
||||
|
||||
val today = LocalDate.now()
|
||||
val memories = ImmichAPI.fetchMemory(today)
|
||||
val memories = api.fetchMemory(today)
|
||||
val asset: Asset
|
||||
var subtitle: String? = null
|
||||
|
||||
@@ -175,15 +219,26 @@ class ImageDownloadWorker(
|
||||
subtitle = "$yearDiff ${if (yearDiff == 1) "year" else "years"} ago"
|
||||
} else {
|
||||
val filters = SearchFilters(size=1)
|
||||
asset = ImmichAPI.fetchSearchResults(filters).first()
|
||||
asset = api.fetchSearchResults(filters).first()
|
||||
}
|
||||
|
||||
ImmichAPI.fetchImage(asset).free() // warm the HTTP disk cache
|
||||
val image = api.fetchImage(asset)
|
||||
return WidgetEntry(
|
||||
asset.id,
|
||||
image,
|
||||
subtitle,
|
||||
assetDeeplink(asset)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun deleteImage(uuid: String) = withContext(Dispatchers.IO) {
|
||||
val file = File(context.cacheDir, imageFilename(uuid))
|
||||
file.delete()
|
||||
}
|
||||
|
||||
private suspend fun saveImage(bitmap: Bitmap, uuid: String) = withContext(Dispatchers.IO) {
|
||||
val file = File(context.cacheDir, imageFilename(uuid))
|
||||
FileOutputStream(file).use { out ->
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,97 +1,122 @@
|
||||
package app.alextran.immich.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.os.CancellationSignal
|
||||
import app.alextran.immich.NativeByteBuffer
|
||||
import app.alextran.immich.core.HttpClientManager
|
||||
import app.alextran.immich.images.ImageFetcherManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import app.alextran.immich.widget.model.*
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import es.antonborri.home_widget.HomeWidgetPlugin
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
object ImmichAPI {
|
||||
class ImmichAPI(cfg: ServerConfig) {
|
||||
|
||||
companion object {
|
||||
fun getServerConfig(context: Context): ServerConfig? {
|
||||
val prefs = HomeWidgetPlugin.getData(context)
|
||||
|
||||
val serverURL = prefs.getString("widget_server_url", "") ?: ""
|
||||
val sessionKey = prefs.getString("widget_auth_token", "") ?: ""
|
||||
val customHeadersJSON = prefs.getString("widget_custom_headers", "") ?: ""
|
||||
|
||||
if (serverURL.isBlank() || sessionKey.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
var customHeaders: Map<String, String> = HashMap<String, String>()
|
||||
|
||||
if (customHeadersJSON.isNotBlank()) {
|
||||
val stringMapType = object : TypeToken<Map<String, String>>() {}.type
|
||||
customHeaders = Gson().fromJson(customHeadersJSON, stringMapType)
|
||||
}
|
||||
|
||||
return ServerConfig(
|
||||
serverURL,
|
||||
sessionKey,
|
||||
customHeaders
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private val gson = Gson()
|
||||
private val serverEndpoint: String
|
||||
get() = HttpClientManager.serverUrl ?: throw IllegalStateException("Not logged in")
|
||||
private val serverConfig = cfg
|
||||
|
||||
private fun initialize(context: Context) {
|
||||
HttpClientManager.initialize(context)
|
||||
ImageFetcherManager.initialize(context)
|
||||
}
|
||||
private fun buildRequestURL(endpoint: String, params: List<Pair<String, String>> = emptyList()): URL {
|
||||
val urlString = StringBuilder("${serverConfig.serverEndpoint}$endpoint?sessionKey=${serverConfig.sessionKey}")
|
||||
|
||||
fun isLoggedIn(context: Context): Boolean {
|
||||
initialize(context)
|
||||
return HttpClientManager.serverUrl != null
|
||||
}
|
||||
|
||||
private fun buildRequestURL(endpoint: String, params: List<Pair<String, String>> = emptyList()): String {
|
||||
val url = StringBuilder("$serverEndpoint$endpoint")
|
||||
|
||||
if (params.isNotEmpty()) {
|
||||
url.append("?")
|
||||
url.append(params.joinToString("&") { (key, value) ->
|
||||
"${java.net.URLEncoder.encode(key, "UTF-8")}=${java.net.URLEncoder.encode(value, "UTF-8")}"
|
||||
})
|
||||
for ((key, value) in params) {
|
||||
urlString.append("&${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}")
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
return URL(urlString.toString())
|
||||
}
|
||||
|
||||
private fun HttpURLConnection.applyCustomHeaders() {
|
||||
serverConfig.customHeaders.forEach { (key, value) ->
|
||||
setRequestProperty(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchSearchResults(filters: SearchFilters): List<Asset> = withContext(Dispatchers.IO) {
|
||||
val url = buildRequestURL("/search/random")
|
||||
val body = gson.toJson(filters).toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder().url(url).post(body).build()
|
||||
val connection = (url.openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
applyCustomHeaders()
|
||||
|
||||
HttpClientManager.client.newCall(request).execute().use { response ->
|
||||
val responseBody = response.body?.string() ?: throw Exception("Empty response")
|
||||
val type = object : TypeToken<List<Asset>>() {}.type
|
||||
gson.fromJson(responseBody, type)
|
||||
doOutput = true
|
||||
}
|
||||
|
||||
connection.outputStream.use {
|
||||
OutputStreamWriter(it).use { writer ->
|
||||
writer.write(gson.toJson(filters))
|
||||
writer.flush()
|
||||
}
|
||||
}
|
||||
|
||||
val response = connection.inputStream.bufferedReader().readText()
|
||||
val type = object : TypeToken<List<Asset>>() {}.type
|
||||
gson.fromJson(response, type)
|
||||
}
|
||||
|
||||
suspend fun fetchMemory(date: LocalDate): List<MemoryResult> = withContext(Dispatchers.IO) {
|
||||
val iso8601 = date.format(DateTimeFormatter.ISO_LOCAL_DATE)
|
||||
val url = buildRequestURL("/memories", listOf("for" to iso8601))
|
||||
val request = Request.Builder().url(url).get().build()
|
||||
|
||||
HttpClientManager.client.newCall(request).execute().use { response ->
|
||||
val responseBody = response.body?.string() ?: throw Exception("Empty response")
|
||||
val type = object : TypeToken<List<MemoryResult>>() {}.type
|
||||
gson.fromJson(responseBody, type)
|
||||
val connection = (url.openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
applyCustomHeaders()
|
||||
}
|
||||
|
||||
val response = connection.inputStream.bufferedReader().readText()
|
||||
val type = object : TypeToken<List<MemoryResult>>() {}.type
|
||||
gson.fromJson(response, type)
|
||||
}
|
||||
|
||||
suspend fun fetchImage(asset: Asset): NativeByteBuffer = suspendCancellableCoroutine { cont ->
|
||||
suspend fun fetchImage(asset: Asset): Bitmap = withContext(Dispatchers.IO) {
|
||||
val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview", "edited" to "true"))
|
||||
val signal = CancellationSignal()
|
||||
cont.invokeOnCancellation { signal.cancel() }
|
||||
|
||||
ImageFetcherManager.fetch(
|
||||
url,
|
||||
signal,
|
||||
onSuccess = { buffer -> cont.resume(buffer) },
|
||||
onFailure = { e -> cont.resumeWithException(e) }
|
||||
)
|
||||
val connection = url.openConnection()
|
||||
val data = connection.getInputStream().readBytes()
|
||||
BitmapFactory.decodeByteArray(data, 0, data.size)
|
||||
?: throw Exception("Invalid image data")
|
||||
}
|
||||
|
||||
suspend fun fetchAlbums(): List<Album> = withContext(Dispatchers.IO) {
|
||||
val url = buildRequestURL("/albums")
|
||||
val request = Request.Builder().url(url).get().build()
|
||||
|
||||
HttpClientManager.client.newCall(request).execute().use { response ->
|
||||
val responseBody = response.body?.string() ?: throw Exception("Empty response")
|
||||
val type = object : TypeToken<List<Album>>() {}.type
|
||||
gson.fromJson(responseBody, type)
|
||||
val connection = (url.openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
applyCustomHeaders()
|
||||
}
|
||||
|
||||
val response = connection.inputStream.bufferedReader().readText()
|
||||
val type = object : TypeToken<List<Album>>() {}.type
|
||||
gson.fromJson(response, type)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package app.alextran.immich.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import app.alextran.immich.widget.model.*
|
||||
import es.antonborri.home_widget.HomeWidgetPlugin
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MemoryReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget = PhotoWidget()
|
||||
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray
|
||||
) {
|
||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||
|
||||
appWidgetIds.forEach { widgetID ->
|
||||
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.MEMORIES)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false)
|
||||
val provider = ComponentName(context, MemoryReceiver::class.java)
|
||||
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
|
||||
|
||||
// Launch coroutine to setup a single shot if the app requested the update
|
||||
if (fromMainApp) {
|
||||
glanceIds.forEach { widgetID ->
|
||||
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.MEMORIES)
|
||||
}
|
||||
}
|
||||
|
||||
// make sure the periodic jobs are running
|
||||
glanceIds.forEach { widgetID ->
|
||||
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.MEMORIES)
|
||||
}
|
||||
|
||||
super.onReceive(context, intent)
|
||||
}
|
||||
|
||||
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||
super.onDeleted(context, appWidgetIds)
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
appWidgetIds.forEach { id ->
|
||||
ImageDownloadWorker.cancel(context, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ package app.alextran.immich.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Size
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.core.net.toUri
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.glance.appwidget.*
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.*
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.layout.*
|
||||
@@ -18,28 +18,30 @@ import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import app.alextran.immich.R
|
||||
import app.alextran.immich.images.decodeBitmap
|
||||
import app.alextran.immich.widget.model.*
|
||||
import java.io.File
|
||||
|
||||
class PhotoWidget : GlanceAppWidget() {
|
||||
override var stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)
|
||||
val assetId = state[kAssetId]
|
||||
val subtitle = state[kSubtitleText]
|
||||
val deeplinkURL = state[kDeeplinkURL]?.toUri()
|
||||
val widgetState = state[kWidgetState]
|
||||
|
||||
val bitmap = if (!assetId.isNullOrEmpty() && ImmichAPI.isLoggedIn(context)) {
|
||||
try {
|
||||
ImmichAPI.fetchImage(Asset(assetId, AssetType.IMAGE)).decodeBitmap(Size(500, 500))
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
} else null
|
||||
|
||||
provideContent {
|
||||
val prefs = currentState<MutablePreferences>()
|
||||
|
||||
val imageUUID = prefs[kImageUUID]
|
||||
val subtitle = prefs[kSubtitleText]
|
||||
val deeplinkURL = prefs[kDeeplinkURL]?.toUri()
|
||||
val widgetState = prefs[kWidgetState]
|
||||
var bitmap: Bitmap? = null
|
||||
|
||||
if (imageUUID != null) {
|
||||
// fetch a random photo from server
|
||||
val file = File(context.cacheDir, imageFilename(imageUUID))
|
||||
|
||||
if (file.exists()) {
|
||||
bitmap = loadScaledBitmap(file, 500, 500)
|
||||
}
|
||||
}
|
||||
|
||||
// WIDGET CONTENT
|
||||
Box(
|
||||
|
||||
@@ -4,11 +4,14 @@ import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import es.antonborri.home_widget.HomeWidgetPlugin
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import app.alextran.immich.widget.model.*
|
||||
import es.antonborri.home_widget.HomeWidgetPlugin
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
abstract class WidgetReceiver(private val widgetType: WidgetType) : GlanceAppWidgetReceiver() {
|
||||
class RandomReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget = PhotoWidget()
|
||||
|
||||
override fun onUpdate(
|
||||
@@ -19,25 +22,25 @@ abstract class WidgetReceiver(private val widgetType: WidgetType) : GlanceAppWid
|
||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||
|
||||
appWidgetIds.forEach { widgetID ->
|
||||
ImageDownloadWorker.enqueuePeriodic(context, widgetID, widgetType)
|
||||
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false)
|
||||
val provider = ComponentName(context, this::class.java)
|
||||
val provider = ComponentName(context, RandomReceiver::class.java)
|
||||
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
|
||||
|
||||
// Launch coroutine to setup a single shot if the app requested the update
|
||||
if (fromMainApp) {
|
||||
glanceIds.forEach { widgetID ->
|
||||
ImageDownloadWorker.singleShot(context, widgetID, widgetType)
|
||||
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.RANDOM)
|
||||
}
|
||||
}
|
||||
|
||||
// make sure the periodic jobs are running
|
||||
glanceIds.forEach { widgetID ->
|
||||
ImageDownloadWorker.enqueuePeriodic(context, widgetID, widgetType)
|
||||
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM)
|
||||
}
|
||||
|
||||
super.onReceive(context, intent)
|
||||
@@ -45,12 +48,10 @@ abstract class WidgetReceiver(private val widgetType: WidgetType) : GlanceAppWid
|
||||
|
||||
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||
super.onDeleted(context, appWidgetIds)
|
||||
appWidgetIds.forEach { id ->
|
||||
ImageDownloadWorker.cancel(context, id)
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
appWidgetIds.forEach { id ->
|
||||
ImageDownloadWorker.cancel(context, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryReceiver : WidgetReceiver(WidgetType.MEMORIES)
|
||||
|
||||
class RandomReceiver : WidgetReceiver(WidgetType.RANDOM)
|
||||
@@ -71,18 +71,22 @@ fun RandomConfiguration(context: Context, appWidgetId: Int, glanceId: GlanceId,
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// get albums from server
|
||||
if (!ImmichAPI.isLoggedIn(context)) {
|
||||
val serverCfg = ImmichAPI.getServerConfig(context)
|
||||
|
||||
if (serverCfg == null) {
|
||||
state = WidgetConfigState.LOG_IN
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val api = ImmichAPI(serverCfg)
|
||||
|
||||
val currentState = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
|
||||
val currentAlbumId = currentState[kSelectedAlbum] ?: "NONE"
|
||||
val currentAlbumName = currentState[kSelectedAlbumName] ?: "None"
|
||||
var albumItems: List<DropdownItem>
|
||||
|
||||
try {
|
||||
albumItems = ImmichAPI.fetchAlbums().map {
|
||||
albumItems = api.fetchAlbums().map {
|
||||
DropdownItem(it.albumName, it.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.alextran.immich.widget.model
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.datastore.preferences.core.*
|
||||
|
||||
// MARK: Immich Entities
|
||||
@@ -49,13 +50,19 @@ enum class WidgetConfigState {
|
||||
}
|
||||
|
||||
data class WidgetEntry (
|
||||
val assetId: String,
|
||||
val image: Bitmap,
|
||||
val subtitle: String?,
|
||||
val deeplink: String?
|
||||
)
|
||||
|
||||
data class ServerConfig(
|
||||
val serverEndpoint: String,
|
||||
val sessionKey: String,
|
||||
val customHeaders: Map<String, String>
|
||||
)
|
||||
|
||||
// MARK: Widget State Keys
|
||||
val kAssetId = stringPreferencesKey("assetId")
|
||||
val kImageUUID = stringPreferencesKey("uuid")
|
||||
val kSubtitleText = stringPreferencesKey("subtitle")
|
||||
val kNow = longPreferencesKey("now")
|
||||
val kWidgetState = stringPreferencesKey("state")
|
||||
@@ -68,6 +75,10 @@ const val kWorkerWidgetType = "widgetType"
|
||||
const val kWorkerWidgetID = "widgetId"
|
||||
const val kTriggeredFromApp = "triggeredFromApp"
|
||||
|
||||
fun imageFilename(id: String): String {
|
||||
return "widget_image_$id.jpg"
|
||||
}
|
||||
|
||||
fun assetDeeplink(asset: Asset): String {
|
||||
return "immich://asset?id=${asset.id}"
|
||||
}
|
||||
|
||||
@@ -140,13 +140,6 @@
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
A872EC0CA71550E4AB04E049 /* Shared */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B231F52D2E93A44A00BC45D1 /* Core */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
@@ -264,7 +257,6 @@
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
0FB772A5B9601143383626CA /* Pods */,
|
||||
1754452DD81DA6620E279E51 /* Frameworks */,
|
||||
A872EC0CA71550E4AB04E049 /* Shared */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -370,7 +362,6 @@
|
||||
F0B57D482DF764BE00DC5BCC /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A872EC0CA71550E4AB04E049 /* Shared */,
|
||||
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||
FEE084F22EC172080045228E /* Schemas */,
|
||||
@@ -393,7 +384,6 @@
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A872EC0CA71550E4AB04E049 /* Shared */,
|
||||
F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */,
|
||||
);
|
||||
name = WidgetExtension;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import BackgroundTasks
|
||||
import Flutter
|
||||
import native_video_player
|
||||
import network_info_plus
|
||||
import path_provider_foundation
|
||||
import permission_handler_apple
|
||||
@@ -18,6 +19,8 @@ import UIKit
|
||||
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
||||
}
|
||||
|
||||
SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage
|
||||
URLSessionManager.patchBackgroundDownloader()
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
|
||||
AppDelegate.registerPlugins(with: controller.engine, controller: controller)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import Foundation
|
||||
#if canImport(native_video_player)
|
||||
import native_video_player
|
||||
#endif
|
||||
|
||||
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
|
||||
let HEADERS_KEY = "immich.request_headers"
|
||||
@@ -38,7 +36,7 @@ extension UserDefaults {
|
||||
/// Old sessions are kept alive by Dart's FFI retain until all isolates release them.
|
||||
class URLSessionManager: NSObject {
|
||||
static let shared = URLSessionManager()
|
||||
|
||||
|
||||
private(set) var session: URLSession
|
||||
let delegate: URLSessionManagerDelegate
|
||||
private static let cacheDir: URL = {
|
||||
@@ -53,7 +51,7 @@ class URLSessionManager: NSObject {
|
||||
diskCapacity: 1024 * 1024 * 1024,
|
||||
directory: cacheDir
|
||||
)
|
||||
private static let userAgent: String = {
|
||||
static let userAgent: String = {
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
||||
return "Immich_iOS_\(version)"
|
||||
}()
|
||||
@@ -146,7 +144,7 @@ class URLSessionManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private static func buildSession(delegate: URLSessionDelegate) -> URLSession {
|
||||
private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.urlCache = urlCache
|
||||
config.httpCookieStorage = cookieStorage
|
||||
@@ -160,6 +158,49 @@ class URLSessionManager: NSObject {
|
||||
|
||||
return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
||||
}
|
||||
|
||||
/// Patches background_downloader's URLSession to use shared auth configuration.
|
||||
/// Must be called before background_downloader creates its session (i.e. early in app startup).
|
||||
static func patchBackgroundDownloader() {
|
||||
// Swizzle URLSessionConfiguration.background(withIdentifier:) to inject shared config
|
||||
let originalSel = NSSelectorFromString("backgroundSessionConfigurationWithIdentifier:")
|
||||
let swizzledSel = #selector(URLSessionConfiguration.immich_background(withIdentifier:))
|
||||
if let original = class_getClassMethod(URLSessionConfiguration.self, originalSel),
|
||||
let swizzled = class_getClassMethod(URLSessionConfiguration.self, swizzledSel) {
|
||||
method_exchangeImplementations(original, swizzled)
|
||||
}
|
||||
|
||||
// Add auth challenge handling to background_downloader's UrlSessionDelegate
|
||||
guard let targetClass = NSClassFromString("background_downloader.UrlSessionDelegate") else { return }
|
||||
|
||||
let sessionBlock: @convention(block) (AnyObject, URLSession, URLAuthenticationChallenge,
|
||||
@escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void
|
||||
= { _, session, challenge, completion in
|
||||
URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion)
|
||||
}
|
||||
class_replaceMethod(targetClass,
|
||||
NSSelectorFromString("URLSession:didReceiveChallenge:completionHandler:"),
|
||||
imp_implementationWithBlock(sessionBlock), "v@:@@@?")
|
||||
|
||||
let taskBlock: @convention(block) (AnyObject, URLSession, URLSessionTask, URLAuthenticationChallenge,
|
||||
@escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void
|
||||
= { _, session, task, challenge, completion in
|
||||
URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion, task: task)
|
||||
}
|
||||
class_replaceMethod(targetClass,
|
||||
NSSelectorFromString("URLSession:task:didReceiveChallenge:completionHandler:"),
|
||||
imp_implementationWithBlock(taskBlock), "v@:@@@@?")
|
||||
}
|
||||
}
|
||||
|
||||
private extension URLSessionConfiguration {
|
||||
@objc dynamic class func immich_background(withIdentifier id: String) -> URLSessionConfiguration {
|
||||
// After swizzle, this calls the original implementation
|
||||
let config = immich_background(withIdentifier: id)
|
||||
config.httpCookieStorage = URLSessionManager.cookieStorage
|
||||
config.httpAdditionalHeaders = ["User-Agent": URLSessionManager.userAgent]
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate {
|
||||
@@ -209,11 +250,9 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
|
||||
let credential = URLCredential(identity: identity as! SecIdentity,
|
||||
certificates: nil,
|
||||
persistence: .forSession)
|
||||
#if canImport(native_video_player)
|
||||
if #available(iOS 15, *) {
|
||||
VideoProxyServer.shared.session = session
|
||||
}
|
||||
#endif
|
||||
return completion(.useCredential, credential)
|
||||
}
|
||||
completion(.performDefaultHandling, nil)
|
||||
@@ -230,11 +269,9 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
|
||||
else {
|
||||
return completion(.performDefaultHandling, nil)
|
||||
}
|
||||
#if canImport(native_video_player)
|
||||
if #available(iOS 15, *) {
|
||||
VideoProxyServer.shared.session = session
|
||||
}
|
||||
#endif
|
||||
let credential = URLCredential(user: user, password: password, persistence: .forSession)
|
||||
completion(.useCredential, credential)
|
||||
}
|
||||
@@ -9,7 +9,6 @@ struct ImageEntry: TimelineEntry {
|
||||
var metadata: Metadata = Metadata()
|
||||
|
||||
struct Metadata: Codable {
|
||||
var assetId: String? = nil
|
||||
var subtitle: String? = nil
|
||||
var error: WidgetError? = nil
|
||||
var deepLink: URL? = nil
|
||||
@@ -34,39 +33,80 @@ struct ImageEntry: TimelineEntry {
|
||||
date: entryDate,
|
||||
image: image,
|
||||
metadata: EntryMetadata(
|
||||
assetId: asset.id,
|
||||
subtitle: subtitle,
|
||||
deepLink: asset.deepLink
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
static func saveLast(for key: String, metadata: Metadata) {
|
||||
if let data = try? JSONEncoder().encode(metadata) {
|
||||
UserDefaults.group.set(data, forKey: "widget_last_\(key)")
|
||||
func cache(for key: String) throws {
|
||||
if let containerURL = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
|
||||
) {
|
||||
let imageURL = containerURL.appendingPathComponent("\(key)_image.png")
|
||||
let metadataURL = containerURL.appendingPathComponent(
|
||||
"\(key)_metadata.json"
|
||||
)
|
||||
|
||||
// build metadata JSON
|
||||
let entryMetadata = try JSONEncoder().encode(self.metadata)
|
||||
|
||||
// write to disk
|
||||
try self.image?.pngData()?.write(to: imageURL, options: .atomic)
|
||||
try entryMetadata.write(to: metadataURL, options: .atomic)
|
||||
}
|
||||
}
|
||||
|
||||
static func loadCached(for key: String, at date: Date = Date.now)
|
||||
-> ImageEntry?
|
||||
{
|
||||
if let containerURL = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
|
||||
) {
|
||||
let imageURL = containerURL.appendingPathComponent("\(key)_image.png")
|
||||
let metadataURL = containerURL.appendingPathComponent(
|
||||
"\(key)_metadata.json"
|
||||
)
|
||||
|
||||
guard let imageData = try? Data(contentsOf: imageURL),
|
||||
let metadataJSON = try? Data(contentsOf: metadataURL),
|
||||
let decodedMetadata = try? JSONDecoder().decode(
|
||||
Metadata.self,
|
||||
from: metadataJSON
|
||||
)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ImageEntry(
|
||||
date: date,
|
||||
image: UIImage(data: imageData),
|
||||
metadata: decodedMetadata
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func handleError(
|
||||
for key: String,
|
||||
api: ImmichAPI? = nil,
|
||||
error: WidgetError = .fetchFailed
|
||||
) async -> Timeline<ImageEntry> {
|
||||
// Try to show the last image from the URL cache for transient failures
|
||||
if error == .fetchFailed, let api = api,
|
||||
let data = UserDefaults.group.data(forKey: "widget_last_\(key)"),
|
||||
let cached = try? JSONDecoder().decode(Metadata.self, from: data),
|
||||
let assetId = cached.assetId,
|
||||
let image = try? await api.fetchImage(asset: Asset(id: assetId, type: .image))
|
||||
) -> Timeline<ImageEntry> {
|
||||
var timelineEntry = ImageEntry(
|
||||
date: Date.now,
|
||||
image: nil,
|
||||
metadata: EntryMetadata(error: error)
|
||||
)
|
||||
|
||||
// use cache if generic failed error
|
||||
// we want to show the other errors to the user since without intervention,
|
||||
// it will never succeed
|
||||
if error == .fetchFailed, let cachedEntry = ImageEntry.loadCached(for: key)
|
||||
{
|
||||
let entry = ImageEntry(date: Date.now, image: image, metadata: cached)
|
||||
return Timeline(entries: [entry], policy: .atEnd)
|
||||
timelineEntry = cachedEntry
|
||||
}
|
||||
|
||||
return Timeline(
|
||||
entries: [ImageEntry(date: Date.now, metadata: Metadata(error: error))],
|
||||
policy: .atEnd
|
||||
)
|
||||
return Timeline(entries: [timelineEntry], policy: .atEnd)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// Constants and session configuration are in Shared/SharedURLSession.swift
|
||||
let IMMICH_SHARE_GROUP = "group.app.immich.share"
|
||||
|
||||
enum WidgetError: Error, Codable {
|
||||
case noLogin
|
||||
@@ -104,48 +104,87 @@ struct Album: Codable, Equatable {
|
||||
// MARK: API
|
||||
|
||||
class ImmichAPI {
|
||||
let serverEndpoint: String
|
||||
typealias CustomHeaders = [String:String]
|
||||
struct ServerConfig {
|
||||
let serverEndpoint: String
|
||||
let sessionKey: String
|
||||
let customHeaders: CustomHeaders
|
||||
}
|
||||
|
||||
let serverConfig: ServerConfig
|
||||
|
||||
init() async throws {
|
||||
guard let serverURLs = UserDefaults.group.stringArray(forKey: SERVER_URLS_KEY),
|
||||
let serverURL = serverURLs.first,
|
||||
!serverURL.isEmpty
|
||||
// fetch the credentials from the UserDefaults store that dart placed here
|
||||
guard let defaults = UserDefaults(suiteName: IMMICH_SHARE_GROUP),
|
||||
let serverURL = defaults.string(forKey: "widget_server_url"),
|
||||
let sessionKey = defaults.string(forKey: "widget_auth_token")
|
||||
else {
|
||||
throw WidgetError.noLogin
|
||||
}
|
||||
|
||||
serverEndpoint = serverURL
|
||||
if serverURL == "" || sessionKey == "" {
|
||||
throw WidgetError.noLogin
|
||||
}
|
||||
|
||||
// custom headers come in the form of KV pairs in JSON
|
||||
var customHeadersJSON = (defaults.string(forKey: "widget_custom_headers") ?? "")
|
||||
var customHeaders: CustomHeaders = [:]
|
||||
|
||||
if customHeadersJSON != "",
|
||||
let parsedHeaders = try? JSONDecoder().decode(CustomHeaders.self, from: customHeadersJSON.data(using: .utf8)!) {
|
||||
customHeaders = parsedHeaders
|
||||
}
|
||||
|
||||
serverConfig = ServerConfig(
|
||||
serverEndpoint: serverURL,
|
||||
sessionKey: sessionKey,
|
||||
customHeaders: customHeaders
|
||||
)
|
||||
}
|
||||
|
||||
private func buildRequestURL(
|
||||
serverConfig: ServerConfig,
|
||||
endpoint: String,
|
||||
params: [URLQueryItem] = []
|
||||
) throws(FetchError) -> URL? {
|
||||
guard let baseURL = URL(string: serverEndpoint) else {
|
||||
throw FetchError.invalidURL
|
||||
) -> URL? {
|
||||
guard let baseURL = URL(string: serverConfig.serverEndpoint) else {
|
||||
fatalError("Invalid base URL")
|
||||
}
|
||||
|
||||
// Combine the base URL and API path
|
||||
let fullPath = baseURL.appendingPathComponent(
|
||||
endpoint.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
)
|
||||
|
||||
// Add the session key as a query parameter
|
||||
var components = URLComponents(
|
||||
url: fullPath,
|
||||
resolvingAgainstBaseURL: false
|
||||
)
|
||||
if !params.isEmpty {
|
||||
components?.queryItems = params
|
||||
}
|
||||
components?.queryItems = [
|
||||
URLQueryItem(name: "sessionKey", value: serverConfig.sessionKey)
|
||||
]
|
||||
components?.queryItems?.append(contentsOf: params)
|
||||
|
||||
return components?.url
|
||||
}
|
||||
|
||||
func applyCustomHeaders(for request: inout URLRequest) {
|
||||
for (header, value) in serverConfig.customHeaders {
|
||||
request.addValue(value, forHTTPHeaderField: header)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchSearchResults(with filters: SearchFilter = Album.NONE.filter)
|
||||
async throws
|
||||
-> [Asset]
|
||||
{
|
||||
// get URL
|
||||
guard
|
||||
let searchURL = try buildRequestURL(endpoint: "/search/random")
|
||||
let searchURL = buildRequestURL(
|
||||
serverConfig: serverConfig,
|
||||
endpoint: "/search/random"
|
||||
)
|
||||
else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
@@ -154,15 +193,20 @@ class ImmichAPI {
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = try JSONEncoder().encode(filters)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
applyCustomHeaders(for: &request)
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
|
||||
let (data, _) = try await URLSessionManager.shared.session.data(for: request)
|
||||
// decode data
|
||||
return try JSONDecoder().decode([Asset].self, from: data)
|
||||
}
|
||||
|
||||
func fetchMemory(for date: Date) async throws -> [MemoryResult] {
|
||||
// get URL
|
||||
let memoryParams = [URLQueryItem(name: "for", value: date.ISO8601Format())]
|
||||
guard
|
||||
let searchURL = try buildRequestURL(
|
||||
let searchURL = buildRequestURL(
|
||||
serverConfig: serverConfig,
|
||||
endpoint: "/memories",
|
||||
params: memoryParams
|
||||
)
|
||||
@@ -172,8 +216,11 @@ class ImmichAPI {
|
||||
|
||||
var request = URLRequest(url: searchURL)
|
||||
request.httpMethod = "GET"
|
||||
applyCustomHeaders(for: &request)
|
||||
|
||||
let (data, _) = try await URLSessionManager.shared.session.data(for: request)
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
|
||||
// decode data
|
||||
return try JSONDecoder().decode([MemoryResult].self, from: data)
|
||||
}
|
||||
|
||||
@@ -182,7 +229,8 @@ class ImmichAPI {
|
||||
let assetEndpoint = "/assets/" + asset.id + "/thumbnail"
|
||||
|
||||
guard
|
||||
let fetchURL = try buildRequestURL(
|
||||
let fetchURL = buildRequestURL(
|
||||
serverConfig: serverConfig,
|
||||
endpoint: assetEndpoint,
|
||||
params: thumbnailParams
|
||||
)
|
||||
@@ -190,13 +238,9 @@ class ImmichAPI {
|
||||
throw .invalidURL
|
||||
}
|
||||
|
||||
let request = URLRequest(url: fetchURL)
|
||||
guard let (data, _) = try? await URLSessionManager.shared.session.data(for: request) else {
|
||||
throw .fetchFailed
|
||||
}
|
||||
|
||||
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else {
|
||||
throw .invalidImage
|
||||
guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil)
|
||||
else {
|
||||
throw .invalidURL
|
||||
}
|
||||
|
||||
let decodeOptions: [NSString: Any] = [
|
||||
@@ -219,16 +263,23 @@ class ImmichAPI {
|
||||
}
|
||||
|
||||
func fetchAlbums() async throws -> [Album] {
|
||||
// get URL
|
||||
guard
|
||||
let searchURL = try buildRequestURL(endpoint: "/albums")
|
||||
let searchURL = buildRequestURL(
|
||||
serverConfig: serverConfig,
|
||||
endpoint: "/albums"
|
||||
)
|
||||
else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: searchURL)
|
||||
request.httpMethod = "GET"
|
||||
applyCustomHeaders(for: &request)
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
|
||||
let (data, _) = try await URLSessionManager.shared.session.data(for: request)
|
||||
// decode data
|
||||
return try JSONDecoder().decode([Album].self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
23
mobile/ios/WidgetExtension/UIImage+Resize.swift
Normal file
23
mobile/ios/WidgetExtension/UIImage+Resize.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Utils.swift
|
||||
// Runner
|
||||
//
|
||||
// Created by Alex Tran and Brandon Wees on 6/16/25.
|
||||
//
|
||||
import UIKit
|
||||
|
||||
extension UIImage {
|
||||
/// Crops the image to ensure width and height do not exceed maxSize.
|
||||
/// Keeps original aspect ratio and crops excess equally from edges (center crop).
|
||||
func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? {
|
||||
let canvas = CGSize(
|
||||
width: width,
|
||||
height: CGFloat(ceil(width / size.width * size.height))
|
||||
)
|
||||
let format = imageRendererFormat
|
||||
format.opaque = isOpaque
|
||||
return UIGraphicsImageRenderer(size: canvas, format: format).image {
|
||||
_ in draw(in: CGRect(origin: .zero, size: canvas))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,14 +24,14 @@ struct ImmichMemoryProvider: TimelineProvider {
|
||||
Task {
|
||||
guard let api = try? await ImmichAPI() else {
|
||||
completion(
|
||||
await ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first!
|
||||
ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first!
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard let memories = try? await api.fetchMemory(for: Date.now)
|
||||
else {
|
||||
completion(await ImageEntry.handleError(for: cacheKey, api: api).entries.first!)
|
||||
completion(ImageEntry.handleError(for: cacheKey).entries.first!)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ struct ImmichMemoryProvider: TimelineProvider {
|
||||
dateOffset: 0
|
||||
)
|
||||
else {
|
||||
completion(await ImageEntry.handleError(for: cacheKey, api: api).entries.first!)
|
||||
completion(ImageEntry.handleError(for: cacheKey).entries.first!)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ struct ImmichMemoryProvider: TimelineProvider {
|
||||
|
||||
guard let api = try? await ImmichAPI() else {
|
||||
completion(
|
||||
await ImageEntry.handleError(for: cacheKey, error: .noLogin)
|
||||
ImageEntry.handleError(for: cacheKey, error: .noLogin)
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -129,20 +129,20 @@ struct ImmichMemoryProvider: TimelineProvider {
|
||||
// Load or save a cached asset for when network conditions are bad
|
||||
if search.count == 0 {
|
||||
completion(
|
||||
await ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
|
||||
ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
entries.append(contentsOf: search)
|
||||
} catch {
|
||||
completion(await ImageEntry.handleError(for: cacheKey, api: api))
|
||||
completion(ImageEntry.handleError(for: cacheKey))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// save the last asset for fallback
|
||||
ImageEntry.saveLast(for: cacheKey, metadata: entries.last!.metadata)
|
||||
// cache the last image
|
||||
try? entries.last!.cache(for: cacheKey)
|
||||
|
||||
completion(Timeline(entries: entries, policy: .atEnd))
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
|
||||
let cacheKey = "random_none_\(context.family.rawValue)"
|
||||
|
||||
guard let api = try? await ImmichAPI() else {
|
||||
return await ImageEntry.handleError(for: cacheKey, error: .noLogin).entries
|
||||
return ImageEntry.handleError(for: cacheKey, error: .noLogin).entries
|
||||
.first!
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
|
||||
dateOffset: 0
|
||||
)
|
||||
else {
|
||||
return await ImageEntry.handleError(for: cacheKey, api: api).entries.first!
|
||||
return ImageEntry.handleError(for: cacheKey).entries.first!
|
||||
}
|
||||
|
||||
return entry
|
||||
@@ -102,7 +102,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
|
||||
|
||||
// If we don't have a server config, return an entry with an error
|
||||
guard let api = try? await ImmichAPI() else {
|
||||
return await ImageEntry.handleError(for: cacheKey, error: .noLogin)
|
||||
return ImageEntry.handleError(for: cacheKey, error: .noLogin)
|
||||
}
|
||||
|
||||
// build entries
|
||||
@@ -119,16 +119,16 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
|
||||
|
||||
// Load or save a cached asset for when network conditions are bad
|
||||
if search.count == 0 {
|
||||
return await ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
|
||||
return ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
|
||||
}
|
||||
|
||||
entries.append(contentsOf: search)
|
||||
} catch {
|
||||
return await ImageEntry.handleError(for: cacheKey, api: api)
|
||||
return ImageEntry.handleError(for: cacheKey)
|
||||
}
|
||||
|
||||
// save the last asset for fallback
|
||||
ImageEntry.saveLast(for: cacheKey, metadata: entries.last!.metadata)
|
||||
// cache the last image
|
||||
try? entries.last!.cache(for: cacheKey)
|
||||
|
||||
return Timeline(entries: entries, policy: .atEnd)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,12 @@ const int kTimelineNoneSegmentSize = 120;
|
||||
const int kTimelineAssetLoadBatchSize = 1024;
|
||||
const int kTimelineAssetLoadOppositeSize = 64;
|
||||
|
||||
// Widget keys
|
||||
const String appShareGroupId = "group.app.immich.share";
|
||||
const String kWidgetAuthToken = "widget_auth_token";
|
||||
const String kWidgetServerEndpoint = "widget_server_url";
|
||||
const String kWidgetCustomHeaders = "widget_custom_headers";
|
||||
|
||||
// add widget identifiers here for new widgets
|
||||
// these are used to force a widget refresh
|
||||
// (iOSName, androidFQDN)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class OpenInBrowserActionButton extends ConsumerWidget {
|
||||
final String remoteId;
|
||||
final TimelineOrigin origin;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
final Color? iconColor;
|
||||
|
||||
const OpenInBrowserActionButton({
|
||||
super.key,
|
||||
required this.remoteId,
|
||||
required this.origin,
|
||||
this.iconOnly = false,
|
||||
this.menuItem = false,
|
||||
this.iconColor,
|
||||
});
|
||||
|
||||
void _onTap() async {
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint).replaceFirst('/api', '');
|
||||
|
||||
String originPath = '';
|
||||
switch (origin) {
|
||||
case TimelineOrigin.favorite:
|
||||
originPath = '/favorites';
|
||||
break;
|
||||
case TimelineOrigin.trash:
|
||||
originPath = '/trash';
|
||||
break;
|
||||
case TimelineOrigin.archive:
|
||||
originPath = '/archive';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
final url = '$serverEndpoint$originPath/photos/$remoteId';
|
||||
if (await canLaunchUrl(Uri.parse(url))) {
|
||||
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
label: 'open_in_browser'.t(context: context),
|
||||
iconData: Icons.open_in_browser,
|
||||
iconColor: iconColor,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: _onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -113,17 +113,14 @@ class _AppBarBackButton extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||
final backgroundColor = showingDetails && !context.isDarkTheme ? Colors.white : Colors.black;
|
||||
final foregroundColor = showingDetails && !context.isDarkTheme ? Colors.black : Colors.white;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 12.0),
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: backgroundColor,
|
||||
backgroundColor: showingDetails ? context.colorScheme.surface : Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
iconSize: 22,
|
||||
iconColor: foregroundColor,
|
||||
iconColor: showingDetails ? context.colorScheme.onSurface : Colors.white,
|
||||
padding: EdgeInsets.zero,
|
||||
elevation: showingDetails ? 4 : 0,
|
||||
),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_udid/flutter_udid.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
@@ -89,8 +87,9 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
await _secureStorageService.delete(kSecuredPinCode);
|
||||
await _widgetService.clearCredentials();
|
||||
|
||||
await _authService.logout();
|
||||
unawaited(_widgetService.refreshWidgets());
|
||||
await _ref.read(backgroundUploadServiceProvider).cancel();
|
||||
_ref.read(foregroundUploadServiceProvider).cancel();
|
||||
} finally {
|
||||
@@ -127,7 +126,9 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
await Store.put(StoreKey.accessToken, accessToken);
|
||||
await _apiService.updateHeaders();
|
||||
|
||||
unawaited(_widgetService.refreshWidgets());
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final customHeaders = Store.tryGet(StoreKey.customHeaders);
|
||||
await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders);
|
||||
|
||||
// Get the deviceid from the store if it exists, otherwise generate a new one
|
||||
String deviceId = Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
|
||||
|
||||
20
mobile/lib/repositories/widget.repository.dart
Normal file
20
mobile/lib/repositories/widget.repository.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:home_widget/home_widget.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
final widgetRepositoryProvider = Provider((_) => const WidgetRepository());
|
||||
|
||||
class WidgetRepository {
|
||||
const WidgetRepository();
|
||||
|
||||
Future<void> saveData(String key, String value) async {
|
||||
await HomeWidget.saveWidgetData<String>(key, value);
|
||||
}
|
||||
|
||||
Future<void> refresh(String iosName, String androidName) async {
|
||||
await HomeWidget.updateWidget(iOSName: iosName, qualifiedAndroidName: androidName);
|
||||
}
|
||||
|
||||
Future<void> setAppGroupId(String appGroupId) async {
|
||||
await HomeWidget.setAppGroupId(appGroupId);
|
||||
}
|
||||
}
|
||||
@@ -176,10 +176,6 @@ class ApiService {
|
||||
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
|
||||
urls.add(serverEndpoint);
|
||||
}
|
||||
final serverUrl = Store.tryGet(StoreKey.serverUrl);
|
||||
if (serverUrl != null && serverUrl.isNotEmpty) {
|
||||
urls.add(serverUrl);
|
||||
}
|
||||
final localEndpoint = Store.tryGet(StoreKey.localEndpoint);
|
||||
if (localEndpoint != null && localEndpoint.isNotEmpty) {
|
||||
urls.add(localEndpoint);
|
||||
|
||||
@@ -1,15 +1,42 @@
|
||||
import 'package:home_widget/home_widget.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/repositories/widget.repository.dart';
|
||||
|
||||
final widgetServiceProvider = Provider((_) => const WidgetService());
|
||||
final widgetServiceProvider = Provider((ref) {
|
||||
return WidgetService(ref.watch(widgetRepositoryProvider));
|
||||
});
|
||||
|
||||
class WidgetService {
|
||||
const WidgetService();
|
||||
final WidgetRepository _repository;
|
||||
|
||||
const WidgetService(this._repository);
|
||||
|
||||
Future<void> writeCredentials(String serverURL, String sessionKey, String? customHeaders) async {
|
||||
await _repository.setAppGroupId(appShareGroupId);
|
||||
await _repository.saveData(kWidgetServerEndpoint, serverURL);
|
||||
await _repository.saveData(kWidgetAuthToken, sessionKey);
|
||||
|
||||
if (customHeaders != null && customHeaders.isNotEmpty) {
|
||||
await _repository.saveData(kWidgetCustomHeaders, customHeaders);
|
||||
}
|
||||
|
||||
// wait 3 seconds to ensure the widget is updated, dont block
|
||||
Future.delayed(const Duration(seconds: 3), refreshWidgets);
|
||||
}
|
||||
|
||||
Future<void> clearCredentials() async {
|
||||
await _repository.setAppGroupId(appShareGroupId);
|
||||
await _repository.saveData(kWidgetServerEndpoint, "");
|
||||
await _repository.saveData(kWidgetAuthToken, "");
|
||||
await _repository.saveData(kWidgetCustomHeaders, "");
|
||||
|
||||
// wait 3 seconds to ensure the widget is updated, dont block
|
||||
Future.delayed(const Duration(seconds: 3), refreshWidgets);
|
||||
}
|
||||
|
||||
Future<void> refreshWidgets() async {
|
||||
for (final (iOSName, androidName) in kWidgetNames) {
|
||||
await HomeWidget.updateWidget(iOSName: iOSName, qualifiedAndroidName: androidName);
|
||||
await _repository.refresh(iOSName, androidName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permane
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
|
||||
@@ -75,6 +76,7 @@ enum ActionButtonType {
|
||||
viewInTimeline,
|
||||
download,
|
||||
upload,
|
||||
openInBrowser,
|
||||
unstack,
|
||||
archive,
|
||||
unarchive,
|
||||
@@ -149,6 +151,7 @@ enum ActionButtonType {
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.isStacked,
|
||||
ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView,
|
||||
ActionButtonType.likeActivity =>
|
||||
!context.isInLockedView &&
|
||||
context.currentAlbum != null &&
|
||||
@@ -236,6 +239,13 @@ enum ActionButtonType {
|
||||
),
|
||||
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.openInBrowser => OpenInBrowserActionButton(
|
||||
remoteId: context.asset.remoteId!,
|
||||
origin: context.timelineOrigin,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
iconColor: context.originalTheme?.iconTheme.color,
|
||||
),
|
||||
ActionButtonType.similarPhotos => SimilarPhotosActionButton(
|
||||
assetId: (context.asset as RemoteAsset).id,
|
||||
iconOnly: iconOnly,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A widget that animates implicitly between a play and a pause icon.
|
||||
class AnimatedPlayPause extends StatefulWidget {
|
||||
const AnimatedPlayPause({super.key, required this.playing, this.size, this.color});
|
||||
const AnimatedPlayPause({super.key, required this.playing, this.size, this.color, this.shadows});
|
||||
|
||||
final double? size;
|
||||
final bool playing;
|
||||
final Color? color;
|
||||
final List<Shadow>? shadows;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => AnimatedPlayPauseState();
|
||||
@@ -39,12 +42,32 @@ class AnimatedPlayPauseState extends State<AnimatedPlayPause> with SingleTickerP
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final icon = AnimatedIcon(
|
||||
color: widget.color,
|
||||
size: widget.size,
|
||||
icon: AnimatedIcons.play_pause,
|
||||
progress: animationController,
|
||||
);
|
||||
|
||||
return Center(
|
||||
child: AnimatedIcon(
|
||||
color: widget.color,
|
||||
size: widget.size,
|
||||
icon: AnimatedIcons.play_pause,
|
||||
progress: animationController,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
for (final shadow in widget.shadows ?? const <Shadow>[])
|
||||
Transform.translate(
|
||||
offset: shadow.offset,
|
||||
child: ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: shadow.blurRadius / 2, sigmaY: shadow.blurRadius / 2),
|
||||
child: AnimatedIcon(
|
||||
color: shadow.color,
|
||||
size: widget.size,
|
||||
icon: AnimatedIcons.play_pause,
|
||||
progress: animationController,
|
||||
),
|
||||
),
|
||||
),
|
||||
icon,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,17 +72,14 @@ class VideoControls extends HookConsumerWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconTheme(
|
||||
data: const IconThemeData(shadows: _controlShadows),
|
||||
child: IconButton(
|
||||
iconSize: 32,
|
||||
padding: const EdgeInsets.all(12),
|
||||
constraints: const BoxConstraints(),
|
||||
icon: isFinished
|
||||
? const Icon(Icons.replay, color: Colors.white, size: 32)
|
||||
: AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying),
|
||||
onPressed: () => _toggle(ref, isCasting),
|
||||
),
|
||||
IconButton(
|
||||
iconSize: 32,
|
||||
padding: const EdgeInsets.all(12),
|
||||
constraints: const BoxConstraints(),
|
||||
icon: isFinished
|
||||
? const Icon(Icons.replay, color: Colors.white, size: 32, shadows: _controlShadows)
|
||||
: AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying, shadows: _controlShadows),
|
||||
onPressed: () => _toggle(ref, isCasting),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
|
||||
21
mobile/openapi/lib/api/shared_links_api.dart
generated
21
mobile/openapi/lib/api/shared_links_api.dart
generated
@@ -427,11 +427,7 @@ class SharedLinksApi {
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [AssetIdsDto] assetIdsDto (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<Response> removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto, { String? key, String? slug, }) async {
|
||||
Future<Response> removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/shared-links/{id}/assets'
|
||||
.replaceAll('{id}', id);
|
||||
@@ -443,13 +439,6 @@ class SharedLinksApi {
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
if (slug != null) {
|
||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||
}
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
@@ -473,12 +462,8 @@ class SharedLinksApi {
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [AssetIdsDto] assetIdsDto (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<List<AssetIdsResponseDto>?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto, { String? key, String? slug, }) async {
|
||||
final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto, key: key, slug: slug, );
|
||||
Future<List<AssetIdsResponseDto>?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto,) async {
|
||||
final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
@@ -1194,10 +1194,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1218,8 +1218,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
|
||||
resolved-ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
|
||||
ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
|
||||
resolved-ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
|
||||
url: "https://github.com/immich-app/native_video_player"
|
||||
source: git
|
||||
version: "1.3.1"
|
||||
@@ -1897,10 +1897,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.6"
|
||||
version: "0.7.7"
|
||||
thumbhash:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -56,7 +56,7 @@ dependencies:
|
||||
native_video_player:
|
||||
git:
|
||||
url: https://github.com/immich-app/native_video_player
|
||||
ref: '0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2'
|
||||
ref: 'cdf621bdb7edaf996e118a58a48f6441187d79c6'
|
||||
network_info_plus: ^6.1.3
|
||||
octo_image: ^2.1.0
|
||||
openapi:
|
||||
|
||||
@@ -11605,22 +11605,6 @@
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "slug",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
@@ -11677,6 +11661,7 @@
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "sharedLink.update",
|
||||
"x-immich-state": "Stable"
|
||||
},
|
||||
"put": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.11.0",
|
||||
"@types/node": "^24.12.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
@@ -5987,19 +5987,14 @@ export function updateSharedLink({ id, sharedLinkEditDto }: {
|
||||
/**
|
||||
* Remove assets from a shared link
|
||||
*/
|
||||
export function removeSharedLinkAssets({ id, key, slug, assetIdsDto }: {
|
||||
export function removeSharedLinkAssets({ id, assetIdsDto }: {
|
||||
id: string;
|
||||
key?: string;
|
||||
slug?: string;
|
||||
assetIdsDto: AssetIdsDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: AssetIdsResponseDto[];
|
||||
}>(`/shared-links/${encodeURIComponent(id)}/assets${QS.query(QS.explode({
|
||||
key,
|
||||
slug
|
||||
}))}`, oazapfts.json({
|
||||
}>(`/shared-links/${encodeURIComponent(id)}/assets`, oazapfts.json({
|
||||
...opts,
|
||||
method: "DELETE",
|
||||
body: assetIdsDto
|
||||
|
||||
62
pnpm-lock.yaml
generated
62
pnpm-lock.yaml
generated
@@ -63,7 +63,7 @@ importers:
|
||||
specifier: ^4.13.1
|
||||
version: 4.13.4
|
||||
'@types/node':
|
||||
specifier: ^24.11.0
|
||||
specifier: ^24.12.0
|
||||
version: 24.12.0
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.0.0
|
||||
@@ -220,7 +220,7 @@ importers:
|
||||
specifier: ^3.4.2
|
||||
version: 3.7.1
|
||||
'@types/node':
|
||||
specifier: ^24.11.0
|
||||
specifier: ^24.12.0
|
||||
version: 24.12.0
|
||||
'@types/pg':
|
||||
specifier: ^8.15.1
|
||||
@@ -248,7 +248,7 @@ importers:
|
||||
version: 63.0.0(eslint@10.0.2(jiti@2.6.1))
|
||||
exiftool-vendored:
|
||||
specifier: ^35.0.0
|
||||
version: 35.10.1
|
||||
version: 35.13.1
|
||||
globals:
|
||||
specifier: ^17.0.0
|
||||
version: 17.4.0
|
||||
@@ -323,7 +323,7 @@ importers:
|
||||
version: 1.2.0
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.11.0
|
||||
specifier: ^24.12.0
|
||||
version: 24.12.0
|
||||
typescript:
|
||||
specifier: ^5.3.3
|
||||
@@ -456,7 +456,7 @@ importers:
|
||||
version: 4.4.0
|
||||
exiftool-vendored:
|
||||
specifier: ^35.0.0
|
||||
version: 35.10.1
|
||||
version: 35.13.1
|
||||
express:
|
||||
specifier: ^5.1.0
|
||||
version: 5.2.1
|
||||
@@ -645,7 +645,7 @@ importers:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.0
|
||||
'@types/node':
|
||||
specifier: ^24.11.0
|
||||
specifier: ^24.12.0
|
||||
version: 24.12.0
|
||||
'@types/nodemailer':
|
||||
specifier: ^7.0.0
|
||||
@@ -845,6 +845,12 @@ importers:
|
||||
tabbable:
|
||||
specifier: ^6.2.0
|
||||
version: 6.4.0
|
||||
tailwind-merge:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
tailwind-variants:
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1)
|
||||
thumbhash:
|
||||
specifier: ^0.1.1
|
||||
version: 0.1.1
|
||||
@@ -3919,8 +3925,8 @@ packages:
|
||||
peerDependencies:
|
||||
'@photo-sphere-viewer/core': 5.14.1
|
||||
|
||||
'@photostructure/tz-lookup@11.4.0':
|
||||
resolution: {integrity: sha512-yrFaDbQQZVJIzpCTnoghWO8Rttu22Hg7/JkfP3CM8UKniXYzD80cuv4UAsFkzP5Z6XWceWNsQTqUJHKyGNXzLg==}
|
||||
'@photostructure/tz-lookup@11.5.0':
|
||||
resolution: {integrity: sha512-0DVFriinZ7TeOnm9ytXeSL3NMFU87ZqMjgbPNkd8LgHFLcPg1BDyM1eewFYs+pPM+62S4fSP9Mtgijmn+6y95w==}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
@@ -7210,17 +7216,17 @@ packages:
|
||||
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
exiftool-vendored.exe@13.51.0:
|
||||
resolution: {integrity: sha512-Q49J2c4e+XSGYDJf9PYMVI/IUfUkHLRsPUeDJ2ZekEBVLuw2g7ye9x0vQGWZKwEeZTlnXol7SeBJB0wtAmzM9w==}
|
||||
exiftool-vendored.exe@13.52.0:
|
||||
resolution: {integrity: sha512-8KSHKluRebjm2FL4S8rtwMLMELn/64CTI5BV3zmIdLnpS5N+aJEh6t9Y7aB7YBn5CwUao0T9/rxv4BMQqusukg==}
|
||||
os: [win32]
|
||||
|
||||
exiftool-vendored.pl@13.51.0:
|
||||
resolution: {integrity: sha512-RhDM10w4kv5YNCvECj0aLXZXi0UWyzVo2OS4P/hpmyCHL+NGCkZ6N9z/Yc3ek0cEfCj4AiLhe8C96pnz/Fw9Yg==}
|
||||
exiftool-vendored.pl@13.52.0:
|
||||
resolution: {integrity: sha512-DXsMRRNdjordn1Ckcp1h9OQJRQy9VDDOcs60H+3IP+W9zRnpSU3HqQMhAVKyHR4FzioiGDbREN9BI/M1oDNoEw==}
|
||||
os: ['!win32']
|
||||
hasBin: true
|
||||
|
||||
exiftool-vendored@35.10.1:
|
||||
resolution: {integrity: sha512-orD61HdNcdlegfD80wI+3JE/n+iobYPztpFqv2drLHb1rb2QEKR1QY62r+O0wZHHNIf3Bje+xjweS1hxWignQA==}
|
||||
exiftool-vendored@35.13.1:
|
||||
resolution: {integrity: sha512-RiXz8RrJSBQ5jiZA1yMicmE/FgEFK/4QkU2KsqmlvTvouOOgANsNWv0f0uZbf098Ee933BE4bec5YAOBT0DuIQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
expect-type@1.3.0:
|
||||
@@ -11252,8 +11258,8 @@ packages:
|
||||
tabbable@6.4.0:
|
||||
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
|
||||
|
||||
tailwind-merge@3.4.0:
|
||||
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
|
||||
tailwind-merge@3.5.0:
|
||||
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
|
||||
|
||||
tailwind-variants@3.2.2:
|
||||
resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==}
|
||||
@@ -14959,8 +14965,8 @@ snapshots:
|
||||
simple-icons: 16.9.0
|
||||
svelte: 5.53.7
|
||||
svelte-highlight: 7.9.0
|
||||
tailwind-merge: 3.4.0
|
||||
tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.1)
|
||||
tailwind-merge: 3.5.0
|
||||
tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1)
|
||||
tailwindcss: 4.2.1
|
||||
transitivePeerDependencies:
|
||||
- '@sveltejs/kit'
|
||||
@@ -15989,7 +15995,7 @@ snapshots:
|
||||
'@photo-sphere-viewer/core': 5.14.1
|
||||
three: 0.182.0
|
||||
|
||||
'@photostructure/tz-lookup@11.4.0': {}
|
||||
'@photostructure/tz-lookup@11.5.0': {}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
@@ -19617,21 +19623,21 @@ snapshots:
|
||||
signal-exit: 3.0.7
|
||||
strip-final-newline: 2.0.0
|
||||
|
||||
exiftool-vendored.exe@13.51.0:
|
||||
exiftool-vendored.exe@13.52.0:
|
||||
optional: true
|
||||
|
||||
exiftool-vendored.pl@13.51.0: {}
|
||||
exiftool-vendored.pl@13.52.0: {}
|
||||
|
||||
exiftool-vendored@35.10.1:
|
||||
exiftool-vendored@35.13.1:
|
||||
dependencies:
|
||||
'@photostructure/tz-lookup': 11.4.0
|
||||
'@photostructure/tz-lookup': 11.5.0
|
||||
'@types/luxon': 3.7.1
|
||||
batch-cluster: 17.3.1
|
||||
exiftool-vendored.pl: 13.51.0
|
||||
exiftool-vendored.pl: 13.52.0
|
||||
he: 1.2.0
|
||||
luxon: 3.7.2
|
||||
optionalDependencies:
|
||||
exiftool-vendored.exe: 13.51.0
|
||||
exiftool-vendored.exe: 13.52.0
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
@@ -24554,13 +24560,13 @@ snapshots:
|
||||
|
||||
tabbable@6.4.0: {}
|
||||
|
||||
tailwind-merge@3.4.0: {}
|
||||
tailwind-merge@3.5.0: {}
|
||||
|
||||
tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.1):
|
||||
tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1):
|
||||
dependencies:
|
||||
tailwindcss: 4.2.1
|
||||
optionalDependencies:
|
||||
tailwind-merge: 3.4.0
|
||||
tailwind-merge: 3.5.0
|
||||
|
||||
tailwindcss-email-variants@3.0.5(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)):
|
||||
dependencies:
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.11.0",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/nodemailer": "^7.0.0",
|
||||
"@types/picomatch": "^4.0.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { SharedLinkController } from 'src/controllers/shared-link.controller';
|
||||
import { SharedLinkType } from 'src/enum';
|
||||
import { Permission, SharedLinkType } from 'src/enum';
|
||||
import { SharedLinkService } from 'src/services/shared-link.service';
|
||||
import request from 'supertest';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(SharedLinkController.name, () => {
|
||||
@@ -31,4 +32,16 @@ describe(SharedLinkController.name, () => {
|
||||
expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /shared-links/:id/assets', () => {
|
||||
it('should require shared link update permission', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/shared-links/${factory.uuid()}/assets`).send({ assetIds: [] });
|
||||
|
||||
expect(ctx.authenticate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({ permission: Permission.SharedLinkUpdate, sharedLinkRoute: false }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -180,7 +180,7 @@ export class SharedLinkController {
|
||||
}
|
||||
|
||||
@Delete(':id/assets')
|
||||
@Authenticated({ sharedLink: true })
|
||||
@Authenticated({ permission: Permission.SharedLinkUpdate })
|
||||
@Endpoint({
|
||||
summary: 'Remove assets from a shared link',
|
||||
description:
|
||||
|
||||
@@ -154,10 +154,11 @@ export class StorageCore {
|
||||
}
|
||||
|
||||
async moveAssetVideo(asset: StorageAsset) {
|
||||
const encodedVideoFile = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false });
|
||||
return this.moveFile({
|
||||
entityId: asset.id,
|
||||
pathType: AssetPathType.EncodedVideo,
|
||||
oldPath: asset.encodedVideoPath,
|
||||
oldPath: encodedVideoFile?.path || null,
|
||||
newPath: StorageCore.getEncodedVideoPath(asset),
|
||||
});
|
||||
}
|
||||
@@ -303,21 +304,15 @@ export class StorageCore {
|
||||
case AssetPathType.Original: {
|
||||
return this.assetRepository.update({ id, originalPath: newPath });
|
||||
}
|
||||
case AssetFileType.FullSize: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath });
|
||||
}
|
||||
case AssetFileType.Preview: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Preview, path: newPath });
|
||||
}
|
||||
case AssetFileType.Thumbnail: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Thumbnail, path: newPath });
|
||||
}
|
||||
case AssetPathType.EncodedVideo: {
|
||||
return this.assetRepository.update({ id, encodedVideoPath: newPath });
|
||||
}
|
||||
|
||||
case AssetFileType.FullSize:
|
||||
case AssetFileType.EncodedVideo:
|
||||
case AssetFileType.Thumbnail:
|
||||
case AssetFileType.Preview:
|
||||
case AssetFileType.Sidecar: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath });
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: pathType as AssetFileType, path: newPath });
|
||||
}
|
||||
|
||||
case PersonPathType.Face: {
|
||||
return this.personRepository.update({ id, thumbnailPath: newPath });
|
||||
}
|
||||
|
||||
@@ -154,7 +154,6 @@ export type StorageAsset = {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
files: AssetFile[];
|
||||
encodedVideoPath: string | null;
|
||||
};
|
||||
|
||||
export type Stack = {
|
||||
|
||||
@@ -153,7 +153,6 @@ export type MapAsset = {
|
||||
duplicateId: string | null;
|
||||
duration: string | null;
|
||||
edits?: ShallowDehydrateObject<AssetEditActionItem>[];
|
||||
encodedVideoPath: string | null;
|
||||
exifInfo?: ShallowDehydrateObject<Selectable<Exif>> | null;
|
||||
faces?: ShallowDehydrateObject<AssetFace>[];
|
||||
fileCreatedAt: Date;
|
||||
|
||||
@@ -45,6 +45,7 @@ export enum AssetFileType {
|
||||
Preview = 'preview',
|
||||
Thumbnail = 'thumbnail',
|
||||
Sidecar = 'sidecar',
|
||||
EncodedVideo = 'encoded_video',
|
||||
}
|
||||
|
||||
export enum AlbumUserRole {
|
||||
|
||||
@@ -175,7 +175,6 @@ where
|
||||
select
|
||||
"asset"."id",
|
||||
"asset"."ownerId",
|
||||
"asset"."encodedVideoPath",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
@@ -463,7 +462,6 @@ select
|
||||
"asset"."libraryId",
|
||||
"asset"."ownerId",
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."encodedVideoPath",
|
||||
"asset"."originalPath",
|
||||
"asset"."isOffline",
|
||||
to_json("asset_exif") as "exifInfo",
|
||||
@@ -521,12 +519,17 @@ select
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."type" = $1
|
||||
and (
|
||||
"asset"."encodedVideoPath" is null
|
||||
or "asset"."encodedVideoPath" = $2
|
||||
"asset"."type" = 'VIDEO'
|
||||
and not exists (
|
||||
select
|
||||
"asset_file"."id"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" = 'encoded_video'
|
||||
)
|
||||
and "asset"."visibility" != $3
|
||||
and "asset"."visibility" != 'hidden'
|
||||
and "asset"."deletedAt" is null
|
||||
|
||||
-- AssetJobRepository.getForVideoConversion
|
||||
@@ -534,12 +537,27 @@ select
|
||||
"asset"."id",
|
||||
"asset"."ownerId",
|
||||
"asset"."originalPath",
|
||||
"asset"."encodedVideoPath"
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
) as agg
|
||||
) as "files"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
and "asset"."type" = $2
|
||||
and "asset"."type" = 'VIDEO'
|
||||
|
||||
-- AssetJobRepository.streamForMetadataExtraction
|
||||
select
|
||||
|
||||
@@ -629,13 +629,21 @@ order by
|
||||
|
||||
-- AssetRepository.getForVideo
|
||||
select
|
||||
"asset"."encodedVideoPath",
|
||||
"asset"."originalPath"
|
||||
"asset"."originalPath",
|
||||
(
|
||||
select
|
||||
"asset_file"."path"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" = $1
|
||||
) as "encodedVideoPath"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
and "asset"."type" = $2
|
||||
"asset"."id" = $2
|
||||
and "asset"."type" = $3
|
||||
|
||||
-- AssetRepository.getForOcr
|
||||
select
|
||||
|
||||
@@ -104,7 +104,7 @@ export class AssetJobRepository {
|
||||
getForMigrationJob(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.id', 'asset.ownerId', 'asset.encodedVideoPath'])
|
||||
.select(['asset.id', 'asset.ownerId'])
|
||||
.select(withFiles)
|
||||
.where('asset.id', '=', id)
|
||||
.executeTakeFirst();
|
||||
@@ -268,7 +268,6 @@ export class AssetJobRepository {
|
||||
'asset.libraryId',
|
||||
'asset.ownerId',
|
||||
'asset.livePhotoVideoId',
|
||||
'asset.encodedVideoPath',
|
||||
'asset.originalPath',
|
||||
'asset.isOffline',
|
||||
])
|
||||
@@ -310,11 +309,21 @@ export class AssetJobRepository {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.id'])
|
||||
.where('asset.type', '=', AssetType.Video)
|
||||
.where('asset.type', '=', sql.lit(AssetType.Video))
|
||||
.$if(!force, (qb) =>
|
||||
qb
|
||||
.where((eb) => eb.or([eb('asset.encodedVideoPath', 'is', null), eb('asset.encodedVideoPath', '=', '')]))
|
||||
.where('asset.visibility', '!=', AssetVisibility.Hidden),
|
||||
.where((eb) =>
|
||||
eb.not(
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('asset_file')
|
||||
.select('asset_file.id')
|
||||
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||
.where('asset_file.type', '=', sql.lit(AssetFileType.EncodedVideo)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.where('asset.visibility', '!=', sql.lit(AssetVisibility.Hidden)),
|
||||
)
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.stream();
|
||||
@@ -324,9 +333,10 @@ export class AssetJobRepository {
|
||||
getForVideoConversion(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.id', 'asset.ownerId', 'asset.originalPath', 'asset.encodedVideoPath'])
|
||||
.select(['asset.id', 'asset.ownerId', 'asset.originalPath'])
|
||||
.select(withFiles)
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.type', '=', AssetType.Video)
|
||||
.where('asset.type', '=', sql.lit(AssetType.Video))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
withExif,
|
||||
withFaces,
|
||||
withFacesAndPeople,
|
||||
withFilePath,
|
||||
withFiles,
|
||||
withLibrary,
|
||||
withOwner,
|
||||
@@ -1019,8 +1020,21 @@ export class AssetRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteFile({ assetId, type }: { assetId: string; type: AssetFileType }): Promise<void> {
|
||||
await this.db.deleteFrom('asset_file').where('assetId', '=', asUuid(assetId)).where('type', '=', type).execute();
|
||||
async deleteFile({
|
||||
assetId,
|
||||
type,
|
||||
edited,
|
||||
}: {
|
||||
assetId: string;
|
||||
type: AssetFileType;
|
||||
edited?: boolean;
|
||||
}): Promise<void> {
|
||||
await this.db
|
||||
.deleteFrom('asset_file')
|
||||
.where('assetId', '=', asUuid(assetId))
|
||||
.where('type', '=', type)
|
||||
.$if(edited !== undefined, (qb) => qb.where('isEdited', '=', edited!))
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteFiles(files: Pick<Selectable<AssetFileTable>, 'id'>[]): Promise<void> {
|
||||
@@ -1139,7 +1153,8 @@ export class AssetRepository {
|
||||
async getForVideo(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.encodedVideoPath', 'asset.originalPath'])
|
||||
.select(['asset.originalPath'])
|
||||
.select((eb) => withFilePath(eb, AssetFileType.EncodedVideo).as('encodedVideoPath'))
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.type', '=', AssetType.Video)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -431,7 +431,6 @@ export class DatabaseRepository {
|
||||
.updateTable('asset')
|
||||
.set((eb) => ({
|
||||
originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]),
|
||||
encodedVideoPath: eb.fn('REGEXP_REPLACE', ['encodedVideoPath', source, target]),
|
||||
}))
|
||||
.execute();
|
||||
|
||||
|
||||
@@ -162,6 +162,7 @@ export class EmailRepository {
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
tls: { rejectUnauthorized: !options.ignoreCert },
|
||||
secure: options.secure,
|
||||
auth:
|
||||
options.username || options.password
|
||||
? {
|
||||
|
||||
@@ -70,7 +70,16 @@ export class OAuthRepository {
|
||||
|
||||
try {
|
||||
const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier });
|
||||
const profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck);
|
||||
|
||||
let profile: OAuthProfile;
|
||||
const tokenClaims = tokens.claims();
|
||||
if (tokenClaims && 'email' in tokenClaims) {
|
||||
this.logger.debug('Using ID token claims instead of userinfo endpoint');
|
||||
profile = tokenClaims as OAuthProfile;
|
||||
} else {
|
||||
profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck);
|
||||
}
|
||||
|
||||
if (!profile.sub) {
|
||||
throw new Error('Unexpected profile response, no `sub`');
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import _ from 'lodash';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { Album, columns } from 'src/database';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { SharedLinkType } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
@@ -249,6 +249,20 @@ export class SharedLinkRepository {
|
||||
await this.db.deleteFrom('shared_link').where('shared_link.id', '=', id).execute();
|
||||
}
|
||||
|
||||
@ChunkedArray({ paramIndex: 1 })
|
||||
async addAssets(id: string, assetIds: string[]) {
|
||||
if (assetIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await this.db
|
||||
.insertInto('shared_link_asset')
|
||||
.values(assetIds.map((assetId) => ({ assetId, sharedLinkId: id })))
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.returning(['shared_link_asset.assetId'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
private getSharedLinks(id: string) {
|
||||
return this.db
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`
|
||||
INSERT INTO "asset_file" ("assetId", "type", "path")
|
||||
SELECT "id", 'encoded_video', "encodedVideoPath"
|
||||
FROM "asset"
|
||||
WHERE "encodedVideoPath" IS NOT NULL AND "encodedVideoPath" != '';
|
||||
`.execute(db);
|
||||
|
||||
await sql`ALTER TABLE "asset" DROP COLUMN "encodedVideoPath";`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset" ADD "encodedVideoPath" character varying DEFAULT '';`.execute(db);
|
||||
|
||||
await sql`
|
||||
UPDATE "asset"
|
||||
SET "encodedVideoPath" = af."path"
|
||||
FROM "asset_file" af
|
||||
WHERE "asset"."id" = af."assetId"
|
||||
AND af."type" = 'encoded_video'
|
||||
AND af."isEdited" = false;
|
||||
`.execute(db);
|
||||
}
|
||||
@@ -92,9 +92,6 @@ export class AssetTable {
|
||||
@Column({ type: 'character varying', nullable: true })
|
||||
duration!: string | null;
|
||||
|
||||
@Column({ type: 'character varying', nullable: true, default: '' })
|
||||
encodedVideoPath!: string | null;
|
||||
|
||||
@Column({ type: 'bytea', index: true })
|
||||
checksum!: Buffer; // sha1 checksum
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ReactionType } from 'src/dtos/activity.dto';
|
||||
import { ActivityService } from 'src/services/activity.service';
|
||||
import { ActivityFactory } from 'test/factories/activity.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { getForActivity } from 'test/mappers';
|
||||
import { factory, newUuid, newUuids } from 'test/small.factory';
|
||||
import { newUuid, newUuids } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(ActivityService.name, () => {
|
||||
@@ -24,7 +26,7 @@ describe(ActivityService.name, () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
||||
mocks.activity.search.mockResolvedValue([]);
|
||||
|
||||
await expect(sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId })).resolves.toEqual([]);
|
||||
await expect(sut.getAll(AuthFactory.create({ id: userId }), { assetId, albumId })).resolves.toEqual([]);
|
||||
|
||||
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: undefined });
|
||||
});
|
||||
@@ -36,7 +38,7 @@ describe(ActivityService.name, () => {
|
||||
mocks.activity.search.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId, type: ReactionType.LIKE }),
|
||||
sut.getAll(AuthFactory.create({ id: userId }), { assetId, albumId, type: ReactionType.LIKE }),
|
||||
).resolves.toEqual([]);
|
||||
|
||||
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: true });
|
||||
@@ -48,7 +50,9 @@ describe(ActivityService.name, () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
||||
mocks.activity.search.mockResolvedValue([]);
|
||||
|
||||
await expect(sut.getAll(factory.auth(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual([]);
|
||||
await expect(sut.getAll(AuthFactory.create(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual(
|
||||
[],
|
||||
);
|
||||
|
||||
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: false });
|
||||
});
|
||||
@@ -61,7 +65,10 @@ describe(ActivityService.name, () => {
|
||||
mocks.activity.getStatistics.mockResolvedValue({ comments: 1, likes: 3 });
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
||||
|
||||
await expect(sut.getStatistics(factory.auth(), { assetId, albumId })).resolves.toEqual({ comments: 1, likes: 3 });
|
||||
await expect(sut.getStatistics(AuthFactory.create(), { assetId, albumId })).resolves.toEqual({
|
||||
comments: 1,
|
||||
likes: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,18 +77,18 @@ describe(ActivityService.name, () => {
|
||||
const [albumId, assetId] = newUuids();
|
||||
|
||||
await expect(
|
||||
sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
|
||||
sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should create a comment', async () => {
|
||||
const [albumId, assetId, userId] = newUuids();
|
||||
const activity = factory.activity({ albumId, assetId, userId });
|
||||
const activity = ActivityFactory.create({ albumId, assetId, userId });
|
||||
|
||||
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
||||
mocks.activity.create.mockResolvedValue(getForActivity(activity));
|
||||
|
||||
await sut.create(factory.auth({ user: { id: userId } }), {
|
||||
await sut.create(AuthFactory.create({ id: userId }), {
|
||||
albumId,
|
||||
assetId,
|
||||
type: ReactionType.COMMENT,
|
||||
@@ -99,38 +106,38 @@ describe(ActivityService.name, () => {
|
||||
|
||||
it('should fail because activity is disabled for the album', async () => {
|
||||
const [albumId, assetId] = newUuids();
|
||||
const activity = factory.activity({ albumId, assetId });
|
||||
const activity = ActivityFactory.create({ albumId, assetId });
|
||||
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
||||
mocks.activity.create.mockResolvedValue(getForActivity(activity));
|
||||
|
||||
await expect(
|
||||
sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
|
||||
sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should create a like', async () => {
|
||||
const [albumId, assetId, userId] = newUuids();
|
||||
const activity = factory.activity({ userId, albumId, assetId, isLiked: true });
|
||||
const activity = ActivityFactory.create({ userId, albumId, assetId, isLiked: true });
|
||||
|
||||
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
||||
mocks.activity.create.mockResolvedValue(getForActivity(activity));
|
||||
mocks.activity.search.mockResolvedValue([]);
|
||||
|
||||
await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.LIKE });
|
||||
await sut.create(AuthFactory.create({ id: userId }), { albumId, assetId, type: ReactionType.LIKE });
|
||||
|
||||
expect(mocks.activity.create).toHaveBeenCalledWith({ userId: activity.userId, albumId, assetId, isLiked: true });
|
||||
});
|
||||
|
||||
it('should skip if like exists', async () => {
|
||||
const [albumId, assetId] = newUuids();
|
||||
const activity = factory.activity({ albumId, assetId, isLiked: true });
|
||||
const activity = ActivityFactory.create({ albumId, assetId, isLiked: true });
|
||||
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
||||
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
||||
mocks.activity.search.mockResolvedValue([getForActivity(activity)]);
|
||||
|
||||
await sut.create(factory.auth(), { albumId, assetId, type: ReactionType.LIKE });
|
||||
await sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.LIKE });
|
||||
|
||||
expect(mocks.activity.create).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -138,29 +145,29 @@ describe(ActivityService.name, () => {
|
||||
|
||||
describe('delete', () => {
|
||||
it('should require access', async () => {
|
||||
await expect(sut.delete(factory.auth(), newUuid())).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(sut.delete(AuthFactory.create(), newUuid())).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.activity.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should let the activity owner delete a comment', async () => {
|
||||
const activity = factory.activity();
|
||||
const activity = ActivityFactory.create();
|
||||
|
||||
mocks.access.activity.checkOwnerAccess.mockResolvedValue(new Set([activity.id]));
|
||||
mocks.activity.delete.mockResolvedValue();
|
||||
|
||||
await sut.delete(factory.auth(), activity.id);
|
||||
await sut.delete(AuthFactory.create(), activity.id);
|
||||
|
||||
expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id);
|
||||
});
|
||||
|
||||
it('should let the album owner delete a comment', async () => {
|
||||
const activity = factory.activity();
|
||||
const activity = ActivityFactory.create();
|
||||
|
||||
mocks.access.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set([activity.id]));
|
||||
mocks.activity.delete.mockResolvedValue();
|
||||
|
||||
await sut.delete(factory.auth(), activity.id);
|
||||
await sut.delete(AuthFactory.create(), activity.id);
|
||||
|
||||
expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { Permission } from 'src/enum';
|
||||
import { ApiKeyService } from 'src/services/api-key.service';
|
||||
import { factory, newUuid } from 'test/small.factory';
|
||||
import { ApiKeyFactory } from 'test/factories/api-key.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { SessionFactory } from 'test/factories/session.factory';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(ApiKeyService.name, () => {
|
||||
@@ -14,8 +17,8 @@ describe(ApiKeyService.name, () => {
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new key', async () => {
|
||||
const auth = factory.auth();
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.All] });
|
||||
const auth = AuthFactory.create();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.All] });
|
||||
const key = 'super-secret';
|
||||
|
||||
mocks.crypto.randomBytesAsText.mockReturnValue(key);
|
||||
@@ -34,8 +37,8 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should not require a name', async () => {
|
||||
const auth = factory.auth();
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
||||
const auth = AuthFactory.create();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||
const key = 'super-secret';
|
||||
|
||||
mocks.crypto.randomBytesAsText.mockReturnValue(key);
|
||||
@@ -54,7 +57,9 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw an error if the api key does not have sufficient permissions', async () => {
|
||||
const auth = factory.auth({ apiKey: { permissions: [Permission.AssetRead] } });
|
||||
const auth = AuthFactory.from()
|
||||
.apiKey({ permissions: [Permission.AssetRead] })
|
||||
.build();
|
||||
|
||||
await expect(sut.create(auth, { permissions: [Permission.AssetUpdate] })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
@@ -65,7 +70,7 @@ describe(ApiKeyService.name, () => {
|
||||
describe('update', () => {
|
||||
it('should throw an error if the key is not found', async () => {
|
||||
const id = newUuid();
|
||||
const auth = factory.auth();
|
||||
const auth = AuthFactory.create();
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(void 0);
|
||||
|
||||
@@ -77,8 +82,8 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should update a key', async () => {
|
||||
const auth = factory.auth();
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
||||
const auth = AuthFactory.create();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||
const newName = 'New name';
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||
@@ -93,8 +98,8 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should update permissions', async () => {
|
||||
const auth = factory.auth();
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
||||
const auth = AuthFactory.create();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||
const newPermissions = [Permission.ActivityCreate, Permission.ActivityRead, Permission.ActivityUpdate];
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||
@@ -111,8 +116,8 @@ describe(ApiKeyService.name, () => {
|
||||
describe('api key auth', () => {
|
||||
it('should prevent adding Permission.all', async () => {
|
||||
const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead];
|
||||
const auth = factory.auth({ apiKey: { permissions } });
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id, permissions });
|
||||
const auth = AuthFactory.from().apiKey({ permissions }).build();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions });
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||
|
||||
@@ -125,8 +130,8 @@ describe(ApiKeyService.name, () => {
|
||||
|
||||
it('should prevent adding a new permission', async () => {
|
||||
const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead];
|
||||
const auth = factory.auth({ apiKey: { permissions } });
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id, permissions });
|
||||
const auth = AuthFactory.from().apiKey({ permissions }).build();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions });
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||
|
||||
@@ -138,8 +143,10 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should allow removing permissions', async () => {
|
||||
const auth = factory.auth({ apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] } });
|
||||
const apiKey = factory.apiKey({
|
||||
const auth = AuthFactory.from()
|
||||
.apiKey({ permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] })
|
||||
.build();
|
||||
const apiKey = ApiKeyFactory.create({
|
||||
userId: auth.user.id,
|
||||
permissions: [Permission.AssetRead, Permission.AssetDelete],
|
||||
});
|
||||
@@ -158,10 +165,10 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should allow adding new permissions', async () => {
|
||||
const auth = factory.auth({
|
||||
apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] },
|
||||
});
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.AssetRead] });
|
||||
const auth = AuthFactory.from()
|
||||
.apiKey({ permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] })
|
||||
.build();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.AssetRead] });
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||
mocks.apiKey.update.mockResolvedValue(apiKey);
|
||||
@@ -183,7 +190,7 @@ describe(ApiKeyService.name, () => {
|
||||
|
||||
describe('delete', () => {
|
||||
it('should throw an error if the key is not found', async () => {
|
||||
const auth = factory.auth();
|
||||
const auth = AuthFactory.create();
|
||||
const id = newUuid();
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(void 0);
|
||||
@@ -194,8 +201,8 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should delete a key', async () => {
|
||||
const auth = factory.auth();
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
||||
const auth = AuthFactory.create();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||
mocks.apiKey.delete.mockResolvedValue();
|
||||
@@ -208,8 +215,8 @@ describe(ApiKeyService.name, () => {
|
||||
|
||||
describe('getMine', () => {
|
||||
it('should not work with a session token', async () => {
|
||||
const session = factory.session();
|
||||
const auth = factory.auth({ session });
|
||||
const session = SessionFactory.create();
|
||||
const auth = AuthFactory.from().session(session).build();
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(void 0);
|
||||
|
||||
@@ -219,8 +226,8 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw an error if the key is not found', async () => {
|
||||
const apiKey = factory.authApiKey();
|
||||
const auth = factory.auth({ apiKey });
|
||||
const apiKey = ApiKeyFactory.create();
|
||||
const auth = AuthFactory.from().apiKey(apiKey).build();
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(void 0);
|
||||
|
||||
@@ -230,8 +237,8 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should get a key by id', async () => {
|
||||
const auth = factory.auth();
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
||||
const auth = AuthFactory.create();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||
|
||||
@@ -243,7 +250,7 @@ describe(ApiKeyService.name, () => {
|
||||
|
||||
describe('getById', () => {
|
||||
it('should throw an error if the key is not found', async () => {
|
||||
const auth = factory.auth();
|
||||
const auth = AuthFactory.create();
|
||||
const id = newUuid();
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(void 0);
|
||||
@@ -254,8 +261,8 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should get a key by id', async () => {
|
||||
const auth = factory.auth();
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
||||
const auth = AuthFactory.create();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||
|
||||
@@ -267,8 +274,8 @@ describe(ApiKeyService.name, () => {
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should return all the keys for a user', async () => {
|
||||
const auth = factory.auth();
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
||||
const auth = AuthFactory.create();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||
|
||||
mocks.apiKey.getByUserId.mockResolvedValue([apiKey]);
|
||||
|
||||
|
||||
@@ -163,7 +163,6 @@ const assetEntity = Object.freeze({
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
updatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
isFavorite: false,
|
||||
encodedVideoPath: '',
|
||||
duration: '0:00:00.000000',
|
||||
files: [] as AssetFile[],
|
||||
exifInfo: {
|
||||
@@ -711,13 +710,18 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should return the encoded video path if available', async () => {
|
||||
const asset = AssetFactory.create({ encodedVideoPath: '/path/to/encoded/video.mp4' });
|
||||
const asset = AssetFactory.from()
|
||||
.file({ type: AssetFileType.EncodedVideo, path: '/path/to/encoded/video.mp4' })
|
||||
.build();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.asset.getForVideo.mockResolvedValue(asset);
|
||||
mocks.asset.getForVideo.mockResolvedValue({
|
||||
originalPath: asset.originalPath,
|
||||
encodedVideoPath: asset.files[0].path,
|
||||
});
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: asset.encodedVideoPath!,
|
||||
path: '/path/to/encoded/video.mp4',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
contentType: 'video/mp4',
|
||||
}),
|
||||
@@ -727,7 +731,10 @@ describe(AssetMediaService.name, () => {
|
||||
it('should fall back to the original path', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.asset.getForVideo.mockResolvedValue(asset);
|
||||
mocks.asset.getForVideo.mockResolvedValue({
|
||||
originalPath: asset.originalPath,
|
||||
encodedVideoPath: null,
|
||||
});
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
|
||||
@@ -151,6 +151,10 @@ export class AssetMediaService extends BaseService {
|
||||
}
|
||||
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
|
||||
|
||||
if (auth.sharedLink) {
|
||||
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [asset.id]);
|
||||
}
|
||||
|
||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||
|
||||
return { id: asset.id, status: AssetMediaStatus.CREATED };
|
||||
@@ -341,6 +345,11 @@ export class AssetMediaService extends BaseService {
|
||||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
|
||||
if (auth.sharedLink) {
|
||||
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [duplicateId]);
|
||||
}
|
||||
|
||||
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AssetStats } from 'src/repositories/asset.repository';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { PartnerFactory } from 'test/factories/partner.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { getForAsset, getForAssetDeletion, getForPartner } from 'test/mappers';
|
||||
import { factory, newUuid } from 'test/small.factory';
|
||||
@@ -80,8 +81,8 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
it('should not include partner assets if not in timeline', async () => {
|
||||
const partner = factory.partner({ inTimeline: false });
|
||||
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
||||
const partner = PartnerFactory.create({ inTimeline: false });
|
||||
const auth = AuthFactory.create({ id: partner.sharedWithId });
|
||||
|
||||
mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]);
|
||||
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
|
||||
@@ -92,8 +93,8 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
it('should include partner assets if in timeline', async () => {
|
||||
const partner = factory.partner({ inTimeline: true });
|
||||
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
||||
const partner = PartnerFactory.create({ inTimeline: true });
|
||||
const auth = AuthFactory.create({ id: partner.sharedWithId });
|
||||
|
||||
mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]);
|
||||
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
|
||||
|
||||
@@ -370,7 +370,7 @@ export class AssetService extends BaseService {
|
||||
assetFiles.editedFullsizeFile?.path,
|
||||
assetFiles.editedPreviewFile?.path,
|
||||
assetFiles.editedThumbnailFile?.path,
|
||||
asset.encodedVideoPath,
|
||||
assetFiles.encodedVideoFile?.path,
|
||||
];
|
||||
|
||||
if (deleteOnDisk && !asset.isOffline) {
|
||||
|
||||
@@ -6,9 +6,13 @@ import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
|
||||
import { AuthType, Permission } from 'src/enum';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { UserMetadataItem } from 'src/types';
|
||||
import { ApiKeyFactory } from 'test/factories/api-key.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { SessionFactory } from 'test/factories/session.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
|
||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { factory, newUuid } from 'test/small.factory';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
const oauthResponse = ({
|
||||
@@ -91,8 +95,8 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should successfully log the user in', async () => {
|
||||
const user = { ...(factory.user() as UserAdmin), password: 'immich_password' };
|
||||
const session = factory.session();
|
||||
const user = UserFactory.create({ password: 'immich_password' });
|
||||
const session = SessionFactory.create();
|
||||
mocks.user.getByEmail.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(session);
|
||||
|
||||
@@ -113,8 +117,8 @@ describe(AuthService.name, () => {
|
||||
|
||||
describe('changePassword', () => {
|
||||
it('should change the password', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth({ user });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.create(user);
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' });
|
||||
@@ -132,8 +136,8 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw when password does not match existing password', async () => {
|
||||
const user = factory.user();
|
||||
const auth = factory.auth({ user });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.create(user);
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
mocks.crypto.compareBcrypt.mockReturnValue(false);
|
||||
@@ -144,8 +148,8 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw when user does not have a password', async () => {
|
||||
const user = factory.user();
|
||||
const auth = factory.auth({ user });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.create(user);
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: '' });
|
||||
@@ -154,8 +158,8 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should change the password and logout other sessions', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth({ user });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.create(user);
|
||||
const dto = { password: 'old-password', newPassword: 'new-password', invalidateSessions: true };
|
||||
|
||||
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' });
|
||||
@@ -175,7 +179,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
describe('logout', () => {
|
||||
it('should return the end session endpoint', async () => {
|
||||
const auth = factory.auth();
|
||||
const auth = AuthFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
|
||||
@@ -186,7 +190,7 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should return the default redirect', async () => {
|
||||
const auth = factory.auth();
|
||||
const auth = AuthFactory.create();
|
||||
|
||||
await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({
|
||||
successful: true,
|
||||
@@ -262,11 +266,11 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should validate using authorization header', async () => {
|
||||
const session = factory.session();
|
||||
const session = SessionFactory.create();
|
||||
const sessionWithToken = {
|
||||
id: session.id,
|
||||
updatedAt: session.updatedAt,
|
||||
user: factory.authUser(),
|
||||
user: UserFactory.create(),
|
||||
pinExpiresAt: null,
|
||||
appVersion: null,
|
||||
};
|
||||
@@ -340,7 +344,7 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should accept a base64url key', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const user = UserFactory.create();
|
||||
const sharedLink = { ...sharedLinkStub.valid, user } as any;
|
||||
|
||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLink);
|
||||
@@ -361,7 +365,7 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should accept a hex key', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const user = UserFactory.create();
|
||||
const sharedLink = { ...sharedLinkStub.valid, user } as any;
|
||||
|
||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLink);
|
||||
@@ -396,7 +400,7 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should accept a valid slug', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const user = UserFactory.create();
|
||||
const sharedLink = { ...sharedLinkStub.valid, slug: 'slug-123', user } as any;
|
||||
|
||||
mocks.sharedLink.getBySlug.mockResolvedValue(sharedLink);
|
||||
@@ -428,11 +432,11 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should return an auth dto', async () => {
|
||||
const session = factory.session();
|
||||
const session = SessionFactory.create();
|
||||
const sessionWithToken = {
|
||||
id: session.id,
|
||||
updatedAt: session.updatedAt,
|
||||
user: factory.authUser(),
|
||||
user: UserFactory.create(),
|
||||
pinExpiresAt: null,
|
||||
appVersion: null,
|
||||
};
|
||||
@@ -455,11 +459,11 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw if admin route and not an admin', async () => {
|
||||
const session = factory.session();
|
||||
const session = SessionFactory.create();
|
||||
const sessionWithToken = {
|
||||
id: session.id,
|
||||
updatedAt: session.updatedAt,
|
||||
user: factory.authUser(),
|
||||
user: UserFactory.create(),
|
||||
isPendingSyncReset: false,
|
||||
pinExpiresAt: null,
|
||||
appVersion: null,
|
||||
@@ -477,11 +481,11 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should update when access time exceeds an hour', async () => {
|
||||
const session = factory.session({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() });
|
||||
const session = SessionFactory.create({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() });
|
||||
const sessionWithToken = {
|
||||
id: session.id,
|
||||
updatedAt: session.updatedAt,
|
||||
user: factory.authUser(),
|
||||
user: UserFactory.create(),
|
||||
isPendingSyncReset: false,
|
||||
pinExpiresAt: null,
|
||||
appVersion: null,
|
||||
@@ -517,8 +521,8 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw an error if api key has insufficient permissions', async () => {
|
||||
const authUser = factory.authUser();
|
||||
const authApiKey = factory.authApiKey({ permissions: [] });
|
||||
const authUser = UserFactory.create();
|
||||
const authApiKey = ApiKeyFactory.create({ permissions: [] });
|
||||
|
||||
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
|
||||
|
||||
@@ -533,8 +537,8 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should default to requiring the all permission when omitted', async () => {
|
||||
const authUser = factory.authUser();
|
||||
const authApiKey = factory.authApiKey({ permissions: [Permission.AssetRead] });
|
||||
const authUser = UserFactory.create();
|
||||
const authApiKey = ApiKeyFactory.create({ permissions: [Permission.AssetRead] });
|
||||
|
||||
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
|
||||
|
||||
@@ -548,10 +552,12 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should not require any permission when metadata is set to `false`', async () => {
|
||||
const authUser = factory.authUser();
|
||||
const authApiKey = factory.authApiKey({ permissions: [Permission.ActivityRead] });
|
||||
const authUser = UserFactory.create();
|
||||
const authApiKey = ApiKeyFactory.from({ permissions: [Permission.ActivityRead] })
|
||||
.user(authUser)
|
||||
.build();
|
||||
|
||||
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
|
||||
mocks.apiKey.getKey.mockResolvedValue(authApiKey);
|
||||
|
||||
const result = sut.authenticate({
|
||||
headers: { 'x-api-key': 'auth_token' },
|
||||
@@ -562,10 +568,12 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should return an auth dto', async () => {
|
||||
const authUser = factory.authUser();
|
||||
const authApiKey = factory.authApiKey({ permissions: [Permission.All] });
|
||||
const authUser = UserFactory.create();
|
||||
const authApiKey = ApiKeyFactory.from({ permissions: [Permission.All] })
|
||||
.user(authUser)
|
||||
.build();
|
||||
|
||||
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
|
||||
mocks.apiKey.getKey.mockResolvedValue(authApiKey);
|
||||
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
@@ -629,12 +637,12 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should link an existing user', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const user = UserFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(user);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -649,7 +657,7 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should not link to a user with a different oauth sub', async () => {
|
||||
const user = factory.userAdmin({ isAdmin: true, oauthId: 'existing-sub' });
|
||||
const user = UserFactory.create({ isAdmin: true, oauthId: 'existing-sub' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.user.getByEmail.mockResolvedValueOnce(user);
|
||||
@@ -669,13 +677,13 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should allow auto registering by default', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -690,13 +698,13 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
|
||||
const user = factory.userAdmin({ isAdmin: true });
|
||||
const user = UserFactory.create({ isAdmin: true });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
|
||||
|
||||
await expect(
|
||||
@@ -717,11 +725,11 @@ describe(AuthService.name, () => {
|
||||
'app.immich:///oauth-callback?code=abc123',
|
||||
]) {
|
||||
it(`should use the mobile redirect override for a url of ${url}`, async () => {
|
||||
const user = factory.userAdmin();
|
||||
const user = UserFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
|
||||
|
||||
@@ -735,13 +743,13 @@ describe(AuthService.name, () => {
|
||||
}
|
||||
|
||||
it('should use the default quota', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -755,14 +763,14 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should ignore an invalid storage quota', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 'abc' });
|
||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -776,14 +784,14 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should ignore a negative quota', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: -5 });
|
||||
mocks.user.getAdmin.mockResolvedValue(user);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -797,14 +805,14 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should set quota for 0 quota', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 0 });
|
||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -825,15 +833,15 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should use a valid storage quota', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 5 });
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -855,7 +863,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
it('should sync the profile picture', async () => {
|
||||
const fileId = newUuid();
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg';
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
@@ -871,7 +879,7 @@ describe(AuthService.name, () => {
|
||||
data: new Uint8Array([1, 2, 3, 4, 5]).buffer,
|
||||
});
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -889,7 +897,7 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should not sync the profile picture if the user already has one', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue({
|
||||
@@ -899,7 +907,7 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -914,15 +922,15 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should only allow "admin" and "user" for the role claim', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'foo' });
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -943,14 +951,14 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should create an admin user if the role claim is set to admin', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'admin' });
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -971,7 +979,7 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should accept a custom role claim', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
oauth: { ...systemConfigStub.oauthWithAutoRegister, roleClaim: 'my_role' },
|
||||
@@ -980,7 +988,7 @@ describe(AuthService.name, () => {
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -1003,8 +1011,8 @@ describe(AuthService.name, () => {
|
||||
|
||||
describe('link', () => {
|
||||
it('should link an account', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth({ apiKey: { permissions: [] }, user });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
@@ -1019,8 +1027,8 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should not link an already linked oauth.sub', async () => {
|
||||
const authUser = factory.authUser();
|
||||
const authApiKey = factory.authApiKey({ permissions: [] });
|
||||
const authUser = UserFactory.create();
|
||||
const authApiKey = ApiKeyFactory.create({ permissions: [] });
|
||||
const auth = { user: authUser, apiKey: authApiKey };
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
@@ -1036,8 +1044,8 @@ describe(AuthService.name, () => {
|
||||
|
||||
describe('unlink', () => {
|
||||
it('should unlink an account', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth({ user, apiKey: { permissions: [] } });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
@@ -1050,8 +1058,8 @@ describe(AuthService.name, () => {
|
||||
|
||||
describe('setupPinCode', () => {
|
||||
it('should setup a PIN code', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth({ user });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.create(user);
|
||||
const dto = { pinCode: '123456' };
|
||||
|
||||
mocks.user.getForPinCode.mockResolvedValue({ pinCode: null, password: '' });
|
||||
@@ -1065,8 +1073,8 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should fail if the user already has a PIN code', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth({ user });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.create(user);
|
||||
|
||||
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||
|
||||
@@ -1076,8 +1084,8 @@ describe(AuthService.name, () => {
|
||||
|
||||
describe('changePinCode', () => {
|
||||
it('should change the PIN code', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth({ user });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.create(user);
|
||||
const dto = { pinCode: '123456', newPinCode: '012345' };
|
||||
|
||||
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||
@@ -1091,37 +1099,37 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should fail if the PIN code does not match', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const user = UserFactory.create();
|
||||
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
|
||||
|
||||
await expect(
|
||||
sut.changePinCode(factory.auth({ user }), { pinCode: '000000', newPinCode: '012345' }),
|
||||
sut.changePinCode(AuthFactory.create(user), { pinCode: '000000', newPinCode: '012345' }),
|
||||
).rejects.toThrow('Wrong PIN code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetPinCode', () => {
|
||||
it('should reset the PIN code', async () => {
|
||||
const currentSession = factory.session();
|
||||
const user = factory.userAdmin();
|
||||
const currentSession = SessionFactory.create();
|
||||
const user = UserFactory.create();
|
||||
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
|
||||
mocks.session.lockAll.mockResolvedValue(void 0);
|
||||
mocks.session.update.mockResolvedValue(currentSession);
|
||||
|
||||
await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' });
|
||||
await sut.resetPinCode(AuthFactory.create(user), { pinCode: '123456' });
|
||||
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null });
|
||||
expect(mocks.session.lockAll).toHaveBeenCalledWith(user.id);
|
||||
});
|
||||
|
||||
it('should throw if the PIN code does not match', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const user = UserFactory.create();
|
||||
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
|
||||
|
||||
await expect(sut.resetPinCode(factory.auth({ user }), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code');
|
||||
await expect(sut.resetPinCode(AuthFactory.create(user), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { jwtVerify } from 'jose';
|
||||
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
||||
import { CliService } from 'src/services/cli.service';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
import { describe, it } from 'vitest';
|
||||
|
||||
@@ -15,7 +15,7 @@ describe(CliService.name, () => {
|
||||
|
||||
describe('listUsers', () => {
|
||||
it('should list users', async () => {
|
||||
mocks.user.getList.mockResolvedValue([factory.userAdmin({ isAdmin: true })]);
|
||||
mocks.user.getList.mockResolvedValue([UserFactory.create({ isAdmin: true })]);
|
||||
await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]);
|
||||
expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true });
|
||||
});
|
||||
@@ -32,10 +32,10 @@ describe(CliService.name, () => {
|
||||
});
|
||||
|
||||
it('should default to a random password', async () => {
|
||||
const admin = factory.userAdmin({ isAdmin: true });
|
||||
const admin = UserFactory.create({ isAdmin: true });
|
||||
|
||||
mocks.user.getAdmin.mockResolvedValue(admin);
|
||||
mocks.user.update.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||
mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
|
||||
const ask = vitest.fn().mockImplementation(() => {});
|
||||
|
||||
@@ -50,7 +50,7 @@ describe(CliService.name, () => {
|
||||
});
|
||||
|
||||
it('should use the supplied password', async () => {
|
||||
const admin = factory.userAdmin({ isAdmin: true });
|
||||
const admin = UserFactory.create({ isAdmin: true });
|
||||
|
||||
mocks.user.getAdmin.mockResolvedValue(admin);
|
||||
mocks.user.update.mockResolvedValue(admin);
|
||||
|
||||
@@ -2,9 +2,9 @@ import { MapService } from 'src/services/map.service';
|
||||
import { AlbumFactory } from 'test/factories/album.factory';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { PartnerFactory } from 'test/factories/partner.factory';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { getForAlbum, getForPartner } from 'test/mappers';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(MapService.name, () => {
|
||||
@@ -40,7 +40,7 @@ describe(MapService.name, () => {
|
||||
|
||||
it('should include partner assets', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const partner = factory.partner({ sharedWithId: auth.user.id });
|
||||
const partner = PartnerFactory.create({ sharedWithId: auth.user.id });
|
||||
|
||||
const asset = AssetFactory.from()
|
||||
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
|
||||
|
||||
@@ -2254,7 +2254,9 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should delete existing transcode if current policy does not require transcoding', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video, encodedVideoPath: '/encoded/video/path.mp4' });
|
||||
const asset = AssetFactory.from({ type: AssetType.Video })
|
||||
.file({ type: AssetFileType.EncodedVideo, path: '/encoded/video/path.mp4' })
|
||||
.build();
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } });
|
||||
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
|
||||
@@ -2264,7 +2266,7 @@ describe(MediaService.name, () => {
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
data: { files: [asset.encodedVideoPath] },
|
||||
data: { files: ['/encoded/video/path.mp4'] },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
VideoInterfaces,
|
||||
VideoStreamInfo,
|
||||
} from 'src/types';
|
||||
import { getDimensions } from 'src/utils/asset.util';
|
||||
import { getAssetFile, getDimensions } from 'src/utils/asset.util';
|
||||
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
|
||||
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
@@ -605,10 +605,11 @@ export class MediaService extends BaseService {
|
||||
let { ffmpeg } = await this.getConfig({ withCache: true });
|
||||
const target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream);
|
||||
if (target === TranscodeTarget.None && !this.isRemuxRequired(ffmpeg, format)) {
|
||||
if (asset.encodedVideoPath) {
|
||||
const encodedVideo = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false });
|
||||
if (encodedVideo) {
|
||||
this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`);
|
||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [asset.encodedVideoPath] } });
|
||||
await this.assetRepository.update({ id: asset.id, encodedVideoPath: null });
|
||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [encodedVideo.path] } });
|
||||
await this.assetRepository.deleteFiles([encodedVideo]);
|
||||
} else {
|
||||
this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`);
|
||||
}
|
||||
@@ -656,7 +657,12 @@ export class MediaService extends BaseService {
|
||||
|
||||
this.logger.log(`Successfully encoded ${asset.id}`);
|
||||
|
||||
await this.assetRepository.update({ id: asset.id, encodedVideoPath: output });
|
||||
await this.assetRepository.upsertFile({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.EncodedVideo,
|
||||
path: output,
|
||||
isEdited: false,
|
||||
});
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { PartnerDirection } from 'src/repositories/partner.repository';
|
||||
import { PartnerService } from 'src/services/partner.service';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { PartnerFactory } from 'test/factories/partner.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { getDehydrated, getForPartner } from 'test/mappers';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { getForPartner } from 'test/mappers';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(PartnerService.name, () => {
|
||||
@@ -22,15 +23,9 @@ describe(PartnerService.name, () => {
|
||||
it("should return a list of partners with whom I've shared my library", async () => {
|
||||
const user1 = UserFactory.create();
|
||||
const user2 = UserFactory.create();
|
||||
const sharedWithUser2 = factory.partner({
|
||||
sharedBy: getDehydrated(user1),
|
||||
sharedWith: getDehydrated(user2),
|
||||
});
|
||||
const sharedWithUser1 = factory.partner({
|
||||
sharedBy: getDehydrated(user2),
|
||||
sharedWith: getDehydrated(user1),
|
||||
});
|
||||
const auth = factory.auth({ user: { id: user1.id } });
|
||||
const sharedWithUser2 = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
|
||||
const sharedWithUser1 = PartnerFactory.from().sharedBy(user2).sharedWith(user1).build();
|
||||
const auth = AuthFactory.create({ id: user1.id });
|
||||
|
||||
mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]);
|
||||
|
||||
@@ -41,15 +36,9 @@ describe(PartnerService.name, () => {
|
||||
it('should return a list of partners who have shared their libraries with me', async () => {
|
||||
const user1 = UserFactory.create();
|
||||
const user2 = UserFactory.create();
|
||||
const sharedWithUser2 = factory.partner({
|
||||
sharedBy: getDehydrated(user1),
|
||||
sharedWith: getDehydrated(user2),
|
||||
});
|
||||
const sharedWithUser1 = factory.partner({
|
||||
sharedBy: getDehydrated(user2),
|
||||
sharedWith: getDehydrated(user1),
|
||||
});
|
||||
const auth = factory.auth({ user: { id: user1.id } });
|
||||
const sharedWithUser2 = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
|
||||
const sharedWithUser1 = PartnerFactory.from().sharedBy(user2).sharedWith(user1).build();
|
||||
const auth = AuthFactory.create({ id: user1.id });
|
||||
|
||||
mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]);
|
||||
await expect(sut.search(auth, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined();
|
||||
@@ -61,8 +50,8 @@ describe(PartnerService.name, () => {
|
||||
it('should create a new partner', async () => {
|
||||
const user1 = UserFactory.create();
|
||||
const user2 = UserFactory.create();
|
||||
const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) });
|
||||
const auth = factory.auth({ user: { id: user1.id } });
|
||||
const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
|
||||
const auth = AuthFactory.create({ id: user1.id });
|
||||
|
||||
mocks.partner.get.mockResolvedValue(void 0);
|
||||
mocks.partner.create.mockResolvedValue(getForPartner(partner));
|
||||
@@ -78,8 +67,8 @@ describe(PartnerService.name, () => {
|
||||
it('should throw an error when the partner already exists', async () => {
|
||||
const user1 = UserFactory.create();
|
||||
const user2 = UserFactory.create();
|
||||
const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) });
|
||||
const auth = factory.auth({ user: { id: user1.id } });
|
||||
const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
|
||||
const auth = AuthFactory.create({ id: user1.id });
|
||||
|
||||
mocks.partner.get.mockResolvedValue(getForPartner(partner));
|
||||
|
||||
@@ -93,8 +82,8 @@ describe(PartnerService.name, () => {
|
||||
it('should remove a partner', async () => {
|
||||
const user1 = UserFactory.create();
|
||||
const user2 = UserFactory.create();
|
||||
const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) });
|
||||
const auth = factory.auth({ user: { id: user1.id } });
|
||||
const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
|
||||
const auth = AuthFactory.create({ id: user1.id });
|
||||
|
||||
mocks.partner.get.mockResolvedValue(getForPartner(partner));
|
||||
|
||||
@@ -104,8 +93,8 @@ describe(PartnerService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw an error when the partner does not exist', async () => {
|
||||
const user2 = factory.user();
|
||||
const auth = factory.auth();
|
||||
const user2 = UserFactory.create();
|
||||
const auth = AuthFactory.create();
|
||||
|
||||
mocks.partner.get.mockResolvedValue(void 0);
|
||||
|
||||
@@ -117,8 +106,8 @@ describe(PartnerService.name, () => {
|
||||
|
||||
describe('update', () => {
|
||||
it('should require access', async () => {
|
||||
const user2 = factory.user();
|
||||
const auth = factory.auth();
|
||||
const user2 = UserFactory.create();
|
||||
const auth = AuthFactory.create();
|
||||
|
||||
await expect(sut.update(auth, user2.id, { inTimeline: false })).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
@@ -126,8 +115,8 @@ describe(PartnerService.name, () => {
|
||||
it('should update partner', async () => {
|
||||
const user1 = UserFactory.create();
|
||||
const user2 = UserFactory.create();
|
||||
const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) });
|
||||
const auth = factory.auth({ user: { id: user1.id } });
|
||||
const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
|
||||
const auth = AuthFactory.create({ id: user1.id });
|
||||
|
||||
mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set([user2.id]));
|
||||
mocks.partner.update.mockResolvedValue(getForPartner(partner));
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { JobStatus } from 'src/enum';
|
||||
import { SessionService } from 'src/services/session.service';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { SessionFactory } from 'test/factories/session.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe('SessionService', () => {
|
||||
@@ -25,9 +26,9 @@ describe('SessionService', () => {
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should get the devices', async () => {
|
||||
const currentSession = factory.session();
|
||||
const otherSession = factory.session();
|
||||
const auth = factory.auth({ session: currentSession });
|
||||
const currentSession = SessionFactory.create();
|
||||
const otherSession = SessionFactory.create();
|
||||
const auth = AuthFactory.from().session(currentSession).build();
|
||||
|
||||
mocks.session.getByUserId.mockResolvedValue([currentSession, otherSession]);
|
||||
|
||||
@@ -42,8 +43,8 @@ describe('SessionService', () => {
|
||||
|
||||
describe('logoutDevices', () => {
|
||||
it('should logout all devices', async () => {
|
||||
const currentSession = factory.session();
|
||||
const auth = factory.auth({ session: currentSession });
|
||||
const currentSession = SessionFactory.create();
|
||||
const auth = AuthFactory.from().session(currentSession).build();
|
||||
|
||||
mocks.session.invalidate.mockResolvedValue();
|
||||
|
||||
|
||||
@@ -150,6 +150,12 @@ export class SharedLinkService extends BaseService {
|
||||
}
|
||||
|
||||
async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
|
||||
if (auth.sharedLink) {
|
||||
this.logger.deprecate(
|
||||
'Assets uploaded using shared link authentication are now automatically added to the shared link during upload and in the next major release this endpoint will no longer accept shared link authentication',
|
||||
);
|
||||
}
|
||||
|
||||
const sharedLink = await this.findOrFail(auth.user.id, id);
|
||||
|
||||
if (sharedLink.type !== SharedLinkType.Individual) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { SyncService } from 'src/services/sync.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { PartnerFactory } from 'test/factories/partner.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { getForAsset, getForPartner } from 'test/mappers';
|
||||
import { factory } from 'test/small.factory';
|
||||
@@ -42,7 +43,7 @@ describe(SyncService.name, () => {
|
||||
|
||||
describe('getChangesForDeltaSync', () => {
|
||||
it('should return a response requiring a full sync when partners are out of sync', async () => {
|
||||
const partner = factory.partner();
|
||||
const partner = PartnerFactory.create();
|
||||
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
||||
|
||||
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
|
||||
|
||||
@@ -2,9 +2,10 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { JobName, UserStatus } from 'src/enum';
|
||||
import { UserAdminService } from 'src/services/user-admin.service';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
import { describe } from 'vitest';
|
||||
|
||||
@@ -126,8 +127,8 @@ describe(UserAdminService.name, () => {
|
||||
});
|
||||
|
||||
it('should not allow deleting own account', async () => {
|
||||
const user = factory.userAdmin({ isAdmin: false });
|
||||
const auth = factory.auth({ user });
|
||||
const user = UserFactory.create({ isAdmin: false });
|
||||
const auth = AuthFactory.create(user);
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
await expect(sut.delete(auth, user.id, {})).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@ import { UserAdmin } from 'src/database';
|
||||
import { CacheControl, JobName, UserMetadataKey } from 'src/enum';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
const makeDeletedAt = (daysAgo: number) => {
|
||||
@@ -28,8 +29,8 @@ describe(UserService.name, () => {
|
||||
|
||||
describe('getAll', () => {
|
||||
it('admin should get all users', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth({ user });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.create(user);
|
||||
|
||||
mocks.user.getList.mockResolvedValue([user]);
|
||||
|
||||
@@ -39,8 +40,8 @@ describe(UserService.name, () => {
|
||||
});
|
||||
|
||||
it('non-admin should get all users when publicUsers enabled', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth({ user });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.create(user);
|
||||
|
||||
mocks.user.getList.mockResolvedValue([user]);
|
||||
|
||||
@@ -105,7 +106,7 @@ describe(UserService.name, () => {
|
||||
|
||||
it('should throw an error if the user profile could not be updated with the new image', async () => {
|
||||
const file = { path: '/profile/path' } as Express.Multer.File;
|
||||
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
|
||||
const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
mocks.user.update.mockRejectedValue(new InternalServerErrorException('mocked error'));
|
||||
|
||||
@@ -113,7 +114,7 @@ describe(UserService.name, () => {
|
||||
});
|
||||
|
||||
it('should delete the previous profile image', async () => {
|
||||
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
|
||||
const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
|
||||
const file = { path: '/profile/path' } as Express.Multer.File;
|
||||
const files = [user.profileImagePath];
|
||||
|
||||
@@ -149,7 +150,7 @@ describe(UserService.name, () => {
|
||||
});
|
||||
|
||||
it('should delete the profile image if user has one', async () => {
|
||||
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
|
||||
const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
|
||||
const files = [user.profileImagePath];
|
||||
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
@@ -178,7 +179,7 @@ describe(UserService.name, () => {
|
||||
});
|
||||
|
||||
it('should return the profile picture', async () => {
|
||||
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
|
||||
const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
|
||||
await expect(sut.getProfileImage(user.id)).resolves.toEqual(
|
||||
@@ -205,7 +206,7 @@ describe(UserService.name, () => {
|
||||
});
|
||||
|
||||
it('should queue user ready for deletion', async () => {
|
||||
const user = factory.user();
|
||||
const user = UserFactory.create();
|
||||
mocks.user.getDeletedAfter.mockResolvedValue([{ id: user.id }]);
|
||||
|
||||
await sut.handleUserDeleteCheck();
|
||||
|
||||
@@ -26,6 +26,8 @@ export const getAssetFiles = (files: AssetFile[]) => ({
|
||||
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }),
|
||||
editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
|
||||
editedThumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: true }),
|
||||
|
||||
encodedVideoFile: getAssetFile(files, AssetFileType.EncodedVideo, { isEdited: false }),
|
||||
});
|
||||
|
||||
export const addAssets = async (
|
||||
|
||||
@@ -355,7 +355,16 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
|
||||
.$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!)))
|
||||
.$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!)))
|
||||
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
|
||||
.$if(!!options.encodedVideoPath, (qb) => qb.where('asset.encodedVideoPath', '=', options.encodedVideoPath!))
|
||||
.$if(!!options.encodedVideoPath, (qb) =>
|
||||
qb
|
||||
.innerJoin('asset_file', (join) =>
|
||||
join
|
||||
.onRef('asset.id', '=', 'asset_file.assetId')
|
||||
.on('asset_file.type', '=', AssetFileType.EncodedVideo)
|
||||
.on('asset_file.isEdited', '=', false),
|
||||
)
|
||||
.where('asset_file.path', '=', options.encodedVideoPath!),
|
||||
)
|
||||
.$if(!!options.originalPath, (qb) =>
|
||||
qb.where(sql`f_unaccent(asset."originalPath")`, 'ilike', sql`'%' || f_unaccent(${options.originalPath}) || '%'`),
|
||||
)
|
||||
@@ -380,7 +389,15 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
|
||||
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
|
||||
.$if(options.isOffline !== undefined, (qb) => qb.where('asset.isOffline', '=', options.isOffline!))
|
||||
.$if(options.isEncoded !== undefined, (qb) =>
|
||||
qb.where('asset.encodedVideoPath', options.isEncoded ? 'is not' : 'is', null),
|
||||
qb.where((eb) => {
|
||||
const exists = eb.exists((eb) =>
|
||||
eb
|
||||
.selectFrom('asset_file')
|
||||
.whereRef('assetId', '=', 'asset.id')
|
||||
.where('type', '=', AssetFileType.EncodedVideo),
|
||||
);
|
||||
return options.isEncoded ? exists : eb.not(exists);
|
||||
}),
|
||||
)
|
||||
.$if(options.isMotion !== undefined, (qb) =>
|
||||
qb.where('asset.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null),
|
||||
|
||||
42
server/test/factories/activity.factory.ts
Normal file
42
server/test/factories/activity.factory.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { ActivityTable } from 'src/schema/tables/activity.table';
|
||||
import { build } from 'test/factories/builder.factory';
|
||||
import { ActivityLike, FactoryBuilder, UserLike } from 'test/factories/types';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
|
||||
export class ActivityFactory {
|
||||
#user!: UserFactory;
|
||||
|
||||
private constructor(private value: Selectable<ActivityTable>) {}
|
||||
|
||||
static create(dto: ActivityLike = {}) {
|
||||
return ActivityFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: ActivityLike = {}) {
|
||||
const userId = dto.userId ?? newUuid();
|
||||
return new ActivityFactory({
|
||||
albumId: newUuid(),
|
||||
assetId: null,
|
||||
comment: null,
|
||||
createdAt: newDate(),
|
||||
id: newUuid(),
|
||||
isLiked: false,
|
||||
userId,
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
...dto,
|
||||
}).user({ id: userId });
|
||||
}
|
||||
|
||||
user(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
|
||||
this.#user = build(UserFactory.from(dto), builder);
|
||||
this.value.userId = this.#user.build().id;
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
return { ...this.value, user: this.#user.build() };
|
||||
}
|
||||
}
|
||||
42
server/test/factories/api-key.factory.ts
Normal file
42
server/test/factories/api-key.factory.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { Permission } from 'src/enum';
|
||||
import { ApiKeyTable } from 'src/schema/tables/api-key.table';
|
||||
import { build } from 'test/factories/builder.factory';
|
||||
import { ApiKeyLike, FactoryBuilder, UserLike } from 'test/factories/types';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
|
||||
export class ApiKeyFactory {
|
||||
#user!: UserFactory;
|
||||
|
||||
private constructor(private value: Selectable<ApiKeyTable>) {}
|
||||
|
||||
static create(dto: ApiKeyLike = {}) {
|
||||
return ApiKeyFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: ApiKeyLike = {}) {
|
||||
const userId = dto.userId ?? newUuid();
|
||||
return new ApiKeyFactory({
|
||||
createdAt: newDate(),
|
||||
id: newUuid(),
|
||||
key: Buffer.from('api-key-buffer'),
|
||||
name: 'API Key',
|
||||
permissions: [Permission.All],
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
userId,
|
||||
...dto,
|
||||
}).user({ id: userId });
|
||||
}
|
||||
|
||||
user(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
|
||||
this.#user = build(UserFactory.from(dto), builder);
|
||||
this.value.userId = this.#user.build().id;
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
return { ...this.value, user: this.#user.build() };
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,6 @@ export class AssetFactory {
|
||||
deviceId: '',
|
||||
duplicateId: null,
|
||||
duration: null,
|
||||
encodedVideoPath: null,
|
||||
fileCreatedAt: newDate(),
|
||||
fileModifiedAt: newDate(),
|
||||
isExternal: false,
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ApiKeyFactory } from 'test/factories/api-key.factory';
|
||||
import { build } from 'test/factories/builder.factory';
|
||||
import { SharedLinkFactory } from 'test/factories/shared-link.factory';
|
||||
import { FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types';
|
||||
import { ApiKeyLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
|
||||
export class AuthFactory {
|
||||
#user: UserFactory;
|
||||
#sharedLink?: SharedLinkFactory;
|
||||
#apiKey?: ApiKeyFactory;
|
||||
#session?: AuthDto['session'];
|
||||
|
||||
private constructor(user: UserFactory) {
|
||||
this.#user = user;
|
||||
@@ -20,8 +24,8 @@ export class AuthFactory {
|
||||
return new AuthFactory(UserFactory.from(dto));
|
||||
}
|
||||
|
||||
apiKey() {
|
||||
// TODO
|
||||
apiKey(dto: ApiKeyLike = {}, builder?: FactoryBuilder<ApiKeyFactory>) {
|
||||
this.#apiKey = build(ApiKeyFactory.from(dto), builder);
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -30,6 +34,11 @@ export class AuthFactory {
|
||||
return this;
|
||||
}
|
||||
|
||||
session(dto: Partial<AuthDto['session']> = {}) {
|
||||
this.#session = { id: newUuid(), hasElevatedPermission: false, ...dto };
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): AuthDto {
|
||||
const { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes } = this.#user.build();
|
||||
|
||||
@@ -43,6 +52,8 @@ export class AuthFactory {
|
||||
quotaSizeInBytes,
|
||||
},
|
||||
sharedLink: this.#sharedLink?.build(),
|
||||
apiKey: this.#apiKey?.build(),
|
||||
session: this.#session,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
50
server/test/factories/partner.factory.ts
Normal file
50
server/test/factories/partner.factory.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { PartnerTable } from 'src/schema/tables/partner.table';
|
||||
import { build } from 'test/factories/builder.factory';
|
||||
import { FactoryBuilder, PartnerLike, UserLike } from 'test/factories/types';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
|
||||
export class PartnerFactory {
|
||||
#sharedWith!: UserFactory;
|
||||
#sharedBy!: UserFactory;
|
||||
|
||||
private constructor(private value: Selectable<PartnerTable>) {}
|
||||
|
||||
static create(dto: PartnerLike = {}) {
|
||||
return PartnerFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: PartnerLike = {}) {
|
||||
const sharedById = dto.sharedById ?? newUuid();
|
||||
const sharedWithId = dto.sharedWithId ?? newUuid();
|
||||
return new PartnerFactory({
|
||||
createdAt: newDate(),
|
||||
createId: newUuidV7(),
|
||||
inTimeline: true,
|
||||
sharedById,
|
||||
sharedWithId,
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
...dto,
|
||||
})
|
||||
.sharedBy({ id: sharedById })
|
||||
.sharedWith({ id: sharedWithId });
|
||||
}
|
||||
|
||||
sharedWith(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
|
||||
this.#sharedWith = build(UserFactory.from(dto), builder);
|
||||
this.value.sharedWithId = this.#sharedWith.build().id;
|
||||
return this;
|
||||
}
|
||||
|
||||
sharedBy(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
|
||||
this.#sharedBy = build(UserFactory.from(dto), builder);
|
||||
this.value.sharedById = this.#sharedBy.build().id;
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
return { ...this.value, sharedWith: this.#sharedWith.build(), sharedBy: this.#sharedBy.build() };
|
||||
}
|
||||
}
|
||||
35
server/test/factories/session.factory.ts
Normal file
35
server/test/factories/session.factory.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { SessionTable } from 'src/schema/tables/session.table';
|
||||
import { SessionLike } from 'test/factories/types';
|
||||
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
|
||||
export class SessionFactory {
|
||||
private constructor(private value: Selectable<SessionTable>) {}
|
||||
|
||||
static create(dto: SessionLike = {}) {
|
||||
return SessionFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: SessionLike = {}) {
|
||||
return new SessionFactory({
|
||||
appVersion: null,
|
||||
createdAt: newDate(),
|
||||
deviceOS: 'android',
|
||||
deviceType: 'mobile',
|
||||
expiresAt: null,
|
||||
id: newUuid(),
|
||||
isPendingSyncReset: false,
|
||||
parentId: null,
|
||||
pinExpiresAt: null,
|
||||
token: Buffer.from('abc123'),
|
||||
updateId: newUuidV7(),
|
||||
updatedAt: newDate(),
|
||||
userId: newUuid(),
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
||||
build() {
|
||||
return { ...this.value };
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { ActivityTable } from 'src/schema/tables/activity.table';
|
||||
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { ApiKeyTable } from 'src/schema/tables/api-key.table';
|
||||
import { AssetEditTable } from 'src/schema/tables/asset-edit.table';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { MemoryTable } from 'src/schema/tables/memory.table';
|
||||
import { PartnerTable } from 'src/schema/tables/partner.table';
|
||||
import { PersonTable } from 'src/schema/tables/person.table';
|
||||
import { SessionTable } from 'src/schema/tables/session.table';
|
||||
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
|
||||
import { StackTable } from 'src/schema/tables/stack.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
@@ -26,3 +30,7 @@ export type AssetFaceLike = Partial<Selectable<AssetFaceTable>>;
|
||||
export type PersonLike = Partial<Selectable<PersonTable>>;
|
||||
export type StackLike = Partial<Selectable<StackTable>>;
|
||||
export type MemoryLike = Partial<Selectable<MemoryTable>>;
|
||||
export type PartnerLike = Partial<Selectable<PartnerTable>>;
|
||||
export type ActivityLike = Partial<Selectable<ActivityTable>>;
|
||||
export type ApiKeyLike = Partial<Selectable<ApiKeyTable>>;
|
||||
export type SessionLike = Partial<Selectable<SessionTable>>;
|
||||
|
||||
@@ -183,7 +183,6 @@ export const getForAssetDeletion = (asset: ReturnType<AssetFactory['build']>) =>
|
||||
libraryId: asset.libraryId,
|
||||
ownerId: asset.ownerId,
|
||||
livePhotoVideoId: asset.livePhotoVideoId,
|
||||
encodedVideoPath: asset.encodedVideoPath,
|
||||
originalPath: asset.originalPath,
|
||||
isOffline: asset.isOffline,
|
||||
exifInfo: asset.exifInfo ? getDehydrated(asset.exifInfo) : null,
|
||||
|
||||
@@ -1,26 +1,7 @@
|
||||
import { ShallowDehydrateObject } from 'kysely';
|
||||
import {
|
||||
Activity,
|
||||
Album,
|
||||
ApiKey,
|
||||
AuthApiKey,
|
||||
AuthSharedLink,
|
||||
AuthUser,
|
||||
Exif,
|
||||
Library,
|
||||
Partner,
|
||||
Person,
|
||||
Session,
|
||||
Tag,
|
||||
User,
|
||||
UserAdmin,
|
||||
} from 'src/database';
|
||||
import { AuthApiKey, AuthSharedLink, AuthUser, Exif, Library, UserAdmin } from 'src/database';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEditAction, AssetEditActionItem, MirrorAxis } from 'src/dtos/editing.dto';
|
||||
import { QueueStatisticsDto } from 'src/dtos/queue.dto';
|
||||
import { AssetFileType, AssetOrder, Permission, UserMetadataKey, UserStatus } from 'src/enum';
|
||||
import { UserMetadataItem } from 'src/types';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { AssetFileType, Permission, UserStatus } from 'src/enum';
|
||||
import { v4, v7 } from 'uuid';
|
||||
|
||||
export const newUuid = () => v4();
|
||||
@@ -109,49 +90,6 @@ const authUserFactory = (authUser: Partial<AuthUser> = {}) => {
|
||||
return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes };
|
||||
};
|
||||
|
||||
const partnerFactory = ({
|
||||
sharedBy: sharedByProvided,
|
||||
sharedWith: sharedWithProvided,
|
||||
...partner
|
||||
}: Partial<Partner> = {}) => {
|
||||
const hydrateUser = (user: Partial<ShallowDehydrateObject<User>>) => ({
|
||||
...user,
|
||||
profileChangedAt: user.profileChangedAt ? new Date(user.profileChangedAt) : undefined,
|
||||
});
|
||||
const sharedBy = UserFactory.create(sharedByProvided ? hydrateUser(sharedByProvided) : {});
|
||||
const sharedWith = UserFactory.create(sharedWithProvided ? hydrateUser(sharedWithProvided) : {});
|
||||
|
||||
return {
|
||||
sharedById: sharedBy.id,
|
||||
sharedBy,
|
||||
sharedWithId: sharedWith.id,
|
||||
sharedWith,
|
||||
createId: newUuidV7(),
|
||||
createdAt: newDate(),
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
inTimeline: true,
|
||||
...partner,
|
||||
};
|
||||
};
|
||||
|
||||
const sessionFactory = (session: Partial<Session> = {}) => ({
|
||||
id: newUuid(),
|
||||
createdAt: newDate(),
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
deviceOS: 'android',
|
||||
deviceType: 'mobile',
|
||||
token: Buffer.from('abc123'),
|
||||
parentId: null,
|
||||
expiresAt: null,
|
||||
userId: newUuid(),
|
||||
pinExpiresAt: newDate(),
|
||||
isPendingSyncReset: false,
|
||||
appVersion: session.appVersion ?? null,
|
||||
...session,
|
||||
});
|
||||
|
||||
const queueStatisticsFactory = (dto?: Partial<QueueStatisticsDto>) => ({
|
||||
active: 0,
|
||||
completed: 0,
|
||||
@@ -162,22 +100,6 @@ const queueStatisticsFactory = (dto?: Partial<QueueStatisticsDto>) => ({
|
||||
...dto,
|
||||
});
|
||||
|
||||
const userFactory = (user: Partial<User> = {}) => ({
|
||||
id: newUuid(),
|
||||
name: 'Test User',
|
||||
email: 'test@immich.cloud',
|
||||
avatarColor: null,
|
||||
profileImagePath: '',
|
||||
profileChangedAt: newDate(),
|
||||
metadata: [
|
||||
{
|
||||
key: UserMetadataKey.Onboarding,
|
||||
value: 'true',
|
||||
},
|
||||
] as UserMetadataItem[],
|
||||
...user,
|
||||
});
|
||||
|
||||
const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
|
||||
const {
|
||||
id = newUuid(),
|
||||
@@ -219,34 +141,6 @@ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
|
||||
};
|
||||
};
|
||||
|
||||
const activityFactory = (activity: Omit<Partial<Activity>, 'user'> = {}) => {
|
||||
const userId = activity.userId || newUuid();
|
||||
return {
|
||||
id: newUuid(),
|
||||
comment: null,
|
||||
isLiked: false,
|
||||
userId,
|
||||
user: UserFactory.create({ id: userId }),
|
||||
assetId: newUuid(),
|
||||
albumId: newUuid(),
|
||||
createdAt: newDate(),
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
...activity,
|
||||
};
|
||||
};
|
||||
|
||||
const apiKeyFactory = (apiKey: Partial<ApiKey> = {}) => ({
|
||||
id: newUuid(),
|
||||
userId: newUuid(),
|
||||
createdAt: newDate(),
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
name: 'Api Key',
|
||||
permissions: [Permission.All],
|
||||
...apiKey,
|
||||
});
|
||||
|
||||
const libraryFactory = (library: Partial<Library> = {}) => ({
|
||||
id: newUuid(),
|
||||
createdAt: newDate(),
|
||||
@@ -328,88 +222,15 @@ const assetOcrFactory = (
|
||||
...ocr,
|
||||
});
|
||||
|
||||
const tagFactory = (tag: Partial<Tag>): Tag => ({
|
||||
id: newUuid(),
|
||||
color: null,
|
||||
createdAt: newDate(),
|
||||
parentId: null,
|
||||
updatedAt: newDate(),
|
||||
value: `tag-${newUuid()}`,
|
||||
...tag,
|
||||
});
|
||||
|
||||
const assetEditFactory = (edit?: Partial<AssetEditActionItem>): AssetEditActionItem => {
|
||||
switch (edit?.action) {
|
||||
case AssetEditAction.Crop: {
|
||||
return { action: AssetEditAction.Crop, parameters: { height: 42, width: 42, x: 0, y: 10 }, ...edit };
|
||||
}
|
||||
case AssetEditAction.Mirror: {
|
||||
return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal }, ...edit };
|
||||
}
|
||||
case AssetEditAction.Rotate: {
|
||||
return { action: AssetEditAction.Rotate, parameters: { angle: 90 }, ...edit };
|
||||
}
|
||||
default: {
|
||||
return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const personFactory = (person?: Partial<Person>): Person => ({
|
||||
birthDate: newDate(),
|
||||
color: null,
|
||||
createdAt: newDate(),
|
||||
faceAssetId: null,
|
||||
id: newUuid(),
|
||||
isFavorite: false,
|
||||
isHidden: false,
|
||||
name: 'person',
|
||||
ownerId: newUuid(),
|
||||
thumbnailPath: '/path/to/person/thumbnail.jpg',
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
...person,
|
||||
});
|
||||
|
||||
const albumFactory = (album?: Partial<Omit<Album, 'assets'>>) => ({
|
||||
albumName: 'My Album',
|
||||
albumThumbnailAssetId: null,
|
||||
albumUsers: [],
|
||||
assets: [],
|
||||
createdAt: newDate(),
|
||||
deletedAt: null,
|
||||
description: 'Album description',
|
||||
id: newUuid(),
|
||||
isActivityEnabled: false,
|
||||
order: AssetOrder.Desc,
|
||||
ownerId: newUuid(),
|
||||
sharedLinks: [],
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
...album,
|
||||
});
|
||||
|
||||
export const factory = {
|
||||
activity: activityFactory,
|
||||
apiKey: apiKeyFactory,
|
||||
assetOcr: assetOcrFactory,
|
||||
auth: authFactory,
|
||||
authApiKey: authApiKeyFactory,
|
||||
authUser: authUserFactory,
|
||||
library: libraryFactory,
|
||||
partner: partnerFactory,
|
||||
queueStatistics: queueStatisticsFactory,
|
||||
session: sessionFactory,
|
||||
user: userFactory,
|
||||
userAdmin: userAdminFactory,
|
||||
versionHistory: versionHistoryFactory,
|
||||
jobAssets: {
|
||||
sidecarWrite: assetSidecarWriteFactory,
|
||||
},
|
||||
person: personFactory,
|
||||
assetEdit: assetEditFactory,
|
||||
tag: tagFactory,
|
||||
album: albumFactory,
|
||||
uuid: newUuid,
|
||||
buffer: () => Buffer.from('this is a fake buffer'),
|
||||
date: newDate,
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
"svelte-maplibre": "^1.2.5",
|
||||
"svelte-persisted-store": "^0.12.0",
|
||||
"tabbable": "^6.2.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"thumbhash": "^0.1.1",
|
||||
"transformation-matrix": "^3.1.0",
|
||||
"uplot": "^1.6.32"
|
||||
|
||||
@@ -23,7 +23,25 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
|
||||
node.addEventListener('wheel', onInteractionStart, { capture: true });
|
||||
node.addEventListener('pointerdown', onInteractionStart, { capture: true });
|
||||
|
||||
// Suppress Safari's synthetic dblclick on double-tap. Without this, zoom-image's touchstart
|
||||
// handler zooms to maxZoom (10x), then Safari's synthetic dblclick triggers photo-viewer's
|
||||
// handler which conflicts. Chrome does not fire synthetic dblclick on touch.
|
||||
let lastPointerWasTouch = false;
|
||||
const trackPointerType = (event: PointerEvent) => {
|
||||
lastPointerWasTouch = event.pointerType === 'touch';
|
||||
};
|
||||
const suppressTouchDblClick = (event: MouseEvent) => {
|
||||
if (lastPointerWasTouch) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
node.addEventListener('pointerdown', trackPointerType, { capture: true });
|
||||
node.addEventListener('dblclick', suppressTouchDblClick, { capture: true });
|
||||
|
||||
// Allow zoomed content to render outside the container bounds
|
||||
node.style.overflow = 'visible';
|
||||
// Prevent browser handling of touch gestures so zoom-image can manage them
|
||||
node.style.touchAction = 'none';
|
||||
return {
|
||||
update(newOptions?: { disabled?: boolean }) {
|
||||
options = newOptions;
|
||||
@@ -34,6 +52,8 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
|
||||
}
|
||||
node.removeEventListener('wheel', onInteractionStart, { capture: true });
|
||||
node.removeEventListener('pointerdown', onInteractionStart, { capture: true });
|
||||
node.removeEventListener('pointerdown', trackPointerType, { capture: true });
|
||||
node.removeEventListener('dblclick', suppressTouchDblClick, { capture: true });
|
||||
zoomInstance.cleanup();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -162,8 +162,9 @@
|
||||
<div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}>
|
||||
{@render backdrop?.()}
|
||||
|
||||
<!-- pointer-events-none so events pass through to the container where zoom-image listens -->
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
class="absolute inset-0 pointer-events-none"
|
||||
style:transform={zoomTransform}
|
||||
style:transform-origin={zoomTransform ? '0 0' : undefined}
|
||||
>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { cleanClass } from '$lib';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
class?: ClassValue;
|
||||
}
|
||||
|
||||
let { class: className = '' }: Props = $props();
|
||||
let { class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="absolute h-full w-full bg-gray-300 dark:bg-gray-700 {className}"></div>
|
||||
<div class={cleanClass('absolute h-full w-full bg-gray-300 dark:bg-gray-700', className)}></div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { cleanClass } from '$lib';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
@@ -8,7 +9,7 @@
|
||||
let { class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="delayed inline-flex items-center gap-1 {className}">
|
||||
<div class={cleanClass('delayed inline-flex items-center gap-1', className)}>
|
||||
{#each [0, 1, 2] as i (i)}
|
||||
<span class="dot block size-1.5 rounded-full bg-white shadow-[0_0_3px_rgba(0,0,0,0.6)]" style:--delay="{i * 0.25}s"
|
||||
></span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { cleanClass } from '$lib';
|
||||
import QueueCardBadge from '$lib/components/QueueCardBadge.svelte';
|
||||
import QueueCardButton from '$lib/components/QueueCardButton.svelte';
|
||||
import Badge from '$lib/elements/Badge.svelte';
|
||||
@@ -105,7 +106,10 @@
|
||||
|
||||
<div class="mt-2 flex w-full max-w-md flex-col sm:flex-row">
|
||||
<div
|
||||
class="{commonClasses} rounded-t-lg bg-immich-primary text-white dark:bg-immich-dark-primary dark:text-immich-dark-gray sm:rounded-s-lg sm:rounded-e-none"
|
||||
class={cleanClass(
|
||||
commonClasses,
|
||||
'rounded-t-lg bg-immich-primary text-white dark:bg-immich-dark-primary dark:text-immich-dark-gray sm:rounded-s-lg sm:rounded-e-none',
|
||||
)}
|
||||
>
|
||||
<p>{$t('active')}</p>
|
||||
<p class="text-2xl">
|
||||
@@ -114,7 +118,10 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="{commonClasses} flex-row-reverse rounded-b-lg bg-gray-200 text-immich-dark-bg dark:bg-gray-700 dark:text-immich-gray sm:rounded-s-none sm:rounded-e-lg"
|
||||
class={cleanClass(
|
||||
commonClasses,
|
||||
'flex-row-reverse rounded-b-lg bg-gray-200 text-immich-dark-bg dark:bg-gray-700 dark:text-immich-gray sm:rounded-s-none sm:rounded-e-lg',
|
||||
)}
|
||||
>
|
||||
<p class="text-2xl">
|
||||
{waitingCount.toLocaleString($locale)}
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
<script lang="ts" module>
|
||||
export type Color = 'success' | 'warning';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
interface Props {
|
||||
color: Color;
|
||||
type Props = {
|
||||
color: 'success' | 'warning';
|
||||
children?: Snippet;
|
||||
}
|
||||
};
|
||||
|
||||
let { color, children }: Props = $props();
|
||||
|
||||
const colorClasses: Record<Color, string> = {
|
||||
success: 'bg-green-500/70 text-gray-900 dark:bg-green-700/90 dark:text-gray-100',
|
||||
warning: 'bg-orange-400/70 text-gray-900 dark:bg-orange-900 dark:text-gray-100',
|
||||
};
|
||||
const styles = tv({
|
||||
base: 'w-full p-2 text-center text-sm ',
|
||||
variants: {
|
||||
color: {
|
||||
success: 'bg-green-500/70 text-gray-900 dark:bg-green-700/90 dark:text-gray-100',
|
||||
warning: 'bg-orange-400/70 text-gray-900 dark:bg-orange-900 dark:text-gray-100',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="w-full p-2 text-center text-sm {colorClasses[color]}">
|
||||
<div class={styles({ color })}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
interface Props {
|
||||
color: Colors;
|
||||
@@ -14,24 +15,22 @@
|
||||
|
||||
let { color, disabled = false, onClick = () => {}, children }: Props = $props();
|
||||
|
||||
const colorClasses: Record<Colors, string> = {
|
||||
'light-gray': 'bg-gray-300/80 dark:bg-gray-700',
|
||||
gray: 'bg-gray-300/90 dark:bg-gray-700/90',
|
||||
'dark-gray': 'bg-gray-300 dark:bg-gray-700/80',
|
||||
};
|
||||
|
||||
const hoverClasses = disabled
|
||||
? 'cursor-not-allowed'
|
||||
: 'hover:bg-immich-primary hover:text-white dark:hover:bg-immich-dark-primary dark:hover:text-black';
|
||||
const styles = tv({
|
||||
base: 'flex h-full w-full flex-col place-content-center place-items-center gap-2 px-8 py-2 text-xs text-gray-600 transition-colors dark:text-gray-200 ',
|
||||
variants: {
|
||||
color: {
|
||||
'light-gray': 'bg-gray-300/80 dark:bg-gray-700',
|
||||
gray: 'bg-gray-300/90 dark:bg-gray-700/90',
|
||||
'dark-gray': 'bg-gray-300 dark:bg-gray-700/80',
|
||||
},
|
||||
disabled: {
|
||||
true: 'cursor-not-allowed',
|
||||
false: 'hover:bg-immich-primary hover:text-white dark:hover:bg-immich-dark-primary dark:hover:text-black',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
{disabled}
|
||||
class="flex h-full w-full flex-col place-content-center place-items-center gap-2 px-8 py-2 text-xs text-gray-600 transition-colors dark:text-gray-200 {colorClasses[
|
||||
color
|
||||
]} {hoverClasses}"
|
||||
onclick={onClick}
|
||||
>
|
||||
<button type="button" {disabled} class={styles({ disabled, color })} onclick={onClick}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user