main.py 89 KB

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