1651 lines
64 KiB
Python
1651 lines
64 KiB
Python
"""
|
|
Easynews Monitor Module
|
|
|
|
Monitors Easynews for new media files matching saved search terms.
|
|
Uses TMDB for metadata enrichment and organizes downloads properly.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Dict, List, Optional
|
|
|
|
from modules.universal_logger import get_logger
|
|
from modules.easynews_client import EasynewsClient, EasynewsResult
|
|
from modules.media_identifier import MediaIdentifier
|
|
from modules.activity_status import get_activity_manager
|
|
|
|
logger = get_logger('EasynewsMonitor')
|
|
|
|
# Common words to remove from search queries
|
|
STOPWORDS = {
|
|
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
|
|
'of', 'with', 'by', 'from', 'as', 'is', 'was', 'are', 'were', 'been',
|
|
'be', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
|
|
'could', 'should', 'may', 'might', 'must', 'shall', 'can', 'need',
|
|
'no', 'not', 'very', 'just', 'only', 'also', 'too', 'so', 'than',
|
|
}
|
|
|
|
def clean_search_query(title: str, max_words: int = 5) -> str:
|
|
"""
|
|
Clean a title for Easynews search by removing stopwords and limiting length.
|
|
Keeps the most significant words for better search results.
|
|
"""
|
|
import re
|
|
# Remove punctuation and extra spaces
|
|
cleaned = re.sub(r'[^\w\s]', ' ', title)
|
|
words = cleaned.split()
|
|
|
|
# Filter out stopwords and short words
|
|
significant_words = [w for w in words if w.lower() not in STOPWORDS and len(w) > 2]
|
|
|
|
# If we filtered too much, use original words (but still limit)
|
|
if len(significant_words) < 2:
|
|
significant_words = [w for w in words if len(w) > 2]
|
|
|
|
# Take first N significant words
|
|
return ' '.join(significant_words[:max_words])
|
|
|
|
|
|
# Show name overrides for ambiguous titles
|
|
# Maps (show_name, tmdb_id) or just show_name to a more specific search term
|
|
SHOW_NAME_OVERRIDES = {
|
|
# Grand Hotel (Eva Longoria's ABC show) - search for "Grand Hotel US" to avoid
|
|
# matching French, Norwegian, or Spanish versions
|
|
('Grand Hotel', 82092): 'Grand Hotel US',
|
|
}
|
|
|
|
|
|
class EasynewsMonitor:
|
|
"""
|
|
Monitor for Easynews searches.
|
|
Checks configured search terms and stores discovered results.
|
|
"""
|
|
|
|
def __init__(self, db_path: str, activity_manager=None):
|
|
"""
|
|
Initialize the Easynews Monitor.
|
|
|
|
Args:
|
|
db_path: Path to the SQLite database
|
|
activity_manager: Optional activity manager for status updates
|
|
"""
|
|
self.db_path = db_path
|
|
self.activity_manager = activity_manager
|
|
self.default_output_path = '/opt/immich/md/easynews/'
|
|
self._ensure_tables()
|
|
|
|
def _get_connection(self) -> sqlite3.Connection:
|
|
"""Get a database connection with row factory."""
|
|
conn = sqlite3.connect(self.db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
def _ensure_tables(self):
|
|
"""Ensure all required tables exist."""
|
|
conn = sqlite3.connect(self.db_path)
|
|
try:
|
|
cursor = conn.cursor()
|
|
|
|
# Easynews configuration (singleton, id=1)
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS easynews_config (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
username TEXT,
|
|
password TEXT,
|
|
enabled INTEGER DEFAULT 0,
|
|
check_interval_hours INTEGER DEFAULT 12,
|
|
last_check TEXT,
|
|
auto_download INTEGER DEFAULT 0,
|
|
min_quality TEXT DEFAULT '720p',
|
|
proxy_enabled INTEGER DEFAULT 0,
|
|
proxy_type TEXT DEFAULT 'http',
|
|
proxy_host TEXT,
|
|
proxy_port INTEGER,
|
|
proxy_username TEXT,
|
|
proxy_password TEXT,
|
|
notifications_enabled INTEGER DEFAULT 1,
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
''')
|
|
|
|
# Add notifications_enabled column if it doesn't exist (migration)
|
|
cursor.execute("PRAGMA table_info(easynews_config)")
|
|
columns = [col[1] for col in cursor.fetchall()]
|
|
if 'notifications_enabled' not in columns:
|
|
cursor.execute('ALTER TABLE easynews_config ADD COLUMN notifications_enabled INTEGER DEFAULT 1')
|
|
|
|
# Insert default config row if not exists
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO easynews_config (id, enabled, check_interval_hours, auto_download, min_quality)
|
|
VALUES (1, 0, 12, 0, '720p')
|
|
''')
|
|
|
|
# Easynews results table (linked to celebrity_profiles)
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS easynews_results (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
celebrity_id INTEGER,
|
|
celebrity_name TEXT,
|
|
filename TEXT NOT NULL,
|
|
download_url TEXT NOT NULL,
|
|
size_bytes INTEGER,
|
|
post_date TEXT,
|
|
discovered_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
parsed_title TEXT,
|
|
parsed_season INTEGER,
|
|
parsed_episode INTEGER,
|
|
parsed_year INTEGER,
|
|
tmdb_id INTEGER,
|
|
tmdb_title TEXT,
|
|
poster_url TEXT,
|
|
quality TEXT,
|
|
status TEXT DEFAULT 'new',
|
|
download_path TEXT,
|
|
FOREIGN KEY (celebrity_id) REFERENCES celebrity_profiles(id)
|
|
)
|
|
''')
|
|
|
|
conn.commit()
|
|
logger.debug("Easynews tables ensured")
|
|
except Exception as e:
|
|
logger.error(f"Failed to ensure Easynews tables: {e}")
|
|
finally:
|
|
conn.close()
|
|
|
|
# =========================================================================
|
|
# CONFIGURATION METHODS
|
|
# =========================================================================
|
|
|
|
def get_config(self) -> Dict[str, Any]:
|
|
"""Get the Easynews configuration."""
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT username, password, enabled, check_interval_hours, last_check,
|
|
auto_download, min_quality, proxy_enabled, proxy_type,
|
|
proxy_host, proxy_port, proxy_username, proxy_password,
|
|
notifications_enabled, created_at, updated_at
|
|
FROM easynews_config
|
|
WHERE id = 1
|
|
''')
|
|
row = cursor.fetchone()
|
|
if row:
|
|
return {
|
|
'username': row['username'],
|
|
'password': row['password'],
|
|
'enabled': bool(row['enabled']),
|
|
'check_interval_hours': row['check_interval_hours'],
|
|
'last_check': row['last_check'],
|
|
'auto_download': bool(row['auto_download']),
|
|
'min_quality': row['min_quality'],
|
|
'proxy_enabled': bool(row['proxy_enabled']),
|
|
'proxy_type': row['proxy_type'],
|
|
'proxy_host': row['proxy_host'],
|
|
'proxy_port': row['proxy_port'],
|
|
'proxy_username': row['proxy_username'],
|
|
'proxy_password': row['proxy_password'],
|
|
'notifications_enabled': bool(row['notifications_enabled']) if row['notifications_enabled'] is not None else True,
|
|
'has_credentials': bool(row['username'] and row['password']),
|
|
}
|
|
return {
|
|
'username': None,
|
|
'password': None,
|
|
'enabled': False,
|
|
'check_interval_hours': 12,
|
|
'last_check': None,
|
|
'auto_download': False,
|
|
'min_quality': '720p',
|
|
'proxy_enabled': False,
|
|
'proxy_type': 'http',
|
|
'proxy_host': None,
|
|
'proxy_port': None,
|
|
'proxy_username': None,
|
|
'proxy_password': None,
|
|
'notifications_enabled': True,
|
|
'has_credentials': False,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
def update_config(
|
|
self,
|
|
username: str = None,
|
|
password: str = None,
|
|
enabled: bool = None,
|
|
check_interval_hours: int = None,
|
|
auto_download: bool = None,
|
|
min_quality: str = None,
|
|
proxy_enabled: bool = None,
|
|
proxy_type: str = None,
|
|
proxy_host: str = None,
|
|
proxy_port: int = None,
|
|
proxy_username: str = None,
|
|
proxy_password: str = None,
|
|
notifications_enabled: bool = None,
|
|
) -> bool:
|
|
"""Update Easynews configuration."""
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
|
|
updates = []
|
|
values = []
|
|
|
|
if username is not None:
|
|
updates.append('username = ?')
|
|
values.append(username)
|
|
if password is not None:
|
|
updates.append('password = ?')
|
|
values.append(password)
|
|
if enabled is not None:
|
|
updates.append('enabled = ?')
|
|
values.append(1 if enabled else 0)
|
|
if check_interval_hours is not None:
|
|
updates.append('check_interval_hours = ?')
|
|
values.append(check_interval_hours)
|
|
if auto_download is not None:
|
|
updates.append('auto_download = ?')
|
|
values.append(1 if auto_download else 0)
|
|
if min_quality is not None:
|
|
updates.append('min_quality = ?')
|
|
values.append(min_quality)
|
|
if proxy_enabled is not None:
|
|
updates.append('proxy_enabled = ?')
|
|
values.append(1 if proxy_enabled else 0)
|
|
if proxy_type is not None:
|
|
updates.append('proxy_type = ?')
|
|
values.append(proxy_type)
|
|
if proxy_host is not None:
|
|
updates.append('proxy_host = ?')
|
|
values.append(proxy_host)
|
|
if proxy_port is not None:
|
|
updates.append('proxy_port = ?')
|
|
values.append(proxy_port)
|
|
if proxy_username is not None:
|
|
updates.append('proxy_username = ?')
|
|
values.append(proxy_username)
|
|
if proxy_password is not None:
|
|
updates.append('proxy_password = ?')
|
|
values.append(proxy_password)
|
|
if notifications_enabled is not None:
|
|
updates.append('notifications_enabled = ?')
|
|
values.append(1 if notifications_enabled else 0)
|
|
|
|
if not updates:
|
|
return False
|
|
|
|
updates.append('updated_at = ?')
|
|
values.append(datetime.now().isoformat())
|
|
|
|
cursor.execute(f'''
|
|
UPDATE easynews_config
|
|
SET {', '.join(updates)}
|
|
WHERE id = 1
|
|
''', values)
|
|
conn.commit()
|
|
|
|
logger.info("Updated Easynews configuration")
|
|
return cursor.rowcount > 0
|
|
finally:
|
|
conn.close()
|
|
|
|
def _update_last_checked(self):
|
|
"""Update the last_check timestamp."""
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
UPDATE easynews_config
|
|
SET last_check = ?
|
|
WHERE id = 1
|
|
''', (datetime.now().isoformat(),))
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
def _get_client(self) -> Optional[EasynewsClient]:
|
|
"""Create an Easynews client from current configuration."""
|
|
config = self.get_config()
|
|
if not config['username'] or not config['password']:
|
|
return None
|
|
|
|
return EasynewsClient(
|
|
username=config['username'],
|
|
password=config['password'],
|
|
proxy_enabled=config['proxy_enabled'],
|
|
proxy_type=config['proxy_type'],
|
|
proxy_host=config['proxy_host'],
|
|
proxy_port=config['proxy_port'],
|
|
proxy_username=config['proxy_username'],
|
|
proxy_password=config['proxy_password'],
|
|
)
|
|
|
|
def test_connection(self) -> Dict[str, Any]:
|
|
"""Test the Easynews connection with current credentials."""
|
|
client = self._get_client()
|
|
if not client:
|
|
return {
|
|
'success': False,
|
|
'message': 'No credentials configured'
|
|
}
|
|
return client.test_connection()
|
|
|
|
# =========================================================================
|
|
# SEARCH MANAGEMENT METHODS
|
|
# =========================================================================
|
|
|
|
def _ensure_searches_table(self):
|
|
"""Ensure the easynews_searches table exists."""
|
|
conn = sqlite3.connect(self.db_path)
|
|
try:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS easynews_searches (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
search_term TEXT NOT NULL,
|
|
media_type TEXT DEFAULT 'any',
|
|
tmdb_id INTEGER,
|
|
tmdb_title TEXT,
|
|
poster_url TEXT,
|
|
enabled INTEGER DEFAULT 1,
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
''')
|
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_ens_enabled ON easynews_searches(enabled)')
|
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_ens_term ON easynews_searches(search_term)')
|
|
conn.commit()
|
|
except Exception as e:
|
|
logger.error(f"Failed to ensure easynews_searches table: {e}")
|
|
finally:
|
|
conn.close()
|
|
|
|
def get_all_searches(self) -> List[Dict[str, Any]]:
|
|
"""Get all saved search terms."""
|
|
self._ensure_searches_table()
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT id, search_term, media_type, tmdb_id, tmdb_title,
|
|
poster_url, enabled, created_at, updated_at
|
|
FROM easynews_searches
|
|
ORDER BY created_at DESC
|
|
''')
|
|
return [dict(row) for row in cursor.fetchall()]
|
|
except Exception as e:
|
|
logger.error(f"Failed to get searches: {e}")
|
|
return []
|
|
finally:
|
|
conn.close()
|
|
|
|
def get_search(self, search_id: int) -> Optional[Dict[str, Any]]:
|
|
"""Get a specific search by ID."""
|
|
self._ensure_searches_table()
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT id, search_term, media_type, tmdb_id, tmdb_title,
|
|
poster_url, enabled, created_at, updated_at
|
|
FROM easynews_searches
|
|
WHERE id = ?
|
|
''', (search_id,))
|
|
row = cursor.fetchone()
|
|
return dict(row) if row else None
|
|
except Exception as e:
|
|
logger.error(f"Failed to get search {search_id}: {e}")
|
|
return None
|
|
finally:
|
|
conn.close()
|
|
|
|
def add_search(
|
|
self,
|
|
search_term: str,
|
|
media_type: str = 'any',
|
|
tmdb_id: Optional[int] = None,
|
|
tmdb_title: Optional[str] = None,
|
|
poster_url: Optional[str] = None
|
|
) -> Optional[int]:
|
|
"""Add a new search term."""
|
|
self._ensure_searches_table()
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
INSERT INTO easynews_searches
|
|
(search_term, media_type, tmdb_id, tmdb_title, poster_url)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
''', (search_term, media_type, tmdb_id, tmdb_title, poster_url))
|
|
conn.commit()
|
|
logger.info(f"Added Easynews search: {search_term}")
|
|
return cursor.lastrowid
|
|
except Exception as e:
|
|
logger.error(f"Failed to add search: {e}")
|
|
return None
|
|
finally:
|
|
conn.close()
|
|
|
|
def update_search(
|
|
self,
|
|
search_id: int,
|
|
search_term: Optional[str] = None,
|
|
media_type: Optional[str] = None,
|
|
enabled: Optional[bool] = None,
|
|
tmdb_id: Optional[int] = None,
|
|
tmdb_title: Optional[str] = None,
|
|
poster_url: Optional[str] = None
|
|
) -> bool:
|
|
"""Update an existing search term."""
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
updates = []
|
|
values = []
|
|
|
|
if search_term is not None:
|
|
updates.append('search_term = ?')
|
|
values.append(search_term)
|
|
if media_type is not None:
|
|
updates.append('media_type = ?')
|
|
values.append(media_type)
|
|
if enabled is not None:
|
|
updates.append('enabled = ?')
|
|
values.append(1 if enabled else 0)
|
|
if tmdb_id is not None:
|
|
updates.append('tmdb_id = ?')
|
|
values.append(tmdb_id)
|
|
if tmdb_title is not None:
|
|
updates.append('tmdb_title = ?')
|
|
values.append(tmdb_title)
|
|
if poster_url is not None:
|
|
updates.append('poster_url = ?')
|
|
values.append(poster_url)
|
|
|
|
if not updates:
|
|
return False
|
|
|
|
updates.append('updated_at = ?')
|
|
values.append(datetime.now().isoformat())
|
|
values.append(search_id)
|
|
|
|
cursor.execute(f'''
|
|
UPDATE easynews_searches
|
|
SET {', '.join(updates)}
|
|
WHERE id = ?
|
|
''', values)
|
|
conn.commit()
|
|
logger.info(f"Updated Easynews search {search_id}")
|
|
return cursor.rowcount > 0
|
|
except Exception as e:
|
|
logger.error(f"Failed to update search {search_id}: {e}")
|
|
return False
|
|
finally:
|
|
conn.close()
|
|
|
|
def delete_search(self, search_id: int) -> bool:
|
|
"""Delete a search term."""
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
cursor.execute('DELETE FROM easynews_searches WHERE id = ?', (search_id,))
|
|
conn.commit()
|
|
logger.info(f"Deleted Easynews search {search_id}")
|
|
return cursor.rowcount > 0
|
|
except Exception as e:
|
|
logger.error(f"Failed to delete search {search_id}: {e}")
|
|
return False
|
|
finally:
|
|
conn.close()
|
|
|
|
def check_single_search(self, search_id: int) -> Dict[str, Any]:
|
|
"""Check Easynews for a specific search term."""
|
|
search = self.get_search(search_id)
|
|
if not search:
|
|
return {'success': False, 'message': 'Search not found', 'results_found': 0}
|
|
|
|
config = self.get_config()
|
|
if not config['has_credentials']:
|
|
return {'success': False, 'message': 'No credentials configured', 'results_found': 0}
|
|
|
|
client = self._get_client()
|
|
if not client:
|
|
return {'success': False, 'message': 'Failed to create client', 'results_found': 0}
|
|
|
|
try:
|
|
results = client.search(search['search_term'], results_per_page=250)
|
|
# Store results - simplified version
|
|
results_found = len(results) if results else 0
|
|
logger.info(f"Easynews search '{search['search_term']}' found {results_found} results")
|
|
return {
|
|
'success': True,
|
|
'message': f"Found {results_found} results",
|
|
'results_found': results_found
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Error searching Easynews for '{search['search_term']}': {e}")
|
|
return {'success': False, 'message': str(e), 'results_found': 0}
|
|
|
|
# =========================================================================
|
|
# CELEBRITY METHODS (uses tracked celebrities from Appearances)
|
|
# =========================================================================
|
|
|
|
def get_celebrities(self) -> List[Dict[str, Any]]:
|
|
"""Get all enabled celebrity profiles for Easynews searching."""
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT id, name, slug, image_url, enabled
|
|
FROM celebrity_profiles
|
|
WHERE enabled = 1
|
|
ORDER BY name ASC
|
|
''')
|
|
return [dict(row) for row in cursor.fetchall()]
|
|
finally:
|
|
conn.close()
|
|
|
|
def get_celebrity_count(self) -> int:
|
|
"""Get count of enabled celebrities."""
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT COUNT(*) FROM celebrity_profiles WHERE enabled = 1')
|
|
return cursor.fetchone()[0]
|
|
except Exception:
|
|
return 0
|
|
finally:
|
|
conn.close()
|
|
|
|
def get_celebrity_appearances(self, celebrity_id: int) -> List[Dict[str, Any]]:
|
|
"""Get appearances (TV shows, movies) for a celebrity with TMDB data."""
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT DISTINCT
|
|
show_name, tmdb_show_id, appearance_type,
|
|
season_number, episode_number, episode_title,
|
|
celebrity_name
|
|
FROM celebrity_appearances
|
|
WHERE celebrity_id = ?
|
|
AND tmdb_show_id IS NOT NULL
|
|
ORDER BY show_name, season_number, episode_number
|
|
''', (celebrity_id,))
|
|
return [dict(row) for row in cursor.fetchall()]
|
|
finally:
|
|
conn.close()
|
|
|
|
def get_tmdb_poster(self, tmdb_id: int, media_type: str) -> Optional[str]:
|
|
"""Get TMDB backdrop URL (widescreen) for a show/movie."""
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
# Try to get from appearance_config (has TMDB API key)
|
|
cursor.execute('SELECT tmdb_api_key FROM appearance_config WHERE id = 1')
|
|
row = cursor.fetchone()
|
|
if not row or not row[0]:
|
|
return None
|
|
|
|
api_key = row[0]
|
|
endpoint = 'tv' if media_type == 'TV' else 'movie'
|
|
|
|
import requests
|
|
response = requests.get(
|
|
f"https://api.themoviedb.org/3/{endpoint}/{tmdb_id}",
|
|
params={'api_key': api_key},
|
|
timeout=10
|
|
)
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
# Prefer backdrop (widescreen) over poster (portrait)
|
|
backdrop_path = data.get('backdrop_path')
|
|
if backdrop_path:
|
|
return f"https://image.tmdb.org/t/p/w780{backdrop_path}"
|
|
# Fall back to poster if no backdrop
|
|
poster_path = data.get('poster_path')
|
|
if poster_path:
|
|
return f"https://image.tmdb.org/t/p/w500{poster_path}"
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Failed to get TMDB backdrop: {e}")
|
|
return None
|
|
finally:
|
|
conn.close()
|
|
|
|
# =========================================================================
|
|
# RESULTS MANAGEMENT METHODS
|
|
# =========================================================================
|
|
|
|
def get_results(
|
|
self,
|
|
status: str = None,
|
|
celebrity_id: int = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get discovered results.
|
|
|
|
Args:
|
|
status: Filter by status ('new', 'downloaded', 'ignored', 'failed')
|
|
celebrity_id: Filter by celebrity ID
|
|
limit: Maximum results to return
|
|
offset: Offset for pagination
|
|
|
|
Returns:
|
|
List of result dictionaries
|
|
"""
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
|
|
query = '''
|
|
SELECT r.*, c.name as celebrity_display_name, c.image_url as celebrity_image
|
|
FROM easynews_results r
|
|
LEFT JOIN celebrity_profiles c ON r.celebrity_id = c.id
|
|
'''
|
|
conditions = []
|
|
values = []
|
|
|
|
if status:
|
|
conditions.append('r.status = ?')
|
|
values.append(status)
|
|
if celebrity_id:
|
|
conditions.append('r.celebrity_id = ?')
|
|
values.append(celebrity_id)
|
|
|
|
if conditions:
|
|
query += ' WHERE ' + ' AND '.join(conditions)
|
|
|
|
query += ' ORDER BY r.discovered_at DESC LIMIT ? OFFSET ?'
|
|
values.extend([limit, offset])
|
|
|
|
cursor.execute(query, values)
|
|
return [dict(row) for row in cursor.fetchall()]
|
|
finally:
|
|
conn.close()
|
|
|
|
def get_result_count(self, status: str = None) -> int:
|
|
"""Get count of results, optionally filtered by status."""
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
if status:
|
|
cursor.execute('SELECT COUNT(*) FROM easynews_results WHERE status = ?', (status,))
|
|
else:
|
|
cursor.execute('SELECT COUNT(*) FROM easynews_results')
|
|
return cursor.fetchone()[0]
|
|
finally:
|
|
conn.close()
|
|
|
|
def update_result_status(self, result_id: int, status: str, download_path: str = None) -> bool:
|
|
"""Update a result's status."""
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
if download_path:
|
|
cursor.execute('''
|
|
UPDATE easynews_results
|
|
SET status = ?, download_path = ?
|
|
WHERE id = ?
|
|
''', (status, download_path, result_id))
|
|
else:
|
|
cursor.execute('''
|
|
UPDATE easynews_results
|
|
SET status = ?
|
|
WHERE id = ?
|
|
''', (status, result_id))
|
|
conn.commit()
|
|
return cursor.rowcount > 0
|
|
finally:
|
|
conn.close()
|
|
|
|
def _get_or_create_easynews_preset(self, celebrity_id: int, celebrity_name: str) -> int:
|
|
"""Get or create an Easynews preset for a celebrity."""
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
|
|
# Check if preset exists
|
|
cursor.execute('''
|
|
SELECT id FROM celebrity_search_presets
|
|
WHERE celebrity_id = ? AND source_type = 'easynews'
|
|
''', (celebrity_id,))
|
|
row = cursor.fetchone()
|
|
if row:
|
|
return row[0]
|
|
|
|
# Create new preset
|
|
cursor.execute('''
|
|
INSERT INTO celebrity_search_presets (
|
|
celebrity_id, name, source_type, source_value, content_type, enabled
|
|
) VALUES (?, ?, 'easynews', ?, 'other', 1)
|
|
''', (celebrity_id, f'Easynews - {celebrity_name}', celebrity_name))
|
|
conn.commit()
|
|
return cursor.lastrowid
|
|
finally:
|
|
conn.close()
|
|
|
|
def _save_result(
|
|
self,
|
|
celebrity_id: int,
|
|
celebrity_name: str,
|
|
easynews_result: EasynewsResult,
|
|
parsed_info: Dict[str, Any],
|
|
tmdb_match: Optional[Dict[str, Any]],
|
|
) -> Optional[int]:
|
|
"""Save a discovered result to celebrity_discovered_videos (same as YouTube videos)."""
|
|
import hashlib
|
|
|
|
# Get or create the Easynews preset for this celebrity
|
|
preset_id = self._get_or_create_easynews_preset(celebrity_id, celebrity_name)
|
|
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
|
|
# Create a unique video_id from the filename and URL
|
|
video_id = hashlib.md5(f"{easynews_result.filename}:{easynews_result.download_url}".encode()).hexdigest()[:16]
|
|
|
|
# Check for duplicate in celebrity_discovered_videos
|
|
cursor.execute('''
|
|
SELECT id FROM celebrity_discovered_videos
|
|
WHERE platform = 'easynews' AND video_id = ?
|
|
''', (video_id,))
|
|
|
|
if cursor.fetchone():
|
|
return None # Already exists
|
|
|
|
# Prepare values
|
|
parsed_title = parsed_info.get('title') if parsed_info else None
|
|
parsed_season = parsed_info.get('season') if parsed_info else None
|
|
parsed_episode = parsed_info.get('episode') if parsed_info else None
|
|
parsed_year = parsed_info.get('year') if parsed_info else None
|
|
quality = parsed_info.get('quality') if parsed_info else EasynewsClient.detect_quality(easynews_result.filename)
|
|
|
|
tmdb_title = tmdb_match.get('title') if tmdb_match else None
|
|
poster_url = tmdb_match.get('poster_path') if tmdb_match else None
|
|
|
|
# Build display title
|
|
if tmdb_title:
|
|
if parsed_season and parsed_episode:
|
|
display_title = f"{tmdb_title} S{parsed_season:02d}E{parsed_episode:02d}"
|
|
elif parsed_year:
|
|
display_title = f"{tmdb_title} ({parsed_year})"
|
|
else:
|
|
display_title = tmdb_title
|
|
elif parsed_title:
|
|
display_title = parsed_title
|
|
else:
|
|
display_title = easynews_result.filename
|
|
|
|
# Build description with file info
|
|
size_mb = easynews_result.size_bytes / (1024 * 1024) if easynews_result.size_bytes else 0
|
|
description = f"Quality: {quality or 'Unknown'} | Size: {size_mb:.1f} MB | File: {easynews_result.filename}"
|
|
|
|
# Insert into celebrity_discovered_videos (same table as YouTube)
|
|
cursor.execute('''
|
|
INSERT INTO celebrity_discovered_videos (
|
|
preset_id, celebrity_id, video_id, platform, url, title,
|
|
channel_name, thumbnail, upload_date, description,
|
|
content_type, status
|
|
) VALUES (?, ?, ?, 'easynews', ?, ?, ?, ?, ?, ?, 'other', 'new')
|
|
''', (
|
|
preset_id,
|
|
celebrity_id,
|
|
video_id,
|
|
easynews_result.download_url,
|
|
display_title,
|
|
f"Easynews - {celebrity_name}", # channel_name
|
|
poster_url, # thumbnail (TMDB poster)
|
|
easynews_result.post_date,
|
|
description,
|
|
))
|
|
conn.commit()
|
|
logger.info(f"Saved Easynews result for {celebrity_name}: {display_title}")
|
|
return cursor.lastrowid
|
|
except Exception as e:
|
|
logger.error(f"Failed to save result: {e}")
|
|
return None
|
|
finally:
|
|
conn.close()
|
|
|
|
# =========================================================================
|
|
# CHECK METHODS
|
|
# =========================================================================
|
|
|
|
def get_status(self) -> Dict[str, Any]:
|
|
"""Get current check status from database (works across processes)."""
|
|
activity_manager = get_activity_manager()
|
|
task_status = activity_manager.get_background_task("easynews_check")
|
|
|
|
if task_status and task_status.get('active'):
|
|
extra = task_status.get('extra_data', {}) or {}
|
|
return {
|
|
"is_running": True,
|
|
"current_search": extra.get('current_search'),
|
|
"searches_processed": task_status.get('progress', {}).get('current', 0),
|
|
"total_searches": task_status.get('progress', {}).get('total', 0),
|
|
"results_found": extra.get('results_found', 0),
|
|
"last_check": extra.get('last_check'),
|
|
"error": None,
|
|
}
|
|
|
|
# Not running - return default state
|
|
return {
|
|
"is_running": False,
|
|
"current_search": None,
|
|
"searches_processed": 0,
|
|
"total_searches": 0,
|
|
"results_found": 0,
|
|
"last_check": None,
|
|
"error": None,
|
|
}
|
|
|
|
def check_all_celebrities(
|
|
self,
|
|
progress_callback: Callable[[str, int, int], None] = None,
|
|
from_scheduler: bool = False,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Check Easynews for all tracked celebrities by searching for their appearances.
|
|
|
|
Searches for TV shows and movies the celebrity appears in (from TMDB data),
|
|
not the celebrity name directly.
|
|
|
|
Args:
|
|
progress_callback: Optional callback(search_term, processed, total)
|
|
from_scheduler: If True, send push notifications (scheduler runs only)
|
|
|
|
Returns:
|
|
Dict with check results
|
|
"""
|
|
# Get configuration
|
|
config = self.get_config()
|
|
if not config['has_credentials']:
|
|
return {
|
|
'success': False,
|
|
'message': 'No Easynews credentials configured',
|
|
'results_found': 0,
|
|
}
|
|
|
|
# Get enabled celebrities
|
|
celebrities = self.get_celebrities()
|
|
if not celebrities:
|
|
return {
|
|
'success': True,
|
|
'message': 'No celebrities tracked',
|
|
'results_found': 0,
|
|
}
|
|
|
|
# Build list of all shows/movies to search (deduplicated)
|
|
searches_to_perform = [] # List of (celebrity_id, celebrity_name, show_name, tmdb_id, media_type, season, episode)
|
|
seen_searches = set()
|
|
poster_cache = {} # Cache TMDB posters
|
|
|
|
# First pass: identify TV shows that have episode-specific entries
|
|
# so we can skip generic entries for those shows
|
|
shows_with_episodes = set()
|
|
for celebrity in celebrities:
|
|
appearances = self.get_celebrity_appearances(celebrity['id'])
|
|
for app in appearances:
|
|
show_name = app.get('show_name')
|
|
media_type = app.get('appearance_type', 'TV')
|
|
season = app.get('season_number')
|
|
episode = app.get('episode_number')
|
|
if media_type != 'Movie' and season and episode:
|
|
shows_with_episodes.add(show_name)
|
|
|
|
for celebrity in celebrities:
|
|
appearances = self.get_celebrity_appearances(celebrity['id'])
|
|
for app in appearances:
|
|
show_name = app.get('show_name')
|
|
tmdb_id = app.get('tmdb_show_id')
|
|
media_type = app.get('appearance_type', 'TV')
|
|
season = app.get('season_number')
|
|
episode = app.get('episode_number')
|
|
|
|
if not show_name:
|
|
continue
|
|
|
|
# Build search term (clean to remove stopwords for better results)
|
|
# Check for show name overrides (for ambiguous titles)
|
|
override_key = (show_name, tmdb_id)
|
|
if override_key in SHOW_NAME_OVERRIDES:
|
|
clean_name = SHOW_NAME_OVERRIDES[override_key]
|
|
else:
|
|
clean_name = clean_search_query(show_name)
|
|
if media_type == 'Movie':
|
|
search_term = clean_name
|
|
search_key = f"movie:{show_name}"
|
|
elif season and episode:
|
|
search_term = f"{clean_name} S{season:02d}E{episode:02d}"
|
|
search_key = f"tv:{show_name}:S{season}E{episode}"
|
|
elif season:
|
|
search_term = f"{clean_name} S{season:02d}"
|
|
search_key = f"tv:{show_name}:S{season}"
|
|
else:
|
|
# Skip generic TV entries if we have episode-specific entries for this show
|
|
if show_name in shows_with_episodes:
|
|
logger.debug(f"Skipping generic search for '{show_name}' - has episode-specific entries")
|
|
continue
|
|
search_term = clean_name
|
|
search_key = f"tv:{show_name}"
|
|
|
|
if search_key not in seen_searches:
|
|
seen_searches.add(search_key)
|
|
searches_to_perform.append({
|
|
'celebrity_id': celebrity['id'],
|
|
'celebrity_name': celebrity['name'],
|
|
'show_name': show_name,
|
|
'search_term': search_term,
|
|
'tmdb_id': tmdb_id,
|
|
'media_type': media_type,
|
|
'season': season,
|
|
'episode': episode,
|
|
})
|
|
|
|
if not searches_to_perform:
|
|
return {
|
|
'success': True,
|
|
'message': 'No appearances to search for',
|
|
'results_found': 0,
|
|
}
|
|
|
|
# Initialize status tracking in database (works across processes)
|
|
activity_manager = get_activity_manager()
|
|
easynews_extra_data = {
|
|
"current_search": None,
|
|
"results_found": 0,
|
|
"last_check": None,
|
|
}
|
|
activity_manager.start_background_task(
|
|
task_id="easynews_check",
|
|
task_type="easynews_check",
|
|
display_name="Easynews",
|
|
status="Starting",
|
|
extra_data=easynews_extra_data
|
|
)
|
|
|
|
# Create client
|
|
client = self._get_client()
|
|
if not client:
|
|
activity_manager.stop_background_task("easynews_check")
|
|
return {
|
|
'success': False,
|
|
'message': 'Failed to create Easynews client',
|
|
'results_found': 0,
|
|
}
|
|
|
|
total_new = 0
|
|
|
|
# Crash recovery checkpoint
|
|
from modules.task_checkpoint import TaskCheckpoint
|
|
checkpoint = TaskCheckpoint('easynews_monitor', 'background')
|
|
checkpoint.start(total_items=len(searches_to_perform))
|
|
if checkpoint.is_recovering():
|
|
logger.info("Easynews monitor: recovering — skipping already-completed searches")
|
|
|
|
try:
|
|
for i, search in enumerate(searches_to_perform):
|
|
search_term = search['search_term']
|
|
celebrity_id = search['celebrity_id']
|
|
celebrity_name = search['celebrity_name']
|
|
show_name = search['show_name']
|
|
tmdb_id = search['tmdb_id']
|
|
media_type = search['media_type']
|
|
season = search['season']
|
|
episode = search['episode']
|
|
|
|
if checkpoint.is_completed(search_term):
|
|
continue
|
|
|
|
checkpoint.set_current(search_term)
|
|
|
|
easynews_extra_data["current_search"] = search_term
|
|
activity_manager.update_background_task(
|
|
"easynews_check", f"Searching: {search_term}",
|
|
progress_current=i, progress_total=len(searches_to_perform),
|
|
extra_data=easynews_extra_data
|
|
)
|
|
|
|
if progress_callback:
|
|
progress_callback(search_term, i, len(searches_to_perform))
|
|
|
|
logger.info(f"Searching Easynews: {search_term}")
|
|
|
|
try:
|
|
# Get TMDB poster (cached)
|
|
if tmdb_id and tmdb_id not in poster_cache:
|
|
poster_cache[tmdb_id] = self.get_tmdb_poster(tmdb_id, media_type)
|
|
poster_url = poster_cache.get(tmdb_id)
|
|
|
|
# Search Easynews (use max 250 results to find more episodes)
|
|
results = client.search(search_term, results_per_page=250)
|
|
|
|
for result in results:
|
|
# Skip sample files
|
|
if 'sample' in result.filename.lower():
|
|
continue
|
|
|
|
# Validate result matches the show we're looking for
|
|
if not self._result_matches_show(result.filename, show_name):
|
|
continue
|
|
|
|
# Check quality filter
|
|
quality = EasynewsClient.detect_quality(result.filename)
|
|
if config['min_quality'] and not self._meets_quality(quality, config['min_quality']):
|
|
continue
|
|
|
|
# Save result with proper metadata
|
|
new_id = self._save_appearance_result(
|
|
celebrity_id=celebrity_id,
|
|
celebrity_name=celebrity_name,
|
|
show_name=show_name,
|
|
tmdb_id=tmdb_id,
|
|
media_type=media_type,
|
|
season=season,
|
|
episode=episode,
|
|
poster_url=poster_url,
|
|
easynews_result=result,
|
|
quality=quality,
|
|
)
|
|
if new_id:
|
|
total_new += 1
|
|
easynews_extra_data["results_found"] = total_new
|
|
activity_manager.update_background_task(
|
|
"easynews_check", f"Found {total_new} new results",
|
|
progress_current=i, progress_total=len(searches_to_perform),
|
|
extra_data=easynews_extra_data
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error searching Easynews for '{search_term}': {e}")
|
|
continue
|
|
|
|
checkpoint.mark_completed(search_term)
|
|
|
|
# Checkpoint complete
|
|
checkpoint.finish()
|
|
|
|
# Mark as complete
|
|
easynews_extra_data["last_check"] = datetime.now().isoformat()
|
|
activity_manager.stop_background_task("easynews_check")
|
|
|
|
self._update_last_checked()
|
|
|
|
logger.info(f"Easynews check complete: {total_new} new results found")
|
|
|
|
# Send notification if from scheduler and new results found
|
|
if from_scheduler and total_new > 0:
|
|
config = self.get_config()
|
|
if config.get('notifications_enabled', True):
|
|
self._send_notification(total_new)
|
|
|
|
return {
|
|
'success': True,
|
|
'message': f'Check complete: {total_new} new results found',
|
|
'results_found': total_new,
|
|
'searches_performed': len(searches_to_perform),
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Easynews check failed: {e}")
|
|
activity_manager.stop_background_task("easynews_check")
|
|
return {
|
|
'success': False,
|
|
'message': str(e),
|
|
'results_found': total_new,
|
|
}
|
|
|
|
def _send_notification(self, results_found: int):
|
|
"""
|
|
Send a Pushover notification about new Easynews results found.
|
|
|
|
Args:
|
|
results_found: Number of new results found
|
|
"""
|
|
# Don't send notification if no new results
|
|
if results_found <= 0:
|
|
logger.debug("Skipping Easynews notification - no new results")
|
|
return
|
|
|
|
try:
|
|
import random
|
|
from modules.pushover_notifier import PushoverNotifier
|
|
from modules.settings_manager import SettingsManager
|
|
from modules.unified_database import UnifiedDatabase
|
|
|
|
# Get pushover config from settings
|
|
settings_manager = SettingsManager(self.db_path)
|
|
pushover_config = settings_manager.get('pushover', {})
|
|
|
|
if not pushover_config.get('enabled'):
|
|
logger.debug("Pushover notifications disabled globally")
|
|
return
|
|
|
|
# Create unified_db for recording notification to database
|
|
unified_db = UnifiedDatabase(self.db_path)
|
|
|
|
# Create notifier with unified_db so notification is recorded
|
|
notifier = PushoverNotifier(
|
|
api_token=pushover_config.get('api_token'),
|
|
user_key=pushover_config.get('user_key'),
|
|
unified_db=unified_db
|
|
)
|
|
|
|
# Get show summary from recently discovered videos
|
|
image_path = None
|
|
thumbnail_url = None
|
|
shows = {}
|
|
show_list = []
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
# Get the most recent Easynews results
|
|
cursor.execute('''
|
|
SELECT thumbnail, title, channel_name FROM celebrity_discovered_videos
|
|
WHERE platform = 'easynews' AND thumbnail IS NOT NULL AND thumbnail != ''
|
|
ORDER BY discovered_at DESC
|
|
LIMIT ?
|
|
''', (results_found,))
|
|
rows = cursor.fetchall()
|
|
if rows:
|
|
# Pick a random thumbnail from this batch
|
|
selected = random.choice(rows)
|
|
thumbnail_url = selected['thumbnail']
|
|
|
|
# Build show summary from unique shows in THIS batch only
|
|
shows = {}
|
|
for row in rows:
|
|
# Extract show name from channel_name (e.g., "Easynews - tv:Show Name:S1E1")
|
|
channel = row['channel_name'] or ''
|
|
if channel.startswith('Easynews - '):
|
|
parts = channel.replace('Easynews - ', '').split(':')
|
|
if len(parts) >= 2:
|
|
show_name = parts[1]
|
|
else:
|
|
show_name = row['title'] or 'Unknown'
|
|
else:
|
|
show_name = row['title'] or 'Unknown'
|
|
shows[show_name] = shows.get(show_name, 0) + 1
|
|
if show_name not in show_list:
|
|
show_list.append(show_name)
|
|
|
|
# Download thumbnail to temp file
|
|
if thumbnail_url:
|
|
import urllib.request
|
|
import tempfile
|
|
try:
|
|
temp_dir = tempfile.gettempdir()
|
|
temp_path = f"{temp_dir}/easynews_thumb_{random.randint(1000, 9999)}.jpg"
|
|
urllib.request.urlretrieve(thumbnail_url, temp_path)
|
|
image_path = temp_path
|
|
logger.debug(f"Downloaded thumbnail for notification: {temp_path}")
|
|
except Exception as e:
|
|
logger.debug(f"Could not download thumbnail: {e}")
|
|
finally:
|
|
conn.close()
|
|
|
|
# Build title matching standard notification format
|
|
title = f"📰 {results_found} New Result{'s' if results_found > 1 else ''} on Easynews"
|
|
|
|
# Build HTML message matching standard notification format
|
|
from datetime import datetime
|
|
message_parts = []
|
|
message_parts.append("📱 <b>Platform:</b> Easynews")
|
|
message_parts.append(f"🎥 <b>Results:</b> {results_found} new result{'s' if results_found > 1 else ''}")
|
|
|
|
# Add show breakdown
|
|
if shows:
|
|
show_parts = [f"{name} ({count})" if count > 1 else name
|
|
for name, count in sorted(shows.items(), key=lambda x: -x[1])[:5]]
|
|
message_parts.append(f"📺 <b>Shows:</b> {', '.join(show_parts)}")
|
|
|
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
message_parts.append(f"⏰ <b>Discovered:</b> {now}")
|
|
|
|
message = "\n".join(message_parts)
|
|
|
|
# Set notification context for database recording
|
|
# Store thumbnail_url (not temp file path) so notification page can display it
|
|
notifier._current_notification_context = {
|
|
'platform': 'easynews',
|
|
'source': 'easynews_monitor',
|
|
'content_type': 'video',
|
|
'download_count': results_found,
|
|
'metadata': {'shows': show_list, 'thumbnail_url': thumbnail_url}
|
|
}
|
|
|
|
# Send notification
|
|
success = notifier.send_notification(
|
|
title=title,
|
|
message=message,
|
|
priority=0, # Normal priority
|
|
image_path=image_path,
|
|
html=True
|
|
)
|
|
|
|
# Clean up temp file
|
|
if image_path:
|
|
try:
|
|
import os
|
|
os.unlink(image_path)
|
|
except OSError:
|
|
pass # Best effort cleanup of temp file
|
|
|
|
if success:
|
|
logger.info(f"Sent notification: {results_found} Easynews results found")
|
|
else:
|
|
logger.debug("Notification not sent (disabled or failed)")
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Could not send notification: {e}")
|
|
|
|
def _get_quality_rank(self, quality: Optional[str], source: Optional[str] = None, audio: Optional[str] = None) -> int:
|
|
"""Get numeric rank for quality comparison (higher is better).
|
|
|
|
Combines resolution, source, and audio into a single score:
|
|
- Resolution: 360p=100, 480p=200, 720p=300, 1080p=400, 2160p=500
|
|
- Source: Remux=50, BluRay=40, WEB-DL=30, WEB=20, HDTV=10
|
|
- Audio: Atmos=9, TrueHD=8, DTS-HD=7, 5.1=4, etc.
|
|
"""
|
|
score = 0
|
|
|
|
# Resolution score (most important)
|
|
if quality:
|
|
resolution_scores = {
|
|
'360p': 100,
|
|
'480p': 200,
|
|
'720p': 300,
|
|
'1080p': 400,
|
|
'2160p': 500,
|
|
}
|
|
score += resolution_scores.get(quality.lower(), 0)
|
|
|
|
# Source score
|
|
if source:
|
|
source_scores = {
|
|
'Remux': 50,
|
|
'BluRay': 40,
|
|
'WEB-DL': 30,
|
|
'WEBRip': 25,
|
|
'WEB': 20,
|
|
'HDTV': 10,
|
|
'DVDRip': 5,
|
|
'DVD': 3,
|
|
'CAM': 1,
|
|
}
|
|
score += source_scores.get(source, 0)
|
|
|
|
# Audio score
|
|
if audio:
|
|
audio_scores = {
|
|
'Atmos': 9,
|
|
'TrueHD': 8,
|
|
'DTS-HD': 7,
|
|
'DTS:X': 7,
|
|
'DTS': 6,
|
|
'7.1': 5,
|
|
'5.1': 4,
|
|
'FLAC': 3,
|
|
'AAC': 2,
|
|
'MP3': 1,
|
|
}
|
|
score += audio_scores.get(audio, 0)
|
|
|
|
return score
|
|
|
|
def _save_appearance_result(
|
|
self,
|
|
celebrity_id: int,
|
|
celebrity_name: str,
|
|
show_name: str,
|
|
tmdb_id: int,
|
|
media_type: str,
|
|
season: Optional[int],
|
|
episode: Optional[int],
|
|
poster_url: Optional[str],
|
|
easynews_result: EasynewsResult,
|
|
quality: Optional[str],
|
|
) -> Optional[int]:
|
|
"""Save an Easynews result - only keeps best quality per episode."""
|
|
import hashlib
|
|
|
|
# Get or create the Easynews preset for this celebrity
|
|
preset_id = self._get_or_create_easynews_preset(celebrity_id, celebrity_name)
|
|
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
|
|
# Build a unique key for this show/episode (not per file)
|
|
if media_type == 'Movie':
|
|
content_key = f"movie:{show_name}"
|
|
elif season and episode:
|
|
content_key = f"tv:{show_name}:S{season}E{episode}"
|
|
elif season:
|
|
content_key = f"tv:{show_name}:S{season}"
|
|
else:
|
|
content_key = f"tv:{show_name}"
|
|
# For TV shows without episode info, skip if we already have episode-specific entries
|
|
cursor.execute('''
|
|
SELECT COUNT(*) FROM celebrity_discovered_videos
|
|
WHERE platform = 'easynews'
|
|
AND celebrity_id = ?
|
|
AND channel_name LIKE ?
|
|
''', (celebrity_id, f"Easynews - tv:{show_name}:S%"))
|
|
if cursor.fetchone()[0] > 0:
|
|
# Already have episode-specific entries, skip this generic one
|
|
return None
|
|
|
|
# Detect audio codec and source FIRST (needed for comparison)
|
|
audio_codec = EasynewsClient.detect_audio(easynews_result.filename)
|
|
source = EasynewsClient.detect_source(easynews_result.filename)
|
|
|
|
# Calculate new result's score
|
|
new_score = self._get_quality_rank(quality, source, audio_codec)
|
|
|
|
# Check if we already have this episode - look by channel_name which contains show info
|
|
cursor.execute('''
|
|
SELECT id, metadata FROM celebrity_discovered_videos
|
|
WHERE platform = 'easynews'
|
|
AND celebrity_id = ?
|
|
AND channel_name = ?
|
|
''', (celebrity_id, f"Easynews - {content_key}"))
|
|
|
|
existing = cursor.fetchone()
|
|
if existing:
|
|
# Parse existing quality, source, audio from metadata
|
|
existing_meta = {}
|
|
if existing[1]:
|
|
try:
|
|
existing_meta = json.loads(existing[1])
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass # Invalid JSON metadata, use empty dict
|
|
|
|
existing_quality = existing_meta.get('quality')
|
|
existing_source = existing_meta.get('source')
|
|
existing_audio = existing_meta.get('audio_codec')
|
|
existing_score = self._get_quality_rank(existing_quality, existing_source, existing_audio)
|
|
|
|
# Only replace if new score is better
|
|
if new_score <= existing_score:
|
|
return None # Existing is same or better quality
|
|
|
|
# Delete existing to replace with better quality (upgrade, not truly new)
|
|
logger.info(f"Upgrading {show_name}: score {existing_score} -> {new_score}")
|
|
cursor.execute('DELETE FROM celebrity_discovered_videos WHERE id = ?', (existing[0],))
|
|
is_upgrade = True
|
|
else:
|
|
is_upgrade = False
|
|
|
|
# Create a unique video_id from the filename and URL
|
|
video_id = hashlib.md5(f"{easynews_result.filename}:{easynews_result.download_url}".encode()).hexdigest()[:16]
|
|
|
|
# Build display title with proper formatting
|
|
if media_type == 'Movie':
|
|
display_title = show_name
|
|
elif season and episode:
|
|
display_title = f"{show_name} - S{season:02d}E{episode:02d}"
|
|
elif season:
|
|
display_title = f"{show_name} - Season {season}"
|
|
else:
|
|
display_title = show_name
|
|
|
|
# Build description with file info
|
|
size_mb = easynews_result.size_bytes / (1024 * 1024) if easynews_result.size_bytes else 0
|
|
description = f"Quality: {quality or 'Unknown'} | Source: {source or 'Unknown'} | Audio: {audio_codec or 'Unknown'} | Size: {size_mb:.1f} MB | File: {easynews_result.filename}"
|
|
|
|
# Extract numeric resolution (e.g., "720p" -> 720)
|
|
resolution_int = None
|
|
if quality:
|
|
try:
|
|
resolution_int = int(quality.lower().replace('p', ''))
|
|
except ValueError:
|
|
pass
|
|
|
|
# Build metadata JSON
|
|
metadata = json.dumps({
|
|
'audio_codec': audio_codec,
|
|
'source': source,
|
|
'quality': quality,
|
|
'size_bytes': easynews_result.size_bytes,
|
|
'original_filename': easynews_result.filename,
|
|
})
|
|
|
|
# Insert into celebrity_discovered_videos
|
|
cursor.execute('''
|
|
INSERT INTO celebrity_discovered_videos (
|
|
preset_id, celebrity_id, video_id, platform, url, title,
|
|
channel_name, thumbnail, upload_date, description,
|
|
content_type, status, max_resolution, metadata
|
|
) VALUES (?, ?, ?, 'easynews', ?, ?, ?, ?, ?, ?, 'other', 'new', ?, ?)
|
|
''', (
|
|
preset_id,
|
|
celebrity_id,
|
|
video_id,
|
|
easynews_result.download_url,
|
|
display_title,
|
|
f"Easynews - {content_key}", # channel_name stores content key for dedup
|
|
poster_url, # thumbnail (TMDB poster)
|
|
easynews_result.post_date,
|
|
description,
|
|
resolution_int,
|
|
metadata,
|
|
))
|
|
conn.commit()
|
|
|
|
# Pre-cache thumbnail for faster page loading
|
|
if poster_url:
|
|
self._cache_thumbnail(video_id, poster_url, cursor, conn)
|
|
|
|
# Return None for upgrades (don't count as "new" for notifications)
|
|
# Only return ID for truly new content
|
|
if is_upgrade:
|
|
logger.info(f"Upgraded Easynews result: {display_title} ({quality})")
|
|
return None
|
|
else:
|
|
logger.info(f"Saved NEW Easynews result: {display_title} ({quality})")
|
|
return cursor.lastrowid
|
|
except Exception as e:
|
|
logger.error(f"Failed to save appearance result: {e}")
|
|
return None
|
|
finally:
|
|
conn.close()
|
|
|
|
def _cache_thumbnail(self, video_id: str, thumbnail_url: str, cursor, conn) -> None:
|
|
"""
|
|
Pre-cache thumbnail by fetching from URL and storing in database.
|
|
This speeds up Internet Discovery page loading.
|
|
"""
|
|
try:
|
|
import requests
|
|
response = requests.get(thumbnail_url, timeout=10, headers={
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
|
})
|
|
if response.status_code == 200 and response.content:
|
|
cursor.execute('''
|
|
UPDATE celebrity_discovered_videos
|
|
SET thumbnail_data = ?
|
|
WHERE video_id = ?
|
|
''', (response.content, video_id))
|
|
conn.commit()
|
|
logger.debug(f"Cached thumbnail for {video_id}", module="Easynews")
|
|
except Exception as e:
|
|
logger.debug(f"Failed to cache thumbnail for {video_id}: {e}", module="Easynews")
|
|
|
|
def _meets_quality(self, quality: Optional[str], min_quality: str) -> bool:
|
|
"""Check if quality meets minimum requirement."""
|
|
if not min_quality:
|
|
return True # No minimum set, allow all
|
|
if not quality:
|
|
return False # Can't detect quality but minimum is set, reject
|
|
|
|
quality_order = ['360p', '480p', '720p', '1080p', '2160p']
|
|
|
|
try:
|
|
quality_idx = quality_order.index(quality.lower())
|
|
min_idx = quality_order.index(min_quality.lower())
|
|
return quality_idx >= min_idx
|
|
except ValueError:
|
|
return False # Unknown quality format, reject when minimum is set
|
|
|
|
def _result_matches_show(self, filename: str, show_name: str) -> bool:
|
|
"""Check if a result filename matches the show we're looking for.
|
|
|
|
Validates that key words from the show name appear in the filename
|
|
to avoid false matches (e.g., 'Very Bad Day' vs 'Very Bad Road Trip').
|
|
"""
|
|
import re
|
|
|
|
# Normalize both strings
|
|
filename_lower = filename.lower()
|
|
filename_clean = re.sub(r'[^\w\s]', ' ', filename_lower)
|
|
filename_words = filename_clean.split()
|
|
|
|
# Get significant words from show name (skip stopwords)
|
|
show_words = re.sub(r'[^\w\s]', ' ', show_name.lower()).split()
|
|
significant_words = [w for w in show_words if w.lower() not in STOPWORDS and len(w) > 2]
|
|
|
|
if not significant_words:
|
|
return True # Can't validate, allow
|
|
|
|
# For short titles (2-3 words), require words to appear adjacent in filename
|
|
# This prevents matching scattered words like "christmas...karma" in unrelated content
|
|
if len(significant_words) <= 3:
|
|
# Build a pattern that matches the words in order with optional separators
|
|
pattern = r'[\.\s\-_]*'.join(re.escape(w) for w in significant_words)
|
|
if re.search(pattern, filename_clean):
|
|
return True
|
|
return False
|
|
|
|
# For longer titles, count matches and require 80%
|
|
matches = sum(1 for word in significant_words if word in filename_clean)
|
|
match_ratio = matches / len(significant_words)
|
|
|
|
if len(significant_words) <= 5:
|
|
return match_ratio >= 1.0
|
|
else:
|
|
return match_ratio >= 0.8
|
|
|
|
def _get_tmdb_api_key(self) -> Optional[str]:
|
|
"""Get TMDB API key from appearance config."""
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT tmdb_api_key FROM appearance_config WHERE id = 1')
|
|
row = cursor.fetchone()
|
|
return row[0] if row else None
|
|
except Exception:
|
|
return None
|
|
finally:
|
|
conn.close()
|
|
|
|
# =========================================================================
|
|
# DOWNLOAD METHODS
|
|
# =========================================================================
|
|
|
|
def download_result(
|
|
self,
|
|
result_id: int,
|
|
output_dir: str = None,
|
|
progress_callback: Callable[[int, int], None] = None,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Download a result to the appropriate organized path.
|
|
|
|
Args:
|
|
result_id: ID of the result to download
|
|
output_dir: Override output directory (uses default if not specified)
|
|
progress_callback: Optional callback(downloaded_bytes, total_bytes)
|
|
|
|
Returns:
|
|
Dict with download result
|
|
"""
|
|
# Get result
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT * FROM easynews_results WHERE id = ?', (result_id,))
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
return {'success': False, 'message': 'Result not found'}
|
|
result = dict(row)
|
|
finally:
|
|
conn.close()
|
|
|
|
# Get client
|
|
client = self._get_client()
|
|
if not client:
|
|
return {'success': False, 'message': 'No Easynews credentials configured'}
|
|
|
|
# Determine output path
|
|
base_path = output_dir or self.default_output_path
|
|
|
|
# Use TMDB info to create organized path
|
|
if result.get('tmdb_id') and result.get('tmdb_title'):
|
|
tmdb_api_key = self._get_tmdb_api_key()
|
|
if tmdb_api_key:
|
|
identifier = MediaIdentifier(tmdb_api_key)
|
|
# Create TMDBMatch-like object
|
|
from modules.media_identifier import TMDBMatch
|
|
match = TMDBMatch(
|
|
tmdb_id=result['tmdb_id'],
|
|
title=result['tmdb_title'],
|
|
original_title=None,
|
|
media_type='tv' if result.get('parsed_season') else 'movie',
|
|
year=result.get('parsed_year'),
|
|
poster_path=result.get('poster_url'),
|
|
season_number=result.get('parsed_season'),
|
|
episode_number=result.get('parsed_episode'),
|
|
)
|
|
dest_path = identifier.get_organized_path(match, base_path, result['filename'])
|
|
else:
|
|
dest_path = os.path.join(base_path, result['filename'])
|
|
else:
|
|
dest_path = os.path.join(base_path, result['filename'])
|
|
|
|
# Create directory
|
|
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
|
|
|
# Mark as downloading
|
|
self.update_result_status(result_id, 'downloading')
|
|
|
|
try:
|
|
# Download
|
|
download_result = client.download_file(
|
|
result['download_url'],
|
|
dest_path,
|
|
progress_callback=progress_callback,
|
|
)
|
|
|
|
if download_result['success']:
|
|
self.update_result_status(result_id, 'downloaded', dest_path)
|
|
logger.info(f"Downloaded: {result['filename']} -> {dest_path}")
|
|
return {
|
|
'success': True,
|
|
'path': dest_path,
|
|
'size': download_result.get('size', 0),
|
|
}
|
|
else:
|
|
self.update_result_status(result_id, 'failed')
|
|
return {
|
|
'success': False,
|
|
'message': download_result.get('message', 'Download failed'),
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Download failed for result {result_id}: {e}")
|
|
self.update_result_status(result_id, 'failed')
|
|
return {
|
|
'success': False,
|
|
'message': str(e),
|
|
}
|