feat: migrate persistence from JSON to SQLModel (Phase 1)
CI / Test (Python 3.11) (push) Has been cancelled
CI / Test (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Summary (push) Has been cancelled

- Integrated SQLModel with SQLite for robust data persistence
- Refactored UserManager and WatchlistManager to use SQL queries
- Migrated models to SQLModel with relationships and primary keys
- Updated test suite with in-memory database isolation
- Removed deprecated JSON storage files
This commit is contained in:
root
2026-03-24 10:40:36 +00:00
parent d4d8d8a3b6
commit 29c7040b20
13 changed files with 596 additions and 1165 deletions
+146 -164
View File
@@ -1,4 +1,4 @@
"""Watchlist management system for automatic episode tracking and downloading"""
"""Watchlist management system for automatic episode tracking and downloading with SQLModel support"""
import json
import os
import uuid
@@ -7,8 +7,11 @@ from datetime import datetime, timedelta
from typing import List, Optional, Dict
from pathlib import Path
from sqlmodel import Session, select
from app.database import engine
from app.models.watchlist import (
WatchlistItem,
WatchlistItemTable,
WatchlistItemCreate,
WatchlistItemUpdate,
WatchlistStatus,
@@ -19,55 +22,18 @@ from app.models.watchlist import (
logger = logging.getLogger(__name__)
# Watchlist database file
WATCHLIST_DB_FILE = "config/watchlist.json"
# Settings file remains JSON for simplicity for now
WATCHLIST_SETTINGS_FILE = "config/watchlist_settings.json"
class WatchlistManager:
"""Manages user watchlist for automatic episode downloads"""
"""Manages user watchlist for automatic episode downloads using SQL database"""
def __init__(self, db_file: str = WATCHLIST_DB_FILE):
self.db_file = db_file
def __init__(self):
self.settings_file = WATCHLIST_SETTINGS_FILE
self.watchlist: Dict[str, WatchlistItem] = {}
self.settings: Optional[WatchlistSettings] = None
self._load_watchlist()
self._load_settings()
def _load_watchlist(self):
"""Load watchlist from JSON file"""
try:
if os.path.exists(self.db_file):
with open(self.db_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self.watchlist = {
item_id: WatchlistItem(**item_data)
for item_id, item_data in data.items()
}
logger.info(f"Loaded {len(self.watchlist)} items from watchlist")
else:
self.watchlist = {}
logger.info("Watchlist database not found, starting with empty watchlist")
except Exception as e:
logger.error(f"Error loading watchlist: {e}")
self.watchlist = {}
def _save_watchlist(self):
try:
os.makedirs(os.path.dirname(self.db_file), exist_ok=True)
data = {
item_id: item.model_dump(mode='json')
for item_id, item in self.watchlist.items()
}
temp_file = f"{self.db_file}.tmp"
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False, default=str)
os.replace(temp_file, self.db_file)
logger.debug(f"Saved {len(self.watchlist)} items to watchlist")
except Exception as e:
logger.error(f"Error saving watchlist: {e}")
def _load_settings(self):
"""Load watchlist settings from JSON file"""
try:
@@ -95,167 +61,183 @@ class WatchlistManager:
except Exception as e:
logger.error(f"Error saving settings: {e}")
def _to_api_model(self, db_item: WatchlistItemTable) -> WatchlistItem:
"""Convert database table model to API response model"""
data = db_item.model_dump()
data["genres"] = db_item.genres
return WatchlistItem(**data)
def get_all(self, user_id: Optional[str] = None, status: Optional[WatchlistStatus] = None) -> List[WatchlistItem]:
"""Get all watchlist items, optionally filtered by user and status"""
items = list(self.watchlist.values())
if user_id:
items = [item for item in items if item.user_id == user_id]
if status:
items = [item for item in items if item.status == status]
# Sort by added_at descending
items.sort(key=lambda x: x.added_at, reverse=True)
return items
with Session(engine) as session:
statement = select(WatchlistItemTable)
if user_id:
statement = statement.where(WatchlistItemTable.user_id == user_id)
if status:
statement = statement.where(WatchlistItemTable.status == status)
# Sort by added_at descending
statement = statement.order_by(WatchlistItemTable.added_at.desc())
db_items = session.exec(statement).all()
return [self._to_api_model(item) for item in db_items]
def get_by_id(self, item_id: str) -> Optional[WatchlistItem]:
"""Get a watchlist item by ID"""
return self.watchlist.get(item_id)
"""Get a specific watchlist item by ID"""
with Session(engine) as session:
db_item = session.get(WatchlistItemTable, item_id)
if db_item:
return self._to_api_model(db_item)
return None
def get_by_anime_url(self, anime_url: str, user_id: str) -> Optional[WatchlistItem]:
"""Get a watchlist item by anime URL and user ID"""
for item in self.watchlist.values():
if item.anime_url == anime_url and item.user_id == user_id:
return item
return None
with Session(engine) as session:
statement = select(WatchlistItemTable).where(
WatchlistItemTable.anime_url == anime_url,
WatchlistItemTable.user_id == user_id
)
db_item = session.exec(statement).first()
if db_item:
return self._to_api_model(db_item)
return None
def create(self, user_id: str, item_data: WatchlistItemCreate) -> WatchlistItem:
"""Create a new watchlist item"""
# Check if already exists
existing = self.get_by_anime_url(item_data.anime_url, user_id)
def add(self, user_id: str, item_create: WatchlistItemCreate) -> WatchlistItem:
"""Add a new anime to the watchlist"""
# Check if already in watchlist for this user
existing = self.get_by_anime_url(item_create.anime_url, user_id)
if existing:
raise ValueError(f"Anime already in watchlist (ID: {existing.id})")
return existing
# Create new item
item_id = str(uuid.uuid4())
now = datetime.now()
with Session(engine) as session:
# Create new item
db_item = WatchlistItemTable(
user_id=user_id,
anime_title=item_create.anime_title,
anime_url=item_create.anime_url,
provider_id=item_create.provider_id,
lang=item_create.lang,
auto_download=item_create.auto_download,
quality_preference=item_create.quality_preference,
poster_image=item_create.poster_image,
cover_image=item_create.cover_image,
synopsis=item_create.synopsis,
status=WatchlistStatus.ACTIVE,
added_at=datetime.now(),
updated_at=datetime.now(),
last_episode_downloaded=0
)
db_item.genres = item_create.genres
watchlist_item = WatchlistItem(
id=item_id,
user_id=user_id,
anime_title=item_data.anime_title,
anime_url=item_data.anime_url,
provider_id=item_data.provider_id,
lang=item_data.lang,
auto_download=item_data.auto_download,
quality_preference=item_data.quality_preference,
status=WatchlistStatus.ACTIVE,
poster_image=item_data.poster_image,
cover_image=item_data.cover_image,
synopsis=item_data.synopsis,
genres=item_data.genres,
added_at=now,
updated_at=now,
last_checked=None,
last_episode_downloaded=0,
total_episodes=None
)
session.add(db_item)
session.commit()
session.refresh(db_item)
logger.info(f"Added {db_item.anime_title} to watchlist for user {user_id}")
return self._to_api_model(db_item)
self.watchlist[item_id] = watchlist_item
self._save_watchlist()
logger.info(f"Added anime to watchlist: {watchlist_item.anime_title} (ID: {item_id})")
return watchlist_item
# Alias for backward compatibility if needed
add_item = add
def update(self, item_id: str, update_data) -> Optional[WatchlistItem]:
"""Update a watchlist item
"""Update a watchlist item"""
with Session(engine) as session:
db_item = session.get(WatchlistItemTable, item_id)
if not db_item:
return None
Args:
item_id: Item ID to update
update_data: WatchlistItemUpdate object or dict with fields to update
"""
item = self.watchlist.get(item_id)
if not item:
return None
# Handle both dict and WatchlistItemUpdate
if isinstance(update_data, dict):
update_dict = update_data
else:
update_dict = update_data.model_dump(exclude_unset=True)
# Handle both dict and WatchlistItemUpdate
if isinstance(update_data, dict):
update_dict = update_data
else:
update_dict = update_data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
if hasattr(db_item, key):
setattr(db_item, key, value)
db_item.updated_at = datetime.now()
session.add(db_item)
session.commit()
session.refresh(db_item)
logger.info(f"Updated watchlist item: {item_id}")
return self._to_api_model(db_item)
# Update fields
for field, value in update_dict.items():
if value is not None:
setattr(item, field, value)
item.updated_at = datetime.now()
self._save_watchlist()
logger.info(f"Updated watchlist item: {item_id}")
return item
# Alias for backward compatibility
update_item = update
def delete(self, item_id: str) -> bool:
"""Delete a watchlist item"""
if item_id in self.watchlist:
del self.watchlist[item_id]
self._save_watchlist()
logger.info(f"Deleted watchlist item: {item_id}")
"""Remove an item from the watchlist"""
with Session(engine) as session:
db_item = session.get(WatchlistItemTable, item_id)
if not db_item:
return False
session.delete(db_item)
session.commit()
logger.info(f"Deleted item {item_id} from watchlist")
return True
return False
def update_check_time(self, item_id: str, last_episode: int) -> Optional[WatchlistItem]:
"""Update last_checked time and last_episode_downloaded"""
item = self.watchlist.get(item_id)
if not item:
return None
def update_last_checked(self, item_id: str, last_episode: Optional[int] = None):
"""Update the last_checked timestamp and optionally last episode for an item"""
with Session(engine) as session:
db_item = session.get(WatchlistItemTable, item_id)
if db_item:
db_item.last_checked = datetime.now()
if last_episode is not None:
db_item.last_episode_downloaded = last_episode
session.add(db_item)
session.commit()
item.last_checked = datetime.now()
item.last_episode_downloaded = max(item.last_episode_downloaded, last_episode)
item.updated_at = datetime.now()
self._save_watchlist()
return item
# Alias for backward compatibility
update_check_time = update_last_checked
def get_settings(self) -> WatchlistSettings:
"""Get watchlist settings"""
if not self.settings:
self.settings = WatchlistSettings()
return self.settings
def get_due_items(self) -> List[WatchlistItem]:
"""Get all items that are due for a check based on settings"""
interval = timedelta(hours=self.settings.check_interval_hours)
now = datetime.now()
with Session(engine) as session:
statement = select(WatchlistItemTable).where(
(WatchlistItemTable.status == WatchlistStatus.ACTIVE)
)
db_items = session.exec(statement).all()
due_items = []
for item in db_items:
if not item.last_checked or (item.last_checked + interval) < now:
due_items.append(self._to_api_model(item))
return due_items
def update_settings(self, settings: WatchlistSettings) -> WatchlistSettings:
"""Update watchlist settings"""
"""Update global watchlist settings"""
self.settings = settings
self._save_settings()
logger.info("Updated watchlist settings")
return self.settings
def get_due_for_check(self, check_interval_hours: Optional[int] = None) -> List[WatchlistItem]:
"""Get items that are due for checking"""
if check_interval_hours is None:
check_interval_hours = self.settings.check_interval_hours
cutoff_time = datetime.now() - timedelta(hours=check_interval_hours)
due_items = []
for item in self.watchlist.values():
# Only check active items with auto_download enabled
if item.status != WatchlistStatus.ACTIVE or not item.auto_download:
continue
# Check if due
if item.last_checked is None or item.last_checked < cutoff_time:
due_items.append(item)
logger.info(f"Found {len(due_items)} items due for check")
return due_items
def get_stats(self, user_id: Optional[str] = None) -> Dict:
"""Get watchlist statistics"""
def get_stats(self, user_id: str) -> Dict:
"""Get statistics for a user's watchlist"""
items = self.get_all(user_id=user_id)
stats = {
"total": len(items),
"active": len([i for i in items if i.status == WatchlistStatus.ACTIVE]),
"paused": len([i for i in items if i.status == WatchlistStatus.PAUSED]),
"completed": len([i for i in items if i.status == WatchlistStatus.COMPLETED]),
"auto_download_enabled": len([i for i in items if i.auto_download]),
"total_items": len(items),
"active_items": len([i for i in items if i.status == WatchlistStatus.ACTIVE]),
"paused_items": len([i for i in items if i.status == WatchlistStatus.PAUSED]),
"completed_items": len([i for i in items if i.status == WatchlistStatus.COMPLETED]),
"total_episodes_downloaded": sum(i.last_episode_downloaded for i in items),
"providers": {}
}
# Count by provider
for item in items:
provider = item.provider_id
stats["providers"][provider] = stats["providers"].get(provider, 0) + 1
return stats