Update documentations and clean up (?)
This commit is contained in:
parent
21937f383c
commit
0066d60a4e
172
README.rst
172
README.rst
|
@ -2,17 +2,21 @@
|
||||||
comp - Curses Omni Media Player
|
comp - Curses Omni Media Player
|
||||||
===============================
|
===============================
|
||||||
|
|
||||||
This program is a curses front-end for mpv and youtube-dl.
|
comp is a mpv 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, local and direct URL to video/audio and its own JSON playlist
|
||||||
|
format.
|
||||||
|
|
||||||
.. image:: doc/screenshot.png
|
.. image:: https://github.com/McSinyx/comp/raw/master/doc/screenshot.png
|
||||||
|
|
||||||
Installation
|
Installation
|
||||||
------------
|
------------
|
||||||
|
|
||||||
comp requires Python 3.5+ with ``curses`` module (only available on Unix-like
|
comp requires Python 3.5+ with ``curses`` module (only available on Unix-like
|
||||||
OSes such as GNU/Linux and the BSDs) and ``libmpv``. It also depends on
|
OSes such as GNU/Linux and the BSDs) and ``libmpv`` (available as ``libmpv1``
|
||||||
``python-mpv`` and ``youtube-dl`` but the setup program will automatically
|
in Debian/Ubuntu, openSUSE; and as ``mpv`` in Arch Linux, Gentoo, macOS
|
||||||
install them if they are missing.
|
Homebrew repository). It also depends on ``python-mpv`` and ``youtube-dl`` but
|
||||||
|
the setup program will automatically install them if they are missing.
|
||||||
|
|
||||||
Using pip
|
Using pip
|
||||||
^^^^^^^^^
|
^^^^^^^^^
|
||||||
|
@ -24,35 +28,31 @@ installation.
|
||||||
Using setup.py
|
Using setup.py
|
||||||
^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^
|
||||||
|
|
||||||
To install the latest version or test the development branch (called
|
To install the latest version or test development branches, you'll need to do
|
||||||
``bachelor``, in contrast to ``master``), you'll need to do it manually::
|
it manually::
|
||||||
|
|
||||||
git clone https://github.com/McSinyx/comp.git
|
git clone https://github.com/McSinyx/comp.git
|
||||||
cd comp
|
cd comp
|
||||||
git checkout bachelor # usually master is synced with the PyPI repo
|
./setup.py install
|
||||||
./setup.py install -e .
|
|
||||||
|
|
||||||
Note ``setup.py`` uses ``setuptools`` which is a third-party module and can be
|
Note that ``setup.py`` uses ``setuptools`` which is a third-party module and
|
||||||
install using ``pip3``.
|
can be install using ``pip3``.
|
||||||
|
|
||||||
Usage
|
Command line options
|
||||||
-----
|
--------------------
|
||||||
|
|
||||||
Command line arguments
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
|
|
||||||
::
|
::
|
||||||
|
|
||||||
$ comp --help
|
$ comp --help
|
||||||
usage: comp [-h] [-e {json,mpv,youtube-dl}] [-c CONFIG] [--vid VID]
|
usage: comp [-h] [-e {json,mpv,youtube-dl}] [-c CONFIG] [--vid VID]
|
||||||
[--vo DRIVER] [-f YTDL_FORMAT]
|
[--vo DRIVER] [-f YTDL_FORMAT]
|
||||||
file
|
playlist
|
||||||
|
|
||||||
Curses Online Media Player
|
Curses Omni Media Player
|
||||||
|
|
||||||
positional arguments:
|
positional arguments:
|
||||||
file path or URL to the playlist to be opened
|
playlist path or URL to the playlist
|
||||||
|
|
||||||
optional arguments:
|
optional arguments:
|
||||||
-h, --help show this help message and exit
|
-h, --help show this help message and exit
|
||||||
-e {json,mpv,youtube-dl}, --extractor {json,mpv,youtube-dl}
|
-e {json,mpv,youtube-dl}, --extractor {json,mpv,youtube-dl}
|
||||||
|
@ -61,66 +61,75 @@ Command line arguments
|
||||||
path to the configuration file
|
path to the configuration file
|
||||||
--vid VID initial video channel. auto selects the default, no
|
--vid VID initial video channel. auto selects the default, no
|
||||||
disables video
|
disables video
|
||||||
--vo DRIVER specify the video output backend to be used. See VIDEO
|
--vo DRIVER specify the video output backend to be used. See
|
||||||
OUTPUT DRIVERS in mpv(1) man page for details and
|
VIDEO OUTPUT DRIVERS in mpv(1) for details and
|
||||||
descriptions of available drivers
|
descriptions of available drivers
|
||||||
-f YTDL_FORMAT, --format YTDL_FORMAT
|
-f YTDL_FORMAT, --format YTDL_FORMAT
|
||||||
video format/quality to be passed to youtube-dl
|
video format/quality to be passed to youtube-dl
|
||||||
|
|
||||||
Keyboard control
|
Examples
|
||||||
^^^^^^^^^^^^^^^^
|
^^^^^^^^
|
||||||
|
|
||||||
+--------------+---------------------------------------------+
|
Open a JSON playlist::
|
||||||
| Key | Action |
|
|
||||||
+==============+=============================================+
|
comp -e json test/playlist.json
|
||||||
| Return | Start playing |
|
|
||||||
+--------------+---------------------------------------------+
|
Open a Youtube playlist with video height lower than 720::
|
||||||
| Space | Select the current track |
|
|
||||||
+--------------+---------------------------------------------+
|
comp -f [height<720] https://www.youtube.com/watch?v=pqkHrdYXaTk&list=PLnk14Iku8QM7R3ARnrj1TwYSZleF-i7jT
|
||||||
| ``/``, ``?`` | Search forward/backward for a pattern |
|
|
||||||
+--------------+---------------------------------------------+
|
Keyboard control
|
||||||
| ``<``, ``>`` | Go forward/backward in the playlist |
|
----------------
|
||||||
+--------------+---------------------------------------------+
|
|
||||||
| ``A`` | Toggle mute |
|
+--------------+----------------------------------------------+
|
||||||
+--------------+---------------------------------------------+
|
| Key | Action |
|
||||||
| ``N`` | Repeat previous search in reverse direction |
|
+==============+==============================================+
|
||||||
+--------------+---------------------------------------------+
|
| Return | Start playing |
|
||||||
| ``V`` | Toggle video |
|
+--------------+----------------------------------------------+
|
||||||
+--------------+---------------------------------------------+
|
| Space | Select the current track |
|
||||||
| ``W`` | Save the current playlist under JSON format |
|
+--------------+----------------------------------------------+
|
||||||
+--------------+---------------------------------------------+
|
| ``/``, ``?`` | Search forward/backward for a pattern |
|
||||||
| ``d`` | Delete current entry |
|
+--------------+----------------------------------------------+
|
||||||
+--------------+---------------------------------------------+
|
| ``<``, ``>`` | Go backward/forward in the playlist |
|
||||||
| ``i`` | Insert playlist |
|
+--------------+----------------------------------------------+
|
||||||
+--------------+---------------------------------------------+
|
| ``A`` | Toggle mute |
|
||||||
| ``m``, ``M`` | Cycle through playing modes |
|
+--------------+----------------------------------------------+
|
||||||
+--------------+---------------------------------------------+
|
| ``N`` | Repeat previous search in reverse direction |
|
||||||
| ``n`` | Repeat previous search |
|
+--------------+----------------------------------------------+
|
||||||
+--------------+---------------------------------------------+
|
| ``V`` | Toggle video |
|
||||||
| ``p`` | Toggle pause |
|
+--------------+----------------------------------------------+
|
||||||
+--------------+---------------------------------------------+
|
| ``W`` | Save the current playlist under JSON format |
|
||||||
| ``o`` | Open playlist |
|
+--------------+----------------------------------------------+
|
||||||
+--------------+---------------------------------------------+
|
| ``d`` | Delete current entry |
|
||||||
| ``w`` | Download tracks set by playing mode |
|
+--------------+----------------------------------------------+
|
||||||
+--------------+---------------------------------------------+
|
| ``i`` | Insert playlist |
|
||||||
| Up, ``k`` | Move a single line up |
|
+--------------+----------------------------------------------+
|
||||||
+--------------+---------------------------------------------+
|
| ``m``, ``M`` | Cycle forward/backward through playing modes |
|
||||||
| Down, ``j`` | Move a single line down |
|
+--------------+----------------------------------------------+
|
||||||
+--------------+---------------------------------------------+
|
| ``n`` | Repeat previous search |
|
||||||
| Left, ``h`` | Seek backward 5 seconds |
|
+--------------+----------------------------------------------+
|
||||||
+--------------+---------------------------------------------+
|
| ``p`` | Toggle pause |
|
||||||
| Right, ``l`` | Seek forward 5 seconds |
|
+--------------+----------------------------------------------+
|
||||||
+--------------+---------------------------------------------+
|
| ``o`` | Open playlist |
|
||||||
| Home | Move to the beginning of the playlist |
|
+--------------+----------------------------------------------+
|
||||||
+--------------+---------------------------------------------+
|
| Up, ``k`` | Move a single line up |
|
||||||
| End | Move to the end of the playlist |
|
+--------------+----------------------------------------------+
|
||||||
+--------------+---------------------------------------------+
|
| Down, ``j`` | Move a single line down |
|
||||||
| Page Up | Move a single page up |
|
+--------------+----------------------------------------------+
|
||||||
+--------------+---------------------------------------------+
|
| Left, ``h`` | Seek backward 5 seconds |
|
||||||
| Page Down | Move a single page down |
|
+--------------+----------------------------------------------+
|
||||||
+--------------+---------------------------------------------+
|
| Right, ``l`` | Seek forward 5 seconds |
|
||||||
| F5 | Redraw the screen content |
|
+--------------+----------------------------------------------+
|
||||||
+--------------+---------------------------------------------+
|
| 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
|
Configuration files
|
||||||
-------------------
|
-------------------
|
||||||
|
@ -146,3 +155,12 @@ are listed below::
|
||||||
# Video format/quality to be passed to youtube-dl. See FORMAT SELECTION in
|
# Video format/quality to be passed to youtube-dl. See FORMAT SELECTION in
|
||||||
# youtube-dl(1) man page for more details and descriptions.
|
# youtube-dl(1) man page for more details and descriptions.
|
||||||
format = best
|
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.
|
||||||
|
|
75
comp
75
comp
|
@ -25,7 +25,7 @@ from configparser import ConfigParser
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from gettext import bindtextdomain, gettext as _, textdomain
|
from gettext import bindtextdomain, gettext as _, textdomain
|
||||||
from os import makedirs
|
from os import makedirs
|
||||||
from os.path import abspath, dirname, expanduser
|
from os.path import abspath, dirname, expanduser, expandvars
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
|
|
||||||
from mpv import MPV
|
from mpv import MPV
|
||||||
|
@ -111,7 +111,11 @@ class Comp(Omp):
|
||||||
add_status_str(_(self.mode), x=-5-len(_(self.mode)))
|
add_status_str(_(self.mode), x=-5-len(_(self.mode)))
|
||||||
if not self.mp.mute: add_status_str('A', x=-4, X=-3)
|
if not self.mp.mute: add_status_str('A', x=-4, X=-3)
|
||||||
if self.vid != 'no': add_status_str('V', x=-2, lpad=0)
|
if self.vid != 'no': add_status_str('V', x=-2, lpad=0)
|
||||||
if message: self.adds(message, curses.LINES-1, attr=msgattr, lpad=0)
|
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()
|
self.scr.refresh()
|
||||||
|
|
||||||
def setno(self, *keys):
|
def setno(self, *keys):
|
||||||
|
@ -182,7 +186,7 @@ class Comp(Omp):
|
||||||
self._writeln(y, entry['title'], entry['duration'],
|
self._writeln(y, entry['title'], entry['duration'],
|
||||||
curses.A_NORMAL)
|
curses.A_NORMAL)
|
||||||
|
|
||||||
def redraw(self):
|
def refresh(self):
|
||||||
"""Redraw the whole screen."""
|
"""Redraw the whole screen."""
|
||||||
self._writeln(0, _("Title"), _("Duration"),
|
self._writeln(0, _("Title"), _("Duration"),
|
||||||
curses.color_pair(10) | curses.A_BOLD)
|
curses.color_pair(10) | curses.A_BOLD)
|
||||||
|
@ -191,9 +195,10 @@ class Comp(Omp):
|
||||||
self.scr.clrtobot()
|
self.scr.clrtobot()
|
||||||
self.update_status()
|
self.update_status()
|
||||||
|
|
||||||
|
def property_handler(self, name, val): self.update_status()
|
||||||
|
|
||||||
def __init__(self, entries, json_file, mode, mpv_vid, mpv_vo, ytdlf):
|
def __init__(self, entries, json_file, mode, mpv_vid, mpv_vo, ytdlf):
|
||||||
Omp.__init__(self, entries, lambda name, val: self.update_status(),
|
Omp.__init__(self, entries, json_file, mode, mpv_vid, mpv_vo, ytdlf)
|
||||||
json_file, mode, mpv_vid, mpv_vo, ytdlf)
|
|
||||||
curses.noecho()
|
curses.noecho()
|
||||||
curses.cbreak()
|
curses.cbreak()
|
||||||
self.scr.keypad(True)
|
self.scr.keypad(True)
|
||||||
|
@ -203,7 +208,7 @@ class Comp(Omp):
|
||||||
for i in range(1, 8): curses.init_pair(i, i, -1)
|
for i in range(1, 8): curses.init_pair(i, i, -1)
|
||||||
curses.init_pair(8, -1, 7)
|
curses.init_pair(8, -1, 7)
|
||||||
for i in range(1, 7): curses.init_pair(i + 8, -1, i)
|
for i in range(1, 7): curses.init_pair(i + 8, -1, i)
|
||||||
self.redraw()
|
self.refresh()
|
||||||
|
|
||||||
def __enter__(self): return self
|
def __enter__(self): return self
|
||||||
|
|
||||||
|
@ -220,7 +225,7 @@ class Comp(Omp):
|
||||||
except:
|
except:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def gets(self, prompt):
|
def read_input(self, prompt):
|
||||||
"""Print the prompt string at the bottom of the screen then read
|
"""Print the prompt string at the bottom of the screen then read
|
||||||
from standard input.
|
from standard input.
|
||||||
"""
|
"""
|
||||||
|
@ -266,11 +271,11 @@ class Comp(Omp):
|
||||||
self.print(prev_entry)
|
self.print(prev_entry)
|
||||||
self.print()
|
self.print()
|
||||||
else:
|
else:
|
||||||
self.redraw()
|
self.refresh()
|
||||||
|
|
||||||
def search(self, backward=False):
|
def search(self, backward=False):
|
||||||
"""Prompt then search for a pattern."""
|
"""Prompt then search for a pattern."""
|
||||||
p = re.compile(self.gets('/'), re.IGNORECASE)
|
p = re.compile(self.read_input('/'), re.IGNORECASE)
|
||||||
entries = deque(self.entries)
|
entries = deque(self.entries)
|
||||||
entries.rotate(-self.idx())
|
entries.rotate(-self.idx())
|
||||||
self.search_res = deque(filter(
|
self.search_res = deque(filter(
|
||||||
|
@ -279,7 +284,7 @@ class Comp(Omp):
|
||||||
if self.search_res:
|
if self.search_res:
|
||||||
self.move(self.idx(self.search_res[0]) - self.idx())
|
self.move(self.idx(self.search_res[0]) - self.idx())
|
||||||
else:
|
else:
|
||||||
self.update_status(_("Pattern not found"), curses.color_pair(1))
|
self.print_msg(_("Pattern not found"), error=True)
|
||||||
|
|
||||||
def next_search(self, backward=False):
|
def next_search(self, backward=False):
|
||||||
"""Repeat previous search."""
|
"""Repeat previous search."""
|
||||||
|
@ -287,7 +292,7 @@ class Comp(Omp):
|
||||||
self.search_res.rotate(1 if backward else -1)
|
self.search_res.rotate(1 if backward else -1)
|
||||||
self.move(self.idx(self.search_res[0]) - self.idx())
|
self.move(self.idx(self.search_res[0]) - self.idx())
|
||||||
else:
|
else:
|
||||||
self.update_status(_("Pattern not found"), curses.color_pair(1))
|
self.print_msg(_("Pattern not found"), error=True)
|
||||||
|
|
||||||
def resize(self):
|
def resize(self):
|
||||||
curses.update_lines_cols()
|
curses.update_lines_cols()
|
||||||
|
@ -301,16 +306,16 @@ class Comp(Omp):
|
||||||
elif self.y > l: # shorter than the current entry
|
elif self.y > l: # shorter than the current entry
|
||||||
self.start += self.y - l
|
self.start += self.y - l
|
||||||
self.y = l
|
self.y = l
|
||||||
self.redraw()
|
self.refresh()
|
||||||
elif 0 < self.start > len(self.entries) - l: # longer than the list
|
elif 0 < self.start > len(self.entries) - l: # longer than the list
|
||||||
idx, self.start = self.idx(), min(0, len(self.entries) - l)
|
idx, self.start = self.idx(), min(0, len(self.entries) - l)
|
||||||
self.y = idx - self.start + 1
|
self.y = idx - self.start + 1
|
||||||
if self.y > l:
|
if self.y > l:
|
||||||
self.start += self.y - l
|
self.start += self.y - l
|
||||||
self.y = l
|
self.y = l
|
||||||
self.redraw()
|
self.refresh()
|
||||||
else:
|
else:
|
||||||
self.redraw()
|
self.refresh()
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_value, traceback):
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
curses.nocbreak()
|
curses.nocbreak()
|
||||||
|
@ -324,7 +329,7 @@ parser = ArgumentParser(description=_("Curses Omni Media Player"))
|
||||||
parser.add_argument('-e', '--extractor', default='youtube-dl',
|
parser.add_argument('-e', '--extractor', default='youtube-dl',
|
||||||
choices=('json', 'mpv', 'youtube-dl'), required=False,
|
choices=('json', 'mpv', 'youtube-dl'), required=False,
|
||||||
help=_("playlist extractor, default is youtube-dl"))
|
help=_("playlist extractor, default is youtube-dl"))
|
||||||
parser.add_argument('file', help=_("path or URL to the playlist to be opened"))
|
parser.add_argument('playlist', help=_("path or URL to the playlist"))
|
||||||
parser.add_argument('-c', '--config', default=USER_CONFIG, required=False,
|
parser.add_argument('-c', '--config', default=USER_CONFIG, required=False,
|
||||||
help=_("path to the configuration file"))
|
help=_("path to the configuration file"))
|
||||||
parser.add_argument('--vid', required=False,
|
parser.add_argument('--vid', required=False,
|
||||||
|
@ -332,16 +337,16 @@ parser.add_argument('--vid', required=False,
|
||||||
no disables video"))
|
no disables video"))
|
||||||
parser.add_argument('--vo', required=False, metavar='DRIVER',
|
parser.add_argument('--vo', required=False, metavar='DRIVER',
|
||||||
help=_("specify the video output backend to be used. See\
|
help=_("specify the video output backend to be used. See\
|
||||||
VIDEO OUTPUT DRIVERS in mpv(1) man page for\
|
VIDEO OUTPUT DRIVERS in mpv(1) for details and\
|
||||||
details and descriptions of available drivers"))
|
descriptions of available drivers"))
|
||||||
parser.add_argument('-f', '--format', required=False, metavar='YTDL_FORMAT',
|
parser.add_argument('-f', '--format', required=False, metavar='YTDL_FORMAT',
|
||||||
help=_("video format/quality to be passed to youtube-dl"))
|
help=_("video format/quality to be passed to youtube-dl"))
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
entries = extract_info(args.file, args.extractor)
|
entries = extract_info(args.playlist, args.extractor)
|
||||||
if entries is None:
|
if entries is None:
|
||||||
print(_("'{}': Can't extract playlist").format(args.file))
|
print(_("'{}': Can't extract playlist").format(args.file))
|
||||||
exit()
|
exit()
|
||||||
json_file = args.file if args.extractor == 'json' else ''
|
json_file = args.playlist if args.extractor == 'json' else ''
|
||||||
config = ConfigParser()
|
config = ConfigParser()
|
||||||
config.read(args.config)
|
config.read(args.config)
|
||||||
vid = args.vid or config.get('mpv', 'video', fallback='auto')
|
vid = args.vid or config.get('mpv', 'video', fallback='auto')
|
||||||
|
@ -384,54 +389,42 @@ with Comp(entries, json_file, mode, vid, vo, ytdlf) as comp:
|
||||||
comp.mp.vid = comp.vid
|
comp.mp.vid = comp.vid
|
||||||
comp.update_status()
|
comp.update_status()
|
||||||
elif c == 87: # letter W
|
elif c == 87: # letter W
|
||||||
s = comp.gets(_("Save playlist to [{}]: ").format(comp.json_file))
|
comp.dump_json()
|
||||||
if s: comp.json_file = s
|
|
||||||
try:
|
|
||||||
makedirs(dirname(abspath(comp.json_file)), exist_ok=True)
|
|
||||||
with open(comp.json_file, 'w') as f:
|
|
||||||
json.dump(comp.entries, f, ensure_ascii=False,
|
|
||||||
indent=2, sort_keys=True)
|
|
||||||
except:
|
|
||||||
errmsg = _("'{}': Can't open file for writing").format(
|
|
||||||
comp.json_file)
|
|
||||||
comp.update_status(errmsg, curses.color_pair(1))
|
|
||||||
else:
|
|
||||||
comp.update_status(_("'{}' written").format(comp.json_file))
|
|
||||||
elif c == 100: # letter d
|
elif c == 100: # letter d
|
||||||
comp.entries.pop(comp.idx())
|
comp.entries.pop(comp.idx())
|
||||||
if 1 < len(comp.entries) - curses.LINES + 4 == comp.start:
|
if 1 < len(comp.entries) - curses.LINES + 4 == comp.start:
|
||||||
comp.start -= 1
|
comp.start -= 1
|
||||||
elif comp.idx() == len(comp.entries):
|
elif comp.idx() == len(comp.entries):
|
||||||
comp.y -= 1
|
comp.y -= 1
|
||||||
comp.redraw()
|
comp.refresh()
|
||||||
elif c == 105: # letter i
|
elif c == 105: # letter i
|
||||||
extractor = comp.gets(_("Playlist extractor: "))
|
extractor = comp.read_input(_("Playlist extractor: "))
|
||||||
filename = comp.gets(_("Insert: "))
|
filename = comp.read_input(_("Insert: "))
|
||||||
entries = extract_info(filename, extractor)
|
entries = extract_info(filename, extractor)
|
||||||
if entries is None:
|
if entries is None:
|
||||||
comp.update_status(
|
comp.print_msg(
|
||||||
_("'{}': Can't extract playlist").format(filename))
|
_("'{}': Can't extract playlist").format(filename))
|
||||||
else:
|
else:
|
||||||
bottom = comp.entries[comp.idx():]
|
bottom = comp.entries[comp.idx():]
|
||||||
comp.entries = comp.entries[:comp.idx()]
|
comp.entries = comp.entries[:comp.idx()]
|
||||||
comp.entries.extend(entries)
|
comp.entries.extend(entries)
|
||||||
comp.entries.extend(bottom)
|
comp.entries.extend(bottom)
|
||||||
comp.redraw()
|
comp.refresh()
|
||||||
elif c == 109: # letter m
|
elif c == 109: # letter m
|
||||||
comp.mode = MODES[(MODES.index(comp.mode) + 1) % 8]
|
comp.mode = MODES[(MODES.index(comp.mode) + 1) % 8]
|
||||||
comp.update_status()
|
comp.update_status()
|
||||||
elif c == 110: # letter n
|
elif c == 110: # letter n
|
||||||
comp.next_search()
|
comp.next_search()
|
||||||
elif c == 111: # letter o
|
elif c == 111: # letter o
|
||||||
extractor = comp.gets(_("Playlist extractor: "))
|
extractor = comp.read_input(_("Playlist extractor: "))
|
||||||
filename = comp.gets(_("Open: "))
|
filename = comp.read_input(_("Open: "))
|
||||||
entries = extract_info(filename, extractor)
|
entries = extract_info(filename, extractor)
|
||||||
if entries is None:
|
if entries is None:
|
||||||
comp.update_status(
|
comp.print_msg(
|
||||||
_("'{}': Can't extract playlist").format(filename))
|
_("'{}': Can't extract playlist").format(filename))
|
||||||
else:
|
else:
|
||||||
comp.entries, comp.start, comp.y = entries, 0, 1
|
comp.entries, comp.start, comp.y = entries, 0, 1
|
||||||
comp.redraw()
|
comp.refresh()
|
||||||
elif c == 112: # letter p
|
elif c == 112: # letter p
|
||||||
comp.mp.pause ^= True
|
comp.mp.pause ^= True
|
||||||
elif c in (curses.KEY_UP, 107): # up arrow or letter k
|
elif c in (curses.KEY_UP, 107): # up arrow or letter k
|
||||||
|
|
142
doc/comp.1
Normal file
142
doc/comp.1
Normal file
|
@ -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-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 -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
|
||||||
|
select the current track
|
||||||
|
.TP
|
||||||
|
.B /, ?
|
||||||
|
search forward/backward for a pattern
|
||||||
|
.TP
|
||||||
|
.B <, >
|
||||||
|
go backward/forward in the playlist
|
||||||
|
.TP
|
||||||
|
.B A
|
||||||
|
toggle mute
|
||||||
|
.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
|
||||||
|
delete 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 p
|
||||||
|
toggle pause
|
||||||
|
.TP
|
||||||
|
.B o
|
||||||
|
open playlist
|
||||||
|
.TP
|
||||||
|
.B w
|
||||||
|
download tracks set by playing mode
|
||||||
|
.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/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)
|
46
omp/omp.py
46
omp/omp.py
|
@ -18,6 +18,7 @@
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
from gettext import bindtextdomain, gettext as _, textdomain
|
||||||
from itertools import cycle
|
from itertools import cycle
|
||||||
from os.path import abspath, expanduser, expandvars, isfile
|
from os.path import abspath, expanduser, expandvars, isfile
|
||||||
from random import choice
|
from random import choice
|
||||||
|
@ -25,12 +26,14 @@ from time import gmtime, sleep, strftime
|
||||||
from urllib import request
|
from urllib import request
|
||||||
|
|
||||||
from youtube_dl import YoutubeDL
|
from youtube_dl import YoutubeDL
|
||||||
|
from pkg_resources import resource_filename
|
||||||
from mpv import MPV, MpvFormat
|
from mpv import MPV, MpvFormat
|
||||||
|
|
||||||
DEFAULT_ENTRY = {'filename': '', 'title': '', 'duration': '00:00:00',
|
from .ie import extract_info
|
||||||
'error': False, 'playing': False, 'selected': False}
|
|
||||||
YTDL_OPTS = {'quiet': True, 'default_search': 'ytsearch',
|
# Init gettext
|
||||||
'extract_flat': 'in_playlist'}
|
bindtextdomain('omp', resource_filename('omp', 'locale'))
|
||||||
|
textdomain('omp')
|
||||||
|
|
||||||
|
|
||||||
class Omp(object):
|
class Omp(object):
|
||||||
|
@ -48,8 +51,14 @@ class Omp(object):
|
||||||
playlist (iterator): iterator of tracks according to mode
|
playlist (iterator): iterator of tracks according to mode
|
||||||
search_res (iterator): title-searched results
|
search_res (iterator): title-searched results
|
||||||
vid (str): flag show if video output is enabled
|
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, handler, json_file, mode, mpv_vid, mpv_vo, ytdlf):
|
def __new__(cls, entries, json_file, mode, mpv_vid, mpv_vo, ytdlf):
|
||||||
self = super(Comp, cls).__new__(cls)
|
self = super(Comp, cls).__new__(cls)
|
||||||
self.play_backward, self.reading = False, False
|
self.play_backward, self.reading = False, False
|
||||||
self.playing = -1
|
self.playing = -1
|
||||||
|
@ -60,13 +69,12 @@ class Omp(object):
|
||||||
ytdl=True, ytdl_format=ytdlf)
|
ytdl=True, ytdl_format=ytdlf)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __init__(self, entries, handler, json_file, mode,
|
def __init__(self, entries, json_file, mode, mpv_vid, mpv_vo, ytdlf):
|
||||||
mpv_vid, mpv_vo, ytdlf):
|
|
||||||
if mpv_vo is not None: self.mp['vo'] = mpv_vo
|
if mpv_vo is not None: self.mp['vo'] = mpv_vo
|
||||||
self.mp.observe_property('mute', handler)
|
@self.mp.property_observer('mute')
|
||||||
self.mp.observe_property('pause', handler)
|
@self.mp.property_observer('pause')
|
||||||
self.mp.observe_property('time-pos', handler,
|
@self.mp.property_observer('time-pos', force_fmt=MpvFormat.INT64)
|
||||||
force_fmt=MpvFormat.INT64)
|
def observer(name, value): self.property_handler(name, value)
|
||||||
|
|
||||||
def __enter__(self): return self
|
def __enter__(self): return self
|
||||||
|
|
||||||
|
@ -130,5 +138,21 @@ class Omp(object):
|
||||||
else:
|
else:
|
||||||
self.update_status(_("Pattern not found"), curses.color_pair(1))
|
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))
|
||||||
|
if s: self.json_file = abspath(expanduser(expandvars(s)))
|
||||||
|
try:
|
||||||
|
makedirs(dirname(comp.json_file), exist_ok=True)
|
||||||
|
with open(self.json_file, 'w') as f:
|
||||||
|
json.dump(self.entries, f, ensure_ascii=False,
|
||||||
|
indent=2, sort_keys=True)
|
||||||
|
except:
|
||||||
|
errmsg = _("'{}': Can't open file for writing").format(
|
||||||
|
self.json_file)
|
||||||
|
self.print_msg(errmsg, error=True)
|
||||||
|
else:
|
||||||
|
self.print_msg(_("'{}' written").format(comp.json_file))
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_value, traceback):
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
self.mp.quit()
|
self.mp.quit()
|
||||||
|
|
7
setup.py
7
setup.py
|
@ -1,8 +1,5 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
from os import listdir
|
|
||||||
from os.path import join
|
|
||||||
|
|
||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
|
|
||||||
with open('README.rst') as f:
|
with open('README.rst') as f:
|
||||||
|
@ -10,7 +7,7 @@ with open('README.rst') as f:
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='comp',
|
name='comp',
|
||||||
version='0.3.5',
|
version='0.3.6',
|
||||||
description=('Curses Omni Media Player'),
|
description=('Curses Omni Media Player'),
|
||||||
long_description=long_description,
|
long_description=long_description,
|
||||||
url='https://github.com/McSinyx/comp',
|
url='https://github.com/McSinyx/comp',
|
||||||
|
@ -32,6 +29,8 @@ setup(
|
||||||
keywords='youtube-dl mpv-wrapper curses console-application multimedia',
|
keywords='youtube-dl mpv-wrapper curses console-application multimedia',
|
||||||
packages=['omp'],
|
packages=['omp'],
|
||||||
install_requires=['python-mpv', 'youtube-dl'],
|
install_requires=['python-mpv', 'youtube-dl'],
|
||||||
|
python_requires='>=3.5',
|
||||||
package_data={'omp': ['locale/*/LC_MESSAGES/omp.mo']},
|
package_data={'omp': ['locale/*/LC_MESSAGES/omp.mo']},
|
||||||
|
data_files=[('share/man/man1', ['doc/comp.1'])],
|
||||||
scripts=['comp'],
|
scripts=['comp'],
|
||||||
platforms=['POSIX'])
|
platforms=['POSIX'])
|
||||||
|
|
Loading…
Reference in a new issue