main.py 17 KB

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