Cine-Scribe: A Premium Personal Movie Tracker π¬

As a cinema enthusiast, I've always found that keeping track of the movies I've watched - and more importantly, my ratings for them - can be surprisingly difficult. Generic spreadsheets feel too clinical, I've gone down the Notion, Obsidian, and Airtable rabbit holes for this, and many existing movie tracking apps are cluttered with social features and advertisements that distract from the simple ask of logging the movies I watch.
As for why I would event want to document this, I sometimes forget about a movie I already watched years back. While this is great when it helps me rediscover absolute gems, it's not so when I invest 2-3 hours on a bad movie, again.
I wanted something tailored to me: a private, beautiful, and highly performative way to curate my own cinema history. This led me to build Cine-Scribe.
The Tech Stack
Building Cine-Scribe was an exercise in using modern web technologies to create a seamless, "app-like" experience, but without the bloat of a full Android or iOS app. I chose tools that allow for rapid development without sacrificing the performance or the aesthetic polish required for a premium feel.
Next.js 14 (App Router): For a fast, SEO-friendly, and robust frontend/backend architecture.
Prisma ORM: To manage the movie collection DB with type safety and ease of use.
TMDB API: The backbone of the application, providing access to an extensive database of movies, posters, and metadata.
Tailwind CSS: Used to implement a custom "Deep Space" design system.
SQLite: A lightweight, zero-configuration database perfect for a personal project.
Key Features
π The Cinema Aesthetic
The core of Cine-Scribe is its "Deep Space" design system. I wanted the UI to evoke the feeling of sitting in a darkened theater, using a palette of deep blacks, rich golds, and subtle violet highlights. Every interaction is designed to feel premium and immersive.
I focused heavily on making sure that even while loading large amounts of poster data, the experience remains fluid and polished, ensuring a smooth transition between browsing and viewing details.
π Seamless Movie Discovery
One of the most satisfying parts of the app is searching for a movie. By integrating directly with The Movie Database (TMDB) API, I don't have to manually enter titles or years. I can simply search, select from the results, and Cine-Scribe handles the restβfetching the poster, director, cast, and even generating an IMDB rating scale compatible with my 5-star system. I also love being able to browse through trending or top-rated movies to find something new to add to my list.
β Personal Collection Management
Cine-Scribe isn't just a list; it's a personal journal. I can view my watched movies in a beautiful, easy-to-navigate card layout. Every entry allows me to:
Assign a personal star rating (1-5).
Log the exact date I watched the film.
Filter and sort my collection by year, rating, or recent watch date.
Technical Deep Dive
The Data Model
Using Prisma, I was able to define a clear, type-safe schema that captures all the essential movie metadata while remaining lightweight enough for SQLite.
1model Movie {
2 id String @id @default(cuid())
3 title String
4 year Int
5 director String?
6 cast Json
7 genre String?
8 imdbRating Float?
9 myRating Float?
10 description String?
11 posterUrl String?
12 watchedDate String?
13 dateIdentified DateTime @default(now())
14 tmdbId Int? @unique
15
16 @@index([year])
17 @@index([title])
18 @@index([tmdbId])
19 @@unique([tmdbId])
20}A few design decisions here:
tmdbId is unique and indexed β this is the primary de-duplication key. It ensures the same TMDB movie can never be added twice.
watchedDate is a String? (not a
DateTime) β this allows storing "Unknown" as a sentinel value when the user hasn't specified when they watched the film. This is common when marking movies from the trending/top-rated browse pages.dateIdentified uses a default
now()timestamp to track when a movie was first added to the database, regardless of the watchedDate. This is useful as a sorting option.cast is stored as JSON β an array of actor names serialized from the TMDB credits response.
Indexes on year, title, and tmdbId ensure fast look-ups for filtering and de-duplication checks.
How Movies Are Indexed
Cine-Scribe indexes movies through three distinct pathways, each tapping into the TMDB API:
1export class TMDBClient {
2 private baseUrl = 'https://api.themoviedb.org/3';
3 private imageBaseUrl = 'https://image.tmdb.org/t/p';
4
5 // 1. Title search
6 async searchMovies(query: string, page: number = 1) {
7 const response = await fetch(
8 `${this.baseUrl}/search/movie?api_key=${this.apiKey}&query=${encodeURIComponent(query)}&page=${page}`
9 );
10 return await response.json();
11 }
12
13 // 2. Full movie details (with credits embedded)
14 async getMovieDetails(movieId: number) {
15 const response = await fetch(
16 `${this.baseUrl}/movie/${movieId}?api_key=${this.apiKey}&append_to_response=credits,similar,recommendations`
17 );
18 return await response.json();
19 }
20
21 // 3. Browse trending movies (daily or weekly)
22 async getTrending(timeWindow: 'day' | 'week' = 'day', page: number = 1) {
23 const response = await fetch(
24 `${this.baseUrl}/trending/movie/${timeWindow}?api_key=${this.apiKey}&page=${page}`
25 );
26 return await response.json();
27 }
28
29 // 4. Browse top-rated movies
30 async getTopRated(page: number = 1) {
31 const response = await fetch(
32 `${this.baseUrl}/movie/top_rated?api_key=${this.apiKey}&page=${page}`
33 );
34 return await response.json();
35 }
36
37 // Image URL builders
38 getPosterUrl(path: string | null, size: 'w92' | 'w154' | 'w185' | 'w342' | 'w500' | 'w780' = 'w342'): string {
39 if (!path) return '';
40 return `${this.imageBaseUrl}/${size}${path}`;
41 }
42}The TMDB client is instantiated as a singleton using the TMDB_API_KEY environment variable. It handles all communication with TMDB's REST API, including:
Search β queries TMDB's
/search/movieendpoint with the user's typed inputDetails β fetches full movie info via
/movie/{id}withappend_to_response=credits,similar,recommendationsto get cast, crew, and recommendations in a single requestTrending β pulls
/trending/movie/{day|week}for the browse pageTop-rated β pulls
/movie/top_ratedfor the browse page
Poster and backdrop images use TMDB's image CDN with configurable sizes (w92 through w780/original).
Deduplication: Preventing Duplicate Entries
One of the most important aspects of Cine-Scribe is preventing the same movie from being added twice. The system uses a two-layer deduplication strategy:
Layer 1: TMDB ID matching (primary) Since tmdbId has a @unique constraint in Prisma, the first line of defense is checking whether the TMDB ID already exists in the database:
1const existingByTmdb = await prisma.movie.findFirst({
2 where: { tmdbId: parseInt(tmdbId) },
3});
4
5if (existingByTmdb) {
6 return NextResponse.json(
7 { error: 'This movie is already in your collection!', isDuplicate: true },
8 { status: 409 },
9 );
10}Layer 2: Title + Year matching (secondary) SQLite doesn't support case-insensitive search modes natively, so the fallback is to fetch all movies and compare title+year in JavaScript:
1const allMovies = await prisma.movie.findMany({
2 select: { title: true, year: true },
3});
4
5const existingByTitle = allMovies.find(
6 (m) =>
7 m.title.toLowerCase() === movieDetails.title.toLowerCase() &&
8 m.year === titleYear,
9);
10
11if (existingByTitle) {
12 return NextResponse.json(
13 { error: 'A movie with this title and year is already in your collection!', isDuplicate: true },
14 { status: 409 },
15 );
16}This catches cases where the same movie might have been added under a slightly different title format (e.g., "Inception" vs "Inception (2010)"). Both checks return HTTP 409 (Conflict) with an isDuplicate: true flag so the frontend can display a friendly message.
Adding Movies Without a Watched Date
When I browse the Trending or Top Rated pages and click the "mark as watched" checkbox, Cine-Scribe adds the movie with watchedDate: "Unknown". This is the default state for movies discovered through the browse pages; I'm flagging them for my collection but haven't necessarily watched them yet, or I simply don't recall when I did.
Here's the POST /api/movies/mark-watched endpoint logic:
1// POST /api/movies/mark-watched
2
3// After deduplication checks pass...
4// Extract director from TMDB credits
5const director = movieDetails?.credits?.crew?.find(
6 (c: any) => c.job === 'Director' || c.department === 'Directing',
7)?.name;
8
9// Create movie entry with "Unknown" watchedDate
10const newMovie = await prisma.movie.create({
11 data: {
12 title: movieDetails.title,
13 year: parseInt((movieDetails.release_date || '').substring(0, 4)),
14 tmdbId: parseInt(tmdbId),
15 director: director || undefined,
16 cast: JSON.stringify(
17 movieDetails?.credits?.cast?.slice(0, 5).map((c: any) => c.name) || [],
18 ),
19 genre: movieDetails?.genres?.[0]?.name || undefined,
20 imdbRating: movieDetails?.vote_average
21 ? movieDetails.vote_average / 2 // Convert from 10-point to match IMDB scale
22 : undefined,
23 description: movieDetails?.overview || undefined,
24 posterUrl: movieDetails?.poster_path
25 ? tmdbClient.getPosterUrl(movieDetails.poster_path, 'w342')
26 : undefined,
27 watchedDate: 'Unknown', // <-- Key difference from search-based adds
28 },
29});Notice that watchedDate is hardcoded to 'Unknown'. This sentinel string is used throughout the app to differentiate between "user watched this and specified the date" and "user added this but didn't specify when."
In contrast, when a movie is added via the search form, the user provides a watchedDate:
1// POST /api/movies (search-based add)
2
3let parsedWatchedDate: string | null = null;
4if (watchedDate) {
5 try {
6 const d = new Date(watchedDate);
7 if (!isNaN(d.getTime())) {
8 parsedWatchedDate = d.toISOString();
9 }
10 } catch {
11 parsedWatchedDate = null;
12 }
13}
14
15// Falls back to 'Unknown' if invalid
16const newMovie = await prisma.movie.create({
17 data: {
18 // ... other fields ...
19 watchedDate: (parsedWatchedDate as string) || 'Unknown',
20 },
21});Director Lookup Fallback
When a movie is added via search (without TMDB details), the director field may be empty. Cine-Scribe has an optional SearxNG fallback β if SEARXNG_BASE_URL is configured, it searches for movie reviews to extract the director's name:
1if (!director && process.env.SEARXNG_BASE_URL) {
2 const { searxngClient } = await import('@/lib/searxngClient');
3 try {
4 const reviews = await searxngClient.searchReviews(title, year);
5 const match = reviews[0].content.match(/directed by ([^\n]+)/i);
6 if (match) {
7 director = match[1].trim();
8 }
9 } catch (error) {
10 console.error('SearxNG search for director failed:', error);
11 }
12}This is a clever fallback that ensures director data isn't lost even when the TMDB credits endpoint fails to return a director. This also feels a bit unnecessary, just something I wanted to test out since I run a private SearxNG instance.
Collection Fetching and Pagination
The watched collection is fetched with pagination (30 movies per page) using Prisma's skip/take pattern:
1// GET /api/movies?page=N&limit=30
2
3const page = parseInt(searchParams.get('page') || '1', 10);
4const limit = parseInt(searchParams.get('limit') || '30', 10);
5const skip = (page - 1) * limit;
6
7const [movies, total] = await Promise.all([
8 prisma.movie.findMany({
9 orderBy: [{ watchedDate: 'desc' }, { year: 'desc' }],
10 skip,
11 take: limit,
12 }),
13 prisma.movie.count(),
14]);
15
16return NextResponse.json({
17 movies: formattedMovies,
18 total,
19 page,
20 totalPages: Math.ceil(total / limit),
21});Movies are sorted by watchedDate descending (most recently watched first), with year as a secondary sort key. The response includes total and totalPages for the frontend pagination controls.
Bulk Watched-Status Check
To optimize the browse experience, Cine-Scribe uses a bulk status check to determine which TMDB movies are already in the user's collection. This avoids N+1 API calls:
1// POST /api/movies/watched-status
2// Body: { tmdbIds: [1, 2, 3, ...] }
3
4const { tmdbIds } = body;
5const existing = await prisma.movie.findMany({
6 where: { tmdbId: { in: tmdbIds } },
7 select: { tmdbId: true },
8});
9
10const watchedIds = new Set(existing.map((m) => m.tmdbId));The browse page uses this to show checkmarks on already-collected movies without needing individual lookups.
Conclusion
Cine-Scribe started as a way to solve a personal problem: "What was that movie I loved three years ago, and what did I rate it?" By leveraging modern tools like Next.js and Prisma, I was able to build something that isn't just functional, but genuinely beautiful to use.
The journey of building this project has been about more than just code; it's been about creating a digital space that reflects my passion for cinema.
Happy watching! πΏ