Compare commits
111 Commits
50ce373767
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7814fc9718 | ||
|
|
33e9472f17 | ||
| 140bf4acf6 | |||
| 8dcbad6e70 | |||
| 8a5108425b | |||
| 09c11da1ce | |||
| a3346c6e87 | |||
| 0c5b236b77 | |||
| c223e57463 | |||
| a0384b2277 | |||
| bcbd552eab | |||
| 1179b53f2e | |||
| 146a044e69 | |||
| a535e9f22a | |||
| e6faf8e08e | |||
| 0a4dfb845e | |||
| c3191aa000 | |||
| 395b987644 | |||
| 12f54ac5b0 | |||
| 4d255647a1 | |||
| 592194f2ca | |||
| b6a47249d0 | |||
| 86e7648075 | |||
| 1b010d4081 | |||
| fadb0fffcd | |||
| 9d35cc7c68 | |||
| bbf7cc939b | |||
| c11e1fdaf7 | |||
| 19dae63385 | |||
| 4a7f1f06ac | |||
| 1ee6edcb17 | |||
| c26fc3483c | |||
| c180c293b0 | |||
| 15e6b94cbf | |||
| 32e55b9042 | |||
| 77aebffa49 | |||
| ba6dfe321d | |||
| 31cef555a9 | |||
| 001d2ddcf0 | |||
| 65bc199366 | |||
| 8029b2517f | |||
| ff601d3585 | |||
| 3652038cf5 | |||
| e02ea12494 | |||
| 3a557a1d24 | |||
| be84660e2d | |||
| c3290d33a7 | |||
| be7319e96c | |||
| 6e455ed8ce | |||
| ff4d8e4ab4 | |||
| 3e699d61b6 | |||
| c3b83ba1d3 | |||
| 77cba81ef4 | |||
| be88d70935 | |||
| 6cfaca382c | |||
| abd7ed7c5a | |||
| 112f87e764 | |||
| 2f37072187 | |||
| 5b0cf27f07 | |||
| d31fc1ef7f | |||
| aa91156bbc | |||
| 0b482b5d49 | |||
| 50d61b5774 | |||
| d740fd5224 | |||
| 871f668525 | |||
| 62c2c73906 | |||
| 52279752e4 | |||
| 366a2ff183 | |||
| 8f0ce0756f | |||
| 33e4691619 | |||
| 6311b90b21 | |||
| da765ce76e | |||
| 75493ed80e | |||
| bbf8365c70 | |||
| 27f17c16ef | |||
| 97ebcd6c1d | |||
| 399c5fcada | |||
| ea99b74ba8 | |||
| 3abbd5749e | |||
| 2bb5f35db0 | |||
| a15123028c | |||
| b41412071a | |||
| e9140ab6a1 | |||
|
|
8da361b087 | ||
|
|
83e2685c6a | ||
|
|
c24964a1ee | ||
|
|
744af7337b | ||
|
|
623e82fb16 | ||
|
|
fe8028c1f9 | ||
|
|
94f74215e2 | ||
|
|
f422d754b9 | ||
|
|
ebd8ddee6e | ||
|
|
ca5196d9f1 | ||
|
|
ed55478599 | ||
|
|
d6035e6f1f | ||
|
|
3e3d2c7464 | ||
|
|
6f600c9a5c | ||
|
|
fc05a40f02 | ||
|
|
c00d5c7595 | ||
|
|
1405acfaed | ||
|
|
74e9a52096 | ||
|
|
1cd8645957 | ||
|
|
0d6dd94029 | ||
|
|
cb05b739a8 | ||
|
|
3e63281849 | ||
|
|
b3c288a590 | ||
|
|
aaa9d0145e | ||
|
|
f5a35cd1f2 | ||
|
|
1df396590f | ||
|
|
c7ec8c21f2 | ||
|
|
83e1b18c5b |
@@ -9,6 +9,7 @@ class Settings(BaseSettings):
|
||||
secret_key: str = "changeme-use-a-real-secret-in-production"
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 60 * 24 * 7 # 1 week
|
||||
widget_api_key: str = "" # set WIDGET_API_KEY in env for backstage integration
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ def set_sqlite_pragma(dbapi_conn, _):
|
||||
cursor = dbapi_conn.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.execute("PRAGMA busy_timeout=5000")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.close()
|
||||
|
||||
|
||||
@@ -87,6 +89,18 @@ def init_db():
|
||||
_add_column_if_missing(raw_conn, "videos", "like_count", "INTEGER")
|
||||
_add_column_if_missing(raw_conn, "videos", "dislike_count", "INTEGER")
|
||||
raw_conn.commit()
|
||||
# Indexes that make the channel-stats CTE query fast with many channels
|
||||
for idx_sql in [
|
||||
"CREATE INDEX IF NOT EXISTS idx_videos_channel_published ON videos(channel_id, published_at DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_videos_channel_indexed ON videos(channel_id, indexed_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_videos_video_user ON user_videos(video_id, user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_channels_user_status ON user_channels(user_id, status)",
|
||||
]:
|
||||
try:
|
||||
raw_conn.execute(idx_sql)
|
||||
except Exception:
|
||||
pass
|
||||
raw_conn.commit()
|
||||
# executescript handles multi-statement SQL including trigger BEGIN...END blocks
|
||||
raw_conn.executescript(FTS_SETUP_SQL)
|
||||
finally:
|
||||
|
||||
119
backend/main.py
119
backend/main.py
@@ -6,7 +6,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from .config import settings
|
||||
from .database import init_db, SessionLocal
|
||||
from .services import ytdlp as ytdlp_service
|
||||
from .routers import auth, channels, videos, search, downloads, discovery, settings as settings_router, stats as stats_router, export as export_router, collections as collections_router, admin as admin_router
|
||||
from .routers import auth, channels, videos, search, downloads, discovery, settings as settings_router, stats as stats_router, export as export_router, collections as collections_router, admin as admin_router, playlists as playlists_router, widget as widget_router
|
||||
|
||||
app = FastAPI(title="YouTube Hub", version="0.1.0")
|
||||
|
||||
@@ -29,6 +29,8 @@ app.include_router(stats_router.router, prefix="/api/stats", tags=["stats"])
|
||||
app.include_router(export_router.router, prefix="/api/export", tags=["export"])
|
||||
app.include_router(collections_router.router, prefix="/api/collections", tags=["collections"])
|
||||
app.include_router(admin_router.router, prefix="/api/admin", tags=["admin"])
|
||||
app.include_router(playlists_router.router, prefix="/api/playlists", tags=["playlists"])
|
||||
app.include_router(widget_router.router, prefix="/api/widget", tags=["widget"])
|
||||
|
||||
|
||||
os.makedirs(settings.download_path, exist_ok=True)
|
||||
@@ -71,6 +73,29 @@ def on_startup():
|
||||
"ALTER TABLE user_settings ADD COLUMN feed_weight_affinity REAL DEFAULT 5.0",
|
||||
"ALTER TABLE user_settings ADD COLUMN feed_weight_channel REAL DEFAULT 5.0",
|
||||
"ALTER TABLE user_settings ADD COLUMN use_oauth2 INTEGER DEFAULT 0",
|
||||
"ALTER TABLE user_settings ADD COLUMN sync_interval_hours INTEGER DEFAULT 0",
|
||||
"ALTER TABLE user_settings ADD COLUMN subtitle_langs TEXT DEFAULT ''",
|
||||
"""CREATE TABLE IF NOT EXISTS playlists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
youtube_playlist_id TEXT NOT NULL UNIQUE,
|
||||
channel_id INTEGER REFERENCES channels(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
thumbnail_url TEXT,
|
||||
video_count INTEGER DEFAULT 0,
|
||||
video_ids TEXT,
|
||||
indexed_at DATETIME,
|
||||
crawled_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)""",
|
||||
"ALTER TABLE playlists ADD COLUMN video_ids TEXT",
|
||||
"""CREATE TABLE IF NOT EXISTS channel_popular_videos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
|
||||
rank INTEGER NOT NULL,
|
||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(channel_id, video_id)
|
||||
)""",
|
||||
"""CREATE TABLE IF NOT EXISTS search_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -106,6 +131,8 @@ def on_startup():
|
||||
note TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)""",
|
||||
"ALTER TABLE user_videos ADD COLUMN feed_shown_count INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE user_settings ADD COLUMN last_discovery_run DATETIME DEFAULT NULL",
|
||||
]:
|
||||
try:
|
||||
db.execute(text(col_sql))
|
||||
@@ -149,10 +176,94 @@ def on_startup():
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Backfill descriptions for videos that don't have them yet (runs in background)
|
||||
# Start discovery worker and backfill enrichment
|
||||
import threading
|
||||
from .routers.channels import _enrich_missing_task
|
||||
threading.Thread(target=_enrich_missing_task, args=(10,), daemon=True).start()
|
||||
from .routers.channels import _enrich_missing_task, _index_channels_batch
|
||||
from .services.discovery import start_discovery_worker
|
||||
start_discovery_worker()
|
||||
threading.Thread(target=_enrich_missing_task, args=(50,), daemon=True).start()
|
||||
|
||||
def _auto_sync_daemon():
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import text as _text
|
||||
while True:
|
||||
time.sleep(3600)
|
||||
try:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
users_due = db.execute(
|
||||
_text("SELECT user_id, sync_interval_hours FROM user_settings WHERE sync_interval_hours > 0")
|
||||
).mappings().all()
|
||||
for row in users_due:
|
||||
uid = row["user_id"]
|
||||
cutoff = datetime.utcnow() - timedelta(hours=row["sync_interval_hours"])
|
||||
ch_ids = [
|
||||
r["id"] for r in db.execute(
|
||||
_text("""
|
||||
SELECT c.id FROM channels c
|
||||
JOIN user_channels uc ON c.id = uc.channel_id
|
||||
WHERE uc.user_id = :uid AND uc.status = 'followed'
|
||||
AND (c.crawled_at IS NULL OR c.crawled_at < :cutoff)
|
||||
ORDER BY COALESCE(c.crawled_at, '1970-01-01') ASC
|
||||
"""),
|
||||
{"uid": uid, "cutoff": cutoff},
|
||||
).mappings().all()
|
||||
]
|
||||
if ch_ids:
|
||||
threading.Thread(
|
||||
target=_index_channels_batch, args=(ch_ids, uid), daemon=True
|
||||
).start()
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_auto_sync_daemon, daemon=True).start()
|
||||
|
||||
def _auto_discovery_daemon():
|
||||
import time as _time
|
||||
from datetime import datetime as _dt, timedelta as _td
|
||||
from sqlalchemy import text as _text
|
||||
from .services.discovery import schedule_discovery, get_discovery_progress
|
||||
|
||||
def _seconds_until_4am():
|
||||
now = _dt.now()
|
||||
target = now.replace(hour=4, minute=0, second=0, microsecond=0)
|
||||
if now >= target:
|
||||
target += _td(days=1)
|
||||
return (target - now).total_seconds()
|
||||
|
||||
# Sleep until the next 4 AM before doing anything
|
||||
_time.sleep(_seconds_until_4am())
|
||||
|
||||
while True:
|
||||
try:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = db.execute(_text("""
|
||||
SELECT u.id AS user_id,
|
||||
COALESCE(us.discovery_regions, 'US,SE') AS discovery_regions
|
||||
FROM users u
|
||||
LEFT JOIN user_settings us ON u.id = us.user_id
|
||||
""")).mappings().all()
|
||||
|
||||
for row in rows:
|
||||
uid = row["user_id"]
|
||||
prog = get_discovery_progress(uid)
|
||||
if prog and prog.get("running"):
|
||||
continue
|
||||
regions = [r.strip().upper() for r in (row["discovery_regions"] or "US,SE").split(",") if r.strip()]
|
||||
schedule_discovery(uid, regions)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Sleep until the next 4 AM
|
||||
_time.sleep(_seconds_until_4am())
|
||||
|
||||
threading.Thread(target=_auto_discovery_daemon, daemon=True).start()
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -122,6 +122,9 @@ class UserSettings(Base):
|
||||
feed_weight_affinity = Column(Float, default=5.0) # 0–10
|
||||
feed_weight_channel = Column(Float, default=5.0) # 0–10
|
||||
use_oauth2 = Column(Boolean, default=False)
|
||||
sync_interval_hours = Column(Integer, default=0) # 0 = disabled, 6/12/24 = auto-sync interval
|
||||
subtitle_langs = Column(String, default="") # "" = disabled, "en", "en,sv", etc.
|
||||
last_discovery_run = Column(DateTime, nullable=True, default=None)
|
||||
|
||||
|
||||
class DiscoveryQueue(Base):
|
||||
@@ -201,6 +204,21 @@ class CollectionItem(Base):
|
||||
added_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Playlist(Base):
|
||||
__tablename__ = "playlists"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
youtube_playlist_id = Column(String, unique=True, nullable=False, index=True)
|
||||
channel_id = Column(Integer, ForeignKey("channels.id", ondelete="CASCADE"), nullable=True)
|
||||
title = Column(String, nullable=False)
|
||||
description = Column(Text)
|
||||
thumbnail_url = Column(String)
|
||||
video_count = Column(Integer, default=0)
|
||||
video_ids = Column(Text) # JSON array of youtube_video_id strings
|
||||
indexed_at = Column(DateTime)
|
||||
crawled_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class GraphEdge(Base):
|
||||
__tablename__ = "graph_edges"
|
||||
__table_args__ = (UniqueConstraint("from_channel_id", "to_channel_id"),)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import threading as _threading
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
@@ -14,6 +15,9 @@ from ..services import ytdlp
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_tasks: dict = {}
|
||||
_tasks_lock = _threading.Lock()
|
||||
|
||||
|
||||
class ChannelOut(BaseModel):
|
||||
id: int
|
||||
@@ -62,41 +66,11 @@ class VideoOut(BaseModel):
|
||||
is_downloaded: bool = False
|
||||
is_watched: bool = False
|
||||
queued: bool = False
|
||||
view_count: Optional[int] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
_CHANNEL_STATS_SELECT = """
|
||||
SELECT c.*, uc.status, uc.auto_download, uc.muted_until, uc.notes,
|
||||
(SELECT COUNT(*) FROM videos WHERE channel_id = c.id) AS video_count,
|
||||
(SELECT MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_published_at,
|
||||
(SELECT COUNT(*) FROM videos v
|
||||
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||
WHERE v.channel_id = c.id AND COALESCE(uv.watched, 0) = 0) AS unwatched_count,
|
||||
(SELECT COUNT(*) FROM videos v
|
||||
JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||
WHERE v.channel_id = c.id AND uv.watched = 1) AS watched_count,
|
||||
(SELECT COUNT(*) FROM videos v
|
||||
JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||
WHERE v.channel_id = c.id AND uv.downloaded = 1) AS downloaded_count,
|
||||
(SELECT COUNT(*) FROM videos v
|
||||
WHERE v.channel_id = c.id
|
||||
AND (uc.last_seen_at IS NULL OR v.indexed_at > uc.last_seen_at)) AS new_count,
|
||||
(SELECT v.youtube_video_id FROM videos v
|
||||
WHERE v.channel_id = c.id ORDER BY v.published_at DESC LIMIT 1) AS latest_video_id,
|
||||
(SELECT v.title FROM videos v
|
||||
WHERE v.channel_id = c.id ORDER BY v.published_at DESC LIMIT 1) AS latest_video_title,
|
||||
(SELECT
|
||||
CASE WHEN COUNT(*) < 2 THEN NULL
|
||||
ELSE CAST((julianday(MAX(sub.published_at)) - julianday(MIN(sub.published_at))) AS REAL) / (COUNT(*) - 1)
|
||||
END
|
||||
FROM (SELECT published_at FROM videos WHERE channel_id = c.id AND published_at IS NOT NULL ORDER BY published_at DESC LIMIT 15) sub
|
||||
) AS upload_frequency_days
|
||||
FROM channels c
|
||||
JOIN user_channels uc ON c.id = uc.channel_id
|
||||
WHERE uc.user_id = :user_id AND uc.status = 'followed'
|
||||
"""
|
||||
|
||||
|
||||
def _get_channel_or_404(db: Session, channel_id: int) -> Channel:
|
||||
c = db.query(Channel).filter(Channel.id == channel_id).first()
|
||||
@@ -105,7 +79,12 @@ def _get_channel_or_404(db: Session, channel_id: int) -> Channel:
|
||||
return c
|
||||
|
||||
|
||||
def _index_channel_task(channel_id: int, user_id: int):
|
||||
def _index_channels_batch(channel_ids: list[int], user_id: int, delay: float = 1.5):
|
||||
for cid in channel_ids:
|
||||
_index_channel_task(cid, user_id)
|
||||
|
||||
|
||||
def _index_channel_task(channel_id: int, user_id: int, max_videos: int = 30):
|
||||
from ..database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -113,7 +92,7 @@ def _index_channel_task(channel_id: int, user_id: int):
|
||||
if not channel:
|
||||
return
|
||||
|
||||
result = ytdlp.fetch_channel_metadata(channel.youtube_channel_id)
|
||||
result = ytdlp.fetch_channel_metadata(channel.youtube_channel_id, max_videos=max_videos, polite=True)
|
||||
if not result:
|
||||
return
|
||||
|
||||
@@ -169,6 +148,7 @@ def _index_channel_task(channel_id: int, user_id: int):
|
||||
|
||||
if channel_auto:
|
||||
quality = user_settings.preferred_quality if user_settings else "best"
|
||||
subtitle_langs = (user_settings.subtitle_langs or "") if user_settings else ""
|
||||
from ..routers.downloads import _on_progress, _on_complete, _on_error
|
||||
for yt_id, vid_id in new_video_ids:
|
||||
existing_dl = db.query(Download).filter_by(
|
||||
@@ -181,7 +161,7 @@ def _index_channel_task(channel_id: int, user_id: int):
|
||||
import threading
|
||||
t = threading.Thread(
|
||||
target=ytdlp.start_download,
|
||||
args=(yt_id, dl.id, _on_progress, _on_complete, _on_error, quality),
|
||||
args=(yt_id, dl.id, _on_progress, _on_complete, _on_error, quality, subtitle_langs),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
@@ -193,28 +173,24 @@ def _index_channel_task(channel_id: int, user_id: int):
|
||||
|
||||
|
||||
def _discovery_task(user_id: int):
|
||||
from ..database import SessionLocal
|
||||
from ..services.discovery import run_full_discovery
|
||||
db = SessionLocal()
|
||||
from ..services.discovery import schedule_discovery
|
||||
try:
|
||||
run_full_discovery(db, user_id)
|
||||
schedule_discovery(user_id)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _enrich_missing_task(limit: int = 20):
|
||||
"""Fetch full metadata for videos that are missing a description."""
|
||||
"""Fetch full metadata for videos missing description, published_at, or view_count."""
|
||||
from ..database import SessionLocal
|
||||
import time
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT v.id, v.youtube_video_id FROM videos v
|
||||
WHERE v.description IS NULL
|
||||
WHERE v.description IS NULL OR v.published_at IS NULL OR v.view_count IS NULL
|
||||
ORDER BY
|
||||
-- prioritise: followed-channel videos first, then discovery queue, then rest
|
||||
(EXISTS (SELECT 1 FROM user_channels uc
|
||||
WHERE uc.channel_id = v.channel_id AND uc.status = 'followed')) DESC,
|
||||
(EXISTS (SELECT 1 FROM discovery_queue dq
|
||||
@@ -226,12 +202,16 @@ def _enrich_missing_task(limit: int = 20):
|
||||
).mappings().all()
|
||||
for row in rows:
|
||||
try:
|
||||
meta = ytdlp.fetch_video_metadata(row["youtube_video_id"])
|
||||
meta = ytdlp.fetch_video_metadata(row["youtube_video_id"], polite=True)
|
||||
if meta:
|
||||
vid = db.query(Video).filter_by(id=row["id"]).first()
|
||||
if vid:
|
||||
if meta.get("description") is not None:
|
||||
vid.description = meta["description"] or ""
|
||||
if not vid.published_at and meta.get("published_at"):
|
||||
vid.published_at = meta["published_at"]
|
||||
if vid.view_count is None and meta.get("view_count") is not None:
|
||||
vid.view_count = meta["view_count"]
|
||||
if not vid.tags and meta.get("tags"):
|
||||
vid.tags = meta["tags"]
|
||||
if not vid.category and meta.get("category"):
|
||||
@@ -278,26 +258,85 @@ def sync_all_channels(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# Only sync channels not touched in the last 6 hours to avoid hammering YouTube
|
||||
channels = db.execute(
|
||||
text("""
|
||||
SELECT c.id FROM channels c
|
||||
JOIN user_channels uc ON c.id = uc.channel_id
|
||||
WHERE uc.user_id = :uid AND uc.status = 'followed'
|
||||
AND (c.crawled_at IS NULL OR c.crawled_at < datetime('now', '-6 hours'))
|
||||
ORDER BY COALESCE(c.crawled_at, '1970-01-01') ASC
|
||||
"""),
|
||||
{"uid": current_user.id},
|
||||
).mappings().all()
|
||||
|
||||
for row in channels:
|
||||
background_tasks.add_task(_index_channel_task, row["id"], current_user.id)
|
||||
|
||||
if channels:
|
||||
ids = [row["id"] for row in channels]
|
||||
background_tasks.add_task(_index_channels_batch, ids, current_user.id)
|
||||
background_tasks.add_task(_discovery_task, current_user.id)
|
||||
|
||||
background_tasks.add_task(_enrich_missing_task, 20)
|
||||
background_tasks.add_task(_enrich_missing_task, 30)
|
||||
|
||||
return {"indexing": len(channels)}
|
||||
|
||||
|
||||
@router.get("/rss")
|
||||
def rss_feed(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
from fastapi.responses import Response
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT v.youtube_video_id, v.title, v.description, v.published_at,
|
||||
c.name AS channel_name, c.youtube_channel_id
|
||||
FROM videos v
|
||||
JOIN channels c ON v.channel_id = c.id
|
||||
JOIN user_channels uc ON c.id = uc.channel_id AND uc.user_id = :uid AND uc.status = 'followed'
|
||||
WHERE v.published_at IS NOT NULL
|
||||
ORDER BY v.published_at DESC
|
||||
LIMIT 100
|
||||
"""),
|
||||
{"uid": current_user.id},
|
||||
).mappings().all()
|
||||
|
||||
def esc(s):
|
||||
if not s:
|
||||
return ""
|
||||
return str(s).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
pub = r["published_at"]
|
||||
pub_str = pub.strftime("%a, %d %b %Y %H:%M:%S +0000") if pub else ""
|
||||
yt_id = r["youtube_video_id"]
|
||||
items.append(f""" <item>
|
||||
<title>{esc(r['title'])}</title>
|
||||
<link>https://www.youtube.com/watch?v={yt_id}</link>
|
||||
<description>{esc(r['description'] or '')}</description>
|
||||
<author>{esc(r['channel_name'])}</author>
|
||||
<pubDate>{pub_str}</pubDate>
|
||||
<guid>https://www.youtube.com/watch?v={yt_id}</guid>
|
||||
</item>""")
|
||||
|
||||
xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>YTContinue — Following</title>
|
||||
<link>https://www.youtube.com/</link>
|
||||
<description>Latest videos from your followed channels</description>
|
||||
{chr(10).join(items)}
|
||||
</channel>
|
||||
</rss>"""
|
||||
return Response(content=xml, media_type="application/rss+xml; charset=utf-8")
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
def get_active_tasks(current_user: User = Depends(get_current_user)):
|
||||
with _tasks_lock:
|
||||
return list(_tasks.values())
|
||||
|
||||
|
||||
@router.post("/mark-seen", status_code=204)
|
||||
def mark_channels_seen(
|
||||
db: Session = Depends(get_db),
|
||||
@@ -315,11 +354,122 @@ def list_channels(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
rows = db.execute(
|
||||
text(_CHANNEL_STATS_SELECT + "ORDER BY last_published_at DESC"),
|
||||
{"user_id": current_user.id},
|
||||
uid = current_user.id
|
||||
|
||||
# Step 1 — channel rows + user_channel metadata (fast, no video stats)
|
||||
ch_rows = db.execute(
|
||||
text("""
|
||||
SELECT c.id, c.youtube_channel_id, c.name, c.description,
|
||||
c.thumbnail_url, c.banner_url, c.subscriber_count, c.crawled_at,
|
||||
uc.status, uc.auto_download, uc.muted_until, uc.notes, uc.last_seen_at
|
||||
FROM channels c
|
||||
JOIN user_channels uc ON c.id = uc.channel_id
|
||||
WHERE uc.user_id = :uid AND uc.status = 'followed'
|
||||
"""),
|
||||
{"uid": uid},
|
||||
).mappings().all()
|
||||
return [ChannelOut(**dict(r)) for r in rows]
|
||||
|
||||
if not ch_rows:
|
||||
return []
|
||||
|
||||
id_csv = ",".join(str(r["id"]) for r in ch_rows)
|
||||
last_seen = {r["id"]: r["last_seen_at"] for r in ch_rows}
|
||||
|
||||
# Step 2 — aggregated video stats for all channels in one query
|
||||
vstats = {
|
||||
r["channel_id"]: r
|
||||
for r in db.execute(
|
||||
text(f"""
|
||||
SELECT v.channel_id,
|
||||
COUNT(*) AS video_count,
|
||||
MAX(v.published_at) AS last_published_at,
|
||||
julianday(MAX(v.published_at)) - julianday(MIN(v.published_at)) AS date_span_days,
|
||||
SUM(CASE WHEN COALESCE(uv.watched, 0) = 0 THEN 1 ELSE 0 END) AS unwatched_count,
|
||||
SUM(CASE WHEN uv.watched = 1 THEN 1 ELSE 0 END) AS watched_count,
|
||||
SUM(CASE WHEN uv.downloaded = 1 THEN 1 ELSE 0 END) AS downloaded_count
|
||||
FROM videos v
|
||||
LEFT JOIN user_videos uv ON uv.video_id = v.id AND uv.user_id = :uid
|
||||
WHERE v.channel_id IN ({id_csv})
|
||||
GROUP BY v.channel_id
|
||||
"""),
|
||||
{"uid": uid},
|
||||
).mappings().all()
|
||||
}
|
||||
|
||||
# Step 3 — new-video count per channel (videos indexed after last_seen_at)
|
||||
new_counts = {
|
||||
r["channel_id"]: r["new_count"]
|
||||
for r in db.execute(
|
||||
text(f"""
|
||||
SELECT v.channel_id, COUNT(*) AS new_count
|
||||
FROM videos v
|
||||
JOIN user_channels uc
|
||||
ON uc.channel_id = v.channel_id
|
||||
AND uc.user_id = :uid
|
||||
WHERE v.channel_id IN ({id_csv})
|
||||
AND (uc.last_seen_at IS NULL OR v.indexed_at > uc.last_seen_at)
|
||||
GROUP BY v.channel_id
|
||||
"""),
|
||||
{"uid": uid},
|
||||
).mappings().all()
|
||||
}
|
||||
|
||||
# Step 4 — latest video id + title per channel (derived-table join, no correlated subquery)
|
||||
latest = {
|
||||
r["channel_id"]: r
|
||||
for r in db.execute(
|
||||
text(f"""
|
||||
SELECT v.channel_id,
|
||||
v.youtube_video_id AS latest_video_id,
|
||||
v.title AS latest_video_title
|
||||
FROM videos v
|
||||
JOIN (
|
||||
SELECT channel_id, MAX(published_at) AS max_pub
|
||||
FROM videos
|
||||
WHERE channel_id IN ({id_csv})
|
||||
GROUP BY channel_id
|
||||
) m ON v.channel_id = m.channel_id AND v.published_at = m.max_pub
|
||||
GROUP BY v.channel_id
|
||||
"""),
|
||||
).mappings().all()
|
||||
}
|
||||
|
||||
# Merge and build response
|
||||
result = []
|
||||
for r in ch_rows:
|
||||
cid = r["id"]
|
||||
vs = vstats.get(cid) or {}
|
||||
vc = vs.get("video_count") or 0
|
||||
newest = vs.get("last_published_at")
|
||||
span = vs.get("date_span_days")
|
||||
freq = (span / (vc - 1.0)) if (vc >= 2 and span is not None) else None
|
||||
|
||||
result.append(ChannelOut(
|
||||
id=cid,
|
||||
youtube_channel_id=r["youtube_channel_id"],
|
||||
name=r["name"],
|
||||
description=r["description"],
|
||||
thumbnail_url=r["thumbnail_url"],
|
||||
banner_url=r.get("banner_url"),
|
||||
subscriber_count=r.get("subscriber_count"),
|
||||
crawled_at=r.get("crawled_at"),
|
||||
status=r["status"],
|
||||
auto_download=r.get("auto_download"),
|
||||
muted_until=r.get("muted_until"),
|
||||
notes=r.get("notes") or "",
|
||||
video_count=vc,
|
||||
last_published_at=newest,
|
||||
unwatched_count=vs.get("unwatched_count") or 0,
|
||||
watched_count=vs.get("watched_count") or 0,
|
||||
downloaded_count=vs.get("downloaded_count") or 0,
|
||||
new_count=new_counts.get(cid, 0),
|
||||
latest_video_id=latest.get(cid, {}).get("latest_video_id"),
|
||||
latest_video_title=latest.get(cid, {}).get("latest_video_title"),
|
||||
upload_frequency_days=freq,
|
||||
))
|
||||
|
||||
result.sort(key=lambda c: c.last_published_at or datetime.min, reverse=True)
|
||||
return result
|
||||
|
||||
|
||||
# ── Channel Groups (must be before /{channel_id} to avoid route shadowing) ───
|
||||
@@ -452,6 +602,7 @@ def bulk_channel_action(
|
||||
@router.get("/{channel_id}", response_model=ChannelOut)
|
||||
def get_channel(
|
||||
channel_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
@@ -482,11 +633,303 @@ def get_channel(
|
||||
).mappings().first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
|
||||
# Re-index in the background if stale (not crawled in the last hour)
|
||||
stale = True
|
||||
try:
|
||||
crawled_at_raw = row.get("crawled_at")
|
||||
if crawled_at_raw:
|
||||
crawled_at = (
|
||||
crawled_at_raw if isinstance(crawled_at_raw, datetime)
|
||||
else datetime.fromisoformat(str(crawled_at_raw))
|
||||
)
|
||||
stale = (datetime.utcnow() - crawled_at).total_seconds() > 3600
|
||||
except Exception:
|
||||
pass
|
||||
if stale:
|
||||
background_tasks.add_task(_index_channel_task, channel_id, current_user.id)
|
||||
|
||||
return ChannelOut(**dict(row))
|
||||
|
||||
|
||||
@router.get("/{channel_id}/videos", response_model=list[VideoOut])
|
||||
def get_channel_videos(
|
||||
channel_id: int,
|
||||
sort: str = "newest",
|
||||
offset: int = 0,
|
||||
limit: int = 60,
|
||||
q: str = "",
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_get_channel_or_404(db, channel_id)
|
||||
params: dict = {"user_id": current_user.id, "channel_id": channel_id, "limit": limit, "offset": offset}
|
||||
q_clause = ""
|
||||
if q.strip():
|
||||
q_clause = "AND (v.title LIKE :q OR v.description LIKE :q)"
|
||||
params["q"] = f"%{q.strip()}%"
|
||||
|
||||
order = {
|
||||
"newest": "v.published_at DESC NULLS LAST",
|
||||
"oldest": "v.published_at ASC NULLS LAST",
|
||||
"title": "v.title ASC",
|
||||
"unwatched":"COALESCE(uv.watched, 0) ASC, v.published_at DESC NULLS LAST",
|
||||
"popular": "v.view_count DESC NULLS LAST",
|
||||
}.get(sort, "v.published_at DESC NULLS LAST")
|
||||
view_count_clause = "AND v.view_count IS NOT NULL" if sort == "popular" else ""
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url,
|
||||
v.duration_seconds, v.published_at, v.view_count,
|
||||
COALESCE(uv.downloaded, 0) AS is_downloaded,
|
||||
COALESCE(uv.watched, 0) AS is_watched
|
||||
FROM videos v
|
||||
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||
WHERE v.channel_id = :channel_id {view_count_clause} {q_clause}
|
||||
ORDER BY {order}
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
params,
|
||||
).mappings().all()
|
||||
return [VideoOut(**dict(r)) for r in rows]
|
||||
|
||||
|
||||
@router.post("/{channel_id}/fetch-popular", status_code=status.HTTP_202_ACCEPTED)
|
||||
def fetch_popular_videos(
|
||||
channel_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Fetch the channel's most popular videos from YouTube and index them."""
|
||||
channel = _get_channel_or_404(db, channel_id)
|
||||
background_tasks.add_task(_fetch_popular_task, channel_id, channel.youtube_channel_id, channel.name or "")
|
||||
return {"detail": "Fetching popular videos"}
|
||||
|
||||
|
||||
def _fetch_popular_task(channel_id: int, youtube_channel_id: str, channel_name: str = ""):
|
||||
"""Half-and-half popular fetch.
|
||||
|
||||
Phase 1 (fast): flat-playlist crawl of the full channel → store any
|
||||
new videos in DB (title, duration, thumbnail). No individual requests.
|
||||
|
||||
Phase 2 (sequential, polite): fetch each video's watch page one at a time
|
||||
with a 2-second pause between requests to avoid cookie invalidation.
|
||||
Prioritises videos missing view_count; caps at 100 per run.
|
||||
"""
|
||||
import time
|
||||
from ..database import SessionLocal
|
||||
|
||||
task_id = f"popular-{channel_id}"
|
||||
label = f"Popular fetch — {channel_name}" if channel_name else "Popular fetch"
|
||||
|
||||
# Phase 1 — flat-playlist: crawl all channel videos quickly
|
||||
with _tasks_lock:
|
||||
_tasks[task_id] = {
|
||||
"id": task_id,
|
||||
"label": label,
|
||||
"phase": "Crawling channel…",
|
||||
"total": 0,
|
||||
"done": 0,
|
||||
"started_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
if youtube_channel_id.startswith("@"):
|
||||
url = f"https://www.youtube.com/{youtube_channel_id}/videos"
|
||||
else:
|
||||
url = f"https://www.youtube.com/channel/{youtube_channel_id}/videos"
|
||||
|
||||
stdout, _, _ = ytdlp._meta_run([
|
||||
"yt-dlp", url,
|
||||
"--dump-json", "--flat-playlist",
|
||||
"--quiet",
|
||||
*ytdlp._cookie_args(),
|
||||
], timeout=120)
|
||||
|
||||
flat_entries = []
|
||||
for line in stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
info = json.loads(line)
|
||||
yt_id = info.get("id")
|
||||
if yt_id:
|
||||
flat_entries.append({
|
||||
"id": yt_id,
|
||||
"title": info.get("title", ""),
|
||||
"duration": info.get("duration"),
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# Store any new videos from the flat crawl
|
||||
if flat_entries:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
channel = db.query(Channel).filter_by(id=channel_id).first()
|
||||
if channel:
|
||||
for entry in flat_entries:
|
||||
if not db.query(Video).filter_by(youtube_video_id=entry["id"]).first():
|
||||
try:
|
||||
db.add(Video(
|
||||
youtube_video_id=entry["id"],
|
||||
channel_id=channel_id,
|
||||
title=entry["title"],
|
||||
thumbnail_url=ytdlp._stable_thumbnail(entry["id"]),
|
||||
duration_seconds=entry["duration"],
|
||||
tags="[]",
|
||||
))
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Phase 2 — sequential fetches with a polite delay to avoid cookie invalidation
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT youtube_video_id FROM videos
|
||||
WHERE channel_id = :cid
|
||||
ORDER BY (view_count IS NULL) DESC, RANDOM()
|
||||
LIMIT 100
|
||||
"""),
|
||||
{"cid": channel_id},
|
||||
).mappings().all()
|
||||
video_ids = [r["youtube_video_id"] for r in rows]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if not video_ids:
|
||||
with _tasks_lock:
|
||||
_tasks.pop(task_id, None)
|
||||
return
|
||||
|
||||
with _tasks_lock:
|
||||
if task_id in _tasks:
|
||||
_tasks[task_id]["phase"] = "Enriching view counts…"
|
||||
_tasks[task_id]["total"] = len(video_ids)
|
||||
_tasks[task_id]["done"] = 0
|
||||
|
||||
try:
|
||||
for yt_id in video_ids:
|
||||
try:
|
||||
meta = ytdlp.fetch_video_metadata(yt_id, polite=True)
|
||||
if meta:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
vid = db.query(Video).filter_by(youtube_video_id=yt_id).first()
|
||||
if vid:
|
||||
if meta.get("view_count") is not None:
|
||||
vid.view_count = meta["view_count"]
|
||||
if not vid.published_at and meta.get("published_at"):
|
||||
vid.published_at = meta["published_at"]
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
with _tasks_lock:
|
||||
if task_id in _tasks:
|
||||
_tasks[task_id]["done"] += 1
|
||||
finally:
|
||||
with _tasks_lock:
|
||||
_tasks.pop(task_id, None)
|
||||
|
||||
|
||||
@router.post("/{channel_id}/search", status_code=status.HTTP_202_ACCEPTED)
|
||||
def search_channel_youtube(
|
||||
channel_id: int,
|
||||
q: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Search YouTube within this channel and index matching videos."""
|
||||
channel = _get_channel_or_404(db, channel_id)
|
||||
background_tasks.add_task(_search_channel_task, channel_id, channel.youtube_channel_id, q, current_user.id)
|
||||
return {"detail": "Search started"}
|
||||
|
||||
|
||||
def _search_channel_task(channel_id: int, youtube_channel_id: str, q: str, user_id: int):
|
||||
"""Fetch videos matching q from YouTube for this channel and index them."""
|
||||
from ..database import SessionLocal
|
||||
from urllib.parse import quote
|
||||
db = SessionLocal()
|
||||
try:
|
||||
url = f"https://www.youtube.com/channel/{youtube_channel_id}/search?query={quote(q)}"
|
||||
result = ytdlp.fetch_channel_metadata(youtube_channel_id, max_videos=100, polite=True)
|
||||
if not result:
|
||||
return
|
||||
# Filter results by query match (yt-dlp fetches all; we filter titles locally)
|
||||
q_lower = q.lower()
|
||||
matched = [v for v in result.get("videos", []) if q_lower in (v.get("title") or "").lower()]
|
||||
if not matched:
|
||||
matched = result.get("videos", [])[:30]
|
||||
channel = db.query(Channel).filter_by(id=channel_id).first()
|
||||
if not channel:
|
||||
return
|
||||
for vdata in matched:
|
||||
yt_id = vdata.get("youtube_video_id")
|
||||
if not yt_id:
|
||||
continue
|
||||
existing = db.query(Video).filter_by(youtube_video_id=yt_id).first()
|
||||
if not existing:
|
||||
db.add(Video(
|
||||
youtube_video_id=yt_id,
|
||||
channel_id=channel.id,
|
||||
title=vdata.get("title", ""),
|
||||
description=vdata.get("description"),
|
||||
thumbnail_url=vdata.get("thumbnail_url"),
|
||||
duration_seconds=vdata.get("duration_seconds"),
|
||||
published_at=vdata.get("published_at"),
|
||||
tags=vdata.get("tags"),
|
||||
category=vdata.get("category"),
|
||||
))
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{channel_id}/random", response_model=VideoOut)
|
||||
def get_random_channel_video(
|
||||
channel_id: int,
|
||||
unwatched_only: bool = True,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_get_channel_or_404(db, channel_id)
|
||||
unwatched_clause = "AND COALESCE(uv.watched, 0) = 0" if unwatched_only else ""
|
||||
row = db.execute(
|
||||
text(f"""
|
||||
SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url,
|
||||
v.duration_seconds, v.published_at, v.view_count,
|
||||
c.id AS channel_id, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id,
|
||||
COALESCE(uv.downloaded, 0) AS is_downloaded,
|
||||
COALESCE(uv.watched, 0) AS is_watched,
|
||||
COALESCE(uv.queued, 0) AS queued
|
||||
FROM videos v
|
||||
JOIN channels c ON v.channel_id = c.id
|
||||
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||
WHERE v.channel_id = :channel_id {unwatched_clause}
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 1
|
||||
"""),
|
||||
{"user_id": current_user.id, "channel_id": channel_id},
|
||||
).mappings().first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="No videos found")
|
||||
return VideoOut(**dict(row))
|
||||
|
||||
|
||||
@router.get("/{channel_id}/in-progress", response_model=list[VideoOut])
|
||||
def get_channel_in_progress(
|
||||
channel_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -495,13 +938,19 @@ def get_channel_videos(
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url,
|
||||
v.duration_seconds, v.published_at,
|
||||
v.duration_seconds, v.published_at, v.view_count,
|
||||
c.id AS channel_id, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id,
|
||||
COALESCE(uv.downloaded, 0) AS is_downloaded,
|
||||
COALESCE(uv.watched, 0) AS is_watched
|
||||
COALESCE(uv.watched, 0) AS is_watched,
|
||||
COALESCE(uv.queued, 0) AS queued
|
||||
FROM videos v
|
||||
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||
JOIN channels c ON v.channel_id = c.id
|
||||
JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||
WHERE v.channel_id = :channel_id
|
||||
ORDER BY v.published_at DESC
|
||||
AND uv.watch_progress_seconds > 30
|
||||
AND COALESCE(uv.watched, 0) = 0
|
||||
ORDER BY uv.watch_progress_seconds DESC
|
||||
LIMIT 6
|
||||
"""),
|
||||
{"user_id": current_user.id, "channel_id": channel_id},
|
||||
).mappings().all()
|
||||
@@ -559,10 +1008,70 @@ def index_channel(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_get_channel_or_404(db, channel_id)
|
||||
background_tasks.add_task(_index_channel_task, channel_id, current_user.id)
|
||||
background_tasks.add_task(_index_channel_task, channel_id, current_user.id, 100)
|
||||
return {"detail": "Indexing started"}
|
||||
|
||||
|
||||
@router.post("/{channel_id}/index-full", status_code=status.HTTP_202_ACCEPTED)
|
||||
def index_channel_full(
|
||||
channel_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_get_channel_or_404(db, channel_id)
|
||||
background_tasks.add_task(_index_channel_task, channel_id, current_user.id, 0)
|
||||
return {"detail": "Full index started"}
|
||||
|
||||
|
||||
@router.post("/{channel_id}/explore", status_code=status.HTTP_202_ACCEPTED)
|
||||
def explore_channel_older(
|
||||
channel_id: int,
|
||||
page: int = 2,
|
||||
background_tasks: BackgroundTasks = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Fetch a page of older videos from this channel (page 1 = newest 30, page 2 = next 100, etc.)."""
|
||||
_get_channel_or_404(db, channel_id)
|
||||
start = 1 if page <= 1 else (30 + (page - 2) * 100 + 1)
|
||||
background_tasks.add_task(_index_channel_explore_task, channel_id, current_user.id, start, 100)
|
||||
return {"detail": f"Fetching older videos (page {page})", "start": start}
|
||||
|
||||
|
||||
def _index_channel_explore_task(channel_id: int, user_id: int, start_video: int, count: int):
|
||||
from ..database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
channel = db.query(Channel).filter_by(id=channel_id).first()
|
||||
if not channel:
|
||||
return
|
||||
result = ytdlp.fetch_channel_metadata(channel.youtube_channel_id, max_videos=count, start_video=start_video, polite=True)
|
||||
if not result:
|
||||
return
|
||||
for vdata in result.get("videos", []):
|
||||
yt_id = vdata.get("youtube_video_id")
|
||||
if not yt_id:
|
||||
continue
|
||||
if not db.query(Video).filter_by(youtube_video_id=yt_id).first():
|
||||
db.add(Video(
|
||||
youtube_video_id=yt_id,
|
||||
channel_id=channel.id,
|
||||
title=vdata.get("title", ""),
|
||||
description=vdata.get("description"),
|
||||
thumbnail_url=vdata.get("thumbnail_url"),
|
||||
duration_seconds=vdata.get("duration_seconds"),
|
||||
published_at=vdata.get("published_at"),
|
||||
tags=vdata.get("tags"),
|
||||
category=vdata.get("category"),
|
||||
))
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/follow-bulk", status_code=200)
|
||||
def follow_bulk(
|
||||
body: dict,
|
||||
@@ -672,7 +1181,7 @@ def follow_by_url(
|
||||
|
||||
channel = db.query(Channel).filter_by(youtube_channel_id=yt_channel_id).first()
|
||||
if not channel:
|
||||
meta = ytdlp.fetch_channel_metadata(yt_channel_id, max_videos=30)
|
||||
meta = ytdlp.fetch_channel_metadata(yt_channel_id, max_videos=30, polite=True)
|
||||
if not meta or not meta.get("channel"):
|
||||
raise HTTPException(status_code=404, detail="Channel not found on YouTube")
|
||||
ch_data = meta["channel"]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import json
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
@@ -9,7 +10,7 @@ from sqlalchemy import text
|
||||
from ..auth_utils import get_current_user
|
||||
from ..database import get_db
|
||||
from ..models import Channel, DiscoveryQueue, User, UserChannel, UserSettings
|
||||
from ..services.discovery import run_full_discovery
|
||||
from ..services.discovery import schedule_discovery, get_discovery_progress
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -56,9 +57,35 @@ def list_discovery(
|
||||
ORDER BY dq.score DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
{"user_id": current_user.id, "limit": limit, "offset": offset},
|
||||
{"user_id": current_user.id, "limit": limit * 3, "offset": offset},
|
||||
).mappings().all()
|
||||
|
||||
# Load negative affinity tags and use them to filter channels already in the queue
|
||||
neg_affinity = {
|
||||
r["tag"] for r in db.execute(
|
||||
text("SELECT tag FROM user_tag_affinity WHERE user_id = :user_id AND score < -2"),
|
||||
{"user_id": current_user.id},
|
||||
).mappings().all()
|
||||
}
|
||||
if neg_affinity and rows:
|
||||
channel_ids_csv = ",".join(str(r["channel_id"]) for r in rows)
|
||||
vtag_rows = db.execute(
|
||||
text(f"SELECT channel_id, tags FROM videos WHERE channel_id IN ({channel_ids_csv}) AND tags IS NOT NULL LIMIT 1000")
|
||||
).mappings().all()
|
||||
neg_hit: dict[int, int] = {}
|
||||
for vr in vtag_rows:
|
||||
try:
|
||||
for tag in json.loads(vr["tags"] or "[]"):
|
||||
if isinstance(tag, str) and tag.lower().strip() in neg_affinity:
|
||||
neg_hit[vr["channel_id"]] = neg_hit.get(vr["channel_id"], 0) + 1
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
rows = [r for r in rows if neg_hit.get(r["channel_id"], 0) < 3]
|
||||
|
||||
# Add score perturbation so the list doesn't look identical every visit.
|
||||
# ±8 jitter keeps relative ranking meaningful while surfacing different channels.
|
||||
rows = sorted(rows, key=lambda r: r["score"] + random.uniform(-8, 8), reverse=True)
|
||||
rows = rows[:limit]
|
||||
items = []
|
||||
for row in rows:
|
||||
row = dict(row)
|
||||
@@ -133,17 +160,14 @@ def dismiss_discovery(
|
||||
|
||||
@router.post("/refresh", status_code=202)
|
||||
def refresh_discovery(
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
s = db.query(UserSettings).filter_by(user_id=current_user.id).first()
|
||||
regions_str = (s.discovery_regions if s and s.discovery_regions else "US,SE")
|
||||
regions = [r.strip().upper() for r in regions_str.split(",") if r.strip()]
|
||||
background_tasks.add_task(run_full_discovery, db, current_user.id, regions)
|
||||
from .channels import _enrich_missing_task
|
||||
background_tasks.add_task(_enrich_missing_task, 20)
|
||||
return {"detail": "Discovery refresh started"}
|
||||
schedule_discovery(current_user.id, regions)
|
||||
return {"detail": "Discovery queued"}
|
||||
|
||||
|
||||
@router.get("/videos", response_model=list[dict])
|
||||
@@ -177,7 +201,7 @@ def discovery_videos(
|
||||
)
|
||||
)
|
||||
WHERE rn <= 2
|
||||
ORDER BY score DESC, rn ASC, RANDOM()
|
||||
ORDER BY (score + (RANDOM() * 10 - 5)) DESC, rn ASC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
{"user_id": current_user.id, "limit": limit, "offset": offset},
|
||||
@@ -207,9 +231,30 @@ def dismiss_discovery_video(
|
||||
dq = db.query(DiscoveryQueue).filter_by(user_id=current_user.id, channel_id=channel_id).first()
|
||||
if dq:
|
||||
dq.seen = True
|
||||
|
||||
from ..routers.videos import _update_affinity
|
||||
_update_affinity(db, current_user.id, video, -3.0)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def discovery_status(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
s = db.query(UserSettings).filter_by(user_id=current_user.id).first()
|
||||
pending = db.execute(
|
||||
text("SELECT COUNT(*) AS n FROM discovery_queue WHERE user_id = :uid AND seen = 0"),
|
||||
{"uid": current_user.id},
|
||||
).mappings().first()
|
||||
return {
|
||||
"last_run": s.last_discovery_run.isoformat() if s and s.last_discovery_run else None,
|
||||
"pending_count": pending["n"] if pending else 0,
|
||||
"progress": get_discovery_progress(current_user.id),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/community", response_model=list[dict])
|
||||
def community_shelf(
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
@@ -19,6 +19,7 @@ router = APIRouter()
|
||||
class DownloadRequest(BaseModel):
|
||||
youtube_video_id: str
|
||||
quality: Optional[str] = None
|
||||
subtitle_langs: Optional[str] = None # overrides user setting when provided
|
||||
|
||||
|
||||
TRASH_TTL_DAYS = 7
|
||||
@@ -52,6 +53,32 @@ def _on_progress(download_id: int, pct: float):
|
||||
db.close()
|
||||
|
||||
|
||||
def _write_nfo(video: Video, channel: Optional[Channel]) -> None:
|
||||
"""Write a Jellyfin/Kodi-compatible .nfo sidecar next to the video file."""
|
||||
from pathlib import Path
|
||||
try:
|
||||
nfo_path = Path(settings.download_path) / f"{video.youtube_video_id}.nfo"
|
||||
title = (video.title or "").replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
plot = (video.description or "").replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
studio = (channel.name if channel else "").replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
year = video.published_at.year if video.published_at else ""
|
||||
date = video.published_at.strftime("%Y-%m-%d") if video.published_at else ""
|
||||
thumb_url = f"https://i.ytimg.com/vi/{video.youtube_video_id}/maxresdefault.jpg"
|
||||
nfo_path.write_text(f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<movie>
|
||||
<title>{title}</title>
|
||||
<plot>{plot}</plot>
|
||||
<year>{year}</year>
|
||||
<releasedate>{date}</releasedate>
|
||||
<studio>{studio}</studio>
|
||||
<thumb aspect="poster">{thumb_url}</thumb>
|
||||
<thumb aspect="backdrop">{thumb_url}</thumb>
|
||||
<uniqueid type="youtube" default="true">{video.youtube_video_id}</uniqueid>
|
||||
</movie>""", encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _on_complete(download_id: int, file_path: Optional[str], resolution: Optional[str] = None):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -71,6 +98,12 @@ def _on_complete(download_id: int, file_path: Optional[str], resolution: Optiona
|
||||
uv.downloaded = True
|
||||
uv.downloaded_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
# Write Jellyfin sidecar
|
||||
video = db.query(Video).filter_by(id=dl.video_id).first()
|
||||
if video:
|
||||
channel = db.query(Channel).filter_by(id=video.channel_id).first() if video.channel_id else None
|
||||
_write_nfo(video, channel)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -92,7 +125,7 @@ def _ensure_video(db: Session, youtube_video_id: str) -> Video:
|
||||
if video:
|
||||
return video
|
||||
|
||||
meta = ytdlp.fetch_video_metadata(youtube_video_id)
|
||||
meta = ytdlp.fetch_video_metadata(youtube_video_id, polite=True)
|
||||
if not meta:
|
||||
raise HTTPException(status_code=404, detail="Video not found on YouTube")
|
||||
|
||||
@@ -127,6 +160,10 @@ def create_download(
|
||||
user_settings = db.query(UserSettings).filter_by(user_id=current_user.id).first()
|
||||
default_quality = user_settings.preferred_quality if user_settings else "best"
|
||||
quality = body.quality if body.quality in ytdlp.QUALITY_FORMATS else default_quality
|
||||
if body.subtitle_langs is not None:
|
||||
subtitle_langs = body.subtitle_langs.strip()
|
||||
else:
|
||||
subtitle_langs = (user_settings.subtitle_langs or "") if user_settings else ""
|
||||
|
||||
_DL_SELECT = """
|
||||
SELECT d.id, d.status, d.progress_percent, d.resolution,
|
||||
@@ -155,7 +192,7 @@ def create_download(
|
||||
ytdlp.start_download,
|
||||
video.youtube_video_id, dl.id,
|
||||
_on_progress, _on_complete, _on_error,
|
||||
quality,
|
||||
quality, subtitle_langs,
|
||||
)
|
||||
|
||||
row = db.execute(text(_DL_SELECT), {"id": dl.id}).mappings().first()
|
||||
@@ -190,6 +227,11 @@ def _get_quality(db, user_id: int) -> str:
|
||||
return s.preferred_quality if s else "best"
|
||||
|
||||
|
||||
def _get_subtitle_langs(db, user_id: int) -> str:
|
||||
s = db.query(UserSettings).filter_by(user_id=user_id).first()
|
||||
return (s.subtitle_langs or "") if s else ""
|
||||
|
||||
|
||||
@router.post("/channel/{channel_id}", status_code=202)
|
||||
def download_channel_videos(
|
||||
channel_id: int,
|
||||
@@ -198,6 +240,7 @@ def download_channel_videos(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
quality = _get_quality(db, current_user.id)
|
||||
subtitle_langs = _get_subtitle_langs(db, current_user.id)
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT v.id, v.youtube_video_id
|
||||
@@ -216,7 +259,7 @@ def download_channel_videos(
|
||||
db.flush()
|
||||
background_tasks.add_task(
|
||||
ytdlp.start_download, row["youtube_video_id"], dl.id,
|
||||
_on_progress, _on_complete, _on_error, quality,
|
||||
_on_progress, _on_complete, _on_error, quality, subtitle_langs,
|
||||
)
|
||||
count += 1
|
||||
db.commit()
|
||||
@@ -230,6 +273,7 @@ def download_following_videos(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
quality = _get_quality(db, current_user.id)
|
||||
subtitle_langs = _get_subtitle_langs(db, current_user.id)
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT v.id, v.youtube_video_id
|
||||
@@ -282,6 +326,7 @@ def _purge_expired_trash(db: Session):
|
||||
|
||||
|
||||
def _delete_download_record(db: Session, dl: "Download", user_id: int):
|
||||
from pathlib import Path
|
||||
video = db.query(Video).filter_by(id=dl.video_id).first()
|
||||
if video:
|
||||
fp = ytdlp.predicted_file_path(video.youtube_video_id)
|
||||
@@ -290,6 +335,13 @@ def _delete_download_record(db: Session, dl: "Download", user_id: int):
|
||||
os.remove(fp)
|
||||
except OSError:
|
||||
pass
|
||||
# Remove NFO sidecar if present
|
||||
nfo = Path(settings.download_path) / f"{video.youtube_video_id}.nfo"
|
||||
if nfo.exists():
|
||||
try:
|
||||
os.remove(nfo)
|
||||
except OSError:
|
||||
pass
|
||||
uv = db.query(UserVideo).filter_by(user_id=user_id, video_id=dl.video_id).first()
|
||||
if uv:
|
||||
uv.downloaded = False
|
||||
@@ -297,6 +349,38 @@ def _delete_download_record(db: Session, dl: "Download", user_id: int):
|
||||
db.delete(dl)
|
||||
|
||||
|
||||
@router.post("/nfo/generate", status_code=200)
|
||||
def generate_nfo_files(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Generate .nfo sidecar files for all completed downloads that have a file on disk."""
|
||||
from pathlib import Path
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT v.id, v.youtube_video_id, v.channel_id
|
||||
FROM downloads d
|
||||
JOIN videos v ON d.video_id = v.id
|
||||
WHERE d.user_id = :uid AND d.status = 'complete'
|
||||
"""),
|
||||
{"uid": current_user.id},
|
||||
).mappings().all()
|
||||
|
||||
written = 0
|
||||
for row in rows:
|
||||
fp = Path(settings.download_path) / f"{row['youtube_video_id']}.mp4"
|
||||
if not fp.exists():
|
||||
continue
|
||||
video = db.query(Video).filter_by(id=row["id"]).first()
|
||||
if not video:
|
||||
continue
|
||||
channel = db.query(Channel).filter_by(id=video.channel_id).first() if video.channel_id else None
|
||||
_write_nfo(video, channel)
|
||||
written += 1
|
||||
|
||||
return {"generated": written}
|
||||
|
||||
|
||||
@router.delete("/all", status_code=204)
|
||||
def delete_all_downloads(
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
220
backend/routers/playlists.py
Normal file
220
backend/routers/playlists.py
Normal file
@@ -0,0 +1,220 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..auth_utils import get_current_user
|
||||
from ..database import get_db
|
||||
from ..models import Channel, Playlist, Video, User
|
||||
from ..services import ytdlp
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class PlaylistOut(BaseModel):
|
||||
id: int
|
||||
youtube_playlist_id: str
|
||||
channel_id: Optional[int]
|
||||
title: str
|
||||
description: Optional[str]
|
||||
thumbnail_url: Optional[str]
|
||||
video_count: int
|
||||
indexed_at: Optional[datetime]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PlaylistVideoOut(BaseModel):
|
||||
id: int
|
||||
youtube_video_id: str
|
||||
title: str
|
||||
thumbnail_url: Optional[str]
|
||||
duration_seconds: Optional[int]
|
||||
published_at: Optional[datetime]
|
||||
view_count: Optional[int]
|
||||
is_downloaded: bool = False
|
||||
is_watched: bool = False
|
||||
channel_id: Optional[int] = None
|
||||
channel_name: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@router.get("/channel/{channel_id}", response_model=list[PlaylistOut])
|
||||
def get_channel_playlists(
|
||||
channel_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
rows = db.query(Playlist).filter_by(channel_id=channel_id).order_by(Playlist.video_count.desc()).all()
|
||||
return rows
|
||||
|
||||
|
||||
@router.post("/channel/{channel_id}/fetch", status_code=status.HTTP_202_ACCEPTED)
|
||||
def fetch_channel_playlists(
|
||||
channel_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
channel = db.query(Channel).filter_by(id=channel_id).first()
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
background_tasks.add_task(_fetch_playlists_task, channel_id, channel.youtube_channel_id)
|
||||
return {"detail": "Fetching playlists"}
|
||||
|
||||
|
||||
def _first_video_thumbnail(youtube_playlist_id: str) -> Optional[str]:
|
||||
"""Fetch just the first video of a playlist and return a stable thumbnail URL."""
|
||||
try:
|
||||
videos = ytdlp.fetch_playlist_videos(youtube_playlist_id, max_videos=1)
|
||||
if videos:
|
||||
vid_id = videos[0].get("youtube_video_id")
|
||||
if vid_id:
|
||||
from ..services.ytdlp import _stable_thumbnail
|
||||
return _stable_thumbnail(vid_id)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_playlists_task(channel_id: int, youtube_channel_id: str):
|
||||
from ..database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
playlists = ytdlp.fetch_channel_playlists(youtube_channel_id)
|
||||
for pl in playlists:
|
||||
pl_id = pl["youtube_playlist_id"]
|
||||
thumb = pl.get("thumbnail_url") or _first_video_thumbnail(pl_id)
|
||||
existing = db.query(Playlist).filter_by(youtube_playlist_id=pl_id).first()
|
||||
if existing:
|
||||
existing.title = pl["title"] or existing.title
|
||||
if pl.get("video_count"):
|
||||
existing.video_count = pl["video_count"]
|
||||
if thumb and not existing.thumbnail_url:
|
||||
existing.thumbnail_url = thumb
|
||||
else:
|
||||
db.add(Playlist(
|
||||
youtube_playlist_id=pl_id,
|
||||
channel_id=channel_id,
|
||||
title=pl["title"],
|
||||
description=pl.get("description"),
|
||||
thumbnail_url=thumb,
|
||||
video_count=pl.get("video_count") or 0,
|
||||
))
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{playlist_id}/videos", response_model=list[PlaylistVideoOut])
|
||||
def get_playlist_videos(
|
||||
playlist_id: int,
|
||||
offset: int = 0,
|
||||
limit: int = 60,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
playlist = db.query(Playlist).filter_by(id=playlist_id).first()
|
||||
if not playlist:
|
||||
raise HTTPException(status_code=404, detail="Playlist not found")
|
||||
if not playlist.indexed_at:
|
||||
return []
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url,
|
||||
v.duration_seconds, v.published_at, v.view_count,
|
||||
c.id AS channel_id, c.name AS channel_name,
|
||||
COALESCE(uv.downloaded, 0) AS is_downloaded,
|
||||
COALESCE(uv.watched, 0) AS is_watched
|
||||
FROM videos v
|
||||
LEFT JOIN channels c ON v.channel_id = c.id
|
||||
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||
WHERE v.youtube_video_id IN (
|
||||
SELECT value FROM json_each((
|
||||
SELECT video_ids FROM playlists WHERE id = :playlist_id
|
||||
))
|
||||
)
|
||||
ORDER BY v.published_at DESC NULLS LAST
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
{"user_id": current_user.id, "playlist_id": playlist_id, "limit": limit, "offset": offset},
|
||||
).mappings().all()
|
||||
return [PlaylistVideoOut(**dict(r)) for r in rows]
|
||||
|
||||
|
||||
@router.post("/{playlist_id}/index", status_code=status.HTTP_202_ACCEPTED)
|
||||
def index_playlist(
|
||||
playlist_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
playlist = db.query(Playlist).filter_by(id=playlist_id).first()
|
||||
if not playlist:
|
||||
raise HTTPException(status_code=404, detail="Playlist not found")
|
||||
background_tasks.add_task(_index_playlist_task, playlist_id, playlist.youtube_playlist_id, playlist.channel_id)
|
||||
return {"detail": "Indexing playlist"}
|
||||
|
||||
|
||||
def _index_playlist_task(playlist_id: int, youtube_playlist_id: str, channel_id: Optional[int]):
|
||||
from ..database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
videos = ytdlp.fetch_playlist_videos(youtube_playlist_id)
|
||||
playlist = db.query(Playlist).filter_by(id=playlist_id).first()
|
||||
if not playlist:
|
||||
return
|
||||
|
||||
video_yt_ids = []
|
||||
for vdata in videos:
|
||||
yt_id = vdata.get("youtube_video_id")
|
||||
if not yt_id:
|
||||
continue
|
||||
video_yt_ids.append(yt_id)
|
||||
existing = db.query(Video).filter_by(youtube_video_id=yt_id).first()
|
||||
if existing:
|
||||
if vdata.get("view_count") is not None:
|
||||
existing.view_count = vdata["view_count"]
|
||||
else:
|
||||
ch_id = channel_id
|
||||
if not ch_id and vdata.get("channel", {}).get("youtube_channel_id"):
|
||||
ch = db.query(Channel).filter_by(
|
||||
youtube_channel_id=vdata["channel"]["youtube_channel_id"]
|
||||
).first()
|
||||
if ch:
|
||||
ch_id = ch.id
|
||||
db.add(Video(
|
||||
youtube_video_id=yt_id,
|
||||
channel_id=ch_id,
|
||||
title=vdata.get("title", ""),
|
||||
thumbnail_url=vdata.get("thumbnail_url"),
|
||||
duration_seconds=vdata.get("duration_seconds"),
|
||||
published_at=vdata.get("published_at"),
|
||||
view_count=vdata.get("view_count"),
|
||||
tags="[]",
|
||||
))
|
||||
|
||||
playlist.video_count = len(video_yt_ids)
|
||||
playlist.indexed_at = datetime.utcnow()
|
||||
playlist.video_ids = json.dumps(video_yt_ids)
|
||||
if not playlist.thumbnail_url and video_yt_ids:
|
||||
from ..services.ytdlp import _stable_thumbnail
|
||||
playlist.thumbnail_url = _stable_thumbnail(video_yt_ids[0])
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
return
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Enrich dates and view counts for videos missing them — runs in background
|
||||
import threading
|
||||
from ..routers.channels import _enrich_missing_task
|
||||
threading.Thread(target=_enrich_missing_task, args=(len(video_yt_ids),), daemon=True).start()
|
||||
@@ -264,8 +264,9 @@ def search(
|
||||
|
||||
source = "local" if (video_results or channel_results) else "none"
|
||||
|
||||
# Fall back to live yt-dlp search if no local results or explicitly requested
|
||||
if not video_results or live:
|
||||
# Fall back to live yt-dlp search if no local results or explicitly requested.
|
||||
# Skip if a download is active — concurrent yt-dlp sessions invalidate cookies.
|
||||
if (not video_results or live) and not ytdlp.is_download_active():
|
||||
try:
|
||||
live_raw = ytdlp.search_youtube(q)
|
||||
live_results = _live_search_to_results(db, current_user.id, live_raw)
|
||||
|
||||
@@ -34,6 +34,8 @@ class SettingsOut(BaseModel):
|
||||
feed_weight_affinity: float = 5.0
|
||||
feed_weight_channel: float = 5.0
|
||||
use_oauth2: bool = False
|
||||
sync_interval_hours: int = 0
|
||||
subtitle_langs: str = ""
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -55,6 +57,8 @@ class SettingsPatch(BaseModel):
|
||||
feed_weight_affinity: Optional[float] = Field(None, ge=0.0, le=10.0)
|
||||
feed_weight_channel: Optional[float] = Field(None, ge=0.0, le=10.0)
|
||||
use_oauth2: Optional[bool] = None
|
||||
sync_interval_hours: Optional[int] = Field(None, ge=0, le=168)
|
||||
subtitle_langs: Optional[str] = None
|
||||
|
||||
|
||||
def _get_or_create(db: Session, user_id: int) -> UserSettings:
|
||||
@@ -123,6 +127,10 @@ def update_settings(
|
||||
if body.use_oauth2 is not None:
|
||||
s.use_oauth2 = body.use_oauth2
|
||||
ytdlp.set_oauth2(body.use_oauth2)
|
||||
if body.sync_interval_hours is not None:
|
||||
s.sync_interval_hours = body.sync_interval_hours
|
||||
if body.subtitle_langs is not None:
|
||||
s.subtitle_langs = body.subtitle_langs.strip()
|
||||
|
||||
db.commit()
|
||||
db.refresh(s)
|
||||
@@ -242,7 +250,7 @@ def ytdlp_test(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = subprocess.run(
|
||||
test_stdout, test_stderr, test_code = ytdlp._meta_run(
|
||||
[
|
||||
"yt-dlp",
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
@@ -250,15 +258,15 @@ def ytdlp_test(
|
||||
"--extractor-args", "youtube:player_client=web",
|
||||
*cookie_args,
|
||||
],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
timeout=30,
|
||||
)
|
||||
return {
|
||||
"node_path": node_path,
|
||||
"node_version": node_version,
|
||||
"yt_dlp_version": yt_version,
|
||||
"cookie_args": cookie_args,
|
||||
"returncode": result.returncode,
|
||||
"stdout_lines": result.stdout.splitlines()[:5],
|
||||
"stderr_tail": result.stderr.splitlines()[-20:],
|
||||
"success": result.returncode == 0,
|
||||
"returncode": test_code,
|
||||
"stdout_lines": test_stdout.splitlines()[:5],
|
||||
"stderr_tail": test_stderr.splitlines()[-20:],
|
||||
"success": test_code == 0,
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..auth_utils import get_current_user
|
||||
from ..config import settings
|
||||
from ..database import get_db
|
||||
from ..models import User, UserTagAffinity
|
||||
|
||||
@@ -81,7 +85,7 @@ def get_stats(
|
||||
avg_completion = db.execute(
|
||||
text("""
|
||||
SELECT AVG(uv.completion_percent) AS avg_pct,
|
||||
COUNT(CASE WHEN uv.completion_percent >= 90 THEN 1 END) AS finished_count,
|
||||
COUNT(CASE WHEN uv.completion_percent >= 75 THEN 1 END) AS finished_count,
|
||||
COUNT(CASE WHEN uv.completion_percent < 20 AND uv.completion_percent IS NOT NULL THEN 1 END) AS bailed_count,
|
||||
SUM(uv.rewatch_count) AS total_rewatches,
|
||||
COUNT(CASE WHEN uv.rewatch_count > 0 THEN 1 END) AS rewatched_videos
|
||||
@@ -110,7 +114,19 @@ def get_stats(
|
||||
SELECT tag, score FROM user_tag_affinity
|
||||
WHERE user_id = :uid AND score > 0
|
||||
ORDER BY score DESC
|
||||
LIMIT 20
|
||||
LIMIT 60
|
||||
"""),
|
||||
{"uid": uid},
|
||||
).mappings().all()
|
||||
|
||||
peak_hours = db.execute(
|
||||
text("""
|
||||
SELECT CAST(strftime('%H', uv.last_watched_at) AS INTEGER) AS hour,
|
||||
COUNT(*) AS count
|
||||
FROM user_videos uv
|
||||
WHERE uv.user_id = :uid AND uv.watched = 1 AND uv.last_watched_at IS NOT NULL
|
||||
GROUP BY hour
|
||||
ORDER BY hour ASC
|
||||
"""),
|
||||
{"uid": uid},
|
||||
).mappings().all()
|
||||
@@ -120,6 +136,24 @@ def get_stats(
|
||||
{"uid": uid},
|
||||
).mappings().first()
|
||||
|
||||
started_count = db.execute(
|
||||
text("""
|
||||
SELECT COUNT(*) AS n FROM user_videos
|
||||
WHERE user_id = :uid AND watched = 0
|
||||
AND (watch_progress_seconds > 0 OR last_watched_at IS NOT NULL)
|
||||
"""),
|
||||
{"uid": uid},
|
||||
).mappings().first()
|
||||
|
||||
try:
|
||||
disk = shutil.disk_usage(settings.download_path)
|
||||
download_bytes = sum(
|
||||
e.stat().st_size for e in os.scandir(settings.download_path) if e.is_file()
|
||||
)
|
||||
except Exception:
|
||||
disk = None
|
||||
download_bytes = 0
|
||||
|
||||
return {
|
||||
"total_watched": totals["total_watched"] or 0,
|
||||
"total_watch_seconds": totals["total_watch_seconds"] or 0,
|
||||
@@ -139,8 +173,16 @@ def get_stats(
|
||||
"total_rewatches": avg_completion["total_rewatches"] or 0,
|
||||
"rewatched_videos": avg_completion["rewatched_videos"] or 0,
|
||||
"total_liked": liked_count["n"] or 0,
|
||||
"started_count": started_count["n"] or 0,
|
||||
"top_categories": [dict(r) for r in top_categories],
|
||||
"peak_hours": [dict(r) for r in peak_hours],
|
||||
"taste_profile": [dict(r) for r in taste_profile],
|
||||
"disk": {
|
||||
"total_bytes": disk.total if disk else None,
|
||||
"free_bytes": disk.free if disk else None,
|
||||
"used_bytes": disk.used if disk else None,
|
||||
"download_bytes": download_bytes,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import random
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@@ -17,6 +18,11 @@ from ..services.scoring import get_surprise_videos, get_discovery_injection
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Tracks which video IDs currently have a background enrichment running,
|
||||
# so repeated polls from the frontend don't spawn duplicate yt-dlp calls.
|
||||
_enriching: set[str] = set()
|
||||
_enriching_lock = threading.Lock()
|
||||
|
||||
|
||||
def _update_affinity(db: Session, user_id: int, video: Video, delta: float):
|
||||
"""Adjust tag/category affinity scores for a video. delta > 0 = positive signal."""
|
||||
@@ -37,7 +43,6 @@ def _update_affinity(db: Session, user_id: int, video: Video, delta: float):
|
||||
existing.score = max(existing.score + delta, -20.0)
|
||||
existing.updated_at = datetime.utcnow()
|
||||
else:
|
||||
if delta > 0:
|
||||
db.add(UserTagAffinity(user_id=user_id, tag=tag, score=delta))
|
||||
|
||||
|
||||
@@ -68,6 +73,7 @@ class VideoDetail(BaseModel):
|
||||
view_count: Optional[int] = None
|
||||
like_count: Optional[int] = None
|
||||
dislike_count: Optional[int] = None
|
||||
channel_thumbnail_url: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -147,6 +153,7 @@ def home_feed(
|
||||
v.duration_seconds, v.published_at, v.tags, v.category,
|
||||
c.id AS channel_id, c.name AS channel_name,
|
||||
c.youtube_channel_id AS channel_youtube_id,
|
||||
c.thumbnail_url AS channel_thumbnail_url,
|
||||
COALESCE(uv.watched, 0) AS watched,
|
||||
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
|
||||
COALESCE(uv.downloaded, 0) AS is_downloaded,
|
||||
@@ -179,6 +186,7 @@ def home_feed(
|
||||
v.duration_seconds, v.published_at, v.tags, v.category,
|
||||
c.id AS channel_id, c.name AS channel_name,
|
||||
c.youtube_channel_id AS channel_youtube_id,
|
||||
c.thumbnail_url AS channel_thumbnail_url,
|
||||
COALESCE(uv.watched, 0) AS watched,
|
||||
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
|
||||
COALESCE(uv.downloaded, 0) AS is_downloaded,
|
||||
@@ -206,6 +214,58 @@ def home_feed(
|
||||
for r in rows
|
||||
]
|
||||
|
||||
if mode == "rediscover":
|
||||
# Older unwatched videos from followed channels, ranked by tag affinity
|
||||
affinity_rows = db.execute(
|
||||
text("SELECT tag, score FROM user_tag_affinity WHERE user_id = :user_id AND score > 0"),
|
||||
{"user_id": current_user.id},
|
||||
).mappings().all()
|
||||
affinity = {r["tag"]: r["score"] for r in affinity_rows}
|
||||
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url,
|
||||
v.duration_seconds, v.published_at, v.tags, v.category,
|
||||
c.id AS channel_id, c.name AS channel_name,
|
||||
c.youtube_channel_id AS channel_youtube_id,
|
||||
c.thumbnail_url AS channel_thumbnail_url,
|
||||
COALESCE(uv.watched, 0) AS watched,
|
||||
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
|
||||
COALESCE(uv.downloaded, 0) AS is_downloaded,
|
||||
COALESCE(uv.queued, 0) AS queued,
|
||||
NULL AS file_path
|
||||
FROM videos v
|
||||
JOIN channels c ON v.channel_id = c.id
|
||||
JOIN user_channels uc
|
||||
ON c.id = uc.channel_id AND uc.user_id = :user_id AND uc.status = 'followed'
|
||||
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||
WHERE COALESCE(uv.watched, 0) = 0
|
||||
AND v.published_at < datetime('now', '-90 days')
|
||||
AND (uc.muted_until IS NULL OR datetime(uc.muted_until) < datetime('now'))
|
||||
{duration_clause}
|
||||
ORDER BY RANDOM()
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
{"user_id": current_user.id, "limit": min(limit * 4, 200), "offset": offset},
|
||||
).mappings().all()
|
||||
|
||||
if affinity:
|
||||
import json as _json
|
||||
def _affinity_score(row):
|
||||
try:
|
||||
tags = _json.loads(row["tags"] or "[]")
|
||||
return sum(affinity.get(t.lower().strip(), 0) for t in tags if isinstance(t, str))
|
||||
except Exception:
|
||||
return 0
|
||||
rows = sorted(rows, key=_affinity_score, reverse=True)
|
||||
|
||||
rows = rows[:limit]
|
||||
return [
|
||||
VideoDetail(**{k: v for k, v in dict(r).items() if k not in ("watched",)},
|
||||
is_watched=bool(r["watched"]))
|
||||
for r in rows
|
||||
]
|
||||
|
||||
if mode == "inbox":
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
@@ -213,6 +273,7 @@ def home_feed(
|
||||
v.duration_seconds, v.published_at, v.tags, v.category,
|
||||
c.id AS channel_id, c.name AS channel_name,
|
||||
c.youtube_channel_id AS channel_youtube_id,
|
||||
c.thumbnail_url AS channel_thumbnail_url,
|
||||
COALESCE(uv.watched, 0) AS watched,
|
||||
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
|
||||
COALESCE(uv.downloaded, 0) AS is_downloaded,
|
||||
@@ -242,6 +303,15 @@ def home_feed(
|
||||
]
|
||||
|
||||
# mode == "ranked" (default)
|
||||
import random as _random
|
||||
|
||||
# Pull a large candidate pool per page. Each page draws from a NON-overlapping
|
||||
# slice of the scored list so pagination actually moves through new material.
|
||||
# candidate_limit >> limit so tier-sampling has real variety to choose from.
|
||||
candidate_limit = min(limit * 15, 600)
|
||||
page_num = offset // limit if limit > 0 else 0
|
||||
sql_offset = page_num * candidate_limit # non-overlapping pages
|
||||
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
WITH channel_stats AS (
|
||||
@@ -249,7 +319,9 @@ def home_feed(
|
||||
v.channel_id,
|
||||
COUNT(CASE WHEN uv.watched = 1 THEN 1 END) AS watched_count,
|
||||
COUNT(CASE WHEN uv.liked = 1 THEN 1 END) AS liked_count,
|
||||
SUM(CASE WHEN uv.rating IS NOT NULL THEN uv.rating ELSE 0 END) AS rating_sum
|
||||
SUM(CASE WHEN uv.rating IS NOT NULL THEN uv.rating ELSE 0 END) AS rating_sum,
|
||||
AVG(CASE WHEN uv.completion_percent IS NOT NULL THEN uv.completion_percent END) AS avg_completion_pct,
|
||||
COUNT(CASE WHEN uv.watched = 1 AND uv.last_watched_at > datetime('now', '-30 days') THEN 1 END) AS recent_watches
|
||||
FROM videos v
|
||||
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||
GROUP BY v.channel_id
|
||||
@@ -260,22 +332,32 @@ def home_feed(
|
||||
v.duration_seconds, v.published_at, v.tags, v.category,
|
||||
c.id AS channel_id, c.name AS channel_name,
|
||||
c.youtube_channel_id AS channel_youtube_id,
|
||||
c.thumbnail_url AS channel_thumbnail_url,
|
||||
COALESCE(uv.watched, 0) AS watched,
|
||||
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
|
||||
COALESCE(uv.downloaded, 0) AS is_downloaded,
|
||||
COALESCE(uv.queued, 0) AS queued,
|
||||
uv.rating AS rating,
|
||||
NULL AS file_path,
|
||||
(SQRT(CAST(COALESCE(cs.watched_count, 0) AS REAL)) * 6.0
|
||||
+ COALESCE(cs.liked_count, 0) * 12.0
|
||||
+ COALESCE(cs.rating_sum, 0) * 8.0) * :w_channel
|
||||
(SQRT(CAST(COALESCE(cs.watched_count, 0) AS REAL)) * 5.0
|
||||
+ COALESCE(cs.liked_count, 0) * 10.0
|
||||
+ COALESCE(cs.rating_sum, 0) * 8.0
|
||||
+ COALESCE(cs.avg_completion_pct, 50.0) * 0.08
|
||||
+ COALESCE(cs.recent_watches, 0) * 4.0
|
||||
) * :w_channel
|
||||
+ MAX(COALESCE(julianday(v.published_at) - julianday('now'), -90), -365) * :w_recency
|
||||
+ COALESCE((
|
||||
SELECT uta.score FROM user_tag_affinity uta
|
||||
SELECT COALESCE(SUM(uta.score), 0)
|
||||
FROM user_tag_affinity uta
|
||||
WHERE uta.user_id = :user_id
|
||||
AND uta.tag = LOWER(COALESCE(v.category, ''))
|
||||
LIMIT 1
|
||||
), 0) * 3.0 * :w_affinity
|
||||
AND (uta.tag = LOWER(COALESCE(v.category, ''))
|
||||
OR instr(LOWER(COALESCE(v.tags, '')), '"' || uta.tag || '"') > 0)
|
||||
LIMIT 5
|
||||
), 0) * :w_affinity
|
||||
- CASE WHEN COALESCE(uv.completion_percent, 100) < 20
|
||||
AND COALESCE(uv.watch_progress_seconds, 0) > 30
|
||||
THEN 25 ELSE 0 END
|
||||
- 3 * MIN(COALESCE(uv.feed_shown_count, 0), 10)
|
||||
AS score,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY v.channel_id
|
||||
@@ -292,21 +374,71 @@ def home_feed(
|
||||
{duration_clause}
|
||||
)
|
||||
SELECT * FROM scored
|
||||
WHERE rn <= 3
|
||||
ORDER BY score DESC, RANDOM()
|
||||
LIMIT :limit OFFSET :offset
|
||||
WHERE rn <= 15
|
||||
ORDER BY score DESC
|
||||
LIMIT :candidate_limit OFFSET :sql_offset
|
||||
"""),
|
||||
{"user_id": current_user.id, "limit": limit, "offset": offset, "hide_watched": 1 if hide_watched else 0,
|
||||
{"user_id": current_user.id, "candidate_limit": candidate_limit, "sql_offset": sql_offset,
|
||||
"hide_watched": 1 if hide_watched else 0,
|
||||
"w_recency": w_recency, "w_affinity": w_affinity, "w_channel": w_channel},
|
||||
).mappings().all()
|
||||
|
||||
# Tier-based sampling: scores span -365..+100+ so ±N jitter is useless.
|
||||
# Instead split the ranked pool into thirds and randomly sample from each,
|
||||
# so every reshuffle genuinely picks a different mix of top/mid/wildcard videos.
|
||||
candidates = [dict(r) for r in rows]
|
||||
n = len(candidates)
|
||||
|
||||
if n <= limit:
|
||||
_random.shuffle(candidates)
|
||||
top = candidates
|
||||
else:
|
||||
split1 = max(n * 2 // 5, limit) # top 40 %
|
||||
split2 = max(n * 4 // 5, split1 + 1) # next 40 %
|
||||
t1 = candidates[:split1]
|
||||
t2 = candidates[split1:split2]
|
||||
t3 = candidates[split2:]
|
||||
|
||||
# 60 % from t1, 30 % from t2, 10 % wildcards from t3
|
||||
n1 = limit * 6 // 10
|
||||
n2 = limit * 3 // 10
|
||||
n3 = limit - n1 - n2
|
||||
|
||||
picked = (
|
||||
_random.sample(t1, min(n1, len(t1)))
|
||||
+ (_random.sample(t2, min(n2, len(t2))) if t2 else [])
|
||||
+ (_random.sample(t3, min(n3, len(t3))) if t3 else [])
|
||||
)
|
||||
# Fill any shortfall when a tier was smaller than requested
|
||||
if len(picked) < limit:
|
||||
already = {id(x) for x in picked}
|
||||
rest = [x for x in candidates if id(x) not in already]
|
||||
if rest:
|
||||
picked += _random.sample(rest, min(limit - len(picked), len(rest)))
|
||||
|
||||
_random.shuffle(picked)
|
||||
top = picked[:limit]
|
||||
|
||||
# Track impressions — penalises videos shown but not clicked on repeat visits
|
||||
if page_num == 0 and top:
|
||||
for item in top:
|
||||
if not item["watched"]:
|
||||
db.execute(text("""
|
||||
INSERT INTO user_videos (user_id, video_id, feed_shown_count)
|
||||
VALUES (:uid, :vid, 1)
|
||||
ON CONFLICT (user_id, video_id)
|
||||
DO UPDATE SET feed_shown_count = feed_shown_count + 1
|
||||
"""), {"uid": current_user.id, "vid": item["id"]})
|
||||
db.commit()
|
||||
|
||||
followed = [
|
||||
VideoDetail(**{k: v for k, v in dict(r).items() if k not in ("watched", "score", "rn")},
|
||||
is_watched=bool(r["watched"]))
|
||||
for r in rows
|
||||
VideoDetail(**{k: v for k, v in item.items() if k not in ("watched", "score", "rn")},
|
||||
is_watched=bool(item["watched"]))
|
||||
for item in top
|
||||
]
|
||||
|
||||
# Inject discovery cards on every page: 1 every 5 followed cards.
|
||||
disc_per_page = max(limit // 5, 1)
|
||||
# Inject discovery cards: 1 every 3 followed cards (~25% recommendations).
|
||||
disc_per_page = max(limit // 3, 1)
|
||||
disc_offset = (offset // limit) * disc_per_page if limit > 0 else 0
|
||||
|
||||
disc_rows = db.execute(
|
||||
@@ -314,7 +446,8 @@ def home_feed(
|
||||
SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url,
|
||||
v.duration_seconds, v.published_at, v.tags, v.category,
|
||||
c.id AS channel_id, c.name AS channel_name,
|
||||
c.youtube_channel_id AS channel_youtube_id
|
||||
c.youtube_channel_id AS channel_youtube_id,
|
||||
c.thumbnail_url AS channel_thumbnail_url
|
||||
FROM discovery_queue dq
|
||||
JOIN channels c ON dq.channel_id = c.id
|
||||
JOIN videos v ON v.channel_id = c.id
|
||||
@@ -335,17 +468,23 @@ def home_feed(
|
||||
{"user_id": current_user.id, "disc_limit": disc_per_page, "disc_offset": disc_offset},
|
||||
).mappings().all()
|
||||
|
||||
import random as _rand
|
||||
disc_list = [dict(r) for r in disc_rows]
|
||||
# Shuffle top-tier recs so the same channel doesn't always appear first
|
||||
if len(disc_list) > 3:
|
||||
top, rest = disc_list[:3], disc_list[3:]
|
||||
_rand.shuffle(rest)
|
||||
disc_list = top + rest
|
||||
disc = [
|
||||
VideoDetail(**{k: v for k, v in dict(r).items()},
|
||||
is_recommended=True, is_watched=False, is_downloaded=False)
|
||||
for r in disc_rows
|
||||
VideoDetail(**r, is_recommended=True, is_watched=False, is_downloaded=False)
|
||||
for r in disc_list
|
||||
]
|
||||
|
||||
# Interleave: one discovery card every 5 followed cards
|
||||
# Interleave: one discovery card every 3 followed cards
|
||||
result: list[VideoDetail] = []
|
||||
disc_iter = iter(disc)
|
||||
for i, v in enumerate(followed):
|
||||
if i > 0 and i % 5 == 0:
|
||||
if i > 0 and i % 3 == 0:
|
||||
rec = next(disc_iter, None)
|
||||
if rec:
|
||||
result.append(rec)
|
||||
@@ -426,6 +565,7 @@ _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.dislike_count,
|
||||
c.id AS channel_id, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id,
|
||||
c.thumbnail_url AS channel_thumbnail_url,
|
||||
COALESCE(uv.watched, 0) AS watched,
|
||||
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
|
||||
COALESCE(uv.downloaded, 0) AS is_downloaded,
|
||||
@@ -451,9 +591,9 @@ def _row_to_detail(row) -> VideoDetail:
|
||||
)
|
||||
|
||||
|
||||
def _upsert_video_from_yt(db: Session, youtube_video_id: str) -> bool:
|
||||
def _upsert_video_from_yt(db: Session, youtube_video_id: str, polite: bool = False) -> bool:
|
||||
"""Fetch fresh metadata from yt-dlp and upsert video + channel. Returns True if successful."""
|
||||
meta = ytdlp.fetch_video_metadata(youtube_video_id)
|
||||
meta = ytdlp.fetch_video_metadata(youtube_video_id, polite=polite)
|
||||
if not meta:
|
||||
return False
|
||||
|
||||
@@ -566,18 +706,12 @@ def import_chapters(
|
||||
import json as _json
|
||||
|
||||
video = db.query(Video).filter(Video.id == video_id).first()
|
||||
if not video:
|
||||
if not video or video.chapters is None:
|
||||
# chapters=NULL means enrichment hasn't run yet; the background fetch
|
||||
# triggered by get_video_by_yt_id will fill this in. Don't call yt-dlp
|
||||
# here — it runs polite=False and races with active downloads.
|
||||
return []
|
||||
|
||||
# chapters=NULL means never fetched; fetch now and cache the result (even if empty)
|
||||
if video.chapters is None:
|
||||
_upsert_video_from_yt(db, video.youtube_video_id)
|
||||
db.refresh(video)
|
||||
# Mark as checked even if no chapters found, so we don't re-fetch next time
|
||||
if video.chapters is None:
|
||||
video.chapters = "[]"
|
||||
db.commit()
|
||||
|
||||
chapters = _json.loads(video.chapters or "[]")
|
||||
# Skip if trivial (single chapter) or already imported
|
||||
if len(chapters) < 2:
|
||||
@@ -647,6 +781,52 @@ def delete_bookmark(
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/by-yt/{youtube_video_id}/subs")
|
||||
def get_available_subs(
|
||||
youtube_video_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return subtitle languages available on YouTube for a video (yt-dlp call, slow)."""
|
||||
return ytdlp.fetch_available_subs(youtube_video_id)
|
||||
|
||||
|
||||
@router.post("/by-yt/{youtube_video_id}/download-subs")
|
||||
def download_subs(
|
||||
youtube_video_id: str,
|
||||
body: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Download subtitle file(s) only for an already-downloaded video."""
|
||||
langs = (body.get("subtitle_langs") or "").strip()
|
||||
if not langs:
|
||||
raise HTTPException(status_code=400, detail="subtitle_langs required")
|
||||
ok = ytdlp.download_subs_only(youtube_video_id, langs)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=500, detail="Subtitle download failed")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/by-yt/{youtube_video_id}/subtitle-files")
|
||||
def list_subtitle_files(
|
||||
youtube_video_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List .vtt subtitle files already on disk for a downloaded video (instant)."""
|
||||
import re as _re
|
||||
from pathlib import Path
|
||||
from ..config import settings as _cfg
|
||||
pat = _re.compile(rf'^{_re.escape(youtube_video_id)}\.(.+)\.vtt$')
|
||||
subs = []
|
||||
try:
|
||||
for f in Path(_cfg.download_path).iterdir():
|
||||
m = pat.match(f.name)
|
||||
if m:
|
||||
subs.append({"lang": m.group(1), "url": f"/files/{f.name}"})
|
||||
except Exception:
|
||||
pass
|
||||
return sorted(subs, key=lambda s: s["lang"])
|
||||
|
||||
|
||||
@router.get("/by-yt/{youtube_video_id}/comments")
|
||||
def get_comments(
|
||||
youtube_video_id: str,
|
||||
@@ -755,14 +935,24 @@ def get_video_by_yt_id(
|
||||
# Video unknown — must block to get at least a title before we can render anything
|
||||
_upsert_video_from_yt(db, youtube_video_id)
|
||||
elif existing.description is None or existing.chapters is None:
|
||||
# Video known but missing enrichment — fetch in background, return immediately
|
||||
# Video known but missing enrichment — schedule one background fetch.
|
||||
# The frontend polls every 3 s while description is null; without the
|
||||
# dedup guard each poll would spawn its own yt-dlp process.
|
||||
with _enriching_lock:
|
||||
already = youtube_video_id in _enriching
|
||||
if not already:
|
||||
_enriching.add(youtube_video_id)
|
||||
|
||||
if not already:
|
||||
from ..database import SessionLocal
|
||||
def _enrich(yt_id: str):
|
||||
bg_db = SessionLocal()
|
||||
try:
|
||||
_upsert_video_from_yt(bg_db, yt_id)
|
||||
_upsert_video_from_yt(bg_db, yt_id, polite=True)
|
||||
finally:
|
||||
bg_db.close()
|
||||
with _enriching_lock:
|
||||
_enriching.discard(yt_id)
|
||||
background_tasks.add_task(_enrich, youtube_video_id)
|
||||
|
||||
row = db.execute(
|
||||
@@ -877,7 +1067,7 @@ def update_progress(
|
||||
user_id=current_user.id, video_id=video_id, status="complete"
|
||||
).filter(Download.pending_delete_at.is_(None)).first()
|
||||
if dl:
|
||||
dl.pending_delete_at = datetime.utcnow() + timedelta(days=7)
|
||||
dl.pending_delete_at = datetime.utcnow() + timedelta(hours=2)
|
||||
elif body.watched and prev_watched:
|
||||
# Rewatch — strongest positive signal
|
||||
uv.rewatch_count = (uv.rewatch_count or 0) + 1
|
||||
@@ -891,6 +1081,19 @@ def update_progress(
|
||||
if pct < 0.20:
|
||||
_update_affinity(db, current_user.id, video, -0.5)
|
||||
|
||||
# Backend safety net: auto-mark watched at ≥75% completion even if the frontend
|
||||
# didn't send watched=True (e.g. browser closed before debounce fired)
|
||||
if (not prev_watched and not uv.watched
|
||||
and uv.completion_percent is not None and uv.completion_percent >= 75
|
||||
and video.duration_seconds and video.duration_seconds > 60):
|
||||
uv.watched = True
|
||||
_update_affinity(db, current_user.id, video, +2.0)
|
||||
dl = db.query(Download).filter_by(
|
||||
user_id=current_user.id, video_id=video_id, status="complete"
|
||||
).filter(Download.pending_delete_at.is_(None)).first()
|
||||
if dl:
|
||||
dl.pending_delete_at = datetime.utcnow() + timedelta(hours=2)
|
||||
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
107
backend/routers/widget.py
Normal file
107
backend/routers/widget.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Read-only widget endpoints for external dashboards (e.g. backstage).
|
||||
Auth: X-Widget-Key header must match WIDGET_API_KEY env var.
|
||||
No user session required — returns data for the first admin user.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..config import settings
|
||||
from ..database import get_db
|
||||
from ..models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _require_widget_key(x_widget_key: Optional[str] = Header(default=None)):
|
||||
if not settings.widget_api_key:
|
||||
raise HTTPException(status_code=503, detail="widget API not configured")
|
||||
if x_widget_key != settings.widget_api_key:
|
||||
raise HTTPException(status_code=401, detail="invalid widget key")
|
||||
|
||||
|
||||
def _get_widget_user(db: Session) -> User:
|
||||
user = db.query(User).filter_by(is_admin=True).order_by(User.id).first()
|
||||
if not user:
|
||||
user = db.query(User).order_by(User.id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=503, detail="no users")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/recent")
|
||||
def recent_videos(
|
||||
limit: int = 12,
|
||||
db: Session = Depends(get_db),
|
||||
_: None = Depends(_require_widget_key),
|
||||
):
|
||||
"""Recent unwatched videos from followed channels."""
|
||||
user = _get_widget_user(db)
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT v.youtube_video_id, v.title, v.thumbnail_url,
|
||||
v.duration_seconds, v.published_at,
|
||||
c.name AS channel_name, c.youtube_channel_id AS channel_yt_id,
|
||||
COALESCE(uv.watched, 0) AS watched
|
||||
FROM videos v
|
||||
JOIN channels c ON v.channel_id = c.id
|
||||
JOIN user_channels uc ON c.id = uc.channel_id
|
||||
AND uc.user_id = :uid AND uc.status = 'followed'
|
||||
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :uid
|
||||
WHERE COALESCE(uv.watched, 0) = 0
|
||||
AND v.published_at IS NOT NULL
|
||||
AND (uc.muted_until IS NULL OR datetime(uc.muted_until) < datetime('now'))
|
||||
ORDER BY v.published_at DESC
|
||||
LIMIT :limit
|
||||
"""),
|
||||
{"uid": user.id, "limit": limit},
|
||||
).mappings().all()
|
||||
|
||||
return {
|
||||
"videos": [
|
||||
{
|
||||
"youtube_video_id": r["youtube_video_id"],
|
||||
"title": r["title"],
|
||||
"channel_name": r["channel_name"],
|
||||
"thumbnail_url": r["thumbnail_url"],
|
||||
"published_at": r["published_at"],
|
||||
"duration_seconds": r["duration_seconds"],
|
||||
"url": f"https://yt.nullinput.io/watch/{r['youtube_video_id']}",
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def widget_stats(
|
||||
db: Session = Depends(get_db),
|
||||
_: None = Depends(_require_widget_key),
|
||||
):
|
||||
"""Quick stats: unwatched count, channel count, recent activity."""
|
||||
user = _get_widget_user(db)
|
||||
row = db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE COALESCE(uv.watched, 0) = 0) AS unwatched,
|
||||
COUNT(*) FILTER (WHERE v.published_at >= datetime('now', '-7 days')
|
||||
AND COALESCE(uv.watched, 0) = 0) AS new_this_week,
|
||||
COUNT(DISTINCT uc.channel_id) AS channel_count
|
||||
FROM user_channels uc
|
||||
JOIN channels c ON c.id = uc.channel_id
|
||||
JOIN videos v ON v.channel_id = c.id
|
||||
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :uid
|
||||
WHERE uc.user_id = :uid AND uc.status = 'followed'
|
||||
"""),
|
||||
{"uid": user.id},
|
||||
).mappings().first()
|
||||
|
||||
return {
|
||||
"unwatched": row["unwatched"] if row else 0,
|
||||
"new_this_week": row["new_this_week"] if row else 0,
|
||||
"channel_count": row["channel_count"] if row else 0,
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Discovery engine — search-based crawl, trending, community signal, category clustering."""
|
||||
import json
|
||||
import queue as _queue
|
||||
import random
|
||||
import threading as _threading
|
||||
import time as _time
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
@@ -8,11 +11,21 @@ from sqlalchemy import text
|
||||
from ..models import Channel, UserChannel, DiscoveryQueue, Video
|
||||
from . import ytdlp
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background task queue — spaces yt-dlp calls 30-90 s apart and shuffles
|
||||
# call types so we don't fire 10 searches in a row.
|
||||
# ---------------------------------------------------------------------------
|
||||
_task_queue: _queue.Queue = _queue.Queue()
|
||||
_progress: dict[int, dict] = {} # user_id -> {total, done, running}
|
||||
_progress_lock = _threading.Lock()
|
||||
_worker_started = False
|
||||
_worker_lock = _threading.Lock()
|
||||
|
||||
|
||||
def _fetch_and_index_channel(db: Session, channel: Channel):
|
||||
"""Fetch full metadata + recent videos for a discovered channel."""
|
||||
"""Fetch metadata + recent videos for a discovered channel (one yt-dlp call only)."""
|
||||
try:
|
||||
result = ytdlp.fetch_channel_metadata(channel.youtube_channel_id, max_videos=10)
|
||||
result = ytdlp.fetch_channel_metadata(channel.youtube_channel_id, max_videos=10, polite=True)
|
||||
if not result:
|
||||
return
|
||||
ch_data = result.get("channel", {})
|
||||
@@ -21,32 +34,9 @@ def _fetch_and_index_channel(db: Session, channel: Channel):
|
||||
setattr(channel, k, v)
|
||||
channel.crawled_at = datetime.utcnow()
|
||||
|
||||
videos = result.get("videos", [])
|
||||
|
||||
# For videos missing a date (RSS didn't cover them or flat-playlist had no timestamp),
|
||||
# do individual fetches — capped at 3 to avoid slow-downs.
|
||||
dateless = [v for v in videos if not v.get("published_at")]
|
||||
individual_fetched: dict[str, dict] = {}
|
||||
for vdata in dateless[:3]:
|
||||
for vdata in result.get("videos", []):
|
||||
yt_id = vdata.get("youtube_video_id")
|
||||
if not yt_id:
|
||||
continue
|
||||
try:
|
||||
meta = ytdlp.fetch_video_metadata(yt_id)
|
||||
if meta and meta.get("published_at"):
|
||||
individual_fetched[yt_id] = meta
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for vdata in videos:
|
||||
yt_id = vdata.get("youtube_video_id")
|
||||
if not yt_id:
|
||||
continue
|
||||
# Prefer individually-fetched metadata if we retrieved it
|
||||
if yt_id in individual_fetched:
|
||||
vdata = individual_fetched[yt_id]
|
||||
# Skip videos we still can't date — undated videos break feed ordering
|
||||
if not vdata.get("published_at"):
|
||||
if not yt_id or not vdata.get("published_at"):
|
||||
continue
|
||||
if not db.query(Video).filter_by(youtube_video_id=yt_id).first():
|
||||
db.add(Video(
|
||||
@@ -77,14 +67,18 @@ def _upsert_channel(db: Session, channel_data: dict) -> Channel | None:
|
||||
return channel
|
||||
|
||||
|
||||
_MAX_DISCOVERY_SCORE = 50.0
|
||||
|
||||
|
||||
def _add_to_discovery(
|
||||
db: Session, user_id: int, channel_id: int, score: float, source: str,
|
||||
preview_json: str | None = None,
|
||||
):
|
||||
score = min(score, _MAX_DISCOVERY_SCORE)
|
||||
existing = db.query(DiscoveryQueue).filter_by(user_id=user_id, channel_id=channel_id).first()
|
||||
if existing:
|
||||
# Accumulate scores across sources but cap to prevent one dominant signal
|
||||
existing.score = existing.score + score * 0.5
|
||||
# Accumulate across sources but cap so no single signal dominates forever
|
||||
existing.score = min(existing.score + score * 0.5, _MAX_DISCOVERY_SCORE)
|
||||
if preview_json and not existing.preview_json:
|
||||
existing.preview_json = preview_json
|
||||
return
|
||||
@@ -100,12 +94,16 @@ def _add_to_discovery(
|
||||
def _search_and_store(
|
||||
db: Session, user_id: int, queries: list[str],
|
||||
followed_yt_ids: set[str], score_multiplier: float, source: str,
|
||||
neg_affinity_tags: frozenset[str] = frozenset(),
|
||||
):
|
||||
"""Run YouTube searches for the given queries and add results to discovery."""
|
||||
discovered: dict[str, dict] = {}
|
||||
|
||||
for query in queries:
|
||||
try:
|
||||
results = ytdlp.search_youtube(query, max_results=20)
|
||||
results = ytdlp.search_youtube(query, max_results=40, polite=True)
|
||||
except Exception:
|
||||
results = []
|
||||
for video in results:
|
||||
ch = video.get("channel", {})
|
||||
yt_id = ch.get("youtube_channel_id")
|
||||
@@ -120,8 +118,6 @@ def _search_and_store(
|
||||
"thumbnail_url": video["thumbnail_url"],
|
||||
"title": video["title"],
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not discovered:
|
||||
return
|
||||
@@ -145,6 +141,24 @@ def _search_and_store(
|
||||
uc = db.query(UserChannel).filter_by(user_id=user_id, channel_id=channel.id).first()
|
||||
if uc and uc.status in ("followed", "dismissed"):
|
||||
continue
|
||||
|
||||
# Skip channels whose indexed videos heavily overlap with negatively-rated tags
|
||||
if neg_affinity_tags and not is_new and channel.crawled_at:
|
||||
neg_hit = 0
|
||||
vtags = db.execute(
|
||||
text("SELECT tags FROM videos WHERE channel_id = :cid AND tags IS NOT NULL LIMIT 20"),
|
||||
{"cid": channel.id},
|
||||
).scalars().all()
|
||||
for tags_json in vtags:
|
||||
try:
|
||||
for tag in json.loads(tags_json or "[]"):
|
||||
if isinstance(tag, str) and tag.lower().strip() in neg_affinity_tags:
|
||||
neg_hit += 1
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
if neg_hit >= 3:
|
||||
continue
|
||||
|
||||
preview_json = json.dumps(info["previews"]) if info["previews"] else None
|
||||
_add_to_discovery(
|
||||
db, user_id, channel.id,
|
||||
@@ -157,10 +171,9 @@ def _search_and_store(
|
||||
|
||||
db.commit()
|
||||
|
||||
for channel_id in needs_indexing[:5]:
|
||||
channel = db.query(Channel).filter_by(id=channel_id).first()
|
||||
if channel:
|
||||
_fetch_and_index_channel(db, channel)
|
||||
# Queue channel indexing as separate worker tasks (30-90 s gaps apply).
|
||||
for channel_id in needs_indexing[:3]:
|
||||
_task_queue.put((user_id, lambda cid=channel_id: _do_task_index_channel(user_id, cid)))
|
||||
|
||||
|
||||
def crawl_by_search(db: Session, user_id: int):
|
||||
@@ -231,21 +244,32 @@ def crawl_by_search(db: Session, user_id: int):
|
||||
{"user_id": user_id},
|
||||
).mappings().all()
|
||||
|
||||
# Build query pool: top tags + random channel names + categories
|
||||
top_tags = [t for t, _ in sorted(tag_counts.items(), key=lambda x: -x[1])[:6]]
|
||||
# Keep the query count low — each query is a separate yt-dlp subprocess
|
||||
# (its own HTTP session). Too many back-to-back sessions look like a bot.
|
||||
top_tags = [t for t, _ in sorted(tag_counts.items(), key=lambda x: -x[1])[:5]]
|
||||
top_cats = [r["category"] for r in cat_rows]
|
||||
|
||||
# Random sample of followed channel names — diversifies discovery each run
|
||||
# A few randomly-sampled channel names — diversifies results each run
|
||||
sampled_names: list[str] = []
|
||||
if followed_names:
|
||||
sampled_names = random.sample(followed_names, min(8, len(followed_names)))
|
||||
sampled_names = random.sample(followed_names, min(4, len(followed_names)))
|
||||
|
||||
# Combine: tags (most signal) + channel names (broad reach) + categories (fallback)
|
||||
queries = list(dict.fromkeys(top_tags + sampled_names + top_cats))[:15]
|
||||
# One serendipity query to surface content outside the user's direct tag space
|
||||
serendipity = [f"best {top_cats[0]} channels"] if top_cats else []
|
||||
|
||||
# Total target: ≤10 queries
|
||||
queries = list(dict.fromkeys(top_tags + sampled_names + serendipity + top_cats[:2]))[:10]
|
||||
if not queries:
|
||||
return
|
||||
|
||||
_search_and_store(db, user_id, queries, followed_yt_ids, score_multiplier=5.0, source="search")
|
||||
neg_tags = frozenset(
|
||||
r["tag"] for r in db.execute(
|
||||
text("SELECT tag FROM user_tag_affinity WHERE user_id = :user_id AND score < -2"),
|
||||
{"user_id": user_id},
|
||||
).mappings().all()
|
||||
)
|
||||
_search_and_store(db, user_id, queries, followed_yt_ids, score_multiplier=5.0, source="search",
|
||||
neg_affinity_tags=neg_tags)
|
||||
|
||||
|
||||
def update_community_signal(db: Session, user_id: int):
|
||||
@@ -295,22 +319,26 @@ def update_category_clusters(db: Session, user_id: int):
|
||||
if not top_categories:
|
||||
return
|
||||
|
||||
placeholders = ",".join(f"'{c}'" for c in top_categories)
|
||||
candidate_rows = db.execute(
|
||||
text(f"""
|
||||
# Use JSON_EACH / parameterized IN via repeated queries to avoid SQL injection
|
||||
candidate_channel_ids: set[int] = set()
|
||||
for cat in top_categories:
|
||||
cat_rows = db.execute(
|
||||
text("""
|
||||
SELECT DISTINCT v.channel_id
|
||||
FROM videos v
|
||||
WHERE v.category IN ({placeholders})
|
||||
WHERE v.category = :cat
|
||||
AND v.channel_id NOT IN (
|
||||
SELECT channel_id FROM user_channels WHERE user_id = :user_id
|
||||
)
|
||||
LIMIT 100
|
||||
LIMIT 50
|
||||
"""),
|
||||
{"user_id": user_id},
|
||||
{"cat": cat, "user_id": user_id},
|
||||
).mappings().all()
|
||||
for row in cat_rows:
|
||||
candidate_channel_ids.add(row["channel_id"])
|
||||
|
||||
for row in candidate_rows:
|
||||
_add_to_discovery(db, user_id, row["channel_id"], score=3.0, source="category")
|
||||
for channel_id in candidate_channel_ids:
|
||||
_add_to_discovery(db, user_id, channel_id, score=5.0, source="category")
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -354,8 +382,15 @@ def update_liked_signal(db: Session, user_id: int):
|
||||
{"user_id": user_id},
|
||||
).scalars().all())
|
||||
|
||||
top_tags = [t for t, _ in sorted(tag_counts.items(), key=lambda x: -x[1])[:6]]
|
||||
_search_and_store(db, user_id, top_tags, followed_yt_ids, score_multiplier=10.0, source="liked")
|
||||
top_tags = [t for t, _ in sorted(tag_counts.items(), key=lambda x: -x[1])[:4]]
|
||||
neg_tags = frozenset(
|
||||
r["tag"] for r in db.execute(
|
||||
text("SELECT tag FROM user_tag_affinity WHERE user_id = :user_id AND score < -2"),
|
||||
{"user_id": user_id},
|
||||
).mappings().all()
|
||||
)
|
||||
_search_and_store(db, user_id, top_tags, followed_yt_ids, score_multiplier=10.0, source="liked",
|
||||
neg_affinity_tags=neg_tags)
|
||||
|
||||
|
||||
def update_watch_signal(db: Session, user_id: int):
|
||||
@@ -407,41 +442,28 @@ def update_watch_signal(db: Session, user_id: int):
|
||||
{"user_id": user_id},
|
||||
).scalars().all())
|
||||
|
||||
top_tags = [t for t, _ in sorted(qualified.items(), key=lambda x: -x[1])[:6]]
|
||||
|
||||
_search_and_store(db, user_id, top_tags, followed_yt_ids, score_multiplier=3.0, source="watched")
|
||||
top_tags = [t for t, _ in sorted(qualified.items(), key=lambda x: -x[1])[:10]]
|
||||
neg_tags = frozenset(
|
||||
r["tag"] for r in db.execute(
|
||||
text("SELECT tag FROM user_tag_affinity WHERE user_id = :user_id AND score < -2"),
|
||||
{"user_id": user_id},
|
||||
).mappings().all()
|
||||
)
|
||||
_search_and_store(db, user_id, top_tags, followed_yt_ids, score_multiplier=3.0, source="watched",
|
||||
neg_affinity_tags=neg_tags)
|
||||
|
||||
|
||||
def _build_user_tag_profile(db: Session, user_id: int) -> dict[str, float]:
|
||||
"""Return a weighted tag dict from liked (weight 3) + watched (weight 1) videos."""
|
||||
"""Return tag affinity dict (positive = liked, negative = disliked/dismissed)."""
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT v.tags, MAX(uv.liked) AS liked
|
||||
FROM user_videos uv
|
||||
JOIN videos v ON uv.video_id = v.id
|
||||
WHERE uv.user_id = :user_id AND (uv.liked = 1 OR uv.watched = 1)
|
||||
AND v.tags IS NOT NULL AND v.tags != '' AND v.tags != '[]'
|
||||
GROUP BY v.id
|
||||
"""),
|
||||
text("SELECT tag, score FROM user_tag_affinity WHERE user_id = :user_id"),
|
||||
{"user_id": user_id},
|
||||
).mappings().all()
|
||||
|
||||
profile: dict[str, float] = {}
|
||||
for row in rows:
|
||||
weight = 3.0 if row["liked"] else 1.0
|
||||
try:
|
||||
for tag in json.loads(row["tags"]):
|
||||
if isinstance(tag, str):
|
||||
t = tag.lower().strip()
|
||||
if 3 <= len(t) <= 40:
|
||||
profile[t] = profile.get(t, 0.0) + weight
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return profile
|
||||
return {row["tag"]: row["score"] for row in rows}
|
||||
|
||||
|
||||
def _tag_relevance_score(tag_profile: dict[str, float], tags_json: str | None) -> float:
|
||||
"""Score a candidate channel's tags against the user's interest profile."""
|
||||
"""Score a channel's tags against user affinity — positive means relevant, negative means disliked."""
|
||||
if not tag_profile or not tags_json:
|
||||
return 0.0
|
||||
try:
|
||||
@@ -453,35 +475,7 @@ def _tag_relevance_score(tag_profile: dict[str, float], tags_json: str | None) -
|
||||
if isinstance(tag, str):
|
||||
t = tag.lower().strip()
|
||||
score += tag_profile.get(t, 0.0)
|
||||
return min(score, 50.0)
|
||||
|
||||
|
||||
def _dismissed_channel_tags(db: Session, user_id: int) -> set[str]:
|
||||
"""Collect tags of channels this user explicitly dismissed — used to avoid similar content."""
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT v.tags
|
||||
FROM user_channels uc
|
||||
JOIN videos v ON v.channel_id = uc.channel_id
|
||||
WHERE uc.user_id = :user_id AND uc.status = 'dismissed'
|
||||
AND v.tags IS NOT NULL AND v.tags != '' AND v.tags != '[]'
|
||||
LIMIT 500
|
||||
"""),
|
||||
{"user_id": user_id},
|
||||
).mappings().all()
|
||||
|
||||
bad_tags: dict[str, int] = {}
|
||||
for row in rows:
|
||||
try:
|
||||
for tag in json.loads(row["tags"]):
|
||||
if isinstance(tag, str):
|
||||
t = tag.lower().strip()
|
||||
if 3 <= len(t) <= 40:
|
||||
bad_tags[t] = bad_tags.get(t, 0) + 1
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
# Only include tags that appeared in 3+ dismissed-channel videos (strong signal)
|
||||
return {t for t, c in bad_tags.items() if c >= 3}
|
||||
return max(-100.0, min(score, 50.0))
|
||||
|
||||
|
||||
def update_trending_signal(db: Session, user_id: int, regions: list[str]):
|
||||
@@ -490,7 +484,6 @@ def update_trending_signal(db: Session, user_id: int, regions: list[str]):
|
||||
return
|
||||
|
||||
tag_profile = _build_user_tag_profile(db, user_id)
|
||||
dismiss_tags = _dismissed_channel_tags(db, user_id)
|
||||
|
||||
followed_yt_ids = set(db.execute(
|
||||
text("""
|
||||
@@ -556,10 +549,12 @@ def update_trending_signal(db: Session, user_id: int, regions: list[str]):
|
||||
if uc and uc.status in ("followed", "dismissed"):
|
||||
continue
|
||||
|
||||
# Score: base ×4 per region × count, boosted by tag relevance, penalised by dismiss-tag overlap
|
||||
base_score = float(info["count"]) * 4.0 * len(info["regions"])
|
||||
# Cap base_score so a viral trending channel can't dominate the whole queue.
|
||||
# count × 4.0 × regions can reach 300+ without this cap.
|
||||
base_score = min(float(info["count"]) * 4.0 * len(info["regions"]), 18.0)
|
||||
|
||||
# Tag relevance boost (requires channel to have indexed videos)
|
||||
# Tag relevance: positive for liked content, negative for dismissed/disliked.
|
||||
# tag_profile comes from user_tag_affinity which tracks both signals.
|
||||
tag_boost = 0.0
|
||||
if not is_new and channel.crawled_at:
|
||||
tag_rows = db.execute(
|
||||
@@ -568,25 +563,8 @@ def update_trending_signal(db: Session, user_id: int, regions: list[str]):
|
||||
).scalars().all()
|
||||
for tags_json in tag_rows:
|
||||
tag_boost += _tag_relevance_score(tag_profile, tags_json)
|
||||
tag_boost = min(tag_boost, 30.0)
|
||||
|
||||
# Dismiss penalty: if channel's tags overlap heavily with dismissed content, reduce score
|
||||
dismiss_penalty = 0.0
|
||||
if dismiss_tags and not is_new:
|
||||
tag_rows2 = db.execute(
|
||||
text("SELECT tags FROM videos WHERE channel_id = :cid AND tags IS NOT NULL LIMIT 20"),
|
||||
{"cid": channel.id},
|
||||
).scalars().all()
|
||||
for tags_json in tag_rows2:
|
||||
try:
|
||||
for tag in json.loads(tags_json or "[]"):
|
||||
if isinstance(tag, str) and tag.lower().strip() in dismiss_tags:
|
||||
dismiss_penalty += 5.0
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
dismiss_penalty = min(dismiss_penalty, base_score * 0.8)
|
||||
|
||||
final_score = base_score + tag_boost - dismiss_penalty
|
||||
final_score = min(base_score + tag_boost, 25.0)
|
||||
if final_score <= 0:
|
||||
continue
|
||||
|
||||
@@ -597,18 +575,411 @@ def update_trending_signal(db: Session, user_id: int, regions: list[str]):
|
||||
|
||||
db.commit()
|
||||
|
||||
for channel_id in needs_indexing[:5]:
|
||||
channel = db.query(Channel).filter_by(id=channel_id).first()
|
||||
if channel:
|
||||
_fetch_and_index_channel(db, channel)
|
||||
|
||||
def update_graph_signal(db: Session, user_id: int):
|
||||
"""Discover channels featured on followed channels' /channels tab.
|
||||
|
||||
Channels that creators explicitly recommend are high-signal — they're
|
||||
curated by someone whose taste you already follow. Samples up to 12 followed
|
||||
channels per run and fetches their featured channels list in parallel.
|
||||
"""
|
||||
followed_rows = db.execute(
|
||||
text("""
|
||||
SELECT c.youtube_channel_id, c.id
|
||||
FROM channels c
|
||||
JOIN user_channels uc ON c.id = uc.channel_id
|
||||
WHERE uc.user_id = :user_id AND uc.status = 'followed'
|
||||
AND c.youtube_channel_id IS NOT NULL
|
||||
"""),
|
||||
{"user_id": user_id},
|
||||
).mappings().all()
|
||||
|
||||
if not followed_rows:
|
||||
return
|
||||
|
||||
followed_yt_ids = {row["youtube_channel_id"] for row in followed_rows}
|
||||
|
||||
dismissed_ids = set(db.execute(
|
||||
text("SELECT channel_id FROM user_channels WHERE user_id = :user_id AND status = 'dismissed'"),
|
||||
{"user_id": user_id},
|
||||
).scalars().all())
|
||||
|
||||
sample = random.sample(list(followed_rows), min(6, len(followed_rows)))
|
||||
|
||||
featured_map: dict[str, list[str]] = {}
|
||||
for row in sample:
|
||||
try:
|
||||
featured_map[row["youtube_channel_id"]] = ytdlp.fetch_featured_channels(row["youtube_channel_id"])
|
||||
except Exception:
|
||||
featured_map[row["youtube_channel_id"]] = []
|
||||
|
||||
needs_indexing: list[int] = []
|
||||
for source_yt_id, channel_ids in featured_map.items():
|
||||
for yt_id in channel_ids:
|
||||
if yt_id in followed_yt_ids:
|
||||
continue
|
||||
channel = db.query(Channel).filter_by(youtube_channel_id=yt_id).first()
|
||||
is_new = channel is None
|
||||
if not channel:
|
||||
channel = Channel(youtube_channel_id=yt_id, name="", description="", thumbnail_url=None)
|
||||
db.add(channel)
|
||||
db.flush()
|
||||
if channel.id in dismissed_ids:
|
||||
continue
|
||||
uc = db.query(UserChannel).filter_by(user_id=user_id, channel_id=channel.id).first()
|
||||
if uc and uc.status in ("followed", "dismissed"):
|
||||
continue
|
||||
_add_to_discovery(db, user_id, channel.id, score=8.0, source="graph")
|
||||
if is_new or not channel.crawled_at:
|
||||
needs_indexing.append(channel.id)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
def run_full_discovery(db: Session, user_id: int, regions: list[str] | None = None):
|
||||
if regions is None:
|
||||
regions = ["US", "SE"]
|
||||
crawl_by_search(db, user_id)
|
||||
|
||||
# Expire unseen entries older than 14 days so stale high-score channels
|
||||
# don't block fresh results forever.
|
||||
db.execute(
|
||||
text("""
|
||||
DELETE FROM discovery_queue
|
||||
WHERE user_id = :user_id AND seen = 0
|
||||
AND created_at <= datetime('now', '-14 days')
|
||||
"""),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
crawl_by_search(db, user_id) # ~10 yt-dlp calls
|
||||
update_community_signal(db, user_id) # no yt-dlp
|
||||
update_category_clusters(db, user_id) # no yt-dlp
|
||||
update_liked_signal(db, user_id) # ~4 yt-dlp calls
|
||||
# update_watch_signal skipped — tags already included in crawl_by_search
|
||||
update_trending_signal(db, user_id, regions[:1]) # 1 yt-dlp call (first region only)
|
||||
update_graph_signal(db, user_id) # ~6 yt-dlp calls
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Queue-based gradual discovery — each yt-dlp call is its own task, shuffled
|
||||
# so call types are mixed, with 30-90 s gaps between them.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_followed_yt_ids(db: Session, user_id: int) -> set[str]:
|
||||
return set(db.execute(
|
||||
text("""
|
||||
SELECT c.youtube_channel_id FROM channels c
|
||||
JOIN user_channels uc ON c.id = uc.channel_id
|
||||
WHERE uc.user_id = :uid AND uc.status = 'followed'
|
||||
"""),
|
||||
{"uid": user_id},
|
||||
).scalars().all())
|
||||
|
||||
|
||||
def _get_neg_tags(db: Session, user_id: int) -> frozenset[str]:
|
||||
return frozenset(db.execute(
|
||||
text("SELECT tag FROM user_tag_affinity WHERE user_id = :uid AND score < -2"),
|
||||
{"uid": user_id},
|
||||
).scalars().all())
|
||||
|
||||
|
||||
def _stamp_last_run(user_id: int):
|
||||
from ..database import SessionLocal
|
||||
from sqlalchemy import text as _text
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.execute(
|
||||
_text("UPDATE user_settings SET last_discovery_run = :now WHERE user_id = :uid"),
|
||||
{"now": datetime.utcnow(), "uid": user_id},
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _do_task_search(user_id: int, query: str, source: str, score_multiplier: float):
|
||||
from ..database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
followed_yt_ids = _get_followed_yt_ids(db, user_id)
|
||||
neg_tags = _get_neg_tags(db, user_id)
|
||||
_search_and_store(db, user_id, [query], followed_yt_ids, score_multiplier, source, neg_tags)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _do_task_trending(user_id: int, region: str):
|
||||
from ..database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
update_trending_signal(db, user_id, [region])
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _fetch_graph_for_channel(db: Session, user_id: int, source_yt_id: str):
|
||||
"""Fetch featured channels for one followed channel and add to discovery queue."""
|
||||
followed_yt_ids = _get_followed_yt_ids(db, user_id)
|
||||
dismissed_ids = set(db.execute(
|
||||
text("SELECT channel_id FROM user_channels WHERE user_id = :uid AND status = 'dismissed'"),
|
||||
{"uid": user_id},
|
||||
).scalars().all())
|
||||
|
||||
try:
|
||||
featured = ytdlp.fetch_featured_channels(source_yt_id)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
needs_indexing: list[int] = []
|
||||
for yt_id in featured:
|
||||
if yt_id in followed_yt_ids:
|
||||
continue
|
||||
channel = db.query(Channel).filter_by(youtube_channel_id=yt_id).first()
|
||||
is_new = channel is None
|
||||
if not channel:
|
||||
channel = Channel(youtube_channel_id=yt_id, name="", description="", thumbnail_url=None)
|
||||
db.add(channel)
|
||||
db.flush()
|
||||
if channel.id in dismissed_ids:
|
||||
continue
|
||||
uc = db.query(UserChannel).filter_by(user_id=user_id, channel_id=channel.id).first()
|
||||
if uc and uc.status in ("followed", "dismissed"):
|
||||
continue
|
||||
_add_to_discovery(db, user_id, channel.id, score=8.0, source="graph")
|
||||
if is_new or not channel.crawled_at:
|
||||
needs_indexing.append(channel.id)
|
||||
|
||||
db.commit()
|
||||
|
||||
for channel_id in needs_indexing[:2]:
|
||||
_task_queue.put((user_id, lambda cid=channel_id: _do_task_index_channel(user_id, cid)))
|
||||
|
||||
|
||||
def _do_task_graph(user_id: int, source_yt_id: str):
|
||||
from ..database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_fetch_graph_for_channel(db, user_id, source_yt_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _do_task_index_channel(user_id: int, channel_id: int):
|
||||
"""Index one newly-discovered channel (one yt-dlp call). Queued as a separate
|
||||
worker task so the 30-90 s gap applies rather than bursting inline."""
|
||||
from ..database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
channel = db.query(Channel).filter_by(id=channel_id).first()
|
||||
if channel:
|
||||
_fetch_and_index_channel(db, channel)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _worker_loop():
|
||||
while True:
|
||||
try:
|
||||
user_id, task = _task_queue.get(timeout=10)
|
||||
except _queue.Empty:
|
||||
continue
|
||||
|
||||
try:
|
||||
task()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with _progress_lock:
|
||||
p = _progress.get(user_id)
|
||||
if p:
|
||||
p["done"] = min(p["done"] + 1, p["total"])
|
||||
if p["done"] >= p["total"] and p["running"]:
|
||||
p["running"] = False
|
||||
_threading.Thread(target=_stamp_last_run, args=(user_id,), daemon=True).start()
|
||||
|
||||
_task_queue.task_done()
|
||||
|
||||
# Polite gap — only sleep if more tasks are waiting
|
||||
if not _task_queue.empty():
|
||||
_time.sleep(random.uniform(30, 90))
|
||||
|
||||
|
||||
def start_discovery_worker():
|
||||
"""Start the singleton background worker thread (idempotent)."""
|
||||
global _worker_started
|
||||
with _worker_lock:
|
||||
if not _worker_started:
|
||||
_threading.Thread(target=_worker_loop, daemon=True, name="discovery-worker").start()
|
||||
_worker_started = True
|
||||
|
||||
|
||||
def get_discovery_progress(user_id: int) -> dict | None:
|
||||
with _progress_lock:
|
||||
p = _progress.get(user_id)
|
||||
return dict(p) if p is not None else None
|
||||
|
||||
|
||||
def _build_search_task_args(db: Session, user_id: int) -> list[tuple[str, str, float]]:
|
||||
"""Compute all search/liked query strings without executing any yt-dlp calls."""
|
||||
result: list[tuple[str, str, float]] = []
|
||||
|
||||
followed_rows = db.execute(
|
||||
text("""
|
||||
SELECT c.name, c.youtube_channel_id
|
||||
FROM channels c
|
||||
JOIN user_channels uc ON c.id = uc.channel_id
|
||||
WHERE uc.user_id = :user_id AND uc.status = 'followed'
|
||||
"""),
|
||||
{"user_id": user_id},
|
||||
).mappings().all()
|
||||
|
||||
followed_names = [row["name"] for row in followed_rows if row["name"]]
|
||||
|
||||
tag_rows = db.execute(
|
||||
text("""
|
||||
SELECT tags FROM (
|
||||
SELECT v.tags FROM videos v
|
||||
JOIN user_channels uc ON v.channel_id = uc.channel_id
|
||||
WHERE uc.user_id = :user_id AND uc.status = 'followed'
|
||||
AND v.tags IS NOT NULL AND v.tags != '' AND v.tags != '[]'
|
||||
LIMIT 300
|
||||
)
|
||||
UNION ALL
|
||||
SELECT tags FROM (
|
||||
SELECT v.tags FROM user_videos uv
|
||||
JOIN videos v ON uv.video_id = v.id
|
||||
WHERE uv.user_id = :user_id AND uv.liked = 1
|
||||
AND v.tags IS NOT NULL AND v.tags != '' AND v.tags != '[]'
|
||||
LIMIT 100
|
||||
)
|
||||
"""),
|
||||
{"user_id": user_id},
|
||||
).mappings().all()
|
||||
|
||||
tag_counts: dict[str, int] = {}
|
||||
liked_tag_counts: dict[str, int] = {}
|
||||
for row in tag_rows:
|
||||
try:
|
||||
for tag in json.loads(row["tags"]):
|
||||
if isinstance(tag, str):
|
||||
t = tag.lower().strip()
|
||||
if 3 <= len(t) <= 40:
|
||||
tag_counts[t] = tag_counts.get(t, 0) + 1
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
cat_rows = db.execute(
|
||||
text("""
|
||||
SELECT v.category, COUNT(*) AS cnt
|
||||
FROM videos v
|
||||
JOIN user_channels uc ON v.channel_id = uc.channel_id
|
||||
WHERE uc.user_id = :user_id AND uc.status = 'followed'
|
||||
AND v.category IS NOT NULL
|
||||
GROUP BY v.category
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 5
|
||||
"""),
|
||||
{"user_id": user_id},
|
||||
).mappings().all()
|
||||
|
||||
top_tags = [t for t, _ in sorted(tag_counts.items(), key=lambda x: -x[1])[:5]]
|
||||
top_cats = [r["category"] for r in cat_rows]
|
||||
sampled_names = random.sample(followed_names, min(4, len(followed_names))) if followed_names else []
|
||||
serendipity = [f"best {top_cats[0]} channels"] if top_cats else []
|
||||
search_queries = list(dict.fromkeys(top_tags + sampled_names + serendipity + top_cats[:2]))[:10]
|
||||
for q in search_queries:
|
||||
result.append((q, "search", 5.0))
|
||||
|
||||
# Liked signal queries
|
||||
liked_rows = db.execute(
|
||||
text("""
|
||||
SELECT v.tags FROM user_videos uv
|
||||
JOIN videos v ON uv.video_id = v.id
|
||||
WHERE uv.user_id = :user_id AND uv.liked = 1
|
||||
AND v.tags IS NOT NULL AND v.tags != '' AND v.tags != '[]'
|
||||
"""),
|
||||
{"user_id": user_id},
|
||||
).mappings().all()
|
||||
|
||||
for row in liked_rows:
|
||||
try:
|
||||
for tag in json.loads(row["tags"]):
|
||||
if isinstance(tag, str):
|
||||
t = tag.lower().strip()
|
||||
if 3 <= len(t) <= 40:
|
||||
liked_tag_counts[t] = liked_tag_counts.get(t, 0) + 2
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
for q in [t for t, _ in sorted(liked_tag_counts.items(), key=lambda x: -x[1])[:4]]:
|
||||
result.append((q, "liked", 10.0))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _sample_graph_yt_ids(db: Session, user_id: int) -> list[str]:
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT c.youtube_channel_id
|
||||
FROM channels c
|
||||
JOIN user_channels uc ON c.id = uc.channel_id
|
||||
WHERE uc.user_id = :user_id AND uc.status = 'followed'
|
||||
AND c.youtube_channel_id IS NOT NULL
|
||||
"""),
|
||||
{"user_id": user_id},
|
||||
).scalars().all()
|
||||
if not rows:
|
||||
return []
|
||||
return random.sample(list(rows), min(6, len(rows)))
|
||||
|
||||
|
||||
def schedule_discovery(user_id: int, regions: list[str] | None = None):
|
||||
"""Schedule a full discovery sweep, spreading yt-dlp calls 30-90 s apart
|
||||
with call types shuffled so searches, graph fetches, and trending are mixed."""
|
||||
if regions is None:
|
||||
regions = ["US", "SE"]
|
||||
|
||||
from ..database import SessionLocal
|
||||
|
||||
# Fast signals (pure SQL, no yt-dlp) run synchronously right now
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.execute(
|
||||
text("""
|
||||
DELETE FROM discovery_queue
|
||||
WHERE user_id = :uid AND seen = 0
|
||||
AND created_at <= datetime('now', '-14 days')
|
||||
"""),
|
||||
{"uid": user_id},
|
||||
)
|
||||
db.commit()
|
||||
update_community_signal(db, user_id)
|
||||
update_category_clusters(db, user_id)
|
||||
update_liked_signal(db, user_id)
|
||||
update_watch_signal(db, user_id)
|
||||
update_trending_signal(db, user_id, regions)
|
||||
|
||||
search_args = _build_search_task_args(db, user_id)
|
||||
graph_yt_ids = _sample_graph_yt_ids(db, user_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Build one task per yt-dlp call, then shuffle to mix call types
|
||||
tasks: list[tuple[int, object]] = []
|
||||
for query, source, mult in search_args:
|
||||
tasks.append((user_id, lambda q=query, s=source, m=mult: _do_task_search(user_id, q, s, m)))
|
||||
for region in regions[:1]:
|
||||
tasks.append((user_id, lambda r=region: _do_task_trending(user_id, r)))
|
||||
for yt_id in graph_yt_ids:
|
||||
tasks.append((user_id, lambda y=yt_id: _do_task_graph(user_id, y)))
|
||||
|
||||
random.shuffle(tasks)
|
||||
|
||||
with _progress_lock:
|
||||
_progress[user_id] = {"total": len(tasks), "done": 0, "running": bool(tasks)}
|
||||
|
||||
for item in tasks:
|
||||
_task_queue.put(item)
|
||||
|
||||
if not tasks:
|
||||
_stamp_last_run(user_id)
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
"""Subprocess wrapper for yt-dlp."""
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
@@ -12,9 +17,82 @@ from typing import Any
|
||||
from ..config import settings
|
||||
|
||||
|
||||
def _make_private_cookie_copy(args: list[str]) -> tuple[list[str], str | None]:
|
||||
"""Replace --cookies <file> with a private temp copy so concurrent yt-dlp
|
||||
processes never write to the same cookie jar simultaneously."""
|
||||
for i, arg in enumerate(args):
|
||||
if arg == "--cookies" and i + 1 < len(args):
|
||||
source = args[i + 1]
|
||||
if Path(source).exists():
|
||||
try:
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".txt", delete=False)
|
||||
tmp.close()
|
||||
shutil.copy2(source, tmp.name)
|
||||
modified = list(args)
|
||||
modified[i + 1] = tmp.name
|
||||
return modified, tmp.name
|
||||
except Exception:
|
||||
break
|
||||
return list(args), None
|
||||
|
||||
|
||||
def _run(args: list[str], timeout: int = 60) -> tuple[str, str, int]:
|
||||
args, tmp_path = _make_private_cookie_copy(args)
|
||||
try:
|
||||
result = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
||||
return result.stdout, result.stderr, result.returncode
|
||||
finally:
|
||||
if tmp_path:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Global rate limiter for all metadata fetches — prevents concurrent tasks from
|
||||
# hammering YouTube and invalidating cookies.
|
||||
_meta_lock = threading.Lock()
|
||||
_meta_last_call: float = 0.0
|
||||
_META_MIN_GAP = 12.0 # seconds between any two metadata requests
|
||||
|
||||
# Pin to tv_embedded client — it exposes full format list without SABR
|
||||
# restrictions and makes exactly ONE set of API requests per operation.
|
||||
# Without this yt-dlp probes web + mweb + android + tv in sequence,
|
||||
# multiplying requests and triggering YouTube IP rate limits.
|
||||
_YT_CLIENT = ["--extractor-args", "youtube:player_client=tv_embedded"]
|
||||
|
||||
# Active download counter — _meta_run pauses while any download is running so
|
||||
# background discovery and a concurrent download never share the YouTube session
|
||||
# at the same time. Downloads set/clear this; _meta_run reads it.
|
||||
_active_downloads: int = 0
|
||||
_active_downloads_lock = threading.Lock()
|
||||
|
||||
|
||||
def _meta_run(args: list[str], timeout: int = 60) -> tuple[str, str, int]:
|
||||
global _meta_last_call
|
||||
with _meta_lock:
|
||||
# Pause background metadata calls while a download is active.
|
||||
# Running both concurrently causes YouTube to see two requests from the
|
||||
# same session simultaneously, which triggers cookie invalidation.
|
||||
while True:
|
||||
with _active_downloads_lock:
|
||||
if _active_downloads == 0:
|
||||
break
|
||||
time.sleep(3)
|
||||
|
||||
now = time.monotonic()
|
||||
wait = _META_MIN_GAP - (now - _meta_last_call)
|
||||
if wait > 0:
|
||||
time.sleep(wait + random.uniform(1.0, 5.0))
|
||||
try:
|
||||
return _run(args, timeout=timeout)
|
||||
finally:
|
||||
_meta_last_call = time.monotonic()
|
||||
|
||||
|
||||
def is_download_active() -> bool:
|
||||
with _active_downloads_lock:
|
||||
return _active_downloads > 0
|
||||
|
||||
|
||||
def _parse_date(date_str: str | None) -> datetime | None:
|
||||
@@ -130,14 +208,16 @@ def _normalize_channel(info: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def search_youtube(query: str, max_results: int = 40) -> list[dict]:
|
||||
def search_youtube(query: str, max_results: int = 40, polite: bool = False) -> list[dict]:
|
||||
"""Search YouTube via yt-dlp. Uses --flat-playlist for fast results."""
|
||||
stdout, _, code = _run([
|
||||
runner = _meta_run if polite else _run
|
||||
stdout, _, code = runner([
|
||||
"yt-dlp",
|
||||
f"ytsearch{max_results}:{query}",
|
||||
"--dump-json",
|
||||
"--flat-playlist",
|
||||
"--quiet",
|
||||
*_YT_CLIENT,
|
||||
*_cookie_args(),
|
||||
], timeout=60)
|
||||
|
||||
@@ -179,13 +259,14 @@ def fetch_trending(region: str = "US", max_results: int = 50) -> list[dict]:
|
||||
region = region.upper()
|
||||
# CAI%3D = sort by upload date; gl= sets the region
|
||||
url = f"https://www.youtube.com/results?search_query=trending&sp=CAI%253D&gl={region}"
|
||||
stdout, _, code = _run([
|
||||
stdout, _, code = _meta_run([
|
||||
"yt-dlp",
|
||||
url,
|
||||
"--dump-json",
|
||||
"--flat-playlist",
|
||||
"--quiet",
|
||||
"--playlist-end", str(max_results),
|
||||
*_YT_CLIENT,
|
||||
*_cookie_args(),
|
||||
], timeout=60)
|
||||
|
||||
@@ -224,23 +305,27 @@ def _best_thumbnail(thumbnails: list | None) -> str | None:
|
||||
return best[0].get("url") if best else None
|
||||
|
||||
|
||||
def fetch_video_metadata(video_id: str) -> dict | None:
|
||||
"""Fetch metadata for a single video by YouTube ID."""
|
||||
def fetch_video_metadata(video_id: str, polite: bool = False) -> dict | None:
|
||||
"""Fetch metadata for a single video by YouTube ID.
|
||||
|
||||
polite=True applies the global rate limiter (for background batch tasks).
|
||||
polite=False (default) runs immediately for user-facing requests.
|
||||
"""
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
cookie_args = _cookie_args()
|
||||
print(f"[fetch_meta] video={video_id} cookie_args={cookie_args!r}", flush=True)
|
||||
base_cmd = [
|
||||
"yt-dlp", url,
|
||||
"--dump-json", "--no-download", "--no-playlist",
|
||||
"--extractor-args", "youtube:player_client=web",
|
||||
*_YT_CLIENT,
|
||||
]
|
||||
stdout, stderr, code = _run([*base_cmd, *cookie_args], timeout=30)
|
||||
runner = _meta_run if polite else _run
|
||||
stdout, stderr, code = runner([*base_cmd, *cookie_args], timeout=30)
|
||||
if code != 0:
|
||||
print(f"[fetch_meta] FAILED code={code} stderr={stderr[:500]!r}", flush=True)
|
||||
# Retry without auth args — broken cookie config shouldn't block public videos
|
||||
if cookie_args:
|
||||
print(f"[fetch_meta] retrying without cookie args", flush=True)
|
||||
stdout, stderr, code = _run(base_cmd, timeout=30)
|
||||
stdout, stderr, code = runner(base_cmd, timeout=30)
|
||||
if code != 0:
|
||||
print(f"[fetch_meta] retry also FAILED code={code}", flush=True)
|
||||
|
||||
@@ -288,7 +373,7 @@ def _rss_dates(uc_channel_id: str) -> dict[str, datetime]:
|
||||
return {}
|
||||
|
||||
|
||||
def fetch_channel_metadata(channel_id: str, max_videos: int = 30) -> dict | None:
|
||||
def fetch_channel_metadata(channel_id: str, max_videos: int = 30, start_video: int = 1, polite: bool = False) -> dict | None:
|
||||
"""Fetch channel info + recent videos.
|
||||
|
||||
Uses --dump-single-json --flat-playlist for speed, then enriches video dates
|
||||
@@ -303,12 +388,17 @@ def fetch_channel_metadata(channel_id: str, max_videos: int = 30) -> dict | None
|
||||
"--dump-single-json",
|
||||
"--flat-playlist",
|
||||
"--quiet",
|
||||
*_YT_CLIENT,
|
||||
*_cookie_args(),
|
||||
]
|
||||
if start_video > 1:
|
||||
args += ["--playlist-start", str(start_video)]
|
||||
if max_videos > 0:
|
||||
args += ["--playlist-end", str(max_videos)]
|
||||
end = (start_video - 1 + max_videos) if start_video > 1 else max_videos
|
||||
args += ["--playlist-end", str(end)]
|
||||
|
||||
stdout, _, code = _run(args, timeout=60)
|
||||
runner = _meta_run if polite else _run
|
||||
stdout, _, code = runner(args, timeout=60)
|
||||
if not stdout.strip():
|
||||
return None
|
||||
|
||||
@@ -351,13 +441,135 @@ def fetch_channel_metadata(channel_id: str, max_videos: int = 30) -> dict | None
|
||||
return {"channel": channel_info, "videos": videos}
|
||||
|
||||
|
||||
def fetch_channel_playlists(channel_id: str, max_results: int = 100) -> list[dict]:
|
||||
"""Fetch the playlists listed on a channel's /playlists tab."""
|
||||
if channel_id.startswith("@"):
|
||||
url = f"https://www.youtube.com/{channel_id}/playlists"
|
||||
else:
|
||||
url = f"https://www.youtube.com/channel/{channel_id}/playlists"
|
||||
stdout, _, code = _meta_run([
|
||||
"yt-dlp", url,
|
||||
"--dump-json", "--flat-playlist",
|
||||
"--playlist-end", str(max_results),
|
||||
"--quiet",
|
||||
*_YT_CLIENT,
|
||||
*_cookie_args(),
|
||||
], timeout=60)
|
||||
|
||||
playlists = []
|
||||
for line in stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
info = json.loads(line)
|
||||
pl_id = info.get("id") or info.get("playlist_id")
|
||||
title = info.get("title") or info.get("playlist_title") or ""
|
||||
if not pl_id or not title or pl_id == channel_id:
|
||||
continue
|
||||
# Thumbnail: yt-dlp gives a thumbnails array for playlist entries;
|
||||
# fall back to singular thumbnail field. Never use _stable_thumbnail
|
||||
# here because the id is a playlist ID, not a video ID.
|
||||
thumbs = info.get("thumbnails") or []
|
||||
thumb_url = info.get("thumbnail")
|
||||
if thumbs:
|
||||
best = max(thumbs, key=lambda t: (t.get("width") or 0) * (t.get("height") or 0), default=None)
|
||||
if best:
|
||||
thumb_url = best.get("url") or thumb_url
|
||||
playlists.append({
|
||||
"youtube_playlist_id": pl_id,
|
||||
"title": title,
|
||||
"description": info.get("description"),
|
||||
"thumbnail_url": thumb_url,
|
||||
"video_count": info.get("playlist_count") or info.get("n_entries") or 0,
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return playlists
|
||||
|
||||
|
||||
def fetch_playlist_videos(playlist_id: str, max_videos: int = 200) -> list[dict]:
|
||||
"""Fetch videos from a YouTube playlist by playlist ID."""
|
||||
url = f"https://www.youtube.com/playlist?list={playlist_id}"
|
||||
args = [
|
||||
"yt-dlp", url,
|
||||
"--dump-json", "--flat-playlist",
|
||||
"--quiet",
|
||||
*_YT_CLIENT,
|
||||
*_cookie_args(),
|
||||
]
|
||||
if max_videos > 0:
|
||||
args += ["--playlist-end", str(max_videos)]
|
||||
stdout, _, code = _meta_run(args, timeout=120)
|
||||
|
||||
videos = []
|
||||
for line in stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
info = json.loads(line)
|
||||
vid_id = info.get("id")
|
||||
if not vid_id:
|
||||
continue
|
||||
videos.append({
|
||||
"youtube_video_id": vid_id,
|
||||
"title": info.get("title", ""),
|
||||
"thumbnail_url": _stable_thumbnail(vid_id),
|
||||
"duration_seconds": info.get("duration"),
|
||||
"published_at": _parse_published(info),
|
||||
"view_count": info.get("view_count"),
|
||||
"channel": {
|
||||
"youtube_channel_id": info.get("channel_id"),
|
||||
"name": info.get("channel") or info.get("uploader") or "",
|
||||
},
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return videos
|
||||
|
||||
|
||||
def fetch_featured_channels(channel_id: str) -> list[str]:
|
||||
"""Fetch channel IDs from the /channels tab of a YouTube channel.
|
||||
|
||||
The /channels tab lists channels the creator explicitly recommends — a very
|
||||
high-signal source for discovery. Returns UC... channel IDs.
|
||||
"""
|
||||
if channel_id.startswith("@"):
|
||||
url = f"https://www.youtube.com/{channel_id}/channels"
|
||||
else:
|
||||
url = f"https://www.youtube.com/channel/{channel_id}/channels"
|
||||
stdout, _, code = _meta_run([
|
||||
"yt-dlp", url,
|
||||
"--dump-json",
|
||||
"--flat-playlist",
|
||||
"--quiet",
|
||||
*_YT_CLIENT,
|
||||
*_cookie_args(),
|
||||
], timeout=30)
|
||||
|
||||
channel_ids: list[str] = []
|
||||
for line in stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
info = json.loads(line)
|
||||
ch_id = info.get("channel_id") or info.get("id")
|
||||
if ch_id and ch_id.startswith("UC"):
|
||||
channel_ids.append(ch_id)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return channel_ids
|
||||
|
||||
|
||||
def fetch_channel_links(channel_id: str) -> list[str]:
|
||||
"""Extract linked channel IDs from a channel's about/description."""
|
||||
if channel_id.startswith("@"):
|
||||
url = f"https://www.youtube.com/{channel_id}/about"
|
||||
else:
|
||||
url = f"https://www.youtube.com/channel/{channel_id}/about"
|
||||
stdout, _, code = _run([
|
||||
stdout, _, code = _meta_run([
|
||||
"yt-dlp",
|
||||
url,
|
||||
"--dump-json",
|
||||
@@ -365,6 +577,7 @@ def fetch_channel_links(channel_id: str) -> list[str]:
|
||||
"--flat-playlist",
|
||||
"--playlist-end", "1",
|
||||
"--quiet",
|
||||
*_YT_CLIENT,
|
||||
*_cookie_args(),
|
||||
], timeout=30)
|
||||
|
||||
@@ -385,11 +598,84 @@ def fetch_channel_links(channel_id: str) -> list[str]:
|
||||
return list(channel_ids)
|
||||
|
||||
|
||||
def _strip_vtt_cue_settings(video_id: str) -> None:
|
||||
"""Remove position/align/line cue settings from yt-dlp VTT files.
|
||||
|
||||
yt-dlp embeds 'align:start position:0%' in every cue header which pins
|
||||
subtitles to the bottom-left. Stripping them lets CSS ::cue center them.
|
||||
"""
|
||||
for vtt in Path(settings.download_path).glob(f"{video_id}.*.vtt"):
|
||||
try:
|
||||
text = vtt.read_text(encoding="utf-8", errors="replace")
|
||||
cleaned = re.sub(
|
||||
r'(\d{1,2}:\d{2}:\d{2}\.\d{3} --> \d{1,2}:\d{2}:\d{2}\.\d{3})[^\n]*',
|
||||
r'\1',
|
||||
text,
|
||||
)
|
||||
vtt.write_text(cleaned, encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def download_subs_only(video_id: str, subtitle_langs: str) -> bool:
|
||||
"""Download subtitle files only (no video) for an already-downloaded video."""
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
output_template = str(Path(settings.download_path) / f"{video_id}.%(ext)s")
|
||||
_, _, code = _meta_run([
|
||||
"yt-dlp", url,
|
||||
"--skip-download", "--no-playlist",
|
||||
"--write-subs", "--write-auto-subs",
|
||||
"--sub-langs", subtitle_langs,
|
||||
"--convert-subs", "vtt",
|
||||
"-o", output_template,
|
||||
*_YT_CLIENT,
|
||||
*_cookie_args(),
|
||||
], timeout=60)
|
||||
if code == 0:
|
||||
_strip_vtt_cue_settings(video_id)
|
||||
return code == 0
|
||||
|
||||
|
||||
def fetch_available_subs(video_id: str) -> dict:
|
||||
"""Return subtitle languages available on YouTube for a video.
|
||||
|
||||
Returns {"manual": [...], "auto": [...]} where both are sorted lists of
|
||||
BCP-47 lang codes. Manual = human-made; auto = auto-generated captions.
|
||||
"""
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
base_cmd = ["yt-dlp", url, "--dump-json", "--no-download", "--no-playlist", *_YT_CLIENT]
|
||||
cookie_args = _cookie_args()
|
||||
stdout, _, code = _meta_run([*base_cmd, *cookie_args], timeout=30)
|
||||
if code != 0 and cookie_args:
|
||||
stdout, _, code = _meta_run(base_cmd, timeout=30)
|
||||
|
||||
for line in stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
info = json.loads(line)
|
||||
manual = sorted(info.get("subtitles") or {})
|
||||
auto = sorted(set(
|
||||
lang for lang in (info.get("automatic_captions") or {})
|
||||
if not lang.endswith("-orig")
|
||||
))
|
||||
return {"manual": manual, "auto": auto}
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return {"manual": [], "auto": []}
|
||||
|
||||
|
||||
def fetch_video_comments(youtube_video_id: str, max_comments: int = 20) -> list[dict]:
|
||||
"""Fetch top comments via yt-dlp CLI writing to a temp file. Returns empty list on failure."""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
# Don't fire a concurrent yt-dlp session while a download is running — it
|
||||
# causes YouTube to see two simultaneous authenticated sessions and invalidates cookies.
|
||||
if is_download_active():
|
||||
return []
|
||||
|
||||
url = f"https://www.youtube.com/watch?v={youtube_video_id}"
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
@@ -398,11 +684,12 @@ def fetch_video_comments(youtube_video_id: str, max_comments: int = 20) -> list[
|
||||
"yt-dlp", url,
|
||||
"--write-info-json",
|
||||
"--write-comments",
|
||||
"--extractor-args", f"youtube:max_comments={max_comments};comment_sort=top",
|
||||
"--no-download",
|
||||
# Format: thread_count,total_count,replies_per_thread,reply_pages
|
||||
"--extractor-args", f"youtube:max_comments={max_comments},{max_comments},0,0;comment_sort=top",
|
||||
"--skip-download",
|
||||
"--no-playlist",
|
||||
"--quiet",
|
||||
"--output", out_tmpl,
|
||||
*_YT_CLIENT,
|
||||
*_cookie_args(),
|
||||
]
|
||||
_run(args, timeout=90)
|
||||
@@ -449,15 +736,15 @@ def fetch_dislike_count(youtube_video_id: str) -> int | 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]",
|
||||
"1440p": "bestvideo[ext=mp4][height<=1440]+bestaudio[ext=m4a]/bestvideo[height<=1440]+bestaudio/best[height<=1440]",
|
||||
"1080p": "bestvideo[ext=mp4][vcodec^=avc1][height<=1080]+bestaudio[ext=m4a]/bestvideo[ext=mp4][height<=1080]+bestaudio[ext=m4a]/137+140/22/best[height<=1080]",
|
||||
"720p": "bestvideo[ext=mp4][vcodec^=avc1][height<=720]+bestaudio[ext=m4a]/bestvideo[ext=mp4][height<=720]+bestaudio[ext=m4a]/22/best[height<=720]",
|
||||
"480p": "bestvideo[ext=mp4][vcodec^=avc1][height<=480]+bestaudio[ext=m4a]/bestvideo[ext=mp4][height<=480]+bestaudio[ext=m4a]/18/best[height<=480]",
|
||||
"360p": "bestvideo[ext=mp4][height<=360]+bestaudio[ext=m4a]/18/best[height<=360]",
|
||||
"240p": "bestvideo[ext=mp4][height<=240]+bestaudio[ext=m4a]/best[height<=240]",
|
||||
"144p": "bestvideo[ext=mp4][height<=144]+bestaudio[ext=m4a]/best[height<=144]",
|
||||
"best": "bestvideo+bestaudio/best",
|
||||
"2160p": "bestvideo[height<=2160]+bestaudio/bestvideo+bestaudio/best",
|
||||
"1440p": "bestvideo[height<=1440]+bestaudio/bestvideo+bestaudio/best",
|
||||
"1080p": "bestvideo[height<=1080]+bestaudio/bestvideo+bestaudio/best",
|
||||
"720p": "bestvideo[height<=720]+bestaudio/bestvideo+bestaudio/best",
|
||||
"480p": "bestvideo[height<=480]+bestaudio/bestvideo+bestaudio/best",
|
||||
"360p": "bestvideo[height<=360]+bestaudio/bestvideo+bestaudio/best",
|
||||
"240p": "bestvideo[height<=240]+bestaudio/bestvideo+bestaudio/best",
|
||||
"144p": "bestvideo[height<=144]+bestaudio/bestvideo+bestaudio/best",
|
||||
}
|
||||
|
||||
|
||||
@@ -470,6 +757,8 @@ def detect_resolution(file_path: str) -> str | None:
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
height = int(result.stdout.strip())
|
||||
if height >= 2160: return "2160p"
|
||||
if height >= 1440: return "1440p"
|
||||
if height >= 1080: return "1080p"
|
||||
if height >= 720: return "720p"
|
||||
if height >= 480: return "480p"
|
||||
@@ -484,7 +773,7 @@ def predicted_file_path(video_id: str) -> Path:
|
||||
return Path(settings.download_path) / f"{video_id}.mp4"
|
||||
|
||||
|
||||
_SEMAPHORE = threading.Semaphore(3)
|
||||
_SEMAPHORE = threading.Semaphore(6)
|
||||
_semaphore_lock = threading.Lock()
|
||||
_cookies_browser: str = ""
|
||||
_cookies_file: str = ""
|
||||
@@ -501,7 +790,7 @@ _oauth2_state_lock = threading.Lock()
|
||||
def set_max_concurrent(n: int) -> None:
|
||||
global _SEMAPHORE
|
||||
with _semaphore_lock:
|
||||
_SEMAPHORE = threading.Semaphore(max(1, min(n, 10)))
|
||||
_SEMAPHORE = threading.Semaphore(max(1, min(n, 16)))
|
||||
|
||||
|
||||
def set_cookies_browser(browser: str) -> None:
|
||||
@@ -620,40 +909,52 @@ def start_download(
|
||||
on_complete: Any,
|
||||
on_error: Any,
|
||||
quality: str = "best",
|
||||
subtitle_langs: str = "",
|
||||
) -> None:
|
||||
"""Start yt-dlp download in a background thread.
|
||||
|
||||
Uses a single progressive MP4 format so the file is playable as it downloads.
|
||||
--no-part writes directly to the final filename (no .part rename at the end).
|
||||
"""
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
# Predictable output path — lets the player start before download finishes
|
||||
output_template = str(Path(settings.download_path) / f"{video_id}.%(ext)s")
|
||||
|
||||
fmt = QUALITY_FORMATS.get(quality, QUALITY_FORMATS["best"])
|
||||
subtitle_args = (
|
||||
["--write-subs", "--write-auto-subs", "--sub-langs", subtitle_langs, "--convert-subs", "vtt"]
|
||||
if subtitle_langs else []
|
||||
)
|
||||
|
||||
def _run_download():
|
||||
global _active_downloads
|
||||
# Signal to _meta_run that a download is active so it pauses all
|
||||
# background discovery/metadata calls for the duration. Running both
|
||||
# concurrently causes YouTube to see the same session used simultaneously
|
||||
# and invalidates cookies.
|
||||
with _active_downloads_lock:
|
||||
_active_downloads += 1
|
||||
try:
|
||||
with _SEMAPHORE:
|
||||
cookie_args = _cookie_args()
|
||||
print(f"[ytdlp] cookie_args={cookie_args!r}", flush=True)
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
cmd = [
|
||||
"yt-dlp", url,
|
||||
"-f", fmt,
|
||||
"--merge-output-format", "mp4",
|
||||
"--postprocessor-args", "Merger+ffmpeg:-movflags +faststart",
|
||||
"--embed-metadata", "--embed-thumbnail",
|
||||
"--no-part", "--no-mtime",
|
||||
"-o", output_template,
|
||||
"--newline", "--progress", "--no-colors",
|
||||
"--extractor-args", "youtube:player_client=web",
|
||||
*subtitle_args,
|
||||
*_YT_CLIENT,
|
||||
*cookie_args,
|
||||
],
|
||||
]
|
||||
cmd, tmp_cookie_path = _make_private_cookie_copy(cmd)
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
|
||||
file_path = None
|
||||
stream_index = 0
|
||||
output_lines: list[str] = []
|
||||
@@ -673,6 +974,7 @@ def start_download(
|
||||
|
||||
process.wait()
|
||||
if process.returncode == 0:
|
||||
_strip_vtt_cue_settings(video_id)
|
||||
resolution = detect_resolution(file_path) if file_path else None
|
||||
on_complete(download_id, file_path, resolution)
|
||||
else:
|
||||
@@ -680,6 +982,15 @@ def start_download(
|
||||
import logging
|
||||
logging.getLogger(__name__).error("yt-dlp failed (code %d):\n%s", process.returncode, tail)
|
||||
on_error(download_id, f"yt-dlp exited with code {process.returncode}:\n{tail}")
|
||||
finally:
|
||||
if tmp_cookie_path:
|
||||
try:
|
||||
os.unlink(tmp_cookie_path)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
with _active_downloads_lock:
|
||||
_active_downloads -= 1
|
||||
|
||||
thread = threading.Thread(target=_run_download, daemon=True)
|
||||
thread.start()
|
||||
|
||||
@@ -11,6 +11,7 @@ services:
|
||||
DATABASE_URL: sqlite:////data/app.db
|
||||
DOWNLOAD_PATH: /downloads
|
||||
SECRET_KEY: ${SECRET_KEY:-changeme}
|
||||
WIDGET_API_KEY: ${WIDGET_API_KEY:-}
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
|
||||
@@ -41,10 +41,15 @@ export const getChannels = () => api.get("/channels");
|
||||
export const getChannelFeed = (offset = 0) => api.get("/channels/feed", { params: { offset, limit: 24 } });
|
||||
export const getChannel = (id) => api.get(`/channels/${id}`);
|
||||
export const syncAllChannels = () => api.post("/channels/sync-all");
|
||||
export const getChannelVideos = (id) => api.get(`/channels/${id}/videos`);
|
||||
export const getChannelVideos = (id, sort = "newest", offset = 0, limit = 60, q = "") =>
|
||||
api.get(`/channels/${id}/videos`, { params: { sort, offset, limit, ...(q ? { q } : {}) } });
|
||||
export const searchChannelYoutube = (id, q) => api.post(`/channels/${id}/search`, null, { params: { q } });
|
||||
export const followChannel = (id) => api.post(`/channels/${id}/follow`);
|
||||
export const unfollowChannel = (id) => api.delete(`/channels/${id}/follow`);
|
||||
export const indexChannel = (id) => api.post(`/channels/${id}/index`);
|
||||
export const indexChannelFull = (id) => api.post(`/channels/${id}/index-full`);
|
||||
export const exploreChannelOlder = (id, page) => api.post(`/channels/${id}/explore`, null, { params: { page } });
|
||||
export const fetchPopularVideos = (id) => api.post(`/channels/${id}/fetch-popular`);
|
||||
export const followChannelByUrl = (data) => api.post("/channels/follow-by-url", data);
|
||||
export const setChannelAutoDownload = (id, value) => api.patch(`/channels/${id}/auto-download`, { auto_download: value });
|
||||
export const markChannelsSeen = () => api.post("/channels/mark-seen");
|
||||
@@ -59,6 +64,11 @@ export const renameChannelGroup = (id, name) => api.patch(`/channels/groups/${id
|
||||
export const addChannelToGroup = (groupId, channelId) => api.post(`/channels/groups/${groupId}/channels/${channelId}`);
|
||||
export const removeChannelFromGroup = (groupId, channelId) => api.delete(`/channels/groups/${groupId}/channels/${channelId}`);
|
||||
export const bulkChannelAction = (channel_ids, action) => api.post("/channels/bulk-action", { channel_ids, action });
|
||||
export const getActiveTasks = () => api.get("/channels/tasks");
|
||||
export const getRssFeedUrl = () => `/api/channels/rss`;
|
||||
export const getRandomChannelVideo = (id, unwatchedOnly = true) =>
|
||||
api.get(`/channels/${id}/random`, { params: { unwatched_only: unwatchedOnly } });
|
||||
export const getChannelInProgress = (id) => api.get(`/channels/${id}/in-progress`);
|
||||
|
||||
// Videos
|
||||
export const homeFeed = (page = 0, limit = 25, mode = "ranked", duration = "") =>
|
||||
@@ -87,9 +97,12 @@ export const importChapters = (videoId) => api.post(`/videos/${videoId}/bookmark
|
||||
export const clearChapters = (videoId) => api.delete(`/videos/${videoId}/bookmarks/clear-chapters`);
|
||||
|
||||
// Downloads
|
||||
export const createDownload = (youtube_video_id, quality) =>
|
||||
api.post("/downloads", { youtube_video_id, ...(quality ? { quality } : {}) });
|
||||
export const createDownload = (youtube_video_id, quality, subtitle_langs) =>
|
||||
api.post("/downloads", { youtube_video_id, ...(quality ? { quality } : {}), ...(subtitle_langs ? { subtitle_langs } : {}) });
|
||||
export const getDownloads = () => api.get("/downloads");
|
||||
export const getAvailableSubs = (ytId) => api.get(`/videos/by-yt/${ytId}/subs`);
|
||||
export const getSubtitleFiles = (ytId) => api.get(`/videos/by-yt/${ytId}/subtitle-files`);
|
||||
export const downloadSubs = (ytId, subtitle_langs) => api.post(`/videos/by-yt/${ytId}/download-subs`, { subtitle_langs });
|
||||
export const getDownload = (id) => api.get(`/downloads/${id}`);
|
||||
export const deleteDownload = (id) => api.delete(`/downloads/${id}`);
|
||||
export const deleteAllDownloads = () => api.delete("/downloads/all");
|
||||
@@ -127,6 +140,7 @@ export const followDiscovery = (channelId) =>
|
||||
export const dismissDiscovery = (channelId) =>
|
||||
api.post(`/discovery/${channelId}/dismiss`);
|
||||
export const refreshDiscovery = () => api.post("/discovery/refresh");
|
||||
export const getDiscoveryStatus = () => api.get("/discovery/status");
|
||||
export const getCommunityShelf = () => api.get("/discovery/community");
|
||||
|
||||
// Stats
|
||||
@@ -147,3 +161,11 @@ export const deleteCollection = (id) => api.delete(`/collections/${id}`);
|
||||
export const getCollectionVideos = (id) => api.get(`/collections/${id}/videos`);
|
||||
export const addToCollection = (collectionId, videoId) => api.post(`/collections/${collectionId}/videos`, { video_id: videoId });
|
||||
export const removeFromCollection = (collectionId, videoId) => api.delete(`/collections/${collectionId}/videos/${videoId}`);
|
||||
|
||||
// Playlists
|
||||
export const getChannelPlaylists = (channelId) => api.get(`/playlists/channel/${channelId}`);
|
||||
export const fetchChannelPlaylists = (channelId) => api.post(`/playlists/channel/${channelId}/fetch`);
|
||||
export const getPlaylistVideos = (playlistId, offset = 0, limit = 60) =>
|
||||
api.get(`/playlists/${playlistId}/videos`, { params: { offset, limit } });
|
||||
export const indexPlaylist = (playlistId) => api.post(`/playlists/${playlistId}/index`);
|
||||
export const generateNfoFiles = () => api.post("/downloads/nfo/generate");
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function ChannelCard({ channel }) {
|
||||
className={`text-xs font-medium px-4 py-2 rounded-lg transition-colors ${
|
||||
isFollowed || followMut.isSuccess
|
||||
? "bg-zinc-700 text-zinc-300 hover:bg-zinc-600"
|
||||
: "bg-accent text-black hover:bg-yellow-300"
|
||||
: "bg-accent text-black hover:bg-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{isFollowed || followMut.isSuccess ? "Following" : "Follow"}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Outlet, NavLink, useNavigate, Link, useLocation } from "react-router-dom";
|
||||
import { Outlet, NavLink, Link, useLocation } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import SearchBar from "./SearchBar";
|
||||
import { getDownloads, getChannels } from "../api";
|
||||
import { getDownloads, getChannels, getActiveTasks, getDiscoveryStatus, getMe } from "../api";
|
||||
|
||||
function BottomNav({ newCount }) {
|
||||
const tabs = [
|
||||
@@ -29,30 +30,42 @@ function BottomNav({ newCount }) {
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="sm:hidden fixed bottom-0 inset-x-0 z-50 bg-zinc-950/95 backdrop-blur border-t border-zinc-800">
|
||||
<div className="flex items-stretch h-16">
|
||||
<nav
|
||||
className="sm:hidden shrink-0 bg-zinc-950/98 backdrop-blur border-t border-zinc-800"
|
||||
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
|
||||
>
|
||||
<div className="flex items-stretch h-14">
|
||||
{tabs.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
end={tab.end}
|
||||
className={({ isActive }) =>
|
||||
`relative flex-1 flex flex-col items-center justify-center gap-0.5 transition-colors ${
|
||||
isActive ? "text-accent" : "text-zinc-500"
|
||||
`relative flex-1 flex flex-col items-center justify-center gap-0.5 transition-colors outline-none ${
|
||||
isActive ? "text-zinc-100" : "text-zinc-500"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<div className="relative">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{isActive && (
|
||||
<span className="absolute -inset-2 rounded-xl bg-white/10" />
|
||||
)}
|
||||
<svg className="w-[18px] h-[18px] relative" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{tab.icon}
|
||||
</svg>
|
||||
{tab.badge > 0 && (
|
||||
<span className="absolute -top-1 -right-1.5 min-w-[14px] h-3.5 bg-accent text-black text-[9px] font-bold rounded-full flex items-center justify-center px-0.5 leading-none">
|
||||
<span className="absolute -top-1 -right-1.5 min-w-[13px] h-3 bg-zinc-200 text-zinc-900 text-[8px] font-bold rounded-full flex items-center justify-center px-0.5 leading-none">
|
||||
{tab.badge > 99 ? "99+" : tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-medium leading-none">{tab.label}</span>
|
||||
<span className="text-[9px] font-medium leading-none">
|
||||
{tab.label}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
@@ -61,40 +74,128 @@ function BottomNav({ newCount }) {
|
||||
}
|
||||
|
||||
function DownloadIndicator() {
|
||||
const { data } = useQuery({
|
||||
const { data: downloads } = useQuery({
|
||||
queryKey: ["downloads"],
|
||||
queryFn: () => getDownloads().then((r) => r.data),
|
||||
refetchInterval: (query) => {
|
||||
const active = (query.state.data ?? []).some(
|
||||
(d) => d.status === "pending" || d.status === "downloading"
|
||||
);
|
||||
return active ? 1500 : 10_000;
|
||||
return active ? 1500 : 30_000;
|
||||
},
|
||||
});
|
||||
|
||||
const active = (data ?? []).filter(
|
||||
const { data: tasks = [] } = useQuery({
|
||||
queryKey: ["active-tasks"],
|
||||
queryFn: () => getActiveTasks().then((r) => r.data),
|
||||
refetchInterval: (query) => (query.state.data?.length > 0 ? 2000 : 10_000),
|
||||
});
|
||||
|
||||
const { data: discStatus } = useQuery({
|
||||
queryKey: ["discovery-status"],
|
||||
queryFn: () => getDiscoveryStatus().then((r) => r.data),
|
||||
refetchInterval: (query) => (query.state.data?.progress?.running ? 10_000 : 60_000),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const activeDownloads = (downloads ?? []).filter(
|
||||
(d) => d.status === "pending" || d.status === "downloading"
|
||||
);
|
||||
if (!active.length) return null;
|
||||
const discRunning = discStatus?.progress?.running;
|
||||
const discProgress = discStatus?.progress;
|
||||
|
||||
const top = active[0];
|
||||
const pct = top.progress_percent ?? 0;
|
||||
if (!activeDownloads.length && !tasks.length && !discRunning) return null;
|
||||
|
||||
const totalActive = activeDownloads.length + tasks.length + (discRunning ? 1 : 0);
|
||||
|
||||
// Primary label: download % > task phase > discovery
|
||||
let label;
|
||||
if (activeDownloads.length) {
|
||||
const pct = activeDownloads[0].progress_percent ?? 0;
|
||||
label = <span className="font-mono tabular-nums text-[11px]">{pct.toFixed(0)}%</span>;
|
||||
} else if (tasks.length) {
|
||||
const task = tasks[0];
|
||||
const pct = task.total > 0 ? Math.round((task.done / task.total) * 100) : null;
|
||||
label = (
|
||||
<span className="text-[11px] max-w-[80px] truncate hidden sm:inline">
|
||||
{pct !== null ? `${pct}%` : task.phase || "…"}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
label = (
|
||||
<span className="text-[11px] hidden sm:inline">
|
||||
{discProgress ? `${discProgress.done}/${discProgress.total}` : "…"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const indicatorTarget = (activeDownloads.length || tasks.length) ? "/downloads" : "/discovery";
|
||||
|
||||
return (
|
||||
<div className="relative group shrink-0">
|
||||
<Link
|
||||
to="/downloads"
|
||||
className="flex items-center gap-2 px-2.5 py-1 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors text-xs text-zinc-300 shrink-0"
|
||||
title={`${active.length} download${active.length > 1 ? "s" : ""} in progress`}
|
||||
to={indicatorTarget}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors text-xs text-zinc-300"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 animate-spin text-accent" fill="none" viewBox="0 0 24 24">
|
||||
<svg className="w-3 h-3 animate-spin text-zinc-400 shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||
<path className="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
<span className="font-mono tabular-nums">{pct.toFixed(0)}%</span>
|
||||
{active.length > 1 && (
|
||||
<span className="text-zinc-500">+{active.length - 1}</span>
|
||||
{label}
|
||||
{totalActive > 1 && (
|
||||
<span className="hidden sm:inline text-zinc-500">+{totalActive - 1}</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Hover popover */}
|
||||
<div className="absolute top-[calc(100%+6px)] right-0 z-50
|
||||
invisible opacity-0 group-hover:visible group-hover:opacity-100
|
||||
transition-all duration-100 pointer-events-none">
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl p-3 min-w-[220px] max-w-[280px] flex flex-col gap-2">
|
||||
{activeDownloads.map((d) => (
|
||||
<div key={d.id}>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<p className="text-xs text-zinc-200 truncate">{d.video_title || "Downloading…"}</p>
|
||||
<span className="text-[10px] text-zinc-400 tabular-nums shrink-0">{(d.progress_percent ?? 0).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-0.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-accent rounded-full transition-all duration-300" style={{ width: `${d.progress_percent ?? 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{tasks.map((task) => {
|
||||
const pct = task.total > 0 ? (task.done / task.total) * 100 : 0;
|
||||
return (
|
||||
<div key={task.id}>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<p className="text-xs text-zinc-200 truncate">{task.label}</p>
|
||||
{task.total > 0 && (
|
||||
<span className="text-[10px] text-zinc-400 tabular-nums shrink-0">{task.done}/{task.total}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-500 mb-1">{task.phase || "Running…"}</p>
|
||||
<div className="h-0.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-accent rounded-full transition-all duration-300" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{discRunning && discProgress && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<p className="text-xs text-zinc-200">Discovering channels</p>
|
||||
<span className="text-[10px] text-zinc-400 tabular-nums shrink-0">{discProgress.done}/{discProgress.total}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-500 mb-1">Finding channels… spaced over ~20 min</p>
|
||||
<div className="h-0.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-accent rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.round((discProgress.done / discProgress.total) * 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,7 +214,7 @@ function NavItem({ to, children, badge }) {
|
||||
>
|
||||
{children}
|
||||
{badge > 0 && (
|
||||
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 bg-accent text-black text-[10px] font-bold rounded-full flex items-center justify-center px-1 leading-none">
|
||||
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 bg-zinc-200 text-zinc-900 text-[10px] font-bold rounded-full flex items-center justify-center px-1 leading-none">
|
||||
{badge > 99 ? "99+" : badge}
|
||||
</span>
|
||||
)}
|
||||
@@ -135,7 +236,7 @@ function DropItem({ to, children, badge }) {
|
||||
>
|
||||
<span>{children}</span>
|
||||
{badge > 0 && (
|
||||
<span className="min-w-[18px] h-[18px] bg-accent text-black text-[10px] font-bold rounded-full flex items-center justify-center px-1 leading-none shrink-0">
|
||||
<span className="min-w-[18px] h-[18px] bg-zinc-200 text-zinc-900 text-[10px] font-bold rounded-full flex items-center justify-center px-1 leading-none shrink-0">
|
||||
{badge > 99 ? "99+" : badge}
|
||||
</span>
|
||||
)}
|
||||
@@ -189,33 +290,63 @@ function useNewVideosCount() {
|
||||
return channels.reduce((sum, c) => sum + (c.new_count ?? 0), 0);
|
||||
}
|
||||
|
||||
function useNewVideoNotifications(newCount) {
|
||||
const location = useLocation();
|
||||
const prevCount = useRef(newCount);
|
||||
|
||||
useEffect(() => {
|
||||
const enabled = localStorage.getItem("notifications_enabled") === "true";
|
||||
if (!enabled) return;
|
||||
if (Notification.permission !== "granted") return;
|
||||
if (location.pathname === "/following") return;
|
||||
if (newCount > 0 && newCount > prevCount.current) {
|
||||
new Notification("New videos", {
|
||||
body: `${newCount} new video${newCount !== 1 ? "s" : ""} from channels you follow`,
|
||||
});
|
||||
}
|
||||
prevCount.current = newCount;
|
||||
}, [newCount, location.pathname]);
|
||||
}
|
||||
|
||||
function useOffline() {
|
||||
const { isError, error } = useQuery({
|
||||
queryKey: ["health"],
|
||||
queryFn: () => getMe().then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
retry: 1,
|
||||
staleTime: 20_000,
|
||||
});
|
||||
return isError && !error?.response;
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const newCount = useNewVideosCount();
|
||||
const offline = useOffline();
|
||||
useNewVideoNotifications(newCount);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<div className="flex flex-col overflow-hidden" style={{ height: "100dvh" }}>
|
||||
{offline && (
|
||||
<div className="shrink-0 bg-amber-500/10 border-b border-amber-500/30 px-4 py-2 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
</svg>
|
||||
<p className="text-sm text-amber-300">Server unreachable — check that the backend is running.</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-40 bg-zinc-950/90 backdrop-blur border-b border-zinc-800">
|
||||
<div className="max-w-screen-xl mx-auto px-4 h-14 flex items-center gap-4">
|
||||
{/* Logo */}
|
||||
<button
|
||||
onClick={() => navigate("/")}
|
||||
className="font-display font-bold text-lg text-accent shrink-0"
|
||||
>
|
||||
YT Hub
|
||||
</button>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex-1 max-w-2xl">
|
||||
<header className="shrink-0 z-40 bg-zinc-950/90 backdrop-blur border-b border-zinc-800">
|
||||
<div className="max-w-screen-xl mx-auto px-3 h-12 sm:h-14 flex items-center gap-2 sm:gap-4">
|
||||
{/* Search — min-w-0 prevents it from overflowing on narrow screens */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<SearchBar />
|
||||
</div>
|
||||
|
||||
{/* Active downloads indicator */}
|
||||
<DownloadIndicator />
|
||||
|
||||
{/* Nav */}
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden sm:flex items-center gap-0.5">
|
||||
<NavItem to="/">Home</NavItem>
|
||||
|
||||
@@ -246,21 +377,27 @@ export default function Layout() {
|
||||
<NavItem to="/settings">Settings</NavItem>
|
||||
</nav>
|
||||
|
||||
{/* User */}
|
||||
{/* User — hidden on mobile, sign out is in Settings */}
|
||||
<button
|
||||
onClick={logout}
|
||||
className="ml-2 text-xs text-zinc-500 hover:text-zinc-300 transition-colors shrink-0"
|
||||
className="hidden sm:inline-flex ml-2 text-xs text-zinc-500 hover:text-zinc-300 transition-colors shrink-0"
|
||||
>
|
||||
{user?.username} · sign out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 max-w-screen-xl mx-auto w-full px-4 py-6 pb-24 sm:pb-6">
|
||||
{/* Page content — only this area scrolls */}
|
||||
<main
|
||||
data-scroll
|
||||
className="flex-1 min-h-0 overflow-y-auto overscroll-contain"
|
||||
>
|
||||
<div className="max-w-screen-xl mx-auto w-full px-3 sm:px-4 py-4 sm:py-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Bottom nav — natural flex child, always visible on mobile */}
|
||||
<BottomNav newCount={newCount} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,16 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createDownload, toggleQueue, toggleLike, dismissDiscovery, getSettings } from "../api";
|
||||
|
||||
function snippetText(desc) {
|
||||
if (!desc) return "";
|
||||
const s = desc
|
||||
.replace(/https?:\/\/\S+/g, "")
|
||||
.replace(/\n+/g, " ")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
return s.length > 180 ? s.slice(0, 180).trimEnd() + "…" : s;
|
||||
}
|
||||
|
||||
function formatDuration(secs) {
|
||||
if (!secs) return null;
|
||||
const h = Math.floor(secs / 3600);
|
||||
@@ -19,16 +29,22 @@ function formatDate(dateStr) {
|
||||
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function formatViews(n) {
|
||||
if (!n) return null;
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B views`;
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 100_000_000 ? 0 : 1)}M views`;
|
||||
if (n >= 1_000) return `${Math.round(n / 1_000)}K views`;
|
||||
return `${n} views`;
|
||||
}
|
||||
|
||||
function IconBtn({ onClick, title, active, pending, children }) {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClick(e); }}
|
||||
title={title}
|
||||
className={clsx(
|
||||
"flex items-center justify-center w-6 h-6 rounded-full transition-all duration-150",
|
||||
active
|
||||
? "text-accent"
|
||||
: "text-zinc-600 hover:text-zinc-200",
|
||||
"flex items-center justify-center w-7 h-7 rounded-md transition-all duration-150",
|
||||
active ? "text-zinc-100" : "text-zinc-600 hover:text-zinc-300",
|
||||
pending && "opacity-60 cursor-default",
|
||||
)}
|
||||
>
|
||||
@@ -96,7 +112,6 @@ function ThumbnailBlock({ video, isWatched, duration, calmMode, onDismiss, class
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dismiss */}
|
||||
{video.is_recommended && !calmMode && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDismiss?.(); }}
|
||||
@@ -109,39 +124,34 @@ function ThumbnailBlock({ video, isWatched, duration, calmMode, onDismiss, class
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Duration */}
|
||||
{duration && (
|
||||
<span className="absolute bottom-2 right-2 bg-black/75 text-white text-[10px] font-medium px-1.5 py-0.5 rounded-md font-mono tabular-nums">
|
||||
<span className="absolute bottom-1.5 right-1.5 bg-black/80 text-white text-[10px] font-medium px-1.5 py-0.5 rounded font-mono tabular-nums">
|
||||
{duration}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Resolution */}
|
||||
{video.download_resolution && (
|
||||
<span className="absolute bottom-2 left-2 text-[10px] font-medium px-1.5 py-0.5 rounded-md font-mono text-accent bg-black/75">
|
||||
<span className="absolute bottom-1.5 left-1.5 text-[10px] font-medium px-1.5 py-0.5 rounded font-mono text-white/80 bg-black/75">
|
||||
{video.download_resolution}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Watched dot */}
|
||||
{isWatched && (
|
||||
<span className="absolute top-2 left-2 w-2 h-2 rounded-full bg-accent shadow-[0_0_6px_rgba(var(--color-accent-rgb),0.8)]" />
|
||||
<span className="absolute top-2 left-2 w-2 h-2 rounded-full bg-white/60" />
|
||||
)}
|
||||
|
||||
{/* Play hover overlay */}
|
||||
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<div className="w-11 h-11 rounded-full bg-white/15 backdrop-blur-sm flex items-center justify-center border border-white/20">
|
||||
<div className="absolute inset-0 bg-black/25 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<div className="w-10 h-10 rounded-full bg-white/15 backdrop-blur-sm flex items-center justify-center border border-white/20">
|
||||
<svg className="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{video.watch_progress_seconds > 0 && video.duration_seconds > 0 && (
|
||||
<div className="absolute bottom-0 inset-x-0 h-[3px] bg-white/10">
|
||||
<div className="absolute bottom-0 inset-x-0 h-[2px] bg-white/10">
|
||||
<div
|
||||
className="h-full bg-accent"
|
||||
className="h-full bg-white/70"
|
||||
style={{ width: `${Math.min(video.watch_progress_seconds / video.duration_seconds, 1) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -168,13 +178,15 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
|
||||
? (Date.now() - new Date(channelMeta.last_published_at)) / (1000 * 60 * 60 * 24) > 180
|
||||
: false;
|
||||
|
||||
const avatarUrl = video.channel_thumbnail_url ?? channelMeta?.thumbnail_url ?? null;
|
||||
const avatarLetter = video.channel_name?.[0]?.toUpperCase() ?? "?";
|
||||
|
||||
const internalId = video.id ?? video.local_video_id ?? null;
|
||||
const isDownloaded = video.is_downloaded;
|
||||
const isWatched = video.is_watched;
|
||||
const duration = formatDuration(video.duration_seconds);
|
||||
const date = formatDate(video.published_at);
|
||||
|
||||
const [downloaded, setDownloaded] = useState(isDownloaded);
|
||||
const [downloaded, setDownloaded] = useState(video.is_downloaded);
|
||||
const [queued, setQueued] = useState(video.queued ?? false);
|
||||
const [liked, setLiked] = useState(video.liked ?? false);
|
||||
|
||||
@@ -196,7 +208,7 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
|
||||
});
|
||||
|
||||
const actions = (
|
||||
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-0" onClick={(e) => e.stopPropagation()}>
|
||||
<IconBtn onClick={() => navigate(`/watch/${video.youtube_video_id}`)} title="Watch">
|
||||
<svg className="w-3.5 h-3.5 ml-px" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
@@ -243,40 +255,53 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── List variant ─────────────────────────────────────────────────────────
|
||||
// ── List variant ────────────────────────────────────────────────────────────
|
||||
if (variant === "list") {
|
||||
return (
|
||||
<div
|
||||
onClick={() => navigate(`/watch/${video.youtube_video_id}`)}
|
||||
className="group flex gap-5 px-3 py-3.5 rounded-2xl cursor-pointer hover:bg-zinc-800/50 transition-colors duration-150"
|
||||
className="group flex gap-3 sm:gap-4 px-2 sm:px-3 py-2 sm:py-3 rounded-lg cursor-pointer hover:bg-zinc-900/70 transition-colors duration-150"
|
||||
>
|
||||
{/* Thumbnail — compact on mobile, wide on desktop */}
|
||||
<ThumbnailBlock
|
||||
video={video}
|
||||
isWatched={isWatched}
|
||||
duration={duration}
|
||||
calmMode={calmMode}
|
||||
onDismiss={() => dismissMut.mutate()}
|
||||
className="w-56 sm:w-72 aspect-video rounded-xl shrink-0"
|
||||
className="w-32 sm:w-60 md:w-72 aspect-video rounded-lg shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col min-w-0 flex-1 py-0.5 gap-2">
|
||||
<div className="flex flex-col min-w-0 flex-1 gap-1 sm:gap-1.5 py-px sm:py-0.5">
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-[15px] leading-snug text-zinc-50 line-clamp-2">
|
||||
<h3 className="font-semibold text-[12px] sm:text-[14px] leading-snug text-zinc-50 line-clamp-2">
|
||||
{video.title}
|
||||
</h3>
|
||||
|
||||
{/* Channel · date · badges */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[12px] text-zinc-400 truncate">{video.channel_name}</span>
|
||||
{date && <span className="text-zinc-700 text-[12px]">·</span>}
|
||||
{date && <span className="text-[12px] text-zinc-600 shrink-0">{date}</span>}
|
||||
{/* Channel row */}
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-wrap">
|
||||
{avatarUrl && (
|
||||
<img src={avatarUrl} alt="" className="hidden sm:block w-4 h-4 rounded-full object-cover shrink-0" />
|
||||
)}
|
||||
<span className="text-[10px] sm:text-[11px] text-zinc-400 truncate">{video.channel_name}</span>
|
||||
{date && (
|
||||
<>
|
||||
<span className="text-zinc-700 text-[10px]">·</span>
|
||||
<span className="text-[10px] sm:text-[11px] text-zinc-500 shrink-0">{date}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Views + badges */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap text-[10px] text-zinc-600">
|
||||
{video.view_count > 0 && <span>{formatViews(video.view_count)}</span>}
|
||||
{video.is_recommended && !calmMode && (
|
||||
<span className="text-[10px] font-semibold tracking-wide text-indigo-400 bg-indigo-950/60 px-1.5 py-0.5 rounded-full">
|
||||
<span className="text-indigo-400 bg-indigo-950/60 px-1.5 py-0.5 rounded-full font-semibold tracking-wide text-[9px]">
|
||||
Discover
|
||||
</span>
|
||||
)}
|
||||
{isDormant && !calmMode && (
|
||||
<span title="No uploads in 6+ months" className="text-[10px] text-zinc-700 tracking-widest">zzz</span>
|
||||
<span title="No uploads in 6+ months" className="text-zinc-700 tracking-widest text-[9px]">zzz</span>
|
||||
)}
|
||||
{channelNote && (
|
||||
<span title={channelNote} className="text-zinc-700 hover:text-zinc-400 transition-colors cursor-default">
|
||||
@@ -287,17 +312,15 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{video.description ? (
|
||||
<p className="text-[12px] leading-relaxed text-zinc-500 line-clamp-3 flex-1">
|
||||
{video.description.replace(/\n+/g, " ")}
|
||||
{/* Description snippet — desktop only, URLs stripped */}
|
||||
{video.description && snippetText(video.description) && (
|
||||
<p className="hidden sm:block text-[11px] leading-relaxed text-zinc-500 line-clamp-3">
|
||||
{snippetText(video.description)}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex-1" />
|
||||
)}
|
||||
|
||||
{/* Actions — fade in on hover */}
|
||||
<div className="opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity duration-150 mt-auto">
|
||||
{/* Actions — hover only everywhere */}
|
||||
<div className="mt-auto pt-1 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity duration-150">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
@@ -305,13 +328,12 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
|
||||
);
|
||||
}
|
||||
|
||||
// ── Grid variant ─────────────────────────────────────────────────────────
|
||||
// ── Grid variant ────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div
|
||||
onClick={() => navigate(`/watch/${video.youtube_video_id}`)}
|
||||
className={clsx(
|
||||
"group relative flex flex-col cursor-pointer rounded-2xl overflow-hidden",
|
||||
"bg-zinc-900 hover:bg-zinc-800/80 transition-colors duration-150",
|
||||
"group relative flex flex-col cursor-pointer",
|
||||
size === "sm" && "text-xs",
|
||||
)}
|
||||
>
|
||||
@@ -321,45 +343,69 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
|
||||
duration={duration}
|
||||
calmMode={calmMode}
|
||||
onDismiss={() => dismissMut.mutate()}
|
||||
className="aspect-video"
|
||||
className="aspect-video rounded-lg overflow-hidden"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 p-3 flex-1">
|
||||
{/* Title */}
|
||||
<p className="font-medium text-[13px] leading-snug text-zinc-50 line-clamp-2">
|
||||
<div className="flex gap-2 sm:gap-2.5 pt-2 sm:pt-2.5 pb-1 flex-1">
|
||||
{/* Channel avatar */}
|
||||
<div
|
||||
className="shrink-0 mt-0.5"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/channels/${video.channel_id}`); }}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="w-7 h-7 sm:w-8 sm:h-8 rounded-full object-cover hover:opacity-80 transition-opacity"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-full bg-zinc-800 flex items-center justify-center text-[11px] font-bold text-zinc-400 hover:opacity-80 transition-opacity">
|
||||
{avatarLetter}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Text + actions */}
|
||||
<div className="flex flex-col gap-0.5 sm:gap-1 min-w-0 flex-1">
|
||||
<p className="font-medium text-[12px] sm:text-[13px] leading-snug text-zinc-50 line-clamp-2">
|
||||
{video.title}
|
||||
</p>
|
||||
|
||||
{/* Channel + date */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-zinc-500 truncate">{video.channel_name}</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<span className="text-[10px] sm:text-[11px] text-zinc-500 truncate">{video.channel_name}</span>
|
||||
{channelNote && (
|
||||
<span title={channelNote} className="text-zinc-700 hover:text-zinc-400 transition-colors cursor-default">
|
||||
<span title={channelNote} className="text-zinc-700 hover:text-zinc-400 transition-colors cursor-default shrink-0">
|
||||
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2zm2 0v14h14V5zm2 3h10v2H7zm0 4h7v2H7z"/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{isDormant && !calmMode && (
|
||||
<span title="No uploads in 6+ months" className="text-[9px] text-zinc-700 tracking-widest">zzz</span>
|
||||
<span title="No uploads in 6+ months" className="text-[9px] text-zinc-700 tracking-widest shrink-0">zzz</span>
|
||||
)}
|
||||
{date && <span className="text-[11px] text-zinc-700">{date}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
{(video.is_recommended && !calmMode) && (
|
||||
<span className="self-start text-[10px] font-semibold tracking-wide text-indigo-400 bg-indigo-950/60 px-1.5 py-0.5 rounded-full">
|
||||
{video.is_recommended && !calmMode && (
|
||||
<span className="text-[9px] font-semibold tracking-wide text-indigo-400 bg-indigo-950/60 px-1 py-px rounded-full shrink-0">
|
||||
Discover
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions — fade in on hover */}
|
||||
<div className="mt-auto pt-1.5 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity duration-150 border-t border-zinc-800/80">
|
||||
<div className="flex items-center gap-1 flex-wrap text-[10px] text-zinc-600">
|
||||
{date && <span>{date}</span>}
|
||||
{video.view_count > 0 && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{formatViews(video.view_count)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions — desktop hover only (touch users tap to watch) */}
|
||||
<div className="mt-auto pt-1 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity duration-150">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,9 +93,8 @@ export default function VideoPlayer() {
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [downloadId, setDownloadId] = useState(null);
|
||||
const [switchedToLocal, setSwitchedToLocal] = useState(false);
|
||||
const saveTimerRef = useRef(null);
|
||||
const initiatedRef = useRef(null); // track which video we triggered download for
|
||||
const initiatedRef = useRef(null);
|
||||
|
||||
// ── Video metadata ────────────────────────────────────────────────────────
|
||||
const { data: video, refetch: refetchVideo } = useQuery({
|
||||
@@ -119,14 +118,23 @@ export default function VideoPlayer() {
|
||||
},
|
||||
});
|
||||
|
||||
// When download finishes, re-fetch video to get local_file_url and auto-switch
|
||||
// When download finishes, refetch video — local_file_url will appear once the
|
||||
// file exists on disk, which is the single source of truth for switching players
|
||||
useEffect(() => {
|
||||
if (dlStatus?.status === "complete" && !switchedToLocal) {
|
||||
refetchVideo().then(({ data }) => {
|
||||
if (data?.local_file_url) setSwitchedToLocal(true);
|
||||
});
|
||||
if (dlStatus?.status === "complete" && !video?.local_file_url) {
|
||||
refetchVideo();
|
||||
}
|
||||
}, [dlStatus?.status, switchedToLocal, refetchVideo]);
|
||||
}, [dlStatus?.status]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Record a "clicked" impression as soon as we have the video id — even if the
|
||||
// user closes immediately before playback starts.
|
||||
const clickedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (video?.id && !clickedRef.current && !video.is_watched) {
|
||||
clickedRef.current = true;
|
||||
updateProgress(video.id, { watch_progress_seconds: video.watch_progress_seconds ?? 0 });
|
||||
}
|
||||
}, [video?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Trigger download on open ──────────────────────────────────────────────
|
||||
const downloadMut = useMutation({
|
||||
@@ -134,22 +142,16 @@ export default function VideoPlayer() {
|
||||
onSuccess: (res) => {
|
||||
const dl = res.data;
|
||||
setDownloadId(dl.id);
|
||||
// If it came back complete already (was pre-downloaded), just switch now
|
||||
if (dl.status === "complete") {
|
||||
refetchVideo().then(({ data }) => {
|
||||
if (data?.local_file_url) setSwitchedToLocal(true);
|
||||
});
|
||||
}
|
||||
// If already complete (pre-downloaded), refetch to get local_file_url
|
||||
if (dl.status === "complete") refetchVideo();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!youtubeId || initiatedRef.current === youtubeId) return;
|
||||
initiatedRef.current = youtubeId;
|
||||
setSwitchedToLocal(false);
|
||||
setCurrentTime(0);
|
||||
setDownloadId(null);
|
||||
// Small delay so the modal renders before the fetch starts
|
||||
const t = setTimeout(() => downloadMut.mutate(youtubeId), 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [youtubeId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -166,7 +168,7 @@ export default function VideoPlayer() {
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
if (video?.id) {
|
||||
const duration = video.duration_seconds ?? 0;
|
||||
const watched = duration > 0 && secs >= duration * 0.9;
|
||||
const watched = duration > 0 && secs >= duration * 0.75;
|
||||
updateProgress(video.id, { watch_progress_seconds: secs, watched });
|
||||
}
|
||||
}, 10_000);
|
||||
@@ -174,7 +176,6 @@ export default function VideoPlayer() {
|
||||
|
||||
const close = useCallback(() => {
|
||||
setParams((p) => { p.delete("play"); p.delete("pt"); p.delete("pc"); return p; });
|
||||
setSwitchedToLocal(false);
|
||||
clearTimeout(saveTimerRef.current);
|
||||
}, [setParams]);
|
||||
|
||||
@@ -192,7 +193,9 @@ export default function VideoPlayer() {
|
||||
const channelName = video?.channel_name ?? urlChannel;
|
||||
const startAt = video?.watch_progress_seconds ?? 0;
|
||||
const isDownloading = dlStatus && (dlStatus.status === "pending" || dlStatus.status === "downloading");
|
||||
const localUrl = switchedToLocal ? video?.local_file_url : null;
|
||||
// local_file_url is only set by the backend when the file actually exists on disk
|
||||
const localUrl = video?.local_file_url ?? null;
|
||||
const videoLoading = !video;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -216,8 +219,15 @@ export default function VideoPlayer() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Player — local file once ready, YouTube embed while downloading */}
|
||||
{localUrl ? (
|
||||
{/* Player — wait for metadata, then show local file or YouTube embed */}
|
||||
{videoLoading ? (
|
||||
<div className="w-full aspect-video rounded-lg bg-zinc-900 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 animate-spin text-zinc-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
) : localUrl ? (
|
||||
<LocalVideo src={localUrl} startAt={currentTime || startAt} onTimeUpdate={handleTimeUpdate} />
|
||||
) : (
|
||||
<YoutubeEmbed youtubeId={youtubeId} startAt={startAt} onTimeUpdate={handleTimeUpdate} />
|
||||
|
||||
@@ -17,6 +17,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
video::cue {
|
||||
text-align: center;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: #ffffff;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getChannel, getChannelVideos, followChannel, unfollowChannel, indexChannel, downloadChannel } from "../api";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getChannel, getChannelVideos, searchChannelYoutube,
|
||||
followChannel, unfollowChannel, indexChannel, indexChannelFull, exploreChannelOlder, fetchPopularVideos, downloadChannel,
|
||||
getChannelPlaylists, fetchChannelPlaylists, getPlaylistVideos, indexPlaylist,
|
||||
getRandomChannelVideo, getChannelInProgress, createDownload,
|
||||
} from "../api";
|
||||
import VideoCard from "../components/VideoCard";
|
||||
import SortPicker from "../components/SortPicker";
|
||||
|
||||
const LIMIT = 60;
|
||||
|
||||
const TABS = [
|
||||
{ value: "videos", label: "Videos" },
|
||||
{ value: "popular", label: "Popular" },
|
||||
{ value: "playlists", label: "Playlists" },
|
||||
];
|
||||
|
||||
const SORTS = [
|
||||
{ value: "newest", label: "Newest" },
|
||||
{ value: "oldest", label: "Oldest" },
|
||||
{ value: "title", label: "A–Z" },
|
||||
{ value: "unwatched", label: "Unwatched" },
|
||||
];
|
||||
|
||||
function formatSubs(n) {
|
||||
if (!n) return null;
|
||||
@@ -12,35 +31,59 @@ function formatSubs(n) {
|
||||
return String(n);
|
||||
}
|
||||
|
||||
const VIDEO_SORTS = [
|
||||
{ value: "newest", label: "Newest" },
|
||||
{ value: "oldest", label: "Oldest" },
|
||||
{ value: "title", label: "Title A–Z" },
|
||||
{ value: "unwatched", label: "Unwatched first" },
|
||||
];
|
||||
|
||||
function sortVideos(items, sort) {
|
||||
const arr = [...items];
|
||||
if (sort === "oldest") return arr.sort((a, b) => new Date(a.published_at ?? 0) - new Date(b.published_at ?? 0));
|
||||
if (sort === "title") return arr.sort((a, b) => (a.title ?? "").localeCompare(b.title ?? ""));
|
||||
if (sort === "unwatched") return arr.sort((a, b) => Number(a.is_watched) - Number(b.is_watched));
|
||||
return arr.sort((a, b) => new Date(b.published_at ?? 0) - new Date(a.published_at ?? 0));
|
||||
}
|
||||
|
||||
export default function ChannelPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [tab, setTab] = useState("videos");
|
||||
const [sort, setSort] = useState("newest");
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeQ, setActiveQ] = useState("");
|
||||
const [indexing, setIndexing] = useState(false);
|
||||
const [explorePage, setExplorePage] = useState(2);
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedVideos, setSelectedVideos] = useState(new Set());
|
||||
const [bulkDlResult, setBulkDlResult] = useState(null);
|
||||
const searchInputRef = useRef(null);
|
||||
const [openPlaylistId, setOpenPlaylistId] = useState(null);
|
||||
const [playlistOffset, setPlaylistOffset] = useState(0);
|
||||
|
||||
const { data: channel, isLoading: loadingChannel } = useQuery({
|
||||
queryKey: ["channel", id],
|
||||
queryFn: () => getChannel(id).then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: videos, isLoading: loadingVideos } = useQuery({
|
||||
queryKey: ["channel-videos", id],
|
||||
queryFn: () => getChannelVideos(id).then((r) => r.data),
|
||||
const effectiveSort = tab === "popular" ? "popular" : sort;
|
||||
|
||||
const {
|
||||
data: videosData,
|
||||
isLoading: loadingVideos,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["channel-videos", id, effectiveSort, activeQ],
|
||||
queryFn: ({ pageParam = 0 }) =>
|
||||
getChannelVideos(id, effectiveSort, pageParam, LIMIT, activeQ).then((r) => r.data),
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
lastPage.length === LIMIT ? pages.length * LIMIT : undefined,
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const videos = videosData?.pages.flat() ?? [];
|
||||
|
||||
// Refetch after background re-index
|
||||
const refetchedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!id || refetchedRef.current) return;
|
||||
refetchedRef.current = true;
|
||||
const t = setTimeout(() => {
|
||||
qc.invalidateQueries({ queryKey: ["channel", id] });
|
||||
qc.invalidateQueries({ queryKey: ["channel-videos", id] });
|
||||
}, 8000);
|
||||
return () => clearTimeout(t);
|
||||
}, [id, qc]);
|
||||
|
||||
const followMut = useMutation({
|
||||
mutationFn: () =>
|
||||
channel?.status === "followed" ? unfollowChannel(id) : followChannel(id),
|
||||
@@ -50,13 +93,77 @@ export default function ChannelPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const scheduleRefetch = (delayMs) => {
|
||||
setIndexing(true);
|
||||
setTimeout(() => {
|
||||
qc.invalidateQueries({ queryKey: ["channel-videos", id] });
|
||||
qc.invalidateQueries({ queryKey: ["channel", id] });
|
||||
setIndexing(false);
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const indexMut = useMutation({
|
||||
mutationFn: () => indexChannel(id),
|
||||
onSuccess: () => setTimeout(() => qc.invalidateQueries({ queryKey: ["channel-videos", id] }), 4000),
|
||||
onSuccess: () => scheduleRefetch(6000),
|
||||
});
|
||||
|
||||
const fullIndexMut = useMutation({
|
||||
mutationFn: () => indexChannelFull(id),
|
||||
onSuccess: () => scheduleRefetch(45000),
|
||||
});
|
||||
|
||||
const exploreMut = useMutation({
|
||||
mutationFn: () => exploreChannelOlder(id, explorePage),
|
||||
onSuccess: () => {
|
||||
setExplorePage(p => p + 1);
|
||||
scheduleRefetch(20000);
|
||||
},
|
||||
});
|
||||
|
||||
const popularMut = useMutation({
|
||||
mutationFn: () => fetchPopularVideos(id),
|
||||
onSuccess: () => scheduleRefetch(60000),
|
||||
});
|
||||
|
||||
const deepSearchMut = useMutation({
|
||||
mutationFn: () => searchChannelYoutube(id, activeQ || search),
|
||||
onSuccess: () => scheduleRefetch(20000),
|
||||
});
|
||||
|
||||
const { data: playlists = [], refetch: refetchPlaylists } = useQuery({
|
||||
queryKey: ["channel-playlists", id],
|
||||
queryFn: () => getChannelPlaylists(id).then((r) => r.data),
|
||||
enabled: !!id && tab === "playlists",
|
||||
});
|
||||
|
||||
const fetchPlaylistsMut = useMutation({
|
||||
mutationFn: () => fetchChannelPlaylists(id),
|
||||
onSuccess: () => setTimeout(() => refetchPlaylists(), 8000),
|
||||
});
|
||||
|
||||
const { data: playlistVideos, isLoading: loadingPlaylistVideos, refetch: refetchPlaylistVideos } = useQuery({
|
||||
queryKey: ["playlist-videos", openPlaylistId, playlistOffset],
|
||||
queryFn: () => getPlaylistVideos(openPlaylistId, playlistOffset, 60).then((r) => r.data),
|
||||
enabled: !!openPlaylistId,
|
||||
});
|
||||
|
||||
const indexPlaylistMut = useMutation({
|
||||
mutationFn: (plId) => indexPlaylist(plId),
|
||||
onSuccess: () => setTimeout(() => refetchPlaylistVideos(), 15000),
|
||||
});
|
||||
|
||||
const { data: inProgress = [] } = useQuery({
|
||||
queryKey: ["channel-in-progress", id],
|
||||
queryFn: () => getChannelInProgress(id).then((r) => r.data),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const randomMut = useMutation({
|
||||
mutationFn: () => getRandomChannelVideo(id).then((r) => r.data),
|
||||
onSuccess: (video) => navigate(`/watch/${video.youtube_video_id}`),
|
||||
});
|
||||
|
||||
const [dlResult, setDlResult] = useState(null);
|
||||
const [videoSort, setVideoSort] = useState("newest");
|
||||
const dlMut = useMutation({
|
||||
mutationFn: () => downloadChannel(id),
|
||||
onSuccess: (res) => {
|
||||
@@ -65,6 +172,40 @@ export default function ChannelPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const toggleSelectVideo = (ytId) => {
|
||||
setSelectedVideos(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(ytId)) next.delete(ytId); else next.add(ytId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const bulkDownloadMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const ids = [...selectedVideos];
|
||||
await Promise.all(ids.map(ytId => createDownload(ytId)));
|
||||
return ids.length;
|
||||
},
|
||||
onSuccess: (count) => {
|
||||
setBulkDlResult(count);
|
||||
setSelectedVideos(new Set());
|
||||
setSelectMode(false);
|
||||
qc.invalidateQueries({ queryKey: ["downloads"] });
|
||||
setTimeout(() => setBulkDlResult(null), 4000);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
setActiveQ(search.trim());
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearch("");
|
||||
setActiveQ("");
|
||||
searchInputRef.current?.focus();
|
||||
};
|
||||
|
||||
if (loadingChannel) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
@@ -76,102 +217,409 @@ export default function ChannelPage() {
|
||||
if (!channel) return <p className="text-zinc-500">Channel not found.</p>;
|
||||
|
||||
const isFollowed = channel.status === "followed";
|
||||
const isPending = indexMut.isPending || fullIndexMut.isPending || deepSearchMut.isPending || exploreMut.isPending || popularMut.isPending || indexing;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Channel header — banner with overlay, or plain if no banner */}
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Banner */}
|
||||
<div className={`-mx-4 -mt-6 relative ${channel.banner_url ? "" : "bg-zinc-900"} rounded-b-2xl overflow-hidden`}>
|
||||
{channel.banner_url && (
|
||||
<img src={channel.banner_url} alt="" className="w-full h-36 sm:h-52 object-cover" />
|
||||
<img src={channel.banner_url} alt="" className="w-full h-28 sm:h-48 object-cover" />
|
||||
)}
|
||||
{/* Gradient overlay */}
|
||||
<div className={`${channel.banner_url ? "absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" : ""}`} />
|
||||
<div className={channel.banner_url ? "absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" : ""} />
|
||||
|
||||
{/* Info row sits at the bottom of the banner */}
|
||||
<div className={`${channel.banner_url ? "absolute bottom-0 inset-x-0" : ""} px-4 pb-4 pt-3 flex items-end gap-4`}>
|
||||
{/* Avatar */}
|
||||
<div className={`${channel.banner_url ? "absolute bottom-0 inset-x-0" : ""} px-4 pb-4 pt-3 flex items-end gap-3`}>
|
||||
{channel.thumbnail_url ? (
|
||||
<img
|
||||
src={channel.thumbnail_url}
|
||||
alt={channel.name}
|
||||
className="w-16 h-16 sm:w-20 sm:h-20 rounded-full object-cover shrink-0 ring-2 ring-black/40"
|
||||
/>
|
||||
<img src={channel.thumbnail_url} alt={channel.name}
|
||||
className="w-14 h-14 sm:w-20 sm:h-20 rounded-full object-cover shrink-0 ring-2 ring-black/40" />
|
||||
) : (
|
||||
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-full bg-zinc-800 flex items-center justify-center text-2xl sm:text-3xl font-display font-bold text-zinc-400 shrink-0">
|
||||
<div className="w-14 h-14 sm:w-20 sm:h-20 rounded-full bg-zinc-800 flex items-center justify-center text-2xl font-display font-bold text-zinc-400 shrink-0">
|
||||
{channel.name?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name + meta */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="font-display font-bold text-xl sm:text-2xl text-white drop-shadow">{channel.name}</h1>
|
||||
<p className="text-xs sm:text-sm text-zinc-300 mt-0.5 drop-shadow">
|
||||
<h1 className="font-display font-bold text-lg sm:text-2xl text-white drop-shadow leading-tight">{channel.name}</h1>
|
||||
<p className="text-xs text-zinc-400 mt-0.5 drop-shadow">
|
||||
{[
|
||||
formatSubs(channel.subscriber_count) && `${formatSubs(channel.subscriber_count)} subscribers`,
|
||||
`${channel.video_count} videos indexed`,
|
||||
formatSubs(channel.subscriber_count) && `${formatSubs(channel.subscriber_count)} subs`,
|
||||
channel.video_count && `${channel.video_count} indexed`,
|
||||
].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 shrink-0 flex-wrap justify-end">
|
||||
{dlResult != null && (
|
||||
<span className="text-sm text-accent font-mono">
|
||||
{dlResult === 0 ? "Already up to date" : `${dlResult} queued`}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => dlMut.mutate()}
|
||||
disabled={dlMut.isPending}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-black hover:bg-yellow-300 transition-colors disabled:opacity-60 flex items-center gap-2"
|
||||
>
|
||||
{dlMut.isPending ? "Queuing…" : "Download all"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => indexMut.mutate()}
|
||||
disabled={indexMut.isPending || indexMut.isSuccess}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-zinc-800/80 text-zinc-300 hover:bg-zinc-700 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{indexMut.isPending ? "Indexing…" : indexMut.isSuccess ? "Done ✓" : "Re-index"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => followMut.mutate()}
|
||||
disabled={followMut.isPending}
|
||||
className={`text-sm font-medium px-4 py-2 rounded-lg transition-colors ${
|
||||
isFollowed
|
||||
? "bg-zinc-700/80 text-zinc-300 hover:bg-zinc-600"
|
||||
: "bg-zinc-800/80 text-zinc-300 hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
<div className="hidden sm:flex items-center gap-2 shrink-0">
|
||||
<button onClick={() => followMut.mutate()} disabled={followMut.isPending}
|
||||
className={`text-sm font-medium px-4 py-1.5 rounded-lg transition-colors ${isFollowed ? "bg-zinc-700/80 text-zinc-300 hover:bg-zinc-600" : "bg-zinc-800/80 text-zinc-300 hover:bg-zinc-700"}`}>
|
||||
{isFollowed ? "Following" : "Follow"}
|
||||
</button>
|
||||
<button onClick={() => randomMut.mutate()} disabled={randomMut.isPending}
|
||||
title="Play a random unwatched video"
|
||||
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-zinc-800/80 text-zinc-300 hover:bg-zinc-700 transition-colors disabled:opacity-60">
|
||||
{randomMut.isPending ? "…" : "Surprise me"}
|
||||
</button>
|
||||
<button onClick={() => dlMut.mutate()} disabled={dlMut.isPending}
|
||||
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-accent text-black hover:bg-zinc-100 transition-colors disabled:opacity-60">
|
||||
{dlMut.isPending ? "Queuing…" : "Download all"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description below banner */}
|
||||
{channel.description && (
|
||||
<p className="text-sm text-zinc-400 line-clamp-3 -mt-4">{channel.description}</p>
|
||||
{/* Mobile actions */}
|
||||
<div className="sm:hidden flex items-center gap-2 -mt-1">
|
||||
<button onClick={() => followMut.mutate()} disabled={followMut.isPending}
|
||||
className="flex-1 text-sm font-medium py-2 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors">
|
||||
{isFollowed ? "Following ✓" : "Follow"}
|
||||
</button>
|
||||
<button onClick={() => randomMut.mutate()} disabled={randomMut.isPending}
|
||||
className="flex-1 text-sm font-medium py-2 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors disabled:opacity-60">
|
||||
{randomMut.isPending ? "…" : "Surprise me"}
|
||||
</button>
|
||||
<button onClick={() => dlMut.mutate()} disabled={dlMut.isPending}
|
||||
className="flex-1 text-sm font-medium py-2 rounded-lg bg-accent text-black hover:bg-zinc-100 transition-colors disabled:opacity-60">
|
||||
{dlMut.isPending ? "Queuing…" : "Download all"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dlResult != null && (
|
||||
<p className="text-xs text-zinc-400 font-mono -mt-2">
|
||||
{dlResult === 0 ? "Already up to date" : `${dlResult} downloads queued`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Video grid */}
|
||||
{loadingVideos ? (
|
||||
{channel.description && (
|
||||
<p className="text-xs text-zinc-500 line-clamp-2 -mt-1">{channel.description}</p>
|
||||
)}
|
||||
|
||||
{/* Search bar */}
|
||||
<form onSubmit={handleSearch} className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search videos…"
|
||||
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-1.5 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-zinc-600 transition-colors"
|
||||
/>
|
||||
{search && (
|
||||
<button type="button" onClick={clearSearch}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit"
|
||||
className="text-sm px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors shrink-0">
|
||||
Filter
|
||||
</button>
|
||||
{activeQ && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deepSearchMut.mutate()}
|
||||
disabled={deepSearchMut.isPending || indexing}
|
||||
className="text-sm px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 transition-colors shrink-0 disabled:opacity-40"
|
||||
>
|
||||
Search YouTube
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Tabs + controls */}
|
||||
<div className="flex items-center justify-between gap-3 -mt-1 border-b border-zinc-800/60 pb-3">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{TABS.map(t => (
|
||||
<button key={t.value} onClick={() => setTab(t.value)}
|
||||
className={`text-sm px-3 py-1 rounded-md transition-colors font-medium ${
|
||||
tab === t.value ? "text-zinc-100" : "text-zinc-500 hover:text-zinc-300"
|
||||
}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{bulkDlResult != null && (
|
||||
<span className="text-xs text-accent font-mono">{bulkDlResult} queued</span>
|
||||
)}
|
||||
{tab === "videos" && (
|
||||
<button
|
||||
onClick={() => { setSelectMode(v => !v); setSelectedVideos(new Set()); }}
|
||||
className={`text-xs px-2.5 py-1 rounded-lg font-medium transition-colors ${selectMode ? "bg-accent text-black" : "text-zinc-500 hover:text-zinc-300"}`}
|
||||
>
|
||||
{selectMode ? "Cancel" : "Select"}
|
||||
</button>
|
||||
)}
|
||||
{isPending && (
|
||||
<span className="text-xs text-zinc-500 flex items-center gap-1.5">
|
||||
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
Fetching…
|
||||
</span>
|
||||
)}
|
||||
{tab === "popular" ? (
|
||||
<button onClick={() => popularMut.mutate()} disabled={isPending}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-100 font-medium transition-colors disabled:opacity-40">
|
||||
Fetch popular
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={() => indexMut.mutate()} disabled={isPending}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-40">
|
||||
Fetch recent
|
||||
</button>
|
||||
<button onClick={() => fullIndexMut.mutate()} disabled={isPending}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-100 font-medium transition-colors disabled:opacity-40">
|
||||
Fetch all
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort bar — videos tab only */}
|
||||
{tab === "videos" && (
|
||||
<div className="flex items-center gap-0.5 -mt-1">
|
||||
{SORTS.map(s => (
|
||||
<button key={s.value} onClick={() => setSort(s.value)}
|
||||
className={`text-xs px-2.5 py-1 rounded-md transition-colors ${
|
||||
sort === s.value ? "bg-zinc-700 text-zinc-100" : "text-zinc-500 hover:text-zinc-300"
|
||||
}`}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playlist browser */}
|
||||
{tab === "playlists" && (
|
||||
openPlaylistId ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => { setOpenPlaylistId(null); setPlaylistOffset(0); }}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-100 transition-colors">
|
||||
← Back to playlists
|
||||
</button>
|
||||
{(() => {
|
||||
const pl = playlists.find(p => p.id === openPlaylistId);
|
||||
return pl ? <span className="text-sm font-medium text-zinc-200 truncate">{pl.title}</span> : null;
|
||||
})()}
|
||||
<button onClick={() => indexPlaylistMut.mutate(openPlaylistId)}
|
||||
disabled={indexPlaylistMut.isPending}
|
||||
className="ml-auto text-xs text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40">
|
||||
{indexPlaylistMut.isPending ? "Indexing…" : "Re-index"}
|
||||
</button>
|
||||
</div>
|
||||
{loadingPlaylistVideos ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : videos?.length ? (
|
||||
) : playlistVideos?.length ? (
|
||||
<>
|
||||
<div className="flex justify-end">
|
||||
<SortPicker value={videoSort} onChange={setVideoSort} options={VIDEO_SORTS} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{sortVideos(videos, videoSort).map((v) => (
|
||||
<VideoCard key={v.youtube_video_id} video={{ ...v, channel_name: channel.name }} />
|
||||
<div className="flex flex-col gap-1">
|
||||
{playlistVideos.map((v) => (
|
||||
<VideoCard key={v.youtube_video_id} video={v} variant="list" />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-4 mt-2">
|
||||
{playlistOffset > 0 && (
|
||||
<button onClick={() => setPlaylistOffset(o => Math.max(0, o - 60))}
|
||||
className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
|
||||
Previous
|
||||
</button>
|
||||
)}
|
||||
{playlistVideos.length === 60 && (
|
||||
<button onClick={() => setPlaylistOffset(o => o + 60)}
|
||||
className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
|
||||
Next
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-zinc-500 text-sm">No videos indexed yet. Hit Re-index to fetch them.</p>
|
||||
<div className="py-8 flex flex-col items-center gap-3">
|
||||
<p className="text-zinc-500 text-sm">No videos indexed for this playlist yet.</p>
|
||||
<button onClick={() => indexPlaylistMut.mutate(openPlaylistId)}
|
||||
disabled={indexPlaylistMut.isPending}
|
||||
className="text-sm text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40 underline">
|
||||
{indexPlaylistMut.isPending ? "Indexing…" : "Index playlist"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-zinc-500">{playlists.length} playlists</span>
|
||||
<button onClick={() => fetchPlaylistsMut.mutate()}
|
||||
disabled={fetchPlaylistsMut.isPending}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40">
|
||||
{fetchPlaylistsMut.isPending ? "Fetching…" : "Fetch playlists"}
|
||||
</button>
|
||||
</div>
|
||||
{playlists.length === 0 ? (
|
||||
<div className="py-8 flex flex-col items-center gap-3">
|
||||
<p className="text-zinc-500 text-sm">No playlists fetched yet.</p>
|
||||
<button onClick={() => fetchPlaylistsMut.mutate()}
|
||||
disabled={fetchPlaylistsMut.isPending}
|
||||
className="text-sm text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40 underline">
|
||||
Fetch playlists from YouTube
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{playlists.map(pl => (
|
||||
<div key={pl.id}
|
||||
onClick={() => { setOpenPlaylistId(pl.id); setPlaylistOffset(0); }}
|
||||
className="group flex gap-3 sm:gap-4 px-2 sm:px-3 py-2 sm:py-3 rounded-lg cursor-pointer hover:bg-zinc-900/70 transition-colors">
|
||||
{/* Thumbnail */}
|
||||
<div className="relative w-32 sm:w-60 md:w-72 aspect-video rounded-lg overflow-hidden bg-zinc-800 shrink-0">
|
||||
{pl.thumbnail_url ? (
|
||||
<img src={pl.thumbnail_url} alt="" className="w-full h-full object-cover group-hover:scale-[1.03] transition-transform duration-300" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-zinc-600">
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h16M4 10h16M4 14h8M4 18h8" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<span className="absolute bottom-1.5 right-1.5 bg-black/80 text-white text-[10px] font-medium px-1.5 py-0.5 rounded font-mono">
|
||||
{pl.video_count} videos
|
||||
</span>
|
||||
</div>
|
||||
{/* Info */}
|
||||
<div className="flex flex-col gap-1 min-w-0 flex-1 py-px sm:py-0.5">
|
||||
<h3 className="font-semibold text-[12px] sm:text-[14px] leading-snug text-zinc-50 line-clamp-2">{pl.title}</h3>
|
||||
<span className="text-[10px] sm:text-[11px] text-zinc-500">
|
||||
{pl.indexed_at ? "Indexed" : "Not indexed"}
|
||||
</span>
|
||||
{pl.description && (
|
||||
<p className="hidden sm:block text-[11px] leading-relaxed text-zinc-500 line-clamp-1">{pl.description}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); indexPlaylistMut.mutate(pl.id); }}
|
||||
disabled={indexPlaylistMut.isPending}
|
||||
className="mt-auto self-start text-[10px] text-zinc-600 hover:text-zinc-300 transition-colors disabled:opacity-40 opacity-0 group-hover:opacity-100">
|
||||
{indexPlaylistMut.isPending ? "Indexing…" : pl.indexed_at ? "Re-index" : "Index"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Continue watching */}
|
||||
{tab === "videos" && inProgress.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Continue watching</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
{inProgress.map((v) => (
|
||||
<VideoCard key={v.youtube_video_id} video={{ ...v, channel_name: channel.name }} variant="list" />
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-zinc-800/60 mt-1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video list */}
|
||||
{tab !== "playlists" && (loadingVideos ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : videos.length ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{videos.map((v) => (
|
||||
<div key={v.youtube_video_id} className={`flex items-center gap-2 ${selectMode ? "cursor-pointer" : ""}`}
|
||||
onClick={selectMode ? () => toggleSelectVideo(v.youtube_video_id) : undefined}>
|
||||
{selectMode && (
|
||||
<input
|
||||
type="checkbox"
|
||||
readOnly
|
||||
checked={selectedVideos.has(v.youtube_video_id)}
|
||||
className="shrink-0 ml-1 accent-accent w-3.5 h-3.5 pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<VideoCard video={{ ...v, channel_name: channel.name }} variant="list" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasNextPage ? (
|
||||
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}
|
||||
className="mt-4 self-center text-sm text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-40 py-2 px-4">
|
||||
{isFetchingNextPage ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
) : !activeQ && tab === "videos" && (
|
||||
<div className="mt-4 flex flex-col items-center gap-3 py-4 border-t border-zinc-800/50">
|
||||
<p className="text-xs text-zinc-600">{videos.length} videos indexed</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={() => exploreMut.mutate()} disabled={isPending}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-100 font-medium transition-colors disabled:opacity-40">
|
||||
Explore older videos
|
||||
</button>
|
||||
<span className="text-zinc-800 text-xs">·</span>
|
||||
<button onClick={() => fullIndexMut.mutate()} disabled={isPending}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-40">
|
||||
Fetch entire history
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 flex flex-col items-center gap-3">
|
||||
<p className="text-zinc-500 text-sm">
|
||||
{activeQ
|
||||
? `No indexed videos match "${activeQ}"`
|
||||
: tab === "popular"
|
||||
? "No view counts yet — click \"Fetch popular\" to rank indexed videos by views."
|
||||
: "No videos indexed yet."}
|
||||
</p>
|
||||
{activeQ ? (
|
||||
<button onClick={() => deepSearchMut.mutate()} disabled={isPending}
|
||||
className="text-sm text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40 underline">
|
||||
Search YouTube for "{activeQ}"
|
||||
</button>
|
||||
) : tab === "popular" ? (
|
||||
<button onClick={() => popularMut.mutate()} disabled={isPending}
|
||||
className="text-sm text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40 underline">
|
||||
Fetch popular videos from YouTube
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => fullIndexMut.mutate()} disabled={isPending}
|
||||
className="text-sm text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40 underline">
|
||||
Fetch all videos
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Sticky bulk download bar */}
|
||||
{selectMode && selectedVideos.size > 0 && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 px-5 py-3 bg-zinc-800 border border-zinc-700 rounded-2xl shadow-2xl">
|
||||
<span className="text-sm text-zinc-300 font-medium">{selectedVideos.size} selected</span>
|
||||
<button
|
||||
onClick={() => bulkDownloadMut.mutate()}
|
||||
disabled={bulkDownloadMut.isPending}
|
||||
className="px-4 py-1.5 rounded-lg bg-accent text-black text-sm font-semibold hover:bg-zinc-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{bulkDownloadMut.isPending ? "Queuing…" : "Download"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedVideos(new Set())}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function ContinueWatchingPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-300 font-medium">Nothing in progress</p>
|
||||
<p className="text-zinc-500 text-sm">Nothing in progress</p>
|
||||
<p className="text-zinc-500 text-sm mt-1">
|
||||
Videos you've started but not finished will appear here.
|
||||
</p>
|
||||
@@ -34,14 +34,12 @@ export default function ContinueWatchingPage() {
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-zinc-500 -mt-2">{videos.length} video{videos.length !== 1 ? "s" : ""} in progress</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{videos.map((v) => (
|
||||
<VideoCard
|
||||
key={v.youtube_video_id}
|
||||
video={{
|
||||
...v,
|
||||
is_watched: false,
|
||||
}}
|
||||
video={{ ...v, is_watched: false }}
|
||||
variant="list"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getDiscovery, getDiscoveryVideos,
|
||||
followDiscovery, dismissDiscovery, dismissDiscoveryVideo, refreshDiscovery,
|
||||
getDiscoveryStatus,
|
||||
} from "../api";
|
||||
import VideoCard from "../components/VideoCard";
|
||||
import { scrollToTop } from "../utils/scroll";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
@@ -149,7 +151,7 @@ function ChannelCard({ item }) {
|
||||
)}
|
||||
|
||||
{!featured && item.description && (
|
||||
<p className="text-xs text-zinc-500 line-clamp-2">{item.description}</p>
|
||||
<p className="text-xs text-zinc-500 line-clamp-3">{item.description}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-auto pt-1">
|
||||
@@ -159,7 +161,7 @@ function ChannelCard({ item }) {
|
||||
<button
|
||||
onClick={() => followMut.mutate()}
|
||||
disabled={busy}
|
||||
className="w-full py-1.5 rounded-lg bg-accent text-black text-xs font-semibold hover:bg-yellow-300 transition-colors disabled:opacity-50"
|
||||
className="w-full py-1.5 rounded-lg bg-accent text-black text-xs font-semibold hover:bg-zinc-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
@@ -175,7 +177,7 @@ function Tab({ active, onClick, children, count }) {
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={[
|
||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px flex items-center gap-2",
|
||||
"px-3 py-1.5 text-xs font-medium border-b-2 transition-colors -mb-px flex items-center gap-1.5",
|
||||
active
|
||||
? "border-accent text-zinc-100"
|
||||
: "border-transparent text-zinc-500 hover:text-zinc-300",
|
||||
@@ -212,12 +214,21 @@ export default function DiscoveryPage() {
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
const { data: discStatus } = useQuery({
|
||||
queryKey: ["discovery-status"],
|
||||
queryFn: () => getDiscoveryStatus().then(r => r.data),
|
||||
staleTime: 10_000,
|
||||
refetchInterval: (query) => (query.state.data?.progress?.running ? 10_000 : 60_000),
|
||||
});
|
||||
|
||||
const refreshMut = useMutation({
|
||||
mutationFn: refreshDiscovery,
|
||||
onSuccess: () => setTimeout(() => {
|
||||
qc.invalidateQueries({ queryKey: ["discovery"] });
|
||||
qc.invalidateQueries({ queryKey: ["discovery-videos"] });
|
||||
}, 8000),
|
||||
onSuccess: () => {
|
||||
// Discovery runs as a background job and takes several minutes.
|
||||
// Invalidate status immediately so the "queued" state shows, then
|
||||
// re-check every 2 minutes until results land.
|
||||
qc.invalidateQueries({ queryKey: ["discovery-status"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDismissVideo = (video) => {
|
||||
@@ -236,12 +247,24 @@ export default function DiscoveryPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display font-bold text-2xl text-zinc-100">Discover</h1>
|
||||
{discStatus && (
|
||||
<p className="text-xs text-zinc-500 mt-0.5">
|
||||
{discStatus.pending_count > 0
|
||||
? `${discStatus.pending_count} channel${discStatus.pending_count !== 1 ? "s" : ""} queued`
|
||||
: "Queue empty"}
|
||||
{discStatus.last_run
|
||||
? ` · last refreshed ${new Date(discStatus.last_run + "Z").toLocaleDateString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}`
|
||||
: " · never refreshed"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refreshMut.mutate()}
|
||||
disabled={refreshMut.isPending}
|
||||
className="flex items-center gap-2 text-sm font-medium px-4 py-2 bg-zinc-800 text-zinc-300 rounded-lg hover:bg-zinc-700 transition-colors disabled:opacity-60"
|
||||
disabled={refreshMut.isPending || discStatus?.progress?.running}
|
||||
className="shrink-0 flex items-center gap-2 text-sm font-medium px-4 py-2 bg-zinc-800 text-zinc-300 rounded-lg hover:bg-zinc-700 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{refreshMut.isPending && (
|
||||
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
@@ -249,13 +272,13 @@ export default function DiscoveryPage() {
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
)}
|
||||
{refreshMut.isPending ? "Searching…" : "Find more"}
|
||||
{discStatus?.progress?.running ? "Running…" : refreshMut.isSuccess ? "Queued ✓" : refreshMut.isPending ? "Queueing…" : "Find more"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{refreshMut.isSuccess && !refreshMut.isPending && (
|
||||
<div className="px-4 py-3 rounded-xl bg-zinc-800/60 border border-zinc-700/50 text-sm text-zinc-300">
|
||||
Searching YouTube for new channels — results will appear in a few seconds.
|
||||
{refreshMut.isSuccess && !discStatus?.progress?.running && (
|
||||
<div className="px-4 py-3 rounded-xl bg-zinc-800/60 border border-zinc-700/50 text-sm text-zinc-400">
|
||||
Discovery is running — progress shows in the top bar. Searches are spaced out over ~20 minutes. Runs automatically every day.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -283,7 +306,7 @@ export default function DiscoveryPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-300 font-medium">Nothing here yet</p>
|
||||
<p className="text-zinc-500 text-sm">Nothing here yet</p>
|
||||
<p className="text-zinc-500 text-sm mt-1 max-w-xs">
|
||||
Follow a few channels first, then hit "Find more" to discover similar ones.
|
||||
</p>
|
||||
@@ -291,7 +314,7 @@ export default function DiscoveryPage() {
|
||||
<button
|
||||
onClick={() => refreshMut.mutate()}
|
||||
disabled={refreshMut.isPending}
|
||||
className="mt-2 px-6 py-2.5 rounded-xl bg-accent text-black font-semibold text-sm hover:bg-yellow-300 transition-colors disabled:opacity-60"
|
||||
className="mt-2 px-6 py-2.5 rounded-xl bg-accent text-black font-semibold text-sm hover:bg-zinc-100 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{refreshMut.isPending ? "Searching…" : "Find channels"}
|
||||
</button>
|
||||
@@ -305,17 +328,17 @@ export default function DiscoveryPage() {
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => { setChannelPage(p => p - 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
|
||||
onClick={() => { setChannelPage(p => p - 1); scrollToTop(); }}
|
||||
disabled={channelPage === 0}
|
||||
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span className="text-zinc-500 text-sm tabular-nums">Page {channelPage + 1}</span>
|
||||
<span className="text-zinc-500 text-xs tabular-nums">Page {channelPage + 1}</span>
|
||||
<button
|
||||
onClick={() => { setChannelPage(p => p + 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
|
||||
onClick={() => { setChannelPage(p => p + 1); scrollToTop(); }}
|
||||
disabled={!hasNextChannelPage}
|
||||
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
@@ -323,28 +346,29 @@ export default function DiscoveryPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{visibleVideos.map((v) => (
|
||||
<VideoCard
|
||||
key={v.youtube_video_id}
|
||||
video={{ ...v, is_recommended: true }}
|
||||
variant="list"
|
||||
onDismiss={handleDismissVideo}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => { setVideoPage(p => p - 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
|
||||
onClick={() => { setVideoPage(p => p - 1); scrollToTop(); }}
|
||||
disabled={videoPage === 0}
|
||||
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span className="text-zinc-500 text-sm tabular-nums">Page {videoPage + 1}</span>
|
||||
<span className="text-zinc-500 text-xs tabular-nums">Page {videoPage + 1}</span>
|
||||
<button
|
||||
onClick={() => { setVideoPage(p => p + 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
|
||||
onClick={() => { setVideoPage(p => p + 1); scrollToTop(); }}
|
||||
disabled={!hasNextVideoPage}
|
||||
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getDownloads, deleteDownload, deleteAllDownloads, restoreDownload } from "../api";
|
||||
import { getDownloads, deleteDownload, deleteAllDownloads, restoreDownload, getActiveTasks, generateNfoFiles } from "../api";
|
||||
import SortPicker from "../components/SortPicker";
|
||||
|
||||
const HISTORY_SORTS = [
|
||||
@@ -66,6 +66,12 @@ export default function DownloadsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: activeTasks = [] } = useQuery({
|
||||
queryKey: ["active-tasks"],
|
||||
queryFn: () => getActiveTasks().then((r) => r.data),
|
||||
refetchInterval: (query) => (query.state.data?.length > 0 ? 2000 : 5000),
|
||||
});
|
||||
|
||||
const clearAllMut = useMutation({
|
||||
mutationFn: deleteAllDownloads,
|
||||
onSuccess: () => {
|
||||
@@ -74,6 +80,12 @@ export default function DownloadsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const [nfoResult, setNfoResult] = useState(null);
|
||||
const nfoMut = useMutation({
|
||||
mutationFn: generateNfoFiles,
|
||||
onSuccess: (r) => setNfoResult(r.data.generated),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
@@ -95,9 +107,18 @@ export default function DownloadsPage() {
|
||||
const hasRemovable = history.length > 0 || trash.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-display font-bold text-2xl text-zinc-100">Downloads</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => { setNfoResult(null); nfoMut.mutate(); }}
|
||||
disabled={nfoMut.isPending}
|
||||
title="Generate Jellyfin .nfo sidecar files for all completed downloads"
|
||||
className="text-sm text-zinc-500 hover:text-zinc-300 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{nfoMut.isPending ? "Generating…" : nfoResult != null ? `Generated ${nfoResult} NFO` : "Generate NFO"}
|
||||
</button>
|
||||
{hasRemovable && (
|
||||
confirmClear ? (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -126,6 +147,30 @@ export default function DownloadsPage() {
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTasks.length > 0 && (
|
||||
<section>
|
||||
<h2 className="font-display font-semibold text-base text-zinc-400 mb-3">Background Tasks</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
{activeTasks.map((task) => {
|
||||
const pct = task.total > 0 ? (task.done / task.total) * 100 : 0;
|
||||
return (
|
||||
<div key={task.id} className="bg-zinc-900 rounded-xl p-4">
|
||||
<p className="text-sm font-medium text-zinc-100 mb-0.5">{task.label}</p>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-zinc-400">{task.phase || "Running…"}</span>
|
||||
{task.total > 0 && (
|
||||
<span className="text-xs text-zinc-400 tabular-nums">{task.done}/{task.total}</span>
|
||||
)}
|
||||
</div>
|
||||
<ProgressBar pct={pct} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{active.length > 0 && (
|
||||
<section>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getChannelGroups, createChannelGroup, deleteChannelGroup, renameChannelGroup,
|
||||
addChannelToGroup, removeChannelFromGroup,
|
||||
getSettings, bulkChannelAction, followBulk, updateChannelNotes,
|
||||
getRssFeedUrl,
|
||||
} from "../api";
|
||||
import VideoCard from "../components/VideoCard";
|
||||
import SortPicker from "../components/SortPicker";
|
||||
@@ -579,6 +580,8 @@ export default function Following() {
|
||||
const { data: channels = [], isLoading: loadingChannels } = useQuery({
|
||||
queryKey: ["channels"],
|
||||
queryFn: () => getChannels().then((r) => r.data),
|
||||
staleTime: 60_000,
|
||||
gcTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const { data: appSettings } = useQuery({
|
||||
@@ -610,7 +613,10 @@ export default function Following() {
|
||||
useEffect(() => {
|
||||
if (channels.length > 0) {
|
||||
markChannelsSeen().then(() => {
|
||||
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||
// Zero out new_count optimistically — avoids a full re-fetch just to clear badges
|
||||
qc.setQueryData(["channels"], (old) =>
|
||||
old ? old.map((c) => ({ ...c, new_count: 0 })) : old
|
||||
);
|
||||
});
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -704,7 +710,7 @@ export default function Following() {
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-300 font-medium">Not following anyone yet</p>
|
||||
<p className="text-zinc-500 text-sm">Not following anyone yet</p>
|
||||
<p className="text-zinc-500 text-sm mt-1">
|
||||
Hit Follow on a channel while watching a video or searching.
|
||||
</p>
|
||||
@@ -760,7 +766,7 @@ export default function Following() {
|
||||
<button
|
||||
onClick={() => dlAllMut.mutate()}
|
||||
disabled={dlAllMut.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-accent text-black text-sm font-semibold hover:bg-yellow-300 transition-colors disabled:opacity-60"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-accent text-black text-sm font-semibold hover:bg-zinc-100 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{dlAllMut.isPending ? <><Spinner /> Queuing…</> : "Download all new"}
|
||||
</button>
|
||||
@@ -775,13 +781,13 @@ export default function Following() {
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 border-b border-zinc-800">
|
||||
{[["channels", "Channels"], ["feed", "Latest uploads"], ["groups", "Groups"]].map(([key, label]) => (
|
||||
<div className="flex items-center gap-0.5 border-b border-zinc-800">
|
||||
{[["channels", "Channels"], ["feed", "Feed"], ["health", "Health"], ["groups", "Groups"]].map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={[
|
||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||
"px-3 py-1.5 text-xs font-medium border-b-2 transition-colors -mb-px",
|
||||
tab === key
|
||||
? "border-accent text-zinc-100"
|
||||
: "border-transparent text-zinc-500 hover:text-zinc-300",
|
||||
@@ -789,7 +795,7 @@ export default function Following() {
|
||||
>
|
||||
{label}
|
||||
{key === "groups" && groups.length > 0 && (
|
||||
<span className="ml-1.5 text-xs text-zinc-600">{groups.length}</span>
|
||||
<span className="ml-1 text-[10px] text-zinc-600">{groups.length}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
@@ -1001,8 +1007,8 @@ export default function Following() {
|
||||
<p className="text-zinc-500 text-sm">No videos indexed yet — hit Sync all to pull the latest from YouTube.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{sortedFeed.map((v) => <VideoCard key={v.youtube_video_id} video={v} />)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{sortedFeed.map((v) => <VideoCard key={v.youtube_video_id} video={v} variant="list" />)}
|
||||
</div>
|
||||
{hasMoreFeed && (
|
||||
<div className="flex justify-center mt-2">
|
||||
@@ -1017,6 +1023,65 @@ export default function Following() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Health tab ── */}
|
||||
{tab === "health" && (() => {
|
||||
const now = Date.now();
|
||||
const bucket = (ch) => {
|
||||
if (!ch.last_published_at) return "unknown";
|
||||
const days = (now - new Date(ch.last_published_at)) / 86400000;
|
||||
if (days < 30) return "active";
|
||||
if (days < 90) return "slow";
|
||||
if (days < 365) return "dormant";
|
||||
return "dead";
|
||||
};
|
||||
const buckets = {
|
||||
active: { label: "Active", sub: "uploaded in the last 30 days", color: "text-green-400", bg: "bg-green-900/20" },
|
||||
slow: { label: "Slow", sub: "uploaded in the last 90 days", color: "text-yellow-400", bg: "bg-yellow-900/20" },
|
||||
dormant: { label: "Dormant", sub: "no upload in 90–365 days", color: "text-orange-400", bg: "bg-orange-900/20" },
|
||||
dead: { label: "Dead", sub: "no upload in over a year", color: "text-red-400", bg: "bg-red-900/20" },
|
||||
unknown: { label: "Unknown", sub: "never indexed", color: "text-zinc-500", bg: "bg-zinc-800/40" },
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<a
|
||||
href={getRssFeedUrl()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="self-start flex items-center gap-1.5 text-xs text-zinc-500 hover:text-orange-400 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6.18 15.64a2.18 2.18 0 010 4.36 2.18 2.18 0 010-4.36M4 4.44A15.56 15.56 0 0119.56 20h-2.83A12.73 12.73 0 006.18 7.27V4.44M4 10.1a9.9 9.9 0 019.9 9.9h-2.83A7.07 7.07 0 006.18 12.93V10.1H4z"/>
|
||||
</svg>
|
||||
RSS feed
|
||||
</a>
|
||||
{Object.entries(buckets).map(([key, { label, sub, color, bg }]) => {
|
||||
const chs = channels.filter(ch => bucket(ch) === key);
|
||||
if (!chs.length) return null;
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="flex items-baseline gap-2 mb-2">
|
||||
<h2 className={`text-sm font-semibold ${color}`}>{label}</h2>
|
||||
<span className="text-xs text-zinc-600">{sub}</span>
|
||||
<span className="text-xs text-zinc-600 ml-auto">{chs.length} channel{chs.length !== 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
<div className={`rounded-xl ${bg} divide-y divide-zinc-800/40`}>
|
||||
{chs.map(ch => (
|
||||
<ChannelRow
|
||||
key={ch.id}
|
||||
channel={ch}
|
||||
groups={groups}
|
||||
onGroupToggle={handleGroupToggle}
|
||||
hideSubCount={hideSubCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* ── Groups tab ── */}
|
||||
{tab === "groups" && (
|
||||
<GroupsPanel groups={groups} channels={channels} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getHistory } from "../api";
|
||||
import VideoCard from "../components/VideoCard";
|
||||
import { scrollToTop } from "../utils/scroll";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -29,28 +30,28 @@ export default function History() {
|
||||
</div>
|
||||
) : videos.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-20 text-center">
|
||||
<p className="text-zinc-400 text-sm">No watch history yet. Start watching some videos!</p>
|
||||
<p className="text-zinc-500 text-sm">No watch history yet. Start watching some videos!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{videos.map((v) => (
|
||||
<VideoCard key={v.youtube_video_id} video={v} />
|
||||
<VideoCard key={v.youtube_video_id} video={v} variant="list" />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => { setPage(p => p - 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
|
||||
onClick={() => { setPage(p => p - 1); scrollToTop(); }}
|
||||
disabled={page === 0}
|
||||
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span className="text-zinc-500 text-sm tabular-nums">Page {page + 1}</span>
|
||||
<span className="text-zinc-500 text-xs tabular-nums">Page {page + 1}</span>
|
||||
<button
|
||||
onClick={() => { setPage(p => p + 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
|
||||
onClick={() => { setPage(p => p + 1); scrollToTop(); }}
|
||||
disabled={!hasNext}
|
||||
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
|
||||
@@ -2,12 +2,14 @@ import { useState, useMemo } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { homeFeed, surpriseMe, getSettings, updateSettings, getChannels, markChannelsSeen } from "../api";
|
||||
import VideoCard from "../components/VideoCard";
|
||||
import { scrollToTop } from "../utils/scroll";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const FEED_MODES = [
|
||||
{ value: "ranked", label: "For you", hint: "Ranked by your taste" },
|
||||
{ value: "chronological", label: "New", hint: "Everything in date order" },
|
||||
{ value: "rediscover", label: "Rediscover", hint: "Older unwatched videos ranked by your taste" },
|
||||
{ value: "random", label: "Explore", hint: "Random from discovery pool" },
|
||||
{ value: "inbox", label: "Inbox", hint: "New from followed channels since last visit" },
|
||||
];
|
||||
@@ -20,7 +22,7 @@ export default function Home() {
|
||||
const [dismissed, setDismissed] = useState(new Set());
|
||||
const [shuffleKey, setShuffleKey] = useState(0);
|
||||
const [duration, setDuration] = useState("");
|
||||
const [viewMode, setViewMode] = useState(() => localStorage.getItem("home-view-mode") ?? "grid");
|
||||
const [viewMode, setViewMode] = useState(() => localStorage.getItem("home-view-mode") ?? "list");
|
||||
|
||||
const toggleViewMode = () => {
|
||||
const next = viewMode === "grid" ? "list" : "grid";
|
||||
@@ -54,9 +56,9 @@ export default function Home() {
|
||||
};
|
||||
|
||||
const { data: feedData = [], isLoading: loadingFeed } = useQuery({
|
||||
queryKey: ["home-feed", mode, page, hideWatched, duration, mode === "random" ? shuffleKey : 0],
|
||||
queryKey: ["home-feed", mode, page, hideWatched, duration, shuffleKey],
|
||||
queryFn: () => homeFeed(page, PAGE_SIZE, mode, duration).then((r) => r.data),
|
||||
staleTime: 10 * 60_000,
|
||||
staleTime: mode === "ranked" ? 90_000 : 10 * 60_000,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
@@ -95,7 +97,7 @@ export default function Home() {
|
||||
setShuffleKey(k => k + 1);
|
||||
setPage(0);
|
||||
setDismissed(new Set());
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
scrollToTop();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -106,20 +108,21 @@ export default function Home() {
|
||||
</div>
|
||||
) : hasFollowing ? (
|
||||
<section className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<h2 className="font-display font-semibold text-xl text-zinc-200">Home</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Title row + secondary controls */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="font-display font-semibold text-base sm:text-xl text-zinc-200">Home</h2>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={toggleViewMode}
|
||||
title={viewMode === "grid" ? "Switch to list view" : "Switch to grid view"}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors border border-zinc-800"
|
||||
className="flex items-center justify-center w-7 h-7 rounded-md text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
{viewMode === "grid" ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
)}
|
||||
@@ -128,48 +131,39 @@ export default function Home() {
|
||||
onClick={handleHideWatchedToggle}
|
||||
title={hideWatched ? "Showing unwatched only" : "Showing all videos"}
|
||||
className={[
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors border",
|
||||
hideWatched
|
||||
? "bg-accent/10 text-accent border-accent/30"
|
||||
: "text-zinc-500 border-zinc-800 hover:text-zinc-300 hover:border-zinc-700",
|
||||
"flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium transition-colors",
|
||||
hideWatched ? "text-accent" : "text-zinc-600 hover:text-zinc-400",
|
||||
].join(" ")}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{hideWatched ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
)}
|
||||
</svg>
|
||||
{hideWatched ? "Unwatched" : "All"}
|
||||
</button>
|
||||
<div className="flex items-center gap-1 bg-zinc-900 rounded-xl p-1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode switcher — own row, full width on mobile */}
|
||||
<div className="flex items-center gap-0.5 bg-zinc-900 rounded-lg p-0.5 self-start">
|
||||
{FEED_MODES.map(m => (
|
||||
<button
|
||||
key={m.value}
|
||||
onClick={() => handleModeChange(m.value)}
|
||||
title={m.hint}
|
||||
className={[
|
||||
"relative px-3 py-1.5 rounded-lg text-sm font-medium transition-colors",
|
||||
mode === m.value
|
||||
? "bg-zinc-700 text-zinc-100"
|
||||
: "text-zinc-500 hover:text-zinc-300",
|
||||
"relative px-2.5 py-1 rounded-md text-xs font-medium transition-colors",
|
||||
mode === m.value ? "bg-zinc-700 text-zinc-100" : "text-zinc-500 hover:text-zinc-300",
|
||||
].join(" ")}
|
||||
>
|
||||
{m.label}
|
||||
{m.value === "inbox" && inboxCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 bg-accent text-black text-[10px] font-bold rounded-full flex items-center justify-center px-1 leading-none">
|
||||
<span className="absolute -top-1 -right-1 min-w-[13px] h-3 bg-zinc-200 text-zinc-900 text-[8px] font-bold rounded-full flex items-center justify-center px-0.5 leading-none">
|
||||
{inboxCount > 99 ? "99+" : inboxCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration filter */}
|
||||
<div className="flex items-center gap-1.5 -mt-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{[["short", "< 10 min"], ["medium", "10–30 min"], ["long", "30+ min"]].map(([val, label]) => (
|
||||
<button
|
||||
key={val}
|
||||
@@ -187,7 +181,7 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
{mode === "inbox" && (
|
||||
<div className="flex items-center justify-between -mt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-zinc-600">Unwatched videos from followed channels since your last visit.</p>
|
||||
<button
|
||||
onClick={() => markSeenMut.mutate()}
|
||||
@@ -199,11 +193,15 @@ export default function Home() {
|
||||
</div>
|
||||
)}
|
||||
{mode === "chronological" && (
|
||||
<p className="text-xs text-zinc-600 -mt-3">All videos from channels you follow, newest first.</p>
|
||||
<p className="text-xs text-zinc-600">All videos from channels you follow, newest first.</p>
|
||||
)}
|
||||
{mode === "random" && (
|
||||
<div className="flex items-center justify-between -mt-3">
|
||||
<p className="text-xs text-zinc-600">Random from your discovery pool — no weighting, no ranking.</p>
|
||||
{(mode === "ranked" || mode === "random") && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-zinc-600">
|
||||
{mode === "ranked"
|
||||
? "Ranked by your taste — reshuffles show a fresh mix."
|
||||
: "Random from your discovery pool — no weighting, no ranking."}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleReshuffle}
|
||||
className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors shrink-0 ml-4"
|
||||
@@ -235,17 +233,17 @@ export default function Home() {
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => { setPage(p => p - 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
|
||||
onClick={() => { setPage(p => p - 1); scrollToTop(); }}
|
||||
disabled={page === 0}
|
||||
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span className="text-zinc-500 text-sm tabular-nums">Page {page + 1}</span>
|
||||
<span className="text-zinc-500 text-xs tabular-nums">Page {page + 1}</span>
|
||||
<button
|
||||
onClick={() => { setPage(p => p + 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
|
||||
onClick={() => { setPage(p => p + 1); scrollToTop(); }}
|
||||
disabled={!hasNextPage}
|
||||
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
@@ -259,7 +257,7 @@ export default function Home() {
|
||||
<button
|
||||
onClick={() => surpriseMut.mutate()}
|
||||
disabled={surpriseMut.isPending}
|
||||
className="bg-accent text-black font-display font-bold text-lg px-8 py-4 rounded-2xl hover:scale-105 active:scale-95 transition-all disabled:opacity-60 shadow-lg shadow-accent/20"
|
||||
className="bg-accent text-black font-display font-bold text-lg px-8 py-4 rounded-2xl hover:scale-105 active:scale-95 transition-all disabled:opacity-60 shadow-lg"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-2xl">✦</span>
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function LikedPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="font-display font-bold text-2xl text-zinc-100">Liked videos</h1>
|
||||
@@ -54,7 +54,7 @@ export default function LikedPage() {
|
||||
<button
|
||||
onClick={() => refreshMut.mutate()}
|
||||
disabled={refreshMut.isPending || refreshMut.isSuccess}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 transition-colors disabled:opacity-60"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{refreshMut.isPending ? (
|
||||
<>
|
||||
@@ -82,15 +82,15 @@ export default function LikedPage() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
<p className="text-zinc-400 font-medium">No liked videos yet</p>
|
||||
<p className="text-zinc-500 text-sm">No liked videos yet</p>
|
||||
<p className="text-zinc-600 text-sm max-w-xs">
|
||||
Hit the heart on any video. Liked videos teach the discovery engine what you enjoy.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{sortLiked(videos, sort).map((v) => (
|
||||
<VideoCard key={v.youtube_video_id} video={v} />
|
||||
<VideoCard key={v.youtube_video_id} video={v} variant="list" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function QueuePage() {
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-300 font-medium">Queue is empty</p>
|
||||
<p className="text-zinc-500 text-sm">Queue is empty</p>
|
||||
<p className="text-zinc-500 text-sm mt-1">
|
||||
Hit the queue icon on any video to save it for later.
|
||||
</p>
|
||||
@@ -36,11 +36,12 @@ export default function QueuePage() {
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-zinc-500 -mt-2">{videos.length} video{videos.length !== 1 ? "s" : ""} saved</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{videos.map((v) => (
|
||||
<VideoCard
|
||||
key={v.youtube_video_id}
|
||||
video={v}
|
||||
variant="list"
|
||||
onRemoveFromQueue={() => {
|
||||
toggleQueue(v.id).then(() => qc.invalidateQueries({ queryKey: ["queue"] }));
|
||||
}}
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function SearchResults() {
|
||||
const channelsFirst = channels.length > 0 && (videos.length === 0 || source === "local");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="font-display font-semibold text-xl text-zinc-100">
|
||||
Results for <span className="text-accent">"{q}"</span>
|
||||
@@ -108,9 +108,9 @@ export default function SearchResults() {
|
||||
{hasMore ? `${visibleCount} of ${videos.length}` : videos.length}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{visibleVideos.map((v) => (
|
||||
<VideoCard key={v.youtube_video_id} video={v} />
|
||||
<VideoCard key={v.youtube_video_id} video={v} variant="list" />
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
|
||||
@@ -339,68 +339,113 @@ function DiagnosticSection() {
|
||||
}
|
||||
|
||||
function SubscriptionImportSection() {
|
||||
const [status, setStatus] = useState(null); // null | { imported, skipped, errors }
|
||||
const [status, setStatus] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pasteText, setPasteText] = useState("");
|
||||
const [showPaste, setShowPaste] = useState(false);
|
||||
const fileRef = useRef(null);
|
||||
|
||||
const handleFile = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const doImport = async (handles) => {
|
||||
if (!handles.length) {
|
||||
setStatus({ error: "No channel handles found." });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||
// Skip header row (Kanal-id or Channel ID or similar)
|
||||
const dataLines = lines.slice(1);
|
||||
// First column is the channel ID (UCxxxxxxx)
|
||||
const ids = dataLines
|
||||
.map((l) => l.split(",")[0]?.replace(/^"|"$/g, "").trim())
|
||||
.filter((id) => id?.startsWith("UC"));
|
||||
if (!ids.length) {
|
||||
setStatus({ error: "No channel IDs found. Expected UC... IDs in the first column." });
|
||||
return;
|
||||
}
|
||||
const res = await followBulk(ids);
|
||||
const res = await followBulk(handles);
|
||||
setStatus(res.data);
|
||||
} catch (err) {
|
||||
setStatus({ error: err.response?.data?.detail || err.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleFile = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
const text = await file.text();
|
||||
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||
const dataLines = lines.slice(1);
|
||||
const ids = dataLines
|
||||
.map((l) => l.split(",")[0]?.replace(/^"|"$/g, "").trim())
|
||||
.filter((id) => id?.startsWith("UC"));
|
||||
await doImport(ids);
|
||||
};
|
||||
|
||||
const handlePaste = async () => {
|
||||
// Extract @handles from YouTube subscription page text — lines like "@handle•N subscribers"
|
||||
const handles = [...new Set(
|
||||
(pasteText.match(/@[\w.-]+(?=•)/g) || [])
|
||||
)];
|
||||
await doImport(handles);
|
||||
setPasteText("");
|
||||
setShowPaste(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="Subscriptions">
|
||||
<div className="px-5 py-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="px-5 py-4 flex flex-col gap-4">
|
||||
{/* CSV import row */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-200">Import from Google Takeout</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">
|
||||
Upload the <span className="font-mono">subscriptions.csv</span> from a YouTube Google Takeout export.
|
||||
Channels already followed are skipped.
|
||||
</p>
|
||||
<p className="text-sm font-medium text-zinc-200">Import from Google Takeout CSV</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Upload the <span className="font-mono">subscriptions.csv</span> file.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={loading}
|
||||
className="shrink-0 px-4 py-2 rounded-lg bg-accent text-black text-sm font-medium hover:bg-yellow-300 transition-colors disabled:opacity-50"
|
||||
className="shrink-0 px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm hover:bg-zinc-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Importing…" : "Import CSV"}
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleFile} />
|
||||
</div>
|
||||
|
||||
{/* Paste from YouTube row */}
|
||||
<div className="flex flex-col gap-2 border-t border-zinc-800 pt-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-200">Paste from YouTube</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Copy your subscriptions list from YouTube and paste it here — handles starting with @ are extracted automatically.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPaste((v) => !v)}
|
||||
className="shrink-0 px-4 py-2 rounded-lg bg-accent text-black text-sm font-medium hover:bg-zinc-100 transition-colors"
|
||||
>
|
||||
{showPaste ? "Cancel" : "Paste list"}
|
||||
</button>
|
||||
</div>
|
||||
{showPaste && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
value={pasteText}
|
||||
onChange={(e) => setPasteText(e.target.value)}
|
||||
placeholder="Paste your YouTube subscription list here…"
|
||||
rows={6}
|
||||
className="w-full bg-zinc-950 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-zinc-300 font-mono resize-none focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
onClick={handlePaste}
|
||||
disabled={loading || !pasteText.trim()}
|
||||
className="self-end px-4 py-2 rounded-lg bg-accent text-black text-sm font-medium hover:bg-zinc-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Importing…" : `Import ${(pasteText.match(/@[\w.-]+(?=•)/g) || []).length} channels`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status && !status.error && (
|
||||
<p className="text-sm text-zinc-300">
|
||||
Followed <span className="text-accent font-semibold">{status.followed ?? 0}</span> new channels
|
||||
{status.already_following > 0 && <span className="text-zinc-500"> · {status.already_following} already following</span>}
|
||||
{status.new_channels > 0 && <span className="text-zinc-500"> · {status.new_channels} stubs created (index to fetch metadata)</span>}
|
||||
{status.new_channels > 0 && <span className="text-zinc-500"> · {status.new_channels} stubs created</span>}
|
||||
</p>
|
||||
)}
|
||||
{status?.error && (
|
||||
<p className="text-sm text-red-400">{status.error}</p>
|
||||
)}
|
||||
{status?.error && <p className="text-sm text-red-400">{status.error}</p>}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
@@ -555,6 +600,62 @@ function OAuth2Section({ s, qc }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationsSection() {
|
||||
const [permission, setPermission] = useState(() =>
|
||||
"Notification" in window ? Notification.permission : "unsupported"
|
||||
);
|
||||
const [enabled, setEnabled] = useState(() => localStorage.getItem("notifications_enabled") === "true");
|
||||
|
||||
const requestPermission = async () => {
|
||||
if (!("Notification" in window)) return;
|
||||
const result = await Notification.requestPermission();
|
||||
setPermission(result);
|
||||
if (result === "granted") {
|
||||
setEnabled(true);
|
||||
localStorage.setItem("notifications_enabled", "true");
|
||||
new Notification("YTContinue notifications on", {
|
||||
body: "You'll be notified when new videos arrive from channels you follow.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
if (permission !== "granted") {
|
||||
requestPermission();
|
||||
return;
|
||||
}
|
||||
const next = !enabled;
|
||||
setEnabled(next);
|
||||
localStorage.setItem("notifications_enabled", next ? "true" : "false");
|
||||
};
|
||||
|
||||
if (permission === "unsupported") return null;
|
||||
|
||||
return (
|
||||
<Section title="Notifications">
|
||||
<Row
|
||||
label="New video alerts"
|
||||
hint={
|
||||
permission === "denied"
|
||||
? "Blocked by browser — allow notifications for this site in browser settings"
|
||||
: "Get a browser notification when followed channels upload new videos"
|
||||
}
|
||||
>
|
||||
{permission === "denied" ? (
|
||||
<span className="text-xs text-zinc-600">Blocked</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={toggle}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled && permission === "granted" ? "bg-accent" : "bg-zinc-700"}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled && permission === "granted" ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</button>
|
||||
)}
|
||||
</Row>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
@@ -605,6 +706,25 @@ export default function SettingsPage() {
|
||||
<DiagnosticSection />
|
||||
<SubscriptionImportSection />
|
||||
|
||||
{/* Sync */}
|
||||
<Section title="Sync">
|
||||
<Row
|
||||
label="Auto-sync interval"
|
||||
hint="How often to automatically sync your followed channels in the background."
|
||||
>
|
||||
<select
|
||||
value={s?.sync_interval_hours ?? 0}
|
||||
onChange={(e) => set({ sync_interval_hours: Number(e.target.value) })}
|
||||
className="bg-zinc-800 text-zinc-200 text-sm rounded-lg px-3 py-1.5 border border-zinc-700 focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value={0}>Off</option>
|
||||
<option value={6}>Every 6 hours</option>
|
||||
<option value={12}>Every 12 hours</option>
|
||||
<option value={24}>Every 24 hours</option>
|
||||
</select>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* Download quality */}
|
||||
<Section title="Download quality">
|
||||
<Row
|
||||
@@ -646,6 +766,18 @@ export default function SettingsPage() {
|
||||
onChange={(v) => set({ auto_download_on_sync: v })}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
label="Subtitle languages"
|
||||
hint={'Download subtitles for these languages. e.g. "en" or "en,sv". Leave blank to skip.'}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={s?.subtitle_langs ?? ""}
|
||||
onChange={(e) => set({ subtitle_langs: e.target.value })}
|
||||
placeholder="en, sv, …"
|
||||
className="bg-zinc-800 text-zinc-200 text-sm rounded-lg px-3 py-1.5 border border-zinc-700 focus:outline-none focus:border-accent w-36"
|
||||
/>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* Feed */}
|
||||
@@ -747,6 +879,9 @@ export default function SettingsPage() {
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* Notifications */}
|
||||
<NotificationsSection />
|
||||
|
||||
{/* Data */}
|
||||
<Section title="Data">
|
||||
<div className="px-5 py-4 flex items-center justify-between">
|
||||
|
||||
@@ -11,6 +11,14 @@ function fmt(seconds) {
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
function fmtBytes(bytes) {
|
||||
if (!bytes) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let i = 0, v = bytes;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub }) {
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-2xl p-4 flex flex-col gap-1">
|
||||
@@ -54,23 +62,22 @@ export default function Stats() {
|
||||
const maxDayCount = Math.max(...days.map(d => dailyMap[d]?.count || 0), 1);
|
||||
|
||||
const maxCatCount = Math.max(...(data.top_categories || []).map(c => c.watch_count || 0), 1);
|
||||
const topTags = (data.taste_profile || []).slice(0, 12);
|
||||
const maxTagScore = Math.max(...topTags.map(t => t.score || 0), 1);
|
||||
const allTags = data.taste_profile || [];
|
||||
const maxTagScore = Math.max(...allTags.map(t => t.score || 0), 1);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 max-w-4xl mx-auto">
|
||||
<h1 className="font-display font-bold text-2xl text-white">Stats</h1>
|
||||
|
||||
{/* Top numbers */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<StatCard label="Total watched" value={data.total_watched.toLocaleString()} sub={fmt(data.total_watch_seconds) + " total"} />
|
||||
<StatCard label="This week" value={data.this_week.count} sub={fmt(data.this_week.seconds)} />
|
||||
<StatCard label="This month" value={data.this_month.count} sub={fmt(data.this_month.seconds)} />
|
||||
<StatCard label="Total liked" value={(data.total_liked || 0).toLocaleString()} sub="videos" />
|
||||
</div>
|
||||
|
||||
{/* Engagement row */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<StatCard
|
||||
label="Avg completion"
|
||||
value={`${data.avg_completion_percent ?? 0}%`}
|
||||
@@ -79,7 +86,12 @@ export default function Stats() {
|
||||
<StatCard
|
||||
label="Finished"
|
||||
value={(data.finished_count || 0).toLocaleString()}
|
||||
sub="watched ≥90%"
|
||||
sub="watched ≥75%"
|
||||
/>
|
||||
<StatCard
|
||||
label="In progress"
|
||||
value={(data.started_count || 0).toLocaleString()}
|
||||
sub="started, not finished"
|
||||
/>
|
||||
<StatCard
|
||||
label="Bailed early"
|
||||
@@ -91,6 +103,11 @@ export default function Stats() {
|
||||
value={(data.rewatched_videos || 0).toLocaleString()}
|
||||
sub={`${data.total_rewatches || 0} total rewatches`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total liked"
|
||||
value={(data.total_liked || 0).toLocaleString()}
|
||||
sub="videos"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Activity chart */}
|
||||
@@ -170,41 +187,133 @@ export default function Stats() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Taste profile */}
|
||||
{topTags.length > 0 && (
|
||||
{/* Disk usage */}
|
||||
{data.disk?.total_bytes && (
|
||||
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
|
||||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Disk usage</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-400">Downloads</span>
|
||||
<span className="text-zinc-300 font-mono">{fmtBytes(data.disk.download_bytes)}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent/70 rounded-full transition-all"
|
||||
style={{ width: `${Math.min((data.disk.used_bytes / data.disk.total_bytes) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px] text-zinc-600">
|
||||
<span>{fmtBytes(data.disk.used_bytes)} used</span>
|
||||
<span>{fmtBytes(data.disk.free_bytes)} free · {fmtBytes(data.disk.total_bytes)} total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Peak hours */}
|
||||
{data.peak_hours?.length > 0 && (() => {
|
||||
const hourMap = Object.fromEntries(data.peak_hours.map(h => [h.hour, h.count]));
|
||||
const maxCount = Math.max(...data.peak_hours.map(h => h.count), 1);
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
|
||||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Peak watching hours</h2>
|
||||
<div className="flex items-end gap-0.5 h-16">
|
||||
{hours.map(h => {
|
||||
const count = hourMap[h] || 0;
|
||||
const pct = count / maxCount;
|
||||
const label = h === 0 ? "12a" : h < 12 ? `${h}a` : h === 12 ? "12p" : `${h - 12}p`;
|
||||
return (
|
||||
<div key={h} className="flex-1 flex flex-col items-center gap-0.5" title={`${label}: ${count} video${count !== 1 ? "s" : ""}`}>
|
||||
<div
|
||||
className="w-full rounded-sm transition-all"
|
||||
style={{
|
||||
height: count === 0 ? "2px" : `${Math.max(pct * 100, 6)}%`,
|
||||
backgroundColor: count === 0 ? "#27272a" : `hsl(50,95%,${30 + pct * 30}%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-zinc-600">
|
||||
<span>12am</span><span>6am</span><span>12pm</span><span>6pm</span><span>11pm</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Taste profile */}
|
||||
{allTags.length > 0 && (
|
||||
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-4">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Taste profile</h2>
|
||||
<p className="text-[11px] text-zinc-600">built from your watches, likes and bookmarks</p>
|
||||
<p className="text-[11px] text-zinc-600">{allTags.length} interests · built from watches, likes & bookmarks</p>
|
||||
</div>
|
||||
|
||||
{/* Top tier tag cloud */}
|
||||
<div>
|
||||
<p className="text-[10px] text-zinc-600 uppercase tracking-wider mb-2">Top interests</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{topTags.map(t => {
|
||||
{allTags.slice(0, 10).map(t => {
|
||||
const intensity = t.score / maxTagScore;
|
||||
return (
|
||||
<span
|
||||
key={t.tag}
|
||||
title={`score: ${t.score.toFixed(1)}`}
|
||||
className="flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium"
|
||||
className="flex items-center gap-1 px-3 py-1 rounded-full font-semibold"
|
||||
style={{
|
||||
backgroundColor: `rgba(250,204,21,${0.08 + intensity * 0.18})`,
|
||||
backgroundColor: `rgba(250,204,21,${0.1 + intensity * 0.2})`,
|
||||
color: `hsl(50,95%,${55 + intensity * 20}%)`,
|
||||
fontSize: `${11 + intensity * 4}px`,
|
||||
fontSize: `${12 + intensity * 5}px`,
|
||||
}}
|
||||
>
|
||||
{t.tag}
|
||||
<button
|
||||
onClick={() => deleteTag.mutate(t.tag)}
|
||||
disabled={deleteTag.isPending}
|
||||
className="opacity-40 hover:opacity-100 transition-opacity leading-none ml-0.5"
|
||||
title="Remove from taste profile"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
className="opacity-30 hover:opacity-100 transition-opacity leading-none ml-0.5"
|
||||
title="Remove"
|
||||
>×</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All tags as score bars */}
|
||||
{allTags.length > 10 && (
|
||||
<div>
|
||||
<p className="text-[10px] text-zinc-600 uppercase tracking-wider mb-3">All interests</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2">
|
||||
{allTags.map(t => {
|
||||
const pct = (t.score / maxTagScore) * 100;
|
||||
const intensity = t.score / maxTagScore;
|
||||
return (
|
||||
<div key={t.tag} className="flex items-center gap-2 group/tag">
|
||||
<span className="text-[12px] text-zinc-300 w-32 shrink-0 truncate">{t.tag}</span>
|
||||
<div className="flex-1 h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor: `hsl(50,95%,${40 + intensity * 25}%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteTag.mutate(t.tag)}
|
||||
disabled={deleteTag.isPending}
|
||||
className="opacity-0 group-hover/tag:opacity-40 hover:!opacity-100 transition-opacity text-zinc-400 text-xs leading-none shrink-0"
|
||||
title="Remove"
|
||||
>×</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
getSettings, updateSettings, getRelatedVideos, getDownloads, rateVideo,
|
||||
getBookmarks, createBookmark, updateBookmark, deleteBookmark, importChapters, clearChapters,
|
||||
getCollections, addToCollection, getQueue,
|
||||
getVideoComments, refreshVideoComments,
|
||||
getVideoComments, refreshVideoComments, getAvailableSubs, getSubtitleFiles, downloadSubs,
|
||||
} from "../api";
|
||||
import VideoCard from "../components/VideoCard";
|
||||
|
||||
@@ -66,15 +66,15 @@ function linkify(text) {
|
||||
function DescriptionBox({ text }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const lines = text.split("\n");
|
||||
const hasMore = lines.length > 4 || text.length > 300;
|
||||
const displayed = expanded ? text : lines.slice(0, 4).join("\n") + (hasMore ? "…" : "");
|
||||
const hasMore = lines.length > 2 || text.length > 200;
|
||||
const displayed = expanded ? text : lines.slice(0, 2).join("\n") + (hasMore ? "…" : "");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-zinc-900 rounded-xl p-4 cursor-pointer select-none"
|
||||
className="bg-zinc-900 rounded-xl p-3 sm:p-4 cursor-pointer select-none"
|
||||
onClick={() => hasMore && setExpanded(v => !v)}
|
||||
>
|
||||
<p className="text-sm text-zinc-300 whitespace-pre-line leading-relaxed">
|
||||
<p className="text-[13px] text-zinc-300 whitespace-pre-line leading-relaxed">
|
||||
{linkify(displayed)}
|
||||
</p>
|
||||
{hasMore && (
|
||||
@@ -94,7 +94,7 @@ function Chip({ onClick, active, disabled, children }) {
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={[
|
||||
"flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium transition-colors",
|
||||
"flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
active ? "bg-zinc-100 text-zinc-900 hover:bg-white" : "bg-zinc-800 text-zinc-300 hover:bg-zinc-700",
|
||||
disabled && "opacity-40 cursor-not-allowed",
|
||||
].filter(Boolean).join(" ")}
|
||||
@@ -230,7 +230,7 @@ function Placeholder({ video, dlStatus, onPlay, onDownloadAndPlay, isDownloading
|
||||
) : onDownloadAndPlay ? (
|
||||
<button
|
||||
onClick={onDownloadAndPlay}
|
||||
className="flex items-center gap-2 px-6 py-3 rounded-full bg-accent text-black font-bold text-sm hover:bg-yellow-300 transition-colors"
|
||||
className="flex items-center gap-2 px-6 py-3 rounded-full bg-accent text-black font-bold text-sm hover:bg-zinc-100 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
@@ -561,8 +561,10 @@ export default function Watch() {
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [queued, setQueued] = useState(null);
|
||||
const [liked, setLiked] = useState(null);
|
||||
const [rating, setRating] = useState(null);
|
||||
const [disliked, setDisliked] = useState(null);
|
||||
const [isRedownloading, setIsRedownloading] = useState(false);
|
||||
const [selectedQuality, setSelectedQuality] = useState(null);
|
||||
const [selectedSubLang, setSelectedSubLang] = useState("");
|
||||
const [speed, setSpeed] = useState(1);
|
||||
const [autoplay, setAutoplay] = useState(false);
|
||||
const [theater, setTheater] = useState(false);
|
||||
@@ -658,6 +660,21 @@ export default function Watch() {
|
||||
staleTime: sidebarMode === "random" ? 0 : 5 * 60_000,
|
||||
});
|
||||
|
||||
const [subsRequested, setSubsRequested] = useState(false);
|
||||
useEffect(() => { setSubsRequested(false); setSelectedSubLang(""); }, [youtubeVideoId]);
|
||||
const { data: availableSubs, isLoading: subsLoading } = useQuery({
|
||||
queryKey: ["available-subs", youtubeVideoId],
|
||||
queryFn: () => getAvailableSubs(youtubeVideoId).then(r => r.data),
|
||||
enabled: subsRequested && !!youtubeVideoId,
|
||||
staleTime: 30 * 60_000,
|
||||
});
|
||||
const { data: subtitleFiles = [] } = useQuery({
|
||||
queryKey: ["subtitle-files", youtubeVideoId],
|
||||
queryFn: () => getSubtitleFiles(youtubeVideoId).then(r => r.data),
|
||||
enabled: !!youtubeVideoId,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { data: dlStatus } = useQuery({
|
||||
queryKey: ["download-status", downloadId],
|
||||
queryFn: () => getDownload(downloadId).then(r => r.data),
|
||||
@@ -697,33 +714,47 @@ export default function Watch() {
|
||||
.catch(() => { pollTimerRef.current = setTimeout(() => pollForFile(url), 1500); });
|
||||
}, [fileReady]);
|
||||
|
||||
// Only poll once the backend confirms the download is fully written.
|
||||
// Polling before status==="complete" risks playing a partial file.
|
||||
useEffect(() => {
|
||||
if (!dlStatus?.file_url || fileReady || !playRequested) return;
|
||||
pollForFile(dlStatus.file_url);
|
||||
if (fileReady || !playRequested) return;
|
||||
const backendDone = dlStatus?.status === "complete" || !!video?.is_downloaded;
|
||||
const fileUrl = dlStatus?.file_url ?? (video?.is_downloaded ? `/files/${youtubeVideoId}.mp4` : null);
|
||||
if (!backendDone || !fileUrl) return;
|
||||
pollForFile(fileUrl);
|
||||
return () => clearTimeout(pollTimerRef.current);
|
||||
}, [dlStatus?.file_url, fileReady, pollForFile, playRequested]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!video?.is_downloaded || fileReady || !playRequested) return;
|
||||
pollForFile(`/files/${youtubeVideoId}.mp4`);
|
||||
}, [video?.is_downloaded, playRequested]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [playRequested, fileReady, dlStatus?.status, dlStatus?.file_url, video?.is_downloaded, youtubeVideoId, pollForFile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const downloadMut = useMutation({
|
||||
mutationFn: () => createDownload(youtubeVideoId, selectedQuality),
|
||||
mutationFn: ({ quality, subLang } = {}) =>
|
||||
createDownload(youtubeVideoId, quality ?? selectedQuality, subLang ?? selectedSubLang),
|
||||
onSuccess: (res) => {
|
||||
const dl = res.data;
|
||||
setDownloadId(dl.id);
|
||||
setDownloadId(res.data.id);
|
||||
refetchVideo();
|
||||
if (dl.status === "complete" && dl.file_url) pollForFile(dl.file_url);
|
||||
},
|
||||
});
|
||||
|
||||
const handlePlay = useCallback(() => setPlayRequested(true), []);
|
||||
const handleDownloadAndPlay = useCallback(() => {
|
||||
setPlayRequested(true);
|
||||
downloadMut.mutate();
|
||||
pollForFile(`/files/${youtubeVideoId}.mp4`);
|
||||
}, [downloadMut, pollForFile, youtubeVideoId]);
|
||||
downloadMut.mutate({});
|
||||
}, [downloadMut]);
|
||||
const handleRedownload = useCallback(async (quality) => {
|
||||
const dlId = downloadId ?? allDownloads.find(
|
||||
d => d.youtube_video_id === youtubeVideoId && d.status === "complete"
|
||||
)?.id;
|
||||
if (!dlId) return;
|
||||
setIsRedownloading(true);
|
||||
try { await deleteDownload(dlId); } catch (_) {}
|
||||
setFileReady(false);
|
||||
setConfirmedFileUrl(null);
|
||||
setDownloadId(null);
|
||||
setPlayRequested(false);
|
||||
setIsRedownloading(false);
|
||||
qc.invalidateQueries({ queryKey: ["downloads"] });
|
||||
refetchVideo();
|
||||
downloadMut.mutate({ quality });
|
||||
}, [downloadId, allDownloads, youtubeVideoId, downloadMut, refetchVideo, qc]);
|
||||
|
||||
const saveProgress = useCallback((secs) => {
|
||||
if (!video?.id) return;
|
||||
@@ -773,9 +804,18 @@ export default function Watch() {
|
||||
onSuccess: (res) => { setLiked(res.data.liked); qc.invalidateQueries({ queryKey: ["liked-videos"] }); },
|
||||
});
|
||||
|
||||
const rateMut = useMutation({
|
||||
mutationFn: (r) => rateVideo(video.id, r),
|
||||
onSuccess: (res) => setRating(res.data.rating),
|
||||
const dislikeMut = useMutation({
|
||||
mutationFn: () => rateVideo(video.id, isDisliked ? 0 : -1),
|
||||
onSuccess: (res) => setDisliked(res.data.rating === -1),
|
||||
});
|
||||
|
||||
const addSubsMut = useMutation({
|
||||
mutationFn: () => downloadSubs(youtubeVideoId, selectedSubLang),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["subtitle-files", youtubeVideoId] });
|
||||
setSubsRequested(false);
|
||||
setSelectedSubLang("");
|
||||
},
|
||||
});
|
||||
|
||||
const handlePiP = useCallback(async () => {
|
||||
@@ -793,8 +833,9 @@ export default function Watch() {
|
||||
const startAt = video?.watch_progress_seconds ?? 0;
|
||||
const isQueued = queued ?? video?.queued ?? false;
|
||||
const isLiked = liked ?? video?.liked ?? false;
|
||||
const currentRating = rating ?? video?.rating ?? null;
|
||||
const isDisliked = disliked ?? (video?.rating === -1) ?? false;
|
||||
const dlComplete = dlStatus?.status === "complete" || video?.is_downloaded;
|
||||
const downloadedResolution = dlStatus?.resolution ?? video?.download_resolution;
|
||||
const isFollowed = followMut.isSuccess || video?.channel_followed;
|
||||
const subs = formatSubs(channel?.subscriber_count);
|
||||
|
||||
@@ -844,7 +885,7 @@ export default function Watch() {
|
||||
<div className={theater ? "flex flex-col gap-6" : "flex flex-col xl:flex-row gap-6"}>
|
||||
|
||||
{/* ── Left: video + info ───────────────────────────────────────────── */}
|
||||
<div className={theater ? "w-full flex flex-col gap-4" : "flex-1 min-w-0 flex flex-col gap-4"}>
|
||||
<div className={theater ? "w-full flex flex-col gap-4 sm:gap-5" : "flex-1 min-w-0 flex flex-col gap-4 sm:gap-5"}>
|
||||
|
||||
{/* Player */}
|
||||
<div className={theater ? "w-full aspect-video bg-black overflow-hidden shadow-2xl" : "w-full aspect-video bg-black rounded-2xl overflow-hidden shadow-2xl"}>
|
||||
@@ -864,7 +905,17 @@ export default function Watch() {
|
||||
if (video?.watch_progress_seconds > 10) v.currentTime = video.watch_progress_seconds;
|
||||
v.play().catch(() => {});
|
||||
}}
|
||||
>
|
||||
{subtitleFiles.map((s) => (
|
||||
<track
|
||||
key={s.lang}
|
||||
kind="subtitles"
|
||||
src={s.url}
|
||||
srcLang={s.lang}
|
||||
label={s.lang}
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
) : (
|
||||
<Placeholder
|
||||
video={video}
|
||||
@@ -887,11 +938,11 @@ export default function Watch() {
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-xl font-bold text-white leading-snug">{title}</h1>
|
||||
<h1 className="text-base sm:text-xl font-bold text-white leading-snug">{title}</h1>
|
||||
|
||||
{/* Meta + actions row */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500 flex-wrap">
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-500 flex-wrap">
|
||||
{date && <span>{date}</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", "")} likes</span></>}
|
||||
@@ -914,11 +965,16 @@ export default function Watch() {
|
||||
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{!dlComplete && !isDownloading && !downloadMut.isPending && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{!isDownloading && !downloadMut.isPending && !isRedownloading && (
|
||||
<select
|
||||
value={selectedQuality ?? "best"}
|
||||
onChange={(e) => setSelectedQuality(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const q = e.target.value;
|
||||
setSelectedQuality(q);
|
||||
if (dlComplete) handleRedownload(q);
|
||||
}}
|
||||
|
||||
className="bg-zinc-800 text-zinc-300 text-xs rounded-full px-3 py-2 border border-zinc-700 focus:outline-none focus:border-accent"
|
||||
>
|
||||
{[
|
||||
@@ -937,6 +993,69 @@ export default function Watch() {
|
||||
</select>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const onDisk = subtitleFiles.map(s => s.lang);
|
||||
const onDiskSet = new Set(onDisk);
|
||||
|
||||
if (!subsRequested) return (
|
||||
<button
|
||||
onClick={() => setSubsRequested(true)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
CC{onDisk.length > 0 ? ` · ${onDisk.join(", ")}` : ""}
|
||||
</button>
|
||||
);
|
||||
|
||||
const AUTO_LANGS = new Set(["en", "sv", "ja"]);
|
||||
const ytManual = (availableSubs?.manual ?? []).filter(l => !onDiskSet.has(l));
|
||||
const ytManualSet = new Set(ytManual);
|
||||
const ytAuto = (availableSubs?.auto ?? []).filter(l => !onDiskSet.has(l) && !ytManualSet.has(l) && AUTO_LANGS.has(l));
|
||||
const needsDownload = selectedSubLang && !onDiskSet.has(selectedSubLang);
|
||||
|
||||
return (
|
||||
<>
|
||||
<select
|
||||
value={selectedSubLang}
|
||||
onChange={(e) => {
|
||||
const lang = e.target.value;
|
||||
setSelectedSubLang(lang);
|
||||
const v = videoRef.current;
|
||||
if (v) {
|
||||
for (let i = 0; i < v.textTracks.length; i++) {
|
||||
v.textTracks[i].mode =
|
||||
lang && v.textTracks[i].language === lang ? "showing" : "disabled";
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="bg-zinc-800 text-zinc-300 text-xs rounded-full px-3 py-2 border border-zinc-700 focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">No subtitles</option>
|
||||
{onDisk.length > 0 && (
|
||||
<optgroup label="— Downloaded —">
|
||||
{onDisk.map(l => <option key={l} value={l}>{l}</option>)}
|
||||
</optgroup>
|
||||
)}
|
||||
{(subsLoading || ytManual.length > 0 || ytAuto.length > 0) && (
|
||||
<optgroup label={subsLoading ? "— Loading YouTube… —" : "— Available on YouTube —"}>
|
||||
{ytManual.map(l => <option key={l} value={l}>{l}</option>)}
|
||||
{ytAuto.map(l => <option key={l} value={l}>{l} (auto)</option>)}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
{dlComplete && needsDownload && (
|
||||
<button
|
||||
onClick={() => addSubsMut.mutate()}
|
||||
disabled={addSubsMut.isPending}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium bg-accent text-black hover:bg-zinc-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{addSubsMut.isPending && <span className="w-3 h-3 border-2 border-black/40 border-t-transparent rounded-full animate-spin inline-block" />}
|
||||
{addSubsMut.isPending ? "Fetching…" : "Add subtitles"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{fileReady && (
|
||||
<select
|
||||
value={speed}
|
||||
@@ -952,10 +1071,10 @@ export default function Watch() {
|
||||
<Chip
|
||||
active={dlComplete}
|
||||
disabled={dlComplete || isDownloading || downloadMut.isPending}
|
||||
onClick={() => !dlComplete && !isDownloading && downloadMut.mutate()}
|
||||
onClick={() => !dlComplete && !isDownloading && downloadMut.mutate({})}
|
||||
>
|
||||
{dlComplete ? (
|
||||
<><svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"/></svg>Saved</>
|
||||
<><svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"/></svg>Saved{downloadedResolution ? ` · ${downloadedResolution}` : ""}</>
|
||||
) : isDownloading || downloadMut.isPending ? (
|
||||
<><svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/></svg>Downloading</>
|
||||
) : (
|
||||
@@ -972,42 +1091,25 @@ export default function Watch() {
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
|
||||
{video?.id && (
|
||||
<>
|
||||
<Chip active={isLiked} onClick={() => likeMut.mutate()} disabled={likeMut.isPending}>
|
||||
<svg className="w-4 h-4" fill={isLiked ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
|
||||
</svg>
|
||||
{isLiked ? "Liked" : "Like"}
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{video?.id && (
|
||||
<>
|
||||
<Chip
|
||||
active={currentRating === 1}
|
||||
onClick={() => rateMut.mutate(currentRating === 1 ? 0 : 1)}
|
||||
disabled={rateMut.isPending}
|
||||
>
|
||||
<svg className="w-4 h-4" fill={currentRating === 1 ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 9V5a3 3 0 00-3-3l-4 9v11h11.28a2 2 0 002-1.7l1.38-9a2 2 0 00-2-2.3H14z"/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 22H4a2 2 0 01-2-2v-7a2 2 0 012-2h3"/>
|
||||
</svg>
|
||||
Good
|
||||
</Chip>
|
||||
<Chip
|
||||
active={currentRating === -1}
|
||||
onClick={() => rateMut.mutate(currentRating === -1 ? 0 : -1)}
|
||||
disabled={rateMut.isPending}
|
||||
>
|
||||
<svg className="w-4 h-4" fill={currentRating === -1 ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
|
||||
<Chip active={isDisliked} onClick={() => dislikeMut.mutate()} disabled={dislikeMut.isPending} title="Not for me">
|
||||
<svg className="w-4 h-4" fill={isDisliked ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 15v4a3 3 0 003 3l4-9V2H5.72a2 2 0 00-2 1.7l-1.38 9a2 2 0 002 2.3H10z"/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 2h2.67A2.31 2.31 0 0122 4v7a2.31 2.31 0 01-2.33 2H17"/>
|
||||
</svg>
|
||||
Not for me
|
||||
</Chip>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{video?.id && (
|
||||
<Chip active={isQueued} onClick={() => queueMut.mutate()} disabled={queueMut.isPending}>
|
||||
<svg className="w-4 h-4" fill={isQueued ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -1018,16 +1120,15 @@ export default function Watch() {
|
||||
)}
|
||||
|
||||
{fileReady && document.pictureInPictureEnabled && (
|
||||
<Chip onClick={handlePiP}>
|
||||
<Chip onClick={handlePiP} title="Picture in picture">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" strokeWidth="2"/>
|
||||
<rect x="12" y="12" width="8" height="6" rx="1.5" strokeWidth="2"/>
|
||||
</svg>
|
||||
Mini
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
<Chip active={theater} onClick={() => setTheater(t => !t)}>
|
||||
<Chip active={theater} onClick={() => setTheater(t => !t)} title={theater ? "Exit theater" : "Theater mode"}>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{theater ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9L4 4m0 5V4h5M15 9l5-5m0 5V4h-5M9 15l-5 5m0-5v5h5M15 15l5 5m0-5v5h-5" />
|
||||
@@ -1035,7 +1136,6 @@ export default function Watch() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V5a1 1 0 011-1h3M4 16v3a1 1 0 001 1h3m10-4v3a1 1 0 01-1 1h-3M20 8V5a1 1 0 00-1-1h-3" />
|
||||
)}
|
||||
</svg>
|
||||
Theater
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1048,9 +1148,9 @@ export default function Watch() {
|
||||
<Link to={`/channels/${video?.channel_id}`} className="shrink-0">
|
||||
{channel?.thumbnail_url ? (
|
||||
<img src={channel.thumbnail_url} alt={channelName}
|
||||
className="w-11 h-11 rounded-full object-cover" />
|
||||
className="w-9 h-9 sm:w-11 sm:h-11 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-11 h-11 rounded-full flex items-center justify-center font-bold text-white text-base shrink-0"
|
||||
<div className="w-9 h-9 sm:w-11 sm:h-11 rounded-full flex items-center justify-center font-bold text-white text-base shrink-0"
|
||||
style={{ backgroundColor: avatarColor(channelName) }}>
|
||||
{channelName?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
@@ -1084,7 +1184,7 @@ export default function Watch() {
|
||||
|
||||
{/* Tags */}
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="hidden sm:flex flex-wrap gap-1.5">
|
||||
{tags.map(tag => (
|
||||
<span key={tag} className="px-2.5 py-1 rounded-full bg-zinc-800/80 text-zinc-500 text-xs">
|
||||
{tag}
|
||||
@@ -1107,7 +1207,7 @@ export default function Watch() {
|
||||
)}
|
||||
|
||||
{/* Keyboard shortcuts hint */}
|
||||
<p className={`text-xs text-zinc-700 text-center ${theater ? "max-w-4xl mx-auto w-full" : ""}`}>
|
||||
<p className={`hidden sm:block text-xs text-zinc-700 text-center ${theater ? "max-w-4xl mx-auto w-full" : ""}`}>
|
||||
Space/K · pause · F · fullscreen · M · mute · ←/→ seek 5s · ↑/↓ volume · ,/. speed · T · theater
|
||||
</p>
|
||||
|
||||
|
||||
5
frontend/src/utils/scroll.js
Normal file
5
frontend/src/utils/scroll.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export function scrollToTop(behavior = "smooth") {
|
||||
const main = document.querySelector("main[data-scroll]");
|
||||
if (main) main.scrollTo({ top: 0, behavior });
|
||||
else window.scrollTo({ top: 0, behavior });
|
||||
}
|
||||
@@ -10,9 +10,9 @@ export default {
|
||||
},
|
||||
colors: {
|
||||
accent: {
|
||||
DEFAULT: "#f5a623",
|
||||
light: "#fbbf45",
|
||||
dark: "#d4891a",
|
||||
DEFAULT: "#ffffff",
|
||||
light: "#f4f4f5",
|
||||
dark: "#d4d4d8",
|
||||
},
|
||||
},
|
||||
aspectRatio: {
|
||||
|
||||
Reference in New Issue
Block a user