main.py 90 KB

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