main.py 52 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260
  1. import os
  2. import time
  3. import datetime
  4. from datetime import datetime, time as dt_time
  5. import pickle
  6. import threading
  7. import queue
  8. import logging
  9. import logging.handlers
  10. import glob
  11. import json
  12. from concurrent.futures import ThreadPoolExecutor
  13. from googleapiclient.discovery import build
  14. import subprocess
  15. from google.auth.transport.requests import Request
  16. import sys
  17. from auth import get_token, get_token_non_interactive, get_service_account_token
  18. # Home Assistant Integration
  19. try:
  20. from homeassistant_integration import (
  21. init_homeassistant_integration,
  22. update_homeassistant_stats,
  23. stop_homeassistant_integration,
  24. set_service_waiting,
  25. set_service_running,
  26. set_service_completed,
  27. set_service_error
  28. )
  29. HOMEASSISTANT_ENABLED = True
  30. print("🏠 Home Assistant integration available")
  31. except ImportError:
  32. HOMEASSISTANT_ENABLED = False
  33. print("⚠️ Home Assistant integration not available (install paho-mqtt and psutil)")
  34. # Dummy functions if Home Assistant is not available
  35. def set_service_waiting(next_run): pass
  36. def set_service_running(): pass
  37. def set_service_completed(): pass
  38. def set_service_error(): pass
  39. # Dummy functions if not available
  40. def init_homeassistant_integration(*args, **kwargs): return None
  41. def update_homeassistant_stats(*args, **kwargs): pass
  42. def stop_homeassistant_integration(*args, **kwargs): pass
  43. # Constants
  44. OUTPUT_DIR = "/media/media/YouTube" # Change this to your Emby folder
  45. DAYS_BACK = 5 # Videos published in the last 5 days
  46. DAYS_TO_KEEP = 10 # Delete videos older than 10 days
  47. TOKEN_FILE = "youtube_token.pickle" # Token file from the OAuth script
  48. EXIT_TIME = dt_time(6, 0) # Exit the script at 06:00
  49. POPULAR_VIDEOS_MAX_RESULTS = 10 # Number of popular videos to fetch (you can change this)
  50. REGION_CODES = ["US", "GB", "LU", "LK"] # Regions to fetch popular videos from
  51. # Logging Configuration
  52. LOG_DIR = "logs"
  53. LOG_FILE = "youtube_predownloader.log"
  54. LOG_LEVEL = logging.INFO
  55. LOG_MAX_SIZE = 50 * 1024 * 1024 # 50MB per log file
  56. LOG_BACKUP_COUNT = 5 # Keep 5 backup files
  57. LOG_RETENTION_DAYS = 10 # Delete logs older than 10 days
  58. # Conversion Queue Settings
  59. MAX_CONCURRENT_CONVERSIONS = 2 # Maximum number of simultaneous video conversions
  60. CONVERSION_QUEUE_SIZE = 50 # Maximum items in conversion queue
  61. def setup_logging():
  62. """Set up comprehensive logging with file rotation and cleanup."""
  63. # Create logs directory if it doesn't exist
  64. if not os.path.exists(LOG_DIR):
  65. os.makedirs(LOG_DIR)
  66. # Clean up old log files first
  67. cleanup_old_logs()
  68. # Create logger
  69. logger = logging.getLogger()
  70. logger.setLevel(LOG_LEVEL)
  71. # Clear any existing handlers
  72. for handler in logger.handlers[:]:
  73. logger.removeHandler(handler)
  74. # Create formatters
  75. detailed_formatter = logging.Formatter(
  76. '%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s',
  77. datefmt='%Y-%m-%d %H:%M:%S'
  78. )
  79. console_formatter = logging.Formatter(
  80. '%(asctime)s - %(levelname)s - %(message)s',
  81. datefmt='%H:%M:%S'
  82. )
  83. # File handler with rotation
  84. log_file_path = os.path.join(LOG_DIR, LOG_FILE)
  85. file_handler = logging.handlers.RotatingFileHandler(
  86. log_file_path,
  87. maxBytes=LOG_MAX_SIZE,
  88. backupCount=LOG_BACKUP_COUNT,
  89. encoding='utf-8'
  90. )
  91. file_handler.setLevel(logging.DEBUG) # Log everything to file
  92. file_handler.setFormatter(detailed_formatter)
  93. # Console handler (less verbose)
  94. console_handler = logging.StreamHandler(sys.stdout)
  95. console_handler.setLevel(LOG_LEVEL)
  96. console_handler.setFormatter(console_formatter)
  97. # Add handlers to logger
  98. logger.addHandler(file_handler)
  99. logger.addHandler(console_handler)
  100. # Log startup information
  101. logger.info("=" * 80)
  102. logger.info("YouTube Predownloader Starting")
  103. logger.info("=" * 80)
  104. logger.info(f"Log file: {log_file_path}")
  105. logger.info(f"Log level: {logging.getLevelName(LOG_LEVEL)}")
  106. logger.info(f"Output directory: {OUTPUT_DIR}")
  107. logger.info(f"Days back to fetch: {DAYS_BACK}")
  108. logger.info(f"Days to keep videos: {DAYS_TO_KEEP}")
  109. logger.info(f"Log retention days: {LOG_RETENTION_DAYS}")
  110. return logger
  111. def cleanup_old_logs():
  112. """Remove log files older than LOG_RETENTION_DAYS."""
  113. try:
  114. cutoff_time = time.time() - (LOG_RETENTION_DAYS * 86400)
  115. log_pattern = os.path.join(LOG_DIR, "*.log*")
  116. deleted_count = 0
  117. for log_file in glob.glob(log_pattern):
  118. try:
  119. if os.path.getmtime(log_file) < cutoff_time:
  120. os.remove(log_file)
  121. deleted_count += 1
  122. print(f"Deleted old log file: {log_file}")
  123. except (OSError, IOError) as e:
  124. print(f"Failed to delete log file {log_file}: {e}")
  125. if deleted_count > 0:
  126. print(f"Cleaned up {deleted_count} old log files")
  127. except Exception as e:
  128. print(f"Error during log cleanup: {e}")
  129. def log_system_info():
  130. """Log system and environment information."""
  131. logger = logging.getLogger(__name__)
  132. try:
  133. # Log Python and system info
  134. import platform
  135. import psutil
  136. logger.info("System Information:")
  137. logger.info(f" Python version: {platform.python_version()}")
  138. logger.info(f" Platform: {platform.platform()}")
  139. logger.info(f" CPU count: {os.cpu_count()}")
  140. # Memory info
  141. memory = psutil.virtual_memory()
  142. logger.info(f" Total memory: {memory.total // (1024**3):.1f} GB")
  143. logger.info(f" Available memory: {memory.available // (1024**3):.1f} GB")
  144. # Disk space for output directory
  145. if os.path.exists(OUTPUT_DIR):
  146. disk_usage = psutil.disk_usage(OUTPUT_DIR)
  147. logger.info(f" Output disk free: {disk_usage.free // (1024**3):.1f} GB")
  148. # Check required tools
  149. tools = [
  150. ('yt-dlp', ['yt-dlp', '--version']),
  151. ('ffmpeg', ['ffmpeg', '-version']),
  152. ('ffprobe', ['ffprobe', '-version'])
  153. ]
  154. for tool_name, cmd in tools:
  155. try:
  156. result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
  157. if result.returncode == 0:
  158. version_line = result.stdout.split('\n')[0] if result.stdout else "unknown"
  159. logger.info(f" {tool_name}: {version_line}")
  160. else:
  161. logger.warning(f" {tool_name}: Not found or error")
  162. except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError):
  163. logger.error(f" {tool_name}: Not available")
  164. except Exception as e:
  165. logger.error(f"Error collecting system info: {e}")
  166. # Initialize logging at module level
  167. logger = setup_logging()
  168. log_system_info()
  169. # FFmpeg/FFprobe paths
  170. FFPROBE_PATH = "/usr/bin/ffprobe"
  171. FFMPEG_PATH = "/usr/bin/ffmpeg"
  172. # Global conversion queue and executor
  173. conversion_queue = queue.Queue(maxsize=CONVERSION_QUEUE_SIZE)
  174. conversion_executor = ThreadPoolExecutor(max_workers=MAX_CONCURRENT_CONVERSIONS, thread_name_prefix="VideoConverter")
  175. # Progress tracking
  176. download_stats = {
  177. 'total_videos': 0,
  178. 'completed_videos': 0,
  179. 'current_video': '',
  180. 'download_speed': 0,
  181. 'active_downloads': 0
  182. }
  183. conversion_stats = {
  184. 'active_conversions': 0,
  185. 'completed_conversions': 0,
  186. 'current_conversion': '',
  187. 'conversion_progress': 0
  188. }
  189. def update_yt_dlp():
  190. """Update yt-dlp to the latest version."""
  191. logger = logging.getLogger(__name__)
  192. logger.info("Updating yt-dlp to the latest version...")
  193. try:
  194. result = subprocess.run(["pip", "install", "--upgrade", "yt-dlp"],
  195. capture_output=True, text=True, check=True)
  196. logger.info("yt-dlp updated successfully!")
  197. logger.debug(f"yt-dlp update output: {result.stdout}")
  198. except subprocess.CalledProcessError as e:
  199. logger.error(f"Failed to update yt-dlp: {e}")
  200. logger.debug(f"yt-dlp update error: {e.stderr}")
  201. raise
  202. def authenticate_youtube_api():
  203. """Authenticate using our enhanced auth system (tries non-interactive first)."""
  204. logger = logging.getLogger(__name__)
  205. logger.info("Attempting non-interactive authentication...")
  206. # Try non-interactive authentication first
  207. creds = get_token_non_interactive()
  208. if not creds:
  209. logger.error("Non-interactive authentication failed.")
  210. logger.info("You need to run: python3 auth.py --interactive")
  211. logger.info("Or set up service account authentication.")
  212. raise Exception("Authentication failed. No valid credentials available.")
  213. logger.info("Authentication successful!")
  214. # Build the YouTube API client
  215. youtube = build("youtube", "v3", credentials=creds)
  216. logger.debug("YouTube API client created successfully")
  217. return youtube
  218. def authenticate_with_token():
  219. """Legacy function - kept for compatibility but redirects to new auth system."""
  220. return authenticate_youtube_api()
  221. def get_subscribed_channels(youtube):
  222. """Fetch the list of subscribed channels."""
  223. logger = logging.getLogger(__name__)
  224. logger.info("Fetching subscribed channels...")
  225. channels = []
  226. request = youtube.subscriptions().list(
  227. part="snippet",
  228. mine=True,
  229. maxResults=50
  230. )
  231. page_count = 0
  232. while request:
  233. page_count += 1
  234. logger.debug(f"Fetching subscriptions page {page_count}")
  235. response = request.execute()
  236. page_channels = len(response["items"])
  237. logger.debug(f"Found {page_channels} channels on page {page_count}")
  238. for item in response["items"]:
  239. channel_id = item["snippet"]["resourceId"]["channelId"]
  240. channel_title = item["snippet"]["title"]
  241. channels.append(channel_id)
  242. logger.debug(f"Added channel: {channel_title} ({channel_id})")
  243. request = youtube.subscriptions().list_next(request, response)
  244. logger.info(f"Found {len(channels)} subscribed channels")
  245. return channels
  246. def get_recent_videos(youtube, channel_id):
  247. """Fetch videos from a channel published in the last `DAYS_BACK` days."""
  248. logger = logging.getLogger(__name__)
  249. recent_videos = []
  250. published_after = (datetime.datetime.now() - datetime.timedelta(days=DAYS_BACK)).isoformat("T") + "Z"
  251. try:
  252. request = youtube.search().list(
  253. part="snippet",
  254. channelId=channel_id,
  255. publishedAfter=published_after,
  256. maxResults=50,
  257. type="video",
  258. order="date"
  259. )
  260. response = request.execute()
  261. for item in response["items"]:
  262. video_data = {
  263. "video_id": item["id"]["videoId"],
  264. "title": item["snippet"]["title"],
  265. "description": item["snippet"]["description"],
  266. "published_at": item["snippet"]["publishedAt"],
  267. "channel_title": item["snippet"]["channelTitle"]
  268. }
  269. recent_videos.append(video_data)
  270. logger.debug(f"Found recent video: {video_data['title']} from {video_data['channel_title']}")
  271. logger.debug(f"Found {len(recent_videos)} recent videos from channel {channel_id}")
  272. except Exception as e:
  273. logger.error(f"Error fetching recent videos from channel {channel_id}: {e}")
  274. return recent_videos
  275. def get_popular_videos(youtube, region_code):
  276. """Fetch the most popular videos for a given region."""
  277. logger = logging.getLogger(__name__)
  278. popular_videos = []
  279. try:
  280. logger.debug(f"Fetching popular videos for region: {region_code}")
  281. request = youtube.videos().list(
  282. part="snippet,statistics",
  283. chart="mostPopular",
  284. regionCode=region_code, # Region code passed here
  285. maxResults=POPULAR_VIDEOS_MAX_RESULTS
  286. )
  287. response = request.execute()
  288. for item in response["items"]:
  289. video_data = {
  290. "video_id": item["id"],
  291. "title": item["snippet"]["title"],
  292. "description": item["snippet"]["description"],
  293. "published_at": item["snippet"]["publishedAt"],
  294. "channel_title": item["snippet"]["channelTitle"]
  295. }
  296. popular_videos.append(video_data)
  297. logger.debug(f"Found popular video ({region_code}): {video_data['title']} from {video_data['channel_title']}")
  298. logger.info(f"Found {len(popular_videos)} popular videos from region {region_code}")
  299. except Exception as e:
  300. logger.error(f"Error fetching popular videos from region {region_code}: {e}")
  301. return popular_videos
  302. def list_available_formats(video_id):
  303. """List available formats for a video (for debugging)."""
  304. logger = logging.getLogger(__name__)
  305. try:
  306. command = [
  307. "yt-dlp",
  308. "--list-formats",
  309. f"https://www.youtube.com/watch?v={video_id}"
  310. ]
  311. result = subprocess.run(command, capture_output=True, text=True, timeout=30)
  312. if result.returncode == 0:
  313. logger.debug(f"Available formats for {video_id}:")
  314. for line in result.stdout.split('\n'):
  315. if 'mp4' in line or 'webm' in line or '2160p' in line or '1440p' in line or '1080p' in line:
  316. logger.debug(f" {line.strip()}")
  317. else:
  318. logger.warning(f"Could not list formats for {video_id}: {result.stderr}")
  319. except Exception as e:
  320. logger.warning(f"Error listing formats for {video_id}: {e}")
  321. def download_video(video_id, output_path, video_title="Unknown"):
  322. """Download a YouTube video with quality priority - downloads 4K VP9/AV1 if needed for conversion."""
  323. global download_stats
  324. logger = logging.getLogger(__name__)
  325. try:
  326. # Update stats
  327. download_stats['active_downloads'] += 1
  328. download_stats['current_video'] = video_title[:50] + "..." if len(video_title) > 50 else video_title
  329. logger.info(f"Starting download: {video_title} (ID: {video_id})")
  330. logger.debug(f"Download path: {output_path}")
  331. # List available formats for debugging (only in debug mode)
  332. if logger.isEnabledFor(logging.DEBUG):
  333. list_available_formats(video_id)
  334. # Update Home Assistant
  335. update_homeassistant_stats(
  336. download_queue=download_stats['total_videos'] - download_stats['completed_videos'],
  337. conversion_queue=conversion_queue.qsize(),
  338. download_progress=round((download_stats['completed_videos'] / max(download_stats['total_videos'], 1)) * 100, 1),
  339. active_downloads=download_stats['active_downloads'],
  340. active_conversions=conversion_stats['active_conversions']
  341. )
  342. # 4K-prioritized approach: Try Apple TV compatible 4K first, then any 4K, then lower resolutions
  343. command = [
  344. "yt-dlp",
  345. "-f", "(bestvideo[height>=2160][vcodec~='^(avc|h264|hevc|h265)']+bestaudio)/(bestvideo[height>=2160]+bestaudio)/(bestvideo[height>=1440]+bestaudio)/(bestvideo[height>=1080]+bestaudio)/best",
  346. "--merge-output-format", "mp4",
  347. "--no-mtime",
  348. "--newline", # Better for parsing progress
  349. "--verbose" if logger.isEnabledFor(logging.DEBUG) else "--no-warnings",
  350. "--write-info-json", # Write metadata for analysis
  351. f"https://www.youtube.com/watch?v={video_id}",
  352. "-o", os.path.join(output_path, "%(title)s.%(ext)s")
  353. ]
  354. logger.debug(f"yt-dlp command: {' '.join(command)}")
  355. try:
  356. start_time = time.time()
  357. logging.info(f"Starting download with command: {' '.join(command)}")
  358. result = subprocess.run(command, check=True, capture_output=True, text=True)
  359. end_time = time.time()
  360. download_time = end_time - start_time
  361. logger.info(f"Download completed in {download_time:.1f}s: {video_title}")
  362. logging.info(f"yt-dlp stdout: {result.stdout}")
  363. if result.stderr:
  364. logging.warning(f"yt-dlp stderr: {result.stderr}")
  365. logger.debug(f"yt-dlp output: {result.stdout[-500:]}") # Log last 500 chars
  366. # Analyze what was actually downloaded and check Apple TV compatibility
  367. try:
  368. downloaded_files = [f for f in os.listdir(output_path) if f.endswith(('.mp4', '.mkv', '.webm', '.m4v'))]
  369. if downloaded_files:
  370. for file in downloaded_files:
  371. file_path = os.path.join(output_path, file)
  372. try:
  373. # Get video info
  374. probe_cmd = [FFPROBE_PATH, "-v", "quiet", "-print_format", "json",
  375. "-show_streams", file_path]
  376. probe_result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  377. video_info = json.loads(probe_result.stdout)
  378. # Find video stream
  379. video_stream = next((s for s in video_info['streams'] if s['codec_type'] == 'video'), None)
  380. if video_stream:
  381. height = video_stream.get('height', 'unknown')
  382. width = video_stream.get('width', 'unknown')
  383. codec = video_stream.get('codec_name', 'unknown')
  384. bitrate = video_stream.get('bit_rate', 'unknown')
  385. fps = video_stream.get('r_frame_rate', 'unknown')
  386. quality_label = "Unknown"
  387. if height >= 2160:
  388. quality_label = "4K"
  389. elif height >= 1440:
  390. quality_label = "1440p"
  391. elif height >= 1080:
  392. quality_label = "1080p"
  393. elif height >= 720:
  394. quality_label = "720p"
  395. logger.info(f"Downloaded quality: {quality_label} ({width}x{height}), codec: {codec}, fps: {fps}")
  396. if bitrate != 'unknown':
  397. bitrate_mbps = int(bitrate) / 1000000 if bitrate.isdigit() else 'unknown'
  398. logger.info(f"Video bitrate: {bitrate_mbps:.1f} Mbps")
  399. # Log file size
  400. file_size = os.path.getsize(file_path)
  401. file_size_mb = file_size / (1024 * 1024)
  402. logger.info(f"Downloaded file size: {file_size_mb:.1f} MB")
  403. # Check Apple TV compatibility and queue for conversion if needed
  404. needs_conversion = False
  405. conversion_reason = ""
  406. # Check if file needs conversion for Apple TV compatibility
  407. if codec.lower() in ['vp9', 'av01', 'av1']:
  408. needs_conversion = True
  409. conversion_reason = f"Incompatible codec ({codec}) for Apple TV"
  410. elif not file.endswith('.mp4'):
  411. needs_conversion = True
  412. conversion_reason = f"Incompatible container ({file.split('.')[-1]}) for Apple TV"
  413. elif height >= 2160 and codec.lower() not in ['h264', 'avc1', 'hevc', 'h265']:
  414. needs_conversion = True
  415. conversion_reason = f"4K video with non-Apple TV codec ({codec})"
  416. if needs_conversion:
  417. logger.info(f"Queuing for conversion: {conversion_reason}")
  418. # Determine priority based on video resolution
  419. priority = 2 if height >= 2160 else 1 # 4K gets higher priority
  420. add_to_conversion_queue(file_path, priority)
  421. print_queue_status()
  422. else:
  423. logger.info(f"File is Apple TV compatible: {codec} in MP4 container")
  424. except Exception as e:
  425. logger.debug(f"Could not analyze downloaded file {file}: {e}")
  426. except Exception as e:
  427. logger.debug(f"Could not analyze downloaded files: {e}")
  428. except subprocess.CalledProcessError as e:
  429. download_stats['active_downloads'] -= 1
  430. logger.error(f"Download failed for {video_title} (ID: {video_id}): {e}")
  431. logger.debug(f"yt-dlp error output: {e.stderr}")
  432. return False
  433. except Exception as e:
  434. download_stats['active_downloads'] -= 1
  435. logger.error(f"Unexpected error downloading {video_title} (ID: {video_id}): {e}")
  436. # Update completed count
  437. download_stats['completed_videos'] += 1
  438. download_stats['active_downloads'] -= 1
  439. return True
  440. # Update Home Assistant with completion
  441. update_homeassistant_stats(
  442. download_queue=download_stats['total_videos'] - download_stats['completed_videos'],
  443. download_progress=round((download_stats['completed_videos'] / max(download_stats['total_videos'], 1)) * 100, 1),
  444. active_downloads=download_stats['active_downloads']
  445. )
  446. return True
  447. except subprocess.CalledProcessError as e:
  448. download_stats['active_downloads'] -= 1
  449. logger.error(f"Download failed for {video_title} (ID: {video_id}): {e}")
  450. logger.debug(f"yt-dlp error output: {e.stderr}")
  451. return False
  452. except Exception as e:
  453. download_stats['active_downloads'] -= 1
  454. logger.error(f"Unexpected error downloading {video_title} (ID: {video_id}): {e}")
  455. return False
  456. def add_to_conversion_queue(input_file, priority=1):
  457. """Add a video file to the conversion queue with priority."""
  458. logger = logging.getLogger(__name__)
  459. try:
  460. # Higher number = higher priority (4K gets priority 2, others get 1)
  461. conversion_queue.put((priority, input_file), timeout=1)
  462. logger.info(f"Added to conversion queue: {os.path.basename(input_file)} (priority: {priority})")
  463. logger.debug(f"Queue size after addition: {conversion_queue.qsize()}")
  464. return True
  465. except queue.Full:
  466. logger.warning(f"Conversion queue is full! Skipping: {os.path.basename(input_file)}")
  467. return False
  468. except Exception as e:
  469. logger.error(f"Error adding to conversion queue: {e}")
  470. return False
  471. def get_queue_status():
  472. """Get current conversion queue status."""
  473. queue_size = conversion_queue.qsize()
  474. active_conversions = conversion_executor._threads # Active thread count
  475. return {
  476. 'queued': queue_size,
  477. 'active': len(active_conversions) if active_conversions else 0,
  478. 'max_concurrent': MAX_CONCURRENT_CONVERSIONS
  479. }
  480. def print_queue_status():
  481. """Print current queue status."""
  482. logger = logging.getLogger(__name__)
  483. status = get_queue_status()
  484. logger.info(f"Conversion Status: {status['active']}/{status['max_concurrent']} active, {status['queued']} queued")
  485. def process_conversion_queue():
  486. """Background worker to process the conversion queue."""
  487. logger = logging.getLogger(__name__)
  488. logger.info("Conversion queue worker started")
  489. while True:
  490. try:
  491. # Get item from queue (blocks until available)
  492. priority, input_file = conversion_queue.get(timeout=30)
  493. # Check if file still exists
  494. if not os.path.exists(input_file):
  495. logger.warning(f"File no longer exists, skipping conversion: {input_file}")
  496. conversion_queue.task_done()
  497. continue
  498. logger.info(f"Starting conversion (priority {priority}): {os.path.basename(input_file)}")
  499. # Perform the conversion
  500. success = convert_to_apple_tv_compatible(input_file)
  501. if success:
  502. logger.info(f"Conversion completed successfully: {os.path.basename(input_file)}")
  503. else:
  504. logger.error(f"Conversion failed: {os.path.basename(input_file)}")
  505. # Mark task as done
  506. conversion_queue.task_done()
  507. except queue.Empty:
  508. # No items in queue for 30 seconds, continue waiting
  509. logger.debug("Conversion queue empty, waiting for new items...")
  510. continue
  511. except Exception as e:
  512. logger.error(f"Conversion queue worker error: {e}")
  513. try:
  514. conversion_queue.task_done()
  515. except:
  516. pass
  517. def convert_to_apple_tv_compatible(input_file):
  518. """Convert a video to Apple TV 4K compatible format, optimized by resolution."""
  519. global conversion_stats
  520. logger = logging.getLogger(__name__)
  521. base_name = os.path.splitext(input_file)[0]
  522. mp4_file = base_name + ".mp4"
  523. # If input is already MP4, use a temporary output file to avoid overwriting during conversion
  524. temp_output = None
  525. if input_file.lower().endswith('.mp4'):
  526. temp_output = base_name + "_converted.mp4"
  527. final_output = mp4_file
  528. mp4_file = temp_output
  529. else:
  530. final_output = mp4_file
  531. # Update conversion stats
  532. conversion_stats['active_conversions'] += 1
  533. conversion_stats['current_conversion'] = os.path.basename(input_file)
  534. logger.info(f"Converting {os.path.basename(input_file)} to Apple TV compatible format...")
  535. logger.debug(f"Input file: {input_file}")
  536. logger.debug(f"Output file: {mp4_file}")
  537. # Update Home Assistant
  538. update_homeassistant_stats(
  539. conversion_queue=conversion_queue.qsize(),
  540. active_conversions=conversion_stats['active_conversions']
  541. )
  542. # Detect video properties to optimize conversion
  543. try:
  544. import json
  545. probe_cmd = [
  546. FFPROBE_PATH, "-v", "quiet", "-print_format", "json",
  547. "-show_format", "-show_streams", input_file
  548. ]
  549. logger.debug(f"Probing video with command: {' '.join(probe_cmd)}")
  550. probe_result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  551. video_info = json.loads(probe_result.stdout)
  552. # Find video stream
  553. video_stream = next((s for s in video_info['streams'] if s['codec_type'] == 'video'), None)
  554. is_4k = video_stream and int(video_stream.get('height', 0)) >= 2160
  555. is_high_res = video_stream and int(video_stream.get('height', 0)) >= 1080
  556. if video_stream:
  557. height = video_stream.get('height', 'unknown')
  558. codec = video_stream.get('codec_name', 'unknown')
  559. bitrate = video_stream.get('bit_rate', 'unknown')
  560. logger.info(f"Video properties: {height}p, codec: {codec}, bitrate: {bitrate}")
  561. except (subprocess.CalledProcessError, json.JSONDecodeError, Exception) as e:
  562. logger.warning(f"Could not probe video properties: {e}, using default settings")
  563. is_4k = False
  564. is_high_res = True
  565. # Apple TV 4K optimized encoding based on source quality:
  566. if is_4k:
  567. # 4K content: Use H.265 for better compression at high resolution
  568. codec_args = [
  569. "-c:v", "libx265", # H.265 for 4K (better compression)
  570. "-preset", "medium", # Better compression for 4K
  571. "-crf", "20", # Higher quality for 4K content
  572. "-level", "5.1", # Level 5.1 supports 4K
  573. "-pix_fmt", "yuv420p", # Ensure compatibility
  574. ]
  575. logger.info("Using H.265 encoding for 4K content")
  576. elif is_high_res:
  577. # 1080p content: Use H.264 for maximum compatibility
  578. codec_args = [
  579. "-c:v", "libx264", # H.264 for 1080p (max compatibility)
  580. "-profile:v", "high", # High profile for better quality
  581. "-preset", "fast", # Faster encoding for 1080p
  582. "-crf", "21", # Good quality for 1080p
  583. "-level", "4.1", # Level 4.1 supports 1080p at high bitrates
  584. ]
  585. logger.info("Using H.264 encoding for 1080p content")
  586. else:
  587. # Lower resolution: Use H.264 with faster settings
  588. codec_args = [
  589. "-c:v", "libx264", # H.264 for lower res
  590. "-profile:v", "main", # Main profile for lower res
  591. "-preset", "fast", # Fast encoding
  592. "-crf", "23", # Standard quality
  593. "-level", "3.1", # Level 3.1 for lower res
  594. ]
  595. logger.info("Using H.264 encoding for lower resolution content")
  596. command = [
  597. FFMPEG_PATH, "-i", input_file,
  598. *codec_args,
  599. "-c:a", "aac", # AAC audio codec
  600. "-b:a", "192k", # Higher audio bitrate for better quality
  601. "-movflags", "+faststart", # Optimize for streaming/quick playback
  602. "-y", # Overwrite output file if exists
  603. mp4_file
  604. ]
  605. try:
  606. logger.debug(f"FFmpeg command: {' '.join(command)}")
  607. logger.info("Starting conversion... This may take a while for 4K content.")
  608. start_time = time.time()
  609. result = subprocess.run(command, check=True, capture_output=True, text=True)
  610. end_time = time.time()
  611. conversion_time = end_time - start_time
  612. logger.info(f"Conversion completed in {conversion_time:.1f}s: {mp4_file}")
  613. # Check if output file was created and has reasonable size
  614. if os.path.exists(mp4_file):
  615. input_size = os.path.getsize(input_file)
  616. output_size = os.path.getsize(mp4_file)
  617. size_ratio = output_size / input_size if input_size > 0 else 0
  618. logger.info(f"File sizes - Input: {input_size // (1024*1024):.1f}MB, Output: {output_size // (1024*1024):.1f}MB (ratio: {size_ratio:.2f})")
  619. # Only replace original if output seems reasonable
  620. if output_size > input_size * 0.1: # Output should be at least 10% of input size
  621. # If we used a temporary file, replace the original
  622. if temp_output:
  623. os.remove(input_file)
  624. os.rename(mp4_file, final_output)
  625. logger.info(f"Original file replaced with converted version: {os.path.basename(final_output)}")
  626. else:
  627. os.remove(input_file)
  628. logger.info(f"Original file deleted: {os.path.basename(input_file)}")
  629. else:
  630. logger.warning(f"Output file seems too small, keeping original: {os.path.basename(input_file)}")
  631. if temp_output and os.path.exists(mp4_file):
  632. os.remove(mp4_file) # Clean up failed temporary file
  633. # Update completion stats
  634. conversion_stats['active_conversions'] -= 1
  635. conversion_stats['completed_conversions'] += 1
  636. # Update Home Assistant
  637. update_homeassistant_stats(
  638. conversion_queue=conversion_queue.qsize(),
  639. active_conversions=conversion_stats['active_conversions']
  640. )
  641. return True
  642. except subprocess.CalledProcessError as e:
  643. conversion_stats['active_conversions'] -= 1
  644. logger.error(f"Conversion failed for {os.path.basename(input_file)}: {e}")
  645. if e.stderr:
  646. logger.debug(f"FFmpeg error output: {e.stderr}")
  647. # Clean up temporary file if it exists
  648. if temp_output and os.path.exists(mp4_file):
  649. os.remove(mp4_file)
  650. logger.debug(f"Cleaned up temporary file: {mp4_file}")
  651. # Update Home Assistant
  652. update_homeassistant_stats(
  653. conversion_queue=conversion_queue.qsize(),
  654. active_conversions=conversion_stats['active_conversions']
  655. )
  656. return False
  657. except Exception as e:
  658. conversion_stats['active_conversions'] -= 1
  659. logger.error(f"Unexpected error during conversion of {os.path.basename(input_file)}: {e}")
  660. # Update Home Assistant
  661. update_homeassistant_stats(
  662. conversion_queue=conversion_queue.qsize(),
  663. active_conversions=conversion_stats['active_conversions']
  664. )
  665. return False
  666. # Temporarily commented out video conversion functionality
  667. # def convert_to_mp4(input_file):
  668. # """Convert a video to mp4 and delete the original file."""
  669. # mp4_file = os.path.splitext(input_file)[0] + ".mp4"
  670. # print(f"Converting {input_file} to {mp4_file}...")
  671. # command = ["ffmpeg", "-i", input_file, "-c:v", "libx264", "-preset", "fast", "-c:a", "aac", mp4_file]
  672. # subprocess.run(command, check=True)
  673. # print(f"Conversion completed: {mp4_file}")
  674. # if os.path.exists(mp4_file):
  675. # os.remove(input_file)
  676. # print(f"Original file deleted: {input_file}")
  677. def create_nfo_file(video_data, file_path):
  678. """Create an Emby-compatible NFO file."""
  679. logger = logging.getLogger(__name__)
  680. try:
  681. nfo_content = f"""<?xml version="1.0" encoding="utf-8" standalone="yes"?>
  682. <movie>
  683. <title>{video_data['title']}</title>
  684. <plot>{video_data['description']}</plot>
  685. <year>{video_data['published_at'][:4]}</year>
  686. <premiered>{video_data['published_at']}</premiered>
  687. <studio>{video_data['channel_title']}</studio>
  688. </movie>
  689. """
  690. with open(file_path, "w", encoding="utf-8") as nfo_file:
  691. nfo_file.write(nfo_content)
  692. logger.debug(f"Created NFO file: {file_path}")
  693. except Exception as e:
  694. logger.error(f"Failed to create NFO file {file_path}: {e}")
  695. raise
  696. def check_exit_time():
  697. """Check if the current time is past the exit time."""
  698. logger = logging.getLogger(__name__)
  699. now = datetime.now().time()
  700. if now >= EXIT_TIME:
  701. logger.info(f"Current time ({now.strftime('%H:%M:%S')}) exceeds exit time ({EXIT_TIME.strftime('%H:%M:%S')}). Terminating script.")
  702. sys.exit(0)
  703. def delete_old_videos(folder, days=5):
  704. """Delete video files and their folders older than `days`."""
  705. logger = logging.getLogger(__name__)
  706. logger.info(f"Starting cleanup of videos older than {days} days in {folder}")
  707. if not os.path.exists(folder):
  708. logger.warning(f"Output folder does not exist: {folder}")
  709. return
  710. now = time.time()
  711. cutoff = now - (days * 86400) # Convert days to seconds
  712. deleted_files = 0
  713. deleted_dirs = 0
  714. total_size_freed = 0
  715. try:
  716. for root, dirs, files in os.walk(folder, topdown=False):
  717. # Delete files
  718. for file in files:
  719. file_path = os.path.join(root, file)
  720. try:
  721. if os.path.isfile(file_path) and os.path.getmtime(file_path) < cutoff:
  722. file_size = os.path.getsize(file_path)
  723. os.remove(file_path)
  724. deleted_files += 1
  725. total_size_freed += file_size
  726. logger.debug(f"Deleted file: {file_path} ({file_size // (1024*1024):.1f}MB)")
  727. except (OSError, IOError) as e:
  728. logger.error(f"Failed to delete file {file_path}: {e}")
  729. # Remove empty directories
  730. for dir_name in dirs:
  731. dir_path = os.path.join(root, dir_name)
  732. try:
  733. if os.path.exists(dir_path) and not os.listdir(dir_path): # Check if the directory is empty
  734. os.rmdir(dir_path)
  735. deleted_dirs += 1
  736. logger.debug(f"Deleted empty directory: {dir_path}")
  737. except (OSError, IOError) as e:
  738. logger.error(f"Failed to delete directory {dir_path}: {e}")
  739. if deleted_files > 0 or deleted_dirs > 0:
  740. size_mb = total_size_freed / (1024 * 1024)
  741. logger.info(f"Cleanup completed: {deleted_files} files, {deleted_dirs} directories deleted, {size_mb:.1f}MB freed")
  742. else:
  743. logger.info("No old files found to delete")
  744. except Exception as e:
  745. logger.error(f"Error during cleanup: {e}")
  746. def video_exists(output_path, video_id):
  747. """Check if a video already exists in the directory."""
  748. return os.path.exists(os.path.join(output_path, video_id))
  749. def check_exit_time():
  750. """Check if the current time is past the exit time."""
  751. now = datetime.now().time()
  752. if now >= EXIT_TIME:
  753. print("Current time exceeds exit time. Terminating script.")
  754. sys.exit(0)
  755. def main():
  756. global download_stats
  757. logger = logging.getLogger(__name__)
  758. logger.info("Starting main download process")
  759. # Initialize Home Assistant integration
  760. if HOMEASSISTANT_ENABLED:
  761. logger.info("Initializing Home Assistant integration...")
  762. ha_integration = init_homeassistant_integration()
  763. if ha_integration:
  764. logger.info("Home Assistant integration started successfully")
  765. else:
  766. logger.warning("Home Assistant integration failed to start")
  767. # Start the conversion queue worker
  768. logger.info("Starting video conversion queue system...")
  769. logger.info(f"Max concurrent conversions: {MAX_CONCURRENT_CONVERSIONS}")
  770. # Start background conversion worker
  771. conversion_worker = threading.Thread(target=process_conversion_queue, daemon=True, name="ConversionQueueWorker")
  772. conversion_worker.start()
  773. logger.debug("Conversion queue worker thread started")
  774. # Update yt-dlp
  775. try:
  776. update_yt_dlp()
  777. except Exception as e:
  778. logger.error(f"Failed to update yt-dlp: {e}")
  779. # Continue anyway, existing version might work
  780. # Authenticate YouTube API
  781. logger.info("Authenticating with YouTube API...")
  782. try:
  783. youtube = authenticate_youtube_api()
  784. logger.info("YouTube API authentication successful")
  785. except Exception as e:
  786. logger.error(f"YouTube API authentication failed: {e}")
  787. raise
  788. # Ensure output directory exists
  789. if not os.path.exists(OUTPUT_DIR):
  790. logger.info(f"Creating output directory: {OUTPUT_DIR}")
  791. os.makedirs(OUTPUT_DIR)
  792. else:
  793. logger.debug(f"Output directory exists: {OUTPUT_DIR}")
  794. # Clean up old videos
  795. logger.info(f"Cleaning up videos older than {DAYS_TO_KEEP} days...")
  796. delete_old_videos(OUTPUT_DIR, days=DAYS_TO_KEEP)
  797. # Fetch subscribed channels
  798. logger.info("Fetching subscribed channels...")
  799. try:
  800. channels = get_subscribed_channels(youtube)
  801. logger.info(f"Successfully fetched {len(channels)} subscribed channels")
  802. except Exception as e:
  803. logger.error(f"Failed to fetch subscribed channels: {e}")
  804. channels = []
  805. # Count total videos for progress tracking
  806. logger.info("Counting total videos to download...")
  807. total_videos = 0
  808. all_videos = []
  809. # Collect all videos first for counting
  810. for i, channel_id in enumerate(channels):
  811. try:
  812. logger.debug(f"Processing channel {i+1}/{len(channels)}: {channel_id}")
  813. videos = get_recent_videos(youtube, channel_id)
  814. for video in videos:
  815. video_dir = os.path.join(OUTPUT_DIR, video["video_id"])
  816. if not os.path.exists(video_dir):
  817. all_videos.append(('subscription', video))
  818. total_videos += 1
  819. else:
  820. logger.debug(f"Video already exists, skipping: {video['title']}")
  821. except Exception as e:
  822. logger.error(f"Error processing channel {channel_id}: {e}")
  823. # Add popular videos
  824. for region_code in REGION_CODES:
  825. try:
  826. logger.debug(f"Fetching popular videos from region: {region_code}")
  827. popular_videos = get_popular_videos(youtube, region_code)
  828. for video in popular_videos:
  829. video_dir = os.path.join(OUTPUT_DIR, video["video_id"])
  830. if not os.path.exists(video_dir):
  831. all_videos.append(('popular', video))
  832. total_videos += 1
  833. else:
  834. logger.debug(f"Popular video already exists, skipping: {video['title']}")
  835. except Exception as e:
  836. logger.error(f"Error fetching popular videos from region {region_code}: {e}")
  837. # Initialize download stats
  838. download_stats['total_videos'] = total_videos
  839. download_stats['completed_videos'] = 0
  840. logger.info(f"Total videos to download: {total_videos}")
  841. if total_videos == 0:
  842. logger.info("No new videos found to download")
  843. return
  844. # Update initial Home Assistant stats
  845. update_homeassistant_stats(
  846. download_queue=total_videos,
  847. conversion_queue=0,
  848. download_progress=0,
  849. active_downloads=0,
  850. active_conversions=0
  851. )
  852. # Process all videos
  853. for i, (video_type, video) in enumerate(all_videos):
  854. # Check the exit time before processing each video
  855. check_exit_time()
  856. logger.info(f"Processing video {i+1}/{total_videos} ({video_type}): {video['title']}")
  857. video_dir = os.path.join(OUTPUT_DIR, video["video_id"])
  858. try:
  859. # Create directory
  860. os.makedirs(video_dir, exist_ok=True)
  861. logger.debug(f"Created/verified directory: {video_dir}")
  862. # Download the video
  863. if not download_video(video["video_id"], video_dir, video["title"]):
  864. logger.warning(f"Skipping video {video['video_id']} due to download error")
  865. continue # Skip this video if download failed
  866. # Create the NFO file
  867. nfo_file_path = os.path.join(video_dir, "movie.nfo")
  868. try:
  869. create_nfo_file(video, nfo_file_path)
  870. logger.debug(f"Created NFO file: {nfo_file_path}")
  871. except Exception as e:
  872. logger.error(f"Failed to create NFO file: {e}")
  873. # Update completed videos count
  874. download_stats['completed_videos'] += 1
  875. progress = (download_stats['completed_videos'] / download_stats['total_videos']) * 100
  876. # Update Home Assistant stats
  877. update_homeassistant_stats(
  878. download_queue=max(0, download_stats['total_videos'] - download_stats['completed_videos']),
  879. download_progress=round(progress, 1)
  880. )
  881. logger.debug(f"Progress: {download_stats['completed_videos']}/{download_stats['total_videos']} ({progress:.1f}%)")
  882. except Exception as e:
  883. logger.error(f"Error processing video {video['title']}: {e}")
  884. continue
  885. logger.info("All videos processed!")
  886. logger.info(f"Final stats: {download_stats['completed_videos']}/{download_stats['total_videos']} videos completed")
  887. # Wait for all conversions to complete
  888. logger.info("Waiting for all conversions to complete...")
  889. conversion_executor.shutdown(wait=True)
  890. logger.info("All conversions completed!")
  891. # Final Home Assistant update
  892. update_homeassistant_stats(
  893. download_queue=0,
  894. conversion_queue=0,
  895. download_progress=100,
  896. active_downloads=0,
  897. active_conversions=0
  898. )
  899. logger.info("Main download process completed successfully")
  900. logger.info("=" * 80)
  901. def schedule_downloads():
  902. """Schedule downloads to run at specified times"""
  903. logger = logging.getLogger(__name__)
  904. # Configuration for scheduling
  905. START_TIME = dt_time(0, 0) # 00:00
  906. END_TIME = dt_time(7, 0) # 07:00
  907. logger.info("Starting YouTube Predownloader Service with scheduling")
  908. logger.info(f"Service will run between {START_TIME.strftime('%H:%M')} and {END_TIME.strftime('%H:%M')} daily")
  909. while True:
  910. current_time = datetime.now().time()
  911. # Check if we're in the allowed time window
  912. if START_TIME <= current_time <= END_TIME:
  913. try:
  914. set_service_running()
  915. logger.info(f"Starting download session at {datetime.now().strftime('%H:%M:%S')}")
  916. # Run the main download process
  917. main()
  918. set_service_completed()
  919. logger.info(f"Download session completed at {datetime.now().strftime('%H:%M:%S')}")
  920. # Calculate next run time (next day at START_TIME)
  921. next_run = datetime.now().replace(hour=START_TIME.hour, minute=START_TIME.minute, second=0, microsecond=0)
  922. if current_time > START_TIME:
  923. next_run = next_run.replace(day=next_run.day + 1)
  924. set_service_waiting(next_run.isoformat())
  925. # Sleep until next day
  926. sleep_seconds = (next_run - datetime.now()).total_seconds()
  927. logger.info(f"Sleeping until next run at {next_run.strftime('%Y-%m-%d %H:%M:%S')} ({sleep_seconds:.0f} seconds)")
  928. time.sleep(sleep_seconds)
  929. except Exception as e:
  930. logger.error(f"Error during download session: {e}", exc_info=True)
  931. set_service_error()
  932. # Sleep for 1 hour before retrying
  933. logger.info("Sleeping for 1 hour before retrying")
  934. time.sleep(3600)
  935. else:
  936. # We're outside the allowed time window
  937. if current_time < START_TIME:
  938. # Before start time today
  939. next_run = datetime.now().replace(hour=START_TIME.hour, minute=START_TIME.minute, second=0, microsecond=0)
  940. else:
  941. # After end time today, schedule for tomorrow
  942. next_run = datetime.now().replace(hour=START_TIME.hour, minute=START_TIME.minute, second=0, microsecond=0)
  943. next_run = next_run.replace(day=next_run.day + 1)
  944. set_service_waiting(next_run.isoformat())
  945. # Sleep until start time
  946. sleep_seconds = (next_run - datetime.now()).total_seconds()
  947. logger.info(f"Waiting until {next_run.strftime('%Y-%m-%d %H:%M:%S')} to start downloads ({sleep_seconds:.0f} seconds)")
  948. time.sleep(min(sleep_seconds, 3600)) # Check every hour maximum
  949. # Fetch and process most popular videos worldwide (from multiple regions)
  950. def fetch_popular_videos_old():
  951. """Old function kept for reference - this logic is now integrated into main()"""
  952. for region_code in REGION_CODES:
  953. print(f"Fetching the most popular videos from {region_code}...")
  954. popular_videos = get_popular_videos(youtube, region_code)
  955. for video in popular_videos:
  956. # Check the exit time before processing each video
  957. check_exit_time()
  958. print(f"Processing popular video: {video['title']} ({region_code})")
  959. video_dir = os.path.join(OUTPUT_DIR, video["video_id"])
  960. # Check if the video already exists
  961. if os.path.exists(video_dir):
  962. print(f"Skipping download, already exists: {video['title']} ({video['video_id']})")
  963. continue
  964. os.makedirs(video_dir, exist_ok=True)
  965. # Download the popular video
  966. if not download_video(video["video_id"], video_dir):
  967. print(f"Skipping popular video {video['video_id']} due to download error.")
  968. continue # Skip this video if download failed
  969. # Check if conversion is needed (VP9/AV1 codecs or non-MP4 files)
  970. needs_conversion = False
  971. conversion_file = None
  972. for file in os.listdir(video_dir):
  973. if file.endswith(('.mp4', '.mkv', '.webm', '.m4v')):
  974. file_path = os.path.join(video_dir, file)
  975. try:
  976. # Quick check for problematic codecs
  977. probe_cmd = [FFPROBE_PATH, "-v", "quiet", "-select_streams", "v:0",
  978. "-show_entries", "stream=codec_name", "-of", "csv=p=0", file_path]
  979. result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  980. codec = result.stdout.strip().lower()
  981. if codec in ['vp9', 'av01', 'av1'] or not file.endswith('.mp4'):
  982. needs_conversion = True
  983. conversion_file = file_path
  984. print(f"Found {codec} codec - will convert for Apple TV compatibility")
  985. break
  986. except subprocess.CalledProcessError:
  987. # If we can't probe, assume it might need conversion if not MP4
  988. if not file.endswith('.mp4'):
  989. needs_conversion = True
  990. conversion_file = file_path
  991. break
  992. if needs_conversion and conversion_file:
  993. # Determine priority based on video resolution
  994. try:
  995. probe_cmd = [FFPROBE_PATH, "-v", "quiet", "-select_streams", "v:0",
  996. "-show_entries", "stream=height", "-of", "csv=p=0", conversion_file]
  997. result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  998. height = int(result.stdout.strip()) if result.stdout.strip().isdigit() else 1080
  999. priority = 2 if height >= 2160 else 1 # 4K gets higher priority
  1000. except:
  1001. priority = 1 # Default priority
  1002. add_to_conversion_queue(conversion_file, priority)
  1003. print_queue_status()
  1004. # Create the NFO file
  1005. nfo_file_path = os.path.join(video_dir, "movie.nfo")
  1006. create_nfo_file(video, nfo_file_path)
  1007. print("All videos processed successfully!")
  1008. # Final conversion queue status
  1009. print("\n🎬 Final Conversion Queue Status:")
  1010. print_queue_status()
  1011. # Final Home Assistant update
  1012. update_homeassistant_stats(
  1013. download_queue=0,
  1014. conversion_queue=conversion_queue.qsize(),
  1015. download_progress=100,
  1016. active_downloads=0,
  1017. active_conversions=conversion_stats['active_conversions']
  1018. )
  1019. queued_items = conversion_queue.qsize()
  1020. if queued_items > 0:
  1021. print(f"⏳ {queued_items} videos still in conversion queue")
  1022. print("💡 Conversions will continue in the background")
  1023. print("💡 You can monitor progress in Home Assistant")
  1024. else:
  1025. print("✅ All conversions completed!")
  1026. print("🏠 Home Assistant integration will continue running in background")
  1027. if __name__ == "__main__":
  1028. import sys
  1029. # Check for debug mode
  1030. debug_mode = "--debug" in sys.argv
  1031. if debug_mode:
  1032. sys.argv.remove("--debug")
  1033. # Set debug logging
  1034. LOG_LEVEL = logging.DEBUG
  1035. logger = setup_logging()
  1036. logger.info("Debug mode enabled - verbose logging activated")
  1037. logger = logging.getLogger(__name__)
  1038. # Check if we should run as a service with scheduling
  1039. if len(sys.argv) > 1 and sys.argv[1] == "--service":
  1040. logger.info("Starting YouTube Predownloader Service with scheduling")
  1041. try:
  1042. schedule_downloads()
  1043. except KeyboardInterrupt:
  1044. logger.info("Service interrupted by user")
  1045. except Exception as e:
  1046. logger.error(f"Service error: {e}", exc_info=True)
  1047. else:
  1048. logger.info("Running YouTube Predownloader once")
  1049. try:
  1050. main()
  1051. except KeyboardInterrupt:
  1052. logger.info("Process interrupted by user")
  1053. except Exception as e:
  1054. logger.error(f"Process error: {e}", exc_info=True)
  1055. finally:
  1056. logger.info("Process finished")