Compare commits

...

55 commits

Author SHA1 Message Date
Lecygne Noir
4e5e0d256f Prepare README for 0.13.0 2025-06-29 11:51:56 +02:00
Lecygne Noir
524be1d93c Prepare release 0.13.0, bump python version to 3.13 and add ysalmon as contributor 😊 2025-06-29 11:45:25 +02:00
Lecygne Noir
325cee4a69 Add credsdir option from ysalmon-credsdir 2025-06-29 11:35:44 +02:00
LecygneNoir
40b0c43159 Merge branch 'develop' into credsdir 2025-06-29 11:31:10 +02:00
Yann Salmon
8199286023 adding credentialsdir for youtube upload 2025-06-25 20:34:54 +02:00
Yann Salmon
822cf0fa9b Option to read secret file from specific dir 2025-06-23 19:19:50 +02:00
LecygneNoir
e7531cc340 More precise doc for google procedure Consent Screen 2024-08-21 14:16:25 +02:00
LecygneNoir
d8aa349ad3 Update README with new Google dev procedure for youtube apps 2024-08-21 14:14:30 +02:00
LecygneNoir
4d1828a3ad Merge tag 'v0.12.2' into develop
v0.12.2
2022-04-18 13:05:05 +02:00
LecygneNoir
03a8f4e71f Merge branch 'release/v0.12.2' 2022-04-18 13:04:53 +02:00
LecygneNoir
5ca5ff5238 Release v0.12.2 to fix broken dependencies 2022-04-18 13:04:44 +02:00
LecygneNoir
6c16b2f037 Merge tag 'v0.12.1' into develop
v0.12.1
2021-05-21 10:54:55 +02:00
LecygneNoir
36110432da Merge branch 'hotfix/v0.12.1' 2021-05-21 10:54:48 +02:00
LecygneNoir
388f76b855 Fix a bug in when configuring log level 2021-05-21 10:54:34 +02:00
LecygneNoir
ef92fed69d Merge tag 'v0.12.0' into develop
v0.12.0
2021-04-10 12:37:11 +02:00
LecygneNoir
bcb0e267f3 Merge branch 'release/v0.12.0' 2021-04-10 12:37:00 +02:00
LecygneNoir
8bc79853c8 Bump version to v0.12.0 and prepare poetry build for v0.12.0 2021-04-10 12:36:46 +02:00
LecygneNoir
45a1cbccff Merge branch 'feature/remove_formatcheck' into develop 2021-04-10 12:24:10 +02:00
LecygneNoir
0a1360d8e2 Prepare changelo for v0.12.0 2021-04-10 11:41:15 +02:00
LecygneNoir
f8ae2b1c5e Update libraries and dependencies for prismedia 2021-04-10 11:40:59 +02:00
LecygneNoir
0a53e77bd6 Add auto search for thumbnail based on .png in addition to .jpg and .jpeg 2021-04-10 11:40:27 +02:00
LecygneNoir
2f7629ef1e Remove format checks for videos and thumbnail as Youtube and Peertube have no limitation anymore 2021-04-10 11:34:36 +02:00
LecygneNoir
e0a63ed4b2 Revert README to use prismedia as it's indeed the binary script installed through pip 2021-03-13 10:36:16 +01:00
LecygneNoir
ba2a1ebb79 Merge branch 'feature/improve_genconfig' into develop 2021-03-13 10:34:53 +01:00
LecygneNoir
cf3d4c32c3 Better resilience for the genconfig function thanks to happy path and @Zykino suggestion! 2021-02-28 10:27:40 +01:00
LecygneNoir
85f0fe9b6f Add ask_overwirte function to utils in order to be more general and usable in other modules 2021-02-28 10:09:54 +01:00
LecygneNoir
1a006f3b6c Improve genconfig system and documentation for easier use, cf #55 2021-02-27 13:15:30 +01:00
LecygneNoir
cdef038323 Move logger initialization in __init__.py to be able to use it from any module 2021-02-27 13:13:09 +01:00
LecygneNoir
cbf3386bac Remove confusing warning when using genconfig, see #55 2021-02-27 13:13:09 +01:00
LecygneNoir
ca733e0dc3 Merge pull request 'hearthbeat (keepalive ?)' (#54) from Zykino/prismedia:hearthbeat into develop
Reviewed-on: https://git.lecygnenoir.info/LecygneNoir/prismedia/pulls/54

Thanks a lot for the idea and the feature!
2021-02-14 11:59:38 +01:00
Zykino
a4f162320d Fix some spacing formatting and documentation 2021-02-14 00:58:52 +01:00
Zykino
29b1747c3e Visit all of Youtube’s playlists 2021-02-12 16:12:11 +01:00
Zykino
ea39fe9854 Fix some spacing 2021-02-08 16:31:10 +01:00
Zykino
a725e848ab Add an option to use some credits easiely
This tells youtube the apikey is still used and they should not remove 
all of the quota.
2021-02-08 16:30:44 +01:00
LecygneNoir
9b6da1e3dc Merge tag 'v0.11.0' into develop
v0.11.0
2021-01-23 13:00:11 +01:00
LecygneNoir
194e2e4606 Merge branch 'release/v0.11.0' 2021-01-23 13:00:04 +01:00
LecygneNoir
339caeb7f7 Bump files to v0.11.0, fix #50 2021-01-23 12:59:12 +01:00
LecygneNoir
e6375b5aa0 Merge pull request 'Add a progression bar for Peertube's upload' (#52) from Zykino/prismedia:feature/progression into develop
Reviewed-on: https://git.lecygnenoir.info/LecygneNoir/prismedia/pulls/52
2021-01-23 12:36:38 +01:00
Zykino
6add140732 Disable the progressbar when the user want a quiet output 2021-01-23 12:24:10 +01:00
Zykino
c4e3243131 Add clint as requirement 2021-01-09 14:23:20 +01:00
Zykino
93f1205ab8 Make it possible to choose between multiples tipes of progress bar 2021-01-09 13:40:21 +01:00
Zykino
09c2d84357 Add a progression bar to Peertube upload 2021-01-09 13:22:35 +01:00
LecygneNoir
230ac545c4 Patch incorrect loading of NFO keys, breaking the match when the key contains -, and add example for auto-originalDate in NFO 2020-12-25 09:59:23 +01:00
LecygneNoir
42ee7d761b Need to strip the getmtime timestamp as some OSes return timestamp with microsecond (XXXX.YYYY) 2020-12-15 11:25:32 +01:00
LecygneNoir
1a937098d8 Merge branch 'feature/recording_date' into develop 2020-12-13 18:26:16 +01:00
LecygneNoir
736582b495 Change the originalDate behaviour to not default, and add option to auto manage the original date if needed 2020-12-04 09:52:46 +01:00
LecygneNoir
4a9fda5e77 Add one function to deal with date to avoid duplicate code 2020-12-02 11:28:07 +01:00
LecygneNoir
8dc3a86aab Stripe the README from the full --help output to focus on some main features 2020-12-01 09:24:36 +01:00
LecygneNoir
4b7c01a707 Add help for option originalDate and changelog about the feature 2020-11-30 13:23:31 +01:00
LecygneNoir
dc98f2e155 Add functions to manage Original Date of record for peertube 2020-11-30 13:22:24 +01:00
LecygneNoir
60bf26418d Add the originalDate options to Youtube videos 2020-11-30 12:27:09 +01:00
LecygneNoir
447310a17e Add options and bunch of functions to maange the originalDate fields in prismedia, cf #50 2020-11-30 12:26:44 +01:00
LecygneNoir
9b597f461e Merge tag 'v0.10.3' into develop
v0.10.3
2020-11-15 11:14:43 +01:00
LecygneNoir
5c991581e8 bump to v0.10.2 for poetry 2020-11-11 10:47:51 +01:00
LecygneNoir
2f8543b43c Merge tag 'v0.10.2' into develop
v0.10.2
2020-11-11 10:45:48 +01:00
12 changed files with 1068 additions and 775 deletions

View file

@ -1,5 +1,36 @@
# 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 ## v0.10.3
### Fix ### Fix

159
README.md
View file

@ -24,55 +24,57 @@ Scripting your way to upload videos to peertube and youtube. Works with Python 3
### From pip ### From pip
Simply install with Simply install with
```sh
```bash
pip install prismedia pip install prismedia
``` ```
Upgrade with Upgrade with
```sh
```bash
pip install --upgrade prismedia pip install --upgrade prismedia
``` ```
### From source ### From source
Get the source: Get the source:
```sh
```bash
git clone https://git.lecygnenoir.info/LecygneNoir/prismedia.git prismedia git clone https://git.lecygnenoir.info/LecygneNoir/prismedia.git prismedia
``` ```
You may use pip to install requirements: `pip install -r requirements.txt` if you want to use the script directly. 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`) (**note:** requirements are generated via `poetry export -f requirements.txt > requirements.txt`)
Otherwise, you can use [poetry](https://python-poetry.org), which create a virtualenv for the project directly 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) (Or use the existing virtualenv if one is activated)
``` ```sh
poetry install poetry install
``` ```
## Configuration ## Configuration
Generate sample files with `python -m prismedia.genconfig`. Generate configuration files by running `prismedia-init`.
Then rename and edit `peertube_secret` and `youtube_secret.json` with your credentials. (see below)
Then, edit them to fill your credential as explained below.
### Peertube ### Peertube
Set your credentials, peertube server URL. Configuration is in **peertube_secret** file.
You can get client_id and client_secret by logging in your peertube website and reaching the URL: 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 https://domain.example/api/v1/oauth-clients/local
You can set ``OAUTHLIB_INSECURE_TRANSPORT`` to 1 if you do not use https (not recommended)
*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 ask you to authenticate to The first time you connect, prismedia will open your browser to ask you to authenticate to
Youtube and allow the app to use your Youtube channel. Youtube and allow the app to use your Youtube channel.
**It is here you choose which channel you will upload to**. **It is here you choose which channel you will upload to**.
Once authenticated, the token is stored inside the file ``.youtube_credentials.json``. Once authenticated, the token is stored inside the file `.youtube_credentials.json`.
Prismedia will try to use this file at each launch, and re-ask for authentication if it does not exist. Prismedia will try to use this file at each launch, and re-ask for authentication if it does not exist.
**Oauth**: **Oauth**:
@ -81,10 +83,12 @@ If you plan a larger usage, please consider creating your own youtube_secret fil
- Go to the [Google console](https://console.developers.google.com/). - Go to the [Google console](https://console.developers.google.com/).
- Create project. - Create project.
- Side menu: APIs & auth -> APIs - Side menu: APIs & Services -> APIs
- Top menu: Enabled API(s): Enable all Youtube APIs. - Top menu: Enabled API(s): Enable Youtube Data v3 APIs.
- Side menu: APIs & auth -> Credentials. - Side menu: OAuth consent screen
- Create a Client ID: Add credentials -> OAuth 2.0 Client ID -> Other -> Name: prismedia1 -> Create -> OK - Create an app -> User type External -> Add scope from Youtube Data API v3: `.../auth/youtube.force-ssl` and `.../auth/youtube.upload` -> No test user -> save & create
- Side menu: APIs & Services -> Credentials.
- Create a Client ID: Create credentials -> OAuth Client ID -> Other -> Name: prismedia1 -> Create -> OK
- Download JSON: Under the section "OAuth 2.0 client IDs". Save the file to your local system. - Download JSON: Under the section "OAuth 2.0 client IDs". Save the file to your local system.
- Save this JSON as your youtube_secret.json file. - Save this JSON as your youtube_secret.json file.
@ -92,129 +96,56 @@ If you plan a larger usage, please consider creating your own youtube_secret fil
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)) **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:
``` Upload a video:
```sh
prismedia --file="yourvideo.mp4" prismedia --file="yourvideo.mp4"
``` ```
Specify description and tags: Specify description and tags:
```sh
```
prismedia --file="yourvideo.mp4" -d "My supa description" -t "tag1,tag2,foo" prismedia --file="yourvideo.mp4" -d "My supa description" -t "tag1,tag2,foo"
``` ```
Provide a thumbnail: Provide a thumbnail:
```sh
```
prismedia --file="yourvideo.mp4" -d "Video with thumbnail" --thumbnail="/path/to/your/thumbnail.jpg" prismedia --file="yourvideo.mp4" -d "Video with thumbnail" --thumbnail="/path/to/your/thumbnail.jpg"
``` ```
Publish on Peertube only, while using a channel and a playlist, creating them if they do not exist:
```sh
prismedia --file="yourvideo.mp4" --platform=peertube --channel="Cooking recipes" --playlist="Cake recipes" --channelCreate --playlistCreate
```
Use a NFO file to specify your video options: Use a NFO file to specify your video options:
(See [Enhanced NFO](#enhanced-use-of-nfo) for more precise example) (See [Enhanced NFO](#enhanced-use-of-nfo) for more precise example)
``` ```sh
prismedia --file="yourvideo.mp4" --nfo /path/to/your/nfo.txt prismedia --file="yourvideo.mp4" --nfo /path/to/your/nfo.txt
``` ```
To prevent Youtube from disabling your apikey after 90days of inactivity it is recommended to launch this command automatically from a script around once a month. It will make a call to use a few credits from your daily quota.
Use --help to get all available options: 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. This is the only mandatory option.
--name=NAME Name of the video to upload. (default to video filename)
-d, --description=STRING Description of the video. (default: default description)
-t, --tags=STRING Tags for the video. comma separated.
WARN: tags with punctuation (!, ', ", ?, ...)
are not supported by Mastodon to be published from Peertube
-c, --category=STRING Category for the videos, see below. (default: Films)
--cca License should be CreativeCommon Attribution (affects Youtube upload only)
-p, --privacy=STRING Choose between public, unlisted or private. (default: private)
--disable-comments Disable comments (Peertube only as YT API does not support) (default: comments are enabled)
--nsfw Set the video as No Safe For Work (Peertube only as YT API does not support) (default: video is safe)
--nfo=STRING Configure a specific nfo file to set options for the video.
By default Prismedia search a .txt based on the video name and will
decode the file as UTF-8 (so make sure your nfo file is UTF-8 encoded)
See nfo_example.txt for more details
--platform=STRING List of platform(s) to upload to, comma separated.
Supported platforms are youtube and peertube (default is both)
--language=STRING Specify the default language for video. See below for supported language. (default is English)
--publishAt=DATE Publish the video at the given DATE using local server timezone.
DATE should be on the form YYYY-MM-DDThh:mm:ss eg: 2018-03-12T19:00:00
DATE should be in the future
--peertubeAt=DATE
--youtubeAt=DATE Override publishAt for the corresponding platform. Allow to create preview on specific platform
--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.
Logging options
-q --quiet Suppress any log except Critical (alias for --log=critical).
--log=STRING Log level, between debug, info, warning, error, critical. Ignored if --quiet is set (default to info)
-u --url-only Display generated URL after upload directly on stdout, implies --quiet
--batch Display generated URL after upload with platform information for easier parsing. Implies --quiet
Be careful --batch and --url-only are mutually exclusives.
--debug (Deprecated) Alias for --log=debug. Ignored if --log is set
Strict options:
Strict options allow you to force some option to be present when uploading a video. It's useful to be sure you do not
forget something when uploading a video, for example if you use multiples NFO. You may force the presence of description,
tags, thumbnail, ...
All strict option are optionals and are provided only to avoid errors when uploading :-)
All strict options can be specified in NFO directly, the only strict option mandatory on cli is --withNFO
All strict options are off by default
--withNFO Prevent the upload without a NFO, either specified via cli or found in the directory
--withThumbnail Prevent the upload without a thumbnail
--withName Prevent the upload if no name are found
--withDescription Prevent the upload without description
--withTags Prevent the upload without tags
--withPlaylist Prevent the upload if no playlist
--withPublishAt Prevent the upload if no schedule
--withPlatform Prevent the upload if at least one platform is not specified
--withCategory Prevent the upload if no category
--withLanguage Prevent upload if no language
--withChannel Prevent upload if no channel
Categories:
Category is the type of video you upload. Default is films.
Here are available categories from Peertube and Youtube:
music, films, vehicles,
sports, travels, gaming, people,
comedy, entertainment, news,
how to, education, activism, science & technology,
science, technology, animals
Languages:
Language of the video (audio track), choose one. Default is English
Here are available languages from Peertube and Youtube:
Arabic, English, French, German, Hindi, Italian,
Japanese, Korean, Mandarin, Portuguese, Punjabi, Russian, Spanish
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
@ -228,7 +159,7 @@ Recipes/
``` ```
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
``` ```
@ -299,7 +230,7 @@ Available strict options:
- If you use 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
## Inspirations ## Inspirations
Inspired by [peeror](https://git.rigelk.eu/rigelk/peeror) and [youtube-upload](https://github.com/tokland/youtube-upload) 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, @ysalmon, @rigelk 😘

869
poetry.lock generated

File diff suppressed because it is too large Load diff

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

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__':

View file

@ -14,7 +14,8 @@ 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') logger = logging.getLogger('Prismedia')
@ -63,6 +64,13 @@ 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.')
logger.info(template % (str(options.get('--channel')))) logger.info(template % (str(options.get('--channel'))))
@ -255,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') publishAt = convert_peertube_date(publishAt)
tz = get_localzone()
tz = pytz.timezone(str(tz))
publishAt = tz.localize(publishAt).isoformat()
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'))))
@ -294,7 +304,12 @@ def upload_video(oauth, secret, options):
if options.get('--url-only') or options.get('--batch'): if options.get('--url-only') or options.get('--batch'):
logger_stdout = logging.getLogger('stdoutlogs') logger_stdout = logging.getLogger('stdoutlogs')
multipart_data = MultipartEncoder(fields) 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
@ -302,12 +317,14 @@ 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'])
logger.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.'
logger.info(template % (url, uuid)) logger.info(template % (url, uuid))
@ -325,10 +342,49 @@ def upload_video(oauth, secret, options):
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) if options.get('--credentialsdir') :
secret.read(os.path.join(options.get('--credentialsdir'), PEERTUBE_SECRETS_FILE))
else :
secret.read(PEERTUBE_SECRETS_FILE)
except Exception as e: except Exception as e:
logger.critical("Peertube: Error loading " + str(PEERTUBE_SECRETS_FILE) + ": " + str(e)) logger.critical("Peertube: Error loading " + str(PEERTUBE_SECRETS_FILE) + ": " + str(e))
exit(1) exit(1)

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

View file

@ -7,11 +7,12 @@ 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. This is the only mandatory option. -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)
-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.
@ -26,6 +27,7 @@ Options:
By default Prismedia search a .txt based on the video name and will 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) decode the file as UTF-8 (so make sure your nfo file is UTF-8 encoded)
See nfo_example.txt for more details See nfo_example.txt for more details
--credentialsdir=STRING Set directory where to search for secret file.
--platform=STRING List of platform(s) to upload to, comma separated. --platform=STRING List of platform(s) to upload to, comma separated.
Supported platforms are youtube and peertube (default is both) 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) --language=STRING Specify the default language for video. See below for supported language. (default is English)
@ -34,9 +36,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, .jpeg or .png
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) --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)
@ -45,6 +50,10 @@ 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.
@ -54,7 +63,6 @@ Logging options
-u --url-only Display generated URL after upload directly on stdout, implies --quiet -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 --batch Display generated URL after upload with platform information for easier parsing. Implies --quiet
Be careful --batch and --url-only are mutually exclusives. Be careful --batch and --url-only are mutually exclusives.
--debug (Deprecated) Alias for --log=debug. Ignored if --log is set
Strict options: 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 Strict options allow you to force some option to be present when uploading a video. It's useful to be sure you do not
@ -71,6 +79,7 @@ Strict options:
--withTags Prevent the upload without tags --withTags Prevent the upload without tags
--withPlaylist Prevent the upload if no playlist --withPlaylist Prevent the upload if no playlist
--withPublishAt Prevent the upload if no schedule --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 --withPlatform Prevent the upload if at least one platform is not specified
--withCategory Prevent the upload if no category --withCategory Prevent the upload if no category
--withLanguage Prevent upload if no language --withLanguage Prevent upload if no language
@ -100,12 +109,6 @@ import os
import datetime import datetime
import logging import logging
logger = logging.getLogger('Prismedia') 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 docopt import docopt from docopt import docopt
@ -121,16 +124,8 @@ except ImportError:
' 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:
logger.critical('This program requires that the `python-magic` library'
' is installed, NOT the Python bindings to libmagic API \n'
'see https://github.com/ahupp/python-magic\n')
exit(1)
VERSION = "prismedia v0.10.3" VERSION = "prismedia v0.12.2"
VALID_PRIVACY_STATUSES = ('public', 'private', 'unlisted') VALID_PRIVACY_STATUSES = ('public', 'private', 'unlisted')
VALID_CATEGORIES = ( VALID_CATEGORIES = (
@ -145,20 +140,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')
VALID_PROGRESS = ('percentage', 'bigfile', 'accurate')
def validateVideo(path):
supported_types = ['video/mp4']
detected_type = magic.from_file(path, mime=True)
if detected_type not in supported_types:
print("File", path, "detected type is", detected_type, "which is not one of", supported_types)
force_file = ['y', 'yes']
is_forcing = input("Are you sure you selected the correct file? (y/N)")
if is_forcing.lower() not in force_file:
return False
return path
def validateCategory(category): def validateCategory(category):
@ -190,11 +172,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:
@ -202,13 +184,16 @@ def validatePublish(publish):
return True return True
def validateThumbnail(thumbnail): def validateOriginalDate(originalDate):
supported_types = ['image/jpg', 'image/jpeg'] # Check date format and if date is past
if os.path.exists(thumbnail) and \ try:
magic.from_file(thumbnail, mime=True) in supported_types: now = datetime.datetime.now()
return thumbnail originalDate = datetime.datetime.strptime(originalDate, '%Y-%m-%dT%H:%M:%S')
else: if now <= originalDate:
return False
except ValueError:
return False return False
return True
def validateLogLevel(loglevel): def validateLogLevel(loglevel):
@ -217,6 +202,15 @@ def validateLogLevel(loglevel):
return False return False
return True 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): def _optionnalOrStrict(key, scope, error):
option = key.replace('-', '') option = key.replace('-', '')
option = option[0].upper() + option[1:] option = option[0].upper() + option[1:]
@ -234,19 +228,20 @@ def configureLogs(options):
if options.get('--batch') or options.get('--url-only'): if options.get('--batch') or options.get('--url-only'):
options['--quiet'] = True options['--quiet'] = True
if options.get('--quiet'): for handler in logger.handlers or logger.parent.handlers:
# We need to set both log level in the same time if options.get('--quiet'):
logger.setLevel(50) # We need to set both log level in the same time
ch.setLevel(50) logger.setLevel(50)
elif options.get('--log'): handler.setLevel(50)
numeric_level = getattr(logging, options["--log"], None) elif options.get('--log'):
# We need to set both log level in the same time numeric_level = getattr(logging, options["--log"], None)
logger.setLevel(numeric_level) # We need to set both log level in the same time
ch.setLevel(numeric_level) logger.setLevel(numeric_level)
elif options.get('--debug'): handler.setLevel(numeric_level)
logger.warning("DEPRECATION: --debug is deprecated, please use --log=debug instead") elif options.get('--debug'):
logger.setLevel(10) # Deprecated,
ch.setLevel(10) logger.setLevel(10)
handler.setLevel(10)
def configureStdoutLogs(): def configureStdoutLogs():
@ -280,16 +275,18 @@ def main():
Optional('--withTags', default=False): bool, Optional('--withTags', default=False): bool,
Optional('--withPlaylist', default=False): bool, Optional('--withPlaylist', default=False): bool,
Optional('--withPublishAt', default=False): bool, Optional('--withPublishAt', default=False): bool,
Optional('--withOriginalDate', default=False): bool,
Optional('--withPlatform', default=False): bool, Optional('--withPlatform', default=False): bool,
Optional('--withCategory', default=False): bool, Optional('--withCategory', default=False): bool,
Optional('--withLanguage', default=False): bool, Optional('--withLanguage', default=False): bool,
Optional('--withChannel', default=False): bool, Optional('--withChannel', default=False): bool,
Optional('--credentialsdir'): Or(None, And(str, os.path.exists, error='credentialsdir does not exist')),
# This allow to return all other options for further use: https://github.com/keleshev/schema#extra-keys # This allow to return all other options for further use: https://github.com/keleshev/schema#extra-keys
object: object object: object
}) })
schema = Schema({ schema = Schema({
'--file': And(str, os.path.exists, 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 # # Strict option checks - at the moment Schema needs to check Hook and Optional separately #
Hook('--name', handler=_optionnalOrStrict): object, Hook('--name', handler=_optionnalOrStrict): object,
Hook('--description', handler=_optionnalOrStrict): object, Hook('--description', handler=_optionnalOrStrict): object,
@ -298,6 +295,7 @@ def main():
Hook('--language', handler=_optionnalOrStrict): object, Hook('--language', handler=_optionnalOrStrict): object,
Hook('--platform', handler=_optionnalOrStrict): object, Hook('--platform', handler=_optionnalOrStrict): object,
Hook('--publishAt', handler=_optionnalOrStrict): object, Hook('--publishAt', handler=_optionnalOrStrict): object,
Hook('--originalDate', handler=_optionnalOrStrict): object,
Hook('--thumbnail', handler=_optionnalOrStrict): object, Hook('--thumbnail', handler=_optionnalOrStrict): object,
Hook('--channel', handler=_optionnalOrStrict): object, Hook('--channel', handler=_optionnalOrStrict): object,
Hook('--playlist', handler=_optionnalOrStrict): object, Hook('--playlist', handler=_optionnalOrStrict): object,
@ -336,34 +334,47 @@ 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, validatePublishDate,
error="DATE should be the form YYYY-MM-DDThh:mm:ss and has to be in the future") 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, validatePublishDate,
error="DATE should be the form YYYY-MM-DDThh:mm:ss and has to be in the future") 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, validatePublishDate,
error="DATE should be the form YYYY-MM-DDThh:mm:ss and has to be in the future") error="Publish Date should be the form YYYY-MM-DDThh:mm:ss and has to be in the future")
), ),
Optional('--originalDate'): Or(None, And(
str,
validateOriginalDate,
error="Original date should be the form YYYY-MM-DDThh:mm:ss and has to be in the past")
),
Optional('--auto-originalDate'): bool,
Optional('--cca'): bool, Optional('--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 # This allow to return all other options for further use: https://github.com/keleshev/schema#extra-keys
object: object 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 # We need to validate early options first as withNFO and logs options should be prioritized
try: try:
options = earlyoptionSchema.validate(options) options = earlyoptionSchema.validate(options)
@ -377,6 +388,12 @@ def main():
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 # Once NFO are loaded, we need to revalidate strict options in case some were in NFO
try: try:
options = earlyoptionSchema.validate(options) options = earlyoptionSchema.validate(options)

View file

@ -2,12 +2,11 @@
# 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') logger = logging.getLogger('Prismedia')
@ -101,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:
@ -118,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]
@ -125,6 +135,8 @@ 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 # Display some info after research
if not options.get('--thumbnail'): if not options.get('--thumbnail'):
@ -135,6 +147,11 @@ def searchThumbnail(options):
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:
@ -162,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"): if isfile(video_directory + "/" + directory_name + ".txt"):
nfo_directory = loadNFO(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')):
@ -196,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):

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
@ -48,8 +48,8 @@ RETRIABLE_EXCEPTIONS = (
RETRIABLE_STATUS_CODES = [500, 502, 503, 504] RETRIABLE_STATUS_CODES = [500, 502, 503, 504]
CLIENT_SECRETS_FILE = 'youtube_secret.json' CLIENT_SECRETS_FILE_BASE = 'youtube_secret.json'
CREDENTIALS_PATH = ".youtube_credentials.json" CREDENTIALS_PATH_BASE = ".youtube_credentials.json"
SCOPES = ['https://www.googleapis.com/auth/youtube.upload', 'https://www.googleapis.com/auth/youtube.force-ssl'] SCOPES = ['https://www.googleapis.com/auth/youtube.upload', 'https://www.googleapis.com/auth/youtube.force-ssl']
API_SERVICE_NAME = 'youtube' API_SERVICE_NAME = 'youtube'
API_VERSION = 'v3' API_VERSION = 'v3'
@ -60,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)
@ -76,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():
@ -89,6 +90,15 @@ def check_authenticated_scopes():
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
@ -107,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],
@ -119,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": {
} }
} }
@ -128,15 +143,16 @@ 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 = convert_youtube_date(publishAt)
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()
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'):
@ -166,14 +182,24 @@ 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( pageToken = ""
part='snippet,id', while pageToken != None:
mine=True, response = youtube.playlists().list(
maxResults=50 part='snippet,id',
).execute() mine=True,
for playlist in response["items"]: maxResults=50,
if playlist["snippet"]['title'] == playlist_name: pageToken=pageToken
return playlist['id'] ).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):
@ -278,7 +304,7 @@ def resumable_upload(request, resource, method, options):
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:
logger.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)'
logger.info(template % response['id']) logger.info(template % response['id'])
template_stdout = 'https://youtu.be/%s' template_stdout = 'https://youtu.be/%s'
@ -290,36 +316,58 @@ def resumable_upload(request, resource, method, options):
elif method != 'insert' or "id" not in response: elif method != 'insert' or "id" not in response:
logger.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')
logger.critical(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:
logger.warning(error) logger.warning(error)
retry += 1 retry += 1
if retry > MAX_RETRIES: if retry > MAX_RETRIES:
logger.error('Youtube : No longer attempting to retry.') 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
logger.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):
global CLIENT_SECRETS_FILE, CREDENTIALS_PATH
if options.get('--credentialsdir') :
CLIENT_SECRETS_FILE = os.path.join(options.get('--credentialsdir'), CLIENT_SECRETS_FILE_BASE)
CREDENTIALS_PATH = os.path.join(options.get('--credentialsdir'), CREDENTIALS_PATH_BASE)
else :
CLIENT_SECRETS_FILE = CLIENT_SECRETS_FILE_BASE
CREDENTIALS_PATH = CREDENTIALS_PATH_BASE
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:
logger.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))

View file

@ -1,11 +1,12 @@
[tool.poetry] [tool.poetry]
name = "prismedia" name = "prismedia"
version = "0.10.3" version = "0.13.0"
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>",
"Rigel Kent <sendmemail@rigelk.eu>", "Rigel Kent <sendmemail@rigelk.eu>",
"Zykino" "Zykino",
"YSalmon"
] ]
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
@ -17,33 +18,31 @@ homepage = "https://git.lecygnenoir.info/LecygneNoir/prismedia"
keywords = ['peertube', 'youtube', 'prismedia'] keywords = ['peertube', 'youtube', 'prismedia']
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = ">=3.5" python = ">=3.9"
configparser = "^3.7.1" clint = ">=0.5.1"
docopt = "^0.6.2" configparser = ">=3.7.1"
future = "^0.17.1" docopt = ">=0.6.2"
future = ">=0.17.1"
google-api-python-client = ">=1.7.6" google-api-python-client = ">=1.7.6"
google-auth = ">=1.6.1" google-auth = ">=1.6.1"
google-auth-httplib2 = ">=0.0.3" google-auth-httplib2 = ">=0.0.3"
google-auth-oauthlib = ">=0.2.0" google-auth-oauthlib = ">=0.2.0"
httplib2 = "^0.12.1" httplib2 = ">=0.12.1"
oauthlib = "^2.1.0" oauthlib = "=2.1.0"
python-magic = "^0.4.15" requests = ">=2.18.4"
python-magic-bin = { version = "^0.4.14", markers = "platform_system == 'Windows'" } requests-oauthlib = "=1.1.0"
requests = "^2.18.4" requests-toolbelt = ">=0.9.1"
requests-oauthlib = "^0.8.0" pytz = "=2022.1"
requests-toolbelt = "^0.9.1"
schema = ">=0.7.1" schema = ">=0.7.1"
tzlocal = "^1.5.1" tzlocal = ">=1.5.1"
Unidecode = "^1.0.23" Unidecode = ">=1.0.23"
uritemplate = "^3.0.0" uritemplate = ">=3.0.0"
urllib3 = "^1.22" urllib3 = ">=1.22"
[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"

View file

@ -1,129 +1,195 @@
cachetools==3.1.1 \ args==0.1.0 ; python_version >= "3.9" \
--hash=sha256:428266a1c0d36dc5aca63a2d7c5942e88c2c898d72139fca0e97fdd2380517ae \ --hash=sha256:a785b8d837625e9b61c39108532d95b85274acd679693b71ebb5156848fcf814
--hash=sha256:8ea2d3ce97850f31e4a08b0e2b5e6c34997d7216a9d2c98e0f3978630d4da69a cachetools==5.5.2 ; python_version >= "3.9" \
certifi==2020.4.5.1 \ --hash=sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4 \
--hash=sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304 \ --hash=sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a
--hash=sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519 certifi==2025.6.15 ; python_version >= "3.9" \
chardet==3.0.4 \ --hash=sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057 \
--hash=sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691 \ --hash=sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b
--hash=sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae charset-normalizer==3.4.2 ; python_version >= "3.9" \
configparser==3.8.1 \ --hash=sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4 \
--hash=sha256:45d1272aad6cfd7a8a06cf5c73f2ceb6a190f6acc1fa707e7f82a4c053b28b18 \ --hash=sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45 \
--hash=sha256:bc37850f0cc42a1725a796ef7d92690651bf1af37d744cc63161dac62cabee17 --hash=sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7 \
docopt==0.6.2 \ --hash=sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0 \
--hash=sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7 \
--hash=sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d \
--hash=sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d \
--hash=sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0 \
--hash=sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184 \
--hash=sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db \
--hash=sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b \
--hash=sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64 \
--hash=sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b \
--hash=sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8 \
--hash=sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff \
--hash=sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344 \
--hash=sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58 \
--hash=sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e \
--hash=sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471 \
--hash=sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148 \
--hash=sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a \
--hash=sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836 \
--hash=sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e \
--hash=sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63 \
--hash=sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c \
--hash=sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1 \
--hash=sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01 \
--hash=sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366 \
--hash=sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58 \
--hash=sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5 \
--hash=sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c \
--hash=sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2 \
--hash=sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a \
--hash=sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597 \
--hash=sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b \
--hash=sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5 \
--hash=sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb \
--hash=sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f \
--hash=sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0 \
--hash=sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941 \
--hash=sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0 \
--hash=sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86 \
--hash=sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7 \
--hash=sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7 \
--hash=sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455 \
--hash=sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6 \
--hash=sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4 \
--hash=sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0 \
--hash=sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3 \
--hash=sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1 \
--hash=sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6 \
--hash=sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981 \
--hash=sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c \
--hash=sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980 \
--hash=sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645 \
--hash=sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7 \
--hash=sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12 \
--hash=sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa \
--hash=sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd \
--hash=sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef \
--hash=sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f \
--hash=sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2 \
--hash=sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d \
--hash=sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5 \
--hash=sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02 \
--hash=sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3 \
--hash=sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd \
--hash=sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e \
--hash=sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214 \
--hash=sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd \
--hash=sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a \
--hash=sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c \
--hash=sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681 \
--hash=sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba \
--hash=sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f \
--hash=sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a \
--hash=sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28 \
--hash=sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691 \
--hash=sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82 \
--hash=sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a \
--hash=sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027 \
--hash=sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7 \
--hash=sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518 \
--hash=sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf \
--hash=sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b \
--hash=sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9 \
--hash=sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544 \
--hash=sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da \
--hash=sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509 \
--hash=sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f \
--hash=sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a \
--hash=sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f
clint==0.5.1 ; python_version >= "3.9" \
--hash=sha256:05224c32b1075563d0b16d0015faaf9da43aa214e4a2140e51f08789e7a4c5aa
configparser==7.2.0 ; python_version >= "3.9" \
--hash=sha256:b629cc8ae916e3afbd36d1b3d093f34193d851e11998920fdcfc4552218b7b70 \
--hash=sha256:fee5e1f3db4156dcd0ed95bc4edfa3580475537711f67a819c966b389d09ce62
docopt==0.6.2 ; python_version >= "3.9" \
--hash=sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491 --hash=sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491
future==0.17.1 \ future==1.0.0 ; python_version >= "3.9" \
--hash=sha256:67045236dcfd6816dc439556d009594abf643e5eb48992e36beac09c2ca659b8 --hash=sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216 \
google-api-core==1.16.0 \ --hash=sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05
--hash=sha256:92e962a087f1c4b8d1c5c88ade1c1dfd550047dcffb320c57ef6a534a20403e2 \ google-api-core==2.25.1 ; python_version >= "3.9" \
--hash=sha256:859f7392676761f2b160c6ee030c3422135ada4458f0948c5690a6a7c8d86294 --hash=sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7 \
google-api-python-client==1.8.0 \ --hash=sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8
--hash=sha256:0f5b42a14e2d2f7dee40f2e4514531dbe95ebde9c2173b1c4040a65c427e7900 \ google-api-python-client==2.174.0 ; python_version >= "3.9" \
--hash=sha256:5032ad1af5046889649b3848f2e871889fbb6ae440198a549fe1699581300386 --hash=sha256:9eb7616a820b38a9c12c5486f9b9055385c7feb18b20cbafc5c5a688b14f3515 \
google-auth==1.13.1 \ --hash=sha256:f695205ceec97bfaa1590a14282559c4109326c473b07352233a3584cdbf4b89
--hash=sha256:a5ee4c40fef77ea756cf2f1c0adcf475ecb53af6700cf9c133354cdc9b267148 \ google-auth-httplib2==0.2.0 ; python_version >= "3.9" \
--hash=sha256:cab6c707e6ee20e567e348168a5c69dc6480384f777a9e5159f4299ad177dcc0 --hash=sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05 \
google-auth-httplib2==0.0.3 \ --hash=sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d
--hash=sha256:098fade613c25b4527b2c08fa42d11f3c2037dda8995d86de0745228e965d445 \ google-auth-oauthlib==1.2.2 ; python_version >= "3.9" \
--hash=sha256:f1c437842155680cf9918df9bc51c1182fda41feef88c34004bd1978c8157e08 --hash=sha256:11046fb8d3348b296302dd939ace8af0a724042e8029c1b872d87fabc9f41684 \
google-auth-oauthlib==0.2.0 \ --hash=sha256:fd619506f4b3908b5df17b65f39ca8d66ea56986e5472eb5978fd8f3786f00a2
--hash=sha256:226d1d0960f86ba5d9efd426a70b291eaba96f47d071657e0254ea969025728a \ google-auth==2.40.3 ; python_version >= "3.9" \
--hash=sha256:81ba22acada4d13b1d83f9371ab19fd61f1250a542d21cf49e4dcf0637a7344a --hash=sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca \
googleapis-common-protos==1.51.0 \ --hash=sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77
--hash=sha256:013c91704279119150e44ef770086fdbba158c1f978a6402167d47d5409e226e googleapis-common-protos==1.70.0 ; python_version >= "3.9" \
httplib2==0.12.3 \ --hash=sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257 \
--hash=sha256:23914b5487dfe8ef09db6656d6d63afb0cf3054ad9ebc50868ddc8e166b5f8e8 \ --hash=sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8
--hash=sha256:a18121c7c72a56689efbf1aef990139ad940fee1e64c6f2458831736cd593600 httplib2==0.22.0 ; python_version >= "3.9" \
idna==2.9 \ --hash=sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc \
--hash=sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa \ --hash=sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81
--hash=sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb idna==3.10 ; python_version >= "3.9" \
oauthlib==2.1.0 \ --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \
--hash=sha256:d883b36b21a6ad813953803edfa563b1b579d79ca758fe950d1bc9e8b326025b \ --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3
--hash=sha256:ac35665a61c1685c56336bda97d5eefa246f1202618a1d6f34fccb1bdd404162 oauthlib==2.1.0 ; python_version >= "3.9" \
protobuf==3.11.3 \ --hash=sha256:ac35665a61c1685c56336bda97d5eefa246f1202618a1d6f34fccb1bdd404162 \
--hash=sha256:ef2c2e56aaf9ee914d3dccc3408d42661aaf7d9bb78eaa8f17b2e6282f214481 \ --hash=sha256:d883b36b21a6ad813953803edfa563b1b579d79ca758fe950d1bc9e8b326025b
--hash=sha256:dd9aa4401c36785ea1b6fff0552c674bdd1b641319cb07ed1fe2392388e9b0d7 \ proto-plus==1.26.1 ; python_version >= "3.9" \
--hash=sha256:310a7aca6e7f257510d0c750364774034272538d51796ca31d42c3925d12a52a \ --hash=sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66 \
--hash=sha256:e512b7f3a4dd780f59f1bf22c302740e27b10b5c97e858a6061772668cd6f961 \ --hash=sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012
--hash=sha256:fdfb6ad138dbbf92b5dbea3576d7c8ba7463173f7d2cb0ca1bd336ec88ddbd80 \ protobuf==6.31.1 ; python_version >= "3.9" \
--hash=sha256:e2f8a75261c26b2f5f3442b0525d50fd79a71aeca04b5ec270fc123536188306 \ --hash=sha256:0414e3aa5a5f3ff423828e1e6a6e907d6c65c1d5b7e6e975793d5590bdeecc16 \
--hash=sha256:c40973a0aee65422d8cb4e7d7cbded95dfeee0199caab54d5ab25b63bce8135a \ --hash=sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447 \
--hash=sha256:adf0e4d57b33881d0c63bb11e7f9038f98ee0c3e334c221f0858f826e8fb0151 \ --hash=sha256:4ee898bf66f7a8b0bd21bce523814e6fbd8c6add948045ce958b73af7e8878c6 \
--hash=sha256:0bae429443cc4748be2aadfdaf9633297cfaeb24a9a02d0ab15849175ce90fab \ --hash=sha256:6f1227473dc43d44ed644425268eb7c2e488ae245d51c6866d19fe158e207402 \
--hash=sha256:e11df1ac6905e81b815ab6fd518e79be0a58b5dc427a2cf7208980f30694b956 \ --hash=sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e \
--hash=sha256:7774bbbaac81d3ba86de646c39f154afc8156717972bf0450c9dbfa1dc8dbea2 \ --hash=sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9 \
--hash=sha256:8eb9c93798b904f141d9de36a0ba9f9b73cc382869e67c9e642c0aba53b0fc07 \ --hash=sha256:8764cf4587791e7564051b35524b72844f845ad0bb011704c3736cce762d8fe9 \
--hash=sha256:fac513a9dc2a74b99abd2e17109b53945e364649ca03d9f7a0b96aa8d1807d0a \ --hash=sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39 \
--hash=sha256:82d7ac987715d8d1eb4068bf997f3053468e0ce0287e2729c30601feb6602fee \ --hash=sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a
--hash=sha256:73152776dc75f335c476d11d52ec6f0f6925774802cd48d6189f4d5d7fe753f4 \ pyasn1-modules==0.4.2 ; python_version >= "3.9" \
--hash=sha256:52e586072612c1eec18e1174f8e3bb19d08f075fc2e3f91d3b16c919078469d0 \ --hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a \
--hash=sha256:2affcaba328c4662f3bc3c0e9576ea107906b2c2b6422344cdad961734ff6b93 \ --hash=sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6
--hash=sha256:24e3b6ad259544d717902777b33966a1a069208c885576254c112663e6a5bb0f \ pyasn1==0.6.1 ; python_version >= "3.9" \
--hash=sha256:c77c974d1dadf246d789f6dad1c24426137c9091e930dbf50e0a29c1fcf00b1f --hash=sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629 \
pyasn1==0.4.8 \ --hash=sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034
--hash=sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3 \ pyparsing==3.2.3 ; python_version >= "3.9" \
--hash=sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf \ --hash=sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf \
--hash=sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00 \ --hash=sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be
--hash=sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8 \ pytz==2022.1 ; python_version >= "3.9" \
--hash=sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d \ --hash=sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7 \
--hash=sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86 \ --hash=sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c
--hash=sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7 \ requests-oauthlib==1.1.0 ; python_version >= "3.9" \
--hash=sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576 \ --hash=sha256:be76f2bb72ca5525998e81d47913e09b1ca8b7957ae89b46f787a79e68ad5e61 \
--hash=sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12 \ --hash=sha256:eabd8eb700ebed81ba080c6ead96d39d6bdc39996094bd23000204f6965786b0
--hash=sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2 \ requests-toolbelt==1.0.0 ; python_version >= "3.9" \
--hash=sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359 \ --hash=sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6 \
--hash=sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776 \ --hash=sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06
--hash=sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba requests==2.32.4 ; python_version >= "3.9" \
pyasn1-modules==0.2.8 \ --hash=sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c \
--hash=sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e \ --hash=sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422
--hash=sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199 \ rsa==4.2 ; python_version >= "3.13" \
--hash=sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405 \ --hash=sha256:aaefa4b84752e3e99bd8333a2e1e3e7a7da64614042bd66f775573424370108a
--hash=sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb \ rsa==4.9.1 ; python_version >= "3.9" and python_version < "3.13" \
--hash=sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8 \ --hash=sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762 \
--hash=sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74 \ --hash=sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75
--hash=sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d \ schema==0.7.7 ; python_version >= "3.9" \
--hash=sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45 \ --hash=sha256:5d976a5b50f36e74e2157b47097b60002bd4d42e65425fcc9c9befadb4255dde \
--hash=sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4 \ --hash=sha256:7da553abd2958a19dc2547c388cde53398b39196175a9be59ea1caf5ab0a1807
--hash=sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811 \ tzdata==2025.2 ; python_version >= "3.9" and platform_system == "Windows" \
--hash=sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed \ --hash=sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8 \
--hash=sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0 \ --hash=sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9
--hash=sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd tzlocal==5.3.1 ; python_version >= "3.9" \
python-magic==0.4.15 \ --hash=sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd \
--hash=sha256:f3765c0f582d2dfc72c15f3b5a82aecfae9498bd29ca840d72f37d7bd38bfcd5 \ --hash=sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d
--hash=sha256:f2674dcfad52ae6c49d4803fa027809540b130db1dec928cfbb9240316831375 unidecode==1.4.0 ; python_version >= "3.9" \
python-magic-bin==0.4.14; platform_system == "Windows" \ --hash=sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021 \
--hash=sha256:7b1743b3dbf16601d6eedf4e7c2c9a637901b0faaf24ad4df4d4527e7d8f66a4 \ --hash=sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23
--hash=sha256:34a788c03adde7608028203e2dbb208f1f62225ad91518787ae26d603ae68892 \ uritemplate==4.2.0 ; python_version >= "3.9" \
--hash=sha256:90be6206ad31071a36065a2fc169c5afb5e0355cbe6030e87641c6c62edc2b69 --hash=sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e \
pytz==2019.3 \ --hash=sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686
--hash=sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d \ urllib3==2.5.0 ; python_version >= "3.9" \
--hash=sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be --hash=sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760 \
requests==2.23.0 \ --hash=sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc
--hash=sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee \
--hash=sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6
requests-oauthlib==0.8.0 \
--hash=sha256:883ac416757eada6d3d07054ec7092ac21c7f35cb1d2cf82faf205637081f468 \
--hash=sha256:50a8ae2ce8273e384895972b56193c7409601a66d4975774c60c2aed869639ca
requests-toolbelt==0.9.1 \
--hash=sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0 \
--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
uritemplate==3.0.1 \
--hash=sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f \
--hash=sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae
urllib3==1.22 \
--hash=sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b \
--hash=sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f