main.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776
  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. from concurrent.futures import ThreadPoolExecutor
  9. from googleapiclient.discovery import build
  10. import subprocess
  11. from google.auth.transport.requests import Request
  12. import sys
  13. from auth import get_token, get_token_non_interactive, get_service_account_token
  14. # Home Assistant Integration
  15. try:
  16. from homeassistant_integration import (
  17. init_homeassistant_integration,
  18. update_homeassistant_stats,
  19. stop_homeassistant_integration,
  20. set_service_waiting,
  21. set_service_running,
  22. set_service_completed,
  23. set_service_error
  24. )
  25. HOMEASSISTANT_ENABLED = True
  26. print("🏠 Home Assistant integration available")
  27. except ImportError:
  28. HOMEASSISTANT_ENABLED = False
  29. print("⚠️ Home Assistant integration not available (install paho-mqtt and psutil)")
  30. # Dummy functions if Home Assistant is not available
  31. def set_service_waiting(next_run): pass
  32. def set_service_running(): pass
  33. def set_service_completed(): pass
  34. def set_service_error(): pass
  35. # Dummy functions if not available
  36. def init_homeassistant_integration(*args, **kwargs): return None
  37. def update_homeassistant_stats(*args, **kwargs): pass
  38. def stop_homeassistant_integration(*args, **kwargs): pass
  39. # Constants
  40. OUTPUT_DIR = "/media/media/YouTube" # Change this to your Emby folder
  41. DAYS_BACK = 5 # Videos published in the last 5 days
  42. DAYS_TO_KEEP = 10 # Delete videos older than 10 days
  43. TOKEN_FILE = "youtube_token.pickle" # Token file from the OAuth script
  44. EXIT_TIME = dt_time(6, 0) # Exit the script at 06:00
  45. POPULAR_VIDEOS_MAX_RESULTS = 10 # Number of popular videos to fetch (you can change this)
  46. REGION_CODES = ["US", "GB", "LU", "LK"] # Regions to fetch popular videos from
  47. # Conversion Queue Settings
  48. MAX_CONCURRENT_CONVERSIONS = 2 # Maximum number of simultaneous video conversions
  49. CONVERSION_QUEUE_SIZE = 50 # Maximum items in conversion queue
  50. # FFmpeg/FFprobe paths
  51. FFPROBE_PATH = "/usr/bin/ffprobe"
  52. FFMPEG_PATH = "/usr/bin/ffmpeg"
  53. # Global conversion queue and executor
  54. conversion_queue = queue.Queue(maxsize=CONVERSION_QUEUE_SIZE)
  55. conversion_executor = ThreadPoolExecutor(max_workers=MAX_CONCURRENT_CONVERSIONS, thread_name_prefix="VideoConverter")
  56. # Progress tracking
  57. download_stats = {
  58. 'total_videos': 0,
  59. 'completed_videos': 0,
  60. 'current_video': '',
  61. 'download_speed': 0,
  62. 'active_downloads': 0
  63. }
  64. conversion_stats = {
  65. 'active_conversions': 0,
  66. 'completed_conversions': 0,
  67. 'current_conversion': '',
  68. 'conversion_progress': 0
  69. }
  70. def update_yt_dlp():
  71. """Update yt-dlp to the latest version."""
  72. print("Updating yt-dlp to the latest version...")
  73. subprocess.run(["pip", "install", "--upgrade", "yt-dlp"], check=True)
  74. print("yt-dlp updated successfully!")
  75. def authenticate_youtube_api():
  76. """Authenticate using our enhanced auth system (tries non-interactive first)."""
  77. print("Attempting non-interactive authentication...")
  78. # Try non-interactive authentication first
  79. creds = get_token_non_interactive()
  80. if not creds:
  81. print("Non-interactive authentication failed.")
  82. print("You need to run: python3 auth.py --interactive")
  83. print("Or set up service account authentication.")
  84. raise Exception("Authentication failed. No valid credentials available.")
  85. print("Authentication successful!")
  86. # Build the YouTube API client
  87. youtube = build("youtube", "v3", credentials=creds)
  88. return youtube
  89. def authenticate_with_token():
  90. """Legacy function - kept for compatibility but redirects to new auth system."""
  91. return authenticate_youtube_api()
  92. def get_subscribed_channels(youtube):
  93. """Fetch the list of subscribed channels."""
  94. channels = []
  95. request = youtube.subscriptions().list(
  96. part="snippet",
  97. mine=True,
  98. maxResults=50
  99. )
  100. while request:
  101. response = request.execute()
  102. for item in response["items"]:
  103. channels.append(item["snippet"]["resourceId"]["channelId"])
  104. request = youtube.subscriptions().list_next(request, response)
  105. return channels
  106. def get_recent_videos(youtube, channel_id):
  107. """Fetch videos from a channel published in the last `DAYS_BACK` days."""
  108. recent_videos = []
  109. published_after = (datetime.datetime.now() - datetime.timedelta(days=DAYS_BACK)).isoformat("T") + "Z"
  110. request = youtube.search().list(
  111. part="snippet",
  112. channelId=channel_id,
  113. publishedAfter=published_after,
  114. maxResults=50,
  115. type="video",
  116. order="date"
  117. )
  118. response = request.execute()
  119. for item in response["items"]:
  120. recent_videos.append({
  121. "video_id": item["id"]["videoId"],
  122. "title": item["snippet"]["title"],
  123. "description": item["snippet"]["description"],
  124. "published_at": item["snippet"]["publishedAt"],
  125. "channel_title": item["snippet"]["channelTitle"]
  126. })
  127. return recent_videos
  128. def get_popular_videos(youtube, region_code):
  129. """Fetch the most popular videos for a given region."""
  130. popular_videos = []
  131. request = youtube.videos().list(
  132. part="snippet,statistics",
  133. chart="mostPopular",
  134. regionCode=region_code, # Region code passed here
  135. maxResults=POPULAR_VIDEOS_MAX_RESULTS
  136. )
  137. response = request.execute()
  138. for item in response["items"]:
  139. popular_videos.append({
  140. "video_id": item["id"],
  141. "title": item["snippet"]["title"],
  142. "description": item["snippet"]["description"],
  143. "published_at": item["snippet"]["publishedAt"],
  144. "channel_title": item["snippet"]["channelTitle"]
  145. })
  146. return popular_videos
  147. def download_video(video_id, output_path, video_title="Unknown"):
  148. """Download a YouTube video with quality priority - downloads 4K VP9/AV1 if needed for conversion."""
  149. global download_stats
  150. try:
  151. # Update stats
  152. download_stats['active_downloads'] += 1
  153. download_stats['current_video'] = video_title[:50] + "..." if len(video_title) > 50 else video_title
  154. # Update Home Assistant
  155. update_homeassistant_stats(
  156. download_queue=download_stats['total_videos'] - download_stats['completed_videos'],
  157. conversion_queue=conversion_queue.qsize(),
  158. download_progress=round((download_stats['completed_videos'] / max(download_stats['total_videos'], 1)) * 100, 1),
  159. active_downloads=download_stats['active_downloads'],
  160. active_conversions=conversion_stats['active_conversions']
  161. )
  162. # Quality-priority format selection:
  163. command = [
  164. "yt-dlp",
  165. "-f", "(bv[vcodec~='^(avc|h264|hevc|h265)'] / bv[height>=2160] / bv[height>=1080] / best) + (ba[acodec~='^(aac|mp4a)'] / ba)",
  166. "--merge-output-format", "mp4",
  167. "--no-mtime",
  168. "--newline", # Better for parsing progress
  169. f"https://www.youtube.com/watch?v={video_id}",
  170. "-o", os.path.join(output_path, "%(title)s.%(ext)s")
  171. ]
  172. result = subprocess.run(command, check=True, capture_output=True, text=True)
  173. # Update completed count
  174. download_stats['completed_videos'] += 1
  175. download_stats['active_downloads'] -= 1
  176. print(f"Successfully downloaded video {video_id} (Quality priority - will convert VP9/AV1 if needed)")
  177. # Update Home Assistant with completion
  178. update_homeassistant_stats(
  179. download_queue=download_stats['total_videos'] - download_stats['completed_videos'],
  180. download_progress=round((download_stats['completed_videos'] / max(download_stats['total_videos'], 1)) * 100, 1),
  181. active_downloads=download_stats['active_downloads']
  182. )
  183. return True
  184. except subprocess.CalledProcessError as e:
  185. download_stats['active_downloads'] -= 1
  186. print(f"Error downloading video {video_id}: {e}")
  187. return False
  188. def add_to_conversion_queue(input_file, priority=1):
  189. """Add a video file to the conversion queue with priority."""
  190. try:
  191. # Higher number = higher priority (4K gets priority 2, others get 1)
  192. conversion_queue.put((priority, input_file), timeout=1)
  193. print(f"Added to conversion queue: {os.path.basename(input_file)} (priority: {priority})")
  194. return True
  195. except queue.Full:
  196. print(f"⚠️ Conversion queue is full! Skipping: {os.path.basename(input_file)}")
  197. return False
  198. def get_queue_status():
  199. """Get current conversion queue status."""
  200. queue_size = conversion_queue.qsize()
  201. active_conversions = conversion_executor._threads # Active thread count
  202. return {
  203. 'queued': queue_size,
  204. 'active': len(active_conversions) if active_conversions else 0,
  205. 'max_concurrent': MAX_CONCURRENT_CONVERSIONS
  206. }
  207. def print_queue_status():
  208. """Print current queue status."""
  209. status = get_queue_status()
  210. print(f"🎬 Conversion Status: {status['active']}/{status['max_concurrent']} active, {status['queued']} queued")
  211. def process_conversion_queue():
  212. """Background worker to process the conversion queue."""
  213. while True:
  214. try:
  215. # Get item from queue (blocks until available)
  216. priority, input_file = conversion_queue.get(timeout=30)
  217. # Check if file still exists
  218. if not os.path.exists(input_file):
  219. print(f"⚠️ File no longer exists, skipping conversion: {input_file}")
  220. conversion_queue.task_done()
  221. continue
  222. print(f"🔄 Starting conversion (priority {priority}): {os.path.basename(input_file)}")
  223. # Perform the conversion
  224. success = convert_to_apple_tv_compatible(input_file)
  225. if success:
  226. print(f"✅ Conversion completed: {os.path.basename(input_file)}")
  227. else:
  228. print(f"❌ Conversion failed: {os.path.basename(input_file)}")
  229. # Mark task as done
  230. conversion_queue.task_done()
  231. except queue.Empty:
  232. # No items in queue for 30 seconds, continue waiting
  233. continue
  234. except Exception as e:
  235. print(f"❌ Conversion queue error: {e}")
  236. conversion_queue.task_done()
  237. def convert_to_apple_tv_compatible(input_file):
  238. """Convert a video to Apple TV 4K compatible format, optimized by resolution."""
  239. global conversion_stats
  240. base_name = os.path.splitext(input_file)[0]
  241. mp4_file = base_name + ".mp4"
  242. # Update conversion stats
  243. conversion_stats['active_conversions'] += 1
  244. conversion_stats['current_conversion'] = os.path.basename(input_file)
  245. print(f"Converting {input_file} to Apple TV compatible format...")
  246. # Update Home Assistant
  247. update_homeassistant_stats(
  248. conversion_queue=conversion_queue.qsize(),
  249. active_conversions=conversion_stats['active_conversions']
  250. )
  251. # Detect video properties to optimize conversion
  252. try:
  253. import json
  254. probe_cmd = [
  255. FFPROBE_PATH, "-v", "quiet", "-print_format", "json",
  256. "-show_format", "-show_streams", input_file
  257. ]
  258. probe_result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  259. video_info = json.loads(probe_result.stdout)
  260. # Find video stream
  261. video_stream = next((s for s in video_info['streams'] if s['codec_type'] == 'video'), None)
  262. is_4k = video_stream and int(video_stream.get('height', 0)) >= 2160
  263. is_high_res = video_stream and int(video_stream.get('height', 0)) >= 1080
  264. print(f"Video resolution: {video_stream.get('height', 'unknown')}p")
  265. except (subprocess.CalledProcessError, json.JSONDecodeError, Exception) as e:
  266. print(f"Could not probe video properties: {e}, using default settings")
  267. is_4k = False
  268. is_high_res = True
  269. # Apple TV 4K optimized encoding based on source quality:
  270. if is_4k:
  271. # 4K content: Use H.265 for better compression at high resolution
  272. codec_args = [
  273. "-c:v", "libx265", # H.265 for 4K (better compression)
  274. "-preset", "medium", # Better compression for 4K
  275. "-crf", "20", # Higher quality for 4K content
  276. "-level", "5.1", # Level 5.1 supports 4K
  277. "-pix_fmt", "yuv420p", # Ensure compatibility
  278. ]
  279. print("Using H.265 encoding for 4K content")
  280. elif is_high_res:
  281. # 1080p content: Use H.264 for maximum compatibility
  282. codec_args = [
  283. "-c:v", "libx264", # H.264 for 1080p (max compatibility)
  284. "-profile:v", "high", # High profile for better quality
  285. "-preset", "fast", # Faster encoding for 1080p
  286. "-crf", "21", # Good quality for 1080p
  287. "-level", "4.1", # Level 4.1 supports 1080p at high bitrates
  288. ]
  289. print("Using H.264 encoding for 1080p content")
  290. else:
  291. # Lower resolution: Use H.264 with faster settings
  292. codec_args = [
  293. "-c:v", "libx264", # H.264 for lower res
  294. "-profile:v", "main", # Main profile for lower res
  295. "-preset", "fast", # Fast encoding
  296. "-crf", "23", # Standard quality
  297. "-level", "3.1", # Level 3.1 for lower res
  298. ]
  299. print("Using H.264 encoding for lower resolution content")
  300. command = [
  301. FFMPEG_PATH, "-i", input_file,
  302. *codec_args,
  303. "-c:a", "aac", # AAC audio codec
  304. "-b:a", "192k", # Higher audio bitrate for better quality
  305. "-movflags", "+faststart", # Optimize for streaming/quick playback
  306. "-y", # Overwrite output file if exists
  307. mp4_file
  308. ]
  309. try:
  310. print("Starting conversion... This may take a while for 4K content.")
  311. subprocess.run(command, check=True)
  312. print(f"Conversion completed: {mp4_file}")
  313. # Update completion stats
  314. conversion_stats['active_conversions'] -= 1
  315. conversion_stats['completed_conversions'] += 1
  316. # Update Home Assistant
  317. update_homeassistant_stats(
  318. conversion_queue=conversion_queue.qsize(),
  319. active_conversions=conversion_stats['active_conversions']
  320. )
  321. if os.path.exists(mp4_file):
  322. os.remove(input_file)
  323. print(f"Original file deleted: {input_file}")
  324. return True
  325. except subprocess.CalledProcessError as e:
  326. conversion_stats['active_conversions'] -= 1
  327. print(f"Conversion failed for {input_file}: {e}")
  328. # Update Home Assistant
  329. update_homeassistant_stats(
  330. conversion_queue=conversion_queue.qsize(),
  331. active_conversions=conversion_stats['active_conversions']
  332. )
  333. return False
  334. # Temporarily commented out video conversion functionality
  335. # def convert_to_mp4(input_file):
  336. # """Convert a video to mp4 and delete the original file."""
  337. # mp4_file = os.path.splitext(input_file)[0] + ".mp4"
  338. # print(f"Converting {input_file} to {mp4_file}...")
  339. # command = ["ffmpeg", "-i", input_file, "-c:v", "libx264", "-preset", "fast", "-c:a", "aac", mp4_file]
  340. # subprocess.run(command, check=True)
  341. # print(f"Conversion completed: {mp4_file}")
  342. # if os.path.exists(mp4_file):
  343. # os.remove(input_file)
  344. # print(f"Original file deleted: {input_file}")
  345. def create_nfo_file(video_data, file_path):
  346. """Create an Emby-compatible NFO file."""
  347. nfo_content = f"""<?xml version="1.0" encoding="utf-8" standalone="yes"?>
  348. <movie>
  349. <title>{video_data['title']}</title>
  350. <plot>{video_data['description']}</plot>
  351. <year>{video_data['published_at'][:4]}</year>
  352. <premiered>{video_data['published_at']}</premiered>
  353. <studio>{video_data['channel_title']}</studio>
  354. </movie>
  355. """
  356. with open(file_path, "w", encoding="utf-8") as nfo_file:
  357. nfo_file.write(nfo_content)
  358. def delete_old_videos(folder, days=5):
  359. """Delete video files and their folders older than `days`."""
  360. now = time.time()
  361. cutoff = now - (days * 86400) # Convert days to seconds
  362. for root, dirs, files in os.walk(folder, topdown=False):
  363. # Delete files
  364. for file in files:
  365. file_path = os.path.join(root, file)
  366. if os.path.isfile(file_path):
  367. if os.path.getmtime(file_path) < cutoff:
  368. print(f"Deleting file: {file_path}")
  369. os.remove(file_path)
  370. # Remove empty directories
  371. for dir in dirs:
  372. dir_path = os.path.join(root, dir)
  373. if not os.listdir(dir_path): # Check if the directory is empty
  374. print(f"Deleting folder: {dir_path}")
  375. os.rmdir(dir_path)
  376. def video_exists(output_path, video_id):
  377. """Check if a video already exists in the directory."""
  378. return os.path.exists(os.path.join(output_path, video_id))
  379. def check_exit_time():
  380. """Check if the current time is past the exit time."""
  381. now = datetime.now().time()
  382. if now >= EXIT_TIME:
  383. print("Current time exceeds exit time. Terminating script.")
  384. sys.exit(0)
  385. def main():
  386. global download_stats
  387. # Initialize Home Assistant integration
  388. if HOMEASSISTANT_ENABLED:
  389. print("🏠 Initializing Home Assistant integration...")
  390. ha_integration = init_homeassistant_integration()
  391. if ha_integration:
  392. print("✅ Home Assistant integration started")
  393. else:
  394. print("⚠️ Home Assistant integration failed to start")
  395. # Start the conversion queue worker
  396. print("🎬 Starting video conversion queue system...")
  397. print(f"📊 Max concurrent conversions: {MAX_CONCURRENT_CONVERSIONS}")
  398. # Start background conversion worker
  399. conversion_worker = threading.Thread(target=process_conversion_queue, daemon=True, name="ConversionQueueWorker")
  400. conversion_worker.start()
  401. # Update yt-dlp
  402. update_yt_dlp()
  403. # Authenticate YouTube API
  404. print("Authenticating with YouTube API...")
  405. youtube = authenticate_youtube_api()
  406. if not os.path.exists(OUTPUT_DIR):
  407. os.makedirs(OUTPUT_DIR)
  408. # Clean up old videos
  409. print(f"Deleting videos older than {DAYS_TO_KEEP} days in {OUTPUT_DIR}...")
  410. delete_old_videos(OUTPUT_DIR, days=DAYS_TO_KEEP)
  411. # Fetch subscribed channels
  412. print("Fetching subscribed channels...")
  413. channels = get_subscribed_channels(youtube)
  414. # Count total videos for progress tracking
  415. print("📊 Counting total videos to download...")
  416. total_videos = 0
  417. all_videos = []
  418. # Collect all videos first for counting
  419. for channel_id in channels:
  420. videos = get_recent_videos(youtube, channel_id)
  421. for video in videos:
  422. video_dir = os.path.join(OUTPUT_DIR, video["video_id"])
  423. if not os.path.exists(video_dir):
  424. all_videos.append(('subscription', video))
  425. total_videos += 1
  426. # Add popular videos
  427. for region_code in REGION_CODES:
  428. popular_videos = get_popular_videos(youtube, region_code)
  429. for video in popular_videos:
  430. video_dir = os.path.join(OUTPUT_DIR, video["video_id"])
  431. if not os.path.exists(video_dir):
  432. all_videos.append(('popular', video))
  433. total_videos += 1
  434. # Initialize download stats
  435. download_stats['total_videos'] = total_videos
  436. download_stats['completed_videos'] = 0
  437. print(f"📈 Total videos to download: {total_videos}")
  438. # Update initial Home Assistant stats
  439. update_homeassistant_stats(
  440. download_queue=total_videos,
  441. conversion_queue=0,
  442. download_progress=0,
  443. active_downloads=0,
  444. active_conversions=0
  445. )
  446. # Process all videos
  447. for video_type, video in all_videos:
  448. # Check the exit time before processing each video
  449. check_exit_time()
  450. print(f"Processing {video_type} video: {video['title']}")
  451. video_dir = os.path.join(OUTPUT_DIR, video["video_id"])
  452. # Create directory
  453. os.makedirs(video_dir, exist_ok=True)
  454. # Download the video
  455. if not download_video(video["video_id"], video_dir, video["title"]):
  456. print(f"Skipping video {video['video_id']} due to download error.")
  457. continue # Skip this video if download failed
  458. # Check if conversion is needed (VP9/AV1 codecs or non-MP4 files)
  459. needs_conversion = False
  460. conversion_file = None
  461. for file in os.listdir(video_dir):
  462. if file.endswith(('.mp4', '.mkv', '.webm', '.m4v')):
  463. file_path = os.path.join(video_dir, file)
  464. try:
  465. # Quick check for problematic codecs
  466. probe_cmd = [FFPROBE_PATH, "-v", "quiet", "-select_streams", "v:0",
  467. "-show_entries", "stream=codec_name", "-of", "csv=p=0", file_path]
  468. result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  469. codec = result.stdout.strip().lower()
  470. if codec in ['vp9', 'av01', 'av1'] or not file.endswith('.mp4'):
  471. needs_conversion = True
  472. conversion_file = file_path
  473. print(f"Found {codec} codec - will convert for Apple TV compatibility")
  474. break
  475. except subprocess.CalledProcessError:
  476. # If we can't probe, assume it might need conversion if not MP4
  477. if not file.endswith('.mp4'):
  478. needs_conversion = True
  479. conversion_file = file_path
  480. break
  481. if needs_conversion and conversion_file:
  482. # Determine priority based on video resolution
  483. try:
  484. probe_cmd = [FFPROBE_PATH, "-v", "quiet", "-select_streams", "v:0",
  485. "-show_entries", "stream=height", "-of", "csv=p=0", conversion_file]
  486. result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  487. height = int(result.stdout.strip()) if result.stdout.strip().isdigit() else 1080
  488. priority = 2 if height >= 2160 else 1 # 4K gets higher priority
  489. except:
  490. priority = 1 # Default priority
  491. add_to_conversion_queue(conversion_file, priority)
  492. print_queue_status()
  493. # Create the NFO file
  494. nfo_file_path = os.path.join(video_dir, "movie.nfo")
  495. create_nfo_file(video, nfo_file_path)
  496. # Update completed videos count
  497. download_stats['completed_videos'] += 1
  498. progress = (download_stats['completed_videos'] / download_stats['total_videos']) * 100
  499. # Update Home Assistant stats
  500. update_homeassistant_stats(
  501. download_queue=max(0, download_stats['total_videos'] - download_stats['completed_videos']),
  502. download_progress=round(progress, 1)
  503. )
  504. print("✅ All videos processed!")
  505. print(f"📊 Final stats: {download_stats['completed_videos']}/{download_stats['total_videos']} videos completed")
  506. # Wait for all conversions to complete
  507. print("⏳ Waiting for all conversions to complete...")
  508. conversion_executor.shutdown(wait=True)
  509. print("✅ All conversions completed!")
  510. # Final Home Assistant update
  511. update_homeassistant_stats(
  512. download_queue=0,
  513. conversion_queue=0,
  514. download_progress=100,
  515. active_downloads=0,
  516. active_conversions=0
  517. )
  518. def schedule_downloads():
  519. """Schedule downloads to run at specified times"""
  520. # Configuration for scheduling
  521. START_TIME = dt_time(0, 0) # 00:00
  522. END_TIME = dt_time(7, 0) # 07:00
  523. while True:
  524. current_time = datetime.now().time()
  525. # Check if we're in the allowed time window
  526. if START_TIME <= current_time <= END_TIME:
  527. try:
  528. set_service_running()
  529. print(f"🚀 Starting download session at {datetime.now().strftime('%H:%M:%S')}")
  530. # Run the main download process
  531. main()
  532. set_service_completed()
  533. print(f"✅ Download session completed at {datetime.now().strftime('%H:%M:%S')}")
  534. # Calculate next run time (next day at START_TIME)
  535. next_run = datetime.now().replace(hour=START_TIME.hour, minute=START_TIME.minute, second=0, microsecond=0)
  536. if current_time > START_TIME:
  537. next_run = next_run.replace(day=next_run.day + 1)
  538. set_service_waiting(next_run.isoformat())
  539. # Sleep until next day
  540. sleep_seconds = (next_run - datetime.now()).total_seconds()
  541. print(f"😴 Sleeping until next run at {next_run.strftime('%Y-%m-%d %H:%M:%S')}")
  542. time.sleep(sleep_seconds)
  543. except Exception as e:
  544. print(f"❌ Error during download session: {e}")
  545. set_service_error()
  546. # Sleep for 1 hour before retrying
  547. time.sleep(3600)
  548. else:
  549. # We're outside the allowed time window
  550. if current_time < START_TIME:
  551. # Before start time today
  552. next_run = datetime.now().replace(hour=START_TIME.hour, minute=START_TIME.minute, second=0, microsecond=0)
  553. else:
  554. # After end time today, schedule for tomorrow
  555. next_run = datetime.now().replace(hour=START_TIME.hour, minute=START_TIME.minute, second=0, microsecond=0)
  556. next_run = next_run.replace(day=next_run.day + 1)
  557. set_service_waiting(next_run.isoformat())
  558. # Sleep until start time
  559. sleep_seconds = (next_run - datetime.now()).total_seconds()
  560. print(f"⏰ Waiting until {next_run.strftime('%Y-%m-%d %H:%M:%S')} to start downloads")
  561. time.sleep(min(sleep_seconds, 3600)) # Check every hour maximum
  562. # Fetch and process most popular videos worldwide (from multiple regions)
  563. def fetch_popular_videos_old():
  564. """Old function kept for reference - this logic is now integrated into main()"""
  565. for region_code in REGION_CODES:
  566. print(f"Fetching the most popular videos from {region_code}...")
  567. popular_videos = get_popular_videos(youtube, region_code)
  568. for video in popular_videos:
  569. # Check the exit time before processing each video
  570. check_exit_time()
  571. print(f"Processing popular video: {video['title']} ({region_code})")
  572. video_dir = os.path.join(OUTPUT_DIR, video["video_id"])
  573. # Check if the video already exists
  574. if os.path.exists(video_dir):
  575. print(f"Skipping download, already exists: {video['title']} ({video['video_id']})")
  576. continue
  577. os.makedirs(video_dir, exist_ok=True)
  578. # Download the popular video
  579. if not download_video(video["video_id"], video_dir):
  580. print(f"Skipping popular video {video['video_id']} due to download error.")
  581. continue # Skip this video if download failed
  582. # Check if conversion is needed (VP9/AV1 codecs or non-MP4 files)
  583. needs_conversion = False
  584. conversion_file = None
  585. for file in os.listdir(video_dir):
  586. if file.endswith(('.mp4', '.mkv', '.webm', '.m4v')):
  587. file_path = os.path.join(video_dir, file)
  588. try:
  589. # Quick check for problematic codecs
  590. probe_cmd = [FFPROBE_PATH, "-v", "quiet", "-select_streams", "v:0",
  591. "-show_entries", "stream=codec_name", "-of", "csv=p=0", file_path]
  592. result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  593. codec = result.stdout.strip().lower()
  594. if codec in ['vp9', 'av01', 'av1'] or not file.endswith('.mp4'):
  595. needs_conversion = True
  596. conversion_file = file_path
  597. print(f"Found {codec} codec - will convert for Apple TV compatibility")
  598. break
  599. except subprocess.CalledProcessError:
  600. # If we can't probe, assume it might need conversion if not MP4
  601. if not file.endswith('.mp4'):
  602. needs_conversion = True
  603. conversion_file = file_path
  604. break
  605. if needs_conversion and conversion_file:
  606. # Determine priority based on video resolution
  607. try:
  608. probe_cmd = [FFPROBE_PATH, "-v", "quiet", "-select_streams", "v:0",
  609. "-show_entries", "stream=height", "-of", "csv=p=0", conversion_file]
  610. result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  611. height = int(result.stdout.strip()) if result.stdout.strip().isdigit() else 1080
  612. priority = 2 if height >= 2160 else 1 # 4K gets higher priority
  613. except:
  614. priority = 1 # Default priority
  615. add_to_conversion_queue(conversion_file, priority)
  616. print_queue_status()
  617. # Create the NFO file
  618. nfo_file_path = os.path.join(video_dir, "movie.nfo")
  619. create_nfo_file(video, nfo_file_path)
  620. print("All videos processed successfully!")
  621. # Final conversion queue status
  622. print("\n🎬 Final Conversion Queue Status:")
  623. print_queue_status()
  624. # Final Home Assistant update
  625. update_homeassistant_stats(
  626. download_queue=0,
  627. conversion_queue=conversion_queue.qsize(),
  628. download_progress=100,
  629. active_downloads=0,
  630. active_conversions=conversion_stats['active_conversions']
  631. )
  632. queued_items = conversion_queue.qsize()
  633. if queued_items > 0:
  634. print(f"⏳ {queued_items} videos still in conversion queue")
  635. print("💡 Conversions will continue in the background")
  636. print("💡 You can monitor progress in Home Assistant")
  637. else:
  638. print("✅ All conversions completed!")
  639. print("🏠 Home Assistant integration will continue running in background")
  640. if __name__ == "__main__":
  641. import sys
  642. # Check if we should run as a service with scheduling
  643. if len(sys.argv) > 1 and sys.argv[1] == "--service":
  644. print("🔄 Starting YouTube Predownloader Service with scheduling")
  645. schedule_downloads()
  646. else:
  647. print("▶️ Running YouTube Predownloader once")
  648. main()