80 Commits

Author SHA1 Message Date
  LecygneNoir 03a8f4e71f Merge branch 'release/v0.12.2' 1 year ago
  LecygneNoir 5ca5ff5238 Release v0.12.2 to fix broken dependencies 1 year ago
  LecygneNoir 6c16b2f037 Merge tag 'v0.12.1' into develop 2 years ago
  LecygneNoir 36110432da Merge branch 'hotfix/v0.12.1' 2 years ago
  LecygneNoir 388f76b855 Fix a bug in when configuring log level 2 years ago
  LecygneNoir ef92fed69d Merge tag 'v0.12.0' into develop 3 years ago
  LecygneNoir bcb0e267f3 Merge branch 'release/v0.12.0' 3 years ago
  LecygneNoir 8bc79853c8 Bump version to v0.12.0 and prepare poetry build for v0.12.0 3 years ago
  LecygneNoir 45a1cbccff Merge branch 'feature/remove_formatcheck' into develop 3 years ago
  LecygneNoir 0a1360d8e2 Prepare changelo for v0.12.0 3 years ago
  LecygneNoir f8ae2b1c5e Update libraries and dependencies for prismedia 3 years ago
  LecygneNoir 0a53e77bd6 Add auto search for thumbnail based on .png in addition to .jpg and .jpeg 3 years ago
  LecygneNoir 2f7629ef1e Remove format checks for videos and thumbnail as Youtube and Peertube have no limitation anymore 3 years ago
  LecygneNoir e0a63ed4b2 Revert README to use prismedia as it's indeed the binary script installed through pip 3 years ago
  LecygneNoir ba2a1ebb79 Merge branch 'feature/improve_genconfig' into develop 3 years ago
  LecygneNoir cf3d4c32c3 Better resilience for the genconfig function thanks to happy path and @Zykino suggestion! 3 years ago
  LecygneNoir 85f0fe9b6f Add ask_overwirte function to utils in order to be more general and usable in other modules 3 years ago
  LecygneNoir 1a006f3b6c Improve genconfig system and documentation for easier use, cf #55 3 years ago
  LecygneNoir cdef038323 Move logger initialization in __init__.py to be able to use it from any module 3 years ago
  LecygneNoir cbf3386bac Remove confusing warning when using genconfig, see #55 3 years ago
  LecygneNoir ca733e0dc3 Merge pull request 'hearthbeat (keepalive ?)' (#54) from Zykino/prismedia:hearthbeat into develop 3 years ago
  Zykino a4f162320d Fix some spacing formatting and documentation 3 years ago
  Zykino 29b1747c3e Visit all of Youtube’s playlists 3 years ago
  Zykino ea39fe9854 Fix some spacing 3 years ago
  Zykino a725e848ab Add an option to use some credits easiely 3 years ago
  LecygneNoir 9b6da1e3dc Merge tag 'v0.11.0' into develop 3 years ago
  LecygneNoir 194e2e4606 Merge branch 'release/v0.11.0' 3 years ago
  LecygneNoir 339caeb7f7 Bump files to v0.11.0, fix #50 3 years ago
  LecygneNoir e6375b5aa0 Merge pull request 'Add a progression bar for Peertube's upload' (#52) from Zykino/prismedia:feature/progression into develop 3 years ago
  Zykino 6add140732 Disable the progressbar when the user want a quiet output 3 years ago
  Zykino c4e3243131 Add clint as requirement 3 years ago
  Zykino 93f1205ab8 Make it possible to choose between multiples tipes of progress bar 3 years ago
  Zykino 09c2d84357 Add a progression bar to Peertube upload 3 years ago
  LecygneNoir 230ac545c4 Patch incorrect loading of NFO keys, breaking the match when the key contains -, and add example for auto-originalDate in NFO 3 years ago
  LecygneNoir 42ee7d761b Need to strip the getmtime timestamp as some OSes return timestamp with microsecond (XXXX.YYYY) 3 years ago
  LecygneNoir 1a937098d8 Merge branch 'feature/recording_date' into develop 3 years ago
  LecygneNoir 736582b495 Change the originalDate behaviour to not default, and add option to auto manage the original date if needed 3 years ago
  LecygneNoir 4a9fda5e77 Add one function to deal with date to avoid duplicate code 3 years ago
  LecygneNoir 8dc3a86aab Stripe the README from the full --help output to focus on some main features 3 years ago
  LecygneNoir 4b7c01a707 Add help for option originalDate and changelog about the feature 3 years ago
  LecygneNoir dc98f2e155 Add functions to manage Original Date of record for peertube 3 years ago
  LecygneNoir 60bf26418d Add the originalDate options to Youtube videos 3 years ago
  LecygneNoir 447310a17e Add options and bunch of functions to maange the originalDate fields in prismedia, cf #50 3 years ago
  LecygneNoir 9b597f461e Merge tag 'v0.10.3' into develop 3 years ago
  LecygneNoir 8b1470ab31 Merge branch 'hotfix/v0.10.3' 3 years ago
  LecygneNoir 6d15ad18ca Fix peertube pagination index for playlsit, as index begins at 0, not 1, shame on me! 😳 3 years ago
  LecygneNoir 5c991581e8 bump to v0.10.2 for poetry 3 years ago
  LecygneNoir 4956a19d0e bump to v0.10.2 for poetry 3 years ago
  LecygneNoir 2f8543b43c Merge tag 'v0.10.2' into develop 3 years ago
  LecygneNoir 5607c8ea06 Merge branch 'release/v0.10.2' 3 years ago
  LecygneNoir 25435453bd Bump to version v0.10.2 and fix typo in ticket number inside Changelog for v0.10.2 3 years ago
  LecygneNoir dab44244f3 Revert the workaround for Youtube playlist bug now the bug is fixed by Youtube (see #47) 3 years ago
  LecygneNoir 26476347d3 Renable Peertube upload after tests 😓 3 years ago
  LecygneNoir 5160e9e68d Add a check to avoid uploading on Peertube with more than 5 tags (see #48) 3 years ago
  LecygneNoir e61a70460d Merge branch 'feature/41_pt_playlistrecreated' into develop 3 years ago
  LecygneNoir 0aae4da68f Add changelog for #41 and #47 3 years ago
  LecygneNoir cbb7c745de Add pagination to find Peertube existing playlists as default pagination shows 14 playlists max. See #41 3 years ago
  LecygneNoir c2db597388 Fix message about thumbnail where it missed a space, close #49 3 years ago
  LecygneNoir e33a4c91a0 Merge tag 'v0.10.1' into develop 3 years ago
  LecygneNoir bb451e108d Merge branch 'hotfix/v0.10.1' 3 years ago
  LecygneNoir 28a7541fa8 Bump version to v0.10.1 3 years ago
  LecygneNoir 320c3b1a0b Fix bug introduced by v0.10.0 breaking thumbnail on youtube 3 years ago
  LecygneNoir fbfe3356ec Merge tag 'v0.10.0' into develop 3 years ago
  LecygneNoir e7a4d1656a Merge branch 'release/v0.10.0' 3 years ago
  LecygneNoir 1e9b719e0c Bump version to 0.10.0 and edit Changelog en Readme for the 0.10.0 3 years ago
  LecygneNoir ee578e8e82 Add a workaround Youtube API bug regarding playlist to ignore incorrect 404 return, see #47 for more details 3 years ago
  LecygneNoir f8ca4b093a Merge branch 'feature/logs_improvement' into develop 3 years ago
  LecygneNoir 379aef1dd8 Following discussion in #29, rename print-url option and add a batch options 3 years ago
  LecygneNoir 1c441bf67a Add new options --quiet and --print-url for an easier scripting use of prismedia, cf #29 3 years ago
  LecygneNoir dc7dd5cb46 Full rewrite of logging system to introduce --log as an option, and prepare the work for #29 3 years ago
  LecygneNoir 25682af83a Merge branch 'feature/strict_options' into develop 3 years ago
  LecygneNoir 542bb6f1f9 Correction of some typos 3 years ago
  LecygneNoir 5022aface4 Prepare changelog and documentation for strict check option 3 years ago
  LecygneNoir 003830696f Add strict options to force and check the existence of specific option before uploading, following issue #36. Need upgrade of Schema to have Hooks. 3 years ago
  LecygneNoir 2e4e876169 Merge tag 'v0.9.1' into develop 3 years ago
  LecygneNoir e52e7f354d Merge branch 'release/v0.9.1' 3 years ago
  LecygneNoir 701e61413c Update prismedia, documentation and dependencies to v0.9.1 3 years ago
  LecygneNoir 4e20d9efc4 Merge branch 'bypass-mime-verification' of Zykino/prismedia into develop 3 years ago
  Zykino 802d70b8d5 Empower user to force using the file they selected 3 years ago
  LecygneNoir a1c472a5fa Merge tag 'v0.9.0' into develop 4 years ago
12 changed files with 1123 additions and 525 deletions
Unified View
  1. +76
    -9
      CHANGELOG.md
  2. +112
    -102
      README.md
  3. +327
    -153
      poetry.lock
  4. +8
    -1
      prismedia/__init__.py
  5. +11
    -2
      prismedia/genconfig.py
  6. +123
    -43
      prismedia/pt_upload.py
  7. +3
    -2
      prismedia/samples/nfo.txt
  8. +206
    -47
      prismedia/upload.py
  9. +46
    -10
      prismedia/utils.py
  10. +92
    -47
      prismedia/yt_upload.py
  11. +22
    -22
      pyproject.toml
  12. +97
    -87
      requirements.txt

+ 76
- 9
CHANGELOG.md View File

@ -1,9 +1,76 @@
# Changelog # Changelog
## v0.12.2
### Fix
- Adjust dependencies version, as `oauthlib` and `request-oauthlib` are incompatible with peertube process in their new versions
- Add `pytz` as explicit dependency since the previous unexplicit dependancy now install `pytz-deprecation-shim` - which does not work
- Remove peertube-mirror link as it's now (unfortunately) a dead project (fix #63)
## v0.12.1
### Fix
- Fix an error when setting log level in configuration
## v0.12.0
### Features
- Add `--heartbeat` option to send request to youtube API, avoiding youtube to disabling you API account if you do not upload video often (Thanks @Zykino see #54)
- Rework and improve genconfig process to avoid erasing existing configuration and make it more easy to use
- Add a `prismedia-init` script when installing prismedia to easily generate basic configuration (see #55)
- Update multiple dependencies used for prismedia as they were very old.
- Add auto search for thumbnail in `.png` in addition to `.jpg` and `.jepg`.
### Fixes
- Add pagination for youtube playlist to search for all user playlists (Thanks @Zykino)
- Remove file format check for both videos and thumbnail as Youtube and Peertube now accepts more than .mp4 and .jpg (see #60)
## v0.11.0
### Features
- Add the configuration of Original date of Record for Youtube and Peertube (see #50)
- Add a progress bar when uploading on Peertube (Thanks @Zykino, see #52)
## v0.10.3
### Fix
- Fix the pagination for Peertube playlist, as index begins at 0, not 1
## v0.10.2
### Fixes
- Fix a typo in log (missing space when displaying thumbnail) (see #49)
- Add pagination when searching playlist in Peertube as default pagination show only 14 playlists (see #41)
- Add a check to avoid uploading video on Peertube with more than 5 tags (see #48)
- Revert the workaround for Youtube playlist bug now the bug is fixed by Youtube (see #47)
## v0.10.1
### Fix
- Fix a bug introduced with v0.10.0 that broke thumbnail on youtube upload.
## v0.10.0
### Features
- Add the possibility to specify strict checks option to never forgot parameters when uploading (see #36)
- Improve logging system, add options for batch upload and print url-only in the stdout (see #29)
- --debug option is now deprecated in favor of --log=debug
### Fixes
- Workaround against the Youtube API breakdown while adding video in playlist. See #47 for details. Should be removed once Google fix their bugs.
## v0.9.1
### Features
- Possibility to bypass the MIME check for .mp4 when the user is sure of its video (#46 , thanks to @zykino)
- Now **available with pip** for installation! (See the README for doc)
## v0.9.0 ## v0.9.0
### Upgrade from v0.8.0 ### Upgrade from v0.8.0
Now using [poetry](https://python-poetry.org/) for packaging and installing! It's easier to maintain and publish package, but means changes when using prismedia from command line.
Now using [poetry](https://python-poetry.org/) for packaging and installing! It's easier to maintain and publish package, but means changes when using prismedia from command line.
**Using poetry** (recommanded) **Using poetry** (recommanded)
@ -18,8 +85,8 @@ poetry install
prismedia -h prismedia -h
``` ```
**From source**
Prismedia is now seen as a python module, so you need to use `python -m prismedia` instead of `./prismedia_upload.py`.
**From source**
Prismedia is now seen as a python module, so you need to use `python -m prismedia` instead of `./prismedia_upload.py`.
Once you have pulled the new v0.9.0, you may update by using: Once you have pulled the new v0.9.0, you may update by using:
``` ```
pip install -r requirements.txt pip install -r requirements.txt
@ -35,12 +102,12 @@ python -m prismedia -h
## v0.8.0 ## v0.8.0
### Breaking changes ### Breaking changes
Now work with python 3! Support of python 2 is no longer available.
Now work with python 3! Support of python 2 is no longer available.
You should now use python 3 in order to use prismedia You should now use python 3 in order to use prismedia
### Features ### Features
- Add a requirements.txt file to make installing requirement easier.
- Add a debug option to show some infos before uploading (thanks to @zykino)
- Add a requirements.txt file to make installing requirement easier.
- Add a debug option to show some infos before uploading (thanks to @zykino)
- Now uploading to Peertube before Youtube (thanks to @zykino) - Now uploading to Peertube before Youtube (thanks to @zykino)
## v0.7.1 ## v0.7.1
@ -51,7 +118,7 @@ Fix bug #42 , crash on Peertube when video has only one tag
## v0.7.0 ## v0.7.0
### Features ### Features
Support Peertube channel additionally with playlist for Peertube!
Support Peertube channel additionally with playlist for Peertube!
Peertube only as channel are Peertube's feature. See #40 for details. Peertube only as channel are Peertube's feature. See #40 for details.
### Fixes ### Fixes
@ -79,7 +146,7 @@ New feature, the Peertube playlists are now supported!
We do not use channel in place of playlist anymore. We do not use channel in place of playlist anymore.
## v0.6.1-1 Hotfix ## v0.6.1-1 Hotfix
This fix prepares the python3 compatibility.
This fix prepares the python3 compatibility.
**Warning** you need a new prerequisites: python-unidecode **Warning** you need a new prerequisites: python-unidecode
- Remove mastodon tags (mt) options as it's deprecated. Compatibility between Peertube and Mastodon is complete. - Remove mastodon tags (mt) options as it's deprecated. Compatibility between Peertube and Mastodon is complete.
@ -115,4 +182,4 @@ This release is fully compatible with Peertube v1.0.0!
### Fixes ### Fixes
- Display datetime for output - Display datetime for output
- plan video only if upload is successful
- plan video only if upload is successful

+ 112
- 102
README.md View File

@ -5,54 +5,81 @@ Scripting your way to upload videos to peertube and youtube. Works with Python 3
[TOC]: # [TOC]: #
## Table of Contents ## Table of Contents
- [Installation](#installation)
- [Installation](#installation-and-upgrade)
- [From pip](#from-pip)
- [From source](#from-source)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Peertube](#peertube) - [Peertube](#peertube)
- [Youtube](#youtube) - [Youtube](#youtube)
- [Usage](#usage) - [Usage](#usage)
- [Enhanced use of NFO](#enhanced-use-of-nfo) - [Enhanced use of NFO](#enhanced-use-of-nfo)
- [Strict check options](#strict-check-options)
- [Features](#features) - [Features](#features)
- [Compatibility](#compatibility) - [Compatibility](#compatibility)
- [Sources](#sources)
- [Inspirations](#inspirations)
- [Contributors](#contributors) - [Contributors](#contributors)
## Installation
## Installation and upgrade
You may use pip to install requirements: `pip install -r requirements.txt`
(*note:* requirements are generated via `poetry export -f requirements.txt`)
### From pip
Otherwise, you can use [poetry](https://python-poetry.org):
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
``` ```
poetry install # installs the dependency in the current virtualenv,
or creates one specific to the project if no virtualenv is currently active
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`)
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 ## Configuration
Generate sample files with `python -m prismedia.genconfig`.
Then edit `peertube_secret` and `youtube_secret.json` with your credentials. (see below)
Generate configuration files by running `prismedia-init`.
Then, edit them to fill your credential as explained below.
### Peertube ### 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
*Alternatively, you can set ``OAUTHLIB_INSECURE_TRANSPORT`` to 1 if you do not use https (not recommended)*
### Youtube ### Youtube
Configuration is in **youtube_secret.json** file.
Youtube uses combination of oauth and API access to identify. Youtube uses combination of oauth and API access to identify.
**Credentials** **Credentials**
The first time you connect, prismedia will open your browser to as 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``.
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`.
Prismedia will try to use this file at each launch, and re-ask for authentication if it does not exist. 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:
**Oauth**:
The default youtube_secret.json should allow you to upload some videos.
If you plan a larger usage, please consider creating your own youtube_secret file:
- Go to the [Google console](https://console.developers.google.com/). - Go to the [Google console](https://console.developers.google.com/).
- Create project. - Create project.
@ -64,103 +91,61 @@ If you plan an larger usage, please consider creating your own youtube_secret fi
- Save this JSON as your youtube_secret.json file. - Save this JSON as your youtube_secret.json file.
## Usage ## Usage
Support only mp4 for cross compatibility between Youtube and Peertube
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))
Upload a video:
Here are some demonstration of main usage:
```
python -m prismedia --file="yourvideo.mp4"
Upload a video:
```sh
prismedia --file="yourvideo.mp4"
``` ```
Specify description and tags: Specify description and tags:
```
python -m prismedia --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: Provide a thumbnail:
```
python -m prismedia --file="yourvideo.mp4" -d "Video with thumbnail" --thumbnail="/path/to/your/thumbnail.jpg"
```sh
prismedia --file="yourvideo.mp4" -d "Video with thumbnail" --thumbnail="/path/to/your/thumbnail.jpg"
``` ```
Use a NFO file to specify your video options:
(See nfo_example.txt for more precise example)
```
python -m prismedia --file="yourvideo.mp4" --nfo /path/to/your/nfo.txt
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 --help to get all available 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).
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
``` ```
Options:
-f, --file=STRING Path to the video file to upload in mp4
--name=NAME Name of the video to upload. (default to video filename)
--debug Trigger some debug information like options used (default: no)
-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
--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.
-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
Take a look at all available options with `--help`!
```sh
prismedia --help
``` ```
## Enhanced use of NFO ## Enhanced use of NFO
Since Prismedia v0.9.0, the NFO system has been improved to allow hierarchical loading. Since Prismedia v0.9.0, the NFO system has been improved to allow hierarchical loading.
First of all, **if you already used nfo**, either with `--nfo` or by using `videoname.txt`, nothing changes :-)
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 allow you to set some defaults to avoid recreating a full nfo for each video
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:
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` `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: 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/ $ tree Recipes/
Recipes/ Recipes/
├── cli_nfo.txt ├── cli_nfo.txt
@ -173,8 +158,8 @@ Recipes/
└── yourvideo2.txt └── yourvideo2.txt
``` ```
By using
```
By using
```sh
prismedia --file=/path/to/Recipes/yourvideo1.mp4 --nfo=/path/to/Recipes/cli_nfo.txt --cca prismedia --file=/path/to/Recipes/yourvideo1.mp4 --nfo=/path/to/Recipes/cli_nfo.txt --cca
``` ```
@ -186,10 +171,32 @@ Prismedia will:
- erase any previous option regarding CCA as it's specified in cli with `--cca` - 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 - take `yourvideo1.jpg` as thumbnail if no other files has been specified in previous NFO
In other word, Prismedia will now 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
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. 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 ## Features
- [x] Youtube upload - [x] Youtube upload
@ -210,17 +217,20 @@ It allows to specify more easily default options for an entire set of video, dir
- [x] schedule your video with publishAt - [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] 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] Use a config file (NFO) file to retrieve videos arguments
- [x] Allow to choose peertube or youtube upload (to resume failed upload for example)
- [x] Allow choosing peertube or youtube upload (to retry a failed upload for example)
- [x] Usable on Desktop (Linux and/or Windows and/or MacOS) - [x] Usable on Desktop (Linux and/or Windows and/or MacOS)
- [x] Different schedules on platforms to prepare preview - [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 ## Compatibility
- If you still use python2, use the version 0.7.1 (no more updated) - If you still use python2, use the version 0.7.1 (no more updated)
- peertube before 1.0.0-beta4, use the version inside tag 1.0.0-beta3
- If you use peertube before 1.0.0-beta4, use the version inside tag 1.0.0-beta3
## Sources
inspired by [peeror](https://git.rigelk.eu/rigelk/peeror) and [youtube-upload](https://github.com/tokland/youtube-upload)
## Inspirations
Inspired by peeror (First peertube mirror by Rigelk) and [youtube-upload](https://github.com/tokland/youtube-upload)
## Contributors ## Contributors
Thanks to: @Zykino, @meewan, @rigelk 😘
Thanks to: @LecygneNoir, @Zykino, @meewan, @rigelk 😘

+ 327
- 153
poetry.lock View File

@ -1,3 +1,28 @@
[[package]]
category = "main"
description = "Command Arguments for Humans."
name = "args"
optional = false
python-versions = "*"
version = "0.1.0"
[[package]]
category = "main"
description = "Backport of the standard library zoneinfo module"
marker = "python_version >= \"3.6\" and python_version < \"3.9\" or python_version < \"3.9\""
name = "backports.zoneinfo"
optional = false
python-versions = ">=3.6"
version = "0.2.1"
[package.dependencies]
[package.dependencies.importlib-resources]
python = "<3.7"
version = "*"
[package.extras]
tzdata = ["tzdata"]
[[package]] [[package]]
category = "main" category = "main"
description = "Extensible memoizing collections and decorators" description = "Extensible memoizing collections and decorators"
@ -12,27 +37,50 @@ description = "Python package for providing Mozilla's CA Bundle."
name = "certifi" name = "certifi"
optional = false optional = false
python-versions = "*" python-versions = "*"
version = "2020.4.5.1"
version = "2021.10.8"
[[package]]
category = "main"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
marker = "python_version >= \"3\""
name = "charset-normalizer"
optional = false
python-versions = ">=3.5.0"
version = "2.0.12"
[package.extras]
unicode_backport = ["unicodedata2"]
[[package]] [[package]]
category = "main" category = "main"
description = "Universal encoding detector for Python 2 and 3"
name = "chardet"
description = "Python Command Line Interface Tools"
name = "clint"
optional = false optional = false
python-versions = "*" python-versions = "*"
version = "3.0.4"
version = "0.5.1"
[package.dependencies]
args = "*"
[[package]] [[package]]
category = "main" category = "main"
description = "Updated configparser from Python 3.7 for Python 2.6+."
description = "Updated configparser from Python 3.8 for Python 2.6+."
name = "configparser" name = "configparser"
optional = false optional = false
python-versions = ">=2.6"
version = "3.8.1"
python-versions = ">=3.6"
version = "5.2.0"
[package.extras] [package.extras]
docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
testing = ["pytest (>=3.5,<3.7.3 || >3.7.3)", "pytest-checkdocs (>=1.2)", "pytest-flake8"]
docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"]
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "types-backports", "pytest-black (>=0.3.7)", "pytest-mypy"]
[[package]]
category = "main"
description = "Backports and enhancements for the contextlib module"
name = "contextlib2"
optional = false
python-versions = ">=3.6"
version = "21.6.0"
[[package]] [[package]]
category = "main" category = "main"
@ -48,27 +96,24 @@ description = "Clean single-source support for Python 3 and 2"
name = "future" name = "future"
optional = false optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
version = "0.17.1"
version = "0.18.2"
[[package]] [[package]]
category = "main" category = "main"
description = "Google API client core library" description = "Google API client core library"
name = "google-api-core" name = "google-api-core"
optional = false optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
version = "1.16.0"
python-versions = ">=3.6"
version = "2.7.2"
[package.dependencies] [package.dependencies]
google-auth = ">=0.4.0,<2.0dev"
googleapis-common-protos = ">=1.6.0,<2.0dev"
protobuf = ">=3.4.0"
pytz = "*"
google-auth = ">=1.25.0,<3.0dev"
googleapis-common-protos = ">=1.52.0,<2.0dev"
protobuf = ">=3.12.0"
requests = ">=2.18.0,<3.0.0dev" requests = ">=2.18.0,<3.0.0dev"
setuptools = ">=34.0.0"
six = ">=1.10.0"
[package.extras] [package.extras]
grpc = ["grpcio (>=1.8.2,<2.0dev)"]
grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio-status (>=1.33.2,<2.0dev)"]
grpcgcp = ["grpcio-gcp (>=0.2.2)"] grpcgcp = ["grpcio-gcp (>=0.2.2)"]
grpcio-gcp = ["grpcio-gcp (>=0.2.2)"] grpcio-gcp = ["grpcio-gcp (>=0.2.2)"]
@ -77,69 +122,76 @@ category = "main"
description = "Google API Client Library for Python" description = "Google API Client Library for Python"
name = "google-api-python-client" name = "google-api-python-client"
optional = false optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
version = "1.8.0"
python-versions = ">=3.6"
version = "2.44.0"
[package.dependencies] [package.dependencies]
google-api-core = ">=1.13.0,<2dev"
google-auth = ">=1.4.1"
google-auth-httplib2 = ">=0.0.3"
httplib2 = ">=0.9.2,<1dev"
six = ">=1.6.1,<2dev"
uritemplate = ">=3.0.0,<4dev"
google-api-core = ">=1.31.5,<2.0.0 || >2.3.0,<3.0.0dev"
google-auth = ">=1.16.0,<3.0.0dev"
google-auth-httplib2 = ">=0.1.0"
httplib2 = ">=0.15.0,<1dev"
uritemplate = ">=3.0.1,<5"
[[package]] [[package]]
category = "main" category = "main"
description = "Google Authentication Library" description = "Google Authentication Library"
name = "google-auth" name = "google-auth"
optional = false optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
version = "1.13.1"
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*"
version = "2.6.5"
[package.dependencies] [package.dependencies]
cachetools = ">=2.0.0,<5.0"
cachetools = ">=2.0.0,<6.0"
pyasn1-modules = ">=0.2.1" pyasn1-modules = ">=0.2.1"
rsa = ">=3.1.4,<4.1"
setuptools = ">=40.3.0"
six = ">=1.9.0" six = ">=1.9.0"
[package.dependencies.rsa]
python = ">=3.6"
version = ">=3.1.4,<5"
[package.extras]
aiohttp = ["requests (>=2.20.0,<3.0.0dev)", "aiohttp (>=3.6.2,<4.0.0dev)"]
pyopenssl = ["pyopenssl (>=20.0.0)"]
reauth = ["pyu2f (>=0.1.5)"]
[[package]] [[package]]
category = "main" category = "main"
description = "Google Authentication Library: httplib2 transport" description = "Google Authentication Library: httplib2 transport"
name = "google-auth-httplib2" name = "google-auth-httplib2"
optional = false optional = false
python-versions = "*" python-versions = "*"
version = "0.0.3"
version = "0.1.0"
[package.dependencies] [package.dependencies]
google-auth = "*" google-auth = "*"
httplib2 = ">=0.9.1"
httplib2 = ">=0.15.0"
six = "*"
[[package]] [[package]]
category = "main" category = "main"
description = "Google Authentication Library" description = "Google Authentication Library"
name = "google-auth-oauthlib" name = "google-auth-oauthlib"
optional = false optional = false
python-versions = "*"
version = "0.2.0"
python-versions = ">=3.6"
version = "0.5.1"
[package.dependencies] [package.dependencies]
google-auth = "*"
google-auth = ">=1.0.0"
requests-oauthlib = ">=0.7.0" requests-oauthlib = ">=0.7.0"
[package.extras] [package.extras]
tool = ["click"]
tool = ["click (>=6.0.0)"]
[[package]] [[package]]
category = "main" category = "main"
description = "Common protobufs used in Google APIs" description = "Common protobufs used in Google APIs"
name = "googleapis-common-protos" name = "googleapis-common-protos"
optional = false optional = false
python-versions = "*"
version = "1.51.0"
python-versions = ">=3.6"
version = "1.56.0"
[package.dependencies] [package.dependencies]
protobuf = ">=3.6.0"
protobuf = ">=3.12.0"
[package.extras] [package.extras]
grpc = ["grpcio (>=1.0.0)"] grpc = ["grpcio (>=1.0.0)"]
@ -149,16 +201,40 @@ category = "main"
description = "A comprehensive HTTP client library." description = "A comprehensive HTTP client library."
name = "httplib2" name = "httplib2"
optional = false optional = false
python-versions = "*"
version = "0.12.3"
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "0.20.4"
[package.dependencies]
[package.dependencies.pyparsing]
python = ">=3.1"
version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0.2,<3.0.3 || >3.0.3,<4"
[[package]] [[package]]
category = "main" category = "main"
description = "Internationalized Domain Names in Applications (IDNA)" description = "Internationalized Domain Names in Applications (IDNA)"
marker = "python_version >= \"3\""
name = "idna" name = "idna"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "2.9"
python-versions = ">=3.5"
version = "3.3"
[[package]]
category = "main"
description = "Read resources from Python packages"
marker = "python_version >= \"3.6\" and python_version < \"3.7\" or python_version < \"3.7\""
name = "importlib-resources"
optional = false
python-versions = ">=3.6"
version = "5.4.0"
[package.dependencies]
[package.dependencies.zipp]
python = "<3.10"
version = ">=3.1.0"
[package.extras]
docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-black (>=0.3.7)", "pytest-mypy"]
[[package]] [[package]]
category = "main" category = "main"
@ -179,12 +255,8 @@ category = "main"
description = "Protocol Buffers" description = "Protocol Buffers"
name = "protobuf" name = "protobuf"
optional = false optional = false
python-versions = "*"
version = "3.11.3"
[package.dependencies]
setuptools = "*"
six = ">=1.9"
python-versions = ">=3.5"
version = "3.19.4"
[[package]] [[package]]
category = "main" category = "main"
@ -207,61 +279,79 @@ pyasn1 = ">=0.4.6,<0.5.0"
[[package]] [[package]]
category = "main" category = "main"
description = "File type identification using libmagic"
name = "python-magic"
description = "Python parsing module"
marker = "python_version > \"3.0\""
name = "pyparsing"
optional = false optional = false
python-versions = "*"
version = "0.4.15"
python-versions = ">=3.6"
version = "3.0.7"
[package.extras]
diagrams = ["jinja2", "railroad-diagrams"]
[[package]] [[package]]
category = "main" category = "main"
description = "File type identification using libmagic binary package"
marker = "platform_system == \"Windows\""
name = "python-magic-bin"
description = "World timezone definitions, modern and historical"
name = "pytz"
optional = false optional = false
python-versions = "*" python-versions = "*"
version = "0.4.14"
version = "2022.1"
[[package]] [[package]]
category = "main" category = "main"
description = "World timezone definitions, modern and historical"
name = "pytz"
description = "Shims to make deprecation of pytz easier"
name = "pytz-deprecation-shim"
optional = false optional = false
python-versions = "*"
version = "2019.3"
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
version = "0.1.0.post0"
[package.dependencies]
[package.dependencies."backports.zoneinfo"]
python = ">=3.6,<3.9"
version = "*"
[package.dependencies.tzdata]
python = ">=3.6"
version = "*"
[[package]] [[package]]
category = "main" category = "main"
description = "Python HTTP for Humans." description = "Python HTTP for Humans."
name = "requests" name = "requests"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "2.23.0"
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
version = "2.27.1"
[package.dependencies] [package.dependencies]
certifi = ">=2017.4.17" certifi = ">=2017.4.17"
chardet = ">=3.0.2,<4"
idna = ">=2.5,<3"
urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26"
urllib3 = ">=1.21.1,<1.27"
[package.dependencies.charset-normalizer]
python = ">=3"
version = ">=2.0.0,<2.1.0"
[package.dependencies.idna]
python = ">=3"
version = ">=2.5,<4"
[package.extras] [package.extras]
security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"]
socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"]
use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
[[package]] [[package]]
category = "main" category = "main"
description = "OAuthlib authentication support for Requests." description = "OAuthlib authentication support for Requests."
name = "requests-oauthlib" name = "requests-oauthlib"
optional = false optional = false
python-versions = "*"
version = "0.8.0"
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "1.1.0"
[package.dependencies] [package.dependencies]
oauthlib = ">=0.6.2"
oauthlib = ">=2.1.0,<3.0.0"
requests = ">=2.0.0" requests = ">=2.0.0"
[package.extras] [package.extras]
rsa = ["oauthlib (>=0.6.2)", "requests (>=2.0.0)"]
rsa = ["oauthlib (>=2.1.0,<3.0.0)"]
[[package]] [[package]]
category = "main" category = "main"
@ -277,10 +367,11 @@ requests = ">=2.0.1,<3.0.0"
[[package]] [[package]]
category = "main" category = "main"
description = "Pure-Python RSA implementation" description = "Pure-Python RSA implementation"
marker = "python_version >= \"3.6\""
name = "rsa" name = "rsa"
optional = false optional = false
python-versions = "*" python-versions = "*"
version = "4.0"
version = "4.4"
[package.dependencies] [package.dependencies]
pyasn1 = ">=0.1.3" pyasn1 = ">=0.1.3"
@ -291,7 +382,10 @@ description = "Simple data validation library"
name = "schema" name = "schema"
optional = false optional = false
python-versions = "*" python-versions = "*"
version = "0.6.8"
version = "0.7.5"
[package.dependencies]
contextlib2 = ">=0.5.5"
[[package]] [[package]]
category = "main" category = "main"
@ -299,34 +393,52 @@ description = "Python 2 and 3 compatibility utilities"
name = "six" name = "six"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
version = "1.14.0"
version = "1.16.0"
[[package]]
category = "main"
description = "Provider of IANA time zone data"
marker = "python_version >= \"3.6\" or platform_system == \"Windows\""
name = "tzdata"
optional = false
python-versions = ">=2"
version = "2022.1"
[[package]] [[package]]
category = "main" category = "main"
description = "tzinfo object for the local timezone" description = "tzinfo object for the local timezone"
name = "tzlocal" name = "tzlocal"
optional = false optional = false
python-versions = "*"
version = "1.5.1"
python-versions = ">=3.6"
version = "4.2"
[package.dependencies] [package.dependencies]
pytz = "*"
pytz-deprecation-shim = "*"
tzdata = "*"
[package.dependencies."backports.zoneinfo"]
python = "<3.9"
version = "*"
[package.extras]
devenv = ["black", "pyroma", "pytest-cov", "zest.releaser"]
test = ["pytest-mock (>=3.3)", "pytest (>=4.3)"]
[[package]] [[package]]
category = "main" category = "main"
description = "ASCII transliterations of Unicode text" description = "ASCII transliterations of Unicode text"
name = "unidecode" name = "unidecode"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "1.1.1"
python-versions = ">=3.5"
version = "1.3.4"
[[package]] [[package]]
category = "main" category = "main"
description = "URI templates"
description = "Implementation of RFC 6570 URI Templates"
name = "uritemplate" name = "uritemplate"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "3.0.1"
python-versions = ">=3.6"
version = "4.1.1"
[[package]] [[package]]
category = "main" category = "main"
@ -340,88 +452,141 @@ version = "1.22"
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"]
[[package]]
category = "main"
description = "Backport of pathlib-compatible object wrapper for zip files"
marker = "python_version >= \"3.6\" and python_version < \"3.7\" or python_version < \"3.7\""
name = "zipp"
optional = false
python-versions = ">=3.6"
version = "3.6.0"
[package.extras]
docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"]
[metadata] [metadata]
content-hash = "b3063876dbcd6443d0459a9ef376ccdba2a21adc2e7a49d75c9450904b40615f"
python-versions = ">=3.5"
content-hash = "be6573f03c921c7e695d02a9d03b766a756d2c5a61619cbebea44c85a4f66bf6"
python-versions = ">=3.6"
[metadata.files] [metadata.files]
args = [
{file = "args-0.1.0.tar.gz", hash = "sha256:a785b8d837625e9b61c39108532d95b85274acd679693b71ebb5156848fcf814"},
]
"backports.zoneinfo" = [
{file = "backports.zoneinfo-0.2.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc"},
{file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722"},
{file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:1c5742112073a563c81f786e77514969acb58649bcdf6cdf0b4ed31a348d4546"},
{file = "backports.zoneinfo-0.2.1-cp36-cp36m-win32.whl", hash = "sha256:e8236383a20872c0cdf5a62b554b27538db7fa1bbec52429d8d106effbaeca08"},
{file = "backports.zoneinfo-0.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8439c030a11780786a2002261569bdf362264f605dfa4d65090b64b05c9f79a7"},
{file = "backports.zoneinfo-0.2.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac"},
{file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:17746bd546106fa389c51dbea67c8b7c8f0d14b5526a579ca6ccf5ed72c526cf"},
{file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5c144945a7752ca544b4b78c8c41544cdfaf9786f25fe5ffb10e838e19a27570"},
{file = "backports.zoneinfo-0.2.1-cp37-cp37m-win32.whl", hash = "sha256:e55b384612d93be96506932a786bbcde5a2db7a9e6a4bb4bffe8b733f5b9036b"},
{file = "backports.zoneinfo-0.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a76b38c52400b762e48131494ba26be363491ac4f9a04c1b7e92483d169f6582"},
{file = "backports.zoneinfo-0.2.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987"},
{file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1"},
{file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9"},
{file = "backports.zoneinfo-0.2.1-cp38-cp38-win32.whl", hash = "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328"},
{file = "backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6"},
{file = "backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2"},
]
cachetools = [ cachetools = [
{file = "cachetools-3.1.1-py2.py3-none-any.whl", hash = "sha256:428266a1c0d36dc5aca63a2d7c5942e88c2c898d72139fca0e97fdd2380517ae"}, {file = "cachetools-3.1.1-py2.py3-none-any.whl", hash = "sha256:428266a1c0d36dc5aca63a2d7c5942e88c2c898d72139fca0e97fdd2380517ae"},
{file = "cachetools-3.1.1.tar.gz", hash = "sha256:8ea2d3ce97850f31e4a08b0e2b5e6c34997d7216a9d2c98e0f3978630d4da69a"}, {file = "cachetools-3.1.1.tar.gz", hash = "sha256:8ea2d3ce97850f31e4a08b0e2b5e6c34997d7216a9d2c98e0f3978630d4da69a"},
] ]
certifi = [ certifi = [
{file = "certifi-2020.4.5.1-py2.py3-none-any.whl", hash = "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304"},
{file = "certifi-2020.4.5.1.tar.gz", hash = "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"},
{file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"},
{file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"},
]
charset-normalizer = [
{file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"},
{file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"},
] ]
chardet = [
{file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"},
{file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"},
clint = [
{file = "clint-0.5.1.tar.gz", hash = "sha256:05224c32b1075563d0b16d0015faaf9da43aa214e4a2140e51f08789e7a4c5aa"},
] ]
configparser = [ configparser = [
{file = "configparser-3.8.1-py2.py3-none-any.whl", hash = "sha256:45d1272aad6cfd7a8a06cf5c73f2ceb6a190f6acc1fa707e7f82a4c053b28b18"},
{file = "configparser-3.8.1.tar.gz", hash = "sha256:bc37850f0cc42a1725a796ef7d92690651bf1af37d744cc63161dac62cabee17"},
{file = "configparser-5.2.0-py3-none-any.whl", hash = "sha256:e8b39238fb6f0153a069aa253d349467c3c4737934f253ef6abac5fe0eca1e5d"},
{file = "configparser-5.2.0.tar.gz", hash = "sha256:1b35798fdf1713f1c3139016cfcbc461f09edbf099d1fb658d4b7479fcaa3daa"},
]
contextlib2 = [
{file = "contextlib2-21.6.0-py2.py3-none-any.whl", hash = "sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f"},
{file = "contextlib2-21.6.0.tar.gz", hash = "sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869"},
] ]
docopt = [ docopt = [
{file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"},
] ]
future = [ future = [
{file = "future-0.17.1.tar.gz", hash = "sha256:67045236dcfd6816dc439556d009594abf643e5eb48992e36beac09c2ca659b8"},
{file = "future-0.18.2.tar.gz", hash = "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"},
] ]
google-api-core = [ google-api-core = [
{file = "google-api-core-1.16.0.tar.gz", hash = "sha256:92e962a087f1c4b8d1c5c88ade1c1dfd550047dcffb320c57ef6a534a20403e2"},
{file = "google_api_core-1.16.0-py2.py3-none-any.whl", hash = "sha256:859f7392676761f2b160c6ee030c3422135ada4458f0948c5690a6a7c8d86294"},
{file = "google-api-core-2.7.2.tar.gz", hash = "sha256:65480309a7437f739e4476da037af02a3ec8263f1d1f89f72bbdc8f54fe402d2"},
{file = "google_api_core-2.7.2-py3-none-any.whl", hash = "sha256:8fcbe52dc129fd83dca4e638a76f22b3a11579c493e643134e50e9870b233302"},
] ]
google-api-python-client = [ google-api-python-client = [
{file = "google-api-python-client-1.8.0.tar.gz", hash = "sha256:0f5b42a14e2d2f7dee40f2e4514531dbe95ebde9c2173b1c4040a65c427e7900"},
{file = "google_api_python_client-1.8.0-py3-none-any.whl", hash = "sha256:5032ad1af5046889649b3848f2e871889fbb6ae440198a549fe1699581300386"},
{file = "google-api-python-client-2.44.0.tar.gz", hash = "sha256:fc9c31737b82a592d29636c6f77af9cf9666e45ed966d0a5ebe20711aa0df83c"},
{file = "google_api_python_client-2.44.0-py2.py3-none-any.whl", hash = "sha256:0588acf65ea0569ece979c4eecdd5366c98ba314c1212a86a2808de0b7bcd02b"},
] ]
google-auth = [ google-auth = [
{file = "google-auth-1.13.1.tar.gz", hash = "sha256:a5ee4c40fef77ea756cf2f1c0adcf475ecb53af6700cf9c133354cdc9b267148"},
{file = "google_auth-1.13.1-py2.py3-none-any.whl", hash = "sha256:cab6c707e6ee20e567e348168a5c69dc6480384f777a9e5159f4299ad177dcc0"},
{file = "google-auth-2.6.5.tar.gz", hash = "sha256:04e224f241c0566477bb35a8a93be8c635210de743bde454d49393cfb605266d"},
{file = "google_auth-2.6.5-py2.py3-none-any.whl", hash = "sha256:9a88ee548f6fd49467e2e443dfbfe10344e5a270629a137a3a0b3437ec6b02a6"},
] ]
google-auth-httplib2 = [ google-auth-httplib2 = [
{file = "google-auth-httplib2-0.0.3.tar.gz", hash = "sha256:098fade613c25b4527b2c08fa42d11f3c2037dda8995d86de0745228e965d445"},
{file = "google_auth_httplib2-0.0.3-py2.py3-none-any.whl", hash = "sha256:f1c437842155680cf9918df9bc51c1182fda41feef88c34004bd1978c8157e08"},
{file = "google-auth-httplib2-0.1.0.tar.gz", hash = "sha256:a07c39fd632becacd3f07718dfd6021bf396978f03ad3ce4321d060015cc30ac"},
{file = "google_auth_httplib2-0.1.0-py2.py3-none-any.whl", hash = "sha256:31e49c36c6b5643b57e82617cb3e021e3e1d2df9da63af67252c02fa9c1f4a10"},
] ]
google-auth-oauthlib = [ google-auth-oauthlib = [
{file = "google-auth-oauthlib-0.2.0.tar.gz", hash = "sha256:226d1d0960f86ba5d9efd426a70b291eaba96f47d071657e0254ea969025728a"},
{file = "google_auth_oauthlib-0.2.0-py2.py3-none-any.whl", hash = "sha256:81ba22acada4d13b1d83f9371ab19fd61f1250a542d21cf49e4dcf0637a7344a"},
{file = "google-auth-oauthlib-0.5.1.tar.gz", hash = "sha256:30596b824fc6808fdaca2f048e4998cc40fb4b3599eaea66d28dc7085b36c5b8"},
{file = "google_auth_oauthlib-0.5.1-py2.py3-none-any.whl", hash = "sha256:24f67735513c4c7134dbde2f1dee5a1deb6acc8dfcb577d7bff30d213a28e7b0"},
] ]
googleapis-common-protos = [ googleapis-common-protos = [
{file = "googleapis-common-protos-1.51.0.tar.gz", hash = "sha256:013c91704279119150e44ef770086fdbba158c1f978a6402167d47d5409e226e"},
{file = "googleapis-common-protos-1.56.0.tar.gz", hash = "sha256:4007500795bcfc269d279f0f7d253ae18d6dc1ff5d5a73613ffe452038b1ec5f"},
{file = "googleapis_common_protos-1.56.0-py2.py3-none-any.whl", hash = "sha256:60220c89b8bd5272159bed4929ecdc1243ae1f73437883a499a44a1cbc084086"},
] ]
httplib2 = [ httplib2 = [
{file = "httplib2-0.12.3-py3-none-any.whl", hash = "sha256:23914b5487dfe8ef09db6656d6d63afb0cf3054ad9ebc50868ddc8e166b5f8e8"},
{file = "httplib2-0.12.3.tar.gz", hash = "sha256:a18121c7c72a56689efbf1aef990139ad940fee1e64c6f2458831736cd593600"},
{file = "httplib2-0.20.4-py3-none-any.whl", hash = "sha256:8b6a905cb1c79eefd03f8669fd993c36dc341f7c558f056cb5a33b5c2f458543"},
{file = "httplib2-0.20.4.tar.gz", hash = "sha256:58a98e45b4b1a48273073f905d2961666ecf0fbac4250ea5b47aef259eb5c585"},
] ]
idna = [ idna = [
{file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"},
{file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"},
{file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
{file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
]
importlib-resources = [
{file = "importlib_resources-5.4.0-py3-none-any.whl", hash = "sha256:33a95faed5fc19b4bc16b29a6eeae248a3fe69dd55d4d229d2b480e23eeaad45"},
{file = "importlib_resources-5.4.0.tar.gz", hash = "sha256:d756e2f85dd4de2ba89be0b21dba2a3bbec2e871a42a3a16719258a11f87506b"},
] ]
oauthlib = [ oauthlib = [
{file = "oauthlib-2.1.0-py2.py3-none-any.whl", hash = "sha256:d883b36b21a6ad813953803edfa563b1b579d79ca758fe950d1bc9e8b326025b"}, {file = "oauthlib-2.1.0-py2.py3-none-any.whl", hash = "sha256:d883b36b21a6ad813953803edfa563b1b579d79ca758fe950d1bc9e8b326025b"},
{file = "oauthlib-2.1.0.tar.gz", hash = "sha256:ac35665a61c1685c56336bda97d5eefa246f1202618a1d6f34fccb1bdd404162"}, {file = "oauthlib-2.1.0.tar.gz", hash = "sha256:ac35665a61c1685c56336bda97d5eefa246f1202618a1d6f34fccb1bdd404162"},
] ]
protobuf = [ protobuf = [
{file = "protobuf-3.11.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:ef2c2e56aaf9ee914d3dccc3408d42661aaf7d9bb78eaa8f17b2e6282f214481"},
{file = "protobuf-3.11.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:dd9aa4401c36785ea1b6fff0552c674bdd1b641319cb07ed1fe2392388e9b0d7"},
{file = "protobuf-3.11.3-cp35-cp35m-macosx_10_9_intel.whl", hash = "sha256:310a7aca6e7f257510d0c750364774034272538d51796ca31d42c3925d12a52a"},
{file = "protobuf-3.11.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:e512b7f3a4dd780f59f1bf22c302740e27b10b5c97e858a6061772668cd6f961"},
{file = "protobuf-3.11.3-cp35-cp35m-win32.whl", hash = "sha256:fdfb6ad138dbbf92b5dbea3576d7c8ba7463173f7d2cb0ca1bd336ec88ddbd80"},
{file = "protobuf-3.11.3-cp35-cp35m-win_amd64.whl", hash = "sha256:e2f8a75261c26b2f5f3442b0525d50fd79a71aeca04b5ec270fc123536188306"},
{file = "protobuf-3.11.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c40973a0aee65422d8cb4e7d7cbded95dfeee0199caab54d5ab25b63bce8135a"},
{file = "protobuf-3.11.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:adf0e4d57b33881d0c63bb11e7f9038f98ee0c3e334c221f0858f826e8fb0151"},
{file = "protobuf-3.11.3-cp36-cp36m-win32.whl", hash = "sha256:0bae429443cc4748be2aadfdaf9633297cfaeb24a9a02d0ab15849175ce90fab"},
{file = "protobuf-3.11.3-cp36-cp36m-win_amd64.whl", hash = "sha256:e11df1ac6905e81b815ab6fd518e79be0a58b5dc427a2cf7208980f30694b956"},
{file = "protobuf-3.11.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7774bbbaac81d3ba86de646c39f154afc8156717972bf0450c9dbfa1dc8dbea2"},
{file = "protobuf-3.11.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:8eb9c93798b904f141d9de36a0ba9f9b73cc382869e67c9e642c0aba53b0fc07"},
{file = "protobuf-3.11.3-cp37-cp37m-win32.whl", hash = "sha256:fac513a9dc2a74b99abd2e17109b53945e364649ca03d9f7a0b96aa8d1807d0a"},
{file = "protobuf-3.11.3-cp37-cp37m-win_amd64.whl", hash = "sha256:82d7ac987715d8d1eb4068bf997f3053468e0ce0287e2729c30601feb6602fee"},
{file = "protobuf-3.11.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:73152776dc75f335c476d11d52ec6f0f6925774802cd48d6189f4d5d7fe753f4"},
{file = "protobuf-3.11.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:52e586072612c1eec18e1174f8e3bb19d08f075fc2e3f91d3b16c919078469d0"},
{file = "protobuf-3.11.3-py2.7.egg", hash = "sha256:2affcaba328c4662f3bc3c0e9576ea107906b2c2b6422344cdad961734ff6b93"},
{file = "protobuf-3.11.3-py2.py3-none-any.whl", hash = "sha256:24e3b6ad259544d717902777b33966a1a069208c885576254c112663e6a5bb0f"},
{file = "protobuf-3.11.3.tar.gz", hash = "sha256:c77c974d1dadf246d789f6dad1c24426137c9091e930dbf50e0a29c1fcf00b1f"},
{file = "protobuf-3.19.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f51d5a9f137f7a2cec2d326a74b6e3fc79d635d69ffe1b036d39fc7d75430d37"},
{file = "protobuf-3.19.4-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:09297b7972da685ce269ec52af761743714996b4381c085205914c41fcab59fb"},
{file = "protobuf-3.19.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:072fbc78d705d3edc7ccac58a62c4c8e0cec856987da7df8aca86e647be4e35c"},
{file = "protobuf-3.19.4-cp310-cp310-win32.whl", hash = "sha256:7bb03bc2873a2842e5ebb4801f5c7ff1bfbdf426f85d0172f7644fcda0671ae0"},
{file = "protobuf-3.19.4-cp310-cp310-win_amd64.whl", hash = "sha256:f358aa33e03b7a84e0d91270a4d4d8f5df6921abe99a377828839e8ed0c04e07"},
{file = "protobuf-3.19.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:1c91ef4110fdd2c590effb5dca8fdbdcb3bf563eece99287019c4204f53d81a4"},
{file = "protobuf-3.19.4-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c438268eebb8cf039552897d78f402d734a404f1360592fef55297285f7f953f"},
{file = "protobuf-3.19.4-cp36-cp36m-win32.whl", hash = "sha256:835a9c949dc193953c319603b2961c5c8f4327957fe23d914ca80d982665e8ee"},
{file = "protobuf-3.19.4-cp36-cp36m-win_amd64.whl", hash = "sha256:4276cdec4447bd5015453e41bdc0c0c1234eda08420b7c9a18b8d647add51e4b"},
{file = "protobuf-3.19.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6cbc312be5e71869d9d5ea25147cdf652a6781cf4d906497ca7690b7b9b5df13"},
{file = "protobuf-3.19.4-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:54a1473077f3b616779ce31f477351a45b4fef8c9fd7892d6d87e287a38df368"},
{file = "protobuf-3.19.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:435bb78b37fc386f9275a7035fe4fb1364484e38980d0dd91bc834a02c5ec909"},
{file = "protobuf-3.19.4-cp37-cp37m-win32.whl", hash = "sha256:16f519de1313f1b7139ad70772e7db515b1420d208cb16c6d7858ea989fc64a9"},
{file = "protobuf-3.19.4-cp37-cp37m-win_amd64.whl", hash = "sha256:cdc076c03381f5c1d9bb1abdcc5503d9ca8b53cf0a9d31a9f6754ec9e6c8af0f"},
{file = "protobuf-3.19.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:69da7d39e39942bd52848438462674c463e23963a1fdaa84d88df7fbd7e749b2"},
{file = "protobuf-3.19.4-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:48ed3877fa43e22bcacc852ca76d4775741f9709dd9575881a373bd3e85e54b2"},
{file = "protobuf-3.19.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd95d1dfb9c4f4563e6093a9aa19d9c186bf98fa54da5252531cc0d3a07977e7"},
{file = "protobuf-3.19.4-cp38-cp38-win32.whl", hash = "sha256:b38057450a0c566cbd04890a40edf916db890f2818e8682221611d78dc32ae26"},
{file = "protobuf-3.19.4-cp38-cp38-win_amd64.whl", hash = "sha256:7ca7da9c339ca8890d66958f5462beabd611eca6c958691a8fe6eccbd1eb0c6e"},
{file = "protobuf-3.19.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:36cecbabbda242915529b8ff364f2263cd4de7c46bbe361418b5ed859677ba58"},
{file = "protobuf-3.19.4-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:c1068287025f8ea025103e37d62ffd63fec8e9e636246b89c341aeda8a67c934"},
{file = "protobuf-3.19.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96bd766831596d6014ca88d86dc8fe0fb2e428c0b02432fd9db3943202bf8c5e"},
{file = "protobuf-3.19.4-cp39-cp39-win32.whl", hash = "sha256:84123274d982b9e248a143dadd1b9815049f4477dc783bf84efe6250eb4b836a"},
{file = "protobuf-3.19.4-cp39-cp39-win_amd64.whl", hash = "sha256:3112b58aac3bac9c8be2b60a9daf6b558ca3f7681c130dcdd788ade7c9ffbdca"},
{file = "protobuf-3.19.4-py2.py3-none-any.whl", hash = "sha256:8961c3a78ebfcd000920c9060a262f082f29838682b1f7201889300c1fbe0616"},
{file = "protobuf-3.19.4.tar.gz", hash = "sha256:9df0c10adf3e83015ced42a9a7bd64e13d06c4cf45c340d2c63020ea04499d0a"},
] ]
pyasn1 = [ pyasn1 = [
{file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"}, {file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"},
@ -453,55 +618,64 @@ pyasn1-modules = [
{file = "pyasn1_modules-0.2.8-py3.6.egg", hash = "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0"}, {file = "pyasn1_modules-0.2.8-py3.6.egg", hash = "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0"},
{file = "pyasn1_modules-0.2.8-py3.7.egg", hash = "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd"}, {file = "pyasn1_modules-0.2.8-py3.7.egg", hash = "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd"},
] ]
python-magic = [
{file = "python-magic-0.4.15.tar.gz", hash = "sha256:f3765c0f582d2dfc72c15f3b5a82aecfae9498bd29ca840d72f37d7bd38bfcd5"},
{file = "python_magic-0.4.15-py2.py3-none-any.whl", hash = "sha256:f2674dcfad52ae6c49d4803fa027809540b130db1dec928cfbb9240316831375"},
]
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"},
pyparsing = [
{file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"},
{file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"},
] ]
pytz = [ pytz = [
{file = "pytz-2019.3-py2.py3-none-any.whl", hash = "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d"},
{file = "pytz-2019.3.tar.gz", hash = "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"},
{file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"},
{file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"},
]
pytz-deprecation-shim = [
{file = "pytz_deprecation_shim-0.1.0.post0-py2.py3-none-any.whl", hash = "sha256:8314c9692a636c8eb3bda879b9f119e350e93223ae83e70e80c31675a0fdc1a6"},
{file = "pytz_deprecation_shim-0.1.0.post0.tar.gz", hash = "sha256:af097bae1b616dde5c5744441e2ddc69e74dfdcb0c263129610d85b87445a59d"},
] ]
requests = [ requests = [
{file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"},
{file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"},
{file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
{file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"},
] ]
requests-oauthlib = [ 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"},
{file = "requests-oauthlib-1.1.0.tar.gz", hash = "sha256:eabd8eb700ebed81ba080c6ead96d39d6bdc39996094bd23000204f6965786b0"},
{file = "requests_oauthlib-1.1.0-py2.py3-none-any.whl", hash = "sha256:be76f2bb72ca5525998e81d47913e09b1ca8b7957ae89b46f787a79e68ad5e61"},
{file = "requests_oauthlib-1.1.0-py3.7.egg", hash = "sha256:490229d14a98e1b69612dcc1a22887ec14f5487dc1b8c6d7ba7f77a42ce7347b"},
] ]
requests-toolbelt = [ requests-toolbelt = [
{file = "requests-toolbelt-0.9.1.tar.gz", hash = "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"}, {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"}, {file = "requests_toolbelt-0.9.1-py2.py3-none-any.whl", hash = "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f"},
] ]
rsa = [ rsa = [
{file = "rsa-4.0-py2.py3-none-any.whl", hash = "sha256:14ba45700ff1ec9eeb206a2ce76b32814958a98e372006c8fb76ba820211be66"},
{file = "rsa-4.0.tar.gz", hash = "sha256:1a836406405730121ae9823e19c6e806c62bbad73f890574fff50efa4122c487"},
{file = "rsa-4.4-py2.py3-none-any.whl", hash = "sha256:4afbaaecc3e9550c7351fdf0ab3fea1857ff616b85bab59215f00fb42e0e9582"},
{file = "rsa-4.4.tar.gz", hash = "sha256:5d95293bbd0fbee1dd9cb4b72d27b723942eb50584abc8c4f5f00e4bcfa55307"},
] ]
schema = [ schema = [
{file = "schema-0.6.8-py2.py3-none-any.whl", hash = "sha256:d994b0dc4966000037b26898df638e3e2a694cc73636cb2050e652614a350687"},
{file = "schema-0.6.8.tar.gz", hash = "sha256:fa1a53fe5f3b6929725a4e81688c250f46838e25d8c1885a10a590c8c01a7b74"},
{file = "schema-0.7.5-py2.py3-none-any.whl", hash = "sha256:f3ffdeeada09ec34bf40d7d79996d9f7175db93b7a5065de0faa7f41083c1e6c"},
{file = "schema-0.7.5.tar.gz", hash = "sha256:f06717112c61895cabc4707752b88716e8420a8819d71404501e114f91043197"},
] ]
six = [ six = [
{file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"},
{file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"},
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
tzdata = [
{file = "tzdata-2022.1-py2.py3-none-any.whl", hash = "sha256:238e70234214138ed7b4e8a0fab0e5e13872edab3be586ab8198c407620e2ab9"},
{file = "tzdata-2022.1.tar.gz", hash = "sha256:8b536a8ec63dc0751342b3984193a3118f8fca2afe25752bb9b7fffd398552d3"},
] ]
tzlocal = [ tzlocal = [
{file = "tzlocal-1.5.1.tar.gz", hash = "sha256:4ebeb848845ac898da6519b9b31879cf13b6626f7184c496037b818e238f2c4e"},
{file = "tzlocal-4.2-py3-none-any.whl", hash = "sha256:89885494684c929d9191c57aa27502afc87a579be5cdd3225c77c463ea043745"},
{file = "tzlocal-4.2.tar.gz", hash = "sha256:ee5842fa3a795f023514ac2d801c4a81d1743bbe642e3940143326b3a00addd7"},
] ]
unidecode = [ unidecode = [
{file = "Unidecode-1.1.1-py2.py3-none-any.whl", hash = "sha256:1d7a042116536098d05d599ef2b8616759f02985c85b4fef50c78a5aaf10822a"},
{file = "Unidecode-1.1.1.tar.gz", hash = "sha256:2b6aab710c2a1647e928e36d69c21e76b453cd455f4e2621000e54b2a9b8cce8"},
{file = "Unidecode-1.3.4-py3-none-any.whl", hash = "sha256:afa04efcdd818a93237574791be9b2817d7077c25a068b00f8cff7baa4e59257"},
{file = "Unidecode-1.3.4.tar.gz", hash = "sha256:8e4352fb93d5a735c788110d2e7ac8e8031eb06ccbfe8d324ab71735015f9342"},
] ]
uritemplate = [ uritemplate = [
{file = "uritemplate-3.0.1-py2.py3-none-any.whl", hash = "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f"},
{file = "uritemplate-3.0.1.tar.gz", hash = "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae"},
{file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"},
{file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"},
] ]
urllib3 = [ urllib3 = [
{file = "urllib3-1.22-py2.py3-none-any.whl", hash = "sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b"}, {file = "urllib3-1.22-py2.py3-none-any.whl", hash = "sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b"},
{file = "urllib3-1.22.tar.gz", hash = "sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f"}, {file = "urllib3-1.22.tar.gz", hash = "sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f"},
] ]
zipp = [
{file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"},
{file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"},
]

+ 8
- 1
prismedia/__init__.py View File

@ -1,5 +1,12 @@
from future import standard_library from future import standard_library
standard_library.install_aliases() 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 from . import upload
from . import genconfig

+ 11
- 2
prismedia/genconfig.py View File

@ -1,6 +1,10 @@
from os.path import join, abspath, isfile, dirname
from os.path import join, abspath, isfile, dirname, exists
from os import listdir from os import listdir
from shutil import copyfile from shutil import copyfile
import logging
logger = logging.getLogger('Prismedia')
from . import utils
def genconfig(): def genconfig():
@ -8,7 +12,12 @@ def genconfig():
files = [f for f in listdir(path) if isfile(join(path, f))] files = [f for f in listdir(path) if isfile(join(path, f))]
for f in files: for f in files:
copyfile(join(path, f), f)
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__': if __name__ == '__main__':

+ 123
- 43
prismedia/pt_upload.py View File

@ -5,6 +5,7 @@ import os
import mimetypes import mimetypes
import json import json
import logging import logging
import sys
import datetime import datetime
import pytz import pytz
from os.path import splitext, basename, abspath from os.path import splitext, basename, abspath
@ -13,9 +14,11 @@ from tzlocal import get_localzone
from configparser import RawConfigParser from configparser import RawConfigParser
from requests_oauthlib import OAuth2Session from requests_oauthlib import OAuth2Session
from oauthlib.oauth2 import LegacyApplicationClient from oauthlib.oauth2 import LegacyApplicationClient
from requests_toolbelt.multipart.encoder import MultipartEncoder
from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor
from clint.textui.progress import Bar as ProgressBar
from . import utils from . import utils
logger = logging.getLogger('Prismedia')
PEERTUBE_SECRETS_FILE = 'peertube_secret' PEERTUBE_SECRETS_FILE = 'peertube_secret'
PEERTUBE_PRIVACY = { PEERTUBE_PRIVACY = {
@ -43,10 +46,10 @@ def get_authenticated_service(secret):
) )
except Exception as e: except Exception as e:
if hasattr(e, 'message'): if hasattr(e, 'message'):
logging.error("Peertube: Error: " + str(e.message))
logger.critical("Peertube: " + str(e.message))
exit(1) exit(1)
else: else:
logging.error("Peertube: Error: " + str(e))
logger.critical("Peertube: " + str(e))
exit(1) exit(1)
return oauth return oauth
@ -61,9 +64,16 @@ def get_channel_by_name(user_info, options):
return channel['id'] return channel['id']
def convert_peertube_date(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 create_channel(oauth, url, options): def create_channel(oauth, url, options):
template = ('Peertube: Channel %s does not exist, creating it.') template = ('Peertube: Channel %s does not exist, creating it.')
logging.info(template % (str(options.get('--channel'))))
logger.info(template % (str(options.get('--channel'))))
channel_name = utils.cleanString(str(options.get('--channel'))) channel_name = utils.cleanString(str(options.get('--channel')))
# Peertube allows 20 chars max for channel name # Peertube allows 20 chars max for channel name
channel_name = channel_name[:19] channel_name = channel_name[:19]
@ -81,23 +91,23 @@ def create_channel(oauth, url, options):
headers=headers) headers=headers)
except Exception as e: except Exception as e:
if hasattr(e, 'message'): if hasattr(e, 'message'):
logging.error("Error: " + str(e.message))
logger.error("Peertube: " + str(e.message))
else: else:
logging.error("Error: " + str(e))
logger.error("Peertube: " + str(e))
if response is not None: if response is not None:
if response.status_code == 200: if response.status_code == 200:
jresponse = response.json() jresponse = response.json()
jresponse = jresponse['videoChannel'] jresponse = jresponse['videoChannel']
return jresponse['id'] return jresponse['id']
if response.status_code == 409: if response.status_code == 409:
logging.error('Peertube: Error: It seems there is a conflict with an existing channel named '
logger.critical('Peertube: It seems there is a conflict with an existing channel named '
+ channel_name + '.' + channel_name + '.'
' Please beware Peertube internal name is compiled from 20 firsts characters of 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)' ' Also note that channel name are not case sensitive (no uppercase nor accent)'
' Please check your channel name and retry.') ' Please check your channel name and retry.')
exit(1) exit(1)
else: else:
logging.error(('Peertube: Creating channel failed with an unexpected response: '
logger.critical(('Peertube: Creating channel failed with an unexpected response: '
'%s') % response) '%s') % response)
exit(1) exit(1)
@ -106,15 +116,26 @@ def get_default_playlist(user_info):
return user_info['videoChannels'][0]['id'] return user_info['videoChannels'][0]['id']
def get_playlist_by_name(user_playlists, options):
for playlist in user_playlists["data"]:
if playlist['displayName'] == options.get('--playlist'):
return playlist['id']
def get_playlist_by_name(oauth, url, username, options):
start = 0
user_playlists = json.loads(oauth.get(
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'] == options.get('--playlist'):
return playlist['id']
start = start + 100
user_playlists = json.loads(oauth.get(
url+"/api/v1/accounts/"+username+"/video-playlists?start="+str(start)+"&count=100").content)
data = user_playlists["data"]
def create_playlist(oauth, url, options, channel): def create_playlist(oauth, url, options, channel):
template = ('Peertube: Playlist %s does not exist, creating it.') template = ('Peertube: Playlist %s does not exist, creating it.')
logging.info(template % (str(options.get('--playlist'))))
logger.info(template % (str(options.get('--playlist'))))
# We use files for form-data Content # We use files for form-data Content
# see https://requests.readthedocs.io/en/latest/user/quickstart/#post-a-multipart-encoded-file # see https://requests.readthedocs.io/en/latest/user/quickstart/#post-a-multipart-encoded-file
# None is used to mute "filename" field # None is used to mute "filename" field
@ -128,22 +149,22 @@ def create_playlist(oauth, url, options, channel):
files=files) files=files)
except Exception as e: except Exception as e:
if hasattr(e, 'message'): if hasattr(e, 'message'):
logging.error("Error: " + str(e.message))
logger.error("Peertube: " + str(e.message))
else: else:
logging.error("Error: " + str(e))
logger.error("Peertube: " + str(e))
if response is not None: if response is not None:
if response.status_code == 200: if response.status_code == 200:
jresponse = response.json() jresponse = response.json()
jresponse = jresponse['videoPlaylist'] jresponse = jresponse['videoPlaylist']
return jresponse['id'] return jresponse['id']
else: else:
logging.error(('Peertube: Creating the playlist failed with an unexpected response: '
logger.critical(('Peertube: Creating the playlist failed with an unexpected response: '
'%s') % response) '%s') % response)
exit(1) exit(1)
def set_playlist(oauth, url, video_id, playlist_id): def set_playlist(oauth, url, video_id, playlist_id):
logging.info('Peertube: add video to playlist.')
logger.info('Peertube: add video to playlist.')
data = '{"videoId":"' + str(video_id) + '"}' data = '{"videoId":"' + str(video_id) + '"}'
headers = { headers = {
@ -155,14 +176,14 @@ def set_playlist(oauth, url, video_id, playlist_id):
headers=headers) headers=headers)
except Exception as e: except Exception as e:
if hasattr(e, 'message'): if hasattr(e, 'message'):
logging.error("Error: " + str(e.message))
logger.error("Peertube: " + str(e.message))
else: else:
logging.error("Error: " + str(e))
logger.error("Peertube: " + str(e))
if response is not None: if response is not None:
if response.status_code == 200: if response.status_code == 200:
logging.info('Peertube: Video is successfully added to the playlist.')
logger.info('Peertube: Video is successfully added to the playlist.')
else: else:
logging.error(('Peertube: Configuring the playlist failed with an unexpected response: '
logger.critical(('Peertube: Configuring the playlist failed with an unexpected response: '
'%s') % response) '%s') % response)
exit(1) exit(1)
@ -177,13 +198,10 @@ def upload_video(oauth, secret, options):
return (basename(path), open(abspath(path), 'rb'), return (basename(path), open(abspath(path), 'rb'),
mimetypes.types_map[splitext(path)[1]]) mimetypes.types_map[splitext(path)[1]])
def get_playlist(username):
return json.loads(oauth.get(url+"/api/v1/accounts/"+username+"/video-playlists").content)
path = options.get('--file') path = options.get('--file')
url = str(secret.get('peertube', 'peertube_url')).rstrip('/') url = str(secret.get('peertube', 'peertube_url')).rstrip('/')
user_info = get_userinfo() user_info = get_userinfo()
user_playlists = get_playlist(str(secret.get('peertube', 'username').lower()))
username = str(secret.get('peertube', 'username').lower())
# We need to transform fields into tuple to deal with tags as # We need to transform fields into tuple to deal with tags as
# MultipartEncoder does not support list refer # MultipartEncoder does not support list refer
@ -199,14 +217,22 @@ def upload_video(oauth, secret, options):
if options.get('--tags'): if options.get('--tags'):
tags = options.get('--tags').split(',') tags = options.get('--tags').split(',')
tag_number = 0
for strtag in tags: for strtag in tags:
tag_number = tag_number + 1
# Empty tag crashes Peertube, so skip them # Empty tag crashes Peertube, so skip them
if strtag == "": if strtag == "":
continue continue
# Tag more than 30 chars crashes Peertube, so exit and check tags
# Tag more than 30 chars crashes Peertube, so skip tags
if len(strtag) >= 30: if len(strtag) >= 30:
logging.warning("Peertube: Sorry, Peertube does not support tag with more than 30 characters, please reduce tag: " + strtag)
exit(1)
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)) fields.append(("tags[]", strtag))
if options.get('--category'): if options.get('--category'):
@ -237,16 +263,18 @@ def upload_video(oauth, secret, options):
publishAt = options.get('--publishAt') publishAt = options.get('--publishAt')
if 'publishAt' in locals(): if 'publishAt' in locals():
publishAt = datetime.datetime.strptime(publishAt, '%Y-%m-%dT%H:%M:%S')
tz = get_localzone()
tz = pytz.timezone(str(tz))
publishAt = tz.localize(publishAt).isoformat()
publishAt = convert_peertube_date(publishAt)
fields.append(("scheduleUpdate[updateAt]", publishAt)) fields.append(("scheduleUpdate[updateAt]", publishAt))
fields.append(("scheduleUpdate[privacy]", str(PEERTUBE_PRIVACY["public"]))) fields.append(("scheduleUpdate[privacy]", str(PEERTUBE_PRIVACY["public"])))
fields.append(("privacy", str(PEERTUBE_PRIVACY["private"]))) fields.append(("privacy", str(PEERTUBE_PRIVACY["private"])))
else: else:
fields.append(("privacy", str(PEERTUBE_PRIVACY[privacy or "private"]))) fields.append(("privacy", str(PEERTUBE_PRIVACY[privacy or "private"])))
# Set originalDate except if the user force no originalDate
if options.get('--originalDate'):
originalDate = convert_peertube_date(options.get('--originalDate'))
fields.append(("originallyPublishedAt", originalDate))
if options.get('--thumbnail'): if options.get('--thumbnail'):
fields.append(("thumbnailfile", get_file(options.get('--thumbnail')))) fields.append(("thumbnailfile", get_file(options.get('--thumbnail'))))
fields.append(("previewfile", get_file(options.get('--thumbnail')))) fields.append(("previewfile", get_file(options.get('--thumbnail'))))
@ -256,7 +284,7 @@ def upload_video(oauth, secret, options):
if not channel_id and options.get('--channelCreate'): if not channel_id and options.get('--channelCreate'):
channel_id = create_channel(oauth, url, options) channel_id = create_channel(oauth, url, options)
elif not channel_id: elif not channel_id:
logging.warning("Channel `" + options.get('--channel') + "` is unknown, using default channel.")
logger.warning("Peertube: Channel `" + options.get('--channel') + "` is unknown, using default channel.")
channel_id = get_default_channel(user_info) channel_id = get_default_channel(user_info)
else: else:
channel_id = get_default_channel(user_info) channel_id = get_default_channel(user_info)
@ -264,15 +292,24 @@ def upload_video(oauth, secret, options):
fields.append(("channelId", str(channel_id))) fields.append(("channelId", str(channel_id)))
if options.get('--playlist'): if options.get('--playlist'):
playlist_id = get_playlist_by_name(user_playlists, options)
playlist_id = get_playlist_by_name(oauth, url, username, options)
if not playlist_id and options.get('--playlistCreate'): if not playlist_id and options.get('--playlistCreate'):
playlist_id = create_playlist(oauth, url, options, channel_id) playlist_id = create_playlist(oauth, url, options, channel_id)
elif not playlist_id: elif not playlist_id:
logging.warning("Playlist `" + options.get('--playlist') + "` does not exist, please set --playlistCreate"
logger.critical("Peertube: Playlist `" + options.get('--playlist') + "` does not exist, please set --playlistCreate"
" if you want to create it") " if you want to create it")
exit(1) exit(1)
multipart_data = MultipartEncoder(fields)
logger_stdout = None
if options.get('--url-only') or options.get('--batch'):
logger_stdout = logging.getLogger('stdoutlogs')
encoder = MultipartEncoder(fields)
if options.get('--quiet'):
multipart_data = encoder
else:
progress_callback = create_callback(encoder, options.get('--progress'))
multipart_data = MultipartEncoderMonitor(encoder, progress_callback)
headers = { headers = {
'Content-Type': multipart_data.content_type 'Content-Type': multipart_data.content_type
@ -280,39 +317,82 @@ def upload_video(oauth, secret, options):
response = oauth.post(url + "/api/v1/videos/upload", response = oauth.post(url + "/api/v1/videos/upload",
data=multipart_data, data=multipart_data,
headers=headers) headers=headers)
if response is not None: if response is not None:
if response.status_code == 200: if response.status_code == 200:
jresponse = response.json() jresponse = response.json()
jresponse = jresponse['video'] jresponse = jresponse['video']
uuid = jresponse['uuid'] uuid = jresponse['uuid']
video_id = str(jresponse['id']) video_id = str(jresponse['id'])
logging.info('Peertube : Video was successfully uploaded.')
logger.info('Peertube: Video was successfully uploaded.')
template = 'Peertube: Watch it at %s/videos/watch/%s.' template = 'Peertube: Watch it at %s/videos/watch/%s.'
logging.info(template % (url, uuid))
logger.info(template % (url, uuid))
template_stdout = '%s/videos/watch/%s'
if options.get('--url-only'):
logger_stdout.info(template_stdout % (url, uuid))
elif options.get('--batch'):
logger_stdout.info("Peertube: " + template_stdout % (url, uuid))
# Upload is successful we may set playlist # Upload is successful we may set playlist
if options.get('--playlist'): if options.get('--playlist'):
set_playlist(oauth, url, video_id, playlist_id) set_playlist(oauth, url, video_id, playlist_id)
else: else:
logging.error(('Peertube: The upload failed with an unexpected response: '
logger.critical(('Peertube: The upload failed with an unexpected response: '
'%s') % response) '%s') % response)
exit(1) exit(1)
upload_finished = False
def create_callback(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 run(options): def run(options):
secret = RawConfigParser() secret = RawConfigParser()
try: try:
secret.read(PEERTUBE_SECRETS_FILE) secret.read(PEERTUBE_SECRETS_FILE)
except Exception as e: except Exception as e:
logging.error("Peertube: Error loading " + str(PEERTUBE_SECRETS_FILE) + ": " + str(e))
logger.critical("Peertube: Error loading " + str(PEERTUBE_SECRETS_FILE) + ": " + str(e))
exit(1) exit(1)
insecure_transport = secret.get('peertube', 'OAUTHLIB_INSECURE_TRANSPORT') insecure_transport = secret.get('peertube', 'OAUTHLIB_INSECURE_TRANSPORT')
os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = insecure_transport os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = insecure_transport
oauth = get_authenticated_service(secret) oauth = get_authenticated_service(secret)
try: try:
logging.info('Peertube: Uploading video...')
logger.info('Peertube: Uploading video...')
upload_video(oauth, secret, options) upload_video(oauth, secret, options)
except Exception as e: except Exception as e:
if hasattr(e, 'message'): if hasattr(e, 'message'):
logging.error("Peertube: Error: " + str(e.message))
logger.error("Peertube: " + str(e.message))
else: else:
logging.error("Peertube: Error: " + str(e))
logger.error("Peertube: " + str(e))

+ 3
- 2
prismedia/samples/nfo.txt View File

@ -4,6 +4,7 @@
# Some generic options for your videos # Some generic options for your videos
cca = True cca = True
privacy = private privacy = private
disable-comments = True
disable-comments = False
channel = DefaultChannel channel = DefaultChannel
channelCreate = True
channelCreate = True
auto-originalDate = True

+ 206
- 47
prismedia/upload.py View File

@ -7,13 +7,13 @@ prismedia - tool to upload videos to Peertube and Youtube
Usage: Usage:
prismedia --file=<FILE> [options] prismedia --file=<FILE> [options]
prismedia -f <FILE> --tags=STRING [options] prismedia -f <FILE> --tags=STRING [options]
prismedia --hearthbeat
prismedia -h | --help prismedia -h | --help
prismedia --version prismedia --version
Options: Options:
-f, --file=STRING Path to the video file to upload in mp4
-f, --file=STRING Path to the video file to upload. This is the only mandatory option.
--name=NAME Name of the video to upload. (default to video filename) --name=NAME Name of the video to upload. (default to video filename)
--debug Trigger some debug information like options used (default: no)
-d, --description=STRING Description of the video. (default: default description) -d, --description=STRING Description of the video. (default: default description)
-t, --tags=STRING Tags for the video. comma separated. -t, --tags=STRING Tags for the video. comma separated.
WARN: tags with punctuation (!, ', ", ?, ...) WARN: tags with punctuation (!, ', ", ?, ...)
@ -35,9 +35,12 @@ Options:
DATE should be in the future DATE should be in the future
--peertubeAt=DATE --peertubeAt=DATE
--youtubeAt=DATE Override publishAt for the corresponding platform. Allow to create preview on specific platform --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. --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
By default, prismedia search for an image based on video name followed by .jpg, .jpeg or .png
--channel=STRING Set the channel to use for the video (Peertube only) --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. 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) --channelCreate Create the channel if not exists. (Peertube only, default do not create)
@ -46,9 +49,41 @@ Options:
If the playlist is not found, spawn an error except if --playlistCreate is set. 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) --playlistCreate Create the playlist if not exists. (default do not create)
Only relevant if --playlist is set. Only relevant if --playlist is set.
--progress=STRING Set the progress bar view, one of percentage, bigFile (MB), accurate (KB).
--hearthbeat 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. -h --help Show this help.
--version Show version. --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.
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: Categories:
Category is the type of video you upload. Default is films. Category is the type of video you upload. Default is films.
Here are available categories from Peertube and Youtube: Here are available categories from Peertube and Youtube:
@ -69,9 +104,10 @@ import sys
if sys.version_info[0] < 3: if sys.version_info[0] < 3:
raise Exception("Python 3 or a more recent version is required.") raise Exception("Python 3 or a more recent version is required.")
import os
import datetime import datetime
import logging import logging
logging.basicConfig(format='%(asctime)s %(message)s', level=logging.INFO)
logger = logging.getLogger('Prismedia')
from docopt import docopt from docopt import docopt
@ -81,22 +117,14 @@ from . import utils
try: try:
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
from schema import Schema, And, Or, Optional, SchemaError
from schema import Schema, And, Or, Optional, SchemaError, Hook, Use
except ImportError: except ImportError:
logging.error('This program requires that the `schema` data-validation library'
logger.critical('This program requires that the `schema` data-validation library'
' is installed: \n' ' is installed: \n'
'see https://github.com/halst/schema\n') 'see https://github.com/halst/schema\n')
exit(1) 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.9.0"
VERSION = "prismedia v0.12.2"
VALID_PRIVACY_STATUSES = ('public', 'private', 'unlisted') VALID_PRIVACY_STATUSES = ('public', 'private', 'unlisted')
VALID_CATEGORIES = ( VALID_CATEGORIES = (
@ -111,14 +139,7 @@ VALID_LANGUAGES = ('arabic', 'english', 'french',
'german', 'hindi', 'italian', 'german', 'hindi', 'italian',
'japanese', 'korean', 'mandarin', 'japanese', 'korean', 'mandarin',
'portuguese', 'punjabi', 'russian', 'spanish') '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
VALID_PROGRESS = ('percentage', 'bigfile', 'accurate')
def validateCategory(category): def validateCategory(category):
@ -150,11 +171,11 @@ def validateLanguage(language):
return False return False
def validatePublish(publish):
def validatePublishDate(publishDate):
# Check date format and if date is future # Check date format and if date is future
try: try:
now = datetime.datetime.now() now = datetime.datetime.now()
publishAt = datetime.datetime.strptime(publish, '%Y-%m-%dT%H:%M:%S')
publishAt = datetime.datetime.strptime(publishDate, '%Y-%m-%dT%H:%M:%S')
if now >= publishAt: if now >= publishAt:
return False return False
except ValueError: except ValueError:
@ -162,19 +183,121 @@ def validatePublish(publish):
return True return True
def validateThumbnail(thumbnail):
supported_types = ['image/jpg', 'image/jpeg']
if magic.from_file(thumbnail, mime=True) in supported_types:
return thumbnail
else:
def validateOriginalDate(originalDate):
# Check date format and if date is past
try:
now = datetime.datetime.now()
originalDate = datetime.datetime.strptime(originalDate, '%Y-%m-%dT%H:%M:%S')
if now <= originalDate:
return False
except ValueError:
return False return False
return True
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 _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
for handler in logger.handlers or logger.parent.handlers:
if options.get('--quiet'):
# We need to set both log level in the same time
logger.setLevel(50)
handler.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)
handler.setLevel(numeric_level)
elif options.get('--debug'):
# Deprecated,
logger.setLevel(10)
handler.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(): def main():
options = docopt(__doc__, version=VERSION) 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({ schema = Schema({
'--file': And(str, validateVideo, error='file is not supported, please use mp4'),
'--file': And(str, os.path.exists, error='file does not exists, please check path'),
# 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( Optional('--name'): Or(None, And(
str, str,
lambda x: not x.isdigit(), lambda x: not x.isdigit(),
@ -209,47 +332,84 @@ def main():
Optional('--platform'): Or(None, And(str, validatePlatform, error="Sorry, upload platform not supported")), Optional('--platform'): Or(None, And(str, validatePlatform, error="Sorry, upload platform not supported")),
Optional('--publishAt'): Or(None, And( Optional('--publishAt'): Or(None, And(
str, str,
validatePublish,
error="DATE should be the form YYYY-MM-DDThh:mm:ss and has to be in the future")
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( Optional('--peertubeAt'): Or(None, And(
str, str,
validatePublish,
error="DATE should be the form YYYY-MM-DDThh:mm:ss and has to be in the future")
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( Optional('--youtubeAt'): Or(None, And(
str, str,
validatePublish,
error="DATE should be the form YYYY-MM-DDThh:mm:ss and has to be in the future")
validatePublishDate,
error="Publish Date should be the form YYYY-MM-DDThh:mm:ss and has to be in the future")
), ),
Optional('--debug'): bool,
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('--cca'): bool,
Optional('--disable-comments'): bool, Optional('--disable-comments'): bool,
Optional('--nsfw'): bool, Optional('--nsfw'): bool,
Optional('--thumbnail'): Or(None, And( Optional('--thumbnail'): Or(None, And(
str, validateThumbnail, error='thumbnail is not supported, please use jpg/jpeg'),
str, os.path.exists, error='Thumbnail does not exists, please check the path.'),
), ),
Optional('--channel'): Or(None, str), Optional('--channel'): Or(None, str),
Optional('--channelCreate'): bool, Optional('--channelCreate'): bool,
Optional('--playlist'): Or(None, str), Optional('--playlist'): Or(None, str),
Optional('--playlistCreate'): bool, Optional('--playlistCreate'): bool,
Optional('--progress'): Or(None, And(str, validateProgress, error="Sorry, progress visualisation not supported")),
'--hearthbeat': bool,
'--help': bool, '--help': bool,
'--version': 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('--hearthbeat'):
yt_upload.hearthbeat()
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) 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'): if not options.get('--thumbnail'):
options = utils.searchThumbnail(options) options = utils.searchThumbnail(options)
try: try:
options = schema.validate(options) options = schema.validate(options)
except SchemaError as e: except SchemaError as e:
exit(e)
logger.critical(e)
exit(1)
if options.get('--debug'):
print(sys.version)
print(options)
logger.debug("Python " + sys.version)
logger.debug(options)
if options.get('--platform') is None or "peertube" in options.get('--platform'): if options.get('--platform') is None or "peertube" in options.get('--platform'):
pt_upload.run(options) pt_upload.run(options)
@ -258,6 +418,5 @@ def main():
if __name__ == '__main__': if __name__ == '__main__':
import warnings
warnings.warn("use 'python -m prismedia', not 'python -m prismedia.upload'", DeprecationWarning)
logger.warning("DEPRECATION: use 'python -m prismedia', not 'python -m prismedia.upload'")
main() main()

+ 46
- 10
prismedia/utils.py View File

@ -2,12 +2,13 @@
# coding: utf-8 # coding: utf-8
from configparser import RawConfigParser, NoOptionError, NoSectionError from configparser import RawConfigParser, NoOptionError, NoSectionError
from os.path import dirname, splitext, basename, isfile
from os.path import dirname, splitext, basename, isfile, getmtime
import re import re
from os import devnull
from subprocess import check_call, CalledProcessError, STDOUT
import unidecode import unidecode
import logging import logging
import datetime
logger = logging.getLogger('Prismedia')
### CATEGORIES ### ### CATEGORIES ###
YOUTUBE_CATEGORY = { YOUTUBE_CATEGORY = {
@ -99,6 +100,15 @@ def getLanguage(language, platform):
return PEERTUBE_LANGUAGE[language.lower()] return PEERTUBE_LANGUAGE[language.lower()]
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): def remove_empty_kwargs(**kwargs):
good_kwargs = {} good_kwargs = {}
if kwargs is not None: if kwargs is not None:
@ -116,6 +126,8 @@ def searchThumbnail(options):
options['--thumbnail'] = video_directory + options.get('--name') + ".jpg" options['--thumbnail'] = video_directory + options.get('--name') + ".jpg"
elif isfile(video_directory + options.get('--name') + ".jpeg"): elif isfile(video_directory + options.get('--name') + ".jpeg"):
options['--thumbnail'] = video_directory + options.get('--name') + ".jpeg" options['--thumbnail'] = video_directory + options.get('--name') + ".jpeg"
elif isfile(video_directory + options.get('--name') + ".png"):
options['--thumbnail'] = video_directory + options.get('--name') + ".png"
# Then, if we still not have thumbnail, check for thumbnail based on videofile name # Then, if we still not have thumbnail, check for thumbnail based on videofile name
if not options.get('--thumbnail'): if not options.get('--thumbnail'):
video_file = splitext(basename(options.get('--file')))[0] video_file = splitext(basename(options.get('--file')))[0]
@ -123,18 +135,32 @@ def searchThumbnail(options):
options['--thumbnail'] = video_directory + video_file + ".jpg" options['--thumbnail'] = video_directory + video_file + ".jpg"
elif isfile(video_directory + video_file + ".jpeg"): elif isfile(video_directory + video_file + ".jpeg"):
options['--thumbnail'] = video_directory + video_file + ".jpeg" options['--thumbnail'] = video_directory + video_file + ".jpeg"
elif isfile(video_directory + video_file + ".png"):
options['--thumbnail'] = video_directory + video_file + ".png"
# Display some info after research
if not options.get('--thumbnail'):
logger.debug("No thumbnail has been found, continuing")
else:
logger.info("Using " + options.get('--thumbnail') + " as thumbnail")
return options return options
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 # return the nfo as a RawConfigParser object
def loadNFO(filename): def loadNFO(filename):
try: try:
logging.info("Loading " + filename + " as NFO")
logger.info("Loading " + filename + " as NFO")
nfo = RawConfigParser() nfo = RawConfigParser()
nfo.read(filename, encoding='utf-8') nfo.read(filename, encoding='utf-8')
return nfo return nfo
except Exception as e: except Exception as e:
logging.error("Problem loading NFO file " + filename + ": " + str(e))
logger.critical("Problem loading NFO file " + filename + ": " + str(e))
exit(1) exit(1)
return False return False
@ -153,8 +179,8 @@ def parseNFO(options):
elif isfile(video_directory + "/" + "NFO.txt"): elif isfile(video_directory + "/" + "NFO.txt"):
nfo_txt = loadNFO(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 isfile(video_directory + "/" + directory_name + ".txt"):
nfo_directory = loadNFO(video_directory + "/" + directory_name + ".txt")
if options.get('--name'): if options.get('--name'):
if isfile(video_directory + "/" + options.get('--name')): if isfile(video_directory + "/" + options.get('--name')):
@ -168,7 +194,17 @@ def parseNFO(options):
if isfile(options.get('--nfo')): if isfile(options.get('--nfo')):
nfo_cli = loadNFO(options.get('--nfo')) nfo_cli = loadNFO(options.get('--nfo'))
else: else:
logging.error("Given NFO file does not exist, please check your path.")
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) exit(1)
# We need to load NFO in this exact order to keep the priorities # We need to load NFO in this exact order to keep the priorities
@ -177,7 +213,7 @@ def parseNFO(options):
if nfo: if nfo:
# We need to check all options and replace it with the nfo value if not defined (None or False) # 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(): for key, value in options.items():
key = key.replace("-", "")
key = key.replace("--", "")
try: try:
# get string options # get string options
if value is None and nfo.get('video', key): if value is None and nfo.get('video', key):
@ -188,7 +224,7 @@ def parseNFO(options):
except NoOptionError: except NoOptionError:
continue continue
except NoSectionError: except NoSectionError:
logging.error(nfo + " misses section [video], please check syntax of your NFO.")
logger.critical(nfo + " misses section [video], please check syntax of your NFO.")
exit(1) exit(1)
return options return options

+ 92
- 47
prismedia/yt_upload.py View File

@ -1,6 +1,6 @@
#!/usr/bin/env python #!/usr/bin/env python
# coding: utf-8 # coding: utf-8
# From Youtube samples : https://raw.githubusercontent.com/youtube/api-samples/master/python/upload_video.py # noqa
# From Youtube samples: https://raw.githubusercontent.com/youtube/api-samples/master/python/upload_video.py # noqa
import http.client import http.client
import httplib2 import httplib2
@ -23,9 +23,7 @@ from google_auth_oauthlib.flow import InstalledAppFlow
from . import utils from . import utils
logging.basicConfig(format='%(asctime)s %(message)s', level=logging.INFO)
logger = logging.getLogger('Prismedia')
# Explicitly tell the underlying HTTP transport library not to retry, since # Explicitly tell the underlying HTTP transport library not to retry, since
# we are handling retry logic ourselves. # we are handling retry logic ourselves.
@ -62,6 +60,7 @@ def get_authenticated_service():
check_authenticated_scopes() check_authenticated_scopes()
flow = InstalledAppFlow.from_client_secrets_file( flow = InstalledAppFlow.from_client_secrets_file(
CLIENT_SECRETS_FILE, SCOPES) CLIENT_SECRETS_FILE, SCOPES)
if exists(CREDENTIALS_PATH): if exists(CREDENTIALS_PATH):
with open(CREDENTIALS_PATH, 'r') as f: with open(CREDENTIALS_PATH, 'r') as f:
credential_params = json.load(f) credential_params = json.load(f)
@ -78,7 +77,7 @@ def get_authenticated_service():
p = copy.deepcopy(vars(credentials)) p = copy.deepcopy(vars(credentials))
del p["expiry"] del p["expiry"]
json.dump(p, f) json.dump(p, f)
return build(API_SERVICE_NAME, API_VERSION, credentials=credentials, cache_discovery=False)
return build(API_SERVICE_NAME, API_VERSION, credentials=credentials, cache_discovery=False)
def check_authenticated_scopes(): def check_authenticated_scopes():
@ -87,10 +86,19 @@ def check_authenticated_scopes():
credential_params = json.load(f) credential_params = json.load(f)
# Check if all scopes are present # Check if all scopes are present
if credential_params["_scopes"] != SCOPES: if credential_params["_scopes"] != SCOPES:
logging.warning("Youtube: Credentials are obsolete, need to re-authenticate.")
logger.warning("Youtube: Credentials are obsolete, need to re-authenticate.")
os.remove(CREDENTIALS_PATH) 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): def initialize_upload(youtube, options):
path = options.get('--file') path = options.get('--file')
tags = None tags = None
@ -109,6 +117,8 @@ def initialize_upload(youtube, options):
if options.get('--cca'): if options.get('--cca'):
license = "creativeCommon" 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 = { body = {
"snippet": { "snippet": {
"title": options.get('--name') or splitext(basename(path))[0], "title": options.get('--name') or splitext(basename(path))[0],
@ -121,6 +131,9 @@ def initialize_upload(youtube, options):
"status": { "status": {
"privacyStatus": str(options.get('--privacy') or "private"), "privacyStatus": str(options.get('--privacy') or "private"),
"license": str(license or "youtube"), "license": str(license or "youtube"),
},
"recordingDetails": {
} }
} }
@ -130,22 +143,23 @@ def initialize_upload(youtube, options):
elif options.get('--publishAt'): elif options.get('--publishAt'):
publishAt = options.get('--publishAt') publishAt = options.get('--publishAt')
# Check if publishAt variable exists in local variables
if 'publishAt' in locals(): if 'publishAt' in locals():
# Youtube needs microsecond and the local timezone from ISO 8601
publishAt = 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()
publishAt = convert_youtube_date(publishAt)
body['status']['publishAt'] = str(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'): if options.get('--playlist'):
playlist_id = get_playlist_by_name(youtube, options.get('--playlist')) playlist_id = get_playlist_by_name(youtube, options.get('--playlist'))
if not playlist_id and options.get('--playlistCreate'): if not playlist_id and options.get('--playlistCreate'):
playlist_id = create_playlist(youtube, options.get('--playlist')) playlist_id = create_playlist(youtube, options.get('--playlist'))
elif not playlist_id: elif not playlist_id:
logging.warning("Youtube: Playlist `" + options.get('--playlist') + "` is unknown.")
logging.warning("If you want to create it, set the --playlistCreate option.")
logger.warning("Youtube: Playlist `" + options.get('--playlist') + "` is unknown.")
logger.warning("Youtube: If you want to create it, set the --playlistCreate option.")
playlist_id = "" playlist_id = ""
else: else:
playlist_id = "" playlist_id = ""
@ -156,11 +170,11 @@ def initialize_upload(youtube, options):
body=body, body=body,
media_body=MediaFileUpload(path, chunksize=-1, resumable=True) media_body=MediaFileUpload(path, chunksize=-1, resumable=True)
) )
video_id = resumable_upload(insert_request, 'video', 'insert')
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 we get a video_id, upload is successful and we are able to set thumbnail
if video_id and options.get('--thumbnail'): if video_id and options.get('--thumbnail'):
set_thumbnail(youtube, options.get('--thumbnail'), videoId=video_id)
set_thumbnail(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 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 != "": if video_id and playlist_id != "":
@ -168,19 +182,29 @@ def initialize_upload(youtube, options):
def get_playlist_by_name(youtube, playlist_name): def get_playlist_by_name(youtube, playlist_name):
response = youtube.playlists().list(
part='snippet,id',
mine=True,
maxResults=50
).execute()
for playlist in response["items"]:
if playlist["snippet"]['title'] == playlist_name:
return playlist['id']
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): def create_playlist(youtube, playlist_name):
template = ('Youtube: Playlist %s does not exist, creating it.')
logging.info(template % (str(playlist_name)))
template = 'Youtube: Playlist %s does not exist, creating it.'
logger.info(template % (str(playlist_name)))
resources = build_resource({'snippet.title': playlist_name, resources = build_resource({'snippet.title': playlist_name,
'snippet.description': '', 'snippet.description': '',
'status.privacyStatus': 'public'}) 'status.privacyStatus': 'public'})
@ -231,7 +255,7 @@ def build_resource(properties):
return resource return resource
def set_thumbnail(youtube, media_file, **kwargs):
def set_thumbnail(options, youtube, media_file, **kwargs):
kwargs = utils.remove_empty_kwargs(**kwargs) kwargs = utils.remove_empty_kwargs(**kwargs)
request = youtube.thumbnails().set( request = youtube.thumbnails().set(
media_body=MediaFileUpload(media_file, chunksize=-1, media_body=MediaFileUpload(media_file, chunksize=-1,
@ -239,12 +263,11 @@ def set_thumbnail(youtube, media_file, **kwargs):
**kwargs **kwargs
) )
# See full sample for function
return resumable_upload(request, 'thumbnail', 'set')
return resumable_upload(request, 'thumbnail', 'set', options)
def set_playlist(youtube, playlist_id, video_id): def set_playlist(youtube, playlist_id, video_id):
logging.info('Youtube: Configuring playlist...')
logger.info('Youtube: Configuring playlist...')
resource = build_resource({'snippet.playlistId': playlist_id, resource = build_resource({'snippet.playlistId': playlist_id,
'snippet.resourceId.kind': 'youtube#video', 'snippet.resourceId.kind': 'youtube#video',
'snippet.resourceId.videoId': video_id, 'snippet.resourceId.videoId': video_id,
@ -257,65 +280,87 @@ def set_playlist(youtube, playlist_id, video_id):
).execute() ).execute()
except Exception as e: except Exception as e:
if hasattr(e, 'message'): if hasattr(e, 'message'):
logging.error("Youtube: Error: " + str(e.message))
logger.critical("Youtube: " + str(e.message))
exit(1) exit(1)
else: else:
logging.error("Youtube: Error: " + str(e))
logger.critical("Youtube: " + str(e))
exit(1) exit(1)
logging.info('Youtube: Video is correctly added to the playlist.')
logger.info('Youtube: Video is correctly added to the playlist.')
# This method implements an exponential backoff strategy to resume a # This method implements an exponential backoff strategy to resume a
# failed upload. # failed upload.
def resumable_upload(request, resource, method):
def resumable_upload(request, resource, method, options):
response = None response = None
error = None error = None
retry = 0 retry = 0
logger_stdout = None
if options.get('--url-only') or options.get('--batch'):
logger_stdout = logging.getLogger('stdoutlogs')
while response is None: while response is None:
try: try:
template = 'Youtube: Uploading %s...' template = 'Youtube: Uploading %s...'
logging.info(template % resource)
logger.info(template % resource)
status, response = request.next_chunk() status, response = request.next_chunk()
if response is not None: if response is not None:
if method == 'insert' and 'id' in response: if method == 'insert' and 'id' in response:
logging.info('Youtube : Video was successfully uploaded.')
logger.info('Youtube: Video was successfully uploaded.')
template = 'Youtube: Watch it at https://youtu.be/%s (post-encoding could take some time)' template = 'Youtube: Watch it at https://youtu.be/%s (post-encoding could take some time)'
logging.info(template % response['id'])
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'] return response['id']
elif method != 'insert' or "id" not in response: elif method != 'insert' or "id" not in response:
logging.info('Youtube: Thumbnail was successfully set.')
logger.info('Youtube: Thumbnail was successfully set.')
else: else:
template = ('Youtube : The upload failed with an '
template = ('Youtube: The upload failed with an '
'unexpected response: %s') 'unexpected response: %s')
logging.error(template % response)
logger.critical(template % response)
exit(1) exit(1)
except HttpError as e: except HttpError as e:
if e.resp.status in RETRIABLE_STATUS_CODES: if e.resp.status in RETRIABLE_STATUS_CODES:
template = 'Youtube : A retriable HTTP error %d occurred:\n%s'
template = 'Youtube: A retriable HTTP error %d occurred:\n%s'
error = template % (e.resp.status, e.content) error = template % (e.resp.status, e.content)
else: else:
raise raise
except RETRIABLE_EXCEPTIONS as e: except RETRIABLE_EXCEPTIONS as e:
error = 'Youtube : A retriable error occurred: %s' % e
error = 'Youtube: A retriable error occurred: %s' % e
if error is not None: if error is not None:
logging.warning(error)
logger.warning(error)
retry += 1 retry += 1
if retry > MAX_RETRIES: if retry > MAX_RETRIES:
logging.error('Youtube : No longer attempting to retry.')
exit(1)
logger.error('Youtube: No longer attempting to retry.')
max_sleep = 2 ** retry max_sleep = 2 ** retry
sleep_seconds = random.random() * max_sleep sleep_seconds = random.random() * max_sleep
logging.warning('Youtube : Sleeping %f seconds and then retrying...'
logger.warning('Youtube: Sleeping %f seconds and then retrying...'
% sleep_seconds) % sleep_seconds)
time.sleep(sleep_seconds) time.sleep(sleep_seconds)
def hearthbeat():
"""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 hearthbeat:\n%s' %
(e.resp.status, e.content))
def run(options): def run(options):
youtube = get_authenticated_service() youtube = get_authenticated_service()
try: try:
initialize_upload(youtube, options) initialize_upload(youtube, options)
except HttpError as e: except HttpError as e:
logging.error('Youtube : An HTTP error %d occurred:\n%s' % (e.resp.status,
logger.error('Youtube: An HTTP error %d occurred:\n%s' % (e.resp.status,
e.content)) e.content))

+ 22
- 22
pyproject.toml View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "prismedia" name = "prismedia"
version = "0.9.0"
version = "0.12.2"
description = "scripting your way to upload videos on peertube and youtube" description = "scripting your way to upload videos on peertube and youtube"
authors = [ authors = [
"LecygneNoir <git@lecygnenoir.info>", "LecygneNoir <git@lecygnenoir.info>",
@ -17,33 +17,33 @@ homepage = "https://git.lecygnenoir.info/LecygneNoir/prismedia"
keywords = ['peertube', 'youtube', 'prismedia'] keywords = ['peertube', 'youtube', 'prismedia']
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = ">=3.5"
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.6.8"
tzlocal = "^1.5.1"
Unidecode = "^1.0.23"
uritemplate = "^3.0.0"
urllib3 = "^1.22"
python = ">=3.6"
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"
requests = ">=2.18.4"
requests-oauthlib = "=1.1.0"
requests-toolbelt = ">=0.9.1"
pytz = "=2022.1"
schema = ">=0.7.1"
tzlocal = ">=1.5.1"
Unidecode = ">=1.0.23"
uritemplate = ">=3.0.0"
urllib3 = ">=1.22"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
[tool.poetry.scripts] [tool.poetry.scripts]
prismedia = 'prismedia.upload:main' prismedia = 'prismedia.upload:main'
prismedia-init = 'prismedia.genconfig:genconfig'
[build-system] [build-system]
requires = ["poetry>=0.12"] requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api" build-backend = "poetry.masonry.api"

+ 97
- 87
requirements.txt View File

@ -1,65 +1,77 @@
args==0.1.0 \
--hash=sha256:a785b8d837625e9b61c39108532d95b85274acd679693b71ebb5156848fcf814
cachetools==3.1.1 \ cachetools==3.1.1 \
--hash=sha256:428266a1c0d36dc5aca63a2d7c5942e88c2c898d72139fca0e97fdd2380517ae \ --hash=sha256:428266a1c0d36dc5aca63a2d7c5942e88c2c898d72139fca0e97fdd2380517ae \
--hash=sha256:8ea2d3ce97850f31e4a08b0e2b5e6c34997d7216a9d2c98e0f3978630d4da69a --hash=sha256:8ea2d3ce97850f31e4a08b0e2b5e6c34997d7216a9d2c98e0f3978630d4da69a
certifi==2019.11.28 \
--hash=sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3 \
--hash=sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f
chardet==3.0.4 \
--hash=sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691 \
--hash=sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae
configparser==3.8.1 \
--hash=sha256:45d1272aad6cfd7a8a06cf5c73f2ceb6a190f6acc1fa707e7f82a4c053b28b18 \
--hash=sha256:bc37850f0cc42a1725a796ef7d92690651bf1af37d744cc63161dac62cabee17
certifi==2020.12.5 \
--hash=sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830 \
--hash=sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c
chardet==4.0.0 \
--hash=sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5 \
--hash=sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa
clint==0.5.1 \
--hash=sha256:05224c32b1075563d0b16d0015faaf9da43aa214e4a2140e51f08789e7a4c5aa
configparser==5.0.2 \
--hash=sha256:af59f2cdd7efbdd5d111c1976ecd0b82db9066653362f0962d7bf1d3ab89a1fa \
--hash=sha256:85d5de102cfe6d14a5172676f09d19c465ce63d6019cf0a4ef13385fc535e828
contextlib2==0.6.0.post1 \
--hash=sha256:3355078a159fbb44ee60ea80abd0d87b80b78c248643b49aa6d94673b413609b \
--hash=sha256:01f490098c18b19d2bd5bb5dc445b2054d2fa97f09a4280ba2c5f3c394c8162e
docopt==0.6.2 \ docopt==0.6.2 \
--hash=sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491 --hash=sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491
future==0.17.1 \
--hash=sha256:67045236dcfd6816dc439556d009594abf643e5eb48992e36beac09c2ca659b8
google-api-core==1.16.0 \
--hash=sha256:92e962a087f1c4b8d1c5c88ade1c1dfd550047dcffb320c57ef6a534a20403e2 \
--hash=sha256:859f7392676761f2b160c6ee030c3422135ada4458f0948c5690a6a7c8d86294
google-api-python-client==1.8.0 \
--hash=sha256:0f5b42a14e2d2f7dee40f2e4514531dbe95ebde9c2173b1c4040a65c427e7900 \
--hash=sha256:5032ad1af5046889649b3848f2e871889fbb6ae440198a549fe1699581300386
google-auth==1.12.0 \
--hash=sha256:016924388770b7e66c7e9ade1c4c3144ee88812d79697fd6c0dad9abdfcda2fd \
--hash=sha256:01d686448f57d3bc027726474faa1aa650ba333bedb392e06938b0add8ec8d3a
google-auth-httplib2==0.0.3 \
--hash=sha256:098fade613c25b4527b2c08fa42d11f3c2037dda8995d86de0745228e965d445 \
--hash=sha256:f1c437842155680cf9918df9bc51c1182fda41feef88c34004bd1978c8157e08
google-auth-oauthlib==0.2.0 \
--hash=sha256:226d1d0960f86ba5d9efd426a70b291eaba96f47d071657e0254ea969025728a \
--hash=sha256:81ba22acada4d13b1d83f9371ab19fd61f1250a542d21cf49e4dcf0637a7344a
googleapis-common-protos==1.51.0 \
--hash=sha256:013c91704279119150e44ef770086fdbba158c1f978a6402167d47d5409e226e
httplib2==0.12.3 \
--hash=sha256:23914b5487dfe8ef09db6656d6d63afb0cf3054ad9ebc50868ddc8e166b5f8e8 \
--hash=sha256:a18121c7c72a56689efbf1aef990139ad940fee1e64c6f2458831736cd593600
idna==2.9 \
--hash=sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa \
--hash=sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb
oauthlib==2.1.0 \
--hash=sha256:d883b36b21a6ad813953803edfa563b1b579d79ca758fe950d1bc9e8b326025b \
--hash=sha256:ac35665a61c1685c56336bda97d5eefa246f1202618a1d6f34fccb1bdd404162
protobuf==3.11.3 \
--hash=sha256:ef2c2e56aaf9ee914d3dccc3408d42661aaf7d9bb78eaa8f17b2e6282f214481 \
--hash=sha256:dd9aa4401c36785ea1b6fff0552c674bdd1b641319cb07ed1fe2392388e9b0d7 \
--hash=sha256:310a7aca6e7f257510d0c750364774034272538d51796ca31d42c3925d12a52a \
--hash=sha256:e512b7f3a4dd780f59f1bf22c302740e27b10b5c97e858a6061772668cd6f961 \
--hash=sha256:fdfb6ad138dbbf92b5dbea3576d7c8ba7463173f7d2cb0ca1bd336ec88ddbd80 \
--hash=sha256:e2f8a75261c26b2f5f3442b0525d50fd79a71aeca04b5ec270fc123536188306 \
--hash=sha256:c40973a0aee65422d8cb4e7d7cbded95dfeee0199caab54d5ab25b63bce8135a \
--hash=sha256:adf0e4d57b33881d0c63bb11e7f9038f98ee0c3e334c221f0858f826e8fb0151 \
--hash=sha256:0bae429443cc4748be2aadfdaf9633297cfaeb24a9a02d0ab15849175ce90fab \
--hash=sha256:e11df1ac6905e81b815ab6fd518e79be0a58b5dc427a2cf7208980f30694b956 \
--hash=sha256:7774bbbaac81d3ba86de646c39f154afc8156717972bf0450c9dbfa1dc8dbea2 \
--hash=sha256:8eb9c93798b904f141d9de36a0ba9f9b73cc382869e67c9e642c0aba53b0fc07 \
--hash=sha256:fac513a9dc2a74b99abd2e17109b53945e364649ca03d9f7a0b96aa8d1807d0a \
--hash=sha256:82d7ac987715d8d1eb4068bf997f3053468e0ce0287e2729c30601feb6602fee \
--hash=sha256:73152776dc75f335c476d11d52ec6f0f6925774802cd48d6189f4d5d7fe753f4 \
--hash=sha256:52e586072612c1eec18e1174f8e3bb19d08f075fc2e3f91d3b16c919078469d0 \
--hash=sha256:2affcaba328c4662f3bc3c0e9576ea107906b2c2b6422344cdad961734ff6b93 \
--hash=sha256:24e3b6ad259544d717902777b33966a1a069208c885576254c112663e6a5bb0f \
--hash=sha256:c77c974d1dadf246d789f6dad1c24426137c9091e930dbf50e0a29c1fcf00b1f
future==0.18.2 \
--hash=sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d
google-api-core==1.26.3 \
--hash=sha256:b914345c7ea23861162693a27703bab804a55504f7e6e9abcaff174d80df32ac \
--hash=sha256:099762d4b4018cd536bcf85136bf337957da438807572db52f21dc61251be089
google-api-python-client==2.1.0 \
--hash=sha256:f9ac377efe69571aea1acc9e15760d4204aca23c4464eb63f963ae4defc95d97 \
--hash=sha256:921fe10cdff22d1f5b8af7473f9a298efb991fb6ea67dadd6c37fbecfb0575f4
google-auth==1.28.1 \
--hash=sha256:70b39558712826e41f65e5f05a8d879361deaf84df8883e5dd0ec3d0da6ab66e \
--hash=sha256:186fe2564634d67fbbb64f3daf8bc8c9cecbb2a7f535ed1a8a71795e50db8d87
google-auth-httplib2==0.1.0 \
--hash=sha256:a07c39fd632becacd3f07718dfd6021bf396978f03ad3ce4321d060015cc30ac \
--hash=sha256:31e49c36c6b5643b57e82617cb3e021e3e1d2df9da63af67252c02fa9c1f4a10
google-auth-oauthlib==0.4.4 \
--hash=sha256:09832c6e75032f93818edf1affe4746121d640c625a5bef9b5c96af676e98eee \
--hash=sha256:0e92aacacfb94978de3b7972cf4b0f204c3cd206f74ddd0dc0b31e91164e6317
googleapis-common-protos==1.53.0 \
--hash=sha256:a88ee8903aa0a81f6c3cec2d5cf62d3c8aa67c06439b0496b49048fb1854ebf4 \
--hash=sha256:f6d561ab8fb16b30020b940e2dd01cd80082f4762fa9f3ee670f4419b4b8dbd0
httplib2==0.19.1 \
--hash=sha256:2ad195faf9faf079723f6714926e9a9061f694d07724b846658ce08d40f522b4 \
--hash=sha256:0b12617eeca7433d4c396a100eaecfa4b08ee99aa881e6df6e257a7aad5d533d
idna==2.10 \
--hash=sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0 \
--hash=sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6
oauthlib==3.1.0 \
--hash=sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea \
--hash=sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889
packaging==20.9 \
--hash=sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a \
--hash=sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5
protobuf==3.15.8 \
--hash=sha256:fad4f971ec38d8df7f4b632c819bf9bbf4f57cfd7312cf526c69ce17ef32436a \
--hash=sha256:f17b352d7ce33c81773cf81d536ca70849de6f73c96413f17309f4b43ae7040b \
--hash=sha256:4a054b0b5900b7ea7014099e783fb8c4618e4209fffcd6050857517b3f156e18 \
--hash=sha256:efa4c4d4fc9ba734e5e85eaced70e1b63fb3c8d08482d839eb838566346f1737 \
--hash=sha256:07eec4e2ccbc74e95bb9b3afe7da67957947ee95bdac2b2e91b038b832dd71f0 \
--hash=sha256:f9cadaaa4065d5dd4d15245c3b68b967b3652a3108e77f292b58b8c35114b56c \
--hash=sha256:2dc0e8a9e4962207bdc46a365b63a3f1aca6f9681a5082a326c5837ef8f4b745 \
--hash=sha256:f80afc0a0ba13339bbab25ca0409e9e2836b12bb012364c06e97c2df250c3343 \
--hash=sha256:c5566f956a26cda3abdfacc0ca2e21db6c9f3d18f47d8d4751f2209d6c1a5297 \
--hash=sha256:dab75b56a12b1ceb3e40808b5bd9dfdaef3a1330251956e6744e5b6ed8f8830b \
--hash=sha256:3053f13207e7f13dc7be5e9071b59b02020172f09f648e85dc77e3fcb50d1044 \
--hash=sha256:1f0b5d156c3df08cc54bc2c8b8b875648ea4cd7ebb2a9a130669f7547ec3488c \
--hash=sha256:90270fe5732c1f1ff664a3bd7123a16456d69b4e66a09a139a00443a32f210b8 \
--hash=sha256:f42c2f5fb67da5905bfc03733a311f72fa309252bcd77c32d1462a1ad519521e \
--hash=sha256:f6077db37bfa16494dca58a4a02bfdacd87662247ad6bc1f7f8d13ff3f0013e1 \
--hash=sha256:510e66491f1a5ac5953c908aa8300ec47f793130097e4557482803b187a8ee05 \
--hash=sha256:5ff9fa0e67fcab442af9bc8d4ec3f82cb2ff3be0af62dba047ed4187f0088b7d \
--hash=sha256:1c0e9e56202b9dccbc094353285a252e2b7940b74fdf75f1b4e1b137833fabd7 \
--hash=sha256:a0a08c6b2e6d6c74a6eb5bf6184968eefb1569279e78714e239d33126e753403 \
--hash=sha256:0277f62b1e42210cafe79a71628c1d553348da81cbd553402a7f7549c50b11d0
pyasn1==0.4.8 \ pyasn1==0.4.8 \
--hash=sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3 \ --hash=sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3 \
--hash=sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf \ --hash=sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf \
@ -88,39 +100,37 @@ pyasn1-modules==0.2.8 \
--hash=sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed \ --hash=sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed \
--hash=sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0 \ --hash=sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0 \
--hash=sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd --hash=sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd
python-magic==0.4.15 \
--hash=sha256:f3765c0f582d2dfc72c15f3b5a82aecfae9498bd29ca840d72f37d7bd38bfcd5 \
--hash=sha256:f2674dcfad52ae6c49d4803fa027809540b130db1dec928cfbb9240316831375
python-magic-bin==0.4.14; platform_system == "Windows" \
--hash=sha256:7b1743b3dbf16601d6eedf4e7c2c9a637901b0faaf24ad4df4d4527e7d8f66a4 \
--hash=sha256:34a788c03adde7608028203e2dbb208f1f62225ad91518787ae26d603ae68892 \
--hash=sha256:90be6206ad31071a36065a2fc169c5afb5e0355cbe6030e87641c6c62edc2b69
pytz==2019.3 \
--hash=sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d \
--hash=sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be
requests==2.23.0 \
--hash=sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee \
--hash=sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6
requests-oauthlib==0.8.0 \
--hash=sha256:883ac416757eada6d3d07054ec7092ac21c7f35cb1d2cf82faf205637081f468 \
--hash=sha256:50a8ae2ce8273e384895972b56193c7409601a66d4975774c60c2aed869639ca
pyparsing==2.4.7 \
--hash=sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b \
--hash=sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1
pytz==2021.1 \
--hash=sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798 \
--hash=sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da
requests==2.25.1 \
--hash=sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e \
--hash=sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804
requests-oauthlib==1.3.0 \
--hash=sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a \
--hash=sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d \
--hash=sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc
requests-toolbelt==0.9.1 \ requests-toolbelt==0.9.1 \
--hash=sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0 \ --hash=sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0 \
--hash=sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f --hash=sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f
rsa==4.0 \
--hash=sha256:14ba45700ff1ec9eeb206a2ce76b32814958a98e372006c8fb76ba820211be66 \
--hash=sha256:1a836406405730121ae9823e19c6e806c62bbad73f890574fff50efa4122c487
schema==0.6.8 \
--hash=sha256:d994b0dc4966000037b26898df638e3e2a694cc73636cb2050e652614a350687 \
--hash=sha256:fa1a53fe5f3b6929725a4e81688c250f46838e25d8c1885a10a590c8c01a7b74
six==1.14.0 \
--hash=sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c \
--hash=sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a
tzlocal==1.5.1 \
--hash=sha256:4ebeb848845ac898da6519b9b31879cf13b6626f7184c496037b818e238f2c4e
unidecode==1.1.1 \
--hash=sha256:1d7a042116536098d05d599ef2b8616759f02985c85b4fef50c78a5aaf10822a \
--hash=sha256:2b6aab710c2a1647e928e36d69c21e76b453cd455f4e2621000e54b2a9b8cce8
rsa==4.4; python_version >= "3.6" \
--hash=sha256:4afbaaecc3e9550c7351fdf0ab3fea1857ff616b85bab59215f00fb42e0e9582 \
--hash=sha256:5d95293bbd0fbee1dd9cb4b72d27b723942eb50584abc8c4f5f00e4bcfa55307
schema==0.7.4 \
--hash=sha256:cf97e4cd27e203ab6bb35968532de1ed8991bce542a646f0ff1d643629a4945d \
--hash=sha256:fbb6a52eb2d9facf292f233adcc6008cffd94343c63ccac9a1cb1f3e6de1db17
six==1.15.0 \
--hash=sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced \
--hash=sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259
tzlocal==2.1 \
--hash=sha256:e2cb6c6b5b604af38597403e9852872d7f534962ae2954c7f35efcb1ccacf4a4 \
--hash=sha256:643c97c5294aedc737780a49d9df30889321cbe1204eac2c2ec6134035a92e44
unidecode==1.2.0 \
--hash=sha256:12435ef2fc4cdfd9cf1035a1db7e98b6b047fe591892e81f34e94959591fad00 \
--hash=sha256:8d73a97d387a956922344f6b74243c2c6771594659778744b2dbdaad8f6b727d
uritemplate==3.0.1 \ uritemplate==3.0.1 \
--hash=sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f \ --hash=sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f \
--hash=sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae --hash=sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae

Loading…
Cancel
Save