Files
youclonedl/backend/routers/stats.py
Mattias Thall 1b010d4081 Track video clicks as engagement signals
- stats: started_count now includes any video opened (last_watched_at set)
  not just ones with saved progress seconds
- VideoPlayer: fires updateProgress immediately on open so even a
  click-and-back sets last_watched_at and counts as a started video

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 01:19:47 +02:00

200 lines
6.6 KiB
Python

import os
import shutil
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import text
from ..auth_utils import get_current_user
from ..config import settings
from ..database import get_db
from ..models import User, UserTagAffinity
router = APIRouter()
@router.get("")
def get_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
uid = current_user.id
totals = db.execute(
text("""
SELECT
COUNT(*) AS total_watched,
SUM(uv.watch_progress_seconds) AS total_watch_seconds
FROM user_videos uv
WHERE uv.user_id = :uid AND uv.watched = 1
"""),
{"uid": uid},
).mappings().first()
top_channels = db.execute(
text("""
SELECT c.id, c.name,
COUNT(*) AS watch_count,
SUM(uv.watch_progress_seconds) AS watch_seconds
FROM user_videos uv
JOIN videos v ON uv.video_id = v.id
JOIN channels c ON v.channel_id = c.id
WHERE uv.user_id = :uid AND uv.watched = 1
GROUP BY c.id, c.name
ORDER BY watch_seconds DESC
LIMIT 10
"""),
{"uid": uid},
).mappings().all()
daily = db.execute(
text("""
SELECT date(uv.last_watched_at) AS date,
COUNT(*) AS count,
SUM(uv.watch_progress_seconds) AS seconds
FROM user_videos uv
WHERE uv.user_id = :uid
AND uv.watched = 1
AND uv.last_watched_at >= datetime('now', '-30 days')
GROUP BY date(uv.last_watched_at)
ORDER BY date ASC
"""),
{"uid": uid},
).mappings().all()
this_week = db.execute(
text("""
SELECT COUNT(*) AS count, SUM(uv.watch_progress_seconds) AS seconds
FROM user_videos uv
WHERE uv.user_id = :uid AND uv.watched = 1
AND uv.last_watched_at >= datetime('now', '-7 days')
"""),
{"uid": uid},
).mappings().first()
this_month = db.execute(
text("""
SELECT COUNT(*) AS count, SUM(uv.watch_progress_seconds) AS seconds
FROM user_videos uv
WHERE uv.user_id = :uid AND uv.watched = 1
AND uv.last_watched_at >= datetime('now', '-30 days')
"""),
{"uid": uid},
).mappings().first()
avg_completion = db.execute(
text("""
SELECT AVG(uv.completion_percent) AS avg_pct,
COUNT(CASE WHEN uv.completion_percent >= 75 THEN 1 END) AS finished_count,
COUNT(CASE WHEN uv.completion_percent < 20 AND uv.completion_percent IS NOT NULL THEN 1 END) AS bailed_count,
SUM(uv.rewatch_count) AS total_rewatches,
COUNT(CASE WHEN uv.rewatch_count > 0 THEN 1 END) AS rewatched_videos
FROM user_videos uv
WHERE uv.user_id = :uid AND uv.watched = 1
"""),
{"uid": uid},
).mappings().first()
top_categories = db.execute(
text("""
SELECT v.category, COUNT(*) AS watch_count,
AVG(uv.completion_percent) AS avg_completion
FROM user_videos uv
JOIN videos v ON uv.video_id = v.id
WHERE uv.user_id = :uid AND uv.watched = 1 AND v.category IS NOT NULL
GROUP BY v.category
ORDER BY watch_count DESC
LIMIT 8
"""),
{"uid": uid},
).mappings().all()
taste_profile = db.execute(
text("""
SELECT tag, score FROM user_tag_affinity
WHERE user_id = :uid AND score > 0
ORDER BY score DESC
LIMIT 60
"""),
{"uid": uid},
).mappings().all()
peak_hours = db.execute(
text("""
SELECT CAST(strftime('%H', uv.last_watched_at) AS INTEGER) AS hour,
COUNT(*) AS count
FROM user_videos uv
WHERE uv.user_id = :uid AND uv.watched = 1 AND uv.last_watched_at IS NOT NULL
GROUP BY hour
ORDER BY hour ASC
"""),
{"uid": uid},
).mappings().all()
liked_count = db.execute(
text("SELECT COUNT(*) AS n FROM user_videos WHERE user_id = :uid AND liked = 1"),
{"uid": uid},
).mappings().first()
started_count = db.execute(
text("""
SELECT COUNT(*) AS n FROM user_videos
WHERE user_id = :uid AND watched = 0
AND (watch_progress_seconds > 0 OR last_watched_at IS NOT NULL)
"""),
{"uid": uid},
).mappings().first()
try:
disk = shutil.disk_usage(settings.download_path)
download_bytes = sum(
e.stat().st_size for e in os.scandir(settings.download_path) if e.is_file()
)
except Exception:
disk = None
download_bytes = 0
return {
"total_watched": totals["total_watched"] or 0,
"total_watch_seconds": totals["total_watch_seconds"] or 0,
"top_channels": [dict(r) for r in top_channels],
"daily": [dict(r) for r in daily],
"this_week": {
"count": this_week["count"] or 0,
"seconds": this_week["seconds"] or 0,
},
"this_month": {
"count": this_month["count"] or 0,
"seconds": this_month["seconds"] or 0,
},
"avg_completion_percent": round(avg_completion["avg_pct"] or 0, 1),
"finished_count": avg_completion["finished_count"] or 0,
"bailed_count": avg_completion["bailed_count"] or 0,
"total_rewatches": avg_completion["total_rewatches"] or 0,
"rewatched_videos": avg_completion["rewatched_videos"] or 0,
"total_liked": liked_count["n"] or 0,
"started_count": started_count["n"] or 0,
"top_categories": [dict(r) for r in top_categories],
"peak_hours": [dict(r) for r in peak_hours],
"taste_profile": [dict(r) for r in taste_profile],
"disk": {
"total_bytes": disk.total if disk else None,
"free_bytes": disk.free if disk else None,
"used_bytes": disk.used if disk else None,
"download_bytes": download_bytes,
},
}
@router.delete("/taste/{tag}", status_code=204)
def delete_taste_tag(
tag: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
row = db.query(UserTagAffinity).filter_by(user_id=current_user.id, tag=tag).first()
if not row:
raise HTTPException(status_code=404, detail="Tag not found")
db.delete(row)
db.commit()