@ -0,0 +1,13 @@ | |||
For our plugins we are using [yaspy](http://yapsy.sourceforge.net). | |||
# Types | |||
For an example of the exact methods required to be recognized as a particular type of plugin, see the concerned interface definition. | |||
## Interface | |||
Plugins that present an interface (cli, gui, configuration folders, …) for the user to tell Prismedia wich video needs to be uploaded, the infos of the videos, … | |||
## Platform | |||
Also called uploaders, they are the one doing the actual work of uploading video to a particular platform. | |||
## Consumer | |||
Thoses do actions once the upload is finished (successful or failed). |
@ -0,0 +1,58 @@ | |||
from configparser import RawConfigParser | |||
from os.path import splitext, basename, dirname, abspath | |||
class Configuration: | |||
""" | |||
Configuration manager that read the configuration from multiples nfo files that are commons for all interfaces | |||
The configuration will be read and overridden in the following order: | |||
NFO.txt -> nfo.txt -> directory_name.txt -> video_file.txt -> video_name.txt | |||
(value in the rightmost file override values in preceding files) | |||
Your interface may add to this list from either side. Refer to the plugin's description. | |||
A plugin can also override completely this configuration by using other means. For example the cli plugins takes | |||
command line arguments with more importance than any nfo file. | |||
Attributes: | |||
CONFIG_FILE Filename for the base configuration file that should be used to set global behavior for | |||
prismedia and its plugins | |||
""" | |||
CONFIG_FILE = "prismedia.config" # TODO: replace with "config.txt"? something else? | |||
def __init__(self): | |||
self.root_path = dirname(abspath(__file__)) | |||
self.config_parser = RawConfigParser() | |||
self.configuration_file_list = [] | |||
self.base_configuration_file = self.root_path + "/config/" + self.CONFIG_FILE | |||
self.config_parser.read(self.base_configuration_file) | |||
def read_commons_nfo(self, video_path, video_name): | |||
video_directory = dirname(video_path) | |||
directory_name = basename(video_directory) | |||
video_file = splitext(basename(video_path))[0] | |||
self.configuration_file_list.append(video_directory + "/" + "NFO.txt") | |||
self.configuration_file_list.append(video_directory + "/" + "nfo.txt") | |||
self.configuration_file_list.append(video_directory + "/" + directory_name + ".txt") | |||
self.configuration_file_list.append(video_directory + "/" + video_file + ".txt") | |||
if video_name and video_name != video_file: | |||
self.configuration_file_list.append(video_directory + "/" + video_name + ".txt") | |||
self.config_parser.read(self.configuration_file_list) | |||
# Do not use this in actual production ready code. Prismedia should not write any file | |||
# This can be used in local to see how a plugin's variable needs to be written in the global NFO | |||
# I am afraid that trying to save a plugin info will also save the current config for the video | |||
def _write_config(self): | |||
""" | |||
Write the content of the ConfigParser in a file. | |||
""" | |||
# Do not use `assert` since we want to do the opposite and optimize the other way around | |||
if not __debug__: | |||
raise AssertionError("The method `Configuration._write_config` should not be called in production") | |||
with open(self.base_configuration_file + "_generated", "w") as configFile: | |||
self.config_parser.write(configFile) | |||
configuration_instance = Configuration() |
@ -0,0 +1,254 @@ | |||
#!/usr/bin/python | |||
# coding: utf-8 | |||
# NOTE: Since we use config file to set some defaults values, it is not possible to use the standard syntax with brackets, we use parenthesis instead. | |||
# If we were to use them we would override configuration file values with default values of cli. | |||
# TODO: change `youtube-at` and `peertube-at` that are not easily expendable as options in my opinion | |||
# TODO: remove `--url-only` and `--batch` | |||
""" | |||
prismedia - tool to upload videos to different platforms (historically Peertube and Youtube) | |||
Usage: | |||
prismedia [cli] [options] --file=<file> | |||
prismedia <interface> [<parameters>...] | |||
prismedia --heartbeat | |||
prismedia --help | -h | --version | -V | |||
Options: | |||
-f, --file=STRING Path to the video file to upload in mp4. This is the only mandatory option except if you provide the name of a plugin interface (see <interface>). | |||
--thumbnail=STRING Path to a file to use as a thumbnail for the video. | |||
--name=NAME Name of the video to upload. (default to video filename) | |||
-d, --description=STRING Description of the video. (default: default description) | |||
-t, --tag=STRING Tags for the video. comma separated. | |||
WARN: tags with punctuation (!, ', ", ?, ...) | |||
are not supported by Mastodon to be published from Peertube | |||
-c, --category=STRING Category for the videos, see below. (default: Films) | |||
--licence=STRING Creative Common licence tag (for example: CC-BY-SA) (default: proprietary) | |||
-p, --privacy=STRING Choose between public, unlisted or private. (default: private) | |||
--disable-comments Disable comments (Peertube only as YT API does not support) (default: comments are enabled) | |||
--nsfw Set the video as No Safe For Work (Peertube only as YT API does not support) (default: video is safe) | |||
--nfo=STRING Configure a specific nfo file to set options for the video. | |||
By default Prismedia search a .txt based on the video name and will | |||
decode the file as UTF-8 (so make sure your nfo file is UTF-8 encoded) | |||
See nfo_example.txt for more details | |||
--language=STRING Specify the default language for video. See below for supported language. (default is English) | |||
--publish-at=DATE Publish the video at the given DATE using local server timezone. | |||
DATE should be on the form YYYY-MM-DDThh:mm:ss eg: 2018-03-12T19:00:00 | |||
DATE should be in the future | |||
--peertube-at=DATE Override publish-at for the corresponding platform. Allow to create preview on specific platform | |||
--youtube-at=DATE Override publish-at for the corresponding platform. Allow to create preview on specific platform | |||
--original-date=DATE Configure the video as initially recorded at DATE | |||
DATE should be on the form YYYY-MM-DDThh:mm:ss eg: 2018-03-12T19:00:00 | |||
DATE should be in the past | |||
--auto-original-date Automatically use the file modification time as original date | |||
Supported types are jpg and jpeg. | |||
By default, prismedia search for an image based on video name followed by .jpg or .jpeg | |||
--channel=STRING Set the channel to use for the video (Peertube only) | |||
If the channel is not found, spawn an error except if --channelCreate is set. | |||
--channel-create Create the channel if not exists. (Peertube only, default do not create) | |||
Only relevant if --channel is set. | |||
--playlist=STRING Set the playlist to use for the video. | |||
If the playlist is not found, spawn an error except if --playlistCreate is set. | |||
--playlist-create Create the playlist if not exists. (default do not create) | |||
Only relevant if --playlist is set. | |||
--progress=STRING Set the progress bar view, one of percentage, bigFile, accurate. [default: percentage] | |||
--heartbeat Use some credits to show some activity for you apikey so the platform know it is used and would not inactivate your keys. | |||
-h, --help Show this help. Note that calling `help` without the `--` calls a plugin showing a different help for the plugins. | |||
-V, --version Show the version. | |||
Plugins options: | |||
<interface> Interface plugin to use to provide the video to upload. Select the interface you want to use. If `--file` is provided instead the interface will be the command line. | |||
--platform=STRING Platforms plugins to use. Usually one platform plugin upload to one platform website (comma separated list) (default: all) | |||
--consumer=STRING Consumers plugins to use. They are executed after an upload has been done (comma separated list) (default: all) | |||
Logging options: | |||
--log=STRING Log level, between debug, info, warning, error, critical. Ignored if --quiet is set (default to info) | |||
-q, --quiet Suppress any log except Critical (alias for --log=critical). | |||
-u, --url-only Display generated URL after upload directly on stdout, implies --quiet | |||
--batch Display generated URL after upload with platform information for easier parsing. Implies --quiet | |||
Be careful --batch and --url-only are mutually exclusives. | |||
Strict options: | |||
Strict options allow you to force some option to be present when uploading a video. It's useful to be sure you do not | |||
forget something when uploading a video, for example if you use multiples NFO. You may force the presence of description, | |||
tags, thumbnail, ... | |||
All strict option are optionals and are provided only to avoid errors when uploading :-) | |||
All strict options can be specified in NFO directly, the only strict option mandatory on cli is --withNFO | |||
All strict options are off by default | |||
--with-NFO Prevent the upload without a NFO, either specified via cli or found in the directory | |||
--with-thumbnail Prevent the upload without a thumbnail | |||
--with-name Prevent the upload if no name are found | |||
--with-description Prevent the upload without description | |||
--with-tag Prevent the upload without tags | |||
--with-playlist Prevent the upload if no playlist | |||
--with-publish-at Prevent the upload if no schedule | |||
--with-original-date Prevent the upload if no original date configured | |||
--with-platform Prevent the upload if at least one platform is not specified | |||
--with-category Prevent the upload if no category | |||
--with-language Prevent upload if no language | |||
--with-channel Prevent upload if no channel | |||
Categories: | |||
Category is the type of video you upload. Default is films. | |||
Here are available categories from Peertube and Youtube: | |||
music, films, vehicles, sports, travels, gaming, people, | |||
comedy, entertainment, news, how to, education, activism, | |||
science & technology, science, technology, animals | |||
Languages: | |||
Language of the video (audio track), choose one. Default is English | |||
Here are available languages from Peertube and Youtube: | |||
Arabic, English, French, German, Hindi, Italian, Japanese, | |||
Korean, Mandarin, Portuguese, Punjabi, Russian, Spanish | |||
""" | |||
import os | |||
import logging | |||
import pluginInterfaces as pi | |||
import configuration | |||
import utils | |||
import video as vid | |||
from docopt import docopt | |||
from yapsy.PluginManager import PluginManagerSingleton | |||
# logging.basicConfig(level=logging.DEBUG) | |||
VERSION = "prismedia v1.0.0-plugins-alpha" | |||
def loadPlugins(): | |||
from yapsy.ConfigurablePluginManager import ConfigurablePluginManager | |||
config = configuration.configuration_instance | |||
basePluginsPath = [config.root_path + "/plugins"] | |||
# TODO: check if AutoInstallPluginManager can help install new plugins or if it is already easy enough to download | |||
# and unzip a file. | |||
PluginManagerSingleton.setBehaviour([ConfigurablePluginManager]) | |||
pluginManager = PluginManagerSingleton.get() | |||
pluginManager.setPluginPlaces(directories_list=basePluginsPath) | |||
pluginManager.setPluginInfoExtension("prismedia-plugin") | |||
pluginManager.setConfigParser(config.config_parser, pluginManager.config_has_changed) | |||
# Define the various categories corresponding to the different | |||
# kinds of plugins you have defined | |||
pluginManager.setCategoriesFilter({ | |||
pi.PluginTypes.ALL: pi.IPrismediaBasePlugin, | |||
pi.PluginTypes.INTERFACE: pi.IInterfacePlugin, | |||
pi.PluginTypes.PLATFORM: pi.IPlatformPlugin, | |||
pi.PluginTypes.CONSUMER: pi.IConsumerPlugin, | |||
}) | |||
pluginManager.collectPlugins() | |||
return pluginManager | |||
# TODO: cut this function into smaller ones | |||
def main(): | |||
logger = logging.getLogger('Prismedia') | |||
# TODO: Check: Maybe this does not work good when installed via pip. | |||
pluginManager = loadPlugins() | |||
# TODO: add the arguments’s verification (copy/adapt the Schema table) | |||
options = docopt(__doc__, version=VERSION) | |||
# Helper functionalities help the user but do not upload anything | |||
if not utils.helperFunctionalities(options): | |||
exit(os.EX_OK) | |||
# Get all arguments needed by core only before calling any plugin | |||
listPlatforms = utils.getOption(options, "--platform") | |||
listConsumers = utils.getOption(options, "--consumer") | |||
if options["<interface>"]: | |||
interface_name = utils.getOption("<interface>") | |||
else: | |||
interface_name = "cli" | |||
interface = pluginManager.getPluginByName(interface_name, pi.PluginTypes.INTERFACE) | |||
video = vid.Video() | |||
try: | |||
if not interface.plugin_object.prepare_options(video, options): | |||
# The plugin asked to stop execution. | |||
exit(os.EX_OK) | |||
except Exception as e: | |||
logger.critical(utils.get_exception_string(e)) | |||
exit(os.EX_CONFIG) | |||
if listPlatforms: | |||
platforms = pluginManager.getPluginsOf(categories=pi.PluginTypes.PLATFORM, name=[listPlatforms.split(",")]) | |||
else: | |||
platforms = pluginManager.getPluginsOfCategory(pi.PluginTypes.PLATFORM) | |||
if listConsumers: | |||
consumers = pluginManager.getPluginsOf(categories=pi.PluginTypes.CONSUMER, name=[listConsumers.split(",")]) | |||
else: | |||
consumers = pluginManager.getPluginsOfCategory(pi.PluginTypes.CONSUMER) | |||
# Let each plugin check its options before starting any upload | |||
# We cannot merge this loop with the one from interface since the interface can change which plugin to use | |||
# We need to create each platform object in video, so we cannot merge this loop with the following one | |||
for plugin in platforms: | |||
# TODO: Check this is needed or not: in case of no plugin or wrong name maybe the list is empty instead of there being a None value | |||
if plugin is None: | |||
# TODO: log instead to error? critical? | |||
print("No plugin installed name `" + plugin.name + "`.") | |||
exit(os.EX_USAGE) | |||
try: | |||
video.platform[plugin.name] = vid.Platform() | |||
if not plugin.plugin_object.prepare_options(video, options): | |||
# A plugin found ill formed options, it should have logged the precises info | |||
print(plugin.name + " found a malformed option.") | |||
exit(os.EX_CONFIG) | |||
except Exception as e: | |||
logger.critical("Error while preparing plugin `" + plugin.name + "`: " + utils.get_exception_string(e)) | |||
exit(os.EX_CONFIG) | |||
for plugin in consumers: | |||
# TODO: Check this is needed or not: in case of no plugin or wrong name maybe the list is empty instead of there being a None value | |||
if plugin is None: | |||
# TODO: log instead to error? critical? | |||
print("No plugin installed name `" + plugin.name + "`.") | |||
exit(os.EX_USAGE) | |||
try: | |||
if not plugin.plugin_object.prepare_options(video, options): | |||
# A plugin found ill formed options, it should have logged the precises info | |||
print(plugin.name + " found a malformed option.") | |||
exit(os.EX_CONFIG) | |||
except Exception as e: | |||
logger.critical(utils.get_exception_string(e)) | |||
exit(os.EX_CONFIG) | |||
if video.path == "": | |||
# TODO: log instead to error? critical? | |||
print("No valid path to a video file has been provided.") | |||
exit(os.EX_USAGE) | |||
print("All options validated, starting uploads onto platforms") | |||
for platform in platforms: | |||
print("Uploading to: " + platform.name) | |||
try: | |||
platform.plugin_object.upload(video, options) | |||
except Exception as e: # TODO: Maybe not catch every Exception? | |||
logger.critical(utils.get_exception_string(e)) | |||
video.platform[platform.name].error = e | |||
video.platform[platform.name].publishAt = None | |||
video.platform[platform.name].url = None | |||
print("All uploads have been done, calling consumers plugins") | |||
for consumer in consumers: | |||
print("Calling consumer: " + consumer.name) | |||
consumer.plugin_object.finished(video, options) | |||
main() |
@ -0,0 +1,72 @@ | |||
from enum import Enum | |||
from yapsy.IPlugin import IPlugin | |||
class PluginTypes(Enum): | |||
"""Plugin Types possibles to instantiate in this program.""" | |||
ALL = "All" | |||
INTERFACE = "Interface" | |||
PLATFORM = "Platform" | |||
CONSUMER = "Consumer" | |||
class IPrismediaBasePlugin(IPlugin): | |||
""" | |||
Base for prismedia’s plugin. | |||
""" | |||
def prepare_options(self, video, options): | |||
""" | |||
Return a falsy value to exit the program. | |||
- `video`: video object to be uploaded | |||
- `options`: a dictionary of options to be used by Prismedia and other plugins | |||
""" | |||
raise NotImplementedError("`prepare_options` must be reimplemented by %s" % self) | |||
### | |||
# Interface | |||
### | |||
class IInterfacePlugin(IPrismediaBasePlugin): | |||
""" | |||
Interface for the Interface plugin category. | |||
""" | |||
# TODO: Add callback for communicating upload’s progress to the user | |||
### | |||
# Platform | |||
### | |||
class IPlatformPlugin(IPrismediaBasePlugin): | |||
""" | |||
Interface for the Platform plugin category. | |||
""" | |||
def heartbeat(self): | |||
""" | |||
If needed for your platform, use a bit of the api so the platform is aware the keys are still in use. | |||
""" | |||
raise NotImplementedError("`heartbeat` must be reimplemented by %s" % self) | |||
def upload(self, video, options): | |||
""" | |||
The upload function | |||
""" | |||
raise NotImplementedError("`upload` must be reimplemented by %s" % self) | |||
### | |||
# Consumer | |||
### | |||
class IConsumerPlugin(IPrismediaBasePlugin): | |||
""" | |||
Interface for the Consumer plugin category. | |||
""" | |||
def finished(self, video, options): | |||
""" | |||
What to do once the uploads are done. | |||
- `video` is an object containing the video details. The `platforms` key contain a list of the platforms the video has been uploaded to and the status | |||
""" | |||
raise NotImplementedError("`finished` must be reimplemented by %s" % self) |
@ -0,0 +1,9 @@ | |||
[Core] | |||
Name = debug | |||
Module = debug | |||
[Documentation] | |||
Author = Zykino | |||
Version = 0.1 | |||
Website = https://git.lecygnenoir.info/LecygneNoir/prismedia | |||
Description = Show status of elements |
@ -0,0 +1,23 @@ | |||
#!/usr/bin/env python | |||
# coding: utf-8 | |||
import pluginInterfaces as pi | |||
import utils | |||
import video as vid | |||
class Debug(pi.IConsumerPlugin): | |||
""" | |||
Plugin to help knowing the state of prismedia and its variables. | |||
""" | |||
def prepare_options(self, video, options): | |||
print("Debug plugin prepare_options:") | |||
print("Video: ", video) | |||
print("Options: ", options) | |||
return True | |||
def finished(self, video, options): | |||
print("Debug plugin finished:") | |||
print("Video: ", video) | |||
print("Options: ", options) |
@ -0,0 +1,10 @@ | |||
[Core] | |||
Name = cli | |||
Module = cli | |||
[Documentation] | |||
Author = Zykino | |||
Version = 0.1 | |||
Website = https://git.lecygnenoir.info/LecygneNoir/prismedia | |||
Description = This interface plugin is used to get the videos details from the Command Line Interface (cli). | |||
To work properly it needs to have the `--file="<path/to/you/vide/file>"` argument |
@ -0,0 +1,68 @@ | |||
import pluginInterfaces as pi | |||
import utils | |||
import configuration | |||
class Cli(pi.IInterfacePlugin): | |||
""" | |||
This is the default interface plugin. It is used when no interface plugin is specified. | |||
Its core functionality is available as a function call to `prepare_options(video, options)` if you do not need the | |||
Cli object. | |||
This can be useful to let other plugins to rely on the defaults behaviors proposed by this one and extend it. | |||
""" | |||
def prepare_options(self, video, options): | |||
prepare_options(video, options) | |||
def prepare_options(video, options): | |||
# TODO: Add the configuration file from the `--nfo` cli argument | |||
_store_docopt_to_configuration(video, options) | |||
_populate_configuration_into_video(video) | |||
def _store_docopt_to_configuration(options): | |||
items = {} | |||
for key, value in options: | |||
if key.startswith("--"): | |||
options.pop(key) | |||
items[key.strip("- ")] = value | |||
configuration.configuration_instance.config_parser.read_dict(items) | |||
def _populate_configuration_into_video(self, video): | |||
config = configuration.configuration_instance | |||
video.path = utils.getOption(config, "file", video.path) | |||
video.thumbnail = utils.getOption(config, "thumbnail", video.thumbnail) | |||
video.name = utils.getOption(config, "name", video.name) | |||
video.description = utils.getOption(config, "description", video.description) | |||
video.playlistName = utils.getOption(config, "playlist", video.playlistName) | |||
video.privacy = utils.getOption(config, "privacy", video.privacy).lower() | |||
video.category = utils.getOption(config, "category", video.category).lower() | |||
tags = utils.getOption(config, "tag", video.tags) | |||
if isinstance(tags, str): | |||
tags = tags.split(",") | |||
video.tags = tags | |||
video.language = utils.getOption(config, "language", video.language).lower() | |||
video.originalDate = utils.getOption(config, "original-date", video.originalDate) | |||
# TODO: set as an object: { "all": date1, "platformX": date2, … }? | |||
# Maybe the publishAt by platform is better placed in `self.platform` | |||
# And so publishAt would only contains the global date. | |||
video.publishAt = utils.getOption(config, "publish-at", video.publishAt) | |||
# TODO: Add a list of licences | |||
video.licence = utils.getOption(config, "licence", video.licence) | |||
video.disableComments = utils.getOption(config, "disable-comments", video.disableComments) | |||
video.nsfw = utils.getOption(config, "nsfw", video.nsfw) | |||
autoOriginalDate = utils.getOption(config, "auto-original-date", False) | |||
if autoOriginalDate: | |||
# TODO: Implement | |||
raise NotImplementedError("auto-original-date functionality is not yet implemented.") | |||
return video | |||
@ -0,0 +1,9 @@ | |||
[Core] | |||
Name = help | |||
Module = help | |||
[Documentation] | |||
Author = Zykino | |||
Version = 0.1 | |||
Website = https://git.lecygnenoir.info/LecygneNoir/prismedia | |||
Description = Give information about plugins usage by using the syntax `prismedia help [<plugin_name>...]`. |
@ -0,0 +1,51 @@ | |||
import pluginInterfaces as pi | |||
from yapsy.PluginManager import PluginManagerSingleton | |||
class Help(pi.IInterfacePlugin): | |||
""" | |||
The help plugin print the usage information of prismedia’s plugins. | |||
Use it by simply calling `prismedia help <plugin_name>...`. | |||
For example `prismedia help help` bring this help. | |||
""" | |||
def prepare_options(self, video, options): | |||
print(__name__) | |||
pluginManager = PluginManagerSingleton.get() | |||
if options["<parameters>"]: | |||
parameters = options["<parameters>"] | |||
else: | |||
parameters = ["help"] | |||
for p in parameters: | |||
plugin = pluginManager.getPluginByName(p, pi.PluginTypes.ALL) | |||
if plugin is None: | |||
# TODO: log instead to warning? error? | |||
print("No plugin was found with name:", p) | |||
continue | |||
print(plugin.name + "\t" + plugin.description) | |||
print("Usage:", plugin.plugin_object.__doc__) | |||
# Generic help this plugin is able to give for the | |||
if p == "help": | |||
print("The plugins are stored in the following folders:", pluginManager.getPluginLocator().plugins_places) | |||
print("Category:", pi.PluginTypes.INTERFACE.value) | |||
for plugin in pluginManager.getPluginsOfCategory(pi.PluginTypes.INTERFACE): | |||
print("\t" + plugin.name) | |||
print("Category:", pi.PluginTypes.PLATFORM.value) | |||
for plugin in pluginManager.getPluginsOfCategory(pi.PluginTypes.PLATFORM): | |||
print("\t" + plugin.name) | |||
print("Category:", pi.PluginTypes.CONSUMER.value) | |||
for plugin in pluginManager.getPluginsOfCategory(pi.PluginTypes.CONSUMER): | |||
print("\t" + plugin.name) | |||
# Print a line break between each plugin help. | |||
print() | |||
return False |
@ -0,0 +1,11 @@ | |||
[Core] | |||
Name = peertube | |||
Module = peertube | |||
[Documentation] | |||
Author = Le Cygne Noir | |||
Version = 0.1 | |||
Website = https://git.lecygnenoir.info/LecygneNoir/prismedia | |||
Description = Upload to the peertube platform | |||
**NOT SECURE:** If the peertube instance you want to upload to has no SSL certificate (http but not https), you can set the environment variable `OAUTHLIB_INSECURE_TRANSPORT=1`. Keep in mind that using this option makes your credentials vulnerable to interception by a malicious 3rd party. Use this only with dummy credential on a test instance. |
@ -0,0 +1,408 @@ | |||
#!/usr/bin/env python | |||
# coding: utf-8 | |||
import pluginInterfaces as pi | |||
import utils | |||
import mimetypes | |||
import json | |||
import logging | |||
import datetime | |||
import pytz | |||
from os.path import splitext, basename, abspath | |||
from tzlocal import get_localzone | |||
from configparser import RawConfigParser | |||
from requests_oauthlib import OAuth2Session | |||
from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor | |||
from oauthlib.oauth2 import LegacyApplicationClient | |||
from clint.textui.progress import Bar as ProgressBar | |||
from yapsy.PluginManager import PluginManagerSingleton | |||
logger = logging.getLogger('Prismedia') | |||
upload_finished = False | |||
class Peertube(pi.IPlatformPlugin): | |||
""" | |||
Plugin to upload to the Peertube platform. | |||
The connections files should be set as # TODO: EXPLAIN HOW TO SETUP THE SECRET FILES | |||
- `publish-at-peertube=DATE`: overrides the default `publish-at=DATE` for this platform. # TODO: Maybe we will use a [<plugin_name>] section on the config fire, explain that. | |||
""" | |||
NAME = "peertube" # TODO: find if it is possible to get the plugin’s name from inside the plugin | |||
SECRETS_FILE = "peertube_secret" | |||
PRIVACY = { | |||
"public": 1, | |||
"unlisted": 2, | |||
"private": 3 | |||
} | |||
CATEGORY = { | |||
"music": 1, | |||
"films": 2, | |||
"vehicles": 3, | |||
"sport": 5, | |||
"travels": 6, | |||
"gaming": 7, | |||
"people": 8, | |||
"comedy": 9, | |||
"entertainment": 10, | |||
"news": 11, | |||
"how to": 12, | |||
"education": 13, | |||
"activism": 14, | |||
"science & technology": 15, | |||
"science": 15, | |||
"technology": 15, | |||
"animals": 16 | |||
} | |||
LANGUAGE = { | |||
"arabic": "ar", | |||
"english": "en", | |||
"french": "fr", | |||
"german": "de", | |||
"hindi": "hi", | |||
"italian": "it", | |||
"japanese": "ja", | |||
"korean": "ko", | |||
"mandarin": "zh", | |||
"portuguese": "pt", | |||
"punjabi": "pa", | |||
"russian": "ru", | |||
"spanish": "es" | |||
} | |||
def __init__(self): | |||
self.channelCreate = False | |||
self.oauth = {} | |||
self.secret = {} | |||
def prepare_options(self, video, options): | |||
pluginManager = PluginManagerSingleton.get() | |||
# TODO: get the `publish-at-peertube=DATE` option | |||
# TODO: get the `channel` and `channel-create` options | |||
pluginManager.registerOptionFromPlugin("Platform", self.NAME, "publish-at", "2034-05-07T19:00:00") | |||
pluginManager.registerOptionFromPlugin("Platform", self.NAME, "channel", "toto") | |||
pluginManager.registerOptionFromPlugin("Platform", self.NAME, "channel-create", False) | |||
video.platform[self.NAME].channel = "" | |||
self.secret = RawConfigParser() | |||
self.secret.read(self.SECRETS_FILE) | |||
self.get_authenticated_service() | |||
return True | |||
def get_authenticated_service(self): | |||
instance_url = str(self.secret.get('peertube', 'peertube_url')).rstrip("/") | |||
oauth_client = LegacyApplicationClient( | |||
client_id=str(self.secret.get('peertube', 'client_id')) | |||
) | |||
self.oauth = OAuth2Session(client=oauth_client) | |||
self.oauth.fetch_token( | |||
token_url=str(instance_url + '/api/v1/users/token'), | |||
# lower as peertube does not store uppercase for pseudo | |||
username=str(self.secret.get('peertube', 'username').lower()), | |||
password=str(self.secret.get('peertube', 'password')), | |||
client_id=str(self.secret.get('peertube', 'client_id')), | |||
client_secret=str(self.secret.get('peertube', 'client_secret')) | |||
) | |||
def convert_peertube_date(self, date): | |||
date = datetime.datetime.strptime(date, '%Y-%m-%dT%H:%M:%S') | |||
tz = get_localzone() | |||
tz = pytz.timezone(str(tz)) | |||
return tz.localize(date).isoformat() | |||
def get_default_channel(self, user_info): | |||
return user_info['videoChannels'][0]['id'] | |||
def get_channel_by_name(self, user_info, video): | |||
for channel in user_info["videoChannels"]: | |||
if channel['displayName'] == video.platform[self.NAME].channel: | |||
return channel['id'] | |||
def create_channel(self, instance_url, video): | |||
template = ('Peertube: Channel %s does not exist, creating it.') | |||
logger.info(template % (video.platform[self.NAME].channel)) | |||
channel_name = utils.cleanString(video.platform[self.NAME].channel) | |||
# Peertube allows 20 chars max for channel name | |||
channel_name = channel_name[:19] | |||
data = '{"name":"' + channel_name + '", \ | |||
"displayName":"' + video.platform[self.NAME].channel + '", \ | |||
"description":null, \ | |||
"support":null}' | |||
headers = { | |||
'Content-Type': "application/json; charset=UTF-8" | |||
} | |||
try: | |||
response = self.oauth.post(instance_url + "/api/v1/video-channels/", | |||
data=data.encode('utf-8'), | |||
headers=headers) | |||
except Exception as e: | |||
logger.error("Peertube: " + utils.get_exception_string(e)) | |||
if response is not None: | |||
if response.status_code == 200: | |||
jresponse = response.json() | |||
jresponse = jresponse['videoChannel'] | |||
return jresponse['id'] | |||
if response.status_code == 409: | |||
logger.critical('Peertube: It seems there is a conflict with an existing channel named ' | |||
+ channel_name + '.' | |||
' Please beware Peertube internal name is compiled from 20 firsts characters of channel name.' | |||
' Also note that channel name are not case sensitive (no uppercase nor accent)' | |||
' Please check your channel name and retry.') | |||
exit(1) | |||
else: | |||
logger.critical(('Peertube: Creating channel failed with an unexpected response: ' | |||
'%s') % response) | |||
exit(1) | |||
def get_default_playlist(self, user_info): | |||
return user_info['videoChannels'][0]['id'] | |||
def get_playlist_by_name(self, instance_url, username, video): | |||
start = 0 | |||
user_playlists = json.loads(self.oauth.get( | |||
instance_url + "/api/v1/accounts/" + username + "/video-playlists?start=" + str( | |||
start) + "&count=100").content) | |||
total = user_playlists["total"] | |||
data = user_playlists["data"] | |||
# We need to iterate on pagination as peertube returns max 100 playlists (see #41) | |||
while start < total: | |||
for playlist in data: | |||
if playlist['displayName'] == video.playlistName: | |||
return playlist['id'] | |||
start = start + 100 | |||
user_playlists = json.loads(self.oauth.get( | |||
instance_url + "/api/v1/accounts/" + username + "/video-playlists?start=" + str( | |||
start) + "&count=100").content) | |||
data = user_playlists["data"] | |||
def create_playlist(self, instance_url, video, channel): | |||
template = ('Peertube: Playlist %s does not exist, creating it.') | |||
logger.info(template % (str(video.playlistName))) | |||
# We use files for form-data Content | |||
# see https://requests.readthedocs.io/en/latest/user/quickstart/#post-a-multipart-encoded-file | |||
# None is used to mute "filename" field | |||
files = {'displayName': (None, str(video.playlistName)), | |||
'privacy': (None, "1"), | |||
'description': (None, "null"), | |||
'videoChannelId': (None, str(channel)), | |||
'thumbnailfile': (None, "null")} | |||
try: | |||
response = self.oauth.post(instance_url + "/api/v1/video-playlists/", | |||
files=files) | |||
except Exception as e: | |||
logger.error("Peertube: " + utils.get_exception_string(e)) | |||
if response is not None: | |||
if response.status_code == 200: | |||
jresponse = response.json() | |||
jresponse = jresponse['videoPlaylist'] | |||
return jresponse['id'] | |||
else: | |||
logger.critical(('Peertube: Creating the playlist failed with an unexpected response: ' | |||
'%s') % response) | |||
exit(1) | |||
def set_playlist(self, instance_url, video_id, playlist_id): | |||
logger.info('Peertube: add video to playlist.') | |||
data = '{"videoId":"' + str(video_id) + '"}' | |||
headers = { | |||
'Content-Type': "application/json" | |||
} | |||
try: | |||
response = self.oauth.post(instance_url + "/api/v1/video-playlists/" + str(playlist_id) + "/videos", | |||
data=data, | |||
headers=headers) | |||
except Exception as e: | |||
logger.error("Peertube: " + utils.get_exception_string(e)) | |||
if response is not None: | |||
if response.status_code == 200: | |||
logger.info('Peertube: Video is successfully added to the playlist.') | |||
else: | |||
logger.critical(('Peertube: Configuring the playlist failed with an unexpected response: ' | |||
'%s') % response) | |||
exit(1) | |||
def upload_video(self, video, options): | |||
def get_userinfo(base_url): | |||
return json.loads(self.oauth.get(base_url + "/api/v1/users/me").content) | |||
def get_file(video_path): | |||
mimetypes.init() | |||
return (basename(video_path), open(abspath(video_path), 'rb'), | |||
mimetypes.types_map[splitext(video_path)[1]]) | |||
path = video.path | |||
instance_url = str(self.secret.get('peertube', 'peertube_url')).rstrip('/') | |||
user_info = get_userinfo(instance_url) | |||
username = str(self.secret.get('peertube', 'username').lower()) | |||
# We need to transform fields into tuple to deal with tags as | |||
# MultipartEncoder does not support list refer | |||
# https://github.com/requests/toolbelt/issues/190 and | |||
# https://github.com/requests/toolbelt/issues/205 | |||
fields = [ | |||
("name", video.name), | |||
("licence", "1"), # TODO: get licence from video object | |||
("description", video.description), | |||
("category", str(self.CATEGORY[video.category])), | |||
("language", str(self.LANGUAGE[video.language])), | |||
("commentsEnabled", "0" if video.disableComments else "1"), | |||
("nsfw", "1" if video.nsfw else "0"), | |||
("videofile", get_file(path)) | |||
] | |||
tag_number = 0 | |||
for strtag in video.tags: | |||
tag_number = tag_number + 1 | |||
# Empty tag crashes Peertube, so skip them | |||
if strtag == "": | |||
continue | |||
# Tag more than 30 chars crashes Peertube, so skip tags | |||
if len(strtag) >= 30: | |||
logger.warning( | |||
"Peertube: Sorry, Peertube does not support tag with more than 30 characters, please reduce tag: " + strtag) | |||
logger.warning("Peertube: Meanwhile, this tag will be skipped") | |||
continue | |||
# Peertube supports only 5 tags at the moment | |||
if tag_number > 5: | |||
logger.warning("Peertube: Sorry, Peertube support 5 tags max, additional tag will be skipped") | |||
logger.warning("Peertube: Skipping tag " + strtag) | |||
continue | |||
fields.append(("tags[]", strtag)) | |||
# If peertubeAt exists, use instead of publishAt | |||
if video.platform[self.NAME].publishAt: | |||
publishAt = video.platform[self.NAME].publishAt | |||
elif video.publishAt: | |||
publishAt = video.publishAt | |||
if 'publishAt' in locals(): | |||
publishAt = convert_peertube_date(publishAt) | |||
fields.append(("scheduleUpdate[updateAt]", publishAt)) | |||
fields.append(("scheduleUpdate[privacy]", str(self.PRIVACY["public"]))) | |||
fields.append(("privacy", str(self.PRIVACY["private"]))) | |||
else: | |||
fields.append(("privacy", str(self.PRIVACY[video.privacy]))) | |||
if video.originalDate: | |||
originalDate = convert_peertube_date(video.originalDate) | |||
fields.append(("originallyPublishedAt", originalDate)) | |||
if video.thumbnail: | |||
fields.append(("thumbnailfile", get_file(video.thumbnail))) | |||
fields.append(("previewfile", get_file(video.thumbnail))) | |||
if hasattr(video.platform[self.NAME], "channel"): # TODO: Should always be present | |||
channel_id = self.get_channel_by_name(user_info, video) | |||
if not channel_id and self.channelCreate: | |||
channel_id = self.create_channel(instance_url, video) | |||
elif not channel_id: | |||
logger.warning("Peertube: Channel `" + video.platform[ | |||
self.NAME].channel + "` is unknown, using default channel.") # TODO: debate if we should have the same message and behavior than playlist: "does not exist, please set --channelCreate" | |||
channel_id = self.get_default_channel(user_info) | |||
else: | |||
channel_id = self.get_default_channel(user_info) | |||
fields.append(("channelId", str(channel_id))) | |||
if video.playlistName: | |||
playlist_id = get_playlist_by_name(instance_url, username, video) | |||
if not playlist_id and video.playlistCreate: | |||
playlist_id = create_playlist(instance_url, video, channel_id) | |||
elif not playlist_id: | |||
logger.critical( | |||
"Peertube: Playlist `" + video.playlistName + "` does not exist, please set --playlistCreate" | |||
" if you want to create it") | |||
exit(1) | |||
encoder = MultipartEncoder(fields) | |||
if options.get('--quiet'): | |||
multipart_data = encoder | |||
else: | |||
progress_callback = self.create_callback(encoder, options.get('--progress')) | |||
multipart_data = MultipartEncoderMonitor(encoder, progress_callback) | |||
headers = { | |||
'Content-Type': multipart_data.content_type | |||
} | |||
response = self.oauth.post(instance_url + "/api/v1/videos/upload", | |||
data=multipart_data, | |||
headers=headers) | |||
if response is not None: | |||
if response.status_code == 200: | |||
jresponse = response.json() | |||
jresponse = jresponse['video'] | |||
uuid = jresponse['uuid'] | |||
video_id = str(jresponse['id']) | |||
logger.info("Peertube: Video was successfully uploaded.") | |||
template_url = "%s/videos/watch/%s" | |||
video.platform[self.NAME].url = template_url % (instance_url, uuid) | |||
logger.info("Peertube: Watch it at " + video.platform[self.NAME].url + ".") | |||
# Upload is successful we may set playlist | |||
if 'playlist_id' in locals(): | |||
set_playlist(instance_url, video_id, playlist_id) | |||
else: | |||
logger.critical(('Peertube: The upload failed with an unexpected response: ' | |||
'%s') % response) | |||
exit(1) | |||
def create_callback(self, encoder, progress_type): | |||
upload_size_MB = encoder.len * (1 / (1024 * 1024)) | |||
if progress_type is None or "percentage" in progress_type.lower(): | |||
progress_lambda = lambda x: int((x / encoder.len) * 100) # Default to percentage | |||
elif "bigfile" in progress_type.lower(): | |||
progress_lambda = lambda x: x * (1 / (1024 * 1024)) # MB | |||
elif "accurate" in progress_type.lower(): | |||
progress_lambda = lambda x: x * (1 / (1024)) # kB | |||
else: | |||
# Should not happen outside of development when adding partly a progress type | |||
logger.critical("Peertube: Unknown progress type `" + progress_type + "`") | |||
exit(1) | |||
bar = ProgressBar(expected_size=progress_lambda(encoder.len), label=f"Peertube upload progress ({upload_size_MB:.2f}MB) ", filled_char='=') | |||
def callback(monitor): | |||
# We want the condition to capture the varible from the parent scope, not a local variable that is created after | |||
global upload_finished | |||
progress = progress_lambda(monitor.bytes_read) | |||
bar.show(progress) | |||
if monitor.bytes_read == encoder.len: | |||
if not upload_finished: | |||
# We get two time in the callback with both bytes equals, skip the first | |||
upload_finished = True | |||
else: | |||
# Print a blank line to not (partly) override the progress bar | |||
print() | |||
logger.info("Peertube: Upload finish, Processing…") | |||
return callback | |||
def heartbeat(self): | |||
""" | |||
If needed for your platform, use a bit of the api so the platform is aware the keys are still in use. | |||
""" | |||
print("heartbeat for peertube (nothing to do)") | |||
pass | |||
# def run(options): | |||
def upload(self, video, options): | |||
logger.info('Peertube: Uploading video...') | |||
self.upload_video(video, options) |
@ -1,398 +0,0 @@ | |||
#!/usr/bin/env python | |||
# coding: utf-8 | |||
import os | |||
import mimetypes | |||
import json | |||
import logging | |||
import sys | |||
import datetime | |||
import pytz | |||
from os.path import splitext, basename, abspath | |||
from tzlocal import get_localzone | |||
from configparser import RawConfigParser | |||
from requests_oauthlib import OAuth2Session | |||
from oauthlib.oauth2 import LegacyApplicationClient | |||
from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor | |||
from clint.textui.progress import Bar as ProgressBar | |||
from . import utils | |||
logger = logging.getLogger('Prismedia') | |||
PEERTUBE_SECRETS_FILE = 'peertube_secret' | |||
PEERTUBE_PRIVACY = { | |||
"public": 1, | |||
"unlisted": 2, | |||
"private": 3 | |||
} | |||
def get_authenticated_service(secret): | |||
peertube_url = str(secret.get('peertube', 'peertube_url')).rstrip("/") | |||
oauth_client = LegacyApplicationClient( | |||
client_id=str(secret.get('peertube', 'client_id')) | |||
) | |||
try: | |||
oauth = OAuth2Session(client=oauth_client) | |||
oauth.fetch_token( | |||
token_url=str(peertube_url + '/api/v1/users/token'), | |||
# lower as peertube does not store uppercase for pseudo | |||
username=str(secret.get('peertube', 'username').lower()), | |||
password=str(secret.get('peertube', 'password')), | |||
client_id=str(secret.get('peertube', 'client_id')), | |||
client_secret=str(secret.get('peertube', 'client_secret')) | |||
) | |||
except Exception as e: | |||
if hasattr(e, 'message'): | |||
logger.critical("Peertube: " + str(e.message)) | |||
exit(1) | |||
else: | |||
logger.critical("Peertube: " + str(e)) | |||
exit(1) | |||
return oauth | |||
def get_default_channel(user_info): | |||
return user_info['videoChannels'][0]['id'] | |||
def get_channel_by_name(user_info, options): | |||
for channel in user_info["videoChannels"]: | |||
if channel['displayName'] == options.get('--channel'): | |||
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): | |||
template = ('Peertube: Channel %s does not exist, creating it.') | |||
logger.info(template % (str(options.get('--channel')))) | |||
channel_name = utils.cleanString(str(options.get('--channel'))) | |||
# Peertube allows 20 chars max for channel name | |||
channel_name = channel_name[:19] | |||
data = '{"name":"' + channel_name + '", \ | |||
"displayName":"' + options.get('--channel') + '", \ | |||
"description":null, \ | |||
"support":null}' | |||
headers = { | |||
'Content-Type': "application/json; charset=UTF-8" | |||
} | |||
try: | |||
response = oauth.post(url + "/api/v1/video-channels/", | |||
data=data.encode('utf-8'), | |||
headers=headers) | |||
except Exception as e: | |||
if hasattr(e, 'message'): | |||
logger.error("Peertube: " + str(e.message)) | |||
else: | |||
logger.error("Peertube: " + str(e)) | |||
if response is not None: | |||
if response.status_code == 200: | |||
jresponse = response.json() | |||
jresponse = jresponse['videoChannel'] | |||
return jresponse['id'] | |||
if response.status_code == 409: | |||
logger.critical('Peertube: It seems there is a conflict with an existing channel named ' | |||
+ channel_name + '.' | |||
' Please beware Peertube internal name is compiled from 20 firsts characters of channel name.' | |||
' Also note that channel name are not case sensitive (no uppercase nor accent)' | |||
' Please check your channel name and retry.') | |||
exit(1) | |||
else: | |||
logger.critical(('Peertube: Creating channel failed with an unexpected response: ' | |||
'%s') % response) | |||
exit(1) | |||
def get_default_playlist(user_info): | |||
return user_info['videoChannels'][0]['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): | |||
template = ('Peertube: Playlist %s does not exist, creating it.') | |||
logger.info(template % (str(options.get('--playlist')))) | |||
# We use files for form-data Content | |||
# see https://requests.readthedocs.io/en/latest/user/quickstart/#post-a-multipart-encoded-file | |||
# None is used to mute "filename" field | |||
files = {'displayName': (None, str(options.get('--playlist'))), | |||
'privacy': (None, "1"), | |||
'description': (None, "null"), | |||
'videoChannelId': (None, str(channel)), | |||
'thumbnailfile': (None, "null")} | |||
try: | |||
response = oauth.post(url + "/api/v1/video-playlists/", | |||
files=files) | |||
except Exception as e: | |||
if hasattr(e, 'message'): | |||
logger.error("Peertube: " + str(e.message)) | |||
else: | |||
logger.error("Peertube: " + str(e)) | |||
if response is not None: | |||
if response.status_code == 200: | |||
jresponse = response.json() | |||
jresponse = jresponse['videoPlaylist'] | |||
return jresponse['id'] | |||
else: | |||
logger.critical(('Peertube: Creating the playlist failed with an unexpected response: ' | |||
'%s') % response) | |||
exit(1) | |||
def set_playlist(oauth, url, video_id, playlist_id): | |||
logger.info('Peertube: add video to playlist.') | |||
data = '{"videoId":"' + str(video_id) + '"}' | |||
headers = { | |||
'Content-Type': "application/json" | |||
} | |||
try: | |||
response = oauth.post(url + "/api/v1/video-playlists/"+str(playlist_id)+"/videos", | |||
data=data, | |||
headers=headers) | |||
except Exception as e: | |||
if hasattr(e, 'message'): | |||
logger.error("Peertube: " + str(e.message)) | |||
else: | |||
logger.error("Peertube: " + str(e)) | |||
if response is not None: | |||
if response.status_code == 200: | |||
logger.info('Peertube: Video is successfully added to the playlist.') | |||
else: | |||
logger.critical(('Peertube: Configuring the playlist failed with an unexpected response: ' | |||
'%s') % response) | |||
exit(1) | |||
def upload_video(oauth, secret, options): | |||
def get_userinfo(): | |||
return json.loads(oauth.get(url+"/api/v1/users/me").content) | |||
def get_file(path): | |||
mimetypes.init() | |||
return (basename(path), open(abspath(path), 'rb'), | |||
mimetypes.types_map[splitext(path)[1]]) | |||
path = options.get('--file') | |||
url = str(secret.get('peertube', 'peertube_url')).rstrip('/') | |||
user_info = get_userinfo() | |||
username = str(secret.get('peertube', 'username').lower()) | |||
# We need to transform fields into tuple to deal with tags as | |||
# MultipartEncoder does not support list refer | |||
# https://github.com/requests/toolbelt/issues/190 and | |||
# https://github.com/requests/toolbelt/issues/205 | |||
fields = [ | |||
("name", options.get('--name') or splitext(basename(options.get('--file')))[0]), | |||
("licence", "1"), | |||
("description", options.get('--description') or "default description"), | |||
("nsfw", str(int(options.get('--nsfw')) or "0")), | |||
("videofile", get_file(path)) | |||
] | |||
if options.get('--tags'): | |||
tags = options.get('--tags').split(',') | |||
tag_number = 0 | |||
for strtag in tags: | |||
tag_number = tag_number + 1 | |||
# Empty tag crashes Peertube, so skip them | |||
if strtag == "": | |||
continue | |||
# Tag more than 30 chars crashes Peertube, so skip tags | |||
if len(strtag) >= 30: | |||
logger.warning("Peertube: Sorry, Peertube does not support tag with more than 30 characters, please reduce tag: " + strtag) | |||
logger.warning("Peertube: Meanwhile, this tag will be skipped") | |||
continue | |||
# Peertube supports only 5 tags at the moment | |||
if tag_number > 5: | |||
logger.warning("Peertube: Sorry, Peertube support 5 tags max, additional tag will be skipped") | |||
logger.warning("Peertube: Skipping tag " + strtag) | |||
continue | |||
fields.append(("tags[]", strtag)) | |||
if options.get('--category'): | |||
fields.append(("category", str(utils.getCategory(options.get('--category'), 'peertube')))) | |||
else: | |||
# if no category, set default to 2 (Films) | |||
fields.append(("category", "2")) | |||
if options.get('--language'): | |||
fields.append(("language", str(utils.getLanguage(options.get('--language'), "peertube")))) | |||
else: | |||
# if no language, set default to 1 (English) | |||
fields.append(("language", "en")) | |||
if options.get('--disable-comments'): | |||
fields.append(("commentsEnabled", "0")) | |||
else: | |||
fields.append(("commentsEnabled", "1")) | |||
privacy = None | |||
if options.get('--privacy'): | |||
privacy = options.get('--privacy').lower() | |||
# If peertubeAt exists, use instead of publishAt | |||
if options.get('--peertubeAt'): | |||
publishAt = options.get('--peertubeAt') | |||
elif options.get('--publishAt'): | |||
publishAt = options.get('--publishAt') | |||
if 'publishAt' in locals(): | |||
publishAt = convert_peertube_date(publishAt) | |||
fields.append(("scheduleUpdate[updateAt]", publishAt)) | |||
fields.append(("scheduleUpdate[privacy]", str(PEERTUBE_PRIVACY["public"]))) | |||
fields.append(("privacy", str(PEERTUBE_PRIVACY["private"]))) | |||
else: | |||
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'): | |||
fields.append(("thumbnailfile", get_file(options.get('--thumbnail')))) | |||
fields.append(("previewfile", get_file(options.get('--thumbnail')))) | |||
if options.get('--channel'): | |||
channel_id = get_channel_by_name(user_info, options) | |||
if not channel_id and options.get('--channelCreate'): | |||
channel_id = create_channel(oauth, url, options) | |||
elif not channel_id: | |||
logger.warning("Peertube: Channel `" + options.get('--channel') + "` is unknown, using default channel.") | |||
channel_id = get_default_channel(user_info) | |||
else: | |||
channel_id = get_default_channel(user_info) | |||
fields.append(("channelId", str(channel_id))) | |||
if options.get('--playlist'): | |||
playlist_id = get_playlist_by_name(oauth, url, username, options) | |||
if not playlist_id and options.get('--playlistCreate'): | |||
playlist_id = create_playlist(oauth, url, options, channel_id) | |||
elif not playlist_id: | |||
logger.critical("Peertube: Playlist `" + options.get('--playlist') + "` does not exist, please set --playlistCreate" | |||
" if you want to create it") | |||
exit(1) | |||
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 = { | |||
'Content-Type': multipart_data.content_type | |||
} | |||
response = oauth.post(url + "/api/v1/videos/upload", | |||
data=multipart_data, | |||
headers=headers) | |||
if response is not None: | |||
if response.status_code == 200: | |||
jresponse = response.json() | |||
jresponse = jresponse['video'] | |||
uuid = jresponse['uuid'] | |||
video_id = str(jresponse['id']) | |||
logger.info('Peertube: Video was successfully uploaded.') | |||
template = 'Peertube: Watch it at %s/videos/watch/%s.' | |||
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 | |||
if options.get('--playlist'): | |||
set_playlist(oauth, url, video_id, playlist_id) | |||
else: | |||
logger.critical(('Peertube: The upload failed with an unexpected response: ' | |||
'%s') % response) | |||
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): | |||
secret = RawConfigParser() | |||
try: | |||
secret.read(PEERTUBE_SECRETS_FILE) | |||
except Exception as e: | |||
logger.critical("Peertube: Error loading " + str(PEERTUBE_SECRETS_FILE) + ": " + str(e)) | |||
exit(1) | |||
insecure_transport = secret.get('peertube', 'OAUTHLIB_INSECURE_TRANSPORT') | |||
os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = insecure_transport | |||
oauth = get_authenticated_service(secret) | |||
try: | |||
logger.info('Peertube: Uploading video...') | |||
upload_video(oauth, secret, options) | |||
except Exception as e: | |||
if hasattr(e, 'message'): | |||
logger.error("Peertube: " + str(e.message)) | |||
else: | |||
logger.error("Peertube: " + str(e)) |
@ -0,0 +1,123 @@ | |||
from os.path import dirname, splitext, basename, isfile, normpath, expanduser | |||
class Platform(object): | |||
""" | |||
Store data representing a Platform. | |||
""" | |||
def __init__(self): | |||
self.error = None | |||
self.publishAt = None | |||
self.url = None | |||
def __repr__(self): | |||
return str(self.__dict__) | |||
# TODO: Add container for `with-*` and a `isValid` method to check that all `with-*` options are present | |||
# TODO: We need some list (using enum?) for the commons licences, language, privacy, categories options | |||
class Video(object): | |||
""" | |||
Store data representing a Video. | |||
""" | |||
def __init__(self): | |||
self.path = "" | |||
self.thumbnail = None | |||
self.name = None | |||
self.description = "Video uploaded with Prismedia" | |||
self.playlistName = None | |||
self.playlistCreate = False | |||
self.privacy = "private" | |||
self.category = "films" | |||
self.tags = [] | |||
self.language = "english" | |||
self.originalDate = None # TODO: add a method "extract original date"? -> I feal that needs to be done outside of this class | |||
# TODO: set as an object: { "all": date1, "platformX": date2, …}? | |||
# Maybe the publishAt by platform is better placed in `self.platform` | |||
# And so publishAt would only contains the global date. | |||
self.publishAt = None | |||
# TODO: Add a list of licences | |||
self.licence = "proprietary" | |||
self.disableComments = False | |||
self.nsfw = False | |||
# Each platform should insert here the upload state | |||
self.platform = {} | |||
@property | |||
def path(self): | |||
return self._path | |||
@path.setter | |||
def path(self, value): | |||
path = normpath(expanduser(value)) | |||
if value == "": | |||
self._path = "" | |||
elif isfile(path): | |||
self._path = path | |||
else: | |||
# TODO: log instead to debug? info? | |||
print("The path `" + value + "` does not point to a video") | |||
self._path = "" | |||
@property | |||
def thumbnail(self): | |||
if self._thumbnail is not None: | |||
return self._thumbnail | |||
else: | |||
result = None | |||
video_directory = dirname(self.path) + "/" | |||
# First, check for thumbnail based on videoname | |||
if isfile(video_directory + self.name + ".jpg"): | |||
result = video_directory + self.name + ".jpg" | |||
elif isfile(video_directory + self.name + ".jpeg"): | |||
result = video_directory + self.name + ".jpeg" | |||
# Then, if we still not have thumbnail, check for thumbnail based on videofile name | |||
# NOTE: This may be a the exact same check from the previous conditions if self._name = None. | |||
# Bus as far as I know it is not recommended to use privates properties even in the same class | |||
# Maybe check if self.name == splitext(basename(self.path))[0] | |||
# Not done since it is early dev for the plugins rewrite | |||
if not result: | |||
video_file = splitext(basename(self.path))[0] | |||
if isfile(video_directory + video_file + ".jpg"): | |||
result = video_directory + video_file + ".jpg" | |||
elif isfile(video_directory + video_file + ".jpeg"): | |||
result = video_directory + video_file + ".jpeg" | |||
# TODO: move to caller. Logging the output is its responsibility | |||
# Display some info after research | |||
# if not result: | |||
# logger.debug("No thumbnail has been found, continuing") | |||
# else: | |||
# logger.info("Using " + result + " as thumbnail") | |||
return result | |||
@thumbnail.setter | |||
def thumbnail(self, value): | |||
self._thumbnail = value | |||
@property | |||
def name(self): | |||
if self._name is not None: | |||
return self._name | |||
else: | |||
return splitext(basename(self.path))[0] | |||
@name.setter | |||
def name(self, value): | |||
self._name = value | |||
def __repr__(self): | |||
result = "{\n" | |||
for key in self.__dict__: | |||
result += "\t'" + key + "': " + str(self.__dict__[key]) + ",\n" | |||
result += "}\n" | |||
return result |