Flex Engineers
4 min read

Building CinePlex.live: A Movie Streaming Catalog with a Homegrown Admin CMS

PHPCodeIgniterMySQLTMDB APIWeb ScrapingSEO

Building CinePlex.live: A Movie Streaming Catalog with a Homegrown Admin CMS

CinePlex.live was a movie and TV streaming aggregator our team built and shipped — catalog browsing, search, genre/year discovery, and an admin back office for managing thousands of titles. The interesting part isn't the front-end grid of movie posters; it's everything behind it: a content pipeline that leans on The Movie Database (TMDB) API to do the metadata work a small team can't do by hand, and a few pragmatic, unglamorous decisions that kept a lean operation running a real catalog in production.

From Node prototype to a CodeIgniter production build

The project started as an Express/Sequelize/EJS prototype — a fast way to prove out the data model (movies, admins) against MySQL. Once the catalog and admin workflow were clear, our team rebuilt it on PHP/CodeIgniter 3, which turned out to be the better fit for the actual constraints: cheap shared hosting, no build step, and a request lifecycle simple enough that the team could iterate on views and controllers directly on the server during early growth.

LayerPrototype (Node)Production (PHP)
FrameworkExpressCodeIgniter 3
ORM/DB accessSequelizeCodeIgniter Query Builder
ViewsEJSPHP views + Bootstrap 4
Hosting fitNeeds a Node processStandard LAMP/shared hosting

That switch wasn't about PHP being "better" — it was about matching the stack to where the project actually had to run and who had to maintain it.

Content discovery: channels, genres, and cross-linking

Movies are organized into channels (Bollywood, Hollywood, Tamil, Foreign, Animation), each independently paginated and filterable by genre or year. Every movie page also parses its own genre string and pulls "more like this" blocks per genre on the fly:

$pieces = explode(",", $data['movie'][0]->MovieGenre);
$data['gnr'] = $pieces;
foreach ($pieces as $val) {
    $stkey = "sorted_" . $val;
    $data[$stkey] = $this->Home_model->get_movies_api_lk("MovieGenre", $val, 28, 0);
}

Rather than a separate recommendations table or a join-heavy tagging schema, this treats the genre field as a lightweight index and builds cross-links at render time — simple enough to reason about, and it scales fine at catalog sizes where a recommendation engine would be overkill.

Grid pages (latest, genre, year, channel, search) load their first page server-rendered for SEO, then hand off to AJAX endpoints (ajax_latest_provider, ajax_genre_provider, ajax_channel_provider) for infinite scroll — so search engines see real paginated HTML, while human visitors get a scroll-and-load feed.

A content-ops admin, not just a CRUD form

The admin panel is the actual leverage point of the project. Adding a movie by hand — title, poster, backdrop, synopsis, cast, runtime, TMDB ID — doesn't scale past a few hundred titles. So the upload form is wired directly to the TMDB API: search by title and year, pull back poster/backdrop paths and metadata, and populate the form in one click instead of retyping it from a UI the team doesn't control.

The same idea extends to maintenance. A scheduled admin job walks the catalog, HEAD-checks every poster URL against TMDB's image CDN, and automatically re-resolves and repairs any that have gone dead:

function checkRemoteFile($url) {
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_NOBODY, 1);       // don't download the body
    curl_setopt($ch, CURLOPT_FAILONERROR, 1);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    return curl_exec($ch) !== FALSE;
}

The upload form also probes the remote video host with a HEAD request to report file size before saving the record — useful for visitors deciding whether to stream or download, and cheap because it never touches the file body:

curl_setopt($ch, CURLOPT_NOBODY, TRUE);
curl_setopt($ch, CURLOPT_HEADER, TRUE);
$size = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD);

Neither of these is complicated code. What matters is the decision to spend engineering time automating the boring parts of content ops — metadata entry and link rot — instead of treating them as manual chores that don't scale.

Watch and download links are the thing worth protecting from naive scraping, but the site is anonymous-first — no accounts for visitors. Building full auth just to rate-limit link harvesting would have been the wrong amount of engineering for the problem. Instead, the real link is never in the initial page payload at all: the client resolves its own public IP via Cloudflare's trace endpoint, sends it to an AJAX endpoint, and the server only returns the watch/download markup if that IP is on an allow-list table:

$.get('https://www.cloudflare.com/cdn-cgi/trace', function (data) {
  const ip = data.split('ip=')[1].split('\n')[0];
  $.get(base_url + '/home/ajax_mv_provider', { ip, page: movieId }, function (html) {
    if (html !== '404') document.getElementById('mvall').innerHTML = html;
  });
});
function ajax_mv_provider() {
    $counter = $this->Home_model->approve_user_ip(trim($this->input->get('ip')));
    if ($counter > 0) {
        $watchlink = $this->Home_model->get_movie_watchlink($this->input->get('page'));
        echo '<a href="'.$watchlink[0]->MovieWatchLink.'" class="btn btn-danger">Play</a>';
    } else {
        echo "404";
    }
}

It's a deliberately thin control — a gate, not a fortress — and that's the point: it keeps direct file URLs out of crawlable HTML without forcing every visitor through a signup flow, which would have killed the anonymous browsing experience the product depended on.

SEO as a first-class concern

Every movie page builds its own <title>, meta description, and Open Graph tags from the TMDB-sourced title, year, genre, and synopsis rather than a single static template — because for a catalog site, organic search traffic to individual title pages is the acquisition channel:

$data['meta_title'] = $movie[0]->MovieTitle . " " . $movie[0]->MovieYear . " " . $movie[0]->MovieCategory . " movie";
$data['meta_description'] = $movie[0]->MovieGenre . " " . $movie[0]->MovieStory;
$data['meta_image'] = "https://image.tmdb.org/t/p/w300/" . $movie[0]->poster;

Takeaway

Nothing here is exotic technology — CodeIgniter, MySQL, cURL, jQuery. The engineering value was in matching effort to the actual bottleneck: automate the parts of running a content catalog that don't scale by hand (metadata entry, broken image links), keep the anti-abuse layer as light as the threat model actually requires, and never lose sight of SEO as the product's real acquisition channel. That's the same instinct our team brings to client work — pick boring, reliable tools, and spend the clever engineering budget on the two or three things that actually move the needle.