Skip to content

feat(recs): /api/videos/:id/related — same-channel + FTS + trending fill#65

Merged
aloewright merged 1 commit into
mainfrom
feat/related-videos
May 5, 2026
Merged

feat(recs): /api/videos/:id/related — same-channel + FTS + trending fill#65
aloewright merged 1 commit into
mainfrom
feat/related-videos

Conversation

@aloewright
Copy link
Copy Markdown
Owner

@aloewright aloewright commented May 5, 2026

Summary

  • New /api/videos/:id/related endpoint that returns related videos via three deduped passes: same channel by recency → FTS5 over the source title → trending top-up.
  • Watch.tsx now calls /related instead of the stopgap /trending for the up-next sidebar.
  • Per-(id, limit) KV cache with a 5-minute TTL.

Hidden / soft-deleted / DMCA-disabled / non-ready rows are filtered at query time so takedowns disappear from related lists immediately. Reuses buildFtsQuery from search.ts so FTS5 sanitisation stays in one place.

Closes ALO-152.

Test plan

  • npm run lint — clean
  • npm run type-check — clean
  • npm test -- --run — 414/414 passing (+8 new tests)
  • Manual: open a watch page; up-next list shows same-channel videos first, then FTS matches, then trending; second load hits cache (x-spooool-cache: hit)
  • Manual: hide/DMCA-disable a video and confirm it drops out of related lists within 5 min

https://claude.ai/code/session_01FMnY8ZmsUxSktN5PmjS9JR


Generated by Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Enhanced "Up next" video recommendations with intelligent algorithm prioritizing same-channel uploads, title-based matches, and trending content; includes caching for improved performance.
  • Tests

    • Added comprehensive test coverage for video recommendation functionality and caching behavior.

…ding fill

Replaces the /trending fallback the Watch up-next list used to use. The new
endpoint walks three passes (same channel by recency, FTS5 over the source
title, trending top-up), deduped and capped at the requested limit. Hidden /
soft-deleted / DMCA-disabled / non-ready rows are filtered at query time so
takedowns disappear from related lists immediately. Cached per (id, limit)
in KV with a 5-minute TTL — uploads/deletes don't bust this directly; the
TTL bounds the staleness window.

Closes ALO-152.

https://claude.ai/code/session_01FMnY8ZmsUxSktN5PmjS9JR
Copilot AI review requested due to automatic review settings May 5, 2026 13:17
@ecc-tools
Copy link
Copy Markdown
Contributor

ecc-tools Bot commented May 5, 2026

ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 5, 2026

Caution

Review failed

Pull request was closed or merged during review

Walkthrough

A new "related videos" recommendation endpoint is implemented at GET /api/videos/:id/related to suggest similar content from the same channel, via full-text search, or by trending videos. The frontend Watch page updates to consume this endpoint for the "Up next" list instead of fetching trending videos.

Changes

Related Videos Recommendation System

Layer / File(s) Summary
Endpoint Definition & Types
src/workers/related.ts
Implements GET /api/videos/:id/related handler with multi-stage query strategy: prioritizes same-channel recent uploads, falls back to FTS title matches, then fills remaining slots with trending/top-viewed videos. Includes KV cache with TTL and viewability filtering (soft-deleted, hidden, non-ready, DMCA-disabled excluded). Exports relatedRoutes, RelatedEnv, relatedCacheKey(), and RelatedVideo types.
Route Registration
src/workers/index.ts
Imports and mounts relatedRoutes at the / path in the worker's route chain, positioned after videoRoutes.
Frontend Integration
src/frontend/pages/Watch.tsx
Updates the "Up next" loading effect to fetch from /api/videos/:id/related?limit=12 instead of /api/videos/trending, removes client-side ID filtering, and slices results to 8 items before storing in state and ref.
Tests
src/workers/related.test.ts
Comprehensive Vitest suite with in-memory D1 and KV stubs covering cache key generation, limit validation, source video visibility checks, same-channel prioritization, FTS fallback, trending fill, viewability filtering, cache miss/hit headers, and default limit inference via recorded SQL binds.

Sequence Diagram

sequenceDiagram
    participant Frontend as Watch Page
    participant Worker as Related Endpoint
    participant Cache as KV Cache
    participant DB as D1 Database
    
    Frontend->>Worker: GET /api/videos/:id/related?limit=12
    Worker->>Cache: Check cache for (id, limit)
    alt Cache Hit
        Cache-->>Worker: Return cached videos
        Worker->>Frontend: 200 { videos, cached: true }
    else Cache Miss
        Worker->>DB: Query source video (viewability check)
        DB-->>Worker: Source video details
        Worker->>DB: Query same-channel recent uploads
        DB-->>Worker: Same-channel results
        alt Limit not met
            Worker->>DB: Query FTS matches by title
            DB-->>Worker: FTS results
        end
        alt Still underfilled
            Worker->>DB: Query top-viewed/recent (trending)
            DB-->>Worker: Trending results
        end
        Worker->>Cache: Put videos with TTL (best-effort)
        Worker->>Frontend: 200 { videos, cached: false }
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • aloewright/spooool#39: Both PRs modularize worker routing by extracting route sub-apps (this PR adds relatedRoutes while the retrieved PR extracts videoRoutes), establishing a pattern for composable route registration.
  • aloewright/spooool#23: The related endpoint's FTS fallback logic directly uses the FTS5 index and buildFtsQuery helper introduced in the retrieved PR, creating a direct code dependency.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main change: a new /api/videos/:id/related endpoint with three-pass recommendation logic (same-channel, FTS, trending).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/related-videos

Warning

Review ran into problems

🔥 Problems

These MCP integrations need to be re-authenticated in the Integrations settings: Sentry


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@aloewright aloewright merged commit 8a686d7 into main May 5, 2026
3 of 4 checks passed
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4b11c513a8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/workers/related.ts
Comment on lines +80 to +81
WHERE id = ? AND deleted_at IS NULL AND hidden_at IS NULL
AND (dmca_status IS NULL OR dmca_status != 'disabled')`,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Permit owner-visible hidden videos in related source lookup

The source-row predicate hard-requires hidden_at IS NULL, so /api/videos/:id/related returns 404 for a creator viewing their own hidden video even though GET /api/videos/:id explicitly allows owners to access hidden content. Since Watch.tsx now always uses /related for the sidebar, this causes a regression where owner-visible hidden videos lose Up Next/autoplay entirely. The source lookup should account for the authenticated user (or mirror the visibility rules used by the video detail route) instead of unconditionally rejecting hidden sources.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a related-video recommendation system for the Watch page, replacing the previous trending fallback. The new backend endpoint uses a tiered strategy involving same-channel uploads, FTS5 title matching, and trending top-up, with results cached in KV. Review feedback suggests optimizing the frontend fetch limit to match the display count and ensuring the source video query filters for 'ready' status to maintain consistency with visibility rules.

if (!id) return;
let cancelled = false;
void fetch('/api/videos/trending?limit=12')
void fetch(`/api/videos/${encodeURIComponent(id)}/related?limit=12`)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The frontend is fetching 12 related videos but only displaying 8 (as seen on line 152). Since the /related endpoint now correctly excludes the source video, you can optimize this by requesting exactly the number of videos you intend to show.

Suggested change
void fetch(`/api/videos/${encodeURIComponent(id)}/related?limit=12`)
void fetch(`/api/videos/${encodeURIComponent(id)}/related?limit=8`)

Comment thread src/workers/related.ts
Comment on lines +79 to +81
`SELECT id, user_id, title FROM videos
WHERE id = ? AND deleted_at IS NULL AND hidden_at IS NULL
AND (dmca_status IS NULL OR dmca_status != 'disabled')`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The PR description mentions that 'non-ready rows are filtered at query time', but the initial query to find the source video does not check the status column. If a video is still in encoding or pending_encode status, it might be better to return a 404 or a specific error rather than attempting to find related videos for an incomplete record.

    `SELECT id, user_id, title FROM videos
     WHERE id = ? AND deleted_at IS NULL AND hidden_at IS NULL
       AND status = 'ready'
       AND (dmca_status IS NULL OR dmca_status != 'disabled')`,

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a first-pass related-videos recommendation endpoint for the Watch “Up next” sidebar, and wires the frontend to use it instead of the /trending stopgap. The backend endpoint performs three deduped passes (same-channel recency → title FTS → global fill) and caches per (videoId, limit) in KV.

Changes:

  • Introduce GET /api/videos/:id/related with KV caching and three-pass deduped selection logic.
  • Add a dedicated test suite covering query validation, ordering/fallthrough, filtering, and caching behavior.
  • Update Watch.tsx to fetch related videos for the up-next sidebar.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

File Description
src/workers/related.ts New related-videos API route with same-channel/FTS/fill passes and KV caching.
src/workers/related.test.ts New vitest coverage for the related endpoint and caching key behavior.
src/workers/index.ts Registers relatedRoutes with the worker app router.
src/frontend/pages/Watch.tsx Switches up-next fetch from /trending to /api/videos/:id/related.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/workers/related.ts
Comment on lines +12 to +13
// Hidden / soft-deleted / DMCA-disabled / non-ready rows are filtered at
// query time so a takedown disappears from related lists immediately.
Comment thread src/workers/related.ts
Comment on lines +78 to +87
const source = await c.env.DB.prepare(
`SELECT id, user_id, title FROM videos
WHERE id = ? AND deleted_at IS NULL AND hidden_at IS NULL
AND (dmca_status IS NULL OR dmca_status != 'disabled')`,
)
.bind(id)
.first<{ id: string; user_id: string; title: string }>();
if (!source) {
return c.json({ error: 'Video not found' }, 404);
}
Comment thread src/workers/related.ts
Comment on lines +147 to +156
`SELECT v.id, v.title, v.thumbnail_url, v.view_count, v.created_at,
u.name AS channel_name, u.username AS channel_username
FROM videos v
LEFT JOIN user u ON u.id = v.user_id
WHERE v.deleted_at IS NULL AND v.hidden_at IS NULL
AND v.status = 'ready'
AND (v.dmca_status IS NULL OR v.dmca_status != 'disabled')
AND v.id NOT IN (${placeholders(seenList.length)})
ORDER BY v.view_count DESC, v.created_at DESC
LIMIT ?`,
Comment on lines +145 to +154
void fetch(`/api/videos/${encodeURIComponent(id)}/related?limit=12`)
.then(async (r) => {
if (!r.ok) throw new Error('failed');
return (await r.json()) as { videos: UpNextVideo[] };
})
.then((data) => {
if (cancelled) return;
const filtered = data.videos.filter((v) => v.id !== id).slice(0, 8);
setUpNext(filtered);
upNextRef.current = filtered;
const next = data.videos.slice(0, 8);
setUpNext(next);
upNextRef.current = next;
Comment on lines +51 to +54
// Hand-rolled D1 stub keyed on SQL fragments the route actually issues. New
// queries must be added explicitly so a schema change can never silently fall
// through.
function fakeDB(videos: VideoRow[]): FakeDBResult {
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants