merge: Built-in video thumbnail generator (for remote video thumbnails without needing "Cache remote media" enabled) (!310)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/310
This commit is contained in:
Amelia Yukii 2024-04-03 10:43:30 +00:00
commit 1572b5d981
8 changed files with 120 additions and 3 deletions

View file

@ -163,7 +163,7 @@ redis:
# ┌───────────────────────────┐ # ┌───────────────────────────┐
#───┘ MeiliSearch configuration └───────────────────────────── #───┘ MeiliSearch configuration └─────────────────────────────
# You can set scope to local (default value) or global # You can set scope to local (default value) or global
# (include notes from remote). # (include notes from remote).
#meilisearch: #meilisearch:
@ -255,13 +255,18 @@ proxyRemoteFiles: true
# https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4 # https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4
#videoThumbnailGenerator: https://example.com #videoThumbnailGenerator: https://example.com
# Enables the built-in thumbnail generator for remote videos. (default: false)
# Only useful if "Cache remote files" is disabled, and "videoThumbnailGenerator" is unset.
# Without it, remote video files that are not cached will not have any thumbnails.
#enableBuiltinVideoThumbnailGenerator: false
# Sign to ActivityPub GET request (default: true) # Sign to ActivityPub GET request (default: true)
signToActivityPubGet: true signToActivityPubGet: true
# check that inbound ActivityPub GET requests are signed ("authorized fetch") # check that inbound ActivityPub GET requests are signed ("authorized fetch")
checkActivityPubGetSignature: false checkActivityPubGetSignature: false
# For security reasons, uploading attachments from the intranet is prohibited, # For security reasons, uploading attachments from the intranet is prohibited,
# but exceptions can be made from the following settings. Default value is "undefined". # but exceptions can be made from the following settings. Default value is "undefined".
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)). # Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
#allowedPrivateNetworks: [ #allowedPrivateNetworks: [
# '127.0.0.1/32' # '127.0.0.1/32'

View file

@ -270,6 +270,11 @@ proxyRemoteFiles: true
# https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4 # https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4
#videoThumbnailGenerator: https://example.com #videoThumbnailGenerator: https://example.com
# Enables the built-in thumbnail generator for remote videos. (default: false)
# Only useful if "Cache remote files" is disabled, and "videoThumbnailGenerator" is unset.
# Without it, remote video files that are not cached will not have any thumbnails.
#enableBuiltinVideoThumbnailGenerator: false
# Sign to ActivityPub GET request (default: true) # Sign to ActivityPub GET request (default: true)
signToActivityPubGet: true signToActivityPubGet: true
# check that inbound ActivityPub GET requests are signed ("authorized fetch") # check that inbound ActivityPub GET requests are signed ("authorized fetch")

1
.gitignore vendored
View file

@ -55,6 +55,7 @@ api-docs.json
*.code-workspace *.code-workspace
.DS_Store .DS_Store
/files /files
/cache
ormconfig.json ormconfig.json
temp temp
/packages/frontend/src/**/*.stories.ts /packages/frontend/src/**/*.stories.ts

View file

@ -21,6 +21,7 @@ services:
- shonk - shonk
volumes: volumes:
- ./files:/sharkey/files - ./files:/sharkey/files
- ./cache:/sharkey/cache
- ./.config:/sharkey/.config:ro - ./.config:/sharkey/.config:ro
redis: redis:

View file

@ -88,6 +88,7 @@ type Source = {
mediaProxy?: string; mediaProxy?: string;
proxyRemoteFiles?: boolean; proxyRemoteFiles?: boolean;
videoThumbnailGenerator?: string; videoThumbnailGenerator?: string;
enableBuiltinVideoThumbnailGenerator?: boolean;
customMOTD?: string[]; customMOTD?: string[];
@ -170,6 +171,7 @@ export type Config = {
mediaProxy: string; mediaProxy: string;
externalMediaProxyEnabled: boolean; externalMediaProxyEnabled: boolean;
videoThumbnailGenerator: string | null; videoThumbnailGenerator: string | null;
enableBuiltinVideoThumbnailGenerator: boolean;
redis: RedisOptions & RedisOptionsSource; redis: RedisOptions & RedisOptionsSource;
redisForPubsub: RedisOptions & RedisOptionsSource; redisForPubsub: RedisOptions & RedisOptionsSource;
redisForJobQueue: RedisOptions & RedisOptionsSource; redisForJobQueue: RedisOptions & RedisOptionsSource;
@ -276,6 +278,7 @@ export function loadConfig(): Config {
videoThumbnailGenerator: config.videoThumbnailGenerator ? videoThumbnailGenerator: config.videoThumbnailGenerator ?
config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator
: null, : null,
enableBuiltinVideoThumbnailGenerator: config.enableBuiltinVideoThumbnailGenerator ?? false,
userAgent: `Misskey/${version} (${config.url})`, userAgent: `Misskey/${version} (${config.url})`,
clientEntry: clientManifest['src/_boot_.ts'], clientEntry: clientManifest['src/_boot_.ts'],
clientManifestExists: clientManifestExists, clientManifestExists: clientManifestExists,

View file

@ -16,6 +16,7 @@ const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename); const _dirname = dirname(_filename);
const path = Path.resolve(_dirname, '../../../../files'); const path = Path.resolve(_dirname, '../../../../files');
const cachePath = Path.resolve(_dirname, '../../../../cache');
@Injectable() @Injectable()
export class InternalStorageService { export class InternalStorageService {
@ -30,11 +31,26 @@ export class InternalStorageService {
return Path.resolve(path, key); return Path.resolve(path, key);
} }
@bindThis
public resolveCachePath(key: string) {
return Path.resolve(cachePath, key);
}
@bindThis
public existsCache(key: string) {
return fs.existsSync(this.resolveCachePath(key));
}
@bindThis @bindThis
public read(key: string) { public read(key: string) {
return fs.createReadStream(this.resolvePath(key)); return fs.createReadStream(this.resolvePath(key));
} }
@bindThis
public readCache(key: string) {
return fs.createReadStream(this.resolveCachePath(key));
}
@bindThis @bindThis
public saveFromPath(key: string, srcPath: string) { public saveFromPath(key: string, srcPath: string) {
fs.mkdirSync(path, { recursive: true }); fs.mkdirSync(path, { recursive: true });
@ -49,8 +65,19 @@ export class InternalStorageService {
return `${this.config.url}/files/${key}`; return `${this.config.url}/files/${key}`;
} }
@bindThis
public saveCacheFromBuffer(key: string, data: Buffer) {
fs.mkdirSync(cachePath, { recursive: true });
fs.writeFileSync(this.resolveCachePath(key), data);
}
@bindThis @bindThis
public del(key: string) { public del(key: string) {
fs.unlink(this.resolvePath(key), () => {}); fs.unlink(this.resolvePath(key), () => {});
} }
@bindThis
public delCache(key: string) {
fs.unlink(this.resolveCachePath(key), () => {});
}
} }

View file

@ -50,7 +50,16 @@ export class VideoProcessingService {
@bindThis @bindThis
public getExternalVideoThumbnailUrl(url: string): string | null { public getExternalVideoThumbnailUrl(url: string): string | null {
if (this.config.videoThumbnailGenerator == null) return null; if (this.config.videoThumbnailGenerator == null) {
if (this.config.enableBuiltinVideoThumbnailGenerator) {
return appendQuery(
`${this.config.url}/proxy/thumbnail.webp`,
query({ url }),
);
}
return null;
}
return appendQuery( return appendQuery(
`${this.config.videoThumbnailGenerator}/thumbnail.webp`, `${this.config.videoThumbnailGenerator}/thumbnail.webp`,

View file

@ -4,6 +4,7 @@
*/ */
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as crypto from 'node:crypto';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
@ -34,6 +35,7 @@ const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename); const _dirname = dirname(_filename);
const assets = `${_dirname}/../../server/file/assets/`; const assets = `${_dirname}/../../server/file/assets/`;
const cacheDir = `${_dirname}/../../../../cache/`;
@Injectable() @Injectable()
export class FileServerService { export class FileServerService {
@ -85,6 +87,15 @@ export class FileServerService {
done(); done();
}); });
if (this.config.enableBuiltinVideoThumbnailGenerator) {
fastify.get<{
Querystring: { url: string; };
}>('/proxy/thumbnail.webp', async (request, reply) => {
return await this.videoThumbnailHandler(request, reply)
.catch(err => this.errorHandler(request, reply, err));
});
}
fastify.get<{ fastify.get<{
Params: { url: string; }; Params: { url: string; };
Querystring: { url?: string; }; Querystring: { url?: string; };
@ -466,6 +477,61 @@ export class FileServerService {
} }
} }
@bindThis
private async videoThumbnailHandler(request: FastifyRequest<{ Querystring: { url: string; }; }>, reply: FastifyReply) {
const cacheKey = crypto.createHash('md5').update(request.query.url).digest('base64url');
const cacheFile = `videoThumbnail-${cacheKey}.webp`;
if (this.internalStorageService.existsCache(cacheFile)) {
reply.header('Content-Type', 'image/webp');
reply.header('Cache-Control', 'max-age=31536000, immutable');
return reply.sendFile(cacheFile, cacheDir);
}
const file = await this.getStreamAndTypeFromUrl(request.query.url);
if (file === '404') {
reply.code(404);
reply.header('Cache-Control', 'max-age=86400');
return reply.sendFile('/dummy.png', assets);
}
if (file === '204') {
reply.code(204);
reply.header('Cache-Control', 'max-age=86400');
return;
}
if (file.file?.thumbnailUrl) {
return await reply.redirect(301, file.file.thumbnailUrl);
}
if (!file.mime.startsWith('video/')) {
if ('cleanup' in file) {
file.cleanup();
}
reply.code(400);
return;
}
try {
const image = await this.videoProcessingService.generateVideoThumbnail(file.path);
if ('cleanup' in file) {
file.cleanup();
}
this.internalStorageService.saveCacheFromBuffer(cacheFile, image.data);
reply.header('Content-Type', image.type);
reply.header('Cache-Control', 'max-age=31536000, immutable');
return image.data;
} catch (e) {
if ('cleanup' in file) file.cleanup();
throw e;
}
}
@bindThis @bindThis
private async getStreamAndTypeFromUrl(url: string): Promise< private async getStreamAndTypeFromUrl(url: string): Promise<
{ state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: MiDriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: MiDriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }