From ab1bf5ce93579914dc22401b12618708cf95151f Mon Sep 17 00:00:00 2001 From: Borewit Date: Sat, 19 Jul 2025 12:55:53 +0200 Subject: [PATCH] Replace http-server with fastify Communicate directory listings in JSON. Switch module from CommonJs to ECMAScript, consistent with front-end code. --- package.json | 7 ++- public/config.yaml.example | 3 ++ server/server.js | 105 +++++++++++++++++++++++++++++++++++++ src/file-explorer.js | 61 +++------------------ webpack.config.js | 100 ++++++++++++++++++----------------- webpack.dev.js | 13 +++-- 6 files changed, 182 insertions(+), 107 deletions(-) create mode 100644 server/server.js diff --git a/package.json b/package.json index 0a7c457..a934fc3 100644 --- a/package.json +++ b/package.json @@ -5,17 +5,20 @@ "author": "Henrique Vianna (https://henriquevianna.com)", "license": "AGPL-3.0", "description": "Media player and real-time audio spectrum analyzer", + "type": "module", "scripts": { "build": "webpack", - "start": "http-server", + "server": "node server/server.js", + "start": "node run server", "dev": "webpack serve --config webpack.dev.js" }, "devDependencies": { + "@fastify/static": "^8.2.0", "audiomotion-analyzer": "^4.5.1", "buffer": "^6.0.3", "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^7.0.0", - "http-server": "^14.1.1", + "fastify": "^5.4.0", "idb-keyval": "^6.2.1", "js-yaml": "^4.1.0", "mini-css-extract-plugin": "^2.9.0", diff --git a/public/config.yaml.example b/public/config.yaml.example index ae51294..f85c141 100644 --- a/public/config.yaml.example +++ b/public/config.yaml.example @@ -9,3 +9,6 @@ enableLocalAccess: true # Initial state of the Media Panel (“close” expands the analyzer area) # open | close frontPanel: open + +# Location to shared media folder +mediaFolder: public\music diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..2dc8f35 --- /dev/null +++ b/server/server.js @@ -0,0 +1,105 @@ +// server/index.js +import Fastify from 'fastify'; +import fastifyStatic from '@fastify/static'; +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// ESM __dirname workaround +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Paths +const rootDir = path.resolve(__dirname, '..'); +const publicDir = path.join(rootDir, 'public'); +const configPath = path.join(publicDir, 'config.yaml'); + +const app = Fastify({ logger: true }); + +const fileExtensionFilter = { + covers: /\.(jpg|jpeg|webp|avif|png|gif|bmp)$/i, + subs: /\.vtt$/i, + file: /.*/, +} + +// 1. Load config.yaml and resolve mediaFolder +let musicDir = path.join(publicDir, 'music'); // fallback default + +async function loadConfig() { + try { + const yamlRaw = await fs.readFile(configPath, 'utf8'); + const config = loadYaml(yamlRaw); + + if (config && typeof config.mediaFolder === 'string') { + // Resolve mediaFolder relative to rootDir + musicDir = path.resolve(rootDir, config.mediaFolder.replace(/^\/+/, '')); + app.log.info(`mediaFolder set to: ${musicDir}`); + } + } catch (err) { + app.log.warn(`Failed to load config.yaml: ${err.message}`); + } +} + +await loadConfig(); + +// Serve entire /public as root +app.register(fastifyStatic, { + root: publicDir, + prefix: '/', // makes index.html available at / + index: ['index.html'], +}); + +app.get('/music/*', async (request, reply) => { + try { + // Extract requested path + const rawPath = request.params['*'] || ''; + const decodedPath = decodeURIComponent(rawPath); + const safeSubPath = path.normalize(decodedPath).replace(/^(\.\.(\/|\\|$))+/, ''); + + const targetPath = path.join(musicDir, safeSubPath); + const stat = await fs.stat(targetPath); + + if (stat.isFile()) { + // Send the file (with correct headers) + return reply.sendFile(safeSubPath, musicDir); // Fastify Static will stream the file + } + + if (!stat.isDirectory()) { + return reply.code(404).send({ error: 'Not a file or directory' }); + } + + // Handle directory listing + const entries = await fs.readdir(targetPath, { withFileTypes: true }); + + const dirs = [], files = [], imgs = [], subs = []; + + for (const entry of entries) { + const name = entry.name; + if (entry.isDirectory()) { + dirs.push({ name }); + } else if (entry.isFile()) { + if (name.match(fileExtensionFilter.covers)) imgs.push({ name }); + else if (name.match(fileExtensionFilter.subs)) subs.push({ name }); + if (name.match(fileExtensionFilter.file) && !name.startsWith('.')) files.push({ name }); + } + } + + reply.type('application/json').send({ dirs, files, imgs, subs }); + + } catch (err) { + if (err.code === 'ENOENT') { + reply.code(404).send({ error: 'Not found' }); + } else { + app.log.error(err); + reply.code(500).send({ error: 'Internal server error' }); + } + } +}); + +// Start server +app.listen({ port: 8080 }, err => { + if (err) { + app.log.error(err); + process.exit(1); + } +}); diff --git a/src/file-explorer.js b/src/file-explorer.js index 0f970b6..56bc2c5 100644 --- a/src/file-explorer.js +++ b/src/file-explorer.js @@ -268,7 +268,7 @@ export async function getDirectoryContents( path, dirHandle ) { else { // Web server const response = await fetch( path ); - content = response.ok ? await response.text() : false; + content = response.ok ? await response.json() : false; } } catch( e ) { @@ -365,58 +365,7 @@ export function parseWebIndex( content ) { */ export function parseDirectory( content, path ) { - // NOTE: `path` is currently used only to generate the correct `src` for subs files when - // reading an arbitrary directory. For all other files, only the filename is included. - // - // TO-DO: add an extra `path` or `src` property to *all* entries in the returned object, - // so we don't have to deal with this everywhere else! - - const coverExtensions = /\.(jpg|jpeg|webp|avif|png|gif|bmp)$/i, - subsExtensions = /\.vtt$/i; - - let dirs = [], - files = [], - imgs = [], - subs = []; - - // helper function - const findImg = ( arr, pattern ) => { - const regexp = new RegExp( `${pattern}.*${coverExtensions.source}`, 'i' ); - return arr.find( el => ( el.name || el ).match( regexp ) ); - } - - if ( Array.isArray( content ) && supportsFileSystemAPI ) { - // File System entries - for ( const fileObj of content ) { - const { name, handle, dirHandle } = fileObj; - if ( handle instanceof FileSystemDirectoryHandle ) - dirs.push( fileObj ); - else if ( handle instanceof FileSystemFileHandle ) { - if ( name.match( coverExtensions ) ) - imgs.push( fileObj ); - else if ( name.match( subsExtensions ) ) - subs.push( fileObj ); - if ( name.match( fileExtensions ) ) - files.push( fileObj ); - } - } - } - else { - // Web server HTML content - for ( const { url, file } of parseWebIndex( content ) ) { - const fileObj = { name: file }; - if ( url.slice( -1 ) == '/' ) - dirs.push( fileObj ); - else { - if ( file.match( coverExtensions ) ) - imgs.push( fileObj ); - else if ( file.match( subsExtensions ) ) - subs.push( fileObj ); - if ( file.match( fileExtensions ) ) - files.push( fileObj ); - } - } - } + let {dirs, files, imgs, subs} = content; // attach subtitle entries to their respective media files for ( const sub of subs ) { @@ -428,6 +377,12 @@ export function parseDirectory( content, path ) { fileEntry.subs = { src: path ? path + name : makePath( name ), lang, handle }; } + // helper function + const findImg = (arr, pattern) => { + const regexp = new RegExp(pattern, 'i'); + return arr.find(el => (el.name || el).match(regexp)); + }; + const cover = findImg( imgs, 'cover' ) || findImg( imgs, 'folder' ) || findImg( imgs, 'front' ) || imgs[0]; // case-insensitive sorting with international charset support - thanks https://stackoverflow.com/a/40390844/2370385 diff --git a/webpack.config.js b/webpack.config.js index f598811..cb709af 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,50 +1,54 @@ -const webpack = require('webpack'); -const path = require('path'); -const MiniCssExtractPlugin = require("mini-css-extract-plugin"); -const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); +import path from 'path'; +import { fileURLToPath } from 'url'; +import webpack from 'webpack'; +import MiniCssExtractPlugin from 'mini-css-extract-plugin'; +import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'; -module.exports = { - mode: 'production', - entry: './src/index.js', - module: { - rules: [ - { - test: /\.css$/, - use: [ - MiniCssExtractPlugin.loader, - { - loader: 'css-loader', - options: { url: false } - } - ] - } - ] - }, - optimization: { - minimizer: [ `...`, new CssMinimizerPlugin() ], - splitChunks: { - cacheGroups: { - vendor: { - test: /[\\/]node_modules[\\/]/, - name: 'vendors', - chunks: 'all', - }, - }, - }, - }, - plugins: [ - new MiniCssExtractPlugin({ - filename: 'styles.css', - }), - new webpack.ProvidePlugin({ - Buffer: ['buffer', 'Buffer'], - process: 'process/browser.js', - }), - ], - output: { - filename: pathData => { - return pathData.chunk.name === 'main' ? 'audioMotion.js' : '[name].js'; - }, - path: path.resolve( __dirname, 'public' ) - } +// ESM __dirname workaround +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export default { + mode: 'production', + entry: './src/index.js', + module: { + rules: [ + { + test: /\.css$/, + use: [ + MiniCssExtractPlugin.loader, + { + loader: 'css-loader', + options: { url: false }, + }, + ], + }, + ], + }, + optimization: { + minimizer: ['...', new CssMinimizerPlugin()], + splitChunks: { + cacheGroups: { + vendor: { + test: /[\\/]node_modules[\\/]/, + name: 'vendors', + chunks: 'all', + }, + }, + }, + }, + plugins: [ + new MiniCssExtractPlugin({ + filename: 'styles.css', + }), + new webpack.ProvidePlugin({ + Buffer: ['buffer', 'Buffer'], + process: 'process/browser.js', + }), + ], + output: { + filename: pathData => + pathData.chunk.name === 'main' ? 'audioMotion.js' : '[name].js', + path: path.resolve(__dirname, 'public'), + }, }; diff --git a/webpack.dev.js b/webpack.dev.js index 83d74a8..77907d2 100644 --- a/webpack.dev.js +++ b/webpack.dev.js @@ -1,7 +1,12 @@ -const path = require('path'); -const webpack = require('webpack'); +import path from 'path'; +import { fileURLToPath } from 'url'; +import webpack from 'webpack'; -module.exports = { +// ESM __dirname workaround +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export default { mode: 'development', entry: './src/index.js', devtool: 'inline-source-map', @@ -24,7 +29,7 @@ module.exports = { { test: /\.css$/, use: [ - 'style-loader', // Use style-loader instead of MiniCssExtractPlugin in dev + 'style-loader', { loader: 'css-loader', options: { url: false },