Compare commits

..

1 Commits

Author SHA1 Message Date
Jason Rasmussen
6e69052478 fix: album thumbnail refresh 2026-02-04 16:32:08 -05:00
9 changed files with 122 additions and 164 deletions

View File

@@ -2221,6 +2221,71 @@
"x-immich-state": "Stable"
}
},
"/albums/{id}/thumbnail": {
"get": {
"description": "Virtual route that redirects to the thumbnail of the album cover asset.",
"operationId": "getAlbumThumbnailRedirect",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Redirect to album thumbnail",
"tags": [
"Albums"
],
"x-immich-history": [
{
"version": "v2.6.0",
"state": "Added"
},
{
"version": "v2.6.0",
"state": "Beta"
}
],
"x-immich-permission": "album.read",
"x-immich-state": "Beta"
}
},
"/albums/{id}/user/{userId}": {
"delete": {
"description": "Remove a user from an album. Use an ID of \"me\" to leave a shared album.",

View File

@@ -1,82 +0,0 @@
import { Command, CommandRunner } from 'nest-commander';
import { CliService } from 'src/services/cli.service';
@Command({
name: 'debug-migrations',
description: 'Run a report to debug issues with database migrations',
})
export class DebugMigrations extends CommandRunner {
constructor(private service: CliService) {
super();
}
async run(): Promise<void> {
try {
const report = await this.service.debugMigrations();
const maxLength = Math.max(...report.results.map((item) => item.name.length));
const success = report.results.filter((item) => item.status === 'applied');
const deleted = report.results.filter((item) => item.status === 'deleted');
const missing = report.results.filter((item) => item.status === 'missing');
for (const item of report.results) {
const name = item.name.padEnd(maxLength, ' ');
switch (item.status) {
case 'applied': {
console.log(`${name}`);
break;
}
case 'deleted': {
console.log(`${name} - Deleted! (this migration does not exist anymore)`);
break;
}
case 'missing': {
console.log(`⚠️ ${name} - Missing! (this migration needs to be applied still)`);
break;
}
}
}
if (missing.length === 0 && deleted.length === 0) {
console.log(`\nAll ${success.length} migrations have been successfully applied! 🎉`);
} else {
console.log(`\nMigration issues detected:`);
console.log(` Missing migrations: ${missing.length}`);
console.log(` Deleted migrations: ${deleted.length}`);
console.log(` Successfully applied migrations: ${success.length}`);
}
} catch (error) {
console.error(error);
console.error('Unable to debug migrations');
}
}
}
@Command({
name: 'debug-schema',
description: 'Run a report to debug issues with database schema',
})
export class DebugSchema extends CommandRunner {
constructor(private service: CliService) {
super();
}
async run(): Promise<void> {
try {
const output = await this.service.debugSchema();
if (output.length === 0) {
console.log('No schema changes detected');
return;
}
console.log(output.join('\n'));
} catch (error) {
console.error(error);
console.error('Unable to debug schema');
}
}
}

View File

@@ -1,4 +1,3 @@
import { DebugMigrations, DebugSchema } from 'src/commands/debug-migrations.command';
import { GrantAdminCommand, PromptEmailQuestion, RevokeAdminCommand } from 'src/commands/grant-admin';
import { ListUsersCommand } from 'src/commands/list-users.command';
import { DisableMaintenanceModeCommand, EnableMaintenanceModeCommand } from 'src/commands/maintenance-mode';
@@ -29,6 +28,4 @@ export const commandsAndQuestions = [
ChangeMediaLocationCommand,
PromptMediaLocationQuestions,
PromptConfirmMoveQuestions,
DebugMigrations,
DebugSchema,
];

View File

@@ -1,4 +1,17 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Put,
Query,
Redirect,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
@@ -73,6 +86,19 @@ export class AlbumController {
return this.service.get(auth, id, dto);
}
@Authenticated({ permission: Permission.AlbumRead, sharedLink: true })
@Get(':id/thumbnail')
@Redirect()
@Endpoint({
summary: 'Redirect to album thumbnail',
description: 'Virtual route that redirects to the thumbnail of the album cover asset.',
history: new HistoryBuilder().added('v2.6.0').beta('v2.6.0'),
})
async getAlbumThumbnailRedirect(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
const url = await this.service.getThumbnailRedirectUrl(auth, id);
return { url, status: 307 };
}
@Patch(':id')
@Authenticated({ permission: Permission.AlbumUpdate })
@Endpoint({

View File

@@ -87,6 +87,16 @@ export class AlbumRepository {
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async getForThumbnailRedirect(id: string) {
return this.db
.selectFrom('asset')
.innerJoin('album', 'album.albumThumbnailAssetId', 'asset.id')
.where('album.id', '=', id)
.select(['asset.id', 'asset.thumbhash'])
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async getByAssetId(ownerId: string, assetId: string) {
return this.db

View File

@@ -281,14 +281,6 @@ export class DatabaseRepository {
return rows[0].db;
}
async getMigrations() {
const { rows } = await sql<{
name: string;
timestamp: string;
}>`SELECT * FROM kysely_migrations ORDER BY name ASC`.execute(this.db);
return rows;
}
async getDimensionSize(table: string, column = 'embedding'): Promise<number> {
const { rows } = await sql<{ dimsize: number }>`
SELECT atttypmod as dimsize

View File

@@ -21,6 +21,7 @@ import { Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
import { BaseService } from 'src/services/base.service';
import { addAssets, removeAssets } from 'src/utils/asset.util';
import { hexOrBufferToBase64 } from 'src/utils/bytes';
import { getPreferences } from 'src/utils/preferences';
@Injectable()
@@ -93,6 +94,23 @@ export class AlbumService extends BaseService {
};
}
async getThumbnailRedirectUrl(auth: AuthDto, id: string) {
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
const asset = await this.albumRepository.getForThumbnailRedirect(id);
if (!asset) {
throw new BadRequestException('Album has no thumbnail');
}
const params = new URLSearchParams();
params.append('edited', 'true');
if (asset.thumbhash) {
params.append('c', hexOrBufferToBase64(asset.thumbhash));
}
return `/api/assets/${asset.id}/thumbnail?${params.toString()}`;
}
async create(auth: AuthDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> {
const albumUsers = dto.albumUsers || [];

View File

@@ -1,80 +1,15 @@
import { Injectable } from '@nestjs/common';
import { isAbsolute, join } from 'node:path';
import postgres from 'postgres';
import { isAbsolute } from 'node:path';
import { SALT_ROUNDS } from 'src/constants';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools';
import { asPostgresConnectionConfig } from 'src/utils/database';
import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
export type MigrationReport = {
files: string[];
rows: Array<{ name: string; timestamp: string }>;
results: MigrationStatus[];
};
type MigrationStatus = {
name: string;
status: 'applied' | 'missing' | 'deleted';
};
@Injectable()
export class CliService extends BaseService {
async debugMigrations(): Promise<MigrationReport> {
// eslint-disable-next-line unicorn/prefer-module
const allFiles = await this.storageRepository.readdir(join(__dirname, '../schema/migrations'));
const files = allFiles.filter((file) => file.endsWith('.js')).map((file) => file.slice(0, -3));
const rows = await this.databaseRepository.getMigrations();
const filesSet = new Set(files);
const rowsSet = new Set(rows.map((item) => item.name));
const combined = [...filesSet, ...rowsSet].toSorted();
const results: MigrationStatus[] = [];
for (const name of combined) {
if (filesSet.has(name) && rowsSet.has(name)) {
results.push({ name, status: 'applied' });
continue;
}
if (filesSet.has(name) && !rowsSet.has(name)) {
results.push({ name, status: 'missing' });
continue;
}
if (!filesSet.has(name) && rowsSet.has(name)) {
results.push({ name, status: 'deleted' });
continue;
}
}
return { files, rows, results };
}
async debugSchema() {
const source = schemaFromCode({ overrides: true, namingStrategy: 'default' });
const { database } = this.configRepository.getEnv();
const db = postgres(asPostgresConnectionConfig(database.config));
const target = await schemaFromDatabase(db, {});
console.log(source.warnings.join('\n'));
const up = schemaDiff(source, target, {
tables: { ignoreExtra: true },
functions: { ignoreExtra: false },
parameters: { ignoreExtra: true },
});
if (up.items.length === 0) {
return [];
}
return up.asSql();
}
async listUsers(): Promise<UserAdminResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: true });
return users.map((user) => mapUserAdmin(user));

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import AssetCover from '$lib/components/sharedlinks-page/covers/asset-cover.svelte';
import NoCover from '$lib/components/sharedlinks-page/covers/no-cover.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { type AlbumResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
@@ -14,9 +13,7 @@
let { album, preload = false, class: className = '' }: Props = $props();
let alt = $derived(album.albumName || $t('unnamed_album'));
let thumbnailUrl = $derived(
album.albumThumbnailAssetId ? getAssetMediaUrl({ id: album.albumThumbnailAssetId }) : null,
);
let thumbnailUrl = $derived(album.albumThumbnailAssetId ? `/api/albums/${album.id}/thumbnail` : null);
</script>
{#if thumbnailUrl}