diff --git a/README.md b/README.md index 189dfd9..189bb56 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,8 @@ Languages: - [x] set default language - [x] thumbnail/preview - [x] multiple lines description (see [issue 4](https://git.lecygnenoir.info/LecygneNoir/prismedia/issues/4)) - - [ ] add videos to playlist (YT & PT workflow: upload video, find playlist id, add video to playlist) + - [x] add videos to playlist for Peertube + - [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 @@ -164,4 +165,4 @@ Languages: If your server uses peertube before 1.0.0-beta4, use the version inside tag 1.0.0-beta3! ## Sources -inspired by [peeror](https://git.drycat.fr/rigelk/Peeror) and [youtube-upload](https://github.com/tokland/youtube-upload) +inspired by [peeror](https://git.rigelk.eu/rigelk/peeror) and [youtube-upload](https://github.com/tokland/youtube-upload) \ No newline at end of file diff --git a/lib/pt_upload.py b/lib/pt_upload.py index b6a4626..6e3a9cf 100644 --- a/lib/pt_upload.py +++ b/lib/pt_upload.py @@ -51,18 +51,59 @@ def get_authenticated_service(secret): return oauth +def get_default_playlist(user_info): + return user_info['videoChannels'][0]['id'] + + +def get_playlist_by_name(user_info, options): + for playlist in user_info["videoChannels"]: + if playlist['displayName'] == options.get('--playlist'): + return playlist['id'] + + +def create_playlist(oauth, url, options): + template = ('Peertube: Playlist %s does not exist, creating it.') + logging.info(template % (str(options.get('--playlist')))) + data = '{"name":"' + utils.cleanString(str(options.get('--playlist'))) +'", \ + "displayName":"' + str(options.get('--playlist')) +'", \ + "description":null}' + + headers = { + 'Content-Type': "application/json" + } + try: + response = oauth.post(url + "/api/v1/video-channels/", + data=data, + headers=headers) + except Exception as e: + if hasattr(e, 'message'): + logging.error("Error: " + str(e.message)) + else: + logging.error("Error: " + str(e)) + if response is not None: + if response.status_code == 200: + jresponse = response.json() + jresponse = jresponse['videoChannel'] + return jresponse['id'] + else: + logging.error(('Peertube: The upload failed with an unexpected response: ' + '%s') % response) + exit(1) + + def upload_video(oauth, secret, options): def get_userinfo(): - user_info = json.loads(oauth.get(url + "/api/v1/users/me").content) - return str(user_info["id"]) + return json.loads(oauth.get(url+"/api/v1/users/me").content) def get_file(path): mimetypes.init() return (basename(path), open(abspath(path), 'rb'), mimetypes.types_map[splitext(path)[1]]) + path = options.get('--file') url = str(secret.get('peertube', 'peertube_url')).rstrip('/') + user_info = get_userinfo() # We need to transform fields into tuple to deal with tags as # MultipartEncoder does not support list refer @@ -73,8 +114,7 @@ def upload_video(oauth, secret, options): ("licence", "1"), ("description", options.get('--description') or "default description"), ("nsfw", str(int(options.get('--nsfw')) or "0")), - ("channelId", get_userinfo()), - ("videofile", get_file(options.get('--file'))) + ("videofile", get_file(path)) ] if options.get('--tags'): @@ -89,7 +129,7 @@ def upload_video(oauth, secret, options): exit(1) # If Mastodon compatibility is enabled, clean tags from special characters if options.get('--mt'): - strtag = utils.mastodonTag(strtag) + strtag = utils.cleanString(strtag) fields.append(("tags", strtag)) if options.get('--category'): @@ -129,12 +169,22 @@ def upload_video(oauth, secret, options): fields.append(("thumbnailfile", get_file(options.get('--thumbnail')))) fields.append(("previewfile", get_file(options.get('--thumbnail')))) + if options.get('--playlist'): + playlist_id = get_playlist_by_name(user_info, options) + if not playlist_id and options.get('--playlistCreate'): + playlist_id = create_playlist(oauth, url, options) + elif not playlist_id: + logging.warning("Playlist `" + options.get('--playlist') + "` is unknown, using default playlist.") + playlist_id = get_default_playlist(user_info) + else: + playlist_id = get_default_playlist(user_info) + fields.append(("channelId", str(playlist_id))) + multipart_data = MultipartEncoder(fields) headers = { 'Content-Type': multipart_data.content_type } - response = oauth.post(url + "/api/v1/videos/upload", data=multipart_data, headers=headers) @@ -150,7 +200,6 @@ def upload_video(oauth, secret, options): else: logging.error(('Peertube: The upload failed with an unexpected response: ' '%s') % response) - print(response.json()) exit(1) diff --git a/lib/utils.py b/lib/utils.py index bfabaad..4234116 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -193,15 +193,15 @@ def parseNFO(options): def upcaseFirstLetter(s): return s[0].upper() + s[1:] -def mastodonTag(tag): - tags = tag.split(' ') - mtag = '' - for s in tags: +def cleanString(toclean): + toclean = toclean.split(' ') + cleaned = '' + for s in toclean: if s == '': continue - strtag = unicodedata.normalize('NFKD', unicode (s, 'utf-8')).encode('ASCII', 'ignore') - strtag = ''.join(e for e in strtag if e.isalnum()) - strtag = upcaseFirstLetter(strtag) - mtag = mtag + strtag + strtoclean = unicodedata.normalize('NFKD', unicode (s, 'utf-8')).encode('ASCII', 'ignore') + strtoclean = ''.join(e for e in strtoclean if e.isalnum()) + strtoclean = upcaseFirstLetter(strtoclean) + cleaned = cleaned + strtoclean - return mtag + return cleaned diff --git a/lib/yt_upload.py b/lib/yt_upload.py index b28d495..daebdc9 100644 --- a/lib/yt_upload.py +++ b/lib/yt_upload.py @@ -9,6 +9,7 @@ import time import copy import json from os.path import splitext, basename, exists +import os import google.oauth2.credentials import datetime import pytz @@ -51,13 +52,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): @@ -71,7 +73,7 @@ def get_authenticated_service(): client_secret=credential_params["_client_secret"] ) else: - credentials = flow.run_local_server() + credentials = flow.run_console() with open(CREDENTIALS_PATH, 'w') as f: p = copy.deepcopy(vars(credentials)) del p["expiry"] @@ -79,6 +81,16 @@ def get_authenticated_service(): return build(API_SERVICE_NAME, API_VERSION, credentials=credentials, cache_discovery=False) +def check_authenticated_scopes(): + if exists(CREDENTIALS_PATH): + with open(CREDENTIALS_PATH, 'r') as f: + credential_params = json.load(f) + # Check if all scopes are present + if credential_params["_scopes"] != SCOPES: + logging.warning("Youtube: Credentials are obsolete, need to re-authenticate.") + os.remove(CREDENTIALS_PATH) + + def initialize_upload(youtube, options): path = options.get('--file') tags = None @@ -121,6 +133,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 +156,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 +237,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): diff --git a/nfo_example.txt b/nfo_example.txt index a4d4096..9a3890d 100644 --- a/nfo_example.txt +++ b/nfo_example.txt @@ -18,6 +18,8 @@ cca = True privacy = private disable-comments = True thumbnail = /path/to/your/thumbnail.jpg # Set the absolute path to your thumbnail +playlist = My Test Playlist +playlistCreate = True nsfw = True platform = youtube, peertube language = French diff --git a/prismedia_upload.py b/prismedia_upload.py index 4634440..68ed0a2 100755 --- a/prismedia_upload.py +++ b/prismedia_upload.py @@ -37,6 +37,10 @@ Options: --thumbnail=STRING Path to a file to use as a thumbnail for the video. Supported types are jpg and jpeg. By default, prismedia search for an image based on video name followed by .jpg or .jpeg + --playlist=STRING Set the playlist to use for the video. Also known as Channel for Peertube. + If the playlist is not found, spawn an error except if --playlist-create is set. + --playlistCreate Create the playlist if not exists. (default do not create) + Only relevant if --playlist is set. -h --help Show this help. --version Show version. @@ -211,6 +215,8 @@ if __name__ == '__main__': Optional('--thumbnail'): Or(None, And( str, validateThumbnail, error='thumbnail is not supported, please use jpg/jpeg'), ), + Optional('--playlist'): Or(None, str), + Optional('--playlistCreate'): bool, '--help': bool, '--version': bool })