main.py 81 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806
  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. # First, check what format would be downloaded to determine compatibility
  332. logger.debug(f"Checking video format compatibility for: {video_title}")
  333. simulate_command = [
  334. "yt-dlp", "--simulate", "--print", "%(format_id)s|%(vcodec)s|%(height)s|%(ext)s",
  335. "-f", "(bestvideo[height>=2160][vcodec~='^(avc|h264|hevc|h265)']+bestaudio)/(bestvideo[height>=2160]+bestaudio)/(bestvideo[height>=1440]+bestaudio)/(bestvideo[height>=1080]+bestaudio)/best",
  336. f"https://www.youtube.com/watch?v={video_id}"
  337. ]
  338. will_be_compatible = True
  339. predicted_codec = "unknown"
  340. predicted_height = 0
  341. temp_filename = None
  342. final_filename = None
  343. try:
  344. sim_result = subprocess.run(simulate_command, capture_output=True, text=True, timeout=30)
  345. if sim_result.returncode == 0 and sim_result.stdout.strip():
  346. format_info = sim_result.stdout.strip().split('|')
  347. if len(format_info) >= 3:
  348. predicted_codec = format_info[1] if format_info[1] != 'none' else 'unknown'
  349. predicted_height = int(format_info[2]) if format_info[2].isdigit() else 0
  350. # Check if the predicted codec is Apple TV incompatible
  351. if any(incompatible in predicted_codec.lower() for incompatible in ['vp9', 'av01', 'av1']):
  352. will_be_compatible = False
  353. # Create temporary filename for incompatible formats
  354. sanitized_title = sanitize_filename(video_title)
  355. temp_filename = f"{sanitized_title}.incompatible"
  356. final_filename = f"{sanitized_title}"
  357. logger.info(f"⚠️ Incompatible codec detected: {predicted_codec} - downloading as temporary file")
  358. logger.info(f" Temporary: {temp_filename}")
  359. logger.info(f" Final: {final_filename}")
  360. else:
  361. logger.info(f"✅ Compatible codec detected: {predicted_codec}")
  362. except Exception as e:
  363. logger.debug(f"Could not simulate download format: {e}")
  364. # Set output filename based on compatibility prediction
  365. if temp_filename:
  366. output_template = os.path.join(output_path, f"{temp_filename}.%(ext)s")
  367. else:
  368. output_template = os.path.join(output_path, "%(title)s.%(ext)s")
  369. # List available formats for debugging (only in debug mode)
  370. if logger.isEnabledFor(logging.DEBUG):
  371. list_available_formats(video_id)
  372. # Update Home Assistant
  373. update_homeassistant_stats(
  374. download_queue=download_stats['total_videos'] - download_stats['completed_videos'],
  375. conversion_queue=conversion_queue.qsize(),
  376. download_progress=round((download_stats['completed_videos'] / max(download_stats['total_videos'], 1)) * 100, 1),
  377. active_downloads=download_stats['active_downloads'],
  378. active_conversions=conversion_stats['active_conversions']
  379. )
  380. # 4K-prioritized approach: Try Apple TV compatible 4K first, then any 4K, then lower resolutions
  381. command = [
  382. "yt-dlp",
  383. "-f", "(bestvideo[height>=2160][vcodec~='^(avc|h264|hevc|h265)']+bestaudio)/(bestvideo[height>=2160]+bestaudio)/(bestvideo[height>=1440]+bestaudio)/(bestvideo[height>=1080]+bestaudio)/best",
  384. "--merge-output-format", "mp4",
  385. "--no-mtime",
  386. "--newline", # Better for parsing progress
  387. "--verbose" if logger.isEnabledFor(logging.DEBUG) else "--no-warnings",
  388. "--write-info-json", # Write metadata for analysis
  389. f"https://www.youtube.com/watch?v={video_id}",
  390. "-o", output_template
  391. ]
  392. logger.debug(f"yt-dlp command: {' '.join(command)}")
  393. try:
  394. start_time = time.time()
  395. logging.info(f"Starting download with command: {' '.join(command)}")
  396. # Add progress hook for yt-dlp progress tracking
  397. command.extend(["--progress-template", "download:%(progress._percent_str)s %(progress._eta_str)s %(progress._speed_str)s"])
  398. # Start download with progress monitoring
  399. process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1, universal_newlines=True)
  400. current_progress = 0
  401. while True:
  402. output = process.stdout.readline()
  403. if output == '' and process.poll() is not None:
  404. break
  405. if output:
  406. output = output.strip()
  407. logging.debug(f"yt-dlp output: {output}")
  408. # Parse progress from yt-dlp output
  409. if "%" in output and "ETA" in output:
  410. try:
  411. # Extract percentage from progress line
  412. parts = output.split()
  413. for part in parts:
  414. if "%" in part:
  415. progress_str = part.replace("%", "")
  416. if progress_str.replace(".", "").isdigit():
  417. new_progress = float(progress_str)
  418. if abs(new_progress - current_progress) >= 5: # Update every 5%
  419. current_progress = new_progress
  420. # Update Home Assistant with current progress
  421. update_homeassistant_stats(
  422. download_queue=download_stats['total_videos'] - download_stats['completed_videos'],
  423. conversion_queue=conversion_queue.qsize(),
  424. download_progress=round((download_stats['completed_videos'] / max(download_stats['total_videos'], 1)) * 100, 1),
  425. active_downloads=download_stats['active_downloads'],
  426. active_conversions=conversion_stats['active_conversions'],
  427. current_download_progress=current_progress,
  428. current_download_file=video_title[:50]
  429. )
  430. logger.info(f"Download progress: {current_progress:.1f}% - {video_title}")
  431. break
  432. except (ValueError, IndexError):
  433. pass
  434. # Wait for process to complete
  435. process.wait()
  436. end_time = time.time()
  437. if process.returncode != 0:
  438. stderr_output = process.stderr.read()
  439. logging.error(f"yt-dlp failed with return code {process.returncode}: {stderr_output}")
  440. raise subprocess.CalledProcessError(process.returncode, command, stderr=stderr_output)
  441. download_time = end_time - start_time
  442. logger.info(f"Download completed in {download_time:.1f}s: {video_title}")
  443. # Clear current download progress
  444. update_homeassistant_stats(
  445. download_queue=download_stats['total_videos'] - download_stats['completed_videos'],
  446. conversion_queue=conversion_queue.qsize(),
  447. download_progress=round((download_stats['completed_videos'] / max(download_stats['total_videos'], 1)) * 100, 1),
  448. active_downloads=download_stats['active_downloads'],
  449. active_conversions=conversion_stats['active_conversions'],
  450. current_download_progress=100,
  451. current_download_file=""
  452. )
  453. # Analyze what was actually downloaded and check Apple TV compatibility
  454. try:
  455. downloaded_files = [f for f in os.listdir(output_path) if f.endswith(('.mp4', '.mkv', '.webm', '.m4v'))]
  456. if downloaded_files:
  457. for file in downloaded_files:
  458. file_path = os.path.join(output_path, file)
  459. try:
  460. # Get video info
  461. probe_cmd = [FFPROBE_PATH, "-v", "quiet", "-print_format", "json",
  462. "-show_streams", file_path]
  463. probe_result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  464. video_info = json.loads(probe_result.stdout)
  465. # Find video stream
  466. video_stream = next((s for s in video_info['streams'] if s['codec_type'] == 'video'), None)
  467. if video_stream:
  468. height = video_stream.get('height', 'unknown')
  469. width = video_stream.get('width', 'unknown')
  470. codec = video_stream.get('codec_name', 'unknown')
  471. bitrate = video_stream.get('bit_rate', 'unknown')
  472. fps = video_stream.get('r_frame_rate', 'unknown')
  473. quality_label = "Unknown"
  474. if height >= 2160:
  475. quality_label = "4K"
  476. elif height >= 1440:
  477. quality_label = "1440p"
  478. elif height >= 1080:
  479. quality_label = "1080p"
  480. elif height >= 720:
  481. quality_label = "720p"
  482. logger.info(f"Downloaded quality: {quality_label} ({width}x{height}), codec: {codec}, fps: {fps}")
  483. if bitrate != 'unknown':
  484. bitrate_mbps = int(bitrate) / 1000000 if bitrate.isdigit() else 'unknown'
  485. logger.info(f"Video bitrate: {bitrate_mbps:.1f} Mbps")
  486. # Log file size
  487. file_size = os.path.getsize(file_path)
  488. file_size_mb = file_size / (1024 * 1024)
  489. logger.info(f"Downloaded file size: {file_size_mb:.1f} MB")
  490. # Check if this is a temporary incompatible file
  491. is_temp_file = ".incompatible." in file
  492. # Check Apple TV compatibility and queue for conversion if needed
  493. needs_conversion = False
  494. conversion_reason = ""
  495. target_filename = None
  496. if is_temp_file:
  497. # This was downloaded as incompatible - definitely needs conversion
  498. needs_conversion = True
  499. conversion_reason = f"Pre-identified incompatible format ({codec})"
  500. # Set target filename (remove .incompatible from the name but keep extension)
  501. base_name, ext = os.path.splitext(file)
  502. if ".incompatible" in base_name:
  503. clean_base = base_name.replace(".incompatible", "")
  504. target_filename = f"{clean_base}{ext}"
  505. else:
  506. target_filename = file
  507. target_path = os.path.join(output_path, target_filename)
  508. logger.info(f"🔄 Temporary incompatible file detected - will convert to: {target_filename}")
  509. else:
  510. # Regular compatibility check for files downloaded with standard names
  511. if any(incompatible in codec.lower() for incompatible in ['vp9', 'av01', 'av1']):
  512. needs_conversion = True
  513. conversion_reason = f"Incompatible codec ({codec}) for Apple TV"
  514. elif not file.endswith('.mp4'):
  515. needs_conversion = True
  516. conversion_reason = f"Incompatible container ({file.split('.')[-1]}) for Apple TV"
  517. elif height >= 2160 and not any(compatible in codec.lower() for compatible in ['h264', 'avc1', 'hevc', 'h265']):
  518. needs_conversion = True
  519. conversion_reason = f"4K video with non-Apple TV codec ({codec})"
  520. if needs_conversion:
  521. logger.info(f"Queuing for conversion: {conversion_reason}")
  522. # Determine priority based on video resolution
  523. priority = 2 if height >= 2160 else 1 # 4K gets higher priority
  524. # Add to conversion queue with target filename if specified
  525. if target_filename:
  526. add_to_conversion_queue_with_target(file_path, target_path, priority)
  527. logger.info(f"📋 Queued: {file} → {target_filename}")
  528. else:
  529. add_to_conversion_queue(file_path, priority)
  530. logger.info(f"📋 Queued: {file}")
  531. print_queue_status()
  532. else:
  533. logger.info(f"✅ File is Apple TV compatible: {codec} in MP4 container")
  534. except Exception as e:
  535. logger.debug(f"Could not analyze downloaded file {file}: {e}")
  536. except Exception as e:
  537. logger.debug(f"Could not analyze downloaded files: {e}")
  538. except subprocess.CalledProcessError as e:
  539. download_stats['active_downloads'] -= 1
  540. logger.error(f"Download failed for {video_title} (ID: {video_id}): {e}")
  541. logger.debug(f"yt-dlp error output: {e.stderr}")
  542. return False
  543. except Exception as e:
  544. download_stats['active_downloads'] -= 1
  545. logger.error(f"Unexpected error downloading {video_title} (ID: {video_id}): {e}")
  546. # Update completed count
  547. download_stats['completed_videos'] += 1
  548. download_stats['active_downloads'] -= 1
  549. return True
  550. # Update Home Assistant with completion
  551. update_homeassistant_stats(
  552. download_queue=download_stats['total_videos'] - download_stats['completed_videos'],
  553. download_progress=round((download_stats['completed_videos'] / max(download_stats['total_videos'], 1)) * 100, 1),
  554. active_downloads=download_stats['active_downloads']
  555. )
  556. return True
  557. except subprocess.CalledProcessError as e:
  558. download_stats['active_downloads'] -= 1
  559. logger.error(f"Download failed for {video_title} (ID: {video_id}): {e}")
  560. logger.debug(f"yt-dlp error output: {e.stderr}")
  561. return False
  562. except Exception as e:
  563. download_stats['active_downloads'] -= 1
  564. logger.error(f"Unexpected error downloading {video_title} (ID: {video_id}): {e}")
  565. return False
  566. def add_to_conversion_queue(input_file, priority=1):
  567. """Add a video file to the conversion queue with priority."""
  568. logger = logging.getLogger(__name__)
  569. try:
  570. # Higher number = higher priority (4K gets priority 2, others get 1)
  571. conversion_queue.put((priority, input_file), timeout=1)
  572. logger.info(f"Added to conversion queue: {os.path.basename(input_file)} (priority: {priority})")
  573. logger.debug(f"Queue size after addition: {conversion_queue.qsize()}")
  574. return True
  575. except queue.Full:
  576. logger.warning(f"Conversion queue is full! Skipping: {os.path.basename(input_file)}")
  577. return False
  578. except Exception as e:
  579. logger.error(f"Error adding to conversion queue: {e}")
  580. return False
  581. def add_to_conversion_queue_with_target(input_file, target_file, priority=1):
  582. """Add a video file to the conversion queue with a specific target filename."""
  583. logger = logging.getLogger(__name__)
  584. try:
  585. # Higher number = higher priority (4K gets priority 2, others get 1)
  586. # Store as tuple: (priority, input_file, target_file)
  587. conversion_queue.put((priority, input_file, target_file), timeout=1)
  588. logger.info(f"Added to conversion queue: {os.path.basename(input_file)} → {os.path.basename(target_file)} (priority: {priority})")
  589. logger.debug(f"Queue size after addition: {conversion_queue.qsize()}")
  590. return True
  591. except queue.Full:
  592. logger.warning(f"Conversion queue is full! Skipping: {os.path.basename(input_file)}")
  593. return False
  594. except Exception as e:
  595. logger.error(f"Error adding to conversion queue: {e}")
  596. return False
  597. def get_queue_status():
  598. """Get current conversion queue status."""
  599. queue_size = conversion_queue.qsize()
  600. active_conversions = conversion_executor._threads # Active thread count
  601. return {
  602. 'queued': queue_size,
  603. 'active': len(active_conversions) if active_conversions else 0,
  604. 'max_concurrent': MAX_CONCURRENT_CONVERSIONS
  605. }
  606. def print_queue_status():
  607. """Print current queue status."""
  608. logger = logging.getLogger(__name__)
  609. status = get_queue_status()
  610. logger.info(f"Conversion Status: {status['active']}/{status['max_concurrent']} active, {status['queued']} queued")
  611. def process_conversion_queue():
  612. """Background worker to process the conversion queue."""
  613. logger = logging.getLogger(__name__)
  614. logger.info("Conversion queue worker started")
  615. while True:
  616. try:
  617. # Get item from queue (blocks until available)
  618. queue_item = conversion_queue.get(timeout=30)
  619. # Handle both old format (priority, input_file) and new format (priority, input_file, target_file)
  620. if len(queue_item) == 2:
  621. priority, input_file = queue_item
  622. target_file = None
  623. elif len(queue_item) == 3:
  624. priority, input_file, target_file = queue_item
  625. else:
  626. logger.error(f"Invalid queue item format: {queue_item}")
  627. conversion_queue.task_done()
  628. continue
  629. # Check if file still exists
  630. if not os.path.exists(input_file):
  631. logger.warning(f"File no longer exists, skipping conversion: {input_file}")
  632. conversion_queue.task_done()
  633. continue
  634. if target_file:
  635. logger.info(f"Starting conversion (priority {priority}): {os.path.basename(input_file)} → {os.path.basename(target_file)}")
  636. # Perform the conversion with target filename
  637. success = convert_to_apple_tv_compatible_with_target(input_file, target_file)
  638. else:
  639. logger.info(f"Starting conversion (priority {priority}): {os.path.basename(input_file)}")
  640. # Perform the conversion with standard naming
  641. success = convert_to_apple_tv_compatible(input_file)
  642. if success:
  643. logger.info(f"Conversion completed successfully: {os.path.basename(input_file)}")
  644. else:
  645. logger.error(f"Conversion failed: {os.path.basename(input_file)}")
  646. # Mark task as done
  647. conversion_queue.task_done()
  648. except queue.Empty:
  649. # No items in queue for 30 seconds, continue waiting
  650. logger.debug("Conversion queue empty, waiting for new items...")
  651. continue
  652. except Exception as e:
  653. logger.error(f"Conversion queue worker error: {e}")
  654. try:
  655. conversion_queue.task_done()
  656. except:
  657. pass
  658. def sanitize_filename(filename):
  659. """Sanitize a filename to remove invalid characters."""
  660. import re
  661. # Remove invalid filename characters
  662. sanitized = re.sub(r'[<>:"/\\|?*]', '_', filename)
  663. # Remove extra spaces and limit length
  664. sanitized = ' '.join(sanitized.split())[:200]
  665. return sanitized
  666. def test_vaapi_support():
  667. """Test if Intel VAAPI hardware acceleration is available."""
  668. logger = logging.getLogger(__name__)
  669. try:
  670. # Test H.264 VAAPI encoding
  671. test_cmd = [FFMPEG_PATH, "-hide_banner", "-f", "lavfi", "-i", "testsrc=duration=1:size=320x240:rate=1",
  672. "-vaapi_device", "/dev/dri/renderD128", "-vf", "format=nv12,hwupload",
  673. "-c:v", "h264_vaapi", "-frames:v", "1", "-f", "null", "-"]
  674. result = subprocess.run(test_cmd, capture_output=True, text=True, timeout=10)
  675. h264_vaapi_available = result.returncode == 0
  676. # Test HEVC VAAPI encoding (optional, may not work on all Intel GPUs)
  677. test_cmd = [FFMPEG_PATH, "-hide_banner", "-f", "lavfi", "-i", "testsrc=duration=1:size=320x240:rate=1",
  678. "-vaapi_device", "/dev/dri/renderD128", "-vf", "format=nv12,hwupload",
  679. "-c:v", "hevc_vaapi", "-frames:v", "1", "-f", "null", "-"]
  680. result = subprocess.run(test_cmd, capture_output=True, text=True, timeout=10)
  681. hevc_vaapi_available = result.returncode == 0
  682. if h264_vaapi_available and hevc_vaapi_available:
  683. logger.info("🚀 Intel VAAPI hardware acceleration available (H.264 + HEVC)")
  684. return True, True
  685. elif h264_vaapi_available:
  686. logger.info("🚀 Intel VAAPI hardware acceleration available (H.264 only)")
  687. return True, False
  688. else:
  689. logger.info("⚠️ Intel VAAPI hardware acceleration not available, falling back to software encoding")
  690. return False, False
  691. except Exception as e:
  692. logger.warning(f"Could not test VAAPI support: {e}")
  693. return False, False
  694. def convert_to_apple_tv_compatible(input_file):
  695. """Convert a video to Apple TV 4K compatible format with Intel VAAPI hardware acceleration."""
  696. global conversion_stats
  697. logger = logging.getLogger(__name__)
  698. base_name = os.path.splitext(input_file)[0]
  699. mp4_file = base_name + ".mp4"
  700. # If input is already MP4, use a temporary output file to avoid overwriting during conversion
  701. temp_output = None
  702. if input_file.lower().endswith('.mp4'):
  703. temp_output = base_name + "_converted.mp4"
  704. final_output = mp4_file
  705. mp4_file = temp_output
  706. else:
  707. final_output = mp4_file
  708. # Update conversion stats
  709. conversion_stats['active_conversions'] += 1
  710. conversion_stats['current_conversion'] = os.path.basename(input_file)
  711. logger.info(f"Converting {os.path.basename(input_file)} to Apple TV compatible format with GPU acceleration...")
  712. logger.debug(f"Input file: {input_file}")
  713. logger.debug(f"Output file: {mp4_file}")
  714. # Test VAAPI support
  715. vaapi_h264_available, vaapi_hevc_available = test_vaapi_support()
  716. # Update Home Assistant
  717. update_homeassistant_stats(
  718. conversion_queue=conversion_queue.qsize(),
  719. active_conversions=conversion_stats['active_conversions']
  720. )
  721. # Detect video properties to optimize conversion
  722. try:
  723. import json
  724. probe_cmd = [
  725. FFPROBE_PATH, "-v", "quiet", "-print_format", "json",
  726. "-show_format", "-show_streams", input_file
  727. ]
  728. logger.debug(f"Probing video with command: {' '.join(probe_cmd)}")
  729. probe_result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  730. video_info = json.loads(probe_result.stdout)
  731. # Find video stream
  732. video_stream = next((s for s in video_info['streams'] if s['codec_type'] == 'video'), None)
  733. is_4k = video_stream and int(video_stream.get('height', 0)) >= 2160
  734. is_high_res = video_stream and int(video_stream.get('height', 0)) >= 1080
  735. if video_stream:
  736. height = video_stream.get('height', 'unknown')
  737. codec = video_stream.get('codec_name', 'unknown')
  738. bitrate = video_stream.get('bit_rate', 'unknown')
  739. logger.info(f"Video properties: {height}p, codec: {codec}, bitrate: {bitrate}")
  740. except (subprocess.CalledProcessError, json.JSONDecodeError, Exception) as e:
  741. logger.warning(f"Could not probe video properties: {e}, using default settings")
  742. is_4k = False
  743. is_high_res = True
  744. # Intel VAAPI optimized encoding based on source quality and hardware support:
  745. if is_4k and vaapi_hevc_available:
  746. # 4K content with VAAPI HEVC: Use hardware-accelerated H.265
  747. codec_args = [
  748. "-vaapi_device", "/dev/dri/renderD128", # VAAPI device
  749. "-vf", "format=nv12,hwupload", # Upload to GPU memory
  750. "-c:v", "hevc_vaapi", # Intel VAAPI HEVC encoder
  751. "-qp", "20", # Quality parameter (lower = better)
  752. "-rc_mode", "CQP", # Constant Quality mode
  753. ]
  754. logger.info("🚀 Using Intel VAAPI HEVC hardware acceleration for 4K content")
  755. elif (is_4k or is_high_res) and vaapi_h264_available:
  756. # 4K/1080p content with VAAPI H.264: Use hardware-accelerated H.264
  757. quality = "20" if is_4k else "22" # Slightly lower quality for 1080p
  758. codec_args = [
  759. "-vaapi_device", "/dev/dri/renderD128", # VAAPI device
  760. "-vf", "format=nv12,hwupload", # Upload to GPU memory
  761. "-c:v", "h264_vaapi", # Intel VAAPI H.264 encoder
  762. "-qp", quality, # Quality parameter
  763. "-rc_mode", "CQP", # Constant Quality mode
  764. "-profile:v", "high", # H.264 High Profile
  765. ]
  766. quality_label = "4K" if is_4k else "1080p"
  767. logger.info(f"🚀 Using Intel VAAPI H.264 hardware acceleration for {quality_label} content")
  768. elif is_4k:
  769. # 4K content without VAAPI: Fall back to software H.265
  770. codec_args = [
  771. "-c:v", "libx265", # Software H.265 for 4K
  772. "-preset", "medium", # Better compression for 4K
  773. "-crf", "20", # Higher quality for 4K content
  774. "-level", "5.1", # Level 5.1 supports 4K
  775. "-pix_fmt", "yuv420p", # Standard compatibility
  776. ]
  777. logger.info("⚠️ Using software H.265 encoding for 4K content (VAAPI not available)")
  778. elif is_high_res:
  779. # 1080p content without VAAPI: Use software H.264
  780. codec_args = [
  781. "-c:v", "libx264", # Software H.264 for 1080p
  782. "-profile:v", "high", # High profile for better quality
  783. "-preset", "fast", # Faster encoding for 1080p
  784. "-crf", "21", # Good quality for 1080p
  785. "-level", "4.1", # Level 4.1 supports 1080p
  786. ]
  787. logger.info("⚠️ Using software H.264 encoding for 1080p content (VAAPI not available)")
  788. else:
  789. # Lower resolution: Use software H.264 with faster settings
  790. codec_args = [
  791. "-c:v", "libx264", # Software H.264 for lower res
  792. "-profile:v", "main", # Main profile for lower res
  793. "-preset", "fast", # Fast encoding
  794. "-crf", "23", # Standard quality
  795. "-level", "3.1", # Level 3.1 for lower res
  796. ]
  797. logger.info("Using software H.264 encoding for lower resolution content")
  798. # Add progress reporting to FFmpeg command
  799. command = [
  800. FFMPEG_PATH, "-i", input_file,
  801. *codec_args,
  802. "-c:a", "aac", # AAC audio codec
  803. "-b:a", "192k", # Higher audio bitrate for better quality
  804. "-movflags", "+faststart", # Optimize for streaming/quick playback
  805. "-progress", "pipe:1", # Output progress to stdout
  806. "-y", # Overwrite output file if exists
  807. mp4_file
  808. ]
  809. try:
  810. logger.debug(f"FFmpeg command: {' '.join(command)}")
  811. logger.info("Starting conversion... This may take a while for 4K content.")
  812. # Get total duration for progress calculation
  813. duration_cmd = [FFPROBE_PATH, "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", input_file]
  814. try:
  815. duration_result = subprocess.run(duration_cmd, capture_output=True, text=True, check=True)
  816. total_duration = float(duration_result.stdout.strip())
  817. except:
  818. total_duration = 0
  819. logger.debug("Could not determine video duration for progress tracking")
  820. start_time = time.time()
  821. # Start FFmpeg with progress monitoring
  822. process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1, universal_newlines=True)
  823. current_conversion_progress = 0
  824. while True:
  825. output = process.stdout.readline()
  826. if output == '' and process.poll() is not None:
  827. break
  828. if output:
  829. output = output.strip()
  830. # Parse FFmpeg progress output
  831. if output.startswith("out_time_us=") and total_duration > 0:
  832. try:
  833. # Extract time in microseconds
  834. time_us = int(output.split("=")[1])
  835. current_time = time_us / 1000000 # Convert to seconds
  836. progress = min(100, (current_time / total_duration) * 100)
  837. if abs(progress - current_conversion_progress) >= 5: # Update every 5%
  838. current_conversion_progress = progress
  839. # Update Home Assistant with conversion progress
  840. update_homeassistant_stats(
  841. conversion_queue=conversion_queue.qsize(),
  842. active_conversions=conversion_stats['active_conversions'],
  843. current_conversion_progress=current_conversion_progress,
  844. current_conversion_file=os.path.basename(input_file)[:50]
  845. )
  846. logger.info(f"Conversion progress: {current_conversion_progress:.1f}% - {os.path.basename(input_file)}")
  847. except (ValueError, IndexError):
  848. pass
  849. # Wait for process to complete
  850. process.wait()
  851. end_time = time.time()
  852. if process.returncode != 0:
  853. stderr_output = process.stderr.read()
  854. logger.error(f"FFmpeg failed with return code {process.returncode}: {stderr_output}")
  855. raise subprocess.CalledProcessError(process.returncode, command, stderr=stderr_output)
  856. conversion_time = end_time - start_time
  857. logger.info(f"Conversion completed in {conversion_time:.1f}s: {mp4_file}")
  858. # Clear conversion progress
  859. update_homeassistant_stats(
  860. conversion_queue=conversion_queue.qsize(),
  861. active_conversions=conversion_stats['active_conversions'],
  862. current_conversion_progress=100,
  863. current_conversion_file=""
  864. )
  865. # Check if output file was created and has reasonable size
  866. if os.path.exists(mp4_file):
  867. input_size = os.path.getsize(input_file)
  868. output_size = os.path.getsize(mp4_file)
  869. size_ratio = output_size / input_size if input_size > 0 else 0
  870. logger.info(f"File sizes - Input: {input_size // (1024*1024):.1f}MB, Output: {output_size // (1024*1024):.1f}MB (ratio: {size_ratio:.2f})")
  871. # Only replace original if output seems reasonable
  872. if output_size > input_size * 0.1: # Output should be at least 10% of input size
  873. # If we used a temporary file, replace the original
  874. if temp_output:
  875. os.remove(input_file)
  876. os.rename(mp4_file, final_output)
  877. logger.info(f"Original file replaced with converted version: {os.path.basename(final_output)}")
  878. else:
  879. os.remove(input_file)
  880. logger.info(f"Original file deleted: {os.path.basename(input_file)}")
  881. else:
  882. logger.warning(f"Output file seems too small, keeping original: {os.path.basename(input_file)}")
  883. if temp_output and os.path.exists(mp4_file):
  884. os.remove(mp4_file) # Clean up failed temporary file
  885. # Update completion stats
  886. conversion_stats['active_conversions'] -= 1
  887. conversion_stats['completed_conversions'] += 1
  888. # Update Home Assistant
  889. update_homeassistant_stats(
  890. conversion_queue=conversion_queue.qsize(),
  891. active_conversions=conversion_stats['active_conversions']
  892. )
  893. return True
  894. except subprocess.CalledProcessError as e:
  895. conversion_stats['active_conversions'] -= 1
  896. logger.error(f"Conversion failed for {os.path.basename(input_file)}: {e}")
  897. if e.stderr:
  898. logger.debug(f"FFmpeg error output: {e.stderr}")
  899. # Clean up temporary file if it exists
  900. if temp_output and os.path.exists(mp4_file):
  901. os.remove(mp4_file)
  902. logger.debug(f"Cleaned up temporary file: {mp4_file}")
  903. # Update Home Assistant
  904. update_homeassistant_stats(
  905. conversion_queue=conversion_queue.qsize(),
  906. active_conversions=conversion_stats['active_conversions']
  907. )
  908. return False
  909. except Exception as e:
  910. conversion_stats['active_conversions'] -= 1
  911. logger.error(f"Unexpected error during conversion of {os.path.basename(input_file)}: {e}")
  912. # Update Home Assistant
  913. update_homeassistant_stats(
  914. conversion_queue=conversion_queue.qsize(),
  915. active_conversions=conversion_stats['active_conversions']
  916. )
  917. return False
  918. def convert_to_apple_tv_compatible_with_target(input_file, target_file):
  919. """Convert a video to Apple TV 4K compatible format with Intel VAAPI hardware acceleration and specific target filename."""
  920. global conversion_stats
  921. logger = logging.getLogger(__name__)
  922. # Update conversion stats
  923. conversion_stats['active_conversions'] += 1
  924. conversion_stats['current_conversion'] = os.path.basename(input_file)
  925. logger.info(f"Converting {os.path.basename(input_file)} to Apple TV compatible format with GPU acceleration...")
  926. logger.info(f"Target filename: {os.path.basename(target_file)}")
  927. logger.debug(f"Input file: {input_file}")
  928. logger.debug(f"Target file: {target_file}")
  929. # Use a temporary output file during conversion to avoid conflicts
  930. base_name, ext = os.path.splitext(target_file)
  931. temp_output = f"{base_name}.converting{ext}"
  932. # Test VAAPI support
  933. vaapi_h264_available, vaapi_hevc_available = test_vaapi_support()
  934. # Update Home Assistant
  935. update_homeassistant_stats(
  936. conversion_queue=conversion_queue.qsize(),
  937. active_conversions=conversion_stats['active_conversions']
  938. )
  939. # Detect video properties to optimize conversion
  940. try:
  941. import json
  942. probe_cmd = [
  943. FFPROBE_PATH, "-v", "quiet", "-print_format", "json",
  944. "-show_format", "-show_streams", input_file
  945. ]
  946. logger.debug(f"Probing video with command: {' '.join(probe_cmd)}")
  947. probe_result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  948. video_info = json.loads(probe_result.stdout)
  949. # Find video stream
  950. video_stream = next((s for s in video_info['streams'] if s['codec_type'] == 'video'), None)
  951. is_4k = video_stream and int(video_stream.get('height', 0)) >= 2160
  952. is_high_res = video_stream and int(video_stream.get('height', 0)) >= 1080
  953. if video_stream:
  954. height = video_stream.get('height', 'unknown')
  955. codec = video_stream.get('codec_name', 'unknown')
  956. bitrate = video_stream.get('bit_rate', 'unknown')
  957. logger.info(f"Video properties: {height}p, codec: {codec}, bitrate: {bitrate}")
  958. except (subprocess.CalledProcessError, json.JSONDecodeError, Exception) as e:
  959. logger.warning(f"Could not probe video properties: {e}, using default settings")
  960. is_4k = False
  961. is_high_res = True
  962. # Intel VAAPI optimized encoding based on source quality and hardware support
  963. if is_4k and vaapi_hevc_available:
  964. # 4K content with VAAPI HEVC: Use hardware-accelerated H.265
  965. codec_args = [
  966. "-vaapi_device", "/dev/dri/renderD128", # VAAPI device
  967. "-vf", "format=nv12,hwupload", # Upload to GPU memory
  968. "-c:v", "hevc_vaapi", # Intel VAAPI HEVC encoder
  969. "-qp", "20", # Quality parameter (lower = better)
  970. "-rc_mode", "CQP", # Constant Quality mode
  971. ]
  972. logger.info("🚀 Using Intel VAAPI HEVC hardware acceleration for 4K content")
  973. elif (is_4k or is_high_res) and vaapi_h264_available:
  974. # 4K/1080p content with VAAPI H.264: Use hardware-accelerated H.264
  975. quality = "20" if is_4k else "22" # Slightly lower quality for 1080p
  976. codec_args = [
  977. "-vaapi_device", "/dev/dri/renderD128", # VAAPI device
  978. "-vf", "format=nv12,hwupload", # Upload to GPU memory
  979. "-c:v", "h264_vaapi", # Intel VAAPI H.264 encoder
  980. "-qp", quality, # Quality parameter
  981. "-rc_mode", "CQP", # Constant Quality mode
  982. "-profile:v", "high", # H.264 High Profile
  983. ]
  984. quality_label = "4K" if is_4k else "1080p"
  985. logger.info(f"🚀 Using Intel VAAPI H.264 hardware acceleration for {quality_label} content")
  986. elif is_4k:
  987. # 4K content without VAAPI: Fall back to software H.265
  988. codec_args = [
  989. "-c:v", "libx265", # Software H.265 for 4K
  990. "-preset", "medium", # Better compression for 4K
  991. "-crf", "20", # Higher quality for 4K content
  992. "-level", "5.1", # Level 5.1 supports 4K
  993. "-pix_fmt", "yuv420p", # Standard compatibility
  994. ]
  995. logger.info("⚠️ Using software H.265 encoding for 4K content (VAAPI not available)")
  996. elif is_high_res:
  997. # 1080p content without VAAPI: Use software H.264
  998. codec_args = [
  999. "-c:v", "libx264", # Software H.264 for 1080p
  1000. "-profile:v", "high", # High profile for better quality
  1001. "-preset", "fast", # Faster encoding for 1080p
  1002. "-crf", "21", # Good quality for 1080p
  1003. "-level", "4.1", # Level 4.1 supports 1080p
  1004. ]
  1005. logger.info("⚠️ Using software H.264 encoding for 1080p content (VAAPI not available)")
  1006. else:
  1007. # Lower resolution: Use software H.264 with faster settings
  1008. codec_args = [
  1009. "-c:v", "libx264", # Software H.264 for lower res
  1010. "-profile:v", "main", # Main profile for lower res
  1011. "-preset", "fast", # Fast encoding
  1012. "-crf", "23", # Standard quality
  1013. "-level", "3.1", # Level 3.1 for lower res
  1014. ]
  1015. logger.info("Using software H.264 encoding for lower resolution content")
  1016. # Add progress reporting to FFmpeg command
  1017. command = [
  1018. FFMPEG_PATH, "-i", input_file,
  1019. *codec_args,
  1020. "-c:a", "aac", # AAC audio codec
  1021. "-b:a", "192k", # Higher audio bitrate for better quality
  1022. "-movflags", "+faststart", # Optimize for streaming/quick playback
  1023. "-progress", "pipe:1", # Output progress to stdout
  1024. "-y", # Overwrite output file if exists
  1025. temp_output
  1026. ]
  1027. try:
  1028. logger.debug(f"FFmpeg command: {' '.join(command)}")
  1029. logger.info("Starting conversion... This may take a while for 4K content.")
  1030. # Get total duration for progress calculation
  1031. duration_cmd = [FFPROBE_PATH, "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", input_file]
  1032. try:
  1033. duration_result = subprocess.run(duration_cmd, capture_output=True, text=True, check=True)
  1034. total_duration = float(duration_result.stdout.strip())
  1035. except:
  1036. total_duration = 0
  1037. logger.debug("Could not determine video duration for progress tracking")
  1038. start_time = time.time()
  1039. # Start FFmpeg with progress monitoring
  1040. process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1, universal_newlines=True)
  1041. current_conversion_progress = 0
  1042. while True:
  1043. output = process.stdout.readline()
  1044. if output == '' and process.poll() is not None:
  1045. break
  1046. if output:
  1047. output = output.strip()
  1048. # Parse FFmpeg progress output
  1049. if output.startswith("out_time_us=") and total_duration > 0:
  1050. try:
  1051. # Extract time in microseconds
  1052. time_us = int(output.split("=")[1])
  1053. current_time = time_us / 1000000 # Convert to seconds
  1054. progress = min(100, (current_time / total_duration) * 100)
  1055. if abs(progress - current_conversion_progress) >= 5: # Update every 5%
  1056. current_conversion_progress = progress
  1057. # Update Home Assistant with conversion progress
  1058. update_homeassistant_stats(
  1059. conversion_queue=conversion_queue.qsize(),
  1060. active_conversions=conversion_stats['active_conversions'],
  1061. current_conversion_progress=current_conversion_progress,
  1062. current_conversion_file=os.path.basename(input_file)[:50]
  1063. )
  1064. logger.info(f"Conversion progress: {current_conversion_progress:.1f}% - {os.path.basename(input_file)}")
  1065. except (ValueError, IndexError):
  1066. pass
  1067. # Wait for process to complete
  1068. process.wait()
  1069. end_time = time.time()
  1070. if process.returncode != 0:
  1071. stderr_output = process.stderr.read()
  1072. logger.error(f"FFmpeg failed with return code {process.returncode}: {stderr_output}")
  1073. raise subprocess.CalledProcessError(process.returncode, command, stderr=stderr_output)
  1074. conversion_time = end_time - start_time
  1075. logger.info(f"Conversion completed in {conversion_time:.1f}s")
  1076. # Clear conversion progress
  1077. update_homeassistant_stats(
  1078. conversion_queue=conversion_queue.qsize(),
  1079. active_conversions=conversion_stats['active_conversions'],
  1080. current_conversion_progress=100,
  1081. current_conversion_file=""
  1082. )
  1083. # Check if output file was created and has reasonable size
  1084. if os.path.exists(temp_output):
  1085. input_size = os.path.getsize(input_file)
  1086. output_size = os.path.getsize(temp_output)
  1087. size_ratio = output_size / input_size if input_size > 0 else 0
  1088. logger.info(f"File sizes - Input: {input_size // (1024*1024):.1f}MB, Output: {output_size // (1024*1024):.1f}MB (ratio: {size_ratio:.2f})")
  1089. # Only replace original if output seems reasonable
  1090. if output_size > input_size * 0.1:
  1091. # Move temporary file to final target location
  1092. os.rename(temp_output, target_file)
  1093. # Remove the original incompatible file
  1094. os.remove(input_file)
  1095. logger.info(f"✅ Conversion successful: {os.path.basename(input_file)} → {os.path.basename(target_file)}")
  1096. else:
  1097. logger.warning(f"Output file seems too small, keeping original: {os.path.basename(input_file)}")
  1098. if os.path.exists(temp_output):
  1099. os.remove(temp_output)
  1100. # Update completion stats
  1101. conversion_stats['active_conversions'] -= 1
  1102. conversion_stats['completed_conversions'] += 1
  1103. # Update Home Assistant
  1104. update_homeassistant_stats(
  1105. conversion_queue=conversion_queue.qsize(),
  1106. active_conversions=conversion_stats['active_conversions']
  1107. )
  1108. return True
  1109. except subprocess.CalledProcessError as e:
  1110. conversion_stats['active_conversions'] -= 1
  1111. logger.error(f"Conversion failed for {os.path.basename(input_file)}: {e}")
  1112. if e.stderr:
  1113. logger.debug(f"FFmpeg error output: {e.stderr}")
  1114. # Clean up temporary file if it exists
  1115. if os.path.exists(temp_output):
  1116. os.remove(temp_output)
  1117. logger.debug(f"Cleaned up temporary file: {temp_output}")
  1118. # Update Home Assistant
  1119. update_homeassistant_stats(
  1120. conversion_queue=conversion_queue.qsize(),
  1121. active_conversions=conversion_stats['active_conversions']
  1122. )
  1123. return False
  1124. except Exception as e:
  1125. conversion_stats['active_conversions'] -= 1
  1126. logger.error(f"Unexpected error during conversion of {os.path.basename(input_file)}: {e}")
  1127. # Clean up temporary file if it exists
  1128. if os.path.exists(temp_output):
  1129. os.remove(temp_output)
  1130. logger.debug(f"Cleaned up temporary file: {temp_output}")
  1131. # Update Home Assistant
  1132. update_homeassistant_stats(
  1133. conversion_queue=conversion_queue.qsize(),
  1134. active_conversions=conversion_stats['active_conversions']
  1135. )
  1136. return False
  1137. # Temporarily commented out video conversion functionality
  1138. # def convert_to_mp4(input_file):
  1139. # """Convert a video to mp4 and delete the original file."""
  1140. # mp4_file = os.path.splitext(input_file)[0] + ".mp4"
  1141. # print(f"Converting {input_file} to {mp4_file}...")
  1142. # command = ["ffmpeg", "-i", input_file, "-c:v", "libx264", "-preset", "fast", "-c:a", "aac", mp4_file]
  1143. # subprocess.run(command, check=True)
  1144. # print(f"Conversion completed: {mp4_file}")
  1145. # if os.path.exists(mp4_file):
  1146. # os.remove(input_file)
  1147. # print(f"Original file deleted: {input_file}")
  1148. def create_nfo_file(video_data, file_path):
  1149. """Create an Emby-compatible NFO file."""
  1150. logger = logging.getLogger(__name__)
  1151. try:
  1152. nfo_content = f"""<?xml version="1.0" encoding="utf-8" standalone="yes"?>
  1153. <movie>
  1154. <title>{video_data['title']}</title>
  1155. <plot>{video_data['description']}</plot>
  1156. <year>{video_data['published_at'][:4]}</year>
  1157. <premiered>{video_data['published_at']}</premiered>
  1158. <studio>{video_data['channel_title']}</studio>
  1159. </movie>
  1160. """
  1161. with open(file_path, "w", encoding="utf-8") as nfo_file:
  1162. nfo_file.write(nfo_content)
  1163. logger.debug(f"Created NFO file: {file_path}")
  1164. except Exception as e:
  1165. logger.error(f"Failed to create NFO file {file_path}: {e}")
  1166. raise
  1167. def check_exit_time():
  1168. """Check if the current time is past the exit time."""
  1169. logger = logging.getLogger(__name__)
  1170. now = datetime.now().time()
  1171. if now >= EXIT_TIME:
  1172. logger.info(f"Current time ({now.strftime('%H:%M:%S')}) exceeds exit time ({EXIT_TIME.strftime('%H:%M:%S')}). Terminating script.")
  1173. sys.exit(0)
  1174. def delete_old_videos(folder, days=5):
  1175. """Delete video files and their folders older than `days`."""
  1176. logger = logging.getLogger(__name__)
  1177. logger.info(f"Starting cleanup of videos older than {days} days in {folder}")
  1178. if not os.path.exists(folder):
  1179. logger.warning(f"Output folder does not exist: {folder}")
  1180. return
  1181. now = time.time()
  1182. cutoff = now - (days * 86400) # Convert days to seconds
  1183. deleted_files = 0
  1184. deleted_dirs = 0
  1185. total_size_freed = 0
  1186. try:
  1187. for root, dirs, files in os.walk(folder, topdown=False):
  1188. # Delete files
  1189. for file in files:
  1190. file_path = os.path.join(root, file)
  1191. try:
  1192. if os.path.isfile(file_path) and os.path.getmtime(file_path) < cutoff:
  1193. file_size = os.path.getsize(file_path)
  1194. os.remove(file_path)
  1195. deleted_files += 1
  1196. total_size_freed += file_size
  1197. logger.debug(f"Deleted file: {file_path} ({file_size // (1024*1024):.1f}MB)")
  1198. except (OSError, IOError) as e:
  1199. logger.error(f"Failed to delete file {file_path}: {e}")
  1200. # Remove empty directories
  1201. for dir_name in dirs:
  1202. dir_path = os.path.join(root, dir_name)
  1203. try:
  1204. if os.path.exists(dir_path) and not os.listdir(dir_path): # Check if the directory is empty
  1205. os.rmdir(dir_path)
  1206. deleted_dirs += 1
  1207. logger.debug(f"Deleted empty directory: {dir_path}")
  1208. except (OSError, IOError) as e:
  1209. logger.error(f"Failed to delete directory {dir_path}: {e}")
  1210. if deleted_files > 0 or deleted_dirs > 0:
  1211. size_mb = total_size_freed / (1024 * 1024)
  1212. logger.info(f"Cleanup completed: {deleted_files} files, {deleted_dirs} directories deleted, {size_mb:.1f}MB freed")
  1213. else:
  1214. logger.info("No old files found to delete")
  1215. except Exception as e:
  1216. logger.error(f"Error during cleanup: {e}")
  1217. def video_exists(output_path, video_id):
  1218. """Check if a video already exists in the directory."""
  1219. return os.path.exists(os.path.join(output_path, video_id))
  1220. def check_exit_time():
  1221. """Check if the current time is past the exit time."""
  1222. now = datetime.now().time()
  1223. if now >= EXIT_TIME:
  1224. print("Current time exceeds exit time. Terminating script.")
  1225. sys.exit(0)
  1226. def main():
  1227. global download_stats
  1228. logger = logging.getLogger(__name__)
  1229. logger.info("Starting main download process")
  1230. # Initialize Home Assistant integration
  1231. if HOMEASSISTANT_ENABLED:
  1232. logger.info("Initializing Home Assistant integration...")
  1233. ha_integration = init_homeassistant_integration()
  1234. if ha_integration:
  1235. logger.info("Home Assistant integration started successfully")
  1236. else:
  1237. logger.warning("Home Assistant integration failed to start")
  1238. # Start the conversion queue worker
  1239. logger.info("Starting video conversion queue system...")
  1240. logger.info(f"Max concurrent conversions: {MAX_CONCURRENT_CONVERSIONS}")
  1241. # Start background conversion worker
  1242. conversion_worker = threading.Thread(target=process_conversion_queue, daemon=True, name="ConversionQueueWorker")
  1243. conversion_worker.start()
  1244. logger.debug("Conversion queue worker thread started")
  1245. # Update yt-dlp
  1246. try:
  1247. update_yt_dlp()
  1248. except Exception as e:
  1249. logger.error(f"Failed to update yt-dlp: {e}")
  1250. # Continue anyway, existing version might work
  1251. # Authenticate YouTube API
  1252. logger.info("Authenticating with YouTube API...")
  1253. try:
  1254. youtube = authenticate_youtube_api()
  1255. logger.info("YouTube API authentication successful")
  1256. except Exception as e:
  1257. logger.error(f"YouTube API authentication failed: {e}")
  1258. raise
  1259. # Ensure output directory exists
  1260. if not os.path.exists(OUTPUT_DIR):
  1261. logger.info(f"Creating output directory: {OUTPUT_DIR}")
  1262. os.makedirs(OUTPUT_DIR)
  1263. else:
  1264. logger.debug(f"Output directory exists: {OUTPUT_DIR}")
  1265. # Clean up old videos
  1266. logger.info(f"Cleaning up videos older than {DAYS_TO_KEEP} days...")
  1267. delete_old_videos(OUTPUT_DIR, days=DAYS_TO_KEEP)
  1268. # Fetch subscribed channels
  1269. logger.info("Fetching subscribed channels...")
  1270. try:
  1271. channels = get_subscribed_channels(youtube)
  1272. logger.info(f"Successfully fetched {len(channels)} subscribed channels")
  1273. except Exception as e:
  1274. logger.error(f"Failed to fetch subscribed channels: {e}")
  1275. channels = []
  1276. # Count total videos for progress tracking
  1277. logger.info("Counting total videos to download...")
  1278. total_videos = 0
  1279. all_videos = []
  1280. # Collect all videos first for counting
  1281. for i, channel_id in enumerate(channels):
  1282. try:
  1283. logger.debug(f"Processing channel {i+1}/{len(channels)}: {channel_id}")
  1284. videos = get_recent_videos(youtube, channel_id)
  1285. for video in videos:
  1286. video_dir = os.path.join(OUTPUT_DIR, video["video_id"])
  1287. if not os.path.exists(video_dir):
  1288. all_videos.append(('subscription', video))
  1289. total_videos += 1
  1290. else:
  1291. logger.debug(f"Video already exists, skipping: {video['title']}")
  1292. except Exception as e:
  1293. logger.error(f"Error processing channel {channel_id}: {e}")
  1294. # Add popular videos
  1295. for region_code in REGION_CODES:
  1296. try:
  1297. logger.debug(f"Fetching popular videos from region: {region_code}")
  1298. popular_videos = get_popular_videos(youtube, region_code)
  1299. for video in popular_videos:
  1300. video_dir = os.path.join(OUTPUT_DIR, video["video_id"])
  1301. if not os.path.exists(video_dir):
  1302. all_videos.append(('popular', video))
  1303. total_videos += 1
  1304. else:
  1305. logger.debug(f"Popular video already exists, skipping: {video['title']}")
  1306. except Exception as e:
  1307. logger.error(f"Error fetching popular videos from region {region_code}: {e}")
  1308. # Initialize download stats
  1309. download_stats['total_videos'] = total_videos
  1310. download_stats['completed_videos'] = 0
  1311. logger.info(f"Total videos to download: {total_videos}")
  1312. if total_videos == 0:
  1313. logger.info("No new videos found to download")
  1314. return
  1315. # Update initial Home Assistant stats
  1316. update_homeassistant_stats(
  1317. download_queue=total_videos,
  1318. conversion_queue=0,
  1319. download_progress=0,
  1320. active_downloads=0,
  1321. active_conversions=0
  1322. )
  1323. # Process all videos
  1324. for i, (video_type, video) in enumerate(all_videos):
  1325. # Check the exit time before processing each video
  1326. check_exit_time()
  1327. logger.info(f"Processing video {i+1}/{total_videos} ({video_type}): {video['title']}")
  1328. video_dir = os.path.join(OUTPUT_DIR, video["video_id"])
  1329. try:
  1330. # Create directory
  1331. os.makedirs(video_dir, exist_ok=True)
  1332. logger.debug(f"Created/verified directory: {video_dir}")
  1333. # Download the video
  1334. if not download_video(video["video_id"], video_dir, video["title"]):
  1335. logger.warning(f"Skipping video {video['video_id']} due to download error")
  1336. continue # Skip this video if download failed
  1337. # Create the NFO file
  1338. nfo_file_path = os.path.join(video_dir, "movie.nfo")
  1339. try:
  1340. create_nfo_file(video, nfo_file_path)
  1341. logger.debug(f"Created NFO file: {nfo_file_path}")
  1342. except Exception as e:
  1343. logger.error(f"Failed to create NFO file: {e}")
  1344. # Update completed videos count
  1345. download_stats['completed_videos'] += 1
  1346. progress = (download_stats['completed_videos'] / download_stats['total_videos']) * 100
  1347. # Update Home Assistant stats
  1348. update_homeassistant_stats(
  1349. download_queue=max(0, download_stats['total_videos'] - download_stats['completed_videos']),
  1350. download_progress=round(progress, 1)
  1351. )
  1352. logger.debug(f"Progress: {download_stats['completed_videos']}/{download_stats['total_videos']} ({progress:.1f}%)")
  1353. except Exception as e:
  1354. logger.error(f"Error processing video {video['title']}: {e}")
  1355. continue
  1356. logger.info("All videos processed!")
  1357. logger.info(f"Final stats: {download_stats['completed_videos']}/{download_stats['total_videos']} videos completed")
  1358. # Wait for all conversions to complete
  1359. logger.info("Waiting for all conversions to complete...")
  1360. conversion_executor.shutdown(wait=True)
  1361. logger.info("All conversions completed!")
  1362. # Final Home Assistant update
  1363. update_homeassistant_stats(
  1364. download_queue=0,
  1365. conversion_queue=0,
  1366. download_progress=100,
  1367. active_downloads=0,
  1368. active_conversions=0
  1369. )
  1370. logger.info("Main download process completed successfully")
  1371. logger.info("=" * 80)
  1372. def schedule_downloads():
  1373. """Schedule downloads to run at specified times"""
  1374. logger = logging.getLogger(__name__)
  1375. # Configuration for scheduling
  1376. START_TIME = dt_time(0, 0) # 00:00
  1377. END_TIME = dt_time(7, 0) # 07:00
  1378. logger.info("Starting YouTube Predownloader Service with scheduling")
  1379. logger.info(f"Service will run between {START_TIME.strftime('%H:%M')} and {END_TIME.strftime('%H:%M')} daily")
  1380. while True:
  1381. current_time = datetime.now().time()
  1382. # Check if we're in the allowed time window
  1383. if START_TIME <= current_time <= END_TIME:
  1384. try:
  1385. set_service_running()
  1386. logger.info(f"Starting download session at {datetime.now().strftime('%H:%M:%S')}")
  1387. # Run the main download process
  1388. main()
  1389. set_service_completed()
  1390. logger.info(f"Download session completed at {datetime.now().strftime('%H:%M:%S')}")
  1391. # Calculate next run time (next day at START_TIME)
  1392. next_run = datetime.now().replace(hour=START_TIME.hour, minute=START_TIME.minute, second=0, microsecond=0)
  1393. if current_time > START_TIME:
  1394. next_run = next_run.replace(day=next_run.day + 1)
  1395. set_service_waiting(next_run.isoformat())
  1396. # Sleep until next day
  1397. sleep_seconds = (next_run - datetime.now()).total_seconds()
  1398. logger.info(f"Sleeping until next run at {next_run.strftime('%Y-%m-%d %H:%M:%S')} ({sleep_seconds:.0f} seconds)")
  1399. time.sleep(sleep_seconds)
  1400. except Exception as e:
  1401. logger.error(f"Error during download session: {e}", exc_info=True)
  1402. set_service_error()
  1403. # Sleep for 1 hour before retrying
  1404. logger.info("Sleeping for 1 hour before retrying")
  1405. time.sleep(3600)
  1406. else:
  1407. # We're outside the allowed time window
  1408. if current_time < START_TIME:
  1409. # Before start time today
  1410. next_run = datetime.now().replace(hour=START_TIME.hour, minute=START_TIME.minute, second=0, microsecond=0)
  1411. else:
  1412. # After end time today, schedule for tomorrow
  1413. next_run = datetime.now().replace(hour=START_TIME.hour, minute=START_TIME.minute, second=0, microsecond=0)
  1414. next_run = next_run.replace(day=next_run.day + 1)
  1415. set_service_waiting(next_run.isoformat())
  1416. # Sleep until start time
  1417. sleep_seconds = (next_run - datetime.now()).total_seconds()
  1418. logger.info(f"Waiting until {next_run.strftime('%Y-%m-%d %H:%M:%S')} to start downloads ({sleep_seconds:.0f} seconds)")
  1419. time.sleep(min(sleep_seconds, 3600)) # Check every hour maximum
  1420. # Fetch and process most popular videos worldwide (from multiple regions)
  1421. def fetch_popular_videos_old():
  1422. """Old function kept for reference - this logic is now integrated into main()"""
  1423. for region_code in REGION_CODES:
  1424. print(f"Fetching the most popular videos from {region_code}...")
  1425. popular_videos = get_popular_videos(youtube, region_code)
  1426. for video in popular_videos:
  1427. # Check the exit time before processing each video
  1428. check_exit_time()
  1429. print(f"Processing popular video: {video['title']} ({region_code})")
  1430. video_dir = os.path.join(OUTPUT_DIR, video["video_id"])
  1431. # Check if the video already exists
  1432. if os.path.exists(video_dir):
  1433. print(f"Skipping download, already exists: {video['title']} ({video['video_id']})")
  1434. continue
  1435. os.makedirs(video_dir, exist_ok=True)
  1436. # Download the popular video
  1437. if not download_video(video["video_id"], video_dir):
  1438. print(f"Skipping popular video {video['video_id']} due to download error.")
  1439. continue # Skip this video if download failed
  1440. # Check if conversion is needed (VP9/AV1 codecs or non-MP4 files)
  1441. needs_conversion = False
  1442. conversion_file = None
  1443. for file in os.listdir(video_dir):
  1444. if file.endswith(('.mp4', '.mkv', '.webm', '.m4v')):
  1445. file_path = os.path.join(video_dir, file)
  1446. try:
  1447. # Quick check for problematic codecs
  1448. probe_cmd = [FFPROBE_PATH, "-v", "quiet", "-select_streams", "v:0",
  1449. "-show_entries", "stream=codec_name", "-of", "csv=p=0", file_path]
  1450. result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  1451. codec = result.stdout.strip().lower()
  1452. if codec in ['vp9', 'av01', 'av1'] or not file.endswith('.mp4'):
  1453. needs_conversion = True
  1454. conversion_file = file_path
  1455. print(f"Found {codec} codec - will convert for Apple TV compatibility")
  1456. break
  1457. except subprocess.CalledProcessError:
  1458. # If we can't probe, assume it might need conversion if not MP4
  1459. if not file.endswith('.mp4'):
  1460. needs_conversion = True
  1461. conversion_file = file_path
  1462. break
  1463. if needs_conversion and conversion_file:
  1464. # Determine priority based on video resolution
  1465. try:
  1466. probe_cmd = [FFPROBE_PATH, "-v", "quiet", "-select_streams", "v:0",
  1467. "-show_entries", "stream=height", "-of", "csv=p=0", conversion_file]
  1468. result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  1469. height = int(result.stdout.strip()) if result.stdout.strip().isdigit() else 1080
  1470. priority = 2 if height >= 2160 else 1 # 4K gets higher priority
  1471. except:
  1472. priority = 1 # Default priority
  1473. add_to_conversion_queue(conversion_file, priority)
  1474. print_queue_status()
  1475. # Create the NFO file
  1476. nfo_file_path = os.path.join(video_dir, "movie.nfo")
  1477. create_nfo_file(video, nfo_file_path)
  1478. print("All videos processed successfully!")
  1479. # Final conversion queue status
  1480. print("\n🎬 Final Conversion Queue Status:")
  1481. print_queue_status()
  1482. # Final Home Assistant update
  1483. update_homeassistant_stats(
  1484. download_queue=0,
  1485. conversion_queue=conversion_queue.qsize(),
  1486. download_progress=100,
  1487. active_downloads=0,
  1488. active_conversions=conversion_stats['active_conversions']
  1489. )
  1490. queued_items = conversion_queue.qsize()
  1491. if queued_items > 0:
  1492. print(f"⏳ {queued_items} videos still in conversion queue")
  1493. print("💡 Conversions will continue in the background")
  1494. print("💡 You can monitor progress in Home Assistant")
  1495. else:
  1496. print("✅ All conversions completed!")
  1497. print("🏠 Home Assistant integration will continue running in background")
  1498. if __name__ == "__main__":
  1499. import sys
  1500. # Check for debug mode
  1501. debug_mode = "--debug" in sys.argv
  1502. if debug_mode:
  1503. sys.argv.remove("--debug")
  1504. # Set debug logging
  1505. LOG_LEVEL = logging.DEBUG
  1506. logger = setup_logging()
  1507. logger.info("Debug mode enabled - verbose logging activated")
  1508. logger = logging.getLogger(__name__)
  1509. # Check if we should run as a service with scheduling
  1510. if len(sys.argv) > 1 and sys.argv[1] == "--service":
  1511. logger.info("Starting YouTube Predownloader Service with scheduling")
  1512. try:
  1513. schedule_downloads()
  1514. except KeyboardInterrupt:
  1515. logger.info("Service interrupted by user")
  1516. except Exception as e:
  1517. logger.error(f"Service error: {e}", exc_info=True)
  1518. else:
  1519. logger.info("Running YouTube Predownloader once")
  1520. try:
  1521. main()
  1522. except KeyboardInterrupt:
  1523. logger.info("Process interrupted by user")
  1524. except Exception as e:
  1525. logger.error(f"Process error: {e}", exc_info=True)
  1526. finally:
  1527. logger.info("Process finished")