Skip to content

Commit 75e30ee

Browse files
committed
fix(youtube): make video ingestion work on Postgres + extract transcripts
YouTube items silently produced no content on Postgres: the processor returns item_type="video" but the ItemType enum (and thus the Postgres `itemtype` enum) had no `video` member, so the write was rejected with InvalidTextRepresentation. SQLite doesn't enforce enums, so this only ever failed in prod. - add `video` to the ItemType enum + Alembic migration that ALTERs the Postgres enum (autocommit block; idempotent; no-op on SQLite) - fetcher: roll back the session before recording _processing_error so a failed flush no longer raises PendingRollbackError and swallows the real error — this is why the failure left zero trace anywhere - youtube: update transcript extraction to youtube-transcript-api v1.x (instance .list(); FetchedTranscript.to_raw_data()); the old static list_transcripts() was gone, so transcripts never extracted
1 parent 350353d commit 75e30ee

4 files changed

Lines changed: 76 additions & 10 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""add 'video' value to the itemtype enum
2+
3+
The YouTube processor produces item_type="video", but the Postgres `itemtype`
4+
enum was created without it, so writing a YouTube item failed with
5+
InvalidTextRepresentation and the extraction was silently dropped. SQLite does
6+
not enforce enums, which is why this only surfaced on Postgres.
7+
8+
Revision ID: a7b8c9d0e1f2
9+
Revises: f6a7b8c9d0e1
10+
Create Date: 2026-06-09 00:00:00.000000
11+
"""
12+
13+
from typing import Sequence, Union
14+
15+
from alembic import op
16+
17+
revision: str = "a7b8c9d0e1f2"
18+
down_revision: Union[str, None] = "f6a7b8c9d0e1"
19+
branch_labels: Union[str, Sequence[str], None] = None
20+
depends_on: Union[str, Sequence[str], None] = None
21+
22+
23+
def upgrade() -> None:
24+
bind = op.get_bind()
25+
if bind.dialect.name != "postgresql":
26+
# SQLite (and others) store enums as plain text — no type to alter.
27+
return
28+
# Postgres forbids ALTER TYPE ... ADD VALUE inside a transaction block,
29+
# so run it in an autocommit block. IF NOT EXISTS keeps it idempotent.
30+
with op.get_context().autocommit_block():
31+
op.execute("ALTER TYPE itemtype ADD VALUE IF NOT EXISTS 'video'")
32+
33+
34+
def downgrade() -> None:
35+
# Postgres has no supported way to drop an enum value; intentionally a no-op.
36+
pass

src/fourdpocket/models/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class ItemType(str, enum.Enum):
1616
image = "image"
1717
pdf = "pdf"
1818
code_snippet = "code_snippet"
19+
video = "video"
1920

2021

2122
class SourcePlatform(str, enum.Enum):

src/fourdpocket/processors/youtube.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,12 @@ async def process(self, url: str, **kwargs) -> ProcessorResult:
132132
try:
133133
from youtube_transcript_api import YouTubeTranscriptApi
134134
try:
135-
tlist = YouTubeTranscriptApi.list_transcripts(video_id)
135+
# youtube-transcript-api v1.x is instance-based (.list); older
136+
# releases exposed a static list_transcripts(). Support both.
137+
if hasattr(YouTubeTranscriptApi, "list_transcripts"):
138+
tlist = YouTubeTranscriptApi.list_transcripts(video_id)
139+
else:
140+
tlist = YouTubeTranscriptApi().list(video_id)
136141
t = None
137142
try:
138143
t = tlist.find_manually_created_transcript(["en"])
@@ -145,13 +150,27 @@ async def process(self, url: str, **kwargs) -> ProcessorResult:
145150
break
146151
if t:
147152
fetched = t.fetch()
148-
for entry in fetched:
153+
# v1.x returns a FetchedTranscript of snippet objects;
154+
# to_raw_data() normalizes to the legacy list-of-dicts shape.
155+
entries = (
156+
fetched.to_raw_data()
157+
if hasattr(fetched, "to_raw_data")
158+
else fetched
159+
)
160+
for entry in entries:
149161
if isinstance(entry, dict):
150-
transcript_segments.append({
151-
"text": entry.get("text", ""),
152-
"start": float(entry.get("start", 0.0)),
153-
"duration": float(entry.get("duration", 0.0)),
154-
})
162+
text = entry.get("text", "")
163+
start = entry.get("start", 0.0)
164+
duration = entry.get("duration", 0.0)
165+
else:
166+
text = getattr(entry, "text", "")
167+
start = getattr(entry, "start", 0.0)
168+
duration = getattr(entry, "duration", 0.0)
169+
transcript_segments.append({
170+
"text": text,
171+
"start": float(start),
172+
"duration": float(duration),
173+
})
155174
metadata["transcript_language"] = t.language
156175
metadata["transcript_auto_generated"] = t.is_generated
157176
except Exception as e:

src/fourdpocket/workers/fetcher.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,17 @@ def fetch_and_process_url(item_id: str, url: str, user_id: str) -> dict:
163163

164164
except Exception as e:
165165
logger.error("Failed to process item %s: %s", item_id, e)
166-
item.item_metadata = {**item.item_metadata, "_processing_error": str(e)[:500]}
167-
db.add(item)
168-
db.commit()
166+
# The failure may have left the session in a rolled-back state (e.g.
167+
# a DataError mid-flush). Roll back first so recording the error
168+
# doesn't itself raise PendingRollbackError and swallow the failure.
169+
db.rollback()
170+
try:
171+
item = db.get(KnowledgeItem, uuid.UUID(item_id))
172+
if item is not None:
173+
item.item_metadata = {**item.item_metadata, "_processing_error": str(e)[:500]}
174+
db.add(item)
175+
db.commit()
176+
except Exception as record_err:
177+
logger.error("Failed to record processing error for %s: %s", item_id, record_err)
178+
db.rollback()
169179
return {"status": "error", "error": str(e)[:500]}

0 commit comments

Comments
 (0)