main.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  1. import os
  2. import time
  3. import datetime
  4. import pickle
  5. import threading
  6. import queue
  7. from concurrent.futures import ThreadPoolExecutor
  8. from googleapiclient.discovery import build
  9. import subprocess
  10. from google.auth.transport.requests import Request
  11. import sys
  12. from auth import get_token, get_token_non_interactive, get_service_account_token
  13. # Constants
  14. OUTPUT_DIR = "/media/media/YouTube" # Change this to your Emby folder
  15. DAYS_BACK = 5 # Videos published in the last 5 days
  16. DAYS_TO_KEEP = 10 # Delete videos older than 10 days
  17. TOKEN_FILE = "youtube_token.pickle" # Token file from the OAuth script
  18. EXIT_TIME = datetime.time(6, 0) # Exit the script at 06:00
  19. POPULAR_VIDEOS_MAX_RESULTS = 10 # Number of popular videos to fetch (you can change this)
  20. REGION_CODES = ["US", "GB", "LU", "LK"] # Regions to fetch popular videos from
  21. # Conversion Queue Settings
  22. MAX_CONCURRENT_CONVERSIONS = 2 # Maximum number of simultaneous video conversions
  23. CONVERSION_QUEUE_SIZE = 50 # Maximum items in conversion queue
  24. # Global conversion queue and executor
  25. conversion_queue = queue.Queue(maxsize=CONVERSION_QUEUE_SIZE)
  26. conversion_executor = ThreadPoolExecutor(max_workers=MAX_CONCURRENT_CONVERSIONS, thread_name_prefix="VideoConverter")
  27. def update_yt_dlp():
  28. """Update yt-dlp to the latest version."""
  29. print("Updating yt-dlp to the latest version...")
  30. subprocess.run(["pip", "install", "--upgrade", "yt-dlp"], check=True)
  31. print("yt-dlp updated successfully!")
  32. def authenticate_youtube_api():
  33. """Authenticate using our enhanced auth system (tries non-interactive first)."""
  34. print("Attempting non-interactive authentication...")
  35. # Try non-interactive authentication first
  36. creds = get_token_non_interactive()
  37. if not creds:
  38. print("Non-interactive authentication failed.")
  39. print("You need to run: python3 auth.py --interactive")
  40. print("Or set up service account authentication.")
  41. raise Exception("Authentication failed. No valid credentials available.")
  42. print("Authentication successful!")
  43. # Build the YouTube API client
  44. youtube = build("youtube", "v3", credentials=creds)
  45. return youtube
  46. def authenticate_with_token():
  47. """Legacy function - kept for compatibility but redirects to new auth system."""
  48. return authenticate_youtube_api()
  49. def get_subscribed_channels(youtube):
  50. """Fetch the list of subscribed channels."""
  51. channels = []
  52. request = youtube.subscriptions().list(
  53. part="snippet",
  54. mine=True,
  55. maxResults=50
  56. )
  57. while request:
  58. response = request.execute()
  59. for item in response["items"]:
  60. channels.append(item["snippet"]["resourceId"]["channelId"])
  61. request = youtube.subscriptions().list_next(request, response)
  62. return channels
  63. def get_recent_videos(youtube, channel_id):
  64. """Fetch videos from a channel published in the last `DAYS_BACK` days."""
  65. recent_videos = []
  66. published_after = (datetime.datetime.now() - datetime.timedelta(days=DAYS_BACK)).isoformat("T") + "Z"
  67. request = youtube.search().list(
  68. part="snippet",
  69. channelId=channel_id,
  70. publishedAfter=published_after,
  71. maxResults=50,
  72. type="video",
  73. order="date"
  74. )
  75. response = request.execute()
  76. for item in response["items"]:
  77. recent_videos.append({
  78. "video_id": item["id"]["videoId"],
  79. "title": item["snippet"]["title"],
  80. "description": item["snippet"]["description"],
  81. "published_at": item["snippet"]["publishedAt"],
  82. "channel_title": item["snippet"]["channelTitle"]
  83. })
  84. return recent_videos
  85. def get_popular_videos(youtube, region_code):
  86. """Fetch the most popular videos for a given region."""
  87. popular_videos = []
  88. request = youtube.videos().list(
  89. part="snippet,statistics",
  90. chart="mostPopular",
  91. regionCode=region_code, # Region code passed here
  92. maxResults=POPULAR_VIDEOS_MAX_RESULTS
  93. )
  94. response = request.execute()
  95. for item in response["items"]:
  96. popular_videos.append({
  97. "video_id": item["id"],
  98. "title": item["snippet"]["title"],
  99. "description": item["snippet"]["description"],
  100. "published_at": item["snippet"]["publishedAt"],
  101. "channel_title": item["snippet"]["channelTitle"]
  102. })
  103. return popular_videos
  104. def download_video(video_id, output_path):
  105. """Download a YouTube video with quality priority - downloads 4K VP9/AV1 if needed for conversion."""
  106. try:
  107. # Quality-priority format selection:
  108. # 1. First try: Apple TV compatible codecs at any resolution
  109. # 2. Fallback: Download highest quality available (including 4K VP9/AV1 for conversion)
  110. # 3. Last resort: Best available format
  111. command = [
  112. "yt-dlp",
  113. "-f", "(bv[vcodec~='^(avc|h264|hevc|h265)'] / bv[height>=2160] / bv[height>=1080] / best) + (ba[acodec~='^(aac|mp4a)'] / ba)",
  114. "--merge-output-format", "mp4",
  115. "--no-mtime",
  116. f"https://www.youtube.com/watch?v={video_id}",
  117. "-o", os.path.join(output_path, "%(title)s.%(ext)s")
  118. ]
  119. subprocess.run(command, check=True)
  120. print(f"Successfully downloaded video {video_id} (Quality priority - will convert VP9/AV1 if needed)")
  121. except subprocess.CalledProcessError as e:
  122. print(f"Error downloading video {video_id}: {e}")
  123. return False
  124. return True
  125. def add_to_conversion_queue(input_file, priority=1):
  126. """Add a video file to the conversion queue with priority."""
  127. try:
  128. # Higher number = higher priority (4K gets priority 2, others get 1)
  129. conversion_queue.put((priority, input_file), timeout=1)
  130. print(f"Added to conversion queue: {os.path.basename(input_file)} (priority: {priority})")
  131. return True
  132. except queue.Full:
  133. print(f"⚠️ Conversion queue is full! Skipping: {os.path.basename(input_file)}")
  134. return False
  135. def get_queue_status():
  136. """Get current conversion queue status."""
  137. queue_size = conversion_queue.qsize()
  138. active_conversions = conversion_executor._threads # Active thread count
  139. return {
  140. 'queued': queue_size,
  141. 'active': len(active_conversions) if active_conversions else 0,
  142. 'max_concurrent': MAX_CONCURRENT_CONVERSIONS
  143. }
  144. def print_queue_status():
  145. """Print current queue status."""
  146. status = get_queue_status()
  147. print(f"🎬 Conversion Status: {status['active']}/{status['max_concurrent']} active, {status['queued']} queued")
  148. def process_conversion_queue():
  149. """Background worker to process the conversion queue."""
  150. while True:
  151. try:
  152. # Get item from queue (blocks until available)
  153. priority, input_file = conversion_queue.get(timeout=30)
  154. # Check if file still exists
  155. if not os.path.exists(input_file):
  156. print(f"⚠️ File no longer exists, skipping conversion: {input_file}")
  157. conversion_queue.task_done()
  158. continue
  159. print(f"🔄 Starting conversion (priority {priority}): {os.path.basename(input_file)}")
  160. # Perform the conversion
  161. success = convert_to_apple_tv_compatible(input_file)
  162. if success:
  163. print(f"✅ Conversion completed: {os.path.basename(input_file)}")
  164. else:
  165. print(f"❌ Conversion failed: {os.path.basename(input_file)}")
  166. # Mark task as done
  167. conversion_queue.task_done()
  168. except queue.Empty:
  169. # No items in queue for 30 seconds, continue waiting
  170. continue
  171. except Exception as e:
  172. print(f"❌ Conversion queue error: {e}")
  173. conversion_queue.task_done()
  174. def convert_to_apple_tv_compatible(input_file):
  175. """Convert a video to Apple TV 4K compatible format, optimized by resolution."""
  176. base_name = os.path.splitext(input_file)[0]
  177. mp4_file = base_name + ".mp4"
  178. print(f"Converting {input_file} to Apple TV compatible format...")
  179. # Detect video properties to optimize conversion
  180. try:
  181. import json
  182. probe_cmd = [
  183. "ffprobe", "-v", "quiet", "-print_format", "json",
  184. "-show_format", "-show_streams", input_file
  185. ]
  186. probe_result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  187. video_info = json.loads(probe_result.stdout)
  188. # Find video stream
  189. video_stream = next((s for s in video_info['streams'] if s['codec_type'] == 'video'), None)
  190. is_4k = video_stream and int(video_stream.get('height', 0)) >= 2160
  191. is_high_res = video_stream and int(video_stream.get('height', 0)) >= 1080
  192. print(f"Video resolution: {video_stream.get('height', 'unknown')}p")
  193. except (subprocess.CalledProcessError, json.JSONDecodeError, Exception) as e:
  194. print(f"Could not probe video properties: {e}, using default settings")
  195. is_4k = False
  196. is_high_res = True
  197. # Apple TV 4K optimized encoding based on source quality:
  198. if is_4k:
  199. # 4K content: Use H.265 for better compression at high resolution
  200. codec_args = [
  201. "-c:v", "libx265", # H.265 for 4K (better compression)
  202. "-preset", "medium", # Better compression for 4K
  203. "-crf", "20", # Higher quality for 4K content
  204. "-level", "5.1", # Level 5.1 supports 4K
  205. "-pix_fmt", "yuv420p", # Ensure compatibility
  206. ]
  207. print("Using H.265 encoding for 4K content")
  208. elif is_high_res:
  209. # 1080p content: Use H.264 for maximum compatibility
  210. codec_args = [
  211. "-c:v", "libx264", # H.264 for 1080p (max compatibility)
  212. "-profile:v", "high", # High profile for better quality
  213. "-preset", "fast", # Faster encoding for 1080p
  214. "-crf", "21", # Good quality for 1080p
  215. "-level", "4.1", # Level 4.1 supports 1080p at high bitrates
  216. ]
  217. print("Using H.264 encoding for 1080p content")
  218. else:
  219. # Lower resolution: Use H.264 with faster settings
  220. codec_args = [
  221. "-c:v", "libx264", # H.264 for lower res
  222. "-profile:v", "main", # Main profile for lower res
  223. "-preset", "fast", # Fast encoding
  224. "-crf", "23", # Standard quality
  225. "-level", "3.1", # Level 3.1 for lower res
  226. ]
  227. print("Using H.264 encoding for lower resolution content")
  228. command = [
  229. "ffmpeg", "-i", input_file,
  230. *codec_args,
  231. "-c:a", "aac", # AAC audio codec
  232. "-b:a", "192k", # Higher audio bitrate for better quality
  233. "-movflags", "+faststart", # Optimize for streaming/quick playback
  234. "-y", # Overwrite output file if exists
  235. mp4_file
  236. ]
  237. try:
  238. print("Starting conversion... This may take a while for 4K content.")
  239. subprocess.run(command, check=True)
  240. print(f"Conversion completed: {mp4_file}")
  241. if os.path.exists(mp4_file):
  242. os.remove(input_file)
  243. print(f"Original file deleted: {input_file}")
  244. return True
  245. except subprocess.CalledProcessError as e:
  246. print(f"Conversion failed for {input_file}: {e}")
  247. return False
  248. # Temporarily commented out video conversion functionality
  249. # def convert_to_mp4(input_file):
  250. # """Convert a video to mp4 and delete the original file."""
  251. # mp4_file = os.path.splitext(input_file)[0] + ".mp4"
  252. # print(f"Converting {input_file} to {mp4_file}...")
  253. # command = ["ffmpeg", "-i", input_file, "-c:v", "libx264", "-preset", "fast", "-c:a", "aac", mp4_file]
  254. # subprocess.run(command, check=True)
  255. # print(f"Conversion completed: {mp4_file}")
  256. # if os.path.exists(mp4_file):
  257. # os.remove(input_file)
  258. # print(f"Original file deleted: {input_file}")
  259. def create_nfo_file(video_data, file_path):
  260. """Create an Emby-compatible NFO file."""
  261. nfo_content = f"""<?xml version="1.0" encoding="utf-8" standalone="yes"?>
  262. <movie>
  263. <title>{video_data['title']}</title>
  264. <plot>{video_data['description']}</plot>
  265. <year>{video_data['published_at'][:4]}</year>
  266. <premiered>{video_data['published_at']}</premiered>
  267. <studio>{video_data['channel_title']}</studio>
  268. </movie>
  269. """
  270. with open(file_path, "w", encoding="utf-8") as nfo_file:
  271. nfo_file.write(nfo_content)
  272. def delete_old_videos(folder, days=5):
  273. """Delete video files and their folders older than `days`."""
  274. now = time.time()
  275. cutoff = now - (days * 86400) # Convert days to seconds
  276. for root, dirs, files in os.walk(folder, topdown=False):
  277. # Delete files
  278. for file in files:
  279. file_path = os.path.join(root, file)
  280. if os.path.isfile(file_path):
  281. if os.path.getmtime(file_path) < cutoff:
  282. print(f"Deleting file: {file_path}")
  283. os.remove(file_path)
  284. # Remove empty directories
  285. for dir in dirs:
  286. dir_path = os.path.join(root, dir)
  287. if not os.listdir(dir_path): # Check if the directory is empty
  288. print(f"Deleting folder: {dir_path}")
  289. os.rmdir(dir_path)
  290. def video_exists(output_path, video_id):
  291. """Check if a video already exists in the directory."""
  292. return os.path.exists(os.path.join(output_path, video_id))
  293. def check_exit_time():
  294. """Check if the current time is past the exit time."""
  295. now = datetime.datetime.now().time()
  296. if now >= EXIT_TIME:
  297. print("Current time exceeds exit time. Terminating script.")
  298. sys.exit(0)
  299. def main():
  300. # Start the conversion queue worker
  301. print("🎬 Starting video conversion queue system...")
  302. print(f"📊 Max concurrent conversions: {MAX_CONCURRENT_CONVERSIONS}")
  303. # Start background conversion worker
  304. conversion_worker = threading.Thread(target=process_conversion_queue, daemon=True, name="ConversionQueueWorker")
  305. conversion_worker.start()
  306. # Update yt-dlp
  307. update_yt_dlp()
  308. # Authenticate YouTube API
  309. print("Authenticating with YouTube API...")
  310. youtube = authenticate_youtube_api()
  311. if not os.path.exists(OUTPUT_DIR):
  312. os.makedirs(OUTPUT_DIR)
  313. # Clean up old videos
  314. print(f"Deleting videos older than {DAYS_TO_KEEP} days in {OUTPUT_DIR}...")
  315. delete_old_videos(OUTPUT_DIR, days=DAYS_TO_KEEP)
  316. # Fetch subscribed channels
  317. print("Fetching subscribed channels...")
  318. channels = get_subscribed_channels(youtube)
  319. # Fetch and process recent videos from subscribed channels
  320. for channel_id in channels:
  321. print(f"Fetching recent videos for channel: {channel_id}")
  322. videos = get_recent_videos(youtube, channel_id)
  323. for video in videos:
  324. # Check the exit time before processing each video
  325. check_exit_time()
  326. print(f"Processing video: {video['title']}")
  327. video_dir = os.path.join(OUTPUT_DIR, video["video_id"])
  328. # Check if the video already exists
  329. if os.path.exists(video_dir):
  330. print(f"Skipping download, already exists: {video['title']} ({video['video_id']})")
  331. continue
  332. os.makedirs(video_dir, exist_ok=True)
  333. # Download the video
  334. if not download_video(video["video_id"], video_dir):
  335. print(f"Skipping video {video['video_id']} due to download error.")
  336. continue # Skip this video if download failed
  337. # Check if conversion is needed (VP9/AV1 codecs or non-MP4 files)
  338. needs_conversion = False
  339. conversion_file = None
  340. for file in os.listdir(video_dir):
  341. if file.endswith(('.mp4', '.mkv', '.webm', '.m4v')):
  342. file_path = os.path.join(video_dir, file)
  343. try:
  344. # Quick check for problematic codecs
  345. probe_cmd = ["ffprobe", "-v", "quiet", "-select_streams", "v:0",
  346. "-show_entries", "stream=codec_name", "-of", "csv=p=0", file_path]
  347. result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  348. codec = result.stdout.strip().lower()
  349. if codec in ['vp9', 'av01', 'av1'] or not file.endswith('.mp4'):
  350. needs_conversion = True
  351. conversion_file = file_path
  352. print(f"Found {codec} codec - will convert for Apple TV compatibility")
  353. break
  354. except subprocess.CalledProcessError:
  355. # If we can't probe, assume it might need conversion if not MP4
  356. if not file.endswith('.mp4'):
  357. needs_conversion = True
  358. conversion_file = file_path
  359. break
  360. if needs_conversion and conversion_file:
  361. # Determine priority based on video resolution
  362. try:
  363. probe_cmd = ["ffprobe", "-v", "quiet", "-select_streams", "v:0",
  364. "-show_entries", "stream=height", "-of", "csv=p=0", conversion_file]
  365. result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  366. height = int(result.stdout.strip()) if result.stdout.strip().isdigit() else 1080
  367. priority = 2 if height >= 2160 else 1 # 4K gets higher priority
  368. except:
  369. priority = 1 # Default priority
  370. add_to_conversion_queue(conversion_file, priority)
  371. print_queue_status()
  372. # Create the NFO file
  373. nfo_file_path = os.path.join(video_dir, "movie.nfo")
  374. create_nfo_file(video, nfo_file_path)
  375. # Fetch and process most popular videos worldwide (from multiple regions)
  376. for region_code in REGION_CODES:
  377. print(f"Fetching the most popular videos from {region_code}...")
  378. popular_videos = get_popular_videos(youtube, region_code)
  379. for video in popular_videos:
  380. # Check the exit time before processing each video
  381. check_exit_time()
  382. print(f"Processing popular video: {video['title']} ({region_code})")
  383. video_dir = os.path.join(OUTPUT_DIR, video["video_id"])
  384. # Check if the video already exists
  385. if os.path.exists(video_dir):
  386. print(f"Skipping download, already exists: {video['title']} ({video['video_id']})")
  387. continue
  388. os.makedirs(video_dir, exist_ok=True)
  389. # Download the popular video
  390. if not download_video(video["video_id"], video_dir):
  391. print(f"Skipping popular video {video['video_id']} due to download error.")
  392. continue # Skip this video if download failed
  393. # Check if conversion is needed (VP9/AV1 codecs or non-MP4 files)
  394. needs_conversion = False
  395. conversion_file = None
  396. for file in os.listdir(video_dir):
  397. if file.endswith(('.mp4', '.mkv', '.webm', '.m4v')):
  398. file_path = os.path.join(video_dir, file)
  399. try:
  400. # Quick check for problematic codecs
  401. probe_cmd = ["ffprobe", "-v", "quiet", "-select_streams", "v:0",
  402. "-show_entries", "stream=codec_name", "-of", "csv=p=0", file_path]
  403. result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  404. codec = result.stdout.strip().lower()
  405. if codec in ['vp9', 'av01', 'av1'] or not file.endswith('.mp4'):
  406. needs_conversion = True
  407. conversion_file = file_path
  408. print(f"Found {codec} codec - will convert for Apple TV compatibility")
  409. break
  410. except subprocess.CalledProcessError:
  411. # If we can't probe, assume it might need conversion if not MP4
  412. if not file.endswith('.mp4'):
  413. needs_conversion = True
  414. conversion_file = file_path
  415. break
  416. if needs_conversion and conversion_file:
  417. # Determine priority based on video resolution
  418. try:
  419. probe_cmd = ["ffprobe", "-v", "quiet", "-select_streams", "v:0",
  420. "-show_entries", "stream=height", "-of", "csv=p=0", conversion_file]
  421. result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
  422. height = int(result.stdout.strip()) if result.stdout.strip().isdigit() else 1080
  423. priority = 2 if height >= 2160 else 1 # 4K gets higher priority
  424. except:
  425. priority = 1 # Default priority
  426. add_to_conversion_queue(conversion_file, priority)
  427. print_queue_status()
  428. # Create the NFO file
  429. nfo_file_path = os.path.join(video_dir, "movie.nfo")
  430. create_nfo_file(video, nfo_file_path)
  431. print("All videos processed successfully!")
  432. # Final conversion queue status
  433. print("\n🎬 Final Conversion Queue Status:")
  434. print_queue_status()
  435. queued_items = conversion_queue.qsize()
  436. if queued_items > 0:
  437. print(f"⏳ {queued_items} videos still in conversion queue")
  438. print("💡 Conversions will continue in the background")
  439. print("💡 You can monitor progress by checking the output folder")
  440. else:
  441. print("✅ All conversions completed!")
  442. if __name__ == "__main__":
  443. main()