Compare commits
96 Commits
6f600c9a5c
...
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 |
@@ -9,6 +9,7 @@ class Settings(BaseSettings):
|
|||||||
secret_key: str = "changeme-use-a-real-secret-in-production"
|
secret_key: str = "changeme-use-a-real-secret-in-production"
|
||||||
algorithm: str = "HS256"
|
algorithm: str = "HS256"
|
||||||
access_token_expire_minutes: int = 60 * 24 * 7 # 1 week
|
access_token_expire_minutes: int = 60 * 24 * 7 # 1 week
|
||||||
|
widget_api_key: str = "" # set WIDGET_API_KEY in env for backstage integration
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
119
backend/main.py
119
backend/main.py
@@ -6,7 +6,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import init_db, SessionLocal
|
from .database import init_db, SessionLocal
|
||||||
from .services import ytdlp as ytdlp_service
|
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")
|
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(export_router.router, prefix="/api/export", tags=["export"])
|
||||||
app.include_router(collections_router.router, prefix="/api/collections", tags=["collections"])
|
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(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)
|
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_affinity REAL DEFAULT 5.0",
|
||||||
"ALTER TABLE user_settings ADD COLUMN feed_weight_channel 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 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 (
|
"""CREATE TABLE IF NOT EXISTS search_history (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -106,6 +131,8 @@ def on_startup():
|
|||||||
note TEXT DEFAULT '',
|
note TEXT DEFAULT '',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
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:
|
try:
|
||||||
db.execute(text(col_sql))
|
db.execute(text(col_sql))
|
||||||
@@ -149,10 +176,94 @@ def on_startup():
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
# Backfill descriptions for videos that don't have them yet (runs in background)
|
# Start discovery worker and backfill enrichment
|
||||||
import threading
|
import threading
|
||||||
from .routers.channels import _enrich_missing_task
|
from .routers.channels import _enrich_missing_task, _index_channels_batch
|
||||||
threading.Thread(target=_enrich_missing_task, args=(3,), daemon=True).start()
|
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")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ class UserSettings(Base):
|
|||||||
feed_weight_affinity = Column(Float, default=5.0) # 0–10
|
feed_weight_affinity = Column(Float, default=5.0) # 0–10
|
||||||
feed_weight_channel = Column(Float, default=5.0) # 0–10
|
feed_weight_channel = Column(Float, default=5.0) # 0–10
|
||||||
use_oauth2 = Column(Boolean, default=False)
|
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):
|
class DiscoveryQueue(Base):
|
||||||
@@ -201,6 +204,21 @@ class CollectionItem(Base):
|
|||||||
added_at = Column(DateTime, default=datetime.utcnow)
|
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):
|
class GraphEdge(Base):
|
||||||
__tablename__ = "graph_edges"
|
__tablename__ = "graph_edges"
|
||||||
__table_args__ = (UniqueConstraint("from_channel_id", "to_channel_id"),)
|
__table_args__ = (UniqueConstraint("from_channel_id", "to_channel_id"),)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import threading as _threading
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -14,6 +15,9 @@ from ..services import ytdlp
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
_tasks: dict = {}
|
||||||
|
_tasks_lock = _threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
class ChannelOut(BaseModel):
|
class ChannelOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
@@ -62,6 +66,7 @@ class VideoOut(BaseModel):
|
|||||||
is_downloaded: bool = False
|
is_downloaded: bool = False
|
||||||
is_watched: bool = False
|
is_watched: bool = False
|
||||||
queued: bool = False
|
queued: bool = False
|
||||||
|
view_count: Optional[int] = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -75,15 +80,11 @@ def _get_channel_or_404(db: Session, channel_id: int) -> Channel:
|
|||||||
|
|
||||||
|
|
||||||
def _index_channels_batch(channel_ids: list[int], user_id: int, delay: float = 1.5):
|
def _index_channels_batch(channel_ids: list[int], user_id: int, delay: float = 1.5):
|
||||||
"""Run channel syncs sequentially with a polite delay between requests."""
|
for cid in channel_ids:
|
||||||
import time
|
|
||||||
for i, cid in enumerate(channel_ids):
|
|
||||||
if i > 0:
|
|
||||||
time.sleep(delay)
|
|
||||||
_index_channel_task(cid, user_id)
|
_index_channel_task(cid, user_id)
|
||||||
|
|
||||||
|
|
||||||
def _index_channel_task(channel_id: int, user_id: int):
|
def _index_channel_task(channel_id: int, user_id: int, max_videos: int = 30):
|
||||||
from ..database import SessionLocal
|
from ..database import SessionLocal
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -91,7 +92,7 @@ def _index_channel_task(channel_id: int, user_id: int):
|
|||||||
if not channel:
|
if not channel:
|
||||||
return
|
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:
|
if not result:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -147,6 +148,7 @@ def _index_channel_task(channel_id: int, user_id: int):
|
|||||||
|
|
||||||
if channel_auto:
|
if channel_auto:
|
||||||
quality = user_settings.preferred_quality if user_settings else "best"
|
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
|
from ..routers.downloads import _on_progress, _on_complete, _on_error
|
||||||
for yt_id, vid_id in new_video_ids:
|
for yt_id, vid_id in new_video_ids:
|
||||||
existing_dl = db.query(Download).filter_by(
|
existing_dl = db.query(Download).filter_by(
|
||||||
@@ -159,7 +161,7 @@ def _index_channel_task(channel_id: int, user_id: int):
|
|||||||
import threading
|
import threading
|
||||||
t = threading.Thread(
|
t = threading.Thread(
|
||||||
target=ytdlp.start_download,
|
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,
|
daemon=True,
|
||||||
)
|
)
|
||||||
t.start()
|
t.start()
|
||||||
@@ -171,28 +173,24 @@ def _index_channel_task(channel_id: int, user_id: int):
|
|||||||
|
|
||||||
|
|
||||||
def _discovery_task(user_id: int):
|
def _discovery_task(user_id: int):
|
||||||
from ..database import SessionLocal
|
from ..services.discovery import schedule_discovery
|
||||||
from ..services.discovery import run_full_discovery
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
try:
|
||||||
run_full_discovery(db, user_id)
|
schedule_discovery(user_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _enrich_missing_task(limit: int = 20):
|
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
|
from ..database import SessionLocal
|
||||||
|
import time
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
text("""
|
text("""
|
||||||
SELECT v.id, v.youtube_video_id FROM videos v
|
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
|
ORDER BY
|
||||||
-- prioritise: followed-channel videos first, then discovery queue, then rest
|
|
||||||
(EXISTS (SELECT 1 FROM user_channels uc
|
(EXISTS (SELECT 1 FROM user_channels uc
|
||||||
WHERE uc.channel_id = v.channel_id AND uc.status = 'followed')) DESC,
|
WHERE uc.channel_id = v.channel_id AND uc.status = 'followed')) DESC,
|
||||||
(EXISTS (SELECT 1 FROM discovery_queue dq
|
(EXISTS (SELECT 1 FROM discovery_queue dq
|
||||||
@@ -202,16 +200,18 @@ def _enrich_missing_task(limit: int = 20):
|
|||||||
"""),
|
"""),
|
||||||
{"limit": limit},
|
{"limit": limit},
|
||||||
).mappings().all()
|
).mappings().all()
|
||||||
for i, row in enumerate(rows):
|
for row in rows:
|
||||||
if i > 0:
|
|
||||||
import time; time.sleep(2)
|
|
||||||
try:
|
try:
|
||||||
meta = ytdlp.fetch_video_metadata(row["youtube_video_id"])
|
meta = ytdlp.fetch_video_metadata(row["youtube_video_id"], polite=True)
|
||||||
if meta:
|
if meta:
|
||||||
vid = db.query(Video).filter_by(id=row["id"]).first()
|
vid = db.query(Video).filter_by(id=row["id"]).first()
|
||||||
if vid:
|
if vid:
|
||||||
if meta.get("description") is not None:
|
if meta.get("description") is not None:
|
||||||
vid.description = meta["description"] or ""
|
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"):
|
if not vid.tags and meta.get("tags"):
|
||||||
vid.tags = meta["tags"]
|
vid.tags = meta["tags"]
|
||||||
if not vid.category and meta.get("category"):
|
if not vid.category and meta.get("category"):
|
||||||
@@ -275,11 +275,68 @@ def sync_all_channels(
|
|||||||
background_tasks.add_task(_index_channels_batch, ids, current_user.id)
|
background_tasks.add_task(_index_channels_batch, ids, current_user.id)
|
||||||
background_tasks.add_task(_discovery_task, current_user.id)
|
background_tasks.add_task(_discovery_task, current_user.id)
|
||||||
|
|
||||||
background_tasks.add_task(_enrich_missing_task, 5)
|
background_tasks.add_task(_enrich_missing_task, 30)
|
||||||
|
|
||||||
return {"indexing": len(channels)}
|
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)
|
@router.post("/mark-seen", status_code=204)
|
||||||
def mark_channels_seen(
|
def mark_channels_seen(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -545,6 +602,7 @@ def bulk_channel_action(
|
|||||||
@router.get("/{channel_id}", response_model=ChannelOut)
|
@router.get("/{channel_id}", response_model=ChannelOut)
|
||||||
def get_channel(
|
def get_channel(
|
||||||
channel_id: int,
|
channel_id: int,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@@ -575,11 +633,303 @@ def get_channel(
|
|||||||
).mappings().first()
|
).mappings().first()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Channel not found")
|
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))
|
return ChannelOut(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{channel_id}/videos", response_model=list[VideoOut])
|
@router.get("/{channel_id}/videos", response_model=list[VideoOut])
|
||||||
def get_channel_videos(
|
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,
|
channel_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
@@ -588,13 +938,19 @@ def get_channel_videos(
|
|||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
text("""
|
text("""
|
||||||
SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url,
|
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.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
|
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
|
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},
|
{"user_id": current_user.id, "channel_id": channel_id},
|
||||||
).mappings().all()
|
).mappings().all()
|
||||||
@@ -652,10 +1008,70 @@ def index_channel(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
_get_channel_or_404(db, channel_id)
|
_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"}
|
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)
|
@router.post("/follow-bulk", status_code=200)
|
||||||
def follow_bulk(
|
def follow_bulk(
|
||||||
body: dict,
|
body: dict,
|
||||||
@@ -765,7 +1181,7 @@ def follow_by_url(
|
|||||||
|
|
||||||
channel = db.query(Channel).filter_by(youtube_channel_id=yt_channel_id).first()
|
channel = db.query(Channel).filter_by(youtube_channel_id=yt_channel_id).first()
|
||||||
if not channel:
|
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"):
|
if not meta or not meta.get("channel"):
|
||||||
raise HTTPException(status_code=404, detail="Channel not found on YouTube")
|
raise HTTPException(status_code=404, detail="Channel not found on YouTube")
|
||||||
ch_data = meta["channel"]
|
ch_data = meta["channel"]
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
|
import random
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
@@ -9,7 +10,7 @@ from sqlalchemy import text
|
|||||||
from ..auth_utils import get_current_user
|
from ..auth_utils import get_current_user
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import Channel, DiscoveryQueue, User, UserChannel, UserSettings
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -56,9 +57,35 @@ def list_discovery(
|
|||||||
ORDER BY dq.score DESC
|
ORDER BY dq.score DESC
|
||||||
LIMIT :limit OFFSET :offset
|
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()
|
).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 = []
|
items = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
row = dict(row)
|
row = dict(row)
|
||||||
@@ -133,17 +160,14 @@ def dismiss_discovery(
|
|||||||
|
|
||||||
@router.post("/refresh", status_code=202)
|
@router.post("/refresh", status_code=202)
|
||||||
def refresh_discovery(
|
def refresh_discovery(
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
s = db.query(UserSettings).filter_by(user_id=current_user.id).first()
|
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_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()]
|
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)
|
schedule_discovery(current_user.id, regions)
|
||||||
from .channels import _enrich_missing_task
|
return {"detail": "Discovery queued"}
|
||||||
background_tasks.add_task(_enrich_missing_task, 20)
|
|
||||||
return {"detail": "Discovery refresh started"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/videos", response_model=list[dict])
|
@router.get("/videos", response_model=list[dict])
|
||||||
@@ -177,7 +201,7 @@ def discovery_videos(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
WHERE rn <= 2
|
WHERE rn <= 2
|
||||||
ORDER BY score DESC, rn ASC, RANDOM()
|
ORDER BY (score + (RANDOM() * 10 - 5)) DESC, rn ASC
|
||||||
LIMIT :limit OFFSET :offset
|
LIMIT :limit OFFSET :offset
|
||||||
"""),
|
"""),
|
||||||
{"user_id": current_user.id, "limit": limit, "offset": offset},
|
{"user_id": current_user.id, "limit": limit, "offset": offset},
|
||||||
@@ -214,6 +238,23 @@ def dismiss_discovery_video(
|
|||||||
db.commit()
|
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])
|
@router.get("/community", response_model=list[dict])
|
||||||
def community_shelf(
|
def community_shelf(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ router = APIRouter()
|
|||||||
class DownloadRequest(BaseModel):
|
class DownloadRequest(BaseModel):
|
||||||
youtube_video_id: str
|
youtube_video_id: str
|
||||||
quality: Optional[str] = None
|
quality: Optional[str] = None
|
||||||
|
subtitle_langs: Optional[str] = None # overrides user setting when provided
|
||||||
|
|
||||||
|
|
||||||
TRASH_TTL_DAYS = 7
|
TRASH_TTL_DAYS = 7
|
||||||
@@ -52,6 +53,32 @@ def _on_progress(download_id: int, pct: float):
|
|||||||
db.close()
|
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):
|
def _on_complete(download_id: int, file_path: Optional[str], resolution: Optional[str] = None):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -71,6 +98,12 @@ def _on_complete(download_id: int, file_path: Optional[str], resolution: Optiona
|
|||||||
uv.downloaded = True
|
uv.downloaded = True
|
||||||
uv.downloaded_at = datetime.utcnow()
|
uv.downloaded_at = datetime.utcnow()
|
||||||
db.commit()
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -92,7 +125,7 @@ def _ensure_video(db: Session, youtube_video_id: str) -> Video:
|
|||||||
if video:
|
if video:
|
||||||
return video
|
return video
|
||||||
|
|
||||||
meta = ytdlp.fetch_video_metadata(youtube_video_id)
|
meta = ytdlp.fetch_video_metadata(youtube_video_id, polite=True)
|
||||||
if not meta:
|
if not meta:
|
||||||
raise HTTPException(status_code=404, detail="Video not found on YouTube")
|
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()
|
user_settings = db.query(UserSettings).filter_by(user_id=current_user.id).first()
|
||||||
default_quality = user_settings.preferred_quality if user_settings else "best"
|
default_quality = user_settings.preferred_quality if user_settings else "best"
|
||||||
quality = body.quality if body.quality in ytdlp.QUALITY_FORMATS else default_quality
|
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 = """
|
_DL_SELECT = """
|
||||||
SELECT d.id, d.status, d.progress_percent, d.resolution,
|
SELECT d.id, d.status, d.progress_percent, d.resolution,
|
||||||
@@ -155,7 +192,7 @@ def create_download(
|
|||||||
ytdlp.start_download,
|
ytdlp.start_download,
|
||||||
video.youtube_video_id, dl.id,
|
video.youtube_video_id, dl.id,
|
||||||
_on_progress, _on_complete, _on_error,
|
_on_progress, _on_complete, _on_error,
|
||||||
quality,
|
quality, subtitle_langs,
|
||||||
)
|
)
|
||||||
|
|
||||||
row = db.execute(text(_DL_SELECT), {"id": dl.id}).mappings().first()
|
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"
|
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)
|
@router.post("/channel/{channel_id}", status_code=202)
|
||||||
def download_channel_videos(
|
def download_channel_videos(
|
||||||
channel_id: int,
|
channel_id: int,
|
||||||
@@ -198,6 +240,7 @@ def download_channel_videos(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
quality = _get_quality(db, current_user.id)
|
quality = _get_quality(db, current_user.id)
|
||||||
|
subtitle_langs = _get_subtitle_langs(db, current_user.id)
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
text("""
|
text("""
|
||||||
SELECT v.id, v.youtube_video_id
|
SELECT v.id, v.youtube_video_id
|
||||||
@@ -216,7 +259,7 @@ def download_channel_videos(
|
|||||||
db.flush()
|
db.flush()
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
ytdlp.start_download, row["youtube_video_id"], dl.id,
|
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
|
count += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -230,6 +273,7 @@ def download_following_videos(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
quality = _get_quality(db, current_user.id)
|
quality = _get_quality(db, current_user.id)
|
||||||
|
subtitle_langs = _get_subtitle_langs(db, current_user.id)
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
text("""
|
text("""
|
||||||
SELECT v.id, v.youtube_video_id
|
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):
|
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()
|
video = db.query(Video).filter_by(id=dl.video_id).first()
|
||||||
if video:
|
if video:
|
||||||
fp = ytdlp.predicted_file_path(video.youtube_video_id)
|
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)
|
os.remove(fp)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
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()
|
uv = db.query(UserVideo).filter_by(user_id=user_id, video_id=dl.video_id).first()
|
||||||
if uv:
|
if uv:
|
||||||
uv.downloaded = False
|
uv.downloaded = False
|
||||||
@@ -297,6 +349,38 @@ def _delete_download_record(db: Session, dl: "Download", user_id: int):
|
|||||||
db.delete(dl)
|
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)
|
@router.delete("/all", status_code=204)
|
||||||
def delete_all_downloads(
|
def delete_all_downloads(
|
||||||
db: Session = Depends(get_db),
|
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"
|
source = "local" if (video_results or channel_results) else "none"
|
||||||
|
|
||||||
# Fall back to live yt-dlp search if no local results or explicitly requested
|
# Fall back to live yt-dlp search if no local results or explicitly requested.
|
||||||
if not video_results or live:
|
# 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:
|
try:
|
||||||
live_raw = ytdlp.search_youtube(q)
|
live_raw = ytdlp.search_youtube(q)
|
||||||
live_results = _live_search_to_results(db, current_user.id, live_raw)
|
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_affinity: float = 5.0
|
||||||
feed_weight_channel: float = 5.0
|
feed_weight_channel: float = 5.0
|
||||||
use_oauth2: bool = False
|
use_oauth2: bool = False
|
||||||
|
sync_interval_hours: int = 0
|
||||||
|
subtitle_langs: str = ""
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
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_affinity: Optional[float] = Field(None, ge=0.0, le=10.0)
|
||||||
feed_weight_channel: 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
|
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:
|
def _get_or_create(db: Session, user_id: int) -> UserSettings:
|
||||||
@@ -123,6 +127,10 @@ def update_settings(
|
|||||||
if body.use_oauth2 is not None:
|
if body.use_oauth2 is not None:
|
||||||
s.use_oauth2 = body.use_oauth2
|
s.use_oauth2 = body.use_oauth2
|
||||||
ytdlp.set_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.commit()
|
||||||
db.refresh(s)
|
db.refresh(s)
|
||||||
@@ -242,7 +250,7 @@ def ytdlp_test(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
result = subprocess.run(
|
test_stdout, test_stderr, test_code = ytdlp._meta_run(
|
||||||
[
|
[
|
||||||
"yt-dlp",
|
"yt-dlp",
|
||||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||||
@@ -250,15 +258,15 @@ def ytdlp_test(
|
|||||||
"--extractor-args", "youtube:player_client=web",
|
"--extractor-args", "youtube:player_client=web",
|
||||||
*cookie_args,
|
*cookie_args,
|
||||||
],
|
],
|
||||||
capture_output=True, text=True, timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"node_path": node_path,
|
"node_path": node_path,
|
||||||
"node_version": node_version,
|
"node_version": node_version,
|
||||||
"yt_dlp_version": yt_version,
|
"yt_dlp_version": yt_version,
|
||||||
"cookie_args": cookie_args,
|
"cookie_args": cookie_args,
|
||||||
"returncode": result.returncode,
|
"returncode": test_code,
|
||||||
"stdout_lines": result.stdout.splitlines()[:5],
|
"stdout_lines": test_stdout.splitlines()[:5],
|
||||||
"stderr_tail": result.stderr.splitlines()[-20:],
|
"stderr_tail": test_stderr.splitlines()[-20:],
|
||||||
"success": result.returncode == 0,
|
"success": test_code == 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from ..auth_utils import get_current_user
|
from ..auth_utils import get_current_user
|
||||||
|
from ..config import settings
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import User, UserTagAffinity
|
from ..models import User, UserTagAffinity
|
||||||
|
|
||||||
@@ -81,7 +85,7 @@ def get_stats(
|
|||||||
avg_completion = db.execute(
|
avg_completion = db.execute(
|
||||||
text("""
|
text("""
|
||||||
SELECT AVG(uv.completion_percent) AS avg_pct,
|
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,
|
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,
|
SUM(uv.rewatch_count) AS total_rewatches,
|
||||||
COUNT(CASE WHEN uv.rewatch_count > 0 THEN 1 END) AS rewatched_videos
|
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
|
SELECT tag, score FROM user_tag_affinity
|
||||||
WHERE user_id = :uid AND score > 0
|
WHERE user_id = :uid AND score > 0
|
||||||
ORDER BY score DESC
|
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},
|
{"uid": uid},
|
||||||
).mappings().all()
|
).mappings().all()
|
||||||
@@ -120,6 +136,24 @@ def get_stats(
|
|||||||
{"uid": uid},
|
{"uid": uid},
|
||||||
).mappings().first()
|
).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 {
|
return {
|
||||||
"total_watched": totals["total_watched"] or 0,
|
"total_watched": totals["total_watched"] or 0,
|
||||||
"total_watch_seconds": totals["total_watch_seconds"] 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,
|
"total_rewatches": avg_completion["total_rewatches"] or 0,
|
||||||
"rewatched_videos": avg_completion["rewatched_videos"] or 0,
|
"rewatched_videos": avg_completion["rewatched_videos"] or 0,
|
||||||
"total_liked": liked_count["n"] 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],
|
"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],
|
"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 os
|
||||||
import random
|
import random
|
||||||
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -17,6 +18,11 @@ from ..services.scoring import get_surprise_videos, get_discovery_injection
|
|||||||
|
|
||||||
router = APIRouter()
|
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):
|
def _update_affinity(db: Session, user_id: int, video: Video, delta: float):
|
||||||
"""Adjust tag/category affinity scores for a video. delta > 0 = positive signal."""
|
"""Adjust tag/category affinity scores for a video. delta > 0 = positive signal."""
|
||||||
@@ -208,6 +214,58 @@ def home_feed(
|
|||||||
for r in rows
|
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":
|
if mode == "inbox":
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
text(f"""
|
text(f"""
|
||||||
@@ -245,6 +303,15 @@ def home_feed(
|
|||||||
]
|
]
|
||||||
|
|
||||||
# mode == "ranked" (default)
|
# 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(
|
rows = db.execute(
|
||||||
text(f"""
|
text(f"""
|
||||||
WITH channel_stats AS (
|
WITH channel_stats AS (
|
||||||
@@ -253,7 +320,8 @@ def home_feed(
|
|||||||
COUNT(CASE WHEN uv.watched = 1 THEN 1 END) AS watched_count,
|
COUNT(CASE WHEN uv.watched = 1 THEN 1 END) AS watched_count,
|
||||||
COUNT(CASE WHEN uv.liked = 1 THEN 1 END) AS liked_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
|
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
|
FROM videos v
|
||||||
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||||
GROUP BY v.channel_id
|
GROUP BY v.channel_id
|
||||||
@@ -274,7 +342,9 @@ def home_feed(
|
|||||||
(SQRT(CAST(COALESCE(cs.watched_count, 0) AS REAL)) * 5.0
|
(SQRT(CAST(COALESCE(cs.watched_count, 0) AS REAL)) * 5.0
|
||||||
+ COALESCE(cs.liked_count, 0) * 10.0
|
+ COALESCE(cs.liked_count, 0) * 10.0
|
||||||
+ COALESCE(cs.rating_sum, 0) * 8.0
|
+ COALESCE(cs.rating_sum, 0) * 8.0
|
||||||
+ COALESCE(cs.avg_completion_pct, 50.0) * 0.08) * :w_channel
|
+ 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
|
+ MAX(COALESCE(julianday(v.published_at) - julianday('now'), -90), -365) * :w_recency
|
||||||
+ COALESCE((
|
+ COALESCE((
|
||||||
SELECT COALESCE(SUM(uta.score), 0)
|
SELECT COALESCE(SUM(uta.score), 0)
|
||||||
@@ -284,6 +354,10 @@ def home_feed(
|
|||||||
OR instr(LOWER(COALESCE(v.tags, '')), '"' || uta.tag || '"') > 0)
|
OR instr(LOWER(COALESCE(v.tags, '')), '"' || uta.tag || '"') > 0)
|
||||||
LIMIT 5
|
LIMIT 5
|
||||||
), 0) * :w_affinity
|
), 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,
|
AS score,
|
||||||
ROW_NUMBER() OVER (
|
ROW_NUMBER() OVER (
|
||||||
PARTITION BY v.channel_id
|
PARTITION BY v.channel_id
|
||||||
@@ -300,21 +374,71 @@ def home_feed(
|
|||||||
{duration_clause}
|
{duration_clause}
|
||||||
)
|
)
|
||||||
SELECT * FROM scored
|
SELECT * FROM scored
|
||||||
WHERE rn <= 3
|
WHERE rn <= 15
|
||||||
ORDER BY score DESC, RANDOM()
|
ORDER BY score DESC
|
||||||
LIMIT :limit OFFSET :offset
|
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},
|
"w_recency": w_recency, "w_affinity": w_affinity, "w_channel": w_channel},
|
||||||
).mappings().all()
|
).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 = [
|
followed = [
|
||||||
VideoDetail(**{k: v for k, v in dict(r).items() if k not in ("watched", "score", "rn")},
|
VideoDetail(**{k: v for k, v in item.items() if k not in ("watched", "score", "rn")},
|
||||||
is_watched=bool(r["watched"]))
|
is_watched=bool(item["watched"]))
|
||||||
for r in rows
|
for item in top
|
||||||
]
|
]
|
||||||
|
|
||||||
# Inject discovery cards on every page: 1 every 5 followed cards.
|
# Inject discovery cards: 1 every 3 followed cards (~25% recommendations).
|
||||||
disc_per_page = max(limit // 5, 1)
|
disc_per_page = max(limit // 3, 1)
|
||||||
disc_offset = (offset // limit) * disc_per_page if limit > 0 else 0
|
disc_offset = (offset // limit) * disc_per_page if limit > 0 else 0
|
||||||
|
|
||||||
disc_rows = db.execute(
|
disc_rows = db.execute(
|
||||||
@@ -344,17 +468,23 @@ def home_feed(
|
|||||||
{"user_id": current_user.id, "disc_limit": disc_per_page, "disc_offset": disc_offset},
|
{"user_id": current_user.id, "disc_limit": disc_per_page, "disc_offset": disc_offset},
|
||||||
).mappings().all()
|
).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 = [
|
disc = [
|
||||||
VideoDetail(**{k: v for k, v in dict(r).items()},
|
VideoDetail(**r, is_recommended=True, is_watched=False, is_downloaded=False)
|
||||||
is_recommended=True, is_watched=False, is_downloaded=False)
|
for r in disc_list
|
||||||
for r in disc_rows
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Interleave: one discovery card every 5 followed cards
|
# Interleave: one discovery card every 3 followed cards
|
||||||
result: list[VideoDetail] = []
|
result: list[VideoDetail] = []
|
||||||
disc_iter = iter(disc)
|
disc_iter = iter(disc)
|
||||||
for i, v in enumerate(followed):
|
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)
|
rec = next(disc_iter, None)
|
||||||
if rec:
|
if rec:
|
||||||
result.append(rec)
|
result.append(rec)
|
||||||
@@ -461,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."""
|
"""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:
|
if not meta:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -576,18 +706,12 @@ def import_chapters(
|
|||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
video = db.query(Video).filter(Video.id == video_id).first()
|
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 []
|
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 "[]")
|
chapters = _json.loads(video.chapters or "[]")
|
||||||
# Skip if trivial (single chapter) or already imported
|
# Skip if trivial (single chapter) or already imported
|
||||||
if len(chapters) < 2:
|
if len(chapters) < 2:
|
||||||
@@ -657,6 +781,52 @@ def delete_bookmark(
|
|||||||
db.commit()
|
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")
|
@router.get("/by-yt/{youtube_video_id}/comments")
|
||||||
def get_comments(
|
def get_comments(
|
||||||
youtube_video_id: str,
|
youtube_video_id: str,
|
||||||
@@ -765,14 +935,24 @@ def get_video_by_yt_id(
|
|||||||
# Video unknown — must block to get at least a title before we can render anything
|
# Video unknown — must block to get at least a title before we can render anything
|
||||||
_upsert_video_from_yt(db, youtube_video_id)
|
_upsert_video_from_yt(db, youtube_video_id)
|
||||||
elif existing.description is None or existing.chapters is None:
|
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
|
from ..database import SessionLocal
|
||||||
def _enrich(yt_id: str):
|
def _enrich(yt_id: str):
|
||||||
bg_db = SessionLocal()
|
bg_db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
_upsert_video_from_yt(bg_db, yt_id)
|
_upsert_video_from_yt(bg_db, yt_id, polite=True)
|
||||||
finally:
|
finally:
|
||||||
bg_db.close()
|
bg_db.close()
|
||||||
|
with _enriching_lock:
|
||||||
|
_enriching.discard(yt_id)
|
||||||
background_tasks.add_task(_enrich, youtube_video_id)
|
background_tasks.add_task(_enrich, youtube_video_id)
|
||||||
|
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
@@ -887,7 +1067,7 @@ def update_progress(
|
|||||||
user_id=current_user.id, video_id=video_id, status="complete"
|
user_id=current_user.id, video_id=video_id, status="complete"
|
||||||
).filter(Download.pending_delete_at.is_(None)).first()
|
).filter(Download.pending_delete_at.is_(None)).first()
|
||||||
if dl:
|
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:
|
elif body.watched and prev_watched:
|
||||||
# Rewatch — strongest positive signal
|
# Rewatch — strongest positive signal
|
||||||
uv.rewatch_count = (uv.rewatch_count or 0) + 1
|
uv.rewatch_count = (uv.rewatch_count or 0) + 1
|
||||||
@@ -901,10 +1081,10 @@ def update_progress(
|
|||||||
if pct < 0.20:
|
if pct < 0.20:
|
||||||
_update_affinity(db, current_user.id, video, -0.5)
|
_update_affinity(db, current_user.id, video, -0.5)
|
||||||
|
|
||||||
# Backend safety net: auto-mark watched at ≥90% completion even if the frontend
|
# 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)
|
# didn't send watched=True (e.g. browser closed before debounce fired)
|
||||||
if (not prev_watched and not uv.watched
|
if (not prev_watched and not uv.watched
|
||||||
and uv.completion_percent is not None and uv.completion_percent >= 90
|
and uv.completion_percent is not None and uv.completion_percent >= 75
|
||||||
and video.duration_seconds and video.duration_seconds > 60):
|
and video.duration_seconds and video.duration_seconds > 60):
|
||||||
uv.watched = True
|
uv.watched = True
|
||||||
_update_affinity(db, current_user.id, video, +2.0)
|
_update_affinity(db, current_user.id, video, +2.0)
|
||||||
@@ -912,7 +1092,7 @@ def update_progress(
|
|||||||
user_id=current_user.id, video_id=video_id, status="complete"
|
user_id=current_user.id, video_id=video_id, status="complete"
|
||||||
).filter(Download.pending_delete_at.is_(None)).first()
|
).filter(Download.pending_delete_at.is_(None)).first()
|
||||||
if dl:
|
if dl:
|
||||||
dl.pending_delete_at = datetime.utcnow() + timedelta(days=7)
|
dl.pending_delete_at = datetime.utcnow() + timedelta(hours=2)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
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."""
|
"""Discovery engine — search-based crawl, trending, community signal, category clustering."""
|
||||||
import json
|
import json
|
||||||
|
import queue as _queue
|
||||||
import random
|
import random
|
||||||
|
import threading as _threading
|
||||||
|
import time as _time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
@@ -8,11 +11,21 @@ from sqlalchemy import text
|
|||||||
from ..models import Channel, UserChannel, DiscoveryQueue, Video
|
from ..models import Channel, UserChannel, DiscoveryQueue, Video
|
||||||
from . import ytdlp
|
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):
|
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:
|
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:
|
if not result:
|
||||||
return
|
return
|
||||||
ch_data = result.get("channel", {})
|
ch_data = result.get("channel", {})
|
||||||
@@ -21,32 +34,9 @@ def _fetch_and_index_channel(db: Session, channel: Channel):
|
|||||||
setattr(channel, k, v)
|
setattr(channel, k, v)
|
||||||
channel.crawled_at = datetime.utcnow()
|
channel.crawled_at = datetime.utcnow()
|
||||||
|
|
||||||
videos = result.get("videos", [])
|
for vdata in 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]:
|
|
||||||
yt_id = vdata.get("youtube_video_id")
|
yt_id = vdata.get("youtube_video_id")
|
||||||
if not yt_id:
|
if not yt_id or not vdata.get("published_at"):
|
||||||
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"):
|
|
||||||
continue
|
continue
|
||||||
if not db.query(Video).filter_by(youtube_video_id=yt_id).first():
|
if not db.query(Video).filter_by(youtube_video_id=yt_id).first():
|
||||||
db.add(Video(
|
db.add(Video(
|
||||||
@@ -77,14 +67,18 @@ def _upsert_channel(db: Session, channel_data: dict) -> Channel | None:
|
|||||||
return channel
|
return channel
|
||||||
|
|
||||||
|
|
||||||
|
_MAX_DISCOVERY_SCORE = 50.0
|
||||||
|
|
||||||
|
|
||||||
def _add_to_discovery(
|
def _add_to_discovery(
|
||||||
db: Session, user_id: int, channel_id: int, score: float, source: str,
|
db: Session, user_id: int, channel_id: int, score: float, source: str,
|
||||||
preview_json: str | None = None,
|
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()
|
existing = db.query(DiscoveryQueue).filter_by(user_id=user_id, channel_id=channel_id).first()
|
||||||
if existing:
|
if existing:
|
||||||
# Accumulate scores across sources but cap to prevent one dominant signal
|
# Accumulate across sources but cap so no single signal dominates forever
|
||||||
existing.score = existing.score + score * 0.5
|
existing.score = min(existing.score + score * 0.5, _MAX_DISCOVERY_SCORE)
|
||||||
if preview_json and not existing.preview_json:
|
if preview_json and not existing.preview_json:
|
||||||
existing.preview_json = preview_json
|
existing.preview_json = preview_json
|
||||||
return
|
return
|
||||||
@@ -100,12 +94,16 @@ def _add_to_discovery(
|
|||||||
def _search_and_store(
|
def _search_and_store(
|
||||||
db: Session, user_id: int, queries: list[str],
|
db: Session, user_id: int, queries: list[str],
|
||||||
followed_yt_ids: set[str], score_multiplier: float, source: 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."""
|
"""Run YouTube searches for the given queries and add results to discovery."""
|
||||||
discovered: dict[str, dict] = {}
|
discovered: dict[str, dict] = {}
|
||||||
|
|
||||||
for query in queries:
|
for query in queries:
|
||||||
try:
|
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:
|
for video in results:
|
||||||
ch = video.get("channel", {})
|
ch = video.get("channel", {})
|
||||||
yt_id = ch.get("youtube_channel_id")
|
yt_id = ch.get("youtube_channel_id")
|
||||||
@@ -120,8 +118,6 @@ def _search_and_store(
|
|||||||
"thumbnail_url": video["thumbnail_url"],
|
"thumbnail_url": video["thumbnail_url"],
|
||||||
"title": video["title"],
|
"title": video["title"],
|
||||||
})
|
})
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not discovered:
|
if not discovered:
|
||||||
return
|
return
|
||||||
@@ -145,6 +141,24 @@ def _search_and_store(
|
|||||||
uc = db.query(UserChannel).filter_by(user_id=user_id, channel_id=channel.id).first()
|
uc = db.query(UserChannel).filter_by(user_id=user_id, channel_id=channel.id).first()
|
||||||
if uc and uc.status in ("followed", "dismissed"):
|
if uc and uc.status in ("followed", "dismissed"):
|
||||||
continue
|
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
|
preview_json = json.dumps(info["previews"]) if info["previews"] else None
|
||||||
_add_to_discovery(
|
_add_to_discovery(
|
||||||
db, user_id, channel.id,
|
db, user_id, channel.id,
|
||||||
@@ -157,10 +171,9 @@ def _search_and_store(
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
for channel_id in needs_indexing[:5]:
|
# Queue channel indexing as separate worker tasks (30-90 s gaps apply).
|
||||||
channel = db.query(Channel).filter_by(id=channel_id).first()
|
for channel_id in needs_indexing[:3]:
|
||||||
if channel:
|
_task_queue.put((user_id, lambda cid=channel_id: _do_task_index_channel(user_id, cid)))
|
||||||
_fetch_and_index_channel(db, channel)
|
|
||||||
|
|
||||||
|
|
||||||
def crawl_by_search(db: Session, user_id: int):
|
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},
|
{"user_id": user_id},
|
||||||
).mappings().all()
|
).mappings().all()
|
||||||
|
|
||||||
# Build query pool: top tags + random channel names + categories
|
# Keep the query count low — each query is a separate yt-dlp subprocess
|
||||||
top_tags = [t for t, _ in sorted(tag_counts.items(), key=lambda x: -x[1])[:6]]
|
# (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]
|
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] = []
|
sampled_names: list[str] = []
|
||||||
if followed_names:
|
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)
|
# One serendipity query to surface content outside the user's direct tag space
|
||||||
queries = list(dict.fromkeys(top_tags + sampled_names + top_cats))[:15]
|
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:
|
if not queries:
|
||||||
return
|
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):
|
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:
|
if not top_categories:
|
||||||
return
|
return
|
||||||
|
|
||||||
placeholders = ",".join(f"'{c}'" for c in top_categories)
|
# Use JSON_EACH / parameterized IN via repeated queries to avoid SQL injection
|
||||||
candidate_rows = db.execute(
|
candidate_channel_ids: set[int] = set()
|
||||||
text(f"""
|
for cat in top_categories:
|
||||||
|
cat_rows = db.execute(
|
||||||
|
text("""
|
||||||
SELECT DISTINCT v.channel_id
|
SELECT DISTINCT v.channel_id
|
||||||
FROM videos v
|
FROM videos v
|
||||||
WHERE v.category IN ({placeholders})
|
WHERE v.category = :cat
|
||||||
AND v.channel_id NOT IN (
|
AND v.channel_id NOT IN (
|
||||||
SELECT channel_id FROM user_channels WHERE user_id = :user_id
|
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()
|
).mappings().all()
|
||||||
|
for row in cat_rows:
|
||||||
|
candidate_channel_ids.add(row["channel_id"])
|
||||||
|
|
||||||
for row in candidate_rows:
|
for channel_id in candidate_channel_ids:
|
||||||
_add_to_discovery(db, user_id, row["channel_id"], score=3.0, source="category")
|
_add_to_discovery(db, user_id, channel_id, score=5.0, source="category")
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -354,8 +382,15 @@ def update_liked_signal(db: Session, user_id: int):
|
|||||||
{"user_id": user_id},
|
{"user_id": user_id},
|
||||||
).scalars().all())
|
).scalars().all())
|
||||||
|
|
||||||
top_tags = [t for t, _ in sorted(tag_counts.items(), key=lambda x: -x[1])[:6]]
|
top_tags = [t for t, _ in sorted(tag_counts.items(), key=lambda x: -x[1])[:4]]
|
||||||
_search_and_store(db, user_id, top_tags, followed_yt_ids, score_multiplier=10.0, source="liked")
|
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):
|
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},
|
{"user_id": user_id},
|
||||||
).scalars().all())
|
).scalars().all())
|
||||||
|
|
||||||
top_tags = [t for t, _ in sorted(qualified.items(), key=lambda x: -x[1])[:6]]
|
top_tags = [t for t, _ in sorted(qualified.items(), key=lambda x: -x[1])[:10]]
|
||||||
|
neg_tags = frozenset(
|
||||||
_search_and_store(db, user_id, top_tags, followed_yt_ids, score_multiplier=3.0, source="watched")
|
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]:
|
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(
|
rows = db.execute(
|
||||||
text("""
|
text("SELECT tag, score FROM user_tag_affinity WHERE user_id = :user_id"),
|
||||||
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
|
|
||||||
"""),
|
|
||||||
{"user_id": user_id},
|
{"user_id": user_id},
|
||||||
).mappings().all()
|
).mappings().all()
|
||||||
|
return {row["tag"]: row["score"] for row in rows}
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _tag_relevance_score(tag_profile: dict[str, float], tags_json: str | None) -> float:
|
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:
|
if not tag_profile or not tags_json:
|
||||||
return 0.0
|
return 0.0
|
||||||
try:
|
try:
|
||||||
@@ -453,35 +475,7 @@ def _tag_relevance_score(tag_profile: dict[str, float], tags_json: str | None) -
|
|||||||
if isinstance(tag, str):
|
if isinstance(tag, str):
|
||||||
t = tag.lower().strip()
|
t = tag.lower().strip()
|
||||||
score += tag_profile.get(t, 0.0)
|
score += tag_profile.get(t, 0.0)
|
||||||
return min(score, 50.0)
|
return max(-100.0, 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}
|
|
||||||
|
|
||||||
|
|
||||||
def update_trending_signal(db: Session, user_id: int, regions: list[str]):
|
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
|
return
|
||||||
|
|
||||||
tag_profile = _build_user_tag_profile(db, user_id)
|
tag_profile = _build_user_tag_profile(db, user_id)
|
||||||
dismiss_tags = _dismissed_channel_tags(db, user_id)
|
|
||||||
|
|
||||||
followed_yt_ids = set(db.execute(
|
followed_yt_ids = set(db.execute(
|
||||||
text("""
|
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"):
|
if uc and uc.status in ("followed", "dismissed"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Score: base ×4 per region × count, boosted by tag relevance, penalised by dismiss-tag overlap
|
# Cap base_score so a viral trending channel can't dominate the whole queue.
|
||||||
base_score = float(info["count"]) * 4.0 * len(info["regions"])
|
# 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
|
tag_boost = 0.0
|
||||||
if not is_new and channel.crawled_at:
|
if not is_new and channel.crawled_at:
|
||||||
tag_rows = db.execute(
|
tag_rows = db.execute(
|
||||||
@@ -568,25 +563,8 @@ def update_trending_signal(db: Session, user_id: int, regions: list[str]):
|
|||||||
).scalars().all()
|
).scalars().all()
|
||||||
for tags_json in tag_rows:
|
for tags_json in tag_rows:
|
||||||
tag_boost += _tag_relevance_score(tag_profile, tags_json)
|
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
|
final_score = min(base_score + tag_boost, 25.0)
|
||||||
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
|
|
||||||
if final_score <= 0:
|
if final_score <= 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -597,18 +575,411 @@ def update_trending_signal(db: Session, user_id: int, regions: list[str]):
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
for channel_id in needs_indexing[:5]:
|
|
||||||
channel = db.query(Channel).filter_by(id=channel_id).first()
|
def update_graph_signal(db: Session, user_id: int):
|
||||||
if channel:
|
"""Discover channels featured on followed channels' /channels tab.
|
||||||
_fetch_and_index_channel(db, channel)
|
|
||||||
|
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):
|
def run_full_discovery(db: Session, user_id: int, regions: list[str] | None = None):
|
||||||
if regions is None:
|
if regions is None:
|
||||||
regions = ["US", "SE"]
|
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_community_signal(db, user_id)
|
||||||
update_category_clusters(db, user_id)
|
update_category_clusters(db, user_id)
|
||||||
update_liked_signal(db, user_id)
|
|
||||||
update_watch_signal(db, user_id)
|
search_args = _build_search_task_args(db, user_id)
|
||||||
update_trending_signal(db, user_id, regions)
|
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."""
|
"""Subprocess wrapper for yt-dlp."""
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -12,9 +17,82 @@ from typing import Any
|
|||||||
from ..config import settings
|
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]:
|
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)
|
result = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
||||||
return result.stdout, result.stderr, result.returncode
|
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:
|
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."""
|
"""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",
|
"yt-dlp",
|
||||||
f"ytsearch{max_results}:{query}",
|
f"ytsearch{max_results}:{query}",
|
||||||
"--dump-json",
|
"--dump-json",
|
||||||
"--flat-playlist",
|
"--flat-playlist",
|
||||||
"--quiet",
|
"--quiet",
|
||||||
|
*_YT_CLIENT,
|
||||||
*_cookie_args(),
|
*_cookie_args(),
|
||||||
], timeout=60)
|
], timeout=60)
|
||||||
|
|
||||||
@@ -179,13 +259,14 @@ def fetch_trending(region: str = "US", max_results: int = 50) -> list[dict]:
|
|||||||
region = region.upper()
|
region = region.upper()
|
||||||
# CAI%3D = sort by upload date; gl= sets the region
|
# 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}"
|
url = f"https://www.youtube.com/results?search_query=trending&sp=CAI%253D&gl={region}"
|
||||||
stdout, _, code = _run([
|
stdout, _, code = _meta_run([
|
||||||
"yt-dlp",
|
"yt-dlp",
|
||||||
url,
|
url,
|
||||||
"--dump-json",
|
"--dump-json",
|
||||||
"--flat-playlist",
|
"--flat-playlist",
|
||||||
"--quiet",
|
"--quiet",
|
||||||
"--playlist-end", str(max_results),
|
"--playlist-end", str(max_results),
|
||||||
|
*_YT_CLIENT,
|
||||||
*_cookie_args(),
|
*_cookie_args(),
|
||||||
], timeout=60)
|
], timeout=60)
|
||||||
|
|
||||||
@@ -224,23 +305,27 @@ def _best_thumbnail(thumbnails: list | None) -> str | None:
|
|||||||
return best[0].get("url") if best else None
|
return best[0].get("url") if best else None
|
||||||
|
|
||||||
|
|
||||||
def fetch_video_metadata(video_id: str) -> dict | None:
|
def fetch_video_metadata(video_id: str, polite: bool = False) -> dict | None:
|
||||||
"""Fetch metadata for a single video by YouTube ID."""
|
"""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}"
|
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||||
cookie_args = _cookie_args()
|
cookie_args = _cookie_args()
|
||||||
print(f"[fetch_meta] video={video_id} cookie_args={cookie_args!r}", flush=True)
|
print(f"[fetch_meta] video={video_id} cookie_args={cookie_args!r}", flush=True)
|
||||||
base_cmd = [
|
base_cmd = [
|
||||||
"yt-dlp", url,
|
"yt-dlp", url,
|
||||||
"--dump-json", "--no-download", "--no-playlist",
|
"--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:
|
if code != 0:
|
||||||
print(f"[fetch_meta] FAILED code={code} stderr={stderr[:500]!r}", flush=True)
|
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:
|
if cookie_args:
|
||||||
print(f"[fetch_meta] retrying without cookie args", flush=True)
|
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:
|
if code != 0:
|
||||||
print(f"[fetch_meta] retry also FAILED code={code}", flush=True)
|
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 {}
|
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.
|
"""Fetch channel info + recent videos.
|
||||||
|
|
||||||
Uses --dump-single-json --flat-playlist for speed, then enriches video dates
|
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",
|
"--dump-single-json",
|
||||||
"--flat-playlist",
|
"--flat-playlist",
|
||||||
"--quiet",
|
"--quiet",
|
||||||
|
*_YT_CLIENT,
|
||||||
*_cookie_args(),
|
*_cookie_args(),
|
||||||
]
|
]
|
||||||
|
if start_video > 1:
|
||||||
|
args += ["--playlist-start", str(start_video)]
|
||||||
if max_videos > 0:
|
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():
|
if not stdout.strip():
|
||||||
return None
|
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}
|
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]:
|
def fetch_channel_links(channel_id: str) -> list[str]:
|
||||||
"""Extract linked channel IDs from a channel's about/description."""
|
"""Extract linked channel IDs from a channel's about/description."""
|
||||||
if channel_id.startswith("@"):
|
if channel_id.startswith("@"):
|
||||||
url = f"https://www.youtube.com/{channel_id}/about"
|
url = f"https://www.youtube.com/{channel_id}/about"
|
||||||
else:
|
else:
|
||||||
url = f"https://www.youtube.com/channel/{channel_id}/about"
|
url = f"https://www.youtube.com/channel/{channel_id}/about"
|
||||||
stdout, _, code = _run([
|
stdout, _, code = _meta_run([
|
||||||
"yt-dlp",
|
"yt-dlp",
|
||||||
url,
|
url,
|
||||||
"--dump-json",
|
"--dump-json",
|
||||||
@@ -365,6 +577,7 @@ def fetch_channel_links(channel_id: str) -> list[str]:
|
|||||||
"--flat-playlist",
|
"--flat-playlist",
|
||||||
"--playlist-end", "1",
|
"--playlist-end", "1",
|
||||||
"--quiet",
|
"--quiet",
|
||||||
|
*_YT_CLIENT,
|
||||||
*_cookie_args(),
|
*_cookie_args(),
|
||||||
], timeout=30)
|
], timeout=30)
|
||||||
|
|
||||||
@@ -385,11 +598,84 @@ def fetch_channel_links(channel_id: str) -> list[str]:
|
|||||||
return list(channel_ids)
|
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]:
|
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."""
|
"""Fetch top comments via yt-dlp CLI writing to a temp file. Returns empty list on failure."""
|
||||||
import os
|
import os
|
||||||
import tempfile
|
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}"
|
url = f"https://www.youtube.com/watch?v={youtube_video_id}"
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
@@ -403,6 +689,7 @@ def fetch_video_comments(youtube_video_id: str, max_comments: int = 20) -> list[
|
|||||||
"--skip-download",
|
"--skip-download",
|
||||||
"--no-playlist",
|
"--no-playlist",
|
||||||
"--output", out_tmpl,
|
"--output", out_tmpl,
|
||||||
|
*_YT_CLIENT,
|
||||||
*_cookie_args(),
|
*_cookie_args(),
|
||||||
]
|
]
|
||||||
_run(args, timeout=90)
|
_run(args, timeout=90)
|
||||||
@@ -449,15 +736,15 @@ def fetch_dislike_count(youtube_video_id: str) -> int | None:
|
|||||||
|
|
||||||
|
|
||||||
QUALITY_FORMATS = {
|
QUALITY_FORMATS = {
|
||||||
"best": "bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/22/18/bestvideo+bestaudio/best",
|
"best": "bestvideo+bestaudio/best",
|
||||||
"2160p": "bestvideo[ext=mp4][height<=2160]+bestaudio[ext=m4a]/bestvideo[height<=2160]+bestaudio/best[height<=2160]",
|
"2160p": "bestvideo[height<=2160]+bestaudio/bestvideo+bestaudio/best",
|
||||||
"1440p": "bestvideo[ext=mp4][height<=1440]+bestaudio[ext=m4a]/bestvideo[height<=1440]+bestaudio/best[height<=1440]",
|
"1440p": "bestvideo[height<=1440]+bestaudio/bestvideo+bestaudio/best",
|
||||||
"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]",
|
"1080p": "bestvideo[height<=1080]+bestaudio/bestvideo+bestaudio/best",
|
||||||
"720p": "bestvideo[ext=mp4][vcodec^=avc1][height<=720]+bestaudio[ext=m4a]/bestvideo[ext=mp4][height<=720]+bestaudio[ext=m4a]/22/best[height<=720]",
|
"720p": "bestvideo[height<=720]+bestaudio/bestvideo+bestaudio/best",
|
||||||
"480p": "bestvideo[ext=mp4][vcodec^=avc1][height<=480]+bestaudio[ext=m4a]/bestvideo[ext=mp4][height<=480]+bestaudio[ext=m4a]/18/best[height<=480]",
|
"480p": "bestvideo[height<=480]+bestaudio/bestvideo+bestaudio/best",
|
||||||
"360p": "bestvideo[ext=mp4][height<=360]+bestaudio[ext=m4a]/18/best[height<=360]",
|
"360p": "bestvideo[height<=360]+bestaudio/bestvideo+bestaudio/best",
|
||||||
"240p": "bestvideo[ext=mp4][height<=240]+bestaudio[ext=m4a]/best[height<=240]",
|
"240p": "bestvideo[height<=240]+bestaudio/bestvideo+bestaudio/best",
|
||||||
"144p": "bestvideo[ext=mp4][height<=144]+bestaudio[ext=m4a]/best[height<=144]",
|
"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,
|
capture_output=True, text=True, timeout=15,
|
||||||
)
|
)
|
||||||
height = int(result.stdout.strip())
|
height = int(result.stdout.strip())
|
||||||
|
if height >= 2160: return "2160p"
|
||||||
|
if height >= 1440: return "1440p"
|
||||||
if height >= 1080: return "1080p"
|
if height >= 1080: return "1080p"
|
||||||
if height >= 720: return "720p"
|
if height >= 720: return "720p"
|
||||||
if height >= 480: return "480p"
|
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"
|
return Path(settings.download_path) / f"{video_id}.mp4"
|
||||||
|
|
||||||
|
|
||||||
_SEMAPHORE = threading.Semaphore(3)
|
_SEMAPHORE = threading.Semaphore(6)
|
||||||
_semaphore_lock = threading.Lock()
|
_semaphore_lock = threading.Lock()
|
||||||
_cookies_browser: str = ""
|
_cookies_browser: str = ""
|
||||||
_cookies_file: str = ""
|
_cookies_file: str = ""
|
||||||
@@ -501,7 +790,7 @@ _oauth2_state_lock = threading.Lock()
|
|||||||
def set_max_concurrent(n: int) -> None:
|
def set_max_concurrent(n: int) -> None:
|
||||||
global _SEMAPHORE
|
global _SEMAPHORE
|
||||||
with _semaphore_lock:
|
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:
|
def set_cookies_browser(browser: str) -> None:
|
||||||
@@ -620,40 +909,52 @@ def start_download(
|
|||||||
on_complete: Any,
|
on_complete: Any,
|
||||||
on_error: Any,
|
on_error: Any,
|
||||||
quality: str = "best",
|
quality: str = "best",
|
||||||
|
subtitle_langs: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Start yt-dlp download in a background thread.
|
"""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).
|
--no-part writes directly to the final filename (no .part rename at the end).
|
||||||
"""
|
"""
|
||||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
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")
|
output_template = str(Path(settings.download_path) / f"{video_id}.%(ext)s")
|
||||||
|
|
||||||
fmt = QUALITY_FORMATS.get(quality, QUALITY_FORMATS["best"])
|
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():
|
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:
|
with _SEMAPHORE:
|
||||||
cookie_args = _cookie_args()
|
cookie_args = _cookie_args()
|
||||||
print(f"[ytdlp] cookie_args={cookie_args!r}", flush=True)
|
print(f"[ytdlp] cookie_args={cookie_args!r}", flush=True)
|
||||||
process = subprocess.Popen(
|
cmd = [
|
||||||
[
|
|
||||||
"yt-dlp", url,
|
"yt-dlp", url,
|
||||||
"-f", fmt,
|
"-f", fmt,
|
||||||
"--merge-output-format", "mp4",
|
"--merge-output-format", "mp4",
|
||||||
"--postprocessor-args", "Merger+ffmpeg:-movflags +faststart",
|
|
||||||
"--embed-metadata", "--embed-thumbnail",
|
|
||||||
"--no-part", "--no-mtime",
|
"--no-part", "--no-mtime",
|
||||||
"-o", output_template,
|
"-o", output_template,
|
||||||
"--newline", "--progress", "--no-colors",
|
"--newline", "--progress", "--no-colors",
|
||||||
"--extractor-args", "youtube:player_client=web",
|
*subtitle_args,
|
||||||
|
*_YT_CLIENT,
|
||||||
*cookie_args,
|
*cookie_args,
|
||||||
],
|
]
|
||||||
|
cmd, tmp_cookie_path = _make_private_cookie_copy(cmd)
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
file_path = None
|
file_path = None
|
||||||
stream_index = 0
|
stream_index = 0
|
||||||
output_lines: list[str] = []
|
output_lines: list[str] = []
|
||||||
@@ -673,6 +974,7 @@ def start_download(
|
|||||||
|
|
||||||
process.wait()
|
process.wait()
|
||||||
if process.returncode == 0:
|
if process.returncode == 0:
|
||||||
|
_strip_vtt_cue_settings(video_id)
|
||||||
resolution = detect_resolution(file_path) if file_path else None
|
resolution = detect_resolution(file_path) if file_path else None
|
||||||
on_complete(download_id, file_path, resolution)
|
on_complete(download_id, file_path, resolution)
|
||||||
else:
|
else:
|
||||||
@@ -680,6 +982,15 @@ def start_download(
|
|||||||
import logging
|
import logging
|
||||||
logging.getLogger(__name__).error("yt-dlp failed (code %d):\n%s", process.returncode, tail)
|
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}")
|
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 = threading.Thread(target=_run_download, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ services:
|
|||||||
DATABASE_URL: sqlite:////data/app.db
|
DATABASE_URL: sqlite:////data/app.db
|
||||||
DOWNLOAD_PATH: /downloads
|
DOWNLOAD_PATH: /downloads
|
||||||
SECRET_KEY: ${SECRET_KEY:-changeme}
|
SECRET_KEY: ${SECRET_KEY:-changeme}
|
||||||
|
WIDGET_API_KEY: ${WIDGET_API_KEY:-}
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
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 getChannelFeed = (offset = 0) => api.get("/channels/feed", { params: { offset, limit: 24 } });
|
||||||
export const getChannel = (id) => api.get(`/channels/${id}`);
|
export const getChannel = (id) => api.get(`/channels/${id}`);
|
||||||
export const syncAllChannels = () => api.post("/channels/sync-all");
|
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 followChannel = (id) => api.post(`/channels/${id}/follow`);
|
||||||
export const unfollowChannel = (id) => api.delete(`/channels/${id}/follow`);
|
export const unfollowChannel = (id) => api.delete(`/channels/${id}/follow`);
|
||||||
export const indexChannel = (id) => api.post(`/channels/${id}/index`);
|
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 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 setChannelAutoDownload = (id, value) => api.patch(`/channels/${id}/auto-download`, { auto_download: value });
|
||||||
export const markChannelsSeen = () => api.post("/channels/mark-seen");
|
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 addChannelToGroup = (groupId, channelId) => api.post(`/channels/groups/${groupId}/channels/${channelId}`);
|
||||||
export const removeChannelFromGroup = (groupId, channelId) => api.delete(`/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 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
|
// Videos
|
||||||
export const homeFeed = (page = 0, limit = 25, mode = "ranked", duration = "") =>
|
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`);
|
export const clearChapters = (videoId) => api.delete(`/videos/${videoId}/bookmarks/clear-chapters`);
|
||||||
|
|
||||||
// Downloads
|
// Downloads
|
||||||
export const createDownload = (youtube_video_id, quality) =>
|
export const createDownload = (youtube_video_id, quality, subtitle_langs) =>
|
||||||
api.post("/downloads", { youtube_video_id, ...(quality ? { quality } : {}) });
|
api.post("/downloads", { youtube_video_id, ...(quality ? { quality } : {}), ...(subtitle_langs ? { subtitle_langs } : {}) });
|
||||||
export const getDownloads = () => api.get("/downloads");
|
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 getDownload = (id) => api.get(`/downloads/${id}`);
|
||||||
export const deleteDownload = (id) => api.delete(`/downloads/${id}`);
|
export const deleteDownload = (id) => api.delete(`/downloads/${id}`);
|
||||||
export const deleteAllDownloads = () => api.delete("/downloads/all");
|
export const deleteAllDownloads = () => api.delete("/downloads/all");
|
||||||
@@ -127,6 +140,7 @@ export const followDiscovery = (channelId) =>
|
|||||||
export const dismissDiscovery = (channelId) =>
|
export const dismissDiscovery = (channelId) =>
|
||||||
api.post(`/discovery/${channelId}/dismiss`);
|
api.post(`/discovery/${channelId}/dismiss`);
|
||||||
export const refreshDiscovery = () => api.post("/discovery/refresh");
|
export const refreshDiscovery = () => api.post("/discovery/refresh");
|
||||||
|
export const getDiscoveryStatus = () => api.get("/discovery/status");
|
||||||
export const getCommunityShelf = () => api.get("/discovery/community");
|
export const getCommunityShelf = () => api.get("/discovery/community");
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
@@ -147,3 +161,11 @@ export const deleteCollection = (id) => api.delete(`/collections/${id}`);
|
|||||||
export const getCollectionVideos = (id) => api.get(`/collections/${id}/videos`);
|
export const getCollectionVideos = (id) => api.get(`/collections/${id}/videos`);
|
||||||
export const addToCollection = (collectionId, videoId) => api.post(`/collections/${collectionId}/videos`, { video_id: videoId });
|
export const addToCollection = (collectionId, videoId) => api.post(`/collections/${collectionId}/videos`, { video_id: videoId });
|
||||||
export const removeFromCollection = (collectionId, videoId) => api.delete(`/collections/${collectionId}/videos/${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 ${
|
className={`text-xs font-medium px-4 py-2 rounded-lg transition-colors ${
|
||||||
isFollowed || followMut.isSuccess
|
isFollowed || followMut.isSuccess
|
||||||
? "bg-zinc-700 text-zinc-300 hover:bg-zinc-600"
|
? "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"}
|
{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 { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useRef, useEffect } from "react";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import SearchBar from "./SearchBar";
|
import SearchBar from "./SearchBar";
|
||||||
import { getDownloads, getChannels } from "../api";
|
import { getDownloads, getChannels, getActiveTasks, getDiscoveryStatus, getMe } from "../api";
|
||||||
|
|
||||||
function BottomNav({ newCount }) {
|
function BottomNav({ newCount }) {
|
||||||
const tabs = [
|
const tabs = [
|
||||||
@@ -41,7 +42,7 @@ function BottomNav({ newCount }) {
|
|||||||
end={tab.end}
|
end={tab.end}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`relative flex-1 flex flex-col items-center justify-center gap-0.5 transition-colors outline-none ${
|
`relative flex-1 flex flex-col items-center justify-center gap-0.5 transition-colors outline-none ${
|
||||||
isActive ? "text-accent" : "text-zinc-500"
|
isActive ? "text-zinc-100" : "text-zinc-500"
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -49,13 +50,13 @@ function BottomNav({ newCount }) {
|
|||||||
<>
|
<>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<span className="absolute -inset-2 rounded-xl bg-accent/10" />
|
<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">
|
<svg className="w-[18px] h-[18px] relative" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
{tab.icon}
|
{tab.icon}
|
||||||
</svg>
|
</svg>
|
||||||
{tab.badge > 0 && (
|
{tab.badge > 0 && (
|
||||||
<span className="absolute -top-1 -right-1.5 min-w-[13px] h-3 bg-accent text-black text-[8px] 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}
|
{tab.badge > 99 ? "99+" : tab.badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -73,7 +74,7 @@ function BottomNav({ newCount }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DownloadIndicator() {
|
function DownloadIndicator() {
|
||||||
const { data } = useQuery({
|
const { data: downloads } = useQuery({
|
||||||
queryKey: ["downloads"],
|
queryKey: ["downloads"],
|
||||||
queryFn: () => getDownloads().then((r) => r.data),
|
queryFn: () => getDownloads().then((r) => r.data),
|
||||||
refetchInterval: (query) => {
|
refetchInterval: (query) => {
|
||||||
@@ -84,29 +85,117 @@ function DownloadIndicator() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
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"
|
(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];
|
if (!activeDownloads.length && !tasks.length && !discRunning) return null;
|
||||||
const pct = top.progress_percent ?? 0;
|
|
||||||
|
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 (
|
return (
|
||||||
|
<div className="relative group shrink-0">
|
||||||
<Link
|
<Link
|
||||||
to="/downloads"
|
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 shrink-0"
|
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"
|
||||||
title={`${active.length} download${active.length > 1 ? "s" : ""} in progress`}
|
|
||||||
>
|
>
|
||||||
<svg className="w-3 h-3 animate-spin text-accent shrink-0" 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" />
|
<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" />
|
<path className="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-mono tabular-nums text-[11px]">{pct.toFixed(0)}%</span>
|
{label}
|
||||||
{active.length > 1 && (
|
{totalActive > 1 && (
|
||||||
<span className="hidden sm:inline text-zinc-500">+{active.length - 1}</span>
|
<span className="hidden sm:inline text-zinc-500">+{totalActive - 1}</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +214,7 @@ function NavItem({ to, children, badge }) {
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{badge > 0 && (
|
{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}
|
{badge > 99 ? "99+" : badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -147,7 +236,7 @@ function DropItem({ to, children, badge }) {
|
|||||||
>
|
>
|
||||||
<span>{children}</span>
|
<span>{children}</span>
|
||||||
{badge > 0 && (
|
{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}
|
{badge > 99 ? "99+" : badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -201,24 +290,54 @@ function useNewVideosCount() {
|
|||||||
return channels.reduce((sum, c) => sum + (c.new_count ?? 0), 0);
|
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() {
|
export default function Layout() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
|
||||||
const newCount = useNewVideosCount();
|
const newCount = useNewVideosCount();
|
||||||
|
const offline = useOffline();
|
||||||
|
useNewVideoNotifications(newCount);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col overflow-hidden" style={{ height: "100dvh" }}>
|
<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 */}
|
||||||
<header className="shrink-0 z-40 bg-zinc-950/90 backdrop-blur border-b border-zinc-800">
|
<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">
|
<div className="max-w-screen-xl mx-auto px-3 h-12 sm:h-14 flex items-center gap-2 sm:gap-4">
|
||||||
{/* Logo */}
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/")}
|
|
||||||
className="font-display font-bold text-base sm:text-lg text-accent shrink-0"
|
|
||||||
>
|
|
||||||
YT
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Search — min-w-0 prevents it from overflowing on narrow screens */}
|
{/* Search — min-w-0 prevents it from overflowing on narrow screens */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { createDownload, toggleQueue, toggleLike, dismissDiscovery, getSettings } from "../api";
|
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) {
|
function formatDuration(secs) {
|
||||||
if (!secs) return null;
|
if (!secs) return null;
|
||||||
const h = Math.floor(secs / 3600);
|
const h = Math.floor(secs / 3600);
|
||||||
@@ -33,8 +43,8 @@ function IconBtn({ onClick, title, active, pending, children }) {
|
|||||||
onClick={(e) => { e.stopPropagation(); onClick(e); }}
|
onClick={(e) => { e.stopPropagation(); onClick(e); }}
|
||||||
title={title}
|
title={title}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center justify-center w-7 h-7 rounded-full transition-all duration-150",
|
"flex items-center justify-center w-7 h-7 rounded-md transition-all duration-150",
|
||||||
active ? "text-accent" : "text-zinc-600 hover:text-zinc-200",
|
active ? "text-zinc-100" : "text-zinc-600 hover:text-zinc-300",
|
||||||
pending && "opacity-60 cursor-default",
|
pending && "opacity-60 cursor-default",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -121,13 +131,13 @@ function ThumbnailBlock({ video, isWatched, duration, calmMode, onDismiss, class
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{video.download_resolution && (
|
{video.download_resolution && (
|
||||||
<span className="absolute bottom-1.5 left-1.5 text-[10px] font-medium px-1.5 py-0.5 rounded 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}
|
{video.download_resolution}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isWatched && (
|
{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" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-black/25 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
<div className="absolute inset-0 bg-black/25 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
@@ -139,9 +149,9 @@ function ThumbnailBlock({ video, isWatched, duration, calmMode, onDismiss, class
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{video.watch_progress_seconds > 0 && video.duration_seconds > 0 && (
|
{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
|
<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}%` }}
|
style={{ width: `${Math.min(video.watch_progress_seconds / video.duration_seconds, 1) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -250,7 +260,7 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => navigate(`/watch/${video.youtube_video_id}`)}
|
onClick={() => navigate(`/watch/${video.youtube_video_id}`)}
|
||||||
className="group flex gap-3 sm:gap-4 px-2 sm:px-3 py-2 sm:py-3 rounded-xl 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 */}
|
{/* Thumbnail — compact on mobile, wide on desktop */}
|
||||||
<ThumbnailBlock
|
<ThumbnailBlock
|
||||||
@@ -302,15 +312,15 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description — desktop only, 2 lines max */}
|
{/* Description snippet — desktop only, URLs stripped */}
|
||||||
{video.description && (
|
{video.description && snippetText(video.description) && (
|
||||||
<p className="hidden sm:block text-[11px] leading-relaxed text-zinc-500 line-clamp-2">
|
<p className="hidden sm:block text-[11px] leading-relaxed text-zinc-500 line-clamp-3">
|
||||||
{video.description.replace(/\n+/g, " ")}
|
{snippetText(video.description)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions — always visible on mobile, fade on desktop */}
|
{/* Actions — hover only everywhere */}
|
||||||
<div className="mt-auto pt-1 sm:opacity-0 sm:pointer-events-none sm:group-hover:opacity-100 sm:group-hover:pointer-events-auto sm:transition-opacity sm:duration-150">
|
<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}
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -323,8 +333,7 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
|
|||||||
<div
|
<div
|
||||||
onClick={() => navigate(`/watch/${video.youtube_video_id}`)}
|
onClick={() => navigate(`/watch/${video.youtube_video_id}`)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"group relative flex flex-col cursor-pointer rounded-2xl",
|
"group relative flex flex-col cursor-pointer",
|
||||||
"bg-zinc-900 hover:bg-zinc-800/80 transition-colors duration-150",
|
|
||||||
size === "sm" && "text-xs",
|
size === "sm" && "text-xs",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -334,10 +343,10 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
|
|||||||
duration={duration}
|
duration={duration}
|
||||||
calmMode={calmMode}
|
calmMode={calmMode}
|
||||||
onDismiss={() => dismissMut.mutate()}
|
onDismiss={() => dismissMut.mutate()}
|
||||||
className="aspect-video rounded-t-2xl overflow-hidden"
|
className="aspect-video rounded-lg overflow-hidden"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex gap-2 sm:gap-2.5 p-2 sm:p-3 flex-1">
|
<div className="flex gap-2 sm:gap-2.5 pt-2 sm:pt-2.5 pb-1 flex-1">
|
||||||
{/* Channel avatar */}
|
{/* Channel avatar */}
|
||||||
<div
|
<div
|
||||||
className="shrink-0 mt-0.5"
|
className="shrink-0 mt-0.5"
|
||||||
@@ -347,10 +356,10 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
|
|||||||
<img
|
<img
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-7 h-7 sm:w-8 sm:h-8 rounded-full object-cover hover:ring-2 hover:ring-accent/50 transition-all"
|
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-700 flex items-center justify-center text-[11px] font-bold text-zinc-400 hover:ring-2 hover:ring-accent/50 transition-all">
|
<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}
|
{avatarLetter}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -126,6 +126,16 @@ export default function VideoPlayer() {
|
|||||||
}
|
}
|
||||||
}, [dlStatus?.status]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [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 ──────────────────────────────────────────────
|
// ── Trigger download on open ──────────────────────────────────────────────
|
||||||
const downloadMut = useMutation({
|
const downloadMut = useMutation({
|
||||||
mutationFn: (ytId) => createDownload(ytId),
|
mutationFn: (ytId) => createDownload(ytId),
|
||||||
@@ -158,7 +168,7 @@ export default function VideoPlayer() {
|
|||||||
saveTimerRef.current = setTimeout(() => {
|
saveTimerRef.current = setTimeout(() => {
|
||||||
if (video?.id) {
|
if (video?.id) {
|
||||||
const duration = video.duration_seconds ?? 0;
|
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 });
|
updateProgress(video.id, { watch_progress_seconds: secs, watched });
|
||||||
}
|
}
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
|
|||||||
@@ -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 {
|
@layer utilities {
|
||||||
.line-clamp-2 {
|
.line-clamp-2 {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { getChannel, getChannelVideos, followChannel, unfollowChannel, indexChannel, downloadChannel } from "../api";
|
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 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) {
|
function formatSubs(n) {
|
||||||
if (!n) return null;
|
if (!n) return null;
|
||||||
@@ -12,35 +31,59 @@ function formatSubs(n) {
|
|||||||
return String(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() {
|
export default function ChannelPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
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({
|
const { data: channel, isLoading: loadingChannel } = useQuery({
|
||||||
queryKey: ["channel", id],
|
queryKey: ["channel", id],
|
||||||
queryFn: () => getChannel(id).then((r) => r.data),
|
queryFn: () => getChannel(id).then((r) => r.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: videos, isLoading: loadingVideos } = useQuery({
|
const effectiveSort = tab === "popular" ? "popular" : sort;
|
||||||
queryKey: ["channel-videos", id],
|
|
||||||
queryFn: () => getChannelVideos(id).then((r) => r.data),
|
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({
|
const followMut = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
channel?.status === "followed" ? unfollowChannel(id) : followChannel(id),
|
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({
|
const indexMut = useMutation({
|
||||||
mutationFn: () => indexChannel(id),
|
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 [dlResult, setDlResult] = useState(null);
|
||||||
const [videoSort, setVideoSort] = useState("newest");
|
|
||||||
const dlMut = useMutation({
|
const dlMut = useMutation({
|
||||||
mutationFn: () => downloadChannel(id),
|
mutationFn: () => downloadChannel(id),
|
||||||
onSuccess: (res) => {
|
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) {
|
if (loadingChannel) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16">
|
<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>;
|
if (!channel) return <p className="text-zinc-500">Channel not found.</p>;
|
||||||
|
|
||||||
const isFollowed = channel.status === "followed";
|
const isFollowed = channel.status === "followed";
|
||||||
|
const isPending = indexMut.isPending || fullIndexMut.isPending || deepSearchMut.isPending || exploreMut.isPending || popularMut.isPending || indexing;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-5">
|
||||||
{/* Channel header — banner with overlay, or plain if no banner */}
|
{/* Banner */}
|
||||||
<div className={`-mx-4 -mt-6 relative ${channel.banner_url ? "" : "bg-zinc-900"} rounded-b-2xl overflow-hidden`}>
|
<div className={`-mx-4 -mt-6 relative ${channel.banner_url ? "" : "bg-zinc-900"} rounded-b-2xl overflow-hidden`}>
|
||||||
{channel.banner_url && (
|
{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-3`}>
|
||||||
<div className={`${channel.banner_url ? "absolute bottom-0 inset-x-0" : ""} px-4 pb-4 pt-3 flex items-end gap-4`}>
|
|
||||||
{/* Avatar */}
|
|
||||||
{channel.thumbnail_url ? (
|
{channel.thumbnail_url ? (
|
||||||
<img
|
<img src={channel.thumbnail_url} alt={channel.name}
|
||||||
src={channel.thumbnail_url}
|
className="w-14 h-14 sm:w-20 sm:h-20 rounded-full object-cover shrink-0 ring-2 ring-black/40" />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<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()}
|
{channel.name?.[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Name + meta */}
|
|
||||||
<div className="flex-1 min-w-0">
|
<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>
|
<h1 className="font-display font-bold text-lg sm:text-2xl text-white drop-shadow leading-tight">{channel.name}</h1>
|
||||||
<p className="text-xs sm:text-sm text-zinc-300 mt-0.5 drop-shadow">
|
<p className="text-xs text-zinc-400 mt-0.5 drop-shadow">
|
||||||
{[
|
{[
|
||||||
formatSubs(channel.subscriber_count) && `${formatSubs(channel.subscriber_count)} subscribers`,
|
formatSubs(channel.subscriber_count) && `${formatSubs(channel.subscriber_count)} subs`,
|
||||||
`${channel.video_count} videos indexed`,
|
channel.video_count && `${channel.video_count} indexed`,
|
||||||
].filter(Boolean).join(" · ")}
|
].filter(Boolean).join(" · ")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
<div className="hidden sm:flex items-center gap-2 shrink-0">
|
||||||
<div className="flex items-center gap-2 shrink-0 flex-wrap justify-end">
|
<button onClick={() => followMut.mutate()} disabled={followMut.isPending}
|
||||||
{dlResult != null && (
|
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"}`}>
|
||||||
<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"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isFollowed ? "Following" : "Follow"}
|
{isFollowed ? "Following" : "Follow"}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description below banner */}
|
{/* Mobile actions */}
|
||||||
{channel.description && (
|
<div className="sm:hidden flex items-center gap-2 -mt-1">
|
||||||
<p className="text-sm text-zinc-400 line-clamp-3 -mt-4">{channel.description}</p>
|
<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 */}
|
{channel.description && (
|
||||||
{loadingVideos ? (
|
<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="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 className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : videos?.length ? (
|
) : playlistVideos?.length ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-end">
|
<div className="flex flex-col gap-1">
|
||||||
<SortPicker value={videoSort} onChange={setVideoSort} options={VIDEO_SORTS} />
|
{playlistVideos.map((v) => (
|
||||||
</div>
|
<VideoCard key={v.youtube_video_id} video={v} variant="list" />
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{sortVideos(videos, videoSort).map((v) => (
|
|
||||||
<VideoCard key={v.youtube_video_id} video={{ ...v, channel_name: channel.name }} variant="list" />
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function ContinueWatchingPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<p className="text-zinc-500 text-sm mt-1">
|
||||||
Videos you've started but not finished will appear here.
|
Videos you've started but not finished will appear here.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
getDiscovery, getDiscoveryVideos,
|
getDiscovery, getDiscoveryVideos,
|
||||||
followDiscovery, dismissDiscovery, dismissDiscoveryVideo, refreshDiscovery,
|
followDiscovery, dismissDiscovery, dismissDiscoveryVideo, refreshDiscovery,
|
||||||
|
getDiscoveryStatus,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import VideoCard from "../components/VideoCard";
|
import VideoCard from "../components/VideoCard";
|
||||||
import { scrollToTop } from "../utils/scroll";
|
import { scrollToTop } from "../utils/scroll";
|
||||||
@@ -150,7 +151,7 @@ function ChannelCard({ item }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!featured && item.description && (
|
{!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">
|
<div className="mt-auto pt-1">
|
||||||
@@ -160,7 +161,7 @@ function ChannelCard({ item }) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => followMut.mutate()}
|
onClick={() => followMut.mutate()}
|
||||||
disabled={busy}
|
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
|
Follow
|
||||||
</button>
|
</button>
|
||||||
@@ -176,7 +177,7 @@ function Tab({ active, onClick, children, count }) {
|
|||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={[
|
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
|
active
|
||||||
? "border-accent text-zinc-100"
|
? "border-accent text-zinc-100"
|
||||||
: "border-transparent text-zinc-500 hover:text-zinc-300",
|
: "border-transparent text-zinc-500 hover:text-zinc-300",
|
||||||
@@ -213,12 +214,21 @@ export default function DiscoveryPage() {
|
|||||||
placeholderData: (prev) => prev,
|
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({
|
const refreshMut = useMutation({
|
||||||
mutationFn: refreshDiscovery,
|
mutationFn: refreshDiscovery,
|
||||||
onSuccess: () => setTimeout(() => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["discovery"] });
|
// Discovery runs as a background job and takes several minutes.
|
||||||
qc.invalidateQueries({ queryKey: ["discovery-videos"] });
|
// Invalidate status immediately so the "queued" state shows, then
|
||||||
}, 8000),
|
// re-check every 2 minutes until results land.
|
||||||
|
qc.invalidateQueries({ queryKey: ["discovery-status"] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDismissVideo = (video) => {
|
const handleDismissVideo = (video) => {
|
||||||
@@ -237,12 +247,24 @@ export default function DiscoveryPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Header */}
|
{/* 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>
|
<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
|
<button
|
||||||
onClick={() => refreshMut.mutate()}
|
onClick={() => refreshMut.mutate()}
|
||||||
disabled={refreshMut.isPending}
|
disabled={refreshMut.isPending || discStatus?.progress?.running}
|
||||||
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"
|
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 && (
|
{refreshMut.isPending && (
|
||||||
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
@@ -250,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" />
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
{refreshMut.isPending ? "Searching…" : "Find more"}
|
{discStatus?.progress?.running ? "Running…" : refreshMut.isSuccess ? "Queued ✓" : refreshMut.isPending ? "Queueing…" : "Find more"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{refreshMut.isSuccess && !refreshMut.isPending && (
|
{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-300">
|
<div className="px-4 py-3 rounded-xl bg-zinc-800/60 border border-zinc-700/50 text-sm text-zinc-400">
|
||||||
Searching YouTube for new channels — results will appear in a few seconds.
|
Discovery is running — progress shows in the top bar. Searches are spaced out over ~20 minutes. Runs automatically every day.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -284,7 +306,7 @@ export default function DiscoveryPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<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.
|
Follow a few channels first, then hit "Find more" to discover similar ones.
|
||||||
</p>
|
</p>
|
||||||
@@ -292,7 +314,7 @@ export default function DiscoveryPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => refreshMut.mutate()}
|
onClick={() => refreshMut.mutate()}
|
||||||
disabled={refreshMut.isPending}
|
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"}
|
{refreshMut.isPending ? "Searching…" : "Find channels"}
|
||||||
</button>
|
</button>
|
||||||
@@ -308,15 +330,15 @@ export default function DiscoveryPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => { setChannelPage(p => p - 1); scrollToTop(); }}
|
onClick={() => { setChannelPage(p => p - 1); scrollToTop(); }}
|
||||||
disabled={channelPage === 0}
|
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
|
← Prev
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => { setChannelPage(p => p + 1); scrollToTop(); }}
|
onClick={() => { setChannelPage(p => p + 1); scrollToTop(); }}
|
||||||
disabled={!hasNextChannelPage}
|
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 →
|
Next →
|
||||||
</button>
|
</button>
|
||||||
@@ -338,15 +360,15 @@ export default function DiscoveryPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => { setVideoPage(p => p - 1); scrollToTop(); }}
|
onClick={() => { setVideoPage(p => p - 1); scrollToTop(); }}
|
||||||
disabled={videoPage === 0}
|
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
|
← Prev
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => { setVideoPage(p => p + 1); scrollToTop(); }}
|
onClick={() => { setVideoPage(p => p + 1); scrollToTop(); }}
|
||||||
disabled={!hasNextVideoPage}
|
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 →
|
Next →
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
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";
|
import SortPicker from "../components/SortPicker";
|
||||||
|
|
||||||
const HISTORY_SORTS = [
|
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({
|
const clearAllMut = useMutation({
|
||||||
mutationFn: deleteAllDownloads,
|
mutationFn: deleteAllDownloads,
|
||||||
onSuccess: () => {
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16">
|
<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;
|
const hasRemovable = history.length > 0 || trash.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="font-display font-bold text-2xl text-zinc-100">Downloads</h1>
|
<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 && (
|
{hasRemovable && (
|
||||||
confirmClear ? (
|
confirmClear ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -126,6 +147,30 @@ export default function DownloadsPage() {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{active.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getChannelGroups, createChannelGroup, deleteChannelGroup, renameChannelGroup,
|
getChannelGroups, createChannelGroup, deleteChannelGroup, renameChannelGroup,
|
||||||
addChannelToGroup, removeChannelFromGroup,
|
addChannelToGroup, removeChannelFromGroup,
|
||||||
getSettings, bulkChannelAction, followBulk, updateChannelNotes,
|
getSettings, bulkChannelAction, followBulk, updateChannelNotes,
|
||||||
|
getRssFeedUrl,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import VideoCard from "../components/VideoCard";
|
import VideoCard from "../components/VideoCard";
|
||||||
import SortPicker from "../components/SortPicker";
|
import SortPicker from "../components/SortPicker";
|
||||||
@@ -709,7 +710,7 @@ export default function Following() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<p className="text-zinc-500 text-sm mt-1">
|
||||||
Hit Follow on a channel while watching a video or searching.
|
Hit Follow on a channel while watching a video or searching.
|
||||||
</p>
|
</p>
|
||||||
@@ -765,7 +766,7 @@ export default function Following() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => dlAllMut.mutate()}
|
onClick={() => dlAllMut.mutate()}
|
||||||
disabled={dlAllMut.isPending}
|
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"}
|
{dlAllMut.isPending ? <><Spinner /> Queuing…</> : "Download all new"}
|
||||||
</button>
|
</button>
|
||||||
@@ -780,13 +781,13 @@ export default function Following() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex items-center gap-1 border-b border-zinc-800">
|
<div className="flex items-center gap-0.5 border-b border-zinc-800">
|
||||||
{[["channels", "Channels"], ["feed", "Latest uploads"], ["groups", "Groups"]].map(([key, label]) => (
|
{[["channels", "Channels"], ["feed", "Feed"], ["health", "Health"], ["groups", "Groups"]].map(([key, label]) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => setTab(key)}
|
onClick={() => setTab(key)}
|
||||||
className={[
|
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
|
tab === key
|
||||||
? "border-accent text-zinc-100"
|
? "border-accent text-zinc-100"
|
||||||
: "border-transparent text-zinc-500 hover:text-zinc-300",
|
: "border-transparent text-zinc-500 hover:text-zinc-300",
|
||||||
@@ -794,7 +795,7 @@ export default function Following() {
|
|||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{key === "groups" && groups.length > 0 && (
|
{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>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -1022,6 +1023,65 @@ export default function Following() {
|
|||||||
</div>
|
</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 ── */}
|
{/* ── Groups tab ── */}
|
||||||
{tab === "groups" && (
|
{tab === "groups" && (
|
||||||
<GroupsPanel groups={groups} channels={channels} />
|
<GroupsPanel groups={groups} channels={channels} />
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function History() {
|
|||||||
</div>
|
</div>
|
||||||
) : videos.length === 0 ? (
|
) : videos.length === 0 ? (
|
||||||
<div className="flex flex-col items-center gap-3 py-20 text-center">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -43,15 +43,15 @@ export default function History() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => { setPage(p => p - 1); scrollToTop(); }}
|
onClick={() => { setPage(p => p - 1); scrollToTop(); }}
|
||||||
disabled={page === 0}
|
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
|
← Prev
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => { setPage(p => p + 1); scrollToTop(); }}
|
onClick={() => { setPage(p => p + 1); scrollToTop(); }}
|
||||||
disabled={!hasNext}
|
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 →
|
Next →
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const PAGE_SIZE = 25;
|
|||||||
const FEED_MODES = [
|
const FEED_MODES = [
|
||||||
{ value: "ranked", label: "For you", hint: "Ranked by your taste" },
|
{ value: "ranked", label: "For you", hint: "Ranked by your taste" },
|
||||||
{ value: "chronological", label: "New", hint: "Everything in date order" },
|
{ 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: "random", label: "Explore", hint: "Random from discovery pool" },
|
||||||
{ value: "inbox", label: "Inbox", hint: "New from followed channels since last visit" },
|
{ value: "inbox", label: "Inbox", hint: "New from followed channels since last visit" },
|
||||||
];
|
];
|
||||||
@@ -55,9 +56,9 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { data: feedData = [], isLoading: loadingFeed } = useQuery({
|
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),
|
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,
|
placeholderData: (prev) => prev,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,20 +108,21 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
) : hasFollowing ? (
|
) : hasFollowing ? (
|
||||||
<section className="flex flex-col gap-6">
|
<section className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
{/* Title row + secondary controls */}
|
||||||
<h2 className="font-display font-semibold text-xl text-zinc-200">Home</h2>
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center 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
|
<button
|
||||||
onClick={toggleViewMode}
|
onClick={toggleViewMode}
|
||||||
title={viewMode === "grid" ? "Switch to list view" : "Switch to grid view"}
|
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" ? (
|
{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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||||
</svg>
|
</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" />
|
<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>
|
</svg>
|
||||||
)}
|
)}
|
||||||
@@ -129,48 +131,39 @@ export default function Home() {
|
|||||||
onClick={handleHideWatchedToggle}
|
onClick={handleHideWatchedToggle}
|
||||||
title={hideWatched ? "Showing unwatched only" : "Showing all videos"}
|
title={hideWatched ? "Showing unwatched only" : "Showing all videos"}
|
||||||
className={[
|
className={[
|
||||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors border",
|
"flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium transition-colors",
|
||||||
hideWatched
|
hideWatched ? "text-accent" : "text-zinc-600 hover:text-zinc-400",
|
||||||
? "bg-accent/10 text-accent border-accent/30"
|
|
||||||
: "text-zinc-500 border-zinc-800 hover:text-zinc-300 hover:border-zinc-700",
|
|
||||||
].join(" ")}
|
].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"}
|
{hideWatched ? "Unwatched" : "All"}
|
||||||
</button>
|
</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 => (
|
{FEED_MODES.map(m => (
|
||||||
<button
|
<button
|
||||||
key={m.value}
|
key={m.value}
|
||||||
onClick={() => handleModeChange(m.value)}
|
onClick={() => handleModeChange(m.value)}
|
||||||
title={m.hint}
|
title={m.hint}
|
||||||
className={[
|
className={[
|
||||||
"relative px-3 py-1.5 rounded-lg text-sm font-medium transition-colors",
|
"relative px-2.5 py-1 rounded-md text-xs font-medium transition-colors",
|
||||||
mode === m.value
|
mode === m.value ? "bg-zinc-700 text-zinc-100" : "text-zinc-500 hover:text-zinc-300",
|
||||||
? "bg-zinc-700 text-zinc-100"
|
|
||||||
: "text-zinc-500 hover:text-zinc-300",
|
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{m.label}
|
{m.label}
|
||||||
{m.value === "inbox" && inboxCount > 0 && (
|
{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}
|
{inboxCount > 99 ? "99+" : inboxCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Duration filter */}
|
{/* 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]) => (
|
{[["short", "< 10 min"], ["medium", "10–30 min"], ["long", "30+ min"]].map(([val, label]) => (
|
||||||
<button
|
<button
|
||||||
key={val}
|
key={val}
|
||||||
@@ -188,7 +181,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === "inbox" && (
|
{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>
|
<p className="text-xs text-zinc-600">Unwatched videos from followed channels since your last visit.</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => markSeenMut.mutate()}
|
onClick={() => markSeenMut.mutate()}
|
||||||
@@ -200,11 +193,15 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{mode === "chronological" && (
|
{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" && (
|
{(mode === "ranked" || mode === "random") && (
|
||||||
<div className="flex items-center justify-between -mt-3">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs text-zinc-600">Random from your discovery pool — no weighting, no ranking.</p>
|
<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
|
<button
|
||||||
onClick={handleReshuffle}
|
onClick={handleReshuffle}
|
||||||
className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors shrink-0 ml-4"
|
className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors shrink-0 ml-4"
|
||||||
@@ -238,15 +235,15 @@ export default function Home() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => { setPage(p => p - 1); scrollToTop(); }}
|
onClick={() => { setPage(p => p - 1); scrollToTop(); }}
|
||||||
disabled={page === 0}
|
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
|
← Prev
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => { setPage(p => p + 1); scrollToTop(); }}
|
onClick={() => { setPage(p => p + 1); scrollToTop(); }}
|
||||||
disabled={!hasNextPage}
|
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 →
|
Next →
|
||||||
</button>
|
</button>
|
||||||
@@ -260,7 +257,7 @@ export default function Home() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => surpriseMut.mutate()}
|
onClick={() => surpriseMut.mutate()}
|
||||||
disabled={surpriseMut.isPending}
|
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="flex items-center gap-2">
|
||||||
<span className="text-2xl">✦</span>
|
<span className="text-2xl">✦</span>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function LikedPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-display font-bold text-2xl text-zinc-100">Liked videos</h1>
|
<h1 className="font-display font-bold text-2xl text-zinc-100">Liked videos</h1>
|
||||||
@@ -54,7 +54,7 @@ export default function LikedPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => refreshMut.mutate()}
|
onClick={() => refreshMut.mutate()}
|
||||||
disabled={refreshMut.isPending || refreshMut.isSuccess}
|
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 ? (
|
{refreshMut.isPending ? (
|
||||||
<>
|
<>
|
||||||
@@ -82,7 +82,7 @@ export default function LikedPage() {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
<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" />
|
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>
|
</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">
|
<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.
|
Hit the heart on any video. Liked videos teach the discovery engine what you enjoy.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function QueuePage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<p className="text-zinc-500 text-sm mt-1">
|
||||||
Hit the queue icon on any video to save it for later.
|
Hit the queue icon on any video to save it for later.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export default function SearchResults() {
|
|||||||
const channelsFirst = channels.length > 0 && (videos.length === 0 || source === "local");
|
const channelsFirst = channels.length > 0 && (videos.length === 0 || source === "local");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<h1 className="font-display font-semibold text-xl text-zinc-100">
|
<h1 className="font-display font-semibold text-xl text-zinc-100">
|
||||||
Results for <span className="text-accent">"{q}"</span>
|
Results for <span className="text-accent">"{q}"</span>
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ function SubscriptionImportSection() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPaste((v) => !v)}
|
onClick={() => setShowPaste((v) => !v)}
|
||||||
className="shrink-0 px-4 py-2 rounded-lg bg-accent text-black text-sm font-medium hover:bg-yellow-300 transition-colors"
|
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"}
|
{showPaste ? "Cancel" : "Paste list"}
|
||||||
</button>
|
</button>
|
||||||
@@ -430,7 +430,7 @@ function SubscriptionImportSection() {
|
|||||||
<button
|
<button
|
||||||
onClick={handlePaste}
|
onClick={handlePaste}
|
||||||
disabled={loading || !pasteText.trim()}
|
disabled={loading || !pasteText.trim()}
|
||||||
className="self-end px-4 py-2 rounded-lg bg-accent text-black text-sm font-medium hover:bg-yellow-300 transition-colors disabled:opacity-50"
|
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`}
|
{loading ? "Importing…" : `Import ${(pasteText.match(/@[\w.-]+(?=•)/g) || []).length} channels`}
|
||||||
</button>
|
</button>
|
||||||
@@ -600,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() {
|
export default function SettingsPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -650,6 +706,25 @@ export default function SettingsPage() {
|
|||||||
<DiagnosticSection />
|
<DiagnosticSection />
|
||||||
<SubscriptionImportSection />
|
<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 */}
|
{/* Download quality */}
|
||||||
<Section title="Download quality">
|
<Section title="Download quality">
|
||||||
<Row
|
<Row
|
||||||
@@ -691,6 +766,18 @@ export default function SettingsPage() {
|
|||||||
onChange={(v) => set({ auto_download_on_sync: v })}
|
onChange={(v) => set({ auto_download_on_sync: v })}
|
||||||
/>
|
/>
|
||||||
</Row>
|
</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>
|
</Section>
|
||||||
|
|
||||||
{/* Feed */}
|
{/* Feed */}
|
||||||
@@ -792,6 +879,9 @@ export default function SettingsPage() {
|
|||||||
</Row>
|
</Row>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<NotificationsSection />
|
||||||
|
|
||||||
{/* Data */}
|
{/* Data */}
|
||||||
<Section title="Data">
|
<Section title="Data">
|
||||||
<div className="px-5 py-4 flex items-center justify-between">
|
<div className="px-5 py-4 flex items-center justify-between">
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ function fmt(seconds) {
|
|||||||
return `${h}h ${m}m`;
|
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 }) {
|
function StatCard({ label, value, sub }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-zinc-900 rounded-2xl p-4 flex flex-col gap-1">
|
<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 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 maxCatCount = Math.max(...(data.top_categories || []).map(c => c.watch_count || 0), 1);
|
||||||
const topTags = (data.taste_profile || []).slice(0, 12);
|
const allTags = data.taste_profile || [];
|
||||||
const maxTagScore = Math.max(...topTags.map(t => t.score || 0), 1);
|
const maxTagScore = Math.max(...allTags.map(t => t.score || 0), 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8 max-w-4xl mx-auto">
|
<div className="flex flex-col gap-8 max-w-4xl mx-auto">
|
||||||
<h1 className="font-display font-bold text-2xl text-white">Stats</h1>
|
<h1 className="font-display font-bold text-2xl text-white">Stats</h1>
|
||||||
|
|
||||||
{/* Top numbers */}
|
{/* 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="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 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="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>
|
</div>
|
||||||
|
|
||||||
{/* Engagement row */}
|
{/* 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
|
<StatCard
|
||||||
label="Avg completion"
|
label="Avg completion"
|
||||||
value={`${data.avg_completion_percent ?? 0}%`}
|
value={`${data.avg_completion_percent ?? 0}%`}
|
||||||
@@ -79,7 +86,12 @@ export default function Stats() {
|
|||||||
<StatCard
|
<StatCard
|
||||||
label="Finished"
|
label="Finished"
|
||||||
value={(data.finished_count || 0).toLocaleString()}
|
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
|
<StatCard
|
||||||
label="Bailed early"
|
label="Bailed early"
|
||||||
@@ -91,6 +103,11 @@ export default function Stats() {
|
|||||||
value={(data.rewatched_videos || 0).toLocaleString()}
|
value={(data.rewatched_videos || 0).toLocaleString()}
|
||||||
sub={`${data.total_rewatches || 0} total rewatches`}
|
sub={`${data.total_rewatches || 0} total rewatches`}
|
||||||
/>
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Total liked"
|
||||||
|
value={(data.total_liked || 0).toLocaleString()}
|
||||||
|
sub="videos"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Activity chart */}
|
{/* Activity chart */}
|
||||||
@@ -170,41 +187,133 @@ export default function Stats() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Taste profile */}
|
{/* Disk usage */}
|
||||||
{topTags.length > 0 && (
|
{data.disk?.total_bytes && (
|
||||||
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
|
<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">
|
<div className="flex items-baseline gap-2">
|
||||||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Taste profile</h2>
|
<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>
|
</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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{topTags.map(t => {
|
{allTags.slice(0, 10).map(t => {
|
||||||
const intensity = t.score / maxTagScore;
|
const intensity = t.score / maxTagScore;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={t.tag}
|
key={t.tag}
|
||||||
title={`score: ${t.score.toFixed(1)}`}
|
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={{
|
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}%)`,
|
color: `hsl(50,95%,${55 + intensity * 20}%)`,
|
||||||
fontSize: `${11 + intensity * 4}px`,
|
fontSize: `${12 + intensity * 5}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t.tag}
|
{t.tag}
|
||||||
<button
|
<button
|
||||||
onClick={() => deleteTag.mutate(t.tag)}
|
onClick={() => deleteTag.mutate(t.tag)}
|
||||||
disabled={deleteTag.isPending}
|
disabled={deleteTag.isPending}
|
||||||
className="opacity-40 hover:opacity-100 transition-opacity leading-none ml-0.5"
|
className="opacity-30 hover:opacity-100 transition-opacity leading-none ml-0.5"
|
||||||
title="Remove from taste profile"
|
title="Remove"
|
||||||
>
|
>×</button>
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
getSettings, updateSettings, getRelatedVideos, getDownloads, rateVideo,
|
getSettings, updateSettings, getRelatedVideos, getDownloads, rateVideo,
|
||||||
getBookmarks, createBookmark, updateBookmark, deleteBookmark, importChapters, clearChapters,
|
getBookmarks, createBookmark, updateBookmark, deleteBookmark, importChapters, clearChapters,
|
||||||
getCollections, addToCollection, getQueue,
|
getCollections, addToCollection, getQueue,
|
||||||
getVideoComments, refreshVideoComments,
|
getVideoComments, refreshVideoComments, getAvailableSubs, getSubtitleFiles, downloadSubs,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import VideoCard from "../components/VideoCard";
|
import VideoCard from "../components/VideoCard";
|
||||||
|
|
||||||
@@ -66,8 +66,8 @@ function linkify(text) {
|
|||||||
function DescriptionBox({ text }) {
|
function DescriptionBox({ text }) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const lines = text.split("\n");
|
const lines = text.split("\n");
|
||||||
const hasMore = lines.length > 4 || text.length > 300;
|
const hasMore = lines.length > 2 || text.length > 200;
|
||||||
const displayed = expanded ? text : lines.slice(0, 4).join("\n") + (hasMore ? "…" : "");
|
const displayed = expanded ? text : lines.slice(0, 2).join("\n") + (hasMore ? "…" : "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -230,7 +230,7 @@ function Placeholder({ video, dlStatus, onPlay, onDownloadAndPlay, isDownloading
|
|||||||
) : onDownloadAndPlay ? (
|
) : onDownloadAndPlay ? (
|
||||||
<button
|
<button
|
||||||
onClick={onDownloadAndPlay}
|
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">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
@@ -561,8 +561,10 @@ export default function Watch() {
|
|||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [queued, setQueued] = useState(null);
|
const [queued, setQueued] = useState(null);
|
||||||
const [liked, setLiked] = 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 [selectedQuality, setSelectedQuality] = useState(null);
|
||||||
|
const [selectedSubLang, setSelectedSubLang] = useState("");
|
||||||
const [speed, setSpeed] = useState(1);
|
const [speed, setSpeed] = useState(1);
|
||||||
const [autoplay, setAutoplay] = useState(false);
|
const [autoplay, setAutoplay] = useState(false);
|
||||||
const [theater, setTheater] = useState(false);
|
const [theater, setTheater] = useState(false);
|
||||||
@@ -658,6 +660,21 @@ export default function Watch() {
|
|||||||
staleTime: sidebarMode === "random" ? 0 : 5 * 60_000,
|
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({
|
const { data: dlStatus } = useQuery({
|
||||||
queryKey: ["download-status", downloadId],
|
queryKey: ["download-status", downloadId],
|
||||||
queryFn: () => getDownload(downloadId).then(r => r.data),
|
queryFn: () => getDownload(downloadId).then(r => r.data),
|
||||||
@@ -697,33 +714,47 @@ export default function Watch() {
|
|||||||
.catch(() => { pollTimerRef.current = setTimeout(() => pollForFile(url), 1500); });
|
.catch(() => { pollTimerRef.current = setTimeout(() => pollForFile(url), 1500); });
|
||||||
}, [fileReady]);
|
}, [fileReady]);
|
||||||
|
|
||||||
|
// Only poll once the backend confirms the download is fully written.
|
||||||
|
// Polling before status==="complete" risks playing a partial file.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dlStatus?.file_url || fileReady || !playRequested) return;
|
if (fileReady || !playRequested) return;
|
||||||
pollForFile(dlStatus.file_url);
|
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);
|
return () => clearTimeout(pollTimerRef.current);
|
||||||
}, [dlStatus?.file_url, fileReady, pollForFile, playRequested]);
|
}, [playRequested, fileReady, dlStatus?.status, dlStatus?.file_url, video?.is_downloaded, youtubeVideoId, pollForFile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!video?.is_downloaded || fileReady || !playRequested) return;
|
|
||||||
pollForFile(`/files/${youtubeVideoId}.mp4`);
|
|
||||||
}, [video?.is_downloaded, playRequested]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
const downloadMut = useMutation({
|
const downloadMut = useMutation({
|
||||||
mutationFn: () => createDownload(youtubeVideoId, selectedQuality),
|
mutationFn: ({ quality, subLang } = {}) =>
|
||||||
|
createDownload(youtubeVideoId, quality ?? selectedQuality, subLang ?? selectedSubLang),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
const dl = res.data;
|
setDownloadId(res.data.id);
|
||||||
setDownloadId(dl.id);
|
|
||||||
refetchVideo();
|
refetchVideo();
|
||||||
if (dl.status === "complete" && dl.file_url) pollForFile(dl.file_url);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handlePlay = useCallback(() => setPlayRequested(true), []);
|
const handlePlay = useCallback(() => setPlayRequested(true), []);
|
||||||
const handleDownloadAndPlay = useCallback(() => {
|
const handleDownloadAndPlay = useCallback(() => {
|
||||||
setPlayRequested(true);
|
setPlayRequested(true);
|
||||||
downloadMut.mutate();
|
downloadMut.mutate({});
|
||||||
pollForFile(`/files/${youtubeVideoId}.mp4`);
|
}, [downloadMut]);
|
||||||
}, [downloadMut, pollForFile, youtubeVideoId]);
|
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) => {
|
const saveProgress = useCallback((secs) => {
|
||||||
if (!video?.id) return;
|
if (!video?.id) return;
|
||||||
@@ -773,9 +804,18 @@ export default function Watch() {
|
|||||||
onSuccess: (res) => { setLiked(res.data.liked); qc.invalidateQueries({ queryKey: ["liked-videos"] }); },
|
onSuccess: (res) => { setLiked(res.data.liked); qc.invalidateQueries({ queryKey: ["liked-videos"] }); },
|
||||||
});
|
});
|
||||||
|
|
||||||
const rateMut = useMutation({
|
const dislikeMut = useMutation({
|
||||||
mutationFn: (r) => rateVideo(video.id, r),
|
mutationFn: () => rateVideo(video.id, isDisliked ? 0 : -1),
|
||||||
onSuccess: (res) => setRating(res.data.rating),
|
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 () => {
|
const handlePiP = useCallback(async () => {
|
||||||
@@ -793,8 +833,9 @@ export default function Watch() {
|
|||||||
const startAt = video?.watch_progress_seconds ?? 0;
|
const startAt = video?.watch_progress_seconds ?? 0;
|
||||||
const isQueued = queued ?? video?.queued ?? false;
|
const isQueued = queued ?? video?.queued ?? false;
|
||||||
const isLiked = liked ?? video?.liked ?? 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 dlComplete = dlStatus?.status === "complete" || video?.is_downloaded;
|
||||||
|
const downloadedResolution = dlStatus?.resolution ?? video?.download_resolution;
|
||||||
const isFollowed = followMut.isSuccess || video?.channel_followed;
|
const isFollowed = followMut.isSuccess || video?.channel_followed;
|
||||||
const subs = formatSubs(channel?.subscriber_count);
|
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"}>
|
<div className={theater ? "flex flex-col gap-6" : "flex flex-col xl:flex-row gap-6"}>
|
||||||
|
|
||||||
{/* ── Left: video + info ───────────────────────────────────────────── */}
|
{/* ── Left: video + info ───────────────────────────────────────────── */}
|
||||||
<div className={theater ? "w-full flex flex-col gap-3 sm:gap-4" : "flex-1 min-w-0 flex flex-col gap-3 sm: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 */}
|
{/* 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"}>
|
<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;
|
if (video?.watch_progress_seconds > 10) v.currentTime = video.watch_progress_seconds;
|
||||||
v.play().catch(() => {});
|
v.play().catch(() => {});
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
{subtitleFiles.map((s) => (
|
||||||
|
<track
|
||||||
|
key={s.lang}
|
||||||
|
kind="subtitles"
|
||||||
|
src={s.url}
|
||||||
|
srcLang={s.lang}
|
||||||
|
label={s.lang}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
|
</video>
|
||||||
) : (
|
) : (
|
||||||
<Placeholder
|
<Placeholder
|
||||||
video={video}
|
video={video}
|
||||||
@@ -915,10 +966,15 @@ export default function Watch() {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
{!dlComplete && !isDownloading && !downloadMut.isPending && (
|
{!isDownloading && !downloadMut.isPending && !isRedownloading && (
|
||||||
<select
|
<select
|
||||||
value={selectedQuality ?? "best"}
|
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"
|
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>
|
</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 && (
|
{fileReady && (
|
||||||
<select
|
<select
|
||||||
value={speed}
|
value={speed}
|
||||||
@@ -952,10 +1071,10 @@ export default function Watch() {
|
|||||||
<Chip
|
<Chip
|
||||||
active={dlComplete}
|
active={dlComplete}
|
||||||
disabled={dlComplete || isDownloading || downloadMut.isPending}
|
disabled={dlComplete || isDownloading || downloadMut.isPending}
|
||||||
onClick={() => !dlComplete && !isDownloading && downloadMut.mutate()}
|
onClick={() => !dlComplete && !isDownloading && downloadMut.mutate({})}
|
||||||
>
|
>
|
||||||
{dlComplete ? (
|
{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 ? (
|
) : 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</>
|
<><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>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{video?.id && (
|
{video?.id && (
|
||||||
|
<>
|
||||||
<Chip active={isLiked} onClick={() => likeMut.mutate()} disabled={likeMut.isPending}>
|
<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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
{isLiked ? "Liked" : "Like"}
|
{isLiked ? "Liked" : "Like"}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
<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">
|
||||||
{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">
|
|
||||||
<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="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"/>
|
<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>
|
</svg>
|
||||||
Not for me
|
|
||||||
</Chip>
|
</Chip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{video?.id && (
|
{video?.id && (
|
||||||
<Chip active={isQueued} onClick={() => queueMut.mutate()} disabled={queueMut.isPending}>
|
<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">
|
<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 && (
|
{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">
|
<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="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"/>
|
<rect x="12" y="12" width="8" height="6" rx="1.5" strokeWidth="2"/>
|
||||||
</svg>
|
</svg>
|
||||||
Mini
|
|
||||||
</Chip>
|
</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">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
{theater ? (
|
{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" />
|
<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" />
|
<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>
|
</svg>
|
||||||
Theater
|
|
||||||
</Chip>
|
</Chip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ export default {
|
|||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: "#f5a623",
|
DEFAULT: "#ffffff",
|
||||||
light: "#fbbf45",
|
light: "#f4f4f5",
|
||||||
dark: "#d4891a",
|
dark: "#d4d4d8",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
aspectRatio: {
|
aspectRatio: {
|
||||||
|
|||||||
Reference in New Issue
Block a user