diff --git a/README.md b/README.md index 14c8a7e..189bb56 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Languages: - [x] thumbnail/preview - [x] multiple lines description (see [issue 4](https://git.lecygnenoir.info/LecygneNoir/prismedia/issues/4)) - [x] add videos to playlist for Peertube - - [ ] add videos to playlist for Youtube + - [x] add videos to playlist for Youtube - [x] Use a config file (NFO) file to retrieve videos arguments - [x] Allow to choose peertube or youtube upload (to resume failed upload for example) - [x] Add publishAt option to plan your videos diff --git a/lib/yt_upload.py b/lib/yt_upload.py index b28d495..0574aa1 100644 --- a/lib/yt_upload.py +++ b/lib/yt_upload.py @@ -51,13 +51,14 @@ RETRIABLE_STATUS_CODES = [500, 502, 503, 504] CLIENT_SECRETS_FILE = 'youtube_secret.json' CREDENTIALS_PATH = ".youtube_credentials.json" -SCOPES = ['https://www.googleapis.com/auth/youtube.upload'] +SCOPES = ['https://www.googleapis.com/auth/youtube.upload', 'https://www.googleapis.com/auth/youtube.force-ssl'] API_SERVICE_NAME = 'youtube' API_VERSION = 'v3' # Authorize the request and store authorization credentials. def get_authenticated_service(): + check_authenticated_scopes() flow = InstalledAppFlow.from_client_secrets_file( CLIENT_SECRETS_FILE, SCOPES) if exists(CREDENTIALS_PATH): @@ -121,6 +122,17 @@ def initialize_upload(youtube, options): publishAt = tz.localize(publishAt).isoformat() body['status']['publishAt'] = str(publishAt) + if options.get('--playlist'): + playlist_id = get_playlist_by_name(youtube, options.get('--playlist')) + if not playlist_id and options.get('--playlistCreate'): + playlist_id = create_playlist(youtube, options.get('--playlist')) + elif not playlist_id: + logging.warning("Youtube: Playlist `" + options.get('--playlist') + "` is unknown.") + logging.warning("If you want to create it, set the --playlistCreate option.") + playlist_id = "" + else: + playlist_id = "" + # Call the API's videos.insert method to create and upload the video. insert_request = youtube.videos().insert( part=','.join(body.keys()), @@ -133,9 +145,77 @@ def initialize_upload(youtube, options): if video_id and options.get('--thumbnail'): set_thumbnail(youtube, options.get('--thumbnail'), videoId=video_id) + # If we get a video_id, upload is successful and we are able to set playlist + if video_id and options.get('--playlist'): + set_playlist(youtube, playlist_id, video_id) + + +def get_playlist_by_name(youtube, playlist_name): + response = youtube.playlists().list( + part='snippet,id', + mine=True, + maxResults=50 + ).execute() + for playlist in response["items"]: + if playlist["snippet"]['title'] == playlist_name: + return playlist['id'] + + +def create_playlist(youtube, playlist_name): + template = ('Youtube: Playlist %s does not exist, creating it.') + logging.info(template % (str(playlist_name))) + resources = build_resource({'snippet.title': playlist_name, + 'snippet.description': '', + 'status.privacyStatus': 'public'}) + response = youtube.playlists().insert( + body=resources, + part='status,snippet,id' + ).execute() + return response["id"] + + +def build_resource(properties): + resource = {} + for p in properties: + # Given a key like "snippet.title", split into "snippet" and "title", where + # "snippet" will be an object and "title" will be a property in that object. + prop_array = p.split('.') + ref = resource + for pa in range(0, len(prop_array)): + is_array = False + key = prop_array[pa] + + # For properties that have array values, convert a name like + # "snippet.tags[]" to snippet.tags, and set a flag to handle + # the value as an array. + if key[-2:] == '[]': + key = key[0:len(key)-2:] + is_array = True + + if pa == (len(prop_array) - 1): + # Leave properties without values out of inserted resource. + if properties[p]: + if is_array: + ref[key] = properties[p].split(',') + else: + ref[key] = properties[p] + elif key not in ref: + # For example, the property is "snippet.title", but the resource does + # not yet have a "snippet" object. Create the snippet object here. + # Setting "ref = ref[key]" means that in the next time through the + # "for pa in range ..." loop, we will be setting a property in the + # resource's "snippet" object. + ref[key] = {} + ref = ref[key] + else: + # For example, the property is "snippet.description", and the resource + # already has a "snippet" object. + ref = ref[key] + return resource + def set_thumbnail(youtube, media_file, **kwargs): - kwargs = utils.remove_empty_kwargs(**kwargs) # See full sample for function + kwargs = utils.remove_empty_kwargs(**kwargs) request = youtube.thumbnails().set( media_body=MediaFileUpload(media_file, chunksize=-1, resumable=True), @@ -146,6 +226,26 @@ def set_thumbnail(youtube, media_file, **kwargs): return resumable_upload(request, 'thumbnail', 'set') +def set_playlist(youtube, playlist_id, video_id): + logging.info('Youtube: Configuring playlist...') + resource = build_resource({'snippet.playlistId': playlist_id, + 'snippet.resourceId.kind': 'youtube#video', + 'snippet.resourceId.videoId': video_id, + 'snippet.position': ''} + ) + try: + youtube.playlistItems().insert( + body=resource, + part='snippet' + ).execute() + except Exception as e: + if hasattr(e, 'message'): + logging.error("Youtube: Error: " + str(e.message)) + else: + logging.error("Youtube: Error: " + str(e)) + logging.info('Youtube: Video is correclty added to the playlist.') + + # This method implements an exponential backoff strategy to resume a # failed upload. def resumable_upload(request, resource, method):