Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,11 @@ SUPPORTED_PROVIDERS=netflix,disney_plus,prime_video,apple_tv_plus
NEW_RELEASE_DAYS=90
RECENTLY_ADDED_DAYS=30
LEAVING_SOON_DAYS=30

# Bounded TMDB enrichment policy. Backfills can override max titles explicitly.
TMDB_ENRICHMENT_MAX_TITLES_PER_RUN=250
TMDB_ENRICHMENT_UPCOMING_REFRESH_DAYS=7
TMDB_ENRICHMENT_RECENT_REFRESH_DAYS=30
TMDB_ENRICHMENT_SERIES_REFRESH_DAYS=90
TMDB_ENRICHMENT_MOVIE_REFRESH_DAYS=180
TMDB_ENRICHMENT_PROVIDER_REFRESH_DAYS=60
44 changes: 41 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,11 +175,23 @@ make frontend-dev
```

Open `http://127.0.0.1:5173`. The v0.5 frontend reads API health, catalog
freshness, reference filters, Top 10, New Releases, and Recently Added from
FastAPI, proving the complete local serving path. The browser never calls either
upstream data provider. See the
freshness, reference filters, Top 10, New Releases, Recently Added, and Upcoming
from FastAPI, proving the complete local serving path. The browser never calls
either upstream data provider. See the
[frontend guide](frontend/README.md) for configuration and quality commands.

Lifecycle-only titles can be enriched incrementally with canonical TMDB
metadata and current watch-provider evidence without repeating broad discovery:

```bash
python -m ingestion.enrich_streaming_metadata --event-type upcoming --country GR
make dbt-publish
```

The command independently skips title IDs with retained metadata or provider
responses and reports its bounded
TMDB request count, and makes no Streaming Availability requests.

## Dependency management

`pyproject.toml` is the canonical project configuration. It contains package
Expand Down Expand Up @@ -300,6 +312,32 @@ only be used for a bounded set while the incremental enrichment queue is built:
python -m ingestion.run --max-pages 1 --enrich
```

Plan the dedicated catalog enrichment locally before making any TMDB requests:

```bash
python -m ingestion.enrich_catalog --mode incremental --dry-run
```

The initial broad backfill selects titles that have never retained metadata or
provider payloads. Set an explicit cap appropriate for the catalog size:

```bash
python -m ingestion.enrich_catalog --mode backfill --max-titles 10000 --dry-run
python -m ingestion.enrich_catalog --mode backfill --max-titles 10000
```

Afterward, routine incremental runs deduplicate titles, prioritize product
value, and refresh only new or stale payloads:

```bash
python -m ingestion.enrich_catalog --mode incremental
```

Add `--metadata-only` to skip watch-provider payloads. Add
`--plan-output data/enrichment-plan.json` to save the complete local plan for
inspection. Planning never calls TMDB; only execution does. Completed batches
are retained as immutable Parquet files, allowing later plans to resume.

To override the configured regions for one run, repeat `--country`:

```bash
Expand Down
34 changes: 33 additions & 1 deletion api/tests/test_discovery_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ def _catalog_row(
"tmdb_discovery" if is_available else "streaming_availability",
datetime.now(UTC),
"https://www.netflix.com/title/example" if provider_key == "netflix" else None,
None,
None,
)


Expand All @@ -96,7 +98,7 @@ def _create_catalog(path: Path) -> None:
is_available boolean, is_upcoming boolean, poster_path varchar,
backdrop_path varchar, metadata_source varchar,
availability_source varchar, last_updated_at timestamptz,
watch_url varchar
watch_url varchar, episode_count integer, season_count integer
)
"""
)
Expand Down Expand Up @@ -251,6 +253,35 @@ def test_region_and_provider_never_leak_across_http_routes(catalog_app: FastAPI)
assert [item["tmdb_id"] for item in greek_disney.json()["items"]] == [5]


def test_title_search_uses_local_scoped_catalog(catalog_app: FastAPI) -> None:
response = _get(
catalog_app,
"/api/v1/discovery/search",
[("query", "Title 1"), ("region", "GR"), ("providers", "netflix")],
)
other_region = _get(
catalog_app,
"/api/v1/discovery/search",
[("query", "Title 1"), ("region", "US"), ("providers", "netflix")],
)

assert response.status_code == 200
assert response.json()["query"] == "Title 1"
assert [item["tmdb_id"] for item in response.json()["items"]] == [1]
assert other_region.json()["items"] == []


def test_title_search_treats_sql_wildcards_as_text(catalog_app: FastAPI) -> None:
response = _get(
catalog_app,
"/api/v1/discovery/search",
[("query", "%_"), ("region", "GR"), ("providers", "netflix")],
)

assert response.status_code == 200
assert response.json()["items"] == []


def test_api_exposes_verified_watch_urls_with_scoped_availability(
catalog_app: FastAPI,
) -> None:
Expand Down Expand Up @@ -322,6 +353,7 @@ def test_openapi_contains_the_complete_v04_contract(catalog_app: FastAPI) -> Non
"/api/v1/catalog/genres",
"/api/v1/catalog/filter-options",
"/api/v1/discovery/top-10",
"/api/v1/discovery/search",
"/api/v1/discovery/new-releases",
"/api/v1/discovery/recently-added",
"/api/v1/discovery/upcoming",
Expand Down
6 changes: 4 additions & 2 deletions api/tests/test_query_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ def _create_catalog(path: Path) -> None:
metadata_source varchar,
availability_source varchar,
last_updated_at timestamp,
watch_url varchar
watch_url varchar,
episode_count integer,
season_count integer
)
"""
)
Expand Down Expand Up @@ -201,7 +203,7 @@ def _create_catalog(path: Path) -> None:
datetime(2026, 8, 22),
),
]
rows = [(*row, None) for row in rows]
rows = [(*row, None, None, None) for row in rows]
placeholders = ", ".join("?" for _ in rows[0])
connection.executemany(
f"insert into main_marts.catalog_availability values ({placeholders})",
Expand Down
6 changes: 6 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,12 @@ filters are applied. It must never claim to be an official provider Top 10.
- Broad TMDB catalog backfills are discovery-only and reuse the metadata already
present in discovery responses.
- Incremental jobs enrich only new, changed, or prioritized TMDB titles.
- Catalog enrichment has two explicit modes. One-time `backfill` selects titles
with no retained metadata/provider payload. Routine `incremental` mode uses
configurable freshness windows and a per-run title cap, deduplicates
`(tmdb_id, content_type)` across regions and providers, and prioritizes
upcoming, recently added, recently released, Top 10, incomplete, and popular
titles. Planning reads only WatchPulse-owned data and supports dry runs.
- Periodic reconciliation corrects missed upstream lifecycle changes.
- HTTP clients use timeouts, conservative throttling, capped retries, and
exponential backoff for transport errors, rate limits, and server errors.
Expand Down
18 changes: 18 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,24 @@ frontend renders an external link only for validated HTTPS values, opens it in
a separate tab with safe relationship attributes, and leaves other provider
badges informational.

## ADR-020: Incrementally enrich lifecycle-only titles from TMDB

Status: Accepted (2026-08-24)

Streaming lifecycle events can introduce upcoming or newly added TMDB IDs that
are absent from the current provider discovery snapshot. Embedded lifecycle
metadata may omit descriptions and does not provide TMDB poster paths.

WatchPulse extracts the stable TMDB ID from retained lifecycle data and fetches
metadata plus current watch-provider evidence in a separate bounded ingestion
command. Raw responses are stored locally. `stg_tmdb_metadata` takes precedence
over broad discovery and streaming metadata fallbacks, while targeted provider
evidence reconciles current availability with lifecycle announcements. An
already-available series may therefore be both current and upcoming; the UI
labels this as a new season without claiming a season number absent upstream.
Browser requests never trigger enrichment, and each retained response type is
skipped independently on subsequent runs.

## Open decisions

These remain unresolved and should receive new ADRs when decided:
Expand Down
6 changes: 6 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,12 @@ independent loading, empty, failure, and retry behavior.
The sixth increment adds Recently Added based on provider addition time rather
than content release date, and extracts a reusable lifecycle rail for consistent
loading, empty, failure, retry, and card behavior across the remaining sections.
The seventh increment adds Upcoming from future provider arrival evidence and
surfaces expected arrival dates directly on cards without browser-side upstream
requests. Targeted TMDB reconciliation lets an available series also carry an
upcoming-season state without guessing a season number.
It also consolidates discovery into a content-first workspace with a sticky
desktop filter sidebar, denser rails, and a responsive stacked mobile layout.
Verified provider deep links are retained from historical lifecycle payloads
and exposed as optional safe card actions; TMDB-only rows remain unlinked
rather than receiving guessed destinations.
Expand Down
6 changes: 6 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ state and the same query-parameter contract as every FastAPI discovery section.
The Top 10, New Releases, and Recently Added rails query only FastAPI and render
reusable poster cards; ranking badges remain specific to Top 10. Recently Added
uses provider addition time and remains distinct from content release recency.
Upcoming uses future provider arrival evidence and displays the expected date.
An already-available series may also appear there as a new-season announcement.
On desktop, region, provider, and global controls remain in a sticky sidebar
while discovery rails use the wider content workspace. The layout stacks for
touch and narrow screens, and every control still updates local API queries
immediately without an Apply action.
Poster artwork is loaded from TMDB's public image CDN using the ingested
`poster_path`; this is an image asset request, not a TMDB API lookup, and it
uses no API key or discovery quota. Missing or failed images have a local
Expand Down
1 change: 1 addition & 0 deletions frontend/public/tmdb-logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 8 additions & 1 deletion frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,15 @@ test("shows catalog readiness returned by the local API", async () => {

render(<App />);
expect(screen.getByText("Connecting to WatchPulse…")).toBeInTheDocument();
expect(await screen.findByText(/Updated/)).toBeInTheDocument();
const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
expect(await screen.findByText(/Updated/)).toHaveTextContent(`(${timeZone})`);
expect(await screen.findByRole("button", { name: /Netflix/ })).toBeInTheDocument();
expect(screen.getByRole("img", { name: "The Movie Database (TMDB)" })).toBeInTheDocument();
expect(screen.getByText(/uses TMDB and the TMDB APIs/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: "JustWatch" })).toHaveAttribute(
"href",
"https://www.justwatch.com",
);
});

test("shows an honest error when the catalog is unavailable", async () => {
Expand Down
80 changes: 56 additions & 24 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { GlobalFilters } from "./components/GlobalFilters";
import { NewReleasesRail } from "./components/NewReleasesRail";
import { RecentlyAddedRail } from "./components/RecentlyAddedRail";
import { TopTenRail } from "./components/TopTenRail";
import { TitleSearch } from "./components/TitleSearch";
import { UpcomingRail } from "./components/UpcomingRail";
import { type CatalogScope, type GlobalFilters as FilterValue } from "./discovery";
import { loadPreferences } from "./preferences";
import "./styles.css";
Expand All @@ -14,10 +16,14 @@ type LoadState =
| { kind: "ready"; status: CatalogStatus }
| { kind: "error"; message: string };

const formatRefresh = (value: string) =>
new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(
new Date(value),
);
const formatRefresh = (value: string) => {
const formatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
const timeZone = formatter.resolvedOptions().timeZone || "UTC";
return `${formatter.format(new Date(value))} (${timeZone})`;
};

export default function App() {
const [state, setState] = useState<LoadState>({ kind: "loading" });
Expand Down Expand Up @@ -58,26 +64,52 @@ export default function App() {
<span className="freshness"><span className="status-dot" />Updated {formatRefresh(state.status.freshness.latest_source_updated_at)}</span>
) : <span className="version">v0.5 preview</span>}
</nav>
<section className="hero">
<p className="eyebrow">YOUR STREAMS. ONE DECISION.</p>
<h1>Find your next <span>great watch.</span></h1>
<p className="intro">WatchPulse searches the catalog available on your services and in your region—without the endless scroll.</p>
</section>
{state.kind === "loading" && <p className="catalog-notice" aria-live="polite">Connecting to WatchPulse…</p>}
{state.kind === "error" && <p className="catalog-notice error" role="alert">{state.message}</p>}
{state.kind === "ready" && <DiscoverySetup onScopeChange={handleScopeChange} />}
{scope && scope.providers.length > 0 && (
<>
<GlobalFilters scope={scope} value={filters} onChange={setFilters} />
<TopTenRail scope={scope} filters={filters} />
<NewReleasesRail scope={scope} filters={filters} />
<RecentlyAddedRail scope={scope} filters={filters} />
</>
)}
{scope && scope.providers.length === 0 && (
<p className="selection-prompt">Select at least one streaming service to refine your catalog.</p>
)}
<p className="coming-next">Upcoming and Leaving Soon arrive next.</p>
<div className="app-layout">
<aside className="discovery-sidebar" aria-label="Discovery controls">
{state.kind === "loading" && <p className="catalog-notice" aria-live="polite">Connecting to WatchPulse…</p>}
{state.kind === "error" && <p className="catalog-notice error" role="alert">{state.message}</p>}
{state.kind === "ready" && <DiscoverySetup onScopeChange={handleScopeChange} />}
{scope && scope.providers.length > 0 && (
<GlobalFilters scope={scope} value={filters} onChange={setFilters} />
)}
</aside>

<div className="content-workspace">
<section className="hero">
<p className="eyebrow">YOUR STREAM. ONE DECISION.</p>
<h1>Find your next <span>great watch.</span></h1>
<p className="intro">WatchPulse searches the catalog available on your services and in your region—without the endless scroll.</p>
</section>
{scope && scope.providers.length > 0 && (
<>
<TitleSearch scope={scope} filters={filters} />
<TopTenRail scope={scope} filters={filters} />
<NewReleasesRail scope={scope} filters={filters} />
<RecentlyAddedRail scope={scope} filters={filters} />
<UpcomingRail scope={scope} filters={filters} />
</>
)}
{scope && scope.providers.length === 0 && (
<p className="selection-prompt">Select at least one streaming service to start discovering titles.</p>
)}
<p className="coming-next">Leaving Soon and title details arrive next.</p>
</div>
</div>
<footer className="credits" aria-label="Credits">
<a
className="tmdb-credit"
href="https://www.themoviedb.org"
target="_blank"
rel="noopener noreferrer"
aria-label="Visit The Movie Database"
>
<img src="/tmdb-logo.svg" alt="The Movie Database (TMDB)" />
</a>
<div>
<p>This website uses TMDB and the TMDB APIs but is not endorsed, certified, or otherwise approved by TMDB.</p>
<p>Streaming availability data provided in part by <a href="https://www.justwatch.com" target="_blank" rel="noopener noreferrer">JustWatch</a>.</p>
</div>
</footer>
</main>
);
}
29 changes: 29 additions & 0 deletions frontend/src/api/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export type CatalogItem = {
release_date: string | null;
release_year: number | null;
runtime_minutes: number | null;
episode_count?: number | null;
season_count?: number | null;
original_language: string | null;
genre_ids: number[];
genre_names: string[];
Expand Down Expand Up @@ -161,3 +163,30 @@ export async function getRecentlyAdded(
);
return response.items;
}

export async function getUpcoming(
scope: CatalogScope,
filters: GlobalFilters,
signal?: AbortSignal,
): Promise<CatalogItem[]> {
const response = await getJson<{ items: CatalogItem[] }>(
`/api/v1/discovery/upcoming?${discoveryParams(scope, filters)}`,
signal,
);
return response.items;
}

export async function searchTitles(
query: string,
scope: CatalogScope,
filters: GlobalFilters,
signal?: AbortSignal,
): Promise<CatalogItem[]> {
const params = discoveryParams(scope, filters);
params.set("query", query);
const response = await getJson<{ items: CatalogItem[] }>(
`/api/v1/discovery/search?${params}`,
signal,
);
return response.items;
}
Loading