diff --git a/.env.example b/.env.example index 1b97c18..db0f0bb 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,8 @@ ADMIN_USER=admin ADMIN_PASSWORD=admin ADMIN_COOKIE=muelleel -UPLOAD_PATH=/tmp +UPLOAD_PATH=/tmp/uploads +VIDEO_PATH=/tmp/videos TEAMSPEAK_LINK=http://example.com DISCORD_LINK=http://example.com diff --git a/astro.config.mjs b/astro.config.mjs index 91e7bf3..3a0b847 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -36,6 +36,8 @@ export default defineConfig({ ADMIN_COOKIE: envField.string({ context: 'server', access: 'secret', default: 'muelleel' }), UPLOAD_PATH: envField.string({ context: 'server', access: 'secret', optional: true }), + VIDEO_PATH: envField.string({ context: 'server', access: 'secret', optional: true }), + MAX_UPLOAD_BYTES: envField.number({ context: 'server', access: 'secret', default: 20 * 1024 * 1024 }), START_DATE: envField.string({ context: 'server', access: 'secret', default: '1970-01-01' }), diff --git a/flake.nix b/flake.nix index 7fbd410..cb69c95 100644 --- a/flake.nix +++ b/flake.nix @@ -42,6 +42,7 @@ ADMIN_COOKIE = "muelleel"; UPLOAD_PATH = "./.data/uploads"; + VIDEO_PATH = "./.data/videos"; TEAMSPEAK_LINK = "http://example.com"; DISCORD_LINK = "http://example.com"; @@ -71,7 +72,7 @@ process-compose.dev = { imports = [ inputs.services-flake.processComposeModules.default ]; - cli.preHook = "mkdir -p .data/uploads .data/logs"; + cli.preHook = "mkdir -p .data/uploads .data/videos .data/logs"; defaults.processSettings = { name, ... }: { availability.restart = lib.mkDefault "on_failure"; diff --git a/package-lock.json b/package-lock.json index a2301f9..f8e8502 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,10 +14,12 @@ "@iconify-json/heroicons": "^1.2.3", "@iconify/tailwind4": "^1.2.3", "@tailwindcss/vite": "^4.3.3", + "@videojs/html": "^10.2.0", "astro": "^7.4.4", "bcrypt": "^6.0.0", "daisyui": "^5.7.50", "drizzle-orm": "^0.45.4", + "music-metadata": "^12.3.2", "mysql2": "^3.24.6", "nanostores": "^1.5.5", "pino": "^10.4.0", @@ -425,6 +427,16 @@ "node": ">=6.9.0" } }, + "node_modules/@borewit/text-codec": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.3.tgz", + "integrity": "sha512-49CycTz1aAZmvFcwkeFyhud1HUmRIB5Tabf7ALV+VA4Iu85MTfz2cUwzPagMFAdVBvrWdkCxG67I30tUwwpKsw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@bruits/satteri-darwin-arm64": { "version": "0.10.5", "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz", @@ -1525,6 +1537,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz", + "integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@lit/context": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.6.tgz", + "integrity": "sha512-M26qDE6UkQbZA2mQ3RjJ3Gzd8TxP+/0obMgE5HfkfLhEEyYE3Bui4A5XHiGPjy0MUGAyxB3QgVuw2ciS0kHn6A==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^1.6.2 || ^2.1.0" + } + }, + "node_modules/@lit/reactive-element": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz", + "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.6.tgz", @@ -2764,6 +2800,30 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tokenizer/inflate": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.6.0.tgz", + "integrity": "sha512-+0RDxhBkyT9xMnGdJAwmats9yxRLR8ypJrQgrnLqOu4Gv/aHlnxYgdGVy2QTwvBl6IF/w2Hr+ZNirH+iMUbVZA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "strtok3": "^10.6.2", + "token-types": "^6.1.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", @@ -3115,6 +3175,165 @@ "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", "license": "ISC" }, + "node_modules/@videojs/core": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@videojs/core/-/core-10.2.0.tgz", + "integrity": "sha512-xYF4aWKq4Us0SzxwAZmpbeONgTSogH3gF0yfDlZEusaPrpx1rsXwAELas1PjcwzUC+jwMAQ54C6q3RAZ/xbHwA==", + "license": "Apache-2.0", + "dependencies": { + "@videojs/media": "10.2.0", + "@videojs/store": "10.2.0", + "@videojs/utils": "10.2.0", + "video.js": "10.2.0" + } + }, + "node_modules/@videojs/element": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@videojs/element/-/element-10.2.0.tgz", + "integrity": "sha512-eGsRhK7nxUkPbjrXGHZDlNJsVTovJCzg6w1+B3aOOWOpfxvLSEUwR8KJ+VPtzqAcbS3lZ5LQjxFyomv1dirh+Q==", + "license": "Apache-2.0", + "dependencies": { + "@lit/context": "^1.1.0" + } + }, + "node_modules/@videojs/html": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@videojs/html/-/html-10.2.0.tgz", + "integrity": "sha512-Bas9JqHlbiLx9oLhLvCWJr+UoUQ9Xb/Z156O7d/DQ8ujxuV8I7f6vWgkcLOjUjp78rBZjPIy/O+wMPBNbIJpSw==", + "license": "Apache-2.0", + "dependencies": { + "@videojs/core": "10.2.0", + "@videojs/element": "10.2.0", + "@videojs/media": "10.2.0", + "@videojs/native-hls-video": "10.2.0", + "@videojs/spf": "10.2.0", + "@videojs/store": "10.2.0", + "@videojs/utils": "10.2.0" + }, + "peerDependencies": { + "@videojs/cloudflare-video": "10.2.0", + "@videojs/dash-video": "10.2.0", + "@videojs/google-cast": "10.2.0", + "@videojs/hlsjs-video": "10.2.0", + "@videojs/mux-audio": "10.2.0", + "@videojs/mux-data": "10.2.0", + "@videojs/mux-video": "10.2.0", + "@videojs/playerjs-video": "10.2.0", + "@videojs/shaka-video": "10.2.0", + "@videojs/spotify-audio": "10.2.0", + "@videojs/tiktok-video": "10.2.0", + "@videojs/twitch-video": "10.2.0", + "@videojs/vimeo-video": "10.2.0", + "@videojs/wistia-video": "10.2.0", + "@videojs/youtube-video": "10.2.0" + }, + "peerDependenciesMeta": { + "@videojs/cloudflare-video": { + "optional": true + }, + "@videojs/dash-video": { + "optional": true + }, + "@videojs/google-cast": { + "optional": true + }, + "@videojs/hlsjs-video": { + "optional": true + }, + "@videojs/mux-audio": { + "optional": true + }, + "@videojs/mux-data": { + "optional": true + }, + "@videojs/mux-video": { + "optional": true + }, + "@videojs/playerjs-video": { + "optional": true + }, + "@videojs/shaka-video": { + "optional": true + }, + "@videojs/spotify-audio": { + "optional": true + }, + "@videojs/tiktok-video": { + "optional": true + }, + "@videojs/twitch-video": { + "optional": true + }, + "@videojs/vimeo-video": { + "optional": true + }, + "@videojs/wistia-video": { + "optional": true + }, + "@videojs/youtube-video": { + "optional": true + } + } + }, + "node_modules/@videojs/media": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@videojs/media/-/media-10.2.0.tgz", + "integrity": "sha512-bdlq1nav3xeuWsmxN1eWEdJcbS1QxHoMZq2+4DJ19yWAGSheXDt52xDNTgNhoKY0FoJFTdNEsv/4qxR/eW+IjA==", + "license": "Apache-2.0", + "dependencies": { + "@videojs/utils": "10.2.0" + } + }, + "node_modules/@videojs/native-hls-video": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@videojs/native-hls-video/-/native-hls-video-10.2.0.tgz", + "integrity": "sha512-+0ISYTxbPtXc530CvTqCkoKrOPzxpAqvdgw79cRTOAG/7sR4xgQqt341O+P0HesH5kYeMwkLfWcSM964FAcMTw==", + "license": "Apache-2.0", + "dependencies": { + "@videojs/media": "10.2.0", + "@videojs/spf": "10.2.0", + "@videojs/utils": "10.2.0" + } + }, + "node_modules/@videojs/spf": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@videojs/spf/-/spf-10.2.0.tgz", + "integrity": "sha512-ovoHDqR5td0nfeZeCu2GWbdb3SVqP9udEAZr/4/VNUKYKstOg21rlGYS/We/our17kkxcp6Dvg7H98xLzn83pQ==", + "license": "Apache-2.0", + "dependencies": { + "@videojs/media": "10.2.0", + "@videojs/utils": "10.2.0", + "signal-polyfill": "^0.2.2" + } + }, + "node_modules/@videojs/store": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@videojs/store/-/store-10.2.0.tgz", + "integrity": "sha512-rKV2tCigF1ifVFk1n+svGMpWLdEsBl3LHYbD9hrHQZtSI2SAwgwvD5W5vA0hcc0W3f9cblsrS0zgT/JxQlKfrw==", + "license": "Apache-2.0", + "dependencies": { + "@videojs/utils": "10.2.0", + "react-compiler-runtime": "^1.0.0" + }, + "peerDependencies": { + "@videojs/element": "10.2.0", + "react": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@videojs/element": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@videojs/utils": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@videojs/utils/-/utils-10.2.0.tgz", + "integrity": "sha512-+uV+rg5Dtq0OpPT3OQsybA+grWyDaluFIQ6f5X8Jqb98gy0momQBo7yPA92TlVL7eiUrPpYb4eX0xeTXLKL73Q==", + "license": "Apache-2.0" + }, "node_modules/acorn": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", @@ -3530,6 +3749,19 @@ "node": ">= 18" } }, + "node_modules/content-type": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-3.1.1.tgz", + "integrity": "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cookie": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", @@ -4409,6 +4641,24 @@ "flat-cache": "^6.1.23" } }, + "node_modules/file-type": { + "version": "22.3.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-22.3.0.tgz", + "integrity": "sha512-PrPedgnnWl5bWz3JHgar0QSzRvzeyFh7dZ9JpYOgcPZu4KALS9zw4MMa6qXRYyjeBsAlpaWB2boLuG991+0HIQ==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.6.0", + "strtok3": "^10.6.3", + "token-types": "^6.1.2", + "uint8array-extras": "^1.6.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4719,6 +4969,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5270,6 +5540,19 @@ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "license": "CC0-1.0" }, + "node_modules/media-typer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-2.0.0.tgz", + "integrity": "sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/meshoptimizer": { "version": "0.18.1", "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", @@ -5457,6 +5740,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/music-metadata": { + "version": "12.3.2", + "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-12.3.2.tgz", + "integrity": "sha512-U+Wu8c/7xa8wP331oVuRTAJSHcEWQIkLjpuyRJG+CAIszGYhpr5CRDv6PqkkVHDoX3ndjmdJlB6S49AaLq8SbQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.2", + "@tokenizer/token": "^0.3.0", + "content-type": "^3.1.1", + "debug": "^4.4.3", + "file-type": "^22.2.0", + "media-typer": "^2.0.0", + "strtok3": "^10.6.2", + "token-types": "^6.1.2", + "uint8array-extras": "^1.6.0", + "win-guid": "^0.2.1" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/mysql2": { "version": "3.24.6", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.6.tgz", @@ -6163,6 +6477,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-compiler-runtime": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/react-compiler-runtime/-/react-compiler-runtime-1.0.0.tgz", + "integrity": "sha512-rRfjYv66HlG8896yPUDONgKzG5BxZD1nV9U6rkm+7VCuvQc903C4MjcoZR4zPw53IKSOX9wMQVpA1IAbRtzQ7w==", + "license": "MIT", + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental" + } + }, "node_modules/readdirp": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", @@ -6838,6 +7171,12 @@ "node": ">=20" } }, + "node_modules/signal-polyfill": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/signal-polyfill/-/signal-polyfill-0.2.2.tgz", + "integrity": "sha512-p63Y4Er5/eMQ9RHg0M0Y64NlsQKpiu6MDdhBXpyywRuWiPywhJTpKJ1iB5K2hJEbFZ0BnDS7ZkJ+0AfTuL37Rg==", + "license": "Apache-2.0" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -6954,6 +7293,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strtok3": { + "version": "10.6.3", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.6.3.tgz", + "integrity": "sha512-MD8mENgyXMr2mDaDfzP3RWYDSyYQxIIou7HhCiCJiBekrsoDtUG0id3vYnjDfY9ndOEbazFXe13Qw6u/1AB8ew==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/suf-log": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/suf-log/-/suf-log-2.5.3.tgz", @@ -7286,6 +7641,24 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -7381,6 +7754,18 @@ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", "license": "MIT" }, + "node_modules/uint8array-extras": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.6.0.tgz", + "integrity": "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ultrahtml": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.7.0.tgz", @@ -7666,6 +8051,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/video.js": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/video.js/-/video.js-10.2.0.tgz", + "integrity": "sha512-YoezDYXY6iDkuhLGnyOz96XBKjwB0cQbPNq5ck1FAf2JcqfLnnwkNQ4ofbwhlpAM55RFp6VDhSvKe5wVcFV11Q==", + "license": "Apache-2.0" + }, "node_modules/vite": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", @@ -7778,6 +8169,12 @@ "node": ">= 8" } }, + "node_modules/win-guid": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz", + "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==", + "license": "MIT" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 8a1274e..627367c 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,12 @@ "@iconify-json/heroicons": "^1.2.3", "@iconify/tailwind4": "^1.2.3", "@tailwindcss/vite": "^4.3.3", + "@videojs/html": "^10.2.0", "astro": "^7.4.4", "bcrypt": "^6.0.0", "daisyui": "^5.7.50", "drizzle-orm": "^0.45.4", + "music-metadata": "^12.3.2", "mysql2": "^3.24.6", "nanostores": "^1.5.5", "pino": "^10.4.0", diff --git a/src/app/website/video/VideoPlayer.svelte b/src/app/website/video/VideoPlayer.svelte new file mode 100644 index 0000000..3304459 --- /dev/null +++ b/src/app/website/video/VideoPlayer.svelte @@ -0,0 +1,44 @@ + + + + + + + + + + + + diff --git a/src/pages/video/[name].astro b/src/pages/video/[name].astro new file mode 100644 index 0000000..7c82767 --- /dev/null +++ b/src/pages/video/[name].astro @@ -0,0 +1,32 @@ +--- +import WebsiteLayout from '@layouts/website/WebsiteLayout.astro'; +import VideoPlayer from '@app/website/video/VideoPlayer.svelte'; +import { getVideoMetadata, resolveVideo } from '@util/video.ts'; + +const { name } = Astro.params; + +const video = name ? await resolveVideo(name) : null; +if (!video) return new Response(null, { status: 404 }); + +const metadata = await getVideoMetadata(video); +if (!metadata) return new Response(null, { status: 404 }); + +const { title, description } = metadata; +const basePath = import.meta.env.BASE_URL.replace(/\/$/, ''); +const streamUrl = `${basePath}/video/${encodeURIComponent(video.name)}/stream`; +--- + + +
+
+

Video

+

{title}

+
+ + + + {description && ( +

{description}

+ )} +
+
diff --git a/src/pages/video/[name]/stream.ts b/src/pages/video/[name]/stream.ts new file mode 100644 index 0000000..aa4af1a --- /dev/null +++ b/src/pages/video/[name]/stream.ts @@ -0,0 +1,12 @@ +import type { APIContext, APIRoute } from 'astro'; +import { createVideoResponse, resolveVideo } from '@util/video.ts'; + +async function handle(context: APIContext, headersOnly = false) { + const video = await resolveVideo(context.params.name ?? ''); + if (!video) return new Response(null, { status: 404 }); + + return createVideoResponse(video, context.request, headersOnly); +} + +export const HEAD: APIRoute = (context) => handle(context, true); +export const GET: APIRoute = (context) => handle(context); diff --git a/src/util/video.ts b/src/util/video.ts new file mode 100644 index 0000000..18ee076 --- /dev/null +++ b/src/util/video.ts @@ -0,0 +1,185 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { parseFile } from 'music-metadata'; +import { VIDEO_PATH } from 'astro:env/server'; +import { logger } from './log'; + +const videoTypes = [ + ['mp4', 'video/mp4'], + ['webm', 'video/webm'] +]; + +export interface ResolvedVideo { + name: string; + filename: string; + path: string; + contentType: string; + size: number; + modifiedAt: Date; +} + +export interface VideoRange { + start: number; + end: number; +} + +export interface VideoMetadata { + title: string; + description: string | null; +} + +interface VideoMetadataCacheEntry { + modifiedAtMs: number; + size: number; + title: string; + description: string | null; +} + +const videoMetadataCache = new Map(); + +export async function resolveVideo(name: string): Promise { + if (!VIDEO_PATH || !name) return null; + + const directory = path.resolve(VIDEO_PATH); + const safeName = path.basename(name); + + if (!safeName) return null; + + for (const [extension, contentType] of videoTypes) { + const filename = `${safeName}.${extension}`; + const filePath = path.join(directory, filename); + + try { + const stats = await fs.promises.stat(filePath); + if (!stats.isFile()) continue; + + return { + name: safeName, + filename, + path: filePath, + contentType, + size: stats.size, + modifiedAt: stats.mtime + }; + } catch (e: NodeJS.ErrnoException) { + // check if file doesn't exist + if (e.code === 'ENOENT') { + continue; + } + logger.warn( + { + error: e.message + }, + 'error while reading video file' + ); + } + } + + return null; +} + +export async function getVideoMetadata(video: ResolvedVideo): Promise { + const modifiedAtMs = video.modifiedAt.getTime(); + const cached = videoMetadataCache.get(video.path); + + if (cached && cached.modifiedAtMs === modifiedAtMs && cached.size === video.size) { + return cached; + } + + let metadata; + try { + const parsed = await parseFile(video.path, { + duration: false, + skipCovers: true + }); + + metadata = { + title: parsed.common.title?.trim() || video.name, + description: + parsed.common.longDescription?.trim() || + parsed.common.description?.map((value) => value.trim()).find(Boolean) || + null + }; + } catch { + // couldn't read metadata + return null; + } + + videoMetadataCache.set(video.path, { + modifiedAtMs, + size: video.size, + ...metadata + }); + + return metadata; +} + +export function parseVideoRange(rangeHeader: string | null, size: number): VideoRange | 'invalid' | null { + if (!rangeHeader) return null; + if (size <= 0) return 'invalid'; + + const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()); + if (!match) return 'invalid'; + + const [, startValue, endValue] = match; + if (!startValue && !endValue) return 'invalid'; + + let start: number; + let end: number; + + if (!startValue) { + const suffixLength = Number(endValue); + if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return 'invalid'; + + start = Math.max(size - suffixLength, 0); + end = size - 1; + } else { + start = Number(startValue); + if (!Number.isSafeInteger(start) || start < 0 || start >= size) return 'invalid'; + + end = endValue ? Number(endValue) : size - 1; + if (!Number.isSafeInteger(end) || end < start) return 'invalid'; + + end = Math.min(end, size - 1); + } + + return { start, end }; +} + +export function createVideoResponse(video: ResolvedVideo, request: Request, headersOnly = false): Response { + const range = parseVideoRange(request.headers.get('range'), video.size); + + if (range === 'invalid') { + return new Response(null, { + status: 416, + headers: { + 'Accept-Ranges': 'bytes', + 'Content-Range': `bytes */${video.size}` + } + }); + } + + const start = range?.start ?? 0; + const end = range?.end ?? video.size - 1; + const contentLength = video.size === 0 ? 0 : end - start + 1; + + const headers: Record = { + 'Accept-Ranges': 'bytes', + 'Content-Length': contentLength.toString(), + 'Content-Type': video.contentType, + 'Cache-Control': 'public, max-age=3600', + 'Last-Modified': video.modifiedAt.toUTCString() + }; + + if (range) { + headers['Content-Range'] = `bytes ${start}-${end}/${video.size}`; + } + + if (headersOnly) { + return new Response(null, { status: range ? 206 : 200, headers }); + } + + const stream = fs.createReadStream(video.path, video.size === 0 ? undefined : { start, end }); + + return new Response(stream as any, { status: range ? 206 : 200, headers }); +}