diff --git a/deno.json b/deno.json index 9e8ea49..1ce5480 100644 --- a/deno.json +++ b/deno.json @@ -20,6 +20,7 @@ "id": "docker exec -ti autorender-server deno task id", "board": "docker exec -ti autorender-server deno task board", "processing": "docker exec -ti autorender-server deno task processing", + "optimize": "docker exec -ti autorender-server deno task optimize", "migrate": "docker exec -ti autorender-server deno task migrate", "build": "docker compose build", "up": "docker compose up", diff --git a/src/server/app/App.tsx b/src/server/app/App.tsx index d6a2bae..5ff17e6 100644 --- a/src/server/app/App.tsx +++ b/src/server/app/App.tsx @@ -31,11 +31,13 @@ const metaNames: (keyof RouteMeta)[] = [ const getCSP = (nonce: string) => { return [ `default-src 'self';`, - `script-src 'nonce-${nonce}' cdnjs.cloudflare.com;`, - `style-src 'nonce-${nonce}' cdnjs.cloudflare.com https://fonts.googleapis.com;`, + `script-src 'nonce-${nonce}' 'strict-dynamic' 'unsafe-inline' https:;`, + `style-src 'nonce-${nonce}';`, `font-src 'self' https://fonts.gstatic.com;`, `media-src 'self' blob: *.backblazeb2.com;`, `img-src 'self' data: cdn.discordapp.com *.backblazeb2.com;`, + `object-src 'none';`, + `base-uri 'none';`, ].join(' '); }; @@ -70,21 +72,25 @@ export const Head = ({ initialState }: HeadProps) => { return ; })} {title} + `; - const moduleScriptTag = ``; + const moduleScriptTag = + ``; - const hotReloadScriptTag = Deno.env.get('HOT_RELOAD')!.toLowerCase() === 'true' + const hotReloadScriptTag = isHotReloadEnabled ? `` : ''; diff --git a/src/server/app/views/NotFound.tsx b/src/server/app/views/NotFound.tsx index a9ed061..75ccf31 100644 --- a/src/server/app/views/NotFound.tsx +++ b/src/server/app/views/NotFound.tsx @@ -50,7 +50,7 @@ export const NotFound = () => {
404
diff --git a/src/server/app/views/Video.tsx b/src/server/app/views/Video.tsx index 037be2a..6d97455 100644 --- a/src/server/app/views/Video.tsx +++ b/src/server/app/views/Video.tsx @@ -545,8 +545,10 @@ export const VideoView = () => { - - + {hasVideo && } + {data.pending === PendingStatus.FinishedRender && isAllowedToRerender && ( + + )} ); }; diff --git a/src/server/deno.json b/src/server/deno.json index 6fb7623..d8e82a3 100644 --- a/src/server/deno.json +++ b/src/server/deno.json @@ -10,6 +10,7 @@ "board": "deno run --import-map=../import_map.json --no-prompt --allow-env --allow-read=.env,.env.defaults,.env.example,/logs/server/board_info.log,/logs/server/board_error.log,/storage --allow-write=/logs/server/board_info.log,/logs/server/board_error.log,/storage --allow-net=autorender.database:3307,board.portal2.sr,mel.board.portal2.sr tasks/board.ts", "dev:board": "deno run --import-map=../import_map.json --no-prompt --allow-env --allow-read=.env,.env.defaults,.env.example,/logs/server/board_info.log,/logs/server/board_error.log,/storage --allow-write=/logs/server/board_info.log,/logs/server/board_error.log,/storage --allow-net=autorender.database:3307,board.portal2.sr,mel.board.portal2.sr --watch tasks/board.ts", "processing": "deno run --import-map=../import_map.json --no-prompt --allow-env --allow-read=.env,.env.defaults,.env.example,/logs/server/processing_info.log,/logs/server/processing_error.log,/storage --allow-write=/logs/server/processing_info.log,/logs/server/processing_error.log,/storage --allow-net=autorender.database:3307 --allow-run=ffprobe,ffmpeg tasks/processing.ts", + "optimize": "deno run --import-map=../import_map.json --no-prompt --allow-env --allow-read=.env,.env.defaults,.env.example,/logs/server/processing_info.log,/logs/server/processing_error.log,/storage --allow-write=/logs/server/processing_info.log,/logs/server/processing_error.log,/storage --allow-net=autorender.database:3307 --allow-run=ffprobe,ffmpeg tasks/optimize_images.ts", "dev:processing": "deno run --import-map=../import_map.json --no-prompt --allow-env --allow-read=.env,.env.defaults,.env.example,/logs/server/processing_info.log,/logs/server/processing_error.log,/storage --allow-write=/logs/server/processing_info.log,/logs/server/processing_error.log,/storage --allow-net=autorender.database:3307 --allow-run=ffprobe,ffmpeg --watch tasks/processing.ts", "id": "deno run --import-map=../import_map.json --no-prompt --allow-env --allow-read=.env,.env.defaults,.env.example,/storage --allow-net=autorender.database:3307 tasks/id.ts" }, diff --git a/src/server/main.ts b/src/server/main.ts index 2df9611..33e7f4b 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -1417,18 +1417,18 @@ apiV1 ], ); - type Metdata = { + type Metadata = { segments: DemoMetadata['segments']; }; mtriggers.forEach((mtrigger) => { try { const metadata = JSON.parse(mtrigger.demo_metadata) as DemoMetadata; - (mtrigger.demo_metadata as unknown as Metdata) = { + (mtrigger.demo_metadata as unknown as Metadata) = { segments: metadata.segments, }; } catch { - (mtrigger.demo_metadata as unknown as Metdata) = { + (mtrigger.demo_metadata as unknown as Metadata) = { segments: [], }; } @@ -2329,7 +2329,7 @@ router.get('/storage/previews/:share_id', async (ctx) => { `filename="${encodeURIComponent(basename(path))}"`, ); - ctx.response.headers.set('Cache-Control', 'public, max-age=300'); + ctx.response.headers.set('Cache-Control', 'public, max-age=31536000'); Ok(ctx, preview, 'image/webp'); } catch (err) { @@ -2357,7 +2357,7 @@ router.get('/storage/thumbnails/:share_id/:small(small)?', async (ctx) => { `filename="${encodeURIComponent(basename(path))}"`, ); - ctx.response.headers.set('Cache-Control', 'public, max-age=300'); + ctx.response.headers.set('Cache-Control', 'max-age=31536000'); Ok(ctx, preview, 'image/webp'); } catch (err) { @@ -2374,6 +2374,9 @@ router.get('/security.txt', async (ctx) => { router.get('/.well-known/security.txt', async (ctx) => { Ok(ctx, await Deno.readFile(getStorageFilePath('security.txt')), 'text/plain'); }); +router.get('/robots.txt', (ctx) => { + Ok(ctx, 'user-agent: *\ndisallow: /api/\ndisallow: /connect/\n', 'text/plain'); +}); router.get('/storage/files/autorender.cfg', async (ctx) => { Ok(ctx, await Deno.readFile(getStorageFilePath('autorender.cfg')), 'text/plain'); }); @@ -2388,7 +2391,7 @@ const routeToImages = async (ctx: Context, file: string, contentType: string) => try { const image = await Deno.readFile(`./app/assets/images/${file}`); - ctx.response.headers.set('Cache-Control', 'public, max-age=300'); + ctx.response.headers.set('Cache-Control', 'max-age=31536000'); Ok(ctx, image, contentType); } catch (err) { @@ -2405,9 +2408,16 @@ router.get( '/assets/images/:file([\\w]+\\.jpg)', async (ctx) => await routeToImages(ctx, ctx.params.file!, 'image/jpeg'), ); +router.get( + '/assets/images/:file([\\w]+\\.webp)', + async (ctx) => await routeToImages(ctx, ctx.params.file!, 'image/webp'), +); router.get('/assets/js/:file([\\w]+\\.js)', async (ctx) => { try { const js = await Deno.readFile(`./app/assets/js/${ctx.params.file}`); + + !isHotReloadEnabled && ctx.response.headers.set('Cache-Control', 'max-age=31536000'); + Ok(ctx, js, 'text/javascript'); } catch (err) { logger.error(err); diff --git a/src/server/tasks/optimize_images.ts b/src/server/tasks/optimize_images.ts new file mode 100644 index 0000000..c1cfacf --- /dev/null +++ b/src/server/tasks/optimize_images.ts @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2023-2024, NeKz + * + * SPDX-License-Identifier: MIT + */ + +import 'dotenv/load.ts'; +import { db } from '../db.ts'; +import { Video } from '~/shared/models.ts'; +import { installLogger, logger } from '../logger.ts'; +import { getVideoFilePath, getVideoThumbnailPath, getVideoThumbnailSmallPath } from '../utils.ts'; + +const FFMPEG_PROCESS_TIMEOUT = 5 * 60 * 1_000; +const AUTORENDER_PUBLIC_URI = Deno.env.get('AUTORENDER_PUBLIC_URI')!; + +installLogger('processing'); + +addEventListener('unhandledrejection', (ev) => { + ev.preventDefault(); + logger.error('unhandledrejection', { reason: ev.reason }); +}); + +type VideoSelect = Pick; + +const decoder = new TextDecoder(); + +const getVideoLength = async (video: VideoSelect) => { + try { + const args = [ + '-i', + `${getVideoFilePath(video.video_id)}`, + '-show_entries', + 'format=duration', + '-v', + 'quiet', + '-of', + 'default=noprint_wrappers=1', + ]; + + logger.info('ffprobe', args.join(' ')); + + const command = new Deno.Command('ffprobe', { args, stdout: 'piped', stderr: 'piped' }); + const proc = command.spawn(); + const procTimeout = setTimeout(() => { + try { + proc.kill(); + } catch (err) { + logger.error(err); + } + }, FFMPEG_PROCESS_TIMEOUT); + + const output = await proc.output(); + clearTimeout(procTimeout); + + const result = decoder.decode(output.stdout) ?? ''; + + if (!output.success) { + const error = decoder.decode(output.stderr) ?? ''; + logger.error(`Video length command error: ${output.code}\nResult:${result}\nError:${error}`); + return null; + } + + const videoLength = parseFloat(result.split('=').at(1) ?? ''); + return isNaN(videoLength) ? null : videoLength; + } catch (err) { + logger.error(err); + return null; + } +}; + +const getThumbnailUrl = async (video: VideoSelect, options: { videoLength: number; small?: boolean }) => { + try { + const args = [ + '-ss', + Math.floor(options.videoLength / 2).toString(), + '-i', + getVideoFilePath(video.video_id), + '-vcodec', + 'libwebp', + '-lossless', + '0', + ...(options.small + ? [ + '-quality', + '90', + '-s', + '360x202', + ] + : []), + '-vframes', + '1', + '-y', + (options.small ? getVideoThumbnailSmallPath : getVideoThumbnailPath)(video), + ]; + + logger.info('ffmpeg', args.join(' ')); + + const command = new Deno.Command('ffmpeg', { args, stdout: 'piped', stderr: 'piped' }); + const proc = command.spawn(); + const procTimeout = setTimeout(() => { + try { + proc.kill(); + } catch (err) { + logger.error(err); + } + }, FFMPEG_PROCESS_TIMEOUT); + + const output = await proc.output(); + clearTimeout(procTimeout); + + const result = decoder.decode(output.stdout) ?? ''; + + if (!output.success) { + const error = decoder.decode(output.stderr) ?? ''; + logger.error(`Video thumbnail command error: ${output.code}\nResult:${result}\nError:${error}`); + return null; + } + + return `${AUTORENDER_PUBLIC_URI}/storage/thumbnails/${video.share_id}${options.small ? '/small' : ''}`; + } catch (err) { + logger.error(err); + return null; + } +}; + +const processVideos = async () => { + const videos = await db.query( + `select BIN_TO_UUID(video_id) as video_id + , share_id + , created_at + , video_external_id + from videos + where processed = 1 + and video_length > 0 + and video_url is not null`, + ); + + if (videos.length) { + logger.info(`Processing ${videos.length} new videos`); + } + + for (const video of videos) { + logger.info('Video', { video }); + + const videoLength = await getVideoLength(video); + const thumbnailSmallUrl = videoLength ? await getThumbnailUrl(video, { videoLength, small: true }) : null; + + logger.info('Updating video', video.video_id, { + videoLength, + thumbnailSmallUrl, + }); + + await db.execute( + `update videos + set processed = 1 + , thumbnail_url_small = ? + where video_id = UUID_TO_BIN(?)`, + [ + thumbnailSmallUrl, + video.video_id, + ], + ); + } +}; + +try { + await processVideos(); +} catch (err) { + logger.error(err); +} diff --git a/src/server/tasks/processing.ts b/src/server/tasks/processing.ts index f5b6c30..784bf6f 100644 --- a/src/server/tasks/processing.ts +++ b/src/server/tasks/processing.ts @@ -142,6 +142,8 @@ const getThumbnailUrl = async (video: VideoSelect, options: { videoLength: numbe '0', ...(options.small ? [ + '-quality', + '90', '-s', '360x202', ]