A lightweight, standards-driven Node.js video server that serves video files from a directory tree via REST API with HTTP range request support for streaming to iOS and web clients.
- Read-only file serving: Serves video files without modifying the directory tree
- Built-in web UI: Visit the server root in a browser to browse and play your videos — no extra setup
- REST API: Clean REST endpoints for browsing directories and streaming videos
- HTTP Range Requests: Full support for video seeking/scrubbing
- Thumbnail Generation: Automatic thumbnail extraction at 1% into each video
- Metadata Extraction: FFmpeg-based video metadata extraction (duration, resolution, codec)
- SQLite Database: Caches metadata and thumbnail information
- CORS Enabled: Works with web and iOS clients
- System File Filtering: Automatically excludes .DS_Store, Thumbs.db, hidden files, etc.
- Directory-first: Always reads directly from filesystem (no filename caching)
- Node.js 16+
- FFmpeg installed on your system
- macOS:
brew install ffmpeg - Ubuntu/Debian:
apt install ffmpeg - Windows: Download from https://ffmpeg.org/
- macOS:
Run the server directly without installing — npx downloads it on first use:
npx eyezo /path/to/your/videosTo install from source:
npm installThen start the server:
node eyezo.js /path/to/your/videosThe server will start on port 3000 by default. You can customize the port and host:
PORT=8080 HOST=localhost node eyezo.js /path/to/your/videosnpm run dev /path/to/your/videosOpen the server's root URL in any browser to use the built-in web UI:
http://localhost:3000/
The UI is a single self-contained page (no build step) served from public/index.html. It lets you:
- Browse folders with breadcrumb navigation
- See a thumbnail grid with duration, resolution, and file size
- Play videos in the browser, with seeking via HTTP range requests
- Resume playback — watch progress is saved automatically and restored on reopen
The root URL uses content negotiation: browsers (requests sending Accept: text/html) receive the web UI, while API clients receive the JSON API descriptor as before, so existing clients are unaffected.
GET /api/health - Health check
GET /api/config - Server configuration
GET /api/browse - Browse root directory
GET /api/browse/* - Browse subdirectory
Query Parameters:
- sort: name|date|size (default: name)
- order: asc|desc (default: asc)
Example:
GET /api/browse/movies/action?sort=name&order=asc
Response:
{
"path": "/movies/action",
"parent": "/movies",
"directories": [
{
"name": "2024",
"path": "/full/path/movies/action/2024",
"relativePath": "movies/action/2024",
"urlPath": "movies/action/2024",
"modified": 1715875200
}
],
"videos": [
{
"name": "movie.mp4",
"path": "/full/path/movie.mp4",
"relativePath": "movies/action/movie.mp4",
"urlPath": "movies/action/movie.mp4",
"size": 1073741824,
"modified": 1715875200,
"extension": ".mp4",
"mimeType": "video/mp4",
"duration": 7200.5,
"thumbnailUrl": "/api/thumbnail/movies/action/movie.mp4"
}
],
"totalDirectories": 1,
"totalVideos": 1
}GET /api/video/* - Stream video file with range request support
Headers:
Range: bytes=start-end (optional, for seeking)
Example:
GET /api/video/movies/action/movie.mp4
GET /api/video/movies/action/movie.mp4
Headers: Range: bytes=0-1023
The server returns:
200 OKfor full file streaming206 Partial Contentfor range requests- Proper
Accept-Ranges,Content-Range, andContent-Typeheaders
GET /api/thumbnail/* - Get video thumbnail (JPEG)
Example:
GET /api/thumbnail/movies/action/movie.mp4
Thumbnails are:
- Generated lazily on first request
- Extracted at 1% into the video
- 320x180 resolution
- Cached in
~/.local/eyezo-server/thumbnails/
GET /api/metadata/* - Get video metadata
Example:
GET /api/metadata/movies/action/movie.mp4
Response:
{
"relativePath": "movies/action/movie.mp4",
"size": 1073741824,
"modified": 1715875200,
"duration": 7200.5,
"width": 1920,
"height": 1080,
"codec": "h264",
"bitrate": 5000000,
"hasThumbnail": true,
"lastScanned": 1715875300
}POST /api/scan - Trigger metadata/thumbnail generation
Body:
{ "path": "movies/action" } // Optional, scans all if omitted
This endpoint:
- Recursively scans the specified directory (or all videos if no path)
- Extracts metadata for all videos
- Generates thumbnails for all videos
- Updates the database
POST /api/watch-progress - Save playback position for a video
GET /api/watch-progress/* - Get saved position for a video
DELETE /api/watch-progress/* - Clear saved position for a video
Save body:
{ "path": "movies/action/movie.mp4", "position": 123.45 }Get response:
{
"path": "movies/action/movie.mp4",
"position": 123.45,
"lastWatched": 1715875300
}Used by the built-in web UI to resume playback where you left off. position is in seconds; a never-watched video returns 0.
SQLite database stored at: ~/.local/eyezo-server/database.sqlite
Contains:
- Video metadata (duration, resolution, codec, bitrate)
- Thumbnail status
- Configuration
- Scan history
Thumbnail cache stored at: ~/.local/eyezo-server/thumbnails/
Filenames are MD5 hashes of the video's relative path.
- MP4 (.mp4, .m4v)
- MKV (.mkv)
- WebM (.webm)
- QuickTime (.mov)
- AVI (.avi)
- WMV (.wmv)
- FLV (.flv)
- MPEG (.mpg, .mpeg, .m2v)
- 3GP (.3gp, .3g2)
- MPEG-TS (.mts, .ts, .m2ts)
- VOB (.vob)
- OGG Video (.ogv)
The server ships with a built-in web UI — just open the root URL. The snippet below shows how to embed the video and thumbnail endpoints in your own page instead:
<!DOCTYPE html>
<html>
<head>
<title>Video Player</title>
</head>
<body>
<video controls width="800">
<source src="http://localhost:3000/api/video/movies/action/movie.mp4" type="video/mp4">
</video>
<img src="http://localhost:3000/api/thumbnail/movies/action/movie.mp4" alt="Thumbnail">
</body>
</html>// Browse directory
const response = await fetch('http://localhost:3000/api/browse/movies');
const data = await response.json();
console.log('Videos:', data.videos);
console.log('Subdirectories:', data.directories);
// Get metadata
const metadata = await fetch('http://localhost:3000/api/metadata/movies/movie.mp4');
const videoInfo = await metadata.json();
console.log('Duration:', videoInfo.duration);
console.log('Resolution:', `${videoInfo.width}x${videoInfo.height}`);import AVKit
let url = URL(string: "http://localhost:3000/api/video/movies/movie.mp4")!
let player = AVPlayer(url: url)
let playerViewController = AVPlayerViewController()
playerViewController.player = player
present(playerViewController, animated: true) {
player.play()
}# Health check
curl http://localhost:3000/api/health
# Browse root
curl http://localhost:3000/api/browse
# Browse subdirectory
curl http://localhost:3000/api/browse/movies/action
# Stream video (full)
curl http://localhost:3000/api/video/movie.mp4 -o test.mp4
# Stream video (range request)
curl -H "Range: bytes=0-1023" http://localhost:3000/api/video/movie.mp4
# Get thumbnail
curl http://localhost:3000/api/thumbnail/movie.mp4 -o thumb.jpg
# Get metadata
curl http://localhost:3000/api/metadata/movie.mp4
# Trigger scan
curl -X POST http://localhost:3000/api/scan \
-H "Content-Type: application/json" \
-d '{"path": "movies"}'The server validates all paths to prevent directory traversal attacks. Requests like:
GET /api/video/../../etc/passwd
Will be rejected with a 403 Forbidden error.
This server does not include authentication. It's designed for use on trusted networks (home/local network). If you need to expose it to the internet, consider:
- Running it behind a reverse proxy (nginx, Apache) with authentication
- Using a VPN
- Implementing IP-based access control
- Streaming: Uses Node.js streams for efficient memory usage with large files
- Database: Synchronous SQLite for fast metadata queries
- Caching: Thumbnails and metadata are cached to reduce FFmpeg overhead
- CORS: Preflight requests are cached for 24 hours
If you get errors about FFmpeg:
# macOS
brew install ffmpeg
# Ubuntu/Debian
sudo apt update && sudo apt install ffmpeg
# Verify installation
ffmpeg -versionChange the port:
PORT=8080 node eyezo.js /path/to/videosEnsure the video directory is readable:
ls -la /path/to/videosIf you get database locked errors, ensure only one instance of the server is running.
eyezo-server/
├── package.json
├── README.md
├── .gitignore
├── eyezo.js # Entry point
├── public/
│ └── index.html # Built-in web UI (served at /)
├── src/
│ ├── app.js # Fastify app setup
│ ├── config/
│ │ ├── constants.js # System file patterns, video extensions
│ │ └── database.js # SQLite connection and schema
│ ├── routes/
│ │ ├── index.js # Health check, config endpoints
│ │ ├── browse.js # Directory browsing
│ │ ├── video.js # Video streaming
│ │ ├── thumbnail.js # Thumbnail serving
│ │ ├── metadata.js # Metadata and scanning
│ │ └── watch-progress.js # Watch progress save/resume
│ ├── services/
│ │ ├── filesystem.js # Directory traversal, filtering
│ │ ├── video-stream.js # Range request handling
│ │ ├── thumbnail.js # Thumbnail generation
│ │ ├── metadata.js # Metadata extraction
│ │ └── database.js # Database queries
│ └── utils/
│ ├── mime-types.js # MIME type detection
│ ├── path-utils.js # Path validation
│ └── error-handler.js # Error classes
└── scripts/
└── init-db.js # Database initialization
MIT
Issues and pull requests are welcome!
- Built with Fastify
- Video processing with FFmpeg
- Database with better-sqlite3