- Admin: video management with Google Maps match status, manual restaurant mapping, restaurant remap on name change - Admin: user management tab with favorites/reviews detail - Admin: channel deletion fix for IDs with slashes - Frontend: responsive mobile layout (map top, list bottom, 2-row header) - Frontend: channel-colored map markers with legend - Frontend: my reviews list, favorites toggle, visit counter overlay - Frontend: force light mode for dark theme devices - Backend: visit tracking (site_visits table), user reviews endpoint - Backend: bulk transcript/extract streaming, geocode key fixes - Nginx config for production deployment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
90 lines
2.7 KiB
Python
90 lines
2.7 KiB
Python
"""Channel API routes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from core import youtube
|
|
|
|
_executor = ThreadPoolExecutor(max_workers=4)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class ChannelCreate(BaseModel):
|
|
channel_id: str
|
|
channel_name: str
|
|
title_filter: str | None = None
|
|
|
|
|
|
@router.get("")
|
|
def list_channels():
|
|
return youtube.get_active_channels()
|
|
|
|
|
|
@router.post("", status_code=201)
|
|
def create_channel(body: ChannelCreate):
|
|
try:
|
|
row_id = youtube.add_channel(body.channel_id, body.channel_name, body.title_filter)
|
|
return {"id": row_id, "channel_id": body.channel_id}
|
|
except Exception as e:
|
|
if "UQ_CHANNELS_CID" in str(e).upper():
|
|
raise HTTPException(409, "Channel already exists")
|
|
raise
|
|
|
|
|
|
def _do_scan(channel_id: str, full: bool):
|
|
"""Sync scan logic, runs in thread pool."""
|
|
channels = youtube.get_active_channels()
|
|
ch = next((c for c in channels if c["channel_id"] == channel_id), None)
|
|
if not ch:
|
|
return None
|
|
|
|
after = None if full else youtube.get_latest_video_date(ch["id"])
|
|
title_filter = ch.get("title_filter")
|
|
existing_vids = youtube.get_existing_video_ids(ch["id"])
|
|
|
|
candidates = []
|
|
total_fetched = 0
|
|
for videos_page in youtube.fetch_channel_videos_iter(channel_id, published_after=after):
|
|
total_fetched += len(videos_page)
|
|
new_in_page = 0
|
|
for v in videos_page:
|
|
if title_filter and title_filter not in v["title"]:
|
|
continue
|
|
if v["video_id"] in existing_vids:
|
|
continue
|
|
candidates.append(v)
|
|
new_in_page += 1
|
|
if not full and new_in_page == 0 and total_fetched > 50:
|
|
break
|
|
|
|
new_count = youtube.save_videos_batch(ch["id"], candidates)
|
|
return {"total_fetched": total_fetched, "new_videos": new_count}
|
|
|
|
|
|
@router.post("/{channel_id}/scan")
|
|
async def scan_channel(channel_id: str, full: bool = False):
|
|
"""Trigger a scan for new videos from this channel (non-blocking)."""
|
|
loop = asyncio.get_event_loop()
|
|
result = await loop.run_in_executor(_executor, _do_scan, channel_id, full)
|
|
if result is None:
|
|
raise HTTPException(404, "Channel not found")
|
|
return result
|
|
|
|
|
|
@router.delete("/{channel_id:path}")
|
|
def delete_channel(channel_id: str):
|
|
"""Deactivate a channel. Accepts channel_id or DB id."""
|
|
deleted = youtube.deactivate_channel(channel_id)
|
|
if not deleted:
|
|
# Try by DB id
|
|
deleted = youtube.deactivate_channel_by_db_id(channel_id)
|
|
if not deleted:
|
|
raise HTTPException(404, "Channel not found")
|
|
return {"ok": True}
|