main.py 81 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805
  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
  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 = ["LK"] # Regions to fetch popular videos from
  51. # Logging Configuration
  52. LOG_DIR = "logs"
  53. LOG_FILE = "youtube_predownloader.log"
  54. LOG_LEVEL = logging.INFO
  55. LOG_MAX_SIZE = 50 * 1024 * 1024 # 50MB per log file
  56. LOG_BACKUP_COUNT = 5 # Keep 5 backup files
  57. LOG_RETENTION_DAYS = 10 # Delete logs older than 10 days
  58. # Conversion Queue Settings
  59. MAX_CONCURRENT_CONVERSIONS = 2 # Maximum number of simultaneous video conversions
  60. CONVERSION_QUEUE_SIZE = 50 # Maximum items in conversion queue
  61. def setup_logging():
  62. """Set up comprehensive logging with file rotation and cleanup."""
  63. # Create logs directory if it doesn't exist
  64. if not os.path.exists(LOG_DIR):
  65. os.makedirs(LOG_DIR)
  66. # Clean up old log files first
  67. cleanup_old_logs()
  68. # Create logger
  69. logger = logging.getLogger()
  70. logger.setLevel(LOG_LEVEL)
  71. # Clear any existing handlers
  72. for handler in logger.handlers[:]:
  73. logger.removeHandler(handler)
  74. # Create formatters
  75. detailed_formatter = logging.Formatter(
  76. '%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s',
  77. datefmt='%Y-%m-%d %H:%M:%S'
  78. )
  79. console_formatter = logging.Formatter(
  80. '%(asctime)s - %(levelname)s - %(message)s',
  81. datefmt='%H:%M:%S'
  82. )
  83. # File handler with rotation
  84. log_file_path = os.path.join(LOG_DIR, LOG_FILE)
  85. file_handler = logging.handlers.RotatingFileHandler(
  86. log_file_path,
  87. maxBytes=LOG_MAX_SIZE,
  88. backupCount=LOG_BACKUP_COUNT,
  89. encoding='utf-8'
  90. )
  91. file_handler.setLevel(logging.DEBUG) # Log everything to file
  92. file_handler.setFormatter(detailed_formatter)
  93. # Console handler (less verbose)
  94. console_handler = logging.StreamHandler(sys.stdout)
  95. console_handler.setLevel(LOG_LEVEL)
  96. console_handler.setFormatter(console_formatter)
  97. # Add handlers to logger
  98. logger.addHandler(file_handler)
  99. logger.addHandler(console_handler)
  100. # Log startup information
  101. logger.info("=" * 80)
  102. logger.info("YouTube Predownloader Starting")
  103. logger.info("=" * 80)
  104. logger.info(f"Log file: {log_file_path}")
  105. logger.info(f"Log level: {logging.getLevelName(LOG_LEVEL)}")
  106. logger.info(f"Output directory: {OUTPUT_DIR}")
  107. logger.info(f"Days back to fetch: {DAYS_BACK}")
  108. logger.info(f"Days to keep videos: {DAYS_TO_KEEP}")
  109. logger.info(f"Log retention days: {LOG_RETENTION_DAYS}")
  110. return logger
  111. def cleanup_old_logs():
  112. """Remove log files older than LOG_RETENTION_DAYS."""
  113. try:
  114. cutoff_time = time.time() - (LOG_RETENTION_DAYS * 86400)
  115. log_pattern = os.path.join(LOG_DIR, "*.log*")
  116. deleted_count = 0
  117. for log_file in glob.glob(log_pattern):
  118. try:
  119. if os.path.getmtime(log_file) < cutoff_time:
  120. os.remove(log_file)
  121. deleted_count += 1
  122. print(f"Deleted old log file: {log_file}")
  123. except (OSError, IOError) as e:
  124. print(f"Failed to delete log file {log_file}: {e}")
  125. if deleted_count > 0:
  126. print(f"Cleaned up {deleted_count} old log files")
  127. except Exception as e:
  128. print(f"Error during log cleanup: {e}")
  129. def log_system_info():
  130. """Log system and environment information."""
  131. logger = logging.getLogger(__name__)
  132. try:
  133. # Log Python and system info
  134. import platform
  135. import psutil
  136. logger.info("System Information:")
  137. logger.info(f" Python version: {platform.python_version()}")
  138. logger.info(f" Platform: {platform.platform()}")
  139. logger.info(f" CPU count: {os.cpu_count()}")
  140. # Memory info
  141. memory = psutil.virtual_memory()
  142. logger.info(f" Total memory: {memory.total // (1024**3):.1f} GB")
  143. logger.info(f" Available memory: {memory.available // (1024**3):.1f} GB")
  144. # Disk space for output directory
  145. if os.path.exists(OUTPUT_DIR):
  146. disk_usage = psutil.disk_usage(OUTPUT_DIR)
  147. logger.info(f" Output disk free: {disk_usage.free // (1024**3):.1f} GB")
  148. # Check required tools
  149. tools = [
  150. ('yt-dlp', ['yt-dlp', '--version']),
  151. ('ffmpeg', ['ffmpeg', '-version']),
  152. ('ffprobe', ['ffprobe', '-version'])
  153. ]
  154. for tool_name, cmd in tools:
  155. try:
  156. result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
  157. if result.returncode == 0:
  158. version_line = result.stdout.split('\n')[0] if result.stdout else "unknown"
  159. logger.info(f" {tool_name}: {version_line}")
  160. else:
  161. logger.warning(f" {tool_name}: Not found or error")
  162. except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError):
  163. logger.error(f" {tool_name}: Not available")
  164. except Exception as e:
  165. logger.error(f"Error collecting system info: {e}")
  166. # Initialize logging at module level
  167. logger = setup_logging()
  168. log_system_info()
  169. # FFmpeg/FFprobe paths
  170. FFPROBE_PATH = "/usr/bin/ffprobe"
  171. FFMPEG_PATH = "/usr/bin/ffmpeg"
  172. # Global conversion queue and executor
  173. conversion_queue = queue.Queue(maxsize=CONVERSION_QUEUE_SIZE)
  174. conversion_executor = ThreadPoolExecutor(max_workers=MAX_CONCURRENT_CONVERSIONS, thread_name_prefix="VideoConverter")
  175. # Progress tracking
  176. download_stats = {
  177. 'total_videos': 0,
  178. 'completed_videos': 0,
  179. 'current_video': '',
  180. 'download_speed': 0,
  181. 'active_downloads': 0
  182. }
  183. conversion_stats = {
  184. 'active_conversions': 0,
  185. 'completed_conversions': 0,
  186. 'current_conversion': '',
  187. 'conversion_progress': 0
  188. }
  189. def update_yt_dlp():
  190. """Update yt-dlp to the latest version."""
  191. logger = logging.getLogger(__name__)
  192. logger.info("Updating yt-dlp to the latest version...")
  193. try:
  194. result = subprocess.run(["pip", "install", "--upgrade", "yt-dlp"],
  195. capture_output=True, text=True, check=True)
  196. logger.info("yt-dlp updated successfully!")
  197. logger.debug(f"yt-dlp update output: {result.stdout}")
  198. except subprocess.CalledProcessError as e:
  199. logger.error(f"Failed to update yt-dlp: {e}")
  200. logger.debug(f"yt-dlp update error: {e.stderr}")
  201. raise
  202. def authenticate_youtube_api():
  203. """Authenticate using 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.datetime.now() - datetime.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. logger = logging.getLogger(__name__)
  613. logger.info("Conversion queue worker started")
  614. while True:
  615. try:
  616. # Get item from queue (blocks until available)
  617. queue_item = conversion_queue.get(timeout=30)
  618. # Handle both old format (priority, input_file) and new format (priority, input_file, target_file)
  619. if len(queue_item) == 2:
  620. priority, input_file = queue_item
  621. target_file = None
  622. elif len(queue_item) == 3:
  623. priority, input_file, target_file = queue_item
  624. else:
  625. logger.error(f"Invalid queue item format: {queue_item}")
  626. conversion_queue.task_done()
  627. continue
  628. # Check if file still exists
  629. if not os.path.exists(input_file):
  630. logger.warning(f"File no longer exists, skipping conversion: {input_file}")
  631. conversion_queue.task_done()
  632. continue
  633. if target_file:
  634. logger.info(f"Starting conversion (priority {priority}): {os.path.basename(input_file)} → {os.path.basename(target_file)}")
  635. # Perform the conversion with target filename
  636. success = convert_to_apple_tv_compatible_with_target(input_file, target_file)
  637. else:
  638. logger.info(f"Starting conversion (priority {priority}): {os.path.basename(input_file)}")
  639. # Perform the conversion with standard naming
  640. success = convert_to_apple_tv_compatible(input_file)
  641. if success:
  642. logger.info(f"Conversion completed successfully: {os.path.basename(input_file)}")
  643. else:
  644. logger.error(f"Conversion failed: {os.path.basename(input_file)}")
  645. # Mark task as done
  646. conversion_queue.task_done()
  647. except queue.Empty:
  648. # No items in queue for 30 seconds, continue waiting
  649. logger.debug("Conversion queue empty, waiting for new items...")
  650. continue
  651. except Exception as e:
  652. logger.error(f"Conversion queue worker error: {e}")
  653. try:
  654. conversion_queue.task_done()
  655. except:
  656. pass
  657. def sanitize_filename(filename):
  658. """Sanitize a filename to remove invalid characters."""
  659. import re
  660. # Remove invalid filename characters
  661. sanitized = re.sub(r'[<>:"/\\|?*]', '_', filename)
  662. # Remove extra spaces and limit length
  663. sanitized = ' '.join(sanitized.split())[:200]
  664. return sanitized
  665. def test_vaapi_support():
  666. """Test if Intel VAAPI hardware acceleration is available."""
  667. logger = logging.getLogger(__name__)
  668. try:
  669. # Test H.264 VAAPI encoding
  670. test_cmd = [FFMPEG_PATH, "-hide_banner", "-f", "lavfi", "-i", "testsrc=duration=1:size=320x240:rate=1",
  671. "-vaapi_device", "/dev/dri/renderD128", "-vf", "format=nv12,hwupload",
  672. "-c:v", "h264_vaapi", "-frames:v", "1", "-f", "null", "-"]
  673. result = subprocess.run(test_cmd, capture_output=True, text=True, timeout=10)
  674. h264_vaapi_available = result.returncode == 0
  675. # Test HEVC VAAPI encoding (optional, may not work on all Intel GPUs)
  676. test_cmd = [FFMPEG_PATH, "-hide_banner", "-f", "lavfi", "-i", "testsrc=duration=1:size=320x240:rate=1",
  677. "-vaapi_device", "/dev/dri/renderD128", "-vf", "format=nv12,hwupload",
  678. "-c:v", "hevc_vaapi", "-frames:v", "1", "-f", "null", "-"]
  679. result = subprocess.run(test_cmd, capture_output=True, text=True, timeout=10)
  680. hevc_vaapi_available = result.returncode == 0
  681. if h264_vaapi_available and hevc_vaapi_available:
  682. logger.info("🚀 Intel VAAPI hardware acceleration available (H.264 + HEVC)")
  683. return True, True
  684. elif h264_vaapi_available:
  685. logger.info("🚀 Intel VAAPI hardware acceleration available (H.264 only)")
  686. return True, False
  687. else:
  688. logger.info("⚠️ Intel VAAPI hardware acceleration not available, falling back to software encoding")
  689. return False, False
  690. except Exception as e:
  691. logger.warning(f"Could not test VAAPI support: {e}")
  692. return False, False
  693. def convert_to_apple_tv_compatible(input_file):
  694. """Convert a video to Apple TV 4K compatible format with Intel VAAPI hardware acceleration."""
  695. global conversion_stats
  696. logger = logging.getLogger(__name__)
  697. base_name = os.path.splitext(input_file)[0]
  698. mp4_file = base_name + ".mp4"
  699. # If input is already MP4, use a temporary output file to avoid overwriting during conversion
  700. temp_output = None
  701. if input_file.lower().endswith('.mp4'):
  702. temp_output = base_name + "_converted.mp4"
  703. final_output = mp4_file
  704. mp4_file = temp_output
  705. else:
  706. final_output = mp4_file
  707. # Update conversion stats
  708. conversion_stats['active_conversions'] += 1
  709. conversion_stats['current_conversion'] = os.path.basename(input_file)
  710. logger.info(f"Converting {os.path.basename(input_file)} to Apple TV compatible format with GPU acceleration...")
  711. logger.debug(f"Input file: {input_file}")
  712. logger.debug(f"Output file: {mp4_file}")
  713. # Test VAAPI support
  714. vaapi_h264_available, vaapi_hevc_available = test_vaapi_support()
  715. # Update Home Assistant
  716. update_homeassistant_stats(
  717. conversion_queue=conversion_queue.qsize(),
  718. active_conversions=conversion_stats['active_conversions']
  719. )
  720. # Detect video properties to optimize conversion
  721. try:
  722. import json
  723. probe_cmd = [
  724. FFPROBE_PATH, "-v", "quiet", "-print_format", "json",
  725. "-show_format", "-show_streams", input_file
  726. ]
  727. logger.debug(f"Probing video with command: {' '.join(probe_cmd)}")
  728. probe_result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  729. video_info = json.loads(probe_result.stdout)
  730. # Find video stream
  731. video_stream = next((s for s in video_info['streams'] if s['codec_type'] == 'video'), None)
  732. is_4k = video_stream and int(video_stream.get('height', 0)) >= 2160
  733. is_high_res = video_stream and int(video_stream.get('height', 0)) >= 1080
  734. if video_stream:
  735. height = video_stream.get('height', 'unknown')
  736. codec = video_stream.get('codec_name', 'unknown')
  737. bitrate = video_stream.get('bit_rate', 'unknown')
  738. logger.info(f"Video properties: {height}p, codec: {codec}, bitrate: {bitrate}")
  739. except (subprocess.CalledProcessError, json.JSONDecodeError, Exception) as e:
  740. logger.warning(f"Could not probe video properties: {e}, using default settings")
  741. is_4k = False
  742. is_high_res = True
  743. # Intel VAAPI optimized encoding based on source quality and hardware support:
  744. if is_4k and vaapi_hevc_available:
  745. # 4K content with VAAPI HEVC: Use hardware-accelerated H.265
  746. codec_args = [
  747. "-vaapi_device", "/dev/dri/renderD128", # VAAPI device
  748. "-vf", "format=nv12,hwupload", # Upload to GPU memory
  749. "-c:v", "hevc_vaapi", # Intel VAAPI HEVC encoder
  750. "-qp", "20", # Quality parameter (lower = better)
  751. "-rc_mode", "CQP", # Constant Quality mode
  752. ]
  753. logger.info("🚀 Using Intel VAAPI HEVC hardware acceleration for 4K content")
  754. elif (is_4k or is_high_res) and vaapi_h264_available:
  755. # 4K/1080p content with VAAPI H.264: Use hardware-accelerated H.264
  756. quality = "20" if is_4k else "22" # Slightly lower quality for 1080p
  757. codec_args = [
  758. "-vaapi_device", "/dev/dri/renderD128", # VAAPI device
  759. "-vf", "format=nv12,hwupload", # Upload to GPU memory
  760. "-c:v", "h264_vaapi", # Intel VAAPI H.264 encoder
  761. "-qp", quality, # Quality parameter
  762. "-rc_mode", "CQP", # Constant Quality mode
  763. "-profile:v", "high", # H.264 High Profile
  764. ]
  765. quality_label = "4K" if is_4k else "1080p"
  766. logger.info(f"🚀 Using Intel VAAPI H.264 hardware acceleration for {quality_label} content")
  767. elif is_4k:
  768. # 4K content without VAAPI: Fall back to software H.265
  769. codec_args = [
  770. "-c:v", "libx265", # Software H.265 for 4K
  771. "-preset", "medium", # Better compression for 4K
  772. "-crf", "20", # Higher quality for 4K content
  773. "-level", "5.1", # Level 5.1 supports 4K
  774. "-pix_fmt", "yuv420p", # Standard compatibility
  775. ]
  776. logger.info("⚠️ Using software H.265 encoding for 4K content (VAAPI not available)")
  777. elif is_high_res:
  778. # 1080p content without VAAPI: Use software H.264
  779. codec_args = [
  780. "-c:v", "libx264", # Software H.264 for 1080p
  781. "-profile:v", "high", # High profile for better quality
  782. "-preset", "fast", # Faster encoding for 1080p
  783. "-crf", "21", # Good quality for 1080p
  784. "-level", "4.1", # Level 4.1 supports 1080p
  785. ]
  786. logger.info("⚠️ Using software H.264 encoding for 1080p content (VAAPI not available)")
  787. else:
  788. # Lower resolution: Use software H.264 with faster settings
  789. codec_args = [
  790. "-c:v", "libx264", # Software H.264 for lower res
  791. "-profile:v", "main", # Main profile for lower res
  792. "-preset", "fast", # Fast encoding
  793. "-crf", "23", # Standard quality
  794. "-level", "3.1", # Level 3.1 for lower res
  795. ]
  796. logger.info("Using software H.264 encoding for lower resolution content")
  797. # Add progress reporting to FFmpeg command
  798. command = [
  799. FFMPEG_PATH, "-i", input_file,
  800. *codec_args,
  801. "-c:a", "aac", # AAC audio codec
  802. "-b:a", "192k", # Higher audio bitrate for better quality
  803. "-movflags", "+faststart", # Optimize for streaming/quick playback
  804. "-progress", "pipe:1", # Output progress to stdout
  805. "-y", # Overwrite output file if exists
  806. mp4_file
  807. ]
  808. try:
  809. logger.debug(f"FFmpeg command: {' '.join(command)}")
  810. logger.info("Starting conversion... This may take a while for 4K content.")
  811. # Get total duration for progress calculation
  812. duration_cmd = [FFPROBE_PATH, "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", input_file]
  813. try:
  814. duration_result = subprocess.run(duration_cmd, capture_output=True, text=True, check=True)
  815. total_duration = float(duration_result.stdout.strip())
  816. except:
  817. total_duration = 0
  818. logger.debug("Could not determine video duration for progress tracking")
  819. start_time = time.time()
  820. # Start FFmpeg with progress monitoring
  821. process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1, universal_newlines=True)
  822. current_conversion_progress = 0
  823. while True:
  824. output = process.stdout.readline()
  825. if output == '' and process.poll() is not None:
  826. break
  827. if output:
  828. output = output.strip()
  829. # Parse FFmpeg progress output
  830. if output.startswith("out_time_us=") and total_duration > 0:
  831. try:
  832. # Extract time in microseconds
  833. time_us = int(output.split("=")[1])
  834. current_time = time_us / 1000000 # Convert to seconds
  835. progress = min(100, (current_time / total_duration) * 100)
  836. if abs(progress - current_conversion_progress) >= 5: # Update every 5%
  837. current_conversion_progress = progress
  838. # Update Home Assistant with conversion progress
  839. update_homeassistant_stats(
  840. conversion_queue=conversion_queue.qsize(),
  841. active_conversions=conversion_stats['active_conversions'],
  842. current_conversion_progress=current_conversion_progress,
  843. current_conversion_file=os.path.basename(input_file)[:50]
  844. )
  845. logger.info(f"Conversion progress: {current_conversion_progress:.1f}% - {os.path.basename(input_file)}")
  846. except (ValueError, IndexError):
  847. pass
  848. # Wait for process to complete
  849. process.wait()
  850. end_time = time.time()
  851. if process.returncode != 0:
  852. stderr_output = process.stderr.read()
  853. logger.error(f"FFmpeg failed with return code {process.returncode}: {stderr_output}")
  854. raise subprocess.CalledProcessError(process.returncode, command, stderr=stderr_output)
  855. conversion_time = end_time - start_time
  856. logger.info(f"Conversion completed in {conversion_time:.1f}s: {mp4_file}")
  857. # Clear conversion progress
  858. update_homeassistant_stats(
  859. conversion_queue=conversion_queue.qsize(),
  860. active_conversions=conversion_stats['active_conversions'],
  861. current_conversion_progress=100,
  862. current_conversion_file=""
  863. )
  864. # Check if output file was created and has reasonable size
  865. if os.path.exists(mp4_file):
  866. input_size = os.path.getsize(input_file)
  867. output_size = os.path.getsize(mp4_file)
  868. size_ratio = output_size / input_size if input_size > 0 else 0
  869. logger.info(f"File sizes - Input: {input_size // (1024*1024):.1f}MB, Output: {output_size // (1024*1024):.1f}MB (ratio: {size_ratio:.2f})")
  870. # Only replace original if output seems reasonable
  871. if output_size > input_size * 0.1: # Output should be at least 10% of input size
  872. # If we used a temporary file, replace the original
  873. if temp_output:
  874. os.remove(input_file)
  875. os.rename(mp4_file, final_output)
  876. logger.info(f"Original file replaced with converted version: {os.path.basename(final_output)}")
  877. else:
  878. os.remove(input_file)
  879. logger.info(f"Original file deleted: {os.path.basename(input_file)}")
  880. else:
  881. logger.warning(f"Output file seems too small, keeping original: {os.path.basename(input_file)}")
  882. if temp_output and os.path.exists(mp4_file):
  883. os.remove(mp4_file) # Clean up failed temporary file
  884. # Update completion stats
  885. conversion_stats['active_conversions'] -= 1
  886. conversion_stats['completed_conversions'] += 1
  887. # Update Home Assistant
  888. update_homeassistant_stats(
  889. conversion_queue=conversion_queue.qsize(),
  890. active_conversions=conversion_stats['active_conversions']
  891. )
  892. return True
  893. except subprocess.CalledProcessError as e:
  894. conversion_stats['active_conversions'] -= 1
  895. logger.error(f"Conversion failed for {os.path.basename(input_file)}: {e}")
  896. if e.stderr:
  897. logger.debug(f"FFmpeg error output: {e.stderr}")
  898. # Clean up temporary file if it exists
  899. if temp_output and os.path.exists(mp4_file):
  900. os.remove(mp4_file)
  901. logger.debug(f"Cleaned up temporary file: {mp4_file}")
  902. # Update Home Assistant
  903. update_homeassistant_stats(
  904. conversion_queue=conversion_queue.qsize(),
  905. active_conversions=conversion_stats['active_conversions']
  906. )
  907. return False
  908. except Exception as e:
  909. conversion_stats['active_conversions'] -= 1
  910. logger.error(f"Unexpected error during conversion of {os.path.basename(input_file)}: {e}")
  911. # Update Home Assistant
  912. update_homeassistant_stats(
  913. conversion_queue=conversion_queue.qsize(),
  914. active_conversions=conversion_stats['active_conversions']
  915. )
  916. return False
  917. def convert_to_apple_tv_compatible_with_target(input_file, target_file):
  918. """Convert a video to Apple TV 4K compatible format with Intel VAAPI hardware acceleration and specific target filename."""
  919. global conversion_stats
  920. logger = logging.getLogger(__name__)
  921. # Update conversion stats
  922. conversion_stats['active_conversions'] += 1
  923. conversion_stats['current_conversion'] = os.path.basename(input_file)
  924. logger.info(f"Converting {os.path.basename(input_file)} to Apple TV compatible format with GPU acceleration...")
  925. logger.info(f"Target filename: {os.path.basename(target_file)}")
  926. logger.debug(f"Input file: {input_file}")
  927. logger.debug(f"Target file: {target_file}")
  928. # Use a temporary output file during conversion to avoid conflicts
  929. base_name, ext = os.path.splitext(target_file)
  930. temp_output = f"{base_name}.converting{ext}"
  931. # Test VAAPI support
  932. vaapi_h264_available, vaapi_hevc_available = test_vaapi_support()
  933. # Update Home Assistant
  934. update_homeassistant_stats(
  935. conversion_queue=conversion_queue.qsize(),
  936. active_conversions=conversion_stats['active_conversions']
  937. )
  938. # Detect video properties to optimize conversion
  939. try:
  940. import json
  941. probe_cmd = [
  942. FFPROBE_PATH, "-v", "quiet", "-print_format", "json",
  943. "-show_format", "-show_streams", input_file
  944. ]
  945. logger.debug(f"Probing video with command: {' '.join(probe_cmd)}")
  946. probe_result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  947. video_info = json.loads(probe_result.stdout)
  948. # Find video stream
  949. video_stream = next((s for s in video_info['streams'] if s['codec_type'] == 'video'), None)
  950. is_4k = video_stream and int(video_stream.get('height', 0)) >= 2160
  951. is_high_res = video_stream and int(video_stream.get('height', 0)) >= 1080
  952. if video_stream:
  953. height = video_stream.get('height', 'unknown')
  954. codec = video_stream.get('codec_name', 'unknown')
  955. bitrate = video_stream.get('bit_rate', 'unknown')
  956. logger.info(f"Video properties: {height}p, codec: {codec}, bitrate: {bitrate}")
  957. except (subprocess.CalledProcessError, json.JSONDecodeError, Exception) as e:
  958. logger.warning(f"Could not probe video properties: {e}, using default settings")
  959. is_4k = False
  960. is_high_res = True
  961. # Intel VAAPI optimized encoding based on source quality and hardware support
  962. if is_4k and vaapi_hevc_available:
  963. # 4K content with VAAPI HEVC: Use hardware-accelerated H.265
  964. codec_args = [
  965. "-vaapi_device", "/dev/dri/renderD128", # VAAPI device
  966. "-vf", "format=nv12,hwupload", # Upload to GPU memory
  967. "-c:v", "hevc_vaapi", # Intel VAAPI HEVC encoder
  968. "-qp", "20", # Quality parameter (lower = better)
  969. "-rc_mode", "CQP", # Constant Quality mode
  970. ]
  971. logger.info("🚀 Using Intel VAAPI HEVC hardware acceleration for 4K content")
  972. elif (is_4k or is_high_res) and vaapi_h264_available:
  973. # 4K/1080p content with VAAPI H.264: Use hardware-accelerated H.264
  974. quality = "20" if is_4k else "22" # Slightly lower quality for 1080p
  975. codec_args = [
  976. "-vaapi_device", "/dev/dri/renderD128", # VAAPI device
  977. "-vf", "format=nv12,hwupload", # Upload to GPU memory
  978. "-c:v", "h264_vaapi", # Intel VAAPI H.264 encoder
  979. "-qp", quality, # Quality parameter
  980. "-rc_mode", "CQP", # Constant Quality mode
  981. "-profile:v", "high", # H.264 High Profile
  982. ]
  983. quality_label = "4K" if is_4k else "1080p"
  984. logger.info(f"🚀 Using Intel VAAPI H.264 hardware acceleration for {quality_label} content")
  985. elif is_4k:
  986. # 4K content without VAAPI: Fall back to software H.265
  987. codec_args = [
  988. "-c:v", "libx265", # Software H.265 for 4K
  989. "-preset", "medium", # Better compression for 4K
  990. "-crf", "20", # Higher quality for 4K content
  991. "-level", "5.1", # Level 5.1 supports 4K
  992. "-pix_fmt", "yuv420p", # Standard compatibility
  993. ]
  994. logger.info("⚠️ Using software H.265 encoding for 4K content (VAAPI not available)")
  995. elif is_high_res:
  996. # 1080p content without VAAPI: Use software H.264
  997. codec_args = [
  998. "-c:v", "libx264", # Software H.264 for 1080p
  999. "-profile:v", "high", # High profile for better quality
  1000. "-preset", "fast", # Faster encoding for 1080p
  1001. "-crf", "21", # Good quality for 1080p
  1002. "-level", "4.1", # Level 4.1 supports 1080p
  1003. ]
  1004. logger.info("⚠️ Using software H.264 encoding for 1080p content (VAAPI not available)")
  1005. else:
  1006. # Lower resolution: Use software H.264 with faster settings
  1007. codec_args = [
  1008. "-c:v", "libx264", # Software H.264 for lower res
  1009. "-profile:v", "main", # Main profile for lower res
  1010. "-preset", "fast", # Fast encoding
  1011. "-crf", "23", # Standard quality
  1012. "-level", "3.1", # Level 3.1 for lower res
  1013. ]
  1014. logger.info("Using software H.264 encoding for lower resolution content")
  1015. # Add progress reporting to FFmpeg command
  1016. command = [
  1017. FFMPEG_PATH, "-i", input_file,
  1018. *codec_args,
  1019. "-c:a", "aac", # AAC audio codec
  1020. "-b:a", "192k", # Higher audio bitrate for better quality
  1021. "-movflags", "+faststart", # Optimize for streaming/quick playback
  1022. "-progress", "pipe:1", # Output progress to stdout
  1023. "-y", # Overwrite output file if exists
  1024. temp_output
  1025. ]
  1026. try:
  1027. logger.debug(f"FFmpeg command: {' '.join(command)}")
  1028. logger.info("Starting conversion... This may take a while for 4K content.")
  1029. # Get total duration for progress calculation
  1030. duration_cmd = [FFPROBE_PATH, "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", input_file]
  1031. try:
  1032. duration_result = subprocess.run(duration_cmd, capture_output=True, text=True, check=True)
  1033. total_duration = float(duration_result.stdout.strip())
  1034. except:
  1035. total_duration = 0
  1036. logger.debug("Could not determine video duration for progress tracking")
  1037. start_time = time.time()
  1038. # Start FFmpeg with progress monitoring
  1039. process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1, universal_newlines=True)
  1040. current_conversion_progress = 0
  1041. while True:
  1042. output = process.stdout.readline()
  1043. if output == '' and process.poll() is not None:
  1044. break
  1045. if output:
  1046. output = output.strip()
  1047. # Parse FFmpeg progress output
  1048. if output.startswith("out_time_us=") and total_duration > 0:
  1049. try:
  1050. # Extract time in microseconds
  1051. time_us = int(output.split("=")[1])
  1052. current_time = time_us / 1000000 # Convert to seconds
  1053. progress = min(100, (current_time / total_duration) * 100)
  1054. if abs(progress - current_conversion_progress) >= 5: # Update every 5%
  1055. current_conversion_progress = progress
  1056. # Update Home Assistant with conversion progress
  1057. update_homeassistant_stats(
  1058. conversion_queue=conversion_queue.qsize(),
  1059. active_conversions=conversion_stats['active_conversions'],
  1060. current_conversion_progress=current_conversion_progress,
  1061. current_conversion_file=os.path.basename(input_file)[:50]
  1062. )
  1063. logger.info(f"Conversion progress: {current_conversion_progress:.1f}% - {os.path.basename(input_file)}")
  1064. except (ValueError, IndexError):
  1065. pass
  1066. # Wait for process to complete
  1067. process.wait()
  1068. end_time = time.time()
  1069. if process.returncode != 0:
  1070. stderr_output = process.stderr.read()
  1071. logger.error(f"FFmpeg failed with return code {process.returncode}: {stderr_output}")
  1072. raise subprocess.CalledProcessError(process.returncode, command, stderr=stderr_output)
  1073. conversion_time = end_time - start_time
  1074. logger.info(f"Conversion completed in {conversion_time:.1f}s")
  1075. # Clear conversion progress
  1076. update_homeassistant_stats(
  1077. conversion_queue=conversion_queue.qsize(),
  1078. active_conversions=conversion_stats['active_conversions'],
  1079. current_conversion_progress=100,
  1080. current_conversion_file=""
  1081. )
  1082. # Check if output file was created and has reasonable size
  1083. if os.path.exists(temp_output):
  1084. input_size = os.path.getsize(input_file)
  1085. output_size = os.path.getsize(temp_output)
  1086. size_ratio = output_size / input_size if input_size > 0 else 0
  1087. logger.info(f"File sizes - Input: {input_size // (1024*1024):.1f}MB, Output: {output_size // (1024*1024):.1f}MB (ratio: {size_ratio:.2f})")
  1088. # Only replace original if output seems reasonable
  1089. if output_size > input_size * 0.1:
  1090. # Move temporary file to final target location
  1091. os.rename(temp_output, target_file)
  1092. # Remove the original incompatible file
  1093. os.remove(input_file)
  1094. logger.info(f"✅ Conversion successful: {os.path.basename(input_file)} → {os.path.basename(target_file)}")
  1095. else:
  1096. logger.warning(f"Output file seems too small, keeping original: {os.path.basename(input_file)}")
  1097. if os.path.exists(temp_output):
  1098. os.remove(temp_output)
  1099. # Update completion stats
  1100. conversion_stats['active_conversions'] -= 1
  1101. conversion_stats['completed_conversions'] += 1
  1102. # Update Home Assistant
  1103. update_homeassistant_stats(
  1104. conversion_queue=conversion_queue.qsize(),
  1105. active_conversions=conversion_stats['active_conversions']
  1106. )
  1107. return True
  1108. except subprocess.CalledProcessError as e:
  1109. conversion_stats['active_conversions'] -= 1
  1110. logger.error(f"Conversion failed for {os.path.basename(input_file)}: {e}")
  1111. if e.stderr:
  1112. logger.debug(f"FFmpeg error output: {e.stderr}")
  1113. # Clean up temporary file if it exists
  1114. if os.path.exists(temp_output):
  1115. os.remove(temp_output)
  1116. logger.debug(f"Cleaned up temporary file: {temp_output}")
  1117. # Update Home Assistant
  1118. update_homeassistant_stats(
  1119. conversion_queue=conversion_queue.qsize(),
  1120. active_conversions=conversion_stats['active_conversions']
  1121. )
  1122. return False
  1123. except Exception as e:
  1124. conversion_stats['active_conversions'] -= 1
  1125. logger.error(f"Unexpected error during conversion of {os.path.basename(input_file)}: {e}")
  1126. # Clean up temporary file if it exists
  1127. if os.path.exists(temp_output):
  1128. os.remove(temp_output)
  1129. logger.debug(f"Cleaned up temporary file: {temp_output}")
  1130. # Update Home Assistant
  1131. update_homeassistant_stats(
  1132. conversion_queue=conversion_queue.qsize(),
  1133. active_conversions=conversion_stats['active_conversions']
  1134. )
  1135. return False
  1136. # Temporarily commented out video conversion functionality
  1137. # def convert_to_mp4(input_file):
  1138. # """Convert a video to mp4 and delete the original file."""
  1139. # mp4_file = os.path.splitext(input_file)[0] + ".mp4"
  1140. # print(f"Converting {input_file} to {mp4_file}...")
  1141. # command = ["ffmpeg", "-i", input_file, "-c:v", "libx264", "-preset", "fast", "-c:a", "aac", mp4_file]
  1142. # subprocess.run(command, check=True)
  1143. # print(f"Conversion completed: {mp4_file}")
  1144. # if os.path.exists(mp4_file):
  1145. # os.remove(input_file)
  1146. # print(f"Original file deleted: {input_file}")
  1147. def create_nfo_file(video_data, file_path):
  1148. """Create an Emby-compatible NFO file."""
  1149. logger = logging.getLogger(__name__)
  1150. try:
  1151. nfo_content = f"""<?xml version="1.0" encoding="utf-8" standalone="yes"?>
  1152. <movie>
  1153. <title>{video_data['title']}</title>
  1154. <plot>{video_data['description']}</plot>
  1155. <year>{video_data['published_at'][:4]}</year>
  1156. <premiered>{video_data['published_at']}</premiered>
  1157. <studio>{video_data['channel_title']}</studio>
  1158. </movie>
  1159. """
  1160. with open(file_path, "w", encoding="utf-8") as nfo_file:
  1161. nfo_file.write(nfo_content)
  1162. logger.debug(f"Created NFO file: {file_path}")
  1163. except Exception as e:
  1164. logger.error(f"Failed to create NFO file {file_path}: {e}")
  1165. raise
  1166. def check_exit_time():
  1167. """Check if the current time is past the exit time."""
  1168. logger = logging.getLogger(__name__)
  1169. now = datetime.now().time()
  1170. if now >= EXIT_TIME:
  1171. logger.info(f"Current time ({now.strftime('%H:%M:%S')}) exceeds exit time ({EXIT_TIME.strftime('%H:%M:%S')}). Terminating script.")
  1172. sys.exit(0)
  1173. def delete_old_videos(folder, days=5):
  1174. """Delete video files and their folders older than `days`."""
  1175. logger = logging.getLogger(__name__)
  1176. logger.info(f"Starting cleanup of videos older than {days} days in {folder}")
  1177. if not os.path.exists(folder):
  1178. logger.warning(f"Output folder does not exist: {folder}")
  1179. return
  1180. now = time.time()
  1181. cutoff = now - (days * 86400) # Convert days to seconds
  1182. deleted_files = 0
  1183. deleted_dirs = 0
  1184. total_size_freed = 0
  1185. try:
  1186. for root, dirs, files in os.walk(folder, topdown=False):
  1187. # Delete files
  1188. for file in files:
  1189. file_path = os.path.join(root, file)
  1190. try:
  1191. if os.path.isfile(file_path) and os.path.getmtime(file_path) < cutoff:
  1192. file_size = os.path.getsize(file_path)
  1193. os.remove(file_path)
  1194. deleted_files += 1
  1195. total_size_freed += file_size
  1196. logger.debug(f"Deleted file: {file_path} ({file_size // (1024*1024):.1f}MB)")
  1197. except (OSError, IOError) as e:
  1198. logger.error(f"Failed to delete file {file_path}: {e}")
  1199. # Remove empty directories
  1200. for dir_name in dirs:
  1201. dir_path = os.path.join(root, dir_name)
  1202. try:
  1203. if os.path.exists(dir_path) and not os.listdir(dir_path): # Check if the directory is empty
  1204. os.rmdir(dir_path)
  1205. deleted_dirs += 1
  1206. logger.debug(f"Deleted empty directory: {dir_path}")
  1207. except (OSError, IOError) as e:
  1208. logger.error(f"Failed to delete directory {dir_path}: {e}")
  1209. if deleted_files > 0 or deleted_dirs > 0:
  1210. size_mb = total_size_freed / (1024 * 1024)
  1211. logger.info(f"Cleanup completed: {deleted_files} files, {deleted_dirs} directories deleted, {size_mb:.1f}MB freed")
  1212. else:
  1213. logger.info("No old files found to delete")
  1214. except Exception as e:
  1215. logger.error(f"Error during cleanup: {e}")
  1216. def video_exists(output_path, video_id):
  1217. """Check if a video already exists in the directory."""
  1218. return os.path.exists(os.path.join(output_path, video_id))
  1219. def check_exit_time():
  1220. """Check if the current time is past the exit time."""
  1221. now = datetime.now().time()
  1222. if now >= EXIT_TIME:
  1223. print("Current time exceeds exit time. Terminating script.")
  1224. sys.exit(0)
  1225. def main():
  1226. global download_stats
  1227. logger = logging.getLogger(__name__)
  1228. logger.info("Starting main download process")
  1229. # Initialize Home Assistant integration
  1230. if HOMEASSISTANT_ENABLED:
  1231. logger.info("Initializing Home Assistant integration...")
  1232. ha_integration = init_homeassistant_integration()
  1233. if ha_integration:
  1234. logger.info("Home Assistant integration started successfully")
  1235. else:
  1236. logger.warning("Home Assistant integration failed to start")
  1237. # Start the conversion queue worker
  1238. logger.info("Starting video conversion queue system...")
  1239. logger.info(f"Max concurrent conversions: {MAX_CONCURRENT_CONVERSIONS}")
  1240. # Start background conversion worker
  1241. conversion_worker = threading.Thread(target=process_conversion_queue, daemon=True, name="ConversionQueueWorker")
  1242. conversion_worker.start()
  1243. logger.debug("Conversion queue worker thread started")
  1244. # Update yt-dlp
  1245. try:
  1246. update_yt_dlp()
  1247. except Exception as e:
  1248. logger.error(f"Failed to update yt-dlp: {e}")
  1249. # Continue anyway, existing version might work
  1250. # Authenticate YouTube API
  1251. logger.info("Authenticating with YouTube API...")
  1252. try:
  1253. youtube = authenticate_youtube_api()
  1254. logger.info("YouTube API authentication successful")
  1255. except Exception as e:
  1256. logger.error(f"YouTube API authentication failed: {e}")
  1257. raise
  1258. # Ensure output directory exists
  1259. if not os.path.exists(OUTPUT_DIR):
  1260. logger.info(f"Creating output directory: {OUTPUT_DIR}")
  1261. os.makedirs(OUTPUT_DIR)
  1262. else:
  1263. logger.debug(f"Output directory exists: {OUTPUT_DIR}")
  1264. # Clean up old videos
  1265. logger.info(f"Cleaning up videos older than {DAYS_TO_KEEP} days...")
  1266. delete_old_videos(OUTPUT_DIR, days=DAYS_TO_KEEP)
  1267. # Fetch subscribed channels
  1268. logger.info("Fetching subscribed channels...")
  1269. try:
  1270. channels = get_subscribed_channels(youtube)
  1271. logger.info(f"Successfully fetched {len(channels)} subscribed channels")
  1272. except Exception as e:
  1273. logger.error(f"Failed to fetch subscribed channels: {e}")
  1274. channels = []
  1275. # Count total videos for progress tracking
  1276. logger.info("Counting total videos to download...")
  1277. total_videos = 0
  1278. all_videos = []
  1279. # Collect all videos first for counting
  1280. for i, channel_id in enumerate(channels):
  1281. try:
  1282. logger.debug(f"Processing channel {i+1}/{len(channels)}: {channel_id}")
  1283. videos = get_recent_videos(youtube, channel_id)
  1284. for video in videos:
  1285. video_dir = os.path.join(OUTPUT_DIR, video["video_id"])
  1286. if not os.path.exists(video_dir):
  1287. all_videos.append(('subscription', video))
  1288. total_videos += 1
  1289. else:
  1290. logger.debug(f"Video already exists, skipping: {video['title']}")
  1291. except Exception as e:
  1292. logger.error(f"Error processing channel {channel_id}: {e}")
  1293. # Add popular videos
  1294. for region_code in REGION_CODES:
  1295. try:
  1296. logger.debug(f"Fetching popular videos from region: {region_code}")
  1297. popular_videos = get_popular_videos(youtube, region_code)
  1298. for video in popular_videos:
  1299. video_dir = os.path.join(OUTPUT_DIR, video["video_id"])
  1300. if not os.path.exists(video_dir):
  1301. all_videos.append(('popular', video))
  1302. total_videos += 1
  1303. else:
  1304. logger.debug(f"Popular video already exists, skipping: {video['title']}")
  1305. except Exception as e:
  1306. logger.error(f"Error fetching popular videos from region {region_code}: {e}")
  1307. # Initialize download stats
  1308. download_stats['total_videos'] = total_videos
  1309. download_stats['completed_videos'] = 0
  1310. logger.info(f"Total videos to download: {total_videos}")
  1311. if total_videos == 0:
  1312. logger.info("No new videos found to download")
  1313. return
  1314. # Update initial Home Assistant stats
  1315. update_homeassistant_stats(
  1316. download_queue=total_videos,
  1317. conversion_queue=0,
  1318. download_progress=0,
  1319. active_downloads=0,
  1320. active_conversions=0
  1321. )
  1322. # Process all videos
  1323. for i, (video_type, video) in enumerate(all_videos):
  1324. # Check the exit time before processing each video
  1325. check_exit_time()
  1326. logger.info(f"Processing video {i+1}/{total_videos} ({video_type}): {video['title']}")
  1327. video_dir = os.path.join(OUTPUT_DIR, video["video_id"])
  1328. try:
  1329. # Create directory
  1330. os.makedirs(video_dir, exist_ok=True)
  1331. logger.debug(f"Created/verified directory: {video_dir}")
  1332. # Download the video
  1333. if not download_video(video["video_id"], video_dir, video["title"]):
  1334. logger.warning(f"Skipping video {video['video_id']} due to download error")
  1335. continue # Skip this video if download failed
  1336. # Create the NFO file
  1337. nfo_file_path = os.path.join(video_dir, "movie.nfo")
  1338. try:
  1339. create_nfo_file(video, nfo_file_path)
  1340. logger.debug(f"Created NFO file: {nfo_file_path}")
  1341. except Exception as e:
  1342. logger.error(f"Failed to create NFO file: {e}")
  1343. # Update completed videos count
  1344. download_stats['completed_videos'] += 1
  1345. progress = (download_stats['completed_videos'] / download_stats['total_videos']) * 100
  1346. # Update Home Assistant stats
  1347. update_homeassistant_stats(
  1348. download_queue=max(0, download_stats['total_videos'] - download_stats['completed_videos']),
  1349. download_progress=round(progress, 1)
  1350. )
  1351. logger.debug(f"Progress: {download_stats['completed_videos']}/{download_stats['total_videos']} ({progress:.1f}%)")
  1352. except Exception as e:
  1353. logger.error(f"Error processing video {video['title']}: {e}")
  1354. continue
  1355. logger.info("All videos processed!")
  1356. logger.info(f"Final stats: {download_stats['completed_videos']}/{download_stats['total_videos']} videos completed")
  1357. # Wait for all conversions to complete
  1358. logger.info("Waiting for all conversions to complete...")
  1359. conversion_executor.shutdown(wait=True)
  1360. logger.info("All conversions completed!")
  1361. # Final Home Assistant update
  1362. update_homeassistant_stats(
  1363. download_queue=0,
  1364. conversion_queue=0,
  1365. download_progress=100,
  1366. active_downloads=0,
  1367. active_conversions=0
  1368. )
  1369. logger.info("Main download process completed successfully")
  1370. logger.info("=" * 80)
  1371. def schedule_downloads():
  1372. """Schedule downloads to run at specified times"""
  1373. logger = logging.getLogger(__name__)
  1374. # Configuration for scheduling
  1375. START_TIME = dt_time(0, 0) # 00:00
  1376. END_TIME = dt_time(7, 0) # 07:00
  1377. logger.info("Starting YouTube Predownloader Service with scheduling")
  1378. logger.info(f"Service will run between {START_TIME.strftime('%H:%M')} and {END_TIME.strftime('%H:%M')} daily")
  1379. while True:
  1380. current_time = datetime.now().time()
  1381. # Check if we're in the allowed time window
  1382. if START_TIME <= current_time <= END_TIME:
  1383. try:
  1384. set_service_running()
  1385. logger.info(f"Starting download session at {datetime.now().strftime('%H:%M:%S')}")
  1386. # Run the main download process
  1387. main()
  1388. set_service_completed()
  1389. logger.info(f"Download session completed at {datetime.now().strftime('%H:%M:%S')}")
  1390. # Calculate next run time (next day at START_TIME)
  1391. next_run = datetime.now().replace(hour=START_TIME.hour, minute=START_TIME.minute, second=0, microsecond=0)
  1392. if current_time > START_TIME:
  1393. next_run = next_run.replace(day=next_run.day + 1)
  1394. set_service_waiting(next_run.isoformat())
  1395. # Sleep until next day
  1396. sleep_seconds = (next_run - datetime.now()).total_seconds()
  1397. logger.info(f"Sleeping until next run at {next_run.strftime('%Y-%m-%d %H:%M:%S')} ({sleep_seconds:.0f} seconds)")
  1398. time.sleep(sleep_seconds)
  1399. except Exception as e:
  1400. logger.error(f"Error during download session: {e}", exc_info=True)
  1401. set_service_error()
  1402. # Sleep for 1 hour before retrying
  1403. logger.info("Sleeping for 1 hour before retrying")
  1404. time.sleep(3600)
  1405. else:
  1406. # We're outside the allowed time window
  1407. if current_time < START_TIME:
  1408. # Before start time today
  1409. next_run = datetime.now().replace(hour=START_TIME.hour, minute=START_TIME.minute, second=0, microsecond=0)
  1410. else:
  1411. # After end time today, schedule for tomorrow
  1412. next_run = datetime.now().replace(hour=START_TIME.hour, minute=START_TIME.minute, second=0, microsecond=0)
  1413. next_run = next_run.replace(day=next_run.day + 1)
  1414. set_service_waiting(next_run.isoformat())
  1415. # Sleep until start time
  1416. sleep_seconds = (next_run - datetime.now()).total_seconds()
  1417. logger.info(f"Waiting until {next_run.strftime('%Y-%m-%d %H:%M:%S')} to start downloads ({sleep_seconds:.0f} seconds)")
  1418. time.sleep(min(sleep_seconds, 3600)) # Check every hour maximum
  1419. # Fetch and process most popular videos worldwide (from multiple regions)
  1420. def fetch_popular_videos_old():
  1421. """Old function kept for reference - this logic is now integrated into main()"""
  1422. for region_code in REGION_CODES:
  1423. print(f"Fetching the most popular videos from {region_code}...")
  1424. popular_videos = get_popular_videos(youtube, region_code)
  1425. for video in popular_videos:
  1426. # Check the exit time before processing each video
  1427. check_exit_time()
  1428. print(f"Processing popular video: {video['title']} ({region_code})")
  1429. video_dir = os.path.join(OUTPUT_DIR, video["video_id"])
  1430. # Check if the video already exists
  1431. if os.path.exists(video_dir):
  1432. print(f"Skipping download, already exists: {video['title']} ({video['video_id']})")
  1433. continue
  1434. os.makedirs(video_dir, exist_ok=True)
  1435. # Download the popular video
  1436. if not download_video(video["video_id"], video_dir):
  1437. print(f"Skipping popular video {video['video_id']} due to download error.")
  1438. continue # Skip this video if download failed
  1439. # Check if conversion is needed (VP9/AV1 codecs or non-MP4 files)
  1440. needs_conversion = False
  1441. conversion_file = None
  1442. for file in os.listdir(video_dir):
  1443. if file.endswith(('.mp4', '.mkv', '.webm', '.m4v')):
  1444. file_path = os.path.join(video_dir, file)
  1445. try:
  1446. # Quick check for problematic codecs
  1447. probe_cmd = [FFPROBE_PATH, "-v", "quiet", "-select_streams", "v:0",
  1448. "-show_entries", "stream=codec_name", "-of", "csv=p=0", file_path]
  1449. result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  1450. codec = result.stdout.strip().lower()
  1451. if codec in ['vp9', 'av01', 'av1'] or not file.endswith('.mp4'):
  1452. needs_conversion = True
  1453. conversion_file = file_path
  1454. print(f"Found {codec} codec - will convert for Apple TV compatibility")
  1455. break
  1456. except subprocess.CalledProcessError:
  1457. # If we can't probe, assume it might need conversion if not MP4
  1458. if not file.endswith('.mp4'):
  1459. needs_conversion = True
  1460. conversion_file = file_path
  1461. break
  1462. if needs_conversion and conversion_file:
  1463. # Determine priority based on video resolution
  1464. try:
  1465. probe_cmd = [FFPROBE_PATH, "-v", "quiet", "-select_streams", "v:0",
  1466. "-show_entries", "stream=height", "-of", "csv=p=0", conversion_file]
  1467. result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  1468. height = int(result.stdout.strip()) if result.stdout.strip().isdigit() else 1080
  1469. priority = 2 if height >= 2160 else 1 # 4K gets higher priority
  1470. except:
  1471. priority = 1 # Default priority
  1472. add_to_conversion_queue(conversion_file, priority)
  1473. print_queue_status()
  1474. # Create the NFO file
  1475. nfo_file_path = os.path.join(video_dir, "movie.nfo")
  1476. create_nfo_file(video, nfo_file_path)
  1477. print("All videos processed successfully!")
  1478. # Final conversion queue status
  1479. print("\n🎬 Final Conversion Queue Status:")
  1480. print_queue_status()
  1481. # Final Home Assistant update
  1482. update_homeassistant_stats(
  1483. download_queue=0,
  1484. conversion_queue=conversion_queue.qsize(),
  1485. download_progress=100,
  1486. active_downloads=0,
  1487. active_conversions=conversion_stats['active_conversions']
  1488. )
  1489. queued_items = conversion_queue.qsize()
  1490. if queued_items > 0:
  1491. print(f"⏳ {queued_items} videos still in conversion queue")
  1492. print("💡 Conversions will continue in the background")
  1493. print("💡 You can monitor progress in Home Assistant")
  1494. else:
  1495. print("✅ All conversions completed!")
  1496. print("🏠 Home Assistant integration will continue running in background")
  1497. if __name__ == "__main__":
  1498. import sys
  1499. # Check for debug mode
  1500. debug_mode = "--debug" in sys.argv
  1501. if debug_mode:
  1502. sys.argv.remove("--debug")
  1503. # Set debug logging
  1504. LOG_LEVEL = logging.DEBUG
  1505. logger = setup_logging()
  1506. logger.info("Debug mode enabled - verbose logging activated")
  1507. logger = logging.getLogger(__name__)
  1508. # Check if we should run as a service with scheduling
  1509. if len(sys.argv) > 1 and sys.argv[1] == "--service":
  1510. logger.info("Starting YouTube Predownloader Service with scheduling")
  1511. try:
  1512. schedule_downloads()
  1513. except KeyboardInterrupt:
  1514. logger.info("Service interrupted by user")
  1515. except Exception as e:
  1516. logger.error(f"Service error: {e}", exc_info=True)
  1517. else:
  1518. logger.info("Running YouTube Predownloader once")
  1519. try:
  1520. main()
  1521. except KeyboardInterrupt:
  1522. logger.info("Process interrupted by user")
  1523. except Exception as e:
  1524. logger.error(f"Process error: {e}", exc_info=True)
  1525. finally:
  1526. logger.info("Process finished")