mirror of
https://git.lecygnenoir.info/LecygneNoir/prismedia.git
synced 2025-10-03 01:19:15 +02:00
Merge branch 'feature/playlist' into develop
This commit is contained in:
commit
04514c86e6
6 changed files with 190 additions and 21 deletions
|
@ -151,7 +151,8 @@ Languages:
|
||||||
- [x] set default language
|
- [x] set default language
|
||||||
- [x] thumbnail/preview
|
- [x] thumbnail/preview
|
||||||
- [x] multiple lines description (see [issue 4](https://git.lecygnenoir.info/LecygneNoir/prismedia/issues/4))
|
- [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] 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] Allow to choose peertube or youtube upload (to resume failed upload for example)
|
||||||
- [x] Add publishAt option to plan your videos
|
- [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!
|
If your server uses peertube before 1.0.0-beta4, use the version inside tag 1.0.0-beta3!
|
||||||
|
|
||||||
## Sources
|
## 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)
|
|
@ -51,18 +51,59 @@ def get_authenticated_service(secret):
|
||||||
return oauth
|
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 upload_video(oauth, secret, options):
|
||||||
|
|
||||||
def get_userinfo():
|
def get_userinfo():
|
||||||
user_info = json.loads(oauth.get(url + "/api/v1/users/me").content)
|
return json.loads(oauth.get(url+"/api/v1/users/me").content)
|
||||||
return str(user_info["id"])
|
|
||||||
|
|
||||||
def get_file(path):
|
def get_file(path):
|
||||||
mimetypes.init()
|
mimetypes.init()
|
||||||
return (basename(path), open(abspath(path), 'rb'),
|
return (basename(path), open(abspath(path), 'rb'),
|
||||||
mimetypes.types_map[splitext(path)[1]])
|
mimetypes.types_map[splitext(path)[1]])
|
||||||
|
|
||||||
|
path = options.get('--file')
|
||||||
url = str(secret.get('peertube', 'peertube_url')).rstrip('/')
|
url = str(secret.get('peertube', 'peertube_url')).rstrip('/')
|
||||||
|
user_info = get_userinfo()
|
||||||
|
|
||||||
# We need to transform fields into tuple to deal with tags as
|
# We need to transform fields into tuple to deal with tags as
|
||||||
# MultipartEncoder does not support list refer
|
# MultipartEncoder does not support list refer
|
||||||
|
@ -73,8 +114,7 @@ def upload_video(oauth, secret, options):
|
||||||
("licence", "1"),
|
("licence", "1"),
|
||||||
("description", options.get('--description') or "default description"),
|
("description", options.get('--description') or "default description"),
|
||||||
("nsfw", str(int(options.get('--nsfw')) or "0")),
|
("nsfw", str(int(options.get('--nsfw')) or "0")),
|
||||||
("channelId", get_userinfo()),
|
("videofile", get_file(path))
|
||||||
("videofile", get_file(options.get('--file')))
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if options.get('--tags'):
|
if options.get('--tags'):
|
||||||
|
@ -89,7 +129,7 @@ def upload_video(oauth, secret, options):
|
||||||
exit(1)
|
exit(1)
|
||||||
# If Mastodon compatibility is enabled, clean tags from special characters
|
# If Mastodon compatibility is enabled, clean tags from special characters
|
||||||
if options.get('--mt'):
|
if options.get('--mt'):
|
||||||
strtag = utils.mastodonTag(strtag)
|
strtag = utils.cleanString(strtag)
|
||||||
fields.append(("tags", strtag))
|
fields.append(("tags", strtag))
|
||||||
|
|
||||||
if options.get('--category'):
|
if options.get('--category'):
|
||||||
|
@ -129,12 +169,22 @@ def upload_video(oauth, secret, options):
|
||||||
fields.append(("thumbnailfile", get_file(options.get('--thumbnail'))))
|
fields.append(("thumbnailfile", get_file(options.get('--thumbnail'))))
|
||||||
fields.append(("previewfile", 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)
|
multipart_data = MultipartEncoder(fields)
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
'Content-Type': multipart_data.content_type
|
'Content-Type': multipart_data.content_type
|
||||||
}
|
}
|
||||||
|
|
||||||
response = oauth.post(url + "/api/v1/videos/upload",
|
response = oauth.post(url + "/api/v1/videos/upload",
|
||||||
data=multipart_data,
|
data=multipart_data,
|
||||||
headers=headers)
|
headers=headers)
|
||||||
|
@ -150,7 +200,6 @@ def upload_video(oauth, secret, options):
|
||||||
else:
|
else:
|
||||||
logging.error(('Peertube: The upload failed with an unexpected response: '
|
logging.error(('Peertube: The upload failed with an unexpected response: '
|
||||||
'%s') % response)
|
'%s') % response)
|
||||||
print(response.json())
|
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
|
18
lib/utils.py
18
lib/utils.py
|
@ -193,15 +193,15 @@ def parseNFO(options):
|
||||||
def upcaseFirstLetter(s):
|
def upcaseFirstLetter(s):
|
||||||
return s[0].upper() + s[1:]
|
return s[0].upper() + s[1:]
|
||||||
|
|
||||||
def mastodonTag(tag):
|
def cleanString(toclean):
|
||||||
tags = tag.split(' ')
|
toclean = toclean.split(' ')
|
||||||
mtag = ''
|
cleaned = ''
|
||||||
for s in tags:
|
for s in toclean:
|
||||||
if s == '':
|
if s == '':
|
||||||
continue
|
continue
|
||||||
strtag = unicodedata.normalize('NFKD', unicode (s, 'utf-8')).encode('ASCII', 'ignore')
|
strtoclean = unicodedata.normalize('NFKD', unicode (s, 'utf-8')).encode('ASCII', 'ignore')
|
||||||
strtag = ''.join(e for e in strtag if e.isalnum())
|
strtoclean = ''.join(e for e in strtoclean if e.isalnum())
|
||||||
strtag = upcaseFirstLetter(strtag)
|
strtoclean = upcaseFirstLetter(strtoclean)
|
||||||
mtag = mtag + strtag
|
cleaned = cleaned + strtoclean
|
||||||
|
|
||||||
return mtag
|
return cleaned
|
||||||
|
|
117
lib/yt_upload.py
117
lib/yt_upload.py
|
@ -9,6 +9,7 @@ import time
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
from os.path import splitext, basename, exists
|
from os.path import splitext, basename, exists
|
||||||
|
import os
|
||||||
import google.oauth2.credentials
|
import google.oauth2.credentials
|
||||||
import datetime
|
import datetime
|
||||||
import pytz
|
import pytz
|
||||||
|
@ -51,13 +52,14 @@ RETRIABLE_STATUS_CODES = [500, 502, 503, 504]
|
||||||
|
|
||||||
CLIENT_SECRETS_FILE = 'youtube_secret.json'
|
CLIENT_SECRETS_FILE = 'youtube_secret.json'
|
||||||
CREDENTIALS_PATH = ".youtube_credentials.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_SERVICE_NAME = 'youtube'
|
||||||
API_VERSION = 'v3'
|
API_VERSION = 'v3'
|
||||||
|
|
||||||
|
|
||||||
# Authorize the request and store authorization credentials.
|
# Authorize the request and store authorization credentials.
|
||||||
def get_authenticated_service():
|
def get_authenticated_service():
|
||||||
|
check_authenticated_scopes()
|
||||||
flow = InstalledAppFlow.from_client_secrets_file(
|
flow = InstalledAppFlow.from_client_secrets_file(
|
||||||
CLIENT_SECRETS_FILE, SCOPES)
|
CLIENT_SECRETS_FILE, SCOPES)
|
||||||
if exists(CREDENTIALS_PATH):
|
if exists(CREDENTIALS_PATH):
|
||||||
|
@ -71,7 +73,7 @@ def get_authenticated_service():
|
||||||
client_secret=credential_params["_client_secret"]
|
client_secret=credential_params["_client_secret"]
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
credentials = flow.run_local_server()
|
credentials = flow.run_console()
|
||||||
with open(CREDENTIALS_PATH, 'w') as f:
|
with open(CREDENTIALS_PATH, 'w') as f:
|
||||||
p = copy.deepcopy(vars(credentials))
|
p = copy.deepcopy(vars(credentials))
|
||||||
del p["expiry"]
|
del p["expiry"]
|
||||||
|
@ -79,6 +81,16 @@ def get_authenticated_service():
|
||||||
return build(API_SERVICE_NAME, API_VERSION, credentials=credentials, cache_discovery=False)
|
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):
|
def initialize_upload(youtube, options):
|
||||||
path = options.get('--file')
|
path = options.get('--file')
|
||||||
tags = None
|
tags = None
|
||||||
|
@ -121,6 +133,17 @@ def initialize_upload(youtube, options):
|
||||||
publishAt = tz.localize(publishAt).isoformat()
|
publishAt = tz.localize(publishAt).isoformat()
|
||||||
body['status']['publishAt'] = str(publishAt)
|
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.
|
# Call the API's videos.insert method to create and upload the video.
|
||||||
insert_request = youtube.videos().insert(
|
insert_request = youtube.videos().insert(
|
||||||
part=','.join(body.keys()),
|
part=','.join(body.keys()),
|
||||||
|
@ -133,9 +156,77 @@ def initialize_upload(youtube, options):
|
||||||
if video_id and options.get('--thumbnail'):
|
if video_id and options.get('--thumbnail'):
|
||||||
set_thumbnail(youtube, options.get('--thumbnail'), videoId=video_id)
|
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):
|
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(
|
request = youtube.thumbnails().set(
|
||||||
media_body=MediaFileUpload(media_file, chunksize=-1,
|
media_body=MediaFileUpload(media_file, chunksize=-1,
|
||||||
resumable=True),
|
resumable=True),
|
||||||
|
@ -146,6 +237,26 @@ def set_thumbnail(youtube, media_file, **kwargs):
|
||||||
return resumable_upload(request, 'thumbnail', 'set')
|
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
|
# This method implements an exponential backoff strategy to resume a
|
||||||
# failed upload.
|
# failed upload.
|
||||||
def resumable_upload(request, resource, method):
|
def resumable_upload(request, resource, method):
|
||||||
|
|
|
@ -18,6 +18,8 @@ cca = True
|
||||||
privacy = private
|
privacy = private
|
||||||
disable-comments = True
|
disable-comments = True
|
||||||
thumbnail = /path/to/your/thumbnail.jpg # Set the absolute path to your thumbnail
|
thumbnail = /path/to/your/thumbnail.jpg # Set the absolute path to your thumbnail
|
||||||
|
playlist = My Test Playlist
|
||||||
|
playlistCreate = True
|
||||||
nsfw = True
|
nsfw = True
|
||||||
platform = youtube, peertube
|
platform = youtube, peertube
|
||||||
language = French
|
language = French
|
||||||
|
|
|
@ -37,6 +37,10 @@ Options:
|
||||||
--thumbnail=STRING Path to a file to use as a thumbnail for the video.
|
--thumbnail=STRING Path to a file to use as a thumbnail for the video.
|
||||||
Supported types are jpg and jpeg.
|
Supported types are jpg and jpeg.
|
||||||
By default, prismedia search for an image based on video name followed by .jpg or .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.
|
-h --help Show this help.
|
||||||
--version Show version.
|
--version Show version.
|
||||||
|
|
||||||
|
@ -211,6 +215,8 @@ if __name__ == '__main__':
|
||||||
Optional('--thumbnail'): Or(None, And(
|
Optional('--thumbnail'): Or(None, And(
|
||||||
str, validateThumbnail, error='thumbnail is not supported, please use jpg/jpeg'),
|
str, validateThumbnail, error='thumbnail is not supported, please use jpg/jpeg'),
|
||||||
),
|
),
|
||||||
|
Optional('--playlist'): Or(None, str),
|
||||||
|
Optional('--playlistCreate'): bool,
|
||||||
'--help': bool,
|
'--help': bool,
|
||||||
'--version': bool
|
'--version': bool
|
||||||
})
|
})
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue