- auto-sync daemon: background thread checks every hour and syncs followed channels for users with sync_interval_hours set (6/12/24h options) - disk stats: /api/stats now returns total/used/free/download bytes; Stats page shows a disk usage bar - subtitles: subtitle_langs setting (e.g. "en,sv") passed through all download paths; yt-dlp writes .srt files alongside the video - Settings page: sync interval dropdown + subtitle languages input Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
177 lines
5.8 KiB
Python
177 lines
5.8 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 >= 90 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 20
|
|
"""),
|
|
{"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()
|
|
|
|
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,
|
|
"top_categories": [dict(r) for r in top_categories],
|
|
"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()
|