Polish — Search, Recently Added, Cron, Ship It
Search across multiple columns
Right now the search query only looks at the title column. That's annoying — searching "daft" should match every Daft Punk song, even if "Daft" isn't in the track title. Easy fix: OR the LIKE across multiple columns.
if ($q !== '') {
$where[] = "(title LIKE :q1 OR artist LIKE :q2 OR album LIKE :q3)";
$bind['q1'] = "%$q%";
$bind['q2'] = "%$q%";
$bind['q3'] = "%$q%";
}
Note we use three named placeholders even though the value is the same — PDO doesn't let you reuse the same named placeholder in multiple spots. Mildly annoying but easy to work around. Just give them different names.
For better search later (typo tolerance, ranking, partial-word matches), you'd want a real full-text index using MariaDB's FULLTEXT support, or a separate tool like Meilisearch or Typesense. For a personal library, LIKE is plenty.
The "Recently Added" feature
One of the most useful things in a media library is "what's new since I last looked?" We already store added_at when the scanner inserts a row. All we need is a homepage section that pulls the most recent N items.
$recent = db()->query("
SELECT * FROM media
ORDER BY added_at DESC
LIMIT 12
")->fetchAll();
Drop this above the regular list on the library page. Style it as a horizontal scrolling rail or a small grid. Instant "feels like an app" win.
A tiny play queue
Right now your "what to play next" logic is just alphabetical neighbor. Real media apps let you build a queue — click "add to queue" on items, then play through them in order. We can do a janky version with $_SESSION.
The queue is just an array of IDs in the session. Endpoints to add, remove, and clear. The play page peeks at the queue to decide what's next.
Cron — automate the scans
You're going to forget to run the scanner. Then you'll add new music, wonder why it doesn't appear in the library, run the scanner manually, mutter at yourself. Cron solves this. We tell Linux: "every night at 3 AM, run scan + metadata + thumbs in sequence." Future-Eric never thinks about it again.
0 3 * * * /home/erictey/server/homestream/bin/scan.php && \
/home/erictey/server/homestream/bin/metadata.php && \
/home/erictey/server/homestream/bin/thumbs.php >> /var/log/homestream-cron.log 2>&1
The && means "only run the next one if the previous succeeded." Standard chaining. Output goes to a log so you can check it.
Ship: All Three Improvements + Cron
This is the last build project. After this you can call HomeStream done and use it. Use a real album you own, put on your headphones, click play, sit back. That's the moment.
- Open
public/index.php. Update the search block:if ($q !== '') { $where[] = "(title LIKE :q1 OR artist LIKE :q2 OR album LIKE :q3)"; $bind['q1'] = "%$q%"; $bind['q2'] = "%$q%"; $bind['q3'] = "%$q%"; } - Above the main list, add a "Recently Added" rail:
<?php $recent = db()->query("SELECT id, type, title FROM media ORDER BY added_at DESC LIMIT 12")->fetchAll(); ?> <h2 style="color:#5bf0ff;margin-top:20px">Recently added</h2> <div style="display:flex;gap:10px;overflow-x:auto;padding-bottom:10px;margin-bottom:20px"> <?php foreach ($recent as $r): ?> <a href="play.php?id=<?= (int)$r['id'] ?>" style="min-width:160px;padding:10px;border:1px solid #ff2e8855;color:#5bf0ff;text-decoration:none"> <div style="font-size:12px;color:#b9adcf"><?= e(strtoupper($r['type'])) ?></div> <div><?= e($r['title']) ?></div> </a> <?php endforeach; ?> </div> - Build the tiny queue. Create
public/queue.php:<?php declare(strict_types=1); require __DIR__ . '/../lib/auth.php'; require __DIR__ . '/../lib/db.php'; require_auth(); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $_SESSION['queue'] ??= []; $action = $_POST['action'] ?? ''; $id = (int)($_POST['id'] ?? 0); if ($action === 'add' && $id > 0) { $_SESSION['queue'][] = $id; } elseif ($action === 'clear') { $_SESSION['queue'] = []; } elseif ($action === 'shift' && $_SESSION['queue']) { array_shift($_SESSION['queue']); } header('Location: ' . ($_POST['redirect'] ?? 'index.php')); exit; } $ids = $_SESSION['queue'] ?? []; if (!$ids) { echo "<p>Queue is empty. Add tracks from the library.</p>"; exit; } $in = implode(',', array_map('intval', $ids)); $items = db()->query("SELECT id, title, type FROM media WHERE id IN ($in)")->fetchAll(); echo "<ul>"; foreach ($items as $item) { echo "<li>[" . htmlspecialchars($item['type']) . "] " . htmlspecialchars($item['title']) . "</li>"; } echo "</ul>"; - Add "Queue" buttons in the library item row:
<form method="post" action="queue.php" style="display:inline"> <input type="hidden" name="action" value="add"> <input type="hidden" name="id" value="<?= (int)$item['id'] ?>"> <input type="hidden" name="redirect" value="<?= e($_SERVER['REQUEST_URI']) ?>"> <button>+ queue</button> </form> - Schedule the nightly scan with cron. SSH in, then:
Add:sudo crontab -e
Save. Run the three commands manually now to confirm they work outside cron:0 3 * * * /home/erictey/server/homestream/bin/scan.php && /home/erictey/server/homestream/bin/metadata.php && /home/erictey/server/homestream/bin/thumbs.php >> /var/log/homestream-cron.log 2>&1bin/scan.php && bin/metadata.php && bin/thumbs.php. - Optional: hit
http://medtrack.local/... wait that was Part 2.http://192.168.0.19/server/homestream/public/is your home. Bookmark it. Add it to your phone's home screen. Test from every device.
Stretch goals (the never-ending kind):
- Build a real custom audio player with album art, lyrics, like buttons.
- Add multi-user support reusing MedTrack's users table.
- Pull movie posters from TMDB API and store them in storage/posters/.
- Make a tiny "now playing" dock that follows you across pages using sessionStorage.
- Add Chromecast or AirPlay support (involves more JS but the <video> API has hooks).
- Write a tiny mobile app or PWA that talks to your stream endpoint.
What you flexed across all 10 chapters: filesystem walking with RecursiveDirectoryIterator, CLI PHP scripts with shebangs, INSERT IGNORE for idempotent imports, dynamic WHERE clauses with prepared statements, LIMIT/OFFSET pagination, HTTP Range requests with status 206 + Content-Range, fseek/fread chunked streaming with flush(), HTML5 video and audio players with autoplay-next via JS, Composer + getID3 for ID3 tag extraction, ffprobe + ffmpeg shell-outs with escapeshellarg, generated-file caching with the has_thumb flag pattern, Apache Alias for serving cached files, password_hash + password_verify with hash-in-config-file, Apache Require ip for LAN-only access, cron scheduling, full-text-ish search across multiple columns, session-backed play queue.
You built a real app. Not a toy, not a tutorial sandbox — something you'll actually open this evening. Pat yourself on the back. Then pick a new project and start the next thing.