myYouTube/backend/app/main.py
vrubelroman fe16c08daa Implement Phase 6: MeTube download integration
- MeTubeClient encapsulates all MeTube HTTP/Socket.IO calls (verified against
  the real MeTube source: /add returns no job id, GET /history gives a queue
  snapshot for reconciliation, filenames arrive already relative, percent is
  a 0-100 float)
- download_jobs table + service: request/dedup active downloads, apply live
  Socket.IO events (added/updated/completed/canceled/cleared) matched by
  canonical YouTube URL, safe relative-path -> public media URL construction
- Reconciliation on startup against MeTube's live queue/done state (section 19):
  non-terminal jobs recovered where possible, else marked "unknown"; already
  completed jobs are left untouched
- POST/GET /api/videos/{id}/download(-status), recheck-local; feed/video
  detail now report real local availability instead of a stub
- Frontend: download button with live status polling (queued/downloading %/
  postprocessing/completed/failed+retry), local <video> playback with
  YouTube fallback on playback error
- health.py now delegates to MeTubeClient (single place for MeTube calls)

26 new backend tests (63 total). Verified live: Socket.IO connects
successfully to the real MeTube instance on deploy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:56:17 +00:00

100 lines
3.1 KiB
Python

import asyncio
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.sessions import SessionMiddleware
from starlette.types import Scope
class SPAStaticFiles(StaticFiles):
"""Falls back to index.html for unknown paths so client-side routes
(e.g. /categories) work on direct navigation/refresh, not just /."""
async def get_response(self, path: str, scope: Scope):
try:
return await super().get_response(path, scope)
except StarletteHTTPException as exc:
if exc.status_code == 404:
return await super().get_response("index.html", scope)
raise
from app.api.auth import router as auth_router
from app.api.categories import router as categories_router
from app.api.channels import router as channels_router
from app.api.feed import router as feed_router
from app.api.health import router as health_router
from app.api.sync import router as sync_router
from app.api.videos import router as videos_router
from app.config import settings
from app.db import SessionLocal
from app.services import download_jobs
from app.services.metube_client import MeTubeClient
from app.services.scheduler import create_scheduler
logging.basicConfig(level=settings.log_level)
logger = logging.getLogger(__name__)
async def _on_metube_event(event_name: str, payload) -> None:
db = SessionLocal()
try:
await download_jobs.handle_metube_event(db, event_name, payload)
except Exception:
logger.exception("Failed handling MeTube '%s' event", event_name)
finally:
db.close()
def _reconcile_download_jobs() -> None:
db = SessionLocal()
try:
download_jobs.reconcile_on_startup(db)
except Exception:
logger.exception("Failed to reconcile download jobs on startup")
finally:
db.close()
@asynccontextmanager
async def lifespan(_: FastAPI):
logger.info("Application startup")
_reconcile_download_jobs()
scheduler = create_scheduler()
scheduler.start()
metube_task = asyncio.create_task(MeTubeClient().run_event_listener(_on_metube_event))
yield
metube_task.cancel()
scheduler.shutdown(wait=False)
logger.info("Application shutdown")
app = FastAPI(title="MyYouTube", lifespan=lifespan)
app.add_middleware(
SessionMiddleware,
secret_key=settings.app_secret_key,
session_cookie="myyoutube_session",
same_site="lax",
https_only=settings.app_base_url.startswith("https"),
max_age=60 * 60 * 24 * 30,
)
app.include_router(health_router, prefix="/api")
app.include_router(auth_router, prefix="/api")
app.include_router(sync_router, prefix="/api")
app.include_router(channels_router, prefix="/api")
app.include_router(categories_router, prefix="/api")
app.include_router(feed_router, prefix="/api")
app.include_router(videos_router, prefix="/api")
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
if STATIC_DIR.is_dir():
app.mount("/", SPAStaticFiles(directory=STATIC_DIR, html=True), name="static")