Fix comments (Python API), add dislike bar
Comments: switch from CLI --write-comments to yt-dlp Python API with getcomments=True — more reliable, proper extractor_args dict format Dislikes: add dislike_count column, fetch from returnyoutubedislike.com after each video metadata upsert (5s timeout, non-fatal) UI: replace emoji like count with a like/dislike ratio bar — blue fill showing like proportion, labels on each end; views stay in meta row Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,7 @@ def init_db():
|
|||||||
# Column migrations — safe to run on every startup
|
# 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", "view_count", "INTEGER")
|
||||||
_add_column_if_missing(raw_conn, "videos", "like_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()
|
raw_conn.commit()
|
||||||
# executescript handles multi-statement SQL including trigger BEGIN...END blocks
|
# executescript handles multi-statement SQL including trigger BEGIN...END blocks
|
||||||
raw_conn.executescript(FTS_SETUP_SQL)
|
raw_conn.executescript(FTS_SETUP_SQL)
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class Video(Base):
|
|||||||
chapters = Column(Text) # JSON array of {start_time, end_time, title}
|
chapters = Column(Text) # JSON array of {start_time, end_time, title}
|
||||||
view_count = Column(Integer)
|
view_count = Column(Integer)
|
||||||
like_count = Column(Integer)
|
like_count = Column(Integer)
|
||||||
|
dislike_count = Column(Integer)
|
||||||
|
|
||||||
|
|
||||||
class UserVideo(Base):
|
class UserVideo(Base):
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class VideoDetail(BaseModel):
|
|||||||
is_recommended: bool = False
|
is_recommended: bool = False
|
||||||
view_count: Optional[int] = None
|
view_count: Optional[int] = None
|
||||||
like_count: Optional[int] = None
|
like_count: Optional[int] = None
|
||||||
|
dislike_count: Optional[int] = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -423,7 +424,7 @@ def surprise_me(
|
|||||||
|
|
||||||
_VIDEO_SELECT = """
|
_VIDEO_SELECT = """
|
||||||
SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url,
|
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,
|
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.watched, 0) AS watched,
|
||||||
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
|
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:
|
if channel:
|
||||||
video.channel_id = channel.id
|
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()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ def _normalize_video(info: dict) -> dict:
|
|||||||
"chapters": json.dumps(chapters) if chapters else None,
|
"chapters": json.dumps(chapters) if chapters else None,
|
||||||
"view_count": info.get("view_count"),
|
"view_count": info.get("view_count"),
|
||||||
"like_count": info.get("like_count"),
|
"like_count": info.get("like_count"),
|
||||||
|
"dislike_count": info.get("dislike_count"),
|
||||||
"channel": {
|
"channel": {
|
||||||
"youtube_channel_id": info.get("channel_id"),
|
"youtube_channel_id": info.get("channel_id"),
|
||||||
"name": info.get("channel") or info.get("uploader", ""),
|
"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]:
|
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."""
|
"""Fetch top comments via yt-dlp Python API. Returns empty list on failure."""
|
||||||
url = f"https://www.youtube.com/watch?v={youtube_video_id}"
|
import yt_dlp as _yt_dlp
|
||||||
args = [
|
|
||||||
"yt-dlp", url,
|
with _cookies_lock:
|
||||||
"--dump-json",
|
cf = _cookies_file
|
||||||
"--write-comments",
|
b = _cookies_browser
|
||||||
"--extractor-args", f"youtube:max_comments={max_comments};comment_sort=top",
|
oauth2 = _use_oauth2
|
||||||
"--no-download",
|
|
||||||
"--no-playlist",
|
ydl_opts = {
|
||||||
"--quiet",
|
"quiet": True,
|
||||||
*_cookie_args(),
|
"no_warnings": True,
|
||||||
]
|
"skip_download": True,
|
||||||
stdout, _, code = _run(args, timeout=60)
|
"getcomments": True,
|
||||||
if not stdout.strip():
|
"extractor_args": {
|
||||||
return []
|
"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:
|
try:
|
||||||
info = json.loads(stdout.strip())
|
with _yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
except json.JSONDecodeError:
|
info = ydl.extract_info(f"https://www.youtube.com/watch?v={youtube_video_id}", download=False)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not info:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
result = []
|
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"):
|
if c.get("parent") not in (None, "root"):
|
||||||
continue # skip replies
|
continue # skip replies
|
||||||
ts = c.get("timestamp")
|
ts = c.get("timestamp")
|
||||||
published_at = datetime.utcfromtimestamp(ts) if ts else None
|
|
||||||
result.append({
|
result.append({
|
||||||
"youtube_comment_id": c.get("id"),
|
"youtube_comment_id": c.get("id"),
|
||||||
"author": c.get("author"),
|
"author": c.get("author"),
|
||||||
"text": c.get("text"),
|
"text": c.get("text"),
|
||||||
"likes": c.get("like_count") or 0,
|
"likes": c.get("like_count") or 0,
|
||||||
"is_pinned": bool(c.get("is_pinned")),
|
"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)))
|
result.sort(key=lambda c: (not c["is_pinned"], -(c["likes"] or 0)))
|
||||||
return result[:max_comments]
|
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 = {
|
QUALITY_FORMATS = {
|
||||||
"best": "bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/22/18/bestvideo+bestaudio/best",
|
"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]",
|
"2160p": "bestvideo[ext=mp4][height<=2160]+bestaudio[ext=m4a]/bestvideo[height<=2160]+bestaudio/best[height<=2160]",
|
||||||
|
|||||||
@@ -894,7 +894,6 @@ export default function Watch() {
|
|||||||
<div className="flex items-center gap-2 text-sm text-zinc-500 flex-wrap">
|
<div className="flex items-center gap-2 text-sm text-zinc-500 flex-wrap">
|
||||||
{date && <span>{date}</span>}
|
{date && <span>{date}</span>}
|
||||||
{video?.view_count > 0 && <><span>·</span><span>{formatViews(video.view_count)}</span></>}
|
{video?.view_count > 0 && <><span>·</span><span>{formatViews(video.view_count)}</span></>}
|
||||||
{video?.like_count > 0 && <><span>·</span><span>👍 {formatViews(video.like_count).replace(" views", "")}</span></>}
|
|
||||||
{video?.category && <><span>·</span><span>{video.category}</span></>}
|
{video?.category && <><span>·</span><span>{video.category}</span></>}
|
||||||
{video?.duration_seconds && (
|
{video?.duration_seconds && (
|
||||||
<><span>·</span>
|
<><span>·</span>
|
||||||
@@ -912,6 +911,26 @@ export default function Watch() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Like/dislike bar */}
|
||||||
|
{video?.like_count > 0 && (
|
||||||
|
<div className="flex flex-col gap-1 w-full">
|
||||||
|
<div className="flex justify-between text-xs text-zinc-500">
|
||||||
|
<span>{formatViews(video.like_count).replace(" views", "")} likes</span>
|
||||||
|
{video?.dislike_count > 0 && <span>{formatViews(video.dislike_count).replace(" views", "")} dislikes</span>}
|
||||||
|
</div>
|
||||||
|
<div className="h-1 w-full bg-zinc-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 rounded-full"
|
||||||
|
style={{
|
||||||
|
width: video?.dislike_count > 0
|
||||||
|
? `${(video.like_count / (video.like_count + video.dislike_count)) * 100}%`
|
||||||
|
: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{!dlComplete && !isDownloading && !downloadMut.isPending && (
|
{!dlComplete && !isDownloading && !downloadMut.isPending && (
|
||||||
|
|||||||
Reference in New Issue
Block a user