main.py 87 KB

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