main.py 88 KB

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