@ -0,0 +1,13 @@ | |||
For our plugins we are using [yaspy](http://yapsy.sourceforge.net). | |||
# Types | |||
For an example of the exact methods required to be recognized as a particular type of plugin, see the concerned interface definition. | |||
## Interface | |||
Plugins that present an interface (cli, gui, configuration folders, …) for the user to tell Prismedia wich video needs to be uploaded, the infos of the videos, … | |||
## Platform | |||
Also called uploaders, they are the one doing the actual work of uploading video to a particular platform. | |||
## Consumer | |||
Thoses do actions once the upload is finished (successful or failed). |
@ -1,172 +1,234 @@ | |||
# Prismedia | |||
A scripting way to upload videos to peertube and youtube | |||
## Dependencies | |||
Search in your package manager, otherwise use ``pip install --upgrade`` | |||
- google-auth | |||
- google-auth-oauthlib | |||
- google-auth-httplib2 | |||
- google-api-python-client | |||
- docopt | |||
- schema | |||
- python-magic | |||
- requests-toolbelt | |||
- tzlocal | |||
For Peertube and if you want to use the publishAt option, you also need some utilities on you local system | |||
- [atd](https://linux.die.net/man/8/atd) daemon | |||
- [curl](https://linux.die.net/man/1/curl) | |||
- [jq](https://stedolan.github.io/jq/) | |||
Scripting your way to upload videos to peertube and youtube. Works with Python 3.5+. | |||
[TOC]: # | |||
## Table of Contents | |||
- [Installation](#installation-and-upgrade) | |||
- [From pip](#from-pip) | |||
- [From source](#from-source) | |||
- [Configuration](#configuration) | |||
- [Peertube](#peertube) | |||
- [Youtube](#youtube) | |||
- [Usage](#usage) | |||
- [Enhanced use of NFO](#enhanced-use-of-nfo) | |||
- [Strict check options](#strict-check-options) | |||
- [Features](#features) | |||
- [Compatibility](#compatibility) | |||
- [Inspirations](#inspirations) | |||
- [Contributors](#contributors) | |||
## Installation and upgrade | |||
### From pip | |||
Simply install with | |||
```sh | |||
pip install prismedia | |||
``` | |||
Upgrade with | |||
```sh | |||
pip install --upgrade prismedia | |||
``` | |||
### From source | |||
Get the source: | |||
```sh | |||
git clone https://git.lecygnenoir.info/LecygneNoir/prismedia.git prismedia | |||
``` | |||
You may use pip to install requirements: `pip install -r requirements.txt` if you want to use the script directly. | |||
(**note:** requirements are generated via `poetry export -f requirements.txt --output requirements.txt`) | |||
Otherwise, you can use [poetry](https://python-poetry.org), which create a virtualenv for the project directly | |||
(Or use the existing virtualenv if one is activated) | |||
```sh | |||
poetry install | |||
``` | |||
## Configuration | |||
Edit peertube_secret and youtube_secret.json with your credentials. | |||
Generate configuration files by running `prismedia-init`. | |||
Then, edit them to fill your credential as explained below. | |||
### Peertube | |||
Set your credentials, peertube server URL. | |||
You can get client_id and client_secret by logging in your peertube website and reaching the URL: https://domain.example/api/v1/oauth-clients/local | |||
You can set ``OAUTHLIB_INSECURE_TRANSPORT`` to 1 if you do not use https (not recommended) | |||
Configuration is in **peertube_secret** file. | |||
You need your usual credentials and Peertube instance URL, in addition with API client_id and client_secret. | |||
You can get client_id and client_secret by logging in your peertube instance and reaching the URL: | |||
https://domain.example/api/v1/oauth-clients/local | |||
### Youtube | |||
Configuration is in **youtube_secret.json** file. | |||
Youtube uses combination of oauth and API access to identify. | |||
**Credentials** | |||
The first time you connect, prismedia will open your browser to as you to authenticate to | |||
The first time you connect, prismedia will open your browser to ask you to authenticate to | |||
Youtube and allow the app to use your Youtube channel. | |||
**It is here you choose which channel you will upload to**. | |||
Once authenticated, the token is stored inside the file ``.youtube_credentials.json``. | |||
Once authenticated, the token is stored inside the file `.youtube_credentials.json`. | |||
Prismedia will try to use this file at each launch, and re-ask for authentication if it does not exist. | |||
**Oauth**: | |||
The default youtube_secret.json should allow you to upload some videos. | |||
If you plan an larger usage, please consider creating your own youtube_secret file: | |||
- Go to the [Google console](https://console.developers.google.com/). | |||
- Create project. | |||
- Side menu: APIs & auth -> APIs | |||
- Top menu: Enabled API(s): Enable all Youtube APIs. | |||
- Side menu: APIs & auth -> Credentials. | |||
- Create a Client ID: Add credentials -> OAuth 2.0 Client ID -> Other -> Name: prismedia1 -> Create -> OK | |||
- Download JSON: Under the section "OAuth 2.0 client IDs". Save the file to your local system. | |||
- Save this JSON as your youtube_secret.json file. | |||
## How To | |||
Currently in heavy development | |||
Support only mp4 for cross compatibility between Youtube and Peertube | |||
Simply upload a video: | |||
If you plan a larger usage, please consider creating your own youtube_secret file: | |||
- Go to the [Google console](https://console.developers.google.com/). | |||
- Create project. | |||
- Side menu: APIs & auth -> APIs | |||
- Top menu: Enabled API(s): Enable all Youtube APIs. | |||
- Side menu: APIs & auth -> Credentials. | |||
- Create a Client ID: Add credentials -> OAuth 2.0 Client ID -> Other -> Name: prismedia1 -> Create -> OK | |||
- Download JSON: Under the section "OAuth 2.0 client IDs". Save the file to your local system. | |||
- Save this JSON as your youtube_secret.json file. | |||
## Usage | |||
Support only mp4 for cross compatibility between Youtube and Peertube. | |||
**Note that all options may be specified in a NFO file!** (see [Enhanced NFO](#enhanced-use-of-nfo)) | |||
Here are some demonstration of main usage: | |||
Upload a video: | |||
```sh | |||
prismedia --file="yourvideo.mp4" | |||
``` | |||
./prismedia_upload.py --file="yourvideo.mp4" | |||
``` | |||
Specify description and tags: | |||
``` | |||
./prismedia_upload.py --file="yourvideo.mp4" -d "My supa description" -t "tag1,tag2,foo" | |||
```sh | |||
prismedia --file="yourvideo.mp4" -d "My supa description" -t "tag1,tag2,foo" | |||
``` | |||
Provide a thumbnail: | |||
```sh | |||
prismedia --file="yourvideo.mp4" -d "Video with thumbnail" --thumbnail="/path/to/your/thumbnail.jpg" | |||
``` | |||
./prismedia_upload.py --file="yourvideo.mp4" -d "Video with thumbnail" --thumbnail="/path/to/your/thumbnail.jpg" | |||
Publish on Peertube only, while using a channel and a playlist, creating them if they do not exist: | |||
```sh | |||
prismedia --file="yourvideo.mp4" --platform=peertube --channel="Cooking recipes" --playlist="Cake recipes" --channelCreate --playlistCreate | |||
``` | |||
Use a NFO file to specify your video options: | |||
(See [Enhanced NFO](#enhanced-use-of-nfo) for more precise example) | |||
```sh | |||
prismedia --file="yourvideo.mp4" --nfo /path/to/your/nfo.txt | |||
``` | |||
Use a NFO file to specify your video options: | |||
Use some credits to show some activity for you apikey so the platform know it is used and would not put your quota to 0 (only Youtube currently). | |||
``` | |||
./prismedia_upload.py --file="yourvideo.mp4" --nfo /path/to/your/nfo.txt | |||
To prevent Youtube from inactivating your apikey after 90days of inactivity it is recommended to launch this command automatically from a script around once a month. It will mwke a call to use a few credits from your daily quota. | |||
On Linux and MacOS, you can use cron, on Windows the "Task Scheduler". | |||
```sh | |||
prismedia --hearthbeat | |||
``` | |||
Take a look at all available options with `--help`! | |||
```sh | |||
prismedia --help | |||
``` | |||
Use --help to get all available options: | |||
## Enhanced use of NFO | |||
Since Prismedia v0.9.0, the NFO system has been improved to allow hierarchical loading. | |||
First, **if you already used nfo**, either with `--nfo` or by using `videoname.txt`, nothing changes :-) | |||
But you are now able to use a more flexible NFO system, by using priorities. This allows you to set some defaults to avoid recreating a full nfo for each video | |||
Basically, Prismedia will now load options in this order, using the last value found in case of conflict: | |||
`nfo.txt < directory_name.txt < video_name.txt < command line NFO < command line argument` | |||
You'll find a complete set of samples in the [prismedia/samples](prismedia/samples) directory so let's take it as an example: | |||
```sh | |||
$ tree Recipes/ | |||
Recipes/ | |||
├── cli_nfo.txt | |||
├── nfo.txt | |||
├── samples.txt | |||
├── yourvideo1.mp4 | |||
├── yourvideo1.txt | |||
├── yourvideo1.jpg | |||
├── yourvideo2.mp4 | |||
└── yourvideo2.txt | |||
``` | |||
prismedia_upload - tool to upload videos to Peertube and Youtube | |||
Usage: | |||
prismedia_upload.py --file=<FILE> [options] | |||
prismedia_upload.py --file=<FILE> --tags=STRING [--mt options] | |||
prismedia_upload.py -h | --help | |||
prismedia_upload.py --version | |||
Options: | |||
--name=NAME Name of the video to upload. (default to video filename) | |||
-d, --description=STRING Description of the video. (default: default description) | |||
-t, --tags=STRING Tags for the video. comma separated. | |||
WARN: tags with space and special characters (!, ', ", ?, ...) | |||
are not supported by Mastodon to be published from Peertube | |||
use mastodon compatibility below | |||
--mt Force Mastodon compatibility for tags (drop every incompatible characters inside tags) | |||
This option requires --tags | |||
-c, --category=STRING Category for the videos, see below. (default: Films) | |||
--cca License should be CreativeCommon Attribution (affects Youtube upload only) | |||
-p, --privacy=STRING Choose between public, unlisted or private. (default: private) | |||
--disable-comments Disable comments (Peertube only as YT API does not support) (default: comments are enabled) | |||
--nsfw Set the video as No Safe For Work (Peertube only as YT API does not support) (default: video is safe) | |||
--nfo=STRING Configure a specific nfo file to set options for the video. | |||
By default Prismedia search a .txt based on video name | |||
See nfo_example.txt for more details | |||
--platform=STRING List of platform(s) to upload to, comma separated. | |||
Supported platforms are youtube and peertube (default is both) | |||
--language=STRING Specify the default language for video. See below for supported language. (default is English) | |||
--publishAt=DATE Publish the video at the given DATE using local server timezone. | |||
DATE should be on the form YYYY-MM-DDThh:mm:ss eg: 2018-03-12T19:00:00 | |||
DATE should be in the future | |||
For Peertube, requires the "atd" and "curl utilities installed on the system | |||
--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 | |||
-h --help Show this help. | |||
--version Show version. | |||
Categories: | |||
Category is the type of video you upload. Default is films. | |||
Here are available categories from Peertube and Youtube: | |||
music, films, vehicles, | |||
sports, travels, gaming, people, | |||
comedy, entertainment, news, | |||
how to, education, activism, science & technology, | |||
science, technology, animals | |||
Languages: | |||
Language of the video (audio track), choose one. Default is English | |||
Here are available languages from Peertube and Youtube: | |||
Arabic, English, French, German, Hindi, Italian, | |||
Japanese, Korean, Mandarin, Portuguese, Punjabi, Russian, Spanish | |||
By using | |||
```sh | |||
prismedia --file=/path/to/Recipes/yourvideo1.mp4 --nfo=/path/to/Recipes/cli_nfo.txt --cca | |||
``` | |||
Prismedia will: | |||
- look for options in `nfo.txt` | |||
- look for options in `samples.txt` (from directory name) and erase any previous conflicting options | |||
- look for options in `yourvideo1.txt` (from video name) and erase any previous conflicting options | |||
- look for options in `cli_nfo.txt` (from the `--nfo` in command line) and erase any previous conflicting options | |||
- erase any previous option regarding CCA as it's specified in cli with `--cca` | |||
- take `yourvideo1.jpg` as thumbnail if no other files has been specified in previous NFO | |||
In other word, Prismedia will use option given in cli, then look for option in cli_nfo.txt, then complete with video_name.txt, then directory_name.txt, and finally complete with nfo.txt | |||
It allows to specify more easily default options for an entire set of video, directory, playlist and so on. | |||
## Strict check options | |||
Since prismedia v0.10.0, a bunch of special options have been added to force the presence of parameters before uploading. | |||
Strict options allow you to force some option to be present when uploading a video. It's useful to be sure you do not | |||
forget something when uploading a video, for example if you use multiples NFO. You may force the presence of description, | |||
tags, thumbnail, ... | |||
All strict option are optionals and are provided only to avoid errors when uploading :-) | |||
All strict options can be specified in NFO directly, the only strict option mandatory on cli is --withNFO | |||
All strict options are off by default. | |||
Available strict options: | |||
- --withNFO Prevent the upload without a NFO, either specified via cli or found in the directory | |||
- --withThumbnail Prevent the upload without a thumbnail | |||
- --withName Prevent the upload if no name are found | |||
- --withDescription Prevent the upload without description | |||
- --withTags Prevent the upload without tags | |||
- --withPlaylist Prevent the upload if no playlist | |||
- --withPublishAt Prevent the upload if no schedule | |||
- --withPlatform Prevent the upload if at least one platform is not specified | |||
- --withCategory Prevent the upload if no category | |||
- --withLanguage Prevent upload if no language | |||
- --withChannel Prevent upload if no channel | |||
## Features | |||
- [x] Youtube upload | |||
- [x] Peertube upload | |||
- Support of all videos arguments (description, tags, category, licence, ...) | |||
- Support of videos parameters (description, tags, category, licence, ...) | |||
- [x] description | |||
- [x] tags (no more than 30 characters per tag as Peertube does not support it) | |||
- [x] Option to force tags to be compatible with Mastodon publication | |||
- [x] categories | |||
- [x] license: cca or not (Youtube only as Peertube uses Attribution by design) | |||
- [x] privacy (between public, unlisted or private) | |||
- [x] enabling/disabling comment (Peertube only as Youtube API does not support it) | |||
- [x] nsfw (Peertube only as Youtube API does not support it) | |||
- [x] set default language | |||
- [x] thumbnail/preview | |||
- [x] thumbnail | |||
- [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 | |||
- [x] create playlist | |||
- [x] schedule your video with publishAt | |||
- [x] combine channel and playlist (Peertube only as channel is Peertube feature). See [issue 40](https://git.lecygnenoir.info/LecygneNoir/prismedia/issues/40) for detailed usage. | |||
- [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 (need the [atd](https://linux.die.net/man/8/atd) daemon, [curl](https://linux.die.net/man/1/curl) and [jq](https://stedolan.github.io/jq/)) | |||
- [ ] Record and forget: put the video in a directory, and the script uploads it for you | |||
- [ ] Usable on Desktop (Linux and/or Windows and/or MacOS) | |||
- [ ] Graphical User Interface | |||
- [x] Allow choosing peertube or youtube upload (to retry a failed upload for example) | |||
- [x] Usable on Desktop (Linux and/or Windows and/or MacOS) | |||
- [x] Different schedules on platforms to prepare preview | |||
- [x] Possibility to force the presence of upload options | |||
- [ ] Copy and forget, eg possibility to copy video in a directory, and prismedia uploads itself: [Work in progress](https://git.lecygnenoir.info/Zykino/prismedia-autoupload) thanks to @Zykino 🎉 (Discussions in [issue 27](https://git.lecygnenoir.info/LecygneNoir/prismedia/issues/27)) | |||
- [ ] A usable graphical interface | |||
## Compatibility | |||
If your server uses peertube before 1.0.0-beta4, use the version inside tag 1.0.0-beta3! | |||
- If you still use python2, use the version 0.7.1 (no more updated) | |||
- If you use peertube before 1.0.0-beta4, use the version inside tag 1.0.0-beta3 | |||
## Inspirations | |||
Inspired by [peeror](https://git.rigelk.eu/rigelk/peeror) and [youtube-upload](https://github.com/tokland/youtube-upload) | |||
## Sources | |||
inspired by [peeror](https://git.drycat.fr/rigelk/Peeror) and [youtube-upload](https://github.com/tokland/youtube-upload) | |||
## Contributors | |||
Thanks to: @LecygneNoir, @Zykino, @meewan, @rigelk 😘 |
@ -1,161 +0,0 @@ | |||
#!/usr/bin/python | |||
# coding: utf-8 | |||
import os | |||
import mimetypes | |||
import json | |||
import logging | |||
from os.path import splitext, basename, abspath | |||
from ConfigParser import RawConfigParser | |||
from requests_oauthlib import OAuth2Session | |||
from oauthlib.oauth2 import LegacyApplicationClient | |||
from requests_toolbelt.multipart.encoder import MultipartEncoder | |||
import utils | |||
PEERTUBE_SECRETS_FILE = 'peertube_secret' | |||
PEERTUBE_PRIVACY = { | |||
"public": 1, | |||
"unlisted": 2, | |||
"private": 3 | |||
} | |||
def get_authenticated_service(secret): | |||
peertube_url = str(secret.get('peertube', 'peertube_url')).rstrip("/") | |||
oauth_client = LegacyApplicationClient( | |||
client_id=str(secret.get('peertube', 'client_id')) | |||
) | |||
try: | |||
oauth = OAuth2Session(client=oauth_client) | |||
oauth.fetch_token( | |||
token_url=str(peertube_url + '/api/v1/users/token'), | |||
# lower as peertube does not store uppercase for pseudo | |||
username=str(secret.get('peertube', 'username').lower()), | |||
password=str(secret.get('peertube', 'password')), | |||
client_id=str(secret.get('peertube', 'client_id')), | |||
client_secret=str(secret.get('peertube', 'client_secret')) | |||
) | |||
except Exception as e: | |||
if hasattr(e, 'message'): | |||
logging.error("Peertube: Error: " + str(e.message)) | |||
exit(1) | |||
else: | |||
logging.error("Peertube: Error: " + str(e)) | |||
exit(1) | |||
return oauth | |||
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"]) | |||
def get_file(path): | |||
mimetypes.init() | |||
return (basename(path), open(abspath(path), 'rb'), | |||
mimetypes.types_map[splitext(path)[1]]) | |||
url = str(secret.get('peertube', 'peertube_url')).rstrip('/') | |||
# We need to transform fields into tuple to deal with tags as | |||
# MultipartEncoder does not support list refer | |||
# https://github.com/requests/toolbelt/issues/190 and | |||
# https://github.com/requests/toolbelt/issues/205 | |||
fields = [ | |||
("name", options.get('--name') or splitext(basename(options.get('--file')))[0]), | |||
("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'))) | |||
] | |||
if options.get('--tags'): | |||
tags = options.get('--tags').split(',') | |||
for strtag in tags: | |||
# Empty tag crashes Peertube, so skip them | |||
if strtag == "": | |||
continue | |||
# Tag more than 30 chars crashes Peertube, so exit and check tags | |||
if len(strtag) >= 30: | |||
logging.warning("Peertube: Sorry, Peertube does not support tag with more than 30 characters, please reduce your tag size") | |||
exit(1) | |||
# If Mastodon compatibility is enabled, clean tags from special characters | |||
if options.get('--mt'): | |||
strtag = utils.mastodonTag(strtag) | |||
fields.append(("tags", strtag)) | |||
if options.get('--category'): | |||
fields.append(("category", str(utils.getCategory(options.get('--category'), 'peertube')))) | |||
else: | |||
# if no category, set default to 2 (Films) | |||
fields.append(("category", "2")) | |||
if options.get('--language'): | |||
fields.append(("language", str(utils.getLanguage(options.get('--language'), "peertube")))) | |||
else: | |||
# if no language, set default to 1 (English) | |||
fields.append(("language", "en")) | |||
if options.get('--privacy'): | |||
fields.append(("privacy", str(PEERTUBE_PRIVACY[options.get('--privacy').lower()]))) | |||
else: | |||
fields.append(("privacy", "3")) | |||
if options.get('--disable-comments'): | |||
fields.append(("commentsEnabled", "0")) | |||
else: | |||
fields.append(("commentsEnabled", "1")) | |||
if options.get('--thumbnail'): | |||
fields.append(("thumbnailfile", get_file(options.get('--thumbnail')))) | |||
fields.append(("previewfile", get_file(options.get('--thumbnail')))) | |||
multipart_data = MultipartEncoder(fields) | |||
headers = { | |||
'Content-Type': multipart_data.content_type | |||
} | |||
response = oauth.post(url + "/api/v1/videos/upload", | |||
data=multipart_data, | |||
headers=headers) | |||
if response is not None: | |||
if response.status_code == 200: | |||
jresponse = response.json() | |||
jresponse = jresponse['video'] | |||
uuid = jresponse['uuid'] | |||
idvideo = str(jresponse['id']) | |||
logging.info('Peertube : Video was successfully uploaded.') | |||
template = 'Peertube: Watch it at %s/videos/watch/%s.' | |||
logging.info(template % (url, uuid)) | |||
if options.get('--publishAt'): | |||
utils.publishAt(str(options.get('--publishAt')), oauth, url, idvideo, secret) | |||
else: | |||
logging.error(('Peertube: The upload failed with an unexpected response: ' | |||
'%s') % response) | |||
exit(1) | |||
def run(options): | |||
secret = RawConfigParser() | |||
try: | |||
secret.read(PEERTUBE_SECRETS_FILE) | |||
except Exception as e: | |||
logging.error("Peertube: Error loading " + str(PEERTUBE_SECRETS_FILE) + ": " + str(e)) | |||
exit(1) | |||
insecure_transport = secret.get('peertube', 'OAUTHLIB_INSECURE_TRANSPORT') | |||
os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = insecure_transport | |||
oauth = get_authenticated_service(secret) | |||
try: | |||
logging.info('Peertube: Uploading video...') | |||
upload_video(oauth, secret, options) | |||
except Exception as e: | |||
if hasattr(e, 'message'): | |||
logging.error("Peertube: Error: " + str(e.message)) | |||
else: | |||
logging.error("Peertube: Error: " + str(e)) |
@ -1,263 +0,0 @@ | |||
#!/usr/bin/python | |||
# coding: utf-8 | |||
from ConfigParser import RawConfigParser, NoOptionError, NoSectionError | |||
from os.path import dirname, splitext, basename, isfile | |||
from os import devnull | |||
from subprocess import check_call, CalledProcessError, STDOUT | |||
import unicodedata | |||
import logging | |||
### CATEGORIES ### | |||
YOUTUBE_CATEGORY = { | |||
"music": 10, | |||
"films": 1, | |||
"vehicles": 2, | |||
"sport": 17, | |||
"travels": 19, | |||
"gaming": 20, | |||
"people": 22, | |||
"comedy": 23, | |||
"entertainment": 24, | |||
"news": 25, | |||
"how to": 26, | |||
"education": 27, | |||
"activism": 29, | |||
"science & technology": 28, | |||
"science": 28, | |||
"technology": 28, | |||
"animals": 15 | |||
} | |||
PEERTUBE_CATEGORY = { | |||
"music": 1, | |||
"films": 2, | |||
"vehicles": 3, | |||
"sport": 5, | |||
"travels": 6, | |||
"gaming": 7, | |||
"people": 8, | |||
"comedy": 9, | |||
"entertainment": 10, | |||
"news": 11, | |||
"how to": 12, | |||
"education": 13, | |||
"activism": 14, | |||
"science & technology": 15, | |||
"science": 15, | |||
"technology": 15, | |||
"animals": 16 | |||
} | |||
### LANGUAGES ### | |||
YOUTUBE_LANGUAGE = { | |||
"arabic": 'ar', | |||
"english": 'en', | |||
"french": 'fr', | |||
"german": 'de', | |||
"hindi": 'hi', | |||
"italian": 'it', | |||
"japanese": 'ja', | |||
"korean": 'ko', | |||
"mandarin": 'zh-CN', | |||
"portuguese": 'pt-PT', | |||
"punjabi": 'pa', | |||
"russian": 'ru', | |||
"spanish": 'es' | |||
} | |||
PEERTUBE_LANGUAGE = { | |||
"arabic": "ar", | |||
"english": "en", | |||
"french": "fr", | |||
"german": "de", | |||
"hindi": "hi", | |||
"italian": "it", | |||
"japanese": "ja", | |||
"korean": "ko", | |||
"mandarin": "zh", | |||
"portuguese": "pt", | |||
"punjabi": "pa", | |||
"russian": "ru", | |||
"spanish": "es" | |||
} | |||
###################### | |||
def getCategory(category, platform): | |||
if platform == "youtube": | |||
return YOUTUBE_CATEGORY[category.lower()] | |||
else: | |||
return PEERTUBE_CATEGORY[category.lower()] | |||
def getLanguage(language, platform): | |||
if platform == "youtube": | |||
return YOUTUBE_LANGUAGE[language.lower()] | |||
else: | |||
return PEERTUBE_LANGUAGE[language.lower()] | |||
def remove_empty_kwargs(**kwargs): | |||
good_kwargs = {} | |||
if kwargs is not None: | |||
for key, value in kwargs.iteritems(): | |||
if value: | |||
good_kwargs[key] = value | |||
return good_kwargs | |||
def searchThumbnail(options): | |||
video_directory = dirname(options.get('--file')) + "/" | |||
# First, check for thumbnail based on videoname | |||
if options.get('--name'): | |||
if isfile(video_directory + options.get('--name') + ".jpg"): | |||
options['--thumbnail'] = video_directory + options.get('--name') + ".jpg" | |||
elif isfile(video_directory + options.get('--name') + ".jpeg"): | |||
options['--thumbnail'] = video_directory + options.get('--name') + ".jpeg" | |||
# Then, if we still not have thumbnail, check for thumbnail based on videofile name | |||
if not options.get('--thumbnail'): | |||
video_file = splitext(basename(options.get('--file')))[0] | |||
if isfile(video_directory + video_file + ".jpg"): | |||
options['--thumbnail'] = video_directory + video_file + ".jpg" | |||
elif isfile(video_directory + video_file + ".jpeg"): | |||
options['--thumbnail'] = video_directory + video_file + ".jpeg" | |||
return options | |||
# return the nfo as a RawConfigParser object | |||
def loadNFO(options): | |||
video_directory = dirname(options.get('--file')) + "/" | |||
if options.get('--nfo'): | |||
try: | |||
logging.info("Using " + options.get('--nfo') + " as NFO, loading...") | |||
if isfile(options.get('--nfo')): | |||
nfo = RawConfigParser() | |||
nfo.read(options.get('--nfo')) | |||
return nfo | |||
else: | |||
logging.error("Given NFO file does not exist, please check your path.") | |||
exit(1) | |||
except Exception as e: | |||
logging.error("Problem with NFO file: " + str(e)) | |||
exit(1) | |||
else: | |||
if options.get('--name'): | |||
nfo_file = video_directory + options.get('--name') + ".txt" | |||
if isfile(nfo_file): | |||
try: | |||
logging.info("Using " + nfo_file + " as NFO, loading...") | |||
nfo = RawConfigParser() | |||
nfo.read(nfo_file) | |||
return nfo | |||
except Exception as e: | |||
logging.error("Problem with NFO file: " + str(e)) | |||
exit(1) | |||
# if --nfo and --name does not exist, use --file as default | |||
video_file = splitext(basename(options.get('--file')))[0] | |||
nfo_file = video_directory + video_file + ".txt" | |||
if isfile(nfo_file): | |||
try: | |||
logging.info("Using " + nfo_file + " as NFO, loading...") | |||
nfo = RawConfigParser() | |||
nfo.read(nfo_file) | |||
return nfo | |||
except Exception as e: | |||
logging.error("Problem with nfo file: " + str(e)) | |||
exit(1) | |||
logging.info("No suitable NFO found, skipping.") | |||
return False | |||
def parseNFO(options): | |||
nfo = loadNFO(options) | |||
if nfo: | |||
# We need to check all options and replace it with the nfo value if not defined (None or False) | |||
for key, value in options.iteritems(): | |||
key = key.replace("-", "") | |||
try: | |||
# get string options | |||
if value is None and nfo.get('video', key): | |||
options['--' + key] = nfo.get('video', key) | |||
# get boolean options | |||
elif value is False and nfo.getboolean('video', key): | |||
options['--' + key] = nfo.getboolean('video', key) | |||
except NoOptionError: | |||
continue | |||
except NoSectionError: | |||
logging.error("Given NFO file miss section [video], please check syntax of your NFO.") | |||
exit(1) | |||
return options | |||
def upcaseFirstLetter(s): | |||
return s[0].upper() + s[1:] | |||
def publishAt(publishAt, oauth, url, idvideo, secret): | |||
try: | |||
FNULL = open(devnull, 'w') | |||
check_call(["at", "-V"], stdout=FNULL, stderr=STDOUT) | |||
except CalledProcessError: | |||
logging.error("You need to install the atd daemon to use the publishAt option.") | |||
exit(1) | |||
try: | |||
FNULL = open(devnull, 'w') | |||
check_call(["curl", "-V"], stdout=FNULL, stderr=STDOUT) | |||
except CalledProcessError: | |||
logging.error("You need to install the curl command line to use the publishAt option.") | |||
exit(1) | |||
try: | |||
FNULL = open(devnull, 'w') | |||
check_call(["jq", "-V"], stdout=FNULL, stderr=STDOUT) | |||
except CalledProcessError: | |||
logging.error("You need to install the jq command line to use the publishAt option.") | |||
exit(1) | |||
time = publishAt.split("T") | |||
# Remove leading seconds that atd does not manage | |||
if time[1].count(":") == 2: | |||
time[1] = time[1][:-3] | |||
atTime = time[1] + " " + time[0] | |||
refresh_token=str(oauth.__dict__['_client'].__dict__['refresh_token']) | |||
atFile = "/tmp/peertube_" + idvideo + "_" + publishAt + ".at" | |||
try: | |||
openfile = open(atFile,"w") | |||
openfile.write('token=$(curl -X POST -d "client_id=' + str(secret.get('peertube', 'client_id')) + | |||
'&client_secret=' + str(secret.get('peertube', 'client_secret')) + | |||
'&grant_type=refresh_token&refresh_token=' + str(refresh_token) + | |||
'" "' + url + '/api/v1/users/token" | jq -r .access_token)') | |||
openfile.write("\n") | |||
openfile.write('curl "' + url + '/api/v1/videos/' + idvideo + | |||
'" -X PUT -H "Authorization: Bearer ${token}"' + | |||
' -H "Content-Type: multipart/form-data" -F "privacy=1"') | |||
openfile.write("\n ") # atd needs an empty line at the end of the file to load... | |||
openfile.close() | |||
except Exception as e: | |||
if hasattr(e, 'message'): | |||
logging.error("Error: " + str(e.message)) | |||
else: | |||
logging.error("Error: " + str(e)) | |||
try: | |||
FNULL = open(devnull, 'w') | |||
check_call(["at", "-M", "-f", atFile, atTime], stdout=FNULL, stderr=STDOUT) | |||
except Exception as e: | |||
if hasattr(e, 'message'): | |||
logging.error("Error: " + str(e.message)) | |||
else: | |||
logging.error("Error: " + str(e)) | |||
def mastodonTag(tag): | |||
tags = tag.split(' ') | |||
mtag = '' | |||
for s in tags: | |||
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 | |||
return mtag |
@ -1,202 +0,0 @@ | |||
#!/usr/bin/python | |||
# coding: utf-8 | |||
# From Youtube samples : https://raw.githubusercontent.com/youtube/api-samples/master/python/upload_video.py # noqa | |||
import httplib | |||
import httplib2 | |||
import random | |||
import time | |||
import copy | |||
import json | |||
from os.path import splitext, basename, exists | |||
import google.oauth2.credentials | |||
import datetime | |||
import pytz | |||
import logging | |||
from tzlocal import get_localzone | |||
from googleapiclient.discovery import build | |||
from googleapiclient.errors import HttpError | |||
from googleapiclient.http import MediaFileUpload | |||
from google_auth_oauthlib.flow import InstalledAppFlow | |||
import utils | |||
logging.basicConfig(format='%(asctime)s %(message)s', level=logging.INFO) | |||
# Explicitly tell the underlying HTTP transport library not to retry, since | |||
# we are handling retry logic ourselves. | |||
httplib2.RETRIES = 1 | |||
# Maximum number of times to retry before giving up. | |||
MAX_RETRIES = 10 | |||
# Youtube retriables cases | |||
RETRIABLE_EXCEPTIONS = ( | |||
IOError, | |||
httplib2.HttpLib2Error, | |||
httplib.NotConnected, | |||
httplib.IncompleteRead, | |||
httplib.ImproperConnectionState, | |||
httplib.CannotSendRequest, | |||
httplib.CannotSendHeader, | |||
httplib.ResponseNotReady, | |||
httplib.BadStatusLine, | |||
) | |||
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'] | |||
API_SERVICE_NAME = 'youtube' | |||
API_VERSION = 'v3' | |||
# Authorize the request and store authorization credentials. | |||
def get_authenticated_service(): | |||
flow = InstalledAppFlow.from_client_secrets_file( | |||
CLIENT_SECRETS_FILE, SCOPES) | |||
if exists(CREDENTIALS_PATH): | |||
with open(CREDENTIALS_PATH, 'r') as f: | |||
credential_params = json.load(f) | |||
credentials = google.oauth2.credentials.Credentials( | |||
credential_params["token"], | |||
refresh_token=credential_params["_refresh_token"], | |||
token_uri=credential_params["_token_uri"], | |||
client_id=credential_params["_client_id"], | |||
client_secret=credential_params["_client_secret"] | |||
) | |||
else: | |||
credentials = flow.run_local_server() | |||
with open(CREDENTIALS_PATH, 'w') as f: | |||
p = copy.deepcopy(vars(credentials)) | |||
del p["expiry"] | |||
json.dump(p, f) | |||
return build(API_SERVICE_NAME, API_VERSION, credentials=credentials, cache_discovery=False) | |||
def initialize_upload(youtube, options): | |||
path = options.get('--file') | |||
tags = None | |||
if options.get('--tags'): | |||
tags = options.get('--tags').split(',') | |||
category = None | |||
if options.get('--category'): | |||
category = utils.getCategory(options.get('--category'), 'youtube') | |||
language = None | |||
if options.get('--language'): | |||
language = utils.getLanguage(options.get('--language'), "youtube") | |||
license = None | |||
if options.get('--cca'): | |||
license = "creativeCommon" | |||
body = { | |||
"snippet": { | |||
"title": options.get('--name') or splitext(basename(path))[0], | |||
"description": options.get('--description') or "default description", | |||
"tags": tags, | |||
# if no category, set default to 1 (Films) | |||
"categoryId": str(category or 1), | |||
"defaultAudioLanguage": str(language or 'en') | |||
}, | |||
"status": { | |||
"privacyStatus": str(options.get('--privacy') or "private"), | |||
"license": str(license or "youtube"), | |||
} | |||
} | |||
if options.get('--publishAt'): | |||
# Youtube needs microsecond and the local timezone from ISO 8601 | |||
publishAt = options.get('--publishAt') + ".000001" | |||
publishAt = datetime.datetime.strptime(publishAt, '%Y-%m-%dT%H:%M:%S.%f') | |||
tz = get_localzone() | |||
tz = pytz.timezone(str(tz)) | |||
publishAt = tz.localize(publishAt).isoformat() | |||
body['status']['publishAt'] = str(publishAt) | |||
# Call the API's videos.insert method to create and upload the video. | |||
insert_request = youtube.videos().insert( | |||
part=','.join(body.keys()), | |||
body=body, | |||
media_body=MediaFileUpload(path, chunksize=-1, resumable=True) | |||
) | |||
video_id = resumable_upload(insert_request, 'video', 'insert') | |||
# If we get a video_id, upload is successful and we are able to set thumbnail | |||
if video_id and options.get('--thumbnail'): | |||
set_thumbnail(youtube, options.get('--thumbnail'), videoId=video_id) | |||
def set_thumbnail(youtube, media_file, **kwargs): | |||
kwargs = utils.remove_empty_kwargs(**kwargs) # See full sample for function | |||
request = youtube.thumbnails().set( | |||
media_body=MediaFileUpload(media_file, chunksize=-1, | |||
resumable=True), | |||
**kwargs | |||
) | |||
# See full sample for function | |||
return resumable_upload(request, 'thumbnail', 'set') | |||
# This method implements an exponential backoff strategy to resume a | |||
# failed upload. | |||
def resumable_upload(request, resource, method): | |||
response = None | |||
error = None | |||
retry = 0 | |||
while response is None: | |||
try: | |||
template = 'Youtube: Uploading %s...' | |||
logging.info(template % resource) | |||
status, response = request.next_chunk() | |||
if response is not None: | |||
if method == 'insert' and 'id' in response: | |||
logging.info('Youtube : Video was successfully uploaded.') | |||
template = 'Youtube: Watch it at https://youtu.be/%s (post-encoding could take some time)' | |||
logging.info(template % response['id']) | |||
return response['id'] | |||
elif method != 'insert' or "id" not in response: | |||
logging.info('Youtube: Thumbnail was successfully set.') | |||
else: | |||
template = ('Youtube : The upload failed with an ' | |||
'unexpected response: %s') | |||
logging.error(template % response) | |||
exit(1) | |||
except HttpError as e: | |||
if e.resp.status in RETRIABLE_STATUS_CODES: | |||
template = 'Youtube : A retriable HTTP error %d occurred:\n%s' | |||
error = template % (e.resp.status, e.content) | |||
else: | |||
raise | |||
except RETRIABLE_EXCEPTIONS as e: | |||
error = 'Youtube : A retriable error occurred: %s' % e | |||
if error is not None: | |||
logging.warning(error) | |||
retry += 1 | |||
if retry > MAX_RETRIES: | |||
logging.error('Youtube : No longer attempting to retry.') | |||
exit(1) | |||
max_sleep = 2 ** retry | |||
sleep_seconds = random.random() * max_sleep | |||
logging.warning('Youtube : Sleeping %f seconds and then retrying...' | |||
% sleep_seconds) | |||
time.sleep(sleep_seconds) | |||
def run(options): | |||
youtube = get_authenticated_service() | |||
try: | |||
initialize_upload(youtube, options) | |||
except HttpError as e: | |||
logging.error('Youtube : An HTTP error %d occurred:\n%s' % (e.resp.status, | |||
e.content)) |
@ -1,24 +0,0 @@ | |||
### This NFO example show how to construct a NFO for your video ### | |||
### All fields are optional, but you need at least one fields (otherwise NFO is useless :-p) ### | |||
### See --help for options explanation | |||
### Prismedia will search and use NFO in this order: ### | |||
### 1. file passed in command line through --nfo ### | |||
### 2. file inside video directory named after --name command line option append with .txt ### | |||
### 3. file inside video directory named after --file command line option with .txt extension ### | |||
[video] | |||
name = videoname | |||
description = Your complete video description | |||
Multilines description | |||
should be wrote with a blank space | |||
at the beginning of the line :) | |||
tags = list of tags, comma separated | |||
mt = True | |||
category = Films | |||
cca = True | |||
privacy = private | |||
disable-comments = True | |||
thumbnail = /path/to/your/thumbnail.jpg # Set the absolute path to your thumbnail | |||
nsfw = True | |||
platform = youtube, peertube | |||
language = French | |||
publishAt=2034-05-07T19:00:00 |
@ -0,0 +1,577 @@ | |||
[[package]] | |||
name = "args" | |||
version = "0.1.0" | |||
description = "Command Arguments for Humans." | |||
category = "main" | |||
optional = false | |||
python-versions = "*" | |||
[[package]] | |||
name = "cachetools" | |||
version = "4.2.0" | |||
description = "Extensible memoizing collections and decorators" | |||
category = "main" | |||
optional = false | |||
python-versions = "~=3.5" | |||
[[package]] | |||
name = "certifi" | |||
version = "2020.12.5" | |||
description = "Python package for providing Mozilla's CA Bundle." | |||
category = "main" | |||
optional = false | |||
python-versions = "*" | |||
[[package]] | |||
name = "chardet" | |||
version = "4.0.0" | |||
description = "Universal encoding detector for Python 2 and 3" | |||
category = "main" | |||
optional = false | |||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" | |||
[[package]] | |||
name = "clint" | |||
version = "0.5.1" | |||
description = "Python Command Line Interface Tools" | |||
category = "main" | |||
optional = false | |||
python-versions = "*" | |||
[package.dependencies] | |||
args = "*" | |||
[[package]] | |||
name = "configparser" | |||
version = "3.8.1" | |||
description = "Updated configparser from Python 3.7 for Python 2.6+." | |||
category = "main" | |||
optional = false | |||
python-versions = ">=2.6" | |||
[package.extras] | |||
docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] | |||
testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2)", "pytest-flake8"] | |||
[[package]] | |||
name = "contextlib2" | |||
version = "0.6.0.post1" | |||
description = "Backports and enhancements for the contextlib module" | |||
category = "main" | |||
optional = false | |||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" | |||
[[package]] | |||
name = "docopt" | |||
version = "0.6.2" | |||
description = "Pythonic argument parser, that will make you smile" | |||
category = "main" | |||
optional = false | |||
python-versions = "*" | |||
[[package]] | |||
name = "future" | |||
version = "0.17.1" | |||
description = "Clean single-source support for Python 3 and 2" | |||
category = "main" | |||
optional = false | |||
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" | |||
[[package]] | |||
name = "google-api-core" | |||
version = "1.25.0" | |||
description = "Google API client core library" | |||
category = "main" | |||
optional = false | |||
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" | |||
[package.dependencies] | |||
google-auth = ">=1.21.1,<2.0dev" | |||
googleapis-common-protos = ">=1.6.0,<2.0dev" | |||
protobuf = ">=3.12.0" | |||
pytz = "*" | |||
requests = ">=2.18.0,<3.0.0dev" | |||
six = ">=1.13.0" | |||
[package.extras] | |||
grpc = ["grpcio (>=1.29.0,<2.0dev)"] | |||
grpcgcp = ["grpcio-gcp (>=0.2.2)"] | |||
grpcio-gcp = ["grpcio-gcp (>=0.2.2)"] | |||
[[package]] | |||
name = "google-api-python-client" | |||
version = "1.12.2" | |||
description = "Google API Client Library for Python" | |||
category = "main" | |||
optional = false | |||
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" | |||
[package.dependencies] | |||
google-api-core = ">=1.21.0,<2dev" | |||
google-auth = ">=1.16.0" | |||
google-auth-httplib2 = ">=0.0.3" | |||
httplib2 = ">=0.9.2,<1dev" | |||
six = ">=1.13.0,<2dev" | |||
uritemplate = ">=3.0.0,<4dev" | |||
[[package]] | |||
name = "google-auth" | |||
version = "1.24.0" | |||
description = "Google Authentication Library" | |||
category = "main" | |||
optional = false | |||
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" | |||
[package.dependencies] | |||
cachetools = ">=2.0.0,<5.0" | |||
pyasn1-modules = ">=0.2.1" | |||
rsa = [ | |||
{version = "<4.6", markers = "python_version < \"3.6\""}, | |||
{version = ">=3.1.4,<5", markers = "python_version >= \"3.6\""}, | |||
] | |||
six = ">=1.9.0" | |||
[package.extras] | |||
aiohttp = ["aiohttp (>=3.6.2,<4.0.0dev)"] | |||
[[package]] | |||
name = "google-auth-httplib2" | |||
version = "0.0.4" | |||
description = "Google Authentication Library: httplib2 transport" | |||
category = "main" | |||
optional = false | |||
python-versions = "*" | |||
[package.dependencies] | |||
google-auth = "*" | |||
httplib2 = ">=0.9.1" | |||
six = "*" | |||
[[package]] | |||
name = "google-auth-oauthlib" | |||
version = "0.4.2" | |||
description = "Google Authentication Library" | |||
category = "main" | |||
optional = false | |||
python-versions = ">=3.6" | |||
[package.dependencies] | |||
google-auth = "*" | |||
requests-oauthlib = ">=0.7.0" | |||
[package.extras] | |||
tool = ["click"] | |||
[[package]] | |||
name = "googleapis-common-protos" | |||
version = "1.52.0" | |||
description = "Common protobufs used in Google APIs" | |||
category = "main" | |||
optional = false | |||
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" | |||
[package.dependencies] | |||
protobuf = ">=3.6.0" | |||
[package.extras] | |||
grpc = ["grpcio (>=1.0.0)"] | |||
[[package]] | |||
name = "httplib2" | |||
version = "0.12.3" | |||
description = "A comprehensive HTTP client library." | |||
category = "main" | |||
optional = false | |||
python-versions = "*" | |||
[[package]] | |||
name = "idna" | |||
version = "2.10" | |||
description = "Internationalized Domain Names in Applications (IDNA)" | |||
category = "main" | |||
optional = false | |||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" | |||
[[package]] | |||
name = "oauthlib" | |||
version = "2.1.0" | |||
description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" | |||
category = "main" | |||
optional = false | |||
python-versions = "*" | |||
[package.extras] | |||
rsa = ["cryptography"] | |||
signals = ["blinker"] | |||
signedtoken = ["cryptography", "pyjwt (>=1.0.0)"] | |||
test = ["nose", "unittest2", "cryptography", "mock", "pyjwt (>=1.0.0)", "blinker"] | |||
[[package]] | |||
name = "protobuf" | |||
version = "3.14.0" | |||
description = "Protocol Buffers" | |||
category = "main" | |||
optional = false | |||
python-versions = "*" | |||
[package.dependencies] | |||
six = ">=1.9" | |||
[[package]] | |||
name = "pyasn1" | |||
version = "0.4.8" | |||
description = "ASN.1 types and codecs" | |||
category = "main" | |||
optional = false | |||
python-versions = "*" | |||
[[package]] | |||
name = "pyasn1-modules" | |||
version = "0.2.8" | |||
description = "A collection of ASN.1-based protocols modules." | |||
category = "main" | |||
optional = false | |||
python-versions = "*" | |||
[package.dependencies] | |||
pyasn1 = ">=0.4.6,<0.5.0" | |||
[[package]] | |||
name = "python-magic" | |||
version = "0.4.20" | |||
description = "File type identification using libmagic" | |||
category = "main" | |||
optional = false | |||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" | |||
[[package]] | |||
name = "python-magic-bin" | |||
version = "0.4.14" | |||
description = "File type identification using libmagic binary package" | |||
category = "main" | |||
optional = false | |||
python-versions = "*" | |||
[[package]] | |||
name = "pytz" | |||
version = "2020.5" | |||
description = "World timezone definitions, modern and historical" | |||
category = "main" | |||
optional = false | |||
python-versions = "*" | |||
[[package]] | |||
name = "requests" | |||
version = "2.25.1" | |||
description = "Python HTTP for Humans." | |||
category = "main" | |||
optional = false | |||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" | |||
[package.dependencies] | |||
certifi = ">=2017.4.17" | |||
chardet = ">=3.0.2,<5" | |||
idna = ">=2.5,<3" | |||
urllib3 = ">=1.21.1,<1.27" | |||
[package.extras] | |||
security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] | |||
socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] | |||
[[package]] | |||
name = "requests-oauthlib" | |||
version = "0.8.0" | |||
description = "OAuthlib authentication support for Requests." | |||
category = "main" | |||
optional = false | |||
python-versions = "*" | |||
[package.dependencies] | |||
oauthlib = ">=0.6.2" | |||
requests = ">=2.0.0" | |||
[package.extras] | |||
rsa = ["oauthlib[rsa] (>=0.6.2)", "requests (>=2.0.0)"] | |||
[[package]] | |||
name = "requests-toolbelt" | |||
version = "0.9.1" | |||
description = "A utility belt for advanced users of python-requests" | |||
category = "main" | |||
optional = false | |||
python-versions = "*" | |||
[package.dependencies] | |||
requests = ">=2.0.1,<3.0.0" | |||
[[package]] | |||
name = "rsa" | |||
version = "4.5" | |||
description = "Pure-Python RSA implementation" | |||
category = "main" | |||
optional = false | |||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" | |||
[package.dependencies] | |||
pyasn1 = ">=0.1.3" | |||
[[package]] | |||
name = "rsa" | |||
version = "4.7" | |||
description = "Pure-Python RSA implementation" | |||
category = "main" | |||
optional = false | |||
python-versions = ">=3.5, <4" | |||
[package.dependencies] | |||
pyasn1 = ">=0.1.3" | |||
[[package]] | |||
name = "schema" | |||
version = "0.7.3" | |||
description = "Simple data validation library" | |||
category = "main" | |||
optional = false | |||
python-versions = "*" | |||
[package.dependencies] | |||
contextlib2 = ">=0.5.5" | |||
[[package]] | |||
name = "six" | |||
version = "1.15.0" | |||
description = "Python 2 and 3 compatibility utilities" | |||
category = "main" | |||
optional = false | |||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" | |||
[[package]] | |||
name = "tzlocal" | |||
version = "1.5.1" | |||
description = "tzinfo object for the local timezone" | |||
category = "main" | |||
optional = false | |||
python-versions = "*" | |||
[package.dependencies] | |||
pytz = "*" | |||
[[package]] | |||
name = "unidecode" | |||
version = "1.1.2" | |||
description = "ASCII transliterations of Unicode text" | |||
category = "main" | |||
optional = false | |||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" | |||
[[package]] | |||
name = "uritemplate" | |||
version = "3.0.1" | |||
description = "URI templates" | |||
category = "main" | |||
optional = false | |||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" | |||
[[package]] | |||
name = "urllib3" | |||
version = "1.26.2" | |||
description = "HTTP library with thread-safe connection pooling, file post, and more." | |||
category = "main" | |||
optional = false | |||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" | |||
[package.extras] | |||
brotli = ["brotlipy (>=0.6.0)"] | |||
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] | |||
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] | |||
[[package]] | |||
name = "yapsy" | |||
version = "1.12.2" | |||
description = "Yet another plugin system" | |||
category = "main" | |||
optional = false | |||
python-versions = "*" | |||
[metadata] | |||
lock-version = "1.1" | |||
python-versions = ">=3.5" | |||
content-hash = "111e189319a1806c4ccf078c97a6a58738985f31a70a64a9bfc153db7b132008" | |||
[metadata.files] | |||
args = [ | |||
{file = "args-0.1.0.tar.gz", hash = "sha256:a785b8d837625e9b61c39108532d95b85274acd679693b71ebb5156848fcf814"}, | |||
] | |||
cachetools = [ | |||
{file = "cachetools-4.2.0-py3-none-any.whl", hash = "sha256:c6b07a6ded8c78bf36730b3dc452dfff7d95f2a12a2fed856b1a0cb13ca78c61"}, | |||
{file = "cachetools-4.2.0.tar.gz", hash = "sha256:3796e1de094f0eaca982441c92ce96c68c89cced4cd97721ab297ea4b16db90e"}, | |||
] | |||
certifi = [ | |||
{file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, | |||
{file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, | |||
] | |||
chardet = [ | |||
{file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, | |||
{file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, | |||
] | |||
clint = [ | |||
{file = "clint-0.5.1.tar.gz", hash = "sha256:05224c32b1075563d0b16d0015faaf9da43aa214e4a2140e51f08789e7a4c5aa"}, | |||
] | |||
configparser = [ | |||
{file = "configparser-3.8.1-py2.py3-none-any.whl", hash = "sha256:45d1272aad6cfd7a8a06cf5c73f2ceb6a190f6acc1fa707e7f82a4c053b28b18"}, | |||
{file = "configparser-3.8.1.tar.gz", hash = "sha256:bc37850f0cc42a1725a796ef7d92690651bf1af37d744cc63161dac62cabee17"}, | |||
] | |||
contextlib2 = [ | |||
{file = "contextlib2-0.6.0.post1-py2.py3-none-any.whl", hash = "sha256:3355078a159fbb44ee60ea80abd0d87b80b78c248643b49aa6d94673b413609b"}, | |||
{file = "contextlib2-0.6.0.post1.tar.gz", hash = "sha256:01f490098c18b19d2bd5bb5dc445b2054d2fa97f09a4280ba2c5f3c394c8162e"}, | |||
] | |||
docopt = [ | |||
{file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, | |||
] | |||
future = [ | |||
{file = "future-0.17.1.tar.gz", hash = "sha256:67045236dcfd6816dc439556d009594abf643e5eb48992e36beac09c2ca659b8"}, | |||
] | |||
google-api-core = [ | |||
{file = "google-api-core-1.25.0.tar.gz", hash = "sha256:d967beae8d8acdb88fb2f6f769e2ee0ee813042576a08891bded3b8e234150ae"}, | |||
{file = "google_api_core-1.25.0-py2.py3-none-any.whl", hash = "sha256:4656345cba9627ab1290eab51300a6397cc50370d99366133df1ae64b744e1eb"}, | |||
] | |||
google-api-python-client = [ | |||
{file = "google-api-python-client-1.12.2.tar.gz", hash = "sha256:54a7d330833a2e7b0587446d7e4ae6d0244925a9a8e1dfe878f3f7e06cdedb62"}, | |||
{file = "google_api_python_client-1.12.2-py2.py3-none-any.whl", hash = "sha256:05cb331ed1aa15746f606c7e36ea51dbe7c29b1a5df9bbf58140901fe23d7142"}, | |||
] | |||
google-auth = [ | |||
{file = "google-auth-1.24.0.tar.gz", hash = "sha256:0b0e026b412a0ad096e753907559e4bdb180d9ba9f68dd9036164db4fdc4ad2e"}, | |||
{file = "google_auth-1.24.0-py2.py3-none-any.whl", hash = "sha256:ce752cc51c31f479dbf9928435ef4b07514b20261b021c7383bee4bda646acb8"}, | |||
] | |||
google-auth-httplib2 = [ | |||
{file = "google-auth-httplib2-0.0.4.tar.gz", hash = "sha256:8d092cc60fb16517b12057ec0bba9185a96e3b7169d86ae12eae98e645b7bc39"}, | |||
{file = "google_auth_httplib2-0.0.4-py2.py3-none-any.whl", hash = "sha256:aeaff501738b289717fac1980db9711d77908a6c227f60e4aa1923410b43e2ee"}, | |||
] | |||
google-auth-oauthlib = [ | |||
{file = "google-auth-oauthlib-0.4.2.tar.gz", hash = "sha256:65b65bc39ad8cab15039b35e5898455d3d66296d0584d96fe0e79d67d04c51d9"}, | |||
{file = "google_auth_oauthlib-0.4.2-py2.py3-none-any.whl", hash = "sha256:d4d98c831ea21d574699978827490a41b94f05d565c617fe1b420e88f1fc8d8d"}, | |||
] | |||
googleapis-common-protos = [ | |||
{file = "googleapis-common-protos-1.52.0.tar.gz", hash = "sha256:560716c807117394da12cecb0a54da5a451b5cf9866f1d37e9a5e2329a665351"}, | |||
{file = "googleapis_common_protos-1.52.0-py2.py3-none-any.whl", hash = "sha256:c8961760f5aad9a711d37b675be103e0cc4e9a39327e0d6d857872f698403e24"}, | |||
] | |||
httplib2 = [ | |||
{file = "httplib2-0.12.3-py3-none-any.whl", hash = "sha256:23914b5487dfe8ef09db6656d6d63afb0cf3054ad9ebc50868ddc8e166b5f8e8"}, | |||
{file = "httplib2-0.12.3.tar.gz", hash = "sha256:a18121c7c72a56689efbf1aef990139ad940fee1e64c6f2458831736cd593600"}, | |||
] | |||
idna = [ | |||
{file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, | |||
{file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, | |||
] | |||
oauthlib = [ | |||
{file = "oauthlib-2.1.0-py2.py3-none-any.whl", hash = "sha256:d883b36b21a6ad813953803edfa563b1b579d79ca758fe950d1bc9e8b326025b"}, | |||
{file = "oauthlib-2.1.0.tar.gz", hash = "sha256:ac35665a61c1685c56336bda97d5eefa246f1202618a1d6f34fccb1bdd404162"}, | |||
] | |||
protobuf = [ | |||
{file = "protobuf-3.14.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:629b03fd3caae7f815b0c66b41273f6b1900a579e2ccb41ef4493a4f5fb84f3a"}, | |||
{file = "protobuf-3.14.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:5b7a637212cc9b2bcf85dd828b1178d19efdf74dbfe1ddf8cd1b8e01fdaaa7f5"}, | |||
{file = "protobuf-3.14.0-cp35-cp35m-macosx_10_9_intel.whl", hash = "sha256:43b554b9e73a07ba84ed6cf25db0ff88b1e06be610b37656e292e3cbb5437472"}, | |||
{file = "protobuf-3.14.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:5e9806a43232a1fa0c9cf5da8dc06f6910d53e4390be1fa06f06454d888a9142"}, | |||
{file = "protobuf-3.14.0-cp35-cp35m-win32.whl", hash = "sha256:1c51fda1bbc9634246e7be6016d860be01747354ed7015ebe38acf4452f470d2"}, | |||
{file = "protobuf-3.14.0-cp35-cp35m-win_amd64.whl", hash = "sha256:4b74301b30513b1a7494d3055d95c714b560fbb630d8fb9956b6f27992c9f980"}, | |||
{file = "protobuf-3.14.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:86a75477addde4918e9a1904e5c6af8d7b691f2a3f65587d73b16100fbe4c3b2"}, | |||
{file = "protobuf-3.14.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ecc33531a213eee22ad60e0e2aaea6c8ba0021f0cce35dbf0ab03dee6e2a23a1"}, | |||
{file = "protobuf-3.14.0-cp36-cp36m-win32.whl", hash = "sha256:72230ed56f026dd664c21d73c5db73ebba50d924d7ba6b7c0d81a121e390406e"}, | |||
{file = "protobuf-3.14.0-cp36-cp36m-win_amd64.whl", hash = "sha256:0fc96785262042e4863b3f3b5c429d4636f10d90061e1840fce1baaf59b1a836"}, | |||
{file = "protobuf-3.14.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4e75105c9dfe13719b7293f75bd53033108f4ba03d44e71db0ec2a0e8401eafd"}, | |||
{file = "protobuf-3.14.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2a7e2fe101a7ace75e9327b9c946d247749e564a267b0515cf41dfe450b69bac"}, | |||
{file = "protobuf-3.14.0-cp37-cp37m-win32.whl", hash = "sha256:b0d5d35faeb07e22a1ddf8dce620860c8fe145426c02d1a0ae2688c6e8ede36d"}, | |||
{file = "protobuf-3.14.0-cp37-cp37m-win_amd64.whl", hash = "sha256:8971c421dbd7aad930c9bd2694122f332350b6ccb5202a8b7b06f3f1a5c41ed5"}, | |||
{file = "protobuf-3.14.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9616f0b65a30851e62f1713336c931fcd32c057202b7ff2cfbfca0fc7d5e3043"}, | |||
{file = "protobuf-3.14.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:22bcd2e284b3b1d969c12e84dc9b9a71701ec82d8ce975fdda19712e1cfd4e00"}, | |||
{file = "protobuf-3.14.0-py2.py3-none-any.whl", hash = "sha256:0e247612fadda953047f53301a7b0407cb0c3cb4ae25a6fde661597a04039b3c"}, | |||
{file = "protobuf-3.14.0.tar.gz", hash = "sha256:1d63eb389347293d8915fb47bee0951c7b5dab522a4a60118b9a18f33e21f8ce"}, | |||
] | |||
pyasn1 = [ | |||
{file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"}, | |||
{file = "pyasn1-0.4.8-py2.5.egg", hash = "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf"}, | |||
{file = "pyasn1-0.4.8-py2.6.egg", hash = "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00"}, | |||
{file = "pyasn1-0.4.8-py2.7.egg", hash = "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8"}, | |||
{file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"}, | |||
{file = "pyasn1-0.4.8-py3.1.egg", hash = "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86"}, | |||
{file = "pyasn1-0.4.8-py3.2.egg", hash = "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7"}, | |||
{file = "pyasn1-0.4.8-py3.3.egg", hash = "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576"}, | |||
{file = "pyasn1-0.4.8-py3.4.egg", hash = "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12"}, | |||
{file = "pyasn1-0.4.8-py3.5.egg", hash = "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2"}, | |||
{file = "pyasn1-0.4.8-py3.6.egg", hash = "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359"}, | |||
{file = "pyasn1-0.4.8-py3.7.egg", hash = "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776"}, | |||
{file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"}, | |||
] | |||
pyasn1-modules = [ | |||
{file = "pyasn1-modules-0.2.8.tar.gz", hash = "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e"}, | |||
{file = "pyasn1_modules-0.2.8-py2.4.egg", hash = "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199"}, | |||
{file = "pyasn1_modules-0.2.8-py2.5.egg", hash = "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405"}, | |||
{file = "pyasn1_modules-0.2.8-py2.6.egg", hash = "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb"}, | |||
{file = "pyasn1_modules-0.2.8-py2.7.egg", hash = "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8"}, | |||
{file = "pyasn1_modules-0.2.8-py2.py3-none-any.whl", hash = "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74"}, | |||
{file = "pyasn1_modules-0.2.8-py3.1.egg", hash = "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d"}, | |||
{file = "pyasn1_modules-0.2.8-py3.2.egg", hash = "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45"}, | |||
{file = "pyasn1_modules-0.2.8-py3.3.egg", hash = "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4"}, | |||
{file = "pyasn1_modules-0.2.8-py3.4.egg", hash = "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811"}, | |||
{file = "pyasn1_modules-0.2.8-py3.5.egg", hash = "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed"}, | |||
{file = "pyasn1_modules-0.2.8-py3.6.egg", hash = "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0"}, | |||
{file = "pyasn1_modules-0.2.8-py3.7.egg", hash = "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd"}, | |||
] | |||
python-magic = [ | |||
{file = "python-magic-0.4.20.tar.gz", hash = "sha256:0cc52ccad086c377b9194014e3dbf98d94b194344630172510a6a3e716b47801"}, | |||
{file = "python_magic-0.4.20-py2.py3-none-any.whl", hash = "sha256:33ce94d9395aa269a9c5fac10ae124a5fb328ebe248f36efc5a43922edee662e"}, | |||
] | |||
python-magic-bin = [ | |||
{file = "python_magic_bin-0.4.14-py2.py3-none-macosx_10_6_intel.whl", hash = "sha256:7b1743b3dbf16601d6eedf4e7c2c9a637901b0faaf24ad4df4d4527e7d8f66a4"}, | |||
{file = "python_magic_bin-0.4.14-py2.py3-none-win32.whl", hash = "sha256:34a788c03adde7608028203e2dbb208f1f62225ad91518787ae26d603ae68892"}, | |||
{file = "python_magic_bin-0.4.14-py2.py3-none-win_amd64.whl", hash = "sha256:90be6206ad31071a36065a2fc169c5afb5e0355cbe6030e87641c6c62edc2b69"}, | |||
] | |||
pytz = [ | |||
{file = "pytz-2020.5-py2.py3-none-any.whl", hash = "sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4"}, | |||
{file = "pytz-2020.5.tar.gz", hash = "sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5"}, | |||
] | |||
requests = [ | |||
{file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, | |||
{file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, | |||
] | |||
requests-oauthlib = [ | |||
{file = "requests-oauthlib-0.8.0.tar.gz", hash = "sha256:883ac416757eada6d3d07054ec7092ac21c7f35cb1d2cf82faf205637081f468"}, | |||
{file = "requests_oauthlib-0.8.0-py2.py3-none-any.whl", hash = "sha256:50a8ae2ce8273e384895972b56193c7409601a66d4975774c60c2aed869639ca"}, | |||
] | |||
requests-toolbelt = [ | |||
{file = "requests-toolbelt-0.9.1.tar.gz", hash = "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"}, | |||
{file = "requests_toolbelt-0.9.1-py2.py3-none-any.whl", hash = "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f"}, | |||
] | |||
rsa = [ | |||
{file = "rsa-4.5-py2.py3-none-any.whl", hash = "sha256:35c5b5f6675ac02120036d97cf96f1fde4d49670543db2822ba5015e21a18032"}, | |||
{file = "rsa-4.5.tar.gz", hash = "sha256:4d409f5a7d78530a4a2062574c7bd80311bc3af29b364e293aa9b03eea77714f"}, | |||
{file = "rsa-4.7-py3-none-any.whl", hash = "sha256:a8774e55b59fd9fc893b0d05e9bfc6f47081f46ff5b46f39ccf24631b7be356b"}, | |||
{file = "rsa-4.7.tar.gz", hash = "sha256:69805d6b69f56eb05b62daea3a7dbd7aa44324ad1306445e05da8060232d00f4"}, | |||
] | |||
schema = [ | |||
{file = "schema-0.7.3-py2.py3-none-any.whl", hash = "sha256:c331438b60f634cab5664ab720d3083cc444f924d55269530c36b33e3354276f"}, | |||
{file = "schema-0.7.3.tar.gz", hash = "sha256:4cf529318cfd1e844ecbe02f41f7e5aa027463e7403666a52746f31f04f47a5e"}, | |||
] | |||
six = [ | |||
{file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, | |||
{file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, | |||
] | |||
tzlocal = [ | |||
{file = "tzlocal-1.5.1.tar.gz", hash = "sha256:4ebeb848845ac898da6519b9b31879cf13b6626f7184c496037b818e238f2c4e"}, | |||
] | |||
unidecode = [ | |||
{file = "Unidecode-1.1.2-py2.py3-none-any.whl", hash = "sha256:4c9d15d2f73eb0d2649a151c566901f80a030da1ccb0a2043352e1dbf647586b"}, | |||
{file = "Unidecode-1.1.2.tar.gz", hash = "sha256:a039f89014245e0cad8858976293e23501accc9ff5a7bdbc739a14a2b7b85cdc"}, | |||
] | |||
uritemplate = [ | |||
{file = "uritemplate-3.0.1-py2.py3-none-any.whl", hash = "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f"}, | |||
{file = "uritemplate-3.0.1.tar.gz", hash = "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae"}, | |||
] | |||
urllib3 = [ | |||
{file = "urllib3-1.26.2-py2.py3-none-any.whl", hash = "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"}, | |||
{file = "urllib3-1.26.2.tar.gz", hash = "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08"}, | |||
] | |||
yapsy = [ | |||
{file = "Yapsy-1.12.2-py3.6.egg", hash = "sha256:83891e22db0a74445726981df0ef7818dae595454de9cf10b7ba603d45ccd157"}, | |||
{file = "Yapsy-1.12.2.tar.gz", hash = "sha256:d8113d9f9c74eacf65b4663c9c037d278c9cb273b5eee5f0e1803baeedb23f8b"}, | |||
] |
@ -0,0 +1,12 @@ | |||
from future import standard_library | |||
standard_library.install_aliases() | |||
import logging | |||
logger = logging.getLogger('Prismedia') | |||
logger.setLevel(logging.INFO) | |||
ch = logging.StreamHandler() | |||
ch.setLevel(logging.INFO) | |||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s: %(message)s') | |||
ch.setFormatter(formatter) | |||
logger.addHandler(ch) | |||
from . import upload |
@ -0,0 +1,2 @@ | |||
from .upload import main | |||
main() |
@ -0,0 +1,58 @@ | |||
from configparser import RawConfigParser | |||
from os.path import splitext, basename, dirname, abspath | |||
class Configuration: | |||
""" | |||
Configuration manager that read the configuration from multiples nfo files that are commons for all interfaces | |||
The configuration will be read and overridden in the following order: | |||
NFO.txt -> nfo.txt -> directory_name.txt -> video_file.txt -> video_name.txt | |||
(value in the rightmost file override values in preceding files) | |||
Your interface may add to this list from either side. Refer to the plugin's description. | |||
A plugin can also override completely this configuration by using other means. For example the cli plugins takes | |||
command line arguments with more importance than any nfo file. | |||
Attributes: | |||
CONFIG_FILE Filename for the base configuration file that should be used to set global behavior for | |||
prismedia and its plugins | |||
""" | |||
CONFIG_FILE = "prismedia.config" # TODO: replace with "config.txt"? something else? | |||
def __init__(self): | |||
self.root_path = dirname(abspath(__file__)) | |||
self.config_parser = RawConfigParser() | |||
self.configuration_file_list = [] | |||
self.base_configuration_file = self.root_path + "/config/" + self.CONFIG_FILE | |||
self.config_parser.read(self.base_configuration_file) | |||
def read_commons_nfo(self, video_path, video_name): | |||
video_directory = dirname(video_path) | |||
directory_name = basename(video_directory) | |||
video_file = splitext(basename(video_path))[0] | |||
self.configuration_file_list.append(video_directory + "/" + "NFO.txt") | |||
self.configuration_file_list.append(video_directory + "/" + "nfo.txt") | |||
self.configuration_file_list.append(video_directory + "/" + directory_name + ".txt") | |||
self.configuration_file_list.append(video_directory + "/" + video_file + ".txt") | |||
if video_name and video_name != video_file: | |||
self.configuration_file_list.append(video_directory + "/" + video_name + ".txt") | |||
self.config_parser.read(self.configuration_file_list) | |||
# Do not use this in actual production ready code. Prismedia should not write any file | |||
# This can be used in local to see how a plugin's variable needs to be written in the global NFO | |||
# I am afraid that trying to save a plugin info will also save the current config for the video | |||
def _write_config(self): | |||
""" | |||
Write the content of the ConfigParser in a file. | |||
""" | |||
# Do not use `assert` since we want to do the opposite and optimize the other way around | |||
if not __debug__: | |||
raise AssertionError("The method `Configuration._write_config` should not be called in production") | |||
with open(self.base_configuration_file + "_generated", "w") as configFile: | |||
self.config_parser.write(configFile) | |||
configuration_instance = Configuration() |
@ -0,0 +1,254 @@ | |||
#!/usr/bin/python | |||
# coding: utf-8 | |||
# NOTE: Since we use config file to set some defaults values, it is not possible to use the standard syntax with brackets, we use parenthesis instead. | |||
# If we were to use them we would override configuration file values with default values of cli. | |||
# TODO: change `youtube-at` and `peertube-at` that are not easily expendable as options in my opinion | |||
# TODO: remove `--url-only` and `--batch` | |||
""" | |||
prismedia - tool to upload videos to different platforms (historically Peertube and Youtube) | |||
Usage: | |||
prismedia [cli] [options] --file=<file> | |||
prismedia <interface> [<parameters>...] | |||
prismedia --heartbeat | |||
prismedia --help | -h | --version | -V | |||
Options: | |||
-f, --file=STRING Path to the video file to upload in mp4. This is the only mandatory option except if you provide the name of a plugin interface (see <interface>). | |||
--thumbnail=STRING Path to a file to use as a thumbnail for the video. | |||
--name=NAME Name of the video to upload. (default to video filename) | |||
-d, --description=STRING Description of the video. (default: default description) | |||
-t, --tag=STRING Tags for the video. comma separated. | |||
WARN: tags with punctuation (!, ', ", ?, ...) | |||
are not supported by Mastodon to be published from Peertube | |||
-c, --category=STRING Category for the videos, see below. (default: Films) | |||
--licence=STRING Creative Common licence tag (for example: CC-BY-SA) (default: proprietary) | |||
-p, --privacy=STRING Choose between public, unlisted or private. (default: private) | |||
--disable-comments Disable comments (Peertube only as YT API does not support) (default: comments are enabled) | |||
--nsfw Set the video as No Safe For Work (Peertube only as YT API does not support) (default: video is safe) | |||
--nfo=STRING Configure a specific nfo file to set options for the video. | |||
By default Prismedia search a .txt based on the video name and will | |||
decode the file as UTF-8 (so make sure your nfo file is UTF-8 encoded) | |||
See nfo_example.txt for more details | |||
--language=STRING Specify the default language for video. See below for supported language. (default is English) | |||
--publish-at=DATE Publish the video at the given DATE using local server timezone. | |||
DATE should be on the form YYYY-MM-DDThh:mm:ss eg: 2018-03-12T19:00:00 | |||
DATE should be in the future | |||
--peertube-at=DATE Override publish-at for the corresponding platform. Allow to create preview on specific platform | |||
--youtube-at=DATE Override publish-at for the corresponding platform. Allow to create preview on specific platform | |||
--original-date=DATE Configure the video as initially recorded at DATE | |||
DATE should be on the form YYYY-MM-DDThh:mm:ss eg: 2018-03-12T19:00:00 | |||
DATE should be in the past | |||
--auto-original-date Automatically use the file modification time as original date | |||
Supported types are jpg and jpeg. | |||
By default, prismedia search for an image based on video name followed by .jpg or .jpeg | |||
--channel=STRING Set the channel to use for the video (Peertube only) | |||
If the channel is not found, spawn an error except if --channelCreate is set. | |||
--channel-create Create the channel if not exists. (Peertube only, default do not create) | |||
Only relevant if --channel is set. | |||
--playlist=STRING Set the playlist to use for the video. | |||
If the playlist is not found, spawn an error except if --playlistCreate is set. | |||
--playlist-create Create the playlist if not exists. (default do not create) | |||
Only relevant if --playlist is set. | |||
--progress=STRING Set the progress bar view, one of percentage, bigFile, accurate. [default: percentage] | |||
--heartbeat Use some credits to show some activity for you apikey so the platform know it is used and would not inactivate your keys. | |||
-h, --help Show this help. Note that calling `help` without the `--` calls a plugin showing a different help for the plugins. | |||
-V, --version Show the version. | |||
Plugins options: | |||
<interface> Interface plugin to use to provide the video to upload. Select the interface you want to use. If `--file` is provided instead the interface will be the command line. | |||
--platform=STRING Platforms plugins to use. Usually one platform plugin upload to one platform website (comma separated list) (default: all) | |||
--consumer=STRING Consumers plugins to use. They are executed after an upload has been done (comma separated list) (default: all) | |||
Logging options: | |||
--log=STRING Log level, between debug, info, warning, error, critical. Ignored if --quiet is set (default to info) | |||
-q, --quiet Suppress any log except Critical (alias for --log=critical). | |||
-u, --url-only Display generated URL after upload directly on stdout, implies --quiet | |||
--batch Display generated URL after upload with platform information for easier parsing. Implies --quiet | |||
Be careful --batch and --url-only are mutually exclusives. | |||
Strict options: | |||
Strict options allow you to force some option to be present when uploading a video. It's useful to be sure you do not | |||
forget something when uploading a video, for example if you use multiples NFO. You may force the presence of description, | |||
tags, thumbnail, ... | |||
All strict option are optionals and are provided only to avoid errors when uploading :-) | |||
All strict options can be specified in NFO directly, the only strict option mandatory on cli is --withNFO | |||
All strict options are off by default | |||
--with-NFO Prevent the upload without a NFO, either specified via cli or found in the directory | |||
--with-thumbnail Prevent the upload without a thumbnail | |||
--with-name Prevent the upload if no name are found | |||
--with-description Prevent the upload without description | |||
--with-tag Prevent the upload without tags | |||
--with-playlist Prevent the upload if no playlist | |||
--with-publish-at Prevent the upload if no schedule | |||
--with-original-date Prevent the upload if no original date configured | |||
--with-platform Prevent the upload if at least one platform is not specified | |||
--with-category Prevent the upload if no category | |||
--with-language Prevent upload if no language | |||
--with-channel Prevent upload if no channel | |||
Categories: | |||
Category is the type of video you upload. Default is films. | |||
Here are available categories from Peertube and Youtube: | |||
music, films, vehicles, sports, travels, gaming, people, | |||
comedy, entertainment, news, how to, education, activism, | |||
science & technology, science, technology, animals | |||
Languages: | |||
Language of the video (audio track), choose one. Default is English | |||
Here are available languages from Peertube and Youtube: | |||
Arabic, English, French, German, Hindi, Italian, Japanese, | |||
Korean, Mandarin, Portuguese, Punjabi, Russian, Spanish | |||
""" | |||
import os | |||
import logging | |||
import pluginInterfaces as pi | |||
import configuration | |||
import utils | |||
import video as vid | |||
from docopt import docopt | |||
from yapsy.PluginManager import PluginManagerSingleton | |||
# logging.basicConfig(level=logging.DEBUG) | |||
VERSION = "prismedia v1.0.0-plugins-alpha" | |||
def loadPlugins(): | |||
from yapsy.ConfigurablePluginManager import ConfigurablePluginManager | |||
config = configuration.configuration_instance | |||
basePluginsPath = [config.root_path + "/plugins"] | |||
# TODO: check if AutoInstallPluginManager can help install new plugins or if it is already easy enough to download | |||
# and unzip a file. | |||
PluginManagerSingleton.setBehaviour([ConfigurablePluginManager]) | |||
pluginManager = PluginManagerSingleton.get() | |||
pluginManager.setPluginPlaces(directories_list=basePluginsPath) | |||
pluginManager.setPluginInfoExtension("prismedia-plugin") | |||
pluginManager.setConfigParser(config.config_parser, pluginManager.config_has_changed) | |||
# Define the various categories corresponding to the different | |||
# kinds of plugins you have defined | |||
pluginManager.setCategoriesFilter({ | |||
pi.PluginTypes.ALL: pi.IPrismediaBasePlugin, | |||
pi.PluginTypes.INTERFACE: pi.IInterfacePlugin, | |||
pi.PluginTypes.PLATFORM: pi.IPlatformPlugin, | |||
pi.PluginTypes.CONSUMER: pi.IConsumerPlugin, | |||
}) | |||
pluginManager.collectPlugins() | |||
return pluginManager | |||
# TODO: cut this function into smaller ones | |||
def main(): | |||
logger = logging.getLogger('Prismedia') | |||
# TODO: Check: Maybe this does not work good when installed via pip. | |||
pluginManager = loadPlugins() | |||
# TODO: add the arguments’s verification (copy/adapt the Schema table) | |||
options = docopt(__doc__, version=VERSION) | |||
# Helper functionalities help the user but do not upload anything | |||
if not utils.helperFunctionalities(options): | |||
exit(os.EX_OK) | |||
# Get all arguments needed by core only before calling any plugin | |||
listPlatforms = utils.getOption(options, "--platform") | |||
listConsumers = utils.getOption(options, "--consumer") | |||
if options["<interface>"]: | |||
interface_name = utils.getOption("<interface>") | |||
else: | |||
interface_name = "cli" | |||
interface = pluginManager.getPluginByName(interface_name, pi.PluginTypes.INTERFACE) | |||
video = vid.Video() | |||
try: | |||
if not interface.plugin_object.prepare_options(video, options): | |||
# The plugin asked to stop execution. | |||
exit(os.EX_OK) | |||
except Exception as e: | |||
logger.critical(utils.get_exception_string(e)) | |||
exit(os.EX_CONFIG) | |||
if listPlatforms: | |||
platforms = pluginManager.getPluginsOf(categories=pi.PluginTypes.PLATFORM, name=[listPlatforms.split(",")]) | |||
else: | |||
platforms = pluginManager.getPluginsOfCategory(pi.PluginTypes.PLATFORM) | |||
if listConsumers: | |||
consumers = pluginManager.getPluginsOf(categories=pi.PluginTypes.CONSUMER, name=[listConsumers.split(",")]) | |||
else: | |||
consumers = pluginManager.getPluginsOfCategory(pi.PluginTypes.CONSUMER) | |||
# Let each plugin check its options before starting any upload | |||
# We cannot merge this loop with the one from interface since the interface can change which plugin to use | |||
# We need to create each platform object in video, so we cannot merge this loop with the following one | |||
for plugin in platforms: | |||
# TODO: Check this is needed or not: in case of no plugin or wrong name maybe the list is empty instead of there being a None value | |||
if plugin is None: | |||
# TODO: log instead to error? critical? | |||
print("No plugin installed name `" + plugin.name + "`.") | |||
exit(os.EX_USAGE) | |||
try: | |||
video.platform[plugin.name] = vid.Platform() | |||
if not plugin.plugin_object.prepare_options(video, options): | |||
# A plugin found ill formed options, it should have logged the precises info | |||
print(plugin.name + " found a malformed option.") | |||
exit(os.EX_CONFIG) | |||
except Exception as e: | |||
logger.critical("Error while preparing plugin `" + plugin.name + "`: " + utils.get_exception_string(e)) | |||
exit(os.EX_CONFIG) | |||
for plugin in consumers: | |||
# TODO: Check this is needed or not: in case of no plugin or wrong name maybe the list is empty instead of there being a None value | |||
if plugin is None: | |||
# TODO: log instead to error? critical? | |||
print("No plugin installed name `" + plugin.name + "`.") | |||
exit(os.EX_USAGE) | |||
try: | |||
if not plugin.plugin_object.prepare_options(video, options): | |||
# A plugin found ill formed options, it should have logged the precises info | |||
print(plugin.name + " found a malformed option.") | |||
exit(os.EX_CONFIG) | |||
except Exception as e: | |||
logger.critical(utils.get_exception_string(e)) | |||
exit(os.EX_CONFIG) | |||
if video.path == "": | |||
# TODO: log instead to error? critical? | |||
print("No valid path to a video file has been provided.") | |||
exit(os.EX_USAGE) | |||
print("All options validated, starting uploads onto platforms") | |||
for platform in platforms: | |||
print("Uploading to: " + platform.name) | |||
try: | |||
platform.plugin_object.upload(video, options) | |||
except Exception as e: # TODO: Maybe not catch every Exception? | |||
logger.critical(utils.get_exception_string(e)) | |||
video.platform[platform.name].error = e | |||
video.platform[platform.name].publishAt = None | |||
video.platform[platform.name].url = None | |||
print("All uploads have been done, calling consumers plugins") | |||
for consumer in consumers: | |||
print("Calling consumer: " + consumer.name) | |||
consumer.plugin_object.finished(video, options) | |||
main() |
@ -0,0 +1,24 @@ | |||
from os.path import join, abspath, isfile, dirname, exists | |||
from os import listdir | |||
from shutil import copyfile | |||
import logging | |||
logger = logging.getLogger('Prismedia') | |||
from . import utils | |||
def genconfig(): | |||
path = join(dirname(__file__), 'config') | |||
files = [f for f in listdir(path) if isfile(join(path, f))] | |||
for f in files: | |||
final_f = f.replace(".sample", "") | |||
if exists(final_f) and not utils.ask_overwrite(final_f + " already exists. Do you want to overwrite it?"): | |||
continue | |||
copyfile(join(path, f), final_f) | |||
logger.info(str(final_f) + " correctly generated, you may now edit it to fill your credentials.") | |||
if __name__ == '__main__': | |||
genconfig() |
@ -0,0 +1,72 @@ | |||
from enum import Enum | |||
from yapsy.IPlugin import IPlugin | |||
class PluginTypes(Enum): | |||
"""Plugin Types possibles to instantiate in this program.""" | |||
ALL = "All" | |||
INTERFACE = "Interface" | |||
PLATFORM = "Platform" | |||
CONSUMER = "Consumer" | |||
class IPrismediaBasePlugin(IPlugin): | |||
""" | |||
Base for prismedia’s plugin. | |||
""" | |||
def prepare_options(self, video, options): | |||
""" | |||
Return a falsy value to exit the program. | |||
- `video`: video object to be uploaded | |||
- `options`: a dictionary of options to be used by Prismedia and other plugins | |||
""" | |||
raise NotImplementedError("`prepare_options` must be reimplemented by %s" % self) | |||
### | |||
# Interface | |||
### | |||
class IInterfacePlugin(IPrismediaBasePlugin): | |||
""" | |||
Interface for the Interface plugin category. | |||
""" | |||
# TODO: Add callback for communicating upload’s progress to the user | |||
### | |||
# Platform | |||
### | |||
class IPlatformPlugin(IPrismediaBasePlugin): | |||
""" | |||
Interface for the Platform plugin category. | |||
""" | |||
def heartbeat(self): | |||
""" | |||
If needed for your platform, use a bit of the api so the platform is aware the keys are still in use. | |||
""" | |||
raise NotImplementedError("`heartbeat` must be reimplemented by %s" % self) | |||
def upload(self, video, options): | |||
""" | |||
The upload function | |||
""" | |||
raise NotImplementedError("`upload` must be reimplemented by %s" % self) | |||
### | |||
# Consumer | |||
### | |||
class IConsumerPlugin(IPrismediaBasePlugin): | |||
""" | |||
Interface for the Consumer plugin category. | |||
""" | |||
def finished(self, video, options): | |||
""" | |||
What to do once the uploads are done. | |||
- `video` is an object containing the video details. The `platforms` key contain a list of the platforms the video has been uploaded to and the status | |||
""" | |||
raise NotImplementedError("`finished` must be reimplemented by %s" % self) |
@ -0,0 +1,9 @@ | |||
[Core] | |||
Name = debug | |||
Module = debug | |||
[Documentation] | |||
Author = Zykino | |||
Version = 0.1 | |||
Website = https://git.lecygnenoir.info/LecygneNoir/prismedia | |||
Description = Show status of elements |
@ -0,0 +1,23 @@ | |||
#!/usr/bin/env python | |||
# coding: utf-8 | |||
import pluginInterfaces as pi | |||
import utils | |||
import video as vid | |||
class Debug(pi.IConsumerPlugin): | |||
""" | |||
Plugin to help knowing the state of prismedia and its variables. | |||
""" | |||
def prepare_options(self, video, options): | |||
print("Debug plugin prepare_options:") | |||
print("Video: ", video) | |||
print("Options: ", options) | |||
return True | |||
def finished(self, video, options): | |||
print("Debug plugin finished:") | |||
print("Video: ", video) | |||
print("Options: ", options) |
@ -0,0 +1,10 @@ | |||
[Core] | |||
Name = cli | |||
Module = cli | |||
[Documentation] | |||
Author = Zykino | |||
Version = 0.1 | |||
Website = https://git.lecygnenoir.info/LecygneNoir/prismedia | |||
Description = This interface plugin is used to get the videos details from the Command Line Interface (cli). | |||
To work properly it needs to have the `--file="<path/to/you/vide/file>"` argument |
@ -0,0 +1,68 @@ | |||
import pluginInterfaces as pi | |||
import utils | |||
import configuration | |||
class Cli(pi.IInterfacePlugin): | |||
""" | |||
This is the default interface plugin. It is used when no interface plugin is specified. | |||
Its core functionality is available as a function call to `prepare_options(video, options)` if you do not need the | |||
Cli object. | |||
This can be useful to let other plugins to rely on the defaults behaviors proposed by this one and extend it. | |||
""" | |||
def prepare_options(self, video, options): | |||
prepare_options(video, options) | |||
def prepare_options(video, options): | |||
# TODO: Add the configuration file from the `--nfo` cli argument | |||
_store_docopt_to_configuration(video, options) | |||
_populate_configuration_into_video(video) | |||
def _store_docopt_to_configuration(options): | |||
items = {} | |||
for key, value in options: | |||
if key.startswith("--"): | |||
options.pop(key) | |||
items[key.strip("- ")] = value | |||
configuration.configuration_instance.config_parser.read_dict(items) | |||
def _populate_configuration_into_video(self, video): | |||
config = configuration.configuration_instance | |||
video.path = utils.getOption(config, "file", video.path) | |||
video.thumbnail = utils.getOption(config, "thumbnail", video.thumbnail) | |||
video.name = utils.getOption(config, "name", video.name) | |||
video.description = utils.getOption(config, "description", video.description) | |||
video.playlistName = utils.getOption(config, "playlist", video.playlistName) | |||
video.privacy = utils.getOption(config, "privacy", video.privacy).lower() | |||
video.category = utils.getOption(config, "category", video.category).lower() | |||
tags = utils.getOption(config, "tag", video.tags) | |||
if isinstance(tags, str): | |||
tags = tags.split(",") | |||
video.tags = tags | |||
video.language = utils.getOption(config, "language", video.language).lower() | |||
video.originalDate = utils.getOption(config, "original-date", video.originalDate) | |||
# TODO: set as an object: { "all": date1, "platformX": date2, … }? | |||
# Maybe the publishAt by platform is better placed in `self.platform` | |||
# And so publishAt would only contains the global date. | |||
video.publishAt = utils.getOption(config, "publish-at", video.publishAt) | |||
# TODO: Add a list of licences | |||
video.licence = utils.getOption(config, "licence", video.licence) | |||
video.disableComments = utils.getOption(config, "disable-comments", video.disableComments) | |||
video.nsfw = utils.getOption(config, "nsfw", video.nsfw) | |||
autoOriginalDate = utils.getOption(config, "auto-original-date", False) | |||
if autoOriginalDate: | |||
# TODO: Implement | |||
raise NotImplementedError("auto-original-date functionality is not yet implemented.") | |||
return video | |||
@ -0,0 +1,9 @@ | |||
[Core] | |||
Name = help | |||
Module = help | |||
[Documentation] | |||
Author = Zykino | |||
Version = 0.1 | |||
Website = https://git.lecygnenoir.info/LecygneNoir/prismedia | |||
Description = Give information about plugins usage by using the syntax `prismedia help [<plugin_name>...]`. |
@ -0,0 +1,51 @@ | |||
import pluginInterfaces as pi | |||
from yapsy.PluginManager import PluginManagerSingleton | |||
class Help(pi.IInterfacePlugin): | |||
""" | |||
The help plugin print the usage information of prismedia’s plugins. | |||
Use it by simply calling `prismedia help <plugin_name>...`. | |||
For example `prismedia help help` bring this help. | |||
""" | |||
def prepare_options(self, video, options): | |||
print(__name__) | |||
pluginManager = PluginManagerSingleton.get() | |||
if options["<parameters>"]: | |||
parameters = options["<parameters>"] | |||
else: | |||
parameters = ["help"] | |||
for p in parameters: | |||
plugin = pluginManager.getPluginByName(p, pi.PluginTypes.ALL) | |||
if plugin is None: | |||
# TODO: log instead to warning? error? | |||
print("No plugin was found with name:", p) | |||
continue | |||
print(plugin.name + "\t" + plugin.description) | |||
print("Usage:", plugin.plugin_object.__doc__) | |||
# Generic help this plugin is able to give for the | |||
if p == "help": | |||
print("The plugins are stored in the following folders:", pluginManager.getPluginLocator().plugins_places) | |||
print("Category:", pi.PluginTypes.INTERFACE.value) | |||
for plugin in pluginManager.getPluginsOfCategory(pi.PluginTypes.INTERFACE): | |||
print("\t" + plugin.name) | |||
print("Category:", pi.PluginTypes.PLATFORM.value) | |||
for plugin in pluginManager.getPluginsOfCategory(pi.PluginTypes.PLATFORM): | |||
print("\t" + plugin.name) | |||
print("Category:", pi.PluginTypes.CONSUMER.value) | |||
for plugin in pluginManager.getPluginsOfCategory(pi.PluginTypes.CONSUMER): | |||
print("\t" + plugin.name) | |||
# Print a line break between each plugin help. | |||
print() | |||
return False |
@ -0,0 +1,11 @@ | |||
[Core] | |||
Name = peertube | |||
Module = peertube | |||
[Documentation] | |||
Author = Le Cygne Noir | |||
Version = 0.1 | |||
Website = https://git.lecygnenoir.info/LecygneNoir/prismedia | |||
Description = Upload to the peertube platform | |||
**NOT SECURE:** If the peertube instance you want to upload to has no SSL certificate (http but not https), you can set the environment variable `OAUTHLIB_INSECURE_TRANSPORT=1`. Keep in mind that using this option makes your credentials vulnerable to interception by a malicious 3rd party. Use this only with dummy credential on a test instance. |
@ -0,0 +1,408 @@ | |||
#!/usr/bin/env python | |||
# coding: utf-8 | |||
import pluginInterfaces as pi | |||
import utils | |||
import mimetypes | |||
import json | |||
import logging | |||
import datetime | |||
import pytz | |||
from os.path import splitext, basename, abspath | |||
from tzlocal import get_localzone | |||
from configparser import RawConfigParser | |||
from requests_oauthlib import OAuth2Session | |||
from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor | |||
from oauthlib.oauth2 import LegacyApplicationClient | |||
from clint.textui.progress import Bar as ProgressBar | |||
from yapsy.PluginManager import PluginManagerSingleton | |||
logger = logging.getLogger('Prismedia') | |||
upload_finished = False | |||
class Peertube(pi.IPlatformPlugin): | |||
""" | |||
Plugin to upload to the Peertube platform. | |||
The connections files should be set as # TODO: EXPLAIN HOW TO SETUP THE SECRET FILES | |||
- `publish-at-peertube=DATE`: overrides the default `publish-at=DATE` for this platform. # TODO: Maybe we will use a [<plugin_name>] section on the config fire, explain that. | |||
""" | |||
NAME = "peertube" # TODO: find if it is possible to get the plugin’s name from inside the plugin | |||
SECRETS_FILE = "peertube_secret" | |||
PRIVACY = { | |||
"public": 1, | |||
"unlisted": 2, | |||
"private": 3 | |||
} | |||
CATEGORY = { | |||
"music": 1, | |||
"films": 2, | |||
"vehicles": 3, | |||
"sport": 5, | |||
"travels": 6, | |||
"gaming": 7, | |||
"people": 8, | |||
"comedy": 9, | |||
"entertainment": 10, | |||
"news": 11, | |||
"how to": 12, | |||
"education": 13, | |||
"activism": 14, | |||
"science & technology": 15, | |||
"science": 15, | |||
"technology": 15, | |||
"animals": 16 | |||
} | |||
LANGUAGE = { | |||
"arabic": "ar", | |||
"english": "en", | |||
"french": "fr", | |||
"german": "de", | |||
"hindi": "hi", | |||
"italian": "it", | |||
"japanese": "ja", | |||
"korean": "ko", | |||
"mandarin": "zh", | |||
"portuguese": "pt", | |||
"punjabi": "pa", | |||
"russian": "ru", | |||
"spanish": "es" | |||
} | |||
def __init__(self): | |||
self.channelCreate = False | |||
self.oauth = {} | |||
self.secret = {} | |||
def prepare_options(self, video, options): | |||
pluginManager = PluginManagerSingleton.get() | |||
# TODO: get the `publish-at-peertube=DATE` option | |||
# TODO: get the `channel` and `channel-create` options | |||
pluginManager.registerOptionFromPlugin("Platform", self.NAME, "publish-at", "2034-05-07T19:00:00") | |||
pluginManager.registerOptionFromPlugin("Platform", self.NAME, "channel", "toto") | |||
pluginManager.registerOptionFromPlugin("Platform", self.NAME, "channel-create", False) | |||
video.platform[self.NAME].channel = "" | |||
self.secret = RawConfigParser() | |||
self.secret.read(self.SECRETS_FILE) | |||
self.get_authenticated_service() | |||
return True | |||
def get_authenticated_service(self): | |||
instance_url = str(self.secret.get('peertube', 'peertube_url')).rstrip("/") | |||
oauth_client = LegacyApplicationClient( | |||
client_id=str(self.secret.get('peertube', 'client_id')) | |||
) | |||
self.oauth = OAuth2Session(client=oauth_client) | |||
self.oauth.fetch_token( | |||
token_url=str(instance_url + '/api/v1/users/token'), | |||
# lower as peertube does not store uppercase for pseudo | |||
username=str(self.secret.get('peertube', 'username').lower()), | |||
password=str(self.secret.get('peertube', 'password')), | |||
client_id=str(self.secret.get('peertube', 'client_id')), | |||
client_secret=str(self.secret.get('peertube', 'client_secret')) | |||
) | |||
def convert_peertube_date(self, date): | |||
date = datetime.datetime.strptime(date, '%Y-%m-%dT%H:%M:%S') | |||
tz = get_localzone() | |||
tz = pytz.timezone(str(tz)) | |||
return tz.localize(date).isoformat() | |||
def get_default_channel(self, user_info): | |||
return user_info['videoChannels'][0]['id'] | |||
def get_channel_by_name(self, user_info, video): | |||
for channel in user_info["videoChannels"]: | |||
if channel['displayName'] == video.platform[self.NAME].channel: | |||
return channel['id'] | |||
def create_channel(self, instance_url, video): | |||
template = ('Peertube: Channel %s does not exist, creating it.') | |||
logger.info(template % (video.platform[self.NAME].channel)) | |||
channel_name = utils.cleanString(video.platform[self.NAME].channel) | |||
# Peertube allows 20 chars max for channel name | |||
channel_name = channel_name[:19] | |||
data = '{"name":"' + channel_name + '", \ | |||
"displayName":"' + video.platform[self.NAME].channel + '", \ | |||
"description":null, \ | |||
"support":null}' | |||
headers = { | |||
'Content-Type': "application/json; charset=UTF-8" | |||
} | |||
try: | |||
response = self.oauth.post(instance_url + "/api/v1/video-channels/", | |||
data=data.encode('utf-8'), | |||
headers=headers) | |||
except Exception as e: | |||
logger.error("Peertube: " + utils.get_exception_string(e)) | |||
if response is not None: | |||
if response.status_code == 200: | |||
jresponse = response.json() | |||
jresponse = jresponse['videoChannel'] | |||
return jresponse['id'] | |||
if response.status_code == 409: | |||
logger.critical('Peertube: It seems there is a conflict with an existing channel named ' | |||
+ channel_name + '.' | |||
' Please beware Peertube internal name is compiled from 20 firsts characters of channel name.' | |||
' Also note that channel name are not case sensitive (no uppercase nor accent)' | |||
' Please check your channel name and retry.') | |||
exit(1) | |||
else: | |||
logger.critical(('Peertube: Creating channel failed with an unexpected response: ' | |||
'%s') % response) | |||
exit(1) | |||
def get_default_playlist(self, user_info): | |||
return user_info['videoChannels'][0]['id'] | |||
def get_playlist_by_name(self, instance_url, username, video): | |||
start = 0 | |||
user_playlists = json.loads(self.oauth.get( | |||
instance_url + "/api/v1/accounts/" + username + "/video-playlists?start=" + str( | |||
start) + "&count=100").content) | |||
total = user_playlists["total"] | |||
data = user_playlists["data"] | |||
# We need to iterate on pagination as peertube returns max 100 playlists (see #41) | |||
while start < total: | |||
for playlist in data: | |||
if playlist['displayName'] == video.playlistName: | |||
return playlist['id'] | |||
start = start + 100 | |||
user_playlists = json.loads(self.oauth.get( | |||
instance_url + "/api/v1/accounts/" + username + "/video-playlists?start=" + str( | |||
start) + "&count=100").content) | |||
data = user_playlists["data"] | |||
def create_playlist(self, instance_url, video, channel): | |||
template = ('Peertube: Playlist %s does not exist, creating it.') | |||
logger.info(template % (str(video.playlistName))) | |||
# We use files for form-data Content | |||
# see https://requests.readthedocs.io/en/latest/user/quickstart/#post-a-multipart-encoded-file | |||
# None is used to mute "filename" field | |||
files = {'displayName': (None, str(video.playlistName)), | |||
'privacy': (None, "1"), | |||
'description': (None, "null"), | |||
'videoChannelId': (None, str(channel)), | |||
'thumbnailfile': (None, "null")} | |||
try: | |||
response = self.oauth.post(instance_url + "/api/v1/video-playlists/", | |||
files=files) | |||
except Exception as e: | |||
logger.error("Peertube: " + utils.get_exception_string(e)) | |||
if response is not None: | |||
if response.status_code == 200: | |||
jresponse = response.json() | |||
jresponse = jresponse['videoPlaylist'] | |||
return jresponse['id'] | |||
else: | |||
logger.critical(('Peertube: Creating the playlist failed with an unexpected response: ' | |||
'%s') % response) | |||
exit(1) | |||
def set_playlist(self, instance_url, video_id, playlist_id): | |||
logger.info('Peertube: add video to playlist.') | |||
data = '{"videoId":"' + str(video_id) + '"}' | |||
headers = { | |||
'Content-Type': "application/json" | |||
} | |||
try: | |||
response = self.oauth.post(instance_url + "/api/v1/video-playlists/" + str(playlist_id) + "/videos", | |||
data=data, | |||
headers=headers) | |||
except Exception as e: | |||
logger.error("Peertube: " + utils.get_exception_string(e)) | |||
if response is not None: | |||
if response.status_code == 200: | |||
logger.info('Peertube: Video is successfully added to the playlist.') | |||
else: | |||
logger.critical(('Peertube: Configuring the playlist failed with an unexpected response: ' | |||
'%s') % response) | |||
exit(1) | |||
def upload_video(self, video, options): | |||
def get_userinfo(base_url): | |||
return json.loads(self.oauth.get(base_url + "/api/v1/users/me").content) | |||
def get_file(video_path): | |||
mimetypes.init() | |||
return (basename(video_path), open(abspath(video_path), 'rb'), | |||
mimetypes.types_map[splitext(video_path)[1]]) | |||
path = video.path | |||
instance_url = str(self.secret.get('peertube', 'peertube_url')).rstrip('/') | |||
user_info = get_userinfo(instance_url) | |||
username = str(self.secret.get('peertube', 'username').lower()) | |||
# We need to transform fields into tuple to deal with tags as | |||
# MultipartEncoder does not support list refer | |||
# https://github.com/requests/toolbelt/issues/190 and | |||
# https://github.com/requests/toolbelt/issues/205 | |||
fields = [ | |||
("name", video.name), | |||
("licence", "1"), # TODO: get licence from video object | |||
("description", video.description), | |||
("category", str(self.CATEGORY[video.category])), | |||
("language", str(self.LANGUAGE[video.language])), | |||
("commentsEnabled", "0" if video.disableComments else "1"), | |||
("nsfw", "1" if video.nsfw else "0"), | |||
("videofile", get_file(path)) | |||
] | |||
tag_number = 0 | |||
for strtag in video.tags: | |||
tag_number = tag_number + 1 | |||
# Empty tag crashes Peertube, so skip them | |||
if strtag == "": | |||
continue | |||
# Tag more than 30 chars crashes Peertube, so skip tags | |||
if len(strtag) >= 30: | |||
logger.warning( | |||
"Peertube: Sorry, Peertube does not support tag with more than 30 characters, please reduce tag: " + strtag) | |||
logger.warning("Peertube: Meanwhile, this tag will be skipped") | |||
continue | |||
# Peertube supports only 5 tags at the moment | |||
if tag_number > 5: | |||
logger.warning("Peertube: Sorry, Peertube support 5 tags max, additional tag will be skipped") | |||
logger.warning("Peertube: Skipping tag " + strtag) | |||
continue | |||
fields.append(("tags[]", strtag)) | |||
# If peertubeAt exists, use instead of publishAt | |||
if video.platform[self.NAME].publishAt: | |||
publishAt = video.platform[self.NAME].publishAt | |||
elif video.publishAt: | |||
publishAt = video.publishAt | |||
if 'publishAt' in locals(): | |||
publishAt = convert_peertube_date(publishAt) | |||
fields.append(("scheduleUpdate[updateAt]", publishAt)) | |||
fields.append(("scheduleUpdate[privacy]", str(self.PRIVACY["public"]))) | |||
fields.append(("privacy", str(self.PRIVACY["private"]))) | |||
else: | |||
fields.append(("privacy", str(self.PRIVACY[video.privacy]))) | |||
if video.originalDate: | |||
originalDate = convert_peertube_date(video.originalDate) | |||
fields.append(("originallyPublishedAt", originalDate)) | |||
if video.thumbnail: | |||
fields.append(("thumbnailfile", get_file(video.thumbnail))) | |||
fields.append(("previewfile", get_file(video.thumbnail))) | |||
if hasattr(video.platform[self.NAME], "channel"): # TODO: Should always be present | |||
channel_id = self.get_channel_by_name(user_info, video) | |||
if not channel_id and self.channelCreate: | |||
channel_id = self.create_channel(instance_url, video) | |||
elif not channel_id: | |||
logger.warning("Peertube: Channel `" + video.platform[ | |||
self.NAME].channel + "` is unknown, using default channel.") # TODO: debate if we should have the same message and behavior than playlist: "does not exist, please set --channelCreate" | |||
channel_id = self.get_default_channel(user_info) | |||
else: | |||
channel_id = self.get_default_channel(user_info) | |||
fields.append(("channelId", str(channel_id))) | |||
if video.playlistName: | |||
playlist_id = get_playlist_by_name(instance_url, username, video) | |||
if not playlist_id and video.playlistCreate: | |||
playlist_id = create_playlist(instance_url, video, channel_id) | |||
elif not playlist_id: | |||
logger.critical( | |||
"Peertube: Playlist `" + video.playlistName + "` does not exist, please set --playlistCreate" | |||
" if you want to create it") | |||
exit(1) | |||
encoder = MultipartEncoder(fields) | |||
if options.get('--quiet'): | |||
multipart_data = encoder | |||
else: | |||
progress_callback = self.create_callback(encoder, options.get('--progress')) | |||
multipart_data = MultipartEncoderMonitor(encoder, progress_callback) | |||
headers = { | |||
'Content-Type': multipart_data.content_type | |||
} | |||
response = self.oauth.post(instance_url + "/api/v1/videos/upload", | |||
data=multipart_data, | |||
headers=headers) | |||
if response is not None: | |||
if response.status_code == 200: | |||
jresponse = response.json() | |||
jresponse = jresponse['video'] | |||
uuid = jresponse['uuid'] | |||
video_id = str(jresponse['id']) | |||
logger.info("Peertube: Video was successfully uploaded.") | |||
template_url = "%s/videos/watch/%s" | |||
video.platform[self.NAME].url = template_url % (instance_url, uuid) | |||
logger.info("Peertube: Watch it at " + video.platform[self.NAME].url + ".") | |||
# Upload is successful we may set playlist | |||
if 'playlist_id' in locals(): | |||
set_playlist(instance_url, video_id, playlist_id) | |||
else: | |||
logger.critical(('Peertube: The upload failed with an unexpected response: ' | |||
'%s') % response) | |||
exit(1) | |||
def create_callback(self, encoder, progress_type): | |||
upload_size_MB = encoder.len * (1 / (1024 * 1024)) | |||
if progress_type is None or "percentage" in progress_type.lower(): | |||
progress_lambda = lambda x: int((x / encoder.len) * 100) # Default to percentage | |||
elif "bigfile" in progress_type.lower(): | |||
progress_lambda = lambda x: x * (1 / (1024 * 1024)) # MB | |||
elif "accurate" in progress_type.lower(): | |||
progress_lambda = lambda x: x * (1 / (1024)) # kB | |||
else: | |||
# Should not happen outside of development when adding partly a progress type | |||
logger.critical("Peertube: Unknown progress type `" + progress_type + "`") | |||
exit(1) | |||
bar = ProgressBar(expected_size=progress_lambda(encoder.len), label=f"Peertube upload progress ({upload_size_MB:.2f}MB) ", filled_char='=') | |||
def callback(monitor): | |||
# We want the condition to capture the varible from the parent scope, not a local variable that is created after | |||
global upload_finished | |||
progress = progress_lambda(monitor.bytes_read) | |||
bar.show(progress) | |||
if monitor.bytes_read == encoder.len: | |||
if not upload_finished: | |||
# We get two time in the callback with both bytes equals, skip the first | |||
upload_finished = True | |||
else: | |||
# Print a blank line to not (partly) override the progress bar | |||
print() | |||
logger.info("Peertube: Upload finish, Processing…") | |||
return callback | |||
def heartbeat(self): | |||
""" | |||
If needed for your platform, use a bit of the api so the platform is aware the keys are still in use. | |||
""" | |||
print("heartbeat for peertube (nothing to do)") | |||
pass | |||
# def run(options): | |||
def upload(self, video, options): | |||
logger.info('Peertube: Uploading video...') | |||
self.upload_video(video, options) |
@ -0,0 +1,397 @@ | |||
#!/usr/bin/env python | |||
# coding: utf-8 | |||
# From Youtube samples: https://raw.githubusercontent.com/youtube/api-samples/master/python/upload_video.py # noqa | |||
import http.client | |||
import httplib2 | |||
import random | |||
import time | |||
import copy | |||
import json | |||
from os.path import splitext, basename, exists | |||
import os | |||
import google.oauth2.credentials | |||
import datetime | |||
import pytz | |||
import logging | |||
from tzlocal import get_localzone | |||
from googleapiclient.discovery import build | |||
from googleapiclient.errors import HttpError | |||
from googleapiclient.http import MediaFileUpload | |||
from google_auth_oauthlib.flow import InstalledAppFlow | |||
from . import utils | |||
logger = logging.getLogger('Prismedia') | |||
# Explicitly tell the underlying HTTP transport library not to retry, since | |||
# we are handling retry logic ourselves. | |||
httplib2.RETRIES = 1 | |||
# Maximum number of times to retry before giving up. | |||
MAX_RETRIES = 10 | |||
# Youtube retriables cases | |||
RETRIABLE_EXCEPTIONS = ( | |||
IOError, | |||
httplib2.HttpLib2Error, | |||
http.client.NotConnected, | |||
http.client.IncompleteRead, | |||
http.client.ImproperConnectionState, | |||
http.client.CannotSendRequest, | |||
http.client.CannotSendHeader, | |||
http.client.ResponseNotReady, | |||
http.client.BadStatusLine, | |||
) | |||
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', 'https://www.googleapis.com/auth/youtube.force-ssl'] | |||
API_SERVICE_NAME = 'youtube' | |||
API_VERSION = 'v3' | |||
CATEGORY = { | |||
"music": 10, | |||
"films": 1, | |||
"vehicles": 2, | |||
"sport": 17, | |||
"travels": 19, | |||
"gaming": 20, | |||
"people": 22, | |||
"comedy": 23, | |||
"entertainment": 24, | |||
"news": 25, | |||
"how to": 26, | |||
"education": 27, | |||
"activism": 29, | |||
"science & technology": 28, | |||
"science": 28, | |||
"technology": 28, | |||
"animals": 15 | |||
} | |||
LANGUAGE = { | |||
"arabic": 'ar', | |||
"english": 'en', | |||
"french": 'fr', | |||
"german": 'de', | |||
"hindi": 'hi', | |||
"italian": 'it', | |||
"japanese": 'ja', | |||
"korean": 'ko', | |||
"mandarin": 'zh-CN', | |||
"portuguese": 'pt-PT', | |||
"punjabi": 'pa', | |||
"russian": 'ru', | |||
"spanish": 'es' | |||
} | |||
# 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): | |||
with open(CREDENTIALS_PATH, 'r') as f: | |||
credential_params = json.load(f) | |||
credentials = google.oauth2.credentials.Credentials( | |||
credential_params["token"], | |||
refresh_token=credential_params["_refresh_token"], | |||
token_uri=credential_params["_token_uri"], | |||
client_id=credential_params["_client_id"], | |||
client_secret=credential_params["_client_secret"] | |||
) | |||
else: | |||
credentials = flow.run_console() | |||
with open(CREDENTIALS_PATH, 'w') as f: | |||
p = copy.deepcopy(vars(credentials)) | |||
del p["expiry"] | |||
json.dump(p, f) | |||
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: | |||
logger.warning("Youtube: Credentials are obsolete, need to re-authenticate.") | |||
os.remove(CREDENTIALS_PATH) | |||
def convert_youtube_date(date): | |||
# Youtube needs microsecond and the local timezone from ISO 8601 | |||
date = date + ".000001" | |||
date = datetime.datetime.strptime(date, '%Y-%m-%dT%H:%M:%S.%f') | |||
tz = get_localzone() | |||
tz = pytz.timezone(str(tz)) | |||
return tz.localize(date).isoformat() | |||
def initialize_upload(youtube, options): | |||
path = options.get('--file') | |||
tags = None | |||
if options.get('--tags'): | |||
tags = options.get('--tags').split(',') | |||
category = None | |||
if options.get('--category'): | |||
category = CATEGORY[options.get('--category').lower()] | |||
language = None | |||
if options.get('--language'): | |||
language = LANGUAGE[options.get('--language').lower()] | |||
license = None | |||
if options.get('--cca'): | |||
license = "creativeCommon" | |||
# We set recordingDetails empty because it's easier to add options if it already exists | |||
# and if empty, it does not cause problem during upload | |||
body = { | |||
"snippet": { | |||
"title": options.get('--name'), | |||
"description": options.get('--description') or "default description", | |||
"tags": tags, | |||
# if no category, set default to 1 (Films) | |||
"categoryId": str(category or 1), | |||
"defaultAudioLanguage": str(language or 'en') | |||
}, | |||
"status": { | |||
"privacyStatus": str(options.get('--privacy') or "private"), | |||
"license": str(license or "youtube"), | |||
}, | |||
"recordingDetails": { | |||
} | |||
} | |||
# If peertubeAt exists, use instead of publishAt | |||
if options.get('--youtubeAt'): | |||
publishAt = options.get('--youtubeAt') | |||
elif options.get('--publishAt'): | |||
publishAt = options.get('--publishAt') | |||
# Check if publishAt variable exists in local variables | |||
if 'publishAt' in locals(): | |||
publishAt = convert_youtube_date(publishAt) | |||
body['status']['publishAt'] = str(publishAt) | |||
# Set originalDate except if the user force no originalDate | |||
if options.get('--originalDate'): | |||
originalDate = convert_youtube_date(options.get('--originalDate')) | |||
body['recordingDetails']['recordingDate'] = str(originalDate) | |||
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: | |||
logger.warning("Youtube: Playlist `" + options.get('--playlist') + "` is unknown.") | |||
logger.warning("Youtube: 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(list(body.keys())), | |||
body=body, | |||
media_body=MediaFileUpload(path, chunksize=-1, resumable=True) | |||
) | |||
video_id = resumable_upload(insert_request, 'video', 'insert', options) | |||
# If we get a video_id, upload is successful and we are able to set thumbnail | |||
if video_id and options.get('--thumbnail'): | |||
set_thumbnail(options, youtube, options.get('--thumbnail'), videoId=video_id) | |||
# If we get a video_id and a playlist_id, upload is successful and we are able to set playlist | |||
if video_id and playlist_id != "": | |||
set_playlist(youtube, playlist_id, video_id) | |||
def get_playlist_by_name(youtube, playlist_name): | |||
pageToken = "" | |||
while pageToken != None: | |||
response = youtube.playlists().list( | |||
part='snippet,id', | |||
mine=True, | |||
maxResults=50, | |||
pageToken=pageToken | |||
).execute() | |||
for playlist in response["items"]: | |||
if playlist["snippet"]["title"] == playlist_name: | |||
return playlist["id"] | |||
# Ask next page if there are any | |||
if "nextPageToken" in response: | |||
pageToken = response["nextPageToken"] | |||
else: | |||
pageToken = None | |||
def create_playlist(youtube, playlist_name): | |||
template = 'Youtube: Playlist %s does not exist, creating it.' | |||
logger.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(options, youtube, media_file, **kwargs): | |||
kwargs = utils.remove_empty_kwargs(**kwargs) | |||
request = youtube.thumbnails().set( | |||
media_body=MediaFileUpload(media_file, chunksize=-1, | |||
resumable=True), | |||
**kwargs | |||
) | |||
return resumable_upload(request, 'thumbnail', 'set', options) | |||
def set_playlist(youtube, playlist_id, video_id): | |||
logger.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: | |||
logger.critical("Youtube: " + utils.get_exception_string(e)) | |||
exit(1) | |||
logger.info('Youtube: Video is correctly added to the playlist.') | |||
# This method implements an exponential backoff strategy to resume a | |||
# failed upload. | |||
def resumable_upload(request, resource, method, options): | |||
response = None | |||
error = None | |||
retry = 0 | |||
logger_stdout = None | |||
if options.get('--url-only') or options.get('--batch'): | |||
logger_stdout = logging.getLogger('stdoutlogs') | |||
while response is None: | |||
try: | |||
template = 'Youtube: Uploading %s...' | |||
logger.info(template % resource) | |||
status, response = request.next_chunk() | |||
if response is not None: | |||
if method == 'insert' and 'id' in response: | |||
logger.info('Youtube: Video was successfully uploaded.') | |||
template = 'Youtube: Watch it at https://youtu.be/%s (post-encoding could take some time)' | |||
logger.info(template % response['id']) | |||
template_stdout = 'https://youtu.be/%s' | |||
if options.get('--url-only'): | |||
logger_stdout.info(template_stdout % response['id']) | |||
elif options.get('--batch'): | |||
logger_stdout.info("Youtube: " + template_stdout % response['id']) | |||
return response['id'] | |||
elif method != 'insert' or "id" not in response: | |||
logger.info('Youtube: Thumbnail was successfully set.') | |||
else: | |||
template = ('Youtube: The upload failed with an ' | |||
'unexpected response: %s') | |||
logger.critical(template % response) | |||
exit(1) | |||
except HttpError as e: | |||
if e.resp.status in RETRIABLE_STATUS_CODES: | |||
template = 'Youtube: A retriable HTTP error %d occurred:\n%s' | |||
error = template % (e.resp.status, e.content) | |||
else: | |||
raise | |||
except RETRIABLE_EXCEPTIONS as e: | |||
error = 'Youtube: A retriable error occurred: %s' % e | |||
if error is not None: | |||
logger.warning(error) | |||
retry += 1 | |||
if retry > MAX_RETRIES: | |||
logger.error('Youtube: No longer attempting to retry.') | |||
max_sleep = 2 ** retry | |||
sleep_seconds = random.random() * max_sleep | |||
logger.warning('Youtube: Sleeping %f seconds and then retrying...' | |||
% sleep_seconds) | |||
time.sleep(sleep_seconds) | |||
def heartbeat(): | |||
"""Use the minimums credits possibles of the API so google does not readuce to 0 the allowed credits. | |||
This apparently happens after 90 days without any usage of credits. | |||
For more info see the official documentations: | |||
- General informations about quotas: https://developers.google.com/youtube/v3/getting-started#quota | |||
- Quota costs for API requests: https://developers.google.com/youtube/v3/determine_quota_cost | |||
- ToS (Americas) #Usage and Quotas: https://developers.google.com/youtube/terms/api-services-terms-of-service#usage-and-quotas""" | |||
youtube = get_authenticated_service() | |||
try: | |||
get_playlist_by_name(youtube, "Foo") | |||
except HttpError as e: | |||
logger.error('Youtube: An HTTP error %d occurred on heartbeat:\n%s' % | |||
(e.resp.status, e.content)) | |||
def run(options): | |||
youtube = get_authenticated_service() | |||
try: | |||
initialize_upload(youtube, options) | |||
except HttpError as e: | |||
logger.error('Youtube: An HTTP error %d occurred:\n%s' % (e.resp.status, | |||
e.content)) |
@ -0,0 +1,12 @@ | |||
### This NFO is aimed to be passed to prismedia through the --nfo cli option ### | |||
### eg: | |||
### python -m prismedia --file=/path/to/yourvideo.mp4 --nfo=/path/to/cli_nfo.txt ### | |||
### It's the more priority NFO, only erased by direct cli options ### | |||
[video] | |||
disable-comments = False | |||
nsfw = True | |||
# Publish on Peertube at a specific date | |||
peertubeAt = 2034-05-14T19:00:00 | |||
platform = peertube | |||
# debug to display all loaded options | |||
debug = True |
@ -0,0 +1,26 @@ | |||
### This NFO example show how to construct a NFO for your video ### | |||
### All fields are optionals, but you need at least one field (otherwise NFO is useless :-p) ### | |||
### See --help for options explanation | |||
[video] | |||
name = videoname | |||
description = Your complete video description | |||
Multilines description | |||
should be wrote with a blank space | |||
at the beginning of the line :-) | |||
tags = list of tags, comma separated | |||
category = Films | |||
cca = True | |||
privacy = private | |||
disable-comments = True | |||
#thumbnail = /path/to/your/thumbnail.jpg # Set the absolute path to your thumbnail | |||
channel = CookingTest | |||
channelCreate = True | |||
playlist = Desserts Recipes playlist | |||
playlistCreate = True | |||
nsfw = False | |||
platform = youtube, peertube | |||
language = French | |||
publishAt = 2034-05-07T19:00:00 | |||
# platformAt overrides the default publishAt for the corresponding platform | |||
#peertubeAt = 2034-05-14T19:00:00 | |||
#youtubeAt = 2034-05-21T19:00:00 |
@ -0,0 +1,10 @@ | |||
### This NFO is named nfo.txt and is stored in the directory of your videos ### | |||
### This is the less priority NFO, you may use it to set default generic options ### | |||
[video] | |||
# Some generic options for your videos | |||
cca = True | |||
privacy = private | |||
disable-comments = False | |||
channel = DefaultChannel | |||
channelCreate = True | |||
auto-originalDate = True |
@ -0,0 +1,14 @@ | |||
### This NFO is named from the directory where your video are. ### | |||
### While more specific than nfo.txt, it's less priority than other NFO ### | |||
### You may use it for options specific to videos in this directory, but still globals ### | |||
[video] | |||
channel = MyMoreSpecificChannel | |||
disable-comments = False | |||
channelCreate = True | |||
category = Films | |||
playlist = Desserts Recipes playlist | |||
playlistCreate = True | |||
nsfw = False | |||
platform = youtube, peertube | |||
language = French | |||
tags = list of tags, comma separated |
@ -0,0 +1,14 @@ | |||
### This NFO is named from your video name (here let's say your video is named "yourvideo.mp4") ### | |||
### It aims to give options specific to this videos ### | |||
[video] | |||
disable-comments = False | |||
#thumbnail = /path/to/your/thumbnail.jpg # Set the absolute path to your thumbnail | |||
name = videoname | |||
description = Your complete video description | |||
Multilines description | |||
should be wrote with a blank space | |||
at the beginning of the line :-) | |||
publishAt = 2034-05-07T19:00:00 | |||
# platformAt overrides the default publishAt for the corresponding platform | |||
#peertubeAt = 2034-05-14T19:00:00 | |||
#youtubeAt = 2034-05-21T19:00:00 |
@ -0,0 +1,350 @@ | |||
#!/usr/bin/env python | |||
# coding: utf-8 | |||
""" | |||
prismedia - tool to upload videos to Peertube and Youtube | |||
Usage: | |||
prismedia --file=<FILE> [options] | |||
prismedia -f <FILE> --tags=STRING [options] | |||
prismedia --heartbeat | |||
prismedia -h | --help | |||
prismedia --version | |||
Options: | |||
-f, --file=STRING Path to the video file to upload in mp4. This is the only mandatory option. | |||
--name=NAME Name of the video to upload. (default to video filename) | |||
-d, --description=STRING Description of the video. (default: default description) | |||
-t, --tags=STRING Tags for the video. comma separated. | |||
WARN: tags with punctuation (!, ', ", ?, ...) | |||
are not supported by Mastodon to be published from Peertube | |||
-c, --category=STRING Category for the videos, see below. (default: Films) | |||
--cca License should be CreativeCommon Attribution (affects Youtube upload only) | |||
-p, --privacy=STRING Choose between public, unlisted or private. (default: private) | |||
--disable-comments Disable comments (Peertube only as YT API does not support) (default: comments are enabled) | |||
--nsfw Set the video as No Safe For Work (Peertube only as YT API does not support) (default: video is safe) | |||
--nfo=STRING Configure a specific nfo file to set options for the video. | |||
By default Prismedia search a .txt based on the video name and will | |||
decode the file as UTF-8 (so make sure your nfo file is UTF-8 encoded) | |||
See nfo_example.txt for more details | |||
--platform=STRING List of platform(s) to upload to, comma separated. | |||
Supported platforms are youtube and peertube (default is both) | |||
--language=STRING Specify the default language for video. See below for supported language. (default is English) | |||
--publishAt=DATE Publish the video at the given DATE using local server timezone. | |||
DATE should be on the form YYYY-MM-DDThh:mm:ss eg: 2018-03-12T19:00:00 | |||
DATE should be in the future | |||
--peertubeAt=DATE | |||
--youtubeAt=DATE Override publishAt for the corresponding platform. Allow to create preview on specific platform | |||
--originalDate=DATE Configure the video as initially recorded at DATE | |||
DATE should be on the form YYYY-MM-DDThh:mm:ss eg: 2018-03-12T19:00:00 | |||
DATE should be in the past | |||
--auto-originalDate Automatically use the file modification time as original date | |||
--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 | |||
--channel=STRING Set the channel to use for the video (Peertube only) | |||
If the channel is not found, spawn an error except if --channelCreate is set. | |||
--channelCreate Create the channel if not exists. (Peertube only, default do not create) | |||
Only relevant if --channel is set. | |||
--playlist=STRING Set the playlist to use for the video. | |||
If the playlist is not found, spawn an error except if --playlistCreate is set. | |||
--playlistCreate Create the playlist if not exists. (default do not create) | |||
Only relevant if --playlist is set. | |||
--progress=STRING Set the progress bar view, one of percentage, bigFile (MB), accurate (KB). | |||
--heartbeat Use some credits to show some activity for you apikey so the platform know it is used and would not put your quota to 0 (only Youtube currently) | |||
-h --help Show this help. | |||
--version Show version. | |||
Logging options | |||
-q --quiet Suppress any log except Critical (alias for --log=critical). | |||
--log=STRING Log level, between debug, info, warning, error, critical. Ignored if --quiet is set (default to info) | |||
-u --url-only Display generated URL after upload directly on stdout, implies --quiet | |||
--batch Display generated URL after upload with platform information for easier parsing. Implies --quiet | |||
Be careful --batch and --url-only are mutually exclusives. | |||
--debug (Deprecated) Alias for --log=debug. Ignored if --log is set | |||
Strict options: | |||
Strict options allow you to force some option to be present when uploading a video. It's useful to be sure you do not | |||
forget something when uploading a video, for example if you use multiples NFO. You may force the presence of description, | |||
tags, thumbnail, ... | |||
All strict option are optionals and are provided only to avoid errors when uploading :-) | |||
All strict options can be specified in NFO directly, the only strict option mandatory on cli is --withNFO | |||
All strict options are off by default | |||
--withNFO Prevent the upload without a NFO, either specified via cli or found in the directory | |||
--withThumbnail Prevent the upload without a thumbnail | |||
--withName Prevent the upload if no name are found | |||
--withDescription Prevent the upload without description | |||
--withTags Prevent the upload without tags | |||
--withPlaylist Prevent the upload if no playlist | |||
--withPublishAt Prevent the upload if no schedule | |||
--withOriginalDate Prevent the upload if no original date configured | |||
--withPlatform Prevent the upload if at least one platform is not specified | |||
--withCategory Prevent the upload if no category | |||
--withLanguage Prevent upload if no language | |||
--withChannel Prevent upload if no channel | |||
Categories: | |||
Category is the type of video you upload. Default is films. | |||
Here are available categories from Peertube and Youtube: | |||
music, films, vehicles, | |||
sports, travels, gaming, people, | |||
comedy, entertainment, news, | |||
how to, education, activism, science & technology, | |||
science, technology, animals | |||
Languages: | |||
Language of the video (audio track), choose one. Default is English | |||
Here are available languages from Peertube and Youtube: | |||
Arabic, English, French, German, Hindi, Italian, | |||
Japanese, Korean, Mandarin, Portuguese, Punjabi, Russian, Spanish | |||
""" | |||
import sys | |||
if sys.version_info[0] < 3: | |||
raise Exception("Python 3 or a more recent version is required.") | |||
import os | |||
import datetime | |||
import logging | |||
from docopt import docopt | |||
from . import yt_upload | |||
from . import pt_upload | |||
from . import utils | |||
logger = logging.getLogger('Prismedia') | |||
try: | |||
# noinspection PyUnresolvedReferences | |||
from schema import Schema, And, Or, Optional, SchemaError, Hook, Use | |||
except ImportError: | |||
logger.critical('This program requires that the `schema` data-validation library' | |||
' is installed: \n' | |||
'see https://github.com/halst/schema\n') | |||
exit(1) | |||
try: | |||
# noinspection PyUnresolvedReferences | |||
import magic | |||
except ImportError: | |||
logger.critical('This program requires that the `python-magic` library' | |||
' is installed, NOT the Python bindings to libmagic API \n' | |||
'see https://github.com/ahupp/python-magic\n') | |||
exit(1) | |||
VERSION = "prismedia v0.11.0" | |||
def _optionnalOrStrict(key, scope, error): | |||
option = key.replace('-', '') | |||
option = option[0].upper() + option[1:] | |||
if scope["--with" + option] is True and scope[key] is None: | |||
logger.critical("Prismedia: you have required the strict presence of " + key + " but none is found") | |||
exit(1) | |||
return True | |||
def configureLogs(options): | |||
if options.get('--batch') and options.get('--url-only'): | |||
logger.critical("Prismedia: Please use either --batch OR --url-only, not both.") | |||
exit(1) | |||
# batch and url-only implies quiet | |||
if options.get('--batch') or options.get('--url-only'): | |||
options['--quiet'] = True | |||
if options.get('--quiet'): | |||
# We need to set both log level in the same time | |||
logger.setLevel(50) | |||
ch.setLevel(50) | |||
elif options.get('--log'): | |||
numeric_level = getattr(logging, options["--log"], None) | |||
# We need to set both log level in the same time | |||
logger.setLevel(numeric_level) | |||
ch.setLevel(numeric_level) | |||
elif options.get('--debug'): | |||
logger.warning("DEPRECATION: --debug is deprecated, please use --log=debug instead") | |||
logger.setLevel(10) | |||
ch.setLevel(10) | |||
def configureStdoutLogs(): | |||
logger_stdout = logging.getLogger('stdoutlogs') | |||
logger_stdout.setLevel(logging.INFO) | |||
ch_stdout = logging.StreamHandler(stream=sys.stdout) | |||
ch_stdout.setLevel(logging.INFO) | |||
# Default stdout logs is url only | |||
formatter_stdout = logging.Formatter('%(message)s') | |||
ch_stdout.setFormatter(formatter_stdout) | |||
logger_stdout.addHandler(ch_stdout) | |||
def main(): | |||
options = docopt(__doc__, version=VERSION) | |||
earlyoptionSchema = Schema({ | |||
Optional('--log'): Or(None, And( | |||
str, | |||
Use(str.upper), | |||
validateLogLevel, | |||
error="Log level not recognized") | |||
), | |||
Optional('--quiet', default=False): bool, | |||
Optional('--debug'): bool, | |||
Optional('--url-only', default=False): bool, | |||
Optional('--batch', default=False): bool, | |||
Optional('--withNFO', default=False): bool, | |||
Optional('--withThumbnail', default=False): bool, | |||
Optional('--withName', default=False): bool, | |||
Optional('--withDescription', default=False): bool, | |||
Optional('--withTags', default=False): bool, | |||
Optional('--withPlaylist', default=False): bool, | |||
Optional('--withPublishAt', default=False): bool, | |||
Optional('--withOriginalDate', default=False): bool, | |||
Optional('--withPlatform', default=False): bool, | |||
Optional('--withCategory', default=False): bool, | |||
Optional('--withLanguage', default=False): bool, | |||
Optional('--withChannel', default=False): bool, | |||
# This allow to return all other options for further use: https://github.com/keleshev/schema#extra-keys | |||
object: object | |||
}) | |||
schema = Schema({ | |||
'--file': And(str, os.path.exists, validateVideo, error='file is not supported, please use mp4'), | |||
# Strict option checks - at the moment Schema needs to check Hook and Optional separately # | |||
Hook('--name', handler=_optionnalOrStrict): object, | |||
Hook('--description', handler=_optionnalOrStrict): object, | |||
Hook('--tags', handler=_optionnalOrStrict): object, | |||
Hook('--category', handler=_optionnalOrStrict): object, | |||
Hook('--language', handler=_optionnalOrStrict): object, | |||
Hook('--platform', handler=_optionnalOrStrict): object, | |||
Hook('--publishAt', handler=_optionnalOrStrict): object, | |||
Hook('--originalDate', handler=_optionnalOrStrict): object, | |||
Hook('--thumbnail', handler=_optionnalOrStrict): object, | |||
Hook('--channel', handler=_optionnalOrStrict): object, | |||
Hook('--playlist', handler=_optionnalOrStrict): object, | |||
# Validate checks # | |||
Optional('--name'): Or(None, And( | |||
str, | |||
lambda x: not x.isdigit(), | |||
error="The video name should be a string") | |||
), | |||
Optional('--description'): Or(None, And( | |||
str, | |||
lambda x: not x.isdigit(), | |||
error="The video description should be a string") | |||
), | |||
Optional('--tags'): Or(None, And( | |||
str, | |||
lambda x: not x.isdigit(), | |||
error="Tags should be a string") | |||
), | |||
Optional('--category'): Or(None, And( | |||
str, | |||
validateCategory, | |||
error="Category not recognized, please see --help") | |||
), | |||
Optional('--language'): Or(None, And( | |||
str, | |||
validateLanguage, | |||
error="Language not recognized, please see --help") | |||
), | |||
Optional('--privacy'): Or(None, And( | |||
str, | |||
validatePrivacy, | |||
error="Please use recognized privacy between public, unlisted or private") | |||
), | |||
Optional('--nfo'): Or(None, str), | |||
Optional('--platform'): Or(None, And(str, validatePlatform, error="Sorry, upload platform not supported")), | |||
Optional('--publishAt'): Or(None, And( | |||
str, | |||
validatePublishDate, | |||
error="Publish Date should be the form YYYY-MM-DDThh:mm:ss and has to be in the future") | |||
), | |||
Optional('--peertubeAt'): Or(None, And( | |||
str, | |||
validatePublishDate, | |||
error="Publish Date should be the form YYYY-MM-DDThh:mm:ss and has to be in the future") | |||
), | |||
Optional('--youtubeAt'): Or(None, And( | |||
str, | |||
validatePublishDate, | |||
error="Publish Date should be the form YYYY-MM-DDThh:mm:ss and has to be in the future") | |||
), | |||
Optional('--originalDate'): Or(None, And( | |||
str, | |||
validateOriginalDate, | |||
error="Original date should be the form YYYY-MM-DDThh:mm:ss and has to be in the past") | |||
), | |||
Optional('--auto-originalDate'): bool, | |||
Optional('--cca'): bool, | |||
Optional('--disable-comments'): bool, | |||
Optional('--nsfw'): bool, | |||
Optional('--thumbnail'): Or(None, And( | |||
str, validateThumbnail, error='thumbnail is not supported, please use jpg/jpeg'), | |||
), | |||
Optional('--channel'): Or(None, str), | |||
Optional('--channelCreate'): bool, | |||
Optional('--playlist'): Or(None, str), | |||
Optional('--playlistCreate'): bool, | |||
Optional('--progress'): Or(None, And(str, validateProgress, error="Sorry, progress visualisation not supported")), | |||
'--heartbeat': bool, | |||
'--help': bool, | |||
'--version': bool, | |||
# This allow to return all other options for further use: https://github.com/keleshev/schema#extra-keys | |||
object: object | |||
}) | |||
if options.get('--heartbeat'): | |||
yt_upload.heartbeat() | |||
exit(0) | |||
# We need to validate early options first as withNFO and logs options should be prioritized | |||
try: | |||
options = earlyoptionSchema.validate(options) | |||
configureLogs(options) | |||
except SchemaError as e: | |||
logger.critical(e) | |||
exit(1) | |||
if options.get('--url-only') or options.get('--batch'): | |||
configureStdoutLogs() | |||
options = utils.parseNFO(options) | |||
# If after loading NFO we still has no original date and --auto-originalDate is enabled, | |||
# then we need to search from the file | |||
# We need to do that before the strict validation in case --withOriginalDate is enabled | |||
if not options.get('--originalDate') and options.get('--auto-originalDate'): | |||
options['--originalDate'] = utils.searchOriginalDate(options) | |||
# Once NFO are loaded, we need to revalidate strict options in case some were in NFO | |||
try: | |||
options = earlyoptionSchema.validate(options) | |||
except SchemaError as e: | |||
logger.critical(e) | |||
exit(1) | |||
if not options.get('--thumbnail'): | |||
options = utils.searchThumbnail(options) | |||
try: | |||
options = schema.validate(options) | |||
except SchemaError as e: | |||
logger.critical(e) | |||
exit(1) | |||
logger.debug("Python " + sys.version) | |||
logger.debug(options) | |||
if options.get('--platform') is None or "peertube" in options.get('--platform'): | |||
pt_upload.run(options) | |||
if options.get('--platform') is None or "youtube" in options.get('--platform'): | |||
yt_upload.run(options) | |||
if __name__ == '__main__': | |||
logger.warning("DEPRECATION: use 'python -m prismedia', not 'python -m prismedia.upload'") | |||
main() |
@ -0,0 +1,260 @@ | |||
#!/usr/bin/python | |||
# coding: utf-8 | |||
from configparser import RawConfigParser, NoOptionError, NoSectionError | |||
from os.path import dirname, splitext, basename, isfile, getmtime, exists | |||
from yapsy.PluginManager import PluginManagerSingleton | |||
import pluginInterfaces as pi | |||
import re | |||
import unidecode | |||
import logging | |||
import datetime | |||
logger = logging.getLogger("Prismedia") | |||
VALID_PRIVACY_STATUSES = ("public", "private", "unlisted") | |||
VALID_CATEGORIES = ( | |||
"music", "films", "vehicles", | |||
"sports", "travels", "gaming", "people", | |||
"comedy", "entertainment", "news", | |||
"how to", "education", "activism", "science & technology", | |||
"science", "technology", "animals" | |||
) | |||
VALID_LANGUAGES = ("arabic", "english", "french", | |||
"german", "hindi", "italian", | |||
"japanese", "korean", "mandarin", | |||
"portuguese", "punjabi", "russian", "spanish") | |||
VALID_PROGRESS = ("percentage", "bigfile", "accurate") | |||
def helperFunctionalities(options): | |||
pluginManager = PluginManagerSingleton.get() | |||
optionName = "--heartbeat" | |||
if options.get(optionName): | |||
for plugin in pluginManager.getPluginsOfCategory(pi.PluginTypes.PLATFORM): | |||
plugin.plugin_object.heartbeat() | |||
return False | |||
else: | |||
options.pop(optionName) | |||
return True | |||
def get_exception_string(e): | |||
if hasattr(e, "message"): | |||
return str(e.message) | |||
else: | |||
return str(e) | |||
def validateVideo(path): | |||
supported_types = ["video/mp4"] | |||
detected_type = magic.from_file(path, mime=True) | |||
if detected_type not in supported_types: | |||
print("File", path, "detected type is `" + detected_type + "` which is not one of", supported_types) | |||
force_file = ["y", "yes"] | |||
is_forcing = input("Are you sure you selected the correct file? (y/N)") | |||
if is_forcing.lower() not in force_file: | |||
return False | |||
return path | |||
def validateCategory(category): | |||
if category.lower() in VALID_CATEGORIES: | |||
return True | |||
else: | |||
return False | |||
def validatePrivacy(privacy): | |||
if privacy.lower() in VALID_PRIVACY_STATUSES: | |||
return True | |||
else: | |||
return False | |||
# TODO: remove me? | |||
# def validatePlatform(platform): | |||
# for plfrm in platform.split(','): | |||
# if plfrm.lower().replace(" ", "") not in VALID_PLATFORM: | |||
# return False | |||
# | |||
# return True | |||
def validateLanguage(language): | |||
if language.lower() in VALID_LANGUAGES: | |||
return True | |||
else: | |||
return False | |||
def validateDate(date): | |||
return datetime.datetime.strptime(date, "%Y-%m-%dT%H:%M:%S") | |||
def validatePublishDate(publishDate): | |||
# Check date format and if date is future | |||
try: | |||
now = datetime.datetime.now() | |||
publishAt = validateDate(publishDate) | |||
if now >= publishAt: | |||
return False | |||
except ValueError: | |||
return False | |||
return True | |||
def validateOriginalDate(originalDate): | |||
# Check date format and if date is past | |||
try: | |||
now = datetime.datetime.now() | |||
originalDate = validateDate(originalDate) | |||
if now <= originalDate: | |||
return False | |||
except ValueError: | |||
return False | |||
return True | |||
def validateThumbnail(thumbnail): | |||
supported_types = ['image/jpg', 'image/jpeg'] | |||
if exists(thumbnail) and \ | |||
magic.from_file(thumbnail, mime=True) in supported_types: | |||
return thumbnail | |||
else: | |||
return False | |||
def validateLogLevel(loglevel): | |||
numeric_level = getattr(logging, loglevel, None) | |||
if not isinstance(numeric_level, int): | |||
return False | |||
return True | |||
def validateProgress(progress): | |||
for prgs in progress.split(','): | |||
if prgs.lower().replace(" ", "") not in VALID_PROGRESS: | |||
return False | |||
return True | |||
def ask_overwrite(question): | |||
while True: | |||
reply = str(input(question + ' (Yes/[No]): ') or "No").lower().strip() | |||
if reply[:1] == 'y': | |||
return True | |||
if reply[:1] == 'n': | |||
return False | |||
def remove_empty_kwargs(**kwargs): | |||
good_kwargs = {} | |||
if kwargs is not None: | |||
for key, value in kwargs.items(): | |||
if value: | |||
good_kwargs[key] = value | |||
return good_kwargs | |||
def searchOriginalDate(options): | |||
fileModificationDate = str(getmtime(options.get('--file'))).split('.') | |||
return datetime.datetime.fromtimestamp(int(fileModificationDate[0])).isoformat() | |||
# # return the nfo as a RawConfigParser object | |||
# def loadNFO(filename): | |||
# try: | |||
# logger.info("Loading " + filename + " as NFO") | |||
# nfo = RawConfigParser() | |||
# nfo.read(filename, encoding='utf-8') | |||
# return nfo | |||
# except Exception as e: | |||
# logger.critical("Problem loading NFO file " + filename + ": " + str(e)) | |||
# exit(1) | |||
# return False | |||
# | |||
# | |||
# def parseNFO(options): | |||
# video_directory = dirname(options.get('--file')) | |||
# directory_name = basename(video_directory) | |||
# nfo_txt = False | |||
# nfo_directory = False | |||
# nfo_videoname = False | |||
# nfo_file = False | |||
# nfo_cli = False | |||
# | |||
# if isfile(video_directory + "/" + "nfo.txt"): | |||
# nfo_txt = loadNFO(video_directory + "/" + "nfo.txt") | |||
# elif isfile(video_directory + "/" + "NFO.txt"): | |||
# nfo_txt = loadNFO(video_directory + "/" + "NFO.txt") | |||
# | |||
# if isfile(video_directory + "/" + directory_name + ".txt"): | |||
# nfo_directory = loadNFO(video_directory + "/" + directory_name + ".txt") | |||
# | |||
# if options.get('--name'): | |||
# if isfile(video_directory + "/" + options.get('--name')): | |||
# nfo_videoname = loadNFO(video_directory + "/" + options.get('--name') + ".txt") | |||
# | |||
# video_file = splitext(basename(options.get('--file')))[0] | |||
# if isfile(video_directory + "/" + video_file + ".txt"): | |||
# nfo_file = loadNFO(video_directory + "/" + video_file + ".txt") | |||
# | |||
# if options.get('--nfo'): | |||
# if isfile(options.get('--nfo')): | |||
# nfo_cli = loadNFO(options.get('--nfo')) | |||
# else: | |||
# logger.critical("Given NFO file does not exist, please check your path.") | |||
# exit(1) | |||
# | |||
# # If there is no NFO and strict option is enabled, then stop there | |||
# if options.get('--withNFO'): | |||
# if not isinstance(nfo_cli, RawConfigParser) and \ | |||
# not isinstance(nfo_file, RawConfigParser) and \ | |||
# not isinstance(nfo_videoname, RawConfigParser) and \ | |||
# not isinstance(nfo_directory, RawConfigParser) and \ | |||
# not isinstance(nfo_txt, RawConfigParser): | |||
# logger.critical("You have required the strict presence of NFO but none is found, please use a NFO.") | |||
# exit(1) | |||
# | |||
# # We need to load NFO in this exact order to keep the priorities | |||
# # options in cli > nfo_cli > nfo_file > nfo_videoname > nfo_directory > nfo_txt | |||
# for nfo in [nfo_cli, nfo_file, nfo_videoname, nfo_directory, nfo_txt]: | |||
# if nfo: | |||
# # We need to check all options and replace it with the nfo value if not defined (None or False) | |||
# for key, value in options.items(): | |||
# key = key.replace("--", "") | |||
# try: | |||
# # get string options | |||
# if value is None and nfo.get('video', key): | |||
# options['--' + key] = nfo.get('video', key) | |||
# # get boolean options | |||
# elif value is False and nfo.getboolean('video', key): | |||
# options['--' + key] = nfo.getboolean('video', key) | |||
# except NoOptionError: | |||
# continue | |||
# except NoSectionError: | |||
# logger.critical(nfo + " misses section [video], please check syntax of your NFO.") | |||
# exit(1) | |||
# return options | |||
def cleanString(toclean): | |||
toclean = unidecode.unidecode(toclean) | |||
cleaned = re.sub('[^A-Za-z0-9]+', '', toclean) | |||
return cleaned | |||
def getOption(options, optionName, defaultValue=None): | |||
value = options.get(optionName) | |||
options.pop(optionName) | |||
if value is None: | |||
return defaultValue | |||
return value |
@ -0,0 +1,123 @@ | |||
from os.path import dirname, splitext, basename, isfile, normpath, expanduser | |||
class Platform(object): | |||
""" | |||
Store data representing a Platform. | |||
""" | |||
def __init__(self): | |||
self.error = None | |||
self.publishAt = None | |||
self.url = None | |||
def __repr__(self): | |||
return str(self.__dict__) | |||
# TODO: Add container for `with-*` and a `isValid` method to check that all `with-*` options are present | |||
# TODO: We need some list (using enum?) for the commons licences, language, privacy, categories options | |||
class Video(object): | |||
""" | |||
Store data representing a Video. | |||
""" | |||
def __init__(self): | |||
self.path = "" | |||
self.thumbnail = None | |||
self.name = None | |||
self.description = "Video uploaded with Prismedia" | |||
self.playlistName = None | |||
self.playlistCreate = False | |||
self.privacy = "private" | |||
self.category = "films" | |||
self.tags = [] | |||
self.language = "english" | |||
self.originalDate = None # TODO: add a method "extract original date"? -> I feal that needs to be done outside of this class | |||
# TODO: set as an object: { "all": date1, "platformX": date2, …}? | |||
# Maybe the publishAt by platform is better placed in `self.platform` | |||
# And so publishAt would only contains the global date. | |||
self.publishAt = None | |||
# TODO: Add a list of licences | |||
self.licence = "proprietary" | |||
self.disableComments = False | |||
self.nsfw = False | |||
# Each platform should insert here the upload state | |||
self.platform = {} | |||
@property | |||
def path(self): | |||
return self._path | |||
@path.setter | |||
def path(self, value): | |||
path = normpath(expanduser(value)) | |||
if value == "": | |||
self._path = "" | |||
elif isfile(path): | |||
self._path = path | |||
else: | |||
# TODO: log instead to debug? info? | |||
print("The path `" + value + "` does not point to a video") | |||
self._path = "" | |||
@property | |||
def thumbnail(self): | |||
if self._thumbnail is not None: | |||
return self._thumbnail | |||
else: | |||
result = None | |||
video_directory = dirname(self.path) + "/" | |||
# First, check for thumbnail based on videoname | |||
if isfile(video_directory + self.name + ".jpg"): | |||
result = video_directory + self.name + ".jpg" | |||
elif isfile(video_directory + self.name + ".jpeg"): | |||
result = video_directory + self.name + ".jpeg" | |||
# Then, if we still not have thumbnail, check for thumbnail based on videofile name | |||
# NOTE: This may be a the exact same check from the previous conditions if self._name = None. | |||
# Bus as far as I know it is not recommended to use privates properties even in the same class | |||
# Maybe check if self.name == splitext(basename(self.path))[0] | |||
# Not done since it is early dev for the plugins rewrite | |||
if not result: | |||
video_file = splitext(basename(self.path))[0] | |||
if isfile(video_directory + video_file + ".jpg"): | |||
result = video_directory + video_file + ".jpg" | |||
elif isfile(video_directory + video_file + ".jpeg"): | |||
result = video_directory + video_file + ".jpeg" | |||
# TODO: move to caller. Logging the output is its responsibility | |||
# Display some info after research | |||
# if not result: | |||
# logger.debug("No thumbnail has been found, continuing") | |||
# else: | |||
# logger.info("Using " + result + " as thumbnail") | |||
return result | |||
@thumbnail.setter | |||
def thumbnail(self, value): | |||
self._thumbnail = value | |||
@property | |||
def name(self): | |||
if self._name is not None: | |||
return self._name | |||
else: | |||
return splitext(basename(self.path))[0] | |||
@name.setter | |||
def name(self, value): | |||
self._name = value | |||
def __repr__(self): | |||
result = "{\n" | |||
for key in self.__dict__: | |||
result += "\t'" + key + "': " + str(self.__dict__[key]) + ",\n" | |||
result += "}\n" | |||
return result |
@ -1,231 +0,0 @@ | |||
#!/usr/bin/python | |||
# coding: utf-8 | |||
""" | |||
prismedia_upload - tool to upload videos to Peertube and Youtube | |||
Usage: | |||
prismedia_upload.py --file=<FILE> [options] | |||
prismedia_upload.py --file=<FILE> --tags=STRING [--mt options] | |||
prismedia_upload.py -h | --help | |||
prismedia_upload.py --version | |||
Options: | |||
--name=NAME Name of the video to upload. (default to video filename) | |||
-d, --description=STRING Description of the video. (default: default description) | |||
-t, --tags=STRING Tags for the video. comma separated. | |||
WARN: tags with space and special characters (!, ', ", ?, ...) | |||
are not supported by Mastodon to be published from Peertube | |||
use mastodon compatibility below | |||
--mt Force Mastodon compatibility for tags (drop every incompatible characters inside tags) | |||
This option requires --tags | |||
-c, --category=STRING Category for the videos, see below. (default: Films) | |||
--cca License should be CreativeCommon Attribution (affects Youtube upload only) | |||
-p, --privacy=STRING Choose between public, unlisted or private. (default: private) | |||
--disable-comments Disable comments (Peertube only as YT API does not support) (default: comments are enabled) | |||
--nsfw Set the video as No Safe For Work (Peertube only as YT API does not support) (default: video is safe) | |||
--nfo=STRING Configure a specific nfo file to set options for the video. | |||
By default Prismedia search a .txt based on video name | |||
See nfo_example.txt for more details | |||
--platform=STRING List of platform(s) to upload to, comma separated. | |||
Supported platforms are youtube and peertube (default is both) | |||
--language=STRING Specify the default language for video. See below for supported language. (default is English) | |||
--publishAt=DATE Publish the video at the given DATE using local server timezone. | |||
DATE should be on the form YYYY-MM-DDThh:mm:ss eg: 2018-03-12T19:00:00 | |||
DATE should be in the future | |||
For Peertube, requires the "atd" and "curl utilities installed on the system | |||
--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 | |||
-h --help Show this help. | |||
--version Show version. | |||
Categories: | |||
Category is the type of video you upload. Default is films. | |||
Here are available categories from Peertube and Youtube: | |||
music, films, vehicles, | |||
sports, travels, gaming, people, | |||
comedy, entertainment, news, | |||
how to, education, activism, science & technology, | |||
science, technology, animals | |||
Languages: | |||
Language of the video (audio track), choose one. Default is English | |||
Here are available languages from Peertube and Youtube: | |||
Arabic, English, French, German, Hindi, Italian, | |||
Japanese, Korean, Mandarin, Portuguese, Punjabi, Russian, Spanish | |||
""" | |||
from os.path import dirname, realpath | |||
import sys | |||
import datetime | |||
import logging | |||
logging.basicConfig(format='%(asctime)s %(message)s', level=logging.INFO) | |||
from docopt import docopt | |||
# Allows a relative import from the parent folder | |||
sys.path.insert(0, dirname(realpath(__file__)) + "/lib") | |||
import yt_upload | |||
import pt_upload | |||
import utils | |||
try: | |||
# noinspection PyUnresolvedReferences | |||
from schema import Schema, And, Or, Optional, SchemaError | |||
except ImportError: | |||
logging.error('This program requires that the `schema` data-validation library' | |||
' is installed: \n' | |||
'see https://github.com/halst/schema\n') | |||
exit(1) | |||
try: | |||
# noinspection PyUnresolvedReferences | |||
import magic | |||
except ImportError: | |||
logging.error('This program requires that the `python-magic` library' | |||
' is installed, NOT the Python bindings to libmagic API \n' | |||
'see https://github.com/ahupp/python-magic\n') | |||
exit(1) | |||
VERSION = "prismedia v0.5" | |||
VALID_PRIVACY_STATUSES = ('public', 'private', 'unlisted') | |||
VALID_CATEGORIES = ( | |||
"music", "films", "vehicles", | |||
"sports", "travels", "gaming", "people", | |||
"comedy", "entertainment", "news", | |||
"how to", "education", "activism", "science & technology", | |||
"science", "technology", "animals" | |||
) | |||
VALID_PLATFORM = ('youtube', 'peertube') | |||
VALID_LANGUAGES = ('arabic', 'english', 'french', | |||
'german', 'hindi', 'italian', | |||
'japanese', 'korean', 'mandarin', | |||
'portuguese', 'punjabi', 'russian', 'spanish') | |||
def validateVideo(path): | |||
supported_types = ['video/mp4'] | |||
if magic.from_file(path, mime=True) in supported_types: | |||
return path | |||
else: | |||
return False | |||
def validateCategory(category): | |||
if category.lower() in VALID_CATEGORIES: | |||
return True | |||
else: | |||
return False | |||
def validatePrivacy(privacy): | |||
if privacy.lower() in VALID_PRIVACY_STATUSES: | |||
return True | |||
else: | |||
return False | |||
def validatePlatform(platform): | |||
for plfrm in platform.split(','): | |||
if plfrm.lower().replace(" ", "") not in VALID_PLATFORM: | |||
return False | |||
return True | |||
def validateLanguage(language): | |||
if language.lower() in VALID_LANGUAGES: | |||
return True | |||
else: | |||
return False | |||
def validatePublish(publish): | |||
# Check date format and if date is future | |||
try: | |||
now = datetime.datetime.now() | |||
publishAt = datetime.datetime.strptime(publish, '%Y-%m-%dT%H:%M:%S') | |||
if now >= publishAt: | |||
return False | |||
except ValueError: | |||
return False | |||
return True | |||
def validateThumbnail(thumbnail): | |||
supported_types = ['image/jpg', 'image/jpeg'] | |||
if magic.from_file(thumbnail, mime=True) in supported_types: | |||
return thumbnail | |||
else: | |||
return False | |||
if __name__ == '__main__': | |||
options = docopt(__doc__, version=VERSION) | |||
schema = Schema({ | |||
'--file': And(str, validateVideo, error='file is not supported, please use mp4'), | |||
Optional('--name'): Or(None, And( | |||
str, | |||
lambda x: not x.isdigit(), | |||
error="The video name should be a string") | |||
), | |||
Optional('--description'): Or(None, And( | |||
str, | |||
lambda x: not x.isdigit(), | |||
error="The video name should be a string") | |||
), | |||
Optional('--tags'): Or(None, And( | |||
str, | |||
lambda x: not x.isdigit(), | |||
error="Tags should be a string") | |||
), | |||
Optional('--mt'): bool, | |||
Optional('--category'): Or(None, And( | |||
str, | |||
validateCategory, | |||
error="Category not recognized, please see --help") | |||
), | |||
Optional('--language'): Or(None, And( | |||
str, | |||
validateLanguage, | |||
error="Language not recognized, please see --help") | |||
), | |||
Optional('--privacy'): Or(None, And( | |||
str, | |||
validatePrivacy, | |||
error="Please use recognized privacy between public, unlisted or private") | |||
), | |||
Optional('--nfo'): Or(None, str), | |||
Optional('--platform'): Or(None, And(str, validatePlatform, error="Sorry, upload platform not supported")), | |||
Optional('--publishAt'): Or(None, And( | |||
str, | |||
validatePublish, | |||
error="DATE should be the form YYYY-MM-DDThh:mm:ss and has to be in the future") | |||
), | |||
Optional('--cca'): bool, | |||
Optional('--disable-comments'): bool, | |||
Optional('--nsfw'): bool, | |||
Optional('--thumbnail'): Or(None, And( | |||
str, validateThumbnail, error='thumbnail is not supported, please use jpg/jpeg'), | |||
), | |||
'--help': bool, | |||
'--version': bool | |||
}) | |||
options = utils.parseNFO(options) | |||
if not options.get('--thumbnail'): | |||
options = utils.searchThumbnail(options) | |||
try: | |||
options = schema.validate(options) | |||
except SchemaError as e: | |||
exit(e) | |||
if options.get('--platform') is None or "youtube" in options.get('--platform'): | |||
yt_upload.run(options) | |||
if options.get('--platform') is None or "peertube" in options.get('--platform'): | |||
pt_upload.run(options) |
@ -0,0 +1,51 @@ | |||
[tool.poetry] | |||
name = "prismedia" | |||
version = "0.11.0" | |||
description = "scripting your way to upload videos on peertube and youtube" | |||
authors = [ | |||
"LecygneNoir <git@lecygnenoir.info>", | |||
"Rigel Kent <sendmemail@rigelk.eu>", | |||
"Zykino" | |||
] | |||
license = "AGPL-3.0-only" | |||
readme = 'README.md' | |||
repository = "https://git.lecygnenoir.info/LecygneNoir/prismedia" | |||
homepage = "https://git.lecygnenoir.info/LecygneNoir/prismedia" | |||
keywords = ['peertube', 'youtube', 'prismedia'] | |||
[tool.poetry.dependencies] | |||
python = ">=3.5" | |||
clint = "^0.5.1" | |||
configparser = "^3.7.1" | |||
docopt = "^0.6.2" | |||
future = "^0.17.1" | |||
google-api-python-client = ">=1.7.6" | |||
google-auth = ">=1.6.1" | |||
google-auth-httplib2 = ">=0.0.3" | |||
google-auth-oauthlib = ">=0.2.0" | |||
httplib2 = "^0.12.1" | |||
oauthlib = "^2.1.0" | |||
python-magic = "^0.4.15" | |||
python-magic-bin = { version = "^0.4.14", markers = "platform_system == 'Windows'" } | |||
requests = "^2.18.4" | |||
requests-oauthlib = "^0.8.0" | |||
requests-toolbelt = "^0.9.1" | |||
schema = ">=0.7.1" | |||
tzlocal = "^1.5.1" | |||
Unidecode = "^1.0.23" | |||
uritemplate = "^3.0.0" | |||
urllib3 = "^1.22" | |||
Yapsy = "^1.12.2" | |||
[tool.poetry.dev-dependencies] | |||
[tool.poetry.scripts] | |||
prismedia = 'prismedia.upload:main' | |||
prismedia-init = 'prismedia.genconfig:genconfig' | |||
[build-system] | |||
requires = ["poetry>=0.12"] | |||
build-backend = "poetry.masonry.api" |
@ -0,0 +1,141 @@ | |||
args==0.1.0 \ | |||
--hash=sha256:a785b8d837625e9b61c39108532d95b85274acd679693b71ebb5156848fcf814 | |||
cachetools==4.2.0; python_version >= "3.5" and python_version < "4.0" and (python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0") \ | |||
--hash=sha256:c6b07a6ded8c78bf36730b3dc452dfff7d95f2a12a2fed856b1a0cb13ca78c61 \ | |||
--hash=sha256:3796e1de094f0eaca982441c92ce96c68c89cced4cd97721ab297ea4b16db90e | |||
certifi==2020.12.5; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" \ | |||
--hash=sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830 \ | |||
--hash=sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c | |||
chardet==4.0.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" \ | |||
--hash=sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5 \ | |||
--hash=sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa | |||
clint==0.5.1 \ | |||
--hash=sha256:05224c32b1075563d0b16d0015faaf9da43aa214e4a2140e51f08789e7a4c5aa | |||
configparser==3.8.1; python_version >= "2.6" \ | |||
--hash=sha256:45d1272aad6cfd7a8a06cf5c73f2ceb6a190f6acc1fa707e7f82a4c053b28b18 \ | |||
--hash=sha256:bc37850f0cc42a1725a796ef7d92690651bf1af37d744cc63161dac62cabee17 | |||
contextlib2==0.6.0.post1; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" \ | |||
--hash=sha256:3355078a159fbb44ee60ea80abd0d87b80b78c248643b49aa6d94673b413609b \ | |||
--hash=sha256:01f490098c18b19d2bd5bb5dc445b2054d2fa97f09a4280ba2c5f3c394c8162e | |||
docopt==0.6.2 \ | |||
--hash=sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491 | |||
future==0.17.1; (python_version >= "2.6" and python_full_version < "3.0.0") or (python_full_version >= "3.3.0") \ | |||
--hash=sha256:67045236dcfd6816dc439556d009594abf643e5eb48992e36beac09c2ca659b8 | |||
google-api-core==1.25.0; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" \ | |||
--hash=sha256:d967beae8d8acdb88fb2f6f769e2ee0ee813042576a08891bded3b8e234150ae \ | |||
--hash=sha256:4656345cba9627ab1290eab51300a6397cc50370d99366133df1ae64b744e1eb | |||
google-api-python-client==1.12.2; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.4.0") \ | |||
--hash=sha256:54a7d330833a2e7b0587446d7e4ae6d0244925a9a8e1dfe878f3f7e06cdedb62 \ | |||
--hash=sha256:05cb331ed1aa15746f606c7e36ea51dbe7c29b1a5df9bbf58140901fe23d7142 | |||
google-auth-httplib2==0.0.4 \ | |||
--hash=sha256:8d092cc60fb16517b12057ec0bba9185a96e3b7169d86ae12eae98e645b7bc39 \ | |||
--hash=sha256:aeaff501738b289717fac1980db9711d77908a6c227f60e4aa1923410b43e2ee | |||
google-auth-oauthlib==0.4.2; python_version >= "3.6" \ | |||
--hash=sha256:65b65bc39ad8cab15039b35e5898455d3d66296d0584d96fe0e79d67d04c51d9 \ | |||
--hash=sha256:d4d98c831ea21d574699978827490a41b94f05d565c617fe1b420e88f1fc8d8d | |||
google-auth==1.24.0; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.6.0") \ | |||
--hash=sha256:0b0e026b412a0ad096e753907559e4bdb180d9ba9f68dd9036164db4fdc4ad2e \ | |||
--hash=sha256:ce752cc51c31f479dbf9928435ef4b07514b20261b021c7383bee4bda646acb8 | |||
googleapis-common-protos==1.52.0; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" \ | |||
--hash=sha256:560716c807117394da12cecb0a54da5a451b5cf9866f1d37e9a5e2329a665351 \ | |||
--hash=sha256:c8961760f5aad9a711d37b675be103e0cc4e9a39327e0d6d857872f698403e24 | |||
httplib2==0.12.3 \ | |||
--hash=sha256:23914b5487dfe8ef09db6656d6d63afb0cf3054ad9ebc50868ddc8e166b5f8e8 \ | |||
--hash=sha256:a18121c7c72a56689efbf1aef990139ad940fee1e64c6f2458831736cd593600 | |||
idna==2.10; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" \ | |||
--hash=sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0 \ | |||
--hash=sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6 | |||
oauthlib==2.1.0 \ | |||
--hash=sha256:d883b36b21a6ad813953803edfa563b1b579d79ca758fe950d1bc9e8b326025b \ | |||
--hash=sha256:ac35665a61c1685c56336bda97d5eefa246f1202618a1d6f34fccb1bdd404162 | |||
protobuf==3.14.0; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" \ | |||
--hash=sha256:629b03fd3caae7f815b0c66b41273f6b1900a579e2ccb41ef4493a4f5fb84f3a \ | |||
--hash=sha256:5b7a637212cc9b2bcf85dd828b1178d19efdf74dbfe1ddf8cd1b8e01fdaaa7f5 \ | |||
--hash=sha256:43b554b9e73a07ba84ed6cf25db0ff88b1e06be610b37656e292e3cbb5437472 \ | |||
--hash=sha256:5e9806a43232a1fa0c9cf5da8dc06f6910d53e4390be1fa06f06454d888a9142 \ | |||
--hash=sha256:1c51fda1bbc9634246e7be6016d860be01747354ed7015ebe38acf4452f470d2 \ | |||
--hash=sha256:4b74301b30513b1a7494d3055d95c714b560fbb630d8fb9956b6f27992c9f980 \ | |||
--hash=sha256:86a75477addde4918e9a1904e5c6af8d7b691f2a3f65587d73b16100fbe4c3b2 \ | |||
--hash=sha256:ecc33531a213eee22ad60e0e2aaea6c8ba0021f0cce35dbf0ab03dee6e2a23a1 \ | |||
--hash=sha256:72230ed56f026dd664c21d73c5db73ebba50d924d7ba6b7c0d81a121e390406e \ | |||
--hash=sha256:0fc96785262042e4863b3f3b5c429d4636f10d90061e1840fce1baaf59b1a836 \ | |||
--hash=sha256:4e75105c9dfe13719b7293f75bd53033108f4ba03d44e71db0ec2a0e8401eafd \ | |||
--hash=sha256:2a7e2fe101a7ace75e9327b9c946d247749e564a267b0515cf41dfe450b69bac \ | |||
--hash=sha256:b0d5d35faeb07e22a1ddf8dce620860c8fe145426c02d1a0ae2688c6e8ede36d \ | |||
--hash=sha256:8971c421dbd7aad930c9bd2694122f332350b6ccb5202a8b7b06f3f1a5c41ed5 \ | |||
--hash=sha256:9616f0b65a30851e62f1713336c931fcd32c057202b7ff2cfbfca0fc7d5e3043 \ | |||
--hash=sha256:22bcd2e284b3b1d969c12e84dc9b9a71701ec82d8ce975fdda19712e1cfd4e00 \ | |||
--hash=sha256:0e247612fadda953047f53301a7b0407cb0c3cb4ae25a6fde661597a04039b3c \ | |||
--hash=sha256:1d63eb389347293d8915fb47bee0951c7b5dab522a4a60118b9a18f33e21f8ce | |||
pyasn1-modules==0.2.8; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" \ | |||
--hash=sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e \ | |||
--hash=sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199 \ | |||
--hash=sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405 \ | |||
--hash=sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb \ | |||
--hash=sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8 \ | |||
--hash=sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74 \ | |||
--hash=sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d \ | |||
--hash=sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45 \ | |||
--hash=sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4 \ | |||
--hash=sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811 \ | |||
--hash=sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed \ | |||
--hash=sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0 \ | |||
--hash=sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd | |||
pyasn1==0.4.8; python_version >= "2.7" and python_full_version < "3.0.0" and python_version < "3.6" or python_full_version >= "3.6.0" and python_version < "3.6" \ | |||
--hash=sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3 \ | |||
--hash=sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf \ | |||
--hash=sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00 \ | |||
--hash=sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8 \ | |||
--hash=sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d \ | |||
--hash=sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86 \ | |||
--hash=sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7 \ | |||
--hash=sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576 \ | |||
--hash=sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12 \ | |||
--hash=sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2 \ | |||
--hash=sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359 \ | |||
--hash=sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776 \ | |||
--hash=sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba | |||
python-magic-bin==0.4.14; platform_system == "Windows" \ | |||
--hash=sha256:7b1743b3dbf16601d6eedf4e7c2c9a637901b0faaf24ad4df4d4527e7d8f66a4 \ | |||
--hash=sha256:34a788c03adde7608028203e2dbb208f1f62225ad91518787ae26d603ae68892 \ | |||
--hash=sha256:90be6206ad31071a36065a2fc169c5afb5e0355cbe6030e87641c6c62edc2b69 | |||
python-magic==0.4.20; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") \ | |||
--hash=sha256:0cc52ccad086c377b9194014e3dbf98d94b194344630172510a6a3e716b47801 \ | |||
--hash=sha256:33ce94d9395aa269a9c5fac10ae124a5fb328ebe248f36efc5a43922edee662e | |||
pytz==2020.5; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" \ | |||
--hash=sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4 \ | |||
--hash=sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5 | |||
requests-oauthlib==0.8.0 \ | |||
--hash=sha256:883ac416757eada6d3d07054ec7092ac21c7f35cb1d2cf82faf205637081f468 \ | |||
--hash=sha256:50a8ae2ce8273e384895972b56193c7409601a66d4975774c60c2aed869639ca | |||
requests-toolbelt==0.9.1 \ | |||
--hash=sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0 \ | |||
--hash=sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f | |||
requests==2.25.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") \ | |||
--hash=sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e \ | |||
--hash=sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804 | |||
rsa==4.5; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0" and python_version < "4") \ | |||
--hash=sha256:35c5b5f6675ac02120036d97cf96f1fde4d49670543db2822ba5015e21a18032 \ | |||
--hash=sha256:4d409f5a7d78530a4a2062574c7bd80311bc3af29b364e293aa9b03eea77714f \ | |||
--hash=sha256:a8774e55b59fd9fc893b0d05e9bfc6f47081f46ff5b46f39ccf24631b7be356b \ | |||
--hash=sha256:69805d6b69f56eb05b62daea3a7dbd7aa44324ad1306445e05da8060232d00f4 | |||
schema==0.7.3 \ | |||
--hash=sha256:c331438b60f634cab5664ab720d3083cc444f924d55269530c36b33e3354276f \ | |||
--hash=sha256:4cf529318cfd1e844ecbe02f41f7e5aa027463e7403666a52746f31f04f47a5e | |||
six==1.15.0; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" \ | |||
--hash=sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced \ | |||
--hash=sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259 | |||
tzlocal==1.5.1 \ | |||
--hash=sha256:4ebeb848845ac898da6519b9b31879cf13b6626f7184c496037b818e238f2c4e | |||
unidecode==1.1.2; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.4.0") \ | |||
--hash=sha256:4c9d15d2f73eb0d2649a151c566901f80a030da1ccb0a2043352e1dbf647586b \ | |||
--hash=sha256:a039f89014245e0cad8858976293e23501accc9ff5a7bdbc739a14a2b7b85cdc | |||
uritemplate==3.0.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.4.0") \ | |||
--hash=sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f \ | |||
--hash=sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae | |||
urllib3==1.26.2; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0" and python_version < "4") \ | |||
--hash=sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473 \ | |||
--hash=sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08 | |||
yapsy==1.12.2 \ | |||
--hash=sha256:83891e22db0a74445726981df0ef7818dae595454de9cf10b7ba603d45ccd157 \ | |||
--hash=sha256:d8113d9f9c74eacf65b4663c9c037d278c9cb273b5eee5f0e1803baeedb23f8b |