diff --git a/DOCS/man/mpv.rst b/DOCS/man/mpv.rst index 420297483e7e4..614f2ebcc7124 100644 --- a/DOCS/man/mpv.rst +++ b/DOCS/man/mpv.rst @@ -1555,6 +1555,8 @@ works like in older mpv releases: .. include:: javascript.rst +.. include:: python.rst + .. include:: ipc.rst .. include:: changes.rst diff --git a/DOCS/man/python.rst b/DOCS/man/python.rst new file mode 100644 index 0000000000000..e2442bc43ead9 --- /dev/null +++ b/DOCS/man/python.rst @@ -0,0 +1,257 @@ +PYTHON SCRIPTING +================ + +.. + Useful links + ASS markup: https://aegisub.org/docs/latest/ass_tags/ + +mpv can load Python scripts. (See `Script location`_.) + + +Python specific options +----------------------- + +- enable-python + + Config option (/ runtime option) `enable-python` has the default value + `false` (/ `no`) and hence by default Python doesn't initialize on runs of + mpv, unable to load any python script. This option has been set to `false` + because if there's no script need to run then having python on the heap is a + waste of resource. To be able to run python scripts, set `enable-pyhton` to + `yes` on mpv.conf file. + + +mpv_event as a python dictionary +-------------------------------- + +Has the following keys:: + + { + event_id: int; + reply_userdata: int; + error: int; + error_message?: string; + data: any; + } + +- `event_id`; represents an `mpv_event_id`. See: `Event id`_. +- `reply_userdata`; unique id number for a request event. +- `error`; one of mpv's error number or 0. +- `error_message`; (optional) description of the error. +- `data`; type varies depending on the `event_id`:: + + (MPV_EVENT_CLIENT_MESSAGE)data: tuple[str]; + + (MPV_EVENT_PROPERTY_CHANGE)data: { + name: str; # name of the property + value: any; # value of the property + } + + (MPV_EVENT_HOOK)data: { + name: str; # hook_name + id: int; # id to use to call _mpv.hook_continue + } + + +Event id +-------- + +:: + + MPV_EVENT_NONE = 0 + MPV_EVENT_SHUTDOWN = 1 + MPV_EVENT_LOG_MESSAGE = 2 + MPV_EVENT_GET_PROPERTY_REPLY = 3 + MPV_EVENT_SET_PROPERTY_REPLY = 4 + MPV_EVENT_COMMAND_REPLY = 5 + MPV_EVENT_START_FILE = 6 + MPV_EVENT_END_FILE = 7 + MPV_EVENT_FILE_LOADED = 8 + MPV_EVENT_CLIENT_MESSAGE = 16 + MPV_EVENT_VIDEO_RECONFIG = 17 + MPV_EVENT_AUDIO_RECONFIG = 18 + MPV_EVENT_SEEK = 20 + MPV_EVENT_PLAYBACK_RESTART = 21 + MPV_EVENT_PROPERTY_CHANGE = 22 + MPV_EVENT_QUEUE_OVERFLOW = 24 + MPV_EVENT_HOOK = 25 + + +The wrapper module +------------------ + +The `player/python/defaults.py` is the wrapper module to the internal c level +python extension contained in `player/py_extend.c`. It has the most useful class +`Mpv` giving away the functionalities for calling `mpv` API. + +``class Mpv``: + +``Mpv.register_event(self, event_name)`` + See: `Possible event names`_ section + for the argument event_name. Returns a decorator that takes in a function having + the following signature, with an example:: + + @mpv.register_event(mpv.MPV_EVENT_COMMAND_REPLY) + def command_reply(event): + print(event["data"]) + + So, the function put to the `register_event(en)` decorator receives one + argument representing an `mpv_event as a python dictionary`_. + +``Mpv.add_timeout(self, sec, func, *args, name=None, **kwargs)`` + Utilizes `threading.Timer` to schedule the `func` to run after `sec` + seconds. `args` and `kwargs` are passed into the `func` while calling it. + The keyword argument `name` is used to reference the `threading.Timer` + instance to manipulate it, for example, for cancelling it. + +``Mpv.timers`` + Type:: + + Mpv.timers: dict[str, treading.Timer] + + Here the keyword argument `name` to `Mpv.add_timeout` used as key. If `name` + is `None` then `name` is dynamically created. + + Uses:: + + if "detect_crop" in mpv.timers: + mpv.warn("Already cropdetecting!") + + Or:: + + mpv.clear_timer("detect_crop") + +``Mpv.clear_timer(self, name)`` + Given the name of a timer this function cancels it. + +``Mpv.get_opt(self, key, default=None)`` + Returns the option value defined in section `options/script_opts`. If the + key is not found it returns the `default`. + +``Mpv.log(self, level, *args)`` + The following functions can be used to send log messages:: + + Mpv.log(self, level, *args) + Mpv.trace(self, *args) + Mpv.debug(self, *args) + Mpv.info(self, *args) + Mpv.warn(self, *args) + Mpv.error(self, *args) + Mpv.fatal(self, *args) + +``Mpv.osd_message(self, text, duration=-1, osd_level=None)`` + Displays osd messages. See: `OSD Commands`_ for more detail. + +``Mpv.command_string(self, name)`` + Given the string representation of a command `command_string`, runs it. + +``Mpv.commandv(self, *args)`` + Given a number of arguments as command, runs it. Arguments are parsed into + string before running the command. + +``Mpv.command(self, node)`` + Given an `mpv_node` representation in python data types. Runs the node as a + command and returns it's result as another `mpv_node` python representation. + +``Mpv.command_node_async_callback(self, node)`` + Returns a decorator which on invoke calls `Mpv.command_node_async` as the + given `node` and registers a given function to call with the result when the + async command returns. The decorator function return a `registry entry` of the + following form:: + + {"callback": callback_function, "id": async_command_id} + +``Mpv.abort_async_command(self, registry_entry)`` + Given a `registry entry` described above, this function cancels an async + command referenced by `registry_entry["id"]`. + + +``Mpv.find_config_file(self, filename)`` + Given the filename return an mpv configuration file. `None` if file not + found. + +``Mpv.request_event(self, event_name)`` + Given an `event_name` of the form `mpv.MPV_EVENT_CLIENT_MESSAGE` mpv enables + messages when the event has occurred. + +``Mpv.enable_messages(self, level)`` + Given a log `level`, mpv enables log messages above this `level` from the + client. + +``Mpv.observe_property(self, property_name, mpv_format)`` + Returns a decorator which takes in a function to invoke (with the property + value) when the observed property has changed. Example use case:: + + from mpvclient import mpv + + @mpv.observe_property("pause", mpv.MPV_FORMAT_NODE) + def on_pause_change(data): + if data: + mpv.osd_message(f"Paused the video for you!") + +``Mpv.unobserve_property(self, id)`` + Given the id of a property observer remove the observer. + +``Mpv.set_property(self, property_name, mpv_format, data)`` + A set property call to a said mpv_format, set's the property. Property + setters:: + + Mpv.set_property_string(name, data) + Mpv.set_property_osd(name, data) + Mpv.set_property_bool(name, data) + Mpv.set_property_int(name, data) + Mpv.set_property_float(name, data) + Mpv.set_property_node(name, data) + +``Mpv.del_property(self, name)`` + Delete a previously set property. + +``Mpv.get_property(self, property_name, mpv_format)`` + A get property call to a said mpv_format gets the value of the property. + Property getters:: + + Mpv.get_property_string(self, name) + Mpv.get_property_osd(self, name) + Mpv.get_property_bool(self, name) + Mpv.get_property_int(self, name) + Mpv.get_property_float(self, name) + Mpv.get_property_node(self, name) + + +``Mpv.add_binding(self, key=None, name=None, builtin=False, **opts)`` + + key + + name + + builtin + whether to put the binding in the builtin section this means if the user + defines bindings using "{name}", they won't be ignored or overwritten - + instead, they are preferred to the bindings defined with this call + + opts + boolean members (repeatable, complex) + + +Possible event names +-------------------- + +:: + + mpv.MPV_EVENT_NONE + mpv.MPV_EVENT_SHUTDOWN + mpv.MPV_EVENT_LOG_MESSAGE + mpv.MPV_EVENT_GET_PROPERTY_REPLY + mpv.MPV_EVENT_SET_PROPERTY_REPLY + mpv.MPV_EVENT_COMMAND_REPLY + mpv.MPV_EVENT_START_FILE + mpv.MPV_EVENT_END_FILE + mpv.MPV_EVENT_FILE_LOADED + mpv.MPV_EVENT_CLIENT_MESSAGE + mpv.MPV_EVENT_VIDEO_RECONFIG + mpv.MPV_EVENT_AUDIO_RECONFIG + mpv.MPV_EVENT_SEEK + mpv.MPV_EVENT_PLAYBACK_RESTART + mpv.MPV_EVENT_PROPERTY_CHANGE + mpv.MPV_EVENT_QUEUE_OVERFLOW + mpv.MPV_EVENT_HOOK diff --git a/TOOLS/python/README.md b/TOOLS/python/README.md new file mode 100644 index 0000000000000..b9fc0c73a3a4f --- /dev/null +++ b/TOOLS/python/README.md @@ -0,0 +1,23 @@ +mpv python scripts +================== + +The python scripts in this folder are inspired from (/ duplicates of) +`TOOLS/lua/*`. + +The python scripts in this folder can be loaded on a one-time basis by +adding the option + + --script=/path/to/script.py + +to mpv's command line. + +Where appropriate, they may also be placed in ~/.config/mpv/scripts/ from +where they will be automatically loaded when mpv starts. + +This is only a small selection of internally maintained scripts. Some of them +are just for testing mpv internals, or serve as examples. An extensive +user-edited list of 3rd party scripts is available here: + + https://github.com/mpv-player/mpv/wiki/User-Scripts + +(Anyone can add their own scripts to that list.) diff --git a/TOOLS/python/acompressor.py b/TOOLS/python/acompressor.py new file mode 100644 index 0000000000000..6dab890b2f7cc --- /dev/null +++ b/TOOLS/python/acompressor.py @@ -0,0 +1,210 @@ +# This script adds control to the dynamic range compression ffmpeg +# filter including key bindings for adjusting parameters. +# +# See https://ffmpeg.org/ffmpeg-filters.html#acompressor for explanation +# of the parameters. + +import re +from mpvclient import mpv # type: ignore + +o = dict( + default_enable = False, + show_osd = True, + osd_timeout = 4000, + filter_label = mpv.name, + + key_toggle = "n", + key_increase_threshold = "F1", + key_decrease_threshold = "Shift+F1", + key_increase_ratio = "F2", + key_decrease_ratio = "Shift+F2", + key_increase_knee = "F3", + key_decrease_knee = "Shift+F3", + key_increase_makeup = "F4", + key_decrease_makeup = "Shift+F4", + key_increase_attack = "F5", + key_decrease_attack = "Shift+F5", + key_increase_release = "F6", + key_decrease_release = "Shift+F6", + + default_threshold = -25.0, + default_ratio = 3.0, + default_knee = 2.0, + default_makeup = 8.0, + default_attack = 20.0, + default_release = 250.0, + + step_threshold = -2.5, + step_ratio = 1.0, + step_knee = 1.0, + step_makeup = 1.0, + step_attack = 10.0, + step_release = 10.0, +) +mpv.options.read_options(o) + +params = [ + dict(name = "attack", min=0.01, max=2000, hide_default=True, dB="" ), + dict(name = "release", min=0.01, max=9000, hide_default=True, dB="" ), + dict(name = "threshold", min= -30, max= 0, hide_default=False, dB="dB" ), + dict(name = "ratio", min= 1, max= 20, hide_default=False, dB="" ), + dict(name = "knee", min= 1, max= 10, hide_default=True, dB="dB" ), + dict(name = "makeup", min= 0, max= 24, hide_default=False, dB="dB" ), +] + +def parse_value(value): + try: + return float(re.sub("dB$", "", value)) + except ValueError: + return None + + +def format_value(value, decibel): + return f"{value}{decibel}" + + +def show_osd(filter): # noqa: A002 + global o + if not o["show_osd"]: + return + + if not filter["enabled"]: + mpv.commandv("show-text", "Dynamic range compressor: disabled", o["osd_timeout"]) + return + + pretty: str | list = [] + for param in params: + value = parse_value(filter["params"][param["name"]]) + if not (param["hide_default"] and value == o["default_" + param["name"]]): # type: ignore + pretty.append(f"{param["name"].capitalize()}: {value}{param["dB"]}") # type: ignore + + if not pretty: + pretty = "" + else: + pretty = "\n(" + ", ".join(pretty) + ")" # type: ignore + + mpv.commandv("show-text", "Dynamic range compressor: enabled" + pretty, o["osd_timeout"]) + + +def get_filter(): + af = mpv.get_property_node("af") + + for i in range(len(af)): + if af[i]["label"] == o["filter_label"]: + return af, i + + af.append(dict( + name = "acompressor", + label = o["filter_label"], + enabled = False, + params = {}, + )) + + for param in params: + af[len(af) - 1]["params"][param["name"]] = format_value( + o["default_" + param["name"]], param["dB"]) # type: ignore + + return af, len(af) + + +def toggle_acompressor(): + af, i = get_filter() + af[-1]["enabled"] = not af[-1]["enabled"] + mpv.set_property_node("af", af) + show_osd(af[-1]) + + +def update_param(name, increment): + for param in params: + if param["name"] == name.lower(): + af, i = get_filter() + value = parse_value(af[-1]["params"][param["name"]]) + value = max(param["min"], min(value + increment, param["max"])) + af[-1]["params"][param["name"]] = format_value(value, param["dB"]) + af[-1]["enabled"] = True + mpv.set_property_node("af", af) + show_osd(af[-1]) + return + + mpv.error("Unknown parameter '" + name + "'") + + +mpv.add_binding(o["key_toggle"], name="toggle-acompressor")(toggle_acompressor) + + +@mpv.add_binding(o["key_increase_threshold"], + name="acompressor-increase-threshold", repeatable=True) +def increase_threshold(): + update_param("threshold", o["step_threshold"]) + + +@mpv.add_binding(o["key_decrease_threshold"], + name="acompressor-decrease-threshold", repeatable=True) +def decrease_threshold(): + update_param("threshold", -1 * o["step_threshold"]) + + +@mpv.add_binding(o["key_increase_ratio"], + name="acompressor-increase-ratio", repeatable=True) +def increase_ratio(): + update_param("ratio", o["step_ratio"]) + + +@mpv.add_binding(o["key_decrease_ratio"], + name="acompressor-decrease-ratio", repeatable=True) +def decrease_ratio(): + update_param("ratio", -1 * o["step_ratio"]) + + +@mpv.add_binding(o["key_increase_knee"], + name="acompressor-increase-knee", repeatable=True) +def increase_knee(): + update_param("knee", o["step_knee"]) + + +@mpv.add_binding(o["key_decrease_knee"], + name="acompressor-decrease-knee", repeatable=True) +def decrease_knee(): + update_param("knee", -1 * o["step_knee"]) + + +@mpv.add_binding(o["key_increase_makeup"], + name="acompressor-increase-makeup", repeatable=True) +def increase_makeup(): + update_param("makeup", o["step_makeup"]) + + +@mpv.add_binding(o["key_decrease_makeup"], + name="acompressor-decrease-makeup", repeatable=True) +def decrease_makeup(): + update_param("makeup", -1 * o["step_makeup"]) + + +@mpv.add_binding(o["key_increase_attack"], + name="acompressor-increase-attack", repeatable=True) +def increase_attack(): + update_param("attack", o["step_attack"]) + + +@mpv.add_binding(o["key_decrease_attack"], + name="acompressor-decrease-attack", repeatable=True) +def decrease_attack(): + update_param("attack", -1 * o["step_attack"]) + + +@mpv.add_binding(o["key_increase_release"], + name="acompressor-increase-release", repeatable=True) +def increase_release(): + update_param("release", o["step_release"]) + + +@mpv.add_binding(o["key_decrease_release"], + name="acompressor-decrease-release", repeatable=True) +def decrease_release(): + update_param("release", -1 * o["step_release"]) + + +if o["default_enable"]: + af, i = get_filter() + af[-1]["enabled"] = True + mpv.set_property_node("af", af) diff --git a/TOOLS/python/audio-hotplug-test.py b/TOOLS/python/audio-hotplug-test.py new file mode 100644 index 0000000000000..e8104fa5e3c3e --- /dev/null +++ b/TOOLS/python/audio-hotplug-test.py @@ -0,0 +1,8 @@ +from mpvclient import mpv # type: ignore + + +@mpv.observe_property("audio-device-list", mpv.MPV_FORMAT_NODE) +def on_audio_device_list_change(data): + mpv.info("Audio device list changed:") + for d in data: + mpv.info(" - '" + d["name"] + "' (" + d["description"] + ")") diff --git a/TOOLS/python/autocrop.py b/TOOLS/python/autocrop.py new file mode 100644 index 0000000000000..b43dfa23eccba --- /dev/null +++ b/TOOLS/python/autocrop.py @@ -0,0 +1,248 @@ +""" +This script uses the lavfi cropdetect filter and the video-crop property to +automatically crop the currently playing video with appropriate parameters. + +It automatically crops the video when playback starts. + +You can also manually crop the video by pressing the "C" (shift+c) key. +Pressing it again undoes the crop. + +The workflow is as follows: First, it inserts the cropdetect filter. After + (default is 1) seconds, it then sets video-crop based on the +vf-metadata values gathered by cropdetect. The cropdetect filter is removed +after video-crop is set as it is no longer needed. + +Since the crop parameters are determined from the 1 second of video between +inserting the cropdetect filter and setting video-crop, the "C" key should be +pressed at a position in the video where the crop region is unambiguous (i.e., +not a black frame, black background title card, or dark scene). + +If non-copy-back hardware decoding is in use, hwdec is temporarily disabled for +the duration of cropdetect as the filter would fail otherwise. + +These are the default options. They can be overridden by adding +script-opts-append=autocrop-= to mpv.conf. +""" +from mpvclient import mpv # type: ignore + +options = dict( + # Whether to automatically apply crop at the start of playback. If you + # don't want to crop automatically, add + # script-opts-append=autocrop-auto=no to mpv.conf. + auto = True, + # Delay before starting crop in auto mode. You can try to increase this + # value to avoid dark scenes or fade ins at beginning. Automatic cropping + # will not occur if the value is larger than the remaining playback time. + auto_delay = 4, + # Black threshold for cropdetect. Smaller values will generally result in + # less cropping. See limit of + # https://ffmpeg.org/ffmpeg-filters.html#cropdetect + detect_limit = "24/255", + # The value which the width/height should be divisible by. Smaller + # values have better detection accuracy. If you have problems with + # other filters, you can try to set it to 4 or 16. See round of + # https://ffmpeg.org/ffmpeg-filters.html#cropdetect + detect_round = 2, + # The ratio of the minimum clip size to the original. A number from 0 to + # 1. If the picture is over cropped, try adjusting this value. + detect_min_ratio = 0.5, + # How long to gather cropdetect data. Increasing this may be desirable to + # allow cropdetect more time to collect data. + detect_seconds = 1, + # Whether the OSD shouldn't be used when cropdetect and video-crop are + # applied and removed. + suppress_osd = False, +) + +mpv.options.read_options(options) + +cropdetect_label = mpv.name + "-cropdetect" + +hwdec_backup = None + +command_prefix = options["suppress_osd"] and "no-osd" or "" + + +def is_enough_time(seconds): + # Plus 1 second for deviation. + time_needed = seconds + 1 + playtime_remaining = mpv.get_property_node("playtime-remaining") + return playtime_remaining and time_needed < playtime_remaining + + +def is_cropable(time_needed): + if mpv.get_property_node("current-tracks/video/image"): + mpv.warn("autocrop only works for videos.") + return False + + if not is_enough_time(time_needed): + mpv.warn("Not enough time to detect crop.") + return False + + return True + + +def remove_cropdetect(): + for filter in mpv.get_property_node("vf"): # noqa: A001 + if filter["label"] == cropdetect_label: + mpv.command_string(f"{command_prefix} vf remove @{filter["label"]}") + return + + +def restore_hwdec(): + global hwdec_backup + if hwdec_backup: + mpv.set_property_string("hwdec", hwdec_backup) + hwdec_backup = None + + +def cleanup(data=None): + remove_cropdetect() + # Kill all timers. + mpv.clear_timers() + + restore_hwdec() + + +def apply_crop(meta): + # Verify if it is necessary to crop. + is_effective = meta["w"] and meta["h"] and meta["x"] and meta["y"] and \ + (meta["x"] > 0 or meta["y"] > 0 + or meta["w"] < meta["max_w"] or meta["h"] < meta["max_h"]) + + # Verify it is not over cropped. + is_excessive = False + if is_effective and (meta["w"] < meta["min_w"] or meta["h"] < meta["min_h"]): + mpv.info("The area to be cropped is too large.") + mpv.info("You might need to decrease detect_min_ratio.") + is_excessive = True + + if not is_effective or is_excessive: + # Clear any existing crop. + mpv.command_string(f"{command_prefix} set file-local-options/video-crop ''") + return + + # Apply crop. + mpv.command_string("{} set file-local-options/video-crop {}x{}+{}+{}".format( + command_prefix, meta["w"], meta["h"], meta["x"], meta["y"])) + + +def detect_end(): + # Get the metadata and remove the cropdetect filter. + cropdetect_metadata = mpv.get_property_node("vf-metadata/" + cropdetect_label) + remove_cropdetect() + + # Remove the timer of detect_crop. + mpv.clear_timer("detect_crop") + + restore_hwdec() + + # Verify the existence of metadata. + if cropdetect_metadata: + meta = dict( + w = cropdetect_metadata["lavfi.cropdetect.w"], + h = cropdetect_metadata["lavfi.cropdetect.h"], + x = cropdetect_metadata["lavfi.cropdetect.x"], + y = cropdetect_metadata["lavfi.cropdetect.y"], + ) + else: + mpv.error("No crop data.") + mpv.info("Was the cropdetect filter successfully inserted?") + mpv.info("Does your version of FFmpeg support AVFrame metadata?") + return + + # Verify that the metadata meets the requirements and convert it. + if meta["w"] and meta["h"] and meta["x"] and meta["y"]: + width = mpv.get_property_node("width") + height = mpv.get_property_node("height") + + meta = dict( + w = int(meta["w"]), + h = int(meta["h"]), + x = int(meta["x"]), + y = int(meta["y"]), + min_w = width * options["detect_min_ratio"], + min_h = height * options["detect_min_ratio"], + max_w = width, + max_h = height, + ) + else: + mpv.error("Got empty crop data.") + mpv.info("You might need to increase detect_seconds.") + + apply_crop(meta) + + +def detect_crop(): + time_needed = options["detect_seconds"] + + if not is_cropable(time_needed): + return + + hwdec_current = mpv.get_property_string("hwdec-current") + + if not hwdec_current.endswith("-copy") and hwdec_current not in ["no", "crystalhd", "rkmpp"]: + mpv.set_property_string("hwdec", "no") + + mpv.command_string(f"{command_prefix} vf pre @{cropdetect_label}:" + "cropdetect=limit={limit}:round={round}:reset=0") + + # Wait to gather data. + mpv.add_timeout(time_needed, detect_end, name="detect_crop") + + +def on_start(data): + + # Clean up at the beginning. + cleanup() + + # If auto is not true, exit. + if not options["auto"]: + return + + # If it is the beginning, wait for detect_crop + # after auto_delay seconds, otherwise immediately. + playback_time = mpv.get_property_node("playback-time") + is_delay_needed = playback_time and options["auto_delay"] > playback_time + + if is_delay_needed: + + # Verify if there is enough time for autocrop. + time_needed = options["auto_delay"] + options["detect_seconds"] # type: ignore + + if not is_cropable(time_needed): + return + + def auto_delay(): + detect_crop() + + mpv.clear_timer("auto_delay") + + mpv.add_timeout(time_needed, auto_delay, name="auto_delay") + else: + detect_crop() + + +@mpv.add_binding(key="C", name="toggle_crop") +def on_toggle(): + + # If it is during auto_delay, kill the timer. + if "auto_delay" in mpv.timers: + mpv.clear_timer("auto_delay") + + # Cropped => Remove it. + if mpv.get_property_string("video-crop") != "": + mpv.command_string(f"{command_prefix} set file-local-options/video-crop ''") + return + + # Detecting => Leave it. + if "detect_crop" in mpv.timers: + mpv.warn("Already cropdetecting!") + return + + # Neither => Detect crop. + detect_crop() + + +mpv.register_event(mpv.MPV_EVENT_END_FILE)(cleanup) +mpv.register_event(mpv.MPV_EVENT_FILE_LOADED)(on_start) diff --git a/TOOLS/python/autodeint.py b/TOOLS/python/autodeint.py new file mode 100644 index 0000000000000..905d0431a2912 --- /dev/null +++ b/TOOLS/python/autodeint.py @@ -0,0 +1,151 @@ +# This script uses the lavfi idet filter to automatically insert the +# appropriate deinterlacing filter based on a short section of the +# currently playing video. +# +# It registers the key-binding ctrl+d, which when pressed, inserts the filters +# ``vf=idet,lavfi-pullup,idet``. After 4 seconds, it removes these +# filters and decides whether the content is progressive, interlaced, or +# telecined and the interlacing field dominance. +# +# Based on this information, it may set mpv's ``deinterlace`` property (which +# usually inserts the bwdif filter), or insert the ``pullup`` filter if the +# content is telecined. It also sets field dominance with lavfi setfield. +# +# OPTIONS: +# The default detection time may be overridden by adding +# +# --script-opts=autodeint.detect_seconds= +# +# to mpv's arguments. This may be desirable to allow idet more +# time to collect data. +# +# To see counts of the various types of frames for each detection phase, +# the verbosity can be increased with +# +# --msg-level=autodeint=v + +from mpvclient import mpv # type: ignore + +script_name = mpv.name +detect_label = f"{script_name}-detect" +pullup_label = script_name +dominance_label = f"{script_name}-dominance" +ivtc_detect_label = f"{script_name}-ivtc-detect" + +progressive, interlaced_tff, interlaced_bff, interlaced = 0, 1, 2, 3 + +# number of seconds to gather cropdetect data +try: + detect_seconds = float(mpv.get_opt(f"{script_name}.detect_seconds", 4)) +except ValueError: + detect_seconds = 4 + + +def del_filter_if_present(label): + # necessary because mp.command('vf del @label:filter') raises an + # error if the filter doesn't exist + vfs = mpv.get_property_node("vf") + + for i, vf in enumerate(vfs): + if vf["label"] == label: + vfs.pop(i) + mpv.set_property_node("vf", vfs) + return True + return False + + +def add_vf(label, filter): # noqa: A002 + return mpv.command_string(f"vf add @{label}:{filter}") + + +def stop_detect(): + del_filter_if_present(detect_label) + del_filter_if_present(ivtc_detect_label) + + +def judge(label): + # get the metadata + result = mpv.get_property_node(f"vf-metadata/{label}") + num_tff = float(result["lavfi.idet.multiple.tff"]) + num_bff = float(result["lavfi.idet.multiple.bff"]) + num_progressive = float(result["lavfi.idet.multiple.progressive"]) + num_undetermined = float(result["lavfi.idet.multiple.undetermined"]) + num_interlaced = num_tff + num_bff + num_determined = num_interlaced + num_progressive + + mpv.info(label + " progressive = " + str(num_progressive)) + mpv.info(label + " interlaced-tff = " + str(num_tff)) + mpv.info(label + " interlaced-bff = " + str(num_bff)) + mpv.info(label + " undetermined = " + str(num_undetermined)) + + if num_determined < num_undetermined: + mpv.warn("majority undetermined frames") + + if num_progressive > 20*num_interlaced: + return progressive + elif num_tff > 10*num_bff: + return interlaced_tff + elif num_bff > 10*num_tff: + return interlaced_bff + else: + return interlaced + + +def select_filter(): + # handle the first detection filter results + verdict = judge(detect_label) + ivtc_verdict = judge(ivtc_detect_label) + dominance = "auto" + if verdict == progressive: + mpv.info("progressive: doing nothing") + stop_detect() + del_filter_if_present(dominance_label) + del_filter_if_present(pullup_label) + return + else: + if verdict == interlaced_tff: + dominance = "tff" + add_vf(dominance_label, "setfield=mode=" + dominance) + elif verdict == interlaced_bff: + dominance = "bff" + add_vf(dominance_label, "setfield=mode=" + dominance) + else: + del_filter_if_present(dominance_label) + + # handle the ivtc detection filter results + if ivtc_verdict == progressive: + mpv.info(f"telecined with {dominance} field dominance: using pullup") + stop_detect() + else: + mpv.info("interlaced with " + dominance + + " field dominance: setting deinterlace property") + del_filter_if_present(pullup_label) + mpv.set_property_string("deinterlace","yes") + stop_detect() + + +@mpv.add_binding("ctrl+d") +def start_detect(): + # exit if detection is already in progress + if "select_filter" in mpv.timers: + mpv.warn("already detecting!") + return + + mpv.set_property_string("deinterlace", "no") + del_filter_if_present(pullup_label) + del_filter_if_present(dominance_label) + + # insert the detection filters + if not (add_vf(detect_label, "idet") and + add_vf(dominance_label, "setfield=mode=auto") and + add_vf(pullup_label, "lavfi-pullup") and + add_vf(ivtc_detect_label, "idet")): + mpv.error("failed to insert detection filters") + return + + def wrap_select_filter(): + select_filter() + mpv.clear_timer("select_filter") + + # wait to gather data + mpv.add_timeout(detect_seconds, wrap_select_filter, name="select_filter") diff --git a/TOOLS/python/command-test.py b/TOOLS/python/command-test.py new file mode 100644 index 0000000000000..cad9213ac8c2b --- /dev/null +++ b/TOOLS/python/command-test.py @@ -0,0 +1,115 @@ +# Test script for some command API details. + +from mpvclient import mpv # type: ignore + + +@mpv.observe_property("vo-configured", mpv.MPV_FORMAT_FLAG) +def vo_configured(v): + if not v: + return + + mpv.info("async expand-text") + @mpv.command_node_async_callback(["expand-text", "hello ${path}!"]) + def expand_text(success, data, error): + mpv.info(f"done async expand-text: {success} {data} {error}") + + # make screenshot writing very slow + mpv.set_property_string("screenshot-format", "png") + mpv.set_property_string("screenshot-png-compression", "9") + + mpv.info("Slow screenshot command...") + result = mpv.command(["screenshot"]) + mpv.info(f"done, res: {result}") + + mpv.info("Slow screenshot async command...") + @mpv.command_node_async_callback(["screenshot"]) + def screenshot(res, result, error): + mpv.info(f"done (async), res: {res}") + + mpv.info("Broken screenshot async command...") + @mpv.command_node_async_callback(["screenshot-to-file", "/nonexistent/bogus.png"]) + def screenshot_to_file(res, val, err): + mpv.info(f"done err scr: {res} {val} {err}") + + @mpv.command_node_async_callback({"name": "subprocess", + "args": ["sh", "-c", "echo hi && sleep 10s"], + "capture_stdout": True}) + def sub1(res, val, err): + mpv.info(f"done subprocess: {res} {val} {err}") + + def sub2(res, val, err): + mpv.info(f"done sleep inf subprocess: {res} {val} {err}") + + x = mpv.command_node_async_callback( + {"name": "subprocess", "args": ["sleep", "inf"]})(sub2) + + def abort_sleep_sub(): + mpv.info("aborting sleep inf subprocess after timeout") + mpv.abort_async_command(x) + + mpv.add_timeout(15, abort_sleep_sub) + + def subadd(res, val, err): + mpv.info(f"done sub-add stdin: {res} {val} {err}") + + # (assuming this "freezes") + y = mpv.command_node_async_callback({"name": "sub-add", "url": "-"})(subadd) + + def abort_subadd(): + mpv.info("aborting sub-add stdin after timeout") + mpv.abort_async_command(y) + + mpv.add_timeout(20, abort_subadd) + + @mpv.command_node_async_callback({"name": "subprocess", "args": ["wc", "-c"], + "stdin_data": "hello", "capture_stdout": True}) + def wcc(res, val, err): + mpv.info(f"Should be '5': {val["stdout"]}") + + # blocking stdin by default + @mpv.command_node_async_callback({"name": "subprocess", "args": ["cat"], + "capture_stdout": True}) + def cat(_, val, err): + mpv.info(f"Should be 0: {val}") # + str(len(val["stdout"]))) + # mpv.info("Should be 0: " + str(len(val["stdout"]))) + + # stdin + detached + @mpv.command_node_async_callback({"name": "subprocess", + "args": ["bash", "-c", "(sleep 5s ; cat)"], + "stdin_data": "this should appear after 5s.\n", + "detach": True}) + def sleepycat(_, val, err): + mpv.info(f"5s test: {val["status"]}") + + # This should get killed on script exit. + @mpv.command_node_async_callback({"name": "subprocess", "playback_only": False, + "args": ["sleep", "inf"]}) + def sleepinf(_, val, err): + pass + + # Runs detached; should be killed on player exit (forces timeout) + mpv.command({"_flags": ["async"], "name": "subprocess", + "playback_only": False, "args": ["sleep", "inf"]}) + + +counter = 0 + + +def freeze_test(playback_only): + # This "freezes" the script, should be killed via timeout. + global counter + counter = counter and counter + 1 or 0 + mpv.info("freeze! " + str(counter)) + x = mpv.command({"name": "subprocess", "playback_only": playback_only, + "args": ["sleep", "inf"]}) + mpv.info(f"done, killed={x["killed_by_us"]}\n") + + +@mpv.register_event(mpv.MPV_EVENT_SHUTDOWN) +def ft(event): + freeze_test(False) + + +@mpv.observe_property("idle-active", mpv.MPV_FORMAT_NODE) +def ia(data): + freeze_test(True) diff --git a/TOOLS/python/cycle-deinterlace-pullup.py b/TOOLS/python/cycle-deinterlace-pullup.py new file mode 100644 index 0000000000000..239df49fae372 --- /dev/null +++ b/TOOLS/python/cycle-deinterlace-pullup.py @@ -0,0 +1,52 @@ +# This script cycles between deinterlacing, pullup (inverse +# telecine), and both filters off. It uses the "deinterlace" property +# so that a hardware deinterlacer will be used if available. +# +# It overrides the default deinterlace toggle keybinding "D" +# (shift+d), so that rather than merely cycling the "deinterlace" property +# between on and off, it adds a "pullup" step to the cycle. +# +# It provides OSD feedback as to the actual state of the two filters +# after each cycle step/keypress. +# +# Note: if hardware decoding is enabled, pullup filter will likely +# fail to insert. +# +# TODO: It might make sense to use hardware assisted vdpaupp=pullup, +# if available, but I don't have hardware to test it. Patch welcome. + +from mpvclient import mpv # type: ignore + +script_name = mpv.name +pullup_label = f"{script_name}-pullup" + + +def pullup_on(): + for vf in mpv.get_property_node("vf"): + if vf["label"] == pullup_label: + return "yes" + return "no" + + +def do_cycle(): + if pullup_on() == "yes": + # if pullup is on remove it + mpv.command_string(f"vf remove @{pullup_label}:pullup") + return + elif mpv.get_property_string("deinterlace") == "yes": + # if deinterlace is on, turn it off and insert pullup filter + mpv.set_property_string("deinterlace", "no") + mpv.command_string(f"vf add @{pullup_label}:pullup") + return + else: + # if neither is on, turn on deinterlace + mpv.set_property_string("deinterlace", "yes") + return + + +@mpv.add_binding(key="D", name="cycle-deinterlace-pullup") +def cycle_deinterlace_pullup_handler(): + do_cycle() + # independently determine current state and give user feedback + mpv.osd_message("deinterlace: {}\npullup: {}".format( + mpv.get_property_string("deinterlace"), pullup_on())) diff --git a/TOOLS/python/gamma-auto.py b/TOOLS/python/gamma-auto.py new file mode 100644 index 0000000000000..dde1396c0cec6 --- /dev/null +++ b/TOOLS/python/gamma-auto.py @@ -0,0 +1,23 @@ +import math +from mpvclient import mpv # type: ignore + + +def lux_to_gamma(lmin, lmax, rmin, rmax, lux): + if lmax <= lmin or lux == 0: + return 1 + + num = (rmax - rmin) * (math.log(lux, 10) - math.log(lmin, 10)) + den = math.log(lmax, 10) - math.log(lmin, 10) + result = num / den + rmin + + # clamp the result + _max = max(rmax, rmin) + _min = min(rmax, rmin) + + return max(min(result, _max), _min) + + +@mpv.observe_property("ambient-light", mpv.MPV_FORMAT_DOUBLE) +def lux_changed(lux): + gamma = lux_to_gamma(16.0, 256.0, 1.0, 1.2, lux or 0) + mpv.set_property_float("gamma-factor", gamma) diff --git a/TOOLS/python/observe-all.py b/TOOLS/python/observe-all.py new file mode 100644 index 0000000000000..2c939d3732700 --- /dev/null +++ b/TOOLS/python/observe-all.py @@ -0,0 +1,28 @@ +# Test script for property change notification mechanism. +# Note that watching/reading some properties can be very expensive, or +# require the player to synchronously wait on network (when playing +# remote files), so you should in general only watch properties you +# are interested in. + +from mpvclient import mpv #type: ignore + + +def observe(name): + @mpv.observe_property(name, mpv.MPV_FORMAT_NODE) + def func(val): + mpv.info("property '" + name + "' changed to '" + + str(val) + "'") + + +for name in mpv.get_property_node("property-list"): + observe(name) + # if name not in ["osd-sym-cc", "osd-ass-cc", "term-clip-cc", + # "screenshot-template" # fails with: *** %n in writable segment detected *** + # ]: + # observe(name) + + +for name in mpv.get_property_node("options"): + observe(name) + # if name not in ["screenshot-template"]: + # observe("options/" + name) diff --git a/TOOLS/python/ontop-playback.py b/TOOLS/python/ontop-playback.py new file mode 100644 index 0000000000000..8c56329659b56 --- /dev/null +++ b/TOOLS/python/ontop-playback.py @@ -0,0 +1,21 @@ +# makes mpv disable ontop when pausing and re-enable it again when resuming playback +# please note that this won't do anything if ontop was not enabled before pausing + +from mpvclient import mpv # type: ignore + +was_ontop = False + + +@mpv.observe_property("pause", mpv.MPV_FORMAT_FLAG) +def func(value): + ontop = mpv.get_property_node("ontop") + global was_ontop + if value: + if ontop: + mpv.set_property_node("ontop", False) + was_ontop = True + else: + if was_ontop and not ontop: + mpv.set_property_node("ontop", True) + + was_ontop = False diff --git a/TOOLS/python/osd-test.py b/TOOLS/python/osd-test.py new file mode 100644 index 0000000000000..65af82d2be084 --- /dev/null +++ b/TOOLS/python/osd-test.py @@ -0,0 +1,35 @@ +from mpvclient import mpv # type: ignore + +things = [] +for _ in range(2): + things.append({ + "osd1": mpv.create_osd_overlay("ass-events"), + "osd2": mpv.create_osd_overlay("ass-events"), + }) + +things[0]["text"] = "{\\an5}hello\\Nworld" +things[1]["text"] = "{\\pos(400, 200)}something something" + + +def the_do_hickky_thing(): + for i, thing in enumerate(things): + thing["osd1"].data = thing["text"] + thing["osd1"].compute_bounds = True + # thing.osd1.hidden = true + res = thing["osd1"].update() + + thing["osd2"].hidden = True + if res is not None and res["x0"] is not None: + draw = mpv.ass_new() + draw.append("{\\alpha&H80}") + draw.draw_start() + draw.pos(0, 0) + draw.rect_cw(res["x0"], res["y0"], res["x1"], res["y1"]) + draw.draw_stop() + thing["osd2"].hidden = False + thing["osd2"].data = draw.text + + thing["osd2"].update() + + +mpv.add_periodic_timer(2, the_do_hickky_thing, name="the_do_hickky_thing") diff --git a/TOOLS/python/pause-when-minimize.py b/TOOLS/python/pause-when-minimize.py new file mode 100644 index 0000000000000..8a251d4d59a22 --- /dev/null +++ b/TOOLS/python/pause-when-minimize.py @@ -0,0 +1,20 @@ +# This script pauses playback when minimizing the window, and resumes playback +# if it's brought back again. If the player was already paused when minimizing, +# then try not to mess with the pause state. +from mpvclient import mpv # type: ignore + +did_pause_at_minimize = False + +@mpv.observe_property("window-minimized", mpv.MPV_FORMAT_NODE) +def on_window_minimized(value): + pause = mpv.get_property_bool("pause") + global did_pause_at_minimize + + if value: + if not pause: + mpv.set_property_bool("pause", True) + did_pause_at_minimize = True + else: + if did_pause_at_minimize and pause: + mpv.set_property_bool("pause", False) + did_pause_at_minimize = False # Reset to False for probable next cycle diff --git a/TOOLS/python/status-line.py b/TOOLS/python/status-line.py new file mode 100644 index 0000000000000..83b41090f33f6 --- /dev/null +++ b/TOOLS/python/status-line.py @@ -0,0 +1,93 @@ +# Rebuild the terminal status line as a python script +# Be aware that this will require more cpu power! +# Also, this is based on a rather old version of the +# builtin mpv status line. + +from mpvclient import mpv # type: ignore + + +new_status = "" + +# Add a string to the status line +def atsl(s): + global new_status + new_status += s + + +def update_status_line(): + # Reset the status line + global new_status + new_status = "" + + if mpv.get_property_bool("pause"): + atsl("(Paused) ") + elif mpv.get_property_bool("paused-for-cache"): + atsl("(Buffering) ") + + if mpv.get_property_string("aid") != "no": + atsl("A") + + if mpv.get_property_string("vid") != "no": + atsl("V") + + atsl(": ") + + atsl(mpv.get_property_osd("time-pos")) + + atsl(" / ") + atsl(mpv.get_property_osd("duration")) + + atsl(" (") + atsl(mpv.get_property_osd("percent-pos")) + atsl("%)") + + r = mpv.get_property_float("speed") + if r != 1: + atsl(f" x{r:4.2f}") + + r = mpv.get_property_float("avsync") + if r is not None: + atsl(f" A-V: {r}") + + r = mpv.get_property_float("total-avsync-change") + if abs(r) > 0.05: + atsl(f" ct:{r:7.3f}") + + r = mpv.get_property_float("decoder-drop-frame-count") + if r is not None and r > 0: + atsl(" Late: ") + atsl(str(r)) + + r = mpv.get_property_osd("video-bitrate") + if r is not None and r != "": + atsl(" Vb: ") + atsl(r) + + r = mpv.get_property_osd("audio-bitrate") + if r is not None and r != "": + atsl(" Ab: ") + atsl(r) + + r = mpv.get_property_int("cache") + if r is not None and r > 0: + atsl(f" Cache: {r}%% ") + + # Set the new status line + mpv.set_property_string("options/term-status-msg", new_status) + +mpv.add_periodic_timer(1, update_status_line, name="usl") + + +@mpv.observe_property("pause", mpv.MPV_FORMAT_FLAG) +def on_pause_change(value): + if value: + mpv.disable_timer("usl") + else: + mpv.add_periodic_timer(1, update_status_line, name="usl") + mpv.add_timeout(0.1, update_status_line) + + +def wrap_usl(event): + update_status_line() + +mpv.register_event(mpv.MPV_EVENT_SEEK)(wrap_usl) diff --git a/TOOLS/python/test-hooks.py b/TOOLS/python/test-hooks.py new file mode 100644 index 0000000000000..dce4dfbfc974a --- /dev/null +++ b/TOOLS/python/test-hooks.py @@ -0,0 +1,52 @@ +import time +from mpvclient import mpv # type: ignore + +def hardsleep(): + # os.execute("sleep 1s") + time.sleep(1) + + +hooks = ["on_before_start_file", "on_load", "on_load_fail", + "on_preloaded", "on_unload", "on_after_end_file"] + + +def add_hook(name, priority): + @mpv.add_hook(name, priority) + def func(): + print("--- hook: " + name) + hardsleep() + print(" ... continue") + + +for name in hooks: + add_hook(name, 0) + +events = [ + mpv.MPV_EVENT_START_FILE, + mpv.MPV_EVENT_END_FILE, + mpv.MPV_EVENT_FILE_LOADED, + mpv.MPV_EVENT_SEEK, + mpv.MPV_EVENT_PLAYBACK_RESTART, + mpv.MPV_EVENT_SHUTDOWN, +] + + +def register_to_event(name): + @mpv.register_event(name) + def func(event): + print(f"--- event: {name}") + + +for name in events: + register_to_event(name) + + +def observe(name): + @mpv.observe_property(name, mpv.MPV_FORMAT_NODE) + def func(value): + print(f"property '{name}' change to '{value}'") + + +props = ["path", "metadata"] +for name in props: + observe(name) diff --git a/meson.build b/meson.build index 5ffe5597688f2..eee18f5a546f0 100644 --- a/meson.build +++ b/meson.build @@ -666,6 +666,15 @@ if features['javascript'] 'sub/filter_jsre.c') endif + +python3 = dependency('python3-embed', version: '>= 3.12', required: get_option('python')) +features += {'python': python3.found()} +if features['python'] + dependencies += python3 + sources += files('player/python.c', 'player/py_extend.c') + subdir(join_paths('player', 'python')) +endif + lcms2 = dependency('lcms2', version: '>= 2.6', required: get_option('lcms2')) features += {'lcms2': lcms2.found()} if features['lcms2'] @@ -1739,6 +1748,11 @@ conf_data = configuration_data() conf_data.set_quoted('CONFIGURATION', meson.build_options()) conf_data.set_quoted('DEFAULT_OPTICAL_DEVICE', optical_device) +if get_option('buildtype') == 'debug' +conf_data.set('Py_DEBUG', 1) +conf_data.set('Py_REF_DEBUG', 1) +endif + # Loop over all features in the build, create a define and add them to config.h feature_keys = [] foreach feature, allowed: features @@ -1911,6 +1925,7 @@ summary({'cocoa': features['cocoa'] and features['swift'], 'javascript': features['javascript'], 'libmpv': get_option('libmpv'), 'lua': features['lua'], + 'python': features['python'], 'opengl': features['gl'], 'vulkan': features['vulkan'], 'wayland': features['wayland'], diff --git a/meson.options b/meson.options index 836d16d03fcc4..dcd128e7f0bab 100644 --- a/meson.options +++ b/meson.options @@ -15,6 +15,7 @@ option('dvdnav', type: 'feature', value: 'auto', description: 'dvdnav support') option('iconv', type: 'feature', value: 'auto', description: 'iconv') option('javascript', type: 'feature', value: 'auto', description: 'Javascript (MuJS backend)') option('jpeg', type: 'feature', value: 'auto', description: 'libjpeg image writer') +option('python', type: 'feature', value: 'auto', description: 'Python') option('lcms2', type: 'feature', value: 'auto', description: 'LCMS2 support') option('libarchive', type: 'feature', value: 'auto', description: 'libarchive wrapper for reading zip files and more') option('libavdevice', type: 'feature', value: 'auto', description: 'libavdevice') diff --git a/options/options.c b/options/options.c index c5771bd466cdd..fb349c1923041 100644 --- a/options/options.c +++ b/options/options.c @@ -551,7 +551,7 @@ static const m_option_t mp_opts[] = { .flags = M_OPT_NOCFG | M_OPT_PRE_PARSE | M_OPT_FILE}, {"reset-on-next-file", OPT_STRINGLIST(reset_options)}, -#if HAVE_LUA || HAVE_JAVASCRIPT || HAVE_CPLUGINS +#if HAVE_LUA || HAVE_JAVASCRIPT || HAVE_PYTHON || HAVE_CPLUGINS {"scripts", OPT_PATHLIST(script_files), .flags = M_OPT_FILE}, {"script", OPT_CLI_ALIAS("scripts-append")}, {"script-opts", OPT_KEYVALUELIST(script_opts)}, @@ -561,6 +561,9 @@ static const m_option_t mp_opts[] = { #if HAVE_JAVASCRIPT {"js-memory-report", OPT_BOOL(js_memory_report)}, #endif +#if HAVE_PYTHON + {"enable-python", OPT_BOOL(enable_python)}, +#endif #if HAVE_LUA {"osc", OPT_BOOL(lua_load_osc), .flags = UPDATE_BUILTIN_SCRIPTS}, {"ytdl", OPT_BOOL(lua_load_ytdl), .flags = UPDATE_BUILTIN_SCRIPTS}, @@ -1004,6 +1007,9 @@ static const struct MPOpts mp_default_opts = { .osd_level = 1, .osd_on_seek = 1, .osd_duration = 1000, +#if HAVE_PYTHON + .enable_python = false, +#endif #if HAVE_LUA .lua_load_osc = true, .lua_load_ytdl = true, diff --git a/options/options.h b/options/options.h index a8f82a98103e0..d2f59170ee921 100644 --- a/options/options.h +++ b/options/options.h @@ -181,6 +181,7 @@ typedef struct MPOpts { char **script_files; char **script_opts; bool js_memory_report; + bool enable_python; bool lua_load_osc; bool lua_load_ytdl; char *lua_ytdl_format; diff --git a/player/main.c b/player/main.c index 37eb6b18f5c2b..640920120d4aa 100644 --- a/player/main.c +++ b/player/main.c @@ -81,6 +81,12 @@ static const char def_config[] = #include "osdep/w32_register.h" #endif +#if HAVE_PYTHON +#define PY_SSIZE_T_CLEAN +#include +#include "py_extend.h" +#endif + #if HAVE_COCOA #include "osdep/mac/app_bridge.h" #endif @@ -206,6 +212,27 @@ void mp_destroy(struct MPContext *mpctx) uninit_libav(mpctx->global); +#if HAVE_PYTHON + // Do NOT call Py_FinalizeEx() here. + // + // Subinterpreters use OWN_GIL + use_main_obmalloc=0, giving each script + // its own isolated pymalloc arena. Py_EndInterpreter() (called per + // script in end_interpreter()) frees that arena. However, Py_AtExit() + // handlers registered by C extension modules (e.g. _ssl via + // requests/urllib3) are invoked by Py_FinalizeEx() in the main + // interpreter's context. If such a handler holds a reference to a + // Python object that lived in the now-freed subinterpreter arena and + // tries to Py_XDECREF / PyObject_Free it through the main allocator, + // glibc reports "free(): invalid pointer" and aborts. + // + // Skipping Py_FinalizeEx() is safe here: all script subinterpreters are + // already finalized by the time we reach this point, and the OS reclaims + // all remaining memory on process exit. + // if (mpctx->opts->enable_python) { + // if (Py_IsInitialized()) Py_FinalizeEx(); + // } +#endif + mp_msg_uninit(mpctx->global); mp_assert(!mpctx->num_abort_list); talloc_free(mpctx->abort_list); @@ -379,6 +406,12 @@ int mp_initialize(struct MPContext *mpctx, char **options) mp_input_load_config(mpctx->input); +#if HAVE_PYTHON + if (mpctx->opts->enable_python) { + if (PyImport_AppendInittab("mpv", PyInit_mpv) != -1) Py_Initialize(); + } +#endif + // From this point on, all mpctx members are initialized. mpctx->initialized = true; mpctx->mconfig->option_change_callback = mp_option_change_callback; diff --git a/player/py_extend.c b/player/py_extend.c new file mode 100644 index 0000000000000..4271efe1fc875 --- /dev/null +++ b/player/py_extend.c @@ -0,0 +1,1051 @@ +/* + * This file is part of mpv. + * + * mpv is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * mpv is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with mpv. If not, see . + */ + +#define PY_SSIZE_T_CLEAN +#include +#define PY_MPV_EXTENSION_MODULE +#include "py_extend.h" + +#include "core.h" +#include "client.h" + +#include "common/msg_control.h" +#include "input/input.h" +#include "options/path.h" +#include "ta/ta_talloc.h" + + +static void * +fmt_talloc(void *ta_ctx, mpv_format format) +{ + switch (format) { + case MPV_FORMAT_STRING: + case MPV_FORMAT_OSD_STRING: + return talloc(ta_ctx, char *); + case MPV_FORMAT_FLAG: + return talloc(ta_ctx, int); + case MPV_FORMAT_INT64: + return talloc(ta_ctx, int64_t); + case MPV_FORMAT_DOUBLE: + return talloc(ta_ctx, double); + case MPV_FORMAT_NODE: + return talloc(ta_ctx, struct mpv_node); + default: + // TODO: raise Exception + return NULL; + } +} + + +static void makenode(void *ta_ctx, PyObject *obj, mpv_node *node) { + if (obj == Py_None) { + node->format = MPV_FORMAT_NONE; + } + else if (PyBool_Check(obj)) { + node->format = MPV_FORMAT_FLAG; + node->u.flag = !!PyObject_IsTrue(obj); + } + else if (PyFloat_Check(obj)) { + node->format = MPV_FORMAT_DOUBLE; + node->u.double_ = PyFloat_AsDouble(obj); + } + else if (PyLong_Check(obj)) { + node->format = MPV_FORMAT_INT64; + node->u.int64 = (int64_t) PyLong_AsLongLong(obj); + } + else if (PyUnicode_Check(obj)) { + node->format = MPV_FORMAT_STRING; + node->u.string = talloc_strdup(ta_ctx, (char *)PyUnicode_AsUTF8(obj)); + } + else if (PyBytes_Check(obj)) { + node->format = MPV_FORMAT_STRING; + node->u.string = talloc_strdup(ta_ctx, (char *)PyBytes_AsString(obj)); + } + else if (PyList_Check(obj)) { + node->format = MPV_FORMAT_NODE_ARRAY; + mpv_node_list *list = talloc(ta_ctx, mpv_node_list); + node->u.list = list; + int l = (int) PyList_Size(obj); + list->num = l; + list->keys = NULL; + list->values = talloc_array(ta_ctx, mpv_node, l); + for (int i = 0; i < l; i++) { + PyObject *child = PyList_GetItem(obj, i); + makenode(ta_ctx, child, &list->values[i]); + } + } + else if (PyDict_Check(obj)) { + node->format = MPV_FORMAT_NODE_MAP; + mpv_node_list *map = talloc_zero(ta_ctx, mpv_node_list); + node->u.list = map; + int l = (int) PyDict_Size(obj); + map->num = l; + map->keys = talloc_array(ta_ctx, char *, l); + map->values = talloc_array(ta_ctx, mpv_node, l); + + PyObject *key, *value; + Py_ssize_t pos = 0; + while (PyDict_Next(obj, &pos, &key, &value)) { + if (!PyUnicode_Check(key)) { + PyErr_Format(PyExc_TypeError, "node keys must be 'str'"); + } + // pos starts from 1; + int i = (int) pos - 1; + map->keys[i] = talloc_strdup(ta_ctx, (char *)PyUnicode_AsUTF8(key)); + makenode(ta_ctx, value, &map->values[i]); + } + } +} + + +static PyObject * +deconstructnode(struct mpv_node *node) +{ + if (node->format == MPV_FORMAT_NONE) { + Py_RETURN_NONE; + } + else if (node->format == MPV_FORMAT_FLAG) { + if (node->u.flag == 1) { + Py_RETURN_TRUE; + } + Py_RETURN_FALSE; + } + else if (node->format == MPV_FORMAT_INT64) { + return PyLong_FromLongLong(node->u.int64); + } + else if (node->format == MPV_FORMAT_DOUBLE) { + return PyFloat_FromDouble(node->u.double_); + } + else if (node->format == MPV_FORMAT_STRING) { + // return PyUnicode_FromString(node->u.string); + return PyBytes_FromString(node->u.string); + } + else if (node->format == MPV_FORMAT_NODE_ARRAY) { + PyObject *lnode = PyList_New(node->u.list->num); + for (int i = 0; i < node->u.list->num; i++) { + PyList_SetItem(lnode, i, deconstructnode(&node->u.list->values[i])); + } + return lnode; + } + else if (node->format == MPV_FORMAT_NODE_MAP) { + PyObject *dnode = PyDict_New(); + for (int i = 0; i < node->u.list->num; i++) { + PyDict_SetItemString(dnode, node->u.list->keys[i], deconstructnode(&node->u.list->values[i])); + } + return dnode; + } + Py_RETURN_NONE; +} + + +/** + * A wrapper around deconstructnode. + */ +static PyObject * +unmakedata(mpv_format format, void *data) +{ + PyObject *ret; + switch (format) { + case MPV_FORMAT_STRING: + case MPV_FORMAT_OSD_STRING: + // ret = PyUnicode_DecodeFSDefault((char *)data); + ret = PyBytes_FromString((char *)data); + break; + case MPV_FORMAT_FLAG: + ret = PyLong_FromLong(*(int *)data); + break; + case MPV_FORMAT_INT64: + ret = PyLong_FromLongLong(*(int64_t *)data); + break; + case MPV_FORMAT_DOUBLE: + ret = PyFloat_FromDouble(*(double *)data); + break; + case MPV_FORMAT_NODE: + ret = deconstructnode((struct mpv_node *)data); + break; + default: + // TODO: raise Exception + Py_RETURN_NONE; + } + return ret; +} + +// static void print_parse_error2(PyObject *mpv) +// { +// PyThreadState *ts = PyThreadState_GET(); +// PyFrameObject *frame = PyThreadState_GetFrame(ts); +// +// while (NULL != frame) { +// int line = PyFrame_GetLineNumber(frame); +// const char *filename = PyUnicode_AsUTF8(frame->f_code->co_filename); +// const char *funcname = PyUnicode_AsUTF8(frame->f_code->co_name); +// printf(" %s(%d): %s\n", filename, line, funcname); +// frame = frame->f_back; +// } +// +// } + + +static void +print_parse_error(const char *msg) +{ + PyErr_PrintEx(1); + + char *message = talloc_asprintf(NULL, "%s\n%s", + "Below is the line describing the above exception location", msg); + + PyErr_SetString(PyExc_Exception, message); + talloc_free(message); + PyErr_PrintEx(1); +} + + +static PyObject *check_error(int err, char *suffix) +{ + if (err >= 0) { + Py_RETURN_TRUE; + } + const char *errstr = mpv_error_string(err); + char *msg = talloc_asprintf(NULL, "%s %s", suffix, errstr); + PyErr_SetString(PyExc_Exception, msg); + talloc_free(msg); + PyErr_PrintEx(1); // clearing it out lets python to continue (or use: PyErr_Clear()) + Py_RETURN_NONE; +} + + +static PyClientCtx * +get_client_context(PyObject *module) +{ + PyClientCtx *cctx = (PyClientCtx *)PyObject_GetAttrString(module, "context"); + return cctx; +} + + +static PyObject * +py_mpv_extension_ok(PyObject *self, PyObject *args) +{ + Py_RETURN_TRUE; +} + + +static PyObject * +py_mpv_printEx(PyObject *self, PyObject *args) { + PyErr_PrintEx(1); + Py_RETURN_NONE; +} + + +static PyObject * +py_mpv_handle_log(PyObject *self, PyObject *args) +{ + PyObject *mpv, *bytes; + int *level = talloc(NULL, int); + if (!PyArg_ParseTuple(args, "OiO", &mpv, level, &bytes)) { + talloc_free(level); + print_parse_error("Failed to parse args (mpv.handle_log)\n"); + Py_RETURN_NONE; + } + + PyClientCtx *cctx = get_client_context(mpv); + + MP_MSG(cctx, *level, "%s\n", PyBytes_AsString(bytes)); + Py_DECREF(cctx); + talloc_free(level); + Py_RETURN_NONE; +} + + +/** + * @param args tuple + * :param str filename: + */ +static PyObject * +py_mpv_find_config_file(PyObject *self, PyObject *args) +{ + const char **fname = talloc(NULL, const char *); + PyObject *mpv; + + if (!PyArg_ParseTuple(args, "Os", &mpv, fname)) { + talloc_free(fname); + print_parse_error("Failed to parse args (find_config_file)\n"); + Py_RETURN_NONE; + } + + PyClientCtx *cctx = get_client_context(mpv); + + char *path = mp_find_config_file(cctx->ta_ctx, cctx->mpctx->global, *fname); + talloc_free(fname); + Py_DECREF(cctx); + if (path) { + PyObject* ret = PyUnicode_FromString(path); + talloc_free(path); + return ret; + } else { + talloc_free(path); + // PyErr_SetString(PyExc_FileNotFoundError, "Not found"); + Py_RETURN_NONE; + } +} + + +static PyObject * +py_mpv_input_define_section(PyObject *self, PyObject *args) +{ + void *tctx = talloc_new(NULL); + + char **nlco = talloc_array(tctx, char *, 4); + bool *builtin = talloc(tctx, bool); + PyObject *mpv; + if (!PyArg_ParseTuple(args, "Osssps", &mpv, &nlco[0], &nlco[1], &nlco[2], builtin, &nlco[3])) { + talloc_free(tctx); + print_parse_error("Failed to parse args (mpv.mpv_input_define_section)\n"); + Py_RETURN_NONE; + } + + PyClientCtx *cctx = get_client_context(mpv); + + mp_input_define_section(cctx->mpctx->input, nlco[0], nlco[1], nlco[2], *builtin, nlco[3]); + talloc_free(tctx); + Py_DECREF(cctx); + Py_RETURN_NONE; +} + + +static PyObject * +py_mpv_input_enable_section(PyObject *self, PyObject *args) +{ + void *tctx = talloc_new(NULL); + char **name = talloc(tctx, char *); + int *flags = talloc(tctx, int); + PyObject *mpv; + + if (!PyArg_ParseTuple(args, "Osi", &mpv, name, flags)) { + talloc_free(tctx); + print_parse_error("Failed to parse args (mpv.mpv_input_enable_section)\n"); + Py_RETURN_NONE; + } + + PyClientCtx *cctx = get_client_context(mpv); + + mp_input_enable_section(cctx->mpctx->input, *name, *flags); + talloc_free(tctx); + Py_DECREF(cctx); + Py_RETURN_NONE; +} + +/** + * Enables log messages + * @param args tuple + * :param str log_level: + */ +static PyObject * +py_mpv_enable_messages(PyObject *self, PyObject *args) +{ + const char *level; + PyObject *mpv; + + if (!PyArg_ParseTuple(args, "Os", &mpv, &level)) { + print_parse_error("Failed to parse args (mpv.enable_messages)\n"); + Py_RETURN_NONE; + } + + PyClientCtx *cctx = get_client_context(mpv); + + int res = mpv_request_log_messages(cctx->client, level); + Py_DECREF(cctx); + if (res == MPV_ERROR_INVALID_PARAMETER) { + PyErr_SetString(PyExc_Exception, "Invalid Log Error"); + Py_RETURN_NONE; + } + return check_error(res, ""); +} + + +/** + * @param args tuple + * :param str property_name: + * :param int mpv_format: + */ +static PyObject * +py_mpv_get_property(PyObject *self, PyObject *args) +{ + void *tmp = talloc_new(NULL); + const char **name = talloc(tmp, const char *); + mpv_format *format = talloc(tmp, mpv_format); + PyObject *mpv; + + if (!PyArg_ParseTuple(args, "Osi", &mpv, name, format)) { + talloc_free(tmp); + print_parse_error("Failed to parse args (mpv.get_property)\n"); + Py_RETURN_NONE; + } + + PyClientCtx *cctx = get_client_context(mpv); + + if (*format == MPV_FORMAT_NONE) { + talloc_free(tmp); + Py_DECREF(cctx); + Py_RETURN_NONE; + } + + void *out = fmt_talloc(tmp, *format); + int err; + if (*format == MPV_FORMAT_STRING || *format == MPV_FORMAT_OSD_STRING) { + err = mpv_get_property(cctx->client, *name, *format, &out); + } else { + err = mpv_get_property(cctx->client, *name, *format, out); + } + Py_DECREF(cctx); + if (err >= 0) { + PyObject *ret = unmakedata(*format, out); + talloc_free(tmp); + return ret; + } + char *suffix = talloc_asprintf(tmp, "(mpv.get_property) property_name: '%s' (format: '%d')::", *name, *format); + PyObject *ret = check_error(err, suffix); + talloc_free(tmp); + return ret; +} + + +/** + * @param args tuple + * :param str property_name: + * :param int mpv_format: + * :param typing.Any data: + */ +static PyObject * +py_mpv_set_property(PyObject *self, PyObject *args) +{ + void *tctx = talloc_new(NULL); + char **name = talloc(tctx, char *); + mpv_format *format = talloc(tctx, mpv_format); + PyObject *value; + PyObject *mpv; + + if (!PyArg_ParseTuple(args, "OsiO", &mpv, name, format, &value)) { + talloc_free(tctx); + print_parse_error("Failed to parse args (mpv.set_property)\n"); + Py_RETURN_NONE; + } + + PyClientCtx *cctx = get_client_context(mpv); + + int res; + void *data = fmt_talloc(tctx, *format); + + switch (*format) { + case MPV_FORMAT_STRING: + case MPV_FORMAT_OSD_STRING: + if (PyBytes_Check(value)) { + *(char **)data = talloc_strdup(data, PyBytes_AsString(value)); + } + else { + *(char **)data = talloc_strdup(data, PyUnicode_AsUTF8(value)); + } + break; + case MPV_FORMAT_FLAG: + *(int *)data = PyLong_AsLong(value); + break; + case MPV_FORMAT_INT64: + *(int64_t *)data = PyLong_AsLongLong(value); + break; + case MPV_FORMAT_DOUBLE: + *(double *)data = PyFloat_AsDouble(value); + break; + case MPV_FORMAT_NODE: + makenode(tctx, value, (struct mpv_node *)data); + break; + default: + // TODO: raise Exception + talloc_free(tctx); + Py_DECREF(cctx); + Py_RETURN_NONE; + } + res = mpv_set_property(cctx->client, *name, *format, data); + talloc_free(tctx); + Py_DECREF(cctx); + return check_error(res, ""); +} + + +/** + * @param args tuple + * :param str property_name: + */ +static PyObject * +py_mpv_del_property(PyObject *self, PyObject *args) +{ + const char **p = talloc(NULL, const char *); + PyObject *mpv; + + if (!PyArg_ParseTuple(args, "Os", &mpv, p)) { + talloc_free(p); + print_parse_error("Failed to parse args (mpv.del_property)\n"); + Py_RETURN_NONE; + } + + PyClientCtx *cctx = get_client_context(mpv); + + int res = mpv_del_property(cctx->client, *p); + + talloc_free(p); + Py_DECREF(cctx); + return check_error(res, ""); +} + + +/** + * @param args tuple + * :param int reply_userdata: + * :param str property_name: + * :param int mpv_format: + */ +static PyObject * +py_mpv_observe_property(PyObject *self, PyObject *args) +{ + void *tctx = talloc_new(NULL); + const char **name = talloc(tctx, const char *); + mpv_format *format = talloc(tctx, mpv_format); + uint64_t *reply_userdata = talloc(tctx, uint64_t); + PyObject *mpv; + + if (!PyArg_ParseTuple(args, "OKsi", &mpv, reply_userdata, name, format)) { + talloc_free(tctx); + print_parse_error("Failed to parse args (mpv.observe_property)\n"); + // print_parse_error2(PyTuple_GetItem(args, 0)); + Py_RETURN_NONE; + } + + PyClientCtx *ctx = get_client_context(mpv); + + int err = mpv_observe_property(ctx->client, *reply_userdata, *name, *format); + talloc_free(tctx); + Py_DECREF(ctx); + return check_error(err, ""); +} + + +static PyObject * +py_mpv_unobserve_property(PyObject *self, PyObject *args) +{ + uint64_t *reply_userdata = talloc(NULL, uint64_t); + PyObject *mpv; + + if (!PyArg_ParseTuple(args, "OK", &mpv, reply_userdata)) { + talloc_free(reply_userdata); + print_parse_error("Failed to parse args (mpv.unobserve_property)\n"); + Py_RETURN_NONE; + } + + PyClientCtx *ctx = get_client_context(mpv); + + int err = mpv_unobserve_property(ctx->client, *reply_userdata); + + talloc_free(reply_userdata); + Py_DECREF(ctx); + + return check_error(err, ""); +} + + +static PyObject * +py_mpv_command(PyObject *self, PyObject *args) +{ + PyClientCtx *cctx = get_client_context(PyTuple_GetItem(args, 0)); + void *tmp = talloc_new(cctx->ta_ctx); + struct mpv_node *cmd = talloc(tmp, struct mpv_node); + makenode(tmp, PyTuple_GetItem(args, 1), cmd); + struct mpv_node *result = talloc(tmp, struct mpv_node); + if (!PyObject_IsTrue(check_error(mpv_command_node(cctx->client, cmd, result), ""))) { + MP_ERR(cctx, "failed to run node command\n"); + talloc_free(tmp); + Py_DECREF(cctx); + Py_RETURN_NONE; + } + PyObject *ret = deconstructnode(result); + talloc_free(tmp); + Py_DECREF(cctx); + return ret; +} + + +/** + * @param args tuple + * :param PyObject* mpv: + * :param str *args: + */ +static PyObject * +py_mpv_commandv(PyObject *self, PyObject *args) +{ + Py_ssize_t arg_length = PyTuple_Size(args); + const char **argv = talloc_array(NULL, const char *, arg_length); + for (Py_ssize_t i = 0; i < (arg_length - 1); i++) { + argv[i] = talloc_strdup(argv, PyUnicode_AsUTF8(PyTuple_GetItem(args, i + 1))); + } + argv[arg_length - 1] = NULL; + PyClientCtx *cctx = get_client_context(PyTuple_GetItem(args, 0)); + int ret = mpv_command(cctx->client, argv); + Py_DECREF(cctx); + talloc_free(argv); + return check_error(ret, ""); +} + + +// args: string +static PyObject * +py_mpv_command_string(PyObject *self, PyObject *args) +{ + const char **s = talloc(NULL, const char *); + PyObject *mpv; + + if (!PyArg_ParseTuple(args, "Os", &mpv, s)) { + talloc_free(s); + print_parse_error("Failed to parse args (mpv.command_string)\n"); + Py_RETURN_NONE; + } + + PyClientCtx *cctx = get_client_context(mpv); + + int res = mpv_command_string(cctx->client, *s); + talloc_free(s); + Py_DECREF(cctx); + return check_error(res, ""); +} + + +static PyObject * +py_mpv_command_node_async(PyObject *self, PyObject *args) +{ + PyObject *mpv; + PyObject *data; + uint64_t *command_id = talloc(NULL, uint64_t); + if (!PyArg_ParseTuple(args, "OKO", &mpv, command_id, &data)) { + talloc_free(command_id); + print_parse_error("Failed to parse args (mpv.command_node_async)\n"); + Py_RETURN_NONE; + } + + PyClientCtx *cctx = get_client_context(mpv); + void *tmp = talloc_new(cctx->ta_ctx); + + struct mpv_node *cmd = talloc(tmp, struct mpv_node); + makenode(tmp, data, cmd); + + int res = mpv_command_node_async(cctx->client, *command_id, cmd); + + talloc_free(command_id); + talloc_free(tmp); + Py_DECREF(cctx); + return check_error(res, ""); +} + + +static PyObject * +py_mpv_abort_async_command(PyObject *self, PyObject *args) +{ + PyObject *mpv; + uint64_t id; + if (!PyArg_ParseTuple(args, "OK", &mpv, &id)) { + print_parse_error("Failed to parse args (mpv.abort_async_command)\n"); + Py_RETURN_NONE; + } + PyClientCtx *cctx = get_client_context(mpv); + + mpv_abort_async_command(cctx->client, id); + + Py_DECREF(cctx); + Py_RETURN_NONE; +} + + +static PyObject * +py_mpv_hook_add(PyObject *self, PyObject *args) +{ + PyObject *mpv; + void *tmp = talloc_new(NULL); + uint64_t *reply_userdata = talloc(tmp, uint64_t); + char **name = talloc(tmp, char *); + int *priority = talloc(tmp, int); + + if (!PyArg_ParseTuple(args, "OKsi", &mpv, reply_userdata, name, priority)) { + talloc_free(tmp); + print_parse_error("Failed to parse args (mpv.hook_add)\n"); + Py_RETURN_NONE; + } + + PyClientCtx *ctx = get_client_context(mpv); + + int err = mpv_hook_add(ctx->client, *reply_userdata, *name, *priority); + Py_DECREF(ctx); + if (err >= 0) { + talloc_free(tmp); + Py_RETURN_TRUE; + } + char *suffix = talloc_asprintf(tmp, "(mpv.hook_add) (%" PRIu64 ") '%s' '%d':", + *reply_userdata, *name, *priority); + PyObject *ret = check_error(err, suffix); + talloc_free(tmp); + return ret; +} + + +static PyObject * +py_mpv_hook_continue(PyObject *self, PyObject *args) +{ + PyObject *mpv; + void *tmp = talloc_new(NULL); + uint64_t *id = talloc(tmp, uint64_t); + + if (!PyArg_ParseTuple(args, "OK", &mpv, id)) { + talloc_free(tmp); + print_parse_error("Failed to parse args (mpv.hook_continue)\n"); + Py_RETURN_NONE; + } + + PyClientCtx *ctx = get_client_context(mpv); + int err = mpv_hook_continue(ctx->client, *id); + Py_DECREF(ctx); + if (err >= 0) { + talloc_free(tmp); + Py_RETURN_TRUE; + } + + char *suffix = talloc_asprintf(tmp, "(mpv.hook_continue) '%" PRIu64 "':", *id); + PyObject *ret = check_error(err, suffix); + talloc_free(tmp); + return ret; +} + + +/** + * @param args: + * :param int event_id: + * :param int enable: + */ +static PyObject * +py_mpv_request_event(PyObject *self, PyObject *args) +{ + int *args_ = talloc_array(NULL, int, 2); + PyObject *mpv; + + if (!PyArg_ParseTuple(args, "Oii", &mpv, &args_[0], &args_[1])) { + talloc_free(args_); + print_parse_error("Failed to parse args (mpv.request_event)\n"); + Py_RETURN_NONE; + } + + PyClientCtx *cctx = get_client_context(mpv); + + int err = mpv_request_event(cctx->client, args_[0], args_[1]); + talloc_free(args_); + Py_DECREF(cctx); + + return check_error(err, ""); +} + + +/* +* args[0]: DEFAULT_TIMEOUT +* returns: PyLongObject *event_id, PyObject *data +*/ +static PyObject * +py_mpv_wait_event(PyObject *self, PyObject *args) +{ + int *timeout = talloc(NULL, int); + PyObject *mpv; + + if (!PyArg_ParseTuple(args, "Oi", &mpv, timeout)) { + talloc_free(timeout); + print_parse_error("Failed to parse args (mpv.wait_event)\n"); + Py_RETURN_NONE; + } + + PyClientCtx *cctx = get_client_context(mpv); + + mpv_event *event = mpv_wait_event(cctx->client, *timeout); + + talloc_free(timeout); + Py_DECREF(cctx); + + PyObject *ret = PyTuple_New(2); + PyTuple_SetItem(ret, 0, PyLong_FromLong(event->event_id)); + if (event->event_id == MPV_EVENT_CLIENT_MESSAGE) { + mpv_event_client_message *m = (mpv_event_client_message *)event->data; + PyObject *data = PyTuple_New(m->num_args); + for (int i = 0; i < m->num_args; i++) { + PyTuple_SetItem(data, i, PyUnicode_DecodeFSDefault(m->args[i])); + } + PyTuple_SetItem(ret, 1, data); + return ret; + } else if (event->event_id == MPV_EVENT_PROPERTY_CHANGE) { + mpv_event_property *p = (mpv_event_property *)event->data; + PyObject *data = PyTuple_New(2); + PyTuple_SetItem(data, 0, PyUnicode_DecodeFSDefault(p->name)); + PyTuple_SetItem(data, 1, unmakedata(p->format, p->data)); + PyTuple_SetItem(ret, 1, data); + return ret; + } + PyTuple_SetItem(ret, 1, Py_None); + return ret; +} + + +static PyObject *py_mpv_run_event_loop(PyObject *self, PyObject *args) +{ + PyObject *mpv = PyTuple_GetItem(args, 0); + PyClientCtx *cctx = get_client_context(mpv); + mpv_handle *client = cctx->client; + Py_DECREF(cctx); + + while (true) { + // mpv_event *event = mpv_wait_event(client, -1); + /** + * Do NOT put -1 for it's an io blocking call + * let other potential threads survive + */ + mpv_event *event = mpv_wait_event(client, 0.05); + + PyObject *ed = PyDict_New(); + PyDict_SetItemString(ed, "event_id", PyLong_FromLong(event->event_id)); + PyDict_SetItemString(ed, "reply_userdata", PyLong_FromUnsignedLongLong(event->reply_userdata)); + PyDict_SetItemString(ed, "error", PyLong_FromLong(event->error)); + if (event->error < 0) + PyDict_SetItemString(ed, "error_message", PyUnicode_DecodeFSDefault(mpv_error_string(event->error))); + + if (event->event_id == MPV_EVENT_CLIENT_MESSAGE) { + mpv_event_client_message *m = (mpv_event_client_message *)event->data; + PyObject *data = PyTuple_New(m->num_args); + for (int i = 0; i < m->num_args; i++) { + PyTuple_SetItem(data, i, PyUnicode_DecodeFSDefault(m->args[i])); + } + PyDict_SetItemString(ed, "data", data); + } else if (event->event_id == MPV_EVENT_PROPERTY_CHANGE) { + mpv_event_property *p = (mpv_event_property *)event->data; + PyObject *data = PyDict_New(); + PyDict_SetItemString(data, "name", PyUnicode_DecodeFSDefault(p->name)); + PyDict_SetItemString(data, "value", unmakedata(p->format, p->data)); + PyDict_SetItemString(ed, "data", data); + } else if (event->event_id == MPV_EVENT_COMMAND_REPLY) { + mpv_event_command *result_node = (mpv_event_command *)event->data; + PyDict_SetItemString(ed, "data", deconstructnode(&result_node->result)); + } else if (event->event_id == MPV_EVENT_HOOK) { + mpv_event_hook *hook = (mpv_event_hook *)event->data; + PyObject *data = PyDict_New(); + PyDict_SetItemString(data, "name", PyUnicode_DecodeFSDefault(hook->name)); + PyDict_SetItemString(data, "id", PyLong_FromUnsignedLongLong(hook->id)); + PyDict_SetItemString(ed, "data", data); + } else PyDict_SetItemString(ed, "data", Py_None); + + if (PyObject_CallMethod(mpv, "handle_event", "O", ed) == Py_False) { + Py_DECREF(ed); + break; + } + Py_DECREF(ed); + } + Py_RETURN_NONE; +} + + +static PyMethodDef py_mpv_methods[] = { + {"extension_ok", (PyCFunction)py_mpv_extension_ok, METH_VARARGS, /* METH_VARARGS | METH_KEYWORDS (PyObject *self, PyObject *args, PyObject **kwargs) */ + PyDoc_STR("Just a test method to see if extending is working.")}, + {"run_event_loop", (PyCFunction)py_mpv_run_event_loop, METH_VARARGS, + PyDoc_STR("mpv holds here to listen for events.")}, + {"handle_log", (PyCFunction)py_mpv_handle_log, METH_VARARGS, + PyDoc_STR("handles log records emitted from python thread.")}, + {"printEx", (PyCFunction)py_mpv_printEx, METH_VARARGS, + PyDoc_STR("")}, + {"find_config_file", (PyCFunction)py_mpv_find_config_file, METH_VARARGS, + PyDoc_STR("")}, + {"request_event", (PyCFunction)py_mpv_request_event, METH_VARARGS, + PyDoc_STR("")}, + {"enable_messages", (PyCFunction)py_mpv_enable_messages, METH_VARARGS, + PyDoc_STR("")}, + {"get_property", (PyCFunction)py_mpv_get_property, METH_VARARGS, + PyDoc_STR("")}, + {"set_property", (PyCFunction)py_mpv_set_property, METH_VARARGS, + PyDoc_STR("")}, + {"del_property", (PyCFunction)py_mpv_del_property, METH_VARARGS, + PyDoc_STR("")}, + {"observe_property", (PyCFunction)py_mpv_observe_property, METH_VARARGS, + PyDoc_STR("")}, + {"unobserve_property", (PyCFunction)py_mpv_unobserve_property, METH_VARARGS, + PyDoc_STR("")}, + {"mpv_input_define_section", (PyCFunction)py_mpv_input_define_section, METH_VARARGS, + PyDoc_STR("Given input section description, defines the input section.")}, + {"mpv_input_enable_section", (PyCFunction)py_mpv_input_enable_section, METH_VARARGS, + PyDoc_STR("Given name of an input section, enables the input section.")}, + {"commandv", (PyCFunction)py_mpv_commandv, METH_VARARGS, + PyDoc_STR("runs mpv_command given command name and args.")}, + {"command_string", (PyCFunction)py_mpv_command_string, METH_VARARGS, + PyDoc_STR("runs mpv_command_string given a string as the only argument.")}, + {"command", (PyCFunction)py_mpv_command, METH_VARARGS, + PyDoc_STR("runs mpv_command_node given py structure(s, as in list) convertible to mpv_node as the only argument.")}, + {"command_node_async", (PyCFunction)py_mpv_command_node_async, METH_VARARGS, + PyDoc_STR("runs mpv_command_node_async given a command_id and py structure(s, as in list) convertible to mpv_node.")}, + {"abort_async_command", (PyCFunction)py_mpv_abort_async_command, METH_VARARGS, + PyDoc_STR("given an async command id, aborts it.")}, + {"hook_add", (PyCFunction)py_mpv_hook_add, METH_VARARGS, + PyDoc_STR("")}, + {"hook_continue", (PyCFunction)py_mpv_hook_continue, METH_VARARGS, + PyDoc_STR("")}, + {"wait_event", (PyCFunction)py_mpv_wait_event, METH_VARARGS, + PyDoc_STR("Listens for mpv_event and returns event_id and event_data")}, + {NULL, NULL, 0, NULL} /* Sentinel */ +}; + + +static PyTypeObject PyMpv_Type; +static PyObject *MpvError; + + +typedef struct { + PyObject_HEAD + + PyObject *pympv_attr; +} PyMpvObject; + + +#define PyCtxObject_Check(v) Py_IS_TYPE(v, &PyMpv_Type) + + +static void +PyMpv_dealloc(PyMpvObject *self) +{ + PyObject_Free(self); +} + + +static PyObject * +setup(PyObject *self, PyObject *args) +{ + Py_RETURN_NONE; +} + + +static PyMethodDef PyMpv_methods[] = { + {"setup", (PyCFunction)setup, METH_VARARGS, + PyDoc_STR("Just a test method to see if extending is working.")}, + {NULL, NULL, 0, NULL} /* Sentinel */ +}; + + +static PyObject * +PyMpv_getattro(PyMpvObject *self, PyObject *name) +{ + if (self->pympv_attr != NULL) { + PyObject *v = PyDict_GetItemWithError(self->pympv_attr, name); + if (v != NULL) { + Py_INCREF(v); + return v; + } + else if (PyErr_Occurred()) { + return NULL; + } + } + return PyObject_GenericGetAttr((PyObject *)self, name); +} + + +static int +PyMpv_setattr(PyMpvObject *self, const char *name, PyObject *v) +{ + if (self->pympv_attr == NULL) { + self->pympv_attr = PyDict_New(); + if (self->pympv_attr == NULL) + return -1; + } + if (v == NULL) { + int rv = PyDict_DelItemString(self->pympv_attr, name); + if (rv < 0 && PyErr_ExceptionMatches(PyExc_KeyError)) + PyErr_SetString(PyExc_AttributeError, + "delete non-existing PyMpv attribute"); + return rv; + } + else + return PyDict_SetItemString(self->pympv_attr, name, v); +} + + +static PyTypeObject PyMpv_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "mpv.Mpv", + .tp_basicsize = sizeof(PyMpvObject), + .tp_dealloc = (destructor)PyMpv_dealloc, + .tp_getattr = (getattrfunc)0, + .tp_setattr = (setattrfunc)PyMpv_setattr, + .tp_getattro = (getattrofunc)PyMpv_getattro, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_methods = PyMpv_methods, +}; + + +static int +py_mpv_exec(PyObject *m) +{ + if (PyType_Ready(&PyMpv_Type) < 0) + return -1; + + if (MpvError == NULL) { + MpvError = PyErr_NewException("mpv.error", NULL, NULL); + if (MpvError == NULL) + return -1; + } + int rc = PyModule_AddType(m, (PyTypeObject *)MpvError); + if (rc < 0) + return -1; + + if (PyModule_AddType(m, &PyMpv_Type) < 0) + return -1; + + return 0; +} + + +static struct PyModuleDef_Slot py_mpv_slots[] = { + {Py_mod_exec, py_mpv_exec}, + {Py_mod_multiple_interpreters, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED}, + {0, NULL} +}; + + +static int py_mpv_m_clear(PyObject *self) +{ + PyObject_Free(self); + return 0; +} + + +static struct PyModuleDef py_mpv_module_def = { + PyModuleDef_HEAD_INIT, + "mpv", + PyDoc_STR("Extension module container (Python exposed) C function wrappers for controlling mpv (with Python)."), + 0, + py_mpv_methods, + py_mpv_slots, + NULL, + py_mpv_m_clear, + NULL +}; + + +PyMODINIT_FUNC PyInit_mpv(void) +{ + return PyModuleDef_Init(&py_mpv_module_def); +} diff --git a/player/py_extend.h b/player/py_extend.h new file mode 100644 index 0000000000000..72843a725eb11 --- /dev/null +++ b/player/py_extend.h @@ -0,0 +1,39 @@ +/* + * This file is part of mpv. + * + * mpv is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * mpv is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with mpv. If not, see . + */ + +#ifndef PY_MPV_EXTENSION_H +#define PY_MPV_EXTENSION_H +#include + + +typedef struct { + PyObject_HEAD + + const char *filename; + const char *path; + struct MPContext *mpctx; + struct mpv_handle *client; + struct mp_log *log; + PyThreadState *threadState; + void *ta_ctx; +} PyClientCtx; + + +// PyObject *PyInit_mpv(void); +PyMODINIT_FUNC PyInit_mpv(void); + +#endif diff --git a/player/python.c b/player/python.c new file mode 100644 index 0000000000000..03ac21a009abd --- /dev/null +++ b/player/python.c @@ -0,0 +1,210 @@ +/* + * This file is part of mpv. + * + * mpv is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * mpv is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with mpv. If not, see . + */ + +#define PY_SSIZE_T_CLEAN +#include + +#include "py_extend.h" + +#include "common/msg.h" +#include "core.h" +#include "client.h" +#include "ta/ta_talloc.h" + +// List of builtin modules and their contents as strings. +// All these are generated from player/python/*.py +static const char *const builtin_files[][2] = { + {"@/defaults.py", // internal_name: mpvclient +# include "player/python/defaults.py.inc" + }, + {0} +}; + + +PyTypeObject PyClientCtx_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "py_client_ctx", + .tp_basicsize = sizeof(PyClientCtx), + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = "py client context object", +}; + + +static PyObject * +load_local_pystrings(const char *string, char *module_name) +{ + PyObject *defaults = Py_CompileString(string, module_name, Py_file_input); + if (defaults == NULL) { + return NULL; + } + PyObject *defaults_mod = PyImport_ExecCodeModule(module_name, defaults); + Py_DECREF(defaults); + if (defaults_mod == NULL) { + return NULL; + } + return defaults_mod; +} + + +static PyObject * +load_script(const char *script_name, PyObject *defaults, const char *client_name) +{ + PyObject *mpv = PyObject_GetAttrString(defaults, "mpv"); + + const char **pathname = talloc(NULL, const char *); + PyObject *args = PyObject_CallMethod(mpv, "compile_script", "ss", script_name, client_name); + if (args == NULL) { + return NULL; + } + PyObject *client = PyTuple_GetItem(args, 1); + *(const char **)pathname = talloc_strdup(pathname, PyUnicode_AsUTF8(PyTuple_GetItem(args, 0))); + Py_INCREF(client); + Py_DECREF(args); + Py_DECREF(mpv); + if (client == NULL) { + Py_DECREF(client); + talloc_free(pathname); + return NULL; + } + PyObject *client_mod = PyImport_ExecCodeModuleEx(client_name, client, *pathname); + Py_DECREF(client); + talloc_free(pathname); + if (client_mod == NULL) { + return NULL; + } + return client_mod; +} + +static void +end_interpreter(PyClientCtx *client_ctx) +{ + // PyErr_PrintEx(0); + talloc_free(client_ctx->ta_ctx); + Py_EndInterpreter(client_ctx->threadState); +} + + +static int run_client(PyClientCtx *cctx) +{ + PyObject *defaults = load_local_pystrings(builtin_files[0][1], "mpvclient"); + + if (defaults == NULL) { + PyErr_PrintEx(1); + MP_ERR(cctx, "Failed to load defaults (AKA. mpvclient) module.\n"); + end_interpreter(cctx); + return -1; + } + + PyObject *os = PyImport_ImportModule("os"); + PyObject *path = PyObject_GetAttrString(os, "path"); + if (PyObject_CallMethod(path, "exists", "s", cctx->filename) == Py_False) { + MP_ERR(cctx, "%s does not exists.\n", cctx->filename); + end_interpreter(cctx); + return 0; + } + + PyObject *mpv = PyObject_GetAttrString(defaults, "mpv"); + PyObject_SetAttrString(mpv, "context", (PyObject *)cctx); + + const char *cname = mpv_client_name(cctx->client); + PyObject *client = load_script(cctx->filename, defaults, cname); + + Py_DECREF(defaults); + Py_DECREF(os); + Py_DECREF(path); + + if (client == NULL) { + PyErr_PrintEx(1); + MP_ERR(cctx, "Could not load client. discarding: %s.\n", cname); + end_interpreter(cctx); + return 0; + } + + if (PyObject_HasAttrString(client, "mpv") == 0) { + MP_ERR(cctx, "illegal client. does not have an 'mpv' instance (use: from mpvclient import mpv). discarding: %s.\n", cname); + end_interpreter(cctx); + return 0; + } + + PyObject *client_mpv = PyObject_GetAttrString(client, "mpv"); + PyObject *Mpv = PyObject_GetAttrString(defaults, "Mpv"); + + int isins = PyObject_IsInstance(client_mpv, Mpv); + if (isins == 0) { + MP_ERR(cctx, "illegal client. 'mpv' instance is not an instance of mpvclient.Mpv (use: from mpvclient import mpv). discarding: %s.\n", cname); + end_interpreter(cctx); + return 0; + } else if (isins == -1) { + end_interpreter(cctx); + return -1; + } + Py_DECREF(client_mpv); + Py_DECREF(Mpv); + + PyObject_CallMethod(mpv, "run", NULL); + + end_interpreter(cctx); + return 0; +} + + +static int s_load_python(struct mp_script_args *args) +{ + int ret = 0; + + if (!args->mpctx->opts->enable_python) { + MP_WARN(args, "%s\n", "Python has NOT been initialized. Be sure to set option 'enable-python' to 'yes'"); + return ret; + } + + PyInterpreterConfig config = { + .use_main_obmalloc = 0, + .allow_fork = 0, + .allow_exec = 0, + .allow_threads = 1, + .allow_daemon_threads = 0, + .check_multi_interp_extensions = 1, + .gil = PyInterpreterConfig_OWN_GIL, + }; + PyThreadState *threadState = NULL; + Py_NewInterpreterFromConfig(&threadState, &config); + + PyThreadState_Swap(threadState); + + PyClientCtx *ctx = PyObject_New(PyClientCtx, &PyClientCtx_Type); + ctx->filename = args->filename; + ctx->path = args->path; + ctx->client = args->client; + ctx->mpctx = args->mpctx; + ctx->log = args->log; + ctx->threadState = threadState; + ctx->ta_ctx = talloc_new(NULL); + + ret = run_client(ctx); + + if (ret == -1) PyErr_PrintEx(1); + + return ret; +} + + +// main export of this file +const struct mp_scripting mp_scripting_py = { + .name = "python", + .file_ext = "py", + .load = s_load_python, +}; diff --git a/player/python/defaults.py b/player/python/defaults.py new file mode 100644 index 0000000000000..fd05c7fe84775 --- /dev/null +++ b/player/python/defaults.py @@ -0,0 +1,750 @@ +""" +The python wrapper module for the embedded and extended functionalities +""" + +# extension module, see: player/py_extend.c +import mpv as _mpv # type: ignore + +import math +import sys +import traceback +import typing +from threading import Timer +from io import StringIO +from pathlib import Path + +__all__ = ["mpv", "Mpv"] + + +def read_exception(excinfo): + f = StringIO() + traceback.print_exception(*excinfo, file=f) + f.seek(0) + try: + return f.read() + finally: + f.close() + + +class State: + pause = False + + +state = State() + + +class Options: + + def typeconv(self, desttypeval, val): + if isinstance(desttypeval, bool): + if val == "yes": + val = True + elif val == "no": + val = False + else: + mpv.error("Error: Can't convert '" + val + "' to boolean!") + val = None + elif isinstance(desttypeval, int): + try: + val = int(val) + except ValueError: + mpv.error("Error: Can't convert '" + val + "' to number!") + val = None + return val + + def read_options(self, options: dict, identifier: str | None = None, + on_update: typing.Callable[[typing.Any], typing.Any] | None = None, + ): + option_types = options.copy() + + if identifier is None: + identifier = mpv.name + + mpv.debug(f"reading options for {identifier}") + + # read config file + conffilename = f"script-opts/{identifier}.conf" + conffile = mpv.find_config_file(conffilename) + if conffile is None: + mpv.debug(f"{conffilename} not found.") + conffilename = f"lua-settings/{identifier}.conf" + conffile = mpv.find_config_file(conffilename) + if conffile: + mpv.warn("lua-settings/ is deprecated, use directory script-opts/") + + f = conffile and open(conffile) + if not f: + mpv.debug(f"{conffilename} not found.") + else: + # config exists, read values + mpv.info("Opened config file " + conffilename + ".") + linecounter = 1 + for line in f.readlines(): + if line[-1:] == "\r": + line = line[:-1] + + if not line.startswith("#"): + eqpos = line.find("=") + if eqpos != -1: + key = line[:eqpos].strip() + val = line[eqpos+1:].strip() + + if (desttypeval := option_types.get(key, None)) is None: + mpv.warn(f"{conffilename}:{linecounter} unknown key '{key}', ignoring") + else: + convval = self.typeconv(desttypeval, val) + if convval is None: + mpv.error(conffilename+":"+str(linecounter)+ + " error converting value '" + val + + "' for key '" + key + "'") + else: + options[key] = convval + linecounter += 1 + f.close() + + # parse command-line options + prefix = identifier + "-" + # command line options are always applied on top of these + conf_and_default_opts = options.copy() + + def parse_opts(full, opt): + for key, val in full.items(): + if key.find(prefix) == 0: + key = key[len(prefix):] + + # match found values with defaults + if (desttypeval := option_types.get(key)) is None: + mpv.warn("script-opts: unknown key " + key + ", ignoring") + else: + convval = self.typeconv(desttypeval, val) + if convval is None: + mpv.error("script-opts: error converting value '" + val + + "' for key '" + key + "'") + else: + opt[key] = convval + + # initial + parse_opts(mpv.get_property_node("options/script-opts"), options) + + # runtime updates + if on_update: + + @mpv.observe_property("options/script-opts", mpv.MPV_FORMAT_NODE) + def on_option_change(val): + last_opts = options.copy() + new_opts = conf_and_default_opts.copy() + parse_opts(val, new_opts) + changelist = {} + for k, v in new_opts.items(): + if last_opts[k] != v: + # copy to user + options[k] = v + changelist[k] = True + if changelist.keys() and on_update is not None: + on_update(changelist) + + +class Ass: + scale = 4 + text = "" + def append(self, s): + self.text += s + + def draw_start(self): + self.text = "%s{\\p%d}" % (self.text, self.scale) # noqa: UP031 + + def draw_stop(self): + self.text += "{\\p0}" + + def pos(self, x, y): + self.append("{\\pos(%f,%f)}" % (x, y)) # noqa: UP031 + + def rect_cw(self, x0, y0, x1, y1): + self.move_to(x0, y0) + self.line_to(x1, y0) + self.line_to(x1, y1) + self.line_to(x0, y1) + + def move_to(self, x, y): + self.append(" m") + self.coord(x, y) + + def line_to(self, x, y): + self.append(" l") + self.coord(x, y) + + def coord(self, x, y): + scale = 2 ** (self.scale - 1) + ix = math.ceil(x * scale) + iy = math.ceil(y * scale) + self.text = f"{self.text} {ix} {iy}" + + +class OSDOverlay: + next_assid = 1 + def __init__(self, format): # noqa: A002 + self.format = format + OSDOverlay.next_assid += 1 + self.id = OSDOverlay.next_assid + self.data = "" + self.res_x = 0 + self.res_y = 720 + self.z = 0 + + def update(self): + cmd = self.__dict__.copy() + cmd["name"] = "osd-overlay" + cmd["res_x"] = round(cmd["res_x"]) + cmd["res_y"] = round(cmd["res_y"]) + + return mpv.sanitize(mpv.command(cmd)) + + def remove(self): + try: + mpv.command({ + "name": "osd-overlay", + "format": "none", + "id": self.id, + "data": "", + }) + except Exception: + return False + return True + + +class Mpv: + """ + + This class wraps the mpv client (/libmpv) API hooks defined in the + embedded/extended python. See: player/py_extend.c + + """ + MPV_EVENT_NONE = 0 + MPV_EVENT_SHUTDOWN = 1 + MPV_EVENT_LOG_MESSAGE = 2 + MPV_EVENT_GET_PROPERTY_REPLY = 3 + MPV_EVENT_SET_PROPERTY_REPLY = 4 + MPV_EVENT_COMMAND_REPLY = 5 + MPV_EVENT_START_FILE = 6 + MPV_EVENT_END_FILE = 7 + MPV_EVENT_FILE_LOADED = 8 + MPV_EVENT_CLIENT_MESSAGE = 16 + MPV_EVENT_VIDEO_RECONFIG = 17 + MPV_EVENT_AUDIO_RECONFIG = 18 + MPV_EVENT_SEEK = 20 + MPV_EVENT_PLAYBACK_RESTART = 21 + MPV_EVENT_PROPERTY_CHANGE = 22 + MPV_EVENT_QUEUE_OVERFLOW = 24 + MPV_EVENT_HOOK = 25 + + observe_properties: dict = {} + + options = Options() + + def create_osd_overlay(self, format = "ass-events"): # noqa: A002 + return OSDOverlay(format) + + def ass_new(self): + return Ass() + + messages: dict = {} + + def register_script_message(self, name): + def decorate(fn): + self.messages[name] = fn + return decorate + + def unregister_script_message(self, name): + del self.messages[name] + + enabled_events: list = [] + event_handlers: dict = {} + + def register_event(self, name): + + def decorate(fn): + self.request_event(name) + handler_list = self.event_handlers.get(name, []) + handler_list.append(fn) + self.event_handlers[name] = handler_list + + return decorate + + threads: dict = {} + periodic_timer_meta: dict = {} + + @property + def timers(self): + return self.threads + + def disable_timer(self, name): + self.periodic_timer_meta[name]["disabled"] = True + self.clear_timer(name) + + def add_periodic_timer(self, sec, func, *args, name=None, **kwargs): + if name is None: + self.next_bid += 1 + name = f"timer{self.next_bid}" + self.periodic_timer_meta[name] = { + "sec": sec, + "disabled": False, + } + def do(*a, **kw): + func(*a, **kw) + + if not self.periodic_timer_meta[name]["disabled"]: + self.add_timeout(sec, do, *a, name=name, **kw) + + self.add_timeout(sec, do, *args, name=name, **kwargs) + + def add_timeout(self, sec, func, *args, name=None, **kwargs): + t = Timer(sec, func, args=args, kwargs=kwargs) + t.start() + if name is None: + self.next_bid += 1 + name = "timer" + str(self.next_bid) + self.threads[name] = t + + def clear_timer(self, name): + self.threads.pop(name).cancel() + + def get_opt(self, key, default=None): + return self.get_property_node("options/script-opts").get(key, default) + + def print_ref_count(self, obj): + self.info(f"refcount ({repr(obj)}): {sys.getrefcount(obj)}") + + MPV_LOG_LEVEL_FATAL = 0 + MPV_LOG_LEVEL_ERROR = 1 + MPV_LOG_LEVEL_WARN = 2 + MPV_LOG_LEVEL_INFO = 3 + MPV_LOG_LEVEL_V = 5 + MPV_LOG_LEVEL_DEBUG = 6 + MPV_LOG_LEVEL_TRACE = 7 + + def log(self, level, *args): + if not args: + return + msg = " ".join([str(msg) for msg in args]) + _mpv.handle_log(self, level, bytes(msg, "utf-8")) + + def trace(self, *args): + self.log(self.MPV_LOG_LEVEL_TRACE, *args) + + def debug(self, *args): + self.log(self.MPV_LOG_LEVEL_DEBUG, *args) + + def verbose(self, *args): + self.log(self.MPV_LOG_LEVEL_V, *args) + + def info(self, *args): + self.log(self.MPV_LOG_LEVEL_INFO, *args) + + def warn(self, *args): + self.log(self.MPV_LOG_LEVEL_WARN, *args) + + def error(self, *a): + self.log(self.MPV_LOG_LEVEL_ERROR, *a) + + def fatal(self, *a): + self.log(self.MPV_LOG_LEVEL_FATAL, *a) + + def osd_message(self, text, duration=-1, osd_level=None): + args = [text, duration] + if osd_level is not None: + args.append(osd_level) + self.commandv("show-text", *args) + + _name = None + + @property + def name(self): + return self._name + + def read_script(self, filename): + file_path = Path(filename).resolve() + if file_path.is_dir(): + file_path = file_path / "__init__.py" + with file_path.open("r") as f: + return str(file_path), f.read() + + def compile_script(self, filename, client_name): + self._name = client_name + file_path, source = self.read_script(filename) + return file_path, compile(source, file_path, "exec") + + def extension_ok(self) -> bool: + return _mpv.extension_ok() + + def read_ex(self): + return read_exception(sys.exc_info()) + + def call_catch_ex(self, func, *args, default=None, log_level=None, **kwargs): + """ + :param typing.Callable func: + :param tuple[typing.Any] args: + :param dict[str, typing.Any] kwargs: + :param typing.Any default: return value when func fails with exception + :param int log_level: log level to register the message at when func fails with an exception + + Executes :param:`func` passing in the given :param:`args` and + :param:`kwargs` and returns the :param:`func` 's return value. In case + an exception has occurred, it returns the value found in + :param:`default`. + """ + + if log_level is None: + log_level = self.MPV_LOG_LEVEL_ERROR + + try: + return func(*args, **kwargs) + except Exception: + try: + self.log(log_level, read_exception(sys.exc_info())) + except Exception: + pass + return default + + def process_event(self, event_id, event): + data = event["data"] + if event_id == self.MPV_EVENT_CLIENT_MESSAGE: + if data[0] != "key-binding": + self.call_catch_ex(self.messages[data[0]]) + else: + cb_name = data[1] + binding = self.binds[cb_name] + if data[2][0] == "u" or (binding.get("repeatable", False) \ + and data[2][0] == "r"): + self.call_catch_ex(binding["cb"]) + + elif event_id == self.MPV_EVENT_PROPERTY_CHANGE: + self.notify_observer(event) + + elif event_id == self.MPV_EVENT_HOOK: + self.run_hook(data["name"], event["reply_userdata"]) + _mpv.hook_continue(self, data["id"]) + + elif event_id in self.enabled_events: + for cb in self.event_handlers.get(event_id, []): + self.call_catch_ex(cb, event) + + def command_string(self, command_string): + return _mpv.command_string(self, command_string) + + def commandv(self, name, *args): + if len(args) > 50: + raise ValueError("Too many arguments") + return _mpv.commandv(self, name, *args) + + def command(self, node): + """ + :param node: data that resembles an mpv_node; can be a list of such nodes. + """ + return self.sanitize(_mpv.command(self, node)) + + async_call_table: dict = {} + async_last_id = 0 + + def command_node_async(self, node): + self.async_last_id += 1 + return _mpv.command_node_async(self, self.async_last_id, node) + + def command_node_async_callback(self, node): + def decorate(callback): + res = self.command_node_async(node) + if not res: + error = None # TODO: figure out error + self.add_timeout(0, callback, False, None, error) + return res + t = {"callback": callback, "id": self.async_last_id} + self.async_call_table[self.async_last_id] = t + return t + return decorate + + def abort_async_command(self, t): + if command_id := t["id"]: + _mpv.abort_async_command(self, command_id) + + def find_config_file(self, filename): + return _mpv.find_config_file(self, filename) + + def request_event(self, name): + if name not in self.enabled_events: + try: + self.debug(f"Requesting event {name} (enable)") + return _mpv.request_event(self, name, 1) # _mpv.request_event(mpv, event, enable) + finally: + self.enabled_events.append(name) + + hooks: dict = {} + hook_id = 0 + + def add_hook(self, name, priority): + def decorate(func): + self.hook_id += 1 + if name not in self.hooks: + self.hooks[name] = {} + self.hooks[name][self.hook_id] = func + _mpv.hook_add(self, self.hook_id, name, priority) + return decorate + + def run_hook(self, hook_name, reply_userdata): + self.call_catch_ex(self.hooks[hook_name][reply_userdata]) + + def enable_messages(self, level): + return _mpv.enable_messages(self, level) + + def observe_property(self, property_name, mpv_format): + def decorate(func): + self.next_bid += 1 + self.observe_properties[self.next_bid] = { + "callback": func, "id": self.next_bid, + } + _mpv.observe_property(self, self.next_bid, property_name, mpv_format) + return decorate + + + def unobserve_property(self, id): # noqa: A002 + if id not in self.observe_properties: + self.error(f"Unknown property observer id: {id}") + return + _mpv.unobserve_property(self, id) + del self.observe_properties[id] + + def set_property(self, property_name, mpv_format, data): + """ + :param str name: name of the property. + + """ + if not (isinstance(property_name, str) and mpv_format in range(1, 10)): + self.error("TODO: have a pointer to doc string") + return + return _mpv.set_property(self, property_name, mpv_format, data) + + MPV_FORMAT_NODE = 0 + MPV_FORMAT_STRING = 1 + MPV_FORMAT_OSD_STRING = 2 + MPV_FORMAT_FLAG = 3 + MPV_FORMAT_INT64 = 4 + MPV_FORMAT_DOUBLE = 5 + MPV_FORMAT_NODE = 6 + MPV_FORMAT_NODE_ARRAY = 7 + MPV_FORMAT_NODE_MAP = 8 + MPV_FORMAT_BYTE_ARRAY = 9 + + def set_property_string(self, name, data): + return self.set_property(name, self.MPV_FORMAT_STRING, str(data)) + + def set_property_osd(self, name, data): + return self.set_property(name, self.MPV_FORMAT_OSD_STRING, str(data)) + + def set_property_bool(self, name, data): + return self.set_property(name, self.MPV_FORMAT_FLAG, 1 if bool(data) else 0) + + def set_property_int(self, name, data): + return self.set_property(name, self.MPV_FORMAT_INT64, int(data)) + + def set_property_float(self, name, data): + return self.set_property(name, self.MPV_FORMAT_DOUBLE, float(data)) + + def set_property_node(self, name, data): + return self.set_property(name, self.MPV_FORMAT_NODE, data) + + def del_property(self, name): + return _mpv.del_property(self, name) + + def get_property(self, property_name, mpv_format): + if not (isinstance(property_name, str) and mpv_format in range(1, 10)): + self.error("TODO: have a pointer to doc string") + return + return self.sanitize(_mpv.get_property(self, property_name, mpv_format)) + + def get_property_string(self, name): + return self.get_property(name, self.MPV_FORMAT_STRING) + + def get_property_osd(self, name): + return self.get_property(name, self.MPV_FORMAT_OSD_STRING) + + def get_property_bool(self, name): + return bool(self.get_property(name, self.MPV_FORMAT_FLAG)) + + def get_property_int(self, name): + return self.get_property(name, self.MPV_FORMAT_INT64) + + def get_property_float(self, name): + return self.get_property(name, self.MPV_FORMAT_DOUBLE) + + def get_property_node(self, name): + return self.get_property(name, self.MPV_FORMAT_NODE) + + def mpv_input_define_section(self, name, location, contents, builtin, owner): + self.debug("define_section args:", name, location, contents, builtin, owner) + return _mpv.mpv_input_define_section(self, name, location, contents, builtin, owner) + + # If a key binding is not defined in the current section, do not search the + # other sections for it (like the default section). Instead, an unbound + # key warning will be printed. + MP_INPUT_EXCLUSIVE = 1 + # Prefer it to other sections. + MP_INPUT_ON_TOP = 2 + # Let mp_input_test_dragging() return true, even if inside the mouse area. + MP_INPUT_ALLOW_VO_DRAGGING = 4 + # Don't force mouse pointer visible, even if inside the mouse area. + MP_INPUT_ALLOW_HIDE_CURSOR = 8 + + def mpv_input_enable_section(self, name, flags): + """ + Args: + flags: bitwise flags from the values self.MP_INPUT_* + `or` (|) them to pass multiple flags. + """ + return _mpv.mpv_input_enable_section(self, name, flags) + + def set_key_bindings_input_section(self): + location = f"py_{self.name}_bs" + + builtin_binds = "\n".join(sorted( + [binding["input"] for binding in self.binds.values() \ + if binding["builtin"] and binding.get("input")])) + if builtin_binds: + name = f"py_{self.name}_kbs_builtin" + self.mpv_input_define_section(name, location, "\n" + builtin_binds, True, self.name) + self.mpv_input_enable_section(name, self.MP_INPUT_ON_TOP) + + reg_binds = "\n".join(sorted( + [binding["input"] for binding in self.binds.values() \ + if not binding["builtin"] and binding.get("input")])) + if reg_binds: + name = f"py_{self.name}_kbs" + self.mpv_input_define_section(name, location, "\n" + reg_binds, False, self.name) + self.mpv_input_enable_section(name, self.MP_INPUT_ON_TOP) + + def set_input_sections(self): + self.set_key_bindings_input_section() + + def flush(self): + self.debug(f"Flushing {self.name}") + self.set_input_sections() + self.debug(f"Flushed {self.name}") + + binds: dict = {} + next_bid = 1 + + def add_binding(self, key=None, name=None, builtin=False, **opts): + # copied from defaults.js (not sure what e and emit is yet) + self.debug(f"loading binding {key}") + key_data = opts + self.next_bid += 1 + key_data.update(id=self.next_bid, builtin=builtin) + if name is None: + name = f"keybinding_{key}" # unique name + + def decorate(fn): + key_data["cb"] = fn + + if key is not None: + key_data["input"] = key + " script-binding " + self.name + "/" + name + self.binds[name] = key_data + + return decorate + + def has_binding(self): + return bool(self.binds) + + def enable_client_message(self): + self.debug("enabling client message") + if self.request_event(self.MPV_EVENT_CLIENT_MESSAGE): + self.debug("client-message enabled") + else: + self.debug("failed to enable client-message") + + def notify_observer(self, event): + setattr(state, event["data"]["name"], event["data"]["value"]) + if (p := self.observe_properties.get(event["reply_userdata"], None)) is not None: + self.call_catch_ex(p["callback"], event["data"]["value"]) + + def sanitize(self, value): + if isinstance(value, dict): + return self._traverse_dict(value) + elif isinstance(value, (list, tuple)): + return self._traverse_sequence(value) + else: + return self.sanitize_string(value) + + def _traverse_sequence(self, sequence): + make_tuple = False + if isinstance(sequence, tuple): + make_tuple = True + sequence = list(sequence) + for i, value in enumerate(sequence): + sequence[i] = self.sanitize(value) + if make_tuple: + sequence = tuple(sequence) + return sequence + + def _traverse_dict(self, data): + for key, value in data.items(): + data[key] = self.sanitize(value) + return data + + def sanitize_string(self, value): + def sanitize(): + if isinstance(value, bytes): + return value.decode("utf-8") + return value + return self.call_catch_ex(sanitize, default=value, log_level=mpv.MPV_LOG_LEVEL_WARN) + + def handle_event(self, event): + """ + Returns: + boolean specifying whether some event loop breaking + condition has been satisfied. + """ + event_id = event["event_id"] + + if event_id != self.MPV_EVENT_NONE: + self.debug(f"event: {event}\n") + else: + return True + + self.process_event(event_id, self.sanitize(event)) + + if event_id == self.MPV_EVENT_SHUTDOWN: + return False + + return True + + def command_reply(self, event): + item_id, data = event["reply_userdata"], event["data"] + t = self.async_call_table[item_id] + cb = t["callback"] + del self.async_call_table[item_id] + + if event["error"]: + self.call_catch_ex(cb, False, None, event["error_message"]) + else: + self.call_catch_ex(cb, True, data, None) + + def run(self): + def _run(): + self.flush() + self.enable_client_message() + + self.register_event(self.MPV_EVENT_COMMAND_REPLY)(self.command_reply) + + self.debug(f"Running {self.name}") + _mpv.run_event_loop(self) + self.clean_up() + self.call_catch_ex(_run) + + def clean_up(self): + self.clear_timers() + + def clear_timers(self): + for name in self.periodic_timer_meta.keys(): + self.disable_timer(name) + for name in list(self.threads.keys()): + self.clear_timer(name) + + +mpv = Mpv() diff --git a/player/python/meson.build b/player/python/meson.build new file mode 100644 index 0000000000000..6992b1a5c3e81 --- /dev/null +++ b/player/python/meson.build @@ -0,0 +1,9 @@ +python_files = ['defaults.py'] +foreach file: python_files + python_file = custom_target(file, + input: join_paths(source_root, 'player', 'python', file), + output: file + '.inc', + command: [file2string, '@INPUT@', '@OUTPUT@', '@SOURCE_ROOT@'], + ) + sources += python_file +endforeach diff --git a/player/scripting.c b/player/scripting.c index cb3257e1b4fe5..112243bfb2168 100644 --- a/player/scripting.c +++ b/player/scripting.c @@ -42,6 +42,7 @@ extern const struct mp_scripting mp_scripting_lua; extern const struct mp_scripting mp_scripting_cplugin; extern const struct mp_scripting mp_scripting_js; +extern const struct mp_scripting mp_scripting_py; extern const struct mp_scripting mp_scripting_run; static const struct mp_scripting *const scripting_backends[] = { @@ -51,6 +52,9 @@ static const struct mp_scripting *const scripting_backends[] = { #if HAVE_CPLUGINS &mp_scripting_cplugin, #endif +#if HAVE_PYTHON + &mp_scripting_py, +#endif #if HAVE_JAVASCRIPT &mp_scripting_js, #endif