diff --git a/.gitignore b/.gitignore index 9c67092..1ecc479 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ client_secrets_file.json token.json playlists.csv +db.json diff --git a/main.py b/main.py index 86d351d..ea0a40b 100755 --- a/main.py +++ b/main.py @@ -9,8 +9,14 @@ from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import InstalledAppFlow from googleapiclient.discovery import build from googleapiclient.errors import HttpError +from tinydb import TinyDB, Query +from yt_dlp import YoutubeDL _playlists = {} +# output template: CHANNEL/DATE_TITLE_[ID].ext +DEFAULT_OUTPUT_TMPL = "%(channel)s/%(upload_date)s_%(title)s_[%(id)s].%(ext)s" +# download video 1080p or lower with audio +DEFAULT_FORMAT = "bestvideo[height<=1080]+bestaudio/best[height<=1080]" def _truncate_title(title: str, length: int = 30) -> str: @@ -170,13 +176,13 @@ def remove_video_from_playlist(yt_api, plitem_id: str, playlist_id: str, def copy_playlist_items(yt_api, - src_playlist: str, - dst_playlist: str, + src_playlist_id: str, + dst_playlist_id: str, delete_from_src: bool = False, limit: int = -1, dry_run: bool = False): - src_playlist_items = list_playlist(yt_api, src_playlist) - dst_playlist_items = list_playlist(yt_api, dst_playlist) + src_playlist_items = list_playlist(yt_api, src_playlist_id) + dst_playlist_items = list_playlist(yt_api, dst_playlist_id) dst_videos = {pl_item['snippet']['resourceId']['videoId'] for pl_item in dst_playlist_items} @@ -188,10 +194,10 @@ def copy_playlist_items(yt_api, was_processed = False video_id = src_pl_item['snippet']['resourceId']['videoId'] if video_id not in dst_videos: - add_video_to_playlist(yt_api, video_id, dst_playlist, dry_run) + add_video_to_playlist(yt_api, video_id, dst_playlist_id, dry_run) was_processed = True if delete_from_src: - remove_video_from_playlist(yt_api, src_pl_item["id"], src_playlist, dry_run) + remove_video_from_playlist(yt_api, src_pl_item["id"], src_playlist_id, dry_run) was_processed = True if was_processed: processed_amt += 1 @@ -199,8 +205,9 @@ def copy_playlist_items(yt_api, def get_video_info(youtube, video_id: str): try: + # TODO maybe remove 'status' response = youtube.videos().list( - part="snippet", + part="localizations,snippet,contentDetails,statistics,status,topicDetails", id=video_id ).execute() except HttpError as e: @@ -216,7 +223,7 @@ def get_video_info(youtube, video_id: str): def get_playlistitem_info(youtube, playlistitem_id: str): try: response = youtube.playlistItems().list( - part="snippet", + part="snippet,contentDetails", id=playlistitem_id ).execute() except HttpError as e: @@ -262,6 +269,14 @@ def main(): parser_dups.add_argument('-l', '--limit', type=int, default=-1, help='Limit number of videos to process') + parser_download = subparsers.add_parser('download', help='Download videos from a playlist') + parser_download.add_argument('playlist', help='Playlist name/ID') + parser_download.add_argument('dst_folder', help='Destination folder') + parser_download.add_argument('-l', '--limit', type=int, default=-1, + help='Limit number of videos to process') + parser_download.add_argument('-r', '--remove-from-playlist', action='store_true', + help='Remove downloaded videos from the playlist') + args = parser.parse_args() # Disable OAuthlib's HTTPS verification when running locally. @@ -276,19 +291,16 @@ def main(): parser.print_help() return 1 - elif args.command == 'copy': - src_playlist = get_playlist_id(args.src_playlist) - dst_playlist = get_playlist_id(args.dst_playlist) - copy_playlist_items(youtube, src_playlist, dst_playlist, - delete_from_src=False, - limit=args.limit, dry_run=args.dry_run) - - elif args.command == 'move': - src_playlist = get_playlist_id(args.src_playlist) - dst_playlist = get_playlist_id(args.dst_playlist) - copy_playlist_items(youtube, src_playlist, dst_playlist, - delete_from_src=True, - limit=args.limit, dry_run=args.dry_run) + elif args.command in ('copy', 'move'): + delete_from_src = args.command == 'move' + copy_playlist_items( + youtube, + get_playlist_id(args.src_playlist), + get_playlist_id(args.dst_playlist), + delete_from_src=delete_from_src, + limit=args.limit, + dry_run=args.dry_run + ) elif args.command == 'add': playlist_id = get_playlist_id(args.playlist) @@ -339,6 +351,48 @@ def main(): else: plitems_processed.add(video_id) + elif args.command == "download": + db = TinyDB('db.json') + query = Query() + ydl_opts = { + 'outtmpl': os.path.join(args.dst_folder, DEFAULT_OUTPUT_TMPL), + 'format': DEFAULT_FORMAT, + } + + # load playlist items + playlist_id = get_playlist_id(args.playlist) + plitems = list_playlist(youtube, playlist_id) + + # limit number of videos to process + if args.limit > 0: + plitems = plitems[:args.limit] + + for plitem in plitems: + video_id = plitem["snippet"]["resourceId"]["videoId"] + # skip if video is already in the database + if db.search(query.id == video_id): + continue + + video_info = get_video_info(youtube, video_id) + # skip if video is not found on YouTube + if not video_info: + continue + + video_title = _truncate_title(video_info['snippet']['title']) + if args.dry_run: + print(f"Would download video '{video_title}' [{video_id}]" + f" from playlist {args.playlist} to folder {args.dst_folder}") + + else: + # download video + with YoutubeDL(ydl_opts) as ydl: + ydl.download(['https://www.youtube.com/watch?v=' + video_id]) + db.insert(video_info) + + # remove video from playlist + if args.remove_from_playlist: + remove_video_from_playlist(youtube, plitem["id"], playlist_id, args.dry_run) + return 0 diff --git a/requirements.txt b/requirements.txt index 2a5265e..5b66625 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ google-api-python-client google-auth google-auth-httplib2 google-auth-oauthlib +tinydb +yt_dlp