TL;DR: One
index.phpfile. Zero dependencies. Zero database.
Scales to 500k files. MIT licensed. Code on GitHub.
The itch I was scratching
I have a folder of MP3s I wanted to share with a small audience.
Standard options were:
- Plex / Jellyfin → hours of Docker and configuration
- S3 + CloudFront → 40 AWS IAM tabs later I give up
- SoundCloud / YouTube → not self-hosted, algorithmic control
- WordPress + plugin → bloat, database, security patches forever
I just wanted: "upload files, visitors see them, done." So I wondered
— how minimal can a media server actually be?
The constraints I gave myself
- Single
.phpfile - No database
- No
composer.jsonor any package manager - No build step (no webpack, no TypeScript compilation)
- Must handle 500k+ files without choking
- Must work on $3/month shared hosting
Six weeks later: 900 lines of PHP, and it actually does all of that.
Architecture: JSON index cache
The trick to scale a single-file script is avoiding filesystem scans
on every request. On first load:
function rebuildIndex() {
$files = [];
$dh = opendir('.');
while (($f = readdir($dh)) !== false) {
if (!is_file($f) || $f[0] === '.') continue;
$files[] = [
'n' => $f,
's' => filesize($f),
'c' => categorize(pathinfo($f, PATHINFO_EXTENSION))
];
}
shuffle($files);
file_put_contents('_index.cache.json', json_encode($files));
return $files;
}
Subsequent requests read _index.cache.json instead. Cache TTL is
1 hour. Tested with 500k dummy files — response times stay under 50ms.
The hardest part: Ed25519 verification in PHP
I wanted users to authenticate via Waves blockchain wallet
(Keeper extension). Waves uses Curve25519 public keys but
signs with Ed25519. You can't verify Ed25519 signatures with
a Curve25519 key directly.
The birational map between the two curves:
y = (u - 1) / (u + 1) mod p
Where u is the Montgomery x-coordinate (Curve25519) and y is
the Edwards y-coordinate (Ed25519). Plus reconstructing the sign bit
from byte 63 of the signature.
function curve25519ToEd25519(string $curvePk, int $signBit): ?string {
$p = gmp_init('2^255 - 19');
$u = gmp_mod(gmp_init(bin2hex(strrev($curvePk)), 16), $p);
$uPlus1 = gmp_mod(gmp_add($u, 1), $p);
$inv = gmp_invert($uPlus1, $p);
$y = gmp_mod(gmp_mul(gmp_sub($u, 1), $inv), $p);
// ... encode back to 32 bytes, set sign bit
}
Then feed to sodium_crypto_sign_verify_detached(). Works reliably
once you get the byte-order and sign-bit handling right.
Other things I crammed into 900 lines
-
HTTP byte-range streaming for audio/video (
Accept-Ranges: bytes) - MediaSession API so mobile lock-screen controls work
- Infinite scroll via IntersectionObserver with cancelable fetches
- 10 language translations including RTL (Arabic)
- Dark + grayscale light themes
- Mobile-first responsive (row layout on phones, grid on desktop)
-
Shareable URLs that auto-start playlists:
#play=audio&list=a.mp3|b.mp3&from=a.mp3
What I'd do differently
Start with constraints, not features. The "one file" rule
forced every decision to justify itself in bytes.HTTP-range streaming is underrated. MediaSession + byte-range
on a static file gives you 90% of what "proper" streaming services do.JSON cache > database for read-heavy workloads. For this use
case, filesystem metadata in JSON is faster than any SQL query
because there's no query — justjson_decode()once.
What this is NOT
- Not a Plex replacement. It doesn't transcode, scrape metadata, manage libraries.
- Not for shared multi-user servers. One deployment = one creator.
- Not secure by itself — put it behind Cloudflare or nginx with rate limiting.
Try it
MIT licensed. Fork it, break it, rewrite it. If you find a cleaner
way to do the Ed25519 dance in PHP — please tell me.
Building minimalist tools is a discipline. Every feature has to
justify its bytes. Turns out: most web apps can be 10× smaller than
they are. Maybe yours too.
Top comments (0)