Compare commits
86 Commits
Author | SHA1 | Date |
---|---|---|
Nguyễn Gia Phong | 30c0f00b86 | |
Nguyễn Gia Phong | 7575707703 | |
Nguyễn Gia Phong | cee431495d | |
Nguyễn Gia Phong | d70dcf5166 | |
Nguyễn Gia Phong | e824f80116 | |
Nguyễn Gia Phong | 31c6532956 | |
Nguyễn Gia Phong | f0b95cfa86 | |
Nguyễn Gia Phong | 408e3c869f | |
Nguyễn Gia Phong | 0066d60a4e | |
Nguyễn Gia Phong | 21937f383c | |
Nguyễn Gia Phong | b37b4440ac | |
Nguyễn Gia Phong | 27d0c98469 | |
Nguyễn Gia Phong | 4928ee1c6e | |
Nguyễn Gia Phong | fd853b559e | |
Nguyễn Gia Phong | 5be79fd930 | |
Nguyễn Gia Phong | 8bdee9017e | |
Nguyễn Gia Phong | a038e355e7 | |
Nguyễn Gia Phong | 1cd22a5ce9 | |
Nguyễn Gia Phong | 63a3267b6e | |
Nguyễn Gia Phong | ae7618c807 | |
Nguyễn Gia Phong | bdd2ac61cf | |
Nguyễn Gia Phong | d01194223d | |
Nguyễn Gia Phong | 894fa77fd6 | |
Nguyễn Gia Phong | c224c24b84 | |
Nguyễn Gia Phong | 36f73c36f1 | |
Nguyễn Gia Phong | 8a79cafddd | |
Nguyễn Gia Phong | fefa352ae1 | |
Nguyễn Gia Phong | 14a8c21c24 | |
Nguyễn Gia Phong | 861c2e4612 | |
Nguyễn Gia Phong | 082cdc3222 | |
Nguyễn Gia Phong | 202280f760 | |
Nguyễn Gia Phong | f5ea68509e | |
jaseg | 8b9411ec54 | |
Frechdachs | 9cc3a25c73 | |
jaseg | 9fa18058ad | |
jaseg | eb8b6a05d7 | |
jaseg | efbf182723 | |
jaseg | 667ec6f180 | |
jaseg | b71e8a4a55 | |
jaseg | 1ee4361bdd | |
jaseg | 669c4bbfec | |
jaseg | be8d6897eb | |
jaseg | aaddc52da4 | |
jaseg | ab8b8b5477 | |
jaseg | de7b671103 | |
jaseg | 4d6c17d342 | |
jaseg | 97d929e27f | |
jaseg | a8be9bd534 | |
jaseg | adfe131be9 | |
Frechdachs | b6d2b514d5 | |
jaseg | 230f0078fe | |
jaseg | 1feab17c18 | |
jaseg | 1bde43a4d8 | |
jaseg | 1acc39885f | |
jaseg | dc1c4d89a5 | |
jaseg | 14bb2c3223 | |
jaseg | 2bff338c83 | |
jaseg | 7c1343f03d | |
jaseg | a960a2ed4e | |
jaseg | dfdc201ac7 | |
jaseg | 24c70a5ac8 | |
jaseg | 47919b0ca8 | |
jaseg | 0c5530aa27 | |
jaseg | 588d74938c | |
jaseg | 73f2e87f66 | |
jaseg | ae8770df30 | |
jaseg | 35de5ecc93 | |
jaseg | 8d8b061fcc | |
jaseg | f3cab6cdf1 | |
jaseg | 51869a9184 | |
Frechdachs | 1dd8329a42 | |
Frechdachs | ac30a66ed4 | |
Frechdachs | c42fe539ab | |
Frechdachs | bcd8166829 | |
Frechdachs | ee8316a282 | |
jaseg | 5605380fcb | |
Frechdachs | ec91bf3e57 | |
jaseg | 694a2c2c62 | |
jaseg | a1c170d6ff | |
jaseg | fb1704c4dc | |
jaseg | 28b761627f | |
jaseg | df80c10799 | |
jaseg | b19b37708b | |
jaseg | f74f0a3191 | |
jaseg | baee4f4e22 | |
jaseg | 2e6516aa32 |
|
@ -1,3 +1,5 @@
|
||||||
|
MANIFEST
|
||||||
|
build
|
||||||
|
comp.egg-info
|
||||||
dist
|
dist
|
||||||
mpv.egg-info
|
|
||||||
__pycache__
|
__pycache__
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
include LICENSE README.rst doc/screenshot.png
|
|
@ -1,4 +0,0 @@
|
||||||
python-mpv
|
|
||||||
==========
|
|
||||||
|
|
||||||
python-mpv is a ctypes-based python interface to the mpv media player. It gives you more or less full control of all features of the player, just as the lua interface does.
|
|
|
@ -0,0 +1,177 @@
|
||||||
|
===============================
|
||||||
|
comp - Curses Omni Media Player
|
||||||
|
===============================
|
||||||
|
|
||||||
|
**comp** is a `mpv <https://mpv.io/>`_ front-end using curses. It has basic
|
||||||
|
media player functions and can to extract playlists from multiple sources such
|
||||||
|
as media sites supported by `youtube-dl <https://rg3.github.io/youtube-dl/>`_,
|
||||||
|
local and direct URL to video/audio and its own JSON playlist format.
|
||||||
|
|
||||||
|
.. image:: https://github.com/McSinyx/comp/raw/master/doc/screenshot.png
|
||||||
|
|
||||||
|
Installation
|
||||||
|
------------
|
||||||
|
|
||||||
|
**comp** requires Python 3.5+ with ``curses`` module (only available on
|
||||||
|
Unix-like OSes such as GNU/Linux and the BSDs) and ``libmpv`` (available as
|
||||||
|
``libmpv1`` in Debian/Ubuntu, openSUSE; and as ``mpv`` in Arch Linux, Gentoo,
|
||||||
|
macOS Homebrew repository). It also depends on ``python-mpv`` and
|
||||||
|
``youtube-dl`` but the setup program will automatically install them if they
|
||||||
|
are missing.
|
||||||
|
|
||||||
|
As ``setuptools`` will `install in an egg and cause breakage
|
||||||
|
<https://github.com/McSinyx/comp/issues/5>`_, using ``pip`` (Python 3 version)
|
||||||
|
is a must. After `installing it <https://pip.pypa.io/en/latest/installing/>`_,
|
||||||
|
run ``pip3 install comp`` (you might want to add ``--user`` flag to use the
|
||||||
|
`User Scheme <https://pip.pypa.io/en/stable/user_guide/#user-installs>`_).
|
||||||
|
|
||||||
|
For developers, clone the `Github repo <https://github.com/McSinyx/comp>`_ then
|
||||||
|
simply run the ``comp`` executable to test the program. If you insist on
|
||||||
|
installing it, still use ``pip3``: ``pip3 install .``. Note that **comp** is
|
||||||
|
distibuted in a ``wheel`` created via ``./setup.py bdist_wheel``.
|
||||||
|
|
||||||
|
Command line options
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
usage: comp [-h] [-v] [-e {json,mpv,youtube-dl}] [-c CONFIG] [--vid VID]
|
||||||
|
[--vo DRIVER] [-f YTDL_FORMAT]
|
||||||
|
playlist
|
||||||
|
|
||||||
|
Curses Omni Media Player
|
||||||
|
|
||||||
|
positional arguments:
|
||||||
|
playlist path or URL to the playlist
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
-v, --version show program's version number and exit
|
||||||
|
-e {json,mpv,youtube-dl}, --extractor {json,mpv,youtube-dl}
|
||||||
|
playlist extractor, default is youtube-dl
|
||||||
|
-c CONFIG, --config CONFIG
|
||||||
|
path to the configuration file
|
||||||
|
--vid VID initial video channel. auto selects the default, no
|
||||||
|
disables video
|
||||||
|
--vo DRIVER specify the video output backend to be used. See
|
||||||
|
VIDEO OUTPUT DRIVERS in mpv(1) for details and
|
||||||
|
descriptions of available drivers
|
||||||
|
-f YTDL_FORMAT, --format YTDL_FORMAT
|
||||||
|
video format/quality to be passed to youtube-dl
|
||||||
|
|
||||||
|
Examples
|
||||||
|
^^^^^^^^
|
||||||
|
|
||||||
|
Open a JSON playlist::
|
||||||
|
|
||||||
|
comp -e json test/playlist.json
|
||||||
|
|
||||||
|
Open a Youtube playlist with video height lower than 720::
|
||||||
|
|
||||||
|
comp -f '[height<720]' https://www.youtube.com/watch?list=PLnk14Iku8QM7R3ARnrj1TwYSZleF-i7jT
|
||||||
|
|
||||||
|
Keyboard control
|
||||||
|
----------------
|
||||||
|
|
||||||
|
Return
|
||||||
|
Start playing.
|
||||||
|
|
||||||
|
Space, ``p``
|
||||||
|
Toggle pause.
|
||||||
|
|
||||||
|
``/``, ``?``
|
||||||
|
Search forward/backward for a pattern.
|
||||||
|
|
||||||
|
``<``, ``>``
|
||||||
|
Go backward/forward in the playlist.
|
||||||
|
|
||||||
|
``A``
|
||||||
|
Toggle mute.
|
||||||
|
|
||||||
|
``D``
|
||||||
|
Delete the current entry.
|
||||||
|
|
||||||
|
``N``
|
||||||
|
Repeat previous search in reverse direction.
|
||||||
|
|
||||||
|
``V``
|
||||||
|
Toggle video.
|
||||||
|
|
||||||
|
``W``
|
||||||
|
Save the current playlist under JSON format.
|
||||||
|
|
||||||
|
``d``
|
||||||
|
Deselect/reselect the current entry.
|
||||||
|
|
||||||
|
``i``
|
||||||
|
Insert playlist.
|
||||||
|
|
||||||
|
``m``, ``M``
|
||||||
|
Cycle forward/backward through playing modes.
|
||||||
|
|
||||||
|
``n``
|
||||||
|
Repeat previous search.
|
||||||
|
|
||||||
|
``o``
|
||||||
|
Open playlist.
|
||||||
|
|
||||||
|
Up, ``k``
|
||||||
|
Move a single line up.
|
||||||
|
|
||||||
|
Down, ``j``
|
||||||
|
Move a single line down.
|
||||||
|
|
||||||
|
Left, ``h``
|
||||||
|
Seek backward 5 seconds.
|
||||||
|
|
||||||
|
Right, ``l``
|
||||||
|
Seek forward 5 seconds.
|
||||||
|
|
||||||
|
Home
|
||||||
|
Move to the beginning of the playlist.
|
||||||
|
|
||||||
|
End
|
||||||
|
Move to the end of the playlist.
|
||||||
|
|
||||||
|
Page Up
|
||||||
|
Move a single page up.
|
||||||
|
|
||||||
|
Page Down
|
||||||
|
Move a single page down.
|
||||||
|
|
||||||
|
F5
|
||||||
|
Redraw the screen content.
|
||||||
|
|
||||||
|
Configuration files
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
If not specified by the ``--config``, (user-specific) configuration file is
|
||||||
|
``~/.config/mpv/settings.ini``. Default configurations
|
||||||
|
are listed below::
|
||||||
|
|
||||||
|
[comp]
|
||||||
|
# Initial playing mode, which can be one of these 8 modes: play-current,
|
||||||
|
# play-all, play-selected, repeat-current, repeat-all, repeat-selected,
|
||||||
|
# shuffle-all and shuffle-selected.
|
||||||
|
play-mode = play-current
|
||||||
|
|
||||||
|
[mpv]
|
||||||
|
# Initial video channel. auto selects the default, no disables video.
|
||||||
|
video = auto
|
||||||
|
# Specify the video output backend to be used. See VIDEO OUTPUT DRIVERS in
|
||||||
|
# mpv(1) man page for details and descriptions of available drivers.
|
||||||
|
video-output =
|
||||||
|
|
||||||
|
[youtube-dl]
|
||||||
|
# Video format/quality to be passed to youtube-dl. See FORMAT SELECTION in
|
||||||
|
# youtube-dl(1) man page for more details and descriptions.
|
||||||
|
format = best
|
||||||
|
|
||||||
|
|
||||||
|
Bugs
|
||||||
|
----
|
||||||
|
|
||||||
|
Media durations are not extracted from online playlists as
|
||||||
|
``youtube-dl.YoutubeDL`` option ``extract_flat`` is set to ``'in_playlist'``.
|
||||||
|
This is rather a feature to save up bandwidth than a bug because a track's
|
||||||
|
duration is updated when it's played.
|
|
@ -0,0 +1,442 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# comp - Curses Omni Media Player
|
||||||
|
#
|
||||||
|
# comp is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# comp program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with comp. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# Copyright (C) 2017 Nguyễn Gia Phong <vn.mcsinyx@gmail.com>
|
||||||
|
|
||||||
|
import curses
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
from collections import deque
|
||||||
|
from configparser import ConfigParser
|
||||||
|
from functools import reduce
|
||||||
|
from gettext import bindtextdomain, gettext as _, textdomain
|
||||||
|
from os import makedirs
|
||||||
|
from os.path import abspath, dirname, expanduser, expandvars
|
||||||
|
from threading import Thread
|
||||||
|
|
||||||
|
from mpv import MPV
|
||||||
|
from pkg_resources import resource_filename
|
||||||
|
from youtube_dl import YoutubeDL
|
||||||
|
|
||||||
|
from omp import extract_info, Omp
|
||||||
|
|
||||||
|
# Init gettext
|
||||||
|
bindtextdomain('omp', resource_filename('omp', 'locale'))
|
||||||
|
textdomain('omp')
|
||||||
|
|
||||||
|
# Global constants
|
||||||
|
SYSTEM_CONFIG = '/etc/comp/settings.ini'
|
||||||
|
USER_CONFIG = expanduser('~/.config/comp/settings.ini')
|
||||||
|
MODES = ("play-current", "play-all", "play-selected", "repeat-current",
|
||||||
|
"repeat-all", "repeat-selected", "shuffle-all", "shuffle-selected")
|
||||||
|
MODE_STR_LEN = max(len(_(mode)) for mode in MODES)
|
||||||
|
DURATION_COL_LEN = max(len(_("Duration")), 8)
|
||||||
|
|
||||||
|
|
||||||
|
def justified(s, width):
|
||||||
|
"""Return s left-justified of length width."""
|
||||||
|
return s.ljust(width)[:width]
|
||||||
|
|
||||||
|
|
||||||
|
class Comp(Omp):
|
||||||
|
"""Meta object for drawing and playing.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
entries (list): list of all tracks
|
||||||
|
json_file (str): path to save JSON playlist
|
||||||
|
mode (str): the mode to pick and play tracks
|
||||||
|
mp (MPV): an mpv instance
|
||||||
|
play_backward (bool): flag show if to play the previous track
|
||||||
|
play_list (list): list of tracks according to mode
|
||||||
|
played (list): list of previously played tracks
|
||||||
|
playing (int): index of playing track in played
|
||||||
|
playlist (iterator): iterator of tracks according to mode
|
||||||
|
reading (bool): flag show if user input is being read
|
||||||
|
search_res (iterator): title-searched results
|
||||||
|
scr (curses WindowObject): curses window object
|
||||||
|
start (int): index of the first track to be printed on screen
|
||||||
|
vid (str): flag show if video output is enabled
|
||||||
|
y (int): the current y-coordinate
|
||||||
|
"""
|
||||||
|
def __new__(cls, entries, json_file, mode, mpv_vid, mpv_vo, ytdlf):
|
||||||
|
self = object.__new__(cls)
|
||||||
|
self.play_backward, self.reading = False, False
|
||||||
|
self.playing, self.start, self.y = -1, 0, 1
|
||||||
|
self.json_file, self.mode, self.vid = json_file, mode, mpv_vid
|
||||||
|
self.entries, self.played = entries, []
|
||||||
|
self.playlist, self.search_res = iter(()), deque()
|
||||||
|
self.mp = MPV(input_default_bindings=True, input_vo_keyboard=True,
|
||||||
|
ytdl=True, ytdl_format=ytdlf)
|
||||||
|
self.scr = curses.initscr()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def adds(self, s, y, x=0, X=-1, attr=curses.A_NORMAL, lpad=1):
|
||||||
|
"""Paint the string s, added lpad spaces to the left, from
|
||||||
|
(y, x) to (y, X) with attributes attr, overwriting anything
|
||||||
|
previously on the display.
|
||||||
|
"""
|
||||||
|
if self.reading: return
|
||||||
|
y %= curses.LINES
|
||||||
|
x %= curses.COLS
|
||||||
|
length = X % curses.COLS - x + (y != curses.LINES - 1)
|
||||||
|
self.scr.addstr(y, x, (' '*lpad + s).ljust(length)[:length], attr)
|
||||||
|
|
||||||
|
def update_status(self, message='', msgattr=curses.A_NORMAL):
|
||||||
|
"""Update the status lines at the bottom of the screen."""
|
||||||
|
def add_status_str(s, x=0, X=-1, attr=curses.color_pair(12), lpad=1):
|
||||||
|
self.adds(s, curses.LINES - 2, x=x, X=X, attr=attr, lpad=lpad)
|
||||||
|
|
||||||
|
if self.mp.osd.duration is not None:
|
||||||
|
self.played[self.playing]['duration'] = self.mp.osd.duration
|
||||||
|
add_status_str(':', X=5, lpad=3)
|
||||||
|
if self.vid != 'no': add_status_str('V', x=1, X=2)
|
||||||
|
if not self.mp.mute: add_status_str('A', X=1)
|
||||||
|
add_status_str(self.mp.osd.time_pos or '00:00:00', x=4, X=12)
|
||||||
|
add_status_str('/', x=13, X=14)
|
||||||
|
add_status_str(self.mp.osd.duration or '00:00:00', x=15, X=23)
|
||||||
|
add_status_str('|' if self.mp.pause else '>', x=24, X=25)
|
||||||
|
add_status_str(self.mp.media_title or '', x=26,
|
||||||
|
attr=curses.color_pair(12)|curses.A_BOLD)
|
||||||
|
add_status_str(_(self.mode), x=-2-len(_(self.mode)))
|
||||||
|
self.scr.refresh()
|
||||||
|
|
||||||
|
def print_msg(self, message, error=False):
|
||||||
|
attributes = curses.color_pair(1) if error else curses.A_NORMAL
|
||||||
|
self.adds(message, curses.LINES-1, attr=attributes, lpad=0)
|
||||||
|
self.scr.refresh()
|
||||||
|
|
||||||
|
def setno(self, *keys):
|
||||||
|
"""Set all keys of each entry in entries to False."""
|
||||||
|
for entry in self.entries:
|
||||||
|
for key in keys:
|
||||||
|
entry[key] = False
|
||||||
|
|
||||||
|
def play(self, force=False):
|
||||||
|
"""Play the next track."""
|
||||||
|
def mpv_play(entry, force):
|
||||||
|
self.setno('playing')
|
||||||
|
entry['playing'] = True
|
||||||
|
self.mp.vid = self.vid
|
||||||
|
try:
|
||||||
|
self.mp.play(entry['filename'])
|
||||||
|
except:
|
||||||
|
entry['error'] = True
|
||||||
|
self.print(entry)
|
||||||
|
if force: self.mp.pause = False
|
||||||
|
self.mp.wait_for_playback()
|
||||||
|
self.play()
|
||||||
|
entry['playing'] = False
|
||||||
|
self.print(entry)
|
||||||
|
|
||||||
|
if self.play_backward and -self.playing < len(self.played):
|
||||||
|
self.playing -= 1
|
||||||
|
t = self.played[self.playing], force
|
||||||
|
elif self.playing < -1:
|
||||||
|
self.playing += 1
|
||||||
|
t = self.played[self.playing], force
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self.played.append(next(self.playlist))
|
||||||
|
except StopIteration:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
t = self.played[-1], force
|
||||||
|
|
||||||
|
self.play_backward = False
|
||||||
|
play_thread = Thread(target=mpv_play, args=t, daemon=True)
|
||||||
|
play_thread.start()
|
||||||
|
|
||||||
|
def _writeln(self, y, title, duration, attr):
|
||||||
|
title_len = curses.COLS - DURATION_COL_LEN - 3
|
||||||
|
self.adds(title, y, attr=attr)
|
||||||
|
self.adds(duration, y, x=title_len+1, attr=attr)
|
||||||
|
self.scr.refresh()
|
||||||
|
|
||||||
|
def print(self, entry=None, y=None):
|
||||||
|
"""Print the entry in the line y."""
|
||||||
|
if entry is y is None:
|
||||||
|
entry = self.current()
|
||||||
|
y = self.idx() - self.start + 1
|
||||||
|
elif entry is None:
|
||||||
|
entry = self.entries[self.start + y - 1]
|
||||||
|
elif y is None:
|
||||||
|
y = self.idx(entry) - self.start + 1
|
||||||
|
if y < 1 or y > curses.LINES - 3: return
|
||||||
|
|
||||||
|
c = {'error': 1, 'playing': 3, 'selected': 5}
|
||||||
|
color = ((8 if entry is self.current() else 0)
|
||||||
|
| reduce(int.__xor__, (c.get(i, 0) for i in entry if entry[i])))
|
||||||
|
if color:
|
||||||
|
self._writeln(y, entry['title'], entry['duration'],
|
||||||
|
curses.color_pair(color) | curses.A_BOLD)
|
||||||
|
else:
|
||||||
|
self._writeln(y, entry['title'], entry['duration'],
|
||||||
|
curses.A_NORMAL)
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
"""Redraw the whole screen."""
|
||||||
|
self._writeln(0, _("Title"), _("Duration"),
|
||||||
|
curses.color_pair(10) | curses.A_BOLD)
|
||||||
|
for i, entry in enumerate(self.entries[self.start:][:curses.LINES-3]):
|
||||||
|
self.print(entry, i + 1)
|
||||||
|
self.scr.clrtobot()
|
||||||
|
self.update_status()
|
||||||
|
|
||||||
|
def property_handler(self, name, val): self.update_status()
|
||||||
|
|
||||||
|
def __init__(self, entries, json_file, mode, mpv_vid, mpv_vo, ytdlf):
|
||||||
|
curses.noecho()
|
||||||
|
curses.cbreak()
|
||||||
|
self.scr.keypad(True)
|
||||||
|
curses.curs_set(False)
|
||||||
|
curses.start_color()
|
||||||
|
curses.use_default_colors()
|
||||||
|
for i in range(1, 8): curses.init_pair(i, i, -1)
|
||||||
|
curses.init_pair(8, -1, 7)
|
||||||
|
for i in range(1, 7): curses.init_pair(i + 8, -1, i)
|
||||||
|
Omp.__init__(self, entries, json_file, mode, mpv_vid, mpv_vo, ytdlf)
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def __enter__(self): return self
|
||||||
|
|
||||||
|
def idx(self, entry=None):
|
||||||
|
"""Return the index of the current entry."""
|
||||||
|
if entry is None:
|
||||||
|
return self.start + self.y - 1
|
||||||
|
return self.entries.index(entry)
|
||||||
|
|
||||||
|
def current(self):
|
||||||
|
"""Return the current entry."""
|
||||||
|
try:
|
||||||
|
return self.entries[self.idx()]
|
||||||
|
except:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def read_input(self, prompt):
|
||||||
|
"""Print the prompt string at the bottom of the screen then read
|
||||||
|
from standard input.
|
||||||
|
"""
|
||||||
|
self.adds(prompt, curses.LINES - 1, lpad=0)
|
||||||
|
self.reading = True
|
||||||
|
curses.curs_set(True)
|
||||||
|
curses.echo()
|
||||||
|
b = self.scr.getstr(curses.LINES - 1, len(prompt))
|
||||||
|
self.reading = False
|
||||||
|
curses.curs_set(False)
|
||||||
|
curses.noecho()
|
||||||
|
return b.decode()
|
||||||
|
|
||||||
|
def move(self, delta):
|
||||||
|
"""Move to the relatively next delta entry."""
|
||||||
|
if not (self.entries and delta): return
|
||||||
|
start, prev_entry = self.start, self.current()
|
||||||
|
maxy = min(len(self.entries), curses.LINES - 3)
|
||||||
|
|
||||||
|
if self.idx() + delta <= 0:
|
||||||
|
self.start, self.y = 0, 1
|
||||||
|
elif self.idx() + delta >= len(self.entries):
|
||||||
|
self.start, self.y = len(self.entries) - maxy, maxy
|
||||||
|
elif self.y + delta < 1:
|
||||||
|
self.start += self.y + delta - 1
|
||||||
|
self.y = 1
|
||||||
|
elif self.y + delta > curses.LINES - 3:
|
||||||
|
self.start += self.y + delta - maxy
|
||||||
|
self.y = maxy
|
||||||
|
else:
|
||||||
|
self.y += delta
|
||||||
|
|
||||||
|
if self.start == start:
|
||||||
|
self.print(prev_entry)
|
||||||
|
self.print()
|
||||||
|
else:
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def search(self, backward=False):
|
||||||
|
"""Prompt then search for a pattern."""
|
||||||
|
p = re.compile(self.read_input('/'), re.IGNORECASE)
|
||||||
|
entries = deque(self.entries)
|
||||||
|
entries.rotate(-self.idx())
|
||||||
|
self.search_res = deque(filter(
|
||||||
|
lambda entry: p.search(entry['title']) is not None, entries))
|
||||||
|
if backward: self.search_res.reverse()
|
||||||
|
if self.search_res:
|
||||||
|
self.move(self.idx(self.search_res[0]) - self.idx())
|
||||||
|
else:
|
||||||
|
self.print_msg(_("Pattern not found"), error=True)
|
||||||
|
|
||||||
|
def next_search(self, backward=False):
|
||||||
|
"""Repeat previous search."""
|
||||||
|
if self.search_res:
|
||||||
|
self.search_res.rotate(1 if backward else -1)
|
||||||
|
self.move(self.idx(self.search_res[0]) - self.idx())
|
||||||
|
else:
|
||||||
|
self.print_msg(_("Pattern not found"), error=True)
|
||||||
|
|
||||||
|
def resize(self):
|
||||||
|
curses.update_lines_cols()
|
||||||
|
self.scr.clear()
|
||||||
|
l = curses.LINES - 3
|
||||||
|
if curses.COLS < MODE_STR_LEN + 42 or l < 1: # too small
|
||||||
|
sizeerr = _("Current size: {}x{}. Minimum size: {}x4.").format(
|
||||||
|
curses.COLS, curses.LINES, MODE_STR_LEN + 42)
|
||||||
|
self.scr.addstr(0, 0, sizeerr[:curses.LINES*curses.COLS-1])
|
||||||
|
self.scr.refresh()
|
||||||
|
elif self.y > l: # shorter than the current entry
|
||||||
|
self.start += self.y - l
|
||||||
|
self.y = l
|
||||||
|
self.refresh()
|
||||||
|
elif 0 < self.start > len(self.entries) - l: # longer than the list
|
||||||
|
idx, self.start = self.idx(), min(0, len(self.entries) - l)
|
||||||
|
self.y = idx - self.start + 1
|
||||||
|
if self.y > l:
|
||||||
|
self.start += self.y - l
|
||||||
|
self.y = l
|
||||||
|
self.refresh()
|
||||||
|
else:
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
|
curses.nocbreak()
|
||||||
|
self.scr.keypad(False)
|
||||||
|
curses.echo()
|
||||||
|
curses.endwin()
|
||||||
|
Omp.__exit__(self, exc_type, exc_value, traceback)
|
||||||
|
|
||||||
|
|
||||||
|
parser = ArgumentParser(description='Curses Omni Media Player')
|
||||||
|
parser.add_argument('-v', '--version', action='version',
|
||||||
|
version='%(prog)s 0.3.11')
|
||||||
|
parser.add_argument('-e', '--extractor', default='youtube-dl',
|
||||||
|
choices=('json', 'mpv', 'youtube-dl'), required=False,
|
||||||
|
help='playlist extractor, default is youtube-dl')
|
||||||
|
parser.add_argument('playlist', help='path or URL to the playlist')
|
||||||
|
parser.add_argument('-c', '--config', default=USER_CONFIG, required=False,
|
||||||
|
help='path to the configuration file')
|
||||||
|
parser.add_argument('--vid', required=False,
|
||||||
|
help='initial video channel. auto selects the default, no\
|
||||||
|
disables video')
|
||||||
|
parser.add_argument('--vo', required=False, metavar='DRIVER',
|
||||||
|
help='specify the video output backend to be used. See\
|
||||||
|
VIDEO OUTPUT DRIVERS in mpv(1) for details and\
|
||||||
|
descriptions of available drivers')
|
||||||
|
parser.add_argument('-f', '--format', required=False, metavar='YTDL_FORMAT',
|
||||||
|
help='video format/quality to be passed to youtube-dl')
|
||||||
|
args = parser.parse_args()
|
||||||
|
entries = extract_info(args.playlist, args.extractor)
|
||||||
|
if entries is None:
|
||||||
|
print(_("'{}': Can't extract playlist").format(args.playlist))
|
||||||
|
exit()
|
||||||
|
json_file = args.playlist if args.extractor == 'json' else ''
|
||||||
|
config = ConfigParser()
|
||||||
|
config.read(args.config)
|
||||||
|
vid = args.vid or config.get('mpv', 'video', fallback='auto')
|
||||||
|
vo = args.vo or config.get('mpv', 'video-output', fallback=None)
|
||||||
|
mode = config.get('comp', 'play-mode', fallback='play-current')
|
||||||
|
ytdlf = args.format or config.get('youtube-dl', 'format', fallback='best')
|
||||||
|
|
||||||
|
with Comp(entries, json_file, mode, vid, vo, ytdlf) as comp:
|
||||||
|
c = comp.scr.getch()
|
||||||
|
while c != 113: # letter q
|
||||||
|
if c == 10: # curses.KEY_ENTER doesn't work
|
||||||
|
comp.update_playlist()
|
||||||
|
comp.next(force=True)
|
||||||
|
elif c in (32, 112): # space or letter p
|
||||||
|
comp.mp.pause ^= True
|
||||||
|
elif c == 47: # /
|
||||||
|
comp.search()
|
||||||
|
elif c == 60: # <
|
||||||
|
try:
|
||||||
|
if comp.mp.time_pos < 1:
|
||||||
|
comp.next(backward=True)
|
||||||
|
else:
|
||||||
|
comp.seek(0, 'absolute')
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
elif c == 62: # >
|
||||||
|
comp.next()
|
||||||
|
elif c == 63: # ?
|
||||||
|
comp.search(backward=True)
|
||||||
|
elif c == 65: # letter A
|
||||||
|
comp.mp.mute ^= True # hack to toggle bool value
|
||||||
|
elif c == 68: # letter D
|
||||||
|
comp.entries.pop(comp.idx())
|
||||||
|
if 1 < len(comp.entries) - curses.LINES + 4 == comp.start:
|
||||||
|
comp.start -= 1
|
||||||
|
elif comp.idx() == len(comp.entries):
|
||||||
|
comp.y -= 1
|
||||||
|
comp.refresh()
|
||||||
|
elif c == 77: # letter M
|
||||||
|
comp.mode = MODES[(MODES.index(comp.mode) - 1) % 8]
|
||||||
|
comp.update_status()
|
||||||
|
elif c == 78: # letter N
|
||||||
|
comp.next_search(backward=True)
|
||||||
|
elif c == 86: # letter V
|
||||||
|
comp.vid = 'auto' if comp.vid == 'no' else 'no'
|
||||||
|
comp.mp.vid = comp.vid
|
||||||
|
comp.update_status()
|
||||||
|
elif c == 87: # letter W
|
||||||
|
comp.dump_json()
|
||||||
|
elif c == 100: # letter d
|
||||||
|
comp.current()['selected'] = not comp.current().get('selected')
|
||||||
|
comp.move(1)
|
||||||
|
elif c == 105: # letter i
|
||||||
|
extractor = comp.read_input(_("Playlist extractor: "))
|
||||||
|
filename = comp.read_input(_("Insert: "))
|
||||||
|
entries = extract_info(filename, extractor)
|
||||||
|
if entries is None:
|
||||||
|
comp.print_msg(
|
||||||
|
_("'{}': Can't extract playlist").format(filename))
|
||||||
|
else:
|
||||||
|
bottom = comp.entries[comp.idx():]
|
||||||
|
comp.entries = comp.entries[:comp.idx()]
|
||||||
|
comp.entries.extend(entries)
|
||||||
|
comp.entries.extend(bottom)
|
||||||
|
comp.refresh()
|
||||||
|
elif c == 109: # letter m
|
||||||
|
comp.mode = MODES[(MODES.index(comp.mode) + 1) % 8]
|
||||||
|
comp.update_status()
|
||||||
|
elif c == 110: # letter n
|
||||||
|
comp.next_search()
|
||||||
|
elif c == 111: # letter o
|
||||||
|
extractor = comp.read_input(_("Playlist extractor: "))
|
||||||
|
filename = comp.read_input(_("Open: "))
|
||||||
|
entries = extract_info(filename, extractor)
|
||||||
|
if entries is None:
|
||||||
|
comp.print_msg(
|
||||||
|
_("'{}': Can't extract playlist").format(filename))
|
||||||
|
else:
|
||||||
|
comp.entries, comp.start, comp.y = entries, 0, 1
|
||||||
|
comp.refresh()
|
||||||
|
elif c in (curses.KEY_UP, 107): # up arrow or letter k
|
||||||
|
comp.move(-1)
|
||||||
|
elif c in (curses.KEY_DOWN, 106): # down arrow or letter j
|
||||||
|
comp.move(1)
|
||||||
|
elif c in (curses.KEY_LEFT, 104): # left arrow or letter h
|
||||||
|
comp.seek(-5, precision='exact')
|
||||||
|
elif c in (curses.KEY_RIGHT, 108): # right arrow or letter l
|
||||||
|
comp.seek(5, precision='exact')
|
||||||
|
elif c == curses.KEY_HOME: # home
|
||||||
|
comp.move(-len(comp.entries))
|
||||||
|
elif c == curses.KEY_END: # end
|
||||||
|
comp.move(len(comp.entries))
|
||||||
|
elif c == curses.KEY_NPAGE: # page down
|
||||||
|
comp.move(curses.LINES - 4)
|
||||||
|
elif c == curses.KEY_PPAGE: # page up
|
||||||
|
comp.move(4 - curses.LINES)
|
||||||
|
elif c in (curses.KEY_F5, curses.KEY_RESIZE):
|
||||||
|
comp.resize()
|
||||||
|
c = comp.scr.getch()
|
|
@ -0,0 +1,142 @@
|
||||||
|
.\" Process this file with
|
||||||
|
.\" groff -man -Tutf8 comp.1
|
||||||
|
.\"
|
||||||
|
.TH COMP 1 2017-06-17 comp
|
||||||
|
.SH NAME
|
||||||
|
comp \- Curses Omni Media Player
|
||||||
|
.SH SYNOPSIS
|
||||||
|
\fBcomp\fR [\fB-h\fR] [\fB-v\fR] [\fB-e\fR {json,mpv,youtube-dl}]
|
||||||
|
[\fB-c \fICONFIG\fR] [\fB--vid \fIVID\fR] [\fB--vo \fIDRIVER\fR]
|
||||||
|
[\fB-f \fIYTDL_FORMAT\fR] \fIplaylist\fR
|
||||||
|
.SH DESCRIPTION
|
||||||
|
\fBcomp\fR is a
|
||||||
|
.BR mpv (1)
|
||||||
|
front-end using curses. It has basic media player functions and can to extract
|
||||||
|
playlists from multiple sources such as media sites supported by
|
||||||
|
.BR youtube-dl (1),
|
||||||
|
local and direct URL to video/audio and its own JSON playlist format.
|
||||||
|
.SH OPTIONS
|
||||||
|
.SS Positional arguments
|
||||||
|
.TP
|
||||||
|
.B playlist
|
||||||
|
path or URL to the playlist
|
||||||
|
.SS Optional arguments
|
||||||
|
.TP
|
||||||
|
.B -h, --help
|
||||||
|
show this help message and exit
|
||||||
|
.TP
|
||||||
|
.B -v, --version
|
||||||
|
show program's version number and exit
|
||||||
|
.TP
|
||||||
|
.B -e \fR{json,mpv,youtube-dl}, \fB--extractor \fR{json,mpv,youtube-dl}
|
||||||
|
playlist extractor, default is \fIyoutube-dl
|
||||||
|
.TP
|
||||||
|
.B -c \fICONFIG, \fB--config \fICONFIG
|
||||||
|
path to the configuration file
|
||||||
|
.TP
|
||||||
|
.B --vid \fIVID
|
||||||
|
initial video channel. \fIauto\fR selects the default, \fIno\fR disables video
|
||||||
|
.TP
|
||||||
|
.B --vo \fIDRIVER
|
||||||
|
specify the video output backend to be used. See
|
||||||
|
.I VIDEO OUTPUT DRIVERS
|
||||||
|
in
|
||||||
|
.BR mpv (1)
|
||||||
|
for details and descriptions of available drivers
|
||||||
|
.TP
|
||||||
|
.B -f \fIYTDL_FORMAT\fR, \fB--format \fIYTDL_FORMAT
|
||||||
|
video format/quality to be passed to youtube-dl
|
||||||
|
.SH KEYBOARD CONTROL
|
||||||
|
.TP
|
||||||
|
.B Return
|
||||||
|
Start playing.
|
||||||
|
.TP
|
||||||
|
.B Space, p
|
||||||
|
Toggle pause.
|
||||||
|
.TP
|
||||||
|
.B /, ?
|
||||||
|
Search forward/backward for a pattern.
|
||||||
|
.TP
|
||||||
|
.B <, >
|
||||||
|
Go backward/forward in the playlist.
|
||||||
|
.TP
|
||||||
|
.B A
|
||||||
|
Toggle mute.
|
||||||
|
.TP
|
||||||
|
.B D
|
||||||
|
Delete the current entry.
|
||||||
|
.TP
|
||||||
|
.B N
|
||||||
|
Repeat previous search in reverse direction.
|
||||||
|
.TP
|
||||||
|
.B V
|
||||||
|
Toggle video.
|
||||||
|
.TP
|
||||||
|
.B W
|
||||||
|
Save the current playlist under JSON format.
|
||||||
|
.TP
|
||||||
|
.B d
|
||||||
|
Deselect/reselect the current entry.
|
||||||
|
.TP
|
||||||
|
.B i
|
||||||
|
Insert playlist.
|
||||||
|
.TP
|
||||||
|
.B m, M
|
||||||
|
Cycle forward/backward through playing modes.
|
||||||
|
.TP
|
||||||
|
.B n
|
||||||
|
Repeat previous search.
|
||||||
|
.TP
|
||||||
|
.B o
|
||||||
|
Open playlist.
|
||||||
|
.TP
|
||||||
|
.B Up, k
|
||||||
|
Move a single line up.
|
||||||
|
.TP
|
||||||
|
.B Down, j
|
||||||
|
Move a single line down.
|
||||||
|
.TP
|
||||||
|
.B Left, h
|
||||||
|
Seek backward 5 seconds.
|
||||||
|
.TP
|
||||||
|
.B Right, l
|
||||||
|
Seek forward 5 seconds.
|
||||||
|
.TP
|
||||||
|
.B Home
|
||||||
|
Move to the beginning of the playlist.
|
||||||
|
.TP
|
||||||
|
.B End
|
||||||
|
Move to the end of the playlist.
|
||||||
|
.TP
|
||||||
|
.B Page Up
|
||||||
|
Move a single page up.
|
||||||
|
.TP
|
||||||
|
.B Page Down
|
||||||
|
Move a single page down.
|
||||||
|
.TP
|
||||||
|
.B F5
|
||||||
|
Redraw the screen content.
|
||||||
|
.SH FILES
|
||||||
|
.TP
|
||||||
|
.I ~/.config/comp/settings.ini
|
||||||
|
per user configuration file
|
||||||
|
.SH EXAMPLES
|
||||||
|
.nf R
|
||||||
|
Open a JSON playlist:
|
||||||
|
.ft B
|
||||||
|
comp -e json test/playlist.json
|
||||||
|
|
||||||
|
.ft R
|
||||||
|
Open a Youtube playlist with video height lower than 720:
|
||||||
|
.ft B
|
||||||
|
comp -f '[height<720]' https://www.youtube.com/watch?list=PLnk14Iku8QM7R3ARnrj1TwYSZleF-i7jT
|
||||||
|
.SH BUGS
|
||||||
|
.PP
|
||||||
|
Media durations are not extracted from online playlists as youtube-dl
|
||||||
|
extract_flat option is used. This is rather a feature to save up bandwidth than
|
||||||
|
a bug because a track's duration is updated when it's played.
|
||||||
|
.SH AUTHOR
|
||||||
|
Written by Nguyễn Gia Phong.
|
||||||
|
.SH "SEE ALSO"
|
||||||
|
.BR mpv (1),
|
||||||
|
.BR youtube-dl (1)
|
Binary file not shown.
After Width: | Height: | Size: 422 KiB |
Binary file not shown.
|
@ -0,0 +1,78 @@
|
||||||
|
# SOME DESCRIPTIVE TITLE.
|
||||||
|
# Copyright (C) YEAR ORGANIZATION
|
||||||
|
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: \n"
|
||||||
|
"POT-Creation-Date: 2017-04-05 11:00+0700\n"
|
||||||
|
"PO-Revision-Date: 2017-04-06 22:29+0700\n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Generated-By: pygettext.py 1.5\n"
|
||||||
|
"X-Generator: Poedit 1.8.11\n"
|
||||||
|
"Last-Translator: \n"
|
||||||
|
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||||
|
"Language: vi_VN\n"
|
||||||
|
|
||||||
|
msgid "Curses Omni Media Player"
|
||||||
|
msgstr "Phần mềm phát đa phương tiện sử dụng thư viện curses"
|
||||||
|
|
||||||
|
msgid "play-current"
|
||||||
|
msgstr "chơi-một"
|
||||||
|
|
||||||
|
msgid "play-all"
|
||||||
|
msgstr "chơi-tất-cả"
|
||||||
|
|
||||||
|
msgid "play-selected"
|
||||||
|
msgstr "chơi-đã-chọn"
|
||||||
|
|
||||||
|
msgid "repeat-current"
|
||||||
|
msgstr "lặp-một"
|
||||||
|
|
||||||
|
msgid "repeat-all"
|
||||||
|
msgstr "lặp-tất-cả"
|
||||||
|
|
||||||
|
msgid "repeat-selected"
|
||||||
|
msgstr "lặp-đã-chọn"
|
||||||
|
|
||||||
|
msgid "shuffle-all"
|
||||||
|
msgstr "ngẫu-nhiên-tất-cả"
|
||||||
|
|
||||||
|
msgid "shuffle-selected"
|
||||||
|
msgstr "ngẫu-nhiên-đã-chọn"
|
||||||
|
|
||||||
|
msgid "Title"
|
||||||
|
msgstr "Tiêu đề"
|
||||||
|
|
||||||
|
msgid "Current size: {}x{}. Minimum size: {}x4."
|
||||||
|
msgstr "Kích thước hiện tại: {}x{}. Kích thước tối thiểu: {}x4."
|
||||||
|
|
||||||
|
msgid "Save playlist to [{}]: "
|
||||||
|
msgstr "Lưu danh sách phát tại [{}]: "
|
||||||
|
|
||||||
|
msgid "'{}': Can't open file for writing"
|
||||||
|
msgstr "'{}': Không mở được tệp để ghi"
|
||||||
|
|
||||||
|
msgid "'{}' written"
|
||||||
|
msgstr "'{}' đã ghi"
|
||||||
|
|
||||||
|
msgid "Duration"
|
||||||
|
msgstr "Thời lượng"
|
||||||
|
|
||||||
|
msgid "Pattern not found"
|
||||||
|
msgstr "Không tìm thấy mẫu (pattern)"
|
||||||
|
|
||||||
|
msgid "'{}': Can't extract playlist"
|
||||||
|
msgstr "'{}': Không mở được danh sách phát"
|
||||||
|
|
||||||
|
msgid "Playlist extractor: "
|
||||||
|
msgstr "Công cụ mở danh sách phát: "
|
||||||
|
|
||||||
|
msgid "Insert: "
|
||||||
|
msgstr "Chèn: "
|
||||||
|
|
||||||
|
msgid "Open: "
|
||||||
|
msgstr "Mở: "
|
594
mpv.py
594
mpv.py
|
@ -1,594 +0,0 @@
|
||||||
|
|
||||||
from ctypes import *
|
|
||||||
import threading
|
|
||||||
import os
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
# vim: ts=4 sw=4
|
|
||||||
|
|
||||||
|
|
||||||
backend = CDLL('libmpv.so')
|
|
||||||
|
|
||||||
|
|
||||||
class MpvHandle(c_void_p):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorCode:
|
|
||||||
""" For documentation on these, see mpv's libmpv/client.h """
|
|
||||||
SUCCESS = 0
|
|
||||||
EVENT_QUEUE_FULL = -1
|
|
||||||
NOMEM = -2
|
|
||||||
UNINITIALIZED = -3
|
|
||||||
INVALID_PARAMETER = -4
|
|
||||||
OPTION_NOT_FOUND = -5
|
|
||||||
OPTION_FORMAT = -6
|
|
||||||
OPTION_ERROR = -7
|
|
||||||
PROPERTY_NOT_FOUND = -8
|
|
||||||
PROPERTY_FORMAT = -9
|
|
||||||
PROPERTY_UNAVAILABLE = -10
|
|
||||||
PROPERTY_ERROR = -11
|
|
||||||
COMMAND = -12
|
|
||||||
|
|
||||||
EXCEPTION_DICT = {
|
|
||||||
0: None,
|
|
||||||
-1: lambda *a: MemoryError('mpv event queue full', *a),
|
|
||||||
-2: lambda *a: MemoryError('mpv cannot allocate memory', *a),
|
|
||||||
-3: lambda *a: ValueError('Uninitialized mpv handle used', *a),
|
|
||||||
-4: lambda *a: ValueError('Invalid value for mpv parameter', *a),
|
|
||||||
-5: lambda *a: AttributeError('mpv option does not exist', *a),
|
|
||||||
-6: lambda *a: TypeError('Tried to set mpv option using wrong format', *a),
|
|
||||||
-7: lambda *a: ValueError('Invalid value for mpv option', *a),
|
|
||||||
-8: lambda *a: AttributeError('mpv property does not exist', *a),
|
|
||||||
-9: lambda *a: TypeError('Tried to set mpv property using wrong format', *a),
|
|
||||||
-10: lambda *a: AttributeError('mpv property is not available', *a),
|
|
||||||
-11: lambda *a: ValueError('Invalid value for mpv property', *a),
|
|
||||||
-12: lambda *a: SystemError('Error running mpv command', *a)
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def raise_for_ec(kls, func, *args):
|
|
||||||
ec = kls.EXCEPTION_DICT[func(*args)]
|
|
||||||
if ec:
|
|
||||||
raise ec(*args)
|
|
||||||
|
|
||||||
|
|
||||||
class MpvFormat(c_int):
|
|
||||||
NONE = 0
|
|
||||||
STRING = 1
|
|
||||||
OSD_STRING = 2
|
|
||||||
FLAG = 3
|
|
||||||
INT64 = 4
|
|
||||||
DOUBLE = 5
|
|
||||||
NODE = 6
|
|
||||||
NODE_ARRAY = 7
|
|
||||||
NODE_MAP = 8
|
|
||||||
|
|
||||||
|
|
||||||
class MpvEventID(c_int):
|
|
||||||
NONE = 0
|
|
||||||
SHUTDOWN = 1
|
|
||||||
LOG_MESSAGE = 2
|
|
||||||
GET_PROPERTY_REPLY = 3
|
|
||||||
SET_PROPERTY_REPLY = 4
|
|
||||||
COMMAND_REPLY = 5
|
|
||||||
START_FILE = 6
|
|
||||||
END_FILE = 7
|
|
||||||
FILE_LOADED = 8
|
|
||||||
TRACKS_CHANGED = 9
|
|
||||||
TRACK_SWITCHED = 10
|
|
||||||
IDLE = 11
|
|
||||||
PAUSE = 12
|
|
||||||
UNPAUSE = 13
|
|
||||||
TICK = 14
|
|
||||||
SCRIPT_INPUT_DISPATCH = 15
|
|
||||||
CLIENT_MESSAGE = 16
|
|
||||||
VIDEO_RECONFIG = 17
|
|
||||||
AUDIO_RECONFIG = 18
|
|
||||||
METADATA_UPDATE = 19
|
|
||||||
SEEK = 20
|
|
||||||
PLAYBACK_RESTART = 21
|
|
||||||
PROPERTY_CHANGE = 22
|
|
||||||
CHAPTER_CHANGE = 23
|
|
||||||
|
|
||||||
ANY = ( SHUTDOWN, LOG_MESSAGE, GET_PROPERTY_REPLY, SET_PROPERTY_REPLY, COMMAND_REPLY, START_FILE, END_FILE,
|
|
||||||
FILE_LOADED, TRACKS_CHANGED, TRACK_SWITCHED, IDLE, PAUSE, UNPAUSE, TICK, SCRIPT_INPUT_DISPATCH,
|
|
||||||
CLIENT_MESSAGE, VIDEO_RECONFIG, AUDIO_RECONFIG, METADATA_UPDATE, SEEK, PLAYBACK_RESTART, PROPERTY_CHANGE,
|
|
||||||
CHAPTER_CHANGE )
|
|
||||||
|
|
||||||
class MpvEvent(Structure):
|
|
||||||
_fields_ = [('event_id', MpvEventID),
|
|
||||||
('error', c_int),
|
|
||||||
('reply_userdata', c_ulonglong),
|
|
||||||
('data', c_void_p)]
|
|
||||||
|
|
||||||
def as_dict(self):
|
|
||||||
dtype = {MpvEventID.END_FILE: MpvEventEndFile,
|
|
||||||
MpvEventID.PROPERTY_CHANGE: MpvEventProperty,
|
|
||||||
MpvEventID.GET_PROPERTY_REPLY: MpvEventProperty,
|
|
||||||
MpvEventID.LOG_MESSAGE: MpvEventLogMessage,
|
|
||||||
MpvEventID.SCRIPT_INPUT_DISPATCH: MpvEventScriptInputDispatch,
|
|
||||||
MpvEventID.CLIENT_MESSAGE: MpvEventClientMessage
|
|
||||||
}.get(self.event_id.value, None)
|
|
||||||
return {'event_id': self.event_id.value,
|
|
||||||
'error': self.error,
|
|
||||||
'reply_userdata': self.reply_userdata,
|
|
||||||
'event': cast(self.data, POINTER(dtype)).contents.as_dict() if dtype else None}
|
|
||||||
|
|
||||||
class MpvEventProperty(Structure):
|
|
||||||
_fields_ = [('name', c_char_p),
|
|
||||||
('format', MpvFormat),
|
|
||||||
('data', c_void_p)]
|
|
||||||
def as_dict():
|
|
||||||
pass # FIXME
|
|
||||||
|
|
||||||
class MpvEventLogMessage(Structure):
|
|
||||||
_fields_ = [('prefix', c_char_p),
|
|
||||||
('level', c_char_p),
|
|
||||||
('text', c_char_p)]
|
|
||||||
|
|
||||||
def as_dict(self):
|
|
||||||
return { name: getattr(self, name).value for name, _t in _fields_ }
|
|
||||||
|
|
||||||
class MpvEventEndFile(c_int):
|
|
||||||
EOF_OR_INIT_FAILURE = 0
|
|
||||||
RESTARTED = 1
|
|
||||||
ABORTED = 2
|
|
||||||
QUIT = 3
|
|
||||||
|
|
||||||
def as_dict(self):
|
|
||||||
return {'reason': self.value}
|
|
||||||
|
|
||||||
class MpvEventScriptInputDispatch(Structure):
|
|
||||||
_fields_ = [('arg0', c_int),
|
|
||||||
('type', c_char_p)]
|
|
||||||
|
|
||||||
def as_dict(self):
|
|
||||||
pass # TODO
|
|
||||||
|
|
||||||
class MpvEventClientMessage(Structure):
|
|
||||||
_fields_ = [('num_args', c_int),
|
|
||||||
('args', POINTER(c_char_p))]
|
|
||||||
|
|
||||||
def as_dict(self):
|
|
||||||
return { 'args': [ self.args[i].value for i in range(self.num_args.value) ] }
|
|
||||||
|
|
||||||
WakeupCallback = CFUNCTYPE(None, c_void_p)
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_func(name, args=[], res=None):
|
|
||||||
func = getattr(backend, name)
|
|
||||||
if res is not None:
|
|
||||||
func.restype = res
|
|
||||||
func.argtypes = [MpvHandle] + args
|
|
||||||
def wrapper(*args):
|
|
||||||
if res is not None:
|
|
||||||
return func(*args)
|
|
||||||
else:
|
|
||||||
ErrorCode.raise_for_ec(func, *args)
|
|
||||||
globals()['_'+name] = wrapper
|
|
||||||
|
|
||||||
backend.mpv_client_api_version.restype = c_ulong
|
|
||||||
def _mpv_client_api_version():
|
|
||||||
ver = backend.mpv_client_api_version()
|
|
||||||
return ver>>16, ver&0xFFFF
|
|
||||||
|
|
||||||
backend.mpv_free.argtypes = [c_void_p]
|
|
||||||
_mpv_free = backend.mpv_free
|
|
||||||
|
|
||||||
backend.mpv_create.restype = MpvHandle
|
|
||||||
_mpv_create = backend.mpv_create
|
|
||||||
|
|
||||||
_handle_func('mpv_client_name', [], c_char_p)
|
|
||||||
_handle_func('mpv_initialize')
|
|
||||||
_handle_func('mpv_detach_destroy', [], c_int)
|
|
||||||
_handle_func('mpv_terminate_destroy', [], c_int)
|
|
||||||
_handle_func('mpv_load_config_file', [c_char_p])
|
|
||||||
_handle_func('mpv_suspend', [], c_int)
|
|
||||||
_handle_func('mpv_resume', [], c_int)
|
|
||||||
_handle_func('mpv_get_time_us', [], c_ulonglong)
|
|
||||||
|
|
||||||
_handle_func('mpv_set_option', [c_char_p, MpvFormat, c_void_p])
|
|
||||||
_handle_func('mpv_set_option_string', [c_char_p, c_char_p])
|
|
||||||
|
|
||||||
_handle_func('mpv_command', [POINTER(c_char_p)])
|
|
||||||
_handle_func('mpv_command_string', [c_char_p, c_char_p])
|
|
||||||
_handle_func('mpv_command_async', [c_ulonglong, POINTER(c_char_p)])
|
|
||||||
|
|
||||||
_handle_func('mpv_set_property', [c_char_p, MpvFormat, c_void_p])
|
|
||||||
_handle_func('mpv_set_property_string', [c_char_p, c_char_p])
|
|
||||||
_handle_func('mpv_set_property_async', [c_ulonglong, c_char_p, MpvFormat, c_void_p])
|
|
||||||
_handle_func('mpv_get_property', [c_char_p, MpvFormat, c_void_p])
|
|
||||||
_handle_func('mpv_get_property_string', [c_char_p], c_char_p)
|
|
||||||
_handle_func('mpv_get_property_osd_string', [c_char_p], c_char_p)
|
|
||||||
_handle_func('mpv_get_property_async', [c_ulonglong, c_char_p, MpvFormat])
|
|
||||||
_handle_func('mpv_observe_property', [c_ulonglong, c_char_p, MpvFormat])
|
|
||||||
_handle_func('mpv_unobserve_property', [c_ulonglong])
|
|
||||||
|
|
||||||
backend.mpv_event_name.restype = c_char_p
|
|
||||||
backend.mpv_event_name.argtypes = [c_int]
|
|
||||||
_mpv_event_name = backend.mpv_event_name
|
|
||||||
|
|
||||||
_handle_func('mpv_request_event', [MpvEventID, c_int])
|
|
||||||
_handle_func('mpv_request_log_messages', [c_char_p])
|
|
||||||
_handle_func('mpv_wait_event', [c_double], POINTER(MpvEvent))
|
|
||||||
_handle_func('mpv_wakeup', [], c_int)
|
|
||||||
_handle_func('mpv_set_wakeup_callback', [WakeupCallback, c_void_p], c_int)
|
|
||||||
_handle_func('mpv_get_wakeup_pipe', [], c_int)
|
|
||||||
|
|
||||||
|
|
||||||
class ynbool:
|
|
||||||
def __init__(self, val=False):
|
|
||||||
if not val or val == b'no' or val == 'no':
|
|
||||||
self.val = False
|
|
||||||
else:
|
|
||||||
self.val = True
|
|
||||||
|
|
||||||
def __nonzero__(self):
|
|
||||||
return self.val
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return 'yes' if self.val else 'no'
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return str(self.val)
|
|
||||||
|
|
||||||
def _ensure_encoding(possibly_bytes):
|
|
||||||
return possibly_bytes.decode('utf8') if type(possibly_bytes) is bytes else possibly_bytes
|
|
||||||
|
|
||||||
|
|
||||||
def _event_generator(handle):
|
|
||||||
while True:
|
|
||||||
event = _mpv_wait_event(handle, 0).contents
|
|
||||||
if event.event_id.value == MpvEventID.NONE:
|
|
||||||
raise StopIteration()
|
|
||||||
yield event
|
|
||||||
|
|
||||||
class MPV:
|
|
||||||
""" See man mpv(1) for the details of the implemented commands. """
|
|
||||||
def __init__(self, loop=None, **kwargs):
|
|
||||||
self.handle = _mpv_create()
|
|
||||||
|
|
||||||
self.event_callbacks = []
|
|
||||||
self._event_fd = _mpv_get_wakeup_pipe(self.handle)
|
|
||||||
self._playback_cond = threading.Condition()
|
|
||||||
def mpv_event_extractor():
|
|
||||||
os.read(self._event_fd, 512)
|
|
||||||
for event in _event_generator(self.handle):
|
|
||||||
devent = event.as_dict() # copy data from ctypes
|
|
||||||
if devent['event_id'] in (MpvEventID.SHUTDOWN, MpvEventID.END_FILE, MpvEventID.PAUSE):
|
|
||||||
with self._playback_cond:
|
|
||||||
self._playback_cond.notify_all()
|
|
||||||
for callback in self.event_callbacks:
|
|
||||||
callback.call()
|
|
||||||
if loop:
|
|
||||||
loop.add_reader(self._event_fd, mpv_event_extractor)
|
|
||||||
else:
|
|
||||||
def loop_runner():
|
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
loop.add_reader(self._event_fd, mpv_event_extractor)
|
|
||||||
try:
|
|
||||||
loop.run_forever()
|
|
||||||
finally:
|
|
||||||
loop.close()
|
|
||||||
|
|
||||||
self._event_thread = threading.Thread(target=loop_runner, daemon=True)
|
|
||||||
self._event_thread.start()
|
|
||||||
|
|
||||||
_mpv_set_option_string(self.handle, b'audio-display', b'no')
|
|
||||||
istr = lambda o: ('yes' if o else 'no') if type(o) is bool else str(o)
|
|
||||||
for k,v in kwargs.items():
|
|
||||||
_mpv_set_option_string(self.handle, k.replace('_', '-').encode('utf8'), istr(v).encode('utf8'))
|
|
||||||
_mpv_initialize(self.handle)
|
|
||||||
|
|
||||||
def wait_for_playback(self):
|
|
||||||
with self._playback_cond:
|
|
||||||
self._playback_cond.wait()
|
|
||||||
|
|
||||||
# def __del__(self):
|
|
||||||
# _mpv_terminate_destroy(self.handle)
|
|
||||||
|
|
||||||
def command(self, name, *args):
|
|
||||||
args = [name.encode('utf8')] + [ str(arg).encode('utf8') for arg in args if arg is not None ] + [None]
|
|
||||||
_mpv_command(self.handle, (c_char_p*len(args))(*args))
|
|
||||||
|
|
||||||
def seek(self, amount, reference="relative", precision="default-precise"):
|
|
||||||
assert reference in ('relative', 'absolute', 'absolute-percent')
|
|
||||||
assert precision in ('default-precise', 'exact', 'keyframes')
|
|
||||||
self.command('seek', amount, reference, precision)
|
|
||||||
|
|
||||||
def revert_seek(self):
|
|
||||||
self.command('revert_seek');
|
|
||||||
|
|
||||||
def frame_step(self):
|
|
||||||
self.command('frame_step')
|
|
||||||
|
|
||||||
def frame_back_step(self):
|
|
||||||
self.command('frame_back_step')
|
|
||||||
|
|
||||||
def _set_property(self, name, value):
|
|
||||||
self.command('set_property', name, str(value))
|
|
||||||
|
|
||||||
def _add_property(self, name, value=None):
|
|
||||||
self.command('add_property', name, value)
|
|
||||||
|
|
||||||
def _cycle_property(self, name, direction='up'):
|
|
||||||
self.command('cycle_property', name, direction)
|
|
||||||
|
|
||||||
def _multiply_property(self, name, factor):
|
|
||||||
self.command('multiply_property', name, factor)
|
|
||||||
|
|
||||||
def screenshot(self, includes='subtitles', mode='single'):
|
|
||||||
assert includes in ('subtitles', 'video', 'window')
|
|
||||||
assert mode in ('single', 'each-frame')
|
|
||||||
self.command('screenshot', includes, mode)
|
|
||||||
|
|
||||||
def screenshot_to_file(self, filename, includes='subtitles'):
|
|
||||||
assert includes in ('subtitles', 'video', 'window')
|
|
||||||
self.command('screenshot_to_file', filename, includes)
|
|
||||||
|
|
||||||
def playlist_next(self, mode='weak'):
|
|
||||||
assert mode in ('weak', 'force')
|
|
||||||
self.command('playlist_next', mode)
|
|
||||||
|
|
||||||
def playlist_prev(self, mode='weak'):
|
|
||||||
assert mode in ('weak', 'force')
|
|
||||||
self.command('playlist_prev', mode)
|
|
||||||
|
|
||||||
def loadfile(self, filename, mode='replace'):
|
|
||||||
assert mode in ('replace', 'append', 'append-play')
|
|
||||||
self.command('loadfile', filename, mode)
|
|
||||||
|
|
||||||
def loadlist(self, playlist, mode='replace'):
|
|
||||||
self.command('loadlist', playlist, mode)
|
|
||||||
|
|
||||||
def playlist_clear(self):
|
|
||||||
self.command('playlist_clear')
|
|
||||||
|
|
||||||
def playlist_remove(self, index='current'):
|
|
||||||
self.command('playlist_remove', index)
|
|
||||||
|
|
||||||
def playlist_move(self, index1, index2):
|
|
||||||
self.command('playlist_move', index1, index2)
|
|
||||||
|
|
||||||
def run(self, command, *args):
|
|
||||||
self.command('run', command, *args)
|
|
||||||
|
|
||||||
def quit(self, code=None):
|
|
||||||
self.command('quit', code)
|
|
||||||
|
|
||||||
def quit_watch_later(self, code=None):
|
|
||||||
self.command('quit_watch_later', code)
|
|
||||||
|
|
||||||
def sub_add(self, filename):
|
|
||||||
self.command('sub_add', filename)
|
|
||||||
|
|
||||||
def sub_remove(self, sub_id=None):
|
|
||||||
self.command('sub_remove', sub_id)
|
|
||||||
|
|
||||||
def sub_reload(self, sub_id=None):
|
|
||||||
self.command('sub_reload', sub_id)
|
|
||||||
|
|
||||||
def sub_step(self, skip):
|
|
||||||
self.command('sub_step', skip)
|
|
||||||
|
|
||||||
def sub_seek(self, skip):
|
|
||||||
self.command('sub_seek', skip)
|
|
||||||
|
|
||||||
def toggle_osd(self):
|
|
||||||
self.command('osd')
|
|
||||||
|
|
||||||
def show_text(self, string, duration='-', level=None):
|
|
||||||
self.command('show_text', string, duration, level)
|
|
||||||
|
|
||||||
def show_progress(self):
|
|
||||||
self.command('show_progress')
|
|
||||||
|
|
||||||
def discnav(self, command):
|
|
||||||
assert command in ('up', 'up', 'down', 'down', 'left', 'right', 'left', 'right', 'menu', 'select',
|
|
||||||
'prev', 'mouse', 'mouse_move')
|
|
||||||
self.command('discnav', command)
|
|
||||||
|
|
||||||
def write_watch_later_config(self):
|
|
||||||
self.command('write_watch_later_config')
|
|
||||||
|
|
||||||
def overlay_add(self, overlay_id, x, y, file_or_fd, offset, fmt, w, h, stride):
|
|
||||||
self.command('overlay_add', overlay_id, x, y, file_or_fd, offset, fmt, w, h, stride)
|
|
||||||
|
|
||||||
def overlay_remove(self, overlay_id):
|
|
||||||
self.command('overlay_remove', overlay_id)
|
|
||||||
|
|
||||||
def script_message(self, *args):
|
|
||||||
self.command('script_message', *args)
|
|
||||||
|
|
||||||
def script_message_to(self, target, *args):
|
|
||||||
self.command('script_message_to', target, *args)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def metadata(self):
|
|
||||||
...
|
|
||||||
def chapter_metadata(self):
|
|
||||||
...
|
|
||||||
|
|
||||||
def vf_metadata(self):
|
|
||||||
...
|
|
||||||
|
|
||||||
# Convenience functions
|
|
||||||
def play(self, filename):
|
|
||||||
self.command('loadfile', filename)
|
|
||||||
|
|
||||||
# Complex properties
|
|
||||||
|
|
||||||
_VIDEO_PARAMS_LIST = (
|
|
||||||
('pixelformat', str),
|
|
||||||
('w', int),
|
|
||||||
('h', int),
|
|
||||||
('dw', int),
|
|
||||||
('dh', int),
|
|
||||||
('aspect', float),
|
|
||||||
('par', float),
|
|
||||||
('colormatrix', str),
|
|
||||||
('colorlevels', str),
|
|
||||||
('chroma-location', str),
|
|
||||||
('rotate', int))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def video_params(self):
|
|
||||||
return self._get_dict('video-params/', _VIDEO_PARAMS_LIST)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def video_out_params(self):
|
|
||||||
return self._get_dict('video-out-params/', _VIDEO_PARAMS_LIST)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def playlist(self):
|
|
||||||
return self._get_list('playlist/', (('filename', str),))
|
|
||||||
@property
|
|
||||||
def track_list(self):
|
|
||||||
return self._get_list('track-list/', (
|
|
||||||
('id', int),
|
|
||||||
('type', str),
|
|
||||||
('src-id', int),
|
|
||||||
('title', str),
|
|
||||||
('lang', str),
|
|
||||||
('albumart', ynbool),
|
|
||||||
('default', ynbool),
|
|
||||||
('external', ynbool),
|
|
||||||
('external-filename', str),
|
|
||||||
('codec', str),
|
|
||||||
('selected', ynbool)))
|
|
||||||
@property
|
|
||||||
def chapter_list(self):
|
|
||||||
return self._get_dict('chapter-list/', (('title', str), ('time', float)))
|
|
||||||
|
|
||||||
def _get_dict(self, prefix, props):
|
|
||||||
return { name: proptype(_ensure_encoding(_mpv_get_property_string(self.handle, (prefix+name).encode('utf8')))) for name, proptype in props }
|
|
||||||
|
|
||||||
def _get_list(self, prefix, props):
|
|
||||||
count = int(_ensure_encoding(_mpv_get_property_string(self.handle, (prefix+'count').encode('utf8'))))
|
|
||||||
return [ self._get_dict(prefix+str(index)+'/', props) for index in range(count)]
|
|
||||||
|
|
||||||
# TODO: af, vf properties
|
|
||||||
# TODO: edition-list
|
|
||||||
# TODO property-mapped options
|
|
||||||
|
|
||||||
|
|
||||||
def bindproperty(MPV, name, proptype, access):
|
|
||||||
def getter(self):
|
|
||||||
return proptype(_ensure_encoding(_mpv_get_property_string(self.handle, name.encode('utf8'))))
|
|
||||||
def setter(self, value):
|
|
||||||
_mpv_set_property_string(self.handle, name.encode('utf8'), str(proptype(value)).encode('utf8'))
|
|
||||||
def barf(*args):
|
|
||||||
raise NotImplementedError('Access denied')
|
|
||||||
setattr(MPV, name.replace('-', '_'), property(getter if 'r' in access else barf, setter if 'w' in access else barf))
|
|
||||||
|
|
||||||
for name, proptype, access in (
|
|
||||||
('osd-level', int, 'rw'),
|
|
||||||
('osd-scale', float, 'rw'),
|
|
||||||
('loop', str, 'rw'),
|
|
||||||
('loop-file', str, 'rw'),
|
|
||||||
('speed', float, 'rw'),
|
|
||||||
('filename', str, 'r'),
|
|
||||||
('file-size', int, 'r'),
|
|
||||||
('path', str, 'r'),
|
|
||||||
('media-title', str, 'r'),
|
|
||||||
('stream-pos', int, 'rw'),
|
|
||||||
('stream-end', int, 'r'),
|
|
||||||
('length', float, 'r'),
|
|
||||||
('avsync', float, 'r'),
|
|
||||||
('total-avsync-change', float, 'r'),
|
|
||||||
('drop-frame-count', int, 'r'),
|
|
||||||
('percent-pos', int, 'rw'),
|
|
||||||
('ratio-pos', float, 'rw'),
|
|
||||||
('time-pos', float, 'rw'),
|
|
||||||
('time-start', float, 'r'),
|
|
||||||
('time-remaining', float, 'r'),
|
|
||||||
('playtime-remaining', float, 'r'),
|
|
||||||
('chapter', int, 'rw'),
|
|
||||||
('edition', int, 'rw'),
|
|
||||||
('disc-titles', int, 'r'),
|
|
||||||
('disc-title', str, 'rw'),
|
|
||||||
('disc-menu-active', ynbool, 'r'),
|
|
||||||
('chapters', int, 'r'),
|
|
||||||
('editions', int, 'r'),
|
|
||||||
('angle', int, 'rw'),
|
|
||||||
('pause', ynbool, 'rw'),
|
|
||||||
('core-idle', ynbool, 'r'),
|
|
||||||
('cache', int, 'r'),
|
|
||||||
('cache-size' , int, 'rw'),
|
|
||||||
('pause-for-cache', ynbool, 'r'),
|
|
||||||
('eof-reached', ynbool, 'r'),
|
|
||||||
('pts-association-mode', str, 'rw'),
|
|
||||||
('hr-seek', ynbool, 'rw'),
|
|
||||||
('volume', float, 'rw'),
|
|
||||||
('mute', ynbool, 'rw'),
|
|
||||||
('audio-delay', float, 'rw'),
|
|
||||||
('audio-format', str, 'r'),
|
|
||||||
('audio-codec', str, 'r'),
|
|
||||||
('audio-bitrate', float, 'r'),
|
|
||||||
('audio-samplerate', int, 'r'),
|
|
||||||
('audio-channels', str, 'r'),
|
|
||||||
('aid', int, 'rw'),
|
|
||||||
('audio', int, 'rw'),
|
|
||||||
('balance', int, 'rw'),
|
|
||||||
('fullscreen', ynbool, 'rw'),
|
|
||||||
('deinterlace', str, 'rw'),
|
|
||||||
('colormatrix', str, 'rw'),
|
|
||||||
('colormatrix-input-range', str, 'rw'),
|
|
||||||
('colormatrix-output-range', str, 'rw'),
|
|
||||||
('colormatrix-primaries', str, 'rw'),
|
|
||||||
('ontop', ynbool, 'rw'),
|
|
||||||
('border', ynbool, 'rw'),
|
|
||||||
('framedrop', str, 'rw'),
|
|
||||||
('gamma', float, 'rw'),
|
|
||||||
('brightness', int, 'rw'),
|
|
||||||
('contrast', int, 'rw'),
|
|
||||||
('saturation', int, 'rw'),
|
|
||||||
('hue', int, 'rw'),
|
|
||||||
('hwdec', ynbool, 'rw'),
|
|
||||||
('panscan', float, 'rw'),
|
|
||||||
('video-format', str, 'r'),
|
|
||||||
('video-codec', str, 'r'),
|
|
||||||
('video-bitrate', float, 'r'),
|
|
||||||
('width', int, 'r'),
|
|
||||||
('height', int, 'r'),
|
|
||||||
('dwidth', int, 'r'),
|
|
||||||
('dheight', int, 'r'),
|
|
||||||
('fps', float, 'r'),
|
|
||||||
('estimated-vf-fps', float, 'r'),
|
|
||||||
('window-scale', float, 'rw'),
|
|
||||||
('video-aspect', str, 'rw'),
|
|
||||||
('osd-width', int, 'r'),
|
|
||||||
('osd-height', int, 'r'),
|
|
||||||
('osd-par', float, 'r'),
|
|
||||||
('vid', int, 'rw'),
|
|
||||||
('video', int, 'rw'),
|
|
||||||
('video-align-x', float, 'rw'),
|
|
||||||
('video-align-y', float, 'rw'),
|
|
||||||
('video-pan-x', int, 'rw'),
|
|
||||||
('video-pan-y', int, 'rw'),
|
|
||||||
('video-zoom', float, 'rw'),
|
|
||||||
('video-unscaled', ynbool, 'w'),
|
|
||||||
('program', int, 'w'),
|
|
||||||
('sid', int, 'rw'),
|
|
||||||
('secondary-sid', int, 'rw'),
|
|
||||||
('sub', int, 'rw'),
|
|
||||||
('sub-delay', float, 'rw'),
|
|
||||||
('sub-pos', int, 'rw'),
|
|
||||||
('sub-visibility', ynbool, 'rw'),
|
|
||||||
('sub-forced-only', ynbool, 'rw'),
|
|
||||||
('sub-scale', float, 'rw'),
|
|
||||||
('ass-use-margins', ynbool, 'rw'),
|
|
||||||
('ass-vsfilter-aspect-compat', ynbool, 'rw'),
|
|
||||||
('ass-style-override', str, 'rw'),
|
|
||||||
('stream-capture', str, 'rw'),
|
|
||||||
('tv-brightness', int, 'rw'),
|
|
||||||
('tv-contrast', int, 'rw'),
|
|
||||||
('tv-saturation', int, 'rw'),
|
|
||||||
('tv-hue', int, 'rw'),
|
|
||||||
('playlist-pos', int, 'rw'),
|
|
||||||
('playlist-count', int, 'r'),
|
|
||||||
('quvi-format', str, 'rw'),
|
|
||||||
('seekable', ynbool, 'r')):
|
|
||||||
bindproperty(MPV, name, proptype, access)
|
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
# omp - Omni Media Player
|
||||||
|
# This is a part of comp
|
||||||
|
#
|
||||||
|
# comp is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# comp program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with comp. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# Copyright (C) 2017 Nguyễn Gia Phong <vn.mcsinyx@gmail.com>
|
||||||
|
|
||||||
|
"""Omni Media Player - an handy mpv front-end library for interactive
|
||||||
|
control.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .ie import extract_info
|
||||||
|
from .omp import Omp
|
|
@ -0,0 +1,124 @@
|
||||||
|
# ie.py - Omni Media Player infomation extractor
|
||||||
|
# This file is part of comp
|
||||||
|
#
|
||||||
|
# comp is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# comp program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with comp. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# Copyright (C) 2017 Nguyễn Gia Phong <vn.mcsinyx@gmail.com>
|
||||||
|
|
||||||
|
import json
|
||||||
|
from os.path import abspath, expanduser, expandvars, isfile
|
||||||
|
from time import gmtime, sleep, strftime
|
||||||
|
from urllib.request import urlretrieve
|
||||||
|
|
||||||
|
from youtube_dl import YoutubeDL
|
||||||
|
from mpv import MPV
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ENTRY = {'filename': '', 'title': '', 'duration': '00:00:00',
|
||||||
|
'error': False, 'playing': False, 'selected': False}
|
||||||
|
|
||||||
|
|
||||||
|
class YoutubeDLLogger:
|
||||||
|
def debug(self, msg): pass
|
||||||
|
def warning(self, msg): pass
|
||||||
|
def error(self, msg): pass
|
||||||
|
|
||||||
|
|
||||||
|
def json_extract_info(filename):
|
||||||
|
"""Return list of entries extracted from a file using json. If an
|
||||||
|
error occur during the extraction, return None.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not isfile(filename):
|
||||||
|
filename = urlretrieve(filename)[0]
|
||||||
|
with open(filename) as f: raw_info, info = json.load(f), []
|
||||||
|
for i in raw_info:
|
||||||
|
e = DEFAULT_ENTRY.copy()
|
||||||
|
for k in e:
|
||||||
|
if k in i and isinstance(i[k], type(e[k])): e[k] = i[k]
|
||||||
|
info.append(e)
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def mpv_extract_info(filename):
|
||||||
|
"""Return list of entries extracted from a path or URL using mpv. If
|
||||||
|
an error occur during the extraction, return None.
|
||||||
|
"""
|
||||||
|
mp = MPV(ytdl=True, vid=False)
|
||||||
|
mp.play(filename)
|
||||||
|
while mp.duration is None:
|
||||||
|
sleep(0.25)
|
||||||
|
if mp.playback_abort: return None
|
||||||
|
info = {'filename': filename, 'title': mp.media_title.decode(),
|
||||||
|
'duration': mp.osd.duration, 'error': False, 'playing': False,
|
||||||
|
'selected': False}
|
||||||
|
mp.quit()
|
||||||
|
return [info]
|
||||||
|
|
||||||
|
|
||||||
|
def ytdl_extract_info(filename):
|
||||||
|
"""Return list of entries extracted from a path or URL using
|
||||||
|
youtube-dl. If an error occur during the extraction, return None.
|
||||||
|
"""
|
||||||
|
ytdl_opts = {'logger': YoutubeDLLogger(), 'default_search': 'ytsearch',
|
||||||
|
'extract_flat': 'in_playlist'}
|
||||||
|
with YoutubeDL(ytdl_opts) as ytdl:
|
||||||
|
try:
|
||||||
|
raw_info = ytdl.extract_info(filename, download=False)
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
info = raw_info.get('entries', [raw_info])
|
||||||
|
for i in info:
|
||||||
|
if 'webpage_url' in i:
|
||||||
|
i['filename'] = i['webpage_url']
|
||||||
|
elif (i.get('ie_key') == 'Youtube'
|
||||||
|
or i.get('extractor') == 'youtube'):
|
||||||
|
i['filename'] = 'https://youtu.be/' + i['id']
|
||||||
|
else:
|
||||||
|
i['filename'] = i['url']
|
||||||
|
if 'title' not in i:
|
||||||
|
try:
|
||||||
|
i['title'] = ytdl.extract_info(i['filename'],
|
||||||
|
download=False)['title']
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
if 'duration' not in i:
|
||||||
|
i['duration'] = '00:00:00'
|
||||||
|
elif isinstance(i['duration'], int):
|
||||||
|
i['duration'] = strftime('%H:%M:%S', gmtime(i['duration']))
|
||||||
|
for k in 'error', 'playing', 'selected': i.setdefault(k, False)
|
||||||
|
for k in i.copy():
|
||||||
|
if k not in DEFAULT_ENTRY: i.pop(k)
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def extract_info(filename, extractor='youtube-dl'):
|
||||||
|
"""Return list of entries extracted from a path or URL using
|
||||||
|
specified extractor. If an error occur during the extraction,
|
||||||
|
return None.
|
||||||
|
|
||||||
|
The extractor could be either 'json', 'mpv' or 'youtube-dl' and
|
||||||
|
fallback to 'youtube-dl'.
|
||||||
|
"""
|
||||||
|
if isfile(expanduser(expandvars(filename))):
|
||||||
|
filename = abspath(expanduser(expandvars(filename)))
|
||||||
|
if extractor == 'json':
|
||||||
|
return json_extract_info(filename)
|
||||||
|
elif extractor == 'mpv':
|
||||||
|
return mpv_extract_info(filename)
|
||||||
|
else:
|
||||||
|
return ytdl_extract_info(filename)
|
Binary file not shown.
|
@ -0,0 +1,72 @@
|
||||||
|
# SOME DESCRIPTIVE TITLE.
|
||||||
|
# Copyright (C) YEAR ORGANIZATION
|
||||||
|
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: \n"
|
||||||
|
"POT-Creation-Date: 2017-04-05 11:00+0700\n"
|
||||||
|
"PO-Revision-Date: 2017-04-06 22:29+0700\n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Generated-By: pygettext.py 1.5\n"
|
||||||
|
"X-Generator: Poedit 1.8.11\n"
|
||||||
|
"Last-Translator: \n"
|
||||||
|
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||||
|
"Language: vi_VN\n"
|
||||||
|
|
||||||
|
msgid "Curses Online Media Player"
|
||||||
|
msgstr "Phần mềm chơi đa phương tiện trực tuyến sử dụng curses"
|
||||||
|
|
||||||
|
msgid "play-current"
|
||||||
|
msgstr "chơi-một"
|
||||||
|
|
||||||
|
msgid "play-all"
|
||||||
|
msgstr "chơi-tất-cả"
|
||||||
|
|
||||||
|
msgid "play-selected"
|
||||||
|
msgstr "chơi-đã-chọn"
|
||||||
|
|
||||||
|
msgid "repeat-current"
|
||||||
|
msgstr "lặp-một"
|
||||||
|
|
||||||
|
msgid "repeat-all"
|
||||||
|
msgstr "lặp-tất-cả"
|
||||||
|
|
||||||
|
msgid "repeat-selected"
|
||||||
|
msgstr "lặp-đã-chọn"
|
||||||
|
|
||||||
|
msgid "shuffle-all"
|
||||||
|
msgstr "ngẫu-nhiên-tất-cả"
|
||||||
|
|
||||||
|
msgid "shuffle-selected"
|
||||||
|
msgstr "ngẫu-nhiên-đã-chọn"
|
||||||
|
|
||||||
|
msgid "URL"
|
||||||
|
msgstr "URL"
|
||||||
|
|
||||||
|
msgid "Title"
|
||||||
|
msgstr "Tiêu đề"
|
||||||
|
|
||||||
|
msgid "Source"
|
||||||
|
msgstr "Nguồn"
|
||||||
|
|
||||||
|
msgid "Current size: {}x{}. Minimum size: {}x4."
|
||||||
|
msgstr "Kích thước hiện tại: {}x{}. Kích thước tối thiểu: {}x4."
|
||||||
|
|
||||||
|
msgid "Save playlist to [{}]:"
|
||||||
|
msgstr "Lưu playlist tại [{}]:"
|
||||||
|
|
||||||
|
msgid "'{}': Can't open file for writing"
|
||||||
|
msgstr "'{}': Không mở được tệp để ghi"
|
||||||
|
|
||||||
|
msgid "'{}' written"
|
||||||
|
msgstr "'{}' đã ghi"
|
||||||
|
|
||||||
|
msgid "path to playlist in JSON format"
|
||||||
|
msgstr "đường dẫn đến playlist ở định dạng JSON"
|
||||||
|
|
||||||
|
msgid "URL to an playlist on Youtube"
|
||||||
|
msgstr "URL của playlist trên Youtube"
|
|
@ -0,0 +1,160 @@
|
||||||
|
# omp.py - Omni Media Player meta object
|
||||||
|
# This file is part of comp
|
||||||
|
#
|
||||||
|
# comp is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# comp program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with comp. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# Copyright (C) 2017 Nguyễn Gia Phong <vn.mcsinyx@gmail.com>
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections import deque
|
||||||
|
from gettext import bindtextdomain, gettext as _, textdomain
|
||||||
|
from itertools import cycle
|
||||||
|
from os import makedirs
|
||||||
|
from os.path import abspath, dirname, expanduser, expandvars, isfile
|
||||||
|
from random import choice
|
||||||
|
from time import gmtime, sleep, strftime
|
||||||
|
from urllib import request
|
||||||
|
|
||||||
|
from youtube_dl import YoutubeDL
|
||||||
|
from pkg_resources import resource_filename
|
||||||
|
from mpv import MPV, MpvFormat
|
||||||
|
|
||||||
|
from .ie import extract_info
|
||||||
|
|
||||||
|
# Init gettext
|
||||||
|
bindtextdomain('omp', resource_filename('omp', 'locale'))
|
||||||
|
textdomain('omp')
|
||||||
|
|
||||||
|
|
||||||
|
class Omp(object):
|
||||||
|
"""Omni Media Player meta object.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
entries (list): list of all tracks
|
||||||
|
json_file (str): path to save JSON playlist
|
||||||
|
mode (str): the mode to pick and play tracks
|
||||||
|
mp (MPV): an mpv instance
|
||||||
|
play_backward (bool): flag show if to play the previous track
|
||||||
|
play_list (list): list of tracks according to mode
|
||||||
|
played (list): list of previously played tracks
|
||||||
|
playing (int): index of playing track in played
|
||||||
|
playlist (iterator): iterator of tracks according to mode
|
||||||
|
search_res (iterator): title-searched results
|
||||||
|
vid (str): flag show if video output is enabled
|
||||||
|
|
||||||
|
I/O handlers (defined by front-end):
|
||||||
|
print_msg(message, error=False): print a message
|
||||||
|
property_handler(name, val): called when a mpv property updated
|
||||||
|
read_input(prompt): prompt for user input
|
||||||
|
refresh(): update interface content
|
||||||
|
"""
|
||||||
|
def __new__(cls, entries, json_file, mode, mpv_vid, mpv_vo, ytdlf):
|
||||||
|
self = super(Comp, cls).__new__(cls)
|
||||||
|
self.play_backward, self.reading = False, False
|
||||||
|
self.playing = -1
|
||||||
|
self.json_file, self.mode, self.vid = json_file, mode, mpv_vid
|
||||||
|
self.entries, self.played = entries, []
|
||||||
|
self.playlist, self.search_res = iter(()), deque()
|
||||||
|
self.mp = MPV(input_default_bindings=True, input_vo_keyboard=True,
|
||||||
|
ytdl=True, ytdl_format=ytdlf)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __init__(self, entries, json_file, mode, mpv_vid, mpv_vo, ytdlf):
|
||||||
|
if mpv_vo is not None: self.mp['vo'] = mpv_vo
|
||||||
|
@self.mp.property_observer('mute')
|
||||||
|
@self.mp.property_observer('pause')
|
||||||
|
@self.mp.property_observer('time-pos')
|
||||||
|
def observer(name, value): self.property_handler(name, value)
|
||||||
|
self.mp.register_key_binding('q', lambda state, key: None)
|
||||||
|
|
||||||
|
def __enter__(self): return self
|
||||||
|
|
||||||
|
def update_play_list(self, pick):
|
||||||
|
"""Update the list of entries to be played."""
|
||||||
|
if pick == 'current':
|
||||||
|
self.play_list = [self.current()]
|
||||||
|
elif pick == 'all':
|
||||||
|
self.play_list = deque(self.entries)
|
||||||
|
self.play_list.rotate(-self.idx())
|
||||||
|
else:
|
||||||
|
self.play_list = [i for i in self.entries if i.get('selected')]
|
||||||
|
|
||||||
|
def update_playlist(self):
|
||||||
|
"""Update the playlist to be used by play function."""
|
||||||
|
action, pick = self.mode.split('-')
|
||||||
|
self.update_play_list(pick)
|
||||||
|
if action == 'play':
|
||||||
|
self.playlist = iter(self.play_list)
|
||||||
|
elif action == 'repeat':
|
||||||
|
self.playlist = cycle(self.play_list)
|
||||||
|
else:
|
||||||
|
self.playlist = iter(lambda: choice(self.play_list), None)
|
||||||
|
if self.playing < -1: self.played = self.played[:self.playing+1]
|
||||||
|
|
||||||
|
def seek(self, amount, reference='relative', precision='default-precise'):
|
||||||
|
"""Wrap mp.seek with a try clause to avoid crash when nothing is
|
||||||
|
being played.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.mp.seek(amount, reference, precision)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def next(self, force=False, backward=False):
|
||||||
|
self.play_backward = backward
|
||||||
|
if self.mp.idle_active:
|
||||||
|
self.play(force)
|
||||||
|
else:
|
||||||
|
self.seek(100, 'absolute-percent')
|
||||||
|
if force: self.mp.pause = False
|
||||||
|
|
||||||
|
def search(self, backward=False):
|
||||||
|
"""Prompt then search for a pattern."""
|
||||||
|
p = re.compile(self.gets('/'), re.IGNORECASE)
|
||||||
|
entries = deque(self.entries)
|
||||||
|
entries.rotate(-self.idx())
|
||||||
|
self.search_res = deque(filter(
|
||||||
|
lambda entry: p.search(entry['title']) is not None, entries))
|
||||||
|
if backward: self.search_res.reverse()
|
||||||
|
if self.search_res:
|
||||||
|
self.move(self.idx(self.search_res[0]) - self.idx())
|
||||||
|
else:
|
||||||
|
self.update_status(_("Pattern not found"), curses.color_pair(1))
|
||||||
|
|
||||||
|
def next_search(self, backward=False):
|
||||||
|
"""Repeat previous search."""
|
||||||
|
if self.search_res:
|
||||||
|
self.search_res.rotate(1 if backward else -1)
|
||||||
|
self.move(self.idx(self.search_res[0]) - self.idx())
|
||||||
|
else:
|
||||||
|
self.update_status(_("Pattern not found"), curses.color_pair(1))
|
||||||
|
|
||||||
|
def dump_json(self):
|
||||||
|
s = self.read_input(
|
||||||
|
_("Save playlist to [{}]: ").format(self.json_file))
|
||||||
|
self.json_file = abspath(expanduser(expandvars(s or self.json_file)))
|
||||||
|
try:
|
||||||
|
makedirs(dirname(self.json_file), exist_ok=True)
|
||||||
|
except:
|
||||||
|
errmsg = _("'{}': Can't open file for writing").format(
|
||||||
|
self.json_file)
|
||||||
|
self.print_msg(errmsg, error=True)
|
||||||
|
else:
|
||||||
|
with open(self.json_file, 'w') as f:
|
||||||
|
json.dump(self.entries, f, ensure_ascii=False,
|
||||||
|
indent=2, sort_keys=True)
|
||||||
|
self.print_msg(_("'{}' written").format(self.json_file))
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
|
self.mp.quit()
|
|
@ -0,0 +1,17 @@
|
||||||
|
[comp]
|
||||||
|
# Initial playing mode, which can be one of these 8 modes: play-current,
|
||||||
|
# play-all, play-selected, repeat-current, repeat-all, repeat-selected,
|
||||||
|
# shuffle-all and shuffle-selected.
|
||||||
|
play-mode = play-current
|
||||||
|
|
||||||
|
[mpv]
|
||||||
|
# Initial video channel. auto selects the default, no disables video.
|
||||||
|
video = auto
|
||||||
|
# Specify the video output backend to be used. See VIDEO OUTPUT DRIVERS in
|
||||||
|
# mpv(1) man page for details and descriptions of available drivers.
|
||||||
|
video-output =
|
||||||
|
|
||||||
|
[youtube-dl]
|
||||||
|
# Video format/quality to be passed to youtube-dl. See FORMAT SELECTION in
|
||||||
|
# youtube-dl(1) man page for more details and descriptions.
|
||||||
|
format = best
|
43
setup.py
43
setup.py
|
@ -1,13 +1,36 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
from setuptools import setup, find_packages
|
from setuptools import setup
|
||||||
|
|
||||||
|
with open('README.rst') as f:
|
||||||
|
long_description = f.read()
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name = 'mpv',
|
name='comp',
|
||||||
version = '0.1',
|
version='0.3.12',
|
||||||
py_modules = ['mpv'],
|
description=('Curses Omni Media Player'),
|
||||||
description = ('A python interface to the mpv media player'),
|
long_description=long_description,
|
||||||
url = 'https://github.com/jaseg/python-mpv',
|
url='https://github.com/McSinyx/comp',
|
||||||
author = 'jaseg',
|
author='Nguyễn Gia Phong',
|
||||||
author_email = 'github@jaseg.net',
|
author_email='vn.mcsinyx@gmail.com',
|
||||||
license = 'AGPLv2'
|
license='AGPLv3+',
|
||||||
)
|
classifiers=[
|
||||||
|
'Development Status :: 4 - Beta',
|
||||||
|
'Environment :: Console :: Curses',
|
||||||
|
'Intended Audience :: End Users/Desktop',
|
||||||
|
'License :: OSI Approved :: GNU Affero General Public License v3',
|
||||||
|
'Natural Language :: English',
|
||||||
|
'Natural Language :: Vietnamese',
|
||||||
|
'Operating System :: POSIX',
|
||||||
|
'Programming Language :: Python :: 3.5',
|
||||||
|
'Programming Language :: Python :: 3.6',
|
||||||
|
'Topic :: Multimedia :: Sound/Audio :: Players',
|
||||||
|
'Topic :: Multimedia :: Video :: Display'],
|
||||||
|
keywords='youtube-dl mpv-wrapper curses console-application multimedia',
|
||||||
|
packages=['omp'],
|
||||||
|
install_requires=['python-mpv', 'youtube-dl'],
|
||||||
|
python_requires='>=3.5',
|
||||||
|
package_data={'omp': ['locale/*/LC_MESSAGES/omp.mo']},
|
||||||
|
data_files=[('share/man/man1', ['doc/comp.1'])],
|
||||||
|
scripts=['comp'],
|
||||||
|
platforms=['POSIX'])
|
||||||
|
|
Binary file not shown.
|
@ -0,0 +1,330 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"duration": "00:05:21",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/weeI1G46q0o",
|
||||||
|
"playing": false,
|
||||||
|
"selected": true,
|
||||||
|
"title": "DJ Khaled - I'm the One ft. Justin Bieber, Quavo, Chance the Rapper, Lil Wayne"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:04:23",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/JGwWNGJdvx8",
|
||||||
|
"playing": false,
|
||||||
|
"selected": true,
|
||||||
|
"title": "Ed Sheeran - Shape of You [Official Video]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:03:30",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/PMivT7MJ41M",
|
||||||
|
"playing": false,
|
||||||
|
"selected": true,
|
||||||
|
"title": "Bruno Mars - That’s What I Like [Official Video]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:04:46",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/CTFtOOh47oo",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "French Montana - Unforgettable ft. Swae Lee"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:04:45",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/NLZRYQMLDW4",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Kendrick Lamar - DNA."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:04:07",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/FM7MFYoylVs",
|
||||||
|
"playing": false,
|
||||||
|
"selected": true,
|
||||||
|
"title": "The Chainsmokers & Coldplay - Something Just Like This (Lyric)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:03:48",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/72UO0v5ESUo",
|
||||||
|
"playing": false,
|
||||||
|
"selected": true,
|
||||||
|
"title": "Luis Fonsi, Daddy Yankee - Despacito (Audio) ft. Justin Bieber"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/D5drYkLiLI8",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Kygo, Selena Gomez - It Ain't Me (with Selena Gomez) (Audio)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/Zgmvg-zzctI",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Lil Uzi Vert - XO TOUR Llif3 (Produced By TM88)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:12:53",
|
||||||
|
"error": false,
|
||||||
|
"filename": "test/gplv3.ogg",
|
||||||
|
"playing": false,
|
||||||
|
"selected": true,
|
||||||
|
"title": "gplv3.ogg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/xvZqHgFz51I",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Future - Mask Off"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/8j9zMok6two",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Miley Cyrus - Malibu (Official Video)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/dPI-mRFEIH0",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Katy Perry - Bon Appétit (Official) ft. Migos"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/aatr_2MstrI",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Clean Bandit - Symphony feat. Zara Larsson [Official Video]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:34:38",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://www.tube8.com/teen/nicole-ray-and-james-deen/409802/",
|
||||||
|
"playing": false,
|
||||||
|
"selected": true,
|
||||||
|
"title": "Nicole Ray and James Deen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/7F37r50VUTQ",
|
||||||
|
"playing": false,
|
||||||
|
"selected": true,
|
||||||
|
"title": "ZAYN, Taylor Swift - I Don’t Wanna Live Forever (Fifty Shades Darker)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/qFLhGq0060w",
|
||||||
|
"playing": false,
|
||||||
|
"selected": true,
|
||||||
|
"title": "The Weeknd - I Feel It Coming ft. Daft Punk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:02:29",
|
||||||
|
"error": false,
|
||||||
|
"filename": "http://www.html5videoplayer.net/videos/toystory.mp4",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "toystory.mp4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/6ImFf__U6io",
|
||||||
|
"playing": false,
|
||||||
|
"selected": true,
|
||||||
|
"title": "Birdman - Dark Shades (Explicit) ft. Lil Wayne, Mack Maine"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:03:56",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://www.youtube.com/watch?v=3M3xfu0m5o4",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "David Banner - Play (Dirty version)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:03:55",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/NGLxoKOvzu4",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Jason Derulo - Swalla (feat. Nicki Minaj & Ty Dolla $ign) (Official Music Video)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/Hm1YFszJWbQ",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Migos - Slippery feat. Gucci Mane [Official Video]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/SC4xMk98Pdc",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Post Malone - Congratulations ft. Quavo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:03:51",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/nfs8NYg7yQM",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Charlie Puth - Attention [Official Video]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:04:10",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://www.youtube.com/watch?v=sRIkXM8S1J8",
|
||||||
|
"playing": false,
|
||||||
|
"selected": true,
|
||||||
|
"title": "Best Goat Song Versions Compilation Ever! (HD)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/Dst9gZkq1a8",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Travis Scott - goosebumps ft. Kendrick Lamar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/dMK_npDG12Q",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Lorde - Green Light"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/h--P8HzYZ74",
|
||||||
|
"playing": false,
|
||||||
|
"selected": true,
|
||||||
|
"title": "Zedd, Alessia Cara - Stay (Lyric Video)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/Mdh2p03cRfw",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Sam Hunt - Body Like A Back Road (Audio)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:03:40",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/Fq0xEpRDL9Q",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Chris Brown - Privacy (Explicit Version)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:03:36",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/7wtfhZwyrcc",
|
||||||
|
"playing": true,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Imagine Dragons - Believer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/t_jHrUE5IOk",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Maluma - Felices los 4 (Official Video)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/wzZWXrlDj-A",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "DNCE - Kissing Strangers ft. Nicki Minaj"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:03:18",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/AEB6ibtdPZc",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Paramore: Hard Times [OFFICIAL VIDEO]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/vqW18C4plZ8",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "WizKid - Come Closer ft. Drake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/A7xzXDStQnk",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Shawn Mendes - There's Nothing Holdin' Me Back (Lyric Video)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/FG9M0aEpJGE",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "G-Eazy & Kehlani - Good Life (from The Fate of the Furious: The Album) [MUSIC VIDEO]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/vp8VZe5kqEM",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Lady Gaga - The Cure (Audio)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/eP4eqhWc7sI",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Lana Del Rey - Lust For Life (Official Video) ft. The Weeknd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/5qJp6xlKEug",
|
||||||
|
"playing": false,
|
||||||
|
"selected": false,
|
||||||
|
"title": "Gorillaz - Saturnz Barz (Spirit House)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": "00:00:00",
|
||||||
|
"error": false,
|
||||||
|
"filename": "https://youtu.be/9sg-A-eS6Ig",
|
||||||
|
"playing": false,
|
||||||
|
"selected": true,
|
||||||
|
"title": "Enrique Iglesias - SUBEME LA RADIO (Official Video) ft. Descemer Bueno, Zion & Lennox"
|
||||||
|
}
|
||||||
|
]
|
Loading…
Reference in New Issue