mirror of
https://git.joinsharkey.org/Sharkey/Sharkey.git
synced 2024-11-25 17:43:09 +02:00
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:
commit
4ade52ce71
8 changed files with 120 additions and 3 deletions
|
@ -255,6 +255,11 @@ proxyRemoteFiles: true
|
|||
# https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4
|
||||
#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)
|
||||
signToActivityPubGet: true
|
||||
# check that inbound ActivityPub GET requests are signed ("authorized fetch")
|
||||
|
|
|
@ -270,6 +270,11 @@ proxyRemoteFiles: true
|
|||
# https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4
|
||||
#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)
|
||||
signToActivityPubGet: true
|
||||
# check that inbound ActivityPub GET requests are signed ("authorized fetch")
|
||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -55,6 +55,7 @@ api-docs.json
|
|||
*.code-workspace
|
||||
.DS_Store
|
||||
/files
|
||||
/cache
|
||||
ormconfig.json
|
||||
temp
|
||||
/packages/frontend/src/**/*.stories.ts
|
||||
|
|
|
@ -21,6 +21,7 @@ services:
|
|||
- shonk
|
||||
volumes:
|
||||
- ./files:/sharkey/files
|
||||
- ./cache:/sharkey/cache
|
||||
- ./.config:/sharkey/.config:ro
|
||||
|
||||
redis:
|
||||
|
|
|
@ -88,6 +88,7 @@ type Source = {
|
|||
mediaProxy?: string;
|
||||
proxyRemoteFiles?: boolean;
|
||||
videoThumbnailGenerator?: string;
|
||||
enableBuiltinVideoThumbnailGenerator?: boolean;
|
||||
|
||||
customMOTD?: string[];
|
||||
|
||||
|
@ -170,6 +171,7 @@ export type Config = {
|
|||
mediaProxy: string;
|
||||
externalMediaProxyEnabled: boolean;
|
||||
videoThumbnailGenerator: string | null;
|
||||
enableBuiltinVideoThumbnailGenerator: boolean;
|
||||
redis: RedisOptions & RedisOptionsSource;
|
||||
redisForPubsub: RedisOptions & RedisOptionsSource;
|
||||
redisForJobQueue: RedisOptions & RedisOptionsSource;
|
||||
|
@ -276,6 +278,7 @@ export function loadConfig(): Config {
|
|||
videoThumbnailGenerator: config.videoThumbnailGenerator ?
|
||||
config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator
|
||||
: null,
|
||||
enableBuiltinVideoThumbnailGenerator: config.enableBuiltinVideoThumbnailGenerator ?? false,
|
||||
userAgent: `Misskey/${version} (${config.url})`,
|
||||
clientEntry: clientManifest['src/_boot_.ts'],
|
||||
clientManifestExists: clientManifestExists,
|
||||
|
|
|
@ -16,6 +16,7 @@ const _filename = fileURLToPath(import.meta.url);
|
|||
const _dirname = dirname(_filename);
|
||||
|
||||
const path = Path.resolve(_dirname, '../../../../files');
|
||||
const cachePath = Path.resolve(_dirname, '../../../../cache');
|
||||
|
||||
@Injectable()
|
||||
export class InternalStorageService {
|
||||
|
@ -30,11 +31,26 @@ export class InternalStorageService {
|
|||
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
|
||||
public read(key: string) {
|
||||
return fs.createReadStream(this.resolvePath(key));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public readCache(key: string) {
|
||||
return fs.createReadStream(this.resolveCachePath(key));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public saveFromPath(key: string, srcPath: string) {
|
||||
fs.mkdirSync(path, { recursive: true });
|
||||
|
@ -49,8 +65,19 @@ export class InternalStorageService {
|
|||
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
|
||||
public del(key: string) {
|
||||
fs.unlink(this.resolvePath(key), () => {});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public delCache(key: string) {
|
||||
fs.unlink(this.resolveCachePath(key), () => {});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,7 +50,16 @@ export class VideoProcessingService {
|
|||
|
||||
@bindThis
|
||||
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(
|
||||
`${this.config.videoThumbnailGenerator}/thumbnail.webp`,
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
@ -34,6 +35,7 @@ const _filename = fileURLToPath(import.meta.url);
|
|||
const _dirname = dirname(_filename);
|
||||
|
||||
const assets = `${_dirname}/../../server/file/assets/`;
|
||||
const cacheDir = `${_dirname}/../../../../cache/`;
|
||||
|
||||
@Injectable()
|
||||
export class FileServerService {
|
||||
|
@ -85,6 +87,15 @@ export class FileServerService {
|
|||
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<{
|
||||
Params: { 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
|
||||
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; }
|
||||
|
|
Loading…
Reference in a new issue