THU.JUN.18
2026
23:40:08
← back to modules MODULE · 03 · HOMESTREAM
0 / 10 chapters complete · 0%

Polish — Search, Recently Added, Cron, Ship It

Last chapter. HomeStream is functional but rough. Three small additions push it from "I built this" to "I actually use this every week." We'll add a richer search (artist + album, not just title), a "recently added" home view, a session-backed play queue, and a cron job that re-scans your media folder nightly. Then we ship.
Extend the library WHERE to also search artist and album. Add a recently-added rail. Build a tiny session-array play queue. Cron-schedule scan + metadata + thumbs to run every night. Done.

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.

  1. 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%";
    }
  2. 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>
  3. 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>";
  4. 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>
  5. Schedule the nightly scan with cron. SSH in, then:
    sudo crontab -e
    Add:
    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
    Save. Run the three commands manually now to confirm they work outside cron: bin/scan.php && bin/metadata.php && bin/thumbs.php.
  6. 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.