diff --git a/backend/database.py b/backend/database.py index 1024ad0..f7e3e5f 100644 --- a/backend/database.py +++ b/backend/database.py @@ -85,6 +85,7 @@ def init_db(): # Column migrations — safe to run on every startup _add_column_if_missing(raw_conn, "videos", "view_count", "INTEGER") _add_column_if_missing(raw_conn, "videos", "like_count", "INTEGER") + _add_column_if_missing(raw_conn, "videos", "dislike_count", "INTEGER") raw_conn.commit() # executescript handles multi-statement SQL including trigger BEGIN...END blocks raw_conn.executescript(FTS_SETUP_SQL) diff --git a/backend/models.py b/backend/models.py index b7aa343..cc995af 100644 --- a/backend/models.py +++ b/backend/models.py @@ -62,6 +62,7 @@ class Video(Base): chapters = Column(Text) # JSON array of {start_time, end_time, title} view_count = Column(Integer) like_count = Column(Integer) + dislike_count = Column(Integer) class UserVideo(Base): diff --git a/backend/routers/videos.py b/backend/routers/videos.py index cb65385..3110d6f 100644 --- a/backend/routers/videos.py +++ b/backend/routers/videos.py @@ -67,6 +67,7 @@ class VideoDetail(BaseModel): is_recommended: bool = False view_count: Optional[int] = None like_count: Optional[int] = None + dislike_count: Optional[int] = None model_config = {"from_attributes": True} @@ -423,7 +424,7 @@ def surprise_me( _VIDEO_SELECT = """ SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url, - v.duration_seconds, v.published_at, v.tags, v.category, v.view_count, v.like_count, + v.duration_seconds, v.published_at, v.tags, v.category, v.view_count, v.like_count, v.dislike_count, c.id AS channel_id, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id, COALESCE(uv.watched, 0) AS watched, COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds, @@ -488,6 +489,12 @@ def _upsert_video_from_yt(db: Session, youtube_video_id: str) -> bool: if channel: video.channel_id = channel.id + # Fetch crowdsourced dislike count if not already known + if not video.dislike_count: + dislikes = ytdlp.fetch_dislike_count(youtube_video_id) + if dislikes is not None: + video.dislike_count = dislikes + db.commit() return True diff --git a/backend/services/ytdlp.py b/backend/services/ytdlp.py index 1ff1585..6e4abdc 100644 --- a/backend/services/ytdlp.py +++ b/backend/services/ytdlp.py @@ -76,6 +76,7 @@ def _normalize_video(info: dict) -> dict: "chapters": json.dumps(chapters) if chapters else None, "view_count": info.get("view_count"), "like_count": info.get("like_count"), + "dislike_count": info.get("dislike_count"), "channel": { "youtube_channel_id": info.get("channel_id"), "name": info.get("channel") or info.get("uploader", ""), @@ -385,24 +386,47 @@ def fetch_channel_links(channel_id: str) -> list[str]: def fetch_video_comments(youtube_video_id: str, max_comments: int = 20) -> list[dict]: - """Fetch top comments for a single video. Returns empty list on failure.""" - url = f"https://www.youtube.com/watch?v={youtube_video_id}" - args = [ - "yt-dlp", url, - "--dump-json", - "--write-comments", - "--extractor-args", f"youtube:max_comments={max_comments};comment_sort=top", - "--no-download", - "--no-playlist", - "--quiet", - *_cookie_args(), - ] - stdout, _, code = _run(args, timeout=60) - if not stdout.strip(): - return [] + """Fetch top comments via yt-dlp Python API. Returns empty list on failure.""" + import yt_dlp as _yt_dlp + + with _cookies_lock: + cf = _cookies_file + b = _cookies_browser + oauth2 = _use_oauth2 + + ydl_opts = { + "quiet": True, + "no_warnings": True, + "skip_download": True, + "getcomments": True, + "extractor_args": { + "youtube": { + "max_comments": [str(max_comments), str(max_comments)], + "comment_sort": ["top"], + } + }, + } + if oauth2: + ydl_opts["username"] = "oauth2" + ydl_opts["password"] = "" + elif cf and Path(cf).exists(): + ydl_opts["cookiefile"] = cf + else: + for candidate in _AUTO_COOKIES_PATHS: + if Path(candidate).exists(): + ydl_opts["cookiefile"] = candidate + break + else: + if b and not cf: + ydl_opts["cookiesfrombrowser"] = (b,) + try: - info = json.loads(stdout.strip()) - except json.JSONDecodeError: + with _yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(f"https://www.youtube.com/watch?v={youtube_video_id}", download=False) + except Exception: + return [] + + if not info: return [] result = [] @@ -410,20 +434,29 @@ def fetch_video_comments(youtube_video_id: str, max_comments: int = 20) -> list[ if c.get("parent") not in (None, "root"): continue # skip replies ts = c.get("timestamp") - published_at = datetime.utcfromtimestamp(ts) if ts else None result.append({ "youtube_comment_id": c.get("id"), "author": c.get("author"), "text": c.get("text"), "likes": c.get("like_count") or 0, "is_pinned": bool(c.get("is_pinned")), - "published_at": published_at, + "published_at": datetime.utcfromtimestamp(ts) if ts else None, }) - # Sort pinned first, then by likes result.sort(key=lambda c: (not c["is_pinned"], -(c["likes"] or 0))) return result[:max_comments] +def fetch_dislike_count(youtube_video_id: str) -> int | None: + """Fetch dislike count from returnyoutubedislike.com (crowdsourced).""" + try: + url = f"https://returnyoutubedislikeapi.com/votes?videoId={youtube_video_id}" + with urllib.request.urlopen(url, timeout=5) as resp: + data = json.loads(resp.read()) + return data.get("dislikes") + except Exception: + return None + + QUALITY_FORMATS = { "best": "bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/22/18/bestvideo+bestaudio/best", "2160p": "bestvideo[ext=mp4][height<=2160]+bestaudio[ext=m4a]/bestvideo[height<=2160]+bestaudio/best[height<=2160]", diff --git a/frontend/src/pages/Watch.jsx b/frontend/src/pages/Watch.jsx index 2a15a61..fdbf8c3 100644 --- a/frontend/src/pages/Watch.jsx +++ b/frontend/src/pages/Watch.jsx @@ -894,7 +894,6 @@ export default function Watch() {