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`;
+---
+
+
+
+
+
+
+
+ {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 });
+}