letterbox-buddy 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ # Get a free API key at https://www.themoviedb.org/settings/api
2
+ TMDB_API_KEY=your_tmdb_api_key_here
3
+
4
+ # Optional: override where the SQLite database file lives.
5
+ # Defaults to ~/.local/share/letterbox-buddy/db.sqlite3 if not set.
6
+ # LETTERBOX_DB_PATH=/custom/path/to/db.sqlite3
@@ -0,0 +1,12 @@
1
+ .env
2
+ __pycache__/
3
+ *.pyc
4
+ *.db
5
+ *.sqlite3
6
+ .venv/
7
+ venv/
8
+ dist/
9
+ build/
10
+ *.egg-info/
11
+ .pytest_cache/
12
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 alpondr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: letterbox-buddy
3
+ Version: 0.1.0
4
+ Summary: An MCP server that turns your rough movie notes into polished reviews and tracks your watch history
5
+ Project-URL: Homepage, https://github.com/alpondr/letterbox-buddy-mcp
6
+ Project-URL: Repository, https://github.com/alpondr/letterbox-buddy-mcp
7
+ Author: alpondr
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: claude,letterboxd,mcp,model-context-protocol,movies
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: httpx>=0.27.0
20
+ Requires-Dist: mcp>=1.0.0
21
+ Requires-Dist: python-dotenv>=1.0.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # letterbox-buddy
25
+
26
+ An MCP server for people who watch a lot of movies and can never be bothered to write a proper review right after.
27
+
28
+ The idea is simple: you just tell Claude (through Claude Desktop) something like *"watched Fargo tonight, the dialogue was great but it dragged in the middle"*, and it turns that into an actual review, saves it with a rating, pulls the director/genre/year from TMDB automatically, and can spit out Letterboxd-ready text whenever you want it. It also remembers your whole watch history, so you can ask it things like "what have I watched by the Coen brothers" or "what should I watch next based on what I've been into lately."
29
+
30
+ This is a personal tool I built mainly to learn the MCP protocol by actually building something with it, not to ship a polished product. It works and I use it, but treat it like a working prototype, not battle-tested software.
31
+
32
+ ## What it actually does
33
+
34
+ 1. You write a rough note about a movie you watched, in your own words, whatever comes to mind.
35
+ 2. Claude (the one you're already chatting with in Claude Desktop) turns that into a polished review and asks you for a rating.
36
+ 3. The server looks the movie up on TMDB, grabs the director/genres/year, and stores everything in a local SQLite database.
37
+ 4. Later, you can ask for your watch history, stats on directors/genres you've been watching a lot, or recommendations based on your habits.
38
+ 5. When you're ready to log it on Letterboxd, ask for the formatted text and paste it in yourself.
39
+
40
+ No accounts, no cloud database, no automatic posting anywhere. It's just you, Claude, and a `.sqlite3` file on your machine.
41
+
42
+ ## Tools
43
+
44
+ | Tool | What it does |
45
+ |---|---|
46
+ | `search_movie` | Searches TMDB by title, returns candidates so Claude can ask "which one did you mean?" if there are several. |
47
+ | `log_movie_note` | Saves a movie + your raw note + rating (0.5-5 stars, Letterboxd style). Fetches director/genres/year from TMDB the first time a movie shows up. |
48
+ | `get_watch_history` | Lists what you've logged, filterable by director, genre, or date range. |
49
+ | `get_director_stats` | How many times you've watched each director, average rating, which titles. |
50
+ | `get_recommendations` | Returns raw data only - your most-watched director/genre plus movies of theirs you haven't logged yet. No opinions baked in, that's Claude's job. |
51
+ | `generate_letterboxd_text` | Saves the polished review text and hands back a plain, copy-paste-ready version (no title/year/rating, Letterboxd shows those itself). |
52
+
53
+ ## Setup
54
+
55
+ ### 1. Get a TMDB API key
56
+
57
+ Free, just needs an account: https://www.themoviedb.org/settings/api
58
+
59
+ ### 2. Clone and install
60
+
61
+ ```bash
62
+ git clone https://github.com/alpondr/letterbox-buddy-mcp.git
63
+ cd letterbox-buddy-mcp
64
+ python3 -m venv .venv
65
+ source .venv/bin/activate
66
+ pip install -e .
67
+ ```
68
+
69
+ ### 3. Add your API key
70
+
71
+ ```bash
72
+ cp .env.example .env
73
+ ```
74
+
75
+ Then open `.env` and paste your TMDB key in.
76
+
77
+ ### 4. Set up the database (optional, happens automatically anyway)
78
+
79
+ The server creates the SQLite database on first run, at `~/.local/share/letterbox-buddy/db.sqlite3` by default. Want it somewhere else? Set `LETTERBOX_DB_PATH` in your `.env`.
80
+
81
+ If you want to run the migration manually for whatever reason:
82
+
83
+ ```bash
84
+ python -m letterbox_buddy.db.migrate
85
+ ```
86
+
87
+ ### 5. Connect it to Claude Desktop
88
+
89
+ Add this to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
90
+
91
+ ```json
92
+ {
93
+ "mcpServers": {
94
+ "letterbox-buddy": {
95
+ "command": "/absolute/path/to/letterbox-buddy-mcp/.venv/bin/python",
96
+ "args": ["-m", "letterbox_buddy.server"]
97
+ }
98
+ }
99
+ }
100
+ ```
101
+
102
+ Restart Claude Desktop (fully quit, not just close the window) and you should see the tools show up.
103
+
104
+ Once this is published to PyPI, this whole setup collapses into just running `uvx letterbox-buddy` - no clone, no venv, nothing to install by hand.
105
+
106
+ ## Notes on design decisions
107
+
108
+ A few choices here look "wrong" if you're used to bigger projects, so here's the reasoning behind them.
109
+
110
+ **Why SQLite and no auth.** Single machine, single user, nothing to authenticate against - Postgres and JWT would just be unused infrastructure here.
111
+
112
+ **Why no separate LLM API.** Claude is already right there doing the conversation, so a second LLM would just duplicate work for no reason. The server stays dumb on purpose - fetch/store/return data, all the thinking happens in the chat.
113
+
114
+ **Why Letterboxd posting is copy-paste only.** There's no public API for posting on someone's behalf, and I'd rather the last step stay a deliberate human action anyway.
115
+
116
+ **Why "letterbox" not "letterboxd".** Not affiliated with Letterboxd, didn't want the name to imply a partnership that doesn't exist.
117
+
118
+ ## Known limitations / what this isn't
119
+
120
+ - Error handling is minimal - happy path only.
121
+ - No TMDB rate-limit handling or caching.
122
+ - Single machine, no sync between devices.
123
+ - No automated tests, everything checked manually.
124
+ - Single-user only, no accounts.
125
+ - Not on PyPI yet, schema may still change.
@@ -0,0 +1,102 @@
1
+ # letterbox-buddy
2
+
3
+ An MCP server for people who watch a lot of movies and can never be bothered to write a proper review right after.
4
+
5
+ The idea is simple: you just tell Claude (through Claude Desktop) something like *"watched Fargo tonight, the dialogue was great but it dragged in the middle"*, and it turns that into an actual review, saves it with a rating, pulls the director/genre/year from TMDB automatically, and can spit out Letterboxd-ready text whenever you want it. It also remembers your whole watch history, so you can ask it things like "what have I watched by the Coen brothers" or "what should I watch next based on what I've been into lately."
6
+
7
+ This is a personal tool I built mainly to learn the MCP protocol by actually building something with it, not to ship a polished product. It works and I use it, but treat it like a working prototype, not battle-tested software.
8
+
9
+ ## What it actually does
10
+
11
+ 1. You write a rough note about a movie you watched, in your own words, whatever comes to mind.
12
+ 2. Claude (the one you're already chatting with in Claude Desktop) turns that into a polished review and asks you for a rating.
13
+ 3. The server looks the movie up on TMDB, grabs the director/genres/year, and stores everything in a local SQLite database.
14
+ 4. Later, you can ask for your watch history, stats on directors/genres you've been watching a lot, or recommendations based on your habits.
15
+ 5. When you're ready to log it on Letterboxd, ask for the formatted text and paste it in yourself.
16
+
17
+ No accounts, no cloud database, no automatic posting anywhere. It's just you, Claude, and a `.sqlite3` file on your machine.
18
+
19
+ ## Tools
20
+
21
+ | Tool | What it does |
22
+ |---|---|
23
+ | `search_movie` | Searches TMDB by title, returns candidates so Claude can ask "which one did you mean?" if there are several. |
24
+ | `log_movie_note` | Saves a movie + your raw note + rating (0.5-5 stars, Letterboxd style). Fetches director/genres/year from TMDB the first time a movie shows up. |
25
+ | `get_watch_history` | Lists what you've logged, filterable by director, genre, or date range. |
26
+ | `get_director_stats` | How many times you've watched each director, average rating, which titles. |
27
+ | `get_recommendations` | Returns raw data only - your most-watched director/genre plus movies of theirs you haven't logged yet. No opinions baked in, that's Claude's job. |
28
+ | `generate_letterboxd_text` | Saves the polished review text and hands back a plain, copy-paste-ready version (no title/year/rating, Letterboxd shows those itself). |
29
+
30
+ ## Setup
31
+
32
+ ### 1. Get a TMDB API key
33
+
34
+ Free, just needs an account: https://www.themoviedb.org/settings/api
35
+
36
+ ### 2. Clone and install
37
+
38
+ ```bash
39
+ git clone https://github.com/alpondr/letterbox-buddy-mcp.git
40
+ cd letterbox-buddy-mcp
41
+ python3 -m venv .venv
42
+ source .venv/bin/activate
43
+ pip install -e .
44
+ ```
45
+
46
+ ### 3. Add your API key
47
+
48
+ ```bash
49
+ cp .env.example .env
50
+ ```
51
+
52
+ Then open `.env` and paste your TMDB key in.
53
+
54
+ ### 4. Set up the database (optional, happens automatically anyway)
55
+
56
+ The server creates the SQLite database on first run, at `~/.local/share/letterbox-buddy/db.sqlite3` by default. Want it somewhere else? Set `LETTERBOX_DB_PATH` in your `.env`.
57
+
58
+ If you want to run the migration manually for whatever reason:
59
+
60
+ ```bash
61
+ python -m letterbox_buddy.db.migrate
62
+ ```
63
+
64
+ ### 5. Connect it to Claude Desktop
65
+
66
+ Add this to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
67
+
68
+ ```json
69
+ {
70
+ "mcpServers": {
71
+ "letterbox-buddy": {
72
+ "command": "/absolute/path/to/letterbox-buddy-mcp/.venv/bin/python",
73
+ "args": ["-m", "letterbox_buddy.server"]
74
+ }
75
+ }
76
+ }
77
+ ```
78
+
79
+ Restart Claude Desktop (fully quit, not just close the window) and you should see the tools show up.
80
+
81
+ Once this is published to PyPI, this whole setup collapses into just running `uvx letterbox-buddy` - no clone, no venv, nothing to install by hand.
82
+
83
+ ## Notes on design decisions
84
+
85
+ A few choices here look "wrong" if you're used to bigger projects, so here's the reasoning behind them.
86
+
87
+ **Why SQLite and no auth.** Single machine, single user, nothing to authenticate against - Postgres and JWT would just be unused infrastructure here.
88
+
89
+ **Why no separate LLM API.** Claude is already right there doing the conversation, so a second LLM would just duplicate work for no reason. The server stays dumb on purpose - fetch/store/return data, all the thinking happens in the chat.
90
+
91
+ **Why Letterboxd posting is copy-paste only.** There's no public API for posting on someone's behalf, and I'd rather the last step stay a deliberate human action anyway.
92
+
93
+ **Why "letterbox" not "letterboxd".** Not affiliated with Letterboxd, didn't want the name to imply a partnership that doesn't exist.
94
+
95
+ ## Known limitations / what this isn't
96
+
97
+ - Error handling is minimal - happy path only.
98
+ - No TMDB rate-limit handling or caching.
99
+ - Single machine, no sync between devices.
100
+ - No automated tests, everything checked manually.
101
+ - Single-user only, no accounts.
102
+ - Not on PyPI yet, schema may still change.
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "letterbox-buddy"
7
+ version = "0.1.0"
8
+ description = "An MCP server that turns your rough movie notes into polished reviews and tracks your watch history"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "alpondr" },
14
+ ]
15
+ keywords = ["mcp", "model-context-protocol", "letterboxd", "movies", "claude"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: End Users/Desktop",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ ]
25
+ dependencies = [
26
+ "mcp>=1.0.0",
27
+ "httpx>=0.27.0",
28
+ "python-dotenv>=1.0.0",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/alpondr/letterbox-buddy-mcp"
33
+ Repository = "https://github.com/alpondr/letterbox-buddy-mcp"
34
+
35
+ [project.scripts]
36
+ letterbox-buddy = "letterbox_buddy.server:main"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/letterbox_buddy"]
File without changes
@@ -0,0 +1,18 @@
1
+ from .connection import get_connection, get_db_path
2
+ from .migrate import run_migrations
3
+ from .movies import get_or_create_movie
4
+ from .stats import get_director_stats, get_genre_counts, get_watched_tmdb_ids
5
+ from .watch_logs import add_watch_log, get_watch_history, update_polished_review
6
+
7
+ __all__ = [
8
+ "get_connection",
9
+ "get_db_path",
10
+ "run_migrations",
11
+ "get_or_create_movie",
12
+ "add_watch_log",
13
+ "get_watch_history",
14
+ "update_polished_review",
15
+ "get_director_stats",
16
+ "get_genre_counts",
17
+ "get_watched_tmdb_ids",
18
+ ]
@@ -0,0 +1,17 @@
1
+ import os
2
+ import sqlite3
3
+ from pathlib import Path
4
+
5
+
6
+ def get_db_path() -> Path:
7
+ override = os.environ.get("LETTERBOX_DB_PATH")
8
+ path = Path(override).expanduser() if override else Path.home() / ".local" / "share" / "letterbox-buddy" / "db.sqlite3"
9
+ path.parent.mkdir(parents=True, exist_ok=True)
10
+ return path
11
+
12
+
13
+ def get_connection() -> sqlite3.Connection:
14
+ conn = sqlite3.connect(get_db_path())
15
+ conn.execute("PRAGMA foreign_keys = ON")
16
+ conn.row_factory = sqlite3.Row
17
+ return conn
@@ -0,0 +1,23 @@
1
+ from pathlib import Path
2
+
3
+ from .connection import get_connection, get_db_path
4
+
5
+ SCHEMA_PATH = Path(__file__).parent / "schema.sql"
6
+
7
+
8
+ def run_migrations() -> None:
9
+ conn = get_connection()
10
+ try:
11
+ conn.executescript(SCHEMA_PATH.read_text())
12
+ conn.commit()
13
+ finally:
14
+ conn.close()
15
+
16
+
17
+ def main() -> None:
18
+ run_migrations()
19
+ print(f"Database ready at {get_db_path()}")
20
+
21
+
22
+ if __name__ == "__main__":
23
+ main()
@@ -0,0 +1,28 @@
1
+ from .connection import get_connection
2
+
3
+
4
+ def get_or_create_movie(details: dict) -> int:
5
+ conn = get_connection()
6
+ try:
7
+ row = conn.execute("SELECT id FROM movies WHERE tmdb_id = ?", (details["tmdb_id"],)).fetchone()
8
+ if row:
9
+ return row["id"]
10
+
11
+ cursor = conn.execute(
12
+ """
13
+ INSERT INTO movies (tmdb_id, title, director, year, genres, poster_path)
14
+ VALUES (?, ?, ?, ?, ?, ?)
15
+ """,
16
+ (
17
+ details["tmdb_id"],
18
+ details["title"],
19
+ details.get("director"),
20
+ details.get("year"),
21
+ details.get("genres"),
22
+ details.get("poster_path"),
23
+ ),
24
+ )
25
+ conn.commit()
26
+ return cursor.lastrowid
27
+ finally:
28
+ conn.close()
@@ -0,0 +1,25 @@
1
+ CREATE TABLE IF NOT EXISTS movies (
2
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3
+ tmdb_id INTEGER UNIQUE NOT NULL,
4
+ title TEXT NOT NULL,
5
+ director TEXT,
6
+ year INTEGER,
7
+ genres TEXT,
8
+ poster_path TEXT,
9
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
10
+ );
11
+
12
+ CREATE TABLE IF NOT EXISTS watch_logs (
13
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
14
+ movie_id INTEGER NOT NULL REFERENCES movies(id),
15
+ watched_date TEXT,
16
+ raw_note TEXT NOT NULL,
17
+ polished_review TEXT,
18
+ rating REAL CHECK (rating IS NULL OR (rating >= 0.5 AND rating <= 5.0)),
19
+ is_deleted INTEGER NOT NULL DEFAULT 0,
20
+ deleted_at TEXT,
21
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
22
+ );
23
+
24
+ CREATE INDEX IF NOT EXISTS idx_watch_logs_movie_id ON watch_logs(movie_id);
25
+ CREATE INDEX IF NOT EXISTS idx_watch_logs_is_deleted ON watch_logs(is_deleted);
@@ -0,0 +1,66 @@
1
+ from .connection import get_connection
2
+
3
+
4
+ def get_director_stats() -> list[dict]:
5
+ conn = get_connection()
6
+ try:
7
+ rows = conn.execute(
8
+ """
9
+ SELECT m.director,
10
+ COUNT(*) AS watch_count,
11
+ AVG(wl.rating) AS avg_rating,
12
+ GROUP_CONCAT(m.title, ', ') AS titles
13
+ FROM watch_logs wl
14
+ JOIN movies m ON m.id = wl.movie_id
15
+ WHERE wl.is_deleted = 0 AND m.director IS NOT NULL
16
+ GROUP BY m.director
17
+ ORDER BY watch_count DESC
18
+ """
19
+ ).fetchall()
20
+ return [dict(row) for row in rows]
21
+ finally:
22
+ conn.close()
23
+
24
+
25
+ def get_genre_counts() -> list[dict]:
26
+ conn = get_connection()
27
+ try:
28
+ rows = conn.execute(
29
+ """
30
+ SELECT m.genres
31
+ FROM watch_logs wl
32
+ JOIN movies m ON m.id = wl.movie_id
33
+ WHERE wl.is_deleted = 0 AND m.genres IS NOT NULL AND m.genres != ''
34
+ """
35
+ ).fetchall()
36
+ finally:
37
+ conn.close()
38
+
39
+ counts: dict[str, int] = {}
40
+ for row in rows:
41
+ for genre in row["genres"].split(", "):
42
+ genre = genre.strip()
43
+ if genre:
44
+ counts[genre] = counts.get(genre, 0) + 1
45
+
46
+ return sorted(
47
+ ({"genre": g, "watch_count": c} for g, c in counts.items()),
48
+ key=lambda x: x["watch_count"],
49
+ reverse=True,
50
+ )
51
+
52
+
53
+ def get_watched_tmdb_ids() -> set[int]:
54
+ conn = get_connection()
55
+ try:
56
+ rows = conn.execute(
57
+ """
58
+ SELECT DISTINCT m.tmdb_id
59
+ FROM watch_logs wl
60
+ JOIN movies m ON m.id = wl.movie_id
61
+ WHERE wl.is_deleted = 0
62
+ """
63
+ ).fetchall()
64
+ return {row["tmdb_id"] for row in rows}
65
+ finally:
66
+ conn.close()
@@ -0,0 +1,65 @@
1
+ from .connection import get_connection
2
+
3
+
4
+ def add_watch_log(movie_id: int, raw_note: str, rating: float | None, watched_date: str | None) -> int:
5
+ conn = get_connection()
6
+ try:
7
+ cursor = conn.execute(
8
+ """
9
+ INSERT INTO watch_logs (movie_id, watched_date, raw_note, rating)
10
+ VALUES (?, ?, ?, ?)
11
+ """,
12
+ (movie_id, watched_date, raw_note, rating),
13
+ )
14
+ conn.commit()
15
+ return cursor.lastrowid
16
+ finally:
17
+ conn.close()
18
+
19
+
20
+ def update_polished_review(log_id: int, polished_review: str) -> None:
21
+ conn = get_connection()
22
+ try:
23
+ conn.execute(
24
+ "UPDATE watch_logs SET polished_review = ? WHERE id = ? AND is_deleted = 0",
25
+ (polished_review, log_id),
26
+ )
27
+ conn.commit()
28
+ finally:
29
+ conn.close()
30
+
31
+
32
+ def get_watch_history(
33
+ director: str | None = None,
34
+ genre: str | None = None,
35
+ date_from: str | None = None,
36
+ date_to: str | None = None,
37
+ ) -> list[dict]:
38
+ conn = get_connection()
39
+ try:
40
+ query = """
41
+ SELECT wl.id, wl.watched_date, wl.raw_note, wl.polished_review, wl.rating,
42
+ m.title, m.director, m.year, m.genres
43
+ FROM watch_logs wl
44
+ JOIN movies m ON m.id = wl.movie_id
45
+ WHERE wl.is_deleted = 0
46
+ """
47
+ params: list = []
48
+ if director:
49
+ query += " AND m.director = ?"
50
+ params.append(director)
51
+ if genre:
52
+ query += " AND m.genres LIKE ?"
53
+ params.append(f"%{genre}%")
54
+ if date_from:
55
+ query += " AND wl.watched_date >= ?"
56
+ params.append(date_from)
57
+ if date_to:
58
+ query += " AND wl.watched_date <= ?"
59
+ params.append(date_to)
60
+ query += " ORDER BY wl.watched_date DESC"
61
+
62
+ rows = conn.execute(query, params).fetchall()
63
+ return [dict(row) for row in rows]
64
+ finally:
65
+ conn.close()
@@ -0,0 +1,32 @@
1
+ from dotenv import load_dotenv
2
+
3
+ load_dotenv()
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from letterbox_buddy.db import run_migrations
8
+ from letterbox_buddy.tools import history, letterboxd, movies, recommendations, stats
9
+
10
+ mcp = FastMCP("letterbox-buddy")
11
+
12
+
13
+ @mcp.tool()
14
+ def ping() -> str:
15
+ """Health check tool. Returns 'pong' if the server is alive."""
16
+ return "pong"
17
+
18
+
19
+ movies.register(mcp)
20
+ history.register(mcp)
21
+ stats.register(mcp)
22
+ recommendations.register(mcp)
23
+ letterboxd.register(mcp)
24
+
25
+
26
+ def main() -> None:
27
+ run_migrations()
28
+ mcp.run()
29
+
30
+
31
+ if __name__ == "__main__":
32
+ main()
@@ -0,0 +1,17 @@
1
+ from mcp.server.fastmcp import FastMCP
2
+
3
+ from letterbox_buddy.db import get_watch_history as query_watch_history
4
+
5
+
6
+ def register(mcp: FastMCP) -> None:
7
+ @mcp.tool()
8
+ def get_watch_history(
9
+ director: str | None = None,
10
+ genre: str | None = None,
11
+ date_from: str | None = None,
12
+ date_to: str | None = None,
13
+ ) -> list[dict]:
14
+ """List watch history (soft-deleted entries excluded), optionally
15
+ filtered by director, genre (substring match), or a watched_date
16
+ range (YYYY-MM-DD, inclusive on both ends)."""
17
+ return query_watch_history(director=director, genre=genre, date_from=date_from, date_to=date_to)
@@ -0,0 +1,15 @@
1
+ from mcp.server.fastmcp import FastMCP
2
+
3
+ from letterbox_buddy.db import update_polished_review
4
+
5
+
6
+ def register(mcp: FastMCP) -> None:
7
+ @mcp.tool()
8
+ def generate_letterboxd_text(log_id: int, polished_review: str) -> str:
9
+ """Save the polished review for a watch log and return it formatted
10
+ for pasting straight into Letterboxd's review box. Plain text only
11
+ - no title, year, or star rating, since Letterboxd already shows
12
+ those separately when logging a film. Copy-paste, no auto-posting."""
13
+ text = polished_review.strip()
14
+ update_polished_review(log_id, text)
15
+ return text
@@ -0,0 +1,39 @@
1
+ from mcp.server.fastmcp import FastMCP
2
+
3
+ from letterbox_buddy.db import add_watch_log, get_or_create_movie
4
+ from letterbox_buddy.utils.tmdb import get_movie_details, search_movies
5
+
6
+
7
+ def register(mcp: FastMCP) -> None:
8
+ @mcp.tool()
9
+ async def search_movie(query: str) -> list[dict]:
10
+ """Search for a movie on TMDB by title. Returns candidate matches
11
+ (tmdb_id, title, year, overview, poster_path) so the caller can
12
+ disambiguate when there's more than one result."""
13
+ results = await search_movies(query)
14
+ return [
15
+ {
16
+ "tmdb_id": r["id"],
17
+ "title": r["title"],
18
+ "year": r["release_date"][:4] if r.get("release_date") else None,
19
+ "overview": r.get("overview"),
20
+ "poster_path": r.get("poster_path"),
21
+ }
22
+ for r in results[:10]
23
+ ]
24
+
25
+ @mcp.tool()
26
+ async def log_movie_note(
27
+ tmdb_id: int,
28
+ raw_note: str,
29
+ rating: float | None = None,
30
+ watched_date: str | None = None,
31
+ ) -> dict:
32
+ """Save a watch log entry for a movie identified by its TMDB id.
33
+ Fetches director/genres/year from TMDB the first time this movie is
34
+ logged. rating is 0.5-5.0 in Letterboxd star steps. watched_date is
35
+ an ISO date string (YYYY-MM-DD) if provided."""
36
+ details = await get_movie_details(tmdb_id)
37
+ movie_id = get_or_create_movie(details)
38
+ log_id = add_watch_log(movie_id, raw_note, rating, watched_date)
39
+ return {"log_id": log_id, "movie": details}
@@ -0,0 +1,38 @@
1
+ from mcp.server.fastmcp import FastMCP
2
+
3
+ from letterbox_buddy.db import get_director_stats, get_genre_counts, get_watched_tmdb_ids
4
+ from letterbox_buddy.utils.tmdb import discover_by_genre, get_director_filmography, search_person
5
+
6
+
7
+ def register(mcp: FastMCP) -> None:
8
+ @mcp.tool()
9
+ async def get_recommendations() -> dict:
10
+ """Returns raw data ONLY, no opinions or ranking commentary: the
11
+ most-watched director and genre from watch history, plus unwatched
12
+ TMDB movies matching each. The connected assistant is expected to
13
+ turn this into an actual recommendation."""
14
+ director_stats = get_director_stats()
15
+ genre_counts = get_genre_counts()
16
+ watched_ids = get_watched_tmdb_ids()
17
+
18
+ top_director = director_stats[0]["director"] if director_stats else None
19
+ top_genre = genre_counts[0]["genre"] if genre_counts else None
20
+
21
+ unwatched_by_director: list[dict] = []
22
+ if top_director:
23
+ person_id = await search_person(top_director)
24
+ if person_id:
25
+ filmography = await get_director_filmography(person_id)
26
+ unwatched_by_director = [m for m in filmography if m["tmdb_id"] not in watched_ids]
27
+
28
+ unwatched_by_genre: list[dict] = []
29
+ if top_genre:
30
+ candidates = await discover_by_genre(top_genre)
31
+ unwatched_by_genre = [m for m in candidates if m["tmdb_id"] not in watched_ids]
32
+
33
+ return {
34
+ "top_director": top_director,
35
+ "top_genre": top_genre,
36
+ "unwatched_by_director": unwatched_by_director,
37
+ "unwatched_by_genre": unwatched_by_genre,
38
+ }
@@ -0,0 +1,12 @@
1
+ from mcp.server.fastmcp import FastMCP
2
+
3
+ from letterbox_buddy.db import get_director_stats as query_director_stats
4
+
5
+
6
+ def register(mcp: FastMCP) -> None:
7
+ @mcp.tool()
8
+ def get_director_stats() -> list[dict]:
9
+ """Per-director watch counts, average rating, and watched titles,
10
+ sorted by watch_count descending. Directors with no logged movies
11
+ won't appear (soft-deleted entries excluded)."""
12
+ return query_director_stats()
@@ -0,0 +1,128 @@
1
+ import os
2
+
3
+ import httpx
4
+
5
+ TMDB_BASE_URL = "https://api.themoviedb.org/3"
6
+
7
+
8
+ class TMDBError(Exception):
9
+ pass
10
+
11
+
12
+ def _get_api_key() -> str:
13
+ api_key = os.environ.get("TMDB_API_KEY")
14
+ if not api_key:
15
+ raise TMDBError("TMDB_API_KEY is not set. Add it to your .env file.")
16
+ return api_key
17
+
18
+
19
+ async def search_movies(query: str) -> list[dict]:
20
+ async with httpx.AsyncClient() as client:
21
+ response = await client.get(
22
+ f"{TMDB_BASE_URL}/search/movie",
23
+ params={"api_key": _get_api_key(), "query": query, "language": "en-US"},
24
+ )
25
+ response.raise_for_status()
26
+ return response.json()["results"]
27
+
28
+
29
+ async def get_movie_details(tmdb_id: int) -> dict:
30
+ async with httpx.AsyncClient() as client:
31
+ response = await client.get(
32
+ f"{TMDB_BASE_URL}/movie/{tmdb_id}",
33
+ params={
34
+ "api_key": _get_api_key(),
35
+ "language": "en-US",
36
+ "append_to_response": "credits",
37
+ },
38
+ )
39
+ response.raise_for_status()
40
+ data = response.json()
41
+
42
+ director = next(
43
+ (c["name"] for c in data.get("credits", {}).get("crew", []) if c.get("job") == "Director"),
44
+ None,
45
+ )
46
+ return {
47
+ "tmdb_id": data["id"],
48
+ "title": data["title"],
49
+ "director": director,
50
+ "year": int(data["release_date"][:4]) if data.get("release_date") else None,
51
+ "genres": ", ".join(g["name"] for g in data.get("genres", [])),
52
+ "poster_path": data.get("poster_path"),
53
+ }
54
+
55
+
56
+ async def search_person(name: str) -> int | None:
57
+ async with httpx.AsyncClient() as client:
58
+ response = await client.get(
59
+ f"{TMDB_BASE_URL}/search/person",
60
+ params={"api_key": _get_api_key(), "query": name},
61
+ )
62
+ response.raise_for_status()
63
+ results = response.json()["results"]
64
+ return results[0]["id"] if results else None
65
+
66
+
67
+ async def get_director_filmography(person_id: int) -> list[dict]:
68
+ async with httpx.AsyncClient() as client:
69
+ response = await client.get(
70
+ f"{TMDB_BASE_URL}/person/{person_id}/movie_credits",
71
+ params={"api_key": _get_api_key(), "language": "en-US"},
72
+ )
73
+ response.raise_for_status()
74
+ data = response.json()
75
+
76
+ return [
77
+ {
78
+ "tmdb_id": c["id"],
79
+ "title": c["title"],
80
+ "year": c["release_date"][:4] if c.get("release_date") else None,
81
+ }
82
+ for c in data.get("crew", [])
83
+ if c.get("job") == "Director"
84
+ ]
85
+
86
+
87
+ _genre_name_to_id: dict[str, int] | None = None
88
+
89
+
90
+ async def _get_genre_id(genre_name: str) -> int | None:
91
+ global _genre_name_to_id
92
+ if _genre_name_to_id is None:
93
+ async with httpx.AsyncClient() as client:
94
+ response = await client.get(
95
+ f"{TMDB_BASE_URL}/genre/movie/list",
96
+ params={"api_key": _get_api_key(), "language": "en-US"},
97
+ )
98
+ response.raise_for_status()
99
+ _genre_name_to_id = {g["name"]: g["id"] for g in response.json()["genres"]}
100
+ return _genre_name_to_id.get(genre_name)
101
+
102
+
103
+ async def discover_by_genre(genre_name: str, limit: int = 10) -> list[dict]:
104
+ genre_id = await _get_genre_id(genre_name)
105
+ if genre_id is None:
106
+ return []
107
+
108
+ async with httpx.AsyncClient() as client:
109
+ response = await client.get(
110
+ f"{TMDB_BASE_URL}/discover/movie",
111
+ params={
112
+ "api_key": _get_api_key(),
113
+ "with_genres": genre_id,
114
+ "sort_by": "popularity.desc",
115
+ "language": "en-US",
116
+ },
117
+ )
118
+ response.raise_for_status()
119
+ results = response.json()["results"]
120
+
121
+ return [
122
+ {
123
+ "tmdb_id": r["id"],
124
+ "title": r["title"],
125
+ "year": r["release_date"][:4] if r.get("release_date") else None,
126
+ }
127
+ for r in results[:limit]
128
+ ]