Compare commits

..

1 Commits

Author SHA1 Message Date
izzy d2d58c2024 refactor(database restores): use file interceptor (partial impl.) 2026-03-02 09:48:16 +00:00
99 changed files with 571 additions and 12376 deletions
-406
View File
@@ -1,406 +0,0 @@
name: Visual Review
on:
pull_request:
types: [labeled, synchronize]
permissions: {}
jobs:
visual-diff:
name: Visual Diff Screenshots
runs-on: ubuntu-latest
if: >-
(github.event.action == 'labeled' && github.event.label.name == 'visual-review') ||
(github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'visual-review'))
permissions:
contents: read
pull-requests: write
defaults:
run:
working-directory: ./e2e
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout PR branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
fetch-depth: 0
- name: Determine changed web files
id: changed-files
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.token.outputs.token }}
script: |
const files = [];
const perPage = 100;
let page = 1;
while (true) {
const { data } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: perPage,
page,
});
files.push(...data);
if (data.length < perPage) break;
page++;
}
const webPrefixes = ['web/', 'i18n/', 'open-api/typescript-sdk/'];
const webFiles = files
.map(f => f.filename)
.filter(f => webPrefixes.some(p => f.startsWith(p)));
console.log(`Total PR files: ${files.length}`);
console.log(`Web-related files: ${webFiles.length}`);
for (const f of webFiles) {
console.log(` ${f}`);
}
core.setOutput('files', webFiles.join('\n'));
core.setOutput('has_changes', webFiles.length > 0 ? 'true' : 'false');
- name: Setup pnpm
if: steps.changed-files.outputs.has_changes == 'true'
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
if: steps.changed-files.outputs.has_changes == 'true'
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install e2e dependencies
if: steps.changed-files.outputs.has_changes == 'true'
run: pnpm install --frozen-lockfile
- name: Install Playwright
if: steps.changed-files.outputs.has_changes == 'true'
run: pnpm exec playwright install chromium --only-shell
- name: Analyze affected routes
if: steps.changed-files.outputs.has_changes == 'true'
id: routes
env:
CHANGED_FILES: ${{ steps.changed-files.outputs.files }}
run: |
echo "Changed files:"
echo "$CHANGED_FILES"
echo "---"
ROUTES=$(echo "$CHANGED_FILES" | xargs pnpm exec tsx src/screenshots/analyze-deps.ts 2>&1 | tee /dev/stderr | grep "^ /" | sed 's/^ //' || true)
echo "routes<<EOF" >> "$GITHUB_OUTPUT"
echo "$ROUTES" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
if [ -z "$ROUTES" ]; then
echo "has_routes=false" >> "$GITHUB_OUTPUT"
else
echo "has_routes=true" >> "$GITHUB_OUTPUT"
# Build the scenario filter JSON array
SCENARIO_NAMES=$(pnpm exec tsx -e "
import { getScenariosForRoutes } from './src/screenshots/page-map.ts';
const routes = process.argv.slice(1);
const scenarios = getScenariosForRoutes(routes);
console.log(JSON.stringify(scenarios.map(s => s.name)));
" $ROUTES)
echo "scenarios=$SCENARIO_NAMES" >> "$GITHUB_OUTPUT"
echo "Scenarios: $SCENARIO_NAMES"
fi
- name: Post initial comment
if: steps.changed-files.outputs.has_changes == 'true' && steps.routes.outputs.has_routes == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
AFFECTED_ROUTES: ${{ steps.routes.outputs.routes }}
with:
github-token: ${{ steps.token.outputs.token }}
script: |
const routes = process.env.AFFECTED_ROUTES || '';
const body = `## Visual Review\n\nGenerating screenshots for affected pages...\n\nAffected routes:\n\`\`\`\n${routes}\n\`\`\``;
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
# === Screenshot PR version ===
- name: Build SDK (PR)
if: steps.routes.outputs.has_routes == 'true'
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
- name: Build web (PR)
if: steps.routes.outputs.has_routes == 'true'
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./web
- name: Take screenshots (PR)
if: steps.routes.outputs.has_routes == 'true'
env:
PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS: '1'
SCREENSHOT_OUTPUT_DIR: ${{ github.workspace }}/screenshots/pr
SCREENSHOT_SCENARIOS: ${{ steps.routes.outputs.scenarios }}
SCREENSHOT_BASE_URL: http://127.0.0.1:4173
run: |
# Start the preview server in background
cd ../web && pnpm preview --port 4173 --host 127.0.0.1 &
SERVER_PID=$!
# Wait for server to be ready
for i in $(seq 1 30); do
if curl -s http://127.0.0.1:4173 > /dev/null 2>&1; then
echo "Server ready after ${i}s"
break
fi
sleep 1
done
# Run screenshot tests
pnpm exec playwright test --config playwright.screenshot.config.ts || true
# Stop the preview server and all children (pnpm spawns vite as child)
kill $SERVER_PID 2>/dev/null || true
sleep 1
# Ensure port is fully released — kill any lingering vite process
fuser -k 4173/tcp 2>/dev/null || true
sleep 1
# === Screenshot base version ===
# Disable pnpm's verifyDepsBeforeRun for all base steps since the base
# checkout changes package.json files, making them mismatch the lockfile.
- name: Checkout base web directory
if: steps.routes.outputs.has_routes == 'true'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
# Restore web directory from base branch
git checkout "$BASE_SHA" -- web/ open-api/typescript-sdk/ i18n/ || true
# Clear SvelteKit build cache to avoid stale artifacts from the PR build
rm -rf web/.svelte-kit web/build
working-directory: .
- name: Build SDK (base)
if: steps.routes.outputs.has_routes == 'true'
continue-on-error: true
id: base-sdk
env:
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
run: pnpm build
working-directory: ./open-api/typescript-sdk
- name: Build web (base)
if: steps.routes.outputs.has_routes == 'true' && steps.base-sdk.outcome == 'success'
continue-on-error: true
id: base-web
env:
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
run: pnpm build
working-directory: ./web
- name: Take screenshots (base)
if: steps.routes.outputs.has_routes == 'true' && steps.base-web.outcome == 'success'
env:
PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS: '1'
SCREENSHOT_OUTPUT_DIR: ${{ github.workspace }}/screenshots/base
SCREENSHOT_SCENARIOS: ${{ steps.routes.outputs.scenarios }}
SCREENSHOT_BASE_URL: http://127.0.0.1:4173
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
run: |
# Kill any process still on port 4173 from the PR step
fuser -k 4173/tcp 2>/dev/null || true
sleep 1
# Start the preview server in background
cd ../web && pnpm preview --port 4173 --host 127.0.0.1 &
SERVER_PID=$!
# Wait for server to be ready
for i in $(seq 1 30); do
if curl -s http://127.0.0.1:4173 > /dev/null 2>&1; then
echo "Server ready after ${i}s"
break
fi
sleep 1
done
# Run screenshot tests
pnpm exec playwright test --config playwright.screenshot.config.ts || true
# Stop the preview server
kill $SERVER_PID 2>/dev/null || true
fuser -k 4173/tcp 2>/dev/null || true
- name: Restore PR source
if: steps.routes.outputs.has_routes == 'true'
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
git checkout "$HEAD_SHA" -- web/ open-api/typescript-sdk/ i18n/ || true
working-directory: .
# === Compare and report ===
- name: Compare screenshots
if: steps.routes.outputs.has_routes == 'true'
env:
WORKSPACE_DIR: ${{ github.workspace }}
run: |
# Ensure directories exist even if base screenshots were skipped
mkdir -p "$WORKSPACE_DIR/screenshots/base" "$WORKSPACE_DIR/screenshots/pr" "$WORKSPACE_DIR/screenshots/diff"
pnpm exec tsx src/screenshots/compare.ts \
"$WORKSPACE_DIR/screenshots/base" \
"$WORKSPACE_DIR/screenshots/pr" \
"$WORKSPACE_DIR/screenshots/diff"
- name: Upload screenshot artifacts
if: steps.routes.outputs.has_routes == 'true'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: visual-review-screenshots
path: screenshots/
retention-days: 14
- name: Upload HTML report
if: steps.routes.outputs.has_routes == 'true'
id: html-report
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
path: screenshots/diff/visual-review.html
archive: false
retention-days: 14
- name: Post comparison results
if: steps.routes.outputs.has_routes == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
REPORT_URL: ${{ steps.html-report.outputs.artifact-url }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
github-token: ${{ steps.token.outputs.token }}
script: |
const fs = require('fs');
const path = require('path');
const reportPath = path.join(process.env.GITHUB_WORKSPACE, 'screenshots', 'diff', 'report.md');
let body;
try {
body = fs.readFileSync(reportPath, 'utf8');
} catch {
body = '## Visual Review\n\nScreenshot comparison failed. Check the workflow artifacts for details.';
}
// Append links to the HTML report artifact and workflow run
const reportUrl = process.env.REPORT_URL;
const runUrl = process.env.RUN_URL;
body += '\n---\n';
if (reportUrl) {
body += `[View full visual comparison](${reportUrl}) | `;
}
body += `[Download all screenshots](${runUrl}#artifacts)\n`;
// Find and update existing comment or create new one
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.data.find(c =>
c.body && c.body.includes('## Visual Review')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: No web changes
if: steps.changed-files.outputs.has_changes != 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.token.outputs.token }}
script: |
const body = '## Visual Review\n\nNo web-related file changes detected in this PR. Visual review not needed.';
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner, repo: context.repo.repo,
comment_id: existing.id, body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.issue.number, body,
});
}
- name: No affected routes
if: steps.changed-files.outputs.has_changes == 'true' && steps.routes.outputs.has_routes != 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.token.outputs.token }}
script: |
const body = '## Visual Review\n\nChanged files don\'t affect any pages with screenshot scenarios configured.\nTo add coverage, define new scenarios in `e2e/src/screenshots/page-map.ts`.';
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner, repo: context.repo.repo,
comment_id: existing.id, body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.issue.number, body,
});
}
-1
View File
@@ -24,7 +24,6 @@ open-api/typescript-sdk/build
mobile/android/fastlane/report.xml
mobile/ios/fastlane/report.xml
screenshots-output
vite.config.js.timestamp-*
.pnpm-store
.devcontainer/library
+1 -5
View File
@@ -18,10 +18,7 @@
"format:fix": "prettier --write .",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"lint:fix": "pnpm run lint --fix",
"check": "tsc --noEmit",
"screenshots": "pnpm exec playwright test --config playwright.screenshot.config.ts",
"screenshots:compare": "pnpm exec tsx src/screenshots/compare.ts",
"screenshots:analyze": "pnpm exec tsx src/screenshots/analyze-deps.ts"
"check": "tsc --noEmit"
},
"keywords": [],
"author": "",
@@ -54,7 +51,6 @@
"sharp": "^0.34.5",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"tsx": "^4.21.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
"utimes": "^5.2.1",
-27
View File
@@ -1,27 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
const baseUrl = process.env.SCREENSHOT_BASE_URL ?? 'http://127.0.0.1:4173';
export default defineConfig({
testDir: './src/screenshots',
testMatch: /run-scenarios\.ts/,
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
reporter: 'list',
use: {
baseURL: baseUrl,
screenshot: 'off',
trace: 'off',
},
workers: 1,
projects: [
{
name: 'screenshots',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1080 },
},
},
],
});
-249
View File
@@ -1,249 +0,0 @@
/**
* Reverse dependency analyzer for the Immich web app.
*
* Given a list of changed files, traces upward through the import graph
* to find which +page.svelte routes are affected, then maps those to URL paths.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
const WEB_SRC = resolve(import.meta.dirname, '../../../web/src');
const LIB_ALIAS = resolve(WEB_SRC, 'lib');
/** Collect all .svelte, .ts, .js files under web/src/ */
function collectFiles(dir: string): string[] {
const results: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
if (entry === 'node_modules' || entry === '.svelte-kit') {
continue;
}
results.push(...collectFiles(full));
} else if (/\.(svelte|ts|js)$/.test(entry)) {
results.push(full);
}
}
return results;
}
/** Extract import specifiers from a file's source text. */
function extractImports(source: string): string[] {
const specifiers: string[] = [];
// Match: import ... from '...' / import '...' / export ... from '...'
const importRegex = /(?:import|export)\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g;
let match;
while ((match = importRegex.exec(source)) !== null) {
specifiers.push(match[1]);
}
// Match dynamic imports: import('...')
const dynamicRegex = /import\(\s*['"]([^'"]+)['"]\s*\)/g;
while ((match = dynamicRegex.exec(source)) !== null) {
specifiers.push(match[1]);
}
return specifiers;
}
/** Resolve an import specifier to an absolute file path (or null if external). */
function resolveImport(specifier: string, fromFile: string, allFiles: Set<string>): string | null {
// Handle $lib alias
let resolved: string;
if (specifier.startsWith('$lib/') || specifier === '$lib') {
resolved = specifier.replace('$lib', LIB_ALIAS);
} else if (specifier.startsWith('./') || specifier.startsWith('../')) {
resolved = resolve(dirname(fromFile), specifier);
} else {
// External package import — not relevant
return null;
}
// Try exact match, then common extensions
const extensions = ['', '.ts', '.js', '.svelte', '/index.ts', '/index.js', '/index.svelte'];
for (const ext of extensions) {
const candidate = resolved + ext;
if (allFiles.has(candidate)) {
return candidate;
}
}
return null;
}
/** Build the forward dependency graph: file → set of files it imports. */
function buildDependencyGraph(files: string[]): Map<string, Set<string>> {
const fileSet = new Set(files);
const graph = new Map<string, Set<string>>();
for (const file of files) {
const deps = new Set<string>();
graph.set(file, deps);
try {
const source = readFileSync(file, 'utf8');
for (const specifier of extractImports(source)) {
const resolved = resolveImport(specifier, file, fileSet);
if (resolved) {
deps.add(resolved);
}
}
} catch {
// Skip files that can't be read
}
}
return graph;
}
/** Invert the dependency graph: file → set of files that import it. */
function buildReverseDependencyGraph(forwardGraph: Map<string, Set<string>>): Map<string, Set<string>> {
const reverse = new Map<string, Set<string>>();
for (const [file, deps] of forwardGraph) {
for (const dep of deps) {
let importers = reverse.get(dep);
if (!importers) {
importers = new Set();
reverse.set(dep, importers);
}
importers.add(file);
}
}
return reverse;
}
/** BFS from changed files upward through reverse deps to find +page.svelte files. */
function findAffectedPages(changedFiles: string[], reverseGraph: Map<string, Set<string>>): Set<string> {
const visited = new Set<string>();
const pages = new Set<string>();
const queue = [...changedFiles];
while (queue.length > 0) {
const file = queue.shift()!;
if (visited.has(file)) {
continue;
}
visited.add(file);
if (file.endsWith('+page.svelte') || file.endsWith('+layout.svelte')) {
pages.add(file);
// If it's a layout, keep tracing upward because the layout itself
// isn't a page — but the pages under it are affected.
// If it's a +page.svelte, we still want to continue in case
// this page is imported by others.
}
const importers = reverseGraph.get(file);
if (importers) {
for (const importer of importers) {
if (!visited.has(importer)) {
queue.push(importer);
}
}
}
}
// For +layout.svelte hits, also find all +page.svelte under the same directory tree
const layoutDirs: string[] = [];
for (const page of pages) {
if (page.endsWith('+layout.svelte')) {
layoutDirs.push(dirname(page));
pages.delete(page);
}
}
if (layoutDirs.length > 0) {
for (const file of reverseGraph.keys()) {
if (file.endsWith('+page.svelte')) {
for (const layoutDir of layoutDirs) {
if (file.startsWith(layoutDir)) {
pages.add(file);
}
}
}
}
// Also check the forward graph keys for page files under layout dirs
for (const layoutDir of layoutDirs) {
const allFiles = collectFiles(layoutDir);
for (const f of allFiles) {
if (f.endsWith('+page.svelte')) {
pages.add(f);
}
}
}
}
return pages;
}
/** Convert a +page.svelte file path to its URL route. */
export function pageFileToRoute(pageFile: string): string {
const routesDir = resolve(WEB_SRC, 'routes');
let rel = relative(routesDir, dirname(pageFile));
// Remove SvelteKit group markers: (user), (list), etc.
rel = rel.replaceAll(/\([^)]+\)\/?/g, '');
// Remove parameter segments: [albumId=id], [[photos=photos]], [[assetId=id]]
rel = rel.replaceAll(/\[\[?[^\]]+\]\]?\/?/g, '');
// Clean up trailing slashes and normalize
rel = rel.replaceAll(/\/+/g, '/').replace(/\/$/, '');
return '/' + rel;
}
export interface AnalysisResult {
affectedPages: string[];
affectedRoutes: string[];
}
/** Main entry: analyze which routes are affected by the given changed files. */
export function analyzeAffectedRoutes(changedFiles: string[]): AnalysisResult {
// Resolve changed files to absolute paths relative to web/src
const webRoot = resolve(WEB_SRC, '..');
const resolvedChanged = changedFiles
.filter((f) => f.startsWith('web/'))
.map((f) => resolve(webRoot, '..', f))
.filter((f) => statSync(f, { throwIfNoEntry: false })?.isFile());
if (resolvedChanged.length === 0) {
return { affectedPages: [], affectedRoutes: [] };
}
const allFiles = collectFiles(WEB_SRC);
const forwardGraph = buildDependencyGraph(allFiles);
const reverseGraph = buildReverseDependencyGraph(forwardGraph);
const pages = findAffectedPages(resolvedChanged, reverseGraph);
const affectedPages = [...pages].toSorted();
const affectedRoutes = [...new Set(affectedPages.map((f) => pageFileToRoute(f)))].toSorted();
return { affectedPages, affectedRoutes };
}
// CLI usage: node --import tsx analyze-deps.ts file1 file2 ...
if (process.argv[1]?.endsWith('analyze-deps.ts') || process.argv[1]?.endsWith('analyze-deps.js')) {
const files = process.argv.slice(2);
if (files.length === 0) {
console.log('Usage: analyze-deps.ts <changed-file1> <changed-file2> ...');
console.log('Files should be relative to the repo root (e.g. web/src/lib/components/Button.svelte)');
throw new Error('No files provided');
}
const result = analyzeAffectedRoutes(files);
console.log('Affected pages:');
for (const page of result.affectedPages) {
console.log(` ${page}`);
}
console.log('\nAffected routes:');
for (const route of result.affectedRoutes) {
console.log(` ${route}`);
}
}
-335
View File
@@ -1,335 +0,0 @@
/**
* Pixel-level comparison of base vs PR screenshots.
*
* Uses pixelmatch to generate diff images and calculate change percentages.
*
* Usage:
* npx tsx e2e/src/screenshots/compare.ts <base-dir> <pr-dir> <output-dir>
*/
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { basename, join, resolve } from 'node:path';
import { PNG } from 'pngjs';
// pixelmatch is a lightweight dependency — use a simple inline implementation
// based on the approach from the pixelmatch library to avoid adding a new dependency.
// The e2e package already has pngjs.
function pixelMatch(img1Data: Uint8Array, img2Data: Uint8Array, diffData: Uint8Array): number {
let diffCount = 0;
for (let i = 0; i < img1Data.length; i += 4) {
const r1 = img1Data[i];
const g1 = img1Data[i + 1];
const b1 = img1Data[i + 2];
const r2 = img2Data[i];
const g2 = img2Data[i + 1];
const b2 = img2Data[i + 2];
const dr = Math.abs(r1 - r2);
const dg = Math.abs(g1 - g2);
const db = Math.abs(b1 - b2);
// Threshold: if any channel differs by more than 25, mark as different
const isDiff = dr > 25 || dg > 25 || db > 25;
if (isDiff) {
// Red highlight for diff pixels
diffData[i] = 255;
diffData[i + 1] = 0;
diffData[i + 2] = 0;
diffData[i + 3] = 255;
diffCount++;
} else {
// Dimmed original for unchanged pixels
const gray = Math.round(0.299 * r1 + 0.587 * g1 + 0.114 * b1);
diffData[i] = gray;
diffData[i + 1] = gray;
diffData[i + 2] = gray;
diffData[i + 3] = 128;
}
}
return diffCount;
}
export interface ComparisonResult {
name: string;
baseExists: boolean;
prExists: boolean;
diffPixels: number;
totalPixels: number;
changePercent: number;
diffImagePath: string | null;
baseImagePath: string | null;
prImagePath: string | null;
}
export function compareScreenshots(baseDir: string, prDir: string, outputDir: string): ComparisonResult[] {
mkdirSync(outputDir, { recursive: true });
// Collect all screenshot names from both directories
const baseFiles = existsSync(baseDir)
? new Set(readdirSync(baseDir).filter((f) => f.endsWith('.png')))
: new Set<string>();
const prFiles = existsSync(prDir) ? new Set(readdirSync(prDir).filter((f) => f.endsWith('.png'))) : new Set<string>();
const allNames = new Set([...baseFiles, ...prFiles]);
const results: ComparisonResult[] = [];
for (const fileName of [...allNames].toSorted()) {
const name = basename(fileName, '.png');
const basePath = join(baseDir, fileName);
const prPath = join(prDir, fileName);
const baseExists = baseFiles.has(fileName);
const prExists = prFiles.has(fileName);
if (!baseExists || !prExists) {
// New or removed page
results.push({
name,
baseExists,
prExists,
diffPixels: -1,
totalPixels: -1,
changePercent: 100,
diffImagePath: null,
baseImagePath: baseExists ? basePath : null,
prImagePath: prExists ? prPath : null,
});
continue;
}
// Load both PNGs
const basePng = PNG.sync.read(readFileSync(basePath));
const prPng = PNG.sync.read(readFileSync(prPath));
// Handle size mismatches by comparing the overlapping region
const width = Math.max(basePng.width, prPng.width);
const height = Math.max(basePng.height, prPng.height);
// Resize images to the same dimensions (pad with transparent)
const normalizedBase = normalizeImage(basePng, width, height);
const normalizedPr = normalizeImage(prPng, width, height);
const diffPng = new PNG({ width, height });
const totalPixels = width * height;
const diffPixels = pixelMatch(normalizedBase, normalizedPr, diffPng.data as unknown as Uint8Array);
const diffImagePath = join(outputDir, `${name}-diff.png`);
writeFileSync(diffImagePath, PNG.sync.write(diffPng));
results.push({
name,
baseExists,
prExists,
diffPixels,
totalPixels,
changePercent: totalPixels > 0 ? (diffPixels / totalPixels) * 100 : 0,
diffImagePath,
baseImagePath: basePath,
prImagePath: prPath,
});
}
return results;
}
function normalizeImage(png: PNG, targetWidth: number, targetHeight: number): Uint8Array {
if (png.width === targetWidth && png.height === targetHeight) {
return png.data as unknown as Uint8Array;
}
const data = new Uint8Array(targetWidth * targetHeight * 4);
for (let y = 0; y < targetHeight; y++) {
for (let x = 0; x < targetWidth; x++) {
const targetIdx = (y * targetWidth + x) * 4;
if (x < png.width && y < png.height) {
const sourceIdx = (y * png.width + x) * 4;
data[targetIdx] = png.data[sourceIdx];
data[targetIdx + 1] = png.data[sourceIdx + 1];
data[targetIdx + 2] = png.data[sourceIdx + 2];
data[targetIdx + 3] = png.data[sourceIdx + 3];
} else {
// Transparent padding
data[targetIdx + 3] = 0;
}
}
}
return data;
}
/** Generate a text-only markdown summary for the PR comment. */
export function generateMarkdownReport(results: ComparisonResult[]): string {
const changed = results.filter((r) => r.changePercent > 0.1);
const unchanged = results.filter((r) => r.changePercent <= 0.1);
if (changed.length === 0) {
return '## Visual Review\n\nNo visual changes detected in the affected pages.';
}
let md = '## Visual Review\n\n';
md += `Found **${changed.length}** page(s) with visual changes`;
if (unchanged.length > 0) {
md += ` (${unchanged.length} unchanged)`;
}
md += '.\n\n';
md += '| Page | Status | Change |\n';
md += '|------|--------|--------|\n';
for (const result of changed) {
if (result.baseExists && result.prExists) {
md += `| ${result.name} | Changed | ${result.changePercent.toFixed(1)}% |\n`;
} else if (result.prExists) {
md += `| ${result.name} | New | - |\n`;
} else {
md += `| ${result.name} | Removed | - |\n`;
}
}
md += '\n';
if (unchanged.length > 0) {
md += '<details>\n<summary>Unchanged pages</summary>\n\n';
for (const result of unchanged) {
md += `- ${result.name}\n`;
}
md += '\n</details>\n';
}
return md;
}
function imgTag(filePath: string | null, alt: string): string {
if (!filePath || !existsSync(filePath)) {
return `<div class="no-image">${alt} not available</div>`;
}
const data = readFileSync(filePath);
return `<img src="data:image/png;base64,${data.toString('base64')}" alt="${alt}" loading="lazy" />`;
}
/** Generate an HTML report with embedded base64 images for the artifact. */
export function generateHtmlReport(results: ComparisonResult[]): string {
const changed = results.filter((r) => r.changePercent > 0.1);
const unchanged = results.filter((r) => r.changePercent <= 0.1);
let html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visual Review</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
background: #0d1117; color: #e6edf3; padding: 32px; line-height: 1.5; }
.container { max-width: 1800px; margin: 0 auto; }
h1 { font-size: 24px; border-bottom: 1px solid #30363d; padding-bottom: 12px; margin-bottom: 24px; }
.summary { color: #8b949e; margin-bottom: 32px; font-size: 16px; }
.scenario { margin-bottom: 40px; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
.scenario-header { background: #161b22; padding: 12px 16px; display: flex; align-items: center; gap: 12px; }
.scenario-header h2 { font-size: 16px; font-weight: 600; }
.badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; }
.badge-changed { background: #da363380; color: #f85149; }
.badge-new { background: #1f6feb80; color: #58a6ff; }
.badge-removed { background: #6e767e80; color: #8b949e; }
.grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1px; background: #30363d; }
.grid-cell { background: #0d1117; }
.grid-label { text-align: center; padding: 8px; font-size: 13px; color: #8b949e; font-weight: 600;
background: #161b22; text-transform: uppercase; letter-spacing: 0.5px; }
.grid-cell img { width: 100%; display: block; }
.no-image { padding: 40px; text-align: center; color: #484f58; font-style: italic; }
.unchanged-section { margin-top: 32px; color: #8b949e; }
.unchanged-section summary { cursor: pointer; font-size: 14px; }
.unchanged-section ul { margin-top: 8px; padding-left: 24px; }
.unchanged-section li { font-size: 14px; margin: 4px 0; }
</style>
</head>
<body>
<div class="container">
<h1>Visual Review</h1>
`;
if (changed.length === 0) {
html += '<p class="summary">No visual changes detected in the affected pages.</p>';
} else {
html += `<p class="summary">Found <strong>${changed.length}</strong> page(s) with visual changes`;
if (unchanged.length > 0) {
html += ` (${unchanged.length} unchanged)`;
}
html += '.</p>\n';
for (const result of changed) {
html += '<div class="scenario">\n<div class="scenario-header">\n';
html += `<h2>${result.name}</h2>\n`;
if (!result.baseExists) {
html += '<span class="badge badge-new">New</span>\n';
html += '</div>\n';
html += `<div style="padding: 16px;">${imgTag(result.prImagePath, 'PR')}</div>\n`;
html += '</div>\n';
continue;
}
if (!result.prExists) {
html += '<span class="badge badge-removed">Removed</span>\n';
html += '</div>\n</div>\n';
continue;
}
html += `<span class="badge badge-changed">${result.changePercent.toFixed(1)}% changed</span>\n`;
html += '</div>\n';
html += '<div class="grid">\n';
html += `<div class="grid-cell"><div class="grid-label">Base</div>${imgTag(result.baseImagePath, 'Base')}</div>\n`;
html += `<div class="grid-cell"><div class="grid-label">PR</div>${imgTag(result.prImagePath, 'PR')}</div>\n`;
html += `<div class="grid-cell"><div class="grid-label">Diff</div>${imgTag(result.diffImagePath, 'Diff')}</div>\n`;
html += '</div>\n</div>\n';
}
}
if (unchanged.length > 0) {
html += '<div class="unchanged-section">\n<details>\n<summary>Unchanged pages</summary>\n<ul>\n';
for (const result of unchanged) {
html += `<li>${result.name}</li>\n`;
}
html += '</ul>\n</details>\n</div>\n';
}
html += '</div>\n</body>\n</html>';
return html;
}
// CLI usage
if (process.argv[1]?.endsWith('compare.ts') || process.argv[1]?.endsWith('compare.js')) {
const [baseDir, prDir, outputDir] = process.argv.slice(2);
if (!baseDir || !prDir || !outputDir) {
throw new Error('Usage: compare.ts <base-dir> <pr-dir> <output-dir>');
}
const resolvedOutputDir = resolve(outputDir);
const results = compareScreenshots(resolve(baseDir), resolve(prDir), resolvedOutputDir);
console.log('\nComparison Results:');
console.log('==================');
for (const r of results) {
const status = r.changePercent > 0.1 ? 'CHANGED' : 'unchanged';
console.log(` ${r.name}: ${status} (${r.changePercent.toFixed(1)}%)`);
}
const report = generateMarkdownReport(results);
const reportPath = join(resolvedOutputDir, 'report.md');
writeFileSync(reportPath, report);
console.log(`\nMarkdown report written to: ${reportPath}`);
const htmlReport = generateHtmlReport(results);
const htmlPath = join(resolvedOutputDir, 'visual-review.html');
writeFileSync(htmlPath, htmlReport);
console.log(`HTML report written to: ${htmlPath}`);
const jsonPath = join(resolvedOutputDir, 'results.json');
writeFileSync(jsonPath, JSON.stringify(results, null, 2));
console.log(`Results JSON written to: ${jsonPath}`);
}
-187
View File
@@ -1,187 +0,0 @@
/**
* Maps URL routes to screenshot scenario keys.
*
* Routes discovered by the dependency analyzer are matched against this map
* to determine which screenshot scenarios to run. Routes not in this map
* are skipped (they don't have a scenario defined yet).
*/
export interface ScenarioDefinition {
/** The URL path to navigate to */
url: string;
/** Human-readable name for the screenshot file */
name: string;
/** Which mock networks this scenario needs */
mocks: ('base' | 'timeline' | 'memory')[];
/** Optional: selector to wait for before screenshotting */
waitForSelector?: string;
/** Optional: time to wait after page load (ms) for animations to settle */
settleTime?: number;
}
/**
* Map from route paths (as output by analyze-deps) to scenario definitions.
* A single route might map to multiple scenarios (e.g., different states).
*/
export const PAGE_SCENARIOS: Record<string, ScenarioDefinition[]> = {
'/photos': [
{
url: '/photos',
name: 'photos-timeline',
mocks: ['base', 'timeline'],
waitForSelector: '[data-thumbnail-focus-container]',
settleTime: 500,
},
],
'/albums': [
{
url: '/albums',
name: 'albums-list',
mocks: ['base'],
settleTime: 300,
},
],
'/explore': [
{
url: '/explore',
name: 'explore',
mocks: ['base'],
settleTime: 300,
},
],
'/favorites': [
{
url: '/favorites',
name: 'favorites',
mocks: ['base', 'timeline'],
waitForSelector: '#asset-grid',
settleTime: 300,
},
],
'/archive': [
{
url: '/archive',
name: 'archive',
mocks: ['base', 'timeline'],
waitForSelector: '#asset-grid',
settleTime: 300,
},
],
'/trash': [
{
url: '/trash',
name: 'trash',
mocks: ['base', 'timeline'],
waitForSelector: '#asset-grid',
settleTime: 300,
},
],
'/people': [
{
url: '/people',
name: 'people',
mocks: ['base'],
settleTime: 300,
},
],
'/sharing': [
{
url: '/sharing',
name: 'sharing',
mocks: ['base'],
settleTime: 300,
},
],
'/search': [
{
url: '/search',
name: 'search',
mocks: ['base'],
settleTime: 300,
},
],
'/memory': [
{
url: '/memory',
name: 'memory',
mocks: ['base', 'memory'],
settleTime: 500,
},
],
'/user-settings': [
{
url: '/user-settings',
name: 'user-settings',
mocks: ['base'],
settleTime: 300,
},
],
'/map': [
{
url: '/map',
name: 'map',
mocks: ['base'],
settleTime: 500,
},
],
'/admin': [
{
url: '/admin',
name: 'admin-dashboard',
mocks: ['base'],
settleTime: 300,
},
],
'/admin/system-settings': [
{
url: '/admin/system-settings',
name: 'admin-system-settings',
mocks: ['base'],
settleTime: 300,
},
],
'/admin/users': [
{
url: '/admin/users',
name: 'admin-users',
mocks: ['base'],
settleTime: 300,
},
],
'/auth/login': [
{
url: '/auth/login',
name: 'login',
mocks: [],
settleTime: 300,
},
],
'/': [
{
url: '/',
name: 'landing',
mocks: [],
settleTime: 300,
},
],
};
/** Given a list of routes from the analyzer, return the matching scenarios. */
export function getScenariosForRoutes(routes: string[]): ScenarioDefinition[] {
const scenarios: ScenarioDefinition[] = [];
const seen = new Set<string>();
for (const route of routes) {
const defs = PAGE_SCENARIOS[route];
if (defs) {
for (const def of defs) {
if (!seen.has(def.name)) {
seen.add(def.name);
scenarios.push(def);
}
}
}
}
return scenarios;
}
-140
View File
@@ -1,140 +0,0 @@
/**
* Playwright script to capture screenshots for visual diff scenarios.
*
* Usage:
* npx playwright test --config e2e/playwright.screenshot.config.ts
*
* Environment variables:
* SCREENSHOT_SCENARIOS - JSON array of scenario names to run (from page-map.ts)
* If not set, runs all scenarios.
* SCREENSHOT_OUTPUT_DIR - Directory to save screenshots to. Defaults to e2e/screenshots-output.
*/
import { faker } from '@faker-js/faker';
import type { MemoryResponseDto } from '@immich/sdk';
import { test } from '@playwright/test';
import { mkdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { generateMemoriesFromTimeline } from 'src/ui/generators/memory';
import {
createDefaultTimelineConfig,
generateTimelineData,
type TimelineAssetConfig,
type TimelineData,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupMemoryMockApiRoutes } from 'src/ui/mock-network/memory-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
import { PAGE_SCENARIOS, type ScenarioDefinition } from './page-map';
const OUTPUT_DIR = process.env.SCREENSHOT_OUTPUT_DIR || resolve(import.meta.dirname, '../../../screenshots-output');
const SCENARIO_FILTER: string[] | null = process.env.SCREENSHOT_SCENARIOS
? JSON.parse(process.env.SCREENSHOT_SCENARIOS)
: null;
// Collect scenarios to run
const allScenarios: ScenarioDefinition[] = [];
for (const defs of Object.values(PAGE_SCENARIOS)) {
for (const def of defs) {
if (!SCENARIO_FILTER || SCENARIO_FILTER.includes(def.name)) {
allScenarios.push(def);
}
}
}
// Use a fixed seed so screenshots are deterministic across runs
faker.seed(42);
let adminUserId: string;
let timelineData: TimelineData;
let timelineAssets: TimelineAssetConfig[];
let memories: MemoryResponseDto[];
test.beforeAll(async () => {
adminUserId = faker.string.uuid();
timelineData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
timelineAssets = [];
for (const timeBucket of timelineData.buckets.values()) {
timelineAssets.push(...timeBucket);
}
memories = generateMemoriesFromTimeline(
timelineAssets,
adminUserId,
[
{ year: 2024, assetCount: 3 },
{ year: 2023, assetCount: 2 },
],
42,
);
mkdirSync(OUTPUT_DIR, { recursive: true });
});
for (const scenario of allScenarios) {
test(`Screenshot: ${scenario.name}`, async ({ context, page }) => {
// Set up mocks based on scenario requirements
if (scenario.mocks.includes('base')) {
await setupBaseMockApiRoutes(context, adminUserId);
}
if (scenario.mocks.includes('timeline')) {
const testContext = new TimelineTestContext();
testContext.adminId = adminUserId;
await setupTimelineMockApiRoutes(
context,
timelineData,
{
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
},
testContext,
);
}
if (scenario.mocks.includes('memory')) {
await setupMemoryMockApiRoutes(context, memories, {
memoryDeletions: [],
assetRemovals: new Map(),
});
}
// Navigate to the page. Use networkidle so SvelteKit hydrates and API
// calls complete, but fall back to domcontentloaded if it times out
// (e.g. a persistent connection the catch-all mock didn't cover).
try {
await page.goto(scenario.url, { waitUntil: 'networkidle', timeout: 15_000 });
} catch {
console.warn(`networkidle timed out for ${scenario.name}, falling back to current state`);
// Page has already navigated, just continue with what we have
}
// Wait for specific selector if specified
if (scenario.waitForSelector) {
try {
await page.waitForSelector(scenario.waitForSelector, { timeout: 15_000 });
} catch {
console.warn(`Selector ${scenario.waitForSelector} not found for ${scenario.name}, continuing...`);
}
}
// Wait for loading spinners to disappear
await page
.waitForFunction(() => document.querySelectorAll('[data-testid="loading-spinner"]').length === 0, {
timeout: 10_000,
})
.catch(() => {});
// Wait for animations/transitions to settle
await page.waitForTimeout(scenario.settleTime ?? 500);
// Take the screenshot
await page.screenshot({
path: resolve(OUTPUT_DIR, `${scenario.name}.png`),
fullPage: false,
});
});
}
-21
View File
@@ -10,27 +10,6 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
path: '/',
},
]);
// Block socket.io connections — these are persistent WebSocket connections
// that prevent networkidle from resolving since there's no real server.
await context.route('**/api/socket.io**', async (route) => {
return route.abort('connectionrefused');
});
// Catch-all for any /api/ endpoint not explicitly mocked below.
// Registered FIRST so specific routes (registered after) take priority
// (Playwright checks routes in reverse registration order).
// Without this, unmocked API calls hit the static preview server which
// either hangs or returns HTML, preventing networkidle and causing timeouts.
await context.route('**/api/**', async (route) => {
const method = route.request().method();
return route.fulfill({
status: 200,
contentType: 'application/json',
json: method === 'GET' ? [] : {},
});
});
await context.route('**/api/users/me', async (route) => {
return route.fulfill({
status: 200,
-1
View File
@@ -1 +0,0 @@
3.13
+4 -4
View File
@@ -48,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:3de9a8d7aedbb7984dc18f2dff178a7850f16c1ae7c3
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-core-2_2.28.4+20760_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-opencl-2_2.28.4+20760_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/intel-opencl-icd_26.05.37020.3-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-core-2_2.27.10+20617_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-opencl-2_2.27.10+20617_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/intel-opencl-icd_26.01.36711.4-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/libigdgmm12_22.9.0_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/libigdgmm12_22.9.0_amd64.deb && \
dpkg -i *.deb && \
rm *.deb && \
apt-get remove wget -yqq && \
+1 -1
View File
@@ -49,7 +49,7 @@ dev = ["locust>=2.15.1", { include-group = "test" }, { include-group = "lint" }]
[project.optional-dependencies]
cpu = ["onnxruntime>=1.23.2,<2"]
cuda = ["onnxruntime-gpu>=1.23.2,<2"]
openvino = ["onnxruntime-openvino>=1.24.1,<2"]
openvino = ["onnxruntime-openvino>=1.23.0,<2"]
armnn = ["onnxruntime>=1.23.2,<2"]
rknn = ["onnxruntime>=1.23.2,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
rocm = ["onnxruntime-migraphx>=1.23.2,<2"]
+42 -8
View File
@@ -262,6 +262,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coloredlogs"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "humanfriendly" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" },
]
[[package]]
name = "colorlog"
version = "6.9.0"
@@ -874,6 +886,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" },
]
[[package]]
name = "humanfriendly"
version = "10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyreadline3", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" },
]
[[package]]
name = "idna"
version = "3.11"
@@ -993,7 +1017,7 @@ requires-dist = [
{ name = "onnxruntime", marker = "extra == 'rknn'", specifier = ">=1.23.2,<2" },
{ name = "onnxruntime-gpu", marker = "extra == 'cuda'", specifier = ">=1.23.2,<2" },
{ name = "onnxruntime-migraphx", marker = "extra == 'rocm'", specifier = ">=1.23.2,<2" },
{ name = "onnxruntime-openvino", marker = "extra == 'openvino'", specifier = ">=1.24.1,<2" },
{ name = "onnxruntime-openvino", marker = "extra == 'openvino'", specifier = ">=1.23.0,<2" },
{ name = "opencv-python-headless", specifier = ">=4.7.0.72,<5.0" },
{ name = "orjson", specifier = ">=3.9.5" },
{ name = "pillow", specifier = ">=12.1.1,<12.2" },
@@ -1724,9 +1748,10 @@ wheels = [
[[package]]
name = "onnxruntime-openvino"
version = "1.24.1"
version = "1.23.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coloredlogs" },
{ name = "flatbuffers" },
{ name = "numpy" },
{ name = "packaging" },
@@ -1734,12 +1759,12 @@ dependencies = [
{ name = "sympy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/16/69ca742f0b65c40d4de3ff44bb6abc23c47b23e932bc901116176ae69922/onnxruntime_openvino-1.24.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:3007c803634cc69c6d52af1dea7ce729d9bb62b9a11070fd2f959119199007a8", size = 84430935, upload-time = "2026-02-26T13:44:32.193Z" },
{ url = "https://files.pythonhosted.org/packages/aa/73/619bb416bbfc40aebdd493fd6800d2637359294fe683d8a6bae3ff8d869a/onnxruntime_openvino-1.24.1-cp311-cp311-win_amd64.whl", hash = "sha256:8042698232bf67f1f6b219c2b07728d7ae7ddff17d8524588de3675480609aef", size = 13655357, upload-time = "2026-02-26T13:44:35.555Z" },
{ url = "https://files.pythonhosted.org/packages/50/cf/17ba72de2df0fcba349937d2788f154397bbc2d1a2d67772a97e26f6bc5f/onnxruntime_openvino-1.24.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d617fac2f59a6ab5ea59a788c3e1592240a129642519aaeaa774761dfe35150e", size = 84433207, upload-time = "2026-02-26T13:44:41.395Z" },
{ url = "https://files.pythonhosted.org/packages/59/37/d301f2c68b19a9485ed5db3047e0fb52478f3e73eb08c7d2a7c61be7cc1c/onnxruntime_openvino-1.24.1-cp312-cp312-win_amd64.whl", hash = "sha256:f186335a9c9b255633275290da7521d3d4d14c7773fee3127bfa040234d3fa5a", size = 13658075, upload-time = "2026-02-26T13:44:44.905Z" },
{ url = "https://files.pythonhosted.org/packages/08/07/f225999919f56506b603aaa3ff837ad563ab26f86906ed7fa7e5abcd849e/onnxruntime_openvino-1.24.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2c3bb73e68ac27f4891af8a595c1faf574ec68b772e6583c90a0b997a1822782", size = 84433183, upload-time = "2026-02-26T13:44:50.254Z" },
{ url = "https://files.pythonhosted.org/packages/3e/92/46ae2cd565961a89189900f385bb2f13a9fa731ea4674001d23720fbb1e0/onnxruntime_openvino-1.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:434bf49aa71393c577a456c9d76c98e6d6958a833fa0876793e3d5437b5a511a", size = 13658485, upload-time = "2026-02-26T13:44:53.889Z" },
{ url = "https://files.pythonhosted.org/packages/5a/10/adcd4ac68ffc8dee003553125ef5c091be822e2d7c1077d0bb85690baa9c/onnxruntime_openvino-1.23.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:91938837e6e92e30c63d12fad68a8a4959c40d2eade2bd60f38bdd5b6392f8d3", size = 70481480, upload-time = "2025-10-14T15:19:45.882Z" },
{ url = "https://files.pythonhosted.org/packages/97/95/25f28d6fecf300aa0af393e96af9e00cc676e5dab650ab84f2122610df50/onnxruntime_openvino-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:8f05d2d6a804fb70d3f4329d777ac62439773dcc2df827dd5f42644b10bf1fea", size = 13117353, upload-time = "2025-10-14T15:19:49.014Z" },
{ url = "https://files.pythonhosted.org/packages/42/0c/8d97419dfeedf419c5fe5293f3dbc59284855a63ad22e71f46c0010c9dc4/onnxruntime_openvino-1.23.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b963ea19bf9856f3d6b2f719d451f2eeae482a8f69c729906465aa4f27f4d39c", size = 70483359, upload-time = "2025-10-14T15:19:52.88Z" },
{ url = "https://files.pythonhosted.org/packages/29/30/ff6111b16ffb4187c462824aa4e95acc20fdd90f856d44a339d56c6dacd6/onnxruntime_openvino-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:937e52657f94c56990a6e5bd4c3705bd6e970834c7c94e23d300dde6848f2889", size = 13117933, upload-time = "2025-10-14T15:19:58.319Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/e42f618a8ec5fcf825fed4fdc8125f7105256cc6020b84567ecb88d5e2b7/onnxruntime_openvino-1.23.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2e93b9a8323e196b7433866054a59260f2206ab6fb0e7223dda91da71f1db8c5", size = 70483088, upload-time = "2025-10-14T15:20:02.425Z" },
{ url = "https://files.pythonhosted.org/packages/4a/f9/a531dc497dc113dc14df9a9de5aacb1676cadebc3ec6cc7cd3ca65cb3db0/onnxruntime_openvino-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:0ebbf70929de4ce269371cb255536bbedef588932d744da0b40e66c38a620f35", size = 13118206, upload-time = "2025-10-14T15:20:05.587Z" },
]
[[package]]
@@ -2179,6 +2204,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/92/8486ede85fcc088f1b3dba4ce92dd29d126fd96b0008ea213167940a2475/pyparsing-3.1.1-py3-none-any.whl", hash = "sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb", size = 103139, upload-time = "2023-07-30T15:06:59.829Z" },
]
[[package]]
name = "pyreadline3"
version = "3.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/86/3d61a61f36a0067874a00cb4dceb9028d34b6060e47828f7fc86fb9f7ee9/pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae", size = 86465, upload-time = "2022-01-24T20:05:11.66Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/fc/a3c13ded7b3057680c8ae95a9b6cc83e63657c38e0005c400a5d018a33a7/pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb", size = 95203, upload-time = "2022-01-24T20:05:10.442Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
@@ -59,7 +59,7 @@ private open class LocalImagesPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface LocalImageApi {
fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
fun cancelRequest(requestId: Long)
fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit)
@@ -82,8 +82,7 @@ interface LocalImageApi {
val widthArg = args[2] as Long
val heightArg = args[3] as Long
val isVideoArg = args[4] as Boolean
val preferEncodedArg = args[5] as Boolean
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg) { result: Result<Map<String, Long>?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(LocalImagesPigeonUtils.wrapError(error))
@@ -14,7 +14,6 @@ import android.util.Size
import androidx.annotation.RequiresApi
import app.alextran.immich.NativeBuffer
import kotlin.math.*
import java.io.IOException
import java.util.concurrent.Executors
import com.bumptech.glide.Glide
import com.bumptech.glide.Priority
@@ -100,17 +99,12 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
width: Long,
height: Long,
isVideo: Boolean,
preferEncoded: Boolean,
callback: (Result<Map<String, Long>?>) -> Unit
) {
val signal = CancellationSignal()
val task = threadPool.submit {
try {
if (preferEncoded) {
getEncodedImageInternal(assetId, callback, signal)
} else {
getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal)
}
getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal)
} catch (e: Exception) {
when (e) {
is OperationCanceledException -> callback(CANCELLED)
@@ -139,35 +133,6 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
}
}
private fun getEncodedImageInternal(
assetId: String,
callback: (Result<Map<String, Long>?>) -> Unit,
signal: CancellationSignal
) {
signal.throwIfCanceled()
val id = assetId.toLong()
val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id)
signal.throwIfCanceled()
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() }
?: throw IOException("Could not read image data for $assetId")
signal.throwIfCanceled()
val pointer = NativeBuffer.allocate(bytes.size)
try {
val buffer = NativeBuffer.wrap(pointer, bytes.size)
buffer.put(bytes)
signal.throwIfCanceled()
callback(Result.success(mapOf(
"pointer" to pointer,
"length" to bytes.size.toLong()
)))
} catch (e: Exception) {
NativeBuffer.free(pointer)
throw e
}
}
private fun getThumbnailBufferInternal(
assetId: String,
width: Long,
@@ -47,7 +47,7 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface RemoteImageApi {
fun requestImage(url: String, headers: Map<String, String>, requestId: Long, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
fun requestImage(url: String, headers: Map<String, String>, requestId: Long, callback: (Result<Map<String, Long>?>) -> Unit)
fun cancelRequest(requestId: Long)
fun clearCache(callback: (Result<Long>) -> Unit)
@@ -68,8 +68,7 @@ interface RemoteImageApi {
val urlArg = args[0] as String
val headersArg = args[1] as Map<String, String>
val requestIdArg = args[2] as Long
val preferEncodedArg = args[3] as Boolean
api.requestImage(urlArg, headersArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
api.requestImage(urlArg, headersArg, requestIdArg) { result: Result<Map<String, Long>?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(RemoteImagesPigeonUtils.wrapError(error))
@@ -51,7 +51,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
url: String,
headers: Map<String, String>,
requestId: Long,
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
callback: (Result<Map<String, Long>?>) -> Unit
) {
val signal = CancellationSignal()
@@ -78,21 +78,6 @@ class FlutterError (
val details: Any? = null
) : Throwable()
enum class PlatformAssetPlaybackStyle(val raw: Int) {
UNKNOWN(0),
IMAGE(1),
VIDEO(2),
IMAGE_ANIMATED(3),
LIVE_PHOTO(4),
VIDEO_LOOPING(5);
companion object {
fun ofRaw(raw: Int): PlatformAssetPlaybackStyle? {
return values().firstOrNull { it.raw == raw }
}
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class PlatformAsset (
val id: String,
@@ -107,8 +92,7 @@ data class PlatformAsset (
val isFavorite: Boolean,
val adjustmentTime: Long? = null,
val latitude: Double? = null,
val longitude: Double? = null,
val playbackStyle: PlatformAssetPlaybackStyle
val longitude: Double? = null
)
{
companion object {
@@ -126,8 +110,7 @@ data class PlatformAsset (
val adjustmentTime = pigeonVar_list[10] as Long?
val latitude = pigeonVar_list[11] as Double?
val longitude = pigeonVar_list[12] as Double?
val playbackStyle = pigeonVar_list[13] as PlatformAssetPlaybackStyle
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTime, latitude, longitude, playbackStyle)
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTime, latitude, longitude)
}
}
fun toList(): List<Any?> {
@@ -145,7 +128,6 @@ data class PlatformAsset (
adjustmentTime,
latitude,
longitude,
playbackStyle,
)
}
override fun equals(other: Any?): Boolean {
@@ -308,31 +290,26 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as Long?)?.let {
PlatformAssetPlaybackStyle.ofRaw(it.toInt())
}
}
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlatformAsset.fromList(it)
}
}
131.toByte() -> {
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlatformAlbum.fromList(it)
}
}
132.toByte() -> {
131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SyncDelta.fromList(it)
}
}
133.toByte() -> {
132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
HashResult.fromList(it)
}
}
134.toByte() -> {
133.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
CloudIdResult.fromList(it)
}
@@ -342,28 +319,24 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is PlatformAssetPlaybackStyle -> {
stream.write(129)
writeValue(stream, value.raw)
}
is PlatformAsset -> {
stream.write(130)
stream.write(129)
writeValue(stream, value.toList())
}
is PlatformAlbum -> {
stream.write(131)
stream.write(130)
writeValue(stream, value.toList())
}
is SyncDelta -> {
stream.write(132)
stream.write(131)
writeValue(stream, value.toList())
}
is HashResult -> {
stream.write(133)
stream.write(132)
writeValue(stream, value.toList())
}
is CloudIdResult -> {
stream.write(134)
stream.write(133)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
@@ -4,17 +4,11 @@ import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import androidx.exifinterface.media.ExifInterface
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.util.Base64
import android.util.Log
import androidx.core.database.getStringOrNull
import app.alextran.immich.core.ImmichPlugin
import com.bumptech.glide.Glide
import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.ImageHeaderParserUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -34,8 +28,6 @@ sealed class AssetResult {
data class InvalidAsset(val assetId: String) : AssetResult()
}
private const val TAG = "NativeSyncApiImplBase"
@SuppressLint("InlinedApi")
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
private val ctx: Context = context.applicationContext
@@ -47,13 +39,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS)
private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED"
// MediaStore.Files.FileColumns.SPECIAL_FORMAT — S Extensions 21+
// https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT
private const val SPECIAL_FORMAT_COLUMN = "_special_format"
private const val SPECIAL_FORMAT_GIF = 1
private const val SPECIAL_FORMAT_MOTION_PHOTO = 2
private const val SPECIAL_FORMAT_ANIMATED_WEBP = 3
const val MEDIA_SELECTION =
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
val MEDIA_SELECTION_ARGS = arrayOf(
@@ -75,15 +60,9 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
add(MediaStore.MediaColumns.DURATION)
add(MediaStore.MediaColumns.ORIENTATION)
// IS_FAVORITE is only available on Android 11 and above
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
add(MediaStore.MediaColumns.IS_FAVORITE)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(SPECIAL_FORMAT_COLUMN)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Fallback: read XMP from MediaStore to detect Motion Photos
add(MediaStore.MediaColumns.XMP)
}
}.toTypedArray()
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
@@ -130,12 +109,9 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
val orientationColumn =
c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION)
val favoriteColumn = c.getColumnIndex(MediaStore.MediaColumns.IS_FAVORITE)
val specialFormatColumn = c.getColumnIndex(SPECIAL_FORMAT_COLUMN)
val xmpColumn = c.getColumnIndex(MediaStore.MediaColumns.XMP)
while (c.moveToNext()) {
val numericId = c.getLong(idColumn)
val id = numericId.toString()
val id = c.getLong(idColumn).toString()
val name = c.getStringOrNull(nameColumn)
val bucketId = c.getStringOrNull(bucketIdColumn)
val path = c.getStringOrNull(dataColumn)
@@ -149,11 +125,10 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
continue
}
val rawMediaType = c.getInt(mediaTypeColumn)
val assetType: Long = when (rawMediaType) {
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> 1L
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2L
else -> 0L
val mediaType = when (c.getInt(mediaTypeColumn)) {
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> 1
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2
else -> 0
}
// Date taken is milliseconds since epoch, Date added is seconds since epoch
val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000))
@@ -163,19 +138,15 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
val width = c.getInt(widthColumn).toLong()
val height = c.getInt(heightColumn).toLong()
// Duration is milliseconds
val duration = if (rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0L
val duration = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0
else c.getLong(durationColumn) / 1000
val orientation = c.getInt(orientationColumn)
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
val playbackStyle = detectPlaybackStyle(
numericId, rawMediaType, specialFormatColumn, xmpColumn, c
)
val asset = PlatformAsset(
id,
name,
assetType,
mediaType.toLong(),
createdAt,
modifiedAt,
width,
@@ -183,7 +154,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
duration,
orientation.toLong(),
isFavorite,
playbackStyle = playbackStyle,
)
yield(AssetResult.ValidAsset(asset, bucketId))
}
@@ -191,81 +161,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
}
}
/**
* Detects the playback style for an asset using _special_format (API 33+)
* or XMP / MIME / RIFF header fallbacks (pre-33).
*/
@SuppressLint("NewApi")
private fun detectPlaybackStyle(
assetId: Long,
rawMediaType: Int,
specialFormatColumn: Int,
xmpColumn: Int,
cursor: Cursor
): PlatformAssetPlaybackStyle {
// video currently has no special formats, so we can short circuit and avoid unnecessary work
if (rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO) {
return PlatformAssetPlaybackStyle.VIDEO
}
// API 33+: use _special_format from cursor
if (specialFormatColumn != -1) {
val specialFormat = cursor.getInt(specialFormatColumn)
return when {
specialFormat == SPECIAL_FORMAT_MOTION_PHOTO -> PlatformAssetPlaybackStyle.LIVE_PHOTO
specialFormat == SPECIAL_FORMAT_GIF || specialFormat == SPECIAL_FORMAT_ANIMATED_WEBP -> PlatformAssetPlaybackStyle.IMAGE_ANIMATED
rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> PlatformAssetPlaybackStyle.IMAGE
else -> PlatformAssetPlaybackStyle.UNKNOWN
}
}
if (rawMediaType != MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) {
return PlatformAssetPlaybackStyle.UNKNOWN
}
// Pre-API 33 fallback
val uri = ContentUris.withAppendedId(
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
assetId
)
// Read XMP from cursor (API 30+) or ExifInterface stream (pre-30)
val xmp: String? = if (xmpColumn != -1) {
cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8)
} else {
try {
ctx.contentResolver.openInputStream(uri)?.use { stream ->
ExifInterface(stream).getAttribute(ExifInterface.TAG_XMP)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to read XMP for asset $assetId", e)
null
}
}
if (xmp != null && "Camera:MotionPhoto" in xmp) {
return PlatformAssetPlaybackStyle.LIVE_PHOTO
}
try {
ctx.contentResolver.openInputStream(uri)?.use { stream ->
val glide = Glide.get(ctx)
val type = ImageHeaderParserUtils.getType(
glide.registry.imageHeaderParsers,
stream,
glide.arrayPool
)
if (type == ImageHeaderParser.ImageType.GIF || type == ImageHeaderParser.ImageType.ANIMATED_WEBP) {
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
}
return PlatformAssetPlaybackStyle.IMAGE
}
fun getAlbums(): List<PlatformAlbum> {
val albums = mutableListOf<PlatformAlbum>()
val albumsCount = mutableMapOf<String, Int>()
File diff suppressed because one or more lines are too long
+2 -3
View File
@@ -70,7 +70,7 @@ class LocalImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol LocalImageApi {
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func cancelRequest(requestId: Int64) throws
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String: Int64], Error>) -> Void)
}
@@ -90,8 +90,7 @@ class LocalImageApiSetup {
let widthArg = args[2] as! Int64
let heightArg = args[3] as! Int64
let isVideoArg = args[4] as! Bool
let preferEncodedArg = args[5] as! Bool
api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg, preferEncoded: preferEncodedArg) { result in
api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
+25 -68
View File
@@ -7,7 +7,7 @@ class LocalImageRequest {
weak var workItem: DispatchWorkItem?
var isCancelled = false
let callback: (Result<[String: Int64]?, any Error>) -> Void
init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
self.callback = callback
}
@@ -30,11 +30,11 @@ class LocalImageApiImpl: LocalImageApi {
requestOptions.version = .current
return requestOptions
}()
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
bitsPerPixel: 32,
@@ -48,12 +48,12 @@ class LocalImageApiImpl: LocalImageApi {
assetCache.countLimit = 10000
return assetCache
}()
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
ImageProcessing.queue.async {
guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
let (width, height, pointer) = thumbHashToRGBA(hash: data)
completion(.success([
"pointer": Int64(Int(bitPattern: pointer.baseAddress)),
@@ -63,77 +63,34 @@ class LocalImageApiImpl: LocalImageApi {
]))
}
}
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
let request = LocalImageRequest(callback: completion)
let item = DispatchWorkItem {
if request.isCancelled {
return completion(ImageProcessing.cancelledResult)
}
ImageProcessing.semaphore.wait()
defer {
ImageProcessing.semaphore.signal()
}
if request.isCancelled {
return completion(ImageProcessing.cancelledResult)
}
guard let asset = Self.requestAsset(assetId: assetId)
else {
Self.remove(requestId: requestId)
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
return
}
if request.isCancelled {
return completion(ImageProcessing.cancelledResult)
}
if preferEncoded {
let dataOptions = PHImageRequestOptions()
dataOptions.isNetworkAccessAllowed = true
dataOptions.isSynchronous = true
dataOptions.version = .current
var imageData: Data?
Self.imageManager.requestImageDataAndOrientation(
for: asset,
options: dataOptions,
resultHandler: { (data, _, _, _) in
imageData = data
}
)
if request.isCancelled {
Self.remove(requestId: requestId)
return completion(ImageProcessing.cancelledResult)
}
guard let data = imageData else {
Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil)))
}
let length = data.count
let pointer = malloc(length)!
data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length)
if request.isCancelled {
free(pointer)
Self.remove(requestId: requestId)
return completion(ImageProcessing.cancelledResult)
}
request.callback(.success([
"pointer": Int64(Int(bitPattern: pointer)),
"length": Int64(length),
]))
Self.remove(requestId: requestId)
return
}
var image: UIImage?
Self.imageManager.requestImage(
for: asset,
@@ -144,29 +101,29 @@ class LocalImageApiImpl: LocalImageApi {
image = _image
}
)
if request.isCancelled {
return completion(ImageProcessing.cancelledResult)
}
guard let image = image,
let cgImage = image.cgImage else {
Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
}
if request.isCancelled {
return completion(ImageProcessing.cancelledResult)
}
do {
let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat)
if request.isCancelled {
buffer.free()
return completion(ImageProcessing.cancelledResult)
}
request.callback(.success([
"pointer": Int64(Int(bitPattern: buffer.data)),
"width": Int64(buffer.width),
@@ -179,24 +136,24 @@ class LocalImageApiImpl: LocalImageApi {
return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil)))
}
}
request.workItem = item
Self.add(requestId: requestId, request: request)
ImageProcessing.queue.async(execute: item)
}
func cancelRequest(requestId: Int64) {
Self.cancel(requestId: requestId)
}
private static func add(requestId: Int64, request: LocalImageRequest) -> Void {
requestQueue.sync { requests[requestId] = request }
}
private static func remove(requestId: Int64) -> Void {
requestQueue.sync { requests[requestId] = nil }
}
private static func cancel(requestId: Int64) -> Void {
requestQueue.async {
guard let request = requests.removeValue(forKey: requestId) else { return }
@@ -207,12 +164,12 @@ class LocalImageApiImpl: LocalImageApi {
}
}
}
private static func requestAsset(assetId: String) -> PHAsset? {
var asset: PHAsset?
assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) }
if asset != nil { return asset }
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject
else { return nil }
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
@@ -70,7 +70,7 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol RemoteImageApi {
func requestImage(url: String, headers: [String: String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func requestImage(url: String, headers: [String: String], requestId: Int64, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func cancelRequest(requestId: Int64) throws
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
}
@@ -88,8 +88,7 @@ class RemoteImageApiSetup {
let urlArg = args[0] as! String
let headersArg = args[1] as! [String: String]
let requestIdArg = args[2] as! Int64
let preferEncodedArg = args[3] as! Bool
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
+23 -41
View File
@@ -8,7 +8,7 @@ class RemoteImageRequest {
let id: Int64
var isCancelled = false
let completion: (Result<[String: Int64]?, any Error>) -> Void
init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
self.id = id
self.task = task
@@ -32,93 +32,75 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceCreateThumbnailFromImageAlways: true
] as CFDictionary
func requestImage(url: String, headers: [String : String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
func requestImage(url: String, headers: [String : String], requestId: Int64, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
var urlRequest = URLRequest(url: URL(string: url)!)
urlRequest.cachePolicy = .returnCacheDataElseLoad
for (key, value) in headers {
urlRequest.setValue(value, forHTTPHeaderField: key)
}
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error)
Self.handleCompletion(requestId: requestId, data: data, response: response, error: error)
}
let request = RemoteImageRequest(id: requestId, task: task, completion: completion)
os_unfair_lock_lock(&Self.lock)
Self.requests[requestId] = request
os_unfair_lock_unlock(&Self.lock)
task.resume()
}
private static func handleCompletion(requestId: Int64, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) {
private static func handleCompletion(requestId: Int64, data: Data?, response: URLResponse?, error: Error?) {
os_unfair_lock_lock(&Self.lock)
guard let request = requests[requestId] else {
return os_unfair_lock_unlock(&Self.lock)
}
requests[requestId] = nil
os_unfair_lock_unlock(&Self.lock)
if let error = error {
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
return request.completion(.failure(error))
}
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
guard let data = data else {
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
}
ImageProcessing.queue.async {
ImageProcessing.semaphore.wait()
defer { ImageProcessing.semaphore.signal() }
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
// Return raw encoded bytes when requested (for animated images)
if encoded {
let length = data.count
let pointer = malloc(length)!
data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length)
if request.isCancelled {
free(pointer)
return request.completion(ImageProcessing.cancelledResult)
}
return request.completion(
.success([
"pointer": Int64(Int(bitPattern: pointer)),
"length": Int64(length),
]))
}
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else {
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
}
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
do {
let buffer = try vImage_Buffer(cgImage: cgImage, format: rgbaFormat)
if request.isCancelled {
buffer.free()
return request.completion(ImageProcessing.cancelledResult)
}
request.completion(
.success([
"pointer": Int64(Int(bitPattern: buffer.data)),
@@ -131,17 +113,17 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
}
}
}
func cancelRequest(requestId: Int64) {
os_unfair_lock_lock(&Self.lock)
let request = Self.requests[requestId]
os_unfair_lock_unlock(&Self.lock)
guard let request = request else { return }
request.isCancelled = true
request.task?.cancel()
}
func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) {
Task {
let cache = URLSessionManager.shared.session.configuration.urlCache!
+10 -32
View File
@@ -128,15 +128,6 @@ func deepHashMessages(value: Any?, hasher: inout Hasher) {
enum PlatformAssetPlaybackStyle: Int {
case unknown = 0
case image = 1
case video = 2
case imageAnimated = 3
case livePhoto = 4
case videoLooping = 5
}
/// Generated class from Pigeon that represents data sent in messages.
struct PlatformAsset: Hashable {
var id: String
@@ -152,7 +143,6 @@ struct PlatformAsset: Hashable {
var adjustmentTime: Int64? = nil
var latitude: Double? = nil
var longitude: Double? = nil
var playbackStyle: PlatformAssetPlaybackStyle
// swift-format-ignore: AlwaysUseLowerCamelCase
@@ -170,7 +160,6 @@ struct PlatformAsset: Hashable {
let adjustmentTime: Int64? = nilOrValue(pigeonVar_list[10])
let latitude: Double? = nilOrValue(pigeonVar_list[11])
let longitude: Double? = nilOrValue(pigeonVar_list[12])
let playbackStyle = pigeonVar_list[13] as! PlatformAssetPlaybackStyle
return PlatformAsset(
id: id,
@@ -185,8 +174,7 @@ struct PlatformAsset: Hashable {
isFavorite: isFavorite,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
playbackStyle: playbackStyle
longitude: longitude
)
}
func toList() -> [Any?] {
@@ -204,7 +192,6 @@ struct PlatformAsset: Hashable {
adjustmentTime,
latitude,
longitude,
playbackStyle,
]
}
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
@@ -362,20 +349,14 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
case 129:
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
if let enumResultAsInt = enumResultAsInt {
return PlatformAssetPlaybackStyle(rawValue: enumResultAsInt)
}
return nil
case 130:
return PlatformAsset.fromList(self.readValue() as! [Any?])
case 131:
case 130:
return PlatformAlbum.fromList(self.readValue() as! [Any?])
case 132:
case 131:
return SyncDelta.fromList(self.readValue() as! [Any?])
case 133:
case 132:
return HashResult.fromList(self.readValue() as! [Any?])
case 134:
case 133:
return CloudIdResult.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
@@ -385,23 +366,20 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
private class MessagesPigeonCodecWriter: FlutterStandardWriter {
override func writeValue(_ value: Any) {
if let value = value as? PlatformAssetPlaybackStyle {
if let value = value as? PlatformAsset {
super.writeByte(129)
super.writeValue(value.rawValue)
} else if let value = value as? PlatformAsset {
super.writeByte(130)
super.writeValue(value.toList())
} else if let value = value as? PlatformAlbum {
super.writeByte(131)
super.writeByte(130)
super.writeValue(value.toList())
} else if let value = value as? SyncDelta {
super.writeByte(132)
super.writeByte(131)
super.writeValue(value.toList())
} else if let value = value as? HashResult {
super.writeByte(133)
super.writeByte(132)
super.writeValue(value.toList())
} else if let value = value as? CloudIdResult {
super.writeByte(134)
super.writeByte(133)
super.writeValue(value.toList())
} else {
super.writeValue(value)
+1 -2
View File
@@ -173,8 +173,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
type: 0,
durationInSeconds: 0,
orientation: 0,
isFavorite: false,
playbackStyle: .unknown
isFavorite: false
)
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue
+2 -14
View File
@@ -1,17 +1,6 @@
import Photos
extension PHAsset {
var platformPlaybackStyle: PlatformAssetPlaybackStyle {
switch playbackStyle {
case .image: return .image
case .imageAnimated: return .imageAnimated
case .livePhoto: return .livePhoto
case .video: return .video
case .videoLooping: return .videoLooping
@unknown default: return .unknown
}
}
func toPlatformAsset() -> PlatformAsset {
return PlatformAsset(
id: localIdentifier,
@@ -26,8 +15,7 @@ extension PHAsset {
isFavorite: isFavorite,
adjustmentTime: adjustmentTimestamp,
latitude: location?.coordinate.latitude,
longitude: location?.coordinate.longitude,
playbackStyle: platformPlaybackStyle
longitude: location?.coordinate.longitude
)
}
@@ -38,7 +26,7 @@ extension PHAsset {
var filename: String? {
return value(forKey: "filename") as? String
}
var adjustmentTimestamp: Int64? {
if let date = value(forKey: "adjustmentTimestamp") as? Date {
return Int64(date.timeIntervalSince1970)
@@ -11,10 +11,6 @@ enum AssetType {
enum AssetState { local, remote, merged }
// do not change!
// keep in sync with PlatformAssetPlaybackStyle
enum AssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
sealed class BaseAsset {
final String name;
final String? checksum;
@@ -47,14 +43,6 @@ sealed class BaseAsset {
bool get isMotionPhoto => livePhotoVideoId != null;
AssetPlaybackStyle get playbackStyle {
if (isVideo) return AssetPlaybackStyle.video;
if (isMotionPhoto) return AssetPlaybackStyle.livePhoto;
if (isImage && durationInSeconds != null && durationInSeconds! > 0) return AssetPlaybackStyle.imageAnimated;
if (isImage) return AssetPlaybackStyle.image;
return AssetPlaybackStyle.unknown;
}
Duration get duration {
final durationInSeconds = this.durationInSeconds;
if (durationInSeconds != null) {
@@ -5,8 +5,6 @@ class LocalAsset extends BaseAsset {
final String? remoteAssetId;
final String? cloudId;
final int orientation;
@override
final AssetPlaybackStyle playbackStyle;
final DateTime? adjustmentTime;
final double? latitude;
@@ -27,7 +25,6 @@ class LocalAsset extends BaseAsset {
super.isFavorite = false,
super.livePhotoVideoId,
this.orientation = 0,
required this.playbackStyle,
this.adjustmentTime,
this.latitude,
this.longitude,
@@ -59,7 +56,6 @@ class LocalAsset extends BaseAsset {
width: ${width ?? "<NA>"},
height: ${height ?? "<NA>"},
durationInSeconds: ${durationInSeconds ?? "<NA>"},
playbackStyle: $playbackStyle,
remoteId: ${remoteId ?? "<NA>"},
cloudId: ${cloudId ?? "<NA>"},
checksum: ${checksum ?? "<NA>"},
@@ -80,7 +76,6 @@ class LocalAsset extends BaseAsset {
id == other.id &&
cloudId == other.cloudId &&
orientation == other.orientation &&
playbackStyle == other.playbackStyle &&
adjustmentTime == other.adjustmentTime &&
latitude == other.latitude &&
longitude == other.longitude;
@@ -92,7 +87,6 @@ class LocalAsset extends BaseAsset {
id.hashCode ^
remoteId.hashCode ^
orientation.hashCode ^
playbackStyle.hashCode ^
adjustmentTime.hashCode ^
latitude.hashCode ^
longitude.hashCode;
@@ -111,7 +105,6 @@ class LocalAsset extends BaseAsset {
int? durationInSeconds,
bool? isFavorite,
int? orientation,
AssetPlaybackStyle? playbackStyle,
DateTime? adjustmentTime,
double? latitude,
double? longitude,
@@ -131,7 +124,6 @@ class LocalAsset extends BaseAsset {
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
playbackStyle: playbackStyle ?? this.playbackStyle,
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
@@ -435,19 +435,9 @@ extension PlatformToLocalAsset on PlatformAsset {
durationInSeconds: durationInSeconds,
isFavorite: isFavorite,
orientation: orientation,
playbackStyle: _toPlaybackStyle(playbackStyle),
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
latitude: latitude,
longitude: longitude,
isEdited: false,
);
}
AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) {
PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown,
PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image,
PlatformAssetPlaybackStyle.video => AssetPlaybackStyle.video,
PlatformAssetPlaybackStyle.imageAnimated => AssetPlaybackStyle.imageAnimated,
PlatformAssetPlaybackStyle.livePhoto => AssetPlaybackStyle.livePhoto,
PlatformAssetPlaybackStyle.videoLooping => AssetPlaybackStyle.videoLooping,
};
@@ -25,8 +25,6 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
RealColumn get longitude => real().nullable()();
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {id};
}
@@ -45,7 +43,6 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
width: width,
remoteId: remoteId,
orientation: orientation,
playbackStyle: playbackStyle,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
@@ -25,7 +25,6 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
i0.Value<DateTime?> adjustmentTime,
i0.Value<double?> latitude,
i0.Value<double?> longitude,
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
});
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i1.LocalAssetEntityCompanion Function({
@@ -44,7 +43,6 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i0.Value<DateTime?> adjustmentTime,
i0.Value<double?> latitude,
i0.Value<double?> longitude,
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
});
class $$LocalAssetEntityTableFilterComposer
@@ -131,16 +129,6 @@ class $$LocalAssetEntityTableFilterComposer
column: $table.longitude,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnWithTypeConverterFilters<
i2.AssetPlaybackStyle,
i2.AssetPlaybackStyle,
int
>
get playbackStyle => $composableBuilder(
column: $table.playbackStyle,
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
);
}
class $$LocalAssetEntityTableOrderingComposer
@@ -226,11 +214,6 @@ class $$LocalAssetEntityTableOrderingComposer
column: $table.longitude,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<int> get playbackStyle => $composableBuilder(
column: $table.playbackStyle,
builder: (column) => i0.ColumnOrderings(column),
);
}
class $$LocalAssetEntityTableAnnotationComposer
@@ -294,12 +277,6 @@ class $$LocalAssetEntityTableAnnotationComposer
i0.GeneratedColumn<double> get longitude =>
$composableBuilder(column: $table.longitude, builder: (column) => column);
i0.GeneratedColumnWithTypeConverter<i2.AssetPlaybackStyle, int>
get playbackStyle => $composableBuilder(
column: $table.playbackStyle,
builder: (column) => column,
);
}
class $$LocalAssetEntityTableTableManager
@@ -357,8 +334,6 @@ class $$LocalAssetEntityTableTableManager
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion(
name: name,
type: type,
@@ -375,7 +350,6 @@ class $$LocalAssetEntityTableTableManager
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
playbackStyle: playbackStyle,
),
createCompanionCallback:
({
@@ -394,8 +368,6 @@ class $$LocalAssetEntityTableTableManager
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion.insert(
name: name,
type: type,
@@ -412,7 +384,6 @@ class $$LocalAssetEntityTableTableManager
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
playbackStyle: playbackStyle,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
@@ -625,19 +596,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
requiredDuringInsert: false,
);
@override
late final i0.GeneratedColumnWithTypeConverter<i2.AssetPlaybackStyle, int>
playbackStyle =
i0.GeneratedColumn<int>(
'playback_style',
aliasedName,
false,
type: i0.DriftSqlType.int,
requiredDuringInsert: false,
defaultValue: const i4.Constant(0),
).withConverter<i2.AssetPlaybackStyle>(
i1.$LocalAssetEntityTable.$converterplaybackStyle,
);
@override
List<i0.GeneratedColumn> get $columns => [
name,
type,
@@ -654,7 +612,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
adjustmentTime,
latitude,
longitude,
playbackStyle,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -836,12 +793,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
i0.DriftSqlType.double,
data['${effectivePrefix}longitude'],
),
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromSql(
attachedDatabase.typeMapping.read(
i0.DriftSqlType.int,
data['${effectivePrefix}playback_style'],
)!,
),
);
}
@@ -852,10 +803,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
static i0.JsonTypeConverter2<i2.AssetType, int, int> $convertertype =
const i0.EnumIndexConverter<i2.AssetType>(i2.AssetType.values);
static i0.JsonTypeConverter2<i2.AssetPlaybackStyle, int, int>
$converterplaybackStyle = const i0.EnumIndexConverter<i2.AssetPlaybackStyle>(
i2.AssetPlaybackStyle.values,
);
@override
bool get withoutRowId => true;
@override
@@ -879,7 +826,6 @@ class LocalAssetEntityData extends i0.DataClass
final DateTime? adjustmentTime;
final double? latitude;
final double? longitude;
final i2.AssetPlaybackStyle playbackStyle;
const LocalAssetEntityData({
required this.name,
required this.type,
@@ -896,7 +842,6 @@ class LocalAssetEntityData extends i0.DataClass
this.adjustmentTime,
this.latitude,
this.longitude,
required this.playbackStyle,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
@@ -936,11 +881,6 @@ class LocalAssetEntityData extends i0.DataClass
if (!nullToAbsent || longitude != null) {
map['longitude'] = i0.Variable<double>(longitude);
}
{
map['playback_style'] = i0.Variable<int>(
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(playbackStyle),
);
}
return map;
}
@@ -967,9 +907,6 @@ class LocalAssetEntityData extends i0.DataClass
adjustmentTime: serializer.fromJson<DateTime?>(json['adjustmentTime']),
latitude: serializer.fromJson<double?>(json['latitude']),
longitude: serializer.fromJson<double?>(json['longitude']),
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson(
serializer.fromJson<int>(json['playbackStyle']),
),
);
}
@override
@@ -993,9 +930,6 @@ class LocalAssetEntityData extends i0.DataClass
'adjustmentTime': serializer.toJson<DateTime?>(adjustmentTime),
'latitude': serializer.toJson<double?>(latitude),
'longitude': serializer.toJson<double?>(longitude),
'playbackStyle': serializer.toJson<int>(
i1.$LocalAssetEntityTable.$converterplaybackStyle.toJson(playbackStyle),
),
};
}
@@ -1015,7 +949,6 @@ class LocalAssetEntityData extends i0.DataClass
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
i2.AssetPlaybackStyle? playbackStyle,
}) => i1.LocalAssetEntityData(
name: name ?? this.name,
type: type ?? this.type,
@@ -1036,7 +969,6 @@ class LocalAssetEntityData extends i0.DataClass
: this.adjustmentTime,
latitude: latitude.present ? latitude.value : this.latitude,
longitude: longitude.present ? longitude.value : this.longitude,
playbackStyle: playbackStyle ?? this.playbackStyle,
);
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
return LocalAssetEntityData(
@@ -1063,9 +995,6 @@ class LocalAssetEntityData extends i0.DataClass
: this.adjustmentTime,
latitude: data.latitude.present ? data.latitude.value : this.latitude,
longitude: data.longitude.present ? data.longitude.value : this.longitude,
playbackStyle: data.playbackStyle.present
? data.playbackStyle.value
: this.playbackStyle,
);
}
@@ -1086,8 +1015,7 @@ class LocalAssetEntityData extends i0.DataClass
..write('iCloudId: $iCloudId, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude, ')
..write('playbackStyle: $playbackStyle')
..write('longitude: $longitude')
..write(')'))
.toString();
}
@@ -1109,7 +1037,6 @@ class LocalAssetEntityData extends i0.DataClass
adjustmentTime,
latitude,
longitude,
playbackStyle,
);
@override
bool operator ==(Object other) =>
@@ -1129,8 +1056,7 @@ class LocalAssetEntityData extends i0.DataClass
other.iCloudId == this.iCloudId &&
other.adjustmentTime == this.adjustmentTime &&
other.latitude == this.latitude &&
other.longitude == this.longitude &&
other.playbackStyle == this.playbackStyle);
other.longitude == this.longitude);
}
class LocalAssetEntityCompanion
@@ -1150,7 +1076,6 @@ class LocalAssetEntityCompanion
final i0.Value<DateTime?> adjustmentTime;
final i0.Value<double?> latitude;
final i0.Value<double?> longitude;
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
const LocalAssetEntityCompanion({
this.name = const i0.Value.absent(),
this.type = const i0.Value.absent(),
@@ -1167,7 +1092,6 @@ class LocalAssetEntityCompanion
this.adjustmentTime = const i0.Value.absent(),
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
this.playbackStyle = const i0.Value.absent(),
});
LocalAssetEntityCompanion.insert({
required String name,
@@ -1185,7 +1109,6 @@ class LocalAssetEntityCompanion
this.adjustmentTime = const i0.Value.absent(),
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
this.playbackStyle = const i0.Value.absent(),
}) : name = i0.Value(name),
type = i0.Value(type),
id = i0.Value(id);
@@ -1205,7 +1128,6 @@ class LocalAssetEntityCompanion
i0.Expression<DateTime>? adjustmentTime,
i0.Expression<double>? latitude,
i0.Expression<double>? longitude,
i0.Expression<int>? playbackStyle,
}) {
return i0.RawValuesInsertable({
if (name != null) 'name': name,
@@ -1223,7 +1145,6 @@ class LocalAssetEntityCompanion
if (adjustmentTime != null) 'adjustment_time': adjustmentTime,
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
if (playbackStyle != null) 'playback_style': playbackStyle,
});
}
@@ -1243,7 +1164,6 @@ class LocalAssetEntityCompanion
i0.Value<DateTime?>? adjustmentTime,
i0.Value<double?>? latitude,
i0.Value<double?>? longitude,
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
}) {
return i1.LocalAssetEntityCompanion(
name: name ?? this.name,
@@ -1261,7 +1181,6 @@ class LocalAssetEntityCompanion
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
playbackStyle: playbackStyle ?? this.playbackStyle,
);
}
@@ -1315,13 +1234,6 @@ class LocalAssetEntityCompanion
if (longitude.present) {
map['longitude'] = i0.Variable<double>(longitude.value);
}
if (playbackStyle.present) {
map['playback_style'] = i0.Variable<int>(
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(
playbackStyle.value,
),
);
}
return map;
}
@@ -1342,8 +1254,7 @@ class LocalAssetEntityCompanion
..write('iCloudId: $iCloudId, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude, ')
..write('playbackStyle: $playbackStyle')
..write('longitude: $longitude')
..write(')'))
.toString();
}
@@ -26,8 +26,7 @@ SELECT
NULL as latitude,
NULL as longitude,
NULL as adjustmentTime,
rae.is_edited,
0 as playback_style
rae.is_edited
FROM
remote_asset_entity rae
LEFT JOIN
@@ -64,8 +63,7 @@ SELECT
lae.latitude,
lae.longitude,
lae.adjustment_time,
0 as is_edited,
lae.playback_style
0 as is_edited
FROM
local_asset_entity lae
WHERE NOT EXISTS (
+1 -4
View File
@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
);
$arrayStartIndex += generatedlimit.amountOfVariables;
return customSelect(
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
variables: [
for (var $ in userIds) i0.Variable<String>($),
...generatedlimit.introducedVariables,
@@ -67,7 +67,6 @@ class MergedAssetDrift extends i1.ModularAccessor {
longitude: row.readNullable<double>('longitude'),
adjustmentTime: row.readNullable<DateTime>('adjustmentTime'),
isEdited: row.read<bool>('is_edited'),
playbackStyle: row.read<int>('playback_style'),
),
);
}
@@ -140,7 +139,6 @@ class MergedAssetResult {
final double? longitude;
final DateTime? adjustmentTime;
final bool isEdited;
final int playbackStyle;
MergedAssetResult({
this.remoteId,
this.localId,
@@ -163,7 +161,6 @@ class MergedAssetResult {
this.longitude,
this.adjustmentTime,
required this.isEdited,
required this.playbackStyle,
});
}
@@ -28,8 +28,6 @@ class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntity
IntColumn get source => intEnum<TrashOrigin>()();
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {id, albumId};
}
@@ -47,7 +45,6 @@ extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityD
height: height,
width: width,
orientation: orientation,
playbackStyle: playbackStyle,
isEdited: false,
);
}
@@ -23,7 +23,6 @@ typedef $$TrashedLocalAssetEntityTableCreateCompanionBuilder =
i0.Value<bool> isFavorite,
i0.Value<int> orientation,
required i3.TrashOrigin source,
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
});
typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder =
i1.TrashedLocalAssetEntityCompanion Function({
@@ -40,7 +39,6 @@ typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder =
i0.Value<bool> isFavorite,
i0.Value<int> orientation,
i0.Value<i3.TrashOrigin> source,
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
});
class $$TrashedLocalAssetEntityTableFilterComposer
@@ -119,16 +117,6 @@ class $$TrashedLocalAssetEntityTableFilterComposer
column: $table.source,
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
);
i0.ColumnWithTypeConverterFilters<
i2.AssetPlaybackStyle,
i2.AssetPlaybackStyle,
int
>
get playbackStyle => $composableBuilder(
column: $table.playbackStyle,
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
);
}
class $$TrashedLocalAssetEntityTableOrderingComposer
@@ -205,11 +193,6 @@ class $$TrashedLocalAssetEntityTableOrderingComposer
column: $table.source,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<int> get playbackStyle => $composableBuilder(
column: $table.playbackStyle,
builder: (column) => i0.ColumnOrderings(column),
);
}
class $$TrashedLocalAssetEntityTableAnnotationComposer
@@ -266,12 +249,6 @@ class $$TrashedLocalAssetEntityTableAnnotationComposer
i0.GeneratedColumnWithTypeConverter<i3.TrashOrigin, int> get source =>
$composableBuilder(column: $table.source, builder: (column) => column);
i0.GeneratedColumnWithTypeConverter<i2.AssetPlaybackStyle, int>
get playbackStyle => $composableBuilder(
column: $table.playbackStyle,
builder: (column) => column,
);
}
class $$TrashedLocalAssetEntityTableTableManager
@@ -333,8 +310,6 @@ class $$TrashedLocalAssetEntityTableTableManager
i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(),
i0.Value<i3.TrashOrigin> source = const i0.Value.absent(),
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
const i0.Value.absent(),
}) => i1.TrashedLocalAssetEntityCompanion(
name: name,
type: type,
@@ -349,7 +324,6 @@ class $$TrashedLocalAssetEntityTableTableManager
isFavorite: isFavorite,
orientation: orientation,
source: source,
playbackStyle: playbackStyle,
),
createCompanionCallback:
({
@@ -366,8 +340,6 @@ class $$TrashedLocalAssetEntityTableTableManager
i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(),
required i3.TrashOrigin source,
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
const i0.Value.absent(),
}) => i1.TrashedLocalAssetEntityCompanion.insert(
name: name,
type: type,
@@ -382,7 +354,6 @@ class $$TrashedLocalAssetEntityTableTableManager
isFavorite: isFavorite,
orientation: orientation,
source: source,
playbackStyle: playbackStyle,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
@@ -579,19 +550,6 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
i1.$TrashedLocalAssetEntityTable.$convertersource,
);
@override
late final i0.GeneratedColumnWithTypeConverter<i2.AssetPlaybackStyle, int>
playbackStyle =
i0.GeneratedColumn<int>(
'playback_style',
aliasedName,
false,
type: i0.DriftSqlType.int,
requiredDuringInsert: false,
defaultValue: const i4.Constant(0),
).withConverter<i2.AssetPlaybackStyle>(
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle,
);
@override
List<i0.GeneratedColumn> get $columns => [
name,
type,
@@ -606,7 +564,6 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
isFavorite,
orientation,
source,
playbackStyle,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -763,13 +720,6 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
data['${effectivePrefix}source'],
)!,
),
playbackStyle: i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle
.fromSql(
attachedDatabase.typeMapping.read(
i0.DriftSqlType.int,
data['${effectivePrefix}playback_style'],
)!,
),
);
}
@@ -782,10 +732,6 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
const i0.EnumIndexConverter<i2.AssetType>(i2.AssetType.values);
static i0.JsonTypeConverter2<i3.TrashOrigin, int, int> $convertersource =
const i0.EnumIndexConverter<i3.TrashOrigin>(i3.TrashOrigin.values);
static i0.JsonTypeConverter2<i2.AssetPlaybackStyle, int, int>
$converterplaybackStyle = const i0.EnumIndexConverter<i2.AssetPlaybackStyle>(
i2.AssetPlaybackStyle.values,
);
@override
bool get withoutRowId => true;
@override
@@ -807,7 +753,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
final bool isFavorite;
final int orientation;
final i3.TrashOrigin source;
final i2.AssetPlaybackStyle playbackStyle;
const TrashedLocalAssetEntityData({
required this.name,
required this.type,
@@ -822,7 +767,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
required this.isFavorite,
required this.orientation,
required this.source,
required this.playbackStyle,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
@@ -856,13 +800,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
i1.$TrashedLocalAssetEntityTable.$convertersource.toSql(source),
);
}
{
map['playback_style'] = i0.Variable<int>(
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle.toSql(
playbackStyle,
),
);
}
return map;
}
@@ -889,8 +826,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
source: i1.$TrashedLocalAssetEntityTable.$convertersource.fromJson(
serializer.fromJson<int>(json['source']),
),
playbackStyle: i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle
.fromJson(serializer.fromJson<int>(json['playbackStyle'])),
);
}
@override
@@ -914,11 +849,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
'source': serializer.toJson<int>(
i1.$TrashedLocalAssetEntityTable.$convertersource.toJson(source),
),
'playbackStyle': serializer.toJson<int>(
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle.toJson(
playbackStyle,
),
),
};
}
@@ -936,7 +866,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
bool? isFavorite,
int? orientation,
i3.TrashOrigin? source,
i2.AssetPlaybackStyle? playbackStyle,
}) => i1.TrashedLocalAssetEntityData(
name: name ?? this.name,
type: type ?? this.type,
@@ -953,7 +882,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
source: source ?? this.source,
playbackStyle: playbackStyle ?? this.playbackStyle,
);
TrashedLocalAssetEntityData copyWithCompanion(
i1.TrashedLocalAssetEntityCompanion data,
@@ -978,9 +906,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
? data.orientation.value
: this.orientation,
source: data.source.present ? data.source.value : this.source,
playbackStyle: data.playbackStyle.present
? data.playbackStyle.value
: this.playbackStyle,
);
}
@@ -999,8 +924,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation, ')
..write('source: $source, ')
..write('playbackStyle: $playbackStyle')
..write('source: $source')
..write(')'))
.toString();
}
@@ -1020,7 +944,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
isFavorite,
orientation,
source,
playbackStyle,
);
@override
bool operator ==(Object other) =>
@@ -1038,8 +961,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
other.checksum == this.checksum &&
other.isFavorite == this.isFavorite &&
other.orientation == this.orientation &&
other.source == this.source &&
other.playbackStyle == this.playbackStyle);
other.source == this.source);
}
class TrashedLocalAssetEntityCompanion
@@ -1057,7 +979,6 @@ class TrashedLocalAssetEntityCompanion
final i0.Value<bool> isFavorite;
final i0.Value<int> orientation;
final i0.Value<i3.TrashOrigin> source;
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
const TrashedLocalAssetEntityCompanion({
this.name = const i0.Value.absent(),
this.type = const i0.Value.absent(),
@@ -1072,7 +993,6 @@ class TrashedLocalAssetEntityCompanion
this.isFavorite = const i0.Value.absent(),
this.orientation = const i0.Value.absent(),
this.source = const i0.Value.absent(),
this.playbackStyle = const i0.Value.absent(),
});
TrashedLocalAssetEntityCompanion.insert({
required String name,
@@ -1088,7 +1008,6 @@ class TrashedLocalAssetEntityCompanion
this.isFavorite = const i0.Value.absent(),
this.orientation = const i0.Value.absent(),
required i3.TrashOrigin source,
this.playbackStyle = const i0.Value.absent(),
}) : name = i0.Value(name),
type = i0.Value(type),
id = i0.Value(id),
@@ -1108,7 +1027,6 @@ class TrashedLocalAssetEntityCompanion
i0.Expression<bool>? isFavorite,
i0.Expression<int>? orientation,
i0.Expression<int>? source,
i0.Expression<int>? playbackStyle,
}) {
return i0.RawValuesInsertable({
if (name != null) 'name': name,
@@ -1124,7 +1042,6 @@ class TrashedLocalAssetEntityCompanion
if (isFavorite != null) 'is_favorite': isFavorite,
if (orientation != null) 'orientation': orientation,
if (source != null) 'source': source,
if (playbackStyle != null) 'playback_style': playbackStyle,
});
}
@@ -1142,7 +1059,6 @@ class TrashedLocalAssetEntityCompanion
i0.Value<bool>? isFavorite,
i0.Value<int>? orientation,
i0.Value<i3.TrashOrigin>? source,
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
}) {
return i1.TrashedLocalAssetEntityCompanion(
name: name ?? this.name,
@@ -1158,7 +1074,6 @@ class TrashedLocalAssetEntityCompanion
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
source: source ?? this.source,
playbackStyle: playbackStyle ?? this.playbackStyle,
);
}
@@ -1208,13 +1123,6 @@ class TrashedLocalAssetEntityCompanion
i1.$TrashedLocalAssetEntityTable.$convertersource.toSql(source.value),
);
}
if (playbackStyle.present) {
map['playback_style'] = i0.Variable<int>(
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle.toSql(
playbackStyle.value,
),
);
}
return map;
}
@@ -1233,8 +1141,7 @@ class TrashedLocalAssetEntityCompanion
..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation, ')
..write('source: $source, ')
..write('playbackStyle: $playbackStyle')
..write('source: $source')
..write(')'))
.toString();
}
@@ -24,8 +24,6 @@ abstract class ImageRequest {
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0});
Future<ui.Codec?> loadCodec();
void cancel() {
if (_isCancelled) {
return;
@@ -36,7 +34,7 @@ abstract class ImageRequest {
void _onCancelled();
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromEncodedPlatformImage(int address, int length) async {
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length) async {
final pointer = Pointer<Uint8>.fromAddress(address);
if (_isCancelled) {
malloc.free(pointer);
@@ -69,20 +67,6 @@ abstract class ImageRequest {
return null;
}
return (codec, descriptor);
}
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length) async {
final result = await _codecFromEncodedPlatformImage(address, length);
if (result == null) return null;
final (codec, descriptor) = result;
if (_isCancelled) {
descriptor.dispose();
codec.dispose();
return null;
}
final frame = await codec.getNextFrame();
descriptor.dispose();
codec.dispose();
@@ -22,7 +22,6 @@ class LocalImageRequest extends ImageRequest {
width: width,
height: height,
isVideo: assetType == AssetType.video,
preferEncoded: false,
);
if (info == null) {
return null;
@@ -32,26 +31,6 @@ class LocalImageRequest extends ImageRequest {
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
}
@override
Future<ui.Codec?> loadCodec() async {
if (_isCancelled) {
return null;
}
final info = await localImageApi.requestImage(
localId,
requestId: requestId,
width: width,
height: height,
isVideo: assetType == AssetType.video,
preferEncoded: true,
);
if (info == null) return null;
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);
return codec;
}
@override
Future<void> _onCancelled() {
return localImageApi.cancelRequest(requestId);
@@ -12,8 +12,7 @@ class RemoteImageRequest extends ImageRequest {
return null;
}
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: false);
// Android always returns encoded data, so we need to check for both shapes of the response.
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId);
final frame = switch (info) {
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
{'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} =>
@@ -23,19 +22,6 @@ class RemoteImageRequest extends ImageRequest {
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
}
@override
Future<ui.Codec?> loadCodec() async {
if (_isCancelled) {
return null;
}
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: true);
if (info == null) return null;
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);
return codec;
}
@override
Future<void> _onCancelled() {
return remoteImageApi.cancelRequest(requestId);
@@ -16,9 +16,6 @@ class ThumbhashImageRequest extends ImageRequest {
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
}
@override
Future<ui.Codec?> loadCodec() => throw UnsupportedError('Thumbhash does not support codec loading');
@override
void _onCancelled() {}
}
@@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
}
@override
int get schemaVersion => 21;
int get schemaVersion => 20;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -230,10 +230,6 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.isVisible);
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt);
},
from20To21: (m, v21) async {
await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle);
await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle);
},
),
);
@@ -8904,591 +8904,6 @@ i1.GeneratedColumn<bool> _column_102(String aliasedName) =>
),
defaultValue: const CustomExpression('1'),
);
final class Schema21 extends i0.VersionedSchema {
Schema21({required super.database}) : super(version: 21);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxRemoteAlbumOwnerId,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxStackPrimaryAssetId,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetLocalDateTimeDay,
idxRemoteAssetLocalDateTimeMonth,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
];
late final Shape20 userEntity = Shape20(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_84,
_column_85,
_column_91,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape28 remoteAssetEntity = Shape28(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_86,
_column_101,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
attachedDatabase: database,
),
alias: null,
);
late final Shape30 localAssetEntity = Shape30(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_22,
_column_14,
_column_23,
_column_98,
_column_96,
_column_46,
_column_47,
_column_103,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape9 remoteAlbumEntity = Shape9(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_56,
_column_9,
_column_5,
_column_15,
_column_57,
_column_58,
_column_59,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape19 localAlbumEntity = Shape19(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_90,
_column_33,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape22 localAlbumAssetEntity = Shape22(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_34, _column_35, _column_33],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAlbumOwnerId = i1.Index(
'idx_remote_album_owner_id',
'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index(
'idx_remote_asset_local_date_time_day',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))',
);
final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index(
'idx_remote_asset_local_date_time_month',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))',
);
late final Shape21 authUserEntity = Shape21(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_2,
_column_84,
_column_85,
_column_92,
_column_93,
_column_7,
_column_94,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_25, _column_26, _column_27],
attachedDatabase: database,
),
alias: null,
);
late final Shape5 partnerEntity = Shape5(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_28, _column_29, _column_30],
attachedDatabase: database,
),
alias: null,
);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_11,
_column_10,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_50,
_column_51,
_column_52,
_column_53,
_column_54,
_column_55,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_36, _column_60],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_60, _column_25, _column_61],
attachedDatabase: database,
),
alias: null,
);
late final Shape27 remoteAssetCloudIdEntity = Shape27(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_99,
_column_100,
_column_96,
_column_46,
_column_47,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 memoryEntity = Shape11(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_18,
_column_15,
_column_8,
_column_62,
_column_63,
_column_64,
_column_65,
_column_66,
_column_67,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_36, _column_68],
attachedDatabase: database,
),
alias: null,
);
late final Shape14 personEntity = Shape14(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_1,
_column_69,
_column_71,
_column_72,
_column_73,
_column_74,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape29 assetFaceEntity = Shape29(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_36,
_column_76,
_column_77,
_column_78,
_column_79,
_column_80,
_column_81,
_column_82,
_column_83,
_column_102,
_column_18,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_87, _column_88, _column_89],
attachedDatabase: database,
),
alias: null,
);
late final Shape31 trashedLocalAssetEntity = Shape31(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_95,
_column_22,
_column_14,
_column_23,
_column_97,
_column_103,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
}
class Shape30 extends i0.VersionedTable {
Shape30({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationInSeconds =>
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<int> get orientation =>
columnsByName['orientation']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get iCloudId =>
columnsByName['i_cloud_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get adjustmentTime =>
columnsByName['adjustment_time']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<double> get latitude =>
columnsByName['latitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get longitude =>
columnsByName['longitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<int> get playbackStyle =>
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<int> _column_103(String aliasedName) =>
i1.GeneratedColumn<int>(
'playback_style',
aliasedName,
false,
type: i1.DriftSqlType.int,
defaultValue: const CustomExpression('0'),
);
class Shape31 extends i0.VersionedTable {
Shape31({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationInSeconds =>
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get albumId =>
columnsByName['album_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<int> get orientation =>
columnsByName['orientation']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get source =>
columnsByName['source']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get playbackStyle =>
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
}
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -9509,7 +8924,6 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -9608,11 +9022,6 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from19To20(migrator, schema);
return 20;
case 20:
final schema = Schema21(database: database);
final migrator = i1.Migrator(database, schema);
await from20To21(migrator, schema);
return 21;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -9639,7 +9048,6 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -9661,6 +9069,5 @@ i1.OnUpgrade stepByStep({
from17To18: from17To18,
from18To19: from18To19,
from19To20: from19To20,
from20To21: from20To21,
),
);
@@ -301,7 +301,6 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
id: asset.id,
orientation: Value(asset.orientation),
isFavorite: Value(asset.isFavorite),
playbackStyle: Value(asset.playbackStyle),
latitude: Value(asset.latitude),
longitude: Value(asset.longitude),
adjustmentTime: Value(asset.adjustmentTime),
@@ -334,7 +333,6 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
checksum: const Value(null),
orientation: Value(asset.orientation),
isFavorite: Value(asset.isFavorite),
playbackStyle: Value(asset.playbackStyle),
);
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
_db.localAssetEntity,
@@ -101,7 +101,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
isFavorite: row.isFavorite,
durationInSeconds: row.durationInSeconds,
orientation: row.orientation,
playbackStyle: AssetPlaybackStyle.values[row.playbackStyle],
cloudId: row.iCloudId,
latitude: row.latitude,
longitude: row.longitude,
@@ -85,7 +85,6 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
durationInSeconds: Value(item.asset.durationInSeconds),
isFavorite: Value(item.asset.isFavorite),
orientation: Value(item.asset.orientation),
playbackStyle: Value(item.asset.playbackStyle),
source: TrashOrigin.localSync,
);
@@ -148,7 +147,6 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
durationInSeconds: Value(asset.durationInSeconds),
isFavorite: Value(asset.isFavorite),
orientation: Value(asset.orientation),
playbackStyle: Value(asset.playbackStyle),
createdAt: Value(asset.createdAt),
updatedAt: Value(asset.updatedAt),
source: const Value(TrashOrigin.remoteSync),
@@ -197,7 +195,6 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
checksum: Value(e.checksum),
isFavorite: Value(e.isFavorite),
orientation: Value(e.orientation),
playbackStyle: Value(e.playbackStyle),
);
});
@@ -248,7 +245,6 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
checksum: Value(e.asset.checksum),
isFavorite: Value(e.asset.isFavorite),
orientation: Value(e.asset.orientation),
playbackStyle: Value(e.asset.playbackStyle),
source: TrashOrigin.localUser,
albumId: e.albumId,
);
-2
View File
@@ -55,7 +55,6 @@ class LocalImageApi {
required int width,
required int height,
required bool isVideo,
required bool preferEncoded,
}) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix';
@@ -70,7 +69,6 @@ class LocalImageApi {
width,
height,
isVideo,
preferEncoded,
]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
+9 -22
View File
@@ -29,8 +29,6 @@ bool _deepEquals(Object? a, Object? b) {
return a == b;
}
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
class PlatformAsset {
PlatformAsset({
required this.id,
@@ -46,7 +44,6 @@ class PlatformAsset {
this.adjustmentTime,
this.latitude,
this.longitude,
required this.playbackStyle,
});
String id;
@@ -75,8 +72,6 @@ class PlatformAsset {
double? longitude;
PlatformAssetPlaybackStyle playbackStyle;
List<Object?> _toList() {
return <Object?>[
id,
@@ -92,7 +87,6 @@ class PlatformAsset {
adjustmentTime,
latitude,
longitude,
playbackStyle,
];
}
@@ -116,7 +110,6 @@ class PlatformAsset {
adjustmentTime: result[10] as int?,
latitude: result[11] as double?,
longitude: result[12] as double?,
playbackStyle: result[13]! as PlatformAssetPlaybackStyle,
);
}
@@ -323,23 +316,20 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is PlatformAssetPlaybackStyle) {
buffer.putUint8(129);
writeValue(buffer, value.index);
} else if (value is PlatformAsset) {
buffer.putUint8(130);
buffer.putUint8(129);
writeValue(buffer, value.encode());
} else if (value is PlatformAlbum) {
buffer.putUint8(131);
buffer.putUint8(130);
writeValue(buffer, value.encode());
} else if (value is SyncDelta) {
buffer.putUint8(132);
buffer.putUint8(131);
writeValue(buffer, value.encode());
} else if (value is HashResult) {
buffer.putUint8(133);
buffer.putUint8(132);
writeValue(buffer, value.encode());
} else if (value is CloudIdResult) {
buffer.putUint8(134);
buffer.putUint8(133);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
@@ -350,17 +340,14 @@ class _PigeonCodec extends StandardMessageCodec {
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 129:
final int? value = readValue(buffer) as int?;
return value == null ? null : PlatformAssetPlaybackStyle.values[value];
case 130:
return PlatformAsset.decode(readValue(buffer)!);
case 131:
case 130:
return PlatformAlbum.decode(readValue(buffer)!);
case 132:
case 131:
return SyncDelta.decode(readValue(buffer)!);
case 133:
case 132:
return HashResult.decode(readValue(buffer)!);
case 134:
case 133:
return CloudIdResult.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
+1 -7
View File
@@ -53,7 +53,6 @@ class RemoteImageApi {
String url, {
required Map<String, String> headers,
required int requestId,
required bool preferEncoded,
}) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix';
@@ -62,12 +61,7 @@ class RemoteImageApi {
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[
url,
headers,
requestId,
preferEncoded,
]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[url, headers, requestId]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
@@ -8,7 +8,6 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/permanent_delete_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// This delete action has the following behavior:
@@ -26,15 +25,6 @@ class DeletePermanentActionButton extends ConsumerWidget {
return;
}
final count = source == ActionSource.viewer ? 1 : ref.read(multiSelectProvider).selectedAssets.length;
final confirm =
await showDialog<bool>(
context: context,
builder: (context) => PermanentDeleteDialog(count: count),
) ??
false;
if (!confirm) return;
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset();
@@ -5,7 +5,7 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/permanent_delete_dialog.dart';
import 'package:immich_mobile/widgets/asset_grid/trash_delete_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// This delete action has the following behavior:
@@ -28,7 +28,7 @@ class DeleteTrashActionButton extends ConsumerWidget {
final confirmDelete =
await showDialog<bool>(
context: context,
builder: (context) => PermanentDeleteDialog(count: selectCount),
builder: (context) => TrashDeleteDialog(count: selectCount),
) ??
false;
if (!confirmDelete) {
@@ -307,7 +307,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
if (displayAsset.isImage && !isPlayingMotionVideo) {
final size = context.sizeData;
return PhotoView(
key: Key(displayAsset.heroTag),
key: ValueKey(displayAsset.heroTag),
index: widget.index,
imageProvider: getFullImageProvider(displayAsset, size: size),
heroAttributes: heroAttributes,
@@ -335,7 +335,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
}
return PhotoView.customChild(
key: Key(displayAsset.heroTag),
key: ValueKey(displayAsset),
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
@@ -351,11 +351,12 @@ class _AssetPageState extends ConsumerState<AssetPage> {
enablePanAlways: true,
backgroundDecoration: backgroundDecoration,
child: NativeVideoViewer(
key: _NativeVideoViewerKey(displayAsset.heroTag),
key: ValueKey(displayAsset),
asset: displayAsset,
scaleStateNotifier: _videoScaleStateNotifier,
disableScaleGestures: showingDetails,
image: Image(
key: ValueKey(displayAsset.heroTag),
image: getFullImageProvider(displayAsset, size: context.sizeData),
height: context.height,
width: context.width,
@@ -459,25 +460,3 @@ class _AssetPageState extends ConsumerState<AssetPage> {
);
}
}
// A global key is used for video viewers to prevent them from being
// unnecessarily recreated. They're quite expensive, and maintain internal
// state. This can cause videos to restart multiple times during normal usage,
// like a hero animation.
//
// A plain ValueKey is insufficient, as it does not allow widgets to reparent. A
// GlobalObjectKey is fragile, as it checks if the given objects are identical,
// rather than equal. Hero tags are created with string interpolation, which
// prevents Dart from interning them. As such, hero tags are not identical, even
// if they are equal.
class _NativeVideoViewerKey extends GlobalKey {
final String value;
const _NativeVideoViewerKey(this.value) : super.constructor();
@override
bool operator ==(Object other) => other is _NativeVideoViewerKey && other.value == value;
@override
int get hashCode => value.hashCode;
}
@@ -420,18 +420,20 @@ class NativeVideoViewer extends HookConsumerWidget {
child: Stack(
children: [
// Hide thumbnail once video is visible to avoid it showing in background when zooming out on video.
if (!isVisible.value || controller.value == null) Center(child: image),
if (!isVisible.value || controller.value == null) Center(key: ValueKey(asset.heroTag), child: image),
if (aspectRatio.value != null && !isCasting && isCurrent)
Visibility.maintain(
key: ValueKey(asset),
visible: isVisible.value,
child: PhotoView.customChild(
key: ValueKey(asset),
enableRotation: false,
disableScaleGestures: disableScaleGestures,
// Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet.
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state,
childSize: videoContextSize(aspectRatio.value, context),
child: NativeVideoPlayerView(onViewReady: initController),
child: NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController),
),
),
if (showControls) const Center(child: VideoViewerControls()),
@@ -36,7 +36,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
const ShareLinkActionButton(source: ActionSource.timeline),
const UnArchiveActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
@@ -75,7 +75,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
const ShareLinkActionButton(source: ActionSource.timeline),
const UnFavoriteActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
@@ -108,7 +108,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
@@ -119,11 +119,10 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
if (multiselect.onlyLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
],
if (multiselect.onlyLocal || multiselect.hasMerged)
const DeleteLocalActionButton(source: ActionSource.timeline),
if (multiselect.onlyLocal) const UploadActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal) const UploadActionButton(source: ActionSource.timeline),
],
slivers: multiselect.hasRemote
? [
@@ -1,5 +1,3 @@
import 'dart:ui' as ui;
import 'package:async/async.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -77,29 +75,6 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
}
}
Future<ui.Codec?> loadCodecRequest(ImageRequest request) async {
if (isCancelled) {
this.request = null;
PaintingBinding.instance.imageCache.evict(this);
return null;
}
try {
final codec = await request.loadCodec();
if (codec == null || isCancelled) {
codec?.dispose();
PaintingBinding.instance.imageCache.evict(this);
return null;
}
return codec;
} catch (e) {
PaintingBinding.instance.imageCache.evict(this);
rethrow;
} finally {
this.request = null;
}
}
Stream<ImageInfo> initialImageStream() async* {
final cachedOperation = this.cachedOperation;
if (cachedOperation == null) {
@@ -24,12 +24,10 @@ class MultiSelectState {
bool get hasStacked => selectedAssets.any((asset) => asset is RemoteAsset && asset.stackId != null);
bool get hasLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged);
bool get onlyLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
bool get onlyRemote => selectedAssets.any((asset) => asset.storage == AssetState.remote);
MultiSelectState copyWith({
Set<BaseAsset>? selectedAssets,
Set<BaseAsset>? lockedSelectionAssets,
@@ -25,7 +25,6 @@ class FileMediaRepository {
type: AssetType.image,
createdAt: entity.createDateTime,
updatedAt: entity.modifiedDateTime,
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
}
+1 -53
View File
@@ -5,7 +5,6 @@ import 'dart:io';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
@@ -18,7 +17,6 @@ import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
@@ -35,7 +33,7 @@ import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 23;
const int targetVersion = 22;
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
final hasVersion = Store.tryGet(StoreKey.version) != null;
@@ -101,10 +99,6 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
}
}
if (version < 23 && Store.isBetaTimelineEnabled) {
await _populateLocalAssetPlaybackStyle(drift);
}
if (version < 22 && !Store.isBetaTimelineEnabled) {
await Store.put(StoreKey.needBetaMigration, true);
}
@@ -398,52 +392,6 @@ Future<void> migrateStoreToIsar(Isar db, Drift drift) async {
}
}
Future<void> _populateLocalAssetPlaybackStyle(Drift db) async {
try {
final nativeApi = NativeSyncApi();
final albums = await nativeApi.getAlbums();
for (final album in albums) {
final assets = await nativeApi.getAssetsForAlbum(album.id);
await db.batch((batch) {
for (final asset in assets) {
batch.update(
db.localAssetEntity,
LocalAssetEntityCompanion(playbackStyle: Value(_toPlaybackStyle(asset.playbackStyle))),
where: (t) => t.id.equals(asset.id),
);
}
});
}
final trashedAssetMap = await nativeApi.getTrashedAssets();
for (final assets in trashedAssetMap.values) {
await db.batch((batch) {
for (final asset in assets) {
batch.update(
db.trashedLocalAssetEntity,
TrashedLocalAssetEntityCompanion(playbackStyle: Value(_toPlaybackStyle(asset.playbackStyle))),
where: (t) => t.id.equals(asset.id),
);
}
});
}
dPrint(() => "[MIGRATION] Successfully populated playbackStyle for local and trashed assets");
} catch (error) {
dPrint(() => "[MIGRATION] Error while populating playbackStyle: $error");
}
}
AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) {
PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown,
PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image,
PlatformAssetPlaybackStyle.video => AssetPlaybackStyle.video,
PlatformAssetPlaybackStyle.imageAnimated => AssetPlaybackStyle.imageAnimated,
PlatformAssetPlaybackStyle.livePhoto => AssetPlaybackStyle.livePhoto,
PlatformAssetPlaybackStyle.videoLooping => AssetPlaybackStyle.videoLooping,
};
class _DeviceAsset {
final String assetId;
final List<int>? hash;
@@ -3,8 +3,8 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_ui/immich_ui.dart';
class PermanentDeleteDialog extends StatelessWidget {
const PermanentDeleteDialog({super.key, required this.count});
class TrashDeleteDialog extends StatelessWidget {
const TrashDeleteDialog({super.key, required this.count});
final int count;
@@ -62,7 +62,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
pinned: pinned,
snap: snap,
expandedHeight: expandedHeight,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(bottom: Radius.circular(5))),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
automaticallyImplyLeading: false,
centerTitle: false,
title: title ?? const _ImmichLogoWithText(),
-1
View File
@@ -21,7 +21,6 @@ abstract class LocalImageApi {
required int width,
required int height,
required bool isVideo,
required bool preferEncoded,
});
void cancelRequest(int requestId);
-12
View File
@@ -11,15 +11,6 @@ import 'package:pigeon/pigeon.dart';
dartPackageName: 'immich_mobile',
),
)
enum PlatformAssetPlaybackStyle {
unknown,
image,
video,
imageAnimated,
livePhoto,
videoLooping,
}
class PlatformAsset {
final String id;
final String name;
@@ -40,8 +31,6 @@ class PlatformAsset {
final double? latitude;
final double? longitude;
final PlatformAssetPlaybackStyle playbackStyle;
const PlatformAsset({
required this.id,
required this.name,
@@ -56,7 +45,6 @@ class PlatformAsset {
this.adjustmentTime,
this.latitude,
this.longitude,
this.playbackStyle = PlatformAssetPlaybackStyle.unknown,
});
}
-1
View File
@@ -19,7 +19,6 @@ abstract class RemoteImageApi {
String url, {
required Map<String, String> headers,
required int requestId,
required bool preferEncoded,
});
void cancelRequest(int requestId);
@@ -131,7 +131,6 @@ void main() {
durationInSeconds: 0,
orientation: 0,
isFavorite: false,
playbackStyle: PlatformAssetPlaybackStyle.image
);
final assetsToRestore = [LocalAssetStub.image1];
@@ -215,7 +214,6 @@ void main() {
isFavorite: false,
createdAt: 1700000000,
updatedAt: 1732000000,
playbackStyle: PlatformAssetPlaybackStyle.image
);
final localAsset = platformAsset.toLocalAsset();
-4
View File
@@ -23,7 +23,6 @@ import 'schema_v17.dart' as v17;
import 'schema_v18.dart' as v18;
import 'schema_v19.dart' as v19;
import 'schema_v20.dart' as v20;
import 'schema_v21.dart' as v21;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -69,8 +68,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v19.DatabaseAtV19(db);
case 20:
return v20.DatabaseAtV20(db);
case 21:
return v21.DatabaseAtV21(db);
default:
throw MissingSchemaException(version, versions);
}
@@ -97,6 +94,5 @@ class GeneratedHelper implements SchemaInstantiationHelper {
18,
19,
20,
21,
];
}
File diff suppressed because it is too large Load Diff
-2
View File
@@ -64,7 +64,6 @@ abstract final class LocalAssetStub {
type: AssetType.image,
createdAt: DateTime(2025),
updatedAt: DateTime(2025, 2),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
@@ -74,7 +73,6 @@ abstract final class LocalAssetStub {
type: AssetType.image,
createdAt: DateTime(2000),
updatedAt: DateTime(20021),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
}
@@ -194,7 +194,6 @@ void main() {
latitude: 37.7749,
longitude: -122.4194,
adjustmentTime: DateTime(2026, 1, 2),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
@@ -244,7 +243,6 @@ void main() {
cloudId: 'cloud-id-123',
latitude: 37.7749,
longitude: -122.4194,
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
@@ -283,7 +281,6 @@ void main() {
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 2),
cloudId: null, // No cloudId
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
@@ -326,7 +323,6 @@ void main() {
cloudId: 'cloud-id-livephoto',
latitude: 37.7749,
longitude: -122.4194,
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
-1
View File
@@ -155,7 +155,6 @@ abstract final class TestUtils {
width: width,
height: height,
orientation: orientation,
playbackStyle: domain.AssetPlaybackStyle.image,
isEdited: false,
);
}
@@ -27,7 +27,6 @@ class MediumFactory {
type: type ?? AssetType.image,
createdAt: createdAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)),
updatedAt: updatedAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
}
@@ -23,7 +23,6 @@ LocalAsset createLocalAsset({
createdAt: createdAt ?? DateTime.now(),
updatedAt: updatedAt ?? DateTime.now(),
isFavorite: isFavorite,
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
}
+293 -281
View File
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,4 @@
import { Body, Controller, Delete, Get, Next, Param, Post, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Body, Controller, Delete, Get, Next, Param, Post, Res, UseInterceptors } from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
@@ -10,6 +9,7 @@ import {
} from 'src/dtos/database-backup.dto';
import { ApiTag, ImmichCookie, Permission } from 'src/enum';
import { Authenticated, FileResponse, GetLoginDetails } from 'src/middleware/auth.guard';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { LoginDetails } from 'src/services/auth.service';
import { DatabaseBackupService } from 'src/services/database-backup.service';
@@ -91,11 +91,6 @@ export class DatabaseBackupController {
description: 'Uploads .sql/.sql.gz file to restore backup from',
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
})
@UseInterceptors(FileInterceptor('file'))
uploadDatabaseBackup(
@UploadedFile()
file: Express.Multer.File,
): Promise<void> {
return this.service.uploadBackup(file);
}
@UseInterceptors(FileUploadInterceptor)
uploadDatabaseBackup() {}
}
+1
View File
@@ -29,6 +29,7 @@ export enum UploadFieldName {
ASSET_DATA = 'assetData',
SIDECAR_DATA = 'sidecarData',
PROFILE_DATA = 'file',
BACKUP_DATA = 'backup',
}
class AssetMediaBase {
+1
View File
@@ -490,6 +490,7 @@ export enum MetadataKey {
export enum RouteKey {
Asset = 'assets',
User = 'users',
DatabaseBackup = 'admin/database-backups',
}
export enum CacheControl {
@@ -1,17 +1,4 @@
import {
Body,
Controller,
Delete,
Get,
Next,
Param,
Post,
Req,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Body, Controller, Delete, Get, Next, Param, Post, Req, Res, UseInterceptors } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import {
MaintenanceAuthDto,
@@ -34,6 +21,7 @@ import { FilenameParamDto } from 'src/validation';
import type { DatabaseBackupController as _DatabaseBackupController } from 'src/controllers/database-backup.controller';
import type { ServerController as _ServerController } from 'src/controllers/server.controller';
import { DatabaseBackupDeleteDto, DatabaseBackupListResponseDto } from 'src/dtos/database-backup.dto';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
import { DatabaseBackupService } from 'src/services/database-backup.service';
@Controller()
@@ -93,13 +81,8 @@ export class MaintenanceWorkerController {
*/
@Post('admin/database-backups/upload')
@MaintenanceRoute()
@UseInterceptors(FileInterceptor('file'))
uploadDatabaseBackup(
@UploadedFile()
file: Express.Multer.File,
): Promise<void> {
return this.databaseBackupService.uploadBackup(file);
}
@UseInterceptors(FileUploadInterceptor)
uploadDatabaseBackup() {}
@Get('admin/maintenance/status')
maintenanceStatus(@Req() request: Request): Promise<MaintenanceStatusResponseDto> {
@@ -48,6 +48,7 @@ export class FileUploadInterceptor implements NestInterceptor {
private handlers: {
userProfile: RequestHandler;
assetUpload: RequestHandler;
databaseBackup: RequestHandler;
};
private defaultStorage: StorageEngine;
@@ -77,6 +78,7 @@ export class FileUploadInterceptor implements NestInterceptor {
{ name: UploadFieldName.ASSET_DATA, maxCount: 1 },
{ name: UploadFieldName.SIDECAR_DATA, maxCount: 1 },
]),
databaseBackup: instance.single(UploadFieldName.BACKUP_DATA),
};
}
@@ -165,6 +167,10 @@ export class FileUploadInterceptor implements NestInterceptor {
return this.handlers.userProfile;
}
case RouteKey.DatabaseBackup: {
return this.handlers.databaseBackup;
}
default: {
return null;
}
@@ -562,7 +562,6 @@ select
"asset"."checksum",
"asset"."originalPath",
"asset"."isExternal",
"asset"."visibility",
"asset"."originalFileName",
"asset"."livePhotoVideoId",
"asset"."fileCreatedAt",
@@ -594,7 +593,6 @@ from
where
"asset"."deletedAt" is null
and "asset"."id" = $2
and "asset"."visibility" != $3
-- AssetJobRepository.streamForStorageTemplateJob
select
@@ -604,7 +602,6 @@ select
"asset"."checksum",
"asset"."originalPath",
"asset"."isExternal",
"asset"."visibility",
"asset"."originalFileName",
"asset"."livePhotoVideoId",
"asset"."fileCreatedAt",
@@ -635,7 +632,6 @@ from
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."deletedAt" is null
and "asset"."visibility" != $2
-- AssetJobRepository.streamForDeletedJob
select
+4 -3
View File
@@ -123,13 +123,13 @@ with
) as "year"
)
select
"a".*
"a".*,
to_json("asset_exif") as "exifInfo"
from
"today"
inner join lateral (
select
"asset"."id",
"asset"."localDateTime"
"asset".*
from
"asset"
inner join "asset_job_status" on "asset"."id" = "asset_job_status"."assetId"
@@ -151,6 +151,7 @@ with
limit
$7
) as "a" on true
inner join "asset_exif" on "a"."id" = "asset_exif"."assetId"
)
select
date_part(
+16 -24
View File
@@ -102,30 +102,22 @@ order by
"shared_link"."createdAt" desc
-- SharedLinkRepository.getAll
select
"shared_link".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset".*
from
"shared_link_asset"
inner join "asset" on "asset"."id" = "shared_link_asset"."assetId"
where
"shared_link"."id" = "shared_link_asset"."sharedLinkId"
and "asset"."deletedAt" is null
order by
"asset"."fileCreatedAt" asc
limit
$1
) as agg
) as "assets",
select distinct
on ("shared_link"."createdAt") "shared_link".*,
"assets"."assets",
to_json("album") as "album"
from
"shared_link"
left join "shared_link_asset" on "shared_link_asset"."sharedLinkId" = "shared_link"."id"
left join lateral (
select
json_agg("asset") as "assets"
from
"asset"
where
"asset"."id" = "shared_link_asset"."assetId"
and "asset"."deletedAt" is null
) as "assets" on true
left join lateral (
select
"album".*,
@@ -160,12 +152,12 @@ from
and "album"."deletedAt" is null
) as "album" on true
where
"shared_link"."userId" = $2
"shared_link"."userId" = $1
and (
"shared_link"."type" = $3
"shared_link"."type" = $2
or "album"."id" is not null
)
and "shared_link"."albumId" = $4
and "shared_link"."albumId" = $3
order by
"shared_link"."createdAt" desc
@@ -353,7 +353,6 @@ export class AssetJobRepository {
'asset.checksum',
'asset.originalPath',
'asset.isExternal',
'asset.visibility',
'asset.originalFileName',
'asset.livePhotoVideoId',
'asset.fileCreatedAt',
@@ -368,16 +367,13 @@ export class AssetJobRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
getForStorageTemplateJob(id: string, options?: { includeHidden?: boolean }) {
return this.storageTemplateAssetQuery()
.where('asset.id', '=', id)
.$if(!options?.includeHidden, (qb) => qb.where('asset.visibility', '!=', AssetVisibility.Hidden))
.executeTakeFirst();
getForStorageTemplateJob(id: string) {
return this.storageTemplateAssetQuery().where('asset.id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [], stream: true })
streamForStorageTemplateJob() {
return this.storageTemplateAssetQuery().where('asset.visibility', '!=', AssetVisibility.Hidden).stream();
return this.storageTemplateAssetQuery().stream();
}
@GenerateSql({ params: [DummyValue.DATE], stream: true })
+4 -2
View File
@@ -404,7 +404,7 @@ export class AssetRepository {
(qb) =>
qb
.selectFrom('asset')
.select(['asset.id', 'asset.localDateTime'])
.selectAll('asset')
.innerJoin('asset_job_status', 'asset.id', 'asset_job_status.assetId')
.where(sql`(asset."localDateTime" at time zone 'UTC')::date`, '=', sql`today.date`)
.where('asset.ownerId', '=', anyUuid(ownerIds))
@@ -423,7 +423,9 @@ export class AssetRepository {
.as('a'),
(join) => join.onTrue(),
)
.selectAll('a'),
.innerJoin('asset_exif', 'a.id', 'asset_exif.assetId')
.selectAll('a')
.select((eb) => eb.fn.toJson(eb.table('asset_exif')).as('exifInfo')),
)
.selectFrom('res')
.select(sql<number>`date_part('year', ("localDateTime" at time zone 'UTC')::date)::int`.as('year'))
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, sql, Updateable } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { Insertable, Kysely, NotNull, sql, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import _ from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { Album, columns } from 'src/database';
@@ -124,20 +124,19 @@ export class SharedLinkRepository {
.selectFrom('shared_link')
.selectAll('shared_link')
.where('shared_link.userId', '=', userId)
.select((eb) =>
jsonArrayFrom(
.leftJoin('shared_link_asset', 'shared_link_asset.sharedLinkId', 'shared_link.id')
.leftJoinLateral(
(eb) =>
eb
.selectFrom('shared_link_asset')
.whereRef('shared_link.id', '=', 'shared_link_asset.sharedLinkId')
.innerJoin('asset', 'asset.id', 'shared_link_asset.assetId')
.selectFrom('asset')
.select((eb) => eb.fn.jsonAgg('asset').as('assets'))
.whereRef('asset.id', '=', 'shared_link_asset.assetId')
.where('asset.deletedAt', 'is', null)
.selectAll('asset')
.orderBy('asset.fileCreatedAt', 'asc')
.limit(1),
)
.$castTo<MapAsset[]>()
.as('assets'),
.as('assets'),
(join) => join.onTrue(),
)
.select('assets.assets')
.$narrowType<{ assets: NotNull }>()
.leftJoinLateral(
(eb) =>
eb
@@ -180,6 +179,7 @@ export class SharedLinkRepository {
.$if(!!albumId, (eb) => eb.where('shared_link.albumId', '=', albumId!))
.$if(!!id, (eb) => eb.where('shared_link.id', '=', id!))
.orderBy('shared_link.createdAt', 'desc')
.distinctOn(['shared_link.createdAt'])
.execute();
}
@@ -86,6 +86,13 @@ export class AssetMediaService extends BaseService {
}
break;
}
case UploadFieldName.BACKUP_DATA: {
if (mimeTypes.isBackup(filename)) {
return true;
}
break;
}
}
this.logger.error(`Unsupported file type ${filename}`);
@@ -101,6 +108,7 @@ export class AssetMediaService extends BaseService {
[UploadFieldName.ASSET_DATA]: extension,
[UploadFieldName.SIDECAR_DATA]: '.xmp',
[UploadFieldName.PROFILE_DATA]: extension,
[UploadFieldName.BACKUP_DATA]: extension === '.gz' ? '.sql.gz' : extension,
};
return sanitize(`${file.uuid}${lookup[fieldName]}`);
@@ -113,6 +121,9 @@ export class AssetMediaService extends BaseService {
if (fieldName === UploadFieldName.PROFILE_DATA) {
folder = StorageCore.getFolderLocation(StorageFolder.Profile, auth.user.id);
}
if (fieldName === UploadFieldName.BACKUP_DATA) {
folder = StorageCore.getFolderLocation(StorageFolder.Backups, `uploaded-${file.originalName}`);
}
this.storageRepository.mkdirSync(folder);
+1 -12
View File
@@ -1,7 +1,7 @@
import { BadRequestException, Injectable, Optional } from '@nestjs/common';
import { debounce } from 'lodash';
import { DateTime } from 'luxon';
import path, { basename } from 'node:path';
import path from 'node:path';
import { PassThrough, Readable, Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import semver from 'semver';
@@ -252,17 +252,6 @@ export class DatabaseBackupService {
return backupFilePath;
}
async uploadBackup(file: Express.Multer.File): Promise<void> {
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
const fn = basename(file.originalname);
if (!isValidDatabaseBackupName(fn)) {
throw new BadRequestException('Invalid backup name!');
}
const filePath = path.join(backupsFolder, `uploaded-${fn}`);
await this.storageRepository.createOrOverwriteFile(filePath, file.buffer);
}
downloadBackup(fileName: string): ImmichFileResponse {
if (!isValidDatabaseBackupName(fileName)) {
throw new BadRequestException('Invalid backup name!');
@@ -9,9 +9,6 @@ import { userStub } from 'test/fixtures/user.stub';
import { getForStorageTemplate } from 'test/mappers';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
const motionAsset = AssetFactory.from({ type: AssetType.Video }).exif().build();
const stillAsset = AssetFactory.from({ livePhotoVideoId: motionAsset.id }).exif().build();
describe(StorageTemplateService.name, () => {
let sut: StorageTemplateService;
let mocks: ServiceMocks;
@@ -156,58 +153,6 @@ describe(StorageTemplateService.name, () => {
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath });
});
it('should migrate live photo motion video alongside the still image using album in path', async () => {
const motionAsset = AssetFactory.from({
type: AssetType.Video,
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.exif()
.build();
const stillAsset = AssetFactory.from({
livePhotoVideoId: motionAsset.id,
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.exif()
.build();
const album = AlbumFactory.from().asset().build();
const config = structuredClone(defaults);
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
sut.onConfigInit({ newConfig: config });
mocks.user.get.mockResolvedValue(userStub.user1);
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName}`;
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(stillAsset));
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
mocks.album.getByAssetId.mockResolvedValue([album]);
mocks.move.create.mockResolvedValueOnce({
id: '123',
entityId: stillAsset.id,
pathType: AssetPathType.Original,
oldPath: stillAsset.originalPath,
newPath: newStillPicturePath,
});
mocks.move.create.mockResolvedValueOnce({
id: '124',
entityId: motionAsset.id,
pathType: AssetPathType.Original,
oldPath: motionAsset.originalPath,
newPath: newMotionPicturePath,
});
await expect(sut.handleMigrationSingle({ id: stillAsset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2);
expect(mocks.album.getByAssetId).toHaveBeenCalledWith(stillAsset.ownerId, stillAsset.id);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, originalPath: newStillPicturePath });
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath });
});
it('should use handlebar if condition for album', async () => {
const user = UserFactory.create();
const asset = AssetFactory.from().owner(user).exif().build();
@@ -764,18 +709,12 @@ describe(StorageTemplateService.name, () => {
})
.exif()
.build();
const album = AlbumFactory.from().asset().build();
const config = structuredClone(defaults);
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
sut.onConfigInit({ newConfig: config });
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName}`;
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`;
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)]));
mocks.user.getList.mockResolvedValue([userStub.user1]);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
mocks.album.getByAssetId.mockResolvedValue([album]);
mocks.move.create.mockResolvedValueOnce({
id: '123',
@@ -796,53 +735,11 @@ describe(StorageTemplateService.name, () => {
await sut.handleMigration();
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(motionAsset.id);
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, originalPath: newStillPicturePath });
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath });
});
it('should use still photo album info when migrating live photo motion video', async () => {
const user = userStub.user1;
const album = AlbumFactory.from().asset().build();
const config = structuredClone(defaults);
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other{{/if}}/{{filename}}';
sut.onConfigInit({ newConfig: config });
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)]));
mocks.user.getList.mockResolvedValue([user]);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
mocks.album.getByAssetId.mockResolvedValue([album]);
mocks.move.create.mockResolvedValueOnce({
id: '123',
entityId: stillAsset.id,
pathType: AssetPathType.Original,
oldPath: stillAsset.originalPath,
newPath: `/data/library/${user.id}/2022/${album.albumName}/${stillAsset.originalFileName}`,
});
mocks.move.create.mockResolvedValueOnce({
id: '124',
entityId: motionAsset.id,
pathType: AssetPathType.Original,
oldPath: motionAsset.originalPath,
newPath: `/data/library/${user.id}/2022/${album.albumName}/${motionAsset.originalFileName}`,
});
await sut.handleMigration();
expect(mocks.album.getByAssetId).toHaveBeenCalledWith(stillAsset.ownerId, stillAsset.id);
expect(mocks.album.getByAssetId).toHaveBeenCalledTimes(2);
expect(mocks.asset.update).toHaveBeenCalledWith({
id: stillAsset.id,
originalPath: expect.stringContaining(`/${album.albumName}/`),
});
expect(mocks.asset.update).toHaveBeenCalledWith({
id: motionAsset.id,
originalPath: expect.stringContaining(`/${album.albumName}/`),
});
});
});
describe('file rename correctness', () => {
+12 -26
View File
@@ -158,14 +158,12 @@ export class StorageTemplateService extends BaseService {
// move motion part of live photo
if (asset.livePhotoVideoId) {
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId, {
includeHidden: true,
});
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId);
if (!livePhotoVideo) {
return JobStatus.Failed;
}
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }, asset);
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
}
return JobStatus.Success;
}
@@ -193,12 +191,10 @@ export class StorageTemplateService extends BaseService {
// move motion part of live photo
if (asset.livePhotoVideoId) {
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId, {
includeHidden: true,
});
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId);
if (livePhotoVideo) {
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }, asset);
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
}
}
}
@@ -218,7 +214,7 @@ export class StorageTemplateService extends BaseService {
await this.moveRepository.cleanMoveHistorySingle(assetId);
}
async moveAsset(asset: StorageAsset, metadata: MoveAssetMetadata, stillPhoto?: StorageAsset) {
async moveAsset(asset: StorageAsset, metadata: MoveAssetMetadata) {
if (asset.isExternal || StorageCore.isAndroidMotionPath(asset.originalPath)) {
// External assets are not affected by storage template
// TODO: shouldn't this only apply to external assets?
@@ -228,7 +224,7 @@ export class StorageTemplateService extends BaseService {
return this.databaseRepository.withLock(DatabaseLock.StorageTemplateMigration, async () => {
const { id, originalPath, checksum, fileSizeInByte } = asset;
const oldPath = originalPath;
const newPath = await this.getTemplatePath(asset, metadata, stillPhoto);
const newPath = await this.getTemplatePath(asset, metadata);
if (!fileSizeInByte) {
this.logger.error(`Asset ${id} missing exif info, skipping storage template migration`);
@@ -259,11 +255,7 @@ export class StorageTemplateService extends BaseService {
});
}
private async getTemplatePath(
asset: StorageAsset,
metadata: MoveAssetMetadata,
stillPhoto?: StorageAsset,
): Promise<string> {
private async getTemplatePath(asset: StorageAsset, metadata: MoveAssetMetadata): Promise<string> {
const { storageLabel, filename } = metadata;
try {
@@ -304,12 +296,8 @@ export class StorageTemplateService extends BaseService {
let albumName = null;
let albumStartDate = null;
let albumEndDate = null;
const assetForMetadata = stillPhoto || asset;
if (this.template.needsAlbum) {
// For motion videos, use the still photo's album information since motion videos
// don't have album metadata attached directly
const albums = await this.albumRepository.getByAssetId(assetForMetadata.ownerId, assetForMetadata.id);
const albums = await this.albumRepository.getByAssetId(asset.ownerId, asset.id);
const album = albums?.[0];
if (album) {
albumName = album.albumName || null;
@@ -322,18 +310,16 @@ export class StorageTemplateService extends BaseService {
}
}
// For motion videos that are part of live photos, use the still photo's date
// to ensure both parts end up in the same folder
const storagePath = this.render(this.template.compiled, {
asset: assetForMetadata,
asset,
filename: sanitized,
extension,
albumName,
albumStartDate,
albumEndDate,
make: assetForMetadata.make,
model: assetForMetadata.model,
lensModel: assetForMetadata.lensModel,
make: asset.make,
model: asset.model,
lensModel: asset.lensModel,
});
const fullPath = path.normalize(path.join(rootPath, storagePath));
let destination = `${fullPath}.${extension}`;
+1
View File
@@ -149,6 +149,7 @@ export const mimeTypes = {
isProfile: (filename: string) => isType(filename, profile),
isSidecar: (filename: string) => isType(filename, sidecar),
isVideo: (filename: string) => isType(filename, video),
isBackup: (filename: string) => filename.endsWith('.sql') || filename.endsWith('.sql.gz'),
canBeTransparent: (filename: string) => transparentCapableExtensions.has(extname(filename).toLowerCase()),
isRaw: (filename: string) => isType(filename, raw),
lookup,
-1
View File
@@ -12,7 +12,6 @@ export const getForStorageTemplate = (asset: ReturnType<AssetFactory['build']>)
isExternal: asset.isExternal,
checksum: asset.checksum,
timeZone: asset.exifInfo.timeZone,
visibility: asset.visibility,
fileCreatedAt: asset.fileCreatedAt,
originalPath: asset.originalPath,
originalFileName: asset.originalFileName,
-8
View File
@@ -233,14 +233,6 @@ export class MediumTestContext<S extends BaseService = BaseService> {
return { albumUser: { albumId, userId, role }, result };
}
async softDeleteAsset(assetId: string) {
await this.database.updateTable('asset').set({ deletedAt: new Date() }).where('id', '=', assetId).execute();
}
async softDeleteAlbum(albumId: string) {
await this.database.updateTable('album').set({ deletedAt: new Date() }).where('id', '=', albumId).execute();
}
async newJobStatus(dto: Partial<Insertable<AssetJobStatusTable>> & { assetId: string }) {
const jobStatus = mediumFactory.assetJobStatusInsert({ assetId: dto.assetId });
const result = await this.get(AssetRepository).upsertJobStatus(jobStatus);
@@ -95,469 +95,6 @@ describe(SharedLinkService.name, () => {
});
});
describe('getAll', () => {
it('should return all shared links even when they share the same createdAt', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sameTimestamp = '2024-01-01T00:00:00.000Z';
const link1 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
createdAt: sameTimestamp,
});
const link2 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
createdAt: sameTimestamp,
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(2);
const ids = result.map((r) => r.id);
expect(ids).toContain(link1.id);
expect(ids).toContain(link2.id);
});
it('should return shared links sorted by createdAt in descending order', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const link1 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
createdAt: '2021-01-01T00:00:00.000Z',
});
const link2 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
createdAt: '2023-01-01T00:00:00.000Z',
});
const link3 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
createdAt: '2022-01-01T00:00:00.000Z',
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(3);
expect(result.map((r) => r.id)).toEqual([link2.id, link3.id, link1.id]);
});
it('should not return shared links belonging to other users', async () => {
const { sut, ctx } = setup();
const { user: userA } = await ctx.newUser();
const { user: userB } = await ctx.newUser();
const authA = factory.auth({ user: userA });
const authB = factory.auth({ user: userB });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const linkA = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: userA.id,
allowUpload: false,
type: SharedLinkType.Individual,
});
await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: userB.id,
allowUpload: false,
type: SharedLinkType.Individual,
});
const resultA = await sut.getAll(authA, {});
expect(resultA).toHaveLength(1);
expect(resultA[0].id).toBe(linkA.id);
const resultB = await sut.getAll(authB, {});
expect(resultB).toHaveLength(1);
expect(resultB[0].id).not.toBe(linkA.id);
});
it('should filter by albumId', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album: album1 } = await ctx.newAlbum({ ownerId: user.id });
const { album: album2 } = await ctx.newAlbum({ ownerId: user.id });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const link1 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album1.id,
allowUpload: false,
type: SharedLinkType.Album,
});
await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album2.id,
allowUpload: false,
type: SharedLinkType.Album,
});
const result = await sut.getAll(auth, { albumId: album1.id });
expect(result).toHaveLength(1);
expect(result[0].id).toBe(link1.id);
});
it('should return album shared links with album data', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: false,
type: SharedLinkType.Album,
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(1);
expect(result[0].album).toBeDefined();
expect(result[0].album!.id).toBe(album.id);
});
it('should return multiple album shared links without sql error from json group by', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album: album1 } = await ctx.newAlbum({ ownerId: user.id });
const { album: album2 } = await ctx.newAlbum({ ownerId: user.id });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const link1 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album1.id,
allowUpload: false,
type: SharedLinkType.Album,
});
const link2 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album2.id,
allowUpload: false,
type: SharedLinkType.Album,
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(2);
const ids = result.map((r) => r.id);
expect(ids).toContain(link1.id);
expect(ids).toContain(link2.id);
expect(result[0].album).toBeDefined();
expect(result[1].album).toBeDefined();
});
it('should return mixed album and individual shared links together', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const { asset } = await ctx.newAsset({ ownerId: user.id });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const albumLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: false,
type: SharedLinkType.Album,
});
const albumLink2 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: false,
type: SharedLinkType.Album,
});
const individualLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
assetIds: [asset.id],
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(3);
const ids = result.map((r) => r.id);
expect(ids).toContain(albumLink.id);
expect(ids).toContain(albumLink2.id);
expect(ids).toContain(individualLink.id);
});
it('should return only the first asset as cover for an individual shared link', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const assets = await Promise.all([
ctx.newAsset({ ownerId: user.id, fileCreatedAt: '2021-01-01T00:00:00.000Z' }),
ctx.newAsset({ ownerId: user.id, fileCreatedAt: '2023-01-01T00:00:00.000Z' }),
ctx.newAsset({ ownerId: user.id, fileCreatedAt: '2022-01-01T00:00:00.000Z' }),
]);
const sharedLinkRepo = ctx.get(SharedLinkRepository);
await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
assetIds: assets.map(({ asset }) => asset.id),
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(1);
expect(result[0].assets).toHaveLength(1);
expect(result[0].assets[0].id).toBe(assets[0].asset.id);
});
});
describe('get', () => {
it('should not return trashed assets for an individual shared link', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset: visibleAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: visibleAsset.id, make: 'Canon' });
const { asset: trashedAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: trashedAsset.id, make: 'Canon' });
await ctx.softDeleteAsset(trashedAsset.id);
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
assetIds: [visibleAsset.id, trashedAsset.id],
});
const result = await sut.get(auth, sharedLink.id);
expect(result).toBeDefined();
expect(result!.assets).toHaveLength(1);
expect(result!.assets[0].id).toBe(visibleAsset.id);
});
it('should return empty assets when all individually shared assets are trashed', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
await ctx.softDeleteAsset(asset.id);
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
assetIds: [asset.id],
});
await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({
assets: [],
});
});
it('should not return trashed assets in a shared album', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const { asset: visibleAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: visibleAsset.id, make: 'Canon' });
await ctx.newAlbumAsset({ albumId: album.id, assetId: visibleAsset.id });
const { asset: trashedAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: trashedAsset.id, make: 'Canon' });
await ctx.newAlbumAsset({ albumId: album.id, assetId: trashedAsset.id });
await ctx.softDeleteAsset(trashedAsset.id);
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: true,
type: SharedLinkType.Album,
});
await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({
album: expect.objectContaining({ assetCount: 1 }),
});
});
it('should return an empty asset count when all album assets are trashed', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.softDeleteAsset(asset.id);
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: false,
type: SharedLinkType.Album,
});
await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({
album: expect.objectContaining({ assetCount: 0 }),
});
});
it('should not return an album shared link when the album is trashed', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: false,
type: SharedLinkType.Album,
});
await ctx.softDeleteAlbum(album.id);
await expect(sut.get(auth, sharedLink.id)).rejects.toThrow('Shared link not found');
});
});
describe('getAll', () => {
it('should not return trashed assets as cover for an individual shared link', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset: trashedAsset } = await ctx.newAsset({
ownerId: user.id,
fileCreatedAt: '2020-01-01T00:00:00.000Z',
});
await ctx.softDeleteAsset(trashedAsset.id);
const { asset: visibleAsset } = await ctx.newAsset({
ownerId: user.id,
fileCreatedAt: '2021-01-01T00:00:00.000Z',
});
const sharedLinkRepo = ctx.get(SharedLinkRepository);
await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
assetIds: [trashedAsset.id, visibleAsset.id],
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(1);
expect(result[0].assets).toHaveLength(1);
expect(result[0].assets[0].id).toBe(visibleAsset.id);
});
it('should not return an album shared link when the album is trashed', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: false,
type: SharedLinkType.Album,
});
await ctx.softDeleteAlbum(album.id);
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(0);
});
});
it('should remove individually shared asset', async () => {
const { sut, ctx } = setup();
+1 -1
View File
@@ -98,7 +98,7 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.53.5",
"svelte": "5.53.0",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7",
@@ -140,7 +140,7 @@
</ControlAppBar>
{/if}
<section class="my-40 mx-4" bind:clientHeight={viewport.height} bind:clientWidth={viewport.width}>
<GalleryViewer {assets} {assetInteraction} {viewport} allowDeletion={false} />
<GalleryViewer {assets} {assetInteraction} {viewport} />
</section>
{:else if assets.length === 1}
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
@@ -45,7 +45,6 @@
pageHeaderOffset?: number;
slidingWindowOffset?: number;
arrowNavigation?: boolean;
allowDeletion?: boolean;
};
let {
@@ -61,7 +60,6 @@
slidingWindowOffset = 0,
pageHeaderOffset = 0,
arrowNavigation = true,
allowDeletion = true,
}: Props = $props();
let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore;
@@ -275,15 +273,11 @@
if (assetInteraction.selectionActive) {
shortcuts.push(
{ shortcut: { key: 'Escape' }, onShortcut: deselectAllAssets },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: deselectAllAssets },
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
if (allowDeletion) {
shortcuts.push(
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
}
}
return shortcuts;
@@ -21,7 +21,7 @@
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { mapSettings } from '$lib/stores/preferences.store';
import { locale, mapSettings } from '$lib/stores/preferences.store';
import { preferences, user } from '$lib/stores/user.store';
import {
updateStackedAssetInTimeline,
@@ -90,6 +90,8 @@
assetFilter: selectedClusterIds,
});
const displayedAssetCount = $derived(timelineManager?.assetCount ?? assetCount);
$effect.pre(() => {
void timelineOptions;
assetInteraction.clearMultiselect();
@@ -101,7 +103,8 @@
<div class="flex items-center gap-2">
<Icon icon={mdiImageMultiple} size="20" />
<p class="text-sm font-medium text-immich-fg dark:text-immich-dark-fg">
{$t('assets_count', { values: { count: assetCount } })}
{displayedAssetCount.toLocaleString($locale)}
{$t('assets')}
</p>
</div>
<CloseButton onclick={onClose} />
@@ -50,10 +50,7 @@
<svelte:window bind:innerWidth />
<nav
id="dashboard-navbar"
class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm bg-red-50 dark:bg-red-950"
>
<nav id="dashboard-navbar" class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm">
<SkipLink text={$t('skip_to_content')} />
<div
class="grid h-full grid-cols-[--spacing(32)_auto] items-center py-2 sidebar:grid-cols-[--spacing(64)_auto] {noBorder
@@ -83,7 +80,6 @@
<a data-sveltekit-preload-data="hover" href={Route.photos()}>
<Logo variant={mediaQueryManager.isFullSidebar ? 'inline' : 'icon'} class="max-md:h-12" />
</a>
<span class="text-xs font-bold text-red-500 ms-2">[VISUAL TEST]</span>
</div>
<div class="flex justify-between gap-4 lg:gap-8 pe-6">
<div class="hidden w-full max-w-5xl flex-1 tall:ps-0 sm:block">
@@ -1,5 +1,4 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { handleRemoveSharedLinkAssets } from '$lib/services/shared-link.service';
import { getAssetControlContext } from '$lib/utils/context';
import { type SharedLinkResponseDto } from '@immich/sdk';
@@ -24,8 +23,6 @@
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'Delete' }, onShortcut: handleSelect }} />
<IconButton
shape="round"
color="secondary"
@@ -102,7 +102,7 @@ export const handleUploadDatabaseBackup = async () => {
try {
const [file] = await openFilePicker({ multiple: false });
const formData = new FormData();
formData.append('file', file);
formData.append('backup', file);
await uploadRequest<DatabaseBackupUploadDto>({
url: getBaseUrl() + '/admin/database-backups/upload',