Files
youclonedl/backend/models.py
Mattias Thall 5b0cf27f07 Add playlists support and fix explore older videos
- New playlists router: fetch channel playlists from YouTube, index
  playlist videos, browse by playlist with pagination
- Playlist model gets video_ids column to store ordered video list
- Register playlists router in main.py with DB migration
- Add Playlists tab to Channel page: grid of playlist cards, click to
  browse videos, index/re-index per playlist
- Fix explore older videos skipping all entries without published_at;
  flat-playlist entries for older videos rarely include timestamp data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:28:35 +02:00

258 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import datetime
from sqlalchemy import (
Boolean, Column, DateTime, Float, ForeignKey,
Integer, String, Text, UniqueConstraint,
)
from .database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, nullable=False, index=True)
email = Column(String, unique=True, nullable=False, index=True)
hashed_password = Column(String, nullable=False)
is_admin = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
class Channel(Base):
__tablename__ = "channels"
id = Column(Integer, primary_key=True, index=True)
youtube_channel_id = Column(String, unique=True, nullable=False, index=True)
name = Column(String, nullable=False)
description = Column(Text)
thumbnail_url = Column(String)
banner_url = Column(String)
crawled_at = Column(DateTime)
subscriber_count = Column(Integer)
class UserChannel(Base):
__tablename__ = "user_channels"
__table_args__ = (UniqueConstraint("user_id", "channel_id"),)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
channel_id = Column(Integer, ForeignKey("channels.id", ondelete="CASCADE"), nullable=False)
status = Column(String, default="followed") # followed / dismissed / pending
added_at = Column(DateTime, default=datetime.utcnow)
auto_download = Column(Boolean, default=None) # None = use global, True/False = override
last_seen_at = Column(DateTime, default=None)
muted_until = Column(DateTime, default=None)
notes = Column(Text, default="")
class Video(Base):
__tablename__ = "videos"
id = Column(Integer, primary_key=True, index=True)
youtube_video_id = Column(String, unique=True, nullable=False, index=True)
channel_id = Column(Integer, ForeignKey("channels.id", ondelete="SET NULL"), nullable=True)
title = Column(String, nullable=False)
description = Column(Text)
thumbnail_url = Column(String)
duration_seconds = Column(Integer)
published_at = Column(DateTime)
indexed_at = Column(DateTime, default=datetime.utcnow)
tags = Column(Text) # JSON array string
category = Column(String)
chapters = Column(Text) # JSON array of {start_time, end_time, title}
view_count = Column(Integer)
like_count = Column(Integer)
dislike_count = Column(Integer)
class UserVideo(Base):
__tablename__ = "user_videos"
__table_args__ = (UniqueConstraint("user_id", "video_id"),)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
video_id = Column(Integer, ForeignKey("videos.id", ondelete="CASCADE"), nullable=False)
watched = Column(Boolean, default=False)
watch_progress_seconds = Column(Integer, default=0)
completion_percent = Column(Float, default=None) # 0100, set when video ends/navigates away
rewatch_count = Column(Integer, default=0) # incremented each time a completed video is replayed
queued = Column(Boolean, default=False)
downloaded = Column(Boolean, default=False)
liked = Column(Boolean, default=False)
rating = Column(Integer, default=None) # NULL=unrated, 1=thumbs up, -1=thumbs down
downloaded_at = Column(DateTime)
liked_at = Column(DateTime)
last_watched_at = Column(DateTime)
class Download(Base):
__tablename__ = "downloads"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
video_id = Column(Integer, ForeignKey("videos.id", ondelete="CASCADE"), nullable=False)
status = Column(String, default="pending") # pending / downloading / complete / failed
progress_percent = Column(Float, default=0.0)
file_path = Column(String)
resolution = Column(String) # e.g. "1080p", "720p"
created_at = Column(DateTime, default=datetime.utcnow)
completed_at = Column(DateTime)
error_message = Column(Text)
pending_delete_at = Column(DateTime, default=None)
class UserSettings(Base):
__tablename__ = "user_settings"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True)
preferred_quality = Column(String, default="best") # best / 1080p / 720p / 480p / 360p
max_concurrent_downloads = Column(Integer, default=3) # 15
hide_watched_from_feed = Column(Boolean, default=False)
mark_watched_at_percent = Column(Integer, default=90) # 50100
auto_download_on_sync = Column(Boolean, default=False)
cookies_browser = Column(String, default="") # chrome / firefox / etc., "" = disabled
cookies_file = Column(String, default="") # path to Netscape cookies.txt, "" = disabled
theater_mode = Column(Boolean, default=False)
discovery_regions = Column(String, default="US,SE") # comma-separated ISO country codes
calm_mode = Column(Boolean, default=False)
hide_subscriber_counts = Column(Boolean, default=False)
autoplay_enabled = Column(Boolean, default=False)
feed_weight_recency = Column(Float, default=5.0) # 010
feed_weight_affinity = Column(Float, default=5.0) # 010
feed_weight_channel = Column(Float, default=5.0) # 010
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.
class DiscoveryQueue(Base):
__tablename__ = "discovery_queue"
__table_args__ = (UniqueConstraint("user_id", "channel_id"),)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
channel_id = Column(Integer, ForeignKey("channels.id", ondelete="CASCADE"), nullable=False)
score = Column(Float, default=0.0)
source = Column(String) # search / community / category / liked
seen = Column(Boolean, default=False)
preview_json = Column(Text) # JSON: [{thumbnail_url, title}, ...]
created_at = Column(DateTime, default=datetime.utcnow)
class ChannelGroup(Base):
__tablename__ = "channel_groups"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
name = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
class ChannelGroupMember(Base):
__tablename__ = "channel_group_members"
__table_args__ = (UniqueConstraint("group_id", "channel_id"),)
id = Column(Integer, primary_key=True, index=True)
group_id = Column(Integer, ForeignKey("channel_groups.id", ondelete="CASCADE"), nullable=False)
channel_id = Column(Integer, ForeignKey("channels.id", ondelete="CASCADE"), nullable=False)
class UserTagAffinity(Base):
"""Per-user taste signal: how much the user engages with a given tag or category."""
__tablename__ = "user_tag_affinity"
__table_args__ = (UniqueConstraint("user_id", "tag"),)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
tag = Column(String, nullable=False, index=True)
score = Column(Float, default=0.0)
updated_at = Column(DateTime, default=datetime.utcnow)
class VideoComment(Base):
__tablename__ = "video_comments"
id = Column(Integer, primary_key=True, index=True)
video_id = Column(Integer, ForeignKey("videos.id", ondelete="CASCADE"), nullable=False, index=True)
youtube_comment_id = Column(String)
author = Column(String)
text = Column(Text)
likes = Column(Integer, default=0)
is_pinned = Column(Boolean, default=False)
published_at = Column(DateTime)
fetched_at = Column(DateTime, default=datetime.utcnow)
class Collection(Base):
__tablename__ = "collections"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
name = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
class CollectionItem(Base):
__tablename__ = "collection_items"
__table_args__ = (UniqueConstraint("collection_id", "video_id"),)
id = Column(Integer, primary_key=True, index=True)
collection_id = Column(Integer, ForeignKey("collections.id", ondelete="CASCADE"), nullable=False)
video_id = Column(Integer, ForeignKey("videos.id", ondelete="CASCADE"), nullable=False)
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):
__tablename__ = "graph_edges"
__table_args__ = (UniqueConstraint("from_channel_id", "to_channel_id"),)
id = Column(Integer, primary_key=True, index=True)
from_channel_id = Column(Integer, ForeignKey("channels.id", ondelete="CASCADE"), nullable=False)
to_channel_id = Column(Integer, ForeignKey("channels.id", ondelete="CASCADE"), nullable=False)
mention_count = Column(Integer, default=1)
last_seen = Column(DateTime, default=datetime.utcnow)
class SystemConfig(Base):
__tablename__ = "system_config"
key = Column(String, primary_key=True)
value = Column(String, nullable=False)
class SearchHistory(Base):
__tablename__ = "search_history"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
query = Column(String, nullable=False)
searched_at = Column(DateTime, default=datetime.utcnow)
class VideoBookmark(Base):
__tablename__ = "video_bookmarks"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
video_id = Column(Integer, ForeignKey("videos.id", ondelete="CASCADE"), nullable=False)
timestamp_seconds = Column(Integer, nullable=False)
note = Column(Text, default="")
source = Column(String, default="manual") # manual | auto
created_at = Column(DateTime, default=datetime.utcnow)